JavaRush /Курсы /Go SELF /Где объявлять интерфейсы

Где объявлять интерфейсы

Go SELF
24 уровень , 3 лекция
Открыта

1. Введение

Интерфейс в Go — это не просто «тип из воздуха», а часть договора между кусками программы. И вот тут начинается философия (не бойтесь, без бороды и монастыря): кто формулирует договор — тот и задаёт правила. Если интерфейс объявлен «не там», ваш проект постепенно обрастает странными зависимостями, где один пакет тащит другой просто ради типов, а замена реализации превращается в квест.

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

В Go основное практическое правило звучит так:

Интерфейс чаще всего стоит объявлять в том месте, где он нужен — то есть у потребителя.

Чтобы это стало не лозунгом, а понятной техникой, будем разбирать на небольшом мини‑приложении.

2. Правило «интерфейс у потребителя»

Фраза «объявляй интерфейсы там, где используют» означает не «всегда в main», а «в пакете/файле, который зависит от абстракции». То есть там, где код хочет вызвать методы, но не хочет знать, какая конкретно реализация стоит за ними (память, файл, база данных, что угодно).

Давайте продолжим условное учебное приложение Tasker — простейший менеджер задач. У нас есть доменная модель Task и логика добавления/списка. Нам нужно хранилище задач. С точки зрения логики приложения важны действия: «сохранить задачу», «вернуть список задач». Каким способом это сделано — деталь.

Мини‑контракт «хранилища» (интерфейс) логично объявить там, где он нужен: в коде, который управляет задачами.

package tasker

type TaskStore interface {
	Add(text string) error
	List() []string
}

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

Теперь «сервис» задач может зависеть от TaskStore, а не от конкретного MemStore, FileStore и т.п.

package tasker

type Service struct {
	store TaskStore
}

func NewService(store TaskStore) Service {
	return Service{store: store}
}

В этот момент у вас появляется приятная свобода: сервису всё равно, что там внутри store, лишь бы оно умело Add и List.

Мини‑пример Tasker: интерфейс рядом с вызывающим кодом

Когда правило кажется абстрактным, проще всего заземлить его на конкретной структуре файлов. Представим, что у нас есть три пакета: tasker (логика), memstore (памятная реализация) и main (точка входа). Это не «взрослая архитектура», а просто аккуратная раскладка, чтобы было видно направление зависимостей.

Схему полезно читать так: main «склеивает» зависимости, tasker формулирует контракт (интерфейс), memstore предоставляет реализацию. При этом tasker не обязан импортировать memstore.

flowchart LR
  main --> tasker
  main --> memstore
  memstore -. "реализует TaskStore" .-> tasker

Пакет tasker объявляет интерфейс и использует его. Пакет memstore просто реализует нужные методы — и всё. Он не обязан «знать», что существует интерфейс.

Вот как может выглядеть простая реализация «в памяти»:

package memstore

type Store struct {
	items []string
}

func (s *Store) Add(text string) error {
	s.items = append(s.items, text)
	return nil
}

И второй метод:

package memstore

func (s *Store) List() []string {
	return s.items
}

А теперь main собирает всё вместе. Обратите внимание: main зависит от конкретной реализации (memstore.Store), но tasker.Service зависит только от интерфейса (tasker.TaskStore).

package main

import (
	"fmt"

	"example/memstore"
	"example/tasker"
)

func main() {
	st := &memstore.Store{}
	svc := tasker.NewService(st)

	_ = svc // пока просто собрали зависимости
	fmt.Println("tasker запущен") // tasker запущен
}

Ключевой момент: интерфейс объявлен не в memstore, а в tasker, потому что именно tasker формулирует потребность: «мне надо уметь Add и List».

Если интерфейс объявили у реализации: что ломается

Теперь давайте посмотрим на альтернативу, которую новички часто делают «по наитию»: раз у нас есть пакет memstore, то давайте там же объявим интерфейс Store (или Repository, или Storage, или «что‑то очень серьёзное»).

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

Представьте, что memstore объявляет:

package memstore

type Storage interface {
	Add(text string) error
	List() []string
}

И теперь tasker (логика) хочет принимать memstore.Storage. Что получается?

Во‑первых, tasker вынужден импортировать memstore, хотя tasker по смыслу не должен знать, что хранилище именно «mem». Сегодня memstore, завтра filestore, послезавтра sqlstore, и у вас внезапно бизнес‑логика начинает тянуть «технологические» пакеты.

Во‑вторых, интерфейс начинает жить в пакете, который вы, возможно, захотите удалить или заменить. А интерфейс — это часть API. Если «API вашего приложения» спрятан внутри пакета реализации, он становится трудноуправляемым: поменяли реализацию — и заодно поменяли место, где лежит контракт.

В‑третьих, интерфейс почти неизбежно разрастается «под реализацию». У реализации всегда есть соблазн сказать: «Раз уж мы тут, добавим ещё метод DebugDump()… вдруг пригодится». И интерфейс перестаёт быть минимальным.

Здоровая интуиция такая: интерфейс — собственность потребителя, а не поставщика. Поставщик просто удовлетворяет требованиям.

3. Экспортируемые интерфейсы — дорогое обещание

Интерфейс, особенно экспортируемый (с заглавной буквы), — это публичный контракт. Если вы его опубликовали, то добавление нового метода ломает всех, кто этот интерфейс реализует. Это примерно как сказать: «С завтрашнего дня для входа в подъезд нужно не только ключ, но и отпечаток лапы кота». Те, у кого кота нет, начинают грустить.

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

Например, внутри пакета tasker можно так:

package tasker

type store interface {
	Add(text string) error
	List() []string
}

А наружу вы можете экспортировать конкретные функции/типы, которые принимают нужные зависимости, не выставляя интерфейс как «часть публичной конституции».

Если же интерфейс действительно часть публичного API (например, вы пишете библиотеку), тогда он должен быть маленьким, стабильным и сформулированным с точки зрения потребителя.

4. Интерфейс — граница абстракции

Есть ещё одна тонкая, но очень практичная мысль: интерфейс — это не просто набор методов, это граница, через которую «просачиваются» детали. Если вы включили в интерфейс типы из пакета реализации, вы фактически привязали всех потребителей к этим деталям.

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

Плохой дизайн выглядит примерно так (пример специально «кривой», чтобы было видно):

package tasker

import "example/memstore"

type TaskStore interface {
	SaveRow(row memstore.Row) error
}

Теперь tasker зависит от memstore.Row. То есть логика «управления задачами» уже знает внутреннюю структуру «памятного хранилища». Это и есть утечка абстракции.

Хорошее направление мысли: интерфейс должен говорить на языке домена (задачи, строки, ID), а не на языке реализации («Row», «SQLTx», «BoltBucket», «MagicCursor3000»).

5. Почему это облегчает замену реализации

Когда интерфейс объявлен у потребителя и минимален, у вас появляется возможность легко менять реализации без переписывания бизнес‑логики. Даже если вы пока не пишете тесты и не строите сложную архитектуру, это полезно уже сейчас: сначала вы можете хранить задачи в памяти, потом — в файле, но «сердце приложения» не трогать.

Чтобы увидеть идею на пальцах, достаточно сделать вторую реализацию, например «заглушку», которая всегда возвращает пустой список. Это не тест, не мок‑фреймворк и не магия — просто другой тип с теми же методами.

package tasker

type EmptyStore struct{}

func (EmptyStore) Add(text string) error { return nil }
func (EmptyStore) List() []string        { return nil }

Теперь любой код, который работает с TaskStore, может получить EmptyStore{} и не знать, что это «особая» реализация. Это и есть сила контракта: тип не обязан называться Store, чтобы быть хранилищем, он обязан лишь реализовать нужные методы.

При этом важно помнить: если вы делаете методы на *T, то интерфейс реализует *T, а не T. Это не «плохое», просто это часть договора, и на этапе сборки зависимостей нужно передавать правильную форму значения.

6. Как выбрать место для интерфейса в проекте

Интуитивно хочется спросить: «Ну хорошо, а как понять, где “у потребителя”, если у меня всё в одном пакете?» Это нормальный вопрос, особенно в учебных и маленьких проектах.

Рабочая практика такая: объявляйте интерфейс максимально близко к коду, который его использует как зависимость. Чаще всего это означает «рядом с функцией или структурой, которая принимает зависимость».

Например, если у вас есть функция, которая печатает задачи, и ей не важно, откуда они берутся, интерфейс можно объявить прямо рядом:

package tasker

type Lister interface {
	List() []string
}

func PrintAll(x Lister) []string {
	return x.List()
}

Это выглядит почти как «локальный контракт»: функция сказала, что ей нужно — и всё.

Если интерфейс нужен в нескольких местах внутри пакета, выносите его повыше в файл или в отдельный файл типа interfaces.go. Если интерфейс нужен только внутри пакета — делайте его неэкспортируемым. Если интерфейс нужен внешним пользователям пакета — экспортируйте, но будьте готовы, что это обещание придётся держать (и не раздувать интерфейс «на всякий случай»).

Удобно держать в голове маленькую таблицу‑шпаргалку:

Ситуация Что обычно делать
Интерфейс нужен только одной функции/структуре Объявить рядом (в том же файле, близко к месту использования)
Интерфейс нужен нескольким сущностям внутри пакета Вынести на уровень пакета, но оставить неэкспортируемым
Интерфейс — часть публичного API пакета Экспортировать, держать маленьким и стабильным
Интерфейс «про реализацию» (SQL/Redis/Mem) Скорее всего, это запах: интерфейс должен быть про поведение, а не про технологию

7. Типичные ошибки при выборе места для интерфейса

Ошибка №1: интерфейс объявлен в пакете реализации, а бизнес‑логика импортирует реализацию ради интерфейса.
Так часто получается случайно: «ну я же уже в memstore, вот тут и напишу type Store interface». В результате пакет с логикой начинает зависеть от пакета «памяти» или «SQL», а затем вы удивляетесь, почему заменить реализацию сложно. Если интерфейс описывает потребность логики, ему место в пакете логики.

Ошибка №2: экспортируемый интерфейс разросся, и теперь его страшно трогать.
Сначала вы добавили Save, потом Load, потом «ну ещё Clear() пригодится», потом «а давайте Stats()». А потом выясняется, что две реализации из трёх не могут честно реализовать всё это без странных костылей. Экспортируемый интерфейс — обещание, и цена обещания высока: менять такой контракт больно, потому что вы ломаете тех, кто под него подстроился.

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

Ошибка №4: интерфейс создан «потому что интерфейсы нужны», а не потому что есть реальный потребитель.
Иногда интерфейс объявляют заранее, «на вырост», ещё до того, как появился код, который от него выигрывает. В итоге получается абстракция без смысла: ни одной второй реализации, сложнее сигнатуры, больше файлов, а пользы ноль. Интерфейс в Go — инструмент, а не религия: он нужен, когда снижает зависимость кода от деталей.

Ошибка №5: имя интерфейса отражает технологию, а не поведение.
SQLStore, FileRepo, RedisManager в роли интерфейса — часто плохой знак. Интерфейс лучше называть по поведению (например, Store, Saver, Lister), а конкретный тип — по технологии (SQLStore, FileStore). Так при чтении кода мозг не путает «контракт» и «реализацию», и проект становится заметно спокойнее.

1
Задача
Go SELF, 24 уровень, 3 лекция
Недоступна
Интерфейс приветствия
Интерфейс приветствия
1
Задача
Go SELF, 24 уровень, 3 лекция
Недоступна
Сервис заметок
Сервис заметок
1
Задача
Go SELF, 24 уровень, 3 лекция
Недоступна
Склейка пакетов
Склейка пакетов
1
Задача
Go SELF, 24 уровень, 3 лекция
Недоступна
Два сценария
Два сценария
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ