Интерфейсы и неявная реализация

На собеседовании спросят: «Как Go понимает, что тип реализует интерфейс, если нигде не написано implements?»

Duck typing (утиная типизация) — если объект умеет делать то, что требует интерфейс, он ему подходит. Никаких деклараций не нужно: «если крякает как утка — значит утка».

Что выведет этот код?

Начнём с примера. Вот интерфейс и тип, который его реализует:

type Stringer interface {
	String() string
}

type Cat struct {
	Name string
}

func (c Cat) String() string {
	return "Кот по имени " + c.Name
}

func Describe(s Stringer) {
	fmt.Println(s.String())
}

func main() {
	c := Cat{Name: "Барсик"}
	Describe(c)
}

Вывод:

Кот по имени Барсик

Обратите внимание: нигде не написано Cat implements Stringer или Cat : Stringer. Мы просто определили у структуры Cat метод String() string — ровно такой, какой требует интерфейс Stringer. И этого достаточно, чтобы передать Cat в функцию Describe, которая принимает интерфейс.

Как это работает под капотом

В языках вроде Java или C# принадлежность к интерфейсу — это явное решение, зафиксированное в объявлении класса: class Cat implements Stringer. Компилятор смотрит на эту декларацию и проверяет её.

В Go всё наоборот. Компилятор не смотрит на декларации — он смотрит на набор методов у типа. Если у типа T есть все методы, перечисленные в интерфейсе I (с точно такой же сигнатурой), то значение типа T можно использовать там, где ожидается I. Это называется структурной типизацией: важна не «родословная» типа, а его форма — какие у него есть методы.

Отсюда и разговорное название — duck typing: «если оно крякает как утка и плавает как утка, то это утка», даже если нигде не написано «это утка». Проверка происходит на этапе компиляции, поэтому ошибку вы увидите сразу, а не во время выполнения программы, как в Python.

Есть удобный приём, которым пользуются на практике, чтобы явно зафиксировать «этот тип обязан реализовывать вот этот интерфейс» — даже если сейчас нигде в коде эта связь не используется:

var _ Stringer = Cat{}

Эта строка ничего не делает во время выполнения — переменная _ отбрасывается. Но если в какой-то момент вы уберёте у Cat метод String() или измените его сигнатуру, компиляция сломается прямо на этой строке с понятной ошибкой. Это своего рода тест на совместимость, встроенный в компилятор.

Как проверить, реализует ли тип интерфейс

Есть три способа понять, подходит ли тип под интерфейс:

  • Прочитать методы типа. Откройте объявление структуры и посмотрите, какие методы у неё определены — сравните с методами интерфейса.
  • Дать компилятору проверить это за вас — приём с var _ Interface = Type{}, показанный выше.
  • Проверить в рантайме через type assertion — если у вас уже есть значение интерфейсного типа, а не конкретного, можно спросить: «а этот конкретный объект реализует вот такой более узкий интерфейс?» — но об этом подробнее в следующих уроках раздела.

Главная ловушка: nil внутри интерфейса — это не nil

А теперь тот самый вопрос, который любят задавать на собеседованиях именно по этой теме. Что выведет вот такой код?

type Animal interface {
	Sound() string
}

type Dog struct{}

func (d *Dog) Sound() string {
	if d == nil {
		return "..."
	}
	return "Гав!"
}

func GetAnimal() Animal {
	var d *Dog // d == nil, но это указатель конкретного типа *Dog
	return d
}

func main() {
	a := GetAnimal()
	if a == nil {
		fmt.Println("Животного нет")
	} else {
		fmt.Println("Животное есть:", a.Sound())
	}
}

Вывод:

Животное есть: ...

Большинство людей интуитивно ожидают увидеть «Животного нет» — ведь d действительно равен nil. Но проверка a == nil возвращает false. Почему?

Дело в том, что интерфейсное значение в Go — это на самом деле пара из двух частей: (тип, значение). Когда мы возвращаем d (указатель типа *Dog, равный nil) как значение интерфейса Animal, пара получается такой: (*Dog, nil). Тип не пустой — он равен *Dog! А интерфейс равен nil только тогда, когда пуста ОБЕ части пары: и тип, и значение. Поэтому a == nil — ложь: у интерфейса есть конкретный тип, просто внутри лежит нулевое значение этого типа.

Частые ошибки на собеседовании

  • Путают структурную и номинативную типизацию. Говорят «Cat implements Stringer», хотя в Go такого синтаксиса нет вообще — реализация выводится автоматически по набору методов.
  • Не понимают ловушку nil-интерфейса. Функция, которая возвращает интерфейс с типизированным nil-указателем внутри, — частая причина трудноуловимых багов в реальном коде, особенно при обработке ошибок через error.
  • Забывают, что методы с получателем-указателем (*Dog) и получателем-значением (Dog) реализуют интерфейс по-разному. Если метод определён на *Dog, то интерфейсу удовлетворяет именно *Dog, а не Dog — это отдельная частая путаница, к которой мы ещё вернёмся.

Итоги

  • В Go нет ключевого слова implements — принадлежность типа интерфейсу определяется автоматически по набору методов (структурная типизация, «duck typing»).
  • Приём var _ Interface = Type{} — способ явно зафиксировать соответствие и поймать несоответствие ещё на этапе компиляции.
  • Значение интерфейса — это пара (тип, значение). Интерфейс равен nil, только если nil обе части пары — отсюда классическая ловушка с типизированным nil-указателем внутри интерфейса.
Проверьте себя
1. Как компилятор Go определяет, что тип реализует интерфейс?
AПо явному указанию implements в объявлении типа
BПо совпадению набора методов типа с методами интерфейса (структурная типизация)
CПо имени типа, совпадающему с именем интерфейса
DТолько если тип и интерфейс объявлены в одном файле
2. Функция возвращает интерфейс, внутри которого лежит nil-указатель конкретного типа (например, *Dog). Чему будет равно сравнение этого интерфейса с nil?
Atrue, потому что значение внутри равно nil
Bfalse, потому что у интерфейса есть непустой тип, даже если значение nil
CПроизойдёт panic при сравнении
DЭто не скомпилируется