I in SOLID
Принцип разделения интерфейсов
Interface segregation — клиенты не должны зависеть от интерфейсов, которые они не используют.
Данный принцип предостерегает от создания «толстых» интерфейсов, чтобы избежать ситуаций, когда какой-нибудь класс вынужден реализовывать поведение, которое ему не нужно.
Паттерн "репозиторий"
Предположим, мы хотим воспользоваться паттерном «репозиторий» для реализации crud-операций нашего REST API:
interface Repository<T> {
create(item: T): void;
read(): T[];
update(item: T): void;
delete(item: T): void;
}
abstract class AbstractRepository<T> implements Repository<T> {
abstract create(item: T): void;
abstract read(): T[];
abstract update(item: T): void;
abstract delete(item: T): void;
}
interface Item {
name: string;
}
class SimpleItemsRepository extends AbstractRepository<Item> {
private items: Item[] = [];
create(item: Item): void {
this.items.push(item);
}
read(): Item[] {
return this.items;
}
update(updatedItem: Item): void {
const index = this.items.findIndex(item => item === updatedItem);
if (index !== -1) {
this.items[index] = updatedItem;
} else {
console.error("Item not found");
}
}
delete(item: Item): void {
this.items = this.items.filter(i => i !== item);
}
}Казалось бы, всё в порядке. Теперь с помощью реализованного репозитория мы можем работать с разными сущностями и хранилищами. Но вдруг у нас появится сущность, для которой не все CRUD-операции разрешены? Например, мы можем только получать данные, но не можем ничего обновлять и удалять.
class OnlyReadSimpleItemsRepository extends AbstractRepository<Item> {
private items: Item[] = [];
create(item: Item): void {
console.error("Not allowed");
}
read(): Item[] {
return this.items;
}
update(updatedItem: Item): void {
console.error("Not allowed");
}
delete(item: Item): void {
console.error("Not allowed");
}
}Получается ситуация, когда OnlyReadItemsRepository реализует методы, которые ему фактически не нужны. Это плохо, потому что возникает несоответствие ожиданиям реализации интерфейса — вместо желаемого удаления при вызове метода ничего не происходит, и в лучшем случае мы заметим это только в рантайме.
Вместо этого необходимо разделить этот интерфейс. В рамках данного примера реализация каждой операции отдельным интерфейсом кажется избыточной, поэтому разделим операции на «чтение» и «запись».
interface RepositoryRead<T> {
read(): T[];
}
interface RepositoryWrite<T> {
create(item: T): void;
update(item: T): void;
delete(item: T): void;
}
abstract class AbstractRepository<T> implements RepositoryRead<T>, RepositoryWrite<T> {
abstract create(item: T): void;
abstract read(): T[];
abstract update(item: T): void;
abstract delete(item: T): void;
}
interface Item {
name: string;
}
class OnlyReadSimpleItemsRepository implements RepositoryRead<Item>
private items: Item[] = [];
read(): Item[] {
return this.items;
}
}
class SimpleItemsRepository extends AbstractRepository<Item> {
//...
}В итоге получаем более элементарные и изолированные интерфейсы, которые позволяют нам более гибко работать с репозиториями и избежать возникновения «мёртвого кода».
Хуки жизненного цикла
Ярким примером принципа разделения интерфейсов в Angular может послужить реализация хуков жизненного цикла компонентов. Напомню, каждый из 8 хуков описывается независимым интерфейсом. Мы лишь имплементируем для каждого компонента нужные хуки.
import { Component, OnInit, AfterViewInit, OnDestroy} from '@angular/core';
@Component({
//...
})
export class SomeComponent implements OnInit, AfterViewInit, OnDestroy {
constructor() {
//...
}
ngOnInit(): void {
//...
}
ngAfterViewInit(): void {
//...
}
ngOnDestroy(): void {
//...
}
}А что бы было, если бы все хуки были бы объединены в один интерфейс, например, ComponentLifecycle? Тогда наши компоненты должны были бы реализовывать все хуки, независимо от того, какие из них действительно требуются.
import { SimpleChanges } from '@angular/core';
export interface ComponentLifecycle {
ngOnChanges(changes: SimpleChanges): void;
ngOnInit(): void;
ngDoCheck(): void;
ngAfterContentInit(): void;
ngAfterContentChecked(): void;
ngAfterViewInit(): void;
ngAfterViewChecked(): void;
ngOnDestroy(): void;
}При этом эти хуки постоянно вызывались бы, так как определить какой из них является заглушкой, а какой нет, Angular не сможет. Также если вдруг когда-нибудь возникнет новый хук жизненного цикла, мы будем вынуждены обновить наш «толстый» интерфейс, и старые компоненты сломаются, так как не будут реализовывать новый метод, а значит не будут соответствовать интерфейсу.
Итого
Следование принципу разделения интерфейсов обеспечивает более гибкую и прозрачную работу с интерфейсами, позволяет исключить бесполезный код, а также упрощает поддержку и масштабирование с точки зрения обратной совместимости 👍.