Рефлексия в Go

Выясняем – что такое рефлексия. Плюсы и минусы. Альтернативные подходы

Оглавление
Зачем ты озабочен\
В этот хороший миг одним упорным выяснением,\
Как тебя люди другие видят. Тратишь воображение\
На домыслы пустые о домыслах касательно своей персоны.\
У тебя рефлексия, опасная стадия.

Что же такое рефлексия и для чего она нужна?

Рефлексия это механизм, с помощью которого программа может проверять своё состояние, исследовать типы данных и менять свою структуру и поведение во время выполнения. Звучит довольно запутано, но давайте раскладывать по полочкам. Во первых рефлексия как механизм есть не во всех языках программирования. В целом она нужна для оперирования свойствами объектов неизвестных в момент компиляции.

Если говорить про сторону модификации кода в момент выполнения, то тут можно посмотреть на аспектно-ориентированное программирование на примере Go! AOP PHP Framework или на механизмы внедрения зависимости, например, в том же Angular.

В этой статье я хочу коснуться интроспекции типов. По сути это механизм, позволяющий обойти строгую типизацию. Задача, которую решает рефлексия в данном случае это получение дополнительной информации о типе объекта в момент исполнения.

Рефлексия в Go

Для начала разберёмся что доступно в языке Go. К рефлексии, то есть способности кода получать какие-то данные о программе в момент исполнения в Go отвечают два пакета: runtime и непосредственно reflect. С помощью первого можно получать такие данные как стек вызовов или размер занимаемой памяти, но об этом мы поговорим в другой раз. Нас интересует пакет reflect, который предоставляет методы для извлечения информации о типах.

Интерфейсы

Прежде чем разбираться с интроспекцией типов надо упомянуть о том как в Go хранятся переменные с типом интерфейс. Хранятся в памяти они в виде пары: (значение, тип). Например:

var x interface{}

x = "foo"

x представляет из себя пару ("foo", string). Это относится не только к пустым интерфейсам:

var r io.Reader
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
    return nil, err
}
r = tty

r хранит в себе пару (tty, *os.File). Что позволяет осуществить кастинг типа:

w, ok := r.(io.Writer) // ok is true
w.Write([]byte("hello"))

При этом в w также будет хранится пара: (tty, *os.File), что позволяет в дальнейшем привести его например к io.ReadCloser. По сути мы уже применяем рефлексию, проверяя скрытый от нас тип оригинального объекта на удовлетворение определённому интерфейсу.

reflect.Value и reflect.Type

Основными концепциями пакета reflect являются как раз эта пара (значение, тип). Для получения информации о них в пакете представлены классы Value и Type. Оба имеют конструктор, принимающий в себя переменную, которую мы хотим исследовать.

reflectType := reflect.TypeOf(x)
reflectValue := reflect.ValueOf(x)

Стоит отметить, что у reflect.Value есть метод Type, позволяющий извлечь информацию о типе в нужный момент, поэтому чаще всего работа производится именно с типом Value, а Type получают по мере необходимости.

Получение исходного типа

Первое что можно извлечь с помощью пакета reflect — это оригинальный тип. То есть, если мы объявим собственный тип как обёртку над стандартным типом, то можно получить какой именно стандартный тип мы обернули. Для этого необходимо воспользоваться методом Kind у Value или Type.

type MyInt int

var x MyInt = 12
v := reflect.ValueOf(x)
fmt.Println("type:", v.Type().Name())                   // MyInt.
fmt.Println("kind is uint8: ", v.Kind() == reflect.Int) // true.

В пакете reflect есть константы для всех стандартных типов.

Извлечение полей структуры

Кроме того можно получить список полей структуры:

type Data struct {
    Foo string
    Bar int
}
var x Data
t := reflect.TypeOf(x)
fmt.Println("number of fields:", t.NumField())                 // 2.
fmt.Println("name of first field:", t.Field(0).Name)  // Foo.
fmt.Println("type of first field:", t.Field(0).Type.Name())  // string.
fmt.Println("name of second field:", t.Field(1).Name) // Bar.
fmt.Println("type of second field:", t.Field(1).Type.Name()) // int.

Аналогично можно обойти все методы, а также получить список ключей и значений хеш-таблицы или список значений массива.

Структурные тэги как элемент АОП

В последнем примере стоит обратить внимание на то, что метод Field класса Type возвращает объект класса StructField. Этот объект помимо имени и типа поля содержит дополнительную информацию. Из всей доступной информации нас интересует поле: Tag, предоставляющее доступ до структурных тэгов.

type Data struct {
    Foo string `json:"foo"`
    Bar int    `json:"bar"`
}
var x Data
t := reflect.TypeOf(x)
fmt.Println("json tag of first field:", t.Field(0).Tag.Get("json"))  // foo.
fmt.Println("json tag of second field:", t.Field(1).Tag.Get("json")) // bar.

И этот механизм позволяет использовать структурные тэги для внедрения различных аспектов в описании классов. Например, структурные тэги используются в стандартной библиотеке подпакетами encoding, а также многими пакетами для валидации, например, validator, ну и, конечно, не обошлось здесь без ORM, которые вообще все поголовно используют структурные тэги.

Конечно, надо помнить, что структурные тэги не проверяются в момент компиляции и с ними необходимо быть аккуратным, но в будущих версиях Go, надеюсь, появится и проверка при компиляции.

Плюсы и минусы рефлексии

Плюсы рефлексии заключаются в том, что можно отделить определённую логику в отдельные аспекты, не дублируя и не усложняя код. При этом, конечно, сложность не исчезает, а переходит в runtime. Отсюда вытекают и минусы рефлексии: оперирование нетипизированными объектами в момент исполнения. Собственно для надёжной работы с рефлексией просто необходимы тесты, которые будут проверять корректность применения аспектов непосредственно к коду бизнес-логики, а также хорошее покрытие аспекта. Это похоже на то, как в нетипизированных языках необходимо покрывать тестами все возможные комбинации типов входных параметров. Конечно, все возможные комбинации покрывать смысла нет, но проверить нормальный случай, граничные случаи и исключения будет не лишним для аспекта, а для кода бизнес-логики будет достаточно прямого сценария.

Кроме того к минусам рефлексии относят падение скорости работы. Это связанно с выделением дополнительной памяти. Как уже упоминалось выше, интерфейсы представляют из себя кортежи из типа и значения, таким образом, как только мы приводим значение к интерфейсу, выделяется память под эту пару. Кроме того, сами объекты рефлексии reflect.Type и reflect.Value тоже требуют памяти. Но по факту для заранее известного числа объектов это немного, тем более в сравнении с теми же задержками сети. Конечно, если необходимо написать приложение реального времени, то рефлексия будет не лучшим выбором, однако, в большинстве случаев использование рефлексии оправдано.

Примеры использования рефлексии

Рефлексия широко применяется в стандартной библиотеке для сериализации и десериализации, а также форматирования (json, xml, fmt). При этом надо упомянуть, что рефлексия всегда используется как последнее средство. Сначала производятся попытки привести пустой интерфейс к стандартным типам, затем к объявленным внутренним интерфейсам, и только потом формируются объекты рефлексии. Конечно, это делается потому, что стандартная библиотека должна быть настолько быстрой, насколько это возможно, не усложняя интерфейсы. В сторонних библиотеках рефлексия применяется более аггрессивно: ORM, валидация, (де)сериализация и многие другие области. Часто рефлексия применяется для имитации отсутствующих в языке дженериков (например, стандартный пакет sort).

Альтернативы

Перед тем как использовать рефлексию в своей библиотеке стоит рассмотреть альтернативы.

Первое, о чём стоит задуматься, это использование интерфейсов. Если реализация интерфейса не слишком обременительна для клиента и не составляет основную логику вашей библиотеки, то стоит остановиться на этом варианте. Если достаточно интерфейса из стандартной библиотеки, то это ещё лучше.

Однако, возможно помимо интерфейса клиенту будет удобно передавать также стандартные типы, которые не имеют собственных интерфейсов. Тогда будет удобно реализовать собственные обёртки над стандартными типами. В качестве примера подобного подхода можно рассмотреть интерфейс библиотеки логирования https://github.com/uber-go/zap.

Более сложной, но куда более надёжной и быстрой альтернативой является генерация кода. Эта тема заслуживает отдельной статьи, но если не терпится посмотреть — что это такое и как это готовить, то начать можно со знаменитой статьи Роба Пайка Generating code, а продолжить чтение такими монстрами как https://github.com/golang/mock, https://github.com/go-reform/reform и https://github.com/mailru/easyjson.

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

Дополнительное чтение

  1. Go Data Structures: Interfaces https://research.swtch.com/interfaces;
  2. The Laws of Reflection https://blog.golang.org/laws-of-reflection;
  3. Godoc reflect https://godoc.org/reflect#StructField;