Go: полный курс — от основ до современного языка · Глава 8 из 12

Глава 8. Интерфейсы

Прогресс сохранится в этом браузере (войдите, чтобы синхронизировать).

Введение

В прошлой главе ты научился описывать данные структурами и вешать на них поведение методами. Но что, если разным типам нужно вести себя одинаково с точки зрения того, кто их использует? Круг и квадрат — разные структуры, но оба умеют считать площадь. Файл, сетевое соединение и буфер в памяти — совершенно разные вещи, но в каждый можно «писать байты». Как написать одну функцию, которая работает со всеми ними, не зная их конкретных типов? Ответ — интерфейсы. Интерфейс в Go описывает не то, чем объект является, а то, что он умеет делать. Это, пожалуй, самая элегантная часть языка, и именно интерфейсы делают Go-код гибким, тестируемым и расширяемым. В этой главе разберём, как интерфейсы устроены, почему их реализация «неявная», как получить полиморфизм, что такое пустой интерфейс и any, как безопасно извлекать конкретный тип через type assertion и type switch, и познакомимся с ключевыми интерфейсами стандартной библиотеки.

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

Интерфейс — это тип, который описывает набор методов, не давая им реализации. Он говорит: «любое значение, у которого есть вот эти методы, годится сюда». Сам интерфейс ничего не делает — он лишь контракт поведения. Конкретные типы (структуры) этот контракт выполняют, реализуя нужные методы.

// Интерфейс shape требует один метод: area() float64.
// Никакой реализации здесь нет — только описание поведения.
type shape interface {
    area() float64
}

Читается так: «тип shape — это что угодно, что умеет вычислять свою площадь». Это абстракция: имея значение типа shape, ты знаешь только, что можешь позвать .area(), но не знаешь и не должен знать, круг это, квадрат или что-то ещё.

Неявная реализация — ключевая идея Go

В большинстве языков (Java, C#) тип обязан явно объявить: «я реализую интерфейс Shape» через implements. В Go такого объявления нет. Тип реализует интерфейс автоматически, просто имея нужные методы. Это называется структурной или неявной реализацией.

type circle struct {
    radius float64
}

type square struct {
    side float64
}

// У circle есть метод area() float64 — значит, circle УЖЕ реализует shape.
// Мы нигде не писали "circle implements shape". Оно вывелось само.
func (c circle) area() float64 {
    return 3.14159 * c.radius * c.radius
}

func (s square) area() float64 {
    return s.side * s.side
}

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

Полиморфизм: одна функция для разных типов

Теперь главная выгода. Функция, принимающая интерфейс, работает с любым типом, который его реализует. Это и есть полиморфизм — «много форм» за одним контрактом.

// info принимает ЛЮБОЙ shape — и circle, и square, и что угодно с area().
func info(z shape) {
    fmt.Printf("%T имеет площадь %.2f
", z, z.area())
}

// Вариативная функция суммирует площади любых фигур.
func totalArea(shapes ...shape) float64 {
    var sum float64
    for _, s := range shapes {
        sum += s.area()
    }
    return sum
}

func main() {
    c := circle{radius: 5}
    s := square{side: 10}
    info(c) // main.circle имеет площадь 78.54
    info(s) // main.square имеет площадь 100.00
    fmt.Println("Итого:", totalArea(c, s)) // Итого: 178.53981...
}

Функция info написана один раз и работает со всеми фигурами, которые появятся в будущем. Добавишь тип triangle с методом area() — он тут же станет пригоден для info и totalArea без единой правки в них. Это открытость к расширению без изменения существующего кода.

Интерфейсное значение: тип + значение

Полезно понимать, как интерфейс устроен внутри. Значение интерфейсного типа хранит две вещи: динамический тип (какой конкретный тип там лежит) и динамическое значение (само значение этого типа). Когда ты зовёшь метод на интерфейсе, Go смотрит на динамический тип и вызывает его реализацию.

var z shape      // пока внутри: тип nil, значение nil
z = circle{5}    // теперь: тип circle, значение circle{5}
fmt.Printf("%T
", z) // main.circle — печатается динамический тип

Пустой интерфейс и any

Интерфейс без методов — interface{} — не требует ничего. А значит, ему удовлетворяет любой тип. Это «коробка, в которую влезет что угодно». В Go 1.18 появился псевдоним any, полностью эквивалентный interface{}, но читающийся приятнее. Используй any в новом коде.

// any == interface{} — принимает значение любого типа.
func describe(v any) {
    fmt.Printf("значение %v имеет тип %T
", v, v)
}

func main() {
    describe(42)         // значение 42 имеет тип int
    describe("привет")   // значение привет имеет тип string
    describe(true)       // значение true имеет тип bool

    // Слайс разнородных значений — тоже через any.
    mix := []any{1, "два", 3.0, false}
    fmt.Println(mix) // [1 два 3 false]
}

Важное предостережение: any выключает проверки типов. Ты теряешь всё, ради чего существует статическая типизация. Пустой интерфейс — крайняя мера (обобщённые контейнеры, декодирование произвольного JSON, логирование). В большинстве случаев лучше конкретный тип или дженерики (о них — в следующих главах).

Type assertion: достаём конкретный тип обратно

Спрятав значение в интерфейс, ты иногда хочешь достать его конкретный тип назад. Для этого есть type assertion — «утверждение о типе». Синтаксис: x.(T). Есть две формы, и вторая — безопасная.

var v any = "Сидней"

// Опасная форма: если тип не совпадёт — паника (аварийная остановка).
s := v.(string)
fmt.Println(s) // Сидней

// Безопасная форма с двумя значениями: ok сообщает об успехе.
// НИКОГДА не паникует. Это идиома "comma, ok".
s, ok := v.(string)
if ok {
    fmt.Println("это строка:", s)
}

n, ok := v.(int)
if !ok {
    fmt.Println("это не int, n ==", n) // n == 0 (нулевое значение)
}

Всегда предпочитай форму v, ok := x.(T). Она возвращает второе булево значение вместо аварийной паники, и твоя программа остаётся под контролем. Форму без ok используй только когда абсолютно уверен в типе.

Type switch: разбор по многим типам

Если вариантов типа несколько, вместо цепочки assertion удобнее type switch — специальная форма switch, которая ветвится по динамическому типу.

func describe(v any) string {
    // v := v.(type) работает только внутри switch.
    switch x := v.(type) {
    case int:
        return fmt.Sprintf("целое число %d, удвоенное = %d", x, x*2)
    case string:
        return fmt.Sprintf("строка длиной %d", len(x))
    case bool:
        return fmt.Sprintf("булево значение %t", x)
    case nil:
        return "ничего (nil)"
    default:
        return fmt.Sprintf("неизвестный тип %T", x)
    }
}

func main() {
    fmt.Println(describe(21))       // целое число 21, удвоенное = 42
    fmt.Println(describe("Go"))     // строка длиной 2
    fmt.Println(describe(3.14))     // неизвестный тип float64
}

Внутри каждой case переменная x уже имеет соответствующий конкретный тип, так что с ней можно работать напрямую. Type switch — стандартный способ обрабатывать значения из any, например при разборе динамического JSON.

Идиома: принимай интерфейсы, возвращай структуры

Одно из самых цитируемых правил Go: «Accept interfaces, return structs». Функции стоит принимать интерфейсы (максимальная гибкость для вызывающего — можно подсунуть любую реализацию, включая моки в тестах), но возвращать конкретные структуры (вызывающий получает полный, честный тип со всеми полями и методами).

// Плохо: принимаем конкретный тип — жёстко привязались к нему.
// func save(f *os.File) { ... }

// Хорошо: принимаем интерфейс — сгодится файл, буфер, сетевое соединение, мок.
func save(w io.Writer, data string) (int, error) {
    return w.Write([]byte(data))
}

// Возвращаем конкретную структуру, а не интерфейс —
// вызывающий сам решит, к какому интерфейсу её привести.
func newBuffer() *bytes.Buffer {
    return &bytes.Buffer{}
}

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

Stringer: как объект печатает себя

Стандартная библиотека полна маленьких интерфейсов с одним методом. Самый известный — fmt.Stringer: тип, у которого есть метод String() string, сам управляет тем, как его печатает fmt.Println.

type point struct {
    X, Y int
}

// Реализуем fmt.Stringer — метод String() string.
// Теперь fmt печатает point по-нашему, а не как {X Y}.
func (p point) String() string {
    return fmt.Sprintf("(%d, %d)", p.X, p.Y)
}

func main() {
    p := point{3, 4}
    fmt.Println(p) // (3, 4) — сработал наш String()
}

Это тот же полиморфизм: fmt внутри проверяет, реализует ли значение Stringer, и если да — зовёт твой String(). Реализовав один маленький интерфейс, ты аккуратно вписался в готовую инфраструктуру пакета.

io.Writer и io.Reader — интерфейсы, на которых держится Go

Два самых важных интерфейса стандартной библиотеки. io.Writer — «куда можно писать байты» (метод Write), io.Reader — «откуда можно читать байты» (метод Read). Их реализуют файлы, сетевые соединения, буферы, HTTP-тела — десятки типов. Благодаря этому одна функция работает с любым источником или приёмником данных.

package main

import (
    "fmt"
    "io"
    "os"
    "strings"
)

func main() {
    // strings.NewReader даёт io.Reader поверх строки.
    src := strings.NewReader("Живи настоящим моментом.
")

    // os.Stdout реализует io.Writer.
    // io.Copy умеет копировать из ЛЮБОГО Reader в ЛЮБОЙ Writer —
    // ей всё равно, что там конкретно: файл, сеть или строка.
    if _, err := io.Copy(os.Stdout, src); err != nil {
        fmt.Println("ошибка копирования:", err)
    }
}

Одна функция io.Copy закрывает бесконечное множество сценариев: скопировать файл в файл, тело HTTP-ответа на диск, строку в сетевое соединение — потому что она говорит на языке интерфейсов, а не конкретных типов. Это вершина того, ради чего интерфейсы существуют.

Встраивание интерфейсов

Интерфейсы, как и структуры, можно составлять из кусочков. Один интерфейс встраивается в другой, и результат требует все методы обоих. Классический пример из стандартной библиотеки — io.ReadWriter, который просто объединяет Reader и Writer.

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

type Writer interface {
    Write(p []byte) (n int, err error)
}

// ReadWriter встраивает оба интерфейса — требует и Read, и Write.
type ReadWriter interface {
    Reader
    Writer
}

Так из маленьких интерфейсов собираются большие, не дублируя объявления методов. Тип, у которого есть и Read, и Write (например, *bytes.Buffer), автоматически удовлетворяет ReadWriter — снова благодаря неявной реализации.

Интерфейс error — самый важный в языке

Ты уже видел error в сигнатурах. Так вот, error — это обычный интерфейс с одним методом Error() string. Ничего волшебного: любой тип с методом Error() становится ошибкой. Это позволяет создавать собственные типы ошибок, несущие дополнительные данные.

// Встроенное определение из стандартной библиотеки:
// type error interface { Error() string }

// Свой тип ошибки со структурированными полями.
type ValidationError struct {
    Field string
    Msg   string
}

// Реализуем error — метод Error() string.
func (e ValidationError) Error() string {
    return fmt.Sprintf("поле %q: %s", e.Field, e.Msg)
}

func validate(age int) error {
    if age < 0 {
        return ValidationError{Field: "age", Msg: "не может быть отрицательным"}
    }
    return nil
}

func main() {
    if err := validate(-5); err != nil {
        fmt.Println(err) // поле "age": не может быть отрицательным
    }
}

Поскольку error — интерфейс, вызывающий код может через type assertion достать конкретный тип ошибки и посмотреть её поля (err.(ValidationError).Field). Обработку ошибок мы подробно разберём в следующих главах, но фундамент — вот он: ошибка это просто интерфейс.

Ловушка nil-интерфейса

Одна из самых коварных тонкостей Go. Интерфейсное значение равно nil, только если и тип, и значение внутри — nil. Если ты положишь в интерфейс nil-указатель конкретного типа, интерфейс перестанет быть nil, потому что его динамический тип уже не пустой. Это регулярно ломает проверки ошибок.

type myError struct{}

func (e *myError) Error() string { return "что-то пошло не так" }

// ОПАСНАЯ функция: возвращает интерфейс error,
// но кладёт в него типизированный nil-указатель.
func doWork() error {
    var e *myError = nil
    return e // тип *myError, значение nil -> интерфейс НЕ nil!
}

func main() {
    err := doWork()
    if err != nil {
        // Мы попадём СЮДА, хотя "ошибки" по сути нет.
        fmt.Println("неожиданно не nil:", err == nil) // false
    }
}

Мораль: не возвращай типизированный nil-указатель как error. Если ошибки нет — возвращай литеральный nil (return nil), а не переменную-указатель со значением nil. Эта ловушка стоила многим часов отладки.

sort.Interface: полиморфизм в действии

Отличный пример того, как маленький интерфейс даёт огромную гибкость. Пакет sort умеет сортировать что угодно, что реализует три метода: Len, Less и Swap. Сама функция сортировки написана один раз и работает с любым типом, описавшим порядок через эти методы.

import "sort"

type byAge []user // срез структур, который мы научим сортироваться

func (a byAge) Len() int           { return len(a) }
func (a byAge) Less(i, j int) bool { return a[i].Age < a[j].Age }
func (a byAge) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }

func main() {
    users := byAge{{"Анна", 30}, {"Борис", 25}, {"Вера", 40}}
    sort.Sort(users) // sort.Sort принимает интерфейс sort.Interface
    fmt.Println(users) // отсортировано по возрасту
}

Ты не переписываешь алгоритм сортировки — ты лишь объясняешь пакету sort, как сравнивать твои элементы, реализовав контракт. Это ровно та же идея, что и io.Writer: библиотека говорит на языке интерфейса, а ты подставляешь свою реализацию.

Кейс из реального проекта

Ты пишешь систему уведомлений. Уведомление можно отправить по-разному: письмом, в SMS, в Slack. Каждый способ — свой код, свои детали, но с точки зрения бизнес-логики все они «умеют отправить сообщение». Идеальный случай для интерфейса: определяем контракт Notifier, а конкретные каналы его реализуют. Функция рассылки не знает и не хочет знать, какие каналы существуют.

package main

import "fmt"

// Контракт: любой способ доставки уведомления.
type Notifier interface {
    Send(message string) error
}

// Три конкретные реализации — три структуры.
type Email struct {
    Address string
}

func (e Email) Send(message string) error {
    fmt.Printf("[email -> %s] %s
", e.Address, message)
    return nil
}

type SMS struct {
    Phone string
}

func (s SMS) Send(message string) error {
    fmt.Printf("[sms -> %s] %s
", s.Phone, message)
    return nil
}

type Slack struct {
    Channel string
}

func (s Slack) Send(message string) error {
    fmt.Printf("[slack -> #%s] %s
", s.Channel, message)
    return nil
}

// Функция принимает СЛАЙС ИНТЕРФЕЙСОВ — ей всё равно, какие каналы внутри.
// Добавишь новый канал (например, Telegram) — эту функцию менять не нужно.
func broadcast(channels []Notifier, message string) {
    for _, ch := range channels {
        if err := ch.Send(message); err != nil {
            fmt.Printf("не удалось отправить через %T: %v
", ch, err)
        }
    }
}

func main() {
    channels := []Notifier{
        Email{Address: "user@example.com"},
        SMS{Phone: "+7 900 000-00-00"},
        Slack{Channel: "general"},
    }
    broadcast(channels, "Деплой прошёл успешно")
}

Обрати внимание, как это ложится на идиому «принимай интерфейсы»: broadcast берёт []Notifier, а не конкретные каналы. В тестах ты подсунешь фейковый Notifier, который просто запоминает сообщения, и проверишь логику рассылки без реальной отправки писем. Это и есть та тестируемость, ради которой в Go так любят интерфейсы.

Типичные ошибки

Опасный type assertion без ok

Запись s := v.(string) при несовпадении типа роняет программу паникой. В любом коде, где тип может оказаться не тем, используй форму s, ok := v.(string) и проверяй ok. Паника от assertion — частая причина падений в проде.

Возвращать интерфейс вместо структуры

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

Слишком широкие интерфейсы

Интерфейс на десять методов почти невозможно реализовать и замокать. Go-стиль — крохотные интерфейсы, часто на один-два метода (Stringer, Reader, Writer). Чем меньше интерфейс, тем больше типов ему удовлетворяют и тем он полезнее. «Чем больше интерфейс — тем слабее абстракция».

Злоупотребление any

Складывать всё в any, потому что «так гибче», — значит выбросить статическую типизацию. Каждый any потом требует type assertion или type switch и превращает компайл-тайм ошибки в рантайм-паники. Используй конкретные типы или дженерики, а any оставь для действительно динамических данных.

Практика

  1. Объяви интерфейс Animal с методом Sound() string. Реализуй его типами Dog и Cat. Напиши функцию, которая принимает []Animal и печатает звук каждого.
  2. Напиши функцию describe(v any) string на основе type switch, которая различает int, string, []int и возвращает осмысленное описание для каждого.
  3. Добавь к структуре temperature метод String() string, чтобы fmt.Println печатал её как «21.5°C». Проверь, что вывод изменился.
  4. Реализуй интерфейс Notifier из кейса ещё одним каналом Telegram и убедись, что broadcast работает с ним без изменений.
  5. Напиши функцию, которая принимает io.Writer и пишет в него отчёт. Вызови её сначала с os.Stdout, потом с *bytes.Buffer и напечатай содержимое буфера — убедись, что одна функция обслужила оба приёмника.

Итог

Интерфейс в Go описывает поведение, а не структуру: это набор методов, которому тип удовлетворяет неявно, просто имея нужные методы. Из этой неявности вырастает полиморфизм — одна функция обслуживает любой тип, реализующий контракт, — и слабая связанность, потому что интерфейсы определяются там, где используются. Ты научился прятать значения в пустой интерфейс any и безопасно доставать их обратно через v, ok := x.(T) и type switch, усвоил идиому «принимай интерфейсы, возвращай структуры» и познакомился с несущими конструкциями стандартной библиотеки — Stringer, io.Writer, io.Reader. Держи интерфейсы маленькими, а any — на коротком поводке. В следующей главе мы применим всё это в мире конкурентности, где интерфейсы, структуры и каналы работают вместе.

Содержание серии (12)
{# Звук celebration (этап 4.3):