voidlearn
 

1.5Интерфейсы и «ООП» в Go

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

Что такое интерфейс и откуда взялись «две половинки»

Всё сводится к одному рабочему вопросу: как в языке без наследования подставить одну реализацию вместо другой и что за это платишь. Мокнуть базу в тесте, подменить платёжного провайдера, написать функцию, которая одинаково пишет и в файл, и в сеть, - всё это интерфейсы. И тут же рядом два самых частых «убийственных» вопроса собеса: почему err != nil, когда там явно nil, и почему метод не находится, хотя он написан.

1. Интерфейс - это список требований, а не хранилище данных

type Writer interface { Write([]byte) (int, error) } не описывает полей и ничего не хранит. Это объявление вида «мне подойдёт любой тип, у которого есть вот такой метод». Как объявление в газете: «требуется тот, кто умеет писать байты». Кто именно придёт, объявлению безразлично.

2. Интерфейсная переменная - это две половинки: «какой тип» и «где данные»

Когда ты пишешь var w io.Writer = f, в переменную w не «кладётся файл». В неё кладутся два указателя:

  • левая половинка - тип: «здесь лежит *os.File», плюс, для непустого интерфейса, таблица адресов его методов;
  • правая половинка - данные: адрес самого значения.

Аналогия: интерфейс - это ярлык на посылке. На ярлыке написано, что внутри («тип: коробка обуви») и по какому адресу посылка лежит. Сам ярлык не посылка. Его можно скопировать, передать, сравнить с другим ярлыком, и на нём вполне может быть написано «внутри: коробка обуви, содержимое: ничего». Ярлык всё равно существует, он не пустой. На этом и держится вся история с typed nil.

Вызов метода через интерфейс идёт по таблице адресов из левой половинки: какой именно код выполнится, решает не компилятор, а таблица в момент вызова. Это и есть динамическая диспетчеризация, и отсюда цена интерфейса: прыжок по адресу из памяти вместо прямого вызова плюс невозможность заинлайнить метод.

ООП в Go: что есть и чего нет

Механизм классического ООП Есть ли в Go Чем заменено
Класс нет struct + методы, объявленные рядом на уровне пакета
Инкапсуляция да регистр первой буквы: экспортируется из пакета или нет
Наследование реализации нет встраивание (embedding) - это композиция с продвижением методов
Полиморфизм да интерфейсы с неявной реализацией
Абстрактный класс нет интерфейс + встроенная структура с общей реализацией
Конструктор нет функция NewT(...) *T по соглашению; полезное нулевое значение
Перегрузка методов нет разные имена, вариативные параметры, функциональные опции
Дженерики с Go 1.18 type parameters, отдельная тема (1.7)
Исключения нет error как значение; panic - только для багов
super / base нет явный вызов метода встроенного типа: t.Base.Method()

Формулировка для собеса: в Go нет наследования, есть композиция и структурная типизация. Тип реализует интерфейс автоматически, если у него есть нужные методы, без всяких implements. Так разворачивается зависимость: интерфейс не привязан к реализации, а реализация ничего не знает об интерфейсе.

Почему в Go нет наследования: аргументы авторов
  • Хрупкий базовый класс. Тронешь базовый класс, и наследники сломаются неочевидным образом; на глубокой иерархии трассировка вызова превращается в квест.
  • Наследование связывает намертво. Подкласс зависит от реализации родителя, а не от одного лишь контракта. Сильнее связности не бывает.
  • Ромбовидная проблема. Множественное наследование заставляет придумывать правила разрешения конфликтов (C++ virtual, MRO в Python). Go вместо этого объявляет конфликт имён при встраивании ошибкой компиляции, если его не разрешили явно.
  • Композиция гибче. «Has-a» вместо «is-a»: поведение меняешь подстановкой зависимости, а не переопределением метода в наследнике.

Embedding: композиция с продвижением методов

Встраивание - это поле без имени. Имя типа становится именем поля, а все методы и поля встроенного типа продвигаются наружу: их можно вызывать так, будто они объявлены у внешнего типа. Это синтаксический сахар над делегированием, а не наследование.

type Animal struct {
    Name string
}
func (a Animal) Speak() string { return a.Name + " издаёт звук" }
func (a Animal) Describe() string { return "Я " + a.Speak() }

type Dog struct {
    Animal          // встроили: поле называется Animal, методы продвинуты
    Breed string
}

d := Dog{Animal{"Рекс"}, "лабрадор"}
fmt.Println(d.Name)          // "Рекс"  - продвинутое поле, сахар для d.Animal.Name
fmt.Println(d.Speak())       // "Рекс издаёт звук" - продвинутый метод
                             // (пока у Dog нет своего Speak; объявим его ниже,
                             //  и эта же строка начнёт печатать "Рекс гавкает":
                             //  порядок объявлений в файле роли не играет)

// Метод внешнего типа перекрывает продвинутый
func (d Dog) Speak() string { return d.Name + " гавкает" }

fmt.Println(d.Speak())       // "Рекс гавкает"   - вызвался метод Dog
fmt.Println(d.Animal.Speak())// "Рекс издаёт звук" - явный доступ к встроенному
fmt.Println(d.Describe())    // "Я Рекс издаёт звук", а не "гавкает"
Главное отличие от наследования: нет виртуальной диспетчеризации

Describe() объявлен у Animal, его receiver - Animal. Внутри a.Speak() вызывает метод Animal, потому что a - это Animal, и о существовании Dog он не знает и знать не может. В Java/C++ здесь сработал бы полиморфизм и напечаталось бы «гавкает». Встраивание не даёт виртуальных методов. Хочешь такое поведение, объявляй интерфейс и передавай зависимость явно.

Встраивание - это обычное поле без имени Dog в памяти Animal{ Name string } Breed string поля лежат подряд, как у любой структуры; никакого указателя на «родителя» нет Таблица методов Dog (набор имён) d.Name d.Animal.Name продвинуто d.Describe() Animal.Describe(d.Animal) d.Speak() Dog.Speak(d) перекрыт d.Animal.Speak() Animal.Speak(d.Animal) явно Внутри Animal.Describe вызов a.Speak() - это ВСЕГДА Animal.Speak: виртуальности нет. Правила разрешения имени 1. Глубина побеждает: имя на внешнем уровне закрывает такое же имя на глубине 1 и ниже. 2. Ничья на одной глубине = ошибка компиляции «ambiguous selector» - но только при обращении к имени. 3. Встраивать можно и указатель (*Animal), и интерфейс - тогда продвигается его method set.
Механика встраивания. Компилятор просто дописывает переходники: d.Speak() превращается в вызов метода нужного типа с нужным receiver'ом. Никакой таблицы виртуальных функций у структуры нет.
// Встраивание интерфейса в структуру - идиома «частичная реализация»
type ReadWriter interface { io.Reader; io.Writer }   // интерфейс в интерфейсе

type OnlyRead struct {
    io.ReadWriter        // встроен интерфейс: тип формально реализует ReadWriter,
}                        // но методы делегируются в nil, если поле не заполнено

var x OnlyRead
// x.Read(nil)           // panic: nil pointer dereference - метод есть, реализации нет

// Та же идиома с пользой: обёртка перекрывает один метод
type LoggingStore struct {
    Store                       // все методы Store продвинуты
    log *slog.Logger
}
func (s LoggingStore) Get(id string) (User, error) {   // перекрываем только Get
    s.log.Info("get", "id", id)
    return s.Store.Get(id)                              // делегируем дальше
}

// Мьютекс часто встраивают ради «наследования», но это спорно:
type Counter struct {
    sync.Mutex          // Lock/Unlock стали экспортируемыми методами Counter
    n int
}
// лучше так: мьютекс остаётся деталью реализации
type Counter2 struct {
    mu sync.Mutex
    n  int
}
Когда встраивание уместно
  • Декоратор/обёртка: встроил интерфейс, перекрыл один метод, остальные делегируются автоматически, и не надо писать 20 переходников.
  • Расширение чужого типа: type MyRequest struct { *http.Request; TraceID string }.
  • Общий кусок состояния: type Base struct{ ID int; CreatedAt time.Time } во множестве моделей.

И когда неуместно: ради «наследования» поведения. Если тянет написать «базовый класс с виртуальным методом», объявляй интерфейс и передавай зависимость снаружи, иначе получишь тихий баг из примера с Describe().

Внутреннее устройство: iface, eface, itab

Интерфейсное значение - всегда два машинных слова. Что именно в них лежит, зависит от того, пустой интерфейс или нет.

eface - пустой интерфейс (any): 2 слова _type *rtype data unsafe.Pointer runtime._type size, kind, hash, equal сами данные в куче или inline iface - непустой интерфейс: 2 слова tab *itab data unsafe.Pointer itab inter *interfacetype _type *rtype hash uint32 fun [N]uintptr указатели на методы сами данные *T или копия T fun[0] → метод 1 fun[1] → метод 2 Вызов метода через интерфейс: i.Foo() 1. взять i.tab 2. fun[индекс метода] 3. вызвать с i.data inline невозможен itab кэшируется в глобальной хеш-таблице по паре (интерфейс, конкретный тип): создаётся один раз на пару. Статические конверсии компилятор резолвит на этапе компиляции - itab лежит прямо в бинаре. Цена интерфейса: косвенный вызов, отсутствие инлайна, часто - аллокация при упаковке значения.
eface и iface. У пустого интерфейса первое слово - просто дескриптор типа, у непустого - itab, где к дескриптору типа добавлена таблица методов конкретной пары «интерфейс + тип».
// Упрощённо из runtime/runtime2.go:
type eface struct {           // interface{} / any
    _type *_type
    data  unsafe.Pointer
}
type iface struct {           // любой непустой интерфейс
    tab  *itab
    data unsafe.Pointer
}
type itab struct {
    inter *interfacetype      // описание интерфейса
    _type *_type              // описание конкретного типа
    hash  uint32              // копия _type.hash для быстрого type switch
    _     [4]byte
    fun   [1]uintptr          // на самом деле переменной длины: адреса методов
}
Про аллокацию при упаковке значения в интерфейс

Поле data - всегда указатель. Значит, чтобы положить в интерфейс int, его надо разместить где-то в памяти и взять адрес. Это escape в кучу, и именно поэтому fmt.Println(i) для int в бенчмарке показывает аллокации. Есть две оптимизации: значения-указатели кладутся напрямую (лишней аллокации нет), а маленькие целые от 0 до 255 берутся из предвыделенного массива runtime.staticuint64s. Раньше (до Go 1.4) была ещё «direct interface» для однословных значений; сейчас она работает только для типов, которые сами по себе указатель (указатель, мапа, канал, функция, unsafe.Pointer, а также структура/массив из одного такого поля).

Классика: typed nil в error

Самый частый «убийственный» вопрос секции. Ломает он ровно потому, что «две половинки» казались абстракцией, а тут вдруг решают исход if err != nil.

Что такое typed nil. Есть два разных «ничего», и в Go они не одно и то же:

  • Нулевой указатель - var e *MyError. Переменная существует, её тип известен (*MyError), просто адрес внутри нулевой. Это «конкретное значение, которое ни на что не показывает».
  • Пустой интерфейс-переменная - var err error. Обе половинки нулевые: неизвестно ни какой тип, ни где данные. Это «вообще ничего не положили».

Когда ты пишешь return e из функции с типом результата error, рантайм упаковывает значение: заполняет левую половинку («тип: *MyError») и правую («данные: nil»). Вернулся ярлык с надписью, что внутри, пусть внутри и пусто. А сравнение err == nil проверяет, что пусты обе половинки. Левая не пуста. Значит false.

Та же мысль короче, чтобы проговорить вслух на собесе: «nil-указатель, положенный в интерфейс, - это не nil-интерфейс. Интерфейс запомнил тип, а тип - это не nil».

var err error = (*MyError)(nil) интерфейсное значение err tab *itab(error, *MyError) data nil тип ЕСТЬ → интерфейс не nil настоящий nil-интерфейс: var err error tab = nil data = nil обе половинки nil → err == nil даёт true Правило сравнения i == nil ⟺ i.tab == nil И i.data == nil. Сравнение двух интерфейсов: равны типы И равны значения. Поэтому (*MyError)(nil) != nil, хотя внутри лежит нулевой указатель. Проверка внутри метода - тоже не помогает: метод вызовется на nil-receiver'е и, если он не разыменовывает поля, отработает штатно.
Две половинки. Пока в левом слоте лежит дескриптор типа, интерфейс не равен nil, что бы ни лежало в правом.
type MyError struct {
    Code int
}
func (e *MyError) Error() string { return fmt.Sprintf("code=%d", e.Code) }

// Плохо: возвращаем конкретный тип
func doBad(ok bool) error {
    var e *MyError            // e == nil
    if !ok {
        e = &MyError{Code: 500}
    }
    return e                  // в error упаковали (*MyError)(nil)
}

err := doBad(true)
fmt.Println(err == nil)             // false, хотя e был nil
fmt.Println(err)                    // <nil>
fmt.Printf("%T %v\n", err, err)     // *main.MyError <nil>
// Почему <nil>, а не паника: fmt вызывает Error(), тот разыменовывает e.Code
// и паникует на nil-получателе, а fmt ловит панику и подставляет <nil>.
// Если бы Error() не трогал поля (например, return "boom"), напечаталось бы "boom".
// Вот и подвох: в логе nil, а ветка ошибки выполняется.

// Хорошо, вариант 1: возвращать nil явно
func doGood1(ok bool) error {
    if !ok { return &MyError{Code: 500} }
    return nil                // литеральный nil: обе половинки нулевые
}

// Хорошо, вариант 2: если переменная нужна, проверить её перед возвратом
func doGood2(ok bool) error {
    var e *MyError
    if !ok { e = &MyError{Code: 500} }
    if e == nil { return nil }   // e в интерфейс не упаковывается
    return e
}
Как это выглядит в проде

Чаще всего подводят именованные возвращаемые значения конкретного типа и функции вида func parse() (*Result, *ParseError), которые потом оборачивают. Второй источник - defer: он присваивает в именованный err error переменную конкретного типа. Ещё ловят на json.Unmarshal в интерфейсное поле. Практическое правило: функция, возвращающая error, должна возвращать интерфейсный тип на всех путях и явный nil в успешном случае. Линтер nilness из go vet и staticcheck (SA4023) часть таких случаев ловят.

// Поймать typed nil можно рефлексией, но это лечит симптом
func isNil(i any) bool {
    if i == nil { return true }
    v := reflect.ValueOf(i)
    switch v.Kind() {
    case reflect.Ptr, reflect.Map, reflect.Slice, reflect.Chan, reflect.Func, reflect.Interface:
        return v.IsNil()
    }
    return false
}
// Правильнее вообще не создавать typed nil.

Неявная реализация и проверка на этапе компиляции

В Go нет implements. Тип реализует интерфейс, если его method set содержит все методы интерфейса. Это и есть структурная типизация. Плюсы: можно написать интерфейс после реализации, в том числе для чужого пакета; реализация не зависит от абстракции. Минус: опечатка в имени или сигнатуре метода даёт не «класс не реализует интерфейс», а внезапную ошибку в месте использования, иногда далеко от объявления.

// Идиома: компилятор проверяет, что тип реализует интерфейс
var _ io.Writer      = (*MyWriter)(nil)   // *MyWriter реализует io.Writer
var _ error          = (*MyError)(nil)
var _ sort.Interface = (ByAge)(nil)       // для слайс-типа
var _ Storage        = (*PostgresStore)(nil)

// Создаём типизированный nil нужного типа
// и присваиваем пустой переменной интерфейсного типа.
// Значение никуда не идёт, аллокаций нет: работает только проверка типов.
// Если метода не хватает, компилятор скажет:
//   cannot use (*MyWriter)(nil) as io.Writer value:
//   *MyWriter does not implement io.Writer (missing method Write)
Где ставить такую проверку

Рядом с объявлением типа, в том же файле. Это сразу и тест, и документация: читатель видит, какие контракты тип обязан выполнять. Особенно если тип публичный, а интерфейс из другого пакета: без такой строки удалишь метод при рефакторинге, компиляция пройдёт, а сломается у пользователей библиотеки.

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

any - псевдоним для interface{}, добавленный в Go 1.18 (type any = interface{}). Его реализует любой тип, потому что требований ноль. Цена: ты теряешь всю информацию о типе, и вернуть её можно только через рантайм-проверку.

var x any = 42

// 1. Небезопасная форма паникует при несовпадении
n := x.(int)          // 42
// s := x.(string)    // panic: interface conversion: interface {} is int, not string

// 2. Безопасная форма (comma-ok) никогда не паникует
if s, ok := x.(string); ok {
    fmt.Println("строка:", s)
} else {
    fmt.Println("не строка")               // ← сюда: x это int
}

// 3. Type switch ветвится по динамическому типу
switch v := x.(type) {
case nil:
    fmt.Println("nil интерфейс")           // отдельный case для nil
case int:
    fmt.Println("int", v*2)                // ← сюда, печатает: int 84
case string, []byte:
    fmt.Println("текстовое", v)            // а тут v имеет тип any
case error:
    fmt.Println("ошибка", v.Error())       // можно проверять и на интерфейс
case fmt.Stringer:
    fmt.Println("Stringer", v.String())
default:
    fmt.Printf("неизвестный тип %T\n", v)
}
// весь блок при x = 42 печатает:
// не строка
// int 84
Три подвоха в type switch
  • Несколько типов в одном case - переменная v сохраняет интерфейсный тип, а не конкретный. Компилятор не может выбрать один.
  • Порядок case имеет значение, если среди них есть интерфейсы: первый подходящий выигрывает. case error перед case *MyError перехватит всё.
  • case nil срабатывает только для настоящего nil-интерфейса. Typed nil попадёт в case своего типа, и это ещё один способ поймать проблему из предыдущего раздела.

Когда any оправдан: границы, где типы принципиально неизвестны (fmt.Println, json.Unmarshal, кэш общего назначения, логгер с произвольными атрибутами). Когда плох: внутри доменной логики. Он выключает проверку типов, добавляет аллокации на упаковку, заставляет писать switch вместо полиморфизма, и с Go 1.18 в большинстве таких мест правильнее дженерики.

Method set: T против *T

Method set - множество методов, доступных для типа при проверке «реализует ли он интерфейс». Правило звучит асимметрично, и именно на нём чаще всего спотыкаются:

  • Method set типа T - только методы с value receiver.
  • Method set типа *T - методы с value receiver и с pointer receiver.
type T struct{}; func (t T) Val(); func (t *T) Ptr() method set T = { Val } Val() - value recv Ptr() - НЕ входит var t T t.Ptr() КОМПИЛИРУЕТСЯ: сахар для (&t).Ptr(), t адресуема но var i I = t - ОШИБКА, если I требует Ptr() method set *T = { Val, Ptr } Val() - входит Ptr() - входит var p *T = &t p.Val() работает: сахар для (*p).Val() var i I = p - ОК для любого I Почему асимметрия Из *T всегда можно получить T - разыменовать. Из T получить *T можно, только если значение АДРЕСУЕМО. Копия в интерфейсе - не адресуема, брать её адрес было бы бессмысленно. Если бы разрешили: изменение через Ptr() ушло бы в копию внутри интерфейса и потерялось молча. Go предпочитает ошибку компиляции вместо тихо теряющейся мутации.
Правило method set. Компилятор запрещает не вызов метода, а удовлетворение интерфейса: t.Ptr() вызвать можно, а положить t в интерфейс, требующий Ptr(), - нельзя.
type Speaker interface{ Speak() }

type Dog struct {
    name string
}
func (d *Dog) Speak() { fmt.Println(d.name) }   // POINTER receiver

d := Dog{"Рекс"}
d.Speak()                    // ОК: компилятор пишет (&d).Speak(), d адресуема

// var s Speaker = d         // ошибка компиляции:
//   cannot use d (variable of struct type Dog) as Speaker value in variable
//   declaration: Dog does not implement Speaker (method Speak has pointer receiver)
var s Speaker = &d           // ОК

// Где ещё вылезает: элементы мапы не адресуемы
m := map[string]Dog{"a": {"Рекс"}}
// m["a"].Speak()            // ошибка: cannot call pointer method Speak on Dog

// А элементы слайса адресуемы
sl := []Dog{{"Рекс"}}
sl[0].Speak()                // ОК

// Возвращаемое значение функции не адресуемо
// newDog().Speak()          // ошибка, если newDog() возвращает Dog, а не *Dog

Value receiver или pointer receiver

Бери pointer receiver, если… Бери value receiver, если…
метод изменяет получателя тип маленький и неизменяемый по смыслу (time.Time, Point)
структура большая - копия дорога тип - базовый или именованный поверх него (type Celsius float64)
внутри есть sync.Mutex или другой noCopy тип - уже дескриптор (слайс, мапа, канал, функция)
хотя бы один метод типа уже с указателем нужна безопасность от случайных мутаций
нужно отличать nil-получателя метод - чистая функция от значения
Главное правило: консистентность

Не смешивай receiver'ы у одного типа. Если хоть один метод требует указателя, делай указателями все: иначе method set типа T окажется неполным и сам тип перестанет удовлетворять собственным интерфейсам. А ошибка вылезет где-то далеко. Линтер golangci-lint проверяет это через staticcheck и отдельный recvcheck.

type Counter struct {
    n int
}
func (c Counter) IncBad()  { c.n++ }    // работает с копией, инкремент теряется
func (c *Counter) Inc()    { c.n++ }    // так правильно

c := Counter{}
c.IncBad(); c.IncBad()
fmt.Println(c.n)          // 0 - классический вопрос «что выведет»
c.Inc(); c.Inc()
fmt.Println(c.n)          // 2

// nil-receiver законен и иногда полезен
type List struct {
    head  *node
    count int
}
func (l *List) Len() int {
    if l == nil { return 0 }    // метод на nil-указателе вызывается нормально:
    return l.count              // паника будет только при обращении к полю
}
var l *List
fmt.Println(l.Len())            // 0, без паники

Exported и unexported

Видимость в Go задаёт регистр первой буквы идентификатора, и работает она на уровне пакета, а не файла или типа. Name виден снаружи пакета, name - только внутри. Это касается всего: типов, функций, методов, полей структур, констант, переменных.

package store

type User struct {
    ID    int      // виден снаружи
    name  string   // виден только внутри пакета store
}
func (u *User) Name() string { return u.name }   // геттер экспортирован
func (u *User) validate() error { return nil }   // приватный метод

// Внутри пакета доступны любые поля любого его типа:
// приватность не «на объект», а «на пакет».
func compare(a, b User) bool { return a.name == b.name }   // ок

Три следствия, которые проверяют:

  • JSON и рефлексия видят только экспортированные поля. json.Marshal молча пропустит name, а json.Unmarshal не заполнит его. Отсюда DTO-структуры с экспортированными полями и тегами.
  • Встраивание не «отменяет» приватность. Встроив тип из другого пакета, ты получаешь только его экспортированные методы и поля.
  • Приватный тип с публичными методами - законная и полезная конструкция: функция возвращает интерфейс, а конкретный тип наружу не виден вообще.

«Принимай интерфейсы, возвращай структуры»

Правило сообщества: accept interfaces, return structs. Смысл двухсторонний.

// Принимай минимальный интерфейс
// Функция объявляет только то, что реально
// использует. Тестируется тривиально:
// подставили strings.Builder или bytes.Buffer.

func Save(w io.Writer, u User) error {
    return json.NewEncoder(w).Encode(u)
}

// А не так:
func SaveBad(f *os.File, u User) error {
    return json.NewEncoder(f).Encode(u)
}
// теперь без файла на диске не протестировать
// Возвращай конкретный тип, структуру
// Вызывающий видит все методы и поля,
// может сам решить, какой интерфейс ему нужен.

func NewStore(db *sql.DB) *Store {
    return &Store{db: db}
}

// А не так:
func NewStoreBad(db *sql.DB) Storage {
    return &Store{db: db}
}
// теперь новый метод Store не виден
// пользователям, и легко создать typed nil
Оговорки к правилу
  • Это эвристика, а не догма. Возвращать интерфейс уместно, когда у функции несколько реализаций по условию (NewCache(cfg) отдаёт то Redis, то in-memory), или когда конкретный тип принципиально приватный.
  • Вернув интерфейс, легко нарваться на typed nil: достаточно случайно отдать (*T)(nil).
  • Принимать интерфейс бессмысленно, если он раздутый: Storage с 20 методами так же неподставляем в тест, как *sql.DB.

Где объявлять интерфейс: у потребителя

В Java интерфейс объявляют рядом с реализацией и импортируют оба. В Go принято наоборот: интерфейс живёт в пакете, который его использует. Это возможно именно из-за неявной реализации.

Интерфейс у РЕАЛИЗАЦИИ - как в Java service использует Storage package storage interface Storage + *Postgres import service ЗАВИСИТ от пакета с конкретной реализацией Интерфейс у ПОТРЕБИТЕЛЯ - как в Go package service type Storage interface { Get(id) (User, error) } package postgres type Store struct{...} func (s *Store) Get(...) реализует неявно импорта НЕТ ни в одну сторону Связывает их только main/wire: postgres.New(db) передаётся в service.New(store). Это инверсия зависимостей по Мартину - но без единой строки boilerplate, потому что реализация интерфейса структурная.
Направление зависимости. Интерфейс у потребителя означает, что доменный пакет вообще не знает о существовании базы, драйвера и HTTP. Знает только про свои три метода.
// package service - потребитель объявляет ровно то, что ему нужно
type UserGetter interface {
    GetUser(ctx context.Context, id int64) (User, error)
}

type Service struct {
    users UserGetter
}

func New(u UserGetter) *Service { return &Service{users: u} }

// package postgres - реализация ничего не знает про service
type Store struct {
    db *sql.DB
}
func (s *Store) GetUser(ctx context.Context, id int64) (User, error) { /* ... */ }
func (s *Store) SaveUser(ctx context.Context, u User) error          { /* ... */ }
func (s *Store) DeleteUser(ctx context.Context, id int64) error      { /* ... */ }

// main.go - связывание
svc := service.New(postgres.New(db))
Практические выгоды
  • Интерфейсы получаются маленькие. Потребителю нужен один метод, он и объявит интерфейс с одним методом, а не с двадцатью. «The bigger the interface, the weaker the abstraction» (Роб Пайк).
  • Моки генерируются на маленький интерфейс: их проще писать и читать.
  • Нет циклических импортов. Классическая проблема «service импортирует storage, storage импортирует модели service» решается сама собой.
  • Реализацию можно менять свободно: новый метод у *Store ничего не ломает.

Сравнение интерфейсных значений и когда panic

Два интерфейсных значения равны, если совпадают динамические типы и равны динамические значения. Если динамический тип не comparable, будет паника в рантайме: компилятор в общем случае не знает, что там окажется.

var a any = 1
var b any = 1
var c any = int64(1)
var d any = "1"

fmt.Println(a == b)   // true  - одинаковый тип int и одинаковое значение
fmt.Println(a == c)   // false - int != int64, значения даже не сравниваются
fmt.Println(a == d)   // false - разные типы

var s1 any = []int{1}
var s2 any = []int{1}
// fmt.Println(s1 == s2)   // panic: runtime error: comparing uncomparable type []int

// Сравнимость структуры зависит от полей
type P struct {
    X int
}
type B struct {
    S []int
}
var p1, p2 any = P{1}, P{1}
fmt.Println(p1 == p2)      // true
var b1, b2 any = B{}, B{}
// fmt.Println(b1 == b2)   // panic: runtime error: comparing uncomparable type main.B

// Чаще всего сравнивают с nil
var e error
fmt.Println(e == nil)      // true
e = (*MyError)(nil)
fmt.Println(e == nil)      // false - typed nil
Где паника всплывает неожиданно
  • Ключи мапы map[any]V: hash of unhashable type []int.
  • errors.Is: он сравнивает ошибки через ==. Если ошибка - структура со слайсом внутри, будет паника. Поэтому кастомные ошибки либо делают указателями, либо реализуют Is(error) bool.
  • slices.Contains для []any - та же история.
  • Сравнение в тестах: if got != want для any. Используй reflect.DeepEqual или go-cmp.

Рефлексия и её цена

reflect даёт доступ к тем самым двум половинкам прямо из кода: reflect.TypeOf(x) достаёт _type, reflect.ValueOf(x) - пару «тип + данные». Три закона рефлексии (из блога Роба Пайка):

  1. Из интерфейсного значения можно получить reflect.Value.
  2. Из reflect.Value можно вернуться к интерфейсному значению (.Interface()).
  3. Чтобы менять значение через рефлексию, оно должно быть settable, то есть получено через указатель (reflect.ValueOf(&x).Elem()).
type User struct {
    ID   int    `json:"id" db:"user_id"`
    Name string `json:"name"`
}

u := User{1, "Аня"}
t := reflect.TypeOf(u)
v := reflect.ValueOf(u)

for i := 0; i < t.NumField(); i++ {
    f := t.Field(i)
    fmt.Printf("%s %s тег=%q значение=%v\n",
        f.Name, f.Type, f.Tag.Get("db"), v.Field(i).Interface())
}
// ID int тег="user_id" значение=1
// Name string тег="" значение=Аня

// Менять можно только через указатель
p := reflect.ValueOf(&u).Elem()
p.Field(1).SetString("Боря")    // u == {1 Боря}
// v.Field(1).SetString("X")    // panic: reflect: reflect.Value.SetString using unaddressable value
Операция Порядок стоимости Почему
Прямой доступ к полю ~1 нс смещение известно на компиляции
reflect.ValueOf(x).Field(i) десятки нс проверки типов, вычисление смещения в рантайме
v.Interface() аллокация упаковка обратно в интерфейс
json.Marshal сотни нс - мкс обход всей структуры рефлексией + парсинг тегов (кэшируется)
Кодогенерация (easyjson, protobuf) в 3–10 раз быстрее рефлексии нет вообще
Где рефлексия в стандартной библиотеке и что с этим делать

encoding/json, encoding/xml, database/sql (сканирование в any), text/template, fmt (глаголы %v, %+v), sort.Slice (через reflect.Swapper), практически все валидаторы и ORM. Это осознанная сделка: рефлексия платит скоростью за то, что один код работает с любыми типами. Когда она становится узким местом, переходят на кодогенерацию (easyjson, ffjson, protobuf, sqlc) или на дженерики. Ещё деталь: json кэширует разобранную структуру типа в sync.Map, поэтому первый Marshal дороже последующих.

Что выведет код?

// (A)
type E struct{}
func (e *E) Error() string { return "e" }

func f() error {
    var p *E
    return p
}

func main() {
    err := f()
    fmt.Println(err == nil)
    fmt.Println(err)
    if err != nil {
        fmt.Println("обработка ошибки")
    }
}
// (B)
type A struct {
    n int
}
func (a A) Show()  { fmt.Println("A", a.n) }
func (a *A) Inc()  { a.n++ }

type B struct {
    A
}
func (b B) Show()  { fmt.Println("B", b.n) }

func main() {
    b := B{A{1}}
    b.Show()
    b.A.Show()
    b.Inc()
    b.Show()
    var s interface{ Show() } = b
    s.Show()
}

(A) → false, затем e, затем обработка ошибки (fmt.Printf("%T %v", err, err) дал бы *main.E e). Классический typed nil: в интерфейсе лежит тип *E и данные nil, поэтому err != nil. Печатается именно e, а не <nil>: fmt вызывает Error() на nil-получателе, а этот метод не трогает поля e и спокойно возвращает строку. <nil> вы увидели бы, только если бы Error() разыменовал получателя - тогда он бы паниковал, а fmt перехватил бы панику и подставил <nil>. Оба варианта одинаково коварны: сообщение в логе выглядит безобидно, а ветка ошибки при этом выполняется.

(B) → B 1, A 1, затем B 2, затем B 2. b.Show() берёт перекрытый метод B; b.A.Show() - явно метод A. b.Inc() компилируется как (&b.A).Inc(), потому что b адресуема, и меняет встроенное поле. А вот var s interface{ Show() } = b кладёт в интерфейс копию b: если бы после этого мы вызвали b.Inc(), s.Show() всё равно напечатал бы старое значение.

Вопросы

14
Суть: в Go есть инкапсуляция (регистр буквы) и полиморфизм (интерфейсы с неявной реализацией), но нет классов и нет наследования - вместо него композиция через встраивание.

Что есть

  • Данные и поведение отдельно: struct хранит состояние, методы объявляются на уровне пакета с указанием receiver'а. Метод можно добавить любому именованному типу своего пакета, включая type Celsius float64.
  • Инкапсуляция на уровне пакета: регистр первой буквы. Внутри пакета видно всё.
  • Полиморфизм: интерфейсы. Реализация неявная: методы совпали, тип подходит.
  • Композиция: встраивание типов с продвижением методов.

Чего нет и чем заменено

Нет Вместо этого
класс struct + методы пакета
наследование реализации встраивание (композиция)
виртуальные методы / super интерфейс + явная делегация
абстрактный класс интерфейс + встроенная структура с общим кодом
конструктор NewT() по соглашению; полезное нулевое значение
перегрузка методов разные имена, вариативные аргументы, функциональные опции
исключения error как обычное значение

Почему авторы отказались от наследования

  1. Хрупкий базовый класс. Поправил родителя, и наследники сломались, причём неочевидно; в глубокой иерархии ещё попробуй проследить вызов.
  2. Наследование - самая сильная связность. Подкласс зависит от реализации родителя, а не от одного контракта.
  3. Ромб. Множественное наследование заставляет придумывать правила разрешения конфликтов; Go объявляет неоднозначность ошибкой компиляции.
  4. Композиция гибче. Поведение меняешь подстановкой зависимости, а не переопределением метода.
Как это звучит на собесе

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

Суть: встраивание - это поле без имени. Методы и поля встроенного типа продвигаются наружу как сахар для делегирования. Виртуальной диспетчеризации нет: метод встроенного типа всегда вызывает методы встроенного типа.
type Animal struct {
    Name string
}
func (a Animal) Speak() string    { return a.Name + " звучит" }
func (a Animal) Describe() string { return "Я " + a.Speak() }

type Dog struct {
    Animal            // имя поля = имя типа
    Breed string
}
func (d Dog) Speak() string { return d.Name + " гавкает" }   // перекрытие

d := Dog{Animal{"Рекс"}, "лабрадор"}
d.Name          // сахар для d.Animal.Name
d.Speak()       // "Рекс гавкает"  - метод Dog закрыл продвинутый
d.Animal.Speak()// "Рекс звучит"   - явный доступ
d.Describe()    // "Я Рекс звучит", а не "гавкает"

Правила разрешения имени

  1. Побеждает меньшая глубина. Метод внешнего типа закрывает продвинутый с глубины 1 и ниже.
  2. Ничья на одной глубине - ошибка ambiguous selector, но только в момент обращения к имени. Просто встроить два типа с одинаковым методом можно.
  3. Встраивать можно T, *T и интерфейс. Method set зависит от того, что встроено: у struct{ T } продвигаются value-методы T в method set структуры и все методы в method set указателя на неё; у struct{ *T } продвигаются все методы T в оба набора.

Чем это НЕ является

Наследованием. Внутри Animal.Describe receiver имеет тип Animal, и о существовании Dog он ничего не знает: вызов a.Speak() статически связан с Animal.Speak. В Java здесь сработал бы полиморфизм. Это самая частая ошибка приходящих из ООП-языков: пишут «шаблонный метод» через встраивание и получают тихо неправильное поведение.

Где встраивание правда полезно

// 1) Декоратор: встроили интерфейс, перекрыли один метод
type LoggingStore struct {
    Storage                    // остальные методы делегируются автоматически
    log *slog.Logger
}
func (s LoggingStore) Get(id string) (User, error) {
    s.log.Info("get", "id", id)
    return s.Storage.Get(id)
}

// 2) Расширяем чужой тип
type Request struct {
    *http.Request
    TraceID string
}

// 3) Общие поля моделей
type Base struct {
    ID        int64
    CreatedAt time.Time
}
type User struct {
    Base
    Email string
}
Что тут ещё ломается
  • Встроенный интерфейс = nil-поле. struct{ io.Reader } формально реализует io.Reader, но при вызове упадёт nil pointer dereference, если поле не заполнили.
  • Встроенный sync.Mutex делает Lock/Unlock публичными методами твоего типа, и снаружи смогут заблокировать твой объект. Лучше именованное поле mu sync.Mutex.
  • JSON. Встроенная структура «разворачивается» в плоский объект, а встроенная с тегом json:"base" - вкладывается. Часто удивляет.
  • Конфликт имён с String() у двух встроенных типов ломает fmt неочевидным образом.
Суть: интерфейсное значение - два машинных слова. У пустого интерфейса это (*_type, data) - eface; у непустого (*itab, data) - iface, где itab содержит таблицу методов для пары «интерфейс + конкретный тип».
type eface struct {
    _type *_type
    data  unsafe.Pointer
}
type iface struct {
    tab  *itab
    data unsafe.Pointer
}

type itab struct {
    inter *interfacetype   // какой интерфейс
    _type *_type           // какой конкретный тип
    hash  uint32           // копия _type.hash, ускоряет type switch
    fun   [1]uintptr       // переменной длины: адреса методов
}

Как происходит вызов

  1. Берём i.tab.
  2. Берём tab.fun[k], где k - индекс метода, известный на этапе компиляции (методы в itab отсортированы по имени).
  3. Вызываем по этому адресу, передав i.data как receiver.

Это косвенный вызов: инлайн невозможен, ветвление предсказывается хуже. Отсюда правило «интерфейс в горячем цикле стоит денег». Devirtualization в компиляторе Go есть, но работает только когда конкретный тип очевиден статически.

Откуда берётся itab

Для статически известной пары (интерфейс, тип) itab создаёт компилятор, и таблица лежит прямо в бинаре. Для динамических случаев (type assertion к интерфейсу) он создаётся в рантайме и кэшируется в глобальной хеш-таблице: по одному на пару, навсегда.

Что с данными

data - всегда указатель. Поэтому, когда значение упаковывают в интерфейс, его обычно приходится уводить в кучу (escape). Исключения:

  • значение само по себе указатель (*T, map, chan, func) - кладётся напрямую;
  • маленькие целые 0..255 берутся из runtime.staticuint64s;
  • nil-интерфейс - оба слова нулевые.
var x any = 42       // константа: лежит в read-only данных, аллокации нет
var y any = &s       // указатель кладётся в data напрямую, без аллокации
var z any            // eface{nil, nil}
fmt.Println(unsafe.Sizeof(x))   // 16 - два слова
Чем добить

«Из-за того, что data - указатель, интерфейс не может хранить значение инлайн. До Go 1.4 существовала оптимизация direct interface для любых однословных значений, но она усложняла GC, и её оставили только для типов, которые сами по себе указатель. Это же объясняет, почему fmt.Println(i) для int показывает аллокацию в бенчмарке, а для *T - нет.»

Суть: интерфейс равен nil, только если обе половинки нулевые. При возврате (*MyError)(nil) тип известен, поэтому tab != nil - и err != nil.
type MyError struct {
    Code int
}
func (e *MyError) Error() string { return "boom" }

func do() error {
    var e *MyError        // e == nil
    return e              // упаковали (*MyError)(nil) в интерфейс error
}

err := do()
fmt.Println(err == nil)   // false
fmt.Println(err)          // boom - Error() спокойно отработал на nil-получателе,
                          // потому что не трогает поля e. Если бы трогал (e.Code),
                          // он бы паниковал, fmt перехватил бы панику и напечатал <nil>
fmt.Printf("%T\n", err)   // *main.MyError
if err != nil { /* сюда попадём */ }

В интерфейсе лежит {tab: itab(error, *MyError), data: nil}. Сравнение err == nil проверяет оба слова, и первое непустое. Печатается <nil>, потому что Error() на nil-получателе паникует, а fmt ловит эту панику и, увидев нулевой указатель, подставляет <nil>. Отсюда фирменный симптом: в логе написано nil, а ветка ошибки выполняется.

Три источника в реальном коде

  1. Функция возвращает конкретный тип ошибки и результат присваивают в error: func parse() *ParseError → var err error = parse().
  2. Именованное возвращаемое значение конкретного типа плюс defer, который его трогает.
  3. Поле-интерфейс в структуре, куда кладут указатель, который может быть nil (частый случай в конфигурациях и опциях).

Как правильно

// 1) Возвращать литеральный nil на успешном пути
func do() error {
    if bad { return &MyError{500} }
    return nil
}

// 2) Если переменная нужна, проверить перед возвратом
func do2() error {
    var e *MyError
    if bad { e = &MyError{500} }
    if e == nil { return nil }
    return e
}

// 3) Не возвращать из функций конкретный тип ошибки
// func parse() (*Result, *ParseError)   // плохо
func parse() (*Result, error)            // хорошо
Добивки, которые любят
  • «А если добавить в метод Error() проверку на nil?» - не поможет: проблема не в вызове метода, а в сравнении интерфейса с nil. Метод вообще может не вызываться.
  • «А errors.Is(err, nil)?» - вернёт false по той же причине.
  • «Как поймать?» - go vet (анализ nilness) и staticcheck ловят часть случаев; надёжнее договориться никогда не возвращать конкретный тип ошибки.
  • Это касается не одного error: то же самое с любым интерфейсом, включая io.Reader и context.Context.
Суть: в Go нет implements - тип реализует интерфейс, если совпал method set. Проверить явно можно строкой var _ I = (*T)(nil), которая ничего не стоит в рантайме.

Что даёт неявная реализация

  • Интерфейс можно написать после реализации, в том числе для чужого пакета, который ты не можешь менять. *os.File реализует твой type Syncer interface{ Sync() error }, ничего об этом не зная.
  • Реализация не зависит от абстракции. Пакет postgres не импортирует пакет service, где объявлен интерфейс. Это инверсия зависимостей без boilerplate.
  • Интерфейсы получаются маленькими, потому что их пишет потребитель под свою нужду.
  • Нет циклических импортов вида «абстракция ↔ реализация».

Цена

  • Опечатка в сигнатуре не даёт понятной ошибки «не реализует» рядом с типом. Всплывёт она в месте присваивания, иногда в другом пакете.
  • Совпадение имён случайно: тип может «реализовать» интерфейс, не имея такого намерения (в Go это встречается редко, но, например, метод String() нечаянно делает тип fmt.Stringer и меняет вывод в логах).
  • Инструментам сложнее показать «кто реализует этот интерфейс», хотя gopls умеет.

Проверка на этапе компиляции

var _ io.Writer      = (*MyWriter)(nil)   // указатель реализует
var _ error          = (*MyError)(nil)
var _ Storage        = (*PostgresStore)(nil)
var _ fmt.Stringer   = Weekday(0)         // для value receiver - значение
var _ sort.Interface = ByAge(nil)         // для слайс-типа

_ - пустой идентификатор, значение никуда не сохраняется и в бинарь не попадает. (*T)(nil) - типизированный nil, аллокации нет. Работает только проверка типов. Сообщение при ошибке максимально конкретное:

cannot use (*MyWriter)(nil) as io.Writer value in variable declaration:
	*MyWriter does not implement io.Writer (missing method Write)
Практика

Ставь такую строку рядом с объявлением типа, в том же файле, для всех интерфейсов, которые тип обязан выполнять. Это дешёвая документация и защита от рефакторинга: без неё удалишь метод, свой пакет соберётся, а сломается у пользователей. Второй частый приём: var _ = (*T).Method проверяет сигнатуру конкретного метода.

Суть: any - алиас для interface{} (Go 1.18), его реализует любой тип, потому что требований ноль. Он выключает статическую типизацию, поэтому уместен только на границах, где типы принципиально неизвестны.

Где оправдан

  • Форматирование и логирование: fmt.Println(a ...any), slog.Info(msg, args ...any).
  • Сериализация: json.Unmarshal(data, v any) - тип известен только вызывающему.
  • Хранилища общего назначения: sync.Map, context.WithValue.
  • Плагины и динамические данные: разбор конфигов неизвестной схемы.

Чем плох

  1. Нет проверки типов. Ошибка переезжает из компиляции в рантайм: паника при assertion или тихо неверная ветка switch.
  2. Аллокации. Упаковка значения в интерфейс обычно уводит его в кучу.
  3. Читаемость. Сигнатура func Process(v any) any не говорит ничего.
  4. Заражает код. Один any в глубине заставляет писать assertion'ы на всех уровнях выше.
  5. Ключи мап и сравнения начинают паниковать на не-comparable типах.

Чем заменить

// Было (до 1.18):
func Max(a, b any) any { /* type switch, паники, боль */ }

// Стало - дженерики:
func Max[T cmp.Ordered](a, b T) T { if a > b { return a }; return b }

// Было: контейнер на any
type Stack struct {
    items []any
}
// Стало:
type Stack[T any] struct {
    items []T
}

// Было: фиксированный набор вариантов через any
func Handle(v any) { switch v.(type) { case A: ; case B: } }
// Стало: интерфейс с методом - полиморфизм вместо switch
type Handler interface{ Handle() }
Тонкости, которые ценят
  • any и interface{} - буквально один тип (type any = interface{}, алиас через =), а не два разных. Заменяются друг на друга без конверсий.
  • С Go 1.18 any работает и как констрейнт в дженериках, то есть «любой тип», в отличие от comparable.
  • []any - не то же самое, что []int: слайсы не ковариантны, конверсия требует поэлементного копирования с упаковкой.
Суть: i.(T) - извлечение конкретного типа из интерфейса в рантайме. Односложная форма паникует при несовпадении, двухсложная (comma-ok) - нет. switch v := i.(type) - ветвление по динамическому типу.
var i any = "привет"

s := i.(string)              // "привет"
// n := i.(int)              // panic: interface conversion: interface {} is string, not int

s, ok := i.(string)          // "привет", true - безопасно
n, ok := i.(int)             // 0, false - паники нет

// Assertion к интерфейсу проверяет, реализует ли динамический тип ещё и его
if st, ok := i.(fmt.Stringer); ok { fmt.Println(st.String()) }
                             // ok == false: у string нет метода String(), ничего не напечатается
if rc, ok := r.(io.ReadCloser); ok { defer rc.Close() }

Type switch

switch v := i.(type) {
case nil:
    // только настоящий nil-интерфейс, typed nil сюда не попадёт
case int:
    fmt.Println(v + 1)          // v имеет тип int
case string:
    fmt.Println(len(v))         // v имеет тип string
case []byte, [][]byte:
    fmt.Println(v)              // а тут v имеет тип any
case error:
    fmt.Println(v.Error())      // можно матчить и на интерфейсы
default:
    fmt.Printf("%T\n", v)
}

Что важно знать

  • Assertion работает только на интерфейсных значениях. x.(T) для конкретного типа - ошибка компиляции. Это не то же самое, что конверсия T(x).
  • Порядок case важен, если среди них есть интерфейсы: первый подходящий выигрывает. case error перед case *MyError перехватит всё.
  • Несколько типов в одном case - v остаётся интерфейсом.
  • switch i.(type) без присваивания тоже валиден, если значение не нужно.
  • Стоимость: сравнение указателей на _type плюс, для assertion к интерфейсу, поиск и создание itab. Дёшево, но не бесплатно: в горячем цикле от assertion лучше избавиться архитектурно.

Где применяется в реальном коде

// 1) Опциональные интерфейсы, идиома стандартной библиотеки
func Copy(dst io.Writer, src io.Reader) (int64, error) {
    if wt, ok := src.(io.WriterTo); ok { return wt.WriteTo(dst) }  // быстрый путь
    if rf, ok := dst.(io.ReaderFrom); ok { return rf.ReadFrom(src) }
    // медленный путь через буфер
}

// 2) Разбор ошибок, но лучше errors.As
var pathErr *fs.PathError
if errors.As(err, &pathErr) { fmt.Println(pathErr.Path) }   // умеет разворачивать цепочку
if pe, ok := err.(*fs.PathError); ok { }                    // не умеет - только верхний уровень

// 3) Разбор произвольного JSON
var data any
json.Unmarshal(b, &data)
m := data.(map[string]any)      // числа будут float64
n := m["count"].(float64)
На чём ловят

json.Unmarshal в any кладёт все числа как float64, а объекты как map[string]any. Assertion к int паникует. Лечится либо json.Number через UseNumber(), либо нормальной типизированной структурой. Второе: err.(*MyError) вместо errors.As не пробивает обёртки fmt.Errorf("%w"), и проверка молча перестаёт работать после первого же оборачивания.

Суть: method set T = только value-методы; method set *T = value + pointer. Причина: из указателя всегда можно получить значение, а из значения указатель - только если оно адресуемо, а копия внутри интерфейса не адресуема.
type T struct {
    n int
}
func (t T)  Val() {}
func (t *T) Ptr() {}

// method set T  = { Val }
// method set *T = { Val, Ptr }

type I interface{ Ptr() }

var t T
t.Ptr()              // компилируется: сахар для (&t).Ptr(), ведь t адресуема
// var i I = t       // ошибка: T does not implement I (method Ptr has pointer receiver)
var i I = &t         // ОК

Почему запрет именно на интерфейс, а не на вызов

Вызов t.Ptr() компилятор разрешает, потому что видит переменную и может взять её адрес. А когда значение кладут в интерфейс, оно копируется в кучу, и эта копия не имеет стабильного имени. Разреши Go такое, метод с pointer receiver менял бы копию внутри интерфейса, а не исходную переменную, и мутация терялась бы молча. Go предпочёл ошибку компиляции.

Где ещё срабатывает адресуемость

Выражение Адресуемо? Можно вызвать pointer-метод?
переменная t да да
поле структуры s.f (если s адресуема) да да
элемент слайса sl[0] да да
элемент массива-переменной arr[0] да да
элемент мапы m["k"] нет нет
результат функции f() нет нет
значение внутри интерфейса нет нет
константа, литерал T{} нет нет (но (&T{}).Ptr() можно)
m := map[string]T{"a": {}}
// m["a"].Ptr()          // ошибка: cannot call pointer method Ptr on T
v := m["a"]; v.Ptr()     // так - через переменную

sl := []T{{}}
sl[0].Ptr()              // ОК: элемент слайса адресуем

// Обратное направление работает всегда:
p := &T{}
p.Val()                  // сахар для (*p).Val()
Как это формулировать

«Method set - это про удовлетворение интерфейса, а не про возможность вызвать метод. Вызвать pointer-метод у адресуемого значения можно всегда - компилятор сам подставит &. А вот положить значение в интерфейс, требующий pointer-метод, нельзя, потому что копия внутри интерфейса не адресуема и мутация потерялась бы.»

Суть: pointer - если метод меняет получателя, структура большая или внутри есть мьютекс. Value - для маленьких неизменяемых типов. Главное - не смешивать у одного типа.
Pointer receiver (t *T) Value receiver (t T)
метод изменяет поля метод только читает
структура большая (копия дорога) тип маленький: time.Time, Point, число
внутри sync.Mutex, sync.WaitGroup, strings.Builder тип - уже дескриптор: слайс, мапа, канал, функция
надо отличать nil-получателя нужна гарантия неизменяемости
хоть один метод типа уже с указателем все методы типа уже value
type Counter struct {
    n int
}
func (c Counter) IncBad() { c.n++ }     // меняет копию, инкремент теряется
func (c *Counter) Inc()   { c.n++ }     // правильно

c := Counter{}
c.IncBad(); fmt.Println(c.n)   // 0
c.Inc();    fmt.Println(c.n)   // 1

// Мьютекс + value receiver = тихий баг: каждый вызов блокирует свою копию
type Bad struct {
    mu sync.Mutex
    n  int
}
func (b Bad) Lock() { b.mu.Lock() }     // go vet: passes lock by value

Аргументы «за value», которые часто забывают

  • Копия иногда дешевле, чем разыменование. Для структуры из одного-двух слов копия остаётся в регистрах, а указатель добавляет косвенность и может увести объект в кучу (escape analysis).
  • Безопасность. Value receiver гарантирует, что метод не изменит получателя. Для конкурентного кода это ценно само по себе.
  • Method set шире. С value receiver и T, и *T удовлетворяют интерфейсу; с pointer receiver - только *T.

Про «копия дорогая» - где граница

Практический порог обычно называют в районе нескольких машинных слов (примерно до 4–5 полей скалярных типов). Но реальный ответ - померить: escape analysis может свести разницу к нулю или, наоборот, указатель заставит объект уехать в кучу и добавит работы GC. Смотри через go build -gcflags='-m' и бенчмарк.

Правило, которое проговаривают вслух

Консистентность важнее микрооптимизации. Если хотя бы одному методу нужен указатель, делай указателями все методы типа. Иначе method set T окажется неполным, тип перестанет удовлетворять собственным интерфейсам, а ошибка вылезет далеко от объявления. Проверяется линтерами (staticcheck, recvcheck в golangci-lint). И go vet ловит копирование мьютексов анализатором copylocks.

Суть: видимость определяется регистром первой буквы и работает на уровне пакета: заглавная - видно снаружи пакета, строчная - только внутри. Промежуточных уровней (protected, friend) нет.
package store

type User struct {
    ID   int      // экспортировано
    name string   // нет - видно только внутри пакета store
}
func New() *User            { return &User{} }   // экспортировано
func (u *User) Name() string{ return u.name }    // геттер
func (u *User) validate() error { return nil }   // приватный метод

// Внутри пакета видно всё, включая приватные поля чужих типов этого же пакета:
func eq(a, b User) bool { return a.name == b.name }   // ок

Что важно знать

  • Гранулярность - пакет, а не файл и не тип. Два файла одного пакета видят приватные идентификаторы друг друга.
  • Рефлексия видит только экспортированные поля. json.Marshal молча пропустит name, Unmarshal не заполнит. reflect.Value.Field(i).Interface() для приватного поля паникует.
  • Регистр смотрится по первой руне, и это Unicode: Имя - экспортировано (заглавная кириллическая), _x и имя - нет.
  • Приватный тип с публичными методами - законно и полезно: функция возвращает интерфейс, конкретный тип наружу не виден.
  • internal/ - второй механизм: пакет внутри a/b/internal/c импортируется только из поддерева a/b. Экспортированные имена там всё равно недоступны снаружи.

Идиомы вокруг видимости

// 1) Приватное поле + функциональные опции вместо публичных полей
type Server struct {
    timeout time.Duration
}
type Option func(*Server)
func WithTimeout(d time.Duration) Option { return func(s *Server){ s.timeout = d } }
func NewServer(opts ...Option) *Server { /* ... */ }

// 2) Приватный тип, публичный интерфейс
type storage struct {
    db *sql.DB
}
func NewStorage(db *sql.DB) Storage { return &storage{db} }  // тип наружу не виден

// 3) Запрет на литерал без имён полей - приватное поле-маркер: новые поля не сломают вызывающих
type Config struct {
    Name string
    _    struct{}   // литерал Config{"x"} без имён полей не скомпилируется вне пакета
}
Про экспорт и обратную совместимость

Всё экспортированное становится частью публичного API и попадает под гарантии совместимости (для стандартной библиотеки - Go 1 compatibility promise). Отсюда практика: экспортируй минимум, начинай с приватного и открывай по запросу. Спрятать публичное обратно уже не выйдет тихо: это ломающее изменение, оно требует новой major-версии модуля.

Суть: параметр-интерфейс делает функцию максимально подставляемой и тестируемой; возврат конкретного типа не скрывает от вызывающего возможности и не создаёт typed nil.

Почему принимать интерфейс

  • Функция объявляет минимум требований: io.Writer вместо *os.File. В тесте подставишь bytes.Buffer, в проде туда пойдёт файл, сокет или gzip-обёртка.
  • Пропадает зависимость от конкретного пакета, а значит, легче ломать циклы импортов.
  • Интерфейс документирует контракт: видно, что именно функции нужно.

Почему возвращать структуру

  • Вызывающий видит все методы и поля и сам решает, какая абстракция ему нужна. Вернув интерфейс, ты навязываешь свой выбор и скрываешь остальное.
  • Новый метод у конкретного типа ничего не ломает. Новый метод в интерфейсе ломает все реализации.
  • Возврат интерфейса - прямой путь к typed nil, если внутри вернуть (*T)(nil).
  • Конкретный тип позволяет компилятору инлайнить и девиртуализировать вызовы.
// Хорошо
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
func Save(w io.Writer, u User) error { return json.NewEncoder(w).Encode(u) }

// Плохо
func NewStore2(db *sql.DB) Storage { return &Store{db: db} }   // скрыли тип
func Save2(f *os.File, u User) error { }                       // прибили к файлу

Когда правило нарушают осознанно

  1. Фабрика с несколькими реализациями: func NewCache(cfg) Cache возвращает то Redis, то in-memory, так что тип известен только в рантайме.
  2. Конкретный тип принципиально приватный - тогда наружу отдают интерфейс.
  3. Стандартная библиотека так делает: net.Listen отдаёт net.Listener, sql.DB.Begin - *sql.Tx (тут как раз структура). То есть даже stdlib выбирает по ситуации.
Антипаттерн, который сразу видно

Пакет объявляет интерфейс Storage с 15 методами рядом со своей единственной реализацией и возвращает его из конструктора «чтобы можно было замокать». В итоге: интерфейс дублирует тип один-в-один, мок на 15 методов, вызывающий не видит новых методов, а тестируемость не выросла. Правильно так: интерфейс на 1–3 метода в пакете-потребителе, а конструктор возвращает *Store.

Суть: у потребителя. Это возможно благодаря неявной реализации и даёт инверсию зависимостей без единой строки boilerplate.
// package service - потребитель объявляет ровно то, что нужно ему
type UserGetter interface {
    GetUser(ctx context.Context, id int64) (User, error)
}
type Service struct {
    users UserGetter
}
func New(u UserGetter) *Service { return &Service{users: u} }

// package postgres - про service не знает вообще
type Store struct {
    db *sql.DB
}
func (s *Store) GetUser(ctx context.Context, id int64) (User, error) { /* ... */ }
func (s *Store) SaveUser(...) error { /* ... */ }

// main.go - единственное место, где они встречаются
svc := service.New(postgres.New(db))

Что это даёт

  1. Правильное направление зависимостей. Доменный пакет не импортирует пакет с инфраструктурой. Это Dependency Inversion Principle, только бесплатный: реализация-то структурная.
  2. Маленькие интерфейсы. Потребителю нужен один метод, он и объявит интерфейс с одним методом. «The bigger the interface, the weaker the abstraction» (Роб Пайк).
  3. Простые моки. Мок на один метод пишется руками за минуту; мок на 20-методовый интерфейс генерируют и никто не читает.
  4. Нет циклических импортов между слоем абстракции и слоем реализации.
  5. Реализацию можно свободно расширять: новый метод у *Store ничего не ломает, потому что интерфейсы объявлены отдельно.

Когда интерфейс всё-таки живёт у реализации

  • Контракт для внешнего мира: io.Reader, error, http.Handler, sort.Interface - это интерфейсы, которые реализуют чужие типы, и объявить их у каждого потребителя было бы абсурдом.
  • Плагинная архитектура: пакет определяет точку расширения, и десятки внешних реализаций подключаются к ней.
  • Несколько реализаций внутри одного пакета, между которыми переключаются конфигом.
Как это звучит на собесе

«Интерфейс - это потребность потребителя, а не свойство реализации. Поэтому в Go его объявляют там, где используют. Технически это возможно из-за структурной типизации: реализации не надо объявлять, что она что-то реализует. Исключение - интерфейсы, которые задуманы как точка расширения для чужого кода: io.Reader, http.Handler, error.»

Суть: равны, если совпали динамические типы и равны динамические значения. Если динамический тип не comparable (слайс, мапа, функция или структура с таким полем) - panic: comparing uncomparable type.
var a any = 1
var b any = 1
var c any = int64(1)
fmt.Println(a == b)   // true
fmt.Println(a == c)   // false - типы разные, значения даже не сравниваются

var s1 any = []int{1}
var s2 any = []int{1}
// s1 == s2           // panic: runtime error: comparing uncomparable type []int

type P struct {
    X int
}
type B struct {
    S []int
}
var p1, p2 any = P{1}, P{1}
fmt.Println(p1 == p2) // true - структура comparable
var b1, b2 any = B{}, B{}
// b1 == b2           // panic: runtime error: comparing uncomparable type main.B

Почему компилятор это не ловит

Потому что статически он видит только интерфейсный тип. Что окажется внутри, выяснится в рантайме. Сравнение двух конкретных слайсов (s1 == s2 для []int) компилятор запрещает сразу, а сравнение интерфейсов запретить не может.

Где всплывает неожиданно

  • map[any]V - при вставке ключа с не-comparable типом: hash of unhashable type.
  • errors.Is - внутри сравнивает через ==. Ошибка-структура со слайсом внутри уронит проверку. Поэтому кастомные ошибки делают указателями или реализуют метод Is(error) bool.
  • Сравнение в тестах: if got != want для any. Нужен reflect.DeepEqual или go-cmp.
  • slices.Contains для []any, sync.Map с такими ключами.

Как сравнивать безопасно

// 1) Сначала проверить сравнимость
if reflect.TypeOf(a).Comparable() { _ = a == b }

// 2) Глубокое сравнение
reflect.DeepEqual(a, b)          // медленно, но не паникует
cmp.Equal(a, b)                  // go-cmp: стандарт в тестах, diff покажет cmp.Diff

// 3) В дженериках: констрейнт comparable
func Index[T comparable](s []T, v T) int { /* компилятор проверит статически */ }
Тонкость про comparable и Go 1.20

До Go 1.20 any не удовлетворял констрейнту comparable именно потому, что сравнение интерфейсов может паниковать. В 1.20 правило смягчили: интерфейсные типы теперь удовлетворяют comparable, а за возможную панику отвечает разработчик. Это позволило писать map[K]V-подобные дженерик-контейнеры с K = any. Деталь мелкая, но по ней видно, что ты следишь за языком.

Суть: рефлексия - доступ из кода к тем самым двум половинкам интерфейса: reflect.TypeOf достаёт дескриптор типа, reflect.ValueOf - пару «тип + данные». Платишь производительностью и потерей статических проверок.

Три закона рефлексии

  1. Из интерфейсного значения можно получить reflect.Value.
  2. Из reflect.Value можно вернуться в интерфейс - .Interface().
  3. Чтобы менять значение, оно должно быть settable: получено через указатель (reflect.ValueOf(&x).Elem()) и относиться к экспортированному полю.
type User struct {
    ID   int    `json:"id" db:"user_id"`
    Name string `json:"name"`
    age  int    // приватное: рефлексия видит, но не может ни прочитать через
}               // Interface(), ни записать

u := User{1, "Аня", 30}
t, v := reflect.TypeOf(u), reflect.ValueOf(u)
for i := 0; i < t.NumField(); i++ {
    f := t.Field(i)
    fmt.Println(f.Name, f.Type, f.Tag.Get("db"), f.IsExported())
}
// ID int user_id true
// Name string  true
// age int  false

p := reflect.ValueOf(&u).Elem()
p.FieldByName("Name").SetString("Боря")   // ок - settable и экспортировано, u == {1 Боря 30}
// v.FieldByName("Name").SetString("X")
//   panic: reflect: reflect.Value.SetString using unaddressable value
// p.FieldByName("age").SetInt(1)
//   panic: reflect: reflect.Value.SetInt using value obtained using unexported field

Цена

Что Порядок Почему
прямой доступ к полю ~1 нс смещение известно на компиляции
v.Field(i) десятки нс проверки, вычисление смещения в рантайме
v.Interface() + аллокация упаковка обратно в интерфейс
json.Marshal сотни нс - мкс обход структуры + разбор тегов (кэшируется в sync.Map)
кодогенерация в 3–10 раз быстрее рефлексии нет вообще

Где применяется

  • encoding/json, encoding/xml, gopkg.in/yaml - маршалинг по тегам структур.
  • database/sql - Scan в произвольные указатели; ORM целиком.
  • text/template, html/template - доступ к полям по имени.
  • fmt - %v, %+v, %#v.
  • sort.Slice - reflect.Swapper; reflect.DeepEqual; валидаторы, DI-контейнеры, мок-генераторы.

Когда не надо

В горячем пути и там, где типы известны. С Go 1.18 большую часть задач, ради которых раньше брали рефлексию (контейнеры, утилиты над коллекциями), закрывают дженерики: статически и без накладных расходов. Если рефлексия нужна ради сериализации и стала узким местом, переходят на кодогенерацию: easyjson, protobuf, sqlc, go generate.

Что скажут в ответ на «рефлексия - это плохо»

Сразу приводи цитату Роба Пайка: «Clear is better than clever. Reflection is never clear.» И добавляй конкретику: рефлексия убирает проверки компилятора, ломает рефакторинг (переименовал поле, тег остался старым, тесты зелёные), мешает escape analysis и инлайну, увеличивает бинарь. Но она же единственный способ написать json.Marshal, который работает с любым типом. Это инженерный компромисс, а не «плохо».

Что стоит запомнить

  • Интерфейс хранит динамический тип и данные.
  • Реализация неявна и определяется method set.
  • Интерфейс обычно объявляет потребитель.

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

  • error с typed nil не равен nil.
  • Embedding не создаёт наследование.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Проследи значения

Что напечатает фрагмент? Объясни, почему получился такой результат.

type MyError struct{}
func (*MyError) Error() string { return "boom" }

var p *MyError = nil
var err error = p
fmt.Println(err == nil)
Ответ и разбор

Печатается false: интерфейс содержит динамический тип *MyError и nil-значение этого типа, поэтому весь интерфейс не nil.

Код-ревью

2. Найди ошибку и исправь

Функция должна вернуть nil, если ошибки нет. Исправь реализацию и поясни, почему исходник возвращает ненулевой error.

type MyError struct{}
func (*MyError) Error() string { return "boom" }

func load() error {
  var e *MyError
  return e
}
Ответ и разбор

Проверь указатель до преобразования в интерфейс: func load() error { var e *MyError; if e == nil { return nil }; return e }. При return e интерфейс получает динамический тип *MyError, поэтому интерфейсное значение ненулевое.

Применение

3. Реши задачу

Тип File имеет метод Write с receiver *File. Какое значение удовлетворяет io.Writer: File или *File? Как зафиксировать это проверкой компилятора?

Ответ и разбор

Удовлетворяет *File, потому что pointer method set включает метод с receiver *File; method set значения File его не включает. Добавь compile-time assertion var _ io.Writer = (*File)(nil). Если API должен принимать File, поменяй receiver на value только если метод не требует изменять состояние.

Применение

4. Реши задачу

Функции нужна только операция Get(id) (Item, error), но реализация базы также имеет десятки методов. Как оформить зависимость?

Ответ и разбор

Объяви небольшой интерфейс рядом с потребляющей функцией и оставь в нём только Get. Большая реализация автоматически удовлетворит этому интерфейсу. Так контракт отражает нужду потребителя и не заставляет тестовую подделку реализовывать ненужные методы.