Здравствуйте. В прошлом уроке вы восстановили работу со 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 несущественно.

Эту идею удобно запомнить так:
Интерфейс задаёт минимальный набор операций. Тип удовлетворяет ему, если умеет выполнить каждую из них.
Поэтому один тип может реализовывать несколько интерфейсов одновременно:
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 от команды 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-указателю. Поэтому либо создавайте объект сразу корректно, либо используйте обычное именованное поле и проверяйте его наличие там, где это требуется логикой.
Короткое практическое правило:
- Сначала используйте конкретный тип и обычные именованные поля.
- Выделяйте маленький интерфейс, когда потребителю реально нужна только часть поведения или когда нужна подмена реализации.
- Встраивайте структуру только тогда, когда внешний тип действительно должен предоставлять её методы как часть собственного поведения.
- Не используйте 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