Интерфейсы в Go
В Java или C# тип обязан объявить, что он реализует интерфейс. В Go этого объявления нет вовсе: у типа просто есть нужные методы, и он подходит. Звучит как мелкая синтаксическая разница, а меняет она архитектуру целиком - интерфейс можно объявить там, где он нужен, для чужого типа, который о нём никогда не узнает.
Стержень: реализация неявная, поэтому интерфейс объявляют у потребителя и делают его как можно уже.
// Формулировки: «как тип реализует интерфейс?», «где объявлять интерфейс?», «зачем io.Reader из одного метода?»
Реализация без объявления
Условие одно: у типа есть все методы интерфейса с точно такими же сигнатурами. Никакого ключевого слова implements, никакой связи между пакетами. Тип из библиотеки, написанной пять лет назад, спокойно удовлетворяет интерфейсу, который ты объявил сегодня утром.
Отсюда главное архитектурное следствие: направление зависимости переворачивается. Интерфейс принадлежит не тому, кто его реализует, а тому, кто его использует. Пакет-потребитель объявляет, что ему нужно, и не зависит от конкретных реализаций вообще.
Практический эффект виден в тестах. Чтобы подменить хранилище заглушкой, не нужно ни менять пакет хранилища, ни договариваться с его автором: объявляешь у себя интерфейс из двух нужных методов и пишешь заглушку на пятнадцать строк.
// Обратная сторона - опечатку компилятор поймает не там, где ты её сделал, а там, где тип попытались подставить. Лечится строчкой var _ Iface = (*MyType)(nil) рядом с объявлением типа: она ничего не создаёт, но проверяет соответствие прямо в родном файле.
Интерфейс - это пара из двух слов
Внутри переменная интерфейсного типа - пара: указатель на информацию о динамическом типе и указатель на само значение. Проверяется размером: unsafe.Sizeof для any возвращает 16 байт при размере обычного указателя 8. Ровно два машинных слова.
Из этой пары следует всё остальное поведение. Интерфейс равен nil, только когда пусты оба слова. Если тип записан, а значение нулевое - интерфейс уже не nil, и это порождает знаменитую ловушку typed nil, которой посвящена отдельная подтема.
Оттуда же ограничение на сравнение. Интерфейсы сравниваются, если совпали и динамический тип, и значение: два any с числом 1 равны, проверено. А вот два any со слайсами внутри дают панику runtime error: comparing uncomparable type []int, потому что слайсы сравнивать нельзя в принципе. Компилятор это не ловит - падает во время работы.
// Практический вывод: не используй интерфейсные значения как ключи мапы, если не уверен в динамическом типе. Один слайс внутри - и запись в мапу уронит процесс на данных, которых не было в тестах.
- динамический тип
- реальный тип значения, лежащего внутри интерфейсной переменной
Чем уже, тем полезнее
io.Reader состоит из одного метода Read. io.Writer - из одного Write. Это не аскетизм ради красоты: чем меньше методов, тем больше типов подходят и тем проще написать свой. Файл, сетевое соединение, буфер в памяти, архиватор, счётчик байтов - все они Reader, и любую пару можно соединить.
Обратный пример знаком всем: интерфейс Repository с двадцатью методами. Реализовать его заглушкой в тесте - полдня; добавить метод - сломать всех реализующих. Такой интерфейс не абстракция, а копия конкретного типа с лишним шагом.
Правило, вытекающее из неявной реализации: объявляй интерфейс у потребителя и включай в него ровно то, что этот потребитель вызывает. Если сервису от хранилища нужны GetUser и SaveUser, его интерфейс состоит из двух методов, а не из всех, что умеет база.
// И не заводи интерфейс на всякий случай, когда реализация одна и другой не предвидится. Пустая абстракция усложняет чтение и ничего не даёт: в Go добавить интерфейс задним числом дёшево именно потому, что реализующему типу для этого ничего делать не нужно.
// плохо: интерфейс у поставщика, двадцать методов
// хорошо: у потребителя, ровно то, что он зовёт
type userStore interface {
GetUser(ctx context.Context, id int64) (*User, error)
SaveUser(ctx context.Context, u *User) error
}
func NewService(s userStore) *Service { return &Service{store: s} }Как отвечать: «Как тип реализует интерфейс и где его объявлять?»
Реализация в Go неявная: достаточно, чтобы у типа были все методы интерфейса с теми же сигнатурами. Никакого implements писать не нужно, и связи между пакетами не возникает - тип из чужой библиотеки удовлетворяет интерфейсу, о котором ничего не знает. Из этого следует главное правило: интерфейс объявляют у потребителя, а не у поставщика. Пакет говорит, что ему нужно, и не зависит от конкретных реализаций, а подменить их в тесте можно, не трогая чужой код. И делаю я такие интерфейсы максимально узкими: если сервису от хранилища нужны два метода, в интерфейсе два метода, а не двадцать. Образец тут - io.Reader и io.Writer по одному методу, поэтому им удовлетворяет буквально всё и любые два куска можно соединить. Минус неявной реализации один: опечатка в сигнатуре всплывает не в файле типа, а в месте подстановки. Лечу строчкой var _ Iface = (*MyType)(nil) рядом с объявлением - она ловит несоответствие прямо там, где тип живёт.
Механизм назван точно, из него выведено архитектурное правило про потребителя, показан критерий ширины интерфейса на известном примере и назван способ закрыть единственный минус подхода.
На чём валят
- −Объявляют интерфейс рядом с реализацией и тянут зависимость не в ту сторону.
- −Пишут интерфейс на двадцать методов и потом не могут написать заглушку для теста.
- −Заводят интерфейс при единственной реализации, которая и не собиралась меняться.
- −Забывают, что интерфейс - это пара из двух слов, и удивляются typed nil.
- −Кладут интерфейсные значения в ключи мапы и ловят панику на несравнимом типе.
- −Не ставят проверку var _ I = (*T)(nil) и ищут опечатку в сигнатуре по чужим пакетам.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 8, остальные разбираются в тренажёре.
- Что физически хранит переменная интерфейсного типа?A)Пару (динамический тип, значение): дескриптор типа и данныеB)Только указатель на данные, а тип теряется до рантайм-проверкиC)Копию всех методов реализации в виде замыканий внутри значенияD)Сериализованное значение, которое разбирается при каждом вызове метода
показать ответ и разбор
+A)Пару (динамический тип, значение): дескриптор типа и данные// разбор: Интерфейсное значение — это пара: дескриптор динамического типа (таблица методов, itable) и указатель на данные. Поэтому по интерфейсу известно, какой конкретный тип внутри (это читают type assertion и type switch), а вызов метода идёт через таблицу. Отсюда же следствие: интерфейс, хранящий типизированный nil, сам не равен nil.
- Какой дизайн-принцип для интерфейсов считается идиоматичным в Go?A)Заводить крупные интерфейсы с десятками методов на весь сервисB)Маленькие интерфейсы (часто 1 метод) и объявлять их у потребителяC)Объявлять интерфейс рядом с реализацией и экспортировать вместе с нейD)Каждой структуре — свой интерфейс-двойник ради тестируемости
показать ответ и разбор
+B)Маленькие интерфейсы (часто 1 метод) и объявлять их у потребителя// разбор: Идиома Go — маленькие интерфейсы (io.Reader/Writer — один метод) и объявление их на стороне ПОТРЕБИТЕЛЯ, а не производителя: потребитель описывает ровно то поведение, что ему нужно. «Accept interfaces, return structs». Крупные интерфейсы и «интерфейс на каждую структуру» плодят связанность и лишний код. Мелкие композируются в крупные при нужде.
- Что означает пустой интерфейс interface{} (он же any)?A)Тип, у которого нет методов, — ему удовлетворяет любое значениеB)Специальный тип-заглушку, принимающий только указатели и ссылочные типыC)Базовый тип, от которого неявно наследуются все остальные типы языкаD)Тип для значений неизвестного размера, вычисляемого во время работы
показать ответ и разбор
+A)Тип, у которого нет методов, — ему удовлетворяет любое значение// разбор: Требований ноль — значит, подходит что угодно. Отсюда роль any в местах, где тип заранее неизвестен: fmt.Println, json.Unmarshal, ключи и значения обобщённых контейнеров. Цена — потеря типизации: чтобы что-то с содержимым сделать, придётся доставать его ассерцией или type switch, и ошибка вылезет уже во время работы, а не при сборке.
- Зачем в коде пишут строку var _ io.Writer = (*MyType)(nil)?A)Чтобы запретить другим пакетам реализовывать этот интерфейс самостоятельноB)Чтобы зарегистрировать тип в рантайме как реализацию интерфейсаC)Чтобы компилятор проверил, что тип реализует интерфейсD)Чтобы создать нулевой экземпляр типа для последующего использования
показать ответ и разбор
+C)Чтобы компилятор проверил, что тип реализует интерфейс// разбор: Реализация интерфейса в Go неявная, поэтому опечатка в имени метода или не тот получатель обнаруживаются только в точке использования — иногда в чужом пакете. Эта строка ничего не создаёт (nil-указатель в пустую переменную) и не стоит ни байта в рантайме, зато ошибка вылезает при сборке того файла, где тип объявлен.
- Почему в Go интерфейсы принято объявлять в пакете-потребителе, а не рядом с реализацией?A)Компилятор быстрее разрешает такие зависимости при сборке проектаB)Потребитель описывает нужный ему минимум, и пакет с реализацией остаётся независимымC)Иначе реализация не сможет удовлетворить интерфейс из-за неявной реализацииD)Так требуют правила видимости: интерфейс должен лежать рядом с местом вызова
показать ответ и разбор
+B)Потребитель описывает нужный ему минимум, и пакет с реализацией остаётся независимым// разбор: Реализация неявная, поэтому объявлять контракт может тот, кому он нужен. Потребитель описывает ровно те методы, которые вызывает, — часто один-два вместо толстого интерфейса «на всё». Зависимость разворачивается: пакет с реализацией ничего не знает о потребителе, а подменить его в тестах можно любым типом с этими методами, без генераторов моков.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.