Разместить здесь вашу рекламу


Интерфейсы как абстрактные типы данных в Go

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.
Не так давно коллега ретвитнул отличный пост 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.
Источник: https://habr.com/ru/post/450386/


Интересные статьи

Интересные статьи

Продолжение перевода про архитектуру TABNet. Первая часть здесьМы предложили TabNet, новую архитектуру глубокого обучения для табличного обучения. TabNet использует механ...
Источник В этой статье — о настройке проб готовности, работоспособности и запуска для обнаружения и работы с нездоровыми модулями в переводе команды Kubernetes aaS. Чи...
Специализированные процессоры ASIC для конкретных областей — один из способов «перезапустить» закон Мура и преодолеть ограничения универсальных CPU общего назначения. Сейчас это оче...
Часть 1: что такое карты нормалей и как они работают Часть 2: как запекаются карты нормалей Как и многие другие вещи в нашей отрасли, за многие годы карты нормалей эволюционировали, и сегод...
Всем привет! Меня зовут Иван Спиридонов, я создатель сервиса сквозной аналитики R7K12. Наша система помогает выявить наиболее эффективные источники рекламы, которые приносят реальную прибыль комп...