Первый очерк из цикла приключений в мире сусликов.

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

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

Пара слов обо мне. Я всю жизнь занимаюсь разработкой программного обеспечения, в основном в сфере WEB, успел познакомиться с многими языками программирования и поработать в разных крупных компаниях. Сейчас руковожу разработкой в компании NUT.Tech, мы там делаем классные и интересные вещи. В данный момент в основном разработка в отделе построена вокруг Go, поэтому о нём я вам и расскажу.

Статьи серии:

  1. Интерфейсы в Go - как красиво выстрелить себе в ногу

  2. Нарезаем массивы правильно в Go

  3. ...

Пожалуй, начнём. Вы когда-нибудь задумывались, что такое interface? Ну, то есть, не ключевое слово синтаксиса, а что это такое в рантайме? Как выглядит его проинстанцированный объект? А, главное, каким свойством обладает при сравнении с nil? Нет? Тогда устраивайтесь поудобнее, я сейчас вам всё расскажу.

Начнём с определения интерфейса из спецификации языка:

An interface type specifies a method set called its interface. A variable of interface type can store a value of any type with a method set that is any superset of the interface. Such a type is said to implement the interface. The value of an uninitialized variable of interface type is nil.

Мой вольный перевод:

Тип интерфейса определяет множество методов, называющееся интерфейс. Переменная типа интерфейса может хранить значение любого типа с набором методов, который является любым надмножеством интерфейса. Говорят, что такой тип реализует интерфейс. Значение неинициализированной переменной типа интерфейса равно nil.

На мой взгляд, хорошее абстрактное описание, которое не очень сильно помогает осознать интерфейсы на практике, но в целом даёт общее понимание. Очень советую пойти в документации в раздел Interface_types - там много примеров и более подробное описание.

Но этого поста бы не было, будь там вся необходимая информация ;) Допустим, мы уже понимаем, что переменная с интерфейсом в качестве типа это некий объект, который с точки зрения типизации обязан обладать всеми перечисленными в указанном интерфейсе методами.

Необходимо также понимать, что переменные с интерфейсом в качестве типа бессмысленны сами по себе, они не создаются и не используются в отрыве от обычных типов. И именно переменные обычных типов, превращаются в переменные интерфейсного типа. А с точки зрения компилятора в переменную интерфейсного типа может превратится любая переменная обычного типа, у которой её тип содержит все заявленные в интерфейсе методы. Хотя могут быть и дополнительные методы - реализация интерфейса от этого не сломается. Понимаю, всё несколько сложно и запутано, но дальше будут примеры и станет понятнее.

А что происходит, например, когда мы переменную определенного, конкретного типа, превращаем в другую переменную с интерфейсным типом? В Golang, как впрочем и многих других статически типизированных языках, у переменной можно легально получить доступ только к тем атрибутам и методам, которые существуют у типа этой переменной. В случае преобразования переменная получает определенный интерфейсный тип. Одна из очевидных проблем, которую мы встречаем, - потеря доступа ко всем атрибутам нашего конкретного типа и к его методам, которые не были указаны в интерфейсе.

Посмотрим на конкретном примере:

package main

import (
    "fmt"
    "reflect"
)

// создадим некоторый тип Человек,
// который умеет спать, есть и работать
type Man struct{}

func (m Man) Sleep() {}
func (m Man) Eat()   {}
func (m Man) Work()  {}

// и тип Пёс, который у нас умеет только лаять
type Dog struct{}

func (d Dog) Bark() {}

// теперь мы хотим понять, кто может стать программистом,
// для этого мы определяем интерфейс Programmer,
// и определяем в нём метод Work
// этим мы ограничиваем количество объектов,
// которые смогут быть программистами
// так случилось, что в нашем коде, чтобы стать программистом,
// достаточно уметь работать :)
type Programmer interface {
    Work()
}

func main() {
    // итак, теперь посмотрим, как работать с нашими типами и интерфейсом
    
    // для начала создадим некоторого конкретного человека
    Vasiliy := Man{}
    // и посмотрим на его тип
    fmt.Printf("%s\n", reflect.TypeOf(Vasiliy).String()) // main.Man

    // также создадим Василию верного друга
    Sharik := Dog{}
    fmt.Printf("%s\n", reflect.TypeOf(Sharik).String()) // main.Dog

    // что ж, теперь нужно проверить, кто же,
    // Шарик или Василий нам лучше подойдёт на роль программиста
    // для этого создадим абстрактного работника с типом Programmer
    // и попробуем к нему присвоить наши объекты
    var worker Programmer
    worker = Sharik
    // увы, этот код не будет скомпилирован,
    // компилятор выведет следующую ошибку
    // cannot use Sharik (type Dog) as type Programmer in assignment:
    // Dog does not implement Programmer (missing Work method)

    // теперь мы знаем, Шарик пока не может быть программистом,
    // придётся им стать Василию
    worker = Vasiliy // этот код компилируется и работает

    // в целом, этого достаточно для базового понимания интерфейсов,
    // но дальше будет ещё кое-что интересное

    // проверим тип нашего работника теперь
    fmt.Printf("%s\n", reflect.TypeOf(worker).String()) // main.Man
    // в целом выглядит логично: мы сказали,
    // что работником будет Василий, у которого тип Man,
    // но раз уж так, давайте попробуем вызвать
    // метод Sleep у нашего работника
    worker.Sleep()
    // worker.Sleep undefined (type Programmer has no field or method Sleep)
    // увы, этот код не будет работать,
    // так как на самом деле worker != Vasiliy
    // типом переменной worker является интерфейс Programmer,
    // который не содержит метод Sleep()
}

Что же происходит, и почему пакет reflect определяет тип нашего воркера как Man, которым на самом деле он не является? Чтобы это понять, нужно погрузиться в недра исходного кода нашего "суслика" и посмотреть, чем представлен там тип interface:

// https://golang.org/src/runtime/runtime2.go

type iface struct {
    tab  *itab
    data unsafe.Pointer
}

Если не вдаваться в подробности, то в поле tab у нас хранится информация о конкретном типе объекта, который был преобразован в интерфейс. А в поле data - ссылка на реальную область памяти, в которой лежат данные изначального объекта, в нашем случае Василия. Поэтому библиотека reflect, когда хочет получить настоящий исходный тип объекта интерфейса, идёт в это поле tab и получает информацию о типе там.

У нас остаётся возможность преобразовать наш объект интерфейса обратно к исходному типу, для этого у нас есть синтаксис утверждений типа, выглядит он следующим образом:

m, ok := worker.(Man)  
fmt.Printf("%#v\n", m) // main.Man{}  
fmt.Printf("%t\n", ok) // true

Можно использовать и без переменной ok, но в таком случае Вы получите панику, если в интерфейсе окажется несоответствующий тип.

Окей, с этим вроде разобрались, идём дальше.

Как нам говорит документация: The value of an uninitialized variable of interface type is nil. То есть наша переменная интерфейсного типа может принимать nil. Ну это же супер! Если отмотать немного назад, то можно увидеть, как мы изначально инициализировали нашего работника - var worker Programmer. Если верить документации, то в этот момент наш worker был равен nil. Доверяй, но проверяй:

func main() {
    var worker Programmer
    fmt.Printf("%#v\n", reflect.TypeOf(worker)) // <nil>
    // и так, тут уже тип нашего воркера не main.Man,
    // как это было при присвоении туда Василия, а nil
    // ну хорошо, давайте ещё сделаем проверку,
    // действительно ли наш пустой работник равен nil
    fmt.Printf("%t\n", worker == nil) // true
    // отлично, и правда nil
}

Но мы пойдём дальше: а что если попробовать создать пустую переменную типа *Man и преобразовать её к интерфейсу? Пробуем:

func main() {
    var man *Man
    fmt.Printf("%#v\n", man) // (*main.Man)(nil)
    var worker Programmer
    fmt.Printf("%#v\n", worker) // <nil>

    worker = man
    fmt.Printf("%#v\n", worker) // (*main.Man)(nil)
    // наш воркер изменил тип, он больше не совсем nil,
    // а nil от типа *main.Man
    // что же это для нас значит, давайте смотреть

    fmt.Printf("%t\n", man == nil) // true
    // отлично, наш man равен nil,
    // мы же туда ничего не положили и ссылка равна nil
    fmt.Printf("%t\n", worker == man) // true
    // ну тоже хорошо, мы положили в переменную
    // worker переменную man,
    // и как мы уже убеждались ранее, они равны

    // ну и понятно, по законам математики,
    // если x = 0, а y = x, значит y = 0
    fmt.Printf("%t\n", worker == nil) // false
    // а вот и нет, это вам Go, а не математика
}

Давайте разбираться, почему так происходит. Как я уже говорил, объект интерфейса в Go содержит два поля: tab с информацией о конкретном типе и data, где лежит ссылка на сами данные. И вот, по правилам Go, интерфейс может быть равен nil только если оба этих поля не определены.

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

func main()  {
    var worker Programmer
    fmt.Printf("%s\n", reflect.ValueOf(worker)) // <invalid reflect.Value>
    // рефлект не может получить значение из переменной worker,
    // потому что его просто нет,
    // то есть поле `data` у нашего интерфейса не задано
    fmt.Printf("%v\n", reflect.TypeOf(worker)) // <nil>
    // как не может получить и тип,
    // так как он тоже не задан в поле `tab`
    fmt.Printf("%t\n", worker == nil) // true
    // worker равен nil

    var man *Man
    fmt.Printf("%t\n", man == nil) // true
    // переменная man равняется nil
    
    worker = man

    fmt.Printf("%s\n", reflect.ValueOf(worker).Elem())
    // <invalid reflect.Value>
    // после присвоения переменной worker переменной man
    // мы точно также не можем получить значение через reflect
    // так как man у нас не содержит каких-либо данных, он равен `nil`
    fmt.Printf("%v\n", reflect.TypeOf(worker)) // *main.Man
    // а вот с типом интереснее мы из переменной worker
    // получили тип переменной man, то есть *main.Man
    // соответственно поле `tab` у нашего интерфейса уже явно не пустое
    // и поэтому переменная worker больше не будет равна nil
    fmt.Printf("%t\n", worker == nil) // false
}

Как итог, мы имеем интерфейс с заполненным полем tab, и проверка на равенство с nil всегда будет возвращать false. Несмотря на то, что изначально переменная возвращала true при сравнении с nil.

Описано ли это поведение в документации к нашему суслику? - Нет. Ну точнее не совсем. В спецификации языка это не описано, но это есть в FAQ по языку: https://golang.org/doc/faq#nil_error. И там как раз рассмотрена одна из распространённых ошибок, связанная с этим поведением интерфейсов: когда мы из функции возвращаем ссылку на объект нашей кастомной ошибки. Данная ссылка, в свою очередь, может быть nil, если ошибка в функции не произошла. И, когда происходит преобразование нашего объекта кастомной ошибки к интерфейсу error, этот новый преобразованный объект уже никогда больше не будет равен nil, и все проверки if err != nil {} перестанут работать корректно.

Приведу пример чуть более подробный чем в faq:

package main

import "fmt"

// создаём свой собственный тип ошибки,
// обычно это бывает нужно чтобы передавать дополнительные данные
// вместе с ошибкой, так например сделано в библиотеке twirp
type CustomError struct{}

// этот метод нам нужен, чтобы реализовать интерфейс error
func (CustomError) Error() string { return "CustomError" }

// а в этой функции мы будем возвращать нашу ошибку, либо nil
func raiseError(raise bool) *CustomError {
    if raise == true {
        return &CustomError{}
    }
    return nil
}

func main() {
    // для начала нам нужно инициализировать переменную,
    // которая будет принимать результат из функции raiseError
    var err error

    // пробуем вернуть из нашей функции ошибку
    err = raiseError(true)
    fmt.Println(err != nil) // true
    // отлично, err у нас действительно не равен nil

    // а теперь вызовем функцию таким образом, чтобы она вернула nil
    err = raiseError(false)
    fmt.Println(err != nil) // true
    // после преобразования в интерфейс, наш nil уже не очень то и nil
}

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

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


  1. vesper-bot
    24.12.2021 11:33

    А случайно переменные в Go, т.е. тот же man, не состоят ли из ссылки на класс плюс ссылки на данные инстанса? Этого могло бы хватить для описания всех проблем с интерфейсами. Потому что тогда если переменная типа класс, на нил проверяется только data, а если interface, то и класс и данные. (Что по мне нелогично, что толку, что переменной интерфейса присвоен типизированный nil?)


    1. eandr_67
      24.12.2021 16:50
      +4

      В Go не существует классов. Мы можем создать методы не только для структур, но и для любого типа, производного от примитивного: хоть от bool, хоть от int. Соответственно, любой созданный нами тип может реализовывать любой интерфейс. А абсолютно все типы (включая изначально встроенные в язык) реализуют интерфейс interface{}.

      Особенностью принятого в Go подхода является то, что мы можем совершенно корректно вызвать метод для типизированного nullable-указателя. Так что поведение интерфейса, равного nil, и интерфейса, содержащего указатель на nil, отличается не только при сравнении с nil, но и при вызове методов этого интерфейса.

      Ссылку на тип содержат только переменные-интерфейсы. Все прочие переменные имеют тип, точно известный в момент компиляции, и компилятор просто подставляет информацию о типе в нужные места кода.


    1. WinPooh32
      24.12.2021 16:53
      +1

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

      Для слайсов, мап, каналов, интерфейсов оператор '==' работает над контейнером, а не над данными внутри.
      Нетипизированный nil присваивается в контейнер, а типизированный в данные контейнера.
      Чтобы узнать что в данных значение nil нужно преобразовать интерфейс сначала в этот тип, затем проверить на nil, либо через рефлексию получить сырой указатель и уже работать с ним.
      Пример: https://go.dev/play/p/LHO6WsI9hY_R



  1. maxnosib
    26.12.2021 09:50
    +1

    Спасибо автору хорошо рассписал