Golang
Структурная и «утиная» типизация в Golang (+ сравнение с PHP, Python)
Системы типов делятся на два больших лагеря. При номинальной типизации совместимость определяется именем типа и явно объявленным родством (наследование, implements). При структурной типизации совместимость определяется формой типа: набором полей и методов, а имя не важно. «Утиная» типизация (duck typing) - это структурная типизация, перенесённая в рантайм: соответствие проверяется в момент вызова, а не компилятором.
Go стоит особняком. Для именованных типов он номинальный, а для интерфейсов структурный, причём соответствие неявное и проверяется на этапе компиляции. Ниже разберём механику Go по спецификации, а PHP и Python возьмём для контраста.
Именованные типы в Go: номинальная модель
Совместимость значений в Go регулируется правилами assignability. Для двух структур ключевые из них такие:
- типы идентичны (identical), либо
- у них идентичный underlying type, и хотя бы один из типов не именованный (то есть это литерал типа).
Из этого следует: две именованные структуры с одинаковыми полями не присваиваются друг другу.
package main
type Celsius struct{ Degrees float64 }
type Fahrenheit struct{ Degrees float64 }
func main() {
c := Celsius{Degrees: 20}
var f Fahrenheit
f = c // compile error: cannot use c (Celsius) as Fahrenheit
f = Fahrenheit(c) // ok: identical underlying type -> explicit conversion allowed
_ = f
}
Важно различать два понятия:
- Assignable (присваиваемость) - можно присвоить без конверсии. Для разных именованных структур не работает.
- Convertible (конвертируемость) - можно явно сконвертировать, если underlying types идентичны (теги полей при конверсии игнорируются). Это работает, но требует явного
T(x).
Если у структур разный набор полей, не работает ни то, ни другое. Поэтому функция func DescribePerson(p Person) не примет Employee, даже если у того есть все поля Person плюс ещё одно. Имя типа решает.
Интерфейсы: структурная типизация с проверкой при компиляции
Структурная сторона Go живёт в интерфейсах. Тип удовлетворяет интерфейсу, если его method set содержит все методы интерфейса с нужными сигнатурами. Объявлять это явно не нужно: ключевого слова implements в языке нет.
package main
import "fmt"
type Walker interface {
Walk()
}
type Robot struct{ ID string }
// Robot satisfies Walker implicitly, just by having the method.
func (r Robot) Walk() {
fmt.Printf("robot %s steps forward\n", r.ID)
}
func MakeWalk(w Walker) {
w.Walk()
}
func main() {
MakeWalk(Robot{ID: "K-2SO"})
}
Соответствие проверяет компилятор. В этом отличие от рантайм-проверки в Python: ошибку «тип не реализует интерфейс» вы получите при сборке, а не при первом вызове в проде.
Чтобы зафиксировать контракт явно и поймать расхождение раньше, используют статическую проверку присваиванием в nil:
// Compile-time assertion that *Server implements http.Handler.
var _ http.Handler = (*Server)(nil)
Method set: ловушка value vs pointer receiver
Method set зависит от того, с каким приёмником объявлен метод:
| Объявление | Входит в method set T |
Входит в method set *T |
|---|---|---|
func (t T) M() |
да | да |
func (t *T) M() |
нет | да |
Практическое следствие: если метод объявлен с указательным приёмником, то интерфейс реализует только *T, но не T.
type Stringer interface {
String() string
}
type Temp struct{ c float64 }
func (t *Temp) String() string { // pointer receiver
return fmt.Sprintf("%.1f°C", t.c)
}
func main() {
var s Stringer
s = &Temp{c: 21} // ok: *Temp is in the method set
s = Temp{c: 21} // compile error: Temp does not implement Stringer
_ = s
}
Это одна из самых частых причин «непонятных» ошибок компиляции у тех, кто пришёл в Go из других языков.
Рантайм-сторона: type assertion, type switch, any
Когда конкретный тип нужно достать обратно из интерфейса, в дело вступают рантайм-механизмы.
func describe(v any) string {
switch x := v.(type) {
case fmt.Stringer:
return x.String()
case int:
return fmt.Sprintf("int(%d)", x)
default:
return "unknown"
}
}
// Safe type assertion with the comma-ok form.
if s, ok := v.(fmt.Stringer); ok {
_ = s.String()
}
Голый v.(T) без , ok паникует при несоответствии, поэтому в проде почти всегда используют форму с ok или type switch.
Ловушка typed nil
Интерфейс внутри это пара (тип, значение). Интерфейс равен nil только когда обе части пусты. Указатель nil, завёрнутый в интерфейс, даёт не-nil интерфейс.
type MyErr struct{}
func (*MyErr) Error() string { return "boom" }
func doWork() error {
var e *MyErr // nil pointer
return e // BUG: wraps (*MyErr, nil) -> interface is NOT nil
}
func main() {
if err := doWork(); err != nil {
fmt.Println("unexpected:", err) // this branch fires
}
}
Правило: возвращайте nil напрямую (return nil), не «протаскивайте» nil-указатель конкретного типа через интерфейс.
Дженерики: структурная модель в ограничениях типов
С версии 1.18 структурный подход распространился на дженерики. Ограничение типа (constraint) это интерфейс, который может содержать не только методы, но и type set - множество допустимых типов. Тильда ~T означает «любой тип, чей underlying type равен T».
type Number interface {
~int | ~int64 | ~float64
}
// Works for any type whose underlying type is one of the listed.
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
type Money int64 // underlying type is int64 -> satisfies Number via ~int64
То есть и здесь принадлежность определяется структурно (по underlying type), а не по имени.
PHP: интерфейсы номинальны, duck typing - без хинтов
В PHP интерфейсы строго номинальны. Хинт на интерфейс проходит проверку, только если класс объявил implements, наличие подходящего метода роли не играет.
<?php
declare(strict_types=1);
interface Walker
{
public function walk(): void;
}
final class Robot
{
public function walk(): void
{
echo 'robot steps';
}
}
function makeWalk(Walker $w): void
{
$w->walk();
}
makeWalk(new Robot());
// TypeError: makeWalk(): Argument #1 ($w) must be of type Walker, Robot given
Утиная типизация в PHP получается, когда контракт не задан статически и метод вызывается на любом объекте. Проверка уезжает в рантайм.
<?php
declare(strict_types=1);
// No nominal contract: pure runtime duck typing.
function makeWalk(object $w): void
{
$w->walk(); // Error at runtime if walk() is missing
}
Итого по PHP: с интерфейсным хинтом это статическая номинальная типизация, без него утиная с рантайм-проверкой.
Python: рантайм duck typing + статические Protocol
Python исторически утиный: тип не объявляется, метод просто вызывается.
def make_walk(walker): # no type at all
walker.walk() # AttributeError at runtime if absent
С PEP 544 (typing.Protocol) у Python появилась статическая структурная типизация, прямой аналог интерфейсов Go. Класс соответствует протоколу неявно, по форме, а проверку делает статический анализатор (mypy, pyright), не рантайм.
from typing import Protocol
class Walker(Protocol):
def walk(self) -> None: ...
def make_walk(w: Walker) -> None:
w.walk()
class Robot:
def walk(self) -> None:
print("robot steps")
make_walk(Robot()) # passes mypy without any inheritance
Сводное сравнение
| Свойство | Go | PHP | Python |
|---|---|---|---|
| Структуры/классы | Номинально (по имени) | Номинально | Номинально для isinstance, утино на практике |
| Интерфейсы/протоколы | Структурно, неявно | Номинально, явный implements |
Структурно через Protocol, либо ABC номинально |
| Когда проверка соответствия | Компиляция | Хинты в рантайме, типы частично статически | Рантайм; Protocol статически через анализатор |
| Нужно объявлять контракт | Нет | Да | Нет (Protocol) / да (ABC) |
| Цена ошибки | Сборка не пройдёт | TypeError в рантайме |
AttributeError или ошибка анализатора |
| Достать конкретный тип | type assertion / type switch | instanceof |
isinstance / match |
Идиомы Go вокруг структурной типизации
Неявное соответствие интерфейсам это не синтаксический сахар, а основа стиля Go.
Объявляйте интерфейс на стороне потребителя. Не рядом с реализацией, а там, где он используется. Потребитель сам формулирует минимальный контракт, который ему нужен.
Держите интерфейсы маленькими. Идеал 1-2 метода (io.Reader с единственным Read). Это согласуется с принципом разделения интерфейсов (ISP) и упрощает подмену в тестах.
Accept interfaces, return structs. Функции принимают абстракцию, а возвращают конкретный тип.
package store
// The consumer declares only what it actually needs.
type UserSaver interface {
SaveUser(ctx context.Context, u User) error
}
// Register depends on the abstraction, not on a concrete DB.
func Register(ctx context.Context, s UserSaver, u User) error {
return s.SaveUser(ctx, u)
}
Любой тип с методом SaveUser подойдёт: продовая БД, мок в тестах, обёртка с логированием. Код Register менять не нужно. Обратная крайность тоже вредна: не плодите интерфейсы заранее «на будущее». В Go интерфейс дёшево добавить позже, потому что реализациям не нужно ничего знать о нём.
Выводы
- Go двойственен: именованные типы номинальны (правила assignability и identical types), интерфейсы структурны.
- Соответствие интерфейсу неявно и проверяется компилятором, в отличие от рантайм-duck-typing в Python.
- Method set зависит от приёмника: указательный приёмник исключает значение
Tиз реализаций интерфейса. - Помните про typed nil: nil-указатель в интерфейсе даёт не-nil интерфейс.
- Дженерики продолжают структурную линию через type sets и
~. - PHP номинален в интерфейсах, Python даёт и рантайм-duck-typing, и статические
Protocol. - Проектируйте маленькие интерфейсы на стороне потребителя: accept interfaces, return structs.