До сих пор мы работали с конкретными типами: знали точно, что перед нами int,
string или наша собственная структура. Теперь представьте функцию, которая
печатает отчёт о площади фигур. Фигуры бывают квадратные, круглые, треугольные,
и у каждой своя структура. Писать describeSquare, describeCircle и так
далее — тупик: на каждую новую фигуру новая функция. Функции на самом деле
неважно, что за фигура перед ней. Ей важно одно: фигура умеет посчитать свою
площадь.
Именно это и описывает интерфейс — набор методов, которые должен иметь тип.
Интерфейс говорит о поведении значения, а не о том, из каких полей оно состоит. В этой главе разберём, как
интерфейсы устроены в Go, почему реализация у них неявная, почему маленькие
интерфейсы лучше больших, что такое пустой интерфейс и any, и как безопасно
доставать конкретный тип обратно через type assertion и type switch.
Интерфейс перечисляет методы. Любой тип, у которого есть все эти методы,
автоматически удовлетворяет интерфейсу. Объявляется он так:
type Stringer interface { String() string}
Это значит: «Stringer — это что угодно, у чего есть метод String() string».
Никаких полей, никакой реализации — только список требований к поведению.
Переменная типа интерфейса может хранить значение любого подходящего типа.
Если у структуры User есть метод String() string, то так можно:
var s Stringer = User{Name: "Аня"}fmt.Println(s.String()) // вызовется метод String у User
Внутри значение интерфейса — это пара: какой конкретный тип там лежит и
где лежат сами данные. Поэтому через интерфейс можно вызывать методы, не зная
заранее, какой именно тип внутри. Эта пара ещё пригодится в главе об ошибках.
В 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())}
Вывод: Бип-буп, я робот #7.
Почему это удобно? Связь между типом и интерфейсом возникает в точке
использования, а не в точке объявления типа. Вы можете написать интерфейс уже
после того, как тип создан — хоть в чужом пакете — и тип «задним числом» начнёт
ему удовлетворять. Зависимости идут от потребителя к данным, а не наоборот.
Вернёмся к задаче с фигурами. Функция, которая принимает интерфейс, работает с
любым подходящим типом, и переписывать её под каждый новый тип не нужно.
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})}
Вывод:
Площадь = 9.00Площадь = 12.57
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() }
Если метод объявлен с получателем T, им обладают и T, и *T. Если метод объявлен с получателем *T, интерфейсу с таким методом удовлетворяет только *T. Возможность написать value.Method() благодаря автоматическому взятию адреса не добавляет этот метод в набор методов самого T.
Например, если у Counter метод func (c *Counter) Inc(), а интерфейс объявлен как type Incer interface{ Inc() }, то переменной типа Incer можно присвоить &Counter{}, но нельзя Counter{}. Компилятор объяснит это прямо:
cannot use Counter{} (value of struct type Counter) as Incer value in variable declaration:Counter does not implement Incer (method Inc has pointer receiver)
Последние слова — подсказка: «метод Inc объявлен с получателем-указателем». Исправление — передавать адрес, &Counter{}. Вот почему в прошлой главе советовали не смешивать получатели: если у типа все методы на указателе, правило простое — в интерфейс кладём указатель.
Интерфейс без методов — 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 не нужно.
Тот самый Stringer из начала главы на самом деле есть в стандартной
библиотеке, в пакете fmt. Это, пожалуй, первый интерфейс, который вы
реализуете сами:
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)}
Вывод:
21.5°Cсейчас 21.5°C
Мы нигде не сказали fmt, что наш тип — Stringer: пакет сам проверил, есть ли
у значения нужный метод. Это и есть неявная реализация в действии.
Голая type assertion и паника.x.(int) без ok уронит программу, если внутри
не int. По умолчанию пишите n, ok := x.(int) и проверяйте ok.
any вместо нормального типа. Соблазнительно сделать параметр any и «не
думать о типах». Но тогда компилятор перестаёт вас защищать, а вызывающему коду
приходится гадать. Конкретный тип или узкий интерфейс почти всегда лучше.
Слишком большой интерфейс. Интерфейс с кучей методов трудно реализовать и
подменить в тесте. Дробите на маленькие — по одному-двум методам под конкретную
нужду.
Забыли, что метод должен совпасть точно. Тип удовлетворяет интерфейсу, только
если сигнатуры методов совпадают буква в букву: имя, аргументы, возвращаемые
значения. Лишний параметр или другое имя — и реализации «не существует», а ошибка
вылезет лишь при попытке присваивания.
В примере с Shape добавьте Triangle с полями Base и Height типа float64. Реализуйте площадь как половину произведения основания на высоту. Передайте треугольник Base: 4, Height: 3 в существующую describe, не меняя эту функцию. Почему компилятор принимает новый тип?
type Triangle struct { Base float64 Height float64}func (t Triangle) Area() float64 { return t.Base * t.Height / 2}
В main добавьте describe(Triangle{Base: 4, Height: 3}). Метод Area() float64 совпадает с требованием Shape; отдельное объявление реализации не нужно. Если изменить результат метода на int, тип перестанет удовлетворять этому интерфейсу.
Самый распространённый интерфейс Go вы уже видели много раз, не зная об этом.
Это error — интерфейс с единственным методом Error() string. В следующей главе,
«Ошибки как значения», вы будете создавать свои ошибки,
оборачивать их и доставать из них конкретный тип — тем же приёмом, что и type
assertion здесь. А когда понадобится обобщённость без потери типов, которую не
даёт any, помогут дженерики.