Go и Generics
Дженерики (обобщенное программирование) были введены в язык программирования Go в версии 1.18. Они позволяют писать функции и типы данных, которые могут работать с различными типами данных без потери типобезопасности. До появления дженериков разработчики Go часто использовали интерфейсы и тип interface{} для создания функций и структур, способных работать с любыми типами данных, что могло привести к ошибкам времени выполнения из-за неправильного преобразования типов.
Синтаксис
Дженерики в Go определяются с использованием квадратных скобок [] после имени функции или типа. Внутри скобок указываются один или несколько типовых параметров, которые затем можно использовать в теле функции или типа как обычные типы данных.
В этом примере T является типовым параметром, который может быть заменен любым типом данных. Ключевое слово any означает, что T может быть любым типом.
Преимущества
Повторное использование кода: Дженерики позволяют создавать гибкие функции и типы данных, которые можно использовать с различными типами данных без дублирования кода.
Типобезопасность: В отличие от использования interface{}, дженерики обеспечивают проверку типов на этапе компиляции, что уменьшает риск ошибок времени выполнения.
Производительность: Использование дженериков может улучшить производительность, так как избавляет от необходимости преобразования типов и позволяет компилятору оптимизировать генерируемый код.
Ограничения
Синтаксис дженериков может быть сложным для понимания, особенно для начинающих разработчиков.
Обобщенное программирование может усложнить структуру кода и его читаемость.
Не все существующие библиотеки и инструменты уже полностью поддерживают дженерики.
Несмотря на ограничения, введение дженериков в Go значительно улучшило возможности языка для создания более гибкого и безопасного кода.
Следующий код демонстрирует применение обобщенного программирования в Go с использованием типового параметра T, ограниченного интерфейсом Signed. Интерфейс Signed определяет типы, которые могут быть одним из подписанных (signed) целочисленных типов (int, int8, int16, int32, int64). Структура NumericBox использует этот типовый параметр, что позволяет создавать экземпляры NumericBox для любого подписанного целочисленного типа.
package main
import (
"fmt"
)
type Signed interface {
~int | ~int8 | ~int16 | ~int32 | ~int64
}
// NumericBox - обобщенная структура, ограниченная для работы только с числовыми типами.
type NumericBox[T Signed] struct {
Value T
}
// NewNumericBox создает новый экземпляр NumericBox с заданным числовым значением.
func NewNumericBox[T Signed](value T) NumericBox[T] {
return NumericBox[T]{Value: value}
}
// GetValue возвращает числовое значение из NumericBox.
func (b NumericBox[T]) GetValue() T {
return b.Value
}
func main() {
int_32 := NewNumericBox(int32(123))
fmt.Println("IntBox:", int_32.GetValue()) // Вывод: IntBox: 123
int_64 := NewNumericBox(int64(-123))
fmt.Println("IntBox:", int_64.GetValue()) // Вывод: IntBox: -123
// Следующая строка вызовет ошибку компиляции, так как строка не является числовым типом.
// stringBox := NewNumericBox("Hello, Generics!") // string does not satisfy Signed (string missing in ~int | ~int8 | ~int16 | ~int32 | ~int64)
}
Давайте теперь напишем замер скорости работы, простой структуры, интерфейса и Ginerics
package main
import (
"fmt"
"time"
)
type Animal interface { Get() int }
type Dog struct{ Value int }
func (d Dog) Get() int { return d.Value }
type Signed interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 }
type NumericBox[T Signed] struct { Value T }
// Функция, принимающая конкретный тип.
func printInt(i int) {
fmt.Println(i)
}
// функция, принимающая интерфейс Animal.
func printInterface(animal Animal) {
fmt.Println(animal.Get())
}
// Функция, принимающая экземпляр NumericBox с любым поддерживаемым числовым типом.
func printGinerics[T Signed](box NumericBox[T]) {
fmt.Println(box.Value)
}
func main() {
// Замер производительности при использовании конкретного типа.
startConcrete := time.Now()
for i := 0; i < 1000000; i++ {
printInt(i)
}
resultConcrete := time.Since(startConcrete)
// Замер производительности при использовании интерфейса.
startInter := time.Now()
for i := 0; i < 1000000; i++ {
printInterface(Dog{Value: i})
}
resultInter := time.Since(startInter)
// Замер производительности при использовании дженерика.
startGeneric := time.Now()
for i := 0; i < 1000000; i++ {
printGinerics(NumericBox[int]{Value: i})
}
resultGeneric := time.Since(startGeneric)
fmt.Printf("Using concrete type: %v\n", resultConcrete) // Using concrete type: 869.528353ms
fmt.Printf("Using interface: %v\n", resultInter) // Using interface: 877.926898ms
fmt.Printf("Using generic: %v\n", resultGeneric) // Using generic: 841.916956ms
}
В примере обнаружено, что использование дженериков (обобщенных типов) в Go приводит к самому быстрому времени выполнения по сравнению с использованием конкретных типов и интерфейсов. Это может быть связано с несколькими факторами:
Меньшее количество преобразований типов: Когда вы используете дженерики, Go может генерировать код непосредственно для конкретного типа во время компиляции, что уменьшает необходимость в преобразованиях типов или упаковке/распаковке интерфейсов во время выполнения. Это делает операции более быстрыми.
Прямой доступ к данным: В случае дженериков, доступ к данным происходит напрямую, без дополнительного уровня абстракции, который требуется при использовании интерфейсов. Это уменьшает накладные расходы и может привести к более высокой производительности.
Отсутствие динамического поиска методов: Когда вы используете интерфейсы, Go должен выполнить поиск соответствующего метода в таблице виртуальных методов интерфейса во время выполнения. Дженерики же позволяют избежать этого, так как конкретная реализация метода известна на этапе компиляции.
Компилятор оптимизирует код для дженериков: Компилятор Go может выполнять специализацию кода для дженериков, что означает, что он может генерировать оптимизированный код для каждого конкретного случая использования.