
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/
Добавить комментарий