Create your own
Lesson illustration

Неявные интерфейсы и встраивание структур в Go

Здравствуйте. В прошлом уроке вы восстановили работу со struct, методами и указательными ресиверами на примере Task. В частности, *Task был выбран для методов, которые изменяют задачу, чтобы работать с одним конкретным объектом, а не с его копией.

Теперь сделаем следующий важный шаг: научимся описывать не конкретные структуры, а нужное вызывающему коду поведение. В Go это делается интерфейсами, которые реализуются неявно. Затем разберём встраивание (embedding) структур: это удобная композиция, но не классическое наследование. Эти два механизма регулярно встречаются и в backend-коде, и на junior-собеседованиях.

К концу урока вы сможете:

  • определить небольшой интерфейс по набору необходимых методов;
  • понять, реализует ли тип интерфейс, с учётом value- и pointer-receiver;
  • использовать интерфейс в функции, не привязывая её к одной структуре;
  • встроить структуру и объяснить продвижение её полей и методов;
  • отличить композицию от наследования и не создавать лишние абстракции.

Интерфейс описывает потребность, а не происхождение типа

Интерфейс в Go — это набор методов, доступных вызывающему коду. Он не содержит полей и обычно не содержит реализации.

Представьте, что некоторому коду нужно вывести краткое описание задачи. Ему не важно, хранится ли задача в PostgreSQL, пришла ли из тестового fake-репозитория или была создана вручную. Ему важно только одно: объект должен уметь вернуть строковое описание.

type TaskSummary interface {
	Summary() string
}

Этот интерфейс не говорит: «объект является задачей». Он говорит гораздо точнее: «объект умеет выдавать краткое описание».

Добавим метод к знакомому типу Task:

type Task struct {
	ID    int64
	Title string
}

func (t *Task) Summary() string {
	return "Task #" + strconv.FormatInt(t.ID, 10) + ": " + t.Title
}

Теперь функция может работать не с *Task, а с контрактом TaskSummary:

func PrintSummary(s TaskSummary) {
	fmt.Println(s.Summary())
}

Использование:

task := &Task{
	ID:    101,
	Title: "Prepare project README",
}

PrintSummary(task)
// Task #101: Prepare project README

PrintSummary знает только о методе Summary() string. Она не может напрямую обратиться к ID или Title, потому что этих полей нет в контракте:

func PrintSummary(s TaskSummary) {
	fmt.Println(s.Summary())

	// fmt.Println(s.Title) // compilation error
}

Это полезное ограничение. Функция зависит от минимально необходимого поведения, а не от полной внутренней структуры объекта.

Interfaces are implemented implicitly - A Tour of Go

Прочитайте официальный материал A Tour of Go. Он формулирует ключевой принцип: в Go нет отдельного объявления о реализации интерфейса.

В разделе “Interfaces are implemented implicitly” прочитайте основное объяснение, затем посмотрите расположенный ниже пример с интерфейсом I, структурой T и методом M. Обратите внимание: между T и I нет ключевого слова implements; достаточно совпадения метода.


Неявная реализация: совпали методы — контракт выполнен

В Java или C# тип обычно явно объявляет, что реализует интерфейс. В Go такого объявления нет.

Достаточно, чтобы набор методов типа содержал все методы интерфейса с полностью совпадающими:

  • именем;
  • параметрами;
  • типами результатов.

В примере выше *Task реализует TaskSummary, потому что у *Task есть:

Summary() string

При этом тип может иметь и другие методы. Они не мешают:

func (t *Task) Start() bool {
	// логика перехода todo в in_progress
	return true
}

func (t *Task) Complete() bool {
	// логика завершения
	return true
}

Для TaskSummary важен только Summary() string. Наличие Start и Complete несущественно.

Диаграмма показывает интерфейс с требуемыми методами `a`, `b` и `c` и наборы методов нескольких конкретных типов: тип реализует интерфейс, если его набор методов включает все методы, требуемые интерфейсом.

Эту идею удобно запомнить так:

Интерфейс задаёт минимальный набор операций. Тип удовлетворяет ему, если умеет выполнить каждую из них.

Поэтому один тип может реализовывать несколько интерфейсов одновременно:

type TaskSummary interface {
	Summary() string
}

type Startable interface {
	Start() bool
}

Если *Task имеет оба метода, он подходит и для TaskSummary, и для Startable. Не нужно создавать «родительский» интерфейс или иерархию классов только потому, что поведение частично пересекается.

Интерфейс должен появляться со стороны потребителя

В Go интерфейс часто определяют рядом с кодом, который в нём нуждается, а не рядом с конкретной реализацией.

Например, функция печати нуждается в Summary():

type TaskSummary interface {
	Summary() string
}

func PrintSummary(s TaskSummary) {
	fmt.Println(s.Summary())
}

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

type TaskPreview struct {
	Text string
}

func (p TaskPreview) Summary() string {
	return p.Text
}
preview := TaskPreview{
	Text: "Task #101: Prepare project README",
}

PrintSummary(preview)

Это не значит, что нужно создавать интерфейс для каждой структуры. Если функция всегда работает только с Task и нет разумного варианта замены, конкретный тип часто проще и честнее:

func IsTaskDone(task *Task) bool {
	return task.Status == StatusDone
}

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


Ресивер определяет, какой тип реализует интерфейс

В прошлом уроке вы выбрали pointer receiver для изменяемой сущности:

func (t *Task) Summary() string {
	return "Task: " + t.Title
}

Это решение влияет на интерфейсы.

type TaskSummary interface {
	Summary() string
}

В данном случае интерфейс реализует *Task, но не Task.

taskValue := Task{
	Title: "Write unit tests",
}

taskPointer := &taskValue

PrintSummary(taskPointer) // работает

// PrintSummary(taskValue) // compilation error

Причина — в правилах наборов методов:

Тип методаМетоды в наборе TaskМетоды в наборе *Task
func (t Task) Method()естьесть
func (t *Task) Method()нетесть

Иными словами:

  • методы с value receiver доступны и у Task, и у *Task;
  • методы с pointer receiver доступны только у *Task.

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

task := Task{
	Title: "Write unit tests",
}

fmt.Println(task.Summary())

Компилятор может неявно использовать &task для вызова метода. Но это не означает, что значение task стало удовлетворять TaskSummary.

Функция, принимающая интерфейс, требует, чтобы переданное значение уже имело нужный набор методов:

PrintSummary(&task) // корректно

Для доменных сущностей наподобие Task, где вы уже выбрали pointer receiver, обычно передавайте *Task и в интерфейсные функции. Это согласуется с семантикой: вы работаете с конкретной задачей, имеющей идентичность и изменяемое состояние.

Проверка реализации во время компиляции

Иногда полезно явно зафиксировать: «*Task обязан реализовывать этот интерфейс».

var _ TaskSummary = (*Task)(nil)

Это не создаёт полезную переменную и не вызывает метод. Присваивание нужно только компилятору: если *Task перестанет иметь метод Summary() string, код больше не соберётся.

Например, такая строка не скомпилируется, если метод объявлен только для указателя:

// var _ TaskSummary = Task{}

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

Golang: The Last Interface Explanation You'll Ever Need

Посмотрите фрагменты видео “Golang: The Last Interface Explanation You'll Ever Need” от Flo Woelki. На примере фигур оно показывает, как несколько конкретных типов передаются в функцию через один интерфейсный контракт.

Начните с основ интерфейса: обратите внимание на определение метода без тела и на отсутствие implements. Затем посмотрите функцию потребителя, где функция получает интерфейс и вызывает общий метод, не зная конкретный тип аргумента.


Композиция интерфейсов: собрать более широкий контракт

Интерфейс может встраивать другие интерфейсы. Это именно композиция требований.

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

type TaskFinder interface {
	FindTask(id int64) (*Task, bool)
}

type TaskSaver interface {
	SaveTask(task *Task)
}

Код, которому нужен только поиск, должен зависеть только от TaskFinder. Код, который сохраняет задачу, зависит от TaskSaver.

Если некоторому компоненту действительно требуются обе операции, можно собрать контракт:

type TaskStore interface {
	TaskFinder
	TaskSaver
}

Теперь TaskStore требует оба метода:

FindTask(id int64) (*Task, bool)
SaveTask(task *Task)

Важное правило для backend-разработки: не делайте интерфейс шире, чем нужно конкретному потребителю.

Например, HTTP-обработчику создания задачи может быть нужен только метод сохранения. Заставлять его зависеть от огромного Repository с десятком методов — лишняя связанность. Маленькие интерфейсы проще реализовать в тестах, проще менять и проще читать.


Встраивание структур: получить удобный доступ к компоненту

Теперь перейдём к другой форме композиции: встраиванию структур.

Обычное именованное поле выглядит так:

type ProjectTask struct {
	Task        *Task
	ProjectName string
}

Доступ к задаче здесь явный:

card := ProjectTask{
	Task: &Task{
		ID:    101,
		Title: "Prepare README",
	},
	ProjectName: "Task Manager",
}

fmt.Println(card.Task.Title)
fmt.Println(card.Task.Summary())

Это хороший вариант по умолчанию: по коду видно, что ProjectTask содержит задачу.

Встраивание записывается без имени поля:

type ProjectTask struct {
	*Task
	ProjectName string
}

Создание значения почти такое же:

card := ProjectTask{
	Task: &Task{
		ID:    101,
		Title: "Prepare README",
	},
	ProjectName: "Task Manager",
}

Но теперь поля и методы встроенного *Task продвигаются наружу. Поэтому допустимо:

fmt.Println(card.Title)
fmt.Println(card.Summary())

Хотя фактически данные по-прежнему лежат во внутреннем поле Task:

fmt.Println(card.Task.Title)
fmt.Println(card.Task.Summary())

Первый и второй варианты обращения к Title эквивалентны. Встраивание не копирует поля Task в ProjectTask; оно даёт сокращённый путь к ним.

Продвижение методов не превращает композицию в наследование

Поскольку *Task имеет метод Summary(), ProjectTask получает этот метод через встраивание. Поэтому значение card может удовлетворять TaskSummary:

PrintSummary(card)

Но ProjectTask не становится подтипом Task. Следующий код не скомпилируется:

func PrintConcreteTask(task *Task) {
	fmt.Println(task.Title)
}

// PrintConcreteTask(&card) // *ProjectTask is not *Task

Если функции нужна именно исходная задача, передавайте её явно:

PrintConcreteTask(card.Task)

Это принципиальное отличие от классического наследования. ProjectTask не «является» Task; он содержит *Task.

У внешнего типа можно определить одноимённый метод:

func (pt ProjectTask) Summary() string {
	return "[" + pt.ProjectName + "] " + pt.Task.Summary()
}

Теперь:

fmt.Println(card.Summary())
// [Task Manager] Task: Prepare README

Метод ProjectTask.Summary выбирается вместо продвинутого метода Task.Summary. Однако это не полиморфное «переопределение» как в классическом ООП:

  • метод Task.Summary продолжает существовать;
  • его можно вызвать явно через card.Task.Summary();
  • внутри метода Task ресивером остаётся именно *Task, а не внешний ProjectTask.

Поэтому не следует строить цепочки вроде BaseEntity, UserEntity, AdminUserEntity, пытаясь воспроизвести наследование. Такая модель скрывает зависимости и быстро делает поведение неочевидным.

Effective Go

Прочитайте два фрагмента официального руководства Effective Go от команды Go. Первый объясняет проверку реализации интерфейса через var _, второй подробно раскрывает, что именно делает embedding и почему он не равен наследованию.

В разделе “Interfaces and other types”, подразделе “Interface checks”, прочитайте объяснение проверки контракта с помощью blank identifier. Затем в подразделе “Embedding” прочитайте от начала пример ReadWriter, а после него внимательно разберите поведение встроенных структур. Сфокусируйтесь на том, что методы встроенного компонента продвигаются, но их ресивер остаётся внутренним компонентом.


Когда встраивать, а когда оставить обычное поле

Встраивание — это удобный инструмент, но не значение по умолчанию.

ПодходПример доступаКогда подходит
Именованное полеcard.Task.Summary()Связь должна быть явной; внешний тип не должен автоматически предоставлять всё поведение компонента
Встроенное полеcard.Summary()Внешний тип осмысленно объединяет поведение внутреннего компонента со своим и должен предоставлять его как часть собственного API

В стандартной библиотеке примером осмысленной композиции служит bufio.ReadWriter: он объединяет reader и writer, поэтому естественно предоставляет оба набора операций.

В прикладном backend-коде предпочтительнее именованное поле, если вы просто храните зависимость:

type TaskService struct {
	repository TaskSaver
}

Встраивать TaskSaver здесь обычно не нужно. Запись service.SaveTask(task) скрыла бы то, что сохранение выполняет отдельная зависимость. Явный вызов лучше показывает границу:

service.repository.SaveTask(task)

При встраивании указателя есть и практическая опасность: встроенное значение должно быть инициализировано.

var card ProjectTask

// card.Summary() // panic: card.Task == nil

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

Короткое практическое правило:

  1. Сначала используйте конкретный тип и обычные именованные поля.
  2. Выделяйте маленький интерфейс, когда потребителю реально нужна только часть поведения или когда нужна подмена реализации.
  3. Встраивайте структуру только тогда, когда внешний тип действительно должен предоставлять её методы как часть собственного поведения.
  4. Не используйте embedding для имитации иерархии классов.

Итоги

Сегодня вы восстановили два фундаментальных Go-механизма:

  • интерфейс задаёт набор требуемых методов, а не родство типов;
  • тип реализует интерфейс неявно, если его набор методов содержит все методы контракта;
  • extra-методы не мешают, а один тип может удовлетворять нескольким интерфейсам;
  • pointer receiver означает, что интерфейс часто реализует *T, но не T;
  • var _ Interface = (*Type)(nil) позволяет зафиксировать реализацию на этапе компиляции;
  • встраивание продвигает поля и методы внутреннего типа, но не создаёт отношение наследования;
  • встроенный тип остаётся отдельным типом, а его методы выполняются с внутренним ресивером;
  • обычное именованное поле обычно лучше, если не нужно намеренно раскрывать API вложенного компонента.

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

Can't find a good explanation? Sign up and we'll make it for you

Sign up