· 10 мин 👁 1.2k Начинающий

Язык Go — embedding: встраивание типов

Embedding в Go — как встраивать интерфейсы и структуры, продвижение методов, отличие от наследования и правила разрешения конфликтов имён.

embeddingвстраиваниеинтерфейсыструктурыкомпозиция
Содержание

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

Встраивание интерфейсов

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

// стандартные интерфейсы из пакета io
type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

// ReadWriter — объединение двух интерфейсов через embedding
// эквивалентно явному перечислению обоих методов, но читается яснее
type ReadWriter interface {
    Reader
    Writer
}

ReadWriter автоматически требует оба метода: Read и Write. Любой тип, реализующий оба, удовлетворяет всем трём интерфейсам сразу.

Более сложный пример — интерфейс с тремя составными частями:

// ReadWriteCloser объединяет три интерфейса
type ReadWriteCloser interface {
    Reader
    Writer
    io.Closer // Close() error
}

// любой тип с Read, Write и Close удовлетворяет ReadWriteCloser

В интерфейс можно встроить только другой интерфейс — не структуру.

Встраивание структур

Со структурами embedding работает глубже. Встроенный тип указывается без имени поля — только тип:

// bufio.ReadWriter из стандартной библиотеки
// встраивает указатели на Reader и Writer без имён полей
type ReadWriter struct {
    *Reader // *bufio.Reader
    *Writer // *bufio.Writer
}

Сравним с обычным подходом через именованные поля:

// именованные поля — так тоже работает, но методы не продвигаются автоматически
type ReadWriterNamed struct {
    reader *Reader
    writer *Writer
}

// пришлось бы писать форвардинг вручную для каждого метода:
func (rw *ReadWriterNamed) Read(p []byte) (n int, err error) {
    return rw.reader.Read(p)
}
func (rw *ReadWriterNamed) Write(p []byte) (n int, err error) {
    return rw.writer.Write(p)
}
// ... и так для каждого метода обоих типов

Embedding избавляет от этого. bufio.ReadWriter автоматически получает все методы bufio.Reader и bufio.Writer — и удовлетворяет io.Reader, io.Writer и io.ReadWriter без единой строки форвардинга.

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

Ключевое отличие: когда вызывается метод встроенного типа, ресивером остаётся внутренний тип, а не внешний.

rw := &bufio.ReadWriter{...}
rw.Read(p) // вызывает Read у *bufio.Reader, не у *bufio.ReadWriter

Это как если бы форвардинг был написан явно: return rw.Reader.Read(p). Внешний тип не «наследует» поведение — он делегирует его.

// наглядный пример: два типа с одним методом
type Base struct{}

func (b Base) Describe() string {
    return "я Base"
}

type Outer struct {
    Base
}

o := Outer{}
fmt.Println(o.Describe()) // "я Base" — ресивер Base, не Outer

Если нужно переопределить метод — объявляем его на внешнем типе явно:

func (o Outer) Describe() string {
    return "я Outer, внутри: " + o.Base.Describe()
}

fmt.Println(o.Describe()) // "я Outer, внутри: я Base"

Практический пример: Job с логгером

Embedding удобен когда хочется добавить функциональность без лишних полей. Job встраивает *log.Logger — и сразу получает все его методы:

type Job struct {
    Command string
    *log.Logger // встраиваем указатель — нет имени поля
}

// инициализация через конструктор
func NewJob(command string, logger *log.Logger) *Job {
    return &Job{command, logger}
}

// или через составной литерал
job := &Job{
    Command: "backup",
    Logger:  log.New(os.Stderr, "Job: ", log.Ldate),
    // тип без пакетного префикса служит именем поля: Logger, не log.Logger
}

// методы Logger доступны напрямую на job
job.Println("starting now...")      // как будто у Job есть метод Println
job.Printf("running %s", job.Command)

Обратиться к встроенному полю напрямую — через имя типа без пакета:

job.Logger.SetPrefix("URGENT: ") // прямой доступ к встроенному Logger

Можно переопределить метод встроенного типа, добавив контекст:

// переопределяем Printf чтобы автоматически добавлять команду в каждое сообщение
func (job *Job) Printf(format string, args ...interface{}) {
    job.Logger.Printf("%q: %s", job.Command, fmt.Sprintf(format, args...))
}

job.Printf("done") // напечатает: "backup": done

Встраивание нескольких типов

Структура может встраивать несколько типов одновременно:

type Server struct {
    net.Listener          // принимаем соединения
    *sync.Mutex           // защищаем общее состояние
    connections map[string]net.Conn
}

func NewServer(addr string) (*Server, error) {
    l, err := net.Listen("tcp", addr)
    if err != nil {
        return nil, err
    }
    return &Server{
        Listener:    l,
        Mutex:       &sync.Mutex{},
        connections: make(map[string]net.Conn),
    }, nil
}

s, _ := NewServer(":8080")
s.Lock()                          // метод от *sync.Mutex
defer s.Unlock()
conn, _ := s.Accept()             // метод от net.Listener
s.connections[conn.RemoteAddr().String()] = conn

Конфликты имён

При встраивании нескольких типов может возникнуть конфликт имён. Правила разрешения простые.

Правило 1: поле или метод внешнего типа перекрывает одноимённое поле встроенного:

type Logger struct{ Command string }
func (l Logger) Describe() string { return "logger" }

type Job struct {
    Command string // перекрывает Logger.Command
    Logger
}

job := Job{Command: "backup", Logger: Logger{Command: "ignored"}}
fmt.Println(job.Command)          // "backup" — поле Job, не Logger
fmt.Println(job.Logger.Command)   // "ignored" — явный доступ к Logger.Command

Правило 2: если одинаковое имя на одном уровне вложенности — ошибка при обращении (но не при объявлении):

type A struct{ Name string }
type B struct{ Name string }

type C struct {
    A
    B
}

c := C{}
// fmt.Println(c.Name) // ошибка компиляции: ambiguous selector c.Name
fmt.Println(c.A.Name) // ок — явное указание
fmt.Println(c.B.Name) // ок

Если Name нигде не используется — конфликта нет, код компилируется.

Итоги

  • Embedding встраивает тип в структуру или интерфейс без имени поля
  • Методы встроенного типа продвигаются на внешний — форвардинг писать не нужно
  • Ресивер встроенного метода — внутренний тип, не внешний: это делегирование, не наследование
  • Обратиться к встроенному полю — через имя типа без пакетного префикса: job.Logger, не job.log.Logger
  • Внешний тип может переопределить метод встроенного — просто объявите метод с тем же именем
  • Поле внешнего типа перекрывает одноимённое поле встроенного (более глубокого уровня)
  • Одинаковые имена на одном уровне вложенности — ошибка компиляции при обращении