Осторожно, ловушка

23 minute read

Проблема

Go замечательный, простой и понятный язык. Почти все места и конструкции прозрачны как воды Байкала. Даже указатели не пугают, в отличии от C/C++. Но есть парочка мест, которые не так очевидны, как ожидалось.

Один из таких непонятных моментов - это использование наборов методов для указателей и неуказателей в связке с интерфейсами. Сначала я опишу саму проблему, а потом приведу перевод документации про наборы методов(method sets), чтобы было понятно откуда ноги растут.

И так, начнем с простого примера. У нас есть некоторый интерфейс:

type phoner interface {
    call()
}

И мы хотим реализовать этот интерфейс. Для этого создадим свой тип на базе структуры:

type phone struct {
}

func (p *phone) call() {
    fmt.Println("call")
}

Тут видно, что приемник у метода call это переменная типа *phone, то есть это указатель.

Так как phone реализовывает интерфейс phoner, то теперь мы можем использовать интерфейс как тип для наших переменных:

var p phoner = &phone{}
p.call()

В этом коде нет ничего подозрительного. Метод call использует приемник с типом указателя, а в переменную p мы именно указатель и записываем.

Что будет, если мы вместо &phone{} будем использовать просто phone{}?

var p phoner = phone{}
p.call()

В таком случае, ваша программа даже не соберется, потому что типы не совпадают. Интерфейс phoner реализован для типа *phone, но не для типа phone. Что, в принципе, понятно: указатель на значение и само значение это два совершенно разных типа.

Хорошо, давайте теперь реализуем интерфейс не для указателя, а для значения:

type phone struct {
}

func (p phone) call() {
    fmt.Println("call")
}

И будем его использовать

var p phoner = phone{}
p.call()

И тут мы подкрались с самому непонятному месту. Вот так тоже работает:

var p phoner = &phone{}
p.call()

А чтобы разобраться почему все именно так, нужно чуть более внимательно почитать документацию.

Наборы методов(Method Sets)

Далее я перевел небольшой отрывок документации, в котором описаны наборы методов и правила их использования.

Спецификация

Наборы методов для определенного типа имеют важное значение в Go. Набор методов определяет, какой интерфейс можно использовать для значения определенного типа.

В спецификации по языку есть два важных момента, описывающих наборы методов:

Наборы методов: Тип может иметь ассоциированные методы. Набор методов интерфейсного типа это и есть его интерфейс. Набор методов любого другого именного типа type T состоит из всех методов с указанием приемника типа T. Набор методов для типа type *T это набор всех методов с приемником типа *T и/или T(то есть, он включает набор методов T). Любой другой тип имеет пустой набор методов. В рамках одного набора методы должны иметь уникальное название.

Вызовы: Вызов метода x.m() является валидным если набор методов типа x содержит m и список аргументов соответствует списку параметров метода m. Если x адресуемая переменная и набор методов для &x содержит m, то мы можем вызвать метод как x.m(), что, по сути, является сокращением для (&x).m().

Использование

Наверняка, вы будете использовать наборы методов ежедневно. Вы можете использовать методы при работе с переменными, элементами слайса, элементами map и интерфейсами.

Переменные

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

type List []int

func (l List) Len() int        { return len(l) }
func (l *List) Append(val int) { *l = append(*l, val) }

func main() {
    // A bare value
    var lst List
    lst.Append(1)
    fmt.Printf("%v (len: %d)\n", lst, lst.Len())

    // A pointer value
    plst := new(List)
    plst.Append(2)
    fmt.Printf("%v (len: %d)\n", plst, plst.Len())
}

Обратите внимание, что мы вызываем оба метода(и с приемником указателем, и с приемником значением) как для указателя, так и для значения. Чтобы лучше разобраться как это работает, давайте составим табличку методов:

List
- Len() int

*List
- Len() int
- Append(int) 

Тут видно, что в наборе методов для типа List не содержится метод Append(int), тем не менее, вы можете его вызвать и программа будет работать корректно. Это объясняется вторым правилом из спецификации и неявным преобразованием вида:

lst.Append(1)
(&lst).Append(1)

В таком случае, у выражения (&lst) будет тип *List который позволит вызвать метод Append(int). Чтобы лучше запомнить эти правила, стоит рассмотреть методы, использующие указатель или значение как приемник, отдельно от набора методов. Любой метод, в котором приемник это указатель, можно вызывать относительно любого указателя, или значения, указатель на которое мы можем получить(как это происходить в примере выше). Любой метод, в котором приемник это значение, может быть вызван для значения или для указателя, если его можно разименовать(то есть, для любого указателя).

Элементы слайса

Тут все как и с переменными. Так как элементы слайса это, по сути, указатели, то тип приемника не важен. Можно вызывать все методы, в которых приемник это указатель, или значение.

Элементы map

Элементы map не адресуемые. Таким образом, код ниже не заработает:

lists := map[string]List{}
lists["primes"].Append(7) 
// не может быть переписано как (&lists["primes"]).Append(7)

Но если мы сами будем использовать указатели, то все будет хорошо работать:

lists := map[string]*List{}
lists["primes"] = new(List)
lists["primes"].Append(7)
count := lists["primes"].Len() 
// может быть переписано как (*lists["primes"]).Len()

Таким образом, если элемент map это указатель, то могут быть вызваны оба типа методов. А если элемент map простое значение, то могут быть вызваны только методы, у которых приемник значение.

Интерфейсы

Конкретное значение, которое хранится в переменной с типом интерфейса, всегда неадресуемое, аналогично элементу map. Таким образом, когда вы вызываете метод у переменной с интерфейсным типом, то этот метод должен иметь соответствующий тип приемника или значение в переменной должно быть получено из соответствующего типа: для методов с приемником указателем это должен быть указатель, для методов с приемником значением это должно быть значение. При этом, методы с приемниками указателями могут вызываться для переменных интерфейсного типа, если их значения указатель, так как этот указатель можно разименовать. Но методы с приемниками значениями нельзя вызвать, так как значения, сохраненные внутри интерфейсной переменной, не адресуемые. При приведении типа к интерфейсу компилятор следит за тем, чтобы все методы, объявленные в интерфейсе, можно было вызвать и если это не так, то сообщает о ошибке компиляции. Расширим предыдущий пример и покажем валидные и невалидные использования интерфейсов:

type List []int

func (l List) Len() int        { return len(l) }
func (l *List) Append(val int) { *l = append(*l, val) }

type Appender interface {
    Append(int)
}

func CountInto(a Appender, start, end int) {
    for i := start; i <= end; i++ {
        a.Append(i)
    }
}

type Lener interface {
    Len() int
}

func LongEnough(l Lener) bool {
    return l.Len()*10 > 42
}

func main() {
    // Просто значение
    var lst List
    CountInto(lst, 1, 10) // Невалидно: Append ожидает указатель в приемнике
    if LongEnough(lst) {  // Валидно: oдинаковые типы
        fmt.Printf(" - lst is long enough")
    }

    // Указатель на значение
    plst := new(List)
    CountInto(plst, 1, 10) // Валидно: одинаковые типы
    if LongEnough(plst) {  // Валидно: *List можно разименовать
        fmt.Printf(" - plst is long enough")
    }
}