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

Примечание. Если вы еще не познакомились с концепцией дженериков, я настоятельно рекомендую вам сделать это до прочтения этой статьи. Также мнения, высказанные в этой статье, не ограничиваются языком программирования Go.

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

Официальный учебник по дженерикам Go описывает дженерики как способ «объявлять или использовать функции или типы, написанные для работы с любым набором типов». Какое бессмысленное объяснение дженериков! Если это было единственной целью дженериков, то интерфейсы предназначены именно для этого. На самом деле, для работы дженериков требуются интерфейсы. В этом описании не описываются дополнительные функциональные возможности, предусмотренные для дженериков. В частности, дженерики позволяют указать в сигнатуре функции или интерфейсе, что два или более типов интерфейсов должны совместно использовать одну и ту же конкретную реализацию, чтобы разрешить операции, требующие совпадающих реализаций или возвращающие совпадающую реализацию. Странно, что нигде об этом не сказано.

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

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

Недостаток № 1 — дженерики можно использовать только один раз.

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

// Snippet 1
func Print[T interface{}](object T) {
    fmt.Println(object)
}
// Snippet 2
func Print(object interface{}) {
    fmt.Println(object)
}

Из-за этого я предлагаю, чтобы было как минимум предупреждение во время компиляции (или, возможно, даже ошибка) при использовании дженериков таким образом, подобно тому, как Go отказывается компилировать, когда импортированный модуль не используется. Однако заявление, которое я только что сделал, на удивление противоречиво. Некоторые утверждают, что, хотя код одинаков по функциональности, он может быть реализован по-разному под капотом и иметь разные характеристики производительности. Обычно ожидается, что первый будет шаблонизирован таким образом, что компилятор создаст отдельную реализацию Print для каждого типа, что обеспечивает повышенную производительность во время выполнения за счет потенциально большего двоичного файла. Второе всегда будет достигаться с помощью полиморфизма во время выполнения. Это устаревший артефакт того, как дженерики работают в подобных C++, где дженерики возникают тогда и только тогда, когда происходит шаблонирование, чего нет в более современных языках, таких как Java и TypeScript, где стирание типов происходит во время компиляции. Было бы позорно видеть, как Go использует стратегию смешивания шаблонов и дженериков, а не разделения их на две отдельные концепции.

Цитата со страницы проблемы хорошо резюмирует это:

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

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

Недостаток №2 — встроенные операции не реализуют традиционные интерфейсы

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

sum := a.Add(b).Add(c)

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

type Adder[T any] interface {
    Add(other T) T;
}

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

type Adder interface {
    rune | int16 | uint32 | float64 | complex128 | ...
}

У этой системы есть два недостатка. Во-первых, интерфейс определяется набором реализаций, а не фактическим интерфейсом, что предотвращает появление новых реализаций, если вы не добавите их вручную в существующий интерфейс. Это противоречит основной архитектурной цели интерфейсов. Go попытался смягчить это раздражение, представив некоторые встроенные и стандартные интерфейсы библиотек (например, constraints.Integer), поэтому вам не нужно беспокоиться о заполнении этих списков типов, но, к сожалению, эти интерфейсы включают только встроенные типы.

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

Я могу понять решение не вводить в язык перегрузку операторов. По этой причине я бы предложил, чтобы встроенные интерфейсы вместо этого использовали методы для представления этих операций. Затем базовые типы должны изначально реализовывать эти интерфейсы, делая a + b заменяемым на a.Add(b). Таким образом, новые типы могли бы реализовать эти операции, но пользователи интерфейсов операций столкнулись бы с необходимостью вводить a.Add(b) вместо a + b. На мой взгляд, это был бы разумный компромисс, тем более, что многие из реализаций в любом случае могут быть пользовательскими типами, такими как векторы или матрицы, где имена операций должны быть указаны в любом случае.

Недостаток 3 – отсутствие самоссылающихся дженериков

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

type Adder[T any] interface {
    Add(other T) T;
}

Это делает дженерики непригодными для многих критически важных вариантов использования. Любые пользовательские операции (или даже обертка встроенных операций) не могут быть выполнены элегантно. Потенциальный способ, которым Go мог бы представить эту возможность, — это специальный дженерик «self». Возможно, это можно было бы показать неявно, без необходимости добавлять его в список параметров типа вручную, но я решил включить этот параметр типа в приведенный ниже пример синтаксиса:

type Adder[Self] interface {
    Add(other Self) Self;
}

Без этой функциональности мы были бы вынуждены реализовать такой интерфейс:

type AdderManager[T any] interface {
    Add(first T, second T) T;
}

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

Это конец трех критических недостатков, которые я обнаружил в текущем предложении Go по дженерикам.

Предложение по использованию шаблонов-подсказок

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

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

func Print(object interface{}) {
 fmt.Println(object)
}
Print(<”Hello 1">)
Print(<<”Hello 2">>)

Это будет скомпилировано в следующее:

func Print__string(object string) {
 fmt.Println(object)
}
func Print__hello_2() {
 fmt.Println(“Hello 2”)
}
Print__string(“Hello 1”)
Print__hello_2()

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

Print(>”Hello”<)

Эти обозначения также можно использовать в сигнатурах функций для указания предпочтения, но с возможностью переопределения на месте вызова, если это необходимо. Как правило, этого следует избегать в интерфейсах, поскольку разные реализации должны иметь возможность контролировать свою стратегию оптимизации. Обязательный акт шаблонирования также может быть желателен в определенных обстоятельствах. Например, вы можете захотеть создать объект стека фиксированного размера, который должен настаивать на шаблонировании значения длины. Таким образом, реализация имеет доступ к этому значению длины как к константе времени компиляции и, таким образом, может использовать его для построения массива статического размера. Обязательный характер этого шаблона можно было бы обозначить восклицательным знаком перед угловыми скобками: !<<length int>>.

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

Заключение

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