Структурная и «утиная» типизация в Golang (+ сравнение с PHP, Python)
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 интерфейс дёшево добавить позже, потому что реализациям не нужно ничего знать о нём.

Выводы

  1. Go двойственен: именованные типы номинальны (правила assignability и identical types), интерфейсы структурны.
  2. Соответствие интерфейсу неявно и проверяется компилятором, в отличие от рантайм-duck-typing в Python.
  3. Method set зависит от приёмника: указательный приёмник исключает значение T из реализаций интерфейса.
  4. Помните про typed nil: nil-указатель в интерфейсе даёт не-nil интерфейс.
  5. Дженерики продолжают структурную линию через type sets и ~.
  6. PHP номинален в интерфейсах, Python даёт и рантайм-duck-typing, и статические Protocol.
  7. Проектируйте маленькие интерфейсы на стороне потребителя: accept interfaces, return structs.

Quote of the day:

Одного невозможно избыть человеку: стремления к женщине.
By den On April 29, 2024
anon
anon

2 years ago

Первый пример структурной типизации GO не работает

den
den

2 days ago

Fixed!

Leave a reply