В этом посте речь пойдет об интерфейсах — очень важной и интересной фиче языка Go.

Абстрактные типы данных

Для того чтобы лучше понять интерфейсы, полезно разобраться с понятием абстрактных типов данных. Упрощённо абстрактные типы данных можно определить как типы, у которых не может существовать собственных значений. Справедливый вопрос: а зачем такие типы тогда нужны? АТД в первую очередь определяет требования к «обычному» типу данных. Если тип соответствует требованиям, то говорят, что он является реализацией для АТД, а значит, может быть использован в переменных, полях и сигнатурах, где в качестве типа был указан реализуемый АТД.

Например, над любыми числами можно совершать любые, за некоторыми исключениями, арифметические операции. Следовательно, можно объявить абстрактный тип Число, разными реализациями которого будут натуральные, целые, рациональные, вещественные и комплексные числа. Если не важны специфические свойства какого-либо множества чисел, то гораздо удобнее оперировать более абстрактным понятием числа.

Интерфейсы

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

Давайте взглянем на стандартную библиотеку языка. Она содержит много интерфейсов, которые похожи на то, что авторы языка называют хорошим и идиоматичным интерфейсом. Одни из самых используемых — знаменитая тройка Writer, Reader, Closer, интерфейсы из пакета io:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type Writer interface {
	Write(p []byte) (n int, err error)
}

// ...

type Reader interface {
	Read(p []byte) (n int, err error)
}

// ...

type Closer interface {
	Close() error
}

В данных трёх типах можно увидеть идиоматику интерфейсов в Go:

  1. Чем меньше методов, тем лучше. Такая простота даёт много гибкости;
  2. Если у интерфейса только один метод, принято (хоть и не обязательно) называть интерфейс производным от имени метода существительным. Тогда по имени интерфейса сразу понятно его назначение. Сам метод стоит называть глаголом, конечно.

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

1
2
3
4
5
type ReadWriteCloser interface {
	Reader
	Writer
	Closer
}

Пример. Хранение логов

Давайте рассмотрим реалистичный пример. Большая часть приложений, особенно серверных, порождает логи. Как вы знаете, логи могут пригодиться при выяснении причин отказа сервиса или помочь в исправлении багов. Поэтому скорее всего вы, либо команда инфраструктуры, либо команда эксплуатации, либо кто-то ещё заинтересован в том, чтобы логи можно было сохранить в постоянное хранилище, т.к. стандартные поток вывода и поток ошибок для этого не подходят. Обычно роль такого хранилища на себя берет либо файл на диске, либо специальный внешний сервис. При этом не хочется, чтобы компоненты, использующие логгер по прямому назначению были в курсе или как-то влияли на этот аспект. В этот момент на сцене появляется io.Writer.

И логгер из стандартной библиотеки, и многие сторонние библиотеки позволяют настраивать вывод логов. В большинстве случаев для этого логгеру можно передать реализацию Writer. os.File, как мы выяснили раньше, реализует Writer, как и os.Stdout с os.Stderr:

1
2
3
4
5
6
7
8
9
f, err := os.Open("<path-to-file>")
if err != nil {
	return err
}
defer f.Close()

log.Default().SetOutput(f)

log.Println("hello, world!")

В пакете io также присутствует полезная функция MultiWriter возвращающая Writer, построенный из нескольких других Writer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
f, err := os.Open("<path-to-file>")
if err != nil {
	return err
}
defer f.Close()

mw := io.MultiWriter(f, os.Stdout)

log.Default().SetOutput(mw)

log.Println("hello, world!")

Пример. Хранилище данных

Данный пример будет вам встречаться на практике уже почаще.

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

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

Также при написании тестов на код, использующий хранилище или внешнюю службу не всегда целесообразно обращаться к реальной базе данных или API. Вместо этого можно создать мок (mock) — хранилище-заглушку, которое обладает тем же набором методов, что и реальное, но оперирует оно над фейковыми данными. При использовании интерфейсов провернуть такой трюк сильно проще.

Пустые интерфейсы (interface{})

Иногда тип значения может быть не важен вовсе, однако компилятор Go требует, чтобы тип был либо указан явно, либо выводился из константы или возвращаемого значения при вызове функции. В то же время язык позволяет использовать интерфейс, который реализуется абсолютно любым типом. Для этого необходимо, чтобы у типа было ноль или более методов. Как вы понимаете, под данный критерий подходят как базовые типы (строки, числа, слайсы, мапы), так и структуры с любым набором методов.

Иметь дело с пустым интерфейсом напрямую как правило неудобно, поэтому можно воспользоваться type assertion, если имеется предположение относительно более конкретного типа:

1
2
3
4
5
6
7
var a interface{} = "a"

aStr, strOk := a.(string)
fmt.Println(aStr, strOk) // => a true

aInt, intOk := a.(int)
fmt.Println(aInt, intOk) // => 0 false

Либо type switch, если таких типов может быть несколько:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

import "fmt"

func do(i interface{}) {
	switch v := i.(type) {
	case int:
		fmt.Printf("Twice %v is %v\n", v, v*2)
	case string:
		fmt.Printf("%q is %v bytes long\n", v, len(v))
	default:
		fmt.Printf("I don't know about type %T!\n", v)
	}
}

func main() {
	do(21)
	do("hello")
	do(true)
}

Использовать interface{} нужно крайне осторожно и только в случаях, когда без него не обойтись. Поскольку у нас нет 100% гарантии относительно реального типа значения, скрывающегося за interface{}, это порождает неявный, невыраженный в коде контракт и снижает стабильность программы. Обязательно проверяйте через второе возвращаемое значение, действительно ли значение имеет тип, к которому вы пытаетесь его привести, иначе рантайм выбросит панику.

nil и интерфейсы

Есть одна странная деталь относительно nil-значений и интерфейсов. Попробуйте угадать, что выведет программа, а затем запустите её:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
	"fmt"
)

type Example struct {
	Value string
}

type MyInterface interface{}

func example1() MyInterface {
	var e *Example
	return e
}

func example2() MyInterface {
	return nil
}

func main() {
	fmt.Println(example1() == example2())
}

Казалось бы, example1() равно nil, поскольку переменная e внутри функции не была инициализирована. И example2() равно nil, поскольку функция явно возвращает nil. Почему же example1() не равно example2()?

Давайте вместо сравнения значений, посмотрим, что у них внутри:

1
fmt.Printf("%#v, %#v\n", example1(), example2()) // => (*main.Example)(nil), <nil>

Таким образом, мы обнаружим, что example1() в отличие от example2() возвращает не просто nil, а странное (*main.Example)(nil)!

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

  1. example1() возвращает MyInterface, внутри которого и значение, и фактический тип не известны. Т.е. как в example2();
  2. example2() возвращает неинициализированный экземпляр *Example. Т.е. как в example1().

Иными словами необходимо, чтобы либо оба были «полностью» nil, либо оба были одного типа, но не инициализированы.

***

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

Что ещё изучить

  • Раздел об интерфейсах на Effective Go
  • Глава про интерфейсах в A Tour of Go
  • Тред про nil и интерфейсы в почтовой рассылке Go Nuts