Принцип разделения интерфейсов (Interface Segregation Principle)
Клиент не должен зависеть от методов, которыми не пользуется: печать и сканирование - отдельные узкие интерфейсы, а не одно "устройство".
Проблема
В офисе есть МФУ, и под него написали один интерфейс на всё:
interface Device
{
public function print(Document $document): string;
public function scan(): Document;
public function fax(string $number, Document $document): void;
}
final class OfficePrinter implements Device
{
public function print(Document $document): string { /* ... */ }
public function scan(): Document
{
throw new LogicException('this printer cannot scan');
}
public function fax(string $number, Document $document): void
{
throw new LogicException('this printer cannot fax');
}
}Обычный принтер вынужден реализовать сканирование и факс заглушками с исключением. Код, которому нужна только печать, всё равно зависит от Device целиком: тестовый двойник должен реализовать три метода вместо одного, а добавление в Device метода staple() заставит править каждый класс, даже те, что к сшиванию отношения не имеют. Заодно нарушается и подстановка: OfficePrinter формально Device, но падает на половине методов.
Решение
Разбить толстый интерфейс по ролям, которые нужны клиентам: Printer с одним методом print и Scanner с одним методом scan. Устройство реализует столько ролей, сколько умеет: OfficePrinter - только Printer, MultiFunctionDevice - обе. Клиент просит ровно те роли, которыми пользуется: Copier получает отдельно Scanner и Printer, и ему всё равно, одно это устройство или два.
Интерфейс проектируется от клиента, а не от устройства: вопрос не "что умеет МФУ", а "что нужно копиру".
Структура
place - метод самого МФУ, а не роли: положить бумагу на стекло - дело человека у аппарата, копир этого не делает и о методе не знает. Пустое стекло - отдельное исключение GlassEmpty, а не пустой документ.
Как это собирается
Устройства берутся из инвентаря офиса и собираются в DI-контейнере или main. В примере это OfficePrinter на этаже и MultiFunctionDevice на ресепшене. Счёт печатается на офисном принтере напрямую. Посетитель кладёт паспорт на стекло МФУ, и два копира собираются из ролей: один берёт сканер и принтер у самого МФУ, другой сканирует на МФУ, а печатает на этаже. Copier в обоих случаях один и тот же класс, меняется только то, что ему передали.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
| Язык | Особенность |
|---|---|
| PHP | Два интерфейса по одному методу, MultiFunctionDevice implements Printer, Scanner; двойник в тесте - анонимный класс с одним методом |
| Go | Маленькие интерфейсы и их композиция PrintScanner, как io.ReadWriter; Scan возвращает error |
| Python | Protocol с @runtime_checkable: устройства ни от чего не наследуются, роли проверяются по форме |
| TypeScript | Структурные интерфейсы; двойник сканера в тесте - литерал { scan: () => ... } |
Запуск: make test T=principles/solid/interface-segregation
Плюсы и минусы
Тесты показывают выгоду для клиента: двойник сканера в тесте - один метод, без заглушек печати и факса, и Copier с ним работает. Тот же Copier работает с одним МФУ в обеих ролях. Ещё один тест фиксирует, что OfficePrinter реализует только Printer и метода scan у него нет вовсе.
- ✅ Классы реализуют только то, что умеют, без заглушек с исключениями.
- ✅ Тестовые двойники крошечные: один метод вместо всего устройства.
- ✅ Изменение одной роли не задевает классы, которые её не реализуют.
- ❌ Больше интерфейсов, и их нужно правильно назвать.
- ❌ Если раздробить слишком мелко, клиенту придётся принимать пять параметров вместо одного.
Когда не применять
- У интерфейса один клиент и одна реализация - делить его не на что.
- Методы всегда используются вместе (
openиcloseу соединения) - разрезать их значит разрезать одну ответственность. - Интерфейс задан внешним протоколом или библиотекой: его не переделать, можно только обернуть узким адаптером.
Отличия между языками
- PHP - класс может реализовать несколько интерфейсов, и объявлять их нужно явно. Узкие интерфейсы делают анонимные классы в тестах короткими.
- Go - принцип встроен в культуру языка: интерфейсы маленькие (
io.Reader,io.Writer), объявляются у потребителя, а широкие собираются композицией (PrintScanner). Реализация не знает об интерфейсах, поэтому разделить роль можно позже, не трогая устройство. - Python -
Protocolописывает роль у потребителя, как в Go.isinstanceс@runtime_checkableпроверяет только наличие методов, не сигнатуры. - TypeScript - структурная типизация: двойник - объектный литерал с одним методом, а лишние методы у реального устройства клиенту не видны.
Связанные темы
- SOLID - все пять принципов
- Принцип подстановки Лисков - заглушка с исключением ломает и подстановку
- Адаптер - как дать клиенту узкий интерфейс поверх чужого широкого
- Фасад - обратная задача: собрать много ролей за одной простой точкой входа