Интерфейсы в Go: создаём "чистые" типы

Интерфейсы в Go: создаём "чистые" типы

Как разработчики используют интерфейсы в Go, о чём следует помнить и как интерфейс превращается в конкретный тип.

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

Основы интерфейсов: для чего они могут быть использованы?

Язык программирования Go является статически типизированным. Поэтому после объявления переменной она всегда сохраняет свой тип в пределах своей области видимости. Поэтому переменная типа string может принимать только строки. Динамически типизированные языки, такие как JavaScript или Python являются более гибкими.

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

 

type Stringer interface {

    String() string

}

 

func printer(s Stringer) {

        fmt.Println(s.String())

}

 

Интерфейсы также являются типом. В нашем примере интерфейс stringer имеет только один метод. Это типично для Go. Согласно принципу разделения интерфейсов, более разумно определять много маленьких интерфейсов, чем один большой. Этот подход соответствует принципам SOLID (I = Interface Segregation Principle), сформулированным сторонником чистого кода Робертом К. Мартином (Дядя Боб). Согласно этому принципу, интерфейс должен иметь столько методов, сколько ему необходимо.

Дизайн языка Go сознательно поддерживает этот подход. Для того чтобы тип реализовал интерфейс, он должен иметь только определенные методы, в противном случае никаких дополнительных инструкций, таких как implements stringers, не требуется. Проверку реализации берёт на себя компилятор.

Для небольших интерфейсов, состоящих из одного-трех методов, установилось соглашение об именовании. Оно последовательно применяется в стандартной библиотеке. Имя интерфейса состоит из имени метода и букв "er" в конце. Метод String() становится интерфейсом Stringer, а Read() - интерфейсом Reader. Преимущество такого соглашения очевидно: даже без документации понятно, какие методы содержат интерфейс ReadWriter или ReadWriteCloser.

Разработчики, которые уже программировали на Go, наверняка знакомы с интерфейсом stringer. Он определён в пакете fmt стандартной библиотеки. Интерфейс в примере выше является его копией. В связи с этим возникает вопрос, имеют ли смысл нам самим определять интерфейсы, а не использовать готовые? Как же принцип "do not repeat yourself?

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

Для интерфейсов из стандартной библиотеки минимизация зависимостей не играет особой роли, поскольку они являются непосредственным компонентом языка. Поэтому данный пример призван в первую очередь проиллюстрировать лежащий в его основе принцип. Поскольку интерфейс stringer является частью стандарта, в библиотеке существует множество его реализаций. Например, тип time.Time может быть передан в printer:

 

func main() {  

        t := time.Now()

        printer(t)

}

// 2009-11-10 23:00:00 +0000 UTC m=+0.000000001

 

Пример работает, даже если пакет time не знает об интерфейсе stringer. В Go пакету time нет необходимости явно указывать, какие интерфейсы реализуются типом time.Time. Проверку реализации берёт на себя компилятор Go. Поскольку тип time.Time имеет метод string, он может быть использован совместно с printer.

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

Не совсем абстрактным примером может служить функция CheckContact, которая оценивает данные типа Contact, загружаемые из базы данных. Чтобы отвязать этот процесс от конкретной базы данных, можно определить интерфейс загрузчика. В соответствии с соглашением об именовании, этот интерфейс имеет только метод Load(), который, в свою очередь, имеет тип ContactKey в качестве входных данных и Contact в качестве выходных. Требования к загрузчику определяются функцией CheckContact.

 

type Loader interface {

        Load(ContactKey) (Contact, error)

}

 

func CheckContact(uk ContactKey, l Loader) error {

        u, err := l.Load(uk)

        if err != nil {

                return err

        }

        // ...

}

 

Реализация состоит только из карты, в которую можно непосредственно вводить тестовые данные. Если какая-либо запись отсутствует, реализация возвращает ошибку. В идеале тип LoadTester должен храниться непосредственно в тестовом файле.

Загрязнение интерфейсов - предотвращение загрязнения

Определять и использовать интерфейсы в Go очень просто. На практике это обстоятельство часто приводит к тому, что разработчики определяют слишком много интерфейсов для функций. То, что это можно сделать без особых усилий, не всегда имеет смысл. Если в коде определяется слишком много или даже ненужных интерфейсов, то мы говорим о загрязнении кода: interface pollution. Но насколько много - это слишком много?

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

Возможно, разработчики работали на C# или Java, и им показалось естественным создавать интерфейсы перед тем, как писать реализацию. Однако в Go всё не так.

Как мы уже говорили, интерфейсы создаются для создания абстракций. И главное предостережение при программировании с использованием абстракций - помнить, что абстракции нужно не создавать, а раскрывать. Что это означает? Это означает, что не следует начинать создавать абстракции в коде, если для этого нет непосредственной причины. Мы не должны проектировать интерфейсы, а ждать когда они нам понадобятся. Другими словами, мы должны создавать интерфейс тогда, когда он нам нужен, а не тогда, когда мы предвидим, что он может нам понадобиться.

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

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

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

Не проектируйте интерфейсы, а открывайте их".

-Роб Пайк

Давайте не пытаться решить проблему абстрактно, а делать то, что нужно решить сейчас. И последнее, но не менее важное: если неясно, как интерфейс делает код лучше, то, вероятно, следует подумать о его удалении, чтобы сделать код проще.

Интерфейсы во время выполнения

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

Первый инструмент используется для проверки на наличие nil. Этот паттерн обычно используется для обработки ошибок.

 

err := openSomething(name)

if err != nil {

        // error handling

}

 

Проверка на nil только показывает, существует ли значение, но не показывает, какое значение скрывается за ним. Для этого существуют переключатель типов и утверждение типа.

Переключатель типов позволяет проверять интерфейсные переменные на наличие нескольких различных типов. Соответствующая проверка выполняется в операторе case. В этом блоке тип переменной становится известен, и, таким образом, можно напрямую использовать базовый тип.

 

func myFunc(s Stringer) {

        switch v := s.(type) {

        case nil:

                fmt.Println("nil Pointer")

        case *bytes.Buffer:

                fmt.Println("bytes.Buffer", v.Len())

        default:

                fmt.Println("Unknown type")

    }

}

 

В примере показан синтаксис переключателя типов. Здесь также возможна проверка на наличие nil. В случае *bytes.Buffer через переменную v можно получить доступ ко всем полям, свойствам или методам этого типа. В данном примере это будет метод Len().

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

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

 

func myFunc(r io.Reader) {

    buf, ok := r.(*bytes.Buffer)

    if ok {

        fmt.Println(buf.Bytes())

    }

}

 

Здесь показан стандартный шаблон утверждения типа. Переменная ok проверяет, успешно ли выполнено преобразование. Переменная buf имеет тип *bytes.Buffer и может быть использована как таковая. Если утверждение типа не прошло успешно, то buf имеет нулевое значение соответствующего типа. Поскольку речь идет об указателе, то это будет nil.

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

 

func ReadAndClose(r io.Reader) ([]byte, error) {

        type closer interface {

                Close()

        }

        c, ok := r.(closer)

        if ok {

                defer c.Close()

        }

        return ioutil.ReadAll(r)

}

 

Здесь в качестве примера описано преобразование одного интерфейса в другой. Утверждение типа здесь очень полезно. Это связано с тем, что метод Close() не является принципиально необходимым для функции. Однако функция всё равно может использовать этот метод. If ok гарантирует, что метод будет использоваться только в том случае, если он существует.

Когда и как следует использовать интерфейсы в GO?

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

  • Общее поведение
  • Decoupling
  • Ограниченное поведение.

Общее поведение

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

Мы можем определить интерфейс Media следующим образом:

type Media interface {

    Display() string

    Title() string

}

Метод Display возвращает строку, представляющую способ отображения медиафайла, а метод Title - заголовок медиафайла. Считается, что любой тип, реализующий эти два метода, удовлетворяет интерфейсу Media.

Теперь определим некоторые типы, реализующие этот интерфейс:

type Book struct {

    bookTitle string

    author    string

}

 

func (b Book) Display() string {

    return "Book: " + b.bookTitle + " by " + b.author

}

func (b Book) Title() string {

    return b.bookTitle

}

 

type Song struct {

    songTitle string

    artist    string

}

func (s Song) Display() string {

    return "Song: " + s.songTitle + " by " + s.artist

}

func (s Song) Title() string {

    return s.songTitle

}

 

Здесь и Book, и Song реализуют интерфейс Media, поскольку определяют методы Display и Title.

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

 

func displayMedia(m Media) {

    fmt.Println(m.Display())

}

 

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

Decoupling

Другой очень важный пример - это абстрагирование нашего кода от имплементации. Если мы полагаемся на абстракцию, вместо конкретной имплементации, то реализация функционала может быть просто заменена другой логикой без изменения основной части кода. Об этом нам говорит принцип подстановки Барбары Лисков.

Одно из преимуществ Decoupling тесно связано с юнит-тестами. Давайте представим, что мы хоим написать метод CreateNewUser, задача которого состоит в сохранении нового пользователя в базе. При его реализации мы решили основываться на конкретной имплементации и сохранять в базу с использованием структуры mysql.Store:

 

type UserService struct {

        store mysql.Store

}

 

func (us UserService) CreateNewUser(id string) error {

        user := User{id: id}

        return us.store.StoreUser(user)

}

 

Теперь, что делать с тестами? Так как userService основан на действующей имплементации, чтобы сохранять пользователя, единственный способ протестировать этот функционал - только с помощью интеграционных тестов, которые требуют от нас поднятия инстанса MySQL. Или использовать альтернативную технологию, такую как go-sqlmock. Хотя интеграционные тесты очень полезные, это не всегда то, что мы хотели бы использовать. Чтобы получить больше гибкости, мы должны абстрагироваться от имплементации, а это может быть сделано с помощью интерфейса следующим образом:

 

type userStorer interface {

        StoreUser(User) error

}

 

type UserService struct {

        storer userStorer

}

 

func (us UserService) CreateNewUser(id string) error {

        user := User{id: id}

        return us.store.StoreUser(user)

}

 

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

  • Использовать конкретную имплементацию и реализовать интеграционные тесты.
  • Использовать моки и написать юнит-тесты
  • Или использовать оба подхода.

Ограниченное поведение

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

 

type IntConfig struct {

   Value int

}

 

type intConfigGetter interface {

   Get() int

}

 

func (ic IntConfig) Get() int {

   return ic.Value

}

 

type Cache struct {

   cfg intConfigGetter

}

 

func NewCache(cfg intConfigGetter) *Cache {

   return &Cache{cfg: cfg}

}

 

func (f *Cache) Operate() {

   fmt.Println("Config Value: ", f.cfg.Get())

}

 

В приведённом примере структура IntConfig реализует интерфейс intConfigGetter, который имеет единственный метод Get(). Этот метод используется для получения значения конфигурации. Структура Cache, в свою очередь, получает интерфейс intConfigGetter в своей фабричной функции NewCache(), тем самым предоставляя доступ к методу Get(), но ограничивая модификацию Value.

Клиент функции NewCache по-прежнему может передать структуру IntConfig, поскольку она реализует интерфейс intConfigGetter. Однако внутри структуры Cache мы можем только читать конфигурацию в методе Operate, но не модифицировать её.

 

func main() {

        cfg := IntConfig{Value: 10}

        cache := NewCache(cfg)

        cache.Operate()  

        // Outputs: Config Value: 10

}

 

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

Заключение

Реализация интерфейсов в Go лишь незначительно отличается от других языков программирования. Однако эти отличия обусловлены сознательно принятыми решениями. Go поддерживает принцип разделения интерфейсов за счет своей специфической конструкции. Это позволяет без особых усилий определять небольшие эффективные интерфейсы.

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

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

Популярное

Самые популярные посты

Как быть максимально продуктивным на удалённой работе?
Business

Как быть максимально продуктивным на удалённой работе?

Я запустил собственный бизнес и намеренно сделал всё возможное, чтобы работать из любой точки мира. Иногда я сижу с своём кабинете с большим 27-дюймовым монитором в своей квартире в г. Чебоксары. Иногда я нахожусь в офисе или в каком-нибудь кафе в другом городе.

Привет! Меня зовут Сергей Емельянов и я трудоголик
Business PHP

Привет! Меня зовут Сергей Емельянов и я трудоголик

Я программист. В душе я предприниматель. Я начал зарабатывать деньги с 11 лет, в суровые 90-е годы, сдавая стеклотару в местный магазин и обменивая её на сладости. Я зарабатывал столько, что хватало на разные вкусняшки.

Акция! Профессиональный разработчик CRM за 2000 руб. в час

Выделю время под ваш проект. Знания технологий Vtiger CRM, SuiteCRM, Laravel, Vue.js, Golang, React.js. Предлагаю варианты сотрудничества, которые помогут вам воспользоваться преимуществами внешнего опыта, оптимизировать затраты и снизить риски. Полная прозрачность всех этапов работы и учёт временных затрат. Оплачивайте только рабочие часы разработки после приемки задачи. Экономьте на платежах по его содержанию разработчика в штате. Возможно заключение договора по ИП. С чего начать, чтобы нанять профессионального разработчика на full-time? Просто заполните форму!

Telegram
@sergeyem
Telephone
+4915211100235