Интерфейс в 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.
Что здесь на самом деле спрашивают
Лестница почти всегда такая:
- «Как устроен интерфейс внутри?» — проверяют, скажете ли вы «два слова» или ограничитесь словом «контракт».
- «Почему
err != nilсработало, хотя вернулиnil?» — та самая ловушка; спрашивают её чаще всего остального вместе взятого. - «Когда помещение значения в интерфейс выделяет память?» — проверяют, понимаете ли вы, что второе слово — указатель.
- «Вызов через интерфейс дороже прямого? Насколько?» — вопрос на честность: «незначительно» и «в разы» одинаково плохи без числа.
- «Чем
x.(T)отличается отswitch x := v.(type)?» — проверяют, знаете ли вы, что обе формы делают одно и то же. - «Почему мой тип не реализует интерфейс?» (с кодом) — про наборы методов и получатель-указатель.
Дальше урок идёт по этой лестнице снизу вверх: сначала что интерфейс значит, потом что из этого следует, и только в конце — как он устроен в памяти и во что обходится.
База: реализация без объявления
Интерфейс в Go — это список методов, которому дали имя. Stringer ничего
не говорит о том, из чего состоит значение и откуда оно взялось; он говорит
одно: «это умеет String() string». Функция, объявляющая параметр типа
Stringer, тем самым требует не конкретный тип, а способность.
Дальше идёт решение, которого нет в большинстве языков: тип реализует
интерфейс молча. Ни implements, ни списка интерфейсов у типа — компилятор
сам смотрит, есть ли нужные методы, и если есть, значение подходит. Объявлять о
намерении не надо, и объявить его негде.
bench/gointerface/methodsets.sh собирает и запускает программу, в которой
Point не упоминает Stringer ни одним словом:
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: набор методов — где неявность кончается
Тихая реализация имеет ровно одну границу, и на ней спотыкаются все. Метод с получателем-указателем не входит в набор методов значения.
Тот же скрипт пробует собрать программу, где получатель указательный, а присваивают значение:
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
Переменная интерфейсного типа хранит пару: конкретное значение, присвоенное переменной, и дескриптор типа этого значения.
Две вещи, а не одна, — и это не деталь реализации, а наблюдаемая семантика:
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 не установлены.
Теперь посмотрите, что происходит с двумя словами в каждом из четырёх случаев — переключайте:
Ловушка целиком видна на переключении между первым и вторым случаем: второе слово в обоих пусто, отличаются они только первым. Именно поэтому
var p *NotFound // nil-указатель
var err error = p // но интерфейс уже не пуст: тип известен
fmt.Println(err == nil) // falseдаёт false. Значение внутри — нулевой указатель, но слово с типом заполнено,
а правило требует, чтобы пусты были оба.
Где это стреляет в реальном коде. Всегда в одном месте — когда функция
объявлена возвращающей error, а внутри возвращает конкретный тип ошибки:
func do() error {
var e *MyErr // nil
if bad() {
e = &MyErr{}
}
return e // ЛОВУШКА: даже когда e == nil, error != nil
}Вызывающий пишет if err != nil — и получает «ошибку», которой нет.
Лечится это тремя способами, и назвать стоит все три:
- Объявить переменную интерфейсного типа, а не конкретного:
var err error— тогда при отсутствии ошибки в ней действительно пусты оба слова. - Возвращать
nilявно в успешной ветке:return nil, а неreturn e. - Не хранить конкретные типы ошибок в промежуточных переменных — это первопричина, а первые два пункта лечат её симптомы.
Правило для запоминания: конкретный тип не переживает присваивания в интерфейс незаметно. Как только значение положено в интерфейс, его тип там остаётся — даже если самого значения нет.
Механизм 4: утверждение типа — это сравнение указателя
Вопрос «чем x.(T) отличается от type switch» задают, ожидая услышать про
скорость. Правильный ответ начинается с того, что обе формы делают одно и то
же: сравнивают указатель на дескриптор типа в первом слове с известным на
этапе компиляции. Это не разбор структуры и не рефлексия — это сравнение двух
адресов.
Замерено на одинаковой работе — в тех же прогонах и на той же машине, что и остальные числа урока:
| форма | время |
|---|---|
if x, ok := v.(int); ok | 4521–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: чтобы вызывающий мог подставить свою.
Если подставлять некому и не планируется — интерфейс не нужен, и его отсутствие
не недостаток дизайна.
Глубже: как это устроено внутри
Всё сказанное выше описывало, что интерфейс значит, и держится на спецификации. Теперь — как это сделано, и здесь начинается другой сорт утверждений: не правила языка, а нынешняя реализация.
Пара «тип и значение» лежит в памяти как два машинных слова. Это не обещание языка, а то, как сделано сейчас, — и это измеримо:
| размер | |
|---|---|
any | 16 байт |
Stringer | 16 байт |
*Point | 8 байт |
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
Переменная интерфейсного типа хранит пару: конкретное значение, присвоенное переменной, и дескриптор типа этого значения.
Уточнение про первое слово: у пустого интерфейса (any) там просто дескриптор
типа, у интерфейса с методами — таблица методов (itab), в которой рядом с
типом лежат адреса реализаций. Разница важна для цены вызова — о ней ниже.
Дальше — по одному следствию на слово: второе объясняет выделения памяти, первое — цену вызова.
Второе слово — указатель, поэтому упаковка выделяет память
Второе слово хранит не значение, а адрес значения. Отсюда простое правило: если упаковываемое не помещается в указатель, его надо куда-то положить — и это выделение в куче.
Замерено (bench/gointerface/internals.go):
что кладут в any | выделений |
|---|---|
переменная int со значением 42 | 0 |
переменная int со значением 1000 | 1 |
переменная-структура Point | 1 |
готовый указатель *Point | 0 |
Три строки объясняются одним предложением, четвёртая — отдельно.
Указатель кладётся как есть: копировать нечего, выделений ноль. Структуру приходится размещать в куче, потому что во второе слово помещается только адрес. А вот 42 против 1000 — это отдельный факт, который стоит знать: у рантайма есть готовый массив маленьких целых, и для чисел от 0 до 255 берётся адрес оттуда, а не выделяется новый.
Это, кстати, хороший ответ на вопрос «а как замерить»: возьмите тот же код с константой вместо переменной, и выделений станет ноль в обоих случаях — компилятор упакует константу на этапе компиляции. Первая редакция этого замера именно так и ошиблась.
Практический вывод, который и хотят услышать: цена интерфейса — не «сам интерфейс», а то, что упаковываемое значение не помещается в слово. Поэтому в горячем коде интерфейсы с указателями бесплатны по памяти, а интерфейсы со структурами — нет.
Первое слово — таблица, поэтому вызов через неё дороже
Вызов через таблицу дороже прямого. Здесь важно не перепутать причину, иначе ответ звучит как суеверие.
Дорожает не поиск метода в таблице — он копеечный. Дорожает то, что компилятор перестаёт видеть, какой код будет выполнен: прямой вызов он встраивает целиком и оптимизирует вместе с окружающим кодом, а вызов через интерфейс обязан пройти через таблицу методов и остаётся вызовом.
Замерено на 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 — это то, что лежит в первом слове интерфейса с методами: пара «тип
плюс интерфейс» и рядом адреса реализаций методов. Именно из него берётся цель
вызова.
Создаётся он один раз на пару «конкретный тип — интерфейс» и кешируется рантаймом, так что повторные присваивания того же типа в тот же интерфейс ничего не строят заново. Отсюда ответ на частый подвопрос: цена присваивания в интерфейс — это цена упаковки значения, а не построения таблицы.
Как проверить на этапе компиляции, что тип реализует интерфейс?
Строкой-заглушкой:
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. Компилятор этого не увидит, он знает только интерфейс. Для значений из внешних данных безопаснее сравнивать после утверждения типа.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
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)Практика · оцените
Проверка знаний
Функция возвращает error, а внутри — переменную типа *MyErr, равную nil. Чему равно err == nil у вызывающего?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Тип реализует интерфейс молча. Ни
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) и на выбор формы не влияет.
На самом деле
- Семантически это пара «динамический тип и динамическое значение», а в нынешней реализации Go — два машинных слова. Измеримо:
anyиStringerзанимают 16 байт, а*Point— 8. Первое слово — тип или таблица методов, второе — указатель на значение. - Нет: интерфейс равен
nil, только когда пусты оба слова. FAQ языка говорит это дословно — an interface value is nil only if the V and T are both unset. Второе слово пусто, но первое заполнено типом, иerr == nilдаёт false. - Она надёжна, только если в
errorникогда не кладут конкретный тип ошибки. Функция, возвращающаяerror, но хранящая внутри*MyErr, вернёт «ошибку» и тогда, когда ошибки нет. Лечится объявлением переменной интерфейсного типа и явнымreturn nilв успешной ветке. - Не всегда: готовый указатель кладётся во второе слово как есть — ноль выделений. И маленькие целые тоже ноль: у рантайма готов массив чисел от 0 до 255. Выделение появляется там, где упаковываемое не помещается в слово, — например у структуры.
- Поиск в таблице копеечный. Дорожает то, что компилятор перестаёт видеть цель и не встраивает вызов: прямой вызов он оптимизирует вместе с окружающим кодом, а вызов через таблицу остаётся вызовом. Замерено на пустом методе: 11 461 нс против 2804, вчетверо.
- Обе формы делают одно и то же — сравнивают указатель на дескриптор типа. На одинаковой работе вышло 3661–4128 против 4521–5023 нс: разница есть и устойчива, но это пятая часть на пустой операции. Форму выбирают по числу типов, которые надо различить, а не по скорости.
- Только если метод входит в набор методов того, что вы присваиваете. Метод с получателем-указателем в набор методов значения не входит: у копии в интерфейсе нет адреса, и изменить оригинал такой метод не смог бы. Отсюда
Point does not implement Stringerпри присваивании структуры и успех при присваивании адреса. - Сравнение интерфейсов законно, но паникует в рантайме, если внутри лежит несравнимый тип — срез, карта или функция:
comparing uncomparable type. Компилятор этого не увидит, он знает только интерфейс. Для значений из внешних данных безопаснее сравнивать после утверждения типа.
Что разобрано
- Что здесь на самом деле спрашивают
- База: реализация без объявления
- Механизм 1: набор методов — где неявность кончается
- Механизм 2: динамический тип и динамическое значение
- Механизм 3: nil, который не nil
- Механизм 4: утверждение типа — это сравнение указателя
- Механизм 5: как этим пользуются
- Глубже: как это устроено внутри
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
4 ИСТОЧНИКА
- Спецификация 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
- 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
- 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
- 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