Generico! Дженерики в go или стоит ли оно того

37fe414873fdcb3c0caf3bd4c176802f.png

15 марта 2022 года. Был морозный весенний день. Ветер старался доказать, что он не промах и залезть под куртки, кофты и прочие принадлежности гардероба, чтобы из первых рук, куда уж придется, принести весеннее настроение через свежесть. Не очень-то у него это получалось. Причем при любом раскладе. Если попадалась хорошая куртка и не пускала незваного гостя — ветру рассказать о весне не получалось. Если же удавалось забраться за шиворот или пройтись ледяным дыханием свежести по пузу — этого уже не понимал прохожий. Кутался еще сильнее и поскорее старался уйти от этого весеннего настроения. Но это была не единственная неоднозначность. Именно 15-го марта в мир была превнесена еще одна неоднозначность, спровоцировавшая жаркие споры — релиз golang 1.18 вместе с системой generic-ов.

Сама по себе концепция дженериков не нова. Самый известный приход этой концепции в мир — java великая и ужасная. Сама конецепция понятна и даже логична в появлении. На мой взгляд главное, чего должны были достигнуть дженерики — убрать копипасту кода. Дженерики появились, позволяя писать теперь унифицированные функции вместо двух или трех под разные типы данных, но с одинаковыми операциями. Вроде проблема решена, все радуются, со всех концов слышен смех, вверх взлетают шляпы, толпа ликует, в небе разрываются салюты! Или не совсем? Для того, чтобы сломя голову бросаться решать проблему, сама проблема должна быть, а была ли она?

Много где при обьяснении дженериков я встречал реализацию функции, суммирующей разные числа — int, float32, float64. Да, пример показывает, что такое generic, проблема копипасты решена. Но как часто вы пишете функцию для сложения 2, 3 или даже 4 чисел? Все эти примеры оставляют терпкое послевкусие обмана. Сначала завлекают крупными лозунгами, мол Breaking News, прорыв года, все сюда, ваша жизнь не станет прежней! А открываешь статью и там тебе рассказывают, как сложить 2+2 (действительно, моя жизнь не станет прежней — больше не буду верить заголовкам статей!).

А если посмотреть на что-то более практическое? Мне очень понравилась идея попробовать дженерики на стеке (тем более в тот момент мне он понадобился в небольшом личном проекте). Это достаточно простая для понимания и реализации структура. На мой взгляд это идеальный подопытный для экспериментов. При любом раскладе тот или иной вариант пригодится в хозяйстве. Изначально я взял готовую библиотеку, но потом решил сравнить другую реализацию. Спойлер: библиотеку переписал на дженерики и стал использовать в своем проекте. А сравнение производительности двух решений натолкнуло меня на эту статью.

Давайте глянем на простейшую реализацию стека. Если код удобнее видеть на отдельной вкладке — исходники находятcя тут.

Реализация через интерфейсы

package main

type (
	iNode struct {
		val  interface{}
		next *iNode
	}
	InterfaceStack struct {
		top *iNode
		len int
	}
)

func NewInterfaceStack() *InterfaceStack {
	return &InterfaceStack{}
}

func (istack *InterfaceStack) Push(val interface{}) {
	var n iNode = iNode{val: val, next: istack.top}
	istack.len += 1
	istack.top = &n
}

func (istack *InterfaceStack) Pop() interface{} {
	if istack.len <= 0 {
		return nil
	}
	istack.len -= 1
	var n *iNode = istack.top
	istack.top = n.next
	return n.val
}

func (istack *InterfaceStack) Peak() interface{} {
	if istack.len <= 0 {
		return nil
	}
	return istack.top.val
}

func (istack *InterfaceStack) Len() int {
	return istack.len
}

Реализация через дженерики

package main

type (
	gNode[NT any] struct {
		val  NT
		next *gNode[NT]
	}
	GenericStack[ST any] struct {
		top *gNode[ST]
		len int
	}
)

func NewGenericStack[GS any]() *GenericStack[GS] {
	return &GenericStack[GS]{}
}

func (gstack *GenericStack[ST]) Push(val ST) {
	var n gNode[ST] = gNode[ST]{val: val, next: gstack.top}
	gstack.len += 1
	gstack.top = &n
}

func (gstack *GenericStack[ST]) Pop() (res ST, exists bool) {
	if gstack.len <= 0 {
		exists = false
		return
	}
	gstack.len -= 1

	var n *gNode[ST] = gstack.top
	gstack.top = n.next
	return n.val, true
}

func (gstack *GenericStack[ST]) Peak() (res ST, exists bool) {
	if gstack.len <= 0 {
		exists = false
		return
	}
	return gstack.top.val, true
}

func (gstack GenericStack[ST]) Len() int {
	return gstack.len
}

Принципиальных различий нет. Единственное, что дженериках функции Pop и Peak возвращают два аргумента вместо одного.

А теперь помотрим на использование

package main

import "fmt"

func main() {
	// Interface
	istack := NewInterfaceStack()
	istack.Push(12)
	istack.Push(32)
	ival := istack.Pop();
	if ival != nil{
		if val, ok := ival.(int); !ok {
			panic("wrong type in interface stack")
		} else {
			fmt.Printf("Got '%v' from interface stack\n", val)
		}
    }
  
	// Generic
	gstack := NewGenericStack[int]()
	gstack.Push(54)
	gstack.Push(67)
	if val, exists := gstack.Pop(); exists {
		fmt.Printf("Got '%v' from generic stack\n", val)
	}
}

Пример кода есть (и он не просто компилируется, но еще и запускается без ошибок!) так что можно включать режим диванного критика и пройтись по всем аспектам этих двух подходов.

Реализация через интерфейсы. Универсальна под любой тип. Более того, она позволяет хранить разные объекты в одном стеке (особенно актуально, если у вас завалялись одна или две лишние целые ноги, по которым неплохо было бы пострелять). И размер бинарника будет на пару сотен байт меньше, по сравнению с дженериками. А что касается минусов — необходимо постоянно делать преобразование типов, внимательно следить за тем, что кладем в стек, продумывать обработку ошибок. Есть ненулевая вероятность, что ошибку преобразования придется обрабатывать на уровне выше, значит код будет запутаннее. А еще при рефакторинге можно позабыть поменять преобразование типов в каком-либо месте и долго искать откуда валится ошибка.

А теперь пришёл черед дженериков. Мы изначально знаем какой тип, поэтому преобразовывать ничего не нужно, если где-то попытаемся положить в стек неподходящий тип — компилятор надает по рукам. Меньше кода при использовании. Если нам понадобятся стеки нескольких типов, то создание кода будет переложено на компилятор и не потребует дополнительных усилий со стороны разработчика. Из минусов — теперь надо явно проверять наличие элемента в стеке — либо через длину, либо через возвращаемый флаг.

Мне кажется наш диванный аналитик подсуживает дженерикам! Почитать дак прямо идеальная фича. Но это всё касалось только стилистики кода. Но что же у нас есть еще для сравнения? Производительность! На просторах бескрайнего интернета очень часто в аргументации «за дженерики» аргументируют производительностью. Раз нет необходимости преобразования типов, то работать будет быстрее. И если с читабельностью кода всё действительно понятно — дженерики тут выигрывают прозрачностью использования и меньшим количеством кода, то с производительностью всё не так однозначно. Обычно ограничиваются умозаключениями: раз меньше кода исполняется, то работает производительнее. Да, логика понятна, нативна и производительность действительно зависит от количества выполняемых операций. Но вот насколько быстрее? Собственно для ответа на этот вопрос и была сделана такая простая реализация стека. Если вдруг вам потребуется полная версия, то на дженериках лежит тут , а на интерфейсах лежит тут.

Ну, а теперь сами тесты. (код по прежнему можно найти тут, но там только последняя версия — отличается от кода в спойлерах немного).

код тестов интерфейсного подхода

package main

import "testing"

type iTestNode struct {
	val		int
}

func createINode(value int) iTestNode {
	return iTestNode{value}
}

func BenchmarkInterfaceSimpleType(b *testing.B) {
	st := NewInterfaceStack()
	val := 1
	for i := 0; i < b.N; i += 1 {
		st.Push(val)
		if _, ok := st.Pop().(int); !ok {
			panic("Wrong type of data in stack")
		}
	}
}

func BenchmarkInterfaceSimpleCustomType(b *testing.B) {
	st := NewInterfaceStack()
	node := createINode(12)
	for i := 0; i < b.N; i += 1 {
		st.Push(node)
		if _, ok := st.Pop().(iTestNode); !ok {
			panic("Wrong type of data in stack")
		}
	}
}

func BenchmarkInterfaceCustomTypePointer(b *testing.B) {
	st := NewInterfaceStack()
	node := createINode(12)
	for i := 0; i < b.N; i += 1 {
		st.Push(&node)
		if _, ok := st.Pop().(*iTestNode); !ok {
			panic("Wrong type of data in stack")
		}
	}
}

package main

import «testing»

type gTestNode struct {
UserId int64
UserName string
AccessLevel int
Telegram string
Phone string
Skype string
Slack string
Blog string
Instagram string
Facebook string
Twitter string
Avatar []byte
Status string
}

func createGNode () gTestNode {
return gTestNode{
UserId: 12,
UserName: «someuser»,
AccessLevel: 99,
Telegram: @someuserr»,
Phone:»123456789»,
Skype: «someUser»,
Slack: «someuser»,
Blog:»,
Instagram: @instasomeuserr»,
Facebook: «facebook.com/someuser»,
Twitter: «twitter.com/someuser»,
Avatar: make ([]byte, 0),
Status: «ONLINE»,
}
}

//go: noinline
func BenchmarkGenericSimpleType (b *testing.B) {
b.StopTimer ()
st:= NewGenericStackstring
val:= «some string for tests»
b.StartTimer ()
for i:= 0; i < b.N; i += 1 {
st.Push (val)
st.Pop ()
}
}

//go: noinline
func BenchmarkGenericCustomType (b *testing.B) {
b.StopTimer ()
st:= NewGenericStackgTestNode
node:= createGNode ()
b.StartTimer ()
for i:= 0; i < b.N; i += 1 {
st.Push (node)
st.Pop ()
}
}

//go: noinline
func BenchmarkGenericCustomTypePointer (b *testing.B) {
b.StopTimer ()
st:= NewGenericStack*gTestNode
node:= createGNode ()
b.StartTimer ()
for i:= 0; i < b.N; i += 1 {
st.Push (&node)
st.Pop ()
}
}

код тестов подхода на дженериках

package main

import "testing"

type gTestNode struct {
	val      int64
}

func createGNode(value int) gTestNode {
	return gTestNode{value}
}

func BenchmarkGenericSimpleType(b *testing.B) {
	st := NewGenericStack[int]()
	val := 1
	for i := 0; i < b.N; i += 1 {
		st.Push(val)
		st.Pop()
	}
}

func BenchmarkGenericCustomType(b *testing.B) {
	st := NewGenericStack[gTestNode]()
	node := createGNode()
	for i := 0; i < b.N; i += 1 {
		st.Push(node)
		st.Pop()
	}
}

func BenchmarkGenericCustomTypePointer(b *testing.B) {
	st := NewGenericStack[*gTestNode]()
	node := createGNode()
	for i := 0; i < b.N; i += 1 {
		st.Push(&node)
		st.Pop()
	}
}

Машина, на которой проводились тесты

CPU: 8-core AMD Ryzen 7 4700U with Radeon Graphics (-MCP-)
speed/min/max: 1482/1400/2000 MHz Kernel: 5.15.85–1-MANJARO x86_64
Mem: 5500.4/31499.2 MiB (17.5%)
inxi: 3.3.24

Запускать буду не на количество итераций, а на время прохождения теста (т.е. через -benchtime=20s). Сам код запуска тестов будет таким: go test -bench=. -benchtime=20s. Все тесты буду запускать по 5 раз, чтобы определить порядок.

Результаты

goos: linux
goarch: amd64
pkg: github.com/HoskeOwl/goSimpleStack
cpu: AMD Ryzen 7 4700U with Radeon Graphics         
BenchmarkGenericSimpleType-8                    313764921               77.92 ns/op
BenchmarkGenericCustomType-8                    317463438               71.97 ns/op
BenchmarkGenericCustomTypePointer-8             218245879              111.0 ns/op
BenchmarkInterfaceSimpleType-8                  213324286              113.0 ns/op
BenchmarkInterfaceSimpleCustomType-8            222740674              112.8 ns/op
BenchmarkInterfaceCustomTypePointer-8           218896858              111.9 ns/op

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

1) BenchmarkGenericCustomTypePointer сильно выбивается по времени от остальных собратьев;

2) по факту тут у нас не только код работы стека, но и создание объектов. А что если создание объектов вынести за цикл? Ну и чтобы всё было по взрослому — вообще его не учитывать через StopTimer и StartTimer.

Результаты, не учитывающие время создания объектов

goos: linux
goarch: amd64
pkg: github.com/HoskeOwl/goSimpleStack
cpu: AMD Ryzen 7 4700U with Radeon Graphics         
BenchmarkGenericSimpleType-8                    353000452               71.54 ns/op
BenchmarkGenericCustomType-8                    338031572               73.45 ns/op
BenchmarkGenericCustomTypePointer-8             323049594               73.77 ns/op
BenchmarkInterfaceSimpleType-8                  297199362               78.44 ns/op
BenchmarkInterfaceSimpleCustomType-8            298851950               81.57 ns/op
BenchmarkInterfaceCustomTypePointer-8           291001880               83.04 ns/op
PASS
ok      github.com/HoskeOwl/goSimpleStack       191.971s

«А говорят, что дженерики то не настоящие!» — единственное, что хочется выкрикнуть после такого теста. Как только начинаем убирать части программы, не относящиеся к механизмам дженериков, то разница в производительности стремительно сокращается. Плюс мы видим, что BenchmarkGenericCustomTypePointer пришел в норму, значит проблема не в реализации стека. Но есть еще одна вещь, которая также вносит свою лепту — оптимизация компилятора. Давайте отключим и её, добавив нотацию //go:noinline для каждой функции бенчмарка.

Финальный результат

goos: linux
goarch: amd64
pkg: github.com/HoskeOwl/goSimpleStack
cpu: AMD Ryzen 7 4700U with Radeon Graphics         
BenchmarkGenericSimpleType-8                    323416230               72.84 ns/op
BenchmarkGenericCustomType-8                    325032516               71.51 ns/op
BenchmarkGenericCustomTypePointer-8             320308387               75.60 ns/op
BenchmarkInterfaceSimpleType-8                  290263794               81.65 ns/op
BenchmarkInterfaceSimpleCustomType-8            296999368               81.06 ns/op
BenchmarkInterfaceCustomTypePointer-8           291887139               82.64 ns/op
PASS
ok      github.com/HoskeOwl/goSimpleStack       190.310s

Во всех тестах порядок был одинаков. И что мы получили по итогу? Да, дженерики быстрее, но не на много.

Вместо итога: на мой взгляд дженерики — хорошее начинание в golang. Я бы не сказал, что с их выходом стоит бежать и переписывать всю свою старую кодовую базу — профита, по производительности, скорее всего не будет. А вот новый код я бы рекомендовал писать через дженерики. Читабельность возрастёт, а количество ситуаций, в которых удастся выстрелить себе в ногу — уменьшится.

И напоследок для самых пытливых -, а что произойдет если мы вместо простой структуры с одним полем будем использовать более сложную? С количеством полей от 10.

Ответ

goos: linux
goarch: amd64
pkg: github.com/HoskeOwl/goSimpleStack
cpu: AMD Ryzen 7 4700U with Radeon Graphics         
BenchmarkGenericSimpleType-8                    261289566               90.09 ns/op
BenchmarkGenericCustomType-8                    99811050               228.6 ns/op
BenchmarkGenericCustomTypePointer-8             291460882               80.94 ns/op
BenchmarkInterfaceSimpleType-8                  163295492              149.5 ns/op
BenchmarkInterfaceSimpleCustomType-8            84240980               285.6 ns/op
BenchmarkInterfaceCustomTypePointer-8           311288221               78.62 ns/op
PASS
ok      github.com/HoskeOwl/goSimpleStack       183.679s

Ожидаемо — большие структуры много времени забирают на копирование, в этом случае — дженерики вас не спасут. А положение исправит передача структур через ссылки, причём в обоих случаях.

© Habrahabr.ru