Интерфейс в Go

Интерфейсы — это то, что делает Go-код по-настоящему гибким. В большинстве языков для реализации интерфейса нужно явно написать implements. В Go достаточно просто иметь нужные методы — это называется structural typing или duck typing. За этой простотой снаружи скрывается нетривиальное устройство внутри, которое важно понимать, чтобы не попасть в классические ловушки.


Объявление и неявная реализация

go
type Stringer interface { String() string } type User struct { Name string } // User реализует Stringer — просто наличием метода // Никакого "implements Stringer" не нужно func (u User) String() string { return u.Name } var s Stringer = User{Name: "Alice"} // работает fmt.Println(s.String()) // "Alice"

Компилятор проверяет соответствие интерфейсу в момент присвоения. Если метода нет — ошибка компиляции, а не рантайм.

Чтобы явно проверить реализацию во время компиляции — используют blank identifier:

go
var _ Stringer = User{} // если User не реализует Stringer — ошибка компиляции var _ Stringer = (*User)(nil) // проверка для pointer receiver

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


Внутреннее устройство: iface и eface

Переменная интерфейсного типа — это не просто указатель на данные. Под капотом это два слова (два указателя по 8 байт на 64-битной платформе). Конкретная структура зависит от того, пустой интерфейс или нет.

eface — пустой интерфейс (interface{} / any)

go
type eface struct { _type *_type // указатель на описание типа data unsafe.Pointer // указатель на данные }

eface используется для interface{} (он же any начиная с Go 1.18). Хранит только тип и данные — никакой таблицы методов не нужно, потому что через пустой интерфейс методы не вызываются.

iface — непустой интерфейс

go
type iface struct { tab *itab // указатель на таблицу методов (itab) data unsafe.Pointer // указатель на данные } type itab struct { inter *interfacetype // описание интерфейса _type *_type // описание конкретного типа fun [1]uintptr // таблица методов (variable size) }

iface используется для любого непустого интерфейса. itab — это кешируемая структура: Go не строит её заново при каждом присвоении, а хранит в глобальном кеше по паре (interface, concrete type).

Главное различие в одну строку: eface — два слова (тип + данные), iface — два слова (itab + данные), где itab содержит и тип, и таблицу методов для диспетчеризации вызовов.


Как данные попадают в интерфейс

Если значение помещается в машинное слово (≤ 8 байт) — оно может храниться прямо в поле data. Если больше — аллоцируется в куче, data хранит указатель. Это важно с точки зрения производительности: упаковка значений в интерфейс может вызывать аллокации:

go
var s Stringer // Аллокация: User > 8 байт, данные уходят в кучу s = User{Name: "Alice"} // Можно избежать, если работать с указателем s = &User{Name: "Alice"} // data хранит указатель — одна аллокация в любом случае

Composition — интерфейсы из интерфейсов

Интерфейсы можно компоновать из других интерфейсов:

go
type Reader interface { Read(p []byte) (n int, err error) } type Writer interface { Write(p []byte) (n int, err error) } // io.ReadWriter — стандартный пример из stdlib type ReadWriter interface { Reader Writer }

Тип, реализующий ReadWriter, должен иметь оба метода. Это основа модульности в Go: маленькие интерфейсы из одного-двух методов, которые собираются в более крупные по необходимости.

Знаменитая цитата из Go-сообщества: "The bigger the interface, the weaker the abstraction" — Роб Пайк. io.Reader с одним методом применим в тысячах мест. Интерфейс из 15 методов — почти нигде.


nil interface — главная ловушка

Это одна из самых известных ловушек в Go, и она прямо следует из устройства iface/eface.

Интерфейс равен nil только если оба поля (tab/_type и data) равны nil. Если тип задан, а данные nil — интерфейс не nil:

go
func getError() error { var p *MyError = nil // typed nil pointer return p // УПАКОВЫВАЕТСЯ в iface: tab=*MyError, data=nil } err := getError() fmt.Println(err == nil) // false (!!) fmt.Println(err) // <nil> — выглядит как nil, но не nil

Что происходит: p — это *MyError со значением nil. При возврате через error (интерфейс) Go создаёт iface с tab, указывающим на *MyError, и data=nil. Интерфейс ненулевой, потому что тип задан.

Правильный способ вернуть nil error:

go
// Плохо func validate(s string) error { var err *ValidationError if s == "" { err = &ValidationError{"empty"} } return err // всегда ненулевой error! } // Хорошо func validate(s string) error { if s == "" { return &ValidationError{"empty"} } return nil // возвращаем nil интерфейс, не typed nil }

Type assertion

Type assertion — это способ достать конкретный тип из интерфейса:

go
var s Stringer = User{Name: "Alice"} // Одноаргументная форма — паника если тип не совпал u := s.(User) fmt.Println(u.Name) // "Alice" // Двухаргументная форма — безопасно u, ok := s.(User) if ok { fmt.Println(u.Name) } // Частая ошибка: assertion к неправильному типу var s Stringer = User{Name: "Alice"} _, ok := s.(*User) // false — s содержит User, не *User

Type assertion работает за O(1): Go сравнивает указатель на itab в iface с ожидаемым — это одно сравнение указателей.


Type switch

Когда нужно обработать несколько возможных типов — используют type switch:

go
func describe(i interface{}) string { switch v := i.(type) { case int: return fmt.Sprintf("int: %d", v)\n case string: return fmt.Sprintf("string: %q", v)\n case bool: return fmt.Sprintf("bool: %v", v)\n case []int: return fmt.Sprintf("[]int с длиной %d", len(v))\n case nil: return "nil" default: return fmt.Sprintf("неизвестный тип: %T", v)\n } }

v в каждой ветке имеет конкретный тип, а не интерфейсный — компилятор это знает и позволяет обращаться к методам без дополнительных assertion.


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

Вызов метода через интерфейс дороже прямого вызова:

  1. Загружаем itab из iface
  2. Ищем нужный метод в таблице fun
  3. Загружаем data
  4. Вызываем

Это называется dynamic dispatch — диспетчеризация в рантайме. Компилятор не может заинлайнить вызов через интерфейс (в большинстве случаев), потому что не знает конкретный тип на этапе компиляции.

На практике это редко узкое место — разница в единицы наносекунд. Но в очень горячих путях (миллионы вызовов в секунду) конкретный тип вместо интерфейса может дать ощутимый прирост.

go
// Медленнее в hot path: dynamic dispatch func sumInterface(items []Summable) int { total := 0 for _, item := range items { total += item.Sum() // indirect call через itab } return total } // Быстрее: конкретный тип, компилятор может инлайнить func sumConcrete(items []MyType) int { total := 0 for _, item := range items { total += item.Sum() // direct call, возможен инлайнинг } return total }

Паттерны работы с интерфейсами

Accept interfaces, return structs

Классический Go-принцип: функции принимают интерфейсы (гибкость), возвращают конкретные типы (ясность):

go
// Хорошо: принимает io.Reader — работает с файлом, сетью, буфером, чем угодно func ParseConfig(r io.Reader) (*Config, error) { // ... } // Плохо: возвращать интерфейс — скрывает реальный тип, затрудняет использование func NewStorage() Storage { // интерфейс return &RedisStorage{} } // Хорошо: возвращать конкретный тип func NewStorage() *RedisStorage { return &RedisStorage{} }

Исключение: когда возвращаемый интерфейс — часть публичного API и скрытие реализации принципиально (фабричный паттерн, моки).

Определяй интерфейс там, где используешь

В Go интерфейс лучше определять на стороне потребителя, а не производителя:

go
// Пакет storage — экспортирует конкретный тип package storage type Postgres struct{} func (p *Postgres) GetUser(id int) (*User, error) { ... } func (p *Postgres) SaveUser(u *User) error { ... } // Пакет service — определяет интерфейс под свои нужды package service // Нужен только GetUser — определяем минимальный интерфейс type UserRepository interface { GetUser(id int) (*User, error) } type UserService struct { repo UserRepository }

Это делает зависимости явными, а тестирование — тривиальным: достаточно написать mock с одним методом.

Интерфейсы для тестирования

go
// Продакшн-код зависит от интерфейса type Mailer interface { Send(to, subject, body string) error } type OrderService struct { mailer Mailer } // Мок для тестов — реализует тот же интерфейс type MockMailer struct { Sent []string } func (m *MockMailer) Send(to, subject, body string) error { m.Sent = append(m.Sent, to) return nil } func TestOrderService(t *testing.T) { mock := &MockMailer{} svc := OrderService{mailer: mock} svc.PlaceOrder(...) assert.Contains(t, mock.Sent, "customer@example.com") }

Вопросы на собеседовании

Q: Как Go определяет, что тип реализует интерфейс?
A: Structural typing — компилятор проверяет наличие всех методов интерфейса с совпадающими сигнатурами. Явного объявления (implements) не нужно. Проверка происходит в момент присвоения значения переменной интерфейсного типа.

Q: Из чего состоит интерфейсная переменная внутри?
A: Из двух машинных слов. Для непустого интерфейса — iface: указатель на itab (тип + таблица методов) и указатель на данные. Для пустого interface{}/anyeface: указатель на тип и указатель на данные, без таблицы методов.

Q: Чем iface отличается от eface?
A: eface используется для пустого интерфейса — хранит только тип и данные, таблицы методов нет. iface — для непустых интерфейсов — вместо голого типа хранит itab: структуру с типом и таблицей указателей на методы для диспетчеризации вызовов.

Q: Когда интерфейс равен nil?
A: Только когда оба поля — тип и данные — равны nil. Если тип задан, а данные nil (typed nil pointer, упакованный в интерфейс) — интерфейс не nil. Это главная ловушка: var p *MyError = nil; return p при возврате через error даст ненулевой интерфейс.

Q: Что безопаснее — одноаргументный или двухаргументный type assertion?
A: Двухаргументный: v, ok := i.(Type). Одноаргументный v := i.(Type) паникует, если тип не совпал. В продакшн-коде почти всегда нужна форма с ok, кроме случаев, когда несоответствие типа — программная ошибка, а не ожидаемая ситуация.

Q: В чём разница между type assertion и type switch?
A: Type assertion проверяет один конкретный тип: v, ok := i.(Type). Type switch обрабатывает несколько вариантов последовательно, и в каждой ветке v имеет конкретный тип — компилятор это знает. Type switch предпочтительнее цепочки assertion'ов.

Q: Почему вызов метода через интерфейс медленнее прямого вызова?
A: Dynamic dispatch: в рантайме нужно загрузить itab, найти адрес метода в таблице fun, потом вызвать. Прямой вызов известен на этапе компиляции и может быть заинлайнен. Разница — единицы наносекунд, критично только в очень горячих путях.

Q: Что означает принцип "accept interfaces, return structs"?
A: Функции принимают интерфейсы — это даёт гибкость: любой тип с нужными методами подходит. Возвращают конкретные типы — это даёт ясность: вызывающий видит реальную структуру и имеет доступ ко всем её методам, а не только к тем, что объявлены в интерфейсе.

Q: Где лучше определять интерфейс — в пакете производителя или потребителя?
A: На стороне потребителя. Это позволяет определить минимальный интерфейс под конкретные нужды, не завися от того, что экспортирует производитель. Производитель возвращает конкретный тип, а потребитель сам решает, какую абстракцию ему нужна.

Q: Как во время компиляции проверить, что тип реализует интерфейс?
A: var _ MyInterface = (*MyType)(nil) — присвоение nil-указателя нужного типа переменной интерфейсного типа. Если MyType не реализует MyInterface — ошибка компиляции. _ означает, что переменная нам не нужна, это чисто compile-time проверка.


Задачи: Интерфейс


Задача 1: Реализация интерфейса

Уровень: Лёгкая

Что проверяет: базовое понимание интерфейсов и duck typing

Условие: Дан интерфейс Shape. Реализуй два типа Circle и Rectangle, каждый реализует Shape. Напиши функцию totalArea(shapes []Shape) float64 которая возвращает суммарную площадь.

go
type Shape interface { Area() float64 Perimeter() float64 }

Примеры:

text
Circle{Radius: 5} → Area ≈ 78.54, Perimeter ≈ 31.42 Rectangle{Width: 4, Height: 6} → Area = 24, Perimeter = 20 totalArea([]Shape{Circle{5}, Rectangle{4, 6}}) ≈ 102.54

Решение:

go
package main import ( "fmt" "math" ) type Shape interface { Area() float64 Perimeter() float64 } type Circle struct{ Radius float64 } type Rectangle struct{ Width, Height float64 } func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius } func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.Radius } func (r Rectangle) Area() float64 { return r.Width * r.Height } func (r Rectangle) Perimeter() float64 { return 2 * (r.Width + r.Height) } func totalArea(shapes []Shape) float64 { total := 0.0 for _, s := range shapes { total += s.Area() } return total } func main() { shapes := []Shape{Circle{Radius: 5}, Rectangle{Width: 4, Height: 6}} fmt.Printf("%.2f\n", totalArea(shapes)) // 102.54 }

Задача 2: Ловушка nil interface

Уровень: Средняя

Что проверяет: понимание iface, typed nil, главная ловушка интерфейсов

Условие: Что выведет код? Объясни каждый результат.

go
package main import "fmt" type MyError struct{ msg string } func (e *MyError) Error() string { return e.msg } func getError(fail bool) error { var err *MyError if fail { err = &MyError{"something went wrong"} } return err } func getErrorFixed(fail bool) error { if fail { return &MyError{"something went wrong"} } return nil } func main() { err1 := getError(false) err2 := getError(true) err3 := getErrorFixed(false) fmt.Println(err1 == nil) fmt.Println(err2 == nil) fmt.Println(err3 == nil) fmt.Println(err1) }

Ожидаемый ответ:

text
false false true <nil>

Решение:

go
// err1: getError(false) возвращает var err *MyError = nil. // При возврате через interface error создаётся iface: // tab=*MyError, data=nil. // Интерфейс НЕ nil — тип задан. // err1 == nil → false. Печатает <nil> потому что данные nil. // err2: аналогично, но data указывает на MyError. // err2 == nil → false. // err3: getErrorFixed возвращает nil напрямую — // оба поля iface (tab и data) равны nil. // err3 == nil → true. // Вывод: никогда не возвращай typed nil через интерфейс. // Всегда возвращай nil напрямую.

Задача 3: Цепочка middleware через интерфейс

Уровень: Сложная

Что проверяет: практическое применение интерфейсов, паттерн декоратор

Условие: Реализуй систему middleware для обработки сообщений. Есть интерфейс Processor. Реализуй три обёртки: LoggingProcessor (логирует сообщение до и после), UpperCaseProcessor (переводит в верхний регистр) и TrimProcessor (убирает пробелы). Обёртки должны комбинироваться в цепочку.

go
type Processor interface { Process(msg string) string }

Примеры:

text
base := &BaseProcessor{} chain := NewLogging(NewUpperCase(NewTrim(base))) chain.Process(" hello world ") → [LOG] processing: " hello world " → [LOG] result: "HELLO WORLD" → "HELLO WORLD"

Решение:

go
package main import ( "fmt" "strings" ) type Processor interface { Process(msg string) string } // Базовый процессор — возвращает сообщение как есть type BaseProcessor struct{} func (b *BaseProcessor) Process(msg string) string { return msg } // Trim обёртка type TrimProcessor struct{ next Processor } func NewTrim(next Processor) *TrimProcessor { return &TrimProcessor{next: next} } func (t *TrimProcessor) Process(msg string) string { return t.next.Process(strings.TrimSpace(msg)) } // UpperCase обёртка type UpperCaseProcessor struct{ next Processor } func NewUpperCase(next Processor) *UpperCaseProcessor { return &UpperCaseProcessor{next: next} } func (u *UpperCaseProcessor) Process(msg string) string { return u.next.Process(strings.ToUpper(msg)) } // Logging обёртка type LoggingProcessor struct{ next Processor } func NewLogging(next Processor) *LoggingProcessor { return &LoggingProcessor{next: next} } func (l *LoggingProcessor) Process(msg string) string { fmt.Printf("[LOG] processing: %q\n", msg) result := l.next.Process(msg) fmt.Printf("[LOG] result: %q\n", result) return result } func main() { base := &BaseProcessor{} chain := NewLogging(NewUpperCase(NewTrim(base))) result := chain.Process(" hello world ") fmt.Println(result) }