Дженерики в Go устроены как двухступенчатая система: сначала синтаксис объявляет параметры типаименованное место в объявлении обобщённой сущности, вместо которого при инстанцировании подставляется конкретный аргумент типа и ограничения, затем компилятор подставляет конкретные аргументы и проверяет их соответствие ограничениям. Нетривиальность в том, что почти каждый шаг — от разбора объявления до вывода типов — имеет отдельный механизм разрешения неоднозначностей и отдельный набор правил, которые определяют, что именно попадёт в итоговый необобщённый код.
Инстанцирование: что происходит при подстановке
инстанцированиеподстановка конкретных аргументов типа вместо параметров типа в объявлении обобщённой сущности обобщённой функции, метода или типа выполняется подстановкой аргумент типаконкретный тип, который подставляется вместо параметра типа вместо параметр типа. Процедура состоит из двух шагов.
На первом шаге каждый аргумент типа подставляется вместо соответствующего параметра типа во всём объявлении обобщённой сущности, включая сам список параметров типа и любые типы в этом списке. На втором шаге после подстановки каждый аргумент типа должен удовлетворять ограничению соответствующего параметра типа (при необходимости также инстанцированному). Если это условие не выполнено, инстанцирование завершается неудачей. Результатом инстанцирования обобщённого типа, функции или метода является, соответственно, необобщённый тип, функция или метод.
В go/types за это отвечает функция Instantiate: она получает обобщённую сущность и набор аргументов типа и возвращает готовый необобщённый тип, функцию или метод, а при неудаче — ошибку. Сначала она убеждается, что переданный объект действительно обобщённый, — для этого объект должен предоставлять доступ к списку своих параметров типа. Если объект обобщённым не является, работа немедленно прерывается. При включённой валидации функция получает список параметров типа и, если их нет, сообщает об ошибке; затем сравнивает число аргументов с числом параметров и при несовпадении сообщает об ошибке с обоими количествами. При выключенной валидации эти проверки пропускаются, и проверка количества аргументов выполняется позже; если в этот момент контекст проверки отсутствует, вместо ошибки происходит аварийное завершение.
Соответствие аргумента ограничению проверяет функция implements: она отвечает на вопрос, можно ли подставить данный тип вместо параметра с данным ограничением. «Реализует интерфейс» означает, что тип обладает всеми методами, которые требует интерфейс, и потому может использоваться там, где ожидается этот интерфейс; флаг constraint переключает проверку на режим ограничения типа — тогда проверяется не только набор методов, но и то, что тип попадает в множество типовнабор типов, которые допускает ограничение, заданное ограничением. Сначала функция берёт базовые типы обеих сторон, чтобы работать с фактической структурой типов, а не с именованными обёртками. Если базовый тип любой из сторон невалиден, проверка считается пройденной, чтобы не порождать каскад ошибок; так же поступают при невалидном базовом типе указателя. Если передан ненулевой указатель на причину, в него записывается пояснение, почему тип не подходит. Получатель может отсутствовать, когда функция вызывается через экспортируемый API, например AssignableTo.
Синтаксис и объявление
Список параметров типа становится неотличим от выражения в редких случаях, например при записи type T[P *C], type T[P (C)] или type T[P *C|Q]; тогда объявление типа разбирается как объявление массива. Чтобы устранить эту неоднозначность, спецификация разрешает два способа: заключить ограничение в интерфейс (type T[P interface{*C}]) или поставить завершающую запятую (type T[P *C,]).
В списке параметров типа внешние interface{ … } можно опустить, если ограничение — это interface-литерал вида interface{E}, где E — встроенный элемент типа, а не метод. Это удобство действует именно в списке параметров типа, поэтому [T []P] означает [T interface{[]P}], [T ~int] означает [T interface{~int}], а [T int|string] означает [T interface{int|string}]. Вне списка параметров типа такая запись недопустима: type Constraint ~int — ошибка, потому что ~int не находится в списке параметров типа. Причина в том, что элемент ограничения типа задаётся как TypeConstraint = TypeElem, а форма ~T является термом общего интерфейса, задающим множество типов, с underlying-типом T, и в терме ~T underlying-тип T должен быть самим собой, а T не может быть интерфейсом.
Для обобщённого типа методы, связанные с ним, должны в получателе объявлять столько же параметров типа, сколько присутствует в определении обобщённого типа. Параметры типа могут также объявляться спецификацией получателя объявления метода. Если объявление метода задаёт параметры типа (возможно, в дополнение к параметрам типа, объявленным спецификацией получателя), имя метода обозначает обобщённый метод, и такой метод, как и обобщённая функция, должен быть инстанцирован перед вызовом или использованием в качестве значения. Для обобщённого типа все аргументы типа всегда должны быть предоставлены явно.
Ограничения как множества типов
Ограничение параметра типа — это интерфейс, а его множество типов вычисляется функцией computeInterfaceTypeSet. Она вычисляет множество типов для интерфейсного типа и кэширует результат в поле этого типа. Вызывается она, в частности, из метода iface() у TypeParam, когда нужно получить ограничение-интерфейс параметра типа. Кэширование важно потому, что при повторном вызове функция сразу возвращает уже сохранённое значение. Если интерфейс ещё не полностью сконструирован, функция не вычисляет и не сохраняет частичное множество типов, а возвращает topTypeSet — topTypeSetмножество, включающее все возможные типы, которое используется как безопасная замена, пока полное множество ещё не может быть вычислено; так полное множество всё равно будет вычислено позже, и не возникнут ошибки вроде отсутствующих методов. Кроме того, перед вычислением интерфейс помечается как завершённый, что предотвращает бесконечную рекурсию. Для объединений computeUnionTypeSet также кэширует результат и при переполнении возвращает invalidTypeSet — invalidTypeSetмножество, обозначающее, что вычисление множества типов не удалось из-за переполнения, и дальнейшие проверки на его основе не выполняются.
Спецификация требует, чтобы в терме вида ~T собственный (underlying) тип T был сам T, и чтобы T не был интерфейсом. В примере ~[]byte допустимо, поскольку underlying тип []byte — это сам []byte, тогда как ~MyInt недопустимо, потому что underlying тип MyInt — это int, а не MyInt, и ~error недопустимо, потому что error — интерфейс. Реализация проверяет это в двух местах: если тип является интерфейсом, выводится ошибка «invalid use of ~ (%s is an interface)»; если тип не идентичен своему underlying типу, выводится ошибка «invalid use of ~ (underlying type of %s is %s)». В обоих случаях после сообщения об ошибке выполняется continue, чтобы не сообщать ещё одну ошибку для того же терма. Кроме того, использование ~T не разрешено, если T — параметр типа: тогда выводится «type in term %s cannot be a type parameter».
Пересечение списков термов вычисляет intersectTermLists. Сначала она получает пересечение двух списков термов, а затем объединяет флаги сравнимости: comp := xcomp || ycomp. Если результат помечен как сравнимый и не является полным множеством, функция оставляет только те термы, для которых comparableType(t.typ, false, nil) == nil, то есть только сравнимые типы. Если после фильтрации список снова не полон, флаг comp сбрасывается в false; в конце проверяется инвариант assert(!comp || terms.isAll()). Спецификация требует, чтобы множества типов всех неинтерфейсных термов были попарно непересекающимися. Это гарантирует, что объединение термов не содержит перекрывающихся комбинаций вроде a|a, a|~a, ~a|~a или ~a|A, поскольку такие пересечения непусты.
Предобъявленный интерфейсный тип comparable обозначает множество всех неинтерфейсных типов, которые строго сравнимы. Строго сравнимый тип — это сравнимый тип, не являющийся интерфейсным и не составленный из интерфейсных типов: булевы, числовые, строковые, указательные и канальные типы строго сравнимы; структуры строго сравнимы, если все их поля строго сравнимы; массивы — если строго сравним их элемент; параметры типов — если все типы в их множестве типов строго сравнимы. Интерфейсы, не являющиеся параметрами типов, сравнимы, но не строго сравнимы, поэтому они не реализуют comparable, однако удовлетворяют ему. comparable и интерфейсы, прямо или косвенно встраивающие comparable, могут использоваться только как ограничения типов: они не могут быть типами значений или переменных либо компонентами других неинтерфейсных типов. Объединение с более чем одним термом не может содержать предобъявленный идентификатор comparable или интерфейсы, задающие методы, либо встраивающие comparable или интерфейсы с методами. Аргумент типа T удовлетворяет ограничению C, если T реализует C, либо если C записывается как interface{ comparable; E }, где E — базовый интерфейс, а T сравним и реализует E. В реализации при пересечении списков термов флаг сравнимости сохраняется, и если он установлен, из результата удаляются несравнимые термы.
Инстанцирование: подстановка и раскрытие
Подстановка типов выполняется функцией subst, которая вызывается во время раскрытия (expansion) — процесса, когда конкретный экземпляр обобщённого типа наполняется своими аргументами. В этот момент нельзя вызывать методы, которые сами запускают раскрытие, иначе будет взаимная блокировка. Поэтому в ветке *Named функция берёт параметры типа через t.Origin().TypeParams(), а не через t.TypeParams().
Для полей структур и переменных subst.var_ подставляет тип поля и, если он изменился, создаёт копию через cloneVar, которая сохраняет исходный тип как origin. Аналогично для функций subst.func_ при изменении типа сигнатуры вызывает cloneFunc, и та тоже фиксирует origin. Функция markComplete вызывается у вновь созданной структуры после подстановки её полей, чтобы пометить её как полностью построенную. В replaceRecvType при замене типа получателя у метода создаётся новая сигнатура, её получатель клонируется через cloneVar, а сам метод — через cloneFunc.
Вывод типов
core termосновной тип ограничения вместе с информацией о том, помечен ли он тильдой — это то, что возвращает функция coreTerm по параметру типа. Она либо возвращает единственный конкретный тип S (второй результат true), либо терм, соответствующий core typeединственный underlying-тип, общий для всех типов в множестве ограничения, (второй результат false), либо (nil, false). Она перебирает термы ограничения и считает их количество; если терм один, возвращается этот единственный терм с true. Если термов несколько, но общий underlying-тип ненулевой, возвращается терм с этим общим типом и false; при этом если хотя бы один терм имел тильду, результирующий терм тоже помечается тильдой. В вызывающем коде при известном аргументе типа параметр унифицируется с основным типом, а при единственном конкретном типе без тильды и неизвестном аргументе параметр сразу устанавливается в этот тип.
Когда у ограничения параметра типа есть core term, infer.go пытается унифицировать параметр типа с этим основным типом. Если соответствующий аргумент типа известен, применяется нестрогая унификациятипы не обязаны быть идентичными, чтобы сопоставиться, — в отличие от точного режима, где типы унифицируются, только если они идентичны или могут быть сделаны идентичными подстановкой аргументов, при которой определённый тип автоматически сопоставляется со своим базовым литеральным типом. Если же аргумент типа неизвестен, а core term описывает единственный конкретный тип без тильды, то аргумент типа должен быть именно этим типом, и он устанавливается напрямую.
Нетипизированные константы и нетипизированный nil не участвуют в первом проходе вывода типов, потому что для них нельзя сразу выполнить унификацию с параметром типа. При обходе аргументов проверяется, является ли тип аргумента типизированным: если да, выполняется унификация, иначе аргумент добавляется в список отложенных. Нетипизированный nil дополнительно исключается из этого списка, так как у него нет default-типатип, который подставляется нетипизированному значению, когда его тип нужно определить окончательно. На третьем проходе для каждого ещё не определённого параметра типа собирается максимальный нетипизированный тип среди соответствующих аргументов, после чего этот максимум заменяется его default-типом и устанавливается как тип параметра. В примере NewPair(1, 2) оба аргумента — нетипизированные константы, поэтому на первом проходе унифицировать нечего; на третьем проходе обе константы получают default-тип int, и F выводится как int, давая NewPair[int](1, 2).
Унификация типов
режим сопоставленияправило, по которому два типа считаются совместимыми при унификации управляет унификацией типов и может быть exactточный режим, при котором типы совместимы, только если они идентичны или могут быть сделаны идентичными подстановкой аргументов или looseнестрогий режим, при котором именованный тип и литерал типа сопоставляются по underlying-типам, направления каналов игнорируются, а интерфейс требует реализации. При рекурсивном спуске по составной структуре типа режим для элементов остаётся тем же, кроме случая унификации для присваиваемости: тогда на верхнем уровне режим loose, а для элементов он меняется на exact, поскольку типы не обязаны быть идентичными, чтобы быть присваиваемыми. Для уравнения X ≡A Y (включая передачу параметров и возврат) верхние структуры могут унифицироваться loose, но элементы — строго exact. Для уравнения P ≡C C (P — параметр типа, C — его ограничение) правила сложнее: если все типы в множестве типов C имеют один underlying-тип U и у P есть известный аргумент A, то U и A должны унифицироваться loose; аналогично для каналов.
newUnifier создаёт объект unifier, который хранит отображение: каждому параметру типа сопоставляется указатель на Type, изначально nil, если для него не передан соответствующий аргумент; при этом требуется, чтобы параметров было не меньше, чем аргументов, а совпадающие по индексу параметр и аргумент связываются. Метод unify просто вызывает u.nify(x, y, mode, nil), а nify обрабатывает конкретные виды типов: структуры унифицируются при одинаковом числе полей, совпадении embedded, тегов, имён и унификации типов полей; указатели — если унифицируются их базовые типы; кортежи — при равной длине и унификации типов соответствующих элементов; сигнатуры — при совпадении variadic и унификации params и results. Для каналов направление учитывается только в точном режиме: при нестрогой унификации направление игнорируется. Именованные типы унифицируются, если совпадают длины списков аргументов, унифицируются соответствующие аргументы и совпадает origin; интерфейсы — при совпадении comparable, terms и методов, с проверкой циклов. Если x и y не подходят ни под один случай, управление попадает в default, где вызывается паника.
Что из этого следует на практике
- Инстанцирование — это не одна операция, а две: сначала механическая подстановка аргументов во всё объявление, затем проверка соответствия ограничениям. Ошибка на втором шаге отменяет результат целиком.
- Неоднозначность
type T[P *C]разрешается либо оборачиванием ограничения в интерфейс, либо завершающей запятой — иначе объявление разберётся как массив. - Ограничение — это множество типов, и его вычисление кэшируется. Незавершённый интерфейс даёт
topTypeSet, а не частичный результат, чтобы не порождать ложные ошибки. ~Tтребует, чтобы underlying-тип T был самим T и T не был интерфейсом; параметр типа в этой позиции запрещён.- Вывод типов идёт в несколько проходов: нетипизированные константы и
nilоткладываются до третьего прохода, где получают default-тип. - Режим унификации exact требует идентичности (или приводимости подстановкой), loose допускает совпадение по underlying-типам и игнорирует направление каналов; для присваиваемости верхний уровень loose, а элементы exact.
comparableобозначает строго сравнимые неинтерфейсные типы и может использоваться только как ограничение; интерфейсы, не являющиеся параметрами типов, удовлетворяют ему, но не реализуют его.
Где смотреть в коде
- infer.go: tracef
- instantiate.go: Instantiate
- typeset.go: intersectTermLists
- infer.go: coreTerm
- subst.go: cloneVar
- unify.go: nify
- subst.go: cloneFunc
- typeset.go: computeInterfaceTypeSet