Рефлексивное метапрограммирование на Go: цикл for each средствами пакета reflect

в 17:41, , рубрики: Go, метапрограммирование, ооп, Программирование, Проектирование и рефакторинг, рефлексия

Для многих программистов, которые используют или желали бы использовать 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.

Так что, если вы заранее не знаете каким образом другие программисты будут использовать вашу библиотеку и предельное быстродействием для вас не главная цель, то, вполне возможно, рефлексивное метапрограммирвоание будет наилучшим выбором.

github Исходный код на github

Автор: deniskreshikhin

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js