Go — язык, который ценится за простоту и чёткость. Однако даже при минимальном синтаксисе здесь есть нюансы. Особенно это касается работы с функциями и методами. В этой небольшой статье хочу поделиться личными наблюдениями и выводами по нескольким ключевым темам: выбор типа получателя, использование именованных параметров результата, распространённые ошибки при возврате nil, проблемы с файлами в качестве входных данных и поведение defer.

Получатель значения или указателя?

Одна из первых вещей, которую нужно решить при объявлении метода — тип получателя: значение или указатель.

Когда использовать указатель

Если необходимо, чтобы изменения внутри метода влияли на исходный объект — используйте указатель. Например:

type Customer struct {
    Balance float64
}

func (c *Customer) Add(amount float64) {
    c.Balance += amount
}

В этом случае изменение баланса будет отражено в оригинальной структуре.

Также стоит использовать указатель, если:

  • Метод должен модифицировать состояние получателя.

  • Получатель содержит поля, которые нельзя копировать (например, мьютексы).

  • Это крупный объект — указатель эффективнее по памяти.

Интересный момент: передача указателя не означает передачу по ссылке. Go всегда работает с копиями. Просто в случае указателя копируется адрес, а не сам объект. Поэтому все изменения происходят с тем же объектом.

Если сомневаетесь — выбирайте указатель. Это безопаснее и чаще используется в стандартной библиотеке.

А когда использовать значение?

Если нужно, чтобы метод не менял оригинал, можно использовать получатель-значение:

func (c Customer) String() string {
    return fmt.Sprintf("Balance: %.2f", c.Balance)
}

Плюс такого подхода в том что объект затронут не будет.

Также значение подходит, если:

  • Необходимо явно показать, что метод не изменяет объект.

  • Получатель — примитив (int, string), карта, канал или небольшая структура.

Но нужно знать что, даже при использовании значения как получателя, можно влиять на внешний объект, если он содержит вложенные указатели:

type Account struct {
    info *Info
}

func (a Account) UpdateName(name string) {
    a.info.Name = name // Изменяется внешний объект!
}

Хотя метод использует значение, он всё равно может влиять на состояние, потому что поле info является указателем.

Смешивание типов получателей

Можно ли объявлять одни методы со значением, а другие — с указателем? Например, один метод — func (c Customer) Info(), другой — func (c *Customer) Update()?

В общем случае лучше избегать такого смешивания. Это может запутать пользователей API и усложнить понимание кода. Однако в стандартной библиотеке есть исключения. Например, time.Time почти полностью использует получатели-значения для методов вроде .UTC() или .IsZero(). Но метод .UnmarshalBinary(), необходимый для реализации интерфейса encoding.BinaryUnmarshaler, требует указателя. Так что формально смешивание допустимо, если есть веские причины. Но лучше использовать его осторожно и документировать такие решения.

Именованные параметры результата

Go позволяет давать имена возвращаемым значениям. Это может быть полезно для улучшения читаемости, особенно если функция возвращает несколько значений одного типа:

func GetCoordinates(address string) (lat, lng float32, err error)

Здесь сразу понятно, что возвращают эти два числа с плавающей точкой — широту и долготу. Но есть нюансы. Например, именованные параметры автоматически инициализируются нулевыми значениями. Это может привести к неочевидным багам:

func validate(ctx context.Context) (valid bool, err error) {
    if ctx.Err() != nil {
        // Здесь err == nil!
        return
    }
    valid = true
    return
}

В этом примере err был инициализирован как nil, и return без аргументов просто вернул его. То есть даже при отмене контекста ошибка не будет возвращена.

Более того, если смешать return с аргументами и без них, это может запутать того кто читает код:

if err != nil {
    return false, err
}
return // valid = false?

Использовать именованные параметры лучше, если они действительно помогают читать код. Но нужно помнить, что они уже инициализированы и могут скрывать логику. Также луше избегать смешивания return с аргументами и без них в одной функции.

Возврат nil интерфейса

Это одна из самых распространённых ошибок в Go. Допустим, что мы возвращаем error из метода:

func (c Customer) Validate() error {
    var m *MultiError
    if hasErrors {
        m = &MultiError{}
        m.Add(...)
    }
    return m // ОШИБКА!
}

Если проверки успешны, m будет равен nil, но интерфейс error при этом не станет nil. Почему?

Потому что в интерфейс упакован нулевой указатель на конкретную реализацию. Интерфейс при этом не равен nil, поскольку содержит тип (*MultiError) и значение (nil). Такое поведение может быть неожиданным.

Правильный же вариант будет:

if m != nil {
    return m
}
return nil

Это важное различие между указателем и интерфейсом. Лучше проверять такие случаи, особенно если необходимо понимать собственные типы ошибок.

Передача имя файла напрямую

Функция, которая принимает имя файла, часто становится труднотестируемой и менее гибкой. Например:

func CountEmptyLines(filename string) (int, error)

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

func CountEmptyLines(r io.Reader) (int, error)

Теперь можно вызвать эту функцию и с файла, и с HTTP-тела запроса, и с обычной строки:

reader := strings.NewReader("line 1\n\nline 2")
count, _ := CountEmptyLines(reader)

Это делает код более переиспользуемым и легко тестируемым.

defer: аргументы и получатели вычисляются сразу

Кто-то, возможно, думает, что defer запоминает переменные в их финальном состоянии. На самом деле всё не так:

func f() {
    var status string
    defer logStatus(status)

    status = "done"
}

status вычисляется в момент вызова defer , а не при выходе из функции.

Чтобы обойти это, есть 2 варианта:

Передавать указатель:

defer logStatus(&status)

Использовать замыкание:

defer func() {
    logStatus(status)
}()

То же самое относится и к методам. Если вызвать метод через defer, то он получает текущее состояние получателя — значение или указатель.

Заключение

Go — язык, где простота обманчива. Под кажущейся лёгкостью скрываются тонкости, которые требуют понимания. При работе с функциями и методами важно:

  • Выбирать правильный тип получателя.

  • Осторожно использовать именованные параметры результата.

  • Помнить про особенности интерфейсов и nil.

  • Абстрагироваться от источников данных с помощью io.Reader.

  • Учитывать поведение defer и вычисления аргументов.

Комментарии (1)


  1. gohrytt
    30.05.2025 16:38

    Ой, а я видел в продакшн код где через defer пишется лог, в который отправляются вот так через замыкание status, event и ещё пара переменных.
    Никогда так не делайте, это потом читать - глаза вытекают.