Shanraq.org Shanraq.org
Интерфейс в Go: договор по умению, а не по устройству
IT

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: в нём нет ни значения, ни типа. Вызов метода у такого приведёт к аварии, знакомой по прошлому уроку.

Не делайте интерфейс раньше времени

Правило, которое сбережёт вам месяцы: принимайте интерфейсы, возвращайте структуры. Функция объявляет, что ей нужно, а конструктор отдаёт конкретный тип.

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

Образ. Манекен на примерке. Он не человек, но плечи те же — и пиджак проверить можно. Подменное хранилище в тесте ровно такое: не база, но договор выполняет.

Карта урока

Карта урока: одна функция, любое хранилище, договор — список умений

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Где в программе MemoryStore объявляет, что реализует Store?
  2. Почему в main передают &MemoryStore{}, а не MemoryStore{}?
  3. Зачем нужна строка 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, а тесты всё это время будут работать на подменном, без базы и без сети. Функции, которые вы уже написали, не изменятся ни на строку — они и так знают только договор.

Ответы

Показать ответы
  1. Нигде. В Go реализация не объявляется: тип подходит под интерфейс, если у него есть все нужные методы с теми же сигнатурами. Поэтому интерфейс можно описать даже для чужого типа, который о вас не знает.
  2. Потому что методы Add и All объявлены с приёмником-указателем, а значит, договору Store удовлетворяет тип *MemoryStore. У MemoryStore этих методов формально нет, и компилятор откажет.
  3. Она проверяет договор при сборке, а не при первом использовании. Пока хранилище никуда не передаётся, опечатка в имени метода останется незамеченной; эта строка ловит её сразу и ничего не стоит во время работы.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.