сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Интерфейсы и основы Go

Структуры и методы в 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, остальные разбираются в тренажёре.

  1. #go_structs_methods1 / 5
    Метод с получателем-значением func (t T) Set() меняет t.X. Виден ли эффект снаружи?
    A)Да: метод работает с оригиналом структуры
    B)Нет: получатель-значение копия, правки теряются — нужен (t *T)
    C)Да, если эту структуру ещё и передали как аргумент функции
    D)Виден только для полей-указателей внутри, остальные поля копируются
    показать ответ и разбор
    +B)Нет: получатель-значение копия, правки теряются — нужен (t *T)

    // разбор: Получатель-значение — копия структуры; правки в t.X внутри метода не видны снаружи, они уходят в копию. Чтобы метод менял оригинал, нужен получатель-указатель func (t *T). Правило: метод мутирует состояние или структура крупная — указатель; иначе значение. Смешивать типы получателей у одного типа не стоит.

  2. #go_structs_methods2 / 5
    type Celsius float64. Можно ли передать значение Celsius туда, где ждут float64?
    A)Да, автоматически: Celsius и float64 считаются одним и тем же типом
    B)Да, но лишь через промежуточный интерфейс
    C)Нет — именованный тип и базовый вообще никак не связаны
    D)Нет без явного float64(c): именованный тип отличен от базового
    показать ответ и разбор
    +D)Нет без явного float64(c): именованный тип отличен от базового

    // разбор: Определение type Celsius float64 создаёт НОВЫЙ именованный тип с тем же представлением, но отдельной идентичностью. Присвоить Celsius в float64-параметр без явного преобразования float64(c) нельзя — компилятор бракует несовпадение (это ловит логические ошибки: не сложишь Цельсии с Фаренгейтами). Исключение — нетипизированные константы. Само преобразование бесплатно в рантайме.

  3. #go_structs_methods3 / 5
    Что даёт встраивание типа в структуру — поле без имени, только с типом?
    A)Наследование: встроенный тип становится базовым классом для внешнего
    B)Продвижение полей и методов встроенного типа на уровень внешнего
    C)Копирование методов в новый тип с возможностью их переопределить
    D)Автоматическую реализацию всех интерфейсов, которые реализует встроенный тип
    показать ответ и разбор
    +B)Продвижение полей и методов встроенного типа на уровень внешнего

    // разбор: Встраивание — композиция, а не наследование: поля и методы встроенного типа доступны напрямую у внешнего, но никакого полиморфизма базового класса нет. Метод с тем же именем у внешнего типа перекрывает продвинутый, и обратиться к исходному можно явно, назвав встроенное поле по имени типа. Продвинутые методы действительно помогают удовлетворить интерфейс — это следствие, а не отдельный механизм.

  4. #go_structs_methods4 / 5
    У типа T методы объявлены на указателе (func (t *T) M()). Удовлетворяет ли значение T интерфейсу с методом M?
    A)Да: компилятор возьмёт адрес значения автоматически при присваивании
    B)Да, если значение хранится в переменной, а не является временным
    C)Нет: в набор методов типа T такие методы не входят, нужен *T
    D)Нет: методы на указателе вообще не участвуют в реализации интерфейсов
    показать ответ и разбор
    +C)Нет: в набор методов типа T такие методы не входят, нужен *T

    // разбор: Набор методов у T — только методы с получателем-значением; у *T — и те, и другие. Поэтому в интерфейс нужно класть &t, а не t. Путаницу создаёт вызов напрямую: t.M() компилируется, потому что компилятор сам берёт адрес адресуемой переменной. С интерфейсом этого не происходит — там значение копируется, и адрес брать не у чего.

  5. #go_structs_methods5 / 5
    Зачем структуре теги вида json:"name"?
    A)Они задают метаданные поля, которые читают библиотеки через рефлексию
    B)Они переименовывают поле в самой структуре для внешнего кода
    C)Они проверяются компилятором и задают ограничения на значение поля
    D)Они управляют порядком полей структуры в памяти при выравнивании
    показать ответ и разбор
    +A)Они задают метаданные поля, которые читают библиотеки через рефлексию

    // разбор: Тег — просто строка в описании поля; компилятор её не интерпретирует, а библиотеки читают через reflect. Кодировщик JSON берёт оттуда имя и опции вроде omitempty и «-», валидаторы — правила, драйверы БД — имена колонок. Отсюда типичная ловушка: опечатка в теге ничего не сломает при сборке, поле просто уедет в JSON под именем по умолчанию.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.