Go все еще остается новым языком, и если вы работаете с ним, то, скорее всего, это не первый ваш язык программирования.
Переходя на Go с другого языка, вы привносите в него как свой опыт, так и свои предпочтения. Однако то, что вы привыкли делать на предыдущем языке, может оказаться не слишком удачной идеей в Go.
Овладение Go предполагает не только изучение нового синтаксиса, но и освоение нового подхода к программированию.
На Go нужно мыслить по-другому, исходя из особенностей этого языка. Но такой подход требует больше времени и сил, чем многие программисты готовы потратить. Поэтому обычная история — перевести в Go программу, написанную на другом языке, и посмотреть, что из этого выйдет. При таком переводе часто игнорируется идиоматичность [специфика] Go. Первая попытка написать что-либо на Go, например какую-нибудь Java-конструкцию, скорее всего, завершится неудачей, в то время как другой подход, более органичный для Go, может оказаться успешным и прояснить ситуацию. После 10 лет программирования на Java и 10 минут программирования на Go любое сопоставление возможностей языков вряд ли приведет к инсайтам, но результаты будут. Это и есть работа современного программиста (Роб Пайк, Представление, как у Эсмеральды).
Как советует Роб Пайк, нужно потратить время и силы на изучение специфики языка Go, чтобы улучшить навыки работы с ним.
В Go имеется несколько идиом, которых нет в других, традиционных языках. На одной из них — интерфейсах — сосредоточимся в этой статье.
Ниже приведен перечень распространенных ошибок, которые допускаются при написании интерфейсов на Go. Возможно, в других языках они не являются ошибками, но в Go нужно их учитывать. Или, по крайней мере, не работать с ними какое-то время и посмотреть, к чему это приведет.
Для начала немного теории
Начнем с основополагающих принципов, которые следует иметь в виду при чтении этой статьи (если вы с ними уже знакомы, можете пропустить этот раздел).
Принцип разделения интерфейсов: клиент не должен реализовывать интерфейс, который он не использует (иначе говоря, клиент не должен зависеть от неиспользуемых им методов).
Полиморфизм: часть кода меняет свое поведение в зависимости от конкретных данных, которые она получает.
Принцип подстановки (замещения) Лисков: если код зависит от абстракции, то одна реализация может быть заменена другой без изменения кода.
Цель абстрагирования не в том, чтобы быть расплывчатым, а в том, чтобы создать новый семантический уровень, на котором можно быть абсолютно точным (Э. В. Дейкстра).
Интерфейсы — концепции, которые точно отражают идею, используемую для составления программ.
Правильное использование интерфейсов приводит к простоте, удобочитаемости и дизайну органичного кода.
Органичный код — это код, который развивается в ответ на поведение, необходимое в определенный момент времени. Он не заставляет вас заранее продумывать типы и отношения между ними, поскольку вполне вероятно, что вы не поймете их правильно.
Именно поэтому говорят, что Go предпочитает композицию наследованию. Он предлагает небольшой набор моделей поведения, из которых можно составить все, что угодно, в отличие от предопределения типов, наследуемых другими типами, и надежды на то, что они подойдут для проблемной области.
Роб Пайк объяснил этот подход на форуме golang-nuts:
Впрочем, довольно теории, перейдем к самым распространенным ошибкам.
1. Слишком большое количество интерфейсов
Слишком большое количество интерфейсов называется «замусориванием интерфейсами». Это происходит, когда вы начинаете абстрагироваться до написания конкретных типов. Поскольку вы не можете предугадать, какие абстракции вам понадобятся, очень легко написать слишком много интерфейсов, которые впоследствии окажутся либо неправильными, либо бесполезными.
У Роба Пайка есть отличный совет, который помогает избежать замусоривания интерфейсами:
Не проектируйте с интерфейсами, а открывайте их.
Роб советует не думать заранее о том, какие абстракции вам нужны. Можете начать проектирование с конкретных структур, создавая интерфейс только тогда, когда это необходимо. Таким образом, код будет органично развиваться в соответствии с ожидаемым дизайном.
Вероятно, найдутся те, кто создает интерфейсы заранее, полагая, что в будущем им может понадобиться более одной реализации.
Им я хочу сказать следующее:
Будьте ленивы, но в хорошем смысле. Идеальное время для создания интерфейса — когда он действительно нужен, а не когда вы прогнозируете, что он вам понадобится. Вот пример того, к чему приводит преждевременное создание интерфейса.
Бесполезные интерфейсы, как правило, имеют только одну реализацию. Они просто добавляют лишний уровень косвенности, заставляя программистов всегда проходить через них, когда они хотят перейти к реализации.
Интерфейс имеет свою цену: это новая концепция, которую нужно помнить, продумывая Go-код. Как считает Дейкстра, идеальный интерфейс должен быть «новым семантическим уровнем, на котором можно быть абсолютно точным».
Если код требует идеи Box
, то дополнительный интерфейс под названием Container
, реализуемый только Box
, не принесет ничего, кроме путаницы.
Поэтому, прежде чем создавать интерфейс, спросите себя, есть ли у вас несколько реализаций этого интерфейса. Я употребил глагол «есть«, потому что «будет» предполагает, что вы можете предсказать будущее, а это невозможно.
2. Слишком большое количество методов
В PHP-проектах довольно часто можно увидеть интерфейсы с 10 методами. В Go интерфейсы маленькие, среднее количество методов, используемых во всех интерфейсах из стандартной библиотеки, — 2.
Чем больше интерфейс, тем слабее абстракция — один из постулатов Go. По словам Роба Пайка, в нем выражается самая важная закономерность интерфейсов, из которой следует, что чем меньше интерфейс, тем он полезнее.
Чем больше реализаций может быть у интерфейса, тем более универсальным он является. Трудно выполнить несколько реализаций интерфейса с большим набором методов. Чем больше методов, тем более специфичным становится интерфейс. Чем он специфичнее, тем меньше шансов, что разные типы могут демонстрировать одно и то же поведение.
Убедительными примерами полезных интерфейсов являются io.Reader и io.Writer, у которых сотни реализаций. Не менее эффективным является error — настолько мощный интерфейс, что позволяет реализовать всю обработку ошибок в Go.
Помните: в дальнейшем вы можете составить интерфейс из других интерфейсов. Вот, например, ReadWriteCloser
, состоящий из 3 меньших интерфейсов:
type ReadWriteCloser interface {
Reader
Writer
Closer
}
3. Интерфейсы, не ориентированные на поведение
В традиционных языках программирования довольно часто встречаются именные интерфейсы, такие как User
, Request
и так далее. В Go большинство интерфейсов имеют суффикс «er»: Reader
, Writer
, Closer
и т. д. Это потому, что в Go интерфейсы раскрывают поведение, а их имена указывают на это поведение.
Определяя интерфейс в Go, указывается не то, чем он является, а то, что он обеспечивает, — не объекты, а поведение! Вот почему в Go нет интерфейса File
, а есть Reader
и Writer
, ориентированные на поведение, а File
— это объект, реализующий Reader
и Writer
.
Эта же идея упоминается в Effective Go — официальном руководстве по написанию идиоматического Go:
Интерфейсы в Go предоставляют возможность указать поведение объекта: если что-то может это делать, то это можно использовать здесь.
При написании интерфейсов старайтесь думать о действиях или поведении. Определяя интерфейс под названием Thing
(Объект), спросите себя, почему этот Thing
не является структурой.
4. Интерфейс на стороне производителя
При проверках кода часто замечаю, что интерфейсы определяются в том же пакете, где пишется конкретная реализация.
Но, возможно, клиенту не требуется использовать все методы из интерфейса производителя. Напомню, что, согласно принципу разделения интерфейсов, «клиент не должен зависеть от не используемых им методов«. Вот пример:
package main
// ====== Сторона производителя
// Этот интерфейс не нужен
type UsersRepository interface {
GetAllUsers()
GetUser(id string)
}
type UserRepository struct {
}
func (UserRepository) GetAllUsers() {}
func (UserRepository) GetUser(id string) {}
// ====== Сторона клиента
// Клиенту нужен только GetUser, и
// он может создать этот интерфейс, неявно реализуемый
// конкретным UserRepository на его стороне
type UserGetter interface {
GetUser(id string)
}
Если клиенту необходимо использовать все методы на стороне производителя, он может воспользоваться конкретной структурой. Поведение уже доступно в методах struct.
Даже если клиенту потребуется разделить свой код и использовать несколько реализаций, можно создать интерфейс со всеми методами на стороне клиента:
Все это возможно благодаря тому, что интерфейсы в Go удовлетворяются неявно. Клиентскому коду больше не нужно импортировать некий интерфейс и писать implements
, потому что в Go нет такого ключевого слова. Если Implementation
обладает теми же методами, что и Interface
, то Implementation
уже удовлетворяет этому интерфейсу (соответствует ему) и может использоваться в клиентском коде.
5. Возвращение интерфейсов
Если метод возвращает интерфейс, а не конкретную структуру, то все клиенты, вызывающие этот метод, вынуждены работать с одной и той же абстракцией. Вы должны позволить клиентам решать, какие абстракции им нужны, потому что код — их “внутренний двор”.
Это раздражает, когда вы хотите использовать что-то из структуры, но не можете, потому что интерфейс не предоставляет вам доступа. Причины для такого ограничения могут быть, но не всегда. Вот надуманный пример:
package main
import "math"
type Shape interface {
Area() float64
Perimeter() float64
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
func (c Circle) Perimeter() float64 {
return 2 * math.Pi * c.Radius
}
// NewCircle возвращает интерфейс вместо структуры
func NewCircle(radius float64) Shape {
return Circle{Radius: radius}
}
func main() {
circle := NewCircle(5)
// Мы теряем доступ к circle.Radius
}
В приведенном выше примере мы не только теряем доступ к circle.Radius
, но и должны заполнять код утверждениями типов каждый раз, когда хотим получить к нему доступ:
shape := NewCircle(5)
if circle, ok := shape.(Circle); ok {
fmt.Println(circle.Radius)
}
Чтобы следовать закону Постела «относись консервативно к тому, что отправляешь, и либерально к тому, что принимаешь», возвращайте конкретные структуры из своих методов и предпочитайте принимать интерфейсы.
В книге «Практический Go» Дэйва Чейни ясно сказано, почему следующий код:
// Save записывает содержимое doc в файл f.
func Save(f *os.File, doc *Document) error
улучшается благодаря принятию интерфейса:
// Save записывает содержимое doc в поставляемый
// Writer.
func Save(w io.Writer, doc *Document) error
Последующая правка: не думаю, что возвращение интерфейса — плохая идея (спасибо пользователю ThreeFactorAuth):
6. Создание интерфейсов исключительно для тестирования
Вот еще одна причина замусоривания интерфейсами: создание интерфейса с одной реализацией только для получения макета этой реализации.
Если вы злоупотребляете интерфейсами, создавая множество макетов, то в итоге, вместо реальной логики приложения, тестируете имитаторы, которые никогда не используются в производстве. А в реальном коде у вас будут 2 концепции (семантические уровни, как говорит Дейкстра) там, где достаточно одной. И это только ради тестирования. Стоит ли удваивать семантические уровни каждый раз при создании нового теста?
Вы всегда можете использовать testcontainers — библиотеку тестирования с помощью контейнеров Docker — вместо того, чтобы, например, имитировать базу данных. Или просто заведите собственный контейнер, если testcontainers
не поддерживает вашу базу данных.
Или, может быть, следует имитировать не весь объект, а какую-то его часть. Например, если у вас есть структура с 10 методами, вряд ли стоит имитировать всю структуру. Возможно, достаточно сымитировать только небольшую часть и использовать ее в тестах. Имитация всей структуры — слишком ленивое решение для тестирования.
Если вы пишете API, не нужно предоставлять интерфейс своим клиентам, чтобы они использовали его для имитации. Если им надо будет написать моки, они могут сделать это сами, определив интерфейсы на своей стороне (см. пункт 4).
7. Отсутствие проверки соответствия интерфейсов
Допустим, у вас есть пакет, экспортирующий тип User
, и вы реализуете интерфейс Stringer
, потому что по какой-то причине не хотите, чтобы при выводе отображался e-mail:
package users
type User struct {
Name string
Email string
}
func (u User) String() string {
return u.Name
}
Клиент располагает следующим кодом:
package main
import (
"fmt"
"pkg/users"
)
func main() {
u := users.User{
Name: "John Doe",
Email: "[email protected]",
}
fmt.Printf("%s", u)
}
Вывод будет корректным: John Doe
.
Теперь предположим, что вы проводите рефакторинг и по ошибке удаляете или комментируете реализацию String()
, и ваш код выглядит следующим образом:
package users type User struct { Name string Email string }
В этом случае код по-прежнему будет компилироваться и выполняться, но на выходе вы получите {John Doe [email protected]}
. Не было никакой обратной связи, обеспечивающей выполнение вашего предыдущего намерения.
Компилятор помогает, когда у вас есть методы, принимающие User
, но в случаях, подобных описанному выше, он этого делать не будет.
Чтобы гарантировать, что определенный тип реализует интерфейс, можно сделать следующее:
package users
import "fmt"
type User struct {
Name string
Email string
}
var _ fmt.Stringer = User{} // User реализует fmt.Stringer
func (u User) String() string {
return u.Name
}
Теперь, если удалить метод String()
, получим при сборке следующее:
cannot use User{} (value of type User) as fmt.Stringer value in variable declaration: User does not implement fmt.Stringer (missing method String)
В этой строке мы пытались присвоить пустой User{}
переменной типа fmt.Stringer
. Поскольку User{}
перестал реализовывать fmt.Stringer
, мы получили ошибку. Мы использовали _
для имени переменной, потому что на самом деле мы ее не используем, так что никаких выделений не будет.
Выше у нас есть User
, реализующий интерфейс. User
и *User
— это разные типы. Поэтому, если вы хотите, чтобы *User
реализовал интерфейс, сделайте что-то вроде этого:
var _ fmt.Stringer = (*User)(nil) // *User реализует fmt.Stringer
Мне также нравится, что при выполнении этой задачи IDE Goland показывает опцию Implement missing methods (Реализовать отсутствующие методы):
Хотя это классный трюк, не стоит использовать его для каждого типа, реализующего интерфейс, потому что если у вас есть функции, требующие интерфейс, компилятор уже будет выдавать ошибку, если вы попытаетесь использовать типы, не реализующие его. Мне самому пришлось долго поломать голову, чтобы придумать пример для этой статьи, так что это действительно редкий случай.
Вспомним, как нас предупреждают в Effective Go:
Появление пустого идентификатора в этой конструкции указывает на то, что объявление существует только для проверки типа, а не для создания переменной. Однако не делайте этого для каждого типа, удовлетворяющего интерфейсу. По соглашению, такие объявления используются только в том случае, если в коде нет статических преобразований, что случается довольно редко.
Помните: любые советы надо воспринимать с долей здравого смысла. Я написал эту статью, чтобы объяснить альтернативные и идиоматические способы создания Go-кода и показать стоимость принимаемых вами решений, а не для того, чтобы перечислить практики, которых вы должны всегда избегать.
Читайте также:
- Самый быстрый способ cоздать CRUD API в Golang
- Мониторинг приложения Golang с Prometheus, Grafana, New Relic и Sentry
- Создание кастомного балансировщика нагрузки на Go для gRPC с приоритизацией адресов
Читайте нас в Telegram, VK и Дзен
Перевод статьи Andrei Boar: 7 Common Interface Mistakes in Go