Deep Engineering
Средний·Опубликовано·30 МИН

Встраивание структур в Go: композиция, которую путают с наследованием

Собеседование по встраиванию идёт лестницей: есть ли в Go наследование — что будет, если внешний тип объявит тот же метод — почему структура со встроенным типом не реализует интерфейс — что делать с двумя одинаковыми именами — зачем встраивать интерфейс. Прогон отвечает на каждый шаг: поднятый метод вызывает метод своего типа, а не внешнего; два одноимённых поля молча исчезают из JSON; а поднятый вызов в замере от прямого не отличается.

Полное техническое изложение

TL;DR

Встраивание — это поле, объявленное без отдельного имени, чьи поля и методы поднимаются и доступны так, будто объявлены во внешнем типе. Имя у поля всё же есть — имя типа, — и s.Log() разрешается в метод встроенного поля s.Logger, не больше. Это композиция с подъёмом полей и методов, а не наследование, и разница видна на первом же вопросе.

Поднятый метод получает встроенное значение, а не внешнее. Derived встраивает Base и объявляет свой Name. d.Name() возвращает "Derived", а d.Describe() — метод Base, который зовёт Name, — возвращает "describe: Base". У встраивания виртуального вызова нет: внутри Describe получатель — Base, и вызывается его Name, что бы ни объявил внешний тип.

Дальше — то, что отличает понимание от пересказа. Встроенный T поднимает методы с получателем *T только в набор методов *S, поэтому ByValue{} не реализует интерфейс, а &ByValue{} реализует. Два одинаковых имени на наименьшей глубине — не ошибка объявления: компилятор возразит при обращении к имени, а поднятым не становится ни одно из них — и метод интерфейса этим типом не удовлетворён. Встроенный интерфейс, оставшийся nil, компилируется и падает при вызове метода, который внешний тип не объявил. Встроенный sync.Mutex делает Lock, TryLock и Unlock публичными методами *Cache. Две встроенные структуры с полем ID дают JSON без ID вовсе — и без ошибки. А поднятый вызов в замере на go1.24.7 от прямого не отличается в пределах шума: 2,14 нс против 2,18.

Порог входа
Перед уроком достаточно понимать
  • как объявить структуру и метод с получателем;
  • чем получатель-значение отличается от получателя-указателя — это урок о получателе метода;
  • что тип реализует интерфейс, если у него есть все его методы.
Заранее знать не нужно
  • подъём полей и методов, правило глубины, неоднозначный селектор;
  • набор методов при встроенном T и *T, встроенный интерфейс, встроенный sync.Mutex;
  • как встраивание выглядит в encoding/json.

Что здесь на самом деле спрашивают

Лестница почти всегда такая:

  1. «Есть ли в Go наследование?» — разминка; ответ «нет, есть встраивание» правильный, но ничего не объясняет.
  2. «Что будет, если внешний тип объявит тот же метод, что и встроенный?» — здесь проверяют, понимает ли кандидат, что переопределения нет — есть сокрытие.
  3. «Почему структура со встроенным типом не реализует интерфейс?» — про наборы методов, и это тот же вопрос, что в уроке о получателе.
  4. «Что будет, если встроить два типа с одинаковым методом?» — правило глубины.
  5. «Зачем встраивать интерфейс в структуру?» — и чем это опасно.
  6. «Стоит ли встраивать sync.Mutex?» — про то, что встраивание публикует чужие методы.

Стержень у лестницы один: встраивание решает, какой путь к полю подставить в выражение, — и только это. Всё, что ведёт себя «не как наследование», из этой фразы и следует.

База: поле без отдельного имени

Спецификация определяет встраивание через имя, которое не пишут:

A field declared with a type but no explicit field name is called an embedded field.

перевод

Поле, объявленное с типом, но без явного имени, называется встроенным полем.

— Спецификация Go — Struct types
контракт языкаПравила подъёма, наборов методов и глубины — часть спецификации языка; прогон bench/goembed/internals.go их иллюстрирует, а не устанавливает.

Имя у поля всё-таки есть — спецификация тут же уточняет, что им служит неквалифицированное имя типа. Logger встроен в Server, и к нему можно обратиться как s.Logger. Новое здесь другое: поля и методы Logger поднимаются — становятся доступны напрямую, без промежуточного имени:

GO
type Logger struct{ prefix string }
 
func (l Logger) Log(msg string) string { return l.prefix + msg }
 
type Server struct {
    Logger
    name string
}
1) ПОДЪЁМ ПОЛЕЙ И МЕТОДОВ
─────────────────────────
  s.Log("старт")           -> "[api] старт"
  s.Logger.Log("старт")    -> "[api] старт"
  s.prefix                 -> "[api] "

s.Log — поднятый селектор: компилятор разрешает его в метод встроенного поля s.Logger, и результат тот же, что у явного s.Logger.Log. Такие поля и методы спецификация называет поднятыми.

Этого достаточно для ответа уровня Junior на вопрос «есть ли в Go наследование»: нет. Есть встраивание — поле без отдельного имени, чьи поля и методы поднимаются и вызываются напрямую. Это композиция: Server содержит Logger, а не является им. Уточнение для точности: именованное поле log Logger — тоже композиция; встраивание отличается от неё только подъёмом.

Одну таблицу стоит запомнить сразу — она отвечает, реализует ли внешний тип интерфейс; почему она такая, разобрано в Механизме 2:

что встроенометод объявлен с получателемпопадает в набор методов
TTS и *S
T*Tтолько *S
*TT или *TS и *S

Последние слова стоит произнести вслух, потому что на них держится весь следующий шаг. Передать Server туда, где ждут Logger, нельзя — это разные типы; передают s.Logger.

Механизм 1: у встраивания виртуального вызова нет

Вопрос, на котором заканчивается аналогия с наследованием. Base объявляет Name и Describe, причём Describe зовёт Name. Derived встраивает Base и объявляет свой Name:

GO
type Base struct{}
 
func (Base) Name() string { return "Base" }
 
func (b Base) Describe() string { return "describe: " + b.Name() }
 
type Derived struct{ Base }
 
func (Derived) Name() string { return "Derived" }

В языке с виртуальными методами — в Java или в C++ с virtual — d.Describe() вернул бы «describe: Derived»: метод родителя вызвал бы переопределённый метод потомка. В Go:

2) У ВСТРАИВАНИЯ ВИРТУАЛЬНОГО ВЫЗОВА НЕТ
────────────────────────────────────────
  d.Name()                 -> "Derived"
  d.Describe()             -> "describe: Base"
  d.Base.Name()            -> "Base"
  Describe поднят из Base и получает Base, а не Derived:
  Name внутри него — Base.Name, что бы ни объявил внешний тип.

Объяснение — в той самой фразе про путь к полю. d.Describe() — это d.Base.Describe(). Получатель Describe — значение типа Base, и о том, что оно лежит внутри Derived, метод не знает. b.Name() внутри него — вызов на Base, выбранный при компиляции.

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

Если нужно именно то, что даёт наследование, — чтобы общий алгоритм звал переменную часть, — в Go это делают интерфейсом, переданным явно:

GO
type Namer interface{ Name() string }
 
func DescribeAny(n Namer) string { return "describe: " + n.Name() }
8) ДИНАМИЧЕСКИЙ ВЫЗОВ — ЧЕРЕЗ ИНТЕРФЕЙС
───────────────────────────────────────
  DescribeAny(d)           -> "describe: Derived"

Динамическая диспетчеризация в Go есть, но живёт она в интерфейсах, а не во встраивании.

Механизм 2: встроен T или *T — разные наборы методов

Методы поднимаются не все и не всегда. Правило в спецификации короткое:

If S contains an embedded field T, the method sets of S and *S both include promoted methods with receiver T. The method set of *S also includes promoted methods with receiver *T.

перевод

Если S содержит встроенное поле T, наборы методов S и *S оба включают поднятые методы с получателем T. Набор методов *S включает также поднятые методы с получателем *T.

— Спецификация Go — Struct types

Для встроенного указателя правило щедрее:

If S contains an embedded field *T, the method sets of S and *S both include promoted methods with receiver T or *T.

перевод

Если S содержит встроенное поле *T, наборы методов S и *S оба включают поднятые методы с получателем T или *T.

— Спецификация Go — Struct types

Проверка на интерфейсе с методом, объявленным на *Store:

3) НАБОР МЕТОДОВ: ВСТРОЕН T ИЛИ *T
──────────────────────────────────
  ByValue{} (встроен Store)    реализует Saver: false
  &ByValue{}                   реализует Saver: true
  ByPointer{} (встроен *Store) реализует Saver: true
  &ByPointer{}                 реализует Saver: true

Это тот же механизм, что в уроке о получателе метода, — просто на один шаг дальше. Значение ByValue{}, положенное в интерфейс, — копия без адреса; у встроенного в него Store адреса тоже нет, и метод с получателем-указателем вызвать не на чем. У встроенного *Store указатель лежит в самом поле, и адрес для Save есть всегда.

Компилятор на попытку положить такое значение в интерфейс отвечает так:

ptrrecv:
  ./main.go:14:16: cannot use ByValue{} (value of struct type ByValue) as Saver value in variable declaration: ByValue does not implement Saver (method Save has pointer receiver)

Практическое следствие: это сообщение у структуры со встроенным полем лечится так же, как без него — передать указатель на внешнюю структуру. Встраивать *T ради этого — тоже выход, но за него платят полем, которое может оказаться nil.

Механизм 3: глубина — и когда одинаковые имена становятся ошибкой

Встроенные типы могут встраивать свои, и у каждого имени появляется глубина — сколько встроенных полей пройдено до него. Выбор пути решает одно правило:

x.f denotes the field or method at the shallowest depth in T where there is such an f. If there is not exactly one f with shallowest depth, the selector expression is illegal.

перевод

x.f обозначает поле или метод на наименьшей глубине в T, где такое f есть. Если на наименьшей глубине f не ровно одно, выражение-селектор недопустимо.

— Спецификация Go — Selectors

Из первой половины — сокрытие. Внешнее поле на глубине 0 прячет встроенное на глубине 1:

4) ГЛУБИНА: ВНЕШНЕЕ ИМЯ ПРЯЧЕТ ВСТРОЕННОЕ
─────────────────────────────────────────
  o.ID                     -> "внешний"
  o.Inner.ID               -> "внутренний"

Из второй — неоднозначность. File встраивает Reader и Writer, у обоих есть Close. Объявить такой тип можно; ошибкой становится обращение к имени:

С обращением f.Close():
  # deepengineering/bench/goembed/ambiguous
  ./main.go:24:8: ambiguous selector f.Close

Без него (строка 24 закомментирована):
  собрано без ошибок

Но и без обращения неоднозначность не безобидна. При одинаковой наименьшей глубине ни один из кандидатов не становится поднятым Close — значит, метод Close интерфейса типом File не удовлетворён. Присваивание интерфейсу не собирается, хотя f.Close в файле нет, а проверка типа во время выполнения молча даёт false:

ambigiface:
  ./main.go:22:17: cannot use File{} (value of struct type File) as Closer value in variable declaration: File does not implement Closer (ambiguous selector File.Close)
10) НЕОДНОЗНАЧНОЕ ИМЯ И НАБОР МЕТОДОВ
─────────────────────────────────────
  any(File{}).(Closer)     -> реализует: false

Правило касается только наименьшей глубины. То же имя на разной глубине — не ошибка, а одинаковые имена на глубине 0 — уже ошибка объявления (ID redeclared): это два поля одной структуры, а не поднятые имена.

Это различие важно не только для экзамена. Оно значит, что неоднозначность может появиться в коде, который вы не трогали: если в новой версии библиотеки у одного из встроенных типов появится метод с тем же именем, что у другого, ваш вызов перестанет собираться — а проверка типа где-нибудь в другом пакете начнёт молча давать false. Явный путь — f.Reader.Close() — работает в обоих случаях.

Механизм 4: встроенный интерфейс — реализация, которая падает при вызове

Встроить можно не только структуру, но и интерфейс. Тогда внешний тип получает все методы интерфейса — и компилятор считает, что интерфейс реализован, даже если поле осталось nil:

GO
type ReadCloser interface {
    Read() string
    Close() string
}
 
type fakeRC struct {
    ReadCloser
}
 
func (fakeRC) Read() string { return "данные" }
5) ВСТРОЕННЫЙ ИНТЕРФЕЙС, ОСТАВШИЙСЯ nil
───────────────────────────────────────
  rc.Read()                -> "данные"
  rc.Close()               -> паника: runtime error: invalid memory address or nil pointer dereference

Read объявлен в fakeRC и работает. Close поднят из поля ReadCloser, а поле — nil, и вызов падает. Компилятор при этом не возражал ни разу.

Применений у этого приёма два, и оба законные. Тестовая заглушка: интерфейс из десяти методов, а тесту нужен один — встраивают интерфейс и объявляют этот один. Если код под тестом позовёт другой, тест упадёт громко, а не пройдёт молча. Обёртка: встраивают настоящий объект (например, http.ResponseWriter) и объявляют свой вариант одного метода, а остальные идут насквозь. Опасен этот приём ровно тем, что видно в прогоне: обещание «реализует интерфейс» проверяется при компиляции, а выполняется только при вызове.

Механизм 5: что встраивание выносит наружу

Подъём работает для всех — в том числе для тех, кто пользуется вашим типом. Отсюда главный производственный риск встраивания: встроенный тип из чужого пакета продолжает меняться без вас. Новый экспортируемый метод в следующей версии зависимости сам собой становится частью API вашего типа; если такой же метод появится у другого встроенного типа, возникнет неоднозначность из Механизма 3, и тип молча перестанет удовлетворять интерфейсы с этим методом. Ни одна строка вашего кода при этом не меняется.

Встроенный sync.Mutex — частый приём «чтобы писать c.Lock()»:

GO
type Cache struct {
    sync.Mutex
    data map[string]string
}
6) ЧУЖИЕ МЕТОДЫ В ПУБЛИЧНОМ API
───────────────────────────────
  методы *Cache: [Lock TryLock Unlock]

Ни одного из трёх методов Cache не объявлял, а любой код, получивший *Cache, может взять и отпустить его замок. Замок перестаёт быть деталью реализации. Поэтому мьютекс обычно кладут именованным полем — mu sync.Mutex: писать на три символа больше, зато снаружи его не видно. И копировать структуру со встроенным мьютексом нельзя так же, как с именованным, — это разобрано в уроке о sync и atomic.

JSON. Здесь важно не смешать два набора правил: правила селекторов Go и правила выбора полей в encoding/json — разные вещи. Пакет смотрит на встраивание по-своему: выносит поля встроенной структуры на верхний уровень объекта, учитывает теги, которые для селекторов ничего не значат, и конфликт имён решает своим правилом, всегда без ошибки — а в одном случае теряет все спорные поля:

Otherwise there are multiple fields, and all are ignored; no error occurs.

перевод

Иначе полей несколько, и все они игнорируются; ошибки не возникает.

— Пакет encoding/json — Marshal
7) JSON: ПОДЪЁМ И МОЛЧАЛИВЫЙ КОНФЛИКТ
─────────────────────────────────────
  два встроенных ID         {"Title":"report"}
  один из них с тегом       {"ID":2,"Title":"report"}

Audit и Owner оба несут поле ID, и в JSON не оказалось ни одного. Ни ошибки при кодировании, ни предупреждения — поле просто пропало из ответа API. Тег json:"ID" у одного из полей разрешает конфликт в его пользу — а для селектора тег ничего не меняет:

tagsel:
  ./main.go:18:18: ambiguous selector DocTagged{}.ID

Глубже: поднятый вызов от прямого не отличить — путь выбран при компиляции

наблюдение замераbench/goembed/practice.go, go1.24.7 linux/amd64, GOMAXPROCS=2, лучший из 7 кругов. Устойчиво отношение, а не наносекунды.

Всё выше сводится к одному: путь к полю или методу выбирается при компиляции, по глубине. Для встроенной структуры во время выполнения нет ни поиска по цепочке «родителей», ни таблицы методов. Это видно и по цене:

  e.Rev()  прямой вызов                  2.18 нс
  c.Rev()  поднят из встроенного *Engine  2.14 нс
  кратность                              0.98

В замеренных конкретных вызовах на go1.24.7 поднятый и прямой пути не отличаются в пределах шума: c.Rev() и есть c.Engine.Rev(), и дополнительного поиска «по наследованию» во время выполнения нет. Это проверка здравого смысла на одной версии, а не гарантия языка в наносекундах. Оговорка одна: если встроен интерфейс, вызов через него — обычная интерфейсная диспетчеризация, как у любого поля интерфейсного типа.

Отсюда же сводная таблица, которую удобно держать в голове вместо слова «наследование»:

ожидание от наследованияво встраивании
методы и поля родителя доступны у потомкада — подъём
свой метод прячет родительский при вызове снаружида — сокрытие по глубине
метод родителя вызывает переопределённый метод потомканет — у встраивания виртуального вызова нет
потомка можно передать туда, где ждут родителянет — разные типы; передают поле
за это платят во время выполнениянет для встроенной структуры — путь решён при компиляции

Как отвечать на собеседовании

Короткий ответ: наследования в Go нет. Есть встраивание — поле без имени, чьи поля и методы можно вызывать напрямую; s.Log() — сокращение для s.Logger.Log(). Это композиция: внешний тип содержит встроенный, а не является им, и метод встроенного типа работает со своим значением, а не с внешним.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

На «а если внешний тип объявит тот же метод» покажите Describe. «Снаружи d.Name() вернёт внешний Name — он прячет встроенный. Но поднятый Describe, который зовёт Name, получит Base и вызовет Base.Name: в прогоне d.Describe() дал „describe: Base“. У встраивания виртуального вызова нет; если он нужен — интерфейс».

На «почему не реализует интерфейс» назовите правило набора методов. «Встроенный T поднимает методы с получателем *T только в набор *S. Значит, реализует интерфейс указатель на внешнюю структуру, а не значение».

На «два одинаковых метода» — правило глубины. «На наименьшей глубине — ошибка ambiguous selector при обращении; объявление законно, но поднятым не становится ни один кандидат, и метод интерфейса тип уже не удовлетворяет. Внешнее имя на меньшей глубине прячет встроенное».

На «зачем встраивать интерфейс» — заглушки и обёртки, и цена. «Компилятор считает интерфейс реализованным, а метод, который внешний тип не объявил, падает, если поле nil».

Про sync.Mutex и JSON — что выходит наружу. «Встроенный мьютекс публикует Lock и Unlock; поэтому его кладут полем mu. Одноимённые поля двух встроенных структур пропадают из JSON оба, без ошибки».

Дальше спросят

Спросят дальше

Как из своего метода внешнего типа вызвать встроенный?

Короткий ответ

Явным путём: d.Base.Name(). Встроенное поле — обычное поле с именем типа, и к нему всегда можно обратиться. Так пишут обёртки: внешний метод делает своё и зовёт w.ResponseWriter.WriteHeader(code) — встроенный.

Это ещё одна точка, где встраивание отличается от наследования: никакого super нет и не нужно, потому что «родитель» — просто поле.

Спросят дальше

Что будет, если встроенный указатель окажется nil?

Короткий ответ

Зависит от того, к чему обращаются:

9) ВСТРОЕННЫЙ УКАЗАТЕЛЬ, ОСТАВШИЙСЯ nil
───────────────────────────────────────
  h.Ptr()  (получатель *T) -> "получатель nil, метод проверил и отработал"
  h.Val()  (получатель T)  -> паника: runtime error: invalid memory address or nil pointer dereference
  h.X      (поле)          -> паника: runtime error: invalid memory address or nil pointer dereference

Метод с получателем *T получает nil и может его проверить. Для метода с получателем T и для поднятого поля указатель нужно разыменовать — и это паника.

Отсюда практическое правило: встроенный *T удобнее для наборов методов, но добавляет состояние, которого у встроенного T нет, — поле может быть пустым. Конструктор, который его заполняет, становится обязательным.

Спросят дальше

Встраивание интерфейса в интерфейс — это то же самое?

Короткий ответ

Нет, и путать не стоит. Интерфейс, встроенный в интерфейс, просто объединяет наборы методов: io.ReadWriter — это Read и Write вместе. Встраивание здесь не добавляет поля, которое могло бы остаться nil; сам интерфейс nil быть может, как любой другой.

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

Спросят дальше

Стоит ли встраивать типы из чужих пакетов?

Короткий ответ

Осторожно — это главный производственный риск из Механизма 5, по двум причинам. Первая — API: все экспортируемые методы встроенного типа становятся вашими, и снять их потом нельзя, не сломав пользователей. Вторая — неоднозначность: если в новой версии библиотеки у встроенного типа появится метод с тем же именем, что у другого встроенного, обращение к нему перестанет собираться, а тип молча перестанет реализовывать интерфейсы с этим методом.

У именованного поля этих проблем нет; за это платят тем, что нужные методы приходится объявить руками и переадресовать.

Частые заблуждения

Утверждение

Встраивание — это наследование в Go

На самом деле

Встраивание — поле без отдельного имени и подъём его полей и методов: короткая запись пути к ним. Внешний тип содержит встроенный, а не является им: передать Derived туда, где ждут Base, нельзя, передают d.Base.

Утверждение

Свой метод во внешнем типе переопределяет встроенный

На самом деле

Он его прячет — для тех, кто вызывает снаружи. Поднятый метод встроенного типа по-прежнему вызывает метод своего типа: в прогоне d.Name() дал "Derived", а d.Describe() — "describe: Base".

Утверждение

Структура со встроенным типом реализует все интерфейсы, которые реализует он

На самом деле

Не всегда. Встроенный T поднимает методы с получателем *T только в набор *S: ByValue не реализует Saver, а &ByValue реализует. Со встроенным *T — реализуют оба.

Утверждение

Два встроенных типа с одинаковым методом — ошибка компиляции

На самом деле

Объявление законно; ошибка — обращение f.Close(): ambiguous selector f.Close. Но поднятым Close при этом не становится ни один из кандидатов, и метод интерфейса типом File не удовлетворён: присваивание интерфейсу не собирается, а проверка типа даёт false. Явный путь f.Reader.Close() работает.

Утверждение

Если структура компилируется как реализация интерфейса, все его методы работают

На самом деле

Не если интерфейс встроен и поле осталось nil: rc.Close() в прогоне упал с invalid memory address or nil pointer dereference. Проверка при компиляции видит набор методов, а не то, есть ли за полем значение.

Утверждение

При конфликте полей encoding/json возьмёт одно из них или вернёт ошибку

На самом деле

Ни то ни другое: два поля ID на одной глубине без тегов пропадают оба — {"Title":"report"}, и ошибки нет. Так и записано в документации пакета.

Утверждение

Поднятый вызов дороже прямого — его ищут по цепочке во время выполнения

На самом деле

Для встроенной структуры путь выбирается при компиляции. Замер: 2,14 нс против 2,18 у прямого вызова, кратность 0,98.

Практика

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

Практика · что напечатает

Base объявляет Name и Describe, Describe зовёт Name; Derived встраивает Base и объявляет свой Name. Save объявлен на *Store, а ByValue встраивает Store. Doc встраивает Audit и Owner, у обоих поле ID. Что напечатает этот код?
type Base struct{}

func (Base) Name() string       { return "Base" }
func (b Base) Describe() string { return "describe: " + b.Name() }

type Derived struct{ Base }

func (Derived) Name() string { return "Derived" }

d := Derived{}
fmt.Println(d.Name())
fmt.Println(d.Describe())
_, ok := any(ByValue{}).(Saver)
fmt.Println(ok)
b, _ := json.Marshal(Doc{Audit{1}, Owner{2}, "report"})
fmt.Println(string(b))

Практика · оцените

Метод объявлен на *Engine; Car встраивает *Engine. Во сколько раз вызов c.Rev() дороже прямого e.Rev()?
раза

Проверка знаний

Вопрос 1 из 5

Derived встраивает Base и объявляет свой Name. Метод Base.Describe зовёт b.Name(). Что вернёт d.Describe()?

Источники и что читать дальше

3 ИСТОЧНИКА

  1. Спецификация Go — Struct typesОфициальная документация. Определение и правило подъёма. Что такое встроенное поле: «A field declared with a type but no explicit field name is called an embedded field» (Поле, объявленное с типом, но без явного имени, называется встроенным полем); и откуда у него всё-таки имя: «The unqualified type name acts as the field name» (Именем поля служит неквалифицированное имя типа). Что поднимается: «A field or method f of an embedded field in a struct x is called promoted if x.f is a legal selector that denotes that field or method f» (Поле или метод f встроенного поля в структуре x называется поднятым, если x.f — допустимый селектор, обозначающий это поле или метод f). И наборы методов: «If S contains an embedded field T, the method sets of S and *S both include promoted methods with receiver T. The method set of *S also includes promoted methods with receiver *T» (Если S содержит встроенное поле T, наборы методов S и *S оба включают поднятые методы с получателем T. Набор методов *S включает также поднятые методы с получателем *T). И для встроенного указателя: «If S contains an embedded field *T, the method sets of S and *S both include promoted methods with receiver T or *T» (Если S содержит встроенное поле *T, наборы методов S и *S оба включают поднятые методы с получателем T или *T).https://go.dev/ref/spec#Struct_types
  2. Спецификация Go — SelectorsОфициальная документация. Правило глубины, из которого следуют и сокрытие, и неоднозначность: «x.f denotes the field or method at the shallowest depth in T where there is such an f. If there is not exactly one f with shallowest depth, the selector expression is illegal» (x.f обозначает поле или метод на наименьшей глубине в T, где такое f есть. Если на наименьшей глубине таких f не ровно одно, выражение-селектор недопустимо).https://go.dev/ref/spec#Selectors
  3. Пакет encoding/json — MarshalОфициальная документация. Как встроенные структуры выходят в JSON: «Embedded struct fields are usually marshaled as if their inner exported fields were fields in the outer struct» (Поля встроенных структур обычно кодируются так, как если бы их внутренние экспортируемые поля были полями внешней структуры). И правило конфликта, которое урок проверяет прогоном: «Otherwise there are multiple fields, and all are ignored; no error occurs» (Иначе полей несколько, и все они игнорируются; ошибки не возникает).https://pkg.go.dev/encoding/json#Marshal