В статье, упомянутой выше, автор приводит интерфейс из пакета 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.
Комментарии (17)
Jouretz
03.05.2019 23:29+2Я, возможно, чего-то не понимаю. Но в ваших примерах вы дрессируете дрессировщика.
Классу дрессировщик не нужно знать особенности реализации выполнения команд классами животных.
Может имело смысл ввести интерфейс типа TrainedAnimal, с одним публичным методом ExecuteCommand и уже в нём для каждого класса решать, что собака по команде «Голос» лает, слон — трубит, кошка — выполняет рандомное действие, а человек этот интерфейс вообще не реализует, ибо не дрессированный.
А о том чтоб не попасть на арену цирка в качестве питомца заботиться должен был класс дрессировщика, например, не выставляя публично свой метод Speak. «Если оно говорит по команде, почему мы не можем показывать его на сцене?»
И тогда весь смысл нагромождения интерфейсов из примера теряется. Если оно говорящее — пусть говорит, если дрессированное — пусть выполняет команды. Дрессировщик работает только с дрессированным, а похвалить может любое говорящее, человеков тоже хвалить можно, вроде.DexterHD Автор
04.05.2019 01:04Классу дрессировщик не нужно знать особенности реализации выполнения команд классами животных...
Жаль, что вы не привели кода, так бы было чуть проще понять. Но в целом идею я уловил. Если я вас правильно понял, вас смущает метод Tamer.Command(...). И вы предлагаете перенести алгоритм из него внутрь TaimedAnimal.ExecuteCommand(...).
Смотрите, на самом деле дресссировщик не знает особенности того как команда выполняется животным. Данный метод описывает алгоритм который должен произвести дрессировщик по отношению к животному, чтобы оно выполнило команду. Попробую пояснить в виде кода.
func (t *Tamer) Command(action int, a Animal) string { switch action { case ActSit: // Подойти к питомцу // Стать к нему лицом и наклонится // Показать питомцу лакомство return a.Sit() // Произнести команду сидеть } return "" }
Особенности реализации команды животным инкапсулированы внутри этого самого животного.
func (d Dog) Sit() string { // Посмотреть на дрессировщика // Выпрямить передние лапы // Согнуть задние лапы // Повилять хвостом return "sit" }
Надеюсь более менее понятно объяснил?Jouretz
04.05.2019 02:16+1Я не совсем о том. На уровне тех же ваших абстракций, действие («сидеть») выполняется животным и над животным и участие дрессировщика в процессе нужно только для того чтоб отдать команду.
Ваш вариант вполне имеет право на жизнь, но при изменении количества доступных команд/животных, вы столкнётесь с трудностями.
Представьте, например, что появилась у вас обезьяна в цирке, которая знает команду 'читай газету'. В текущем варианте реализации каждому животному нужно будет добавить пустой метод ReadNewspaper. А если бы мы делали через TrainedAnimal, правки нужны были бы только у дрессированной обезьяны, у остальных default case для всех невыполнимых команд.
Сделать так чтоб по команде «сидеть», обезьяна не тупо садилась, а приносила стул (monkey.BringChair) и начинала читать газету (monkey.ReadNewspaper) в принципе невозможно получается (метод Sit у обезьяны есть, но тут нам не нужен).
Смотрите, на самом деле дресссировщик не знает особенности того как команда выполняется животным.
Как минимум, он знает что команда выполняется только тренированным животным, и проверяет тренированность при каждом вызове команды, эта проверка как раз могла бы быть проведена во время проверки интерфейса и один раз.DexterHD Автор
04.05.2019 02:54Я понимаю, да, можно сделать и так. Здесь возникают trade-off.
эта проверка как раз могла бы быть проведена во время проверки интерфейса и один раз.
А если завтра укротитель захочет все таки «тренировать» не тренированное животное? С абстракцией TamedAnimal, у нас не будет такой возможности получается?
func (t *Tamer) Command(action int, a Animal) string { if !a.IsTrained() { t.train(a, action) } ... }
Jouretz
04.05.2019 03:10Хм, ну мы сможем создавать декоратор, принимающий не тренированное животное и отдающий тренированное. И управлять «тренированностью» для каждого класса. А не внезапно начать тренировать всех.
DexterHD Автор
04.05.2019 03:29Да, я понимаю, просто все это не совсем имеет отношение к сути статьи. Смотрите, я сделаю так, как вы предлагаете.
type TamedAnimal interface { Speaker Do(command int) string } type Tamer struct{} func (t *Tamer) Speaks() string { return "WAT?" } func (t *Tamer) Command(action int, a TamedAnimal) string { return a.Do(action) } func (t *Tamer) Praise(a Speaker) string { return a.Speaks() }
Теперь мы имеем более конкретный абстрактный тип. Я согласен. Тем не менее, это не меняет сути статьи. Суть то в том, чтобы показать, как можно создавать абстрактные типы данных используя интерфейсы Go, а так же показать, что использование слишком общих абстракций не всегда дает нужный/ожидаемый результат.
rudinandrey
04.05.2019 00:24-1простите, и вот это предлагается взамен Си и Си++?
:=, *, &? даже начинать учить страшно.Jouretz
04.05.2019 00:28Go предлагается взамен пыхыпы, шарпа и иже с ними, вроде. Он не позиционируется как язык системного программирования. Взамен Си — Rust, но там вам тоже может страшным показаться.
rudinandrey
05.05.2019 21:18Понял, думал все таки взамен «сложных» С, С++. Взамен PHP и так все это дело усложнить? Какой смысл? Что он дает против PHP? Скорость разработки? Легкость разработки? Судя по этой статье я думаю что вряд ли. Да нет, он мне не кажется страшным :) я так, для красного словца уж использовал что страшно. Начал, но пока только начал учить. Про Rust спасибо, тоже что-то такое слышал, тоже было интересно про него узнать.
Jouretz
05.05.2019 22:11Против PHP какую-никакую компилируемость, работу с потоками и т.п. По заверениям разработчиков Go даёт что-то типа «врождённой грамотности», т.е. на нём сложнее написать «плохой» код, но не вникал особо глубоко.
Про Rust ничего лучше их «книжки», вроде, и нет doc.rust-lang.org/stable/book/title-page.html
Рекламятся они, в основном, своей механикой выделения/освобождения памяти + возможностью использовать «бесплатно» абстракции высокого уровня, но там много других плюсов и минусов.
huankun
04.05.2019 09:59По работе возникла необходимость выучить Го. Основы легко запоминаются за неделю-две. Синтаксис после С и С++ немного непривычный, но в целом отторжения не вызывает, особенно после фич 11-17го стандарта, некоторые из которых в Го есть как часть языка — например, запись типа возвращаемого значения после аргументов функции (как в многих других языках) или возврат нескольких значений из функции и т.д. Единственный сложный момент — переключение мозга с ООП на использование Гошных интерфейсов. Это примерно как переход с С на С++, может даже немного проще.
Имхо, на текущий момент область использования языка ограничена микросервисами/вебом, т.к. нормальных библиотек/врапперов работы с системными вещами пока нет. Не смотря на обилие гитхабовских репозиториев с врапперами половина из них тухлые/нерабочие (я про системные вещи, типа хоткеев, звука, работы с буфером обмена). Но для тех же микросервисов он хорош и гораздо проще С++ в использовании и освоении (в полном объёме).rudinandrey
05.05.2019 19:02спасибо. Я так чисто ради интереса начал учить Go, остановился пока в самом зачаточном уровне. Просто работы много, времени мало. А так посмотреть очень хочется на сам язык. Практической пользы только сильно пока не вижу.
TicSo
Sorry, а есть где-то примеры для понимания интерфейсов, например, для логики бэкэнда типового сайта продаж или учетной системы (купил/продал/кому/дата… номенклатура), чтобы увидеть, как они помогают. Люблю животных, но лично мне на их примере не заходит. Возможно, Вам будет удобно привести пример. Я понимаю, что интерфейсы пользуют во всю (web серверы ..), но вот простых примеров, как было бы без них и с ними, — не встречал. Спасибо.
esata
Вот небольшой пример.
Там источник данных реализован в виде интерфейса
kuftachev
Согласен на 100%, вообще ничего не понятно, что автор хотел сказать. Если хоть он сам понял, и то хорошо.
P.S. Не говорю, что я написал бы лучше.
DexterHD Автор
Чтобы понять статью, стоит для начала прочитать оригинал указанный во введении.
Если же вы его прочли и не поняли развитие идеи, попробую все таки пояснить.
DexterHD Автор
К сожалению сложно найти OpenSorce «типовой сайт продаж или учетную систему», да еще и на Go.
Могу напимер порекомендовать взглянуть на код платформы для разработки микросервисов go-micro.
Она построена на базе нескольких абстрактных типов, таких как (Codec, Broker, Registry, Selector, Transport...). В исходном коде платформы существуют одноименные интерфейсы и реализуя их вы можете подменять те или иные компоненты системы.
К примеру интерфейс Broker описывает абстрактный брокер сообщений. Реализуя его вы можете использовать любой брокер в вашем приложении. Примеры реализации брокеров для данной платформы можно найти в репозитории micro/go-plugins