Методы и ресиверы в Go: value против pointer и ловушка method set

Value- или pointer-ресивер — не вопрос вкуса: value-метод работает на копии (мутации теряются), а method set значения НЕ включает pointer-receiver-методы, поэтому тип с таким методом удовлетворяет интерфейсу только как *T. Разбираем выбор ресивера, method set, копирование и цену — на живом стенде

func (c Counter) Inc() или func (c *Counter) Inc() — на первый взгляд разница косметическая, «звёздочка или нет». На деле это два разных решения сразу в трёх плоскостях: видит ли вызывающий мутацию, сколько байт копируется на каждый вызов и — самое коварное — какому интерфейсу удовлетворяет ваш тип. Последнее регулярно ловит даже опытных: тип с методом String() на *T не является fmt.Stringer, если держать его по значению, и компилятор объяснит это далеко от места ошибки. Разберём выбор ресивера по-инженерному — с мутацией на копии, method set, ценой копирования и method value/expression — заземляя каждый пункт на живой стенд.

Это четвёртая статья мини-серии «Основы Go вглубь». Она напрямую стыкуется с интерфейсами: именно method set решает, удовлетворяет ли тип интерфейсу, и именно ресивер определяет method set.

Схема «Полдень»: value- против pointer-ресивера — метод-машина работает с копией [256]int (мутация теряется) против передачи указателя (оригинал меняется); method set: *Doc удовлетворяет Stringer, Doc — нет

В статье

Метод и ресивер: синтаксис

Метод — это функция с особым дополнительным параметром, ресивером (receiver), который пишется в скобках перед именем метода. Ресивер связывает функцию с типом: M становится методом типа T, и вызывается как t.M().

type Counter struct {
	N int
}

// value-ресивер: метод получает КОПИЮ Counter
func (c Counter) IncValue() {
	c.N++ // правится копия — снаружи не видно
}

// pointer-ресивер: метод получает указатель на оригинал
func (c *Counter) IncPtr() {
	c.N++ // правится оригинал
}

Ресивер бывает двух видов, и это ключевая развилка всей статьи:

  • value receiverfunc (c Counter) M(). Метод получает копию значения. Изменения внутри метода видит только копия.
  • pointer receiverfunc (c *Counter) M(). Метод получает указатель на оригинал. Изменения сохраняются в исходном значении.

Ресивером может быть любой именованный тип, объявленный в том же пакете, — не обязательно struct: годится type Celsius float64, type IDList []int и так далее. Нельзя объявлять методы на типах из чужих пакетов и на указательных/интерфейсных типах напрямую.

Важная синтаксическая деталь: вызов метода одинаков для обоих ресиверовc.IncValue() и c.IncPtr(). Для pointer-метода на адресуемом значении Go сам берёт адрес: c.IncPtr() — это сахар для (&c).IncPtr(). Обратно, для value-метода через указатель Go сам разыменует: p.IncValue() эквивалентно (*p).IncValue(). Из-за этого автосахара разница ресиверов на месте вызова невидима — и тем опаснее.

Value против pointer: мутация на копии

Главное практическое следствие вида ресивера: value-метод работает на копии, и мутация теряется; pointer-метод правит оригинал. На маленьком Counter это уже видно, но эффект нагляднее на «тяжёлой» структуре. Стенд cmd/receiver-copy строит структуру Big с массивом [256]int внутри и заполняет её через оба ресивера:

type Big struct {
	data [256]int
}

// value-ресивер: заполняет data, но правит КОПИЮ
func (b Big) Fill(v int) {
	for i := range b.data {
		b.data[i] = v
	}
}

// pointer-ресивер: заполняет data в оригинале
func (b *Big) FillPtr(v int) {
	for i := range b.data {
		b.data[i] = v
	}
}

Запуск go run ./methods/cmd/receiver-copy печатает (сверено на go1.26.3):

== value-ресивер: мутация теряется ==
до  Fill(7):  data[0]=0 sum=0
после Fill(7): data[0]=0 sum=0   <- ничего не изменилось: метод правил КОПИЮ

== pointer-ресивер: мутация сохраняется ==
до  FillPtr(7):  data[0]=0 sum=0
после FillPtr(7): data[0]=7 sum=1792  <- оригинал изменён (256*7)

После Fill(7) оригинал остаётся data[0]=0, sum=0 — метод правил свою копию всего массива и выкинул её на выходе. После FillPtr(7) оригинал стал data[0]=7, sum=1792 (256 × 7). Компилятор при этом молчит: обе версии собираются, «потерянная мутация» — не ошибка типов, а тихо пропавший эффект. Это одна из самых частых причин недоумения «почему мой метод ничего не меняет».

Правило выбора по мутации простое: если метод должен изменять получатель — только pointer-ресивер. Value-ресивер для «сеттера» — это баг, который не ловится компилятором.

Method set и удовлетворение интерфейса

Это узел, ради которого стоит понять ресиверы до конца, и главная стыковка с интерфейсами. Method set типа — это набор методов, которые к нему «прикреплены» и, в частности, определяют, каким интерфейсам тип удовлетворяет. И вот несимметричное правило спецификации:

  • Method set значения T содержит только методы с value-ресивером.
  • Method set указателя *T содержит методы обоих видов — и value-, и pointer-ресивера.

Иначе говоря, pointer-receiver-метод входит в method set *T, но не входит в method set T. Отсюда — эффект, который ловит многих. Возьмём тип Doc с методом String() на указательном ресивере:

type Stringer interface {
	String() string
}

type Doc struct {
	Title string
}

// String объявлен на *Doc — метод в method set только у *Doc
func (d *Doc) String() string {
	return "doc:" + d.Title
}

Тогда присвоение интерфейсу работает только через указатель:

var s Stringer = &Doc{Title: "readme"} // ок: String в method set *Doc

var s Stringer = Doc{} // НЕ компилируется:
// cannot use Doc{} as Stringer: String method has pointer receiver

Ошибка возникает в момент присваивания, часто далеко от объявления метода, и формулировка method has pointer receiver не сразу наводит на мысль. В стенде вторую строку намеренно нет в коде — она не собралась бы и уронила бы весь пакет; позитивный путь через *Doc закреплён тестом.

Обратная сторона правила: value-receiver-метод входит в оба method set. Тип Labeled с методом Label() на value-ресивере удовлетворяет интерфейсу и как значение, и как указатель:

type Labeler interface{ Label() string }

type Labeled struct{ Text string }

func (l Labeled) Label() string { return "label:" + l.Text }

var a Labeler = Labeled{Text: "v"}  // ок: value-метод в method set значения
var b Labeler = &Labeled{Text: "p"} // ок: value-метод в method set указателя

Практический вывод, объясняющий целый пласт идиом Go: если хотя бы один метод типа объявлен на pointer-ресивере, с типом почти всегда работают через указатель — передают &x, а не x, кладут в срез []*Doc, а не []Doc. Иначе значение просто «не пролезет» в интерфейс. Отсюда же общая рекомендация: не смешивайте ресиверы у одного типа — либо все методы value, либо все pointer. Смешанный набор даёт тип, у которого часть методов доступна только через указатель, и рассуждать о его method set становится тяжело. Подробнее про то, как интерфейс хранит пару (тип, значение) и почему nil-указатель в интерфейсе — не nil-интерфейс, — в статье про интерфейсы.

Цена копирования ресивера

Второй мотив выбирать pointer-ресивер — не мутация, а стоимость копирования: value-метод копирует весь получатель при каждом вызове, pointer-метод — один указатель независимо от размера структуры. Так написано в спецификации, и на этом обычно строят совет «большие структуры принимайте по указателю». Проверим его на структуре heavy{[256]int} — два килобайта на amd64.

Первая редакция этого бенча измеряла не то, что заявляла, и это поучительнее самого совета:

func (h heavy) touchValue() int { return h.data[0] } // «копирует всю структуру»
func (h *heavy) touchPtr() int  { return h.data[0] } // копирует указатель

Комментарий в первой строке неверен. Метод читает одно поле, вызов инлайнится, и копировать два килобайта ради data[0] компилятору незачем — он этого и не делает. На Go 1.27 обе версии идут за одинаковые доли наносекунды:

Бенчмарк (инлайн разрешён) ns/op аллокации
BenchmarkReceiverValueInlined 0.24–0.27 0
BenchmarkReceiverPointerInlined 0.24–0.26 0

Копия ресивера — не то, что написано в сигнатуре, а то, что компилятор решил не устранять. Чтобы копирование стало обязательным, нужно запретить инлайн: тогда аргумент передаётся по значению по соглашению о вызовах, и два килобайта действительно едут на стек.

Бенчмарк (//go:noinline) ns/op аллокации
BenchmarkReceiverValueNoInline 28–38 0
BenchmarkReceiverPointerNoInline ~1.4 0

Вот она, цена копирования 2 КБ — около 30 наносекунд, в 20–25 раз дороже передачи указателя. Аллокаций ноль в обоих случаях: копия живёт на стеке, дорого именно копирование байтов, а не работа сборщика.

Третья пара бенчмарков проверяет саму эту оценку и заодно показывает, как разница выглядит в коде, у которого есть настоящая работа. Метод читает все 256 элементов:

Бенчмарк (метод суммирует весь массив) ns/op аллокации
BenchmarkReceiverValueFullRead 167–176 0
BenchmarkReceiverPointerFullRead 134–140 0

Разница — около 33 нс, и это сходится с ценой копирования из предыдущей пары (30 нс): копирование просто добавляется к работе метода. Две независимые пары дали одно и то же число — значит измеряется действительно копирование, а не что-то ещё. В относительных величинах та же разница выглядит уже не как «в 20 раз», а как плюс четверть времени, потому что знаменатель вырос.

Числа сняты на go1.27.0, windows/amd64, три прогона каждой пары; в стенде их намеренно нет — снимайте у себя, про методику — профилирование и бенчмарки.

Что из этого следует для выбора ресивера. Ровно то, что «дорогое копирование» — аргумент куда более узкий, чем принято думать. Он работает там, где вызов не инлайнится: метод крупный, вызывается через интерфейс, уходит в другой пакет без инлайнинга. В коде, где метод мелкий и инлайнится, разницы нет вовсе — и микробенчмарк, написанный «в лоб», покажет именно это, сколько бы килобайт ни лежало в структуре. Выбирать ресивер стоит по семантике — нужна ли вызывающему видимая мутация, нужен ли nil-получатель, консистентен ли набор методов типа, — а не по гадательной экономии наносекунд.

Важно не сделать из этого культ и в другую сторону: на мелких структурах разница исчезает тем более. Для Counter{N int}, time.Time, пары полей копирование получателя стоит копейки, и value-ресивер там совершенно нормален — он даёт неизменяемость и не заставляет думать про nil. Порог «стоит переходить на указатель ради копии» — это структуры в несколько машинных слов и больше, особенно с массивами или встроенными большими полями внутри (не путать со срезами и map — у них сам заголовок мал, см. внутренности срезов и map).

Адресуемость, nil-ресивер, встраивание

Три следствия из механики ресиверов, которые всплывают на практике.

Адресуемость. Автосахар c.IncPtr()(&c).IncPtr() работает, только если c адресуемо — у него можно взять адрес. Локальная переменная и поле структуры адресуемы; элемент map и результат вызова функции — нет. Поэтому pointer-метод нельзя вызвать на элементе map напрямую:

m := map[string]Counter{"a": {}}

m["a"].IncValue() // ок: value-метод, копия элемента
m["a"].IncPtr()   // НЕ компилируется: cannot call pointer method on m["a"]
                  // (элемент map не адресуем — Go не может взять &m["a"])

Обходной путь — достать значение в локальную переменную, изменить, положить обратно (v := m["a"]; v.IncPtr(); m["a"] = v), либо хранить в map указатели: map[string]*Counter. Тот же барьер объясняет, почему нельзя вызвать pointer-метод на литерале — Counter{}.IncPtr() не соберётся.

nil-ресивер. Pointer-метод можно вызвать на nil-указателе — рантайм не падает на самом вызове, паника случится только при разыменовании поля. Метод, написанный с оглядкой на это, корректно работает на nil-получателе; классика — методы обхода древовидных структур:

type Node struct {
	Val         int
	Left, Right *Node
}

// Sum корректен даже если узел nil — nil-дерево имеет сумму 0.
func (n *Node) Sum() int {
	if n == nil { // ресивер может быть nil, это не паника
		return 0
	}
	return n.Val + n.Left.Sum() + n.Right.Sum()
}

Это же — источник известной ловушки с интерфейсами: nil-указатель, положенный в интерфейс, даёт не nil-интерфейс (у интерфейса заполнена часть с типом), поэтому err != nil может быть истинным при nil-указателе внутри. Разбор — в статье про интерфейсы.

Встраивание и продвижение методов. При встраивании (embedding) методы вложенного типа продвигаются во внешний, и вид ресивера сохраняется в method set по тому же правилу. Если встроить T по значению, в method set внешнего значения попадут только value-методы T; чтобы получить и pointer-методы T, встраивают *T. Практический вывод: встраивая тип, у которого важные методы — на pointer-ресивере, встраивайте его указателем (или работайте с внешним типом через *), иначе продвинутый method set окажется урезанным и внешний тип «не пролезет» в нужный интерфейс.

Method value и method expression

Метод в Go — полноценное значение, и есть два способа получить из него функцию.

Method valuef := c.M. Ресивер c «замораживается» в момент взятия значения, а f получает тип обычной функции без ресивера. Для pointer-метода method value держит указатель на связанный получатель и мутирует именно его:

c := &Counter{}
f := c.IncPtr // method value: c связан; тип f — func()
for i := 0; i < 3; i++ {
	f() // мутирует тот же c
}
// c.N == 3

Method expressiong := T.M. Здесь ресивер не связывается, а становится первым явным аргументом функции. Для value-метода Counter.IncValue тип выражения — func(Counter), и получатель передаётся по значению (копией):

g := Counter.IncValue // method expression: тип g — func(Counter)
c := Counter{N: 5}
g(c) // c передан копией — оригинал не меняется
// c.N == 5

Разница по сути: method value фиксирует конкретный получатель и прячет его внутрь замыкания (func()), method expression оставляет получатель открытым параметром (func(Counter)) — удобно, когда нужно применить метод к разным значениям, например передать T.M в sort/slices-хелпер как обычную функцию. Оба следуют тем же правилам ресивера: у method value от pointer-метода вызовы мутируют оригинал, у method expression от value-метода — копию.

Как выбирать ресивер

Свод правил, в порядке приоритета:

  • Метод мутирует получатель → pointer. Value-ресивер для мутации — тихий баг: правки теряются, компилятор молчит.
  • Тип нужно класть в интерфейс, и хотя бы один метод — pointer → работайте через *T и делайте pointer-ресиверы у всех методов. Значение T не удовлетворит интерфейс, если метод объявлен на *T.
  • Структура крупная (массивы, много полей) → pointer, чтобы не копировать её на каждом вызове. Но помните, чему учит замер выше: копию часто устраняет компилятор, и цена проявляется только там, где вызов не инлайнится. Это довод в пользу указателя, а не главный из них; порог — несколько машинных слов и выше, и его стоит проверять замером на своём коде, а не на синтетическом методе, читающем одно поле.
  • Консистентность важнее всего: не смешивайте ресиверы у одного типа. Либо все value, либо все pointer. Смешанный набор даёт неоднородный method set и путаницу с интерфейсами.
  • Маленький неизменяемый тип → value нормален. Пара полей, отсутствие мутации, отсутствие nil-состояния — value-ресивер проще и безопаснее.
  • Есть pointer-метод — избегайте копий значения этого типа: копия несёт своё состояние, и мутация через один экземпляр не видна другому. Особенно осторожно с типами, которые нельзя копировать (sync.Mutex и всё, что его встраивает).

Если сомневаетесь между «маленький и неизменяемый» и «может вырасти / попадёт в интерфейс» — берите pointer: это выбор по умолчанию для доменных структур в Go. Оформление ошибок как значений с методами (Error() string) — отдельный распространённый случай, где ресивер выбирают осознанно; см. обработку ошибок.

Демо и версии

  • Код: живой стенд digital-cookbook/go-fundamentals/methods/. Ловушка value-ресивера на большой структуре — исполняемый пример cmd/receiver-copy (go run ./methods/cmd/receiver-copy, вывод сверен с текстом выше). Корректность двух ресиверов, удовлетворение интерфейса через method set (позитивный путь *Doc и невключаемая строка var s Stringer = Doc{}), method value и method expression покрыты тестами: go test ./methods/. Цена копирования — бенч receiver_bench_test.go: go test -bench=BenchmarkReceiver -benchmem -run='^$' ./methods/. Сниппеты в тексте — выжимки из стенда.
  • Версии: примеры собираются на любом поддерживаемом релизе Go; числа (вывод демо и порядок величин бенча) сняты на go1.26.3, windows/amd64. Абсолютные ns/op зависят от машины, компилятора и инлайнинга — снимайте у себя, в стенде их намеренно нет.
  • Смежные статьи серии и рядом: интерфейсы, обработка ошибок, внутренности срезов и map, профилирование и бенчмарки.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии