
Привет! Меня зовут Дмитрий, я руководитель фронтенд-разработки в компании Интелси.
Сегодня хочу рассказать о принципе открытости/закрытости (Open-Closed Principle) — втором из пяти принципов SOLID, сформулированных Робертом Мартином в книге Agile Software Development: Principles, Patterns, and Practices. Его суть звучит так:
«Программные сущности (классы, модули, функции и т. п.) должны быть открыты для расширения, но закрыты для изменения»
(Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification).
Разберём этот принцип сначала на примере классов. Да, в React они используются не так часто, и вы будете правы, если заметите это. Но сам подход прекрасно переносится и на другие сущности — например, на компоненты. Сейчас важнее понять саму идею, а чуть позже я покажу, как этот принцип используется при работе с React компонентами.
Как мы знаем, проект постоянно развивается: появляются новые задачи, а вместе с ними — изменения и дополнения в коде. Любые правки увеличивают вероятность ошибок. Это естественно, но хочется свести такие риски к минимуму. Для этого код должен быть как можно менее связанным: так изменения затрагивают меньшее количество участков кода.
Идеальная ситуация выглядит так: для расширения мы пишем новый код и встраиваем этот код в существующую систему, не переписывая уже готовое. Именно к этому и ведёт принцип открытости/закрытости. Его цель — проектировать сущности так, чтобы они были гибкими и расширяемыми, но при этом требовали минимум изменений при изменении логики.
Давайте продемонстрируем на примере. Мы хотим создать базовый класс, который поможет нам создавать кошек. У нас есть различные породы кошек, которые наследуют от базового класса определённые черты, как название породы и имя. Это есть у любой кошки. И нам нужно реализовать метод makeSound, который будет вызываться в том случае, когда кошка издаёт звук. Но представим, что каждая порода обладает своим специфическим видом мяуканья.
Для начала, я хочу продемонстрировать ситуацию, которую часто можно встретить в коде, но которая нарушает этот принцип:
abstract class Cat {
protected name: string;
protected breed: string;
constructor(name: string, breed: string) {
this.name = name;
this.breed = breed;
}
makeSound() {
if (this.breed === 'Siamese') {
console.log("Громкое мяу!");
} else if (this.breed === 'Persian') {
console.log("Тихое мурлыканье");
} else if (this.breed === 'Sphynx') {
console.log("Хриплое мяуканье");
}
}
}
class SiameseCat extends Cat {
constructor(public name: string) {
super (name, 'Siamese')
}
}
class PersianCat extends Cat {
constructor(public name: string) {
super (name, 'Persian')
}
}
class SphynxCat extends Cat {
constructor(public name: string) {
super (name, 'Sphynx')
}
}
Этот код работает абсолютно правильно. Такой подход часто можно встретить во многих приложениях. Он выглядит логичным. Каждая кошка издаёт звук. И поэтому почему бы не вынести метод makeSound наверх, чтобы не дублировать логику в дочерних классах. А особенности можно обработать с помощью условий, просто сравнивая тип породы и возвращая определённый тип мяуканья.
Такой подход имеет место быть, но он является не очень хорошим. Он противоречит принципу открытости / закрытости SOLID.
Представим ситуацию, что нам нужно будет добавить ещё одну породу кошек:
abstract class Cat {
protected name: string;
protected breed: string;
constructor(name: string, breed: string) {
this.name = name;
this.breed = breed;
}
makeSound() {
if (this.breed === 'Siamese') {
console.log("Громкое мяу!");
} else if (this.breed === 'Persian') {
console.log("Тихое мурлыканье");
} else if (this.breed === 'Sphynx') {
console.log("Хриплое мяуканье");
} else if (this.breed === 'MaineCoon') {
console.log("Глубокое мурлыканье");
}
}
}
// Тут предыдущие породы...
class MaineCoonCat extends Cat {
constructor(public name: string) {
super (name, 'MaineCoon')
}
}
Для добавления новой породы нам пришлось изменить код метода makeSound. Мы могли допустить ошибку в коде данного метода и мы сломали бы работу метода сразу для всех видов кошек. И чем больше будет видов этих кошек, тем сложнее будет поддерживать код этого метода. Всё потому, что наш код можно расширять только за счёт изменения уже написанного кода. Как же исправить данную ситуацию?
Вот изменённый код:
abstract class Cat {
protected name: string;
protected breed: string;
constructor(name: string, breed: string) {
this.name = name;
this.breed = breed;
}
abstract makeSound(): void;
}
class SiameseCat extends Cat {
constructor(name: string) {
super(name, 'Siamese');
}
makeSound() {
console.log("Громкое мяу!");
}
}
class PersianCat extends Cat {
constructor(name: string) {
super(name, 'Persian');
}
makeSound() {
console.log("Тихое мурлыканье");
}
}
class SphynxCat extends Cat {
constructor(name: string) {
super(name, 'Sphynx');
}
makeSound() {
console.log("Хриплое мяуканье");
}
}
Данный код у нас соответствует принципу открытости / закрытости SOLID. Этот код можно расширять без изменения кода уже написанных сущностей. Если мы добавим новую кошку, то мы просто наследуемся от базового класса и всё. Нам никак не потребуется его код и код других дочерних классов:
abstract class Cat {
protected name: string;
protected breed: string;
constructor(name: string, breed: string) {
this.name = name;
this.breed = breed;
}
abstract makeSound(): void;
}
// Тут предыдущие породы...
class MaineCoonCat extends Cat {
constructor(name: string) {
super(name, 'MaineCoon');
}
makeSound() {
console.log("Громкое мяу!");
}
}
Тут важно понимать, что принцип не запрещает полностью изменять код уже имеющихся сущностей. Если их логика должна быть другой, то такие изменения оправданы, нам придётся вносить изменения в уже готовые модули. Суть принципа в том, что когда мы не хотим изменять поведение, а хотим просто расширить логику приложения, добавив новые сущности, то в этом случае нам нужно сделать всё, что в наших силах, чтобы не поменять уже имеющийся код. И для того, чтобы добиться желаемого, надо на этапе проектирования сделать сущности максимально независимыми друг от друга.
Теперь рассмотрим пример в React:
interface ListItem {
text: string;
}
interface ListProps {
items: Array<ListItem>;
}
const List: React.FC<ListProps> = ({ items }) => (
<ul>
{items.map((item, index) => (
<li key={index}>{item.text}</li>
))}
</ul>
);
const App = () => (
<List items={[{ text: "Hello" }, { text: "World" }]} />
);
Тут мы видим нарушение принципа открытости / закрытости SOLID. Почему? Потому, что в данном компоненте жёстко зашита структура отображаемого элемента списка. Если, например, нам понадобится отобразить список с элементами, которые будут иметь другую структуру, то нам придётся создавать новый компонент списка, либо вносить изменения в текущий, что потенциально приведёт к ошибкам уже работающих и протестированных списков, которые мы создавали ранее.
Давайте перепишем код, чтобы у нас соблюдался наш принцип:
interface ListProps<T> {
items: Array<T>;
renderItem: (item: T, index: number) => React.ReactNode;
}
const List = <T,>({
items,
renderItem
}: ListProps<T>) => {
return <ul>{items.map((item, index) => renderItem(item, index))}</ul>;
};
const App = () => (
<>
<List
items={[{ text: "Hello" }]}
renderItem={(item, index) => (
<li key={index}>
{item.text}
<button>Удалить</button>
</li>
)}
/>
<List
items={[{
id: 1,
text: "Delete me",
buttonText: 'Delete',
onDelete: () => console.log('delete')
}]}
renderItem={(item) => (
<li key={item.id}>
{item.text}
<button>{item.buttonText}</button>
</li>
)}
/>
</>
);
Теперь, как мы видим, компоненту списка List совершенно не важно в каком формате к нему придут данные. Они могут быть абсолютно любыми, что и демонстрируется внутри компонента App. Мы передали два совершенно разных набора данных и два разных метода отображения. Теперь, если нам нужно будет добавить ещё один способ отображения списка, мы можем использовать готовый компонент и написать реализацию метода отображения. Причём, мы никаким образом не будем затрагивать логику компонента List и других отрисованных списков.
Принцип открытости/закрытости SOLID - это очень важный принцип проектирования приложений. Он помогает создавать сущности, которые позволяют расширять функциональность приложения без изменения кода, который был написан ранее, уже протестирован и уже используется. Тем самым, он облегчает процесс поддержки приложений. Он делает наши приложения более стабильными, а внесения изменений более предсказуемыми.