Как пользоваться интерфейсами в Go +8





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

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


В примерах этого поста мы будет пользоваться двумя пакетами animal и circus. Многие вещи в этом посте описывают работу с кодом, граничащим с регулярным применением пакетов.

Как делать не надо


Очень распространенное явление, которое я наблюдаю:

package animals 

type Animal interface {
	Speaks() string
}

// применение Animal
type Dog struct{}
func (a Dog) Speaks() string { return "woof" }

package circus

import "animals"

func Perform(a animal.Animal) string { return a.Speaks() }

Это и есть так называемое использование интерфейсов в стиле Java. Его можно охарактеризовать следующими шагами:

  1. Определить интерфейс.
  2. Определить один тип, удовлетворяющий поведению интерфейса.
  3. Определить методы, удовлетворяющие реализации интерфейса.

Резюмируя, мы имеем дело с «написанием типов, удовлетворяющих интерфейсам». У такого кода есть свой отчетливый запашок, наводящий на следующие мысли:

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

Как надо делать вместо этого


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

Что имеется ввиду: вместо определения Animal в пакете animals, определите его в точке использования, то есть пакете circus *.

package animals

type Dog struct{}
func (a Dog) Speaks() string { return "woof" }

package circus

type Speaker interface {
	Speaks() string
}

func Perform(a Speaker) string { return a.Speaks() }

Более естественный способ сделать это выглядит вот так:

  1. Определить типы
  2. Определить интерфейс в точке использования.

Такой подход снижает зависимость от компонентов пакета animals. Снижение зависимостей — верный путь к созданию отказоустойчивого ПО.

Закон Постела


Есть один хороший принцип для написания хорошего ПО. Речь идет о законе Постела, который часто формулируется следующим образом:
«Относись консервативно к тому, что отсылаешь и либерально к тому, что принимаешь»
В терминах Go закон звучит так:

«Принимайте интерфейсы, возвращайте структуры»

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

func funcName(a INTERFACETYPE) CONCRETETYPE 

Здесь мы принимаем все, что реализует интерфейс, который может быть любым, в том числе и пустым. Отдается при этом значение конкретного типа. Конечно же, ограничение того, чем может быть a имеет свой смысл. Как гласит одна Go-пословица:

«Пустой интерфейс не говорит ничего», — Роб Пайк

Поэтому крайне желательно не допускать того, чтобы функции принимали interface{}.

Пример применения: имитация


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

func Takes(db Database) error 

Если Database — это интерфейс, то в тестовом коде вы можете попросту предоставить имитацию реализации Database без необходимости передавать реальный объект ДБ.

Когда приемлемо определение интерфейса наперед


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

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

  • Запечатанные интерфейсы
  • Абстрактные типы данных
  • Рекурсивные интерфейсы

Далее кратко рассмотрим каждый из них.

Запечатанные интерфейсы


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

Если вы определили что-нибудь такое:

type Fooer interface {
	Foo() 
	sealed()
}

Только пакет, который определил Fooer, может пользоваться им и создавать из него что-нибудь ценное. Это позволяет создавать работающие по методу перебора операторы-переключатели для типов.

Запечатанный интерфейс также позволяет инструментам анализа легко подобрать любые не переборочные совпадения паттерна. Пакет sumtypes от BurntSushi направлен как раз на решение этой задачи.

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


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

Хороший пример этого случая — пакет sort, входящий в стандартную библиотеку. Он определяет сортируемую коллекцию следующим образом

type Interface interface {
    // Len — количество элементов в коллекции.
    Len() int
    // Less сообщает следует ли сортировать элемент 
    // с индексом i перед элементом с индексом j.
    Less(i, j int) bool
    // Swap меняет элементы с индексами i и j.
    Swap(i, j int)
}

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

Тем не менее, я считаю, что это очень изящная форма генериков в Go. Ее применение следует почаще поощрять.

Альтернативный и одновременно изящный варианты дизайна потребуют типов более высокого порядка. В этом посте мы не будем их рассматривать.

Рекурсивные интерфейсы


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

type Fooer interface {
	Foo() Fooer
}

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

Этот паттерн полезен для создания контекстов с последующей работой в них. Загруженный контекстом код обычно заключает сам себя внутри пакета с экспортированием только контекстов (аля пакет tensor), поэтому на практике я встречаю этот случай не так часто. Я могу рассказать еще кое-что о контекстуальных паттернах, но оставлю это для другого поста.

Заключение


Несмотря на то, что один из заголовков поста гласит «Как делать не надо», я ни в коем случае не пытаюсь что-либо запретить. Скорее я хочу сделать так, чтобы читатели почаще задумывались о пограничных условиях, поскольку именно в таких случаях возникают различные нештатные ситуации.

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

Тем не менее я тоже иногда ненароком пишу интерфейсы в стиле Java. Как правило, это бывает, если незадолго до этого написал много кода на Java или Python. Желание к чрезмерному усложнению и «представлению всего в виде классов» иногда проявляется очень сильно, особенно если вы пишете Go-код после написания большого количества объект-ориентированного кода.

Таким образом этот пост также служит в качестве напоминания самому себе о том, как выглядит путь к написанию кода, который не будет впоследствии вызывать головную боль. Жду ваших комментов!

image

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (9):

  1. mr_brain1979
    /#10733677 / +3

    Мммм… Не поймите превратно, но статья, на мой взгляд, скорее для хабра.

    • Tabernakulov
      /#10734175 / -1

      Вы правы. Я думаю, что она бы там и оказалась, если бы у Wirex был блог на Хабре.

      • mr_brain1979
        /#10735497

        Разве это повод постить нерелевантный контент? У меня, вот, например, нет блога на порнхабе, давайте я сюда буду постить?

        • altervision
          /#10735515

          Это лучше тоже на Хабр, релевантнее будет.

        • Tabernakulov
          /#10736609

          Блог предлагает материалы читателям. Дальше, в зависимости от предпочтений, кому-то нравится материал, кому-то нет. Кто-то считает пост релевантным, кто-то нет. Оттого, что, по вашему мнению, материал не соответствует площадке, он не становится менее полезным для тех, кто разбирается в теме.

          • mr_brain1979
            /#10739671

            Я понимаю вашу позицию. Не одобряю, но понимаю. Но! В свое время ГТ отделился от хабра именно затем, чтобы отделить сугубо програмистские статьи от «общеобразовательных», если посмотреть на ситуацию в этом ключе станет очевидно, почему не хочется именно здесь видеть такие статьи.

  2. poxvuibr
    /#10735347

    Будь либерален к тому, что принимаешь, и требователен к тому, что отсылаешь

    В оригинале


    Be conservative with what you do, be liberal with you accept

    Явным образом политическая метафора, переводится как:


    Относись консервативно к тому, что отсылаешь и либерально к тому, что принимаешь.

    Это уже не говоря о том, что "отсылаешь" взято не из статьи, а из перефразировки закона в википедии.

  3. mrobespierre
    /#10739715

    Спасибо за статью, может кто-нибудь поймёт после неё интерфейсы. Когда беседую с гоферами в телеге постоянно спрашиваю: «зачем вам принимать пустой интерфейс на входе и делать case для типа внутри на 120 строк подряд, не лучше ли сделать нормальный, непустой интерфейс?», а в ответ тишина и непонимание…