Go: с нуля до своего блога Урок 13 из 50
Интерфейс в Go: договор по умению, а не по устройству
Тринадцатый урок курса по Go. Интерфейс — список умений: одна функция работает с любым хранилищем, если оно умеет то, что записано в договоре. Почему реализация не объявляется, а получается сама, как проверить её при сборке и почему подменное хранилище делает тесты возможными без базы.
Зачем это нужно
Вы пишете функцию, которая отдаёт список статей. Сегодня они лежат в памяти, через месяц — в базе, а в тестах их вообще не должно быть. Переписывать функцию три раза?
Нет. Функции незачем знать, что за хранилище перед ней. Ей достаточно знать, что оно умеет.
Вы этим уже пользовались. В самом первом сервере обработчик получал w http.ResponseWriter — и w не был структурой, он был интерфейсом: «умею принимать записанные байты». Кто именно на том конце — браузер, файл или тест — вашему коду было безразлично.
Образ. Объявление о работе. В нём не написано, кто вы и откуда. Написано, что вы должны уметь: принять статью и отдать список. Придёт библиотекарь или робот — неважно, если умеет.
Сразу целиком
Новая папка, go mod init sabaq12, main.go:
package main
import "fmt"
type Article struct {
Title string
Words int
}
func (a Article) String() string {
return fmt.Sprintf("%s (%d слов)", a.Title, a.Words)
}
type Store interface {
Add(a Article)
All() []Article
}
type MemoryStore struct {
items []Article
}
func (s *MemoryStore) Add(a Article) { s.items = append(s.items, a) }
func (s *MemoryStore) All() []Article { return s.items }
type LastTwoStore struct {
items []Article
}
func (s *LastTwoStore) Add(a Article) {
s.items = append(s.items, a)
if len(s.items) > 2 {
s.items = s.items[len(s.items)-2:]
}
}
func (s *LastTwoStore) All() []Article { return s.items }
func report(name string, s Store) {
s.Add(Article{Title: "Привет, мир", Words: 150})
s.Add(Article{Title: "О степи", Words: 400})
s.Add(Article{Title: "Что такое шанырак", Words: 1000})
fmt.Println(name, "— статей:", len(s.All()))
for _, a := range s.All() {
fmt.Println(" ", a)
}
}
func main() {
report("память", &MemoryStore{})
report("последние две", &LastTwoStore{})
}
Прежде чем запускать — не заглядывая ниже, скажите вслух, что она напечатает. Потом запустите и сверьте.
память — статей: 3
Привет, мир (150 слов)
О степи (400 слов)
Что такое шанырак (1000 слов)
последние две — статей: 2
О степи (400 слов)
Что такое шанырак (1000 слов)
Функция report написана один раз и не изменилась. Хранилища — разные.
Разбор
Интерфейс — список умений, а не список полей
type Store interface {
Add(a Article)
All() []Article
}
Внутри структуры перечисляют поля — что у неё есть. Внутри интерфейса перечисляют методы — что она умеет. Полей у интерфейса нет вообще.
Store — такой же тип, как Article. Переменную этого типа можно объявить, передать в функцию, положить в срез. Только лежать в ней будет не структура, а «что-нибудь, умеющее Add и All».
Реализация не объявляется — она получается сама
Найдите в программе слово, которым MemoryStore объявляет, что реализует Store. Его там нет.
В Go достаточно иметь нужные методы. Написали Add и All с теми же именами и сигнатурами — значит, тип уже годится, и никому не надо об этом сообщать.
Образ. Никто не приносит справку. В других языках надо написать бумагу: «я реализую этот договор». В Go вас берут, потому что вы умеете, а не потому, что предъявили документ.
Отсюда полезное следствие: интерфейс можно объявить после типов и даже в чужом пакете. Вы описываете, что вам нужно от чужого кода, и он подходит, ничего о вас не зная.
Одна функция, любое хранилище
func report(name string, s Store) {
report не знает ни про MemoryStore, ни про LastTwoStore. Она знает только договор. Появится третье хранилище — функцию менять не придётся.
Обратите внимание на вызов: &MemoryStore{}, со знаком адреса. Методы объявлены с приёмником-указателем — func (s *MemoryStore) Add(...), — потому что Add меняет содержимое. Значит, договору удовлетворяет *MemoryStore, а не MemoryStore. Это прямое продолжение прошлого урока, и здесь оно впервые становится обязательным.
Проверка на этапе сборки
Пока хранилище нигде не передаётся, ошибку в имени метода легко не заметить. Есть строчка, которая проверяет договор при компиляции:
var _ Store = (*MemoryStore)(nil)
Читается так: «убедись, что *MemoryStore годится в Store». Значение не нужно — потому и _. Если метода не хватает, сборка не пройдёт и скажет прямо:
*BrokenStore does not implement Store (missing method All)
Это дешёвая страховка, и её ставят рядом с типом.
fmt.Stringer — интерфейс, которым вы уже пользовались
func (a Article) String() string {
return fmt.Sprintf("%s (%d слов)", a.Title, a.Words)
}
Один метод String() string — и fmt.Println начинает печатать статью по-вашему, а не в фигурных скобках. Это интерфейс fmt.Stringer из стандартной библиотеки: пакет fmt спрашивает у значения, умеет ли оно String, и, если умеет, вызывает.
Вы ничего не подключали и нигде не регистрировались. Просто написали метод с нужным именем.
Что лежит в переменной интерфейсного типа
Две вещи: само значение и его настоящий тип. Поэтому fmt.Printf("%T", s) покажет *main.MemoryStore, а не Store.
Нулевое значение интерфейса — nil: в нём нет ни значения, ни типа. Вызов метода у такого приведёт к аварии, знакомой по прошлому уроку.
Не делайте интерфейс раньше времени
Правило, которое сбережёт вам месяцы: принимайте интерфейсы, возвращайте структуры. Функция объявляет, что ей нужно, а конструктор отдаёт конкретный тип.
И не заводите интерфейс, пока реализация одна. Договор нужен там, где есть выбор; там, где выбора нет, он только добавляет слой, который надо читать.
Образ. Манекен на примерке. Он не человек, но плечи те же — и пиджак проверить можно. Подменное хранилище в тесте ровно такое: не база, но договор выполняет.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Где в программе
MemoryStoreобъявляет, что реализуетStore? - Почему в
mainпередают&MemoryStore{}, а неMemoryStore{}? - Зачем нужна строка
var _ Store = (*MemoryStore)(nil), если программа и без неё работает?
Задание
Обязательное. Напишите функцию total(s Store) int — сумма слов во всех статьях хранилища. Затем в файле main_test.go объявите своё подменное хранилище fakeStore с теми же двумя методами, положите в него две статьи на 150 и 400 слов и проверьте тестом, что total вернёт 550. Настоящее хранилище в тесте использовать нельзя.
По желанию.
- Добавьте третье хранилище
ReverseStore, которое отдаёт статьи в обратном порядке.reportменять нельзя. - Уберите у
MemoryStoreметодAllи прочитайте сообщение компилятора целиком. - Добавьте
String()типуMemoryStoreи напечатайте хранилище черезfmt.Println.
Куда это встанет в блоге
Интерфейс — то место, где блог перестаёт быть учебной программой. Дальше в курсе Store станет настоящим: сначала память, потом Postgres, а тесты всё это время будут работать на подменном, без базы и без сети. Функции, которые вы уже написали, не изменятся ни на строку — они и так знают только договор.
Ответы
Показать ответы
- Нигде. В Go реализация не объявляется: тип подходит под интерфейс, если у него есть все нужные методы с теми же сигнатурами. Поэтому интерфейс можно описать даже для чужого типа, который о вас не знает.
- Потому что методы
AddиAllобъявлены с приёмником-указателем, а значит, договоруStoreудовлетворяет тип*MemoryStore. УMemoryStoreэтих методов формально нет, и компилятор откажет. - Она проверяет договор при сборке, а не при первом использовании. Пока хранилище никуда не передаётся, опечатка в имени метода останется незамеченной; эта строка ловит её сразу и ничего не стоит во время работы.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.