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

Разрешить функции принимать произвольный параметр можно с помощью пустого интерфейса — интерфейса без методов.

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

func PassAnyType(v interface{}) {
    // ...
} 

Note

В Go 1.18 у типа interface{} появился более короткий и понятный псевдоним any (от англ. any — любой).

Но с переменной v нельзя ничего сделать. Нужно привести её к определённому типу оператором v.(Тип).

Оператор приведения типа приводит переменную интерфейсного типа к конкретному типу или другому интерфейсу.

func PassAnyType(v interface{}) {
    i := v.(int)     // если v не число, то будет паника, то есть программа не сможет работать и прекратит выполнение
 
    i, ok := v.(int) // альтернативный формат: если v не число, то будет false
                     // паники не будет 
    if ok {
        // ...
    }
} 

Второй вариант использования более предпочтителен.

Оператором switch можно лаконично запрограммировать логику относительно каждой проверки типа. Для примера напишем свою реализацию функции fmt.Printf:

func Printf(v interface{}) {
    switch v2 := v.(type) {
    case int:
        fmt.Print("Это число " + strconv.FormatInt(v2, 10))
    case string:
        fmt.Print("Это строка " + v2)
    case Stringer:
        fmt.Print("Это тип, реализующий Stringer, " + v2.String())
    default:
        fmt.Print("Неизвестный тип")
    }
} 

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

func Mul(a interface{}, b int) interface{} {
    switch va := a.(type) {
    case int:
        return va * b
    case string:
        return strings.Repeat(va, b)
    case fmt.Stringer:
        return strings.Repeat(va.String(), b)
    default :
        return nil
    }
}

Внутреннее устройство интерфейсов

Чтобы понять, каким образом интерфейсы приводятся к другим типам, следует разобраться, как устроен интерфейс изнутри.

Соединим примеры из предыдущего и текущего уроков:

type User struct {
    Email      string
    Password   string
    LastAccess time.Time
}
 
func (u User) String() string {
    return "user with email " + u.Email
}
 
func Printf(v Stringer) {
    fmt.Print("Это тип, реализующий Stringer, " + v.String())
}
 
func main() {
    u := User{Email: "example@yandex.ru"}
    Printf(u)
} 

Вот какие данные будут храниться в переменной v Stringer:

То, что изображено на схеме, можно посмотреть подробнее в src/runtime/runtime2.go.

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

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

Когда присваиваем переменную конкретного типа, происходит следующее:

// Компилятор создаёт в программе метаданные со списком методов интерфейса
type Stringer interface {
    String() string
}
 
// Компилятор создаёт в памяти структуру с описанием типа User, его полей
type User struct {
    
} 
 
var user Stringer // Объявляется переменная интерфейса — её значение пока что равно nil, в памяти выделяется два машинных слова под указатели tab и data  
 
// Переменной присваивается конкретное значение. Проверяется, удовлетворяет ли тип переменной интерфейсу. 
// При этом в tab записывается указатель на структуру itable, связывающий информацию о типе User и Stringer,
// а в data — указатель на User{}
user := User{}
 
// Интересно, что связывание типа и интерфейса происходит не на этапе компиляции, иначе размер программы был бы слишком большим, а на этапе выполнения. Эта операция кешируется, так что выполняется всего один раз и не влияет на производительность.
 
// Проверяем, что в tab у нас лежит действительно тип User — и если да, то ok true, а в переменную u копируется значение data.   
// Если типы не совпадают, то ok — false
u, ok := user.(User)

Note

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

В Go есть особенность, связанная с nil. Посмотрите ещё раз на схему: интерфейс может быть nil, а может быть с nil-указателем на данные. Покажем пример, где эта особенность приводит к ошибке:

// Собственный тип для ошибок. Аналогичен стандартному error
type Error interface {
    Error() string
}
 
// MyError — структура, реализующая нашу ошибку
type MyError struct {
    // ...
}
 
// Error — метод для удовлетворения интерфейсу Error
func (e *MyError) Error() string {
    return "..."
}
 
 
// переменная типа ошибки — указатель на пустую структуру
var ErrFriday13 = &MyError{}
 
func CheckTodayIsOkay() Error {
    var err *MyError // указатель на переменную типа Error
    // получаем текущее время
    t := time.Now()
    // если день недели пятница и число месяца 13, то вернём ошибку  
    if t.Weekday() == time.Friday && t.Day() == 13 {
        err = ErrFriday13
    }
    // вернём ошибку ... указатель же nil
    return err
}
 
func main() {
    err := CheckTodayIsOkay()
    // проверяем err на nil — и внезапно всегда не nil 
    if err != nil {
        fmt.Println("error is not nil")
        return
    }
 
    fmt.Println("error is nil")
}

Код выведет строчку "error is not nil", потому что Go обернёт nil-указатель *MyError в не-nil-интерфейс Error.
То есть мы присвоили переменной типа interface{} переменную определённого типа. Это значит, что метаданные непустые. Исправить проблему можно так:

func CheckTodayIsOkay() Error {
    var err Error
 
    t := time.Now()
    if t.Weekday() == time.Friday && t.Day() == 13 {
        err = ErrFriday13
    }
 
    return err
} 

В этом случае код выведет строчку "error is nil", потому что сам интерфейс Error будет nil.

Сравнение интерфейсов

Сравнение интерфейсных типов имеет одну особенность, которую важно знать, так как она может привести к неочевидным ошибкам. Рассмотрим следующий пример:

type Stringer interface {
    String string
}
 
// создадим свой собственный тип на основе map 
type MyMap map[string]string
 
func (m MyMap) String () string {
    return fmt.Sprintf("%v", m)
}
 
// И ещё один тип на основе int
type MyInt int
 
func (m MyInt) String () string {
    return fmt.Sprintf("%v", m)
}
 
func main () {
    var mm MyMap
    var mi MyInt
    
    mm = MyMap{}
    mi = MyInt(5)
    
    fmt.Println(mm == mi) // false 
    fmt.Println(mm == mm) // Паника!
}
 

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

Всё дело в том, что интерфейсы сравниваются по цепочке. Сначала сравнивается тип: если типы переменных внутри разные, то вернётся false; если одинаковые, то сравниваются уже сами данные. А map сравнивать между собой нельзя, и получаем панику.

Ключевые мысли

  • Интерфейсные переменные по своей природе динамичны. Их можно преобразовать к переменным другого типа через операцию приведения типа a.(type).
  • Интерфейс изнутри представляет собой два указателя — на обёрнутую переменную и на информацию о её типе. Преобразование типа изменяет информацию о типе либо возвращает обёрнутую переменную.
  • Операция приведения типа — это не то же самое, что преобразование типа.
  • Интерфейс, равный nil, и интерфейс, оборачивающий nil, — разные вещи.
  • Сравнение интерфейсов необходимо делать аккуратно.
  • Наилучшим вариантом использования интерфейса является всё же использование не пустого, а конкретного интерфейса и обращение к его методам. Однако иногда без них не обойтись.

📂 Go | Последнее изменение: 28.08.2024 21:02