open atlas
↑ К треку
Go с нуля до senior GO · 05 · 02

Когда дженерики, когда интерфейсы: решение, которое реально принимает senior-ревьюер

Интерфейсы — для поведения, дженерики — для структур данных и алгоритмов над ними. Пакеты slices/maps как канон, антипаттерны — дженерики везде, constraints из одних методов — и честный перфоманс: девиртуализация шейпов-значений против gcshape-словарей.

GO Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Через полгода после Go 1.18 платформенная команда устроила спринт «модернизации на дженериках»: сорок экспортируемых функций переведены с интерфейсных параметров на параметры типов. process(r io.Reader) стал process[R io.Reader](r R). PR был огромный, зелёный и гордо типизированный. Потом его пометил релизный пайплайн: бинарник потолстел на 12%, полная сборка — примерно на треть дольше, а бенчмарки — ровные. Каждый вызывающий передавал *os.File или *bytes.Reader, так что все инстанцирования попали в один указательный gcshape и диспетчеризовались через словарь — ровно как интерфейсный вызов, который они заменили. Хуже того, сломались два даунстрим-сервиса: сигнатуры функций изменились, а один вызывающий хранил process в переменной функционального типа. Команда откатила тридцать восемь конверсий из сорока. Выжили две настоящие — типизированный Set[T comparable] и дженерик-LRU: обе структуры данных, ни одна не про поведение. Ретро спринта дало одну фразу, которую стоит сохранить: мы перепутали «может быть дженериком» и «должно быть».

К концу ты сможешь назвать запах в комментарии к ревью и объяснить, какая именно часть косвенности пережила конверсию.

Два разных вопроса: поведение или данные?

Интерфейсы и дженерики оба абстрагируют, но отвечают на разные вопросы. Интерфейс отвечает, что это значение умеет делать — он стирает конкретный тип за множеством методов, решение принимается в рантайме; поэтому один []io.Reader может одновременно держать файл, буфер и сетевое соединение. Параметр типа отвечает, над каким типом работает этот код — он сохраняет конкретный тип через всё вычисление, решение принимается при компиляции; поэтому Min[T cmp.Ordered] может вернуть тот же тип, что получил, а не any, который вызывающему придётся assert-ить. Из этих двух фактов вытекает рабочее правило, прямо от авторов языка: если тело функции только вызывает методы значения — берите интерфейс. Тянитесь к параметрам типов, когда строите структуры данных или когда функции должны работать со слайсами, мапами и каналами любого типа элементов. Сигнатура часто выдаёт ответ: func Log[T fmt.Stringer](v T) использует T ровно один раз — параметр типа не сохраняет ничего наблюдаемого; func Dedup[T comparable](xs []T) []T требует, чтобы тип протёк от входа к выходу, чего интерфейс не выразит, не заставив вызывающего делать assertions.

// Поведение: тело только вызывает методы — правильный инструмент интерфейс.
func Log(v fmt.Stringer) { logger.Print(v.String()) }

// Данные: тип элемента должен дожить от аргумента до результата.
func Dedup[T comparable](xs []T) []T {
	seen := make(map[T]struct{}, len(xs))
	out := xs[:0:0]
	for _, x := range xs {
		if _, ok := seen[x]; !ok {
			seen[x] = struct{}{}
			out = append(out, x)
		}
	}
	return out
}

Канон: чему учат slices, maps и cmp

Собственные дженерики стандартной библиотеки — slices, maps, cmp (Go 1.21) — это калибровочный набор для вкуса. Посмотрите, что у них общего: каждая функция — алгоритм над типом элементов контейнера. slices.Contains, slices.SortFunc, maps.Keys, cmp.Ordered — ни одна не моделирует поведение; всем до 1.18 были нужны interface{} и рефлексия (или копипаста по типам). И посмотрите, как поведение входит туда, где алгоритму оно нужно: slices.SortFunc(s, func(a, b T) int) принимает функциональное значение, а не constraint с методами. Передача поведения явной func держит constraint минимальным (any) и позволяет вызывающим сортировать по чему угодно, не объявляя тип. Вот канон в двух правилах: дженерики владеют контейнером и типом элементов; поведение приходит аргументом-функцией или остаётся за интерфейсом. Поймали себя на constraint с множеством методов — [T interface{ Validate() error }] — остановитесь и проверьте, не скажет ли это лучше func(t T) error или обычный интерфейсный параметр.

Викторина

Нужна функция, принимающая любое значение с методом String() string и пишущая его в лог. Интерфейсный параметр или параметр типа — и почему?

Антипаттерны, которые ревьюер должен называть вслух

Дженерики везде. Функция с единственным конкретным использованием получает параметр типа «на будущее». Цена сегодня: тяжелее сигнатура, медленнее сборка, бухгалтерия инстанцирований; выгода: ноль, пока это будущее не наступит — а тогда добавить параметр было бы механической правкой. Пишите конкретную версию первой; обобщайте на втором настоящем вызывающем.

Constraints из одних методов в позиции аргумента. func Render[T fmt.Stringer](v T) — дженерик-запись интерфейсного параметра. У неё есть одна честная ниша: когда вызывающие передают типы-значения и вызов горячий, инстанцирование может убрать аллокацию интерфейсного боксинга и девиртуализовать. Ниша реальна, но узка — сначала измерьте: для аргументов-указателей gcshape-словарь даёт тот же косвенный вызов, что и интерфейс.

Расползание constraints. Самописные пакеты constraints, дублирующие cmp.Ordered; трёхстрочные constraints, экспортированные из пяти пакетов; объединения, перечисляющие типы, которым следовало быть интерфейсом. Constraints — это API; каждый экспортированный — вещь, которую пользователи должны понять, а вы — держать стабильной.

Параметры типов, которые вызывающим приходится писать всегда. Если каждая точка вызова читается как Parse[Config](data), потому что выводу не на что смотреть, подумайте, не хочет ли дизайн обычного аргумента (Parse(data, &cfg)) — он и с существующим кодом компонуется лучше.

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

Производительность, честно

Перфоманс-аргумент за дженерики регулярно перегибают в обе стороны, поэтому вот механика. Для инстанцирований с типами-значениями компилятор генерирует выделенный код: сравнения и вызовы прямые, инлайнируемые, и выигрыш над версией с interface{} реален — нет боксинг-аллокаций, нет динамической диспетчеризации в горячем цикле. Пересобранная так сортировочная машинерия (slices.Sort против sort.Sort на []int) попадает в диапазон «десятки процентов — разы», поэтому контейнеры и алгоритмы и есть каноническое применение. Для инстанцирований с указателями все аргументы делят один gcshape: вызовы на параметре типа идут через словарь — косвенно, без инлайнинга — что примерно интерфейсный вызов под другим именем. Поэтому спринт из Hook и намерил ровные графики: перевод параметров io.Reader в [R io.Reader] перенёс косвенность из интерфейсной таблицы в словарь, не убрав её. И издержки несимметричны: каждый отдельный шейп-значение добавляет машинный код (размер бинарника, кеш инструкций, время сборки) — это и есть те 12%, что поймал пайплайн. Честное резюме для комментария в ревью: дженерики выигрывают там, где типы-значения встречают алгоритмы; играют вничью или слегка проигрывают там, где указатели встречают поведение; и всегда стоят времени компиляции и байтов.

Викторина

PR переводит func process(r io.Reader) в func process[R io.Reader](r R). Продакшен-вызывающие передают *os.File и *bytes.Reader. Какого изменения производительности ждать ревью?

Вспомните перед уходом
  1. 01
    Сформулируй правило выбора интерфейсы/дженерики и примени его к двум случаям: лог-приёмник, принимающий всё со String(), и Dedup над любым comparable-элементом.
  2. 02
    Спринт модернизации перевёл process(r io.Reader) в process[R io.Reader](r R) и намерил ровный перфоманс при +12% к бинарнику. Объясни оба числа механикой.
Итог

Выбор между интерфейсами и дженериками — не вопрос стиля: эти механизмы абстрагируют разное. Интерфейсы стирают конкретный тип за множеством методов в рантайме: инструмент для поведения, для гетерогенных коллекций, для функции, принимающей всё, что умеет читать, печататься или закрываться. Параметры типов сохраняют конкретный тип через вычисление на компиляции: инструмент для данных — контейнеры вроде множеств и кешей и алгоритмы над слайсами, мапами и каналами, где тип элемента должен протечь от аргумента к результату. Правило авторов языка сжимает это: тело только вызывает методы → интерфейс; строится структура данных или алгоритм над типами элементов → дженерики. Пакеты slices, maps и cmp — канон, и они моделируют и правило второго порядка: когда алгоритму нужно поведение, оно приходит func-значением (SortFunc), а не constraint с методами. Антипаттерны, которые надо называть в ревью: дженерики-везде с одним конкретным вызывающим; constraints в духе [T fmt.Stringer] — интерфейсы в скобках (оправданы только измеренным горячим путём, экономящим боксинг для типов-значений); расползание constraints, дублирующее cmp.Ordered; и API, чьи параметры типов невозможно вывести. У правды о производительности две половины: инстанцирования шейпов-значений действительно девиртуализуются и инлайнятся — поэтому выигрывают дженерик-контейнеры и сортировки, — а указательные шейпы диспетчеризуются через gcshape-словарь по цене интерфейсного вызова: поэтому спринт модернизации намерил ровный рантайм, бинарник на 12% толще и сборку на треть дольше. Обобщайте на втором настоящем вызывающем — и пусть то, что варьируется, поведение или данные, выбирает инструмент. Теперь, когда увидишь PR, конвертирующий интерфейсный параметр в параметр типа, задай один вопрос: нужно ли типу элемента протечь от аргумента к результату? Если нет — конверсия покупает только байты в бинарнике и время сборки.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.