GraphLMS

Основы
Начать
Глава 08 · Основы~14 мин чтения

Интерфейсы

О чём глава

До сих пор мы работали с конкретными типами: знали точно, что перед нами int, string или наша собственная структура. Но часто код не должен зависеть от конкретного типа — ему важно лишь, что у значения есть нужное поведение. «Мне всё равно, что это за зверь, лишь бы он умел крякать».

Именно это и описывает интерфейс — набор методов, которые тип обязан иметь. Интерфейс — это контракт о поведении, а не о данных. В этой главе разберём, как интерфейсы устроены в Go, почему реализация у них неявная, почему маленькие интерфейсы лучше больших, что такое пустой интерфейс и any, и как безопасно доставать конкретный тип обратно через type assertion и type switch.

Интерфейс — это набор методов

Интерфейс перечисляет методы. Любой тип, у которого есть все эти методы, автоматически удовлетворяет интерфейсу. Объявляется он так:

type Stringer interface {
    String() string
}

Это значит: «Stringer — это что угодно, у чего есть метод String() string». Никаких полей, никакой реализации — только список требований к поведению.

Переменная типа интерфейса может хранить значение любого подходящего типа:

var s Stringer
s = User{Name: "Аня"}   // подойдёт, если у User есть метод String() string
fmt.Println(s.String())

Под капотом значение интерфейса — это пара: какой конкретный тип там лежит и указатель на сами данные. Поэтому через интерфейс можно вызывать методы, не зная заранее, какой именно тип внутри.

Неявная реализация (duck typing)

В Go нет ключевого слова implements. Тип не объявляет, что реализует интерфейс — он просто имеет нужные методы, и этого достаточно. Это и называют «утиной типизацией»: если что-то ходит как утка и крякает как утка, для нас это утка.

package main
 
import "fmt"
 
type Greeter interface {
	Greet() string
}
 
type Robot struct {
	ID int
}
 
// Просто метод с нужной сигнатурой — про Greeter нигде не сказано ни слова.
func (r Robot) Greet() string {
	return fmt.Sprintf("Бип-буп, я робот #%d", r.ID)
}
 
func main() {
	var g Greeter = Robot{ID: 7} // Robot подходит автоматически
	fmt.Println(g.Greet())
}

Почему это удобно? Связь между типом и интерфейсом возникает в точке использования, а не в точке объявления типа. Вы можете написать интерфейс уже после того, как тип создан — хоть в чужом пакете — и тип «задним числом» начнёт ему удовлетворять. Зависимости идут от потребителя к данным, а не наоборот.

Функция, принимающая интерфейс

Главная сила интерфейсов раскрывается в сигнатурах функций. Функция, которая принимает интерфейс, работает с любым подходящим типом — её не нужно переписывать под каждый новый случай.

package main
 
import (
	"fmt"
	"math"
)
 
type Shape interface {
	Area() float64
}
 
type Square struct{ Side float64 }
type Circle struct{ R float64 }
 
func (s Square) Area() float64 { return s.Side * s.Side }
func (c Circle) Area() float64 { return math.Pi * c.R * c.R }
 
// describe не знает и не хочет знать, что за фигура — лишь бы умела Area().
func describe(sh Shape) {
	fmt.Printf("Площадь = %.2f\n", sh.Area())
}
 
func main() {
	describe(Square{Side: 3})
	describe(Circle{R: 2})
}

Обратите внимание: describe принимает Shape, а не Square или Circle. Добавим завтра треугольник с методом Area()describe примет его без единой правки. Это и есть полиморфизм в Go: одно поведение, много типов.

Маленькие интерфейсы — это хорошо

В Go хороший тон — делать интерфейсы узкими. Идеал — один-два метода. Не зря самые знаменитые интерфейсы стандартной библиотеки крошечные: io.Reader и io.Writer — по одному методу.

Почему меньше — лучше:

  • Меньше требований — больше типов подойдёт. Интерфейс с одним методом удовлетворяет куча типов; с десятью — почти никто.
  • Проще тестировать. Маленький интерфейс легко подменить заглушкой в тесте.
  • Чётче контракт. Узкий интерфейс честно говорит, что именно нужно функции.

Отсюда важный принцип: интерфейс объявляет тот, кто его потребляет, а не тот, кто реализует. Функции нужен «кто-то, кто умеет Area()» — пусть рядом с функцией и живёт маленький Shape. Не нужно заранее придумывать гигантский интерфейс «на все случаи».

// Плохо: один разбухший интерфейс, который требует всё сразу.
type Animal interface {
    Eat()
    Sleep()
    Fly()
    Swim()
    Dig()
}
 
// Хорошо: маленькие интерфейсы под конкретные нужды.
type Flyer interface{ Fly() }
type Swimmer interface{ Swim() }

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

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

В современном Go для пустого интерфейса есть псевдоним any — читается приятнее, а значит ровно то же самое:

var x any         // то же, что interface{}
x = 42            // ок
x = "привет"      // ок
x = []int{1, 2}   // ок

any полезен там, где тип заранее неизвестен: например, fmt.Println принимает ...any и потому печатает что угодно. Но за гибкость приходится платить: пока значение лежит в any, компилятор почти ничего о нём не знает — вы не можете вызвать методы или сложить два таких значения. Чтобы снова получить конкретный тип, его нужно достать обратно.

Совет джуну: any — не «универсальный тип на каждый день». В обычном коде предпочитайте конкретные типы и узкие интерфейсы. any уместен, когда вы действительно работаете с заранее неизвестными данными.

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

Type assertion вытаскивает из интерфейса значение конкретного типа. Синтаксис — x.(T):

var x any = "текст"
s := x.(string)   // s == "текст"

Но что если внутри лежит не тот тип, который вы запросили? Форма с одним значением в этом случае вызовет панику. Поэтому почти всегда используют безопасную форму «comma-ok» — она возвращает второе булево значение ok:

package main
 
import "fmt"
 
func main() {
	var x any = 42
 
	if n, ok := x.(int); ok {
		fmt.Println("это int:", n*2)
	} else {
		fmt.Println("это не int")
	}
 
	// А здесь тип не совпадёт — паники нет, просто ok == false.
	if s, ok := x.(string); ok {
		fmt.Println("строка:", s)
	} else {
		fmt.Println("внутри не строка, а что-то ещё")
	}
}

Запомните правило: форма с одним значением паникует при несовпадении, форма с ok — нет. В рабочем коде по умолчанию берите comma-ok, а голую форму — только когда вы абсолютно уверены в типе и хотите упасть, если ошиблись.

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

Когда вариантов несколько, городить лесенку из if ... ok неудобно. Для этого есть type switchswitch по динамическому типу значения:

package main
 
import "fmt"
 
func describe(v any) string {
	switch val := v.(type) {
	case int:
		return fmt.Sprintf("целое число %d", val)
	case string:
		return fmt.Sprintf("строка длиной %d", len(val))
	case bool:
		return fmt.Sprintf("булево %t", val)
	default:
		// сюда попадёт всё, для чего нет своей case-ветки —
		// например float64, ведь case float64 мы не написали.
		return "неизвестный тип"
	}
}
 
func main() {
	fmt.Println(describe(7))
	fmt.Println(describe("Go"))
	fmt.Println(describe(true))
	fmt.Println(describe(3.14)) // float64 — ветки нет, ловит default
}

Конструкция v.(type) работает только внутри switch. В каждой case-ветке переменная val уже имеет соответствующий конкретный тип, так что с ней можно работать как обычно — никаких дополнительных assertion не нужно.

fmt.Stringer: реальный интерфейс из stdlib

Самый частый интерфейс, который вы реализуете в первые дни, — это fmt.Stringer:

type Stringer interface {
    String() string
}

Функции пакета fmt (Println, Printf с %v и %s, Sprint) проверяют: а нет ли у значения метода String() string? Если есть — печатают именно его результат вместо стандартного представления. То есть вы задаёте, как ваш тип выглядит в выводе.

package main
 
import "fmt"
 
type Temperature float64
 
// Реализуем fmt.Stringer — теперь fmt знает, как нас печатать.
func (t Temperature) String() string {
	return fmt.Sprintf("%.1f°C", float64(t))
}
 
func main() {
	t := Temperature(21.5)
	fmt.Println(t)             // вызовется наш String()
	fmt.Printf("сейчас %v\n", t)
}

Это отличный первый пример «своего» интерфейса: метод простой, эффект сразу виден в выводе, и вы на практике чувствуете, что такое неявная реализация — мы нигде не сказали fmt, что наш тип Stringer, он сам это обнаружил.

Частые ошибки

Голая type assertion и паника. x.(int) без ok уронит программу, если внутри не int. По умолчанию пишите n, ok := x.(int) и проверяйте ok.

any вместо нормального типа. Соблазнительно сделать параметр any и «не думать о типах». Но тогда компилятор перестаёт вас защищать, а вызывающему коду приходится гадать. Конкретный тип или узкий интерфейс почти всегда лучше.

Слишком большой интерфейс. Интерфейс с кучей методов трудно реализовать и подменить в тесте. Дробите на маленькие — по одному-двум методам под конкретную нужду.

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

Что дальше

Интерфейсы — это про поведение, а конкретные типы и структуры — про данные. Дальше вы будете комбинировать их повсюду: принимать узкие интерфейсы на входе функций, реализовывать error (это тоже всего лишь интерфейс с методом Error() string) и строить гибкие, тестируемые архитектуры. А когда понадобится типобезопасная обобщённость без any, на помощь придут дженерики.

Ключевая мысль для запоминания: интерфейс описывает, что значение умеет делать, а не из чего оно состоит. Делайте интерфейсы маленькими, объявляйте их рядом с тем, кто их использует, и доставайте конкретный тип обратно через comma-ok или type switch.