Вот как расшифровывается акроним SOLID:
- S: Single Responsibility Principle (Принцип единственной ответственности).
- O: Open-Closed Principle (Принцип открытости-закрытости).
- L: Liskov Substitution Principle (Принцип подстановки Барбары Лисков).
- I: Interface Segregation Principle (Принцип разделения интерфейса).
- D: Dependency Inversion Principle (Принцип инверсии зависимостей).
Рассмотри подробнее каждый из принципов.
Single Responsibility Principle (Принцип единственной ответственности)
Класс должен быть ответственен лишь за что-то одно. Если класс отвечает за решение нескольких задач, его подсистемы, реализующие решение этих задач, оказываются связанными друг с другом. Изменения в одной такой подсистеме ведут к изменениям в другой.
Пример:
class Animal {
public Animal(String name){ }
public String getAnimalName() { }
void saveAnimal(a: Animal) { }
}
В соответствии с принципом единственной ответственности класс должен решать лишь какую-то одну задачу. Он же решает две, занимаясь работой с хранилищем данных в методе saveAnimal и манипулируя свойствами объекта в конструкторе и в методе getAnimalName.
Для того чтобы привести вышеприведённый код в соответствие с принципом единственной ответственности, создадим ещё один класс, единственной задачей которого является работа с хранилищем, в частности — сохранение в нём объектов класса Animal:
class Animal {
public Animal(String name) { }
String getAnimalName() { }
}
class AnimalDB {
Animal getAnimal( ) { }
void saveAnimal(Animal a) { }
}
Open-Closed Principle (Принцип открытости-закрытости)
Программные сущности (классы, модули, функции) должны быть открыты для расширения, но не для модификации.
Пример:
Допустим у нас есть класс:
public class Logger {
public void Log(String text) {
// сохранить лог в файле
}
}
public class MessageSender {
private Logger logger;
public MessageSender() {
this.logger = new Logger();
}
public void SendMessage(String message) {
// отправка сообщения
logger.Log("Сообщение ");
}
}
И тоже самое происходит в других классах, которые используют Logger. Такая конструкция вполне жизнеспособна до тех, пока мы не решим записывать лог MessageSender в базу данных. Для этого нам надо создать класс, который будет записывать все логи не в текстовый файл, а в базу данных:
public class DbLogger {
public void Log(String text) {
// сохранить лог в БД
}
}
Теперь мы должны изменить класс MessageSender из-за изменившегося бизнес-требования:
public class MessageSender {
private DbLogger logger;
public MessageSender() {
this.logger = new DbLogger();
}
public void SendMessage(String message) {
// отправка сообщения
logger.Log("Сообщение ");
}
}
Но ведь по принципу единственности ответственности не MessageSender отвечает за логирование, почему изменения дошли и до него? Потому что нарушен наш принцип открытости/закрытости. MessageSender не закрыт для модификации. Нам пришлось его изменить, чтобы поменять способ хранения его логов.
В данном случае защитить MessageSender поможет выделение абстракции. Пусть MessageSender зависит от интерфейса Logger:
public interface Logger {
void Log(String text);
}
public class fileLogger implements Logger {
@Override
public void Log(String text) {
// сохранить лог в файле
}
}
public class DbLogger implements Logger {
@Override
public void Log(String text) {
// сохранить лог в БД
}
}
public class MessageSender {
private Logger logger; // интерфейс
public MessageSender(Logger logger) {
this.logger = logger;
}
public void SendMessage(String message) {
// отправка сообщения
logger.Log("Сообщение ");
}
}
Теперь при создании экземпляра MessageSender мы можем передать ему любую реализацию интерфейса Logger.
Liskov Substitution Principle (Принцип подстановки Барбары Лисков)
Изначальное определение данного принципа, которое было дано Барбарой Лисков в 1988 году, выглядело следующим образом:
"Если для каждого объекта o1 типа S существует объект o2 типа T, такой, что для любой программы P, определенной в терминах T, поведение P не изменяется при замене o2 на o1, то S является подтипом T."
То есть иными словами класс S может считаться подклассом T, если замена объектов T на объекты S не приведет к изменению работы программы.
В общем случае данный принцип можно сформулировать так:
Должна быть возможность вместо базового типа подставить любой его подтип.
Фактически принцип подстановки Лисков помогает четче сформулировать иерархию классов, определить функционал для базовых и производных классов и избежать возможных проблем при применении полиморфизма.
Моделируя систему, мы описываем поведение её компонентов, отношения их друг с другом, а не иерархию. Иерархия — удобный инструмент для моделирования, но иногда она приводит к неправильному описанию поведения.
Классический пример
Представим, что есть класс Rectangle, который описывает прямоугольник:
public class Rectangle {
private double width;
private double height;
Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
public void setWidth(double width) {
this.width = width;
}
public void setHeight(double height) {
this.height = height;
}
public double getAreaOf(){
return this.width * this.height;
}
}
Квадрат — тоже прямоугольник, мы можем использовать наследование, чтобы описать его:
public Square extends Rectangle {
private double width;
private double height;
Rectangle(double size) {
super(size, size)
}
public void setWidth(double width) {
this.width = width;
this.height = width;
}
public void setHeight(double height) {
this.width = width;
this.height = width;
}
}
Теперь при установке ширины объекта Square будет установлена и его длина. То же самое при установке длины. Таким образом гарантируется целостность инвариантов объектов Square. С математической точки зрения они будут оставаться корректными фигурами. Тем не менее, помимо того, что теперь наш код содержит лишние вызовы функций, это еще и страшный выстрел в ногу, и сейчас мы увидим почему.
Предположим, при написании класса Rectangle мы написали простой тест:
public class RectangleTest {
@Test
public void areaTest() {
Rectangle rectangle = new Rectangle();
rectangle.setHeight(5);
rectangle.setWidth(7);
double result = rectangle.perimeter();
assertEquals(35, result);
}
}
Очевидно, что при введении класса Square мы должны написать тесты и для него. Так как класс Square находится в соотношении ISA с классом Rectangle, будет логично предположить, что тесты на Rectangle должны быть справедливы и для Square. А чтобы не дублировать код, мы напишем тесты следующим образом:
public class RectangleTest {
@Test
public void areaTest() {
Rectangle rectangle = initRectangle();
rectangle.setHeight(5);
rectangle.setWidth(7);
int result = rectangle.perimeter();
assertEquals(35, result);
}
protected Rectangle initRectangle() {
return new Rectangle();
}
}
public class SquareTest extends RectangleTest {
@Override
protected Rectangle initRectangle() {
return new Square();
}
}
Очевидно, что тест SquareTest.areaTest провалится, так как результат вызова функции perimeter будет не 24, как написано в тесте, а 28. И тут мы должны задать себе очень важный вопрос: правильно ли написан тест, в котором предполагается, что при изменении длины прямоугольника его ширина не изменяется? Очевидно — да. Наш тест наглядно демонстрирует код, который корректно работает с объектом класса Rectangle, но ломается при работе с объектами класса Square. То есть не для каждого объекта типа Square существует объект типа Rectangle такой, что определённая в терминах Rectangle программа (в данном случае тест) не меняется, если вместо объекта типа Rectangle подставить объект типа Square. Следовательно Square — не подтип Rectangle, следовательно LSP нарушается.
Квадрат — это, конечно, прямоугольник, но вот объект класса Square — это определенно не объект класса Rectangle. Дело в том, что поведение объекта класса Square не согласовано с поведением объекта класса Rectangle. Ведь квадрат ведет себя иначе, чем прямоугольник.
Принцип подстановки Лисков наглядно показывает, что в ООП отношение ISA относится именно к поведению. Причем не к внутреннему, а к внешнему поведению, от которого зависят клиенты. Мы всегда должны помнить об этом, когда говорим про моделирование объектов реального мира в парадигме ООП.
С этой точки зрения важность написания тестов становится еще более очевидной. Ведь тесты — это тоже клиенты нашей модели, которые позволяют проверить ее правильность с точки зрения предположений, которые могут сделать пользователи. Ведь понять, правильна модель или нет, мы можем только через ее использование.
Ну а что тогда делать?
Тут я буду банален: все зависит от задачи. Не существует единственно верной модели на все случаи жизни. Мы всегда должны отталкиваться от требований к нашему программному обеспечению и той функциональности, которую хотим предоставить.
Например, и у квадрата, и у прямоугольника есть площадь и, вероятно, в некоторых частях программы нам нужны её значения независимо от того, с какой фигурой мы работаем. В этом случае легко вынести этот метод в интерфейс Shape и прописать его имплементации в классах Square и Rectangle.
Также и у квадрата, и у прямоугольника есть четыре стороны и четыре угла. Возможно, мы захотим получать координаты этих углов в пространстве. Тогда можно вынести интерфейс либо абстрактный класс Quadrangle с соответствующими методами, он также может пригодиться, если мы захотим ввести в свою программу ромбы, параллелограммы, трапеции и прочее.
Interface Segregation Principle (Принцип разделения интерфейса)
"Создавайте узкоспециализированные интерфейсы, предназначенные для конкретного клиента. Клиенты не должны зависеть от интерфейсов, которые они не используют".
Этот принцип направлен на устранение недостатков, связанных с реализацией больших интерфейсов.
Рассмотрим интерфейс Shape:
interface Shape {
drawCircle();
drawSquare();
drawRectangle();
}
В результате все классы, реализующие этот интерфейс, должны реализовать все методы:
class Circle implements Shape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}
class Square implements Shape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}
class Rectangle implements Shape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}
А если нам понадобится добавить еще один метод в интерфейс?
Выход из ситуации:
interface Shape {
draw();
}
interface Circle {
drawCircle();
}
interface Square {
drawSquare();
}
interface Rectangle {
drawRectangle();
}
interface Triangle {
drawTriangle();
}
class CircleImpl implements Circle {
drawCircle() {
//...
}
}
class SquareImpl implements Square {
drawSquare() {
//...
}
}
class RectangleImpl implements Rectangle {
drawRectangle() {
//...
}
}
class CustomShapeImpl implements Shape {
draw() {
//...
}
}
Dependency Inversion Principle (Принцип инверсии зависимостей)
Объектом зависимости должна быть абстракция, а не что-то конкретное.
- Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.
- Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Классическое применение этого принципа — Spring framework. В рамках Spring framework все модули выполнены в виде отдельных компонентов, которые могут работать вместе. Они настолько автономны, что могут быть быть с такой же легкостью задействованы в других программных модулях помимо Spring framework.
Итоги
Здесь мы рассмотрели пять принципов SOLID, которых следует придерживаться каждому ООП-разработчику. Поначалу это может оказаться непросто, но если к этому стремиться, подкрепляя желания практикой, данные принципы становятся естественной частью рабочего процесса, что оказывает огромное положительное воздействие на качество приложений и значительно облегчает их поддержку.