Перейти к основному содержимому

Интерфейсы

1. Что такое интерфейс?

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

Главная фишка Go — Duck Typing (Утиная типизация). В Go нет ключевого слова implements (как в Java или PHP). Тип реализует интерфейс неявно. Если структура имеет все методы, перечисленные в интерфейсе, значит, она автоматически реализует этот интерфейс. "Если птица ходит как утка и крякает как утка, то это утка."

type Speaker interface {
Speak() string
}

type Dog struct{}

// Dog неявно реализует интерфейс Speaker
func (d Dog) Speak() string {
return "Woof!"
}

2. Устройство интерфейса "под капотом"

Это любимый вопрос на Middle-позиции. В памяти переменная типа "интерфейс" занимает 16 байт (на 64-битных системах) и представляет собой структуру из двух указателей (по 8 байт каждый).

В исходниках Go (в рантайме) интерфейсы делятся на два типа: eface (пустой интерфейс) и iface (интерфейс с методами).

1. iface (Интерфейс с методами)

Когда мы используем интерфейс, в котором есть хотя бы один метод (например, error или io.Reader), под капотом создается структура iface:

  1. tab (указатель на itab): Это таблица интерфейса. Она хранит информацию о:
    • Конкретном типе данных, который сейчас лежит в интерфейсе (например, *Dog).
    • Самостоятельном типе интерфейса (например, Speaker).
    • Списке указателей на конкретные реализации методов (таблица виртуальных методов). Именно благодаря этому Go знает, какую функцию вызвать при обращении i.Speak().
  2. data (указатель на данные): Это указатель на саму копию данных (или на оригинал, если в интерфейс передали указатель), например, на экземпляр структуры Dog.

2. eface (Пустой интерфейс interface{} или any)

Пустой интерфейс не имеет методов, поэтому ему не нужна сложная таблица itab для вызова функций. Под капотом это структура eface:

  1. _type: Указатель на информацию о типе значения, которое в данный момент хранится в интерфейсе.
  2. data: Указатель на сами данные.

3. Главная ловушка на собеседовании: nil-интерфейс

Из-за того, что интерфейс состоит из двух указателей (tab и data), возникает самое популярное неочевидное поведение в Go.

Правило: Интерфейс равен nil только тогда, когда оба его внутренних указателя (tab и data) равны nil.

Посмотри на этот код (его часто дают в виде задачки):

func main() {
var p *bytes.Buffer = nil // Обычный типизированный указатель (равен nil)
var i io.Writer = p // Кладем типизированный nil в интерфейс

if i == nil {
fmt.Println("i is nil")
} else {
fmt.Println("i is NOT nil")
}
}

Ответ: Выведется i is NOT nil!

Почему так происходит? Когда мы присваиваем p в i, интерфейс заполняет свои внутренние поля:

  • tab (тип) = *bytes.Buffer (тип известен!)
  • data (значение) = nil Так как поле tab не равно nil, весь интерфейс i больше не равен nil.

Как чинить? Никогда не возвращай конкретный типизированный nil из функции, которая возвращает интерфейс. Возвращай просто nil.

// ПЛОХО: Вернет не-nil интерфейс, если произошла ошибка
func DoSomething() error {
var customErr *MyError = nil
return customErr
}

// ХОРОШО:
func DoSomething() error {
return nil
}

4. Зачем нужен пустой интерфейс (any)?

До версии Go 1.18 везде использовался interface{}. Начиная с 1.18 добавили алиас any. Это одно и то же.

Так как пустой интерфейс не требует реализации никаких методов, абсолютно любой тип в Go реализует пустой интерфейс. Он используется, когда функция должна принимать данные неизвестного заранее типа (как функция fmt.Println, которая принимает ...any).

Но работать с пустым интерфейсом напрямую нельзя (мы не знаем, что внутри). Чтобы достать значение, используют:

  1. Type Assertion (Утверждение типа): val, ok := i.(string)
  2. Type Switch:
    switch v := i.(type) {
    case int:
    fmt.Println("Это число", v)
    case string:
    fmt.Println("Это строка", v)
    }

Резюме

  • Интерфейс — это контракт на поведение, реализуется неявно (Duck Typing).
  • В памяти это 16 байт: указатель на тип/методы (itab) и указатель на данные (data).
  • Интерфейс == nil только если и тип, и данные nil. Типизированный nil внутри интерфейса делает его не равным nil.
  • Динамическая диспетчеризация (вызов нужного метода) происходит в рантайме через itab. Это чуть медленнее, чем прямой вызов метода структуры, поэтому не стоит делать интерфейсы там, где они не нужны для полиморфизма.