Краш-курс по интерфейсам в Go +25


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

В этой статье вы узнаете:

  • в чем отличие от интерфейсов в Java
  • важные и неочевидные последствия этих отличий
  • как устроены интерфейсы под капотом
  • вспомним про пустой интерфейс (interface{})
  • затронем сакральную тему про дженерики
  • разберемся, кто и зачем должен создавать интерфейс
  • и постараемся научиться не абьюзить интерфейсы и начать жить

Header
(artwork by Svitlana Agudova)

Ортогональность


Итак, начнем с первого важного момента, который достаточно легок в понимании — интерфейсы определяют поведение. В этом плане, интерфейс в Go практически не отличается от интерфейсов в Java.

К примеру, вот интерфейс и его реализация в Java:

public interface Speakable {
    public String greeting = "Hello";
    public void sayHello();
}

public class Human implements Speakable {
    public void sayHello() {
        System.out.println(Speakable.greeting);
    }
}

Speakable speaker = new Human();

speaker.sayHello();

Пример на Go:

type Speaker interface {
    SayHello()
}

type Human struct {
    Greeting string
}

func (h Human) SayHello() {
    fmt.Println(h.Greeting)
}
...
var s Speaker
s = Human{Greeting: "Hello"}
s.SayHello()

http://play.golang.org/p/yqvDfgnZ78

На первый взгляд, отличия чисто косметические:

  • в Java используются ключевые слова public/protected/etc, в Go — case первой буквы имени методы определяет видимость
  • в Java чаще называют интерфейсы с постфиксом -able, в Go же принято использовать постфикс -er (Sender, Reader, Closer, etc)
  • в Java используются классы для имплементации интерфейса, в Go — структуры с методами.
  • в Java имплементация интерфейса указывается явно(implements), в Go — неявно(duck typing)
  • в Java и интерфейсы, и классы могут содержать и данные, и методы, в Go — интерфейс не может содержать данные, только методы.

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

Неявная имплементация


Если это выглядит как утка, плавает как утка и крякает как утка, то это, вероятно, утка и есть.

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

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

Важное и не сразу очевидное различие этого заключается в том, как, в итоге, вы строите архитектуру вашей программы — в Java или C++ вы, скорее всего, начинаете с объявления абстрактных классов и интерфейсов, и далее переходите к конкретным реализациям. В Go же наоборот — вы пишете сначала конкретный тип, определяете данные и методы, и только в том случае, если действительно появляется необходимость абстрагировать поведение — создаете отдельный интерфейс. Опять же, масштаб этого различия более ощутим на больших проектах.

Данные vs поведение


Если в Java и классы, и интерфейсы описывают как данные, так и поведение, то в Go эти понятия кардинально разграничены.

Структура хранит данные, но не поведение. Интерфейс хранит поведение, но не данные.

Если вам хочется добавить в интерфейс переменную вроде Hello string — знайте, вы что-то делаете не так. Если вы хотите встроить интерфейс в структуру — вы путаете поведение и данные. В мире Java это нормально, но в Go это важное разделение, поскольку формирует ясность абстракций.

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

Важно понимать, что как только вы начнёте четко разделять абстракции "поведения" и "данных" — вы начнёте более четко понимать назначение и правильный способ использования интерфейсов в Go. Human и Speaker — ортогональны. Human может с легкостью удовлетворять ещё 10-ти интерфейсам (Walker, Listener, Player, Programmer, etc), а Speaker может быть удовлетворён десятками типов, даже из других пакаджей (Robot, Animal, Computer, etc). И всё это, с минимальными накладными синтаксическими расходами, что, опять же, важно в больших кодовых базах.

Устройство интерфейсов


Если вы не сильно поняли, как Human одновременно может быть Speaker-ом и ещё десятком интерфейсов, и при этом быть ортогональными, давайте копнём глубже и посмотрим, как устроены интерфейсы под капотом. В Go 1.5 (который сам написан на Go) интерфейсный тип выглядит вот так:

src/runtime/runtime2.go

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

Где tab — это указатель на Interface Table или itable — структуру, которая хранит некоторые метаданные о типе и список методов, используемых для удовлетворения интерфейса.
data — указывает на фактическую переменную с конкретным (статическим) типом,

Для наглядности, чуть модифицируем наш код следующим образом:

h := Human{Greeting: "Hello"}
s := Speaker(h)
s.SayHello()

http://play.golang.org/p/AB0ExdGN0W

Itable

На рисунке видно, что s состоит из двух поинтеров, первый указывающий на itable для конкретной пары (статический тип Human, интерфейс Speaker), а другой на копию оригинального значения Human.

h := Human{Greeting: "Hello"}
s := Speaker(h)
h.Greeting = "Meow"
s.SayHello() // выведет "hello"

itable


Теперь несколько слов про itable. Поскольку эта таблица будет уникальна для каждой пары интерфейс-статический тип, то просчитывать её на этапе компиляции (early binding) будет нерационально и неэффективно.

Вместо этого, компилятор генерирует метаданные для каждого статического типа, в которых, помимо прочего, хранится список методов, реализованных для данного типа. Аналогично генерируются метаданные со списком методов для каждого интерфейса. Теперь, во время исполнения программы, runtime Go может вычислить itable на лету (late binding) для каждой конкретной пары. Этот itable кешируется, поэтому просчёт происходит только один раз.

Зная это, становится очевидно, почему Go ловит несоответствия типов на этапе компиляции, но кастинг к интерфейсу — во время исполнения. Не забывайте, что именно для того, чтобы безопасно ловить ошибки приведения к интерфейсным типам, существует конструкция comma-ok — if s, ok := h.(Speaker); !ok { ... }.

var s Speaker = string("test") // compile-time error
var s Speaker = io.Reader // compile time error
var h string = Human{} // compile time error
var s interface{}; h = s.(Human) // runtime error

Пустой interface{}


Теперь вспомним про так называемый пустой интерфейс (empty interface) — interface{}, которому удовлетворяет вообще любой тип. Поскольку у пустого интерфейса нет никаких методов, то и itable для него просчитывать и хранить не нужно — достаточно только метаинформации о статическом типе.

Поэтому в памяти пустой интерфейс выглядит примерно так:
Empty Interface

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

Интерфейсы и дженерики


Как известно, в Go дженерные контейнеры ограничены только теми, которые есть в языке — слайсы, мапы. Для написания же дженерных алгоритмов в Go могут использоваться интерфейсы. Классическим примером тут может служить вот такая реализация Binary Tree.

type Item interface {
    // Less tests whether the current item is less than the given argument.
    //
    // This must provide a strict weak ordering.
    // If !a.Less(b) && !b.Less(a), we treat this to mean a == b (i.e. we can only
    // hold one of either a or b in the tree).
    Less(than Item) bool
}

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

type MyInt int
func (m MyInt) Less(than MyInt) bool {
    return m < than
}
b := btree.New(10)
b.ReplaceOrInsert(MyInt(5))

Похожий подход можно увидеть и в стандартной библиотеке в пакете sort — любой тип, который удовлетворяет интерфейсу sort.Interface, может передаваться параметром в функцию sort.Sort, которая его отсортирует:

type Interface interface {
        // Len is the number of elements in the collection.
        Len() int
        // Less reports whether the element with
        // index i should sort before the element with index j.
        Less(i, j int) bool
        // Swap swaps the elements with indexes i and j.
        Swap(i, j int)
}

Например:

type Person struct {
    Name string
    Age  int
}
// ByAge implements sort.Interface for []Person based on
// the Age field.
type ByAge []Person

func (a ByAge) Len() int           { return len(a) }
func (a ByAge) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }
...
people := []Person{
    {"Bob", 31},
    {"John", 42},
    {"Michael", 17},
    {"Jenny", 26},
}

sort.Sort(ByAge(people))

Как перестать абьюзить интерфейсы и начать жить


Многие новички в Go, особенно перешедшие с языков с динамической типизацией, видят в интерфейсных типах способ не работать с конкретными типами. "Заверну-ка я все в interface{}" — думает разработчик, и загрязняет свою программу интерфейсами, чаще всего пустыми.

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

К примеру, пишете вы панель мониторинга, на которой выводите какие-то данные, и данные эти из одного источника приходят в виде float64 значений, а из другого в виде строк ("failed", "success" и т.п.). Как вы реализуете функцию, которая получает значения по каналу и выводит их на экран?

Большинство новичков скажут — легко, сделаем канал пустых интерфейсов (chan interface{}) и будем передавать по нему, а далее делать каст к типу:

func Display(ch chan interface{}) {
    for v := range ch {
        switch x := v.(type) {
        case float64:
            RenderFloat64(x)
        case string:
             RenderString(x)
        }
    }
}

И, хотя такой код тоже имеет право на существование, мы можем сделать это более красиво. Давайте подумаем, что общего в нашем случае у float64 и string? То что они оба должны быть отрендерены — это уже кандидат на создание интерфейса с методом Render. Попробуем:

type Renderer interface {
    Render()
}

Далее, так как мы не можем навешивать методы на стандартные типы (это уже будет другой тип), то создадим свои MyFloat и MyString:

type (
    MyFloat  float64
    MyString string
)

И реализуем методы Render для каждого, автоматически удовлетворяя интерфейсу Renderer:

func (f MyFloat) Render() { ... }
func (s MyString) Render() { ... }

И теперь наша функция Display будет иметь следующий вид:

func Display(ch chan Renderer) {
    for v := range ch {
        v.Render()
    }
}

Гораздо красивее и лаконично, не так ли? Теперь, если у нас добавится ещё один тип, который нужно уметь рендерить в Display — мы просто допишем ему метод Render и ничего больше менять не придется.

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

Размеры интерфейсов


В Go Proverbs есть такой постулат — "Чем больше интерфейс, тем слабее абстракция". В примере выше, маленький интерфейс всего с одним методом помог очень четко описать абстракцию "значения, которое нужно рендерить". Если бы мы создали интерфейс с кучей методов, специфичных для, скажем, string — мы не смогли бы его уже использовать для float64, и пришлось бы придумывать что-то новое.

В Go большинство интерфейсов содержат 1-2 метода, не больше. Но это, конечно, не запрет — если вам совершенно точно нужен интерфейс с сотней методов (например, для моков каких-нибудь) — это тоже ок.

Кто и когда должен создавать интерфейс?


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

Это не так. Каждая библиотека не должна экспортировать интерфейс, и общее правило для определения того, кто должен создавать интерфейс можно описать так:

Интерфейс создается потребителем (consumer), а не продюсером (producer).

Если ваша библиотека реализует StaticType1, нет никакой нужны придумывать для неё интерфейс. Если же вы, как потребитель библиотеки, хотите абстрагировать поведение типа, замокать его и создать StaticType2, который в вашем коде должен быть взаимозаменяем со StaticType1 — вы у себя и имплементируйте Interface, и сами его и используйте. Это ваша задача — вы её и решаете средствами языка.
В уже упомянутой выше библиотеке sort, интерфейс sort.Interface нужен для работы функции sort.Sort — тоесть сама библиотека и является его потребителем.



Резюме


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

Резюмируя, три тезиса:

  • интерфейсы определяют поведение, статические типы — данные
  • чем больше интерфейс, тем слабее абстракция
  • интерфейс, как правило, создается потребителем.

Ссылки





К сожалению, не доступен сервер mySQL