Интерфейсы, встраивание и полиморфизм
Кратко о теме
Заголовок раздела «Кратко о теме»Интерфейс в Go — это тип, который перечисляет набор сигнатур методов (а с Go 1.18 ещё и множество типов в constraint-интерфейсах для дженериков). Ключевое отличие от Java/C#: удовлетворение интерфейса неявное и структурное. Тип не объявляет «я реализую io.Writer» — компилятор сам проверяет, что метод-сет типа содержит все методы интерфейса, в момент присваивания или передачи аргумента. Из этого вытекает главное архитектурное следствие: интерфейс не принадлежит реализации, он принадлежит потребителю. Пакет-клиент объявляет минимальный интерфейс под свою нужду, а пакет-реализация про него вообще не знает и не импортирует его.
В рантайме интерфейсное значение — это всегда пара из двух машинных слов. Для непустого интерфейса это runtime.iface{tab *itab, data unsafe.Pointer}: itab — таблица, привязывающая конкретный тип к конкретному интерфейсу (содержит указатель на дескриптор интерфейса, дескриптор типа, хэш типа и массив указателей на методы fun), data — указатель на само значение. Для пустого интерфейса itab не нужен, там runtime.eface{_type *_type, data unsafe.Pointer} — просто дескриптор типа и данные. В свежих версиях (Go 1.22+) эти структуры переехали в internal/abi (abi.ITab, abi.Type), но модель «два слова: тип + значение» не менялась с самого начала.
Из «двух слов» растут почти все каверзные вопросы. Интерфейс равен nil, только когда оба слова нулевые; если положить в интерфейс нетипизированный nil-указатель, первое слово будет непустым и iface != nil — это классический «typed nil». Сравнение интерфейсов — это сравнение сначала дескрипторов типа, потом значений по правилам == этого типа; если динамический тип несравним (слайс, мапа, функция), сравнение паникует в рантайме. Помещение значения в интерфейс — это «упаковка» (boxing): если значение не указателеподобное, компилятор должен где-то взять адрес, и часто это означает escape в кучу и аллокацию.
Наследования в Go нет. Переиспользование кода даётся встраиванием (embedding) — анонимным полем в структуре, чьи поля и методы промоутятся во внешний тип. Это композиция с синтаксическим сахаром, а не наследование: нет виртуальной диспетчеризации, встроенный тип ничего не знает о внешнем, метод внешнего типа не «переопределяет» метод встроенного для вызовов изнутри встроенного. Полиморфизм же обеспечивается именно интерфейсами: одна и та же переменная интерфейсного типа в разное время держит разные конкретные типы, а вызов метода уходит через itab.fun.
Вопросы и ответы
Заголовок раздела «Вопросы и ответы»Что такое интерфейсы в Go и зачем они нужны?
Заголовок раздела «Что такое интерфейсы в Go и зачем они нужны?»Коротко. Интерфейс — это тип-контракт: набор сигнатур методов, который любой тип удовлетворяет неявно, просто имея эти методы. Нужны для полиморфизма (одна функция работает с разными реализациями), для развязки модулей (клиент зависит от абстракции, а не от конкретного пакета) и для тестируемости (подмена зависимости моком/стабом).
Глубже. В Go интерфейс — инструмент потребителя, а не инструмент иерархии типов. Хороший интерфейс маленький: io.Reader, io.Writer, error, fmt.Stringer — по одному методу. Чем меньше методов, тем больше типов его удовлетворяют и тем дешевле подменить реализацию. Формулировка из практики Go: «принимай интерфейсы, возвращай структуры» — функция должна брать минимально необходимое поведение на вход и отдавать конкретный тип, чтобы вызывающий сам решал, к какому интерфейсу его привести.
package main
import ( "fmt" "io")
func report(w io.Writer, msg string) { fmt.Fprintln(w, msg) }Здесь report одинаково работает с os.Stdout, *strings.Builder, *bytes.Buffer и с сетевым соединением — ни один из этих типов не знает про report.
Как интерфейсы связаны с принципами SOLID?
Заголовок раздела «Как интерфейсы связаны с принципами SOLID?»Коротко. Интерфейсы в Go — прямой инструмент для трёх букв: I (interface segregation) — интерфейсы дробят на мелкие, D (dependency inversion) — модуль верхнего уровня объявляет интерфейс, нижний его реализует, L (Liskov) — реализация обязана соблюдать неявный контракт, а не только сигнатуры.
Глубже. ISP в Go получается почти автоматически: раз удовлетворение структурное, потребитель объявляет ровно те методы, которые ему нужны, и «толстый» сервис автоматически подходит под узкий интерфейс. DIP получается за счёт того, что интерфейс живёт в пакете-потребителе — направление импорта разворачивается, доменный слой не импортирует инфраструктуру. SRP и OCP интерфейсы поддерживают косвенно: новую реализацию можно добавить, не трогая код потребителя. Важная оговорка на собесе: слепой перенос SOLID из Java даёт антипаттерн — интерфейс с 15 методами рядом с единственной реализацией и префиксом I. В Go это считается плохим стилем.
Что такое интерфейсы в Go? Чем отличается interface{} от any ?
Заголовок раздела «Что такое интерфейсы в Go? Чем отличается interface{} от any ?»Коротко. Интерфейс — набор сигнатур методов, реализуемый неявно. any — это предобъявленный алиас для interface{}, добавленный в Go 1.18. Разницы в семантике, размере, поведении нет никакой, отличие чисто в читаемости.
Глубже. В спецификации any объявлен как type any = interface{} — именно алиас (=), а не новый тип, поэтому any и interface{} взаимозаменяемы в любых позициях, включая сигнатуры методов при реализации интерфейсов и сравнение типов через reflect. Появился он потому, что в дженериках нужен был короткий и читаемый способ записать «любой тип» в constraint ([T any]), а тащить interface{} в каждый параметр типов было шумно. В новом коде принято писать any; инструментарий (gopls с modernize-анализаторами) умеет предлагать автозамену. Компилировать any умеет только Go 1.18+, поэтому в файлах с //go:build под старые версии или в модулях с низким go в go.mod он недоступен.
Interfaces. Purpose. Usage a) Polymorphism b) Type determination: type switch c) reflect package
Заголовок раздела «Interfaces. Purpose. Usage a) Polymorphism b) Type determination: type switch c) reflect package»Коротко. Интерфейсы решают три задачи: (a) полиморфизм — вызов метода через itab без знания конкретного типа; (b) определение типа в рантайме — type assertion и type switch, дешёвые операции сравнения дескрипторов типов; (c) рефлексия — reflect принимает any, распаковывает eface и даёт полный доступ к типу и значению, но существенно дороже и без проверок на этапе компиляции.
Глубже. Эти три способа образуют лестницу по цене и по потере статической типизации. Полиморфизм через интерфейс — почти бесплатный (одна косвенность), проверяется компилятором. type switch — проверка в рантайме, но список типов зафиксирован в коде, компилятор проверяет, что каждый case-тип вообще может реализовать интерфейс. reflect — работа с типами, неизвестными на этапе компиляции (JSON-маршалинг, ORM, DI-контейнеры): цена — аллокации, отсутствие инлайнинга и ошибки, которые вылезают только в рантайме в виде паники.
package main
import ( "fmt" "io" "reflect")
func describe(v any) { switch t := v.(type) { // b) type switch case fmt.Stringer: fmt.Println("stringer:", t.String()) case int: fmt.Println("int:", t*2) default: rt := reflect.TypeOf(v) // c) reflect fmt.Println("unknown:", rt, "implements io.Reader:", rt.Implements(reflect.TypeOf((*io.Reader)(nil)).Elem())) }}Constructor Injection, Setter Injection, Interface Injection (pros and cons of each)
Заголовок раздела «Constructor Injection, Setter Injection, Interface Injection (pros and cons of each)»Коротко. Constructor injection — зависимости передаются в конструктор (NewService(repo Repo, log Logger) *Service); плюсы: объект всегда валиден, зависимости видны в сигнатуре, поля можно сделать неизменяемыми. Setter injection — зависимость задаётся после создания (s.SetLogger(l)); плюс — опциональность и возможность подмены в рантайме, минус — объект может существовать в недонастроенном состоянии. Interface injection — зависимость внедряется через интерфейс-инжектор, который сам объект реализует (type LoggerAware interface { InjectLogger(Logger) }); в Go встречается редко и обычно избыточен.
Глубже. В Go идиоматичен именно constructor injection, а опциональные зависимости оформляют не сеттерами, а функциональными опциями — это даёт компромисс: обязательные зависимости в аргументах, опциональные в variadic-параметре, и объект всё равно рождается валидным.
type Option func(*Service)
func WithLogger(l Logger) Option { return func(s *Service) { s.log = l } }
func NewService(repo Repo, opts ...Option) *Service { s := &Service{repo: repo, log: nopLogger{}} for _, o := range opts { o(s) } return s}Setter injection в конкурентной программе — источник гонок: если поле можно переприсвоить после старта горутин, нужен мьютекс или atomic.Pointer. Interface injection в статически типизированном Go не даёт ничего сверх конструктора: он лишь добавляет ещё один интерфейс и рантайм-проверку, что тип его реализует.
How many connections can be opened on a server?
Заголовок раздела «How many connections can be opened on a server?»Коротко. Вопрос не про интерфейсы Go. Теоретический предел — количество уникальных TCP-четвёрок (src IP, src port, dst IP, dst port), поэтому на одном слушающем порту сервер может держать сотни тысяч и миллионы соединений от разных клиентов. Практический предел ставят лимит файловых дескрипторов (ulimit -n, fs.file-max), память под сокетные буферы и, для клиентской стороны, диапазон эфемерных портов (по умолчанию в Linux около 28 тысяч на одну пару src/dst IP).
Глубже. В Go каждое принятое соединение обычно обслуживает как минимум одна горутина; сетевой поллер (netpoller на epoll/kqueue) не тратит поток ОС на ожидание, поэтому узкое место — не потоки, а память: стек горутины начинается с 8 КБ (в актуальных версиях 8 КБ и растёт по мере необходимости) плюс буферы bufio и ядерные буферы сокета. Отсюда практическая арифметика для C1M-задач: нужно поднимать ulimit -n, уменьшать net.ipv4.tcp_rmem/wmem, не держать по два bufio.Reader/Writer с большими буферами на соединение и следить за TIME_WAIT (net.ipv4.ip_local_port_range, SO_REUSEADDR). Если это вопрос про лимиты приложения — правильный ответ на собесе: «упирается в fd, память и эфемерные порты, а не в архитектурный предел».
Что такое интерфейсы и для чего они нужны, чем отличаются от классов?
Заголовок раздела «Что такое интерфейсы и для чего они нужны, чем отличаются от классов?»Коротко. Интерфейс описывает только поведение (сигнатуры методов) и не содержит ни полей, ни реализации, ни состояния. Класс в ООП-языках — это одновременно данные, реализация и, как правило, узел в иерархии наследования. В Go классов нет вообще: есть типы (структуры и др.), методы, объявленные отдельно от типа, и интерфейсы, которые типы удовлетворяют неявно.
Глубже. Три различия, которые обычно хотят услышать. Первое — интерфейс не хранит состояние, он лишь пара «тип + указатель на значение» в рантайме. Второе — связь «тип ↔ интерфейс» устанавливается структурно и в момент присваивания, а не декларативно в объявлении типа, поэтому можно задним числом объявить интерфейс под чужой тип из стороннего пакета. Третье — иерархии нет: интерфейсы могут встраивать другие интерфейсы (io.ReadWriter встраивает io.Reader и io.Writer), но это объединение множеств методов, а не наследование с переопределением.
Что такое интерфейс?
Заголовок раздела «Что такое интерфейс?»Коротко. Тип, задающий набор сигнатур методов. Любое значение, чей метод-сет включает все эти методы, может быть присвоено переменной такого интерфейсного типа; в рантайме такая переменная — два слова: дескриптор динамического типа (через itab) и указатель на данные.
Глубже. Отдельно стоит помнить про метод-сеты, потому что на этом ловят чаще всего: метод-сет типа T включает только методы с получателем-значением, а метод-сет *T — и с получателем-значением, и с указателем. Значит, если func (t *T) Do(), то T интерфейс с Do() не реализует, а *T реализует. Компилятор об этом честно говорит: «T does not implement I (method Do has pointer receiver)». С Go 1.18 интерфейсы также могут содержать элементы-типы (~int | ~string) — такие интерфейсы можно использовать только как ограничения в дженериках, но не как обычные типы переменных.
Как понять, какой объект был передан по интерфейсу?
Заголовок раздела «Как понять, какой объект был передан по интерфейсу?»Коротко. Через type assertion v, ok := x.(ConcreteType) — если хочется проверить один конкретный тип, или через switch v := x.(type) — если вариантов несколько. Если типы неизвестны на этапе компиляции, остаётся reflect.TypeOf(x).
Глубже. Форма с двумя значениями (comma-ok) не паникует: при несовпадении вернёт нулевое значение и ok == false. Форма с одним значением v := x.(T) паникует с interface conversion: interface {} is int, not string. Технически проверка — это сравнение указателя на дескриптор типа в eface/itab с дескриптором T, то есть сравнение указателей, а не строковое сравнение имён; для assertion интерфейса к интерфейсу вызывается runtime.assertI2I, который ищет или строит itab в глобальной таблице itabTable. Проверять тип по строке fmt.Sprintf("%T", x) — антипаттерн: медленно и ломается при совпадении имён из разных пакетов.
if s, ok := x.(fmt.Stringer); ok { fmt.Println(s.String())}Как работает конструкция type switch в Go и для каких сценариев она применяется?
Заголовок раздела «Как работает конструкция type switch в Go и для каких сценариев она применяется?»Коротко. switch v := x.(type) { case T1: ...; case T2: ... } последовательно (логически) сравнивает динамический тип интерфейсного значения с типами в case-ветках; в каждой ветке v имеет тип этой ветки. Применяется для разбора «суммы типов» — узлов AST, вариантов сообщений, ошибок, значений после json.Unmarshal в any.
Глубже. Особенности: case nil срабатывает, когда интерфейс равен nil (оба слова нулевые), и это единственный способ поймать nil-интерфейс внутри type switch. Если в одном case перечислено несколько типов (case int, int64:), то v сохраняет исходный интерфейсный тип, а не конкретный. В case можно указывать не только конкретные типы, но и интерфейсы — тогда проверяется удовлетворение, и порядок веток начинает иметь значение (первый подходящий интерфейс выигрывает). Компилятор для длинных type switch не генерирует линейную цепочку сравнений: в cmd/compile/internal/walk/switch.go ветки сортируются по хэшу типа и разворачиваются в бинарный поиск, так что стоимость растёт логарифмически.
switch v := x.(type) {case nil: fmt.Println("nil interface")case error: fmt.Println("error:", v)case int, int64: fmt.Printf("integer-ish %v (%T)\n", v, v) // v здесь всё ещё anycase []byte: fmt.Println("bytes:", len(v))default: fmt.Printf("unhandled %T\n", v)}Поскольку в Go нет классического наследования классов, какой механизм используется для переиспользования кода и полиморфизма?
Заголовок раздела «Поскольку в Go нет классического наследования классов, какой механизм используется для переиспользования кода и полиморфизма?»Коротко. Переиспользование кода — композиция и встраивание (embedding): анонимное поле в структуре промоутит свои поля и методы во внешний тип. Полиморфизм — интерфейсы (динамическая диспетчеризация через itab) и, начиная с Go 1.18, дженерики (параметрический полиморфизм, разрешаемый на этапе компиляции).
Глубже. Эти три механизма отвечают за разные задачи, и на собесе полезно их разделить. Встраивание — про «не писать делегирующие методы руками»: type Server struct { *log.Logger } даёт srv.Printf бесплатно. Интерфейсы — про «одна точка вызова, много реализаций», цена — косвенный вызов и возможная аллокация при упаковке. Дженерики — про «один алгоритм, много типов, без боксинга и без потери типобезопасности»: slices.Sort, maps.Keys. Ошибка кандидатов — отвечать «встраивание вместо наследования», не упоминая интерфейсы: встраивание само по себе полиморфизма не даёт.
Что такое interface{} и any ? Чем отличаются?
Заголовок раздела «Что такое interface{} и any ? Чем отличаются?»Коротко. См. выше вопрос про interface{} и any: это одно и то же, any — предобъявленный алиас для interface{} с Go 1.18. Отличие только стилистическое; в API стандартной библиотеки Go начиная с 1.18 сигнатуры массово переписали на any (например, fmt.Println(a ...any)), и это не сломало обратную совместимость именно потому, что алиас — тот же тип.
one employee can work on many projects;
Заголовок раздела «one employee can work on many projects;»Коротко. Обрывок исходника, вопрос не восстанавливается — это половина условия про моделирование связи «сотрудники — проекты». Содержательный разбор — ниже, в вопросе про one-to-one / one-to-many / many-to-many и в вопросе про реализацию many-to-many.
one project can have many employees.
Заголовок раздела «one project can have many employees.»Коротко. Обрывок исходника, вопрос не восстанавливается — вторая половина того же условия. Вместе две фразы задают классическую связь many-to-many, которая в реляционной модели требует связующей таблицы; см. ответ ниже.
Когда интерфейс равен nil?
Заголовок раздела «Когда интерфейс равен nil?»Коротко. Интерфейсное значение равно nil тогда и только тогда, когда оба его слова нулевые — и дескриптор динамического типа, и указатель на данные. То есть только если в переменную ничего не клали (или явно присвоили литерал nil).
Глубже. Если положить в интерфейс типизированный nil-указатель, дескриптор типа заполнится, и сравнение с nil даст false, хотя данные внутри — нулевой указатель. Это самый популярный источник багов с error:
package main
import "fmt"
type MyErr struct{}
func (e *MyErr) Error() string { return "boom" }
func mayFail(fail bool) *MyErr { if fail { return &MyErr{} } return nil}
func do(fail bool) error { return mayFail(fail) // *MyErr(nil) упаковывается в error}
func main() { err := do(false) fmt.Println(err == nil) // false — интерфейс не nil!}Лечение — не возвращать конкретный тип ошибки из функции, объявленной с типом error: писать var err error и присваивать только при реальной ошибке, либо возвращать nil явным литералом.
Как проверить что значение интерфейса равно nil?
Заголовок раздела «Как проверить что значение интерфейса равно nil?»Коротко. Обычное x == nil проверяет только «интерфейс пустой». Чтобы поймать typed nil, нужен рефлекс: reflect.ValueOf(x).IsNil() для указателеподобных kind’ов, либо проверять до упаковки, на уровне конкретного типа.
Глубже. IsNil паникует, если kind не из списка «chan, func, interface, map, pointer, slice», поэтому нужна предварительная проверка kind. В Go 1.13+ есть reflect.Value.IsZero, но он отвечает на другой вопрос (нулевое ли значение), хотя для указателей совпадает.
package main
import "reflect"
func isNil(x any) bool { if x == nil { return true } v := reflect.ValueOf(x) switch v.Kind() { case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice, reflect.UnsafePointer: return v.IsNil() } return false}На собесе стоит добавить: рефлексия здесь — это лечение симптома. Правильно проектировать API так, чтобы typed nil в интерфейс не попадал; go vet подобное не ловит, но линтер nilness из golang.org/x/tools и staticcheck часть случаев видят.
Использовал type switch?
Заголовок раздела «Использовал type switch?»Коротко. Да — типичные места: разбор any после json.Unmarshal, обработка разных вариантов ошибок, обход AST, конвертация значений из драйвера БД (driver.Value), написание собственного логгера/маршалера. Каркас ответа: назвать конкретный кейс из своего проекта, сказать, почему не хватило обычного интерфейса, и упомянуть альтернативу.
Глубже. Хороший ответ звучит примерно так: «в HTTP-хендлере на выходе была одна функция маппинга доменной ошибки в HTTP-код; сначала это был type switch по типам ошибок, потом мы перешли на errors.As, потому что появились обёртки через fmt.Errorf("%w"), а type switch по обёрнутой ошибке перестал срабатывать». Это показывает и владение инструментом, и понимание его границ: type switch смотрит только на верхний уровень динамического типа, он не разворачивает цепочку Unwrap.
Что такое интерфейс any?
Заголовок раздела «Что такое интерфейс any?»Коротко. any — пустой интерфейс, то есть интерфейс без методов. Его удовлетворяет любой тип, поэтому переменная типа any может хранить значение произвольного типа. Формально это алиас type any = interface{}, введённый в Go 1.18.
Глубже. В рантайме значение any — это eface: указатель на дескриптор типа плюс указатель на данные. Никаких методов вызвать напрямую нельзя — сначала нужно вернуть статическую типизацию через assertion, type switch или рефлексию. Второе применение any — ограничение в дженериках: func Map[T, U any](...) означает «любой тип без дополнительных требований».
Как работать с any?
Заголовок раздела «Как работать с any?»Коротко. Класть значения в any можно бесплатно (с точностью до возможной аллокации при упаковке), а доставать — только через x.(T) с comma-ok, switch x.(type) или reflect. Никаких операций над any (арифметики, сравнения полей, вызова методов) без предварительного восстановления типа нет.
Глубже. Практические правила. Первое: any в сигнатуре — почти всегда признак того, что нужен дженерик или конкретный интерфейс; исключения — форматирование (fmt), сериализация, кэши, шины сообщений. Второе: any сравнивается через ==, но паникует, если внутри несравнимый тип, поэтому в качестве ключа map[any]V он опасен. Третье: помните про стоимость — упаковка неуказателеподобного значения обычно требует аллокации; для маленьких целых (0–255) рантайм использует заранее подготовленный массив runtime.staticuint64s, а для zero-size типов — runtime.zerobase, поэтому any(5) и any(struct{}{}) аллокаций не делают, а any(struct{ x, y int }{1, 2}) — делает.
func sum(vals []any) (int, error) { total := 0 for _, v := range vals { n, ok := v.(int) if !ok { return 0, fmt.Errorf("unexpected type %T", v) } total += n } return total, nil}Система должна предоставлять интерфейсы для резерва, закрытия резерва и отмены резерва.
Заголовок раздела «Система должна предоставлять интерфейсы для резерва, закрытия резерва и отмены резерва.»Коротко. Обрывок системного задания, а не вопрос. Если это про проектирование API на Go, ожидаемый ответ — один узкий интерфейс на три операции с идемпотентными ключами и явным контекстом.
Глубже. Каноничный набросок, который стоит нарисовать на доске:
type Reservation struct { ID string UserID string Items []Item}
type Reserver interface { Reserve(ctx context.Context, r Reservation) error // резерв Commit(ctx context.Context, reservationID string) error // закрытие резерва Cancel(ctx context.Context, reservationID string) error // отмена}Что здесь важно проговорить: все методы принимают context.Context первым аргументом; операции идемпотентны по reservationID, чтобы повтор после таймаута не задваивал списание; ошибки типизированы (ErrAlreadyCommitted, ErrNotFound, ErrInsufficientStock) и проверяются через errors.Is; резерв имеет TTL, а фоновый процесс отменяет протухшие. Это по сути паттерн Saga/two-phase reservation, и интервьюер обычно ждёт именно упоминания идемпотентности и таймаута.
Что такое интерфейс? Как сравниваются интерфейсы? Какой самый часто сравниваемый интерфейс?
Заголовок раздела «Что такое интерфейс? Как сравниваются интерфейсы? Какой самый часто сравниваемый интерфейс?»Коротко. Интерфейс — набор сигнатур методов (см. выше). Два интерфейсных значения равны, если равны их динамические типы и равны сами значения по правилам == для этого типа; nil-интерфейс равен только nil-интерфейсу. Самый часто сравниваемый интерфейс — error (if err != nil, err == io.EOF, err == sql.ErrNoRows).
Глубже. Если динамический тип несравним — слайс, мапа, функция, либо структура/массив, содержащие такое поле — сравнение компилируется, но паникует в рантайме: panic: runtime error: comparing uncomparable type []int. Компилятор такое не ловит именно потому, что тип известен только в рантайме. Про error важно добавить, что прямое сравнение == с сентинелом сломается, как только ошибку обернут через fmt.Errorf("...: %w", err), поэтому с Go 1.13 сравнивать надо через errors.Is (он идёт по цепочке Unwrap) и errors.As (для проверки типа с извлечением).
var a any = []int{1, 2}var b any = []int{1, 2}_ = a == b // компилируется, но паникует в рантаймеВ чем еще может помочь применение интерфейсов?
Заголовок раздела «В чем еще может помочь применение интерфейсов?»Коротко. Кроме полиморфизма и тестируемости: декораторы и middleware (обёртка с тем же интерфейсом), подмена реализации по конфигу (in-memory vs Postgres), разрыв циклических зависимостей между пакетами, стабилизация публичного API, расширение чужих типов через опциональные интерфейсы.
Глубже. Особенно полезны в Go «опциональные интерфейсы»: код проверяет, реализует ли значение дополнительный интерфейс, и если да — использует быстрый путь. Так устроена стандартная библиотека: io.Copy проверяет io.WriterTo и io.ReaderFrom и в этом случае обходится без промежуточного буфера; http.ResponseWriter дополнительно проверяют на http.Flusher, http.Hijacker, io.ReaderFrom. Второй мощный приём — декоратор: логирование, метрики, ретраи, кэш и circuit breaker навешиваются как обёртки, реализующие тот же интерфейс, без изменения ни клиента, ни базовой реализации.
type Repo interface{ Get(ctx context.Context, id string) (User, error) }
type loggingRepo struct { next Repo log *slog.Logger}
func (l loggingRepo) Get(ctx context.Context, id string) (User, error) { u, err := l.next.Get(ctx, id) l.log.InfoContext(ctx, "repo.Get", "id", id, "err", err) return u, err}Что такое пустой интерфейс?
Заголовок раздела «Что такое пустой интерфейс?»Коротко. interface{} (он же any) — интерфейс без методов. Его удовлетворяет любой тип, включая примитивы, функции и другие интерфейсы, поэтому в переменную такого типа можно положить что угодно.
Глубже. В рантайме пустой интерфейс представлен eface (тип + данные), без itab, потому что таблицы методов там нет. Классический трюк на собесе: «пустой интерфейс ничего не говорит» (Rob Pike) — если функция принимает any, она перекладывает проверку типов с компилятора на рантайм. После Go 1.18 большинство прежних применений any (контейнеры, утилиты над слайсами и мапами) заменяются дженериками, и это правильный современный ответ.
Что можешь рассказать про связи one-to-one, one-to-many, many-to-many?
Заголовок раздела «Что можешь рассказать про связи one-to-one, one-to-many, many-to-many?»Коротко. Это кардинальности связей в модели данных. One-to-one — одной записи соответствует ровно одна запись в другой таблице (внешний ключ с UNIQUE, часто просто выносят редкие/тяжёлые поля). One-to-many — у одной родительской записи много дочерних; внешний ключ живёт на стороне «many». Many-to-many — обе стороны множественные; реализуется через связующую (join) таблицу с двумя внешними ключами.
Глубже. В Go эти связи проявляются в структурах: one-to-one и one-to-many — поле-слайс в родителе (type Project struct { Tasks []Task }), many-to-many — либо слайсы с обеих сторон, либо отдельный тип связи, если у связи есть свои атрибуты (EmployeeProject{EmployeeID, ProjectID, Role, AllocatedHours}). Практическая ловушка, о которой любят спрашивать, — N+1 запросов: загрузили список из 100 проектов и на каждый сделали отдельный запрос за сотрудниками. Лечится джойном или batch-запросом WHERE project_id = ANY($1) с последующей группировкой в памяти.
Что такое интерфейс и как просходит их сравнение?
Заголовок раздела «Что такое интерфейс и как просходит их сравнение?»Коротко. См. выше вопрос про интерфейс и сравнение. Кратко: равны, если совпал динамический тип и равны значения; nil-интерфейс равен только nil-интерфейсу; несравнимый динамический тип даёт панику в рантайме.
Глубже. Отличие, которое стоит добавить в повторном ответе: сравнивать можно интерфейсы разных статических типов, если один приводится к другому (например, error и any) — сравнение всё равно идёт по динамическому типу и значению. И, поскольку itab уникален для пары (интерфейс, тип) и кэшируется в itabTable, сравнение динамических типов — это сравнение указателей, то есть одна инструкция, а не обход дескрипторов.
Что такое утиная типизация?
Заголовок раздела «Что такое утиная типизация?»Коротко. «Если оно ходит как утка и крякает как утка — это утка»: принадлежность типа к абстракции определяется наличием нужных методов, а не явным объявлением наследования. Go реализует статический вариант этой идеи — структурную типизацию: соответствие проверяется компилятором, но без ключевого слова implements.
Глубже. Отличие от классической duck typing из Python/Ruby принципиальное: там проверка происходит в момент вызова метода и падает в рантайме, в Go — в момент присваивания и падает на компиляции. Побочный эффект структурной типизации — «случайное» удовлетворение интерфейса: тип может подойти под интерфейс, реализуя его непреднамеренно, если совпали имена и сигнатуры. Защищаются от этого либо семантически значимыми именами методов, либо маркерным неэкспортируемым методом в интерфейсе (interface { isNode() }), что заодно делает интерфейс закрытым для реализации вне пакета — так эмулируют sealed-типы.
Может ли структура сразу содержать несколько интерфейсов?
Заголовок раздела «Может ли структура сразу содержать несколько интерфейсов?»Коротко. Да, в двух смыслах: тип может реализовывать сколько угодно интерфейсов одновременно (никакого объявления не требуется, достаточно иметь методы), и структура может содержать поля интерфейсных типов, в том числе встроенные анонимно.
Глубже. Встраивание интерфейса в структуру — рабочий приём: type partialFS struct { fs.FS } даёт тип, который формально реализует весь fs.FS, но переопределяет только нужные методы; остальные делегируются встроенному значению. Опасность в том, что если встроенное поле nil, вызов непереопределённого метода даст nil pointer dereference в рантайме, а не ошибку компиляции. В тестах это используют намеренно: встраивают интерфейс в мок и реализуют только те методы, которые тест реально дёргает.
type readOnlyDB struct { DB // встроенный интерфейс}
func (readOnlyDB) Exec(context.Context, string, ...any) error { return errors.New("read-only")}Чем пустой интерфейс отличается от any?
Заголовок раздела «Чем пустой интерфейс отличается от any?»Коротко. Ничем: any — алиас для interface{}, введён в Go 1.18 ради читаемости, особенно в дженериках. Это один и тот же тип, reflect.TypeOf для них вернёт одно и то же, взаимозаменяемы везде.
Для чего используется пустой интерфейс?
Заголовок раздела «Для чего используется пустой интерфейс?»Коротко. Для случаев, когда тип действительно неизвестен на этапе компиляции: форматирование (fmt.Printf), сериализация (json.Marshal(v any)), работа с БД (Scan(dest ...any)), значения в context.WithValue, шины сообщений и generic-контейнеры до Go 1.18.
Глубже. После появления дженериков список законных применений заметно сузился: там, где раньше был []any и рантайм-проверки, теперь []T с параметром типа. Оставшиеся честные кейсы — это ровно те, где тип неизвестен принципиально, а не «лень описать». Кроме того, any нужен на границе с reflect: все входы reflect.ValueOf/TypeOf — это any, и упаковка в eface там неизбежна.
Что такое концепция встраивания? Чем она отличается от наследования в классических объектно-ориентированных языках?
Заголовок раздела «Что такое концепция встраивания? Чем она отличается от наследования в классических объектно-ориентированных языках?»Коротко. Встраивание — анонимное поле в структуре (или анонимный интерфейс в интерфейсе): его экспортируемые и неэкспортируемые поля и методы промоутятся во внешний тип, и к ним можно обращаться напрямую, outer.Method() вместо outer.Inner.Method(). Это композиция с автоматическим делегированием, а не наследование: нет иерархии типов, нет виртуальных методов и нет подстановки подтипа вместо базового.
Глубже. Три конкретных отличия. Первое: C со встроенным A не является A — нельзя передать C туда, где ждут A (но можно туда, где ждут интерфейс, который A удовлетворяет). Второе: нет переопределения с динамической диспетчеризацией — если метод A.Foo() вызывает A.Bar(), а C объявил свой Bar(), вызовется всё равно A.Bar(), потому что получатель внутри A.Foo имеет тип A и про C ничего не знает (в Java здесь сработал бы полиморфизм). Третье: продвижение методов влияет на метод-сет, а значит и на удовлетворение интерфейсов — встроив sync.Mutex, вы неожиданно экспортируете Lock/Unlock наружу, что почти всегда нежелательно (встраивать надо как обычное именованное поле mu sync.Mutex).
Что такое встраивание? Есть структуры A и B, у обоих есть метод Test, встраиваем в C, вызываем c.Test() - что вызовется? Как избежать?
Заголовок раздела «Что такое встраивание? Есть структуры A и B, у обоих есть метод Test, встраиваем в C, вызываем c.Test() - что вызовется? Как избежать?»Коротко. Ничего не вызовется — будет ошибка компиляции ambiguous selector c.Test, потому что A.Test и B.Test промоутятся на одну и ту же глубину и ни один не «побеждает». Избежать: вызвать явно c.A.Test() / c.B.Test() либо объявить собственный метод func (c C) Test() на самом C — он находится на меньшей глубине и перекрывает оба.
Глубже. Правило продвижения формулируется через глубину: селектор разрешается в самый мелкий уровень вложенности, и если на этом уровне ровно один кандидат — он выигрывает, а если два и более — селектор невалиден. Важный нюанс: сама структура C при этом компилируется нормально, ошибка возникает только в месте использования неоднозначного селектора. Второе следствие: пока Test неоднозначен, C не реализует интерфейс с методом Test() — метод-сет его не содержит.
package main
import "fmt"
type A struct{}
func (A) Test() string { return "A" }
type B struct{}
func (B) Test() string { return "B" }
type C struct { A B}
// Явное разрешение неоднозначности:func (c C) Test() string { return c.A.Test() + "+" + c.B.Test() }
func main() { var c C fmt.Println(c.Test()) // A+B; без этого метода — ошибка компиляции}Где хранить интерфейсы? По месту использования или там где реализация?
Заголовок раздела «Где хранить интерфейсы? По месту использования или там где реализация?»Коротко. По месту использования — в пакете-потребителе. Это идиома Go: «интерфейс определяет потребитель». Реализующий пакет про интерфейс не знает и его не импортирует.
Глубже. Практическое следствие — направление зависимостей. Если service объявляет type UserRepo interface { ByID(ctx, id) (User, error) }, то service не импортирует postgres, а postgres не импортирует service; связывает их только main/wire. Это же убирает циклические импорты и позволяет каждому потребителю иметь свой узкий интерфейс из 1–2 методов вместо общего «репозитория» на 20 методов. Исключения, когда интерфейс живёт рядом с реализацией: когда он часть публичного контракта пакета и реализаций заведомо много (io.Reader, sort.Interface, driver.Driver в database/sql), или когда пакет возвращает интерфейс потому, что скрывает несколько внутренних типов.
Почему нужно хранить интерфейс там где он используется?
Заголовок раздела «Почему нужно хранить интерфейс там где он используется?»Коротко. Чтобы зависимость шла от инфраструктуры к домену, а не наоборот; чтобы интерфейсы были минимальными (потребитель знает, что ему реально нужно); чтобы не было циклических импортов и чтобы мок писался тривиально, в том же пакете, где тест.
Глубже. См. выше — отличие в акценте: здесь стоит проговорить «издержки обратного подхода». Если интерфейс лежит рядом с реализацией, он неизбежно разрастается до полного зеркала реализации, любой новый метод реализации попадает в интерфейс и ломает все моки; пакет-потребитель начинает импортировать пакет-инфраструктуру ради интерфейса, и вся развязка теряет смысл; появляются пары user.Service + user.IService, которые ничего не абстрагируют. Дополнительный аргумент — YAGNI: интерфейс с единственной реализацией и без теста, который его подменяет, вообще не нужен.
Что такое встраивание в Golang?
Заголовок раздела «Что такое встраивание в Golang?»Коротко. См. выше про концепцию встраивания: анонимное поле в структуре или анонимный интерфейс в интерфейсе, дающее продвижение полей и методов во внешний тип.
Глубже. Стоит перечислить три формы, которые встречаются в коде: встраивание структуры (type S struct { Base }), встраивание указателя на структуру (type S struct { *Base } — позволяет разделять состояние и допускает nil), встраивание интерфейса в структуру или в другой интерфейс (type ReadWriter interface { Reader; Writer }). Отдельно: имя встроенного поля — это имя типа без пакета (sync.Mutex → поле Mutex), а encoding/json встроенные структуры «расплющивает» в один JSON-объект, если у поля нет тега.
Как реализуется связь many-to-many?
Заголовок раздела «Как реализуется связь many-to-many?»Коротко. Через связующую таблицу (join/junction table) с двумя внешними ключами и составным первичным ключом: employee_project(employee_id, project_id). Если у связи есть собственные атрибуты (роль, доля занятости, даты) — они кладутся в ту же таблицу, и она становится полноценной сущностью.
Глубже. На стороне Go это отдельный тип и явные запросы: одна выборка сотрудников, одна выборка связей WHERE project_id = ANY($1), склейка в памяти — чтобы не получить N+1. Индексы нужны с обеих сторон: PK (employee_id, project_id) покрывает поиск по сотруднику, дополнительный индекс (project_id, employee_id) — поиск по проекту. Дополнительно стоит упомянуть каскады (ON DELETE CASCADE для связей) и то, что soft delete на связующей таблице обычно не нужен.
type EmployeeProject struct { EmployeeID int ProjectID int Role string}Как реализован полиморфизм в Go?
Заголовок раздела «Как реализован полиморфизм в Go?»Коротко. Двумя способами: динамический полиморфизм — через интерфейсы, вызов метода идёт косвенно через таблицу itab.fun; статический (параметрический) — через дженерики с Go 1.18, где компилятор либо инстанцирует код под конкретные типы, либо использует GC-shape stenciling с неявным словарём.
Глубже. Механика интерфейсного вызова: при присваивании конкретного типа интерфейсной переменной компилятор (или рантайм, если конверсия динамическая) подбирает itab для пары (интерфейс, тип); вызов w.Write(p) превращается в загрузку tab.fun[i] и косвенный call с data в качестве получателя. Такой вызов, как правило, не инлайнится, но с Go 1.21 компилятор умеет девиртуализировать интерфейсные вызовы по данным профиля (PGO), а также статически — когда конкретный тип очевиден из кода. Наследования реализации при этом нет: нельзя объявить «базовый метод по умолчанию» в интерфейсе, дефолтная реализация делается встраиванием базовой структуры.
А в общем полиморфизм - это что такое?
Заголовок раздела «А в общем полиморфизм - это что такое?»Коротко. Возможность одного и того же кода работать со значениями разных типов. Классификация: ad hoc (перегрузка — в Go отсутствует), параметрический (дженерики), подтипов/включения (в Go — интерфейсы), плюс приведения/коэрция.
Глубже. Полезно сразу сказать, чего в Go нет: перегрузки функций и операторов, ковариантности типов ([]*Dog не является []Animal), наследования реализации. И чего есть: структурная типизация вместо номинальной, что делает подтипирование более гибким — тип становится «подтипом» интерфейса задним числом, без изменения своего объявления.
Встраивание полноценно заменят наследование?
Заголовок раздела «Встраивание полноценно заменят наследование?»Коротко. Нет. Встраивание закрывает переиспользование кода (делегирование без бойлерплейта), но не даёт двух вещей из наследования: подтипирования (значение внешнего типа нельзя использовать там, где ждут встроенный тип) и виртуальной диспетчеризации (методы встроенного типа не «видят» переопределения во внешнем).
Глубже. Классический пример разрыва — паттерн Template Method. В Java базовый класс вызывает абстрактный step(), который переопределяет наследник. В Go так не выйдет: метод встроенной структуры вызовет её собственный step. Обходится передачей интерфейса внутрь базовой структуры (явная инверсия: type Base struct { hooks Hooks }), но это уже композиция, а не наследование. Правильный вывод для собеседования: Go сознательно отказался от наследования, заменив его связкой «встраивание + интерфейсы», и это покрывает большинство практических задач, но требует другого проектирования, а не механического перевода иерархии классов.
Как удостовериться объекты удовлетворяют одному типу?
Заголовок раздела «Как удостовериться объекты удовлетворяют одному типу?»Коротко. Если речь про идентичность динамических типов двух интерфейсных значений — сравнить дескрипторы: reflect.TypeOf(a) == reflect.TypeOf(b). Если про «оба удовлетворяют одному интерфейсу» — достаточно того, что компилятор принял их присваивание переменным этого интерфейсного типа, либо проверка _, ok := x.(I) в рантайме.
Глубже. reflect.TypeOf возвращает reflect.Type, который для одного и того же типа — один и тот же указатель, поэтому сравнение дешёвое и корректное (в отличие от сравнения %T-строк, где два одноимённых типа из разных пакетов дадут разные полные пути, но код станет медленным и хрупким). Есть нюанс: reflect.TypeOf(nil) вернёт nil, поэтому сравнение двух nil-интерфейсов даст true — иногда это желаемое поведение, иногда нет. Ещё вариант — сравнить сами интерфейсные значения a == b: это сильнее (проверяет и тип, и значение) и может паниковать на несравнимых типах.
Как определить что тип удовлетворяет интерфейсу?
Заголовок раздела «Как определить что тип удовлетворяет интерфейсу?»Коротко. На этапе компиляции — объявлением var _ io.Writer = (*MyType)(nil): если метод-сет не подходит, сборка упадёт. В рантайме — _, ok := x.(io.Writer) или reflect.TypeOf(x).Implements(reflect.TypeOf((*io.Writer)(nil)).Elem()).
Глубже. Приём с (*T)(nil) не выделяет память и не создаёт значение — это просто типизированный nil, чей единственный смысл в проверке типа компилятором; ставят его обычно рядом с объявлением типа. Для типа с value-receiver’ами пишут var _ I = T{}, для указателя — var _ I = (*T)(nil). Вариант с reflect.Implements нужен, когда набор типов известен только в рантайме (плагины, DI-контейнеры); обратите внимание на трюк reflect.TypeOf((*I)(nil)).Elem() — получить reflect.Type интерфейса напрямую нельзя, поскольку TypeOf принимает any и «схлопнет» интерфейс до динамического типа.
type MyWriter struct{}
func (*MyWriter) Write(p []byte) (int, error) { return len(p), nil }
var _ io.Writer = (*MyWriter)(nil) // проверка на этапе компиляцииКак интерфейсы влияют на производительность программы?
Заголовок раздела «Как интерфейсы влияют на производительность программы?»Коротко. Три эффекта: косвенный вызов через itab.fun вместо прямого (и, как правило, потеря инлайнинга и связанных с ним оптимизаций), возможная аллокация при упаковке значения в интерфейс, дополнительное давление на GC из-за этих аллокаций. По абсолютным цифрам вызов дешёвый (единицы наносекунд), болезненна обычно именно аллокация в горячем цикле.
Глубже. Детали, которые отличают сильного кандидата. Упаковка не всегда аллоцирует: если динамический тип «указателеподобный» (указатель, мапа, канал, функция, unsafe.Pointer, а также структура/массив из одного такого поля), значение кладётся в слово data напрямую — это «direct interface». Для маленьких целых 0–255 рантайм отдаёт адрес из статического массива runtime.staticuint64s, для типов нулевого размера — runtime.zerobase. Всё остальное escape-анализ, скорее всего, отправит в кучу. По вызовам: с Go 1.21 в компиляторе есть PGO-девиртуализация — при наличии профиля частый конкретный тип подставляется как быстрый путь с проверкой типа и возможностью инлайна; статическая девиртуализация работает и без PGO, когда компилятор видит конкретный тип. Практический вывод: измеряйте (go test -bench -benchmem, pprof), не бойтесь интерфейсов на границах модулей и убирайте их только в доказанно горячих внутренних циклах — там уместнее дженерики, которые дают полиморфизм без боксинга.
Что такое type switch?
Заголовок раздела «Что такое type switch?»Коротко. Конструкция switch v := x.(type), которая ветвится по динамическому типу интерфейсного значения, связывая в каждой ветке переменную нужного типа. Работает только для интерфейсных значений; для не-интерфейсных типов компилятор выдаёт ошибку.
Глубже. См. подробности выше в вопросе про то, как работает type switch: case nil ловит пустой интерфейс, в ветке с несколькими типами переменная сохраняет интерфейсный тип, среди case-веток могут быть интерфейсы, и компилятор разворачивает длинный switch в бинарный поиск по хэшу типа. Добавлю нюанс стиля: если тип один, идиоматичнее обычный type assertion с comma-ok, а не switch из одной ветки; и go vet предупреждает про impossible type switch case, когда тип заведомо не может реализовать интерфейс.
Где лучше держать интерфейсы? Где используется или где лежит реализация.
Заголовок раздела «Где лучше держать интерфейсы? Где используется или где лежит реализация.»Коротко. Где используется. См. выше два вопроса про расположение интерфейсов — правило то же: интерфейс объявляет потребитель, минимальный по числу методов; реализация о нём не знает.
Глубже. Единственное отличие, которое стоит добавить: практический критерий «когда исключение оправдано». Если интерфейс — это точка расширения библиотеки, которую реализуют внешние пользователи (sort.Interface, http.Handler, database/sql/driver), его место в библиотеке рядом с кодом, который его вызывает — а он там же и есть, ведь библиотека и есть потребитель. То есть исключений в строгом смысле почти нет: правило «интерфейс у потребителя» покрывает и этот случай.
Что такое type assertion?
Заголовок раздела «Что такое type assertion?»Коротко. Утверждение о динамическом типе интерфейсного значения: x.(T). В форме с одним результатом паникует при несовпадении, в форме v, ok := x.(T) — возвращает нулевое значение и false.
Глубже. Если T — конкретный тип, проверяется равенство динамического типа T (сравнение указателей на дескрипторы). Если T — интерфейс, проверяется, что динамический тип реализует T, и результат — новое интерфейсное значение с другим itab; этим занимаются runtime.assertI2I/assertE2I, которые ищут itab в itabTable и при необходимости строят его на лету. Assertion применим только к интерфейсным выражениям — someInt.(int) не скомпилируется. И на nil-интерфейсе любая assertion к конкретному типу даёт ok == false (или панику в однозначной форме).
var r io.Reader = os.Stdinif f, ok := r.(*os.File); ok { fmt.Println("fd =", f.Fd())}if rc, ok := r.(io.ReadCloser); ok { // assertion к другому интерфейсу defer rc.Close()}Для чего нужен пустой интерфейс? А пустая структура?
Заголовок раздела «Для чего нужен пустой интерфейс? А пустая структура?»Коротко. Пустой интерфейс any — для значений, тип которых неизвестен на этапе компиляции (см. выше). Пустая структура struct{} — тип нулевого размера: не занимает памяти, используется как значение в множествах map[K]struct{}, как сигнал в каналах chan struct{}, как получатель для типов без состояния и как маркер в generic-коде.
Глубже. Это две противоположности: any — «любой тип, размер два слова», struct{} — «конкретный тип размера ноль». Все значения struct{} в куче указывают на одну и ту же переменную runtime.zerobase, поэтому make(map[string]struct{}) экономит по слову (а с учётом выравнивания и больше) на каждом элементе против map[string]bool, а chan struct{} явно сообщает читателю «здесь важен факт события, а не данные». Тонкость: адреса разных значений нулевого размера могут совпадать, поэтому сравнивать указатели на struct{} смысла не имеет, а поле нулевого размера в конце структуры компилятор может дополнить паддингом, чтобы указатель на него не выходил за пределы объекта.
Логисты (внутренние пользователи): Работают через веб-интерфейс; Управляют заказами, маршрутами, водителями и параметрами системы.
Заголовок раздела «Логисты (внутренние пользователи): Работают через веб-интерфейс; Управляют заказами, маршрутами, водителями и параметрами системы.»Коротко. Обрывок исходника, вопрос не восстанавливается — это фрагмент описания ролей в системном задании, и слово «интерфейс» здесь означает UI, а не тип в Go.
Глубже. Если такое встречается на системном дизайне, ожидаемый ход мысли — выделить роли (логист, водитель, клиент), для каждой определить каналы доступа (внутренняя админка, мобильное приложение, публичный API), а дальше строить авторизацию по ролям (RBAC) и разные наборы эндпоинтов с разными SLA. К интерфейсам Go это отношения не имеет.
Почему встраивание - не наследование?
Заголовок раздела «Почему встраивание - не наследование?»Коротко. Потому что нет подтипирования (внешний тип не является встроенным и не подставляется вместо него) и нет виртуальной диспетчеризации (методы встроенного типа не вызывают переопределения из внешнего). Встраивание — это композиция плюс автоматическое делегирование, разрешаемое статически на этапе компиляции.
Глубже. Разница видна на коде: если у Base есть func (b Base) Describe() string { return "I am " + b.Name() } и Base.Name() переопределён в Outer, вызов outer.Describe() всё равно возьмёт Base.Name, потому что получатель внутри Describe — значение типа Base, физически часть Outer, но ничего о нём не знающее. Второе отличие — метод-сет: Outer со встроенным Base реализует интерфейсы, которые реализует Base, но *Outer и Outer могут различаться по этому признаку в зависимости от того, встроен ли Base или *Base и какие у методов получатели.
Как сообщить компилятору, что наш тип реализует интерфейс?
Заголовок раздела «Как сообщить компилятору, что наш тип реализует интерфейс?»Коротко. Специального синтаксиса нет — соответствие выводится структурно. Чтобы зафиксировать намерение и получить ошибку компиляции при расхождении, пишут проверку var _ io.Writer = (*MyType)(nil).
Глубже. Такое объявление ничего не стоит в рантайме: _ не создаёт переменную, а (*T)(nil) — константное nil-значение. Это и документация («тип задуман как реализация»), и защита от того, что кто-то поменяет сигнатуру метода. Если проверка падает с «method has pointer receiver» — значит, интерфейс реализует *T, а не T, и вы либо меняете проверку на указатель, либо переводите методы на value-receiver. Ещё одно место, где о намерении сообщают явно, — генерация моков (//go:generate mockgen -source=repo.go), но это уже инструментарий, а не язык.
Как получить переменную, которая не-nil, но nil?
Заголовок раздела «Как получить переменную, которая не-nil, но nil?»Коротко. Положить типизированный nil-указатель в интерфейс: var p *T = nil; var i any = p — теперь i != nil, хотя p == nil. Причина — интерфейс равен nil только когда нулевые оба слова, а здесь слово с дескриптором типа заполнено.
Глубже. Тот же эффект бывает не только с указателями: nil-мапа, nil-слайс, nil-канал и nil-функция, упакованные в интерфейс, тоже дают не-nil интерфейс. Самое опасное проявление — возврат конкретного типа ошибки:
func find() *NotFoundError { return nil }
func handler() error { return find() } // handler() != nil всегдаПравильный паттерн — объявлять локальную переменную типа error и возвращать её, либо возвращать nil литералом на успешном пути. Проверить факт помогает fmt.Printf("%v %T", i, i): напечатает <nil> *main.NotFoundError — значение nil, но тип есть.
Где следует поместить описание интерфейса и почему?
Заголовок раздела «Где следует поместить описание интерфейса и почему?»Коротко. В пакете-потребителе, рядом с кодом, который вызывает эти методы. Причина — минимальность интерфейса, правильное направление зависимостей (реализация зависит от абстракции, а не наоборот), отсутствие циклических импортов и простая подмена в тестах.
Глубже. См. выше три родственных вопроса про расположение интерфейсов. Добавлю оформление: интерфейс потребителя обычно неэкспортируемый или короткий экспортируемый, объявлен в том же файле, где сервис, который его использует; мок для него живёт в _test.go того же пакета. Комментарий к интерфейсу описывает контракт (что гарантируется, какие ошибки возвращаются, потокобезопасен ли), а не перечисляет методы — это то, что реализация обязана соблюдать сверх сигнатур.
Весь собес был посвящен решению задач. Задачи были связаны с использованием двух интерфейсов:
Заголовок раздела «Весь собес был посвящен решению задач. Задачи были связаны с использованием двух интерфейсов:»Коротко. Обрывок исходника, вопрос не восстанавливается — это преамбула к условию задачи. Сами два интерфейса описаны в следующих двух пунктах, разбор — там.
Первый интерфейс имеет два метода: запись данных в файл и чтение данных из файла;
Заголовок раздела «Первый интерфейс имеет два метода: запись данных в файл и чтение данных из файла;»Коротко. Обрывок условия, но восстанавливается: речь про интерфейс хранилища с двумя методами вида Save(name string, data []byte) error и Load(name string) ([]byte, error). Идиоматичнее оформить его через стандартные io.Reader/io.Writer, чтобы реализацию можно было подменить на память или сеть.
Глубже. Каркас, который обычно ждут:
type Storage interface { Write(name string, data []byte) error Read(name string) ([]byte, error)}
type fileStorage struct{ dir string }
func (f fileStorage) Write(name string, data []byte) error { return os.WriteFile(filepath.Join(f.dir, name), data, 0o600)}
func (f fileStorage) Read(name string) ([]byte, error) { return os.ReadFile(filepath.Join(f.dir, name))}Что стоит проговорить вслух: имя файла надо санитизировать (filepath.Clean + проверка на выход из каталога), права 0o600 для чувствительных данных, атомарность записи через временный файл и os.Rename, и что для больших объёмов нужен потоковый вариант на io.Reader/io.Writer, а не []byte целиком в памяти.
Второй интерфейс имеет два метода: шифрование данных и дешифрование данных.
Заголовок раздела «Второй интерфейс имеет два метода: шифрование данных и дешифрование данных.»Коротко. Обрывок условия; восстанавливается как Cipher с Encrypt(plain []byte) ([]byte, error) и Decrypt(cipher []byte) ([]byte, error). Суть задачи почти наверняка — скомпоновать два интерфейса декоратором: хранилище, которое шифрует перед записью и расшифровывает после чтения, реализуя тот же интерфейс Storage.
Глубже. Такая композиция — образцовая демонстрация того, зачем нужны интерфейсы: ни fileStorage, ни Cipher друг о друге не знают.
type Cipher interface { Encrypt(plain []byte) ([]byte, error) Decrypt(enc []byte) ([]byte, error)}
type encryptedStorage struct { next Storage cipher Cipher}
func (e encryptedStorage) Write(name string, data []byte) error { enc, err := e.cipher.Encrypt(data) if err != nil { return fmt.Errorf("encrypt %s: %w", name, err) } return e.next.Write(name, enc)}
func (e encryptedStorage) Read(name string) ([]byte, error) { enc, err := e.next.Read(name) if err != nil { return nil, err } return e.cipher.Decrypt(enc)}По криптографии на таком собесе ждут: AES-GCM через crypto/cipher.NewGCM (аутентифицированное шифрование), уникальный nonce на каждую операцию из crypto/rand, nonce хранится рядом с шифротекстом (обычно префиксом), ключ приходит извне, а не хардкодится, и ошибки дешифрования не должны раскрывать причину. Проверять реализацию удобно тестом с in-memory Storage (map[string][]byte) — ровно тем, ради чего интерфейс и вводился.
Частые ошибки на собесе
Заголовок раздела «Частые ошибки на собесе»- Говорить, что «интерфейс равен nil, если внутри nil». Интерфейс равен nil, только когда нулевые оба слова — тип и значение; typed nil даёт
iface != nil. - Считать
anyновым типом или «более быстрым» вариантом. Это алиасinterface{}, ровно то же самое. - Отвечать «встраивание — это наследование в Go». Нет подтипирования и нет виртуальной диспетчеризации; метод встроенного типа не видит переопределения во внешнем.
- Забывать про метод-сеты: тип с методами на указателе не реализует интерфейс своим значением, и
T{}не присвоить переменной интерфейса. - Класть интерфейсы рядом с реализацией и делать их «зеркалом» структуры на 15 методов вместо узкого интерфейса у потребителя.
- Утверждать, что интерфейсы «ничего не стоят». Стоят: косвенный вызов, потеря инлайна и часто аллокация при упаковке; девиртуализация помогает, но не всегда.
- Сравнивать интерфейсы через
==, не помня, что при несравнимом динамическом типе (слайс, мапа, функция) это паника в рантайме, а для ошибок с Go 1.13 нужныerrors.Is/errors.As. - В ответе про
c.Test()при двух встроенных структурах говорить «вызовется первая». Это ошибка компиляцииambiguous selector.
Что почитать
Заголовок раздела «Что почитать»- Спецификация языка, разделы Interface types, Method sets, Struct types (embedding), Type assertions, Type switches: https://go.dev/ref/spec
- Russ Cox, «Go Data Structures: Interfaces» — классическое описание
itab/eface: https://research.swtch.com/interfaces - Исходники рантайма:
runtime/iface.go(itab, assertI2I, convT*) иinternal/abi/type.go— https://github.com/golang/go/tree/master/src/runtime/iface.go - Go Code Review Comments, раздел Interfaces («интерфейс объявляет потребитель»): https://go.dev/wiki/CodeReviewComments#interfaces
- Effective Go, разделы Interfaces and methods, Embedding: https://go.dev/doc/effective_go#interfaces_and_types
- Profile-guided optimization и девиртуализация интерфейсных вызовов: https://go.dev/doc/pgo