Дженерики в языке Go

?v=1
func Map[F, T any](s []F, f func(F) T) []T {
    r := make([]T, len(s))
    for i, v := range s {
        r[i] = f(v)
    }
    return r
}

Как вы уже наверняка знаете, proposal по дженерикам в Golang принят (официально это называется type parameters) и будет имплементирован в go 1.18. Бета будет доступна уже в конце этого года. А это значит, что пора разобраться, на чём в итоге остановились разработчики языка — ведь черновик type parameters постоянно менялся в течение последних лет.

Технология новая, на практике толком никто не использовал. Поэтому если увидите какую-то неточность в статье, не стесняйтесь указать это в комментариях.

Самостоятельно поиграться с дженериками можно здесь

Итак, поехали.


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

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

func stringExistsInSlice(val string, values []string) bool {
    for _, v := range values {
        if val == v {
            return true
        }
    }

    return false
}

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

func existsInSlice(val int, values []int) bool {
    for _, v := range values {
        if val == v {
            return true
        }
    }

    return false
}

Написать универсальную функцию под все типы — задача не очень простая. Можно использовать reflect и interface{}, как в примере на stackoverflow, но это, понятное дело, выглядит не очень и подвержено ошибкам, не проверяемым в момент компиляции. Или же можно использовать кодогенерацию, что тоже в общем-то так себе, так как это лишний шаг при билде.

Забегая вперёд, в go 1.18 это будет решаться так:

func existsInSlice[T comparable](val T, values []T) bool {
    for _, v := range values {
        if val == v {
            return true
        }
    }

    return false
}

Нужно ли усложнять язык дженериками?

Вопрос дискуссионный. Мнения разделились.

Как известно, язык Go изначально был заточен под максимальную простоту, и обобщение типов может усложнить читабельность кода. Многие противопоставляют Go языку Java, традиционно наполненному обобщениями различного рода, и дженерики — это как первый шаг в эту сторону. Теряется этакая гошная «дубовость» (в хорошем смысле), прямолинейность. Тем более, что в обычном продуктовом коде люди годами обходятся и без этого. Порой проще скопипастить немного кода и не париться. Как говорили в одном докладе, копипаста — это «хорошее медитативное занятие».

С другой стороны, если надо написать универсальную библиотеку для каких-то универсальных целей, то придётся использовать interface{} или кодогенерацию, а это тоже в общем-то читабельности и надёжности не добавляет. Также необходимо отметить, что разработчики языка сделали всё возможное, чтобы дженерики выглядели и использовались как можно проще. Намного проще, чем в других языках.

Согласно результатам опроса 88% респондентов назвали отсутствие дженериков критической проблемой. 18% опрошенных сказали, что не используют Go именно из-за отсутствия этой функциональности (цитата:»18% of respondents are prevented from using Go because of a lack of generics»).


Синтаксис функции с type parameters

Вот простейший пример. Функция, построчно печатающая элементы слайса любого типа.

func PrintSlice[T any](s []T) {
    for _, v := range s {
        fmt.Println(v)
    }
}

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

А вот так происходит вызов такой функции, уже с конкретным типом string:

greatings := []string{"Hello", "world"};
PrintSlice[string](greatings)

Т.е. синтаксически по сути мы передаём в функцию тип как обычный аргумент (параметр). Просто такие «аргументы» передаются и описываются в сигнатуре в отдельных квадратных скобках вместо круглых. Поэтому функциональность так и называется: type parameters.

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

greatings := []string{"Hello", "world"};
PrintSlice(greatings)

Констрейнты (ограничения типов)

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

func [T MyConstraint] (...

, где MyConstraint — это интерфейс, который описывает, каким может быть тип. Этот интерфейс может быть обычным go-интерфейсом, описывающим требуемые методы.

type MyConstraint interface {
    String() string
}

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

type MyConstraint interface {
    type int, int8, int16, int32, int64
}

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

Есть встроенные в язык констрейнты, например any (синоним interface{}) и comparable (ограничивающий типы, для которых определены операторы сравнения).

Также в стандартную библиотеку планируется добавить пакет constraints, где будут добавлены различные полезняшки. Например, constraints.Number (под это подходят любые типы, а ля int, float32 и т.д.)


Типы с обобщениями

Помимо функций подобным образом можно работать и с описанием типа.

Например, если вы пишете функции для умножения и сложения векторов, вам захочется иметь универсальный тип Vector

type Vector[T constraints.Number] []T

При использовании такого типа нужно указать в квадратных скобках его конкретный уже тип:

var myVec Vector[int]

Вот более-менее полный пример функции сложения векторов

type Number interface {
    type int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64
}

type Vector[T Number] []T

func AddVector[T Number](vec1 Vector[T], vec2 Vector[T]) Vector[T] {
    var result Vector[T]
    for i := range vec1 {
        result = append(result, vec1[i]+vec2[i])
    }
    return result
}

func main() {
    v1 := Vector[int]{1, 2, 3}
    v2 := Vector[int]{3, 4, 5}
    result := AddVector(v1, v2)
    fmt.Println(result)
}

поиграться с примером можно здесь: https://go2goplay.golang.org/p/n05eSb5uFXS

(в примере я не использовал встроенный интерфейс constraints.Number, так как на go2goplay.golang.org это почему-то не работает. Пришлось делать свой доморощенный interface Number)

Обратите внимание на то, что здесь нельзя использовать констрейнт any, так как операция сложения определена далеко не для всех типов, и вы получите соответствующую ошибку «operator + not defined».


Некоторые замечания по реализации


Пакеты

В стандартную библиотеку планируется добавить несколько пакетов, таких как slices, maps, chans и т.д., которые будут предоставлять универсальные функции для работы со слайсами, каналами и т.д.

Пакеты container/list, container/ring, sync и другие будут доработаны с точки зрения типобезопасности. Math получит новые универсальные функции для любых чисел (например Min и Max)


Эффективность

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

Цитата:


Generic functions, rather than generic types, can probably be compiled using an interface-based approach. That will optimize compile time, in that the function is only compiled once, but there will be some run time cost.

Generic types may most naturally be compiled multiple times for each set of type arguments. This will clearly carry a compile time cost, but there shouldn’t be any run time cost. Compilers can also choose to implement generic types similarly to interface types, using special purpose methods to access each element that depends on a type parameter.


Отличие от java

Как известно, java удаляет информацию о дженериках после компиляции. В golang это не так.

Также, в Джаве реализована ковариантность и контрвариантность (List, List), в Go всё намного проще: мы просто передаём тип как параметр, а тип ограничен интерфейсом.

Кстати, многие спрашивают, почему нельзя было сделать стандартные для многих языков (включая Java) скобки <>, а ввели новый вариант в виде квадратных скобок?

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

a, b = w < x, y > (z)

можно трактовать как

a, b = (w (z)

Ещё примеры

Типобезопасная функция, объединяющая два канала в один

func Merge[T any](c1, c2 <-chan T) <-chan T {
    r := make(chan T)
    go func(c1, c2 <-chan T, r chan<- T) {
        defer close(r)
        for c1 != nil || c2 != nil {
            select {
            case v1, ok := <-c1:
                if ok {
                    r <- v1
                } else {
                    c1 = nil
                }
            case v2, ok := <-c2:
                if ok {
                    r <- v2
                } else {
                    c2 = nil
                }
            }
        }
    }(c1, c2, r)
    return r
}

Делаем свой Set на основе map

package sets

type Set[T comparable] map[T]struct{}

func Make[T comparable]() Set[T] {
    return make(Set[T])
}

func (s Set[T]) Add(v T) {
    s[v] = struct{}{}
}

func (s Set[T]) Delete(v T) {
    delete(s, v)
}

func (s Set[T]) Contains(v T) bool {
    _, ok := s[v]
    return ok
}

func (s Set[T]) Len() int {
    return len(s)
}

func (s Set[T]) Iterate(f func(T)) {
    for v := range s {
        f(v)
    }
}

Пример использования

s := sets.Make[int]()
s.Add(1)
if s.Contains(2) { panic("unexpected 2") }

Что дальше?

Дальше — больше. Есть куча предложений, как улучшить дженерики: в частности автоматический вывод типов в различных ситуациях или упрощённая работа с zero value. Но это всё по сути ждёт, когда основная функциональность попадёт в язык.

В общем, поживём — увидим. Осталось уже недолго.

© Habrahabr.ru