Deep Engineering
Средний·Опубликовано·30 МИН

Интерфейс в Go: два слова, nil, который не nil, и вызов дороже в четыре раза

Собеседование по интерфейсам идёт лестницей: что лежит в переменной — почему nil бывает не nil — когда упаковка выделяет память — во что обходится вызов через интерфейс — как устроено утверждение типа. Урок проходит её целиком, и всё выводится из одного: слов два, а не одно.

Полное техническое изложение

TL;DR

Тип реализует интерфейс молча. Ни implements, ни списка интерфейсов у типа: достаточно, чтобы у него были нужные методы. Отсюда главное следствие для дизайна — интерфейс принадлежит тому, кто его потребляет, объявляется маленьким и рядом с нуждой, а не рядом с реализацией.

И отсюда же два места, где интерфейс ведёт себя не так, как ждут. Первое: метод с получателем-указателем не входит в набор методов значения, и компилятор говорит это прямым текстом — Point does not implement Stringer (method String has pointer receiver). Второе: интерфейсная переменная несёт пару — динамический тип и динамическое значение — и равна nil, только когда пусты обе половины. Положили в неё нулевой указатель: значения нет, а тип известен, и err == nil даёт false. Это самая известная ловушка Go.

Дальше — устройство и числа. В нынешней реализации пара лежит в памяти как два слова: замерено, any и Stringer занимают 16 байт против 8 у указателя. Второе слово — указатель, поэтому упаковка структуры выделяет память (1 выделение), а готового указателя — нет (0); маленькие целые тоже 0, у рантайма готов массив чисел от 0 до 255. Первое слово — таблица методов, поэтому компилятор не видит, какой код будет выполнен, и не встраивает вызов: на пустом методе вышло 11 461 нс против 2804, в 4,1 раза. Утверждение типа и type switch — это сравнение указателя на тип, а не разбор структуры; разница между ними мала (4521–5023 нс против 3661–4128) и на выбор формы не влияет.

Порог входа
Перед уроком достаточно понимать
  • у типа бывают методы, и вызывают их как x.M();
  • функция может принимать значение, о котором знает только то, что оно умеет делать;
  • в Go есть указатели: &x — адрес значения, и по нему значение можно изменить.
Заранее знать не нужно
  • что такое набор методов, динамический тип и упаковка значения в интерфейс;
  • any, itab, утверждение типа, type switch, типизированный nil.

Что здесь на самом деле спрашивают

Лестница почти всегда такая:

  1. «Как устроен интерфейс внутри?» — проверяют, скажете ли вы «два слова» или ограничитесь словом «контракт».
  2. «Почему err != nil сработало, хотя вернули nil — та самая ловушка; спрашивают её чаще всего остального вместе взятого.
  3. «Когда помещение значения в интерфейс выделяет память?» — проверяют, понимаете ли вы, что второе слово — указатель.
  4. «Вызов через интерфейс дороже прямого? Насколько?» — вопрос на честность: «незначительно» и «в разы» одинаково плохи без числа.
  5. «Чем x.(T) отличается от switch x := v.(type) — проверяют, знаете ли вы, что обе формы делают одно и то же.
  6. «Почему мой тип не реализует интерфейс?» (с кодом) — про наборы методов и получатель-указатель.

Дальше урок идёт по этой лестнице снизу вверх: сначала что интерфейс значит, потом что из этого следует, и только в конце — как он устроен в памяти и во что обходится.

База: реализация без объявления

Интерфейс в Go — это список методов, которому дали имя. Stringer ничего не говорит о том, из чего состоит значение и откуда оно взялось; он говорит одно: «это умеет String() string». Функция, объявляющая параметр типа Stringer, тем самым требует не конкретный тип, а способность.

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

контракт языкаГарантия языка: неявная реализация и правила наборов методов заданы спецификацией. Два слова, itab и цена вызова — уже не про язык, а про реализацию, и разбираются отдельно.

bench/gointerface/methodsets.sh собирает и запускает программу, в которой Point не упоминает Stringer ни одним словом:

GO
type Stringer interface{ String() string }
 
type Point struct{ X, Y int }
 
func (p Point) String() string { return fmt.Sprintf("(%d,%d)", p.X, p.Y) }
 
var s Stringer = Point{1, 2}
assigned: (1,2)
--- exit code: 0

Собралось и напечатало. Из этого выводятся два следствия, которые на собеседовании ценят выше, чем знание про устройство интерфейса в памяти.

Интерфейс принадлежит тому, кто его ПОТРЕБЛЯЕТ, а не тому, кто реализует. Объявить Stringer можно в своём пакете — и Point из чужой библиотеки будет его реализовывать, ничего не зная о вашем существовании. В языках с явной реализацией так нельзя: чтобы тип подошёл под интерфейс, его надо изменить.

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

Практическое следствие того же: интерфейсы в Go маленькие. io.Reader — один метод, io.Writer — один. Интерфейс на десять методов почти всегда означает, что его объявили со стороны реализации, а не со стороны нужды.

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

Механизм 1: набор методов — где неявность кончается

Тихая реализация имеет ровно одну границу, и на ней спотыкаются все. Метод с получателем-указателем не входит в набор методов значения.

Тот же скрипт пробует собрать программу, где получатель указательный, а присваивают значение:

GO
func (p *Point) String() string { return "point" }
 
var byValue Stringer = Point{1, 2}     // ошибка
var byPointer Stringer = &Point{1, 2}  // законно
bad.go:10:25: cannot use Point{…} (value of struct type Point) as Stringer value in variable declaration: Point does not implement Stringer (method String has pointer receiver)
--- go build exit code: 1

Ошибку стоит уметь прочитать вслух целиком: она называет причину сама.

Причина не в синтаксисе. Значение, положенное в интерфейс, — это копия, и адреса у неё нет. Метод с получателем-указателем не смог бы изменить оригинал; тихо менять копию было бы хуже ошибки компиляции, поэтому компилятор отказывается заранее.

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

объявлен методвходит в набор Tвходит в набор *T
func (t T) M()дада
func (t *T) M()нетда

Практическое правило: получатели одного вида у всего типа. Смешивать можно, но тогда набор методов зависит от того, что именно присвоено, — и ошибка находится не там, где сделана.

Механизм 2: динамический тип и динамическое значение

Теперь модель, из которой выводится всё остальное, — и она не про рантайм. Интерфейсная переменная несёт две вещи: какой конкретный тип в ней сейчас лежит и какое значение этого типа.

a variable of interface type stores a pair: the concrete value assigned to the variable, and that value's type descriptor

перевод

Переменная интерфейсного типа хранит пару: конкретное значение, присвоенное переменной, и дескриптор типа этого значения.

The Laws of Reflection

Две вещи, а не одна, — и это не деталь реализации, а наблюдаемая семантика:

GO
var s Stringer          // тип: нет,      значение: нет
s = Point{1, 2}         // тип: Point,    значение: {1, 2}
s = &Point{1, 2}        // тип: *Point,   значение: адрес

Отсюда сразу три вещи, которые спрашивают по отдельности.

Утверждение типа возвращает конкретный тип обратно. s.(Point) спрашивает не «похоже ли», а «динамический тип ровно Point?». Именно поэтому *Point не подойдёт под Point — это разные динамические типы.

Сравнение интерфейсов сравнивает пару целиком. Равны, если совпал и тип, и значение. Отсюда же паника при сравнении интерфейсов с несравнимым динамическим типом внутри: сравнивать срез нечем.

И главное — интерфейс пуст, только когда пусты ОБЕ половины. Это следующий механизм, и это самый частый вопрос собеседования по Go.

Механизм 3: nil, который не nil

Это самый частый вопрос собеседования по Go, и отвечать на него надо не фразой, а механизмом.

Правило одно, и оно записано в FAQ языка:

An interface value is nil only if the V and T are both unset

перевод

Интерфейсное значение равно nil, только если и V, и T не установлены.

Go FAQ — Why is my nil error value not equal to nil?

Теперь посмотрите, что происходит с двумя словами в каждом из четырёх случаев — переключайте:

Ловушка целиком видна на переключении между первым и вторым случаем: второе слово в обоих пусто, отличаются они только первым. Именно поэтому

GO
var p *NotFound      // nil-указатель
var err error = p    // но интерфейс уже не пуст: тип известен
fmt.Println(err == nil)   // false

даёт false. Значение внутри — нулевой указатель, но слово с типом заполнено, а правило требует, чтобы пусты были оба.

Где это стреляет в реальном коде. Всегда в одном месте — когда функция объявлена возвращающей error, а внутри возвращает конкретный тип ошибки:

GO
func do() error {
    var e *MyErr          // nil
    if bad() {
        e = &MyErr{}
    }
    return e              // ЛОВУШКА: даже когда e == nil, error != nil
}

Вызывающий пишет if err != nil — и получает «ошибку», которой нет.

Лечится это тремя способами, и назвать стоит все три:

  1. Объявить переменную интерфейсного типа, а не конкретного: var err error — тогда при отсутствии ошибки в ней действительно пусты оба слова.
  2. Возвращать nil явно в успешной ветке: return nil, а не return e.
  3. Не хранить конкретные типы ошибок в промежуточных переменных — это первопричина, а первые два пункта лечат её симптомы.

Правило для запоминания: конкретный тип не переживает присваивания в интерфейс незаметно. Как только значение положено в интерфейс, его тип там остаётся — даже если самого значения нет.

Механизм 4: утверждение типа — это сравнение указателя

Вопрос «чем x.(T) отличается от type switch» задают, ожидая услышать про скорость. Правильный ответ начинается с того, что обе формы делают одно и то же: сравнивают указатель на дескриптор типа в первом слове с известным на этапе компиляции. Это не разбор структуры и не рефлексия — это сравнение двух адресов.

Замерено на одинаковой работе — в тех же прогонах и на той же машине, что и остальные числа урока:

формавремя
if x, ok := v.(int); ok4521–5023 нс
switch x := v.(type)3661–4128 нс

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

Что действительно надо знать про эти формы:

  • Форма с одним результатом паникует, с двумя — нет: v.(int) против v, ok := x.(int). В коде, который не контролирует источник значения, всегда вторая.
  • Спецификация требует, чтобы x было не nil: x.(T) утверждает, that x is not nil and that the value stored in x is of type T (что x не равно nil и что хранящееся в x значение имеет тип T). Утверждение на чистом nil-интерфейсе не пройдёт никогда.
  • case nil в type switch ловит именно чистый nil — тот, где пусты оба слова. Типизированный nil попадёт в ветку своего типа, и это ещё одно место, где ловушка с nil, который не nil, выходит боком.

Механизм 5: как этим пользуются

Последний механизм — не про устройство, а про то, во что всё сказанное превращается в чужом коде. Три правила, и каждое выводится из уже сказанного.

Интерфейсы объявляют маленькими и там, где потребляют. Это прямо следует из «Базы»: раз реализация неявная, интерфейс не обязан жить рядом с типом. Функция объявляет ровно те методы, которые ей нужны, — и к ней подходит всё, что их имеет. Признак ошибки: интерфейс на восемь методов, объявленный рядом с единственной реализацией. Такой интерфейс ничего не абстрагирует, он повторяет структуру.

Принимай интерфейсы, возвращай структуры. Аргумент-интерфейс расширяет круг того, что можно передать; результат-интерфейс, наоборот, сужает то, что вызывающий может сделать с результатом, и прячет от него методы, которые у конкретного типа есть. Исключение одно и признанное: возвращать интерфейс разумно, когда конкретный тип действительно бывает разный — как у error.

На границах следите за парой «тип и значение». Ловушка с типизированным nil живёт именно там, где интерфейс пересекает границу пакета: внутри функция работает с *MyErr, наружу отдаёт error. Правило простое — на границе объявляйте переменную интерфейсным типом, var err error, и возвращайте nil явно.

Отсюда же ответ на частый вопрос «зачем вообще интерфейс, если реализация одна». Затем же, зачем io.Writer: чтобы вызывающий мог подставить свою. Если подставлять некому и не планируется — интерфейс не нужен, и его отсутствие не недостаток дизайна.

Глубже: как это устроено внутри

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

деталь реализации · Go 1.24Устройство интерфейсной переменной не описано в спецификации: она говорит про пару «тип и значение», но не про то, сколько это машинных слов и что лежит в каждом. Ниже — нынешняя реализация.

Пара «тип и значение» лежит в памяти как два машинных слова. Это не обещание языка, а то, как сделано сейчас, — и это измеримо:

размер
any16 байт
Stringer16 байт
*Point8 байт
Point (структура из двух int)16 байт

Шестнадцать байт там, где указателю хватает восьми, — это два машинных слова. Что в них лежит, сказано в блоге Go прямо:

a variable of interface type stores a pair: the concrete value assigned to the variable, and that value's type descriptor

перевод

Переменная интерфейсного типа хранит пару: конкретное значение, присвоенное переменной, и дескриптор типа этого значения.

The Laws of Reflection

Уточнение про первое слово: у пустого интерфейса (any) там просто дескриптор типа, у интерфейса с методами — таблица методов (itab), в которой рядом с типом лежат адреса реализаций. Разница важна для цены вызова — о ней ниже.

Дальше — по одному следствию на слово: второе объясняет выделения памяти, первое — цену вызова.

Второе слово — указатель, поэтому упаковка выделяет память

Второе слово хранит не значение, а адрес значения. Отсюда простое правило: если упаковываемое не помещается в указатель, его надо куда-то положить — и это выделение в куче.

Замерено (bench/gointerface/internals.go):

что кладут в anyвыделений
переменная int со значением 420
переменная int со значением 10001
переменная-структура Point1
готовый указатель *Point0

Три строки объясняются одним предложением, четвёртая — отдельно.

Указатель кладётся как есть: копировать нечего, выделений ноль. Структуру приходится размещать в куче, потому что во второе слово помещается только адрес. А вот 42 против 1000 — это отдельный факт, который стоит знать: у рантайма есть готовый массив маленьких целых, и для чисел от 0 до 255 берётся адрес оттуда, а не выделяется новый.

Это, кстати, хороший ответ на вопрос «а как замерить»: возьмите тот же код с константой вместо переменной, и выделений станет ноль в обоих случаях — компилятор упакует константу на этапе компиляции. Первая редакция этого замера именно так и ошиблась.

Практический вывод, который и хотят услышать: цена интерфейса — не «сам интерфейс», а то, что упаковываемое значение не помещается в слово. Поэтому в горячем коде интерфейсы с указателями бесплатны по памяти, а интерфейсы со структурами — нет.

Первое слово — таблица, поэтому вызов через неё дороже

наблюдение замераbench/gointerface, go1.24.7 linux/amd64, два ядра. Числа сняты на конкретной машине и на пустом методе: это верхняя оценка разницы, а не свойство интерфейсов вообще.

Вызов через таблицу дороже прямого. Здесь важно не перепутать причину, иначе ответ звучит как суеверие.

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

Замерено на 4096 фигурах двух конкретных типов — в одном прогоне на одной машине, и кратность верна ровно для него:

время
прямой вызов2804 нс
через интерфейс11 461 нс
кратность4,1

И тут же — как этот замер положено делать. Первая редакция сравнивала вызов на переменной конкретного типа с вызовом на переменной интерфейсного и получила 0,87 — «через интерфейс быстрее». Это была не находка, а расписка в том, что замер негоден: у пакетной переменной интерфейсного типа компилятор знает единственный конкретный тип, девиртуализует вызов и встраивает его. Чтобы вызов остался вызовом, нужны два разных конкретных типа в срезе и данные из памяти, а не из литерала.

Сказать это на собеседовании стоит: вопрос «а как вы это мерили» задают следом почти всегда, и «взял два типа, чтобы компилятор не смог девиртуализовать» — это ответ, который отличает измерявшего от пересказавшего.

Что из этого следует для кода. Ничего вроде «не используйте интерфейсы». Четыре раза — это четыре раза от пустого метода в тесном цикле; как только внутри есть настоящая работа, доля вызова падает до незаметной. Правило такое: интерфейс на границе — норма, интерфейс внутри горячего цикла на миллион итераций — то, что стоит померить.

Как отвечать на собеседовании

Короткий ответ: интерфейс — это список методов, и тип реализует его молча, просто потому что нужные методы у него есть. Отсюда две вещи, которые и спрашивают дальше: метод с получателем-указателем не входит в набор методов значения, а сама интерфейсная переменная несёт пару «динамический тип и динамическое значение» — и равна nil, только когда пусты обе половины.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

На «как устроен интерфейс» отвечайте «два слова». «Тип или таблица методов и указатель на значение; шестнадцать байт против восьми у указателя — так это сделано сейчас, спецификация обещает только пару». Дальше почти всё выводится отсюда, и собеседующий это слышит.

Ловушку с nil объясняйте правилом, а не примером. «Интерфейс равен nil, только когда пусты оба слова; здесь второе пусто, а первое заполнено типом». Пример потом — он занимает две строки и подтверждает, а не заменяет.

Про упаковку говорите «второе слово — указатель». «Указатель кладётся как есть, ноль выделений; структуру надо где-то разместить, одно. Маленькие целые — исключение, у рантайма готов массив от 0 до 255». Последнее предложение показывает, что вы это запускали.

Про цену вызова называйте число и причину. «У меня выходило вчетверо на пустом методе, и дорожает не поиск в таблице, а потеря встраивания». И сразу — как мерили: два конкретных типа в срезе, иначе компилятор девиртуализует.

Про x.(T) и type switch скажите «одно и то же». «Обе формы сравнивают указатель на тип; выбирают по числу веток, а не по скорости». Ответ «switch быстрее» без числа звучит хуже, чем «разница есть, но она в пятую часть на пустой операции».

Про набор методов назовите причину, а не правило. «У значения в интерфейсе нет адреса, поэтому метод с получателем-указателем в его набор не входит».

Дальше спросят

Спросят дальше

Можно ли сравнивать интерфейсы через ==?

Короткий ответ

Можно, но осторожно. Спецификация: два интерфейсных значения равны, если у них одинаковые динамические типы и равные динамические значения либо если оба равны nil. То есть сравниваются обе половины пары.

Опасность в том, что сравнение может паниковать в рантайме. Если внутри лежит несравнимый тип — срез, карта, функция, — сравнение даёт comparing uncomparable type. Компилятор этого не поймает: он видит только интерфейс. Поэтому == на any из внешних данных — источник паник, и там безопаснее reflect.DeepEqual или сравнение после утверждения типа.

Спросят дальше

Где объявлять интерфейс — рядом с реализацией или у потребителя?

Короткий ответ

Идиома Go — у потребителя, и это прямо следует из того, что реализация в Go неявная: типу не нужно ничего знать про интерфейс, чтобы ему соответствовать. Значит, интерфейс можно объявить там, где он нужен, и ровно такого размера, какой нужен.

Практическое следствие: интерфейс из одного-двух методов рядом с функцией, которая его принимает, — это нормально и правильно. А большой интерфейс рядом с единственной реализацией — почти всегда лишний слой: он ничего не абстрагирует, но заставляет менять два места вместо одного.

Спросят дальше

Зачем нужен пустой интерфейс и чем он отличается от any?

Короткий ответ

Ничем: any — это псевдоним interface{}, введённый в Go 1.18 ради читаемости. Это буквально одно и то же, и gofmt в новых версиях сам меняет одно на другое.

Смысл же у него простой: пустому интерфейсу соответствует любой тип, потому что требований ноль. Первое слово при этом всё равно заполняется — там дескриптор типа, а не таблица методов. Поэтому any тоже 16 байт, и ловушка с типизированным nil на нём работает точно так же.

Спросят дальше

Что такое itab и когда он создаётся?

Короткий ответ

itab — это то, что лежит в первом слове интерфейса с методами: пара «тип плюс интерфейс» и рядом адреса реализаций методов. Именно из него берётся цель вызова.

Создаётся он один раз на пару «конкретный тип — интерфейс» и кешируется рантаймом, так что повторные присваивания того же типа в тот же интерфейс ничего не строят заново. Отсюда ответ на частый подвопрос: цена присваивания в интерфейс — это цена упаковки значения, а не построения таблицы.

Спросят дальше

Как проверить на этапе компиляции, что тип реализует интерфейс?

Короткий ответ

Строкой-заглушкой:

GO
var _ Stringer = (*Point)(nil)

Здесь ничего не выделяется и ничего не выполняется — это утверждение для компилятора. Если *Point перестанет реализовывать Stringer, сборка упадёт в том пакете, где объявлен тип, а не там, где им пользуются.

Приём стоит знать не ради красоты: без него ошибка находится в чужом пакете через полгода, и сообщение указывает на строку, которую никто не менял.

Спросят дальше

Интерфейс со значением или с указателем — что класть?

Короткий ответ

Смотрите на два вопроса. Первый — набор методов: если хоть один метод с получателем-указателем, класть придётся указатель, иначе не скомпилируется. Второй — размер: упаковка структуры — это выделение в куче, упаковка готового указателя — ноль.

Отсюда обычный выбор: если тип и так живёт по указателю — кладите указатель, это и дешевле, и однороднее. Класть значение осмысленно для маленьких неизменяемых типов, где копия — это признак, а не цена.

Частые заблуждения

Утверждение

интерфейс — это просто указатель на объект

На самом деле

Семантически это пара «динамический тип и динамическое значение», а в нынешней реализации Go — два машинных слова. Измеримо: any и Stringer занимают 16 байт, а *Point — 8. Первое слово — тип или таблица методов, второе — указатель на значение.

Утверждение

если внутри интерфейса лежит nil-указатель, то и интерфейс равен nil

На самом деле

Нет: интерфейс равен nil, только когда пусты оба слова. FAQ языка говорит это дословно — an interface value is nil only if the V and T are both unset (интерфейсное значение равно nil, только если и V, и T не установлены). Второе слово пусто, но первое заполнено типом, и err == nil даёт false.

Утверждение

проверка err != nil надёжна сама по себе

На самом деле

Она надёжна, только если в error никогда не кладут конкретный тип ошибки. Функция, возвращающая error, но хранящая внутри *MyErr, вернёт «ошибку» и тогда, когда ошибки нет. Лечится объявлением переменной интерфейсного типа и явным return nil в успешной ветке.

Утверждение

помещение значения в интерфейс всегда выделяет память

На самом деле

Не всегда: готовый указатель кладётся во второе слово как есть — ноль выделений. И маленькие целые тоже ноль: у рантайма готов массив чисел от 0 до 255. Выделение появляется там, где упаковываемое не помещается в слово, — например у структуры.

Утверждение

вызов через интерфейс дороже, потому что метод ищется в таблице

На самом деле

Поиск в таблице копеечный. Дорожает то, что компилятор перестаёт видеть цель и не встраивает вызов: прямой вызов он оптимизирует вместе с окружающим кодом, а вызов через таблицу остаётся вызовом. Замерено на пустом методе: 11 461 нс против 2804, вчетверо.

Утверждение

type switch заметно быстрее утверждения типа

На самом деле

Обе формы делают одно и то же — сравнивают указатель на дескриптор типа. На одинаковой работе вышло 3661–4128 против 4521–5023 нс: разница есть и устойчива, но это пятая часть на пустой операции. Форму выбирают по числу типов, которые надо различить, а не по скорости.

Утверждение

если у типа есть нужный метод, он реализует интерфейс

На самом деле

Только если метод входит в набор методов того, что вы присваиваете. Метод с получателем-указателем в набор методов значения не входит: у копии в интерфейсе нет адреса, и изменить оригинал такой метод не смог бы. Отсюда Point does not implement Stringer при присваивании структуры и успех при присваивании адреса.

Утверждение

интерфейсы можно свободно сравнивать через ==

На самом деле

Сравнение интерфейсов законно, но паникует в рантайме, если внутри лежит несравнимый тип — срез, карта или функция: comparing uncomparable type. Компилятор этого не увидит, он знает только интерфейс. Для значений из внешних данных безопаснее сравнивать после утверждения типа.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Функция объявлена возвращающей error, но внутри возвращает переменную конкретного типа. Что напечатает этот код?
type NotFound struct{ Key string }

func find(ok bool) error {
var missing *NotFound
if ok {
	return nil
}
return missing
}

err := find(false)
fmt.Println(err == nil)
var p *NotFound
fmt.Println(p == nil)
fmt.Println(reflect.TypeOf(err), err == nil)
fmt.Println(find(true) == nil)

Практика · оцените

Метод Area() на 4096 фигурах. Во сколько раз вызов через интерфейс дороже прямого вызова того же метода?
раза

Проверка знаний

Вопрос 1 из 6

Функция возвращает error, а внутри — переменную типа *MyErr, равную nil. Чему равно err == nil у вызывающего?

Источники и что читать дальше

4 ИСТОЧНИКА

  1. Спецификация Go — Interface types, Type assertions, Comparison operatorsОфициальная документация. Правила, из которых выводится всё поведение. Про сравнение: «Two interface values are equal if they have identical dynamic types and equal dynamic values or if both have the value nil» (Два интерфейсных значения равны, если у них одинаковые динамические типы и равные динамические значения либо если оба равны nil). Про утверждение типа: «For an expression x of interface type, but not a type parameter, and a type T, the primary expression x.(T) asserts that x is not nil and that the value stored in x is of type T» (Для выражения x интерфейсного типа, но не параметра типа, и типа T первичное выражение x.(T) утверждает, что x не равно nil и что хранящееся в x значение имеет тип T).https://go.dev/ref/spec#Interface_types
  2. Go FAQ — Why is my nil error value not equal to nil?Официальная документация. Ответ на самый частый вопрос собеседования, данный самими авторами языка: «Under the covers, interfaces are implemented as two elements, a type T and a value V» (Под капотом интерфейсы реализованы как два элемента — тип T и значение V), и дальше решающее: «An interface value is nil only if the V and T are both unset» (Интерфейсное значение равно nil, только если и V, и T не установлены).https://go.dev/doc/faq#nil_error
  3. The Laws of Reflection — блог GoОфициальная документация. Формулировка про представление интерфейсной переменной, из которой вырастает вся эта тема: «a variable of interface type stores a pair: the concrete value assigned to the variable, and that value's type descriptor» (переменная интерфейсного типа хранит пару: конкретное значение, присвоенное переменной, и дескриптор типа этого значения).https://go.dev/blog/laws-of-reflection
  4. Effective Go — Interfaces and methodsОфициальная документация. Про то, зачем интерфейсы вообще малы: «Interfaces in Go provide a way to specify the behavior of an object: if something can do this, then it can be used here» (Интерфейсы в Go дают способ описать поведение объекта: если нечто умеет это, значит, его можно применить здесь). Отсюда идиома объявлять интерфейс на стороне потребителя, а не рядом с реализацией.https://go.dev/doc/effective_go#interfaces_and_types