INDEX ABOUT

C.04 // Аbstract containers in Go

Системы типов в таких языках как C/C++ основаны на структурном представлении объекта в памяти. В противоположность этому алгебраические системы типов основаны на операциях применяемых к конкретному объекту. Определение типа происходит в двух местах : во время объявления переменной, и в заголовке функции. Создавая новую переменную компилятор должен знать о структуре выделяемой памяти, но есть ли необходимость в информации о структуре объекта во время определения функции. Как показывает практика применения языков с алгебраической типизацией, такой необходимости нет. Осознавая ограниченность структурной типизации, разработчики C++ ввели концепцию шаблонов. Данный подход сложно рассматривать как органическое расширение языка, скорее всего шаблоны похожи на временную меру, которую применили за неимением лучших идей. К той же категории можно отнести и виртуальные функции. Вообще, языки программирования очень редко реализуют полную алгебру типов с соответствующими операциями, мне известны только Haskell, Julia и такие экзотические языки как Maude, Agda, Coq. Вариантом операции объединения в некоторых языках выступает понятие интерфейса, точнее оно является аналогом алгебраического типа, а операция наследования от интерфейса включает структурный тип во множество образуемое интерфейсом. В данной статье я рассмотрю реализацию обобщенного программирования в языке Go. По большей части я написал её для себя, чтобы зафиксировать те приятные ощущения которые возникли у меня после перехода с С++ на Go.

Пару дней назад я взялся за доработку Product Manufacturing Information сервера написанного на Go. Несмотря на то, что я никогда раньше не программировал на этом языке, мне досталась часть отвечающая за взаимодействие с базой данных. Пользуясь предоставленной свободой, я решил выкинуть унаследованный Oracle, заменив его на Mongo. После анализа структуры данных стало понятно, что в принципе можно отбросить и без Mongo, перейдя на встроенную базу типа ключ-значение. Чтобы прочувствовать как на мой выбор отреагировали остальные, представьте себе человека, который на вопрос “чай или кофе”, отвечает - “воду”.С большими трудностями, но мне всё же удалось продавить эту идею. Своё решение я аргументировал тем, что база данных это единственная зависимость проекта, и отбросив её мы сможем построить минимальный docker образ на основе sсratch. Базы вроде Bolt, по своей структуре, отлично подходят для хранения сборок. Легко можно построить иерархию данных с различными правами доступа, скажем, неограниченные возможности работы с моделью для главного инженера, и локально изолированная деталь для рядового проектировщика. Вот только набор дополнительных возможностей у Bolt сильно ограничен. Определение структуры данных, сериализаця, фильтрация, индексирование - всем этим должен заниматься программист. Есть библиотеки типа Storm, но для моего случая они не подошли.

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

func filter(data []interface{}, pred func(interface{}) bool) []interface{}

В реальном проекте такая схема не сработает, представьте …

func test(x int) bool {
    return x > 0
}

func main() {
    data := []int{-2, -1, 0, 1, 2, 3}
    result := filter(data, test)
}

Компиляция такого кода выдаст две ошибки. Первая - совершенно непонятная, о том что нельзя преобразовать тип []int в []interface{}, несмотря на то, что семантика подобной операции очевидна. Вторая - вполне ожидаемая, о невозможности преобразования func(int) bool в func(interface{}) bool, в противном случае произошло бы расширение области определения, что абсолютно некорректно.

Также, мы не можем пользователя заставить писать …

func test(x interface{}) bool {
    return x.(int) > 0
} 

result := filter(make_abstract(data), test)

Просто потому, что в нормальных языках всё иначе. Продолжая искать решение, я наткнулся на стандартный модуль reflect. При поверхностном изучении он кажется ориентированным на тех, кто пишет интерпретаторы для Go. Возможно это не то что нужно, но других вариантов нет. Поэтому рассмотрим его подробно.

Познакомившись поближе с типом Value, я узнал в нём старый-добрый VARIANT хорошо известный всем, кто когда либо программировал COM. Давайте перестроим фильтр используя reflect…

func filter(data interface{}, pred interface{}) interface{}

Как мы видим, теперь и данные и предикат представляются пустыми интерфейсами. Для работы с ними нужно использовать методы модуля reflect. Создадим вспомогательную функцию …

func UnaryFunction(f interface{}) (func(reflect.Value) reflect.Value) {
	vf := reflect.ValueOf(f)
	return func(x reflect.Value) reflect.Value {
		arg := []reflect.Value{x}
		return vf.Call(arg)[0]
	}
}

Эта функция принимает на вход любую другую, конвертирует её в Value и внедряет в более абстрактную. Мы будем использовать её в нашем фильтре …

func filter(vec interface{}, f interface{}) interface{} {
	fun := UnaryFunction(f)
	val := reflect.ValueOf(vec)
	ret := reflect.MakeSlice(val.Type(), val.Len(), val.Len())
	cnt := 0
	for n := 0; n < val.Len(); n++  {
		e := val.Index(n)
		if fun(e).Bool() {
			ret.Index(cnt).Set(e)
			cnt += 1
		}
	}
	return ret.Slice(0, cnt).Interface()
}

Теперь main.go должен отработать нормально. Несмотря на то, что interface{} не имеет методов, мы можем конвертировать его в динамическое представление и работать с ним, почти как с обычным типом данных, в последующем извлекая реальное значение. Разумеется это учебный пример, в реальности reflect.MakeSlice нужно заменить на list, а в коллекцию vec завернуть базу данных, но общий принцип работы с абстрактными объектами понятен, и он достаточно неплохой. Конечно, мы теряем статическую проверку типов, и получаем ошибки времени выполнения, но мы также получаем возможность создания гетерогенных коллекций наподобие тех, что можно встретить в динамических языках.

В заключение приведу ещё пару методов: cast - аналог map (в языке go map зарезервирован), и update - inplace map.

func UnaryOperator(f interface{}) (func(reflect.Value)) {
	vf := reflect.ValueOf(f)
	return func(x reflect.Value) {
		arg := []reflect.Value{x}
		vf.Call(arg)
	}
}


func cast(vec interface{}, f interface{}) interface{} {
	fun := UnaryFunction(f)
	val := reflect.ValueOf(vec)
	ret := reflect.MakeSlice(val.Type(), val.Len(), val.Len())
	for n := 0; n < ret.Len(); n++ {
		elm := val.Index(n)
		ret.Index(n).Set(fun(elm))
	}
	return ret.Interface()
}


func update(vec interface{}, f interface{}) {
	fun := UnaryOperator(f)
	val := reflect.ValueOf(vec)
	for n := 0; n < val.Len(); n++ {
		fun(val.Index(n).Addr())
	}
}

Язык Go часто упрекают в отсутствии шаблонов, но как мы видим, и без них можно прекрасно работать с обобщенными алгоритмами. Также не стоит забывать, что разные языки по-разному реализуют концепцию шаблонов, сравните, к примеру, весь тот кошмар, который творится в С++, и удивительно стройную систему дженериков Julia. Кстати, обобщенные алгоритмы маршалинга в C++ вообще не реализуются, именно из-за недостатка рефлексии. Поля JSON приходится вытаскивать вручную, хотя в Go всё это происходит автоматически.

INDEX ABOUT