До сих пор мы работали с конкретными типами: знали точно, что перед нами int,
string или наша собственная структура. Но часто код не должен зависеть от
конкретного типа — ему важно лишь, что у значения есть нужное поведение.
«Мне всё равно, что это за зверь, лишь бы он умел крякать».
Именно это и описывает интерфейс — набор методов, которые тип обязан иметь.
Интерфейс — это контракт о поведении, а не о данных. В этой главе разберём, как
интерфейсы устроены в Go, почему реализация у них неявная, почему маленькие
интерфейсы лучше больших, что такое пустой интерфейс и any, и как безопасно
доставать конкретный тип обратно через type assertion и type switch.
Интерфейс перечисляет методы. Любой тип, у которого есть все эти методы,
автоматически удовлетворяет интерфейсу. Объявляется он так:
type Stringer interface { String() string}
Это значит: «Stringer — это что угодно, у чего есть метод String() string».
Никаких полей, никакой реализации — только список требований к поведению.
Переменная типа интерфейса может хранить значение любого подходящего типа:
var s Stringers = User{Name: "Аня"} // подойдёт, если у User есть метод String() stringfmt.Println(s.String())
Под капотом значение интерфейса — это пара: какой конкретный тип там лежит и
указатель на сами данные. Поэтому через интерфейс можно вызывать методы, не зная
заранее, какой именно тип внутри.
В Go нет ключевого слова implements. Тип не объявляет, что реализует
интерфейс — он просто имеет нужные методы, и этого достаточно. Это и называют
«утиной типизацией»: если что-то ходит как утка и крякает как утка, для нас это
утка.
package mainimport "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 mainimport ( "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() }
Интерфейс без методов — interface{} — не требует ничего. А раз требований нет,
ему удовлетворяет любой тип. Это «коробка, в которую можно положить что
угодно».
В современном Go для пустого интерфейса есть псевдоним any — читается приятнее,
а значит ровно то же самое:
var x any // то же, что interface{}x = 42 // окx = "привет" // окx = []int{1, 2} // ок
any полезен там, где тип заранее неизвестен: например, fmt.Println принимает
...any и потому печатает что угодно. Но за гибкость приходится платить: пока
значение лежит в any, компилятор почти ничего о нём не знает — вы не можете
вызвать методы или сложить два таких значения. Чтобы снова получить конкретный
тип, его нужно достать обратно.
Совет джуну: any — не «универсальный тип на каждый день». В обычном коде
предпочитайте конкретные типы и узкие интерфейсы. any уместен, когда вы
действительно работаете с заранее неизвестными данными.
Type assertion вытаскивает из интерфейса значение конкретного типа.
Синтаксис — x.(T):
var x any = "текст"s := x.(string) // s == "текст"
Но что если внутри лежит не тот тип, который вы запросили? Форма с одним
значением в этом случае вызовет панику. Поэтому почти всегда используют
безопасную форму «comma-ok» — она возвращает второе булево значение ok:
package mainimport "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, а голую форму — только
когда вы абсолютно уверены в типе и хотите упасть, если ошиблись.
Когда вариантов несколько, городить лесенку из if ... ok неудобно. Для этого есть
type switch — switch по динамическому типу значения:
package mainimport "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:
type Stringer interface { String() string}
Функции пакета fmt (Println, Printf с %v и %s, Sprint) проверяют:
а нет ли у значения метода String() string? Если есть — печатают именно его
результат вместо стандартного представления. То есть вы задаёте, как ваш тип
выглядит в выводе.
package mainimport "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.