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

Для багатьох програмістів, які використовують чи бажали б використовувати Go на практиці, відсутність механізмів параметричного поліморфізму в мові є великим сумом. Але не все так погано як може здатися на перший погляд.
Звичайно в Go не писати узагальнені програми, наприклад у стилі C++ templates, які б практично не впливали на витрати процесорного часу. Такого механізму в мові немає і, цілком можливо, що не передбачається.
З іншого боку, мова є досить потужний вбудований пакет
reflect
, якій дозволяє проводити рефлексію як об'єктів, так і функцій. Якщо не ставити швидкодію у главу кута, то з допомогою цього пакету можна досягати цікавих і гнучких рішень.
У цій статті я покажу як реалізувати
for each
у вигляді типонезависимой рефлексивної функції.

Проблема
У мові Go для перебору елементів колекції (
Array
,
Slice
,
String
) використовується конструкція
range for
:
for i, item := range items {
// do something
}

Аналогічним чином можна вибрати елементи з
Channel
:
for item := range queue {
// do something
}

В загальному-то це перекриває 80% потреб у циклі for each. Але у вбудованої конструкції
range for
є підводні камені, які легко продемонструвати на невеликому прикладі.
Припустимо ми маємо дві структури
Car
та
Bike
(уявімо що пишемо код для автомобільного магазину):
type Car struct{
String Name
Count uint
Price float64
}

type Bike struct{
String Name
Count uint
Price float64
}

Нам потрібно підрахувати вартість всіх автомобілів і мотоциклів які у нас є в наявності.
Що б це можна було зробити одним циклом в Go потрібен новий тип, який узагальнює доступ до полів:
type Vehicle interface{
GetCount() uint
GetPrice() float64
}

func (Car c) GetCount() uint { return c.Count; }
func (Car c) GetPrice() float64 { return c.Price; }
func (b Bike) GetCount() uint { return b.Count; }
func (b Bike) GetPrice() float64 { return b.Price; }

Тепер можна підрахувати сумарну вартість організовуючи обхід
vehicles
з допомогою
range for
:
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()
}

total return
}

Виділяючи цей код в окрему функциюю, як не дивно, ми втрачаємо в гнучкості, т. к. з'являється наступні проблеми:
  • Обмеження суворої типізації елементів. Т. к. конструкція 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 не дозволяє вибрати довільну частину коду і обернути її функцію не втрачаючи гнучкості.
Рішення
Описана проблема в багатьох мовах зі строгою типізацією вирішується залученням механізму параметричного поліморфізму (узагальнення, templates). Але натомість параметричного поліформізма автори Go представили вбудований пакет
reflect
реалізує механізм рефлексії.
З одного боку рефлексія є більш витратним за рішенням ресурсів, але з іншого, вона дозволяє створювати більш гнучкі та інтелектуальні алгоритми.
Рефлексія типу (reflect.Type)
По суті в пакеті
reflect
існує два види рефлексії — це рефлексія типу
reflect.Type
і рефлексія значення
reflect.Value
. Рефлексія типу описує виключно властивості типу, тому дві різні змінні з одним типом будуть мати одну і ту ж рефлексію типу.
var i, int j
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 position zeroth
}

Аналогічним чином можна отримати доступ до опису членів структури:
type Person struct{
String Name
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 !ок {
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
).
Тепер, маючи функцію производяющую бестиповый обхід колекції, можна адаптирвоать її до виклику функції
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()
})

total return
}

Ця функція, на відміну від наведеної на початку статті, набагато гнучкіша, т. до. дозволяє підраховувати суму незалежно від типу колекції і не зобов'язує приводити тип елементів інтерфейсу
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
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.