Интерфейсы в Golang: объявление, реализация, any и type switch

Интерфейсы в Golang: объявление, реализация, any и type switch Полезное

Интерфейс в Go (Golang) — это тип, который описывает набор методов, то есть поведение, а не данные. Любой тип, у которого есть все эти методы, реализует интерфейс неявно: слова implements в Go нет, соответствие проверяет компилятор. Переменная интерфейсного типа хранит пару «конкретный тип + значение» и позволяет вызывать методы, не зная, что лежит внутри.

Ниже — объявление и реализация, пустой интерфейс interface{} и его синоним any, извлечение конкретного типа через type assertion и type switch, io.Writer как главный пример из стандартной библиотеки и три типичные ошибки. Все примеры проверены на Go 1.27.1 (linux/arm64), вывод взят из реального запуска.

Мини-словарь

  • Интерфейсный тип — объявление type X interface { ... }, список методов.
  • Конкретный тип — struct, int, срез и другие типы, у которых есть реальные данные и методы.
  • Интерфейсное значение — то, что лежит в переменной типа X: динамический тип и значение этого типа. Если внутри ничего нет, значение равно nil.
  • Набор методов (method set) — методы, доступные у типа. У T и у *T он разный, от этого зависит, кто реализует интерфейс.

Минимальный пример: объявление и неявная реализация

Аналогия: «пишущий инструмент» — не конкретный предмет, а умение писать. Карандаш и ручка им являются, потому что пишут. В Go так же: talker — это «все, что умеет talk()».

package main

import "fmt"

// talker - интерфейс: любой тип с методом talk() string
type talker interface {
    talk() string
}

type martian struct{}

func (m martian) talk() string {
    return "nack nack"
}

type laser int

func (l laser) talk() string {
    return fmt.Sprintf("pew x%d", int(l))
}

// shout работает с любым talker и не знает о конкретных типах
func shout(t talker) {
    fmt.Println(t.talk() + "!")
}

func main() {
    var t talker // нулевое значение интерфейса - nil

    t = martian{}
    shout(t)

    t = laser(3)
    shout(t)

    fmt.Printf("%T %v\n", t, t)
}

Программа напечатает:

nack nack!
pew x3!
main.laser 3

Что здесь важно:

  1. martian (struct без полей) и laser (целое число) нигде не упоминают talker. Они реализуют его просто потому, что у них есть метод talk() string с точно такой сигнатурой.
  2. Функция shout принимает интерфейс. Если завтра появится новый тип с talk(), shout заработает с ним без изменений — в этом и смысл интерфейсов.
  3. %T показывает динамический тип внутри интерфейсной переменной: сейчас там main.laser.

Имена интерфейсов с одним методом по соглашению строят суффиксом «-er»: talker, io.Writer, io.Reader, fmt.Stringer. Это традиция стандартной библиотеки, а не требование компилятора.

Если метода нет, программа не соберется. Для типа stone struct{} без talk() строка var t talker = stone{} дает ошибку компиляции:

./main.go:10:17: cannot use stone{} (value of struct type stone) as talker value in variable declaration: stone does not implement talker (missing method talk)

Чтобы проверка шла сразу при сборке, даже если тип нигде не присваивается интерфейсу, используют строку var _ io.Writer = (*countingWriter)(nil) — она есть в примере с io.Writer ниже.

io.Writer: интерфейс из стандартной библиотеки

io.Writer объявлен в пакете io с единственным методом Write(p []byte) (n int, err error). Его реализуют os.Stdout и открытые файлы (*os.File), *bytes.Buffer, *strings.Builder, HTTP-ответ в обработчике (http.ResponseWriter), сжимающий *gzip.Writer. Поэтому одна функция может писать куда угодно:

package main

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

// report пишет в любой io.Writer: терминал, буфер, файл, HTTP-ответ
func report(w io.Writer, name string, score int) error {
    _, err := fmt.Fprintf(w, "%s: %d\n", name, score)
    return err
}

// countingWriter оборачивает другой Writer и считает байты
type countingWriter struct {
    w io.Writer
    n int
}

func (c *countingWriter) Write(p []byte) (int, error) {
    n, err := c.w.Write(p)
    c.n += n
    return n, err
}

func main() {
    report(os.Stdout, "stdout", 1)

    var buf bytes.Buffer
    report(&buf, "buffer", 2)
    fmt.Print("из буфера -> ", buf.String())

    var sb strings.Builder
    cw := &countingWriter{w: &sb}
    report(cw, "builder", 3)
    report(cw, "Привет", 4)
    fmt.Printf("%q, байт: %d\n", sb.String(), cw.n)

    // compile-time проверка: *countingWriter реализует io.Writer
    var _ io.Writer = (*countingWriter)(nil)
}

Вывод:

stdout: 1
из буфера -> buffer: 2
"builder: 3\nПривет: 4\n", байт: 27

countingWriter сам является io.Writer и оборачивает другой io.Writer — так устроены многие обертки в стандартной библиотеке. Счетчик показывает байты, а не символы: «Привет» в UTF-8 занимает 12 байт, поэтому итог 27, а не 21.

В учебном примере ошибка report в main не проверяется ради краткости. В рабочем коде результат записи в файл или сеть проверяют всегда: Write может вернуть ошибку или записать меньше байт, чем передано.

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

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

package main

import (
    "bytes"
    "fmt"
    "io"
)

// встраивание: readWriter = все методы io.Reader + все методы io.Writer
type readWriter interface {
    io.Reader
    io.Writer
}

func main() {
    var rw readWriter = &bytes.Buffer{}

    fmt.Fprint(rw, "hello")
    data, _ := io.ReadAll(rw)
    fmt.Println(string(data))

    var w io.Writer = rw // шире -> уже: проверка не нужна
    _, isReader := w.(io.Reader)
    fmt.Println(isReader)
}

Программа выводит hello и true. Присвоить «широкий» интерфейс «узкому» можно без проверок. Обратное направление — только через type assertion (о ней ниже): в w статически виден лишь Write, но динамический тип *bytes.Buffer умеет и Read, поэтому проверка возвращает true.

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

У interface{} нет ни одного метода, поэтому его реализует любой тип. С Go 1.18 есть встроенный синоним any — это ровно тот же тип, не новый. Именно так объявлены параметры fmt.Println(a ...any).

any полезен там, где тип действительно заранее неизвестен: форматированный вывод, разбор произвольного JSON в map[string]any, контейнеры до появления дженериков. Цена — компилятор больше не проверяет тип, и достать значение обратно можно только проверкой во время выполнения. Если функция работает с набором известных типов, чаще подходят обычный интерфейс с методами или дженерики.

Type assertion и type switch

Type assertion v.(T) достает из интерфейсного значения конкретный тип T (или проверяет, что значение реализует другой интерфейс). Type switch делает то же для нескольких вариантов сразу.

package main

import "fmt"

func describe(v any) string {
    switch x := v.(type) {
    case nil:
        return "nil"
    case int:
        return fmt.Sprintf("int, x*2 = %d", x*2)
    case string:
        return fmt.Sprintf("string длиной %d байт", len(x))
    case fmt.Stringer:
        return "Stringer: " + x.String()
    case error:
        return "error: " + x.Error()
    default:
        return fmt.Sprintf("другой тип %T", x)
    }
}

type point struct{ x, y int }

func (p point) String() string { return fmt.Sprintf("(%d;%d)", p.x, p.y) }

func main() {
    values := []any{42, "Привет", point{1, 2}, fmt.Errorf("нет файла"), 3.14, nil}
    for _, v := range values {
        fmt.Println(describe(v))
    }

    var v any = "Go"
    s, ok := v.(string)
    fmt.Println(s, ok)
    n, ok := v.(int)
    fmt.Println(n, ok)

    _ = v.(int) // без ok: паника
}

Вывод (последние строки трассировки сокращены):

int, x*2 = 84
string длиной 12 байт
Stringer: (1;2)
error: нет файла
другой тип float64
nil
Go true
0 false
panic: interface conversion: interface {} is string, not int

Разбор:

  • Внутри каждой ветки case переменная x уже имеет тип этой ветки: в case int с ней можно считать, в case string брать len.
  • Ветки проверяются по порядку. point попал в fmt.Stringer, потому что у него есть String(). Если значение подходит под несколько интерфейсов, сработает первая подходящая ветка.
  • Форма s, ok := v.(T) безопасна: при несовпадении ok == false, а s — нулевое значение T. Форма без ok при несовпадении вызывает панику, поэтому ее берут, только когда тип гарантирован логикой программы.
  • В тексте паники any показан как interface {} — это одно и то же.
Задача Что использовать
Вызвать поведение, не зная типа метод интерфейса
Проверить один тип или интерфейс x, ok := v.(T)
Разобрать несколько вариантов switch x := v.(type)
Найти ошибку нужного типа в цепочке %w errors.AsType[*E](err) (Go 1.26+) или errors.As, а не assertion
Одна функция для набора числовых типов дженерики с ограничением

Интерфейсы-ограничения в дженериках

С Go 1.18 интерфейс может содержать не только методы, но и набор типов: ~int | ~float64. Такой интерфейс — ограничение (constraint) для параметров дженериков, а не обычный тип переменной.

package main

import "fmt"

type Number interface {
    ~int | ~float64
}

func Sum[T Number](xs []T) T {
    var s T
    for _, x := range xs {
        s += x
    }
    return s
}

func main() {
    fmt.Println(Sum([]int{1, 2, 3}))
    fmt.Println(Sum([]float64{0.5, 0.25}))
}

Вывод: 6 и 0.75. Если объявить переменную var n Number = 5, компилятор откажет:

./main.go:10:8: cannot use type Number outside a type constraint: interface contains type constraints

Частые ошибки: неверно -> результат -> исправление

1. Метод с приемником-указателем

Неверно: метод объявлен у *robot, а в интерфейс кладут значение robot.

package main

import "fmt"

type talker interface {
    talk() string
}

type robot struct{ name string }

// метод с приемником-указателем
func (r *robot) talk() string {
    return "I am " + r.name
}

func main() {
    var t talker = robot{name: "R2"}
    fmt.Println(t.talk())
}

Результат — ошибка компиляции:

./main.go:17:17: cannot use robot{…} (value of struct type robot) as talker value in variable declaration: robot does not implement talker (method talk has pointer receiver)

Причина: в набор методов типа robot входят только методы с приемником-значением, а talk объявлен у *robot. Исправление — класть указатель:

package main

import "fmt"

type talker interface {
    talk() string
}

type robot struct{ name string }

func (r *robot) talk() string {
    return "I am " + r.name
}

func main() {
    var t talker = &robot{name: "R2"} // исправлено: указатель
    fmt.Println(t.talk())

    r := robot{name: "C3"}
    fmt.Println(r.talk()) // прямой вызов у адресуемой переменной работает: Go берет &r сам
}

Вывод: I am R2 и I am C3. Прямой вызов r.talk() работает, потому что Go сам берет адрес переменной. Для присваивания интерфейсу такого автоматического & нет.

2. Интерфейс с nil-указателем внутри не равен nil

Неверно: функция возвращает error, внутри которого лежит типизированный nil-указатель.

package main

import (
    "errors"
    "fmt"
)

type myErr struct{}

func (*myErr) Error() string { return "my error" }

// неверно: возвращает типизированный nil-указатель через интерфейс error
func check(fail bool) error {
    var e *myErr // nil-указатель
    if fail {
        e = &myErr{}
    }
    return e
}

// исправлено: при отсутствии ошибки возвращаем литерал nil
func checkFixed(fail bool) error {
    if fail {
        return &myErr{}
    }
    return nil
}

func main() {
    err := check(false)
    fmt.Println(err == nil)       // false
    fmt.Printf("%T %v\n", err, err)

    fmt.Println(checkFixed(false) == nil) // true

    var target *myErr
    fmt.Println(errors.As(check(true), &target))
}

Вывод:

false
*main.myErr my error
true
true

Интерфейсное значение равно nil, только когда пусты и тип, и значение. В check(false) тип есть (*main.myErr), значение — nil-указатель, поэтому err != nil, и вызывающий код решит, что ошибка произошла. Исправление — возвращать литерал nil, а не переменную конкретного типа. Строка my error печатается потому, что Error() не обращается к полям; метод, который разыменует nil-указатель, вызвал бы панику. go vet в Go 1.27.1 этот код не отметил, так что полагаться на него здесь нельзя.

3. Type assertion без ok

Неверно: n := v.(int), когда в v может быть строка. Результат — panic: interface conversion: interface {} is string, not int (показано выше). Исправление — форма n, ok := v.(int) с проверкой ok или type switch с веткой default.

Еще одна ловушка того же рода: сравнение == двух any, внутри которых несравнимые типы (срезы, map, функции), компилируется, но падает при выполнении с panic: runtime error: comparing uncomparable type []int.

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

Это соглашения сообщества Go (руководство Go Code Review Comments, стандартная библиотека), а не правила компилятора:

  • Маленькие интерфейсы. Один-два метода, как io.Reader/io.Writer. Большой интерфейс трудно реализовать в тестах и в альтернативных реализациях.
  • Интерфейс объявляет потребитель. Пакет, которому нужно «что-то с методом Save», сам описывает этот интерфейс; пакет-поставщик обычно возвращает конкретный тип. Отсюда поговорка «принимай интерфейсы, возвращай структуры» — с исключениями вроде error.
  • Не заводить интерфейс заранее. Если реализация одна и подмена в тестах не нужна, интерфейс добавляет лишний уровень косвенности. Его вводят, когда появляется вторая реализация или фейк для теста.

Выводы

  • Интерфейс в Go описывает поведение набором методов; тип реализует его неявно, если у него есть все методы с точными сигнатурами.
  • Интерфейсное значение — пара «динамический тип + значение»; оно равно nil, только когда пусты обе части, отсюда ловушка с nil-указателем в error.
  • any — синоним interface{} с Go 1.18; достать конкретный тип обратно можно через v.(T) с ok или type switch.
  • io.Writer показывает главную пользу интерфейсов: одна функция пишет в терминал, буфер, файл или сеть без изменений.
  • Интерфейсы с наборами типов (~int | ~float64) — только ограничения для дженериков, переменную такого типа объявить нельзя.

Где применяется / связь с практикой

Освойте тему на практике

Интерфейсы в Go встречаются в каждом рабочем сервисе: обработчики HTTP (http.Handler), слой хранения за интерфейсом репозитория, фейки в модульных тестах, middleware-обертки над io.Writer и http.ResponseWriter. Систематически пройти язык от типов и методов до интерфейсов, горутин и тестирования можно на курсе «Go-разработчик. Базовый уровень». Разборы отдельных тем с преподавателями бывают на открытых уроках Otus.

FAQ

Может ли один тип реализовать несколько интерфейсов?
Да. *bytes.Buffer одновременно io.Reader, io.Writer, fmt.Stringer и другие интерфейсы — достаточно иметь нужные методы.

Медленнее ли вызов через интерфейс, чем прямой?
Вызов через интерфейс косвенный и обычно мешает встраиванию функции, поэтому в горячих циклах разница бывает измеримой (сборка с PGO, начиная с Go 1.21, умеет девиртуализировать часть горячих вызовов). В обычном коде она обычно не заметна; решать стоит по результатам бенчмарка go test -bench, а не заранее.

Можно ли использовать интерфейсное значение как ключ map?
Можно, если динамический тип внутри сравнимый. Если там окажется срез или map, код скомпилируется, но запись по такому ключу упадет во время выполнения: panic: runtime error: hash of unhashable type []int.

OTUS Журнал