GO. Принимай интерфейс, возвращай структуру
Одной из первых вещей, которые я усвоил, когда начал работать с Go, было то, что в нем есть так называемые пословицы (proverbs). Это список правил, которые звучат как умные цитаты, которыми вы должны руководствоваться в своем путешествии. Долгое время я не совсем понимал, почему я должен принимать интерфейсы, но возвращать структуры . Я также хотел возвращать интерфейсы, так как это определяло бы, а не то, что это за тип, а то что делает возвращаемый мной тип. За один полный год работы исключительно с Go, я понял как же я ошибался. Этот пост объясняет ход моих мыслей, я надеюсь, что это может спасти некоторых из вас когда-нибудь, прежде чем у вас наступит момент Ага!
Реализация интерфейса на Java
В первые дни своей карьеры я тратил большую часть своего времени на написание Java-кода. Хотя я не помню, как именно я это сделал (с тех пор прошло почти три года), я помню, как работает наследование в том мире.
В Java (и многих других языках) всякий раз, когда вы определяете интерфейс, который должен описывать данный класс, вам необходимо явно связать их вместе:
// SomeInterface.java ... public interface SomeInterface { void hello(); } // SomeClass.java ... public class SomeClass implements SomeInterface { public void hello() { System.out.println("Hello!"); } }
Теперь, всякий раз, когда мы хотим использовать hello()
метод из SomeClass
класса, мы можем создать его экземпляр напрямую...
SomeClass sc = new SomeClass(); sc.hello();
...или как реализацию интерфейса SomeInterface
:
SomeInterface mi = new SomeClass(); mi.hello();
Реализация интерфейса в Go
В Go есть другой подход к реализации интерфейсов. Правило таково: если оно ходит как утка и крякает как утка, это утка , а это значит, что мы судим о конкретной структуре по тому, как она себя ведет, а не по тому, как она определена:
// some.go type SomeStruct struct {} func (SomeStruct) Hello() { fmt.Println("Hello!") } type SomeInterface interface { Hello() }
Теперь, без явного определения соединения, мы можем использовать структуру напрямую...
var ss SomeStruct ss = SomeStruct{} ss.Hello()
var si SomeInterface si = SomeStruct{} si.Hello()
Зачем принимать интерфейсы? Зачем возвращать структуры?
У вас может возникнуть соблазн вернуть интерфейс при объявлении функции, чтобы понимать, что фактически возвращаемая структура действительно реализует интерфейс:
func NewDataSource() DataSource { return MemoryDataSource{} }
Я делал это раз или два, но после продолжительной работы с Go на полную ставку я понял, насколько это плохо. Вы должны понимать, что когда вызывающая программа хочет использовать какую-либо функцию, которая возвращает что-либо, она может быть заинтересована либо в конкретном типе (ожидает определенную структуру), либо в поведении (ожидает интерфейс).
В первом случае вам просто нужно вернуть структуру, конец истории. Но во-втором, вы должны убедиться, что интерфейс действительно реализован, и если реализуемые функции являются частью структуры, так почему бы вам просто не вернуть совместимую структуру?
Мое мышление также основывалось на том факте, что если моя функция NewDataStore()
возвращает интерфейс, то структура MemoryDataSource
должна публично выставлять только функции, являющиеся частью DataSource
. Такой подход приводит к ограничению возможностей структуры.
Давайте определим интерфейс...
type DataStore interface { Data(query string) ([]DataItem, error) }
... который реализуется MemoryDataSource
конструктором, который возвращает этот интерфейс:
type MemoryDataSource struct {} func (mds MemoryDataSource) Data(query string) ([]DataItem, error) { ... } func NewMemoryDataSource() DataSource { return MemoryDataSource{} }
Теперь какой-то сервис требует для работы какой-то источник данных, поэтому мы передаем туда интерфейс:
// someservice.go func NewService(ds DataSource) SomeService { ... return SomeService{ dataSource: ds, } }
Напишем тест для нашего сервиса:
// someservice_test.go ... type mockDataSource struct {} func (mockDataSource) Data(query string) ([]datastore.DataItem, error) { return []datastore.DataItem{ {Value: query, score: 123}, }, nil } ... func TestServiceAction(t *testing.T) { svc := NewService(mockDataSource{}) ... }
Что делать, если интерфейс для DataSource
изменился? Что же, если в него добавлена другая функция, то внезапно мы не сможем использовать нашу фиктивную (mock) реализацию (или любую другую, созданную пользователем), потому что это критическое изменение. Мы знаем, что так быть не должно, потому что наш сервис заботится только о Data(query string) ([]datastore.DataItem, error)
функциональности, и ни о чем другом!
Хороший пример
Хорошим подходом к этой ситуации было бы определение того, что именно мы ожидаем от наших входных параметров. Для этого мы определяем интерфейсы, в которых они используются, и ограничиваем их минимальным минимумом, необходимым для работы нашего кода. Нас не волнует ничего, что может предоставить источник данных, кроме его данных:
// someservice.go ... type dataSource interface { Data(query string) ([]datastore.DataItem, error) }
Определив это, мы можем сделать наш сервис независимым от внешнего интерфейса:
// someservice.go ... func NewService(ds dataSource) SomeService { ... return SomeService{ dataSource: ds, } }
Вот где проявляется магия Go по сравнению с тем, что я видел при использовании Java. Каждый раз, когда мы создаем собственную структуру с этой Data(..)
функцией, ей не нужно ничего знать об dataSource
интерфейсе! Это тот, SomeService
кто знает об обоих и решает, можно ли использовать данный тип в этом месте:
ds := redis.DataSource{} // which has Data(..) function svc := NewService(ds) // it works! redis.DataSource had no idea, but it fits here!
Резюме
В отличие от Java, в Go именно вызывающая программа определяет используемые ею интерфейсы. Мне потребовалось много времени, чтобы понять это, но когда я это сделал, я действительно оценил читабельность Go на еще одном уровне. Это упрощает управление кодом и его понимание, поскольку вы держите все связанные части близко друг к другу.
Источник: https://mycodesmells.com/post/accept-interfaces-return-struct-in-go