Добавление дженериков (generics) в Go (ранее Golang) — самое значительное изменение, которое он претерпел, с момента его релиза. Сообщество Go просило добавить дженерики с самых первых дней языка, и мы, наконец, дождались.

Реализация дженериков в Go сильно отличается от традиционных реализаций, которые можно найти в C++, но все-таки имеет некоторое сходство с реализацией дженериков в Rust. В этой статье вашему вниманию представлен обзор, нацеленный помочь вам сформировать понимание дженериков Go и продемонстрировать, как с ними работать.

Что такое дженерики?

Чтобы использовать дженерики правильно, сперва нужно разобраться, что это такое и зачем это нужно. Дженерики (или “обобщения”) позволяют вам писать код без явного указания конкретных типов данных, которые он должен принимать или возвращать. Другими словами, при написании некоторого кода или структуры данных вы не предоставляете тип значений. Вместо этого параметры типов передаются позже. Дженерики дают возможность указывать типы позже, что позволяет Go-программистам избегать шаблонный код.

Зачем нужны дженерики?

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

Эта проблема коренным образом решается с помощью дженериков.

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

Go 1.18.0 вводит новый синтаксис для предоставления дополнительных метаданных и определения ограничений (constraints) для типов.

package main

import "fmt"

func main() {
        fmt.Println(reverse([]int{1, 2, 3, 4, 5}))
}

// T - это параметр типа, который используется как обычный тип внутри функции
// any - это ограничение на тип, т.е. T должен реализовать интерфейс "any" (об этом чуть ниже)
func reverse[T any](s []T) []T {
        l := len(s)
        r := make([]T, l)

        for i, ele := range s {
                r[l-i-1] = ele
        }
        return r
}

Ссылка на Playground

Как вы можете видеть на изображении выше, квадратные скобки [] используются для указания параметров типа (type parameters), которые представляют собой список идентификаторов и интерфейс-ограничение. Т здесь — это параметр типа, который используется для определения аргументов и возврата типа функции.

Параметр также доступен внутри функции. any является интерфейсом; Т должен реализовывать этот интерфейс. Go 1.18 ввел any в качестве псевдонима для interface{}.

Параметр типа похож на переменную типа — все операции, поддерживаемые обычными типами, поддерживаются и переменными типа (например, функция make). Переменная, инициализированная с помощью параметров типа, будет поддерживать операции ограничения; в приведенном выше примере в качестве ограничения выступает any.

type any = interface{}

Функция имеет []Т в качестве входного и типа возврата. Здесь параметр типа Т используется для определения множества типов, которые можно использовать внутри функции. Такие функции-дженерики (или универсальные функции) инстанцируются путем передачи значения типа в параметр типа.

reverseInt:= reverse[int]

Ссылка на Playground

(Примечание: Когда параметр типа передан типу, он называется “инстанцированным” (instantiated))

Компилятор Go выводит параметр типа, проверяя аргументы, передаваемые функциям. В нашем первом примере он автоматически делает вывод, что параметр типа int, и очень часто вы можете вообще не подставлять его.

// Без подставления типа
fmt.Println(reverse([]int{1, 2, 3, 4, 5}))

// Подставляем тип
fmt.Println(reverse[int]([]int{1, 2, 3, 4, 5}))

Параметры типа

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

Параметры типа в функциях

Использование параметров типа в функциях позволяет программистам писать обобщенный код.

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

// T здесь — параметр типа; он ведет себя как обычный тип
func print[T any](v T){
 fmt.Println(v)
}

Ссылка на Playground

Параметры типа и специальные типы

Здесь дженерики очень полезны тем, что они позволяют нам писать служебные функции над специальными типами.

Слайс

Для создания слайса требуется только один тип, поэтому и параметр типа необходим только один. В приведенном ниже примере показано использование параметра типа Т со слайсом.

// ForEach над слайсом, который будет выполнять функцию для каждого элемента слайса
func ForEach[T any](s []T, f func(ele T, i int , s []T)){
    for i,ele := range s {
        f(ele,i,s)
    }
}

Ссылка на Playground

Map

Для map уже требуется два типа: тип ключа (key) и тип значения (value). Тип значения не имеет никаких ограничений, а вот тип ключа всегда должен удовлетворять ограничение comparable.

// keys возвращают ключ карты
// m здесь является дженериком, задействующим параметры типов K и V
// V ограничен any, т.е. по факту не ограничен
// K ограничен comparable, т.е. это любой тип, который поддерживает операции != и ==
func keys[K comparable, V any](m map[K]V) []K {
// Создание слайса типа K с длиной, равной длине нашей map
    key := make([]K, len(m))
    i := 0
    for k, _ := range m {
        key[i] = k
        i++
    }
    return key
}

Точно так же дженериками поддерживаются и каналы.

Параметры типа в структурах

Go позволяет определять структуры с параметром типа. Синтаксис аналогичен функции-дженерику. Параметр типа можно использовать как в методах, так и в элементах данных структуры.

// T здесь параметр типа с ограничением any
type MyStruct[T any] struct {
    inner T
}

// Никаких новых параметров типа в методах структуры не допускается
func (m *MyStruct[T]) Get() T {
    return m.inner
}
func (m *MyStruct[T]) Set(v T) {
    m.inner = v
}

Определение нового параметра типа в методах структуры не допускается, но параметры, уже указанные в определении структуры, в методах использовать можно.

Параметры типа в типах-дженериках

Типы-дженерики могут быть вложены в другие типы. Параметр типа, определенный в функции или структуре, может быть передан любому другому типу с параметрами типа.

// Структура-дженерик с двумя типами-дженериками
type Enteries[K, V any] struct {
    Key   K
    Value V
}
// Так как map требует от ключа соответствовать ограничению comparable, ключ K ограничен comparable
// В этом примере используется вложение параметров типа
// Enteries[K,V] инициализируют новый тип и используется здесь как возвращаемый тип
// Тип возвращаемого значения этой функции - слайс Enteries, в который передаются типы переданным K и V
func enteries[K comparable, V any](m map[K]V) []Enteries[K, V] {
    // Определяем слайс типа Enteries, передавая параметры типа K и V
    e := make([]Enteries[K, V], len(m))
    i := 0
    for k, v := range m {
        // Создаем значение с использованием нового ключевого слова
        newEntery := new(Enteries[K, V])
        newEntery.Key = k
        newEntery.Value = v
        e[i] = newEntery
        i++
    }
    return e
}

Ссылка на Playground

// Тип Enteries здесь создается путем предоставления требуемого типа, который определен в функции enteries
func enteries[K comparable, V any](m map[K]V) []*Enteries[K, V]

Ограничения типа

В отличие от дженериков в C++, дженерикам Go разрешено выполнять только определенные операции, перечисленные в специальном интерфейсе, известном как ограничение (constraint).

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

Например, в приведенном ниже фрагменте любое значение параметра типа Т поддерживает только метод String - с ним уже вы можете использовать len() или любую другую операцию.

// Stringer — это ограничение
type Stringer interface {
 String() string
}

// T здесь должен реализовать Stringer; T может выполнять только операции, определенные Stringer
func stringer[T Stringer](s T) string {
 return s.String()
}

Ссылка на Playground

Встроенные типы в ограничениях

Новые дополнения к Go позволяют использовать встроенные типы, такие как int и string для реализации интерфейсов, которые используются в качестве ограничений. Интерфейсы с встроенными типами можно использовать только в качестве ограничений.

type Number {
  int
}

В более ранних версиях компилятора Go встроенные типы никогда не реализовывали какой-либо интерфейс, кроме самого interface{}, так как в нем нет методов.

Ограничение со встроенным типом и методом одновременно использовать нельзя, поскольку у встроенных типов очевидно нет определяемых пользователем методов; поэтому реализовать эти ограничения невозможно.

type Number {
  int
  Name()string // int не имеет метода Name
}

Оператор | позволяет объединять типы в набор (т. е. несколько конкретных типов могут реализовать один интерфейс, а результирующий интерфейс позволяет выполнять общие операции из всех типов набора).

type Number interface {
        int | int8 | int16 | int32 | int64 | float32 | float64
}

В приведенном выше примере интерфейс Number поддерживает все операции, которые являются общими для предоставленного набора типов, например <, >, и + — поддерживаются все арифметические операции.

// T как параметр типа теперь поддерживает все типы int и float
// Чтобы иметь возможность выполнять эти операции, ограничение должно реализовывать только те типы, которые поддерживают арифметические операции
func Min[T Number](x, y T) T {
        if x < y {
                return x
        }
        return y
}

Ссылка на Playground

Использование набора из нескольких типов позволяет выполнять общие операции, поддерживаемые этими типами, и писать код, который будет работать для всех типов из набора.

Аппроксимация типа 

Go позволяет создавать пользовательские типы на основе встроенных типов, таких как int, string, и т. д. Оператор ~ позволяет нам указать, что интерфейс также поддерживает все типы с указанным базовым типом.

Например, если вы хотите добавить в функцию Min поддержку типа Point с базовым типом int, это можно сделать с помощью ~.

// Любой тип с заданным базовым типом будет поддерживаться этим интерфейсом
type Number interface {
        ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float32 | ~float64
}

// Тип с базовым типом int
type Point int

func Min[T Number](x, y T) T {
        if x < y {
                return x
        }
        return y
}

func main() {
        // создание типа Point
        x, y := Point(5), Point(2)

        fmt.Println(Min(x, y))

}

Ссылка на Playground

Все встроенные типы поддерживают эту аппроксимацию типа — оператор ~ работает только с ограничениями.

// Оператор объединения и аппроксимация типа используются вместе без интерфейса
func Min[T ~int | ~float32 | ~float64](x, y T) T {
        if x < y {
                return x
        }
        return y
}

Ссылка на Playground

Ограничения также поддерживают вложение; ограничение Number может быть построено из ограничений Integer и Float.

// Integer состоит из всех типов int
type Integer interface {
        ~int | ~int8 | ~int16 | ~int32 | ~int64
}

// Float состоит из всех типов float

type Float interface {
        ~float32 | ~float64
}

// Number формируется из Integer и Float
type Number interface {
        Integer | Float
}

// Использование Number
func Min[T Number](x, y T) T {
        if x < y {
                return x
        }
        return y
}

Ссылка на Playground

Пакет constraints 

Команда Go также предоставила новый пакет с набором полезных ограничений — этот пакет как раз и содержит ограничения Integer, Float и т. д.

Этот пакет экспортирует ограничения для встроенных типов. Поскольку в язык можно добавлять новые встроенные типы, лучше использовать ограничения, определенные в пакете constraints. Важнейшим из них является ограничение [Ordered](https://pkg.go.dev/golang.org/x/exp/constraints#Ordered). Оно определяет все типы, которые поддерживают операторы >, <, ==, и !=.

func min[T constraints.Ordered](x, y T) T {
    if x > y {
        return x
    } else {
        return y
    }
}

Ссылка на Playground

Интерфейсы vs. дженерики

Дженерики не заменяют интерфейсы. Дженерики предназначены для работы с интерфейсами и делают Go более типобезопасным, а также могут использоваться для устранения львиной доли повторяющегося кода.

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

Заключение

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

Мы также рассмотрели операторы объединения и новый синтаксис для реализации интерфейса для встроенных типов, использование аппроксимации типов и дженериков со специальными типами, такими как структуры.

Когда у вас уже сформирована прочная основа из базового понимания, вы можете глубже погрузиться в более сложные темы, такие как использование дженериков с утверждениями типа.

Я уверен, что дженерики послужат строительными блоками для отличной библиотеки, подобной lodash из экосистемы JavaScript. Дженерики также помогают в написании служебных функций для Map, Slice и Channel, потому что трудно написать функции, поддерживающие все типы без пакета reflect.

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


Приглашаем всех желающих на открытое занятие «Внутреннее устройство планировщика Go». Посмотрим на то, как устроен планировщик внутри Go. Узнаем, как эти знания можно использовать в повседневной практике. Записаться можно на странице курса "Golang Developer. Professional".

Комментарии (7)


  1. Bytamine
    00.00.0000 00:00

    Переводить статью годичной давности надо было год назад.


    1. NeoCode
      00.00.0000 00:00

      Почему? Это же не новости, а статья по программированию. Язык Go жив и развивается, а если подходить к этому еще более абстрактно, то концепция дженериков как таковая - это вообще чистая computer science, которая не стареет никогда, как математика.


      1. hello_my_name_is_dany
        00.00.0000 00:00
        +2

        Только уже давно вышел Go 1.19, а тему дженериков размусолили не один десяток раз до того, во время того и после того, как вышла 1.18


        1. NeoCode
          00.00.0000 00:00

          А различные алгоритмы появились вообще задолго до появления компьютеров.


  1. VladimirFarshatov
    00.00.0000 00:00
    +2

    На практике столкнулся с плохой читаемостью синтаксиса дженериков. [] явно не лучший выбор, но возможно это было связано с количеством параметров >2 и длинным неймингом по типу С#. Очень тяжело было читать чужой код. Кмк <> было бы нагляднее.


    1. NeoCode
      00.00.0000 00:00
      -2

      <> создают большие проблемы для парсеров из-за того что это еще и символы "больше/меньше". В принципе можно было пойти по тому же пути, по которому когда-то пошел Си с диграфами и триграфами: придумать сочетание скобок и символов, что-то типа <| |>, и в качестве эквивалента - пару скобок из Unicode, например〈 〉.


  1. Tuxman
    00.00.0000 00:00

    Дженерики дают возможность указывать типы позже, что позволяет Go-программистам избегать шаблонный код.

    Как раз программист тут пишет некий шаблонный код, по которому потом компилятор подставит реальный тип.

    Generics allow Go programmers to specify types later and avoid the boilerplate code.

    В оригинале говорится про бойлерплейт. Упс, я только что посмотрел в wiki, и там boilerplate code==Шаблонный код, ну так себе перевод, если честно.