Разберите последствия: структура встраивает интерфейсное поле, после чего её значение передают в тот же интерфейс. Почему передача успешна, но вызов метода может завершиться паникой?
Передача успешна, потому что методы встроенного интерфейсного поля продвигаются в методический набор структуры. Если само встроенное поле содержит нулевое интерфейсное значение, вызов продвинутого метода не может найти получателя и завершается паникой во время выполнения.
Встраивание в Go предназначено для композиции без явного написания делегирующих методов. Оно позволяет структуре получать методы вложенного типа и тем самым удовлетворять интерфейсам структурно, без объявления намерения реализовать интерфейс.
Такой подход уменьшает шаблонный код, но отделяет проверку совместимости типов от корректности состояния вложенных значений. Компилятор проверяет методический набор, а не то, инициализировано ли встроенное поле в конкретном экземпляре.
Рассмотрим структуру со встроенным интерфейсом. Нулевая структура формально имеет нужный метод, поэтому её можно присвоить переменной этого интерфейсного типа.
Риск возникает при вызове метода: для делегирования требуется обратиться к встроенному интерфейсному полю, но оно равно nil. В результате программа падает во время выполнения, хотя присваивание прошло проверку компилятора.
Если интерфейс Reader встроен в Holder, его метод Read продвигается в методический набор Holder. Поэтому Holder удовлетворяет Reader, и значение Holder{} можно упаковать в Reader.
При этом динамическое значение внешнего интерфейса имеет тип Holder, а не nil. Проверка самого интерфейса на nil поэтому не обнаружит проблему. Во время вызова Read Go использует продвинутый метод, который фактически делегирует вызов встроенному полю Reader; у нулевой структуры это поле равно nil, что приводит к панике.
Безопасное решение — явно инициализировать встроенное поле перед передачей структуры или реализовать метод внешней структуры самостоятельно, добавив проверку состояния. Первый вариант сохраняет делегирование, второй даёт полный контроль над поведением при отсутствии вложенного объекта, но увеличивает объём кода.
Важно отличать нулевое интерфейсное поле от интерфейса, содержащего указатель со значением nil. Во втором случае вызов может не привести к панике, если метод указательного типа умеет корректно работать с нулевым получателем; это зависит от реализации метода.
В обработчике сообщений есть структура LoggingHandler, встраивающая интерфейс Handler. Конструктор иногда возвращает нулевой LoggingHandler, но его передают в API, принимающий Handler; передача успешна, а первая обработка сообщения вызывает панику.
Вариант с проверкой handler == nil недостаточен: интерфейс содержит динамическое значение LoggingHandler и сам не равен nil. Можно запретить нулевую структуру через конструктор, но это не защищает от её создания литералом.
Практичнее реализовать явный метод Handle у LoggingHandler, проверить вложенный Handler и вернуть контролируемую ошибку либо выполнить резервное поведение. Если делегирование обязательно и состояние всегда должно быть полным, следует скрыть поля и создавать объект только через конструктор, который инициализирует вложенный интерфейс.
Почему проверка внешнего интерфейса на nil не выявляет проблему?
Интерфейсное значение считается nil только когда в нём отсутствуют и динамический тип, и динамическое значение. После присваивания Holder{} внешний интерфейс содержит динамический тип Holder, поэтому он не равен nil, даже если вложенное поле Reader нулевое.
Кто именно вызывает панику: внешний интерфейс или встроенное поле?
Внешний интерфейс успешно выбирает реализацию для динамического типа Holder. Затем продвинутый метод пытается делегировать вызов встроенному Reader. Панику вызывает обращение к нулевому интерфейсному получателю на этом этапе, а не само присваивание внешнему интерфейсу.
Всегда ли встроенный интерфейс нужно заменять явной композицией?
Нет. Встраивание удобно, если жизненный цикл вложенного объекта гарантирован и делегирование действительно является требуемым поведением. Явное поле с собственным методом лучше, когда нужно валидировать состояние, возвращать ошибки или различать отсутствие зависимости и корректный вызов.