Это первая часть статьи, посвященной дженерикам в Go, из четырех.

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

Типы

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

Определенное программирование

Вы наверняка знаете, что в Go есть разные типы данных: числа, строки, и так далее. У каждой переменной, у каждого значения есть какой‑то тип — это может быть как встроенный тип, к примеру int, или собственный, как, например, struct.

Go отслеживает использование типов и вы отлично знаете, что он не позволит использовать несовместимые типы — например, вы не можете просто присвоить значение int переменной float64.

И вы наверняка писали функции, принимающие в качестве аргумента какой‑то определенный тип, к примеру, строку. Если бы мы попробовали передать значение другого типа, Go посчитал бы это ошибкой.

Это можно в каком‑то смысле назвать определенным программированием: написание функций, принимающих определенное входное значение. И это скорее всего то, к чему вы привыкли в Go.

Обобщенное программирование

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

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

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

Интерфейсы в качестве типов

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

Вы, вероятно, встречали типы‑интерфейсы, как, например, io.Writer. Вот пример функции, задающей параметр w с типом io.Writer:

func PrintTo(w io.Writer, msg string) {
    fmt.Fprintln(w, msg)
}

В данном случае мы не знаем, какой точный тип у аргумента w будет в рантайме (это динамический тип), но мы можем сказать Go хоть что‑то о нем. Мы говорим, что он должен реализовывать интерфейс io.Writer.

Что значит реализовать интерфейс? Давайте посмотрим на его определение, чтобы поискать подсказки:

type Writer interface {
    Write(p []byte) (n int, err error)
}

Тут говорится, что чтобы быть типом Writer — то есть чтобы реализовывать io.Writer — нужно иметь определенный набор методов. В данном случае это всего лишь один метод с определенной сигнатурой (принимать []byte и возвращать int и error).

Это значит, что io.Writer может быть реализован более, чем одним типом. На самом деле, любой тип с подходящим методом Write автоматически реализует его.

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

К примеру, мы можем создать какую‑нибудь свою структуру и добавить ей метод Write, который не будет ничего делать:

type MyWriter struct {}

func (MyWriter) Write([]byte) (int, error) {
    return 0, nil
}

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

Интерфейсы в качестве параметров

Сам по себе на практике тип MyWriter не особо полезен, поскольку ничего не делает сам по себе. Там не менее, любое значение типа MyWriter — валидный io.Writer, так как имеет нужный метод Write.

У него могут быть и другие методы, но для Go это не важно, чтобы решить, является ли MyWriter типом io.Writer. Ему важно лишь наличие метода Write с правильной сигнатурой.

Это значит, что мы можем передать экземпляр MyWriter, например, в PrintTo:

PrintTo(MyWriter{}, "Hello, world!")

Если мы попробуем передать функции какой‑то другой тип, который не реализует интерфейс, оно не должно сработать:

type BogusWriter struct{}

PrintTo(BogusWriter{}, "This won't compile!")

Так и есть, мы получаем следующую ошибку:

cannot use BogusWriter{} (type BogusWriter) as type io.Writer
in argument to PrintTo:
    BogusWriter does not implement io.Writer
    (missing Write method)

Справедливо. Функция заявляет параметр типа io.Writer не просто так — она планирует вызвать метод Write параметра. Принимая тип интерфейса она сообщает, что именно собирается делать с аргументом: записывать в него!

Go может сразу определить, что это не сработает с типом BogusWriter, поскольку у него нет такого метода. Поэтому Go не дает нам передать BogusWriter туда, где ожидается io.Writer.

Полиморфизм

Но в чем смысл? Почему бы, например, просто не задать функцию PrintTo, принимающую MyWriter в качестве параметра? То есть какой‑то определенный тип?

Интерфейсы делают код гибким

Ну вы уже знаете почему: потому что io.Writer'ом может быть не только один тип. В стандартной библиотеке много таких типов: например, *os.File или *bytes.Buffer. Функция, принимающая io.Writer может работать с любым из них.

Тут становится понятна польза интерфейсов: они позволяют писать крайне гибкие функции. Мы можем избежать написания разных версий одной и той же функции — к примеру, PrintToFile, PrintToBuffer, PrintToBuilder и так далее.

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

Данное поведение имеет свой собственный термин — полиморфизм («много форм»). Это просто значит, что мы можем принимать «много типов» в качестве аргумента, до тех пор пока они реализуют заданный нами интерфейс (то есть набор методов).

Ограничение параметров с помощью интерфейсов

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

Это не обязательно должен быть интерфейс из стандартной библиотеки, как io.Writer; мы можем создать любой нужный нам интерфейс.

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

Ну, мы знаем, что мы будем вызывать String у передаваемого значения, так что у него должен быть этот метод. Как нам выразить это требование в виде интерфейса? Вот так:

type Stringer interface {
    String() string
}

Другими словами, любой тип может быть Stringer, до тех пор пока у него есть метод String. Теперь мы можем создать нашу функцию Stringify, принимающую аргумент с типом созданного нами интерфейса:

func Stringify(s Stringer) string {
    return s.String()
}

На самом деле, такой интерфейс уже существует в стандартной библиотеке (называется fmt.Stringer), но суть вы уловили. Указывая интерфейс в качестве параметра функции мы можем использовать один и тот же код для нескольких динамических типов.

Заметьте, что все, что мы можем потребовать от метода при использовании интерфейсов — его название и сигнатура (то есть, что он принимает и что возвращает). Мы не можем определить, что этот метод будет делать.

И он может вообще ничего не делать, как, например, и было в случае с MyWriter и это нормально: он все еще реализует интерфейс.

Ограничения наборов методов

Данный подход «набора методов» для ограничения входящих параметров полезен, но достаточно ограничен. Предположим, мы хотим написать функцию, которая складывает два числа. Мы могли бы написать что‑то такое:

func AddNumbers(x, y int) int {
    return x + y
}

Это прекрасно работает для значений типа int, но что насчет float64? Ну, нам придется опять писать ту же функцию, но в этот раз с другим типом аргумента и возвращаемого значения:

func AddFloats(x, y float64) float64 {
    return x + y
}

Сама логика функции (x + y) при этом не меняется, так что тут система типов скорее мешает, чем помогает.

И да, нам пришлось бы также писать AddInt64s, AddInt32s, AddUints и так далее, и в каждой из них будет один и тот же код. Это скучно и не совсем то, ради чего мы становились разработчиками.

Так что надо придумать что‑то другое. Может быть, интерфейсы смогут помочь?

Давайте попробуем. Предположим, мы изменим AddNumbers так, чтобы она принимала пару аргументов с типом какого‑то интерфейса вместо определенного типа int или float64.

Но какой интерфейс стоит использовать? Другими словами, какие методы нужны нам от типа аргумента?

Вот тут мы и натыкаемся на ограничения интерфейсов на основе набора методов. Ведь на самом деле у int нет методов, как и у любого другого встроенного типа! Нет какого‑то метода, которые был бы одновременно реализован int, float64 и другими товарищами.

Мы все еще могли бы реализовать какой‑то интерфейс: к примеру, мы могли бы создать структуру с таким методом и передать ее AddNumber. Отлично. Но это не позволит использовать какой‑либо из встроенных типов Go, что само по себе крайне неудобное ограничение.

Пустой интерфейс: any

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

Можем ли мы использовать any в качестве типа аргументов? Так как это позволило бы передавать аргументы любого типа, можно переименовать функцию в AddAnything:

// invalid
func AddAnything(x, y any) any {
    return x + y
}

К сожалению, оно не компилируется:

invalid operation: x + y (operator + not defined on interface)

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

Но почему нет? Потому что мы, по сути, сказали, что x и y могут быть любого типа, но не каждый тип может работать с оператором +.

Если бы x и y были экземплярами структур, например, что бы вообще значило сложить их? Это никак не узнать, так что Go запрещает использовать +.

Но есть и другая, чуть менее очевидная проблема. Нам нужно, чтобы x и y были одного типа, каким бы он ни был. Но поскольку они оба объявлены как any, мы можем вызвать функцию с разными типами для x и y, что уж точно не имело бы смысла.

Проверка типов и switch

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

switch v := x.(type) {
case int:
    return v + y
case float64:
    return v + y
case ...

Используя таким образом switch, мы можем перечислить все типы, поддерживающие оператор + и использовать его с ними.

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

На практике нам нечасто приходится писать такие функции, как AddAnything. Но это так или иначе остается неким ограничением Go, которое, помимо всего прочего, затрудняет написание библиотек общего применения.

В качестве примера возьмем Math из стандартной библиотеки. В ней содержится большое количество полезных функций, таких как Pow, Abs и Max… но использованы они могут быть только с данными типа float64.

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

Go, встречай дженерики

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

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

С чего все началось

По крайней мере, так было до недавнего момента, ведь теперь способ есть. Мы можем использовать дженерики!

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

Релиз Go состоялся 10 ноября 2009. Менее, чем через 24 часа мы увидели первый комментарий про дженерики. - Ian Lance Taylor, “Why Generics?”

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

Так почему же создатели не добавили их позже? Ну, есть пара причин.

Как сейчас обстоят дела

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

Команда Go также очень ценит обратную совместимость: то есть, в язык нельзя добавлять изменения, ломающие ее. Так что при добавлении нового синтаксиса это должно быть сделано так, чтобы не возникало конфликтов с какими‑либо существующими программами. И это сложно!

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

Поэтому для появления дженериков в Go потребовалось десять лет. И то, что мы получили спустя столько времени раздумий и споров — достаточно приятный дизайн. Как и сам Go, они достигают нужного результата без лишних телодвижений.

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

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

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


  1. berezuev
    02.12.2024 12:02

    Да как так?

    В статье про дженерики нет ни слова про дженерики. Зато налили тонну воду про интерфейсы и типы.


  1. leotsarev
    02.12.2024 12:02

    Так что при добавлении нового синтаксиса это должно быть сделано так, чтобы не возникало конфликтов с какими‑либо существующими программами. И это сложно!

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