Generico! Дженерики в go или стоит ли оно того
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
Ожидаемо — большие структуры много времени забирают на копирование, в этом случае — дженерики вас не спасут. А положение исправит передача структур через ссылки, причём в обоих случаях.