Для многих программистов, которые используют или желали бы использовать Go на практике, отсутсвие механизмов параметрического полиморфизма в языке является большой печалью. Но не все так плохо как может показаться на первый взгляд.
Конечно в Go нельзя писать обобщенные программы, например в стиле C++ templates, которые бы практически не влияли на затраты процессорного времени. Такого механизма в языке нет и, вполне возможно, что не предвидится.
С другой стороны, язык представляет довольно мощный встроенный пакет reflect
, которой позволяет производить рефлексию как объектов, так и функций. Если не ставить быстродействие во главу угла, то с помощью этого пакета можно достигать интересных и гибких решений.
В этой статье я покажу как реализовать for each
в виде типонезависимой рефлексивной функции.
Проблема
В языке Go для перебора элементов коллекции (Array
, Slice
, String
) используется конструкция for range
:
for i, item := range items {
// do something
}
Аналогичным образом можно выбрать элементы из Channel
:
for item := range queue {
// do something
}
В общем-то это перекрывает 80% потребностей в цикле for each. Но у встроенной конструкции for range
есть подводные камни, которые легко продемонстрировать на небольшом примере.
Допустим мы имеем две структуры Car
и Bike
(представим что пишем код для автомобильного магазина):
type Car struct{
Name string
Count uint
Price float64
}
type Bike struct{
Name string
Count uint
Price float64
}
Нам нужно подсчитать стоимость всех автомобилей и мотоциклов которые у нас есть в наличии.
Что бы это можно было сделать одним циклом в Go требуется новый тип, который обобщает доступ к полям:
type Vehicle interface{
GetCount() uint
GetPrice() float64
}
func (c Car) GetCount() uint { return c.Count; }
func (c Car) GetPrice() float64 { return c.Price; }
func (b Bike) GetCount() uint { return b.Count; }
func (b Bike) GetPrice() float64 { return b.Price; }
Теперь суммарную стоимость можно подсчитать организуя обход vehicles
с помощью for range
:
vehicles := []Vehicle{
Car{"Banshee ", 1, 10000},
Car{"Enforcer ", 3, 15000},
Car{"Firetruck", 4, 20000},
Bike{"Sanchez", 2, 5000},
Bike{"Freeway", 2, 5000},
}
total := float64(0)
for _, vehicle := range vehicles {
total += float64(vehicle.GetCount()) * vehicle.GetPrice()
}
fmt.Println("total", total)
// $ total 155000
Что бы не писать цикл каждый раз мы можем написать функцию которая принимает на вход тип []Vehicle
и возвращает численный результат:
func GetTotalPrice(vehicles []Vehicle) float64 {
var total float64
for _, vehicle := range vehicles {
total += float64(vehicle.GetCount()) * vehicle.GetPrice()
}
return total
}
Выделяя этот код в отдельную функциюю, как ни странно, мы теряем в гибкости, т.к. появляется следующие проблемы:
- Ограничение строгой типизации элементов. Т.к. конструкция for/range строго типизирована и не производит приведение типов элементов, то приходится явно указывать ожидаемый тип элементов в сигнатуре функции. Как следствие нет возможности передать срез
[]Car
или[]Bike
напрямую, хотя оба типа — иCar
, иBike
, удовлетворяют условиям интерфейсаVehicle
:
cars := []Car{
Car{"Banshee ", 1, 10000},
Car{"Enforcer ", 3, 15000},
Car{"Firetruck", 4, 20000},
}
fmt.Println("total", GetTotalPrice(cars))
// Compilation error: cannot use cars (type []Car) as type []Vehicle in argument to GetTotalPrice
- Ограничение строгой типизации коллекции. Например, нет возмоности передать вместо среза
[]Vehicle
словарьmap[int]Vehicle
:
cars := map[int]Vehicle{
1: Car{"Banshee ", 1, 10000},
2: Car{"Enforcer ", 3, 15000},
3: Car{"Firetruck", 4, 20000},
}
fmt.Println("total", GetTotalPrice(cars))
// Compilation error: cannot use vehicles (type map[int]Vehicle) as type []Vehicle in argument to GetTotalPrice
Другими словами, for/range не позволяет выбрать произвольную часть кода и обернуть её в функцию не теряя гибкости.
Решение
Описанная проблема во многих языках со строгой типизацией решается привлечением механизма параметрического полиморфизма (generics, templates). Но взамен параметрического полиформизма авторы Go представили встроенный пакет reflect
реализующий механизм рефлексии.
С одной стороны рефлексия является более затратным по ресурсам решением, но с другой, она позволяет создавать более гибкие и интеллектуальные алгоритмы.
Рефлексия типа (reflect.Type)
По сути в пакете reflect
существует два вида рефлексии — это рефлексия типа reflect.Type
и рефлексия значения reflect.Value
. Рефлексия типа описывает исключительно свойства типа, поэтому две разные переменные с одним типом будут иметь одну и ту же рефлексию типа.
var i, j int
var k float32
fmt.Println(reflect.TypeOf(i) == reflect.TypeOf(j)) // true
fmt.Println(reflect.TypeOf(i) == reflect.TypeOf(k)) // false
Т.к. в Go типы конструируются на основе базовых типов, то для классификации существует специальное перечисление с типом Kind:
const (
Invalid Kind = iota
Bool
Int
Int8
Int16
Int32
Int64
Uint
Uint8
Uint16
Uint32
Uint64
Uintptr
Float32
Float64
Complex64
Complex128
Array
Chan
Func
Interface
Map
Ptr
Slice
String
Struct
UnsafePointer
)
Таким образом, имея доступ к рефлексии типа reflect.Type
, можно всегда узнать род типа, который позволяет произвести диспетчеризацию без определения полного типа переменной. Например, достаточно знать что переменная является функцией, не вдаваясь в подробности какой конкретный тип имеет эта функция:
valueType := reflect.TypeOf(value)
switch valuteType.Kind() {
case reflect.Func:
fmt.Println("It's a function")
default:
fmt.Println("It's something else")
}
Для удобства записи будем именовать рефлексию типа некоторой переменной тем же именем, но с суффиксом Type
:
callbackType := reflect.TypeOf(callback)
collectionType := reflect.TypeOf(collection)
Кроме рода к которому принадлежит тип, с помощью рефлексии типа можно узнать остальную статическую информацию о типе (т.е. ту информацию которая не меняется во время выполнения). Например, можно узнать количество аргументов функции и тип ожидаемого аргумента на некоторой позиции:
if callbackType.NumIn() > 0 {
keyType := callbackType.In(0) // expected argument type at zeroth position
}
Аналогичным образом можно получить доступ к описанию членов структуры:
type Person struct{
Name string
Email string
}
structType := reflect.TypeOf(Person{})
fmt.Println(structType.Field(0).Name) // Name
fmt.Println(structType.Field(1).Name) // Email
Размер массива так же можно узнать через рефлексию типа:
array := [3]int{1, 2, 3}
arrayType := reflect.TypeOf(array)
fmt.Println(arrayType.Len()) // 3
Но размер среза через рефлексию типа уже узнать нельзя, т.к. эта информация меняется во время выполнения.
slice := []int{1, 2, 3}
sliceType := reflect.TypeOf(slice)
fmt.Println(sliceType.Len()) // panic!
Рефлексия значения (reflect.Value)
Аналогично рефлексии типа, в Go существует рефлексия значения reflect.Value
, которая отражает свойства конкретного значения хранящегося в переменной. Может показаться, что это довольно тривиальная рефлексия, но т.к. в Go переменная с типом interface{}
может хранить всё что угодно — функцию, число, структуру и т.д., то и рефлексия значения вынуждена представлять в более–менее безопасном виде доступ ко всем вероятным возможностям объекта. Что, конечно же, порождает довольно длинный список методов.
Например, рефлексия функции может использоваться для вызова — достаточно передать список аргументов приведённый к типу reflect.Value
:
_callback := reflect.ValueOf(callback)
_callback.Call([]reflect.Value{ values })
Рефлексию коллекции (среза, массива, строки и т.д.) можно использовать для доступа к элементам:
_collection := reflect.ValueOf(collection)
for i := 0; i < _collection.Len(); i++ {
fmt.Println(_collection.Index(i))
}
Аналогичным образом работает рефлексия словаря — для обхода нужно получить список ключей через метод MapKeys
и выбрать элементы через MapIndex
:
for _, k := range _collection.MapKeys() {
keyValueCallback(k, _collection.MapIndex(k))
}
С помощью рефлексии структуры можно получить значения членов. При этом названия и типы членов следует получать из рефлексии типа структуры:
_struct := reflect.ValueOf(aStructIstance)
for i := 0; i < _struct.NumField(); i++ {
name := structType.Field(i).Name
fmt.Println(name, _struct.Field(i))
}
Рефлексивный цикл for each
Итак, если вернуться к for each, то желательно получить функцию которая принимала бы коллекцию и функцию обратного вызова произвольного типа, таким образом ответственность за согласование типов лежала бы на пользователе.
Т.к. единственная возможность в Go передать произвольный тип функции это указать тип interface{}
, то в теле функции необходимо произвести проверки на основе информация содержащейся в рефлексии типа callbackType
:
- убедиться что функция обратного вызова действительно является функцией (через метод
calbackType.Kind()
) - выяснить количество ожидаемых аргументов (метод
callbackType.NumIn()
) - в случае провала вызвать
panic()
В итоге получается примерно такой код:
func ForEach(collection, callback interface{}) {
callbackType := reflect.TypeOf(callback)
_callback := reflect.ValueOf(callback)
if callbackType.Kind() != reflect.Func {
panic("foreach: the second argument should be a function")
}
switch callbackType.NumIn() {
case 1:
// Callback expects only value
case 2:
// Callback expects key-value pair
default:
panic("foreach: the function should have 1 or 2 input arguments")
}
}
Теперь требуется спроектировать вспомогательную функцию которая будет производить обход по коллекции.
В неё удобнее передавать обратный вызов не в бестиповом виде, а в виде функции с двумя аргументами принимающую рефлексии ключа и элемента:
func eachKeyValue(collection interface{}, keyValueCallback func(k, v reflect.Value)) {
_collection := reflect.ValueOf(collection)
collectionType := reflect.TypeOf(collection)
switch collectionType.Kind() {
// loops
}
}
Т.к. алгоритм прохода коллекциии зависит от рода который можно получить через метод Kind()
рефлексии типа, то для диспетчеризации удобно воспользоваться конструкцией switch-case
:
switch collectionType.Kind() {
case reflect.Array: fallthrough
case reflect.Slice: fallthrough
case reflect.String:
for i := 0; i < _collection.Len(); i++ {
keyValueCallback(reflect.ValueOf(i), _collection.Index(i))
}
case reflect.Map:
for _, k := range _collection.MapKeys() {
keyValueCallback(k, _collection.MapIndex(k))
}
case reflect.Chan:
i := 0
for {
elementValue, ok := _collection.Recv()
if !ok {
break
}
keyValueCallback(reflect.ValueOf(i), elementValue)
i += 1
}
case reflect.Struct:
for i := 0; i < _collection.NumField(); i++ {
name := collectionType.Field(i).Name
keyValueCallback(reflect.ValueOf(name), _collection.Field(i))
}
default:
keyValueCallback(reflect.ValueOf(nil), _collection)
}
Как видно из кода, обход массива, среза и строки происходит одинаково. Словарь, канал и структура имеют свой собственный алгоритм обхода. В случае, если род коллекции не подпадает ни под один из перечисленных, алгоритм пытается передать в обратный вызов саму коллекцию, прим этом в качестве ключа указывается рефлексия указателя nil
(которая на вызов метод IsValid()
возвращает false
).
Теперь, имея функцию производяющую беcтиповый обход коллекции, можно адаптирвоать её к вызову из функции ForEach
обернув в замыкание. Это и есть окончательное решение:
func ForEach(collection, callback interface{}) {
callbackType := reflect.TypeOf(callback)
_callback := reflect.ValueOf(callback)
if callbackType.Kind() != reflect.Func {
panic("foreach: the second argument should be a function")
}
switch callbackType.NumIn() {
case 1:
eachKeyValue(collection, func(_key, _value reflect.Value){
_callback.Call([]reflect.Value{ _value })
})
case 2:
keyType := callbackType.In(0)
eachKeyValue(collection, func(_key, _value reflect.Value){
if !_key.IsValid() {
_callback.Call([]reflect.Value{reflect.Zero(keyType), _value })
return
}
_callback.Call([]reflect.Value{ _key, _value })
})
default:
panic("foreach: the function should have 1 or 2 input arguments")
}
}
Надо заметить, что в случае когда функция обратного вызова ожидает передачу двух аргументов (пары ключ/значение) необходимо производить проверку корректности ключа, т.к. он может оказаться невалидным. В последнем случае на основе типа ключа конструируется нулевой объект.
Примеры
Теперь пришло время продемонстироровать что же даёт наш подход. Если вернуться к проблеме, мы теперь можем решить её таким путём:
func GetTotalPrice(vehicles interface{}) float64 {
var total float64
ForEach(vehicles, func(vehicle Vehicle) {
total += float64(vehicle.GetCount()) * vehicle.GetPrice()
})
return total
}
Эта функция, в отличии от приведённой в начале статьи, гораздо гибче, т.к. позволяет подсчитывать сумму вне зависимости от типа коллекции и не обязывает приводить тип элементов к интерфейсу Vehicle
:
vehicles := []Vehicle{
Car{"Banshee ", 1, 10000},
Bike{"Sanchez", 2, 5000},
}
cars := []Car{
Car{"Enforcer ", 3, 15000},
Car{"Firetruck", 4, 20000},
}
vehicleMap := map[int]Vehicle{
1: Car{"Banshee ", 1, 10000},
2: Bike{"Sanchez", 2, 5000},
}
vehicleQueue := make(chan Vehicle, 2)
vehicleQueue <- Car{"Banshee ", 1, 10000}
vehicleQueue <- Bike{"Sanchez", 2, 5000}
close(vehicleQueue)
garage := struct{
MyCar Car
MyBike Bike
}{
Car{"Banshee ", 1, 10000},
Bike{"Sanchez", 1, 5000},
}
fmt.Println(GetTotalPrice(vehicles)) // 20000
fmt.Println(GetTotalPrice(cars)) // 125000
fmt.Println(GetTotalPrice(vehicleMap)) // 20000
fmt.Println(GetTotalPrice(vehicleQueue)) // 20000
fmt.Println(GetTotalPrice(garage)) // 15000
И небольшой бенчмарк для двух идентичных циклов, который наглядно показывает за счёт чего достигается гибкость:
// BenchmarkForEachVehicles1M
total := 0.0
for _, v := range vehicles {
total += v.GetPrice()
}
//BenchmarkForRangeVehicles1M
total := 0.0
ForEach(vehicles, func(v Vehicle) {
total += v.GetPrice()
})
PASS
BenchmarkForEachVehicles1M-2 2000000000 0.20 ns/op
BenchmarkForRangeVehicles1M-2 2000000000 0.01 ns/op
Заключение
Да, в Go нет параметрического полиформизма. Но зато есть пакет reflect
, который предоставляет обширные возможности в области метапрограммирования. Код с использованием reflect
конечно же выглядит намного сложнее, чем типичный код на Go. С другой стороны, рефлексивные функции позволяют создавать более гибкие решения. Это очень важно при написании прикладных библиотек, например, при реализации концепции Active Record
.
Так что, если вы заранее не знаете каким образом другие программисты будут использовать вашу библиотеку и предельное быстродействием для вас не главная цель, то, вполне возможно, рефлексивное метапрограммирвоание будет наилучшим выбором.
Автор: deniskreshikhin