Встраивание структур в Go: разбираем на живом примере
Встраивание (embedding) в Go выглядит как наследование, но работает иначе — и это ловушка для тех, кто пришёл из Java или Python. Разбираем на реальном коде, где embedding спасает, где ломает, и чем платите за удобство.

Зачем вообще встраивание, если есть интерфейсы
Go не поддерживает наследование классов. Вместо этого язык предлагает композицию — и встраивание структур (struct embedding) делает её синтаксически дешёвой. Вы объявляете поле без имени, и все его методы и поля «всплывают» на уровень внешней структуры. Код выглядит так, будто внешний тип унаследовал поведение внутреннего. Но ресивер метода остаётся прежним — и именно здесь начинаются сюрпризы.
Что нужно, чтобы повторить примеры
- Go 1.21+ (примеры проверены на 1.22, но совместимы с 1.21).
- Любой редактор с поддержкой Go — VS Code с расширением Go или GoLand.
- Файл
main.goв пустом модуле:go mod init embedding-demo.
Живой пример: логгер с метриками
Допустим, у вас есть простой структурный логгер, и вы хотите добавить к нему подсчёт записанных сообщений. Очевидное решение — встроить логгер в новую структуру.
Базовый логгер
package main
import (
"fmt"
"time"
)
// Logger — минимальный структурный логгер.
type Logger struct {
Prefix string
}
// Log выводит сообщение с префиксом и временной меткой.
func (l *Logger) Log(msg string) {
fmt.Printf("[%s] %s: %s\n", time.Now().Format(time.TimeOnly), l.Prefix, msg)
}
// SetPrefix меняет префикс и возвращает логгер для цепочки вызовов.
func (l *Logger) SetPrefix(p string) *Logger {
l.Prefix = p
return l
}
Проверка: вызовите Logger{Prefix: "app"}.Log("started") — в консоли появится строка вида [15:04:05] app: started.
Встраиваем в CountingLogger
// CountingLogger считает, сколько раз вызван Log.
type CountingLogger struct {
*Logger // встроенная структура — без имени поля
Count int
}
// Log переопределяет метод встроенного логгера.
func (cl *CountingLogger) Log(msg string) {
cl.Count++
cl.Logger.Log(msg) // явный вызов метода встроенной структуры
}
func main() {
cl := &CountingLogger{
Logger: &Logger{Prefix: "metrics"},
}
cl.Log("request handled")
cl.Log("request handled")
fmt.Printf("total messages: %d\n", cl.Count) // 2
// SetPrefix доступен без явного делегирования
cl.SetPrefix("updated")
cl.Log("new prefix works")
fmt.Printf("total messages: %d\n", cl.Count) // 3
}
Запустите go run main.go. Должны увидеть три строки лога и два счётчика: 2 и 3.
Что здесь произошло и почему это не наследование
Три ключевых момента, которые отличают embedding от наследования в классических ООП-языках:
- Промотирование, а не переопределение. Когда
CountingLoggerобъявляет свойLog, он не «переопределяет» метод родителя — он затеняет промотированный метод. Компилятор просто выбирает ближайший по глубине. - Ресивер не меняется. Внутри
Logger.Logзначениеl— это*Logger, а не*CountingLogger. Встроенная структура ничего не знает о внешней. Полиморфизма «вверх» нет. - Промотируются и поля. Обращение
cl.Prefix— это синтаксический сахар дляcl.Logger.Prefix. Если уCountingLoggerпоявится своё полеPrefix, промотирование отключится — молча, без предупреждения компилятора.
Встраивание — это автоматическое делегирование. Вы получаете удобный синтаксис, но не получаете позднее связывание. Если вам нужен полиморфизм — используйте интерфейсы, а не embedding.
Ловушка с SetPrefix: цепочка возвращает не тот тип
Обратите внимание на SetPrefix. Он возвращает *Logger, а не *CountingLogger. Если написать:
cl.SetPrefix("oops").Log("this bypasses counting")
fmt.Printf("total messages: %d\n", cl.Count) // Count НЕ увеличился
Вызов Log после SetPrefix уходит в Logger.Log — счётчик не затронут. Это классическая ошибка: цепочка вызовов «теряет» внешний тип, потому что встроенный метод ничего о нём не знает.
Как исправить
Два варианта:
- Переопределить
SetPrefixна уровнеCountingLogger, чтобы он возвращал*CountingLogger. - Отказаться от цепочки и вызывать
SetPrefixотдельной строкой — менее красиво, зато тип не ускользает.
Когда embedding помогает, а когда мешает
| Ситуация | Embedding подходит | Лучше явное поле |
|---|---|---|
| Нужно пробросить все методы внутреннего типа наружу | Да — экономит десятки строк делегирования | — |
| Нужно переопределить один-два метода, остальные оставить как есть | Да — затенение работает предсказуемо | — |
Внутренний тип возвращает *self в цепочке | — | Да — иначе цепочка «теряет» внешний тип |
| Два встроенных типа имеют метод с одним именем | — | Да — компилятор выдаст ошибку при вызове, но не при объявлении |
| Встроенный тип экспортирует поля, которые не должны быть частью вашего API | — | Да — embedding «вытаскивает» всё наружу, включая лишнее |
| Нужен полиморфизм: вызов метода внешнего типа из внутреннего | — | Да — embedding этого не даёт; используйте интерфейс |
Конфликт имён: тихая бомба
Если встроить две структуры, у которых есть метод с одинаковым именем, код скомпилируется. Ошибка возникнет только при попытке вызвать этот метод — и только если вы не разрешите неоднозначность явным указанием пути. Это значит, что вы можете добавить второе встраивание, пройти go build и узнать о проблеме в рантайме через месяц, когда кто-то наконец вызовет конфликтный метод.
type A struct{}
func (A) Do() { fmt.Println("A") }
type B struct{}
func (B) Do() { fmt.Println("B") }
type C struct {
A
B
}
// c := C{}
// c.Do() // ошибка компиляции: ambiguous selector c.Do
// c.A.Do() // работает
// c.B.Do() // работает
Линтер go vet это не ловит. Если хотите ловить такие ситуации раньше, стоит посмотреть на правила в golangci-lint — например, govet в расширенном режиме или сторонние линтеры для embedding-конфликтов.
Embedding и интерфейсы: бесплатная реализация
Ещё одна причина использовать embedding — автоматическое удовлетворение интерфейсов. Если Logger реализует интерфейс Writer, то CountingLogger тоже его реализует — без единой строчки дополнительного кода. Это мощно, но опасно: вы можете случайно реализовать интерфейс, который не собирались, и ваш тип начнёт приниматься функциями, для которых он не предназначен.
Проверить, какие интерфейсы реализует тип, можно через go doc или в IDE. Привычка ревьюить промотированные методы после добавления embedding спасает от неожиданных совпадений.
Промотирование (promotion) — механизм, при котором методы и поля встроенного типа становятся доступны на внешнем типе без явного делегирования. Термин из спецификации Go, раздел «Struct types».
Что делать дальше
- Пройдитесь по своему коду и найдите все встраивания. Для каждого ответьте: зачем встроено, а не объявлено именованным полем? Если ответ «так короче» — это повод задуматься.
- Проверьте, не возвращает ли встроенный тип
*self— это источник ловушки с цепочками. - Добавьте в CI линтер
golangci-lintс включённымgovet— он поймает хотя бы часть конфликтов. - Прочитайте раздел спецификации Go Struct types — там формально описаны правила промотирования и разрешения конфликтов имён. Это два экрана текста, но они закрывают все крайние случаи.
Проверка перед сдачей: Заимствования, вода, структура, соответствие требованиям
Спросить за 20 ₽Источники: The Go Programming Language Specification — Struct types, Effective Go — Embedding
Частые вопросы
Чем встраивание структур отличается от наследования?
Можно ли встроить два типа с одинаковыми методами?
Встраивать нужно указатель или значение?
Как узнать, какие интерфейсы мой тип реализует через embedding?
Если я затеняю метод встроенного типа, старый метод всё ещё доступен?
Почему линтер не предупреждает о конфликте имён при встраивании?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.