В свободное от основной работы время автор материала консультирует по 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. Его можно охарактеризовать следующими шагами:
- Определить интерфейс.
- Определить один тип, удовлетворяющий поведению интерфейса.
- Определить методы, удовлетворяющие реализации интерфейса.
Резюмируя, мы имеем дело с «написанием типов, удовлетворяющих интерфейсам». У такого кода есть свой отчетливый запашок, наводящий на следующие мысли:
- Интерфейсу удовлетворяет всего один тип, без каких-либо намерений расширения его в дальнейшем.
- Функции обычно принимают конкретные типы вместо интерфейсных.
Как надо делать вместо этого
Интерфейсы в 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() }
Более естественный способ сделать это выглядит вот так:
- Определить типы
- Определить интерфейс в точке использования.
Такой подход снижает зависимость от компонентов пакета 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-код после написания большого количества объект-ориентированного кода.
Таким образом этот пост также служит в качестве напоминания самому себе о том, как выглядит путь к написанию кода, который не будет впоследствии вызывать головную боль. Жду ваших комментов!
Автор: Wirex