В Java класс рапортует о себе сам: implements Reader, и список родителей зашит в объявление. В Go такого слова нет. Тип реализует интерфейс, если у него есть методы с подходящими сигнатурами, точка. Компилятор сверит набор методов, но не проверит ни одного обещания о поведении: блокирует ли вызов, что возвращается при пустом входе, можно ли звать из двух горутин сразу. Гибкость неявности оплачивается дисциплиной документирования, и это главная сделка языка. Разберём устройство механизма, его сильные стороны и цену, которую платят за молчаливое согласие типов.

Как работает неявная реализация интерфейсов в Go

Определение io.Reader умещается в четыре строки:

type Reader interface {
	Read(p []byte) (n int, err error)
}

Любой тип с методом Read такой же сигнатуры подходит: os.File, bytes.Buffer, обёртка над сетевым соединением. Ни один из них не объявляет "я реализую io.Reader". Зависимость направлена наоборот: пакет io ничего не знает ни про os, ни про bytes, а они тем не менее работают с любой функцией, ждущей io.Reader. Новая реализация в другом пакете удовлетворит интерфейс без правки исходного интерфейса.

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

Проверка идёт в точке использования: тип без нужного метода уронит компиляцию присваивания или вызова. Для явной страховки живёт идиома нулевой стоимости:

var _ io.Reader = (*LogFile)(nil)

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

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

type LogFile struct{ path string }

func (l *LogFile) Read(p []byte) (int, error) { return 0, nil } // получатель *LogFile

var _ io.Reader = LogFile{}  // ошибка компиляции
var _ io.Reader = &LogFile{} // корректно

Значение LogFile не имеет метода Read, его имеет только указатель. Обратное работает: получатель-значение входит в оба набора.

Вторая витрина неявности - fmt.Stringer:

type Stringer interface {
	String() string
}

Пакет fmt ищет метод String у каждого аргумента и печатает его, если нашёлся. Тип из доменной модели со своим String() меняет вид во всех логах и сообщениях сразу, хотя fmt про этот тип никогда не слышал.

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

Интерфейсы вкладываются друг в друга без ограничений:

type ReadWriter interface {
	Reader
	Writer
}

В набор ReadWriter входят оба метода, а io.ReadWriteCloser добавляет ещё Close. Стандартная библиотека собрана из горстки мелких блоков: io.Reader, io.Writer, io.Closer, fmt.Stringer, error. Сортировка работает ровно так же:

type Interface interface {
	Len() int
	Less(i, j int) bool
	Swap(i, j int)
}

Три метода вместо целой иерархии компараторов:

type byLength []string

func (s byLength) Len() int           { return len(s) }
func (s byLength) Less(i, j int) bool { return len(s[i]) < len(s[j]) }
func (s byLength) Swap(i, j int)      { s[i], s[j] = s[j], s[i] }

sort.Sort(byLength(fruits))

Срез строк не просил быть сортируемым, но методы нашлись. Мелкие блоки складываются в крупные: функция, умеющая читать из io.Reader, работает с файлом, сетью, архивом и тестовой строкой одновременно. Библиотека докручивает композицию до предела: io.NopCloser оборачивает голый Reader в ReadCloser с пустым Close, io.TeeReader дублирует поток в лог, io.LimitReader режет объём. Три обёртки собираются в цепочку и дают считывание первых мегабайтов файла с параллельным журналированием, и ни одна строчка стандартных пакетов при этом не изменилась.

Принцип потребителя и узкие интерфейсы у места использования

Идиома звучит так: функции принимают интерфейсы, возвращают конкретные типы. io.Copy(dst io.Writer, src io.Reader) годится для чего угодно именно потому, что требует минимум. Правило потребителя доводит идею до предела: интерфейс объявляет та сторона, которая им пользуется, а не та, которая его выполняет:

type userGetter interface {
	GetUser(ctx context.Context, id string) (*User, error)
}

func BuildProfile(ctx context.Context, db userGetter, id string) (*Profile, error) {
	u, err := db.GetUser(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("get user: %w", err)
	}
	return profileFrom(u), nil
}

Пакет профилей объявил ровно то, что ему нужно: один метод. Подойдёт слой PostgreSQL, кэш с подгрузкой, заглушка из теста. Если завтра хранилище сменит драйвер, код BuildProfile не изменится ни на символ. Узкий интерфейс слабо связывает пакеты, широкий - тащит за собой ненужные методы и делает заглушки громоздкими.

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

type logReader struct {
	r io.Reader
}

func (l logReader) Read(p []byte) (n int, err error) {
	n, err = l.r.Read(p)
	slog.Info("read", "bytes", n, "err", err)
	return n, err
}

Функция, ждущая io.Reader, примет обёртку, не заметив подмены: декоратор просто имеет нужный метод. Явные иерархии потребовали бы отдельного класса-наследника и точки внедрения, тут хватило восьми строк.

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

Цена неявности и правила поведения которые компилятор не проверит

Всё, что проверяет компилятор, - имена и сигнатуры. Всё остальное живёт в прозе документации, и контракт io.Reader хранит страницу правил на четыре строки интерфейса:
1. Read возвращает до len(p) байтов и может использовать весь буфер как черновик;
2. при ошибке после чтения n > 0 байтов функция обязана вернуть n, а ошибку выдать тем же вызовом или следующим;
3. вызывающий сперва обрабатывает прочитанные байты и только потом смотрит на ошибку;
4. возврат нуля байтов при пустой ошибке не означает конец файла;
5. реализация не сохраняет срез p после возврата.

Ни одно из правил не видно в типе. Read, который блокируется навсегда, возвращает выдуманный n или прячет буфер на потом, проходит компиляцию без единого замечания. Именно поэтому документирование для Go не ритуал: описание поведения метода - часть контракта наравне с сигнатурой. Собственный интерфейс принято сразу снабжать прозаическим договором:

// BatchStore persist batches between calls.
// Implementations must be safe for concurrent use.
// Store returns the number of saved records;
// a nil batch is an error, an empty batch saves nothing.
type BatchStore interface {
	Store(ctx context.Context, batch []Record) (int, error)
}

Проверка var _ защитит от пропавшего метода, но не от молчащего: вторую половину контракта держит только текст и ревью. Хороший тест дополняет договор: юнит-кейсы на пустой вход, конкурентный прогон под -race и проверка документированных граничных условий закрывают то, что компилятор не увидит никогда.

Устройство интерфейсного значения в рантайме и цена диспетчеризации

Интерфейсное значение - пара из динамического типа и указателя на данные. Присваивание конкретного значения упаковывает оба поля, а вызов метода идёт через таблицу itab: рантайм кэширует таблицу методов для каждой пары "конкретный тип, интерфейс". Цена проявляется не в поиске таблицы, а в непрямом вызове: компилятор не может встроить тело метода, как он делает это для статического вызова, поэтому в самых горячих циклах конкретный тип иногда выигрывает.

Разбор значения идёт через утверждение типа. Одиночная проверка идёт с формой ok:

if f, ok := r.(*os.File); ok {
	_ = f.Name()
}

Множественный выбор лаконичнее через switch:

switch v := r.(type) {
case *bytes.Buffer:
	_ = v.Bytes()
case *os.File:
	_ = v.Name()
default:
	_ = v
}

Ловушка прячется в nil: интерфейс с типизированным nil-указателем внутри не равен nil, ведь поле типа не пусто. Поэтому ошибкой считается возвращать указатель-заготовку вместо честного nil.

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

С Go 1.18 пустой интерфейс получил короткое имя any, но пословица Go осталась верна исходнику: interface{} не говорит ни о чём. Значение any прогоняется через рефлексию, и каждая проверка переносится из компиляции в рантайм. Пустой интерфейс уместен в JSON-распаковке map[string]any и безликих конфигах, но в обычных сигнатурах конкретный тип или честный интерфейс с методом честнее.

Интерфейсы как основа лёгких заглушек в тестах

Неявная реализация делает заглушку тривиальной: подделке не нужно наследоваться и регистрироваться, у неё просто есть нужный метод:

type fakeGetter struct {
	user *User
	err  error
}

func (f fakeGetter) GetUser(ctx context.Context, id string) (*User, error) {
	return f.user, f.err
}

func TestBuildProfile(t *testing.T) {
	ctx := context.Background()
	p, err := BuildProfile(ctx, fakeGetter{user: &User{ID: "42"}}, "42")
	if err != nil || p.ID != "42" {
		t.Fatalf("profile broken: %v, %v", p, err)
	}
}

Продакшен-код не узнал о подмене: он и не должен был. Ошибочные пути проверяет та же заглушка с полем err:

func TestBuildProfileDBError(t *testing.T) {
	_, err := BuildProfile(context.Background(),
		fakeGetter{err: errors.New("connection refused")}, "42")
	if err == nil {
		t.Fatal("want error, got nil")
	}
}

Сравните с миром явных объявлений, где заглушка обязана унаследоваться от базового класса и переопределить метод с точным именем. В Go мок-фреймворки остаются опцией для толстых RPC-слоёв, а не обязательным слоем инфраструктуры.

Сетевые тесты живут по соседству: httptest.NewServer поднимает настоящий HTTP-сервер на локальном порту и отдаёт *httptest.Server с полем-клиентом. Код, работающий через http.Client с полями-интерфейсами RoundTripper, тестируется без фокусов: транспортом становится и живой сервер, и заглушка, отдающая записанный ответ.

Дженерики после Go 1.18 и антипаттерны интерфейсного проектирования

Дженерики не отменили интерфейсы: сами ограничения, такие как comparable, являются интерфейсами с типовыми параметрами:

func Max[T constraints.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

Разделение простое: интерфейс описывает поведение (у чего есть Read), дженерик - форму данных (что угодно сравнимое). Оба инструмента дружат, а не спорят.

Практический тест для параметра функции тоже короткий. Нужен ли типу выбор между реализациями в тестах и в эксплуатации? Нужен - объявляйте интерфейс из одного-двух методов прямо у места использования. Не нужен - берите конкретный тип: он быстрее, честнее документируется и не плодит лишний файл. Половина плохих интерфейсов в проектах существует потому, что автор не задал себе этот вопрос.

Антипаттерны стары как мир. Интерфейс при единственной реализации "на будущее" - это Java-наследие, которое тянет за собой лишний файл и ноль выгоды: вторая реализация рождает интерфейс, первая не повод. Гигантский интерфейс на двадцать методов связывает всех со всеми, и пословица Go отвечает прямо: чем больше интерфейс, тем слабее абстракция. Интерфейс ради мока - запах: сначала спросите, зачем тесту подделывать то, что не менялось.

Утиная типизация даёт роскошь задним числом: новый тип встраивается в старый каркас, старый код принимает новые реализации без перекомпиляции контракта. Плата известна: форма метода проверена компилятором, смысл - только текстом и репутацией автора. Компилятор держит сигнатуры, инженер держит поведение, и половина качества Go-кода рождается на пересечении этих обязанностей.