Структуры и методы в Go
Метод вызвался, отработал без ошибок, а поле снаружи не изменилось. Потом тот же тип отказался подставляться в интерфейс, хотя нужный метод у него явно есть - и компилятор пишет что-то про pointer receiver. Обе истории про одно: в Go метод получает либо копию, либо адрес, и от этого выбора зависит больше, чем кажется.
Стержень: получатель-значение работает с копией, а набор методов у T и у *T разный.
// Формулировки: «почему метод не изменил структуру?», «встраивание это наследование?», «зачем теги полей?»
Значение или указатель
Метод, объявленный как func (c Counter) Inc(), получает копию структуры. Он честно увеличивает поле - у копии. Копия умирает с концом метода, снаружи ноль. Хочешь менять оригинал - объявляй получатель указателем: func (c *Counter) Inc().
Второй повод взять указатель - размер. Структура на сотню байт копируется при каждом вызове метода, и на горячем пути это заметно. Порог не догма, но обычно про указатель начинают думать где-то после нескольких десятков байт.
У мелких структур значение часто выгоднее: копия остаётся на стеке, не нагружает сборщик мусора и не даёт случайно разделить состояние между горутинами. Это не микрооптимизация, а нормальный дефолт.
// Правило гигиены, которое спрашивают следующим вопросом: не смешивай получатели у одного типа. Если хотя бы один метод объявлен на указателе, делай такими все. Иначе набор методов расползается, и тип неожиданно перестаёт удовлетворять интерфейсу - ровно то, что разбираем дальше.
- получатель
- аргумент метода перед его именем: (t T) для копии или (t *T) для адреса
Набор методов: почему тип «не реализует» интерфейс
Правило короткое. В набор методов типа T входят только методы с получателем-значением. В набор методов *T входят и те, и другие. Причина простая: из значения не всегда можно получить адрес, а из адреса значение получить можно всегда.
Практическое следствие: если Inc объявлен на указателе, интерфейсу Incrementer удовлетворяет только *Counter. Присвоение значения даёт ошибку компиляции с довольно понятным текстом: Counter does not implement Incrementer (method Inc has pointer receiver). В интерфейс нужно класть &c.
Путаницу создаёт то, что прямой вызов c.Inc() при этом компилируется. Компилятор просто сам берёт адрес, потому что c - адресуемая переменная. При укладке в интерфейс он так не делает: туда копируется значение, и брать адрес уже не у чего.
// Хороший приём - проверка на этапе компиляции рядом с объявлением типа: var _ io.Writer = (*MyType)(nil). Строка ничего не создаёт и ничего не стоит, но ловит опечатку в имени метода и не тот получатель прямо в том файле, где тип живёт, а не в чужом пакете через месяц.
type Counter struct{ n int }
func (c *Counter) Inc() { c.n++ }
type Incrementer interface{ Inc() }
var c Counter
c.Inc() // компилируется: компилятор берёт &c сам
var i Incrementer = c // ошибка компиляции:
// Counter does not implement Incrementer (method Inc has pointer receiver)
var j Incrementer = &c // а так всё в порядке- набор методов
- методы, которые учитываются при проверке соответствия интерфейсу
- адресуемость
- свойство переменной, у которой можно взять адрес; у временного значения его нет
Встраивание - это композиция
Поле без имени, только с типом, называется встраиванием. Поля и методы встроенного типа продвигаются на уровень внешнего и доступны напрямую, как свои: если в структуру встроен sync.Mutex, у неё появляются Lock и Unlock.
Это композиция, а не наследование, и разница принципиальна. Полиморфизма базового класса нет: метод внешнего типа не переопределяет метод встроенного - он его затеняет. Встроенный тип продолжает вызывать свои собственные методы и ничего не знает о том, во что он встроен.
Отсюда типичное разочарование людей из мира классов: шаблонный метод, который вызывает переопределённый шаг, тут не работает. Нужное поведение получают интерфейсами - передают зависимость снаружи, а не наследуют.
// Встраивание интерфейса в структуру - отдельный полезный трюк: тип получает все методы интерфейса «бесплатно» (они делегируются во вложенное значение) и можно переопределить только нужные. Так пишут тестовые заглушки, не реализуя интерфейс из двадцати методов целиком.
- встраивание
- поле без имени; его поля и методы продвигаются на уровень внешнего типа
Теги полей: строка, которую никто не проверяет
Тег вроде json:"name,omitempty" - обычная строка, приклеенная к полю. Компилятор её не разбирает и не проверяет вообще. Читают её библиотеки во время работы, через рефлексию: кодировщик JSON берёт оттуда имя поля и флаги, драйверы баз - имена колонок, валидаторы - правила.
Отсюда первая ловушка: опечатка в теге ничего не ломает при сборке. Написал jsno:"name" - код собрался, тесты прошли, а поле уехало в JSON под именем по умолчанию, с заглавной буквы. Ловится это только глазами или линтером.
Вторая ловушка серьёзнее: поле с маленькой буквы не попадёт в JSON вообще, сколько тегов на него ни вешай. Рефлексия не видит неэкспортируемые поля, и кодировщик просто пройдёт мимо - молча, без ошибки.
// Поэтому структуры, которые ездят по сети или в базу, стоит держать отдельно от внутренних: у них другая задача и другие правила. Заодно перестаёшь случайно публиковать наружу поля, добавленные для внутренних нужд.
- тег поля
- строка с метаданными, которую читают библиотеки через рефлексию; компилятор её не проверяет
Как отвечать: «Почему метод не изменил структуру и когда брать указатель?»
Потому что получатель-значение - это копия. Метод func (c Counter) Inc() увеличивает поле у своей копии, копия умирает вместе с вызовом, снаружи ничего не меняется. Чтобы менять оригинал, получатель должен быть указателем. Второй повод взять указатель - крупная структура, копирование которой заметно на горячем пути. У мелких структур значение часто даже выгоднее: копия остаётся на стеке и не грузит сборщик мусора. И есть важное следствие для интерфейсов: в набор методов типа T входят только методы с получателем-значением, а у *T - и те, и другие. Поэтому если методы объявлены на указателе, интерфейсу удовлетворяет только указатель, и компилятор скажет прямо: method Inc has pointer receiver. Сбивает с толку, что прямой вызов c.Inc() при этом компилируется - там компилятор сам берёт адрес адресуемой переменной, а при укладке в интерфейс не берёт, потому что туда копируется значение. Практическое правило: не смешивать получатели у одного типа, и рядом с объявлением ставить проверку var _ I = (*T)(nil), чтобы несоответствие всплывало сразу.
Механизм копии назван прямо, критерий выбора дан по двум осям, следствие для интерфейсов объяснено вместе с причиной путаницы, и добавлены правило единообразия и способ поймать ошибку на компиляции.
На чём валят
- −Меняют поле в методе с получателем-значением и не понимают, почему снаружи ноль.
- −Кладут значение в интерфейс, когда методы объявлены на указателе.
- −Считают встраивание наследованием и ищут полиморфизм базового класса.
- −Смешивают получатели-значения и указатели у одного типа и ломают набор методов.
- −Делают опечатку в теге: сборка проходит, поле уезжает под именем по умолчанию.
- −Забывают заглавную букву в поле - оно молча исчезает из JSON.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 10, остальные разбираются в тренажёре.
- Метод с получателем-значением func (t T) Set() меняет t.X. Виден ли эффект снаружи?A)Да: метод работает с оригиналом структурыB)Нет: получатель-значение копия, правки теряются — нужен (t *T)C)Да, если эту структуру ещё и передали как аргумент функцииD)Виден только для полей-указателей внутри, остальные поля копируются
показать ответ и разбор
+B)Нет: получатель-значение копия, правки теряются — нужен (t *T)// разбор: Получатель-значение — копия структуры; правки в t.X внутри метода не видны снаружи, они уходят в копию. Чтобы метод менял оригинал, нужен получатель-указатель func (t *T). Правило: метод мутирует состояние или структура крупная — указатель; иначе значение. Смешивать типы получателей у одного типа не стоит.
- type Celsius float64. Можно ли передать значение Celsius туда, где ждут float64?A)Да, автоматически: Celsius и float64 считаются одним и тем же типомB)Да, но лишь через промежуточный интерфейсC)Нет — именованный тип и базовый вообще никак не связаныD)Нет без явного float64(c): именованный тип отличен от базового
показать ответ и разбор
+D)Нет без явного float64(c): именованный тип отличен от базового// разбор: Определение type Celsius float64 создаёт НОВЫЙ именованный тип с тем же представлением, но отдельной идентичностью. Присвоить Celsius в float64-параметр без явного преобразования float64(c) нельзя — компилятор бракует несовпадение (это ловит логические ошибки: не сложишь Цельсии с Фаренгейтами). Исключение — нетипизированные константы. Само преобразование бесплатно в рантайме.
- Что даёт встраивание типа в структуру — поле без имени, только с типом?A)Наследование: встроенный тип становится базовым классом для внешнегоB)Продвижение полей и методов встроенного типа на уровень внешнегоC)Копирование методов в новый тип с возможностью их переопределитьD)Автоматическую реализацию всех интерфейсов, которые реализует встроенный тип
показать ответ и разбор
+B)Продвижение полей и методов встроенного типа на уровень внешнего// разбор: Встраивание — композиция, а не наследование: поля и методы встроенного типа доступны напрямую у внешнего, но никакого полиморфизма базового класса нет. Метод с тем же именем у внешнего типа перекрывает продвинутый, и обратиться к исходному можно явно, назвав встроенное поле по имени типа. Продвинутые методы действительно помогают удовлетворить интерфейс — это следствие, а не отдельный механизм.
- У типа T методы объявлены на указателе (func (t *T) M()). Удовлетворяет ли значение T интерфейсу с методом M?A)Да: компилятор возьмёт адрес значения автоматически при присваиванииB)Да, если значение хранится в переменной, а не является временнымC)Нет: в набор методов типа T такие методы не входят, нужен *TD)Нет: методы на указателе вообще не участвуют в реализации интерфейсов
показать ответ и разбор
+C)Нет: в набор методов типа T такие методы не входят, нужен *T// разбор: Набор методов у T — только методы с получателем-значением; у *T — и те, и другие. Поэтому в интерфейс нужно класть &t, а не t. Путаницу создаёт вызов напрямую: t.M() компилируется, потому что компилятор сам берёт адрес адресуемой переменной. С интерфейсом этого не происходит — там значение копируется, и адрес брать не у чего.
- Зачем структуре теги вида
json:"name"?A)Они задают метаданные поля, которые читают библиотеки через рефлексиюB)Они переименовывают поле в самой структуре для внешнего кодаC)Они проверяются компилятором и задают ограничения на значение поляD)Они управляют порядком полей структуры в памяти при выравниваниипоказать ответ и разбор
+A)Они задают метаданные поля, которые читают библиотеки через рефлексию// разбор: Тег — просто строка в описании поля; компилятор её не интерпретирует, а библиотеки читают через reflect. Кодировщик JSON берёт оттуда имя и опции вроде omitempty и «-», валидаторы — правила, драйверы БД — имена колонок. Отсюда типичная ловушка: опечатка в теге ничего не сломает при сборке, поле просто уедет в JSON под именем по умолчанию.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.