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 { valint }  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

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

UPD: как правильно заметили в комментариях — всё зависит от нагруженности сервиса. В данном случе вывод под ненагруженный проект, в ашем случае 10% может быть значительным приростом.

Вместо итога: на мой взгляд дженерики — хорошее начинание в 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

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


ссылка на оригинал статьи https://habr.com/ru/post/712066/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *