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

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

Недавно, в телеге, я писал, что, кажется, нашел баг в ГО. "Свит саммер чайлд", думаю я сейчас, в процессе написания этой статьи, глядя на того Антона?. Но, прямо скажем, Я и не считаю себя до конца неправым в данной ситуации.

Об чём, собственно, речь.

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

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

package main

type CheckerFn[T any] func(T) bool

type Entity struct{}
func (Entity) Get() string { panic(0) }

type Getter interface {
	Get() string
}

func Checker[T Getter](T) bool                          { panic(0) }
func Combiner[T any, C CheckerFn[T]](t T, cs ...C) bool { panic(0) }

func main() {
	Combiner(Entity{}, Checker)
}

А оно не компилируется.. Билд падает с ошибкой func(Entity) bool does not satisfy CheckerFn[Entity] (func(Entity) bool missing in main.CheckerFn[main.Entity])

"Щито!?" - думаю Я и делаю "замечательнейшую" в своей наивности проверку:

var fn CheckerFn[Entity] = Checker

Ну вот же, на эту строку не ругается, никаких проблем с подстановкой типов нет, почему в дженерике-то проблема? Ну и как муха потираю лапки с мыслью "хе-хе-хе, похоже, нашел баг в ГО", хотя, конечно же начинаю копать и пытаться хотя бы обойти проблему с другой стороны.

И.. если убрать CheckerFn[T] из Combiner - все приходит в норму и код становится рабочим.

func Combiner[T any, C func (T) bool](t T, cs ...C) bool { panic(0) }  

Есть, к слову, еще одно решение, о котором я расскажу чуть позже.

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

func Func1(a string) {}
func Func2(a string) {}

func main() {
  v1 := Func1
  v1 = Func2
}

А вот так - нет:

type I1 int16
type I2 int16

func main() {
  v1 := I1(42)
  v1 = I2(0) // cannot use I2(0) (constant 0 of int16 type I2) 
// as I1 value in assignment
}

Типы

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

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

В спецификации языка идентичность описана двумя базовыми тезисами:

  • Два разных именованных типа (Named) не будут идентичны никогда.

  • В противном случае, типы идентичны, если их базовые (underlying) типы структурно-эквивалентны. Ну а дальше описаны правила этой самой структурной эквивалентности для разных типов.

Это объясняет почему не идентичны I1 и I2: несмотря на то, что у них общий базовый тип и они структурно-эквивалентны - они имеют разные именованные типы.
Но не объясняет, на первый взгляд, тоже именованные Func1 и Func2. Так может показаться интуитивно, на практике же - эти функции попросту не относятся к Named типу.

Сущности в го (с точки зрения типизации)

Очень сложно сейчас будет по-просту не переписать спецификацию языка?.

Существуют идентификаторы - по существу, это все к чему можно обратиться по имени. Идентификаторы ссылаются на два типа сущностей: объект и тип.

Объект - конкретная "сущность" в программе к которой можно обратиться: переменная, константа, функция, имя типа,
пакет, встроенная функция и так далее. У каждого объекта есть тип (кроме Label и PkgName). Когда мы пишем
func Foo() {} - мы создаем идентификатор ссылающийся на объект Func.

Тип - описание структуры данных и операций допустимых с этими данными. Есть привычные базовые типы (int, bool, struct, interface и пр..), а есть кастомные типы которые мы с вами и создаем - по существу их лишь два вида: Named и
Signature. Когда мы пишем type MyInt int - создаётся объект TypeName, имеющий тип Named. А когда пишем func(string) bool - не будет никакого внешнего объекта - сразу создастся тип Signature, такие типы мы знаем как "анонимные".

И да, такого типа как "функция" в го не существует. Есть объект Func и есть тип Signature. Потому что объект функция ссылается на сегмент памяти в котором лежит машинный код, а тип у этой функции - это её сигнатура, именно то что важно для проверки типов.

Именно Named позволяет описывать новые структуры данных и связывать их с функциями посредством ресивера. Ресивер, кстати, лишь синтаксический сахар - под капотом это обычная функция где первым параметром идет тип ресивера. Именно поэтому func (e Entity) Get() string и func Get(e Entity) string - будут иметь идентичную сигнатуру.

И теперь, зная про разницу между типом и объектом, можно разобрать почему же с функциями сработало, а с интами - нет.

В первом случае, когда мы имеем дело с функциями, v1 приобретает тип Signature после обращения по идентификатору Func1 - это объект типа Func, а его тип это сигнатура func(string). При присваивании Func2 - точно такая же история: объект Func с сигнатурой func(string). Сигнатуры идентичны, никаких Named типов в игре нет - тайпчекер доволен, присваивание работает.

Во втором случае - мы создали два разных именованных типа. Да, у них одинаковый базовый тип int16, да, они структурно-эквивалентны. Но переменная v1, после обращения к анонимному объекту const со значением 42 и каста к типу I1 (который Named), приобрела именованный тип I1. При попытке присвоить I2(0) - мы так же имеем дело с объектом const, но кастуемым уже к типу I2 (тоже Named). Тайпчекер смотрит: I1 и I2 - два разных именованных типа. Срабатывает первое правило идентичности - именованные типы никогда не идентичны. Тайпчекер не доволен и валит нам компиляцию.

Дженерики

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

Вкратце рассмотрим поведение дженериков, для полноты картины.

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

func Func[T int|string](t T) {}
func Func[T interface{int|string}](t T) {}

Констрейн, по сути, является частным случаем интерфейса, просто вместо перечисления методов перечисляются допустимые типы.

Инстанцирование дженерика - процесс создания нового Named типа на основе исходного дженерика с конкретными типами вместо параметров типа. Происходит это на этапе компиляции и только для типов с которыми дженерик используется.
То есть если дженерик func[T int|int32](T) будет использован только с int32, то компилятор создаст только именованный тип с сигнатурой func(int32).
При этом в системе типов всегда будут храниться и базовый именованный тип и все его инстанцированные варианты.

Так в чем же была проблема?

По-программируем немножко "на бумажке"?

После инстанцирования дженериков получим следующий код. Имена функций ниже - примерно то, какими они получаются во внутрянке компиллятора после инстанцирования.

package main

type CheckerFn_Entity func(Entity) bool

type Entity struct{}
func (Entity) Get() string { panic(0) }

type Getter interface {
	Get() string
}

func Checker_Entity(Entity) bool { panic(0) }
func Combiner_Entity_CheckerFn_Entity(t Entity, cs ...CheckerFn_Entity) bool {
	panic(0)
}

func main() {
  Combiner_Entity_CheckerFn_Entity(Entity{}, Checker_Entity)
}

А проблема была ровно в том, что описано в разделе про типы.
Checker_Entity - это объект Func с типом Signature func(Entity) bool, в то время как CheckerFn_Entity - это Named, пусть и с той же сигнатурой в базовом типе. Срабатывает первое правило идентичности - тайпчекер в ярости, Я в недоумении (на тот момент).

Финальный гвоздь в крышку гроба моего непонимания вогнала строка, которую я написал с целью проверить "а правда ли оно не присваивается":

var fn CheckerFn[Entity] = Checker

Не спрашивайте чего я ожидал увидеть выполнив эту "проверку", но тут опять все работает по правилам. Просто правила эти не идентичности типов, а присваиваемости (assignability). Интересующее нас правило гласит:

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

Что буквально является причиной валидности этой операции, ведь Checker - Signature, а CheckerFn[Entity] - Named, и у них идентичные базовые типы. Еще бы скастовал один тип в другой и удивился что это сработало?
Хотя произошедшее недалеко от правды, компилятор за меня, в данном случае, услужливо разрешил присвоить значение типа Signature к переменной с типом Named. Что, по-существу, недалеко от операции кастинга.

Возникшая проблема, как упоминал выше - решается путем указания в качестве констрейна не именованного типа, а сигнатуры напрямую:

func Combiner[T any, C func (T) bool](t T, cs ...C) bool { panic(0) }

В таком случае во время тайпчека будут сравниваться напрямую два типа Signature и все будет работать как надо.

Альтернативным же решением (то самое "еще одно") – будет использование типа-алиаса (Type Alias), который, в общем, для этих целей и был внедрен в далёком go 1.9:

type CheckerFn[T any] = func (T) bool  

Такая нотация всё так же приведет к созданию TypeName, но ссылаться он будет уже не на новый Named тип, а напрямую на тип Signature. И, в этом случае, все тоже будет работать, потому что сравниваться будут две сигнатуры.

Так что нет, именно бага в системе типов го я не нашел. Но вот странности - вполне.

Вместо заключения

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

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

Отвлечемся от функций в качестве значения и рассмотрим пример такой контр-интуитивности с константами.

package main

type I1 int16
func I1Recv(I1) {}

var IntVar = 42
const IntConst = 42
const IntConst2 int = 42

func main() {
	I1Recv(42)        // 1) works 
	I1Recv(IntVar)    // 2) does not work  
	I1Recv(IntConst)  // 3) works 
	I1Recv(IntConst2) // 4) does not work  
}

Если первые два вызова, в принципе, понятны, то третий и четвертый вскрывают не особо приятные нюансы констант. Они, оказывается, бывают типизированные и нетипизированные с дефолтным типом.
Константы с указанием типа ведут себя вполне ожидаемо, "так же как переменные, только иммутабельные". А вот нетипизированные константы можно использовать везде, куда бы подошел литерал их значения, за это отвечают правила представимости (representability).
Фактически, нотация константы без явного указания типа создаёт именованный алиас к литералу.

Только это напрочь ломает общую концепцию объявления переменных:

  • var x int32 = 42 - объявляет переменную с явным типом, к которому будет приведен литерал 42. Передать такую переменную можно будет туда, где ожидают её тип.

  • var x = 42 - объявляет переменную с типом выведенным из литерала 42. Так как у всех литералов есть подразумеваемый тип, то здесь будет int. Передать такую переменную можно будет туда, где ожидают её тип.

  • const x int32 = 42 - объявляет типизированную константу. Передать такую константу можно будет туда, где ожидают её тип.

  • const x = 42 - объявляет нетипизированную константу с дефолтным типом int. Передать та��ую константу можно будет везде где подойдет либо int либо литерал 42. То есть во все места где ожидаются int, uint, float и т.д.

Последний вариант попросту не имеет смысла - любая переменная (var) в большей степени константа (с точки зрения типов) чем "вот это". Го, у нас, со строгой типизацией, но есть вариантики..

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

package main

type F1 func(bool)

func F1Impl(b bool)     {}
func F1Recv(F1)         {}
func F1RecvGen[T F1](T) {}

var F1Var = F1(func(b bool) {})

func main() {
	F1Recv(func(b bool) {}) // 1)
	F1Recv(F1Impl)          // 2)
	F1Recv(F1Var)           // 3)
	F1RecvGen(F1Impl)       // 4)
	F1RecvGen(F1Var)        // 5)
} 

И это... только вызов номер четыре. Но подождите, в вызове не-генерик функции во втором варианте тоже передается объект функции с типом Signature, а не Named. Почему же тогда там все работает?

Да просто потому что при вызове обычной функции действуют правила уже знакомой нам присваиваемости. А вот в случае с дженериком, чтобы совершить акт выведения типа и инстанцирования (звучит-то как?) - нужно проверить идентичность типов.

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

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

И это, на самом деле, далекооо не полный список странных поведений.

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

Но го живет и развивается, с момента внедрения дженериков, 3.5 года назад, было исправлено уже множество вот таких нюансов. Просто мне "посчастливилось" столкнуться с тем что ещё не подвергалось оценке валидносии.

Иногда стоит и по-ругать, без преувеличения, любимый язык за его странности - это помогает лучше его понять и, возможно, сделать его лучше в будущем. Критика, если она конструктивная - благо. А я вроде бы конструктивно подошёл к вопросу?

———

Подписывайтесь на телегу – я там, порой, что-то умное пишу?

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


  1. NeoCode
    18.11.2025 11:59

    Но не объясняет, на первый взгляд, тоже именованные Func1 и Func2

    Func1 и Func2 это имена "функциональных литералов", а не типов. А для литералов обычно применяют структурную типизацию, которая в случае функций определяется сигнаторой. По сути это как числа или строки. Поэтому все логично.

    PS Я не знаю есть ли языки, в которых можно было бы ввести явный тип функции, и далее проверять, что функциональный литерал соответствует этому типу. Но было бы любопытно, тогда бы функциональные литералы стали бы номинативно типизированными, что-то вроде вот такого (псеводкод):

    type func_t  int=>int
    func func_t foo(int) int {/*...*/} // ok
    func func_t bar(char) string {/*...*/}// error

    И вероятно, такие функции нельзя было бы присвоить/передать куда попало - только туда, где явно прописан тип func_t.


    1. xobotyi Автор
      18.11.2025 11:59

      Func1 и Func2 это имена "функциональных литералов", а не типов.

      Я понимаю, это ведь раскрывается далее по ходу статьи.