SOLID
SOLID является акронимом от следующих пяти принципов:
Принцип единственной ответственности
Класс должен иметь только одну ответственность. (более точная формулировка, звучит так: "Класс должен иметь одну и только одну причину для изменений")
Самый эффективный способ "сломать" приложение — создание божественного класса.
Божественный класс — класс, знающий и делающий слишком много. Этот подход является хорошим примером анти-паттерна
Божественный класс отслеживает большое количество информации и имеет несколько ответственностей. Одна правка кода, с большой вероятностью, может повлиять на другие части класса и опосредованно повлиять на остальные классы, которые используют его. Это приводит к проблемам развития и обслуживания кода, поскольку никто не осмеливается вносить изменения, кроме добавления нового функционала.
Следующий пример представляет класс TypeScript описывающий персону. Этот класс не должен включать валидацию email, так как она не относится к поведению персоны.
class Person {
public name : string;
public surname : string;
public email : string;
constructor(name : string, surname : string, email : string){
this.surname = surname;
this.name = name;
if(this.validateEmail(email)) {
this.email = email;
}
else {
throw new Error("Invalid email!");
}
}
validateEmail(email : string) {
var re = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;
return re.test(email);
}
greet() {
alert("Hi!");
}
}
Мы можем улучшить этот класс путем вынесения ответственности за валидацию email в новый класс Email:
class Email {
public email : string;
constructor(email : string){
if(this.validateEmail(email)) {
this.email = email;
}
else {
throw new Error("Invalid email!");
}
}
validateEmail(email : string) {
var re = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;
return re.test(email);
}
}
class Person {
public name : string;
public surname : string;
public email : Email;
constructor(name : string, surname : string, email : Email){
this.email = email;
this.name = name;
this.surname = surname;
}
greet() {
alert("Hi!");
}
}
Реализация классов с единственной ответственностью, по умолчанию, упрощает его понимание, а также расширение/улучшение.
Принцип открытости/закрытости
Программные сущности должны быть открыты для расширения и закрыты для модификации.
Следующий пример кода является примером кода, написанного без соблюдения принципа открытости/закрытости:
class Rectangle {
public width: number;
public height: number;
}
class Circle {
public radius: number;
}
function getArea(shapes: (Rectangle|Circle)[]) {
return shapes.reduce(
(previous, current) => {
if (current instanceof Rectangle) {
return current.width * current.height;
} else if (current instanceof Circle) {
return current.radius * current.radius * Math.PI;
} else {
throw new Error("Unknown shape!")
}
},
0
);
}
Данный код позволяет нам вычислить площадь двух фигур (прямоугольника и круга). Если мы попытаемся добавить новую фигуру, мы будем расширять программу. Конечно, мы можем добавить поддержку новой фигуры (наше приложение открыто для расширения), проблема в том, что нам понадобится изменять функцию getArea, что говорит о том, что наше приложение также открыто и для модификации.
Для решения данной проблемы, мы можем использовать преимущества полиморфизма в ООП, например так:
interface Shape {
area(): number;
}
class Rectangle implements Shape {
public width: number;
public height: number;
public area() {
return this.width * this.height;
}
}
class Circle implements Shape {
public radius: number;
public area() {
return this.radius * this.radius * Math.PI;
}
}
function getArea(shapes: Shape[]) {
return shapes.reduce(
(previous, current) => previous + current.area(),
0
);
}
Данное решение позволяет нам добавить поддержку новой фигуры (открыто для расширения) без необходимости изменения существующего кода (закрыто для модификации).
Принцип подстановки Барбары Лисков
Объекты в программе должны быть заменяемыми на экземпляры их подтипов без изменения правильности выполнения программы.
Данный принцип, также, призывает нас использовать полиморфизм. В предыдущем примере кода:
function getArea(shapes: Shape[]) {
return shapes.reduce(
(previous, current) => previous + current.area(),
0
);
}
Мы использовали интерфейс Shape для уверенности в том, что наша программа открыта к расширению и закрыта к модификации. Принцип подстановки Барбары Лисков говорит нам, что мы должны иметь возможность передать в функцию getArea экземпляр любого класса, реализующего интерфейс Shape без влияния на работоспособность программы. В языках со статической типизацией, таких как TypeScript, компилятор проверяет корректность имплементации подтипов (например, если в имплементация интерфейса Shape будет отсутствовать метод area, возникнет ошибка компиляции. Это означает, что мы не должны делать много ручной работы для того, чтобы быть уверенными в соответствии кода принципу подстановки Барбары Лисков.
Принцип разделения интерфейса
Много интерфейсов, специально предназначенных для клиентов, лучше, чем один интерфейс общего назначения.
Принцип разделения интерфейсов помогает нам избегать нарушения принципа единственной ответственности и принципа разделения ответственности. Представим, что вы имеете две доменных сущности: Rectangle и Circle. Вы используете эти сущности в доменных сервисах для вычисления их площади и этот подход работает отлично, но только до тех пор, пока не появляется необходимость сериализовать их в одном из инфраструктурных уровней. Мы можем добавить дополнительный метод в интерфейс Shape:
interface Shape {
area(): number;
serialize(): string;
}
class Rectangle implements Shape {
public width: number;
public height: number;
public area() {
return this.width * this.height;
}
public serialize() {
return JSON.stringify(this);
}
}
class Circle implements Shape {
public radius: number;
public area() {
return this.radius * this.radius * Math.PI;
}
public serialize() {
return JSON.stringify(this);
}
}
Наш доменный слой нуждается в методе вычисления площади, но ему нет необходимости ничего знать про сериализацию:
function getArea(shapes: Shape[]) {
return shapes.reduce(
(previous, current) => previous + current.area(),
0
);
}
И, напротив, наш инфраструктурный слой нуждается в методе сериализации, но ничего не знает про вычисление площади:
// ...
return rectangle.serialize();
Проблема в том, что добавление метода serialize
в интерфейс Shape нарушает принципы разделения ответственности и единственной ответственности. Фигура является бизнес-концепцией, а его сериализация — инфраструктурной концепцией. Мы не должны смешивать эти концепции в одном интерфейсе.
Принцип разделения интерфейсов говорит нам, что много клиенто-ориентированных интерфейсов лучше, чем один интерфейс общего назначения, таким образом, мы должны разделить наши интерфейсы:
interface RectangleInterface {
width: number;
height: number;
}
interface CircleInterface {
radius: number;
}
interface Shape {
area(): number;
}
interface Serializable {
serialize(): string;
}
При помощи новых интерфейсов, мы полностью изолируем доменный слой от инфраструктурных концепций.
class Rectangle implements RectangleInterface, Shape {
public width: number;
public height: number;
public area() {
return this.width * this.height;
}
}
class Circle implements CircleInterface, Shape {
public radius: number;
public area() {
return this.radius * this.radius * Math.PI;
}
}
function getArea(shapes: Shape[]) {
return shapes.reduce(
(previous, current) => previous + current.area(),
0
);
}
Теперь в инфраструктурном слое мы можем использовать новый набор сущностей, имеющих функционал сериализации.
class RectangleDTO implements RectangleInterface, Serializable {
public width: number;
public height: number;
public serialize() {
return JSON.stringify(this);
}
}
class CircleDTO implements CircleInterface, Serializable {
public radius: number;
public serialize() {
return JSON.stringify(this);
}
}
Использование нескольких интерфейсов вместо одного интерфейса общего назначения, позволяет избежать нарушения принципов Разделения ответственности (бизнес слой ничего не знает про сериализацию) и Единой ответственности (мы не имеем божественного класса, который знает и о вычислении площади фигур и об их сериализации).
Мы можем спорить о том, что RectangleDTO и Rectangle почти идентичны и был нарушен принцип Не повторяйся (DRY). Мы думаем, что это другой случай. Потому что, эти классы выглядят похожими, но являются выражением разных концепций. Далеко не всегда, похожий код является дублированием.
Даже в случае нарушения принципа DRY, мы будем выбирать между нарушением DRY или SOLID. Мы считаем, что принцип DRY менее важен, нежели принципы SOLID в таком случае.
Принцип инверсии зависимостей
Зависимость на Абстракциях. Нет зависимости на что-то конкретное.
Принцип инверсии зависимостей велит нам всегда стараться использовать в качестве зависимостей интерфейсы, а не конкретные их реализации. Важно понимать, что Инверсия зависимостей и Инъекция зависимостей являются разными понятиями.
К сожалению, принцип инверсии зависимостей представлен в аббревиатуре SOLID буквой D. И всегда к объяснению этого принципа переходят в последнюю очередь, несмотря на то, что он является самым важным в SOLID. Без применения этого принципа, большинство других принципов SOLID применять невозможно. Если мы оглянемся назад и вспомним все принципы, которые мы затронули выше, мы придем к выводу, что использование интерфейсов является ключевым элементом в каждом принципе:
- Зависимость на интерфейсы, которая следует из принципа разделения ответственности, позволяет нам изолировать одни уровни приложения от деталей реализации других и помогает избежать нарушения принципа единственной ответственности.
- Использование интерфейсов позволяет нам заменять одну реализацию другой (принцип подстановки Барбары Лисков).
- С использованием интерфейсов мы можем создавать приложения, открытые к расширению, но закрытые к модификации (принцип открытости/закрытости).
Реализация принципов SOLID в языках программирования. которые не поддерживают интерфейсы или в программных парадигмах, не поддерживающих полиморфизм, является очень неестественным. Например, в JavaScript ES5 или даже ES6, реализация SOLID может быть крайне неестественной. Тем не менее, в TypeScript или Flow это может быть реализовано вполне естественно.