Не так давно коллега ретвитнул отличный пост How to Use Go Interfaces. В нем рассматриваются некоторые ошибки при использовании интерфейсов в Go, а также даются некоторые рекомендации по поводу того, как их все-таки стоит использовать.
В статье, упомянутой выше, автор приводит интерфейс из пакета sort стандартной библиотеки, как пример абстрактного типа данных. Однако, мне кажется, что такой пример не особо хорошо раскрывает идею, когда речь заходит о реальных приложениях. Особенно о приложениях, реализующих логику какой-нибудь бизнес области или решающих проблемы реального мира.
Также при использовании интерфейсов в Go зачастую возникают споры об оверинжиниринге. А еще бывает так, что, после чтения подобного рода рекомендаций, люди мало того что прекращают злоупотреблять интерфейсами, они пытаются практически полностью от них отказаться, тем самым лишая себя использования одной из сильнейших концепций программирования в принципе (и одной из сильных сторон языка Go в частности). На тему типичных ошибок в Go кстати, есть неплохой доклад от Stive Francia из Docker. Там в частности несколько раз упоминаются интерфейсы.
В общем, я согласен с автором статьи. Тем не менее, мне показалось, что тема использования интерфейсов, как абстрактных типов данных в ней раскрыта довольно поверхностно, поэтому мне хотелось бы немного развить ее и поразмышлять на эту тему вместе с вами.
Обратимся к оригиналу
В начале статьи автор приводит небольшой пример кода, с помощью которого указывает на ошибки при использовании интерфейсов, которые частенько совершают разработчики. Вот этот код.
package animal
type Animal interface {
Speaks() string
}
// implementation of Animal
type Dog struct{}
func (a Dog) Speaks() string { return "woof" }
package circus
import "animal"
func Perform(a animal.Animal) string { return a.Speaks() }
Автор называет этот подход “Java-style interface usage”. Когда мы объявляем интерфейс, потом реализуем единственный тип и методы, которые будут удовлетворять данному интерфейсу. Я согласен с автором, подход так себе.Более идиоматический код в оригинальной статье выглядит следующим образом:
package animal
// implementation of Animal
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() }
Здесь в целом все ясно и понятно. Основная идея: “Сперва объявляйте типы, и только потом объявляйте интерфейсы в точке использования”. Это правильно. Но давайте теперь немного разовьем идею применительно к тому, как можно использовать интерфейсы в качестве абстрактных типов данных. Автор к слову указывает на то, что в такой ситуации нет ничего плохого в том, объявить интерфейс “авансом”. Работать будем с тем же кодом.
Поиграем с абстракциями
Итак, у нас есть цирк и есть животные. Внутри цирка есть достаточно абстрактный метод `Perform` (выполнить действие), который принимает интерфейс `Speaker` и заставляет питомца издавать звуки. Например, собаку из примера выше он заставит гавкать. Создадим укротителя зверей. Так как он у нас не немой, мы в общем-то тоже можем заставить его издавать звуки. Интерфейс-то у нас достаточно абстрактный. :)
package circus
type Tamer struct{}
func (t *Tamer) Speaks() string { return "WAT?" }
Пока что все нормально. Едем дальше. Давайте научим нашего укротителя отдавать команды питомцам? Пока что у нас будет одна команда “голос”. :)
package circus
const (
ActVoice = iota
)
func (t *Tamer) Command(action int, a Speaker) string {
switch action {
case ActVoice:
return a.Speaks()
}
return ""
}
package main
import (
"animal"
"circus"
)
func main() {
d := &animal.Dog{}
t := &circus.Tamer{}
t2 := &circus.Tamer{}
t.Command(circus.ActVoice, d) // woof
t.Command(circus.ActVoice, t2) // WAT?
}
Мммм, интересно не правда ли? Кажется, наш коллега не в восторге от того, что он стал питомцем в данном контексте? :D Что же делать? Похоже Speaker здесь не очень подходящая абстракция. Создадим более подходящую (а точнее вернем в некотором роде первую версию из “неправильного примера”), после чего сменим нотацию метода.
package circus
type Animal interface {
Speaker
}
func (t *Tamer) Command(action int, a Animal) string { /* ... */ }
Это ничего не меняет, скажете вы, код все равно будет выполняться, т.к. оба интерфейса реализуют один метод, и окажетесь в общем-то правы.
Тем не менее, этот пример позволяет уловить важную идею. Когда мы говорим об абстрактных типах данных, контекст имеет решающее значение. Введение нового интерфейса, по крайней мере, сделало код на порядок очевиднее и читабельнее.
К слову, один из способов заставить укротителя не выполнять команду “голос” — просто добавить метод, которого у него быть не должно. Давайте добавим такой метод, он будет отдавать информацию о том, поддается ли питомец дрессировке.
package circus
type Animal interface {
Speaker
IsTrained() bool
}
Теперь укротителя нельзя подсунуть вместо питомца.
Расширим поведение
Заставим наших питомцев, для разнообразия, выполнять другие команды, кроме того, давайте добавим, кота.
package animal
type Dog struct{}
func (d Dog) IsTrained() bool { return true }
func (d Dog) Speaks() string { return "woof" }
func (d Dog) Jump() string { return "jumps" }
func (d Dog) Sit() string { return "sit" }
type Cat struct{}
func (c Cat) IsTrained() bool { return false }
func (c Cat) Speaks() string { return "meow!" }
func (c Cat) Jump() string { return "meow!!" }
func (c Cat) Sit() string { return "meow!!!" }
package circus
const (
ActVoice = iota
ActSit
ActJump
)
type Animal interface {
Speaker
IsTrained() bool
Jump() string
Sit() string
}
func (t *Tamer) Command(action int, a Animal) string {
switch action {
case ActVoice:
return a.Speaks()
case ActSit:
return a.Sit()
case ActJump:
return a.Jump()
}
return ""
}
Отлично, теперь мы можем отдавать разные команды нашим животным, и они будут их выполнять. В той или иной степени… :D
package main
import (
"animal"
"circus"
)
func main() {
t := &circus.Tamer{}
d := &animal.Dog{}
t.Command(circus.ActVoice, d) // "woof"
t.Command(circus.ActJump, d) // "jumps"
t.Command(circus.ActSit, d) // "sit"
t2 := &circus.Tamer{}
c := &animal.Cat{}
t2.Command(circus.ActVoice, c) // "meow"
t2.Command(circus.ActJump, c) // "meow!!"
t2.Command(circus.ActSit, c) // "meow!!!"
}
Домашние коты у нас не особо поддаются дрессировке. Поэтому мы поможем укротителю и сделаем так, чтобы он не мучался с ними.
package circus
func (t *Tamer) Command(action int, a Animal) string {
if !a.IsTrained() {
panic("Sorry but this animal doesn't understand your commands")
}
// ...
}
Так-то лучше. В отличие от начального интерфейса Animal, дублирующего Speaker, теперь мы имеем интерфейс `Animal` (являющийся по сути абстрактным типом данных), реализующий вполне осмысленное поведение.
Обсудим размеры интерфейсов
Теперь давайте поразмышляем с вами над такой проблемой, как использование широких интерфейсов (broad interfaces).
Это ситуация, при которой мы используем интерфейсы с большим количеством методов. В данном случае рекомендация звучит примерно так: “Функциям следует принимать интерфейсы, содержащие методы, которые им необходимы”.
В целом, я согласен с тем, что интерфейсы должны быть небольшими, однако в данном случае контекст опять же имеет значение. Вернемся к нашему коду и научим нашего укротителя “хвалить” своего питомца.
В ответ на похвалу питомец будет подавать голос.
package circus
func (t *Tamer) Praise(a Speaker) string {
return a.Speaks()
}
Казалось бы, все отлично, мы используем минимально необходимый интерфейс. Нет ничего лишнего. Но вот опять проблема. Черт побери, теперь мы можем “похвалить” другого тренера и он “подаст голос”. :D Улавливаете?.. Контекст всегда имеет огромное значение.
package main
import (
"animal"
"circus"
)
func main() {
t := &circus.Tamer{}
t2 := &circus.Tamer{}
d := &animal.Dog{}
c := &animal.Cat{}
t.Praise(d) // woof
t.Praise(c) // meow!
t.Praise(t2) // WAT?
}
К чему это я? В данном случае лучшим решением будет все-таки использовать более широкий интерфейс (представляющий абстрактный тип данных “питомец”). Так как мы хотим научится хвалить именно питомца, а не любое создание умеющее издавать звуки.
package circus
// Now we are using Animal interface here.
func (t *Tamer) Praise(a Animal) string {
return a.Speaks()
}
Так значительно лучше. Мы можем похвалить питомца, но не можем похвалить укротителя. Код снова стал более простым и очевидным.
Теперь немного про Закон Постеля
Последний пункт, которого я хотел бы коснуться, это рекомендация, согласно которой нам следует принимать абстрактный тип, а возвращать конкретную структуру. В оригинальной статье данное упоминание приводится в разделе, описывающем так называемый Postel’s Law.
Автор приводит сам закон:.
“Be conservative with what you do, be liberal with you accept”
И интерпретирует его в отношении языка Go
“Go”:“Accept interfaces, return structs”
func funcName(a INTERFACETYPE) CONCRETETYPE
Знаете, в целом я согласен, это хорошая практика. Тем не менее, я еще раз хочу подчеркнуть. Не стоит воспринимать это буквально. Дьявол кроется в деталях. Как всегда важен контекст.
Далеко не всегда функция должна возвращать конкретный тип. Т.е. если вам нужен абстрактный тип, возвращайте его. Не нужно пытаться переписать код избегая абстракции.
Вот небольшой пример. В соседнем “африканском” цирке появился слон, и вы попросили владельцев цирка одолжить слона в новое шоу. Для вас в данном случае важно, только то, что слон умеет выполнять все те же команды, что и другие питомцы. Размер слона или наличие хобота в данном контексте не имеет значения.
package african
import "circus"
type Elephant struct{}
func (e Elephant) Speaks() string { return "pawoo!" }
func (e Elephant) Jump() string { return "o_O" }
func (e Elephant) Sit() string { return "sit" }
func (e Elephant) IsTrained() bool { return true }
func GetElephant() circus.Animal { return &Elephant{} }
package main
import (
"african"
"circus"
)
func main() {
t := &circus.Tamer{}
e := african.GetElephant()
t.Command(circus.ActVoice, e) // "pawoo!"
t.Command(circus.ActJump, e) // "o_O"
t.Command(circus.ActSit, e) // "sit"
}
Как видите, так как нам не важны конкретные параметры слона, отличающие его от других питомцев, мы вполне можем использовать абстракцию, и возврат интерфейса в данном случае будет вполне уместен.
Подведем итог
Контекст — крайне важная штука, когда речь идет об абстракциях. Не стоит пренебрегать абстракциями и боятся их, ровно так же, как и не стоит ими злоупотреблять. Не стоит так же воспринимать рекомендации, как правила. Есть подходы, испытанные временем, есть подходы, которые только предстоит испытать. Надеюсь, мне удалось раскрыть чуть глубже тему использования интерфейсов, как абстрактных типов данных, и уйти от обычных примеров из стандартной библиотеки.
Конечно, для некоторый людей данный пост может показаться слишком очевидным, а примеры высосаными из пальца. Для других мои мысли могут оказаться спорными, а доводы — неубедительными. Тем не менее, кто-то возможно вдохновится и начнет думать чуть глубже не только о коде, но и о сути вещей, а так-же абстракциях в целом.
Главное, друзья, чтобы вы непрерывно развивались и получали истинное наслаждение от работы. Всем добра!
PS. Примеры кода и финальную версию можно найти на GitHub.
Автор: Антон