Принципы SOLID: объяснение человеческим языком

1
7713
views

Перевод статьи «The SOLID Principles of Object-Oriented Programming Explained in Plain English».

Принципы SOLID это пять принципов объектно-ориентированного программирования. Это набор правил и наилучших подходов, которым нужно следовать при создании структуры классов.

Эти пять принципов помогают нам понять необходимость определенных шаблонов проектирования и разобраться в архитектуре ПО в целом. Так что я думаю, что эту тему обязательно должен изучить каждый разработчик.

Из этой статьи вы узнаете все необходимое для начала применения принципов SOLID в ваших проектах.

Мы начнем с небольшого погружения в историю самого термина, а затем перейдем к подробностям: всем «почему?» и «как?» каждого принципа. Рассматривать все это будем на примере создания класса и его постепенного улучшения.

Сбегайте за чашкой кофе или чая, и приступим!

Бэкграунд

Принципы SOLID впервые были представлены знаменитым Дядюшкой Бобом — Робертом Мартином — в его работе «Design Principles and Design Patterns» в 2000 году. Но сам акроним SOLID ввел в оборот Майкл Фезерс, и случилось это несколько позже.

Дядюшка Боб также является автором книг-бестселлеров «Чистый код» и «Чистая архитектура», а кроме того он еще и один из членов «Agile Alliance».

В общем, не удивительно, что все эти концепции чистого кода, объектно-ориентированной архитектуры и шаблонов проектирования как-то связаны и взаимно дополняют друг друга.

Все они служат одной цели:

«Создавать понятный, читаемый, тестируемый код, над которым смогут совместно работать многие разработчики».

Давайте рассмотрим принципы SOLID, а заодно расшифруем акроним:

  • Single Responsibility Principle («Принцип единой ответственности», SRP)
  • Open-Closed Principle («Принцип открытости-закрытости», OCP)
  • Liskov Substitution Principle («Принцип подстановки Барбары Лисков», LSP)
  • Interface Segregation Principle («Принцип разделения интерфейса», ISP)
  • Dependency Inversion Principle («Принцип инверсии зависимостей», DIP)

Принцип единственной ответственности

Принцип единственной ответственности гласит, что класс должен делать какое-то одно действие и, соответственно, для его изменения должна быть только одна причина.

Можно сказать и более «техническим» языком: влиять на спецификацию класса должно только какое-то одно потенциальное изменение в спецификации программы (логика базы данных, логика журналирования и т. п.).

Это означает, что если наш класс — контейнер для данных, скажем, класс Book или Student, и в нем есть какие-то поля, относящиеся к этой сущности, они должны меняться только при изменении модели данных.

Следовать принципу единственной ответственности очень важно. Во-первых, когда несколько разных команд могут работать над одним проектом и редактировать один и тот же класс по многим причинам, это может привести к несовместимости модулей.

Во-вторых, следование этому принципу облегчает контроль версий. Скажем, у нас есть класс для обработки операций базы данных, и мы видим изменение в этом файле в коммитах на GitHub. Если мы последовательно придерживаемся принципа SRP, мы можем быть уверены, что это изменение касается хранения данных или вещей, связанных с базой данных.

Еще пример — конфликты слияния. Они происходят, когда разные команды меняют один и тот же файл. Но если мы следуем SRP, у нас таких конфликтов будет значительно меньше, поскольку файлы могут меняться только по какой-то одной причине. А если конфликты все же будут, их будет легче разрешить.

Распространенные ловушки и антипаттерны

В этом разделе мы рассмотрим некоторые частые ошибки, связанные с принципом единственной ответственности. И, конечно, мы поговорим о том, как такие ошибки исправлять.

Рассмотрим код простой программы для выставления счетов в книжном магазине. Начнем с определения класса Book, который будет использоваться в нашем инвойсе.

class Book {
	String name;
	String authorName;
	int year;
	int price;
	String isbn;

	public Book(String name, String authorName, int year, int price, String isbn) {
		this.name = name;
		this.authorName = authorName;
		this.year = year;
        this.price = price;
		this.isbn = isbn;
	}
}

Это простой класс с несколькими полями, ничего особенного. Я не делаю поля приватными, чтобы нам не пришлось иметь дело с геттерами и сеттерами. Лучше сосредоточимся на логике.

Давайте теперь создадим класс Invoice, где будет содержаться логика для создания инвойса и подсчета общей суммы. Предположим, что наш магазин торгует исключительно книгами.

public class Invoice {

	private Book book;
	private int quantity;
	private double discountRate;
	private double taxRate;
	private double total;

	public Invoice(Book book, int quantity, double discountRate, double taxRate) {
		this.book = book;
		this.quantity = quantity;
		this.discountRate = discountRate;
		this.taxRate = taxRate;
		this.total = this.calculateTotal();
	}

	public double calculateTotal() {
	        double price = ((book.price - book.price * discountRate) * this.quantity);

		double priceWithTaxes = price * (1 + taxRate);

		return priceWithTaxes;
	}

	public void printInvoice() {
            System.out.println(quantity + "x " + book.name + " " +          book.price + "$");
            System.out.println("Discount Rate: " + discountRate);
            System.out.println("Tax Rate: " + taxRate);
            System.out.println("Total: " + total);
	}

        public void saveToFile(String filename) {
	// Creates a file with given name and writes the invoice
	}

}

Вот наш класс Invoice. В нем также содержатся несколько полей, касающихся выставления счета, и три метода:

  • calculateTotal — для подсчета общей суммы,
  • printInvoice — для вывода инвойса в консоль,
  • saveToFile — для записи инвойса в файл.

Прежде чем читать дальше, подумайте, что не так с дизайном этого класса.

Итак, в чем же проблема? Дело в том, что наш класс во многом нарушает принцип единственной ответственности.

Первое нарушение — наш метод printInvoice, содержащий логику вывода. SRP гласит, что наш класс должен иметь единственную причину для изменения. Таковой причиной для нашего класса должно быть изменение системы подсчета.

Но при существующей архитектуре, если мы захотим изменить формат вывода, нам придется изменить наш класс. Вот почему нам не следует смешивать в одном классе логику вывода с бизнес-логикой.

В нашем классе есть еще один метод, нарушающий SRP, — метод saveToFile. Смешивание долгоживущей логики с бизнес-логикой это еще одна распространенная ошибка.

Думайте об этом не только как о записи в файл: это могло бы быть сохранение в базу данных, создание вызова API или еще что-нибудь подобное.

Вы можете спросить, как же нам это все исправить.

Мы можем создать новые классы для вывода и для долгоживущей логики, чтобы нам больше не пришлось изменять класс Invoice по этим причинам.

Мы создаем два класса — InvoicePrinter и InvoicePersistence — и перемещаем туда наши методы.

public class InvoicePrinter {
    private Invoice invoice;

    public InvoicePrinter(Invoice invoice) {
        this.invoice = invoice;
    }

    public void print() {
        System.out.println(invoice.quantity + "x " + invoice.book.name + " " + invoice.book.price + " $");
        System.out.println("Discount Rate: " + invoice.discountRate);
        System.out.println("Tax Rate: " + invoice.taxRate);
        System.out.println("Total: " + invoice.total + " $");
    }
}
public class InvoicePersistence {
    Invoice invoice;

    public InvoicePersistence(Invoice invoice) {
        this.invoice = invoice;
    }

    public void saveToFile(String filename) {
        // Creates a file with given name and writes the invoice
    }
}

Теперь наша структура классов подчинена принципу единственной ответственности. Каждый класс отвечает за какой-то единственный аспект приложения. Отлично!

Принцип открытости-закрытости

Принцип открытости-закрытости требует, чтобы классы были открыты для расширения, но закрыты для модификации.

Под модификацией подразумевается изменение кода существующих классов, а под расширением — добавление нового функционала.

В общем, этот принцип имеет в виду следующее: у нас должна быть возможность добавлять новый функционал, не трогая существующий код класса. Это связано с тем, что когда мы модифицируем существующий код, мы рискуем создать потенциальные баги. Поэтому следует по возможности избегать прикасаться к протестированному и надежному (в целом) коду в продакшене.

Но как же добавить новый функционал, не прикасаясь к классу? Обычно это делается при помощи интерфейсов и абстрактных классов.

Теперь, разобравшись с основами этого принципа, давайте применим его на практике — все в том же приложении для создания инвойсов.

Скажем, пришел начальник и сказал, что инвойсы должны сохраняться в базе данных, чтобы их можно было легко найти. Что ж, это должно быть легко!

Мы создаем базу данных, подключаем ее и добавляем сохраняющий метод в наш класс InvoicePersistence:

public class InvoicePersistence {
    Invoice invoice;

    public InvoicePersistence(Invoice invoice) {
        this.invoice = invoice;
    }

    public void saveToFile(String filename) {
        // Creates a file with given name and writes the invoice
    }

    public void saveToDatabase() {
        // Saves the invoice to database
    }
}

К сожалению, мы (т. е., ленивый разработчик из книжного магазина) не спроектировали классы таким образом, чтобы в будущем они были легко расширяемыми. Поэтому, чтобы добавить новую фичу, нам нужно модифицировать класс InvoicePersistence.

Если бы наш дизайн подчинялся принципу открытости-закрытости, нам бы не понадобилось изменять этот класс.

Итак, будучи ленивым, но смышленым разработчиком из книжного магазина, мы видим проблему дизайна и решаем произвести рефакторинг кода, чтобы он соответствовал принципу открытости-закрытости.

interface InvoicePersistence {

    public void save(Invoice invoice);
}

Мы меняем тип InvoicePersistence на Interface и добавляем метод для сохранения. Таким образом этот метод будет реализован в каждом долгоживущем классе.

public class DatabasePersistence implements InvoicePersistence {

    @Override
    public void save(Invoice invoice) {
        // Save to DB
    }
}
public class FilePersistence implements InvoicePersistence {

    @Override
    public void save(Invoice invoice) {
        // Save to file
    }
}

Теперь наша классовая структура выглядит следующим образом:

Наша долгоживущая логика легко расширяема. Если босс попросит нас добавить еще одну базу данных, причем одна из баз будет MySQL, а другая — MongoDB, мы сможем сделать это с легкостью.

Вы, возможно, думаете, что мы могли бы просто создать несколько классов, обойдясь без интерфейса, и добавить сохраняющий метод в каждый из них.

Но представьте, что мы расширим наше приложение, и у нас появится несколько долгоживущих классов, например, InvoicePersistence, BookPersistence, и мы создадим класс PersistenceManager для управления всеми долгоживущими классами:

public class PersistenceManager {
    InvoicePersistence invoicePersistence;
    BookPersistence bookPersistence;
    
    public PersistenceManager(InvoicePersistence invoicePersistence,
                              BookPersistence bookPersistence) {
        this.invoicePersistence = invoicePersistence;
        this.bookPersistence = bookPersistence;
    }
}

Теперь при помощи полиморфизма мы можем передать в этот класс любой класс, реализующий интерфейс InvoicePersistence. Именно эту гибкость и обеспечивают интерфейсы.

Принцип подстановки Барбары Лисков

Принцип подстановки Барбары Лисков гласит, что подклассы должны заменять свои базовые классы.

Это означает следующее. Если у нас есть класс B, являющийся подклассом класса A, у нас должна быть возможность передать объект класса B любому методу, который ожидает объект класса A, причем этот метод не должен выдать в таком случае какой-то странный output.

Это ожидаемое поведение, потому что когда мы используем наследование, мы предполагаем, что дочерний класс наследует все, что есть у суперкласса. Дочерний класс расширяет поведение, но никогда не сужает его.

Если класс не подчиняется принципу подстановки Барбары Лисков, это приводит к неприятным ошибкам, которые трудно обнаружить.

Принцип Лисков понять легко, но разглядеть в коде трудно. Давайте посмотрим пример.

class Rectangle {
	protected int width, height;

	public Rectangle() {
	}

	public Rectangle(int width, int height) {
		this.width = width;
		this.height = height;
	}

	public int getWidth() {
		return width;
	}

	public void setWidth(int width) {
		this.width = width;
	}

	public int getHeight() {
		return height;
	}

	public void setHeight(int height) {
		this.height = height;
	}

	public int getArea() {
		return width * height;
	}
}

У нас есть простой класс Rectangle и функция getArea, возвращающая площадь прямоугольника.

Мы решили создать другой класс — для квадратов. Вы наверняка знаете, что квадрат это частный случай прямоугольника, в котором ширина равна высоте.

class Square extends Rectangle {
	public Square() {}

	public Square(int size) {
		width = height = size;
	}

	@Override
	public void setWidth(int width) {
		super.setWidth(width);
		super.setHeight(width);
	}

	@Override
	public void setHeight(int height) {
		super.setHeight(height);
		super.setWidth(height);
	}
}

Наш класс Square расширяет класс Rectangle. Мы устанавливаем в конструкторе одинаковое значение высоты и ширины, но мы не хотим, чтобы какой-либо клиент (кто-то, использующий наш класс в своем коде) менял высоту или ширину, нарушая свойство квадрата.

Поэтому мы переопределяем сеттеры, чтобы устанавливать оба свойства при изменении любого из них. Но этим мы нарушили принцип подстановки Лисков.

Давайте создадим основной класс для тестирования функции getArea.

class Test {

   static void getAreaTest(Rectangle r) {
      int width = r.getWidth();
      r.setHeight(10);
      System.out.println("Expected area of " + (width * 10) + ", got " + r.getArea());
   }

   public static void main(String[] args) {
      Rectangle rc = new Rectangle(2, 3);
      getAreaTest(rc);

      Rectangle sq = new Square();
      sq.setWidth(5);
      getAreaTest(sq);
   }
}

Тестировщик в вашей команде создал тестирующую функцию getAreaTest и говорит, что ваша функция getArea не проходит тест для квадратных объектов.

В первом тесте мы создаем прямоугольник с шириной 2 и высотой 3, а затем вызываем getAreaTest. Результат, как и ожидалось, 20. Но когда мы переходим к квадрату, что-то идет не так. Это происходит потому, что вызов функции setHeight в тесте устанавливает также ширину, и результат получается не тот, что ожидался.

Принцип разделения интерфейса

Разделение подразумевает, что вещи нужно хранить отдельно друг от друга, а принцип разделения интерфейса касается (сюрприз!) разделения интерфейсов.

Этот принцип гласит: много клиентоориентированных интерфейсов лучше, чем один интерфейс общего назначения. Клиенты не должны принуждаться к реализации функций, которые им не нужны.

Этот принцип прост как для понимания, так и для применения, так что давайте рассмотрим пример.

public interface ParkingLot {

	void parkCar();	// Decrease empty spot count by 1
	void unparkCar(); // Increase empty spots by 1
	void getCapacity();	// Returns car capacity
	double calculateFee(Car car); // Returns the price based on number of hours
	void doPayment(Car car);
}

class Car {

}

Мы смоделировали очень упрощенную парковку. Это такая парковка, где вы платите почасово. Теперь представьте, что мы хотим реализовать бесплатную парковку.

public class FreeParking implements ParkingLot {

	@Override
	public void parkCar() {
		
	}

	@Override
	public void unparkCar() {

	}

	@Override
	public void getCapacity() {

	}

	@Override
	public double calculateFee(Car car) {
		return 0;
	}

	@Override
	public void doPayment(Car car) {
		throw new Exception("Parking lot is free");
	}
}

Интерфейс нашей парковки состоит из двух частей: логики, касающейся парковки (паркующаяся машина, уезжающая машина, получение информации о вместимости), и логики, касающейся оплаты.

Но это слишком специфично. Из-за этого наш класс FreeParking вынужден реализовывать методы, касающиеся оплаты, а эти методы для него нехарактерны. Давайте разделим интерфейсы.

Теперь мы разделили нашу парковку. С нашей новой моделью мы можем пойти еще дальше: разделить PaidParkingLot, чтоб поддерживать разные типы оплаты.

Теперь наша модель куда более гибкая и расширяемая. Клиентам не нужно реализовывать никакую нерелевантную логику, потому что в интерфейсе парковки мы предоставили только функционал, касающийся самой парковки.

Принцип инверсии зависимостей

Принцип инверсии зависимостей гласит, что наши классы должны зависеть от интерфейсов или абстрактных классов, а не от конкретных классов и функций.

В своей статье (2000 год) Дядюшка Боб подытожил формулировку этого принципа так:

«Если OCP обозначает цель объектно-ориентированной архитектуры, то DIP — основной механизм».

(OCP — принцип открытости-закрытости, а DIP — принцип инверсии зависимостей, — прим. ред. Techrocks).

Эти два принципа, безусловно, связаны друг с другом, и мы уже применяли этот шаблон, когда обсуждали принцип открытости-закрытости.

Нам нужно, чтобы наши классы были открыты для расширения, поэтому мы реорганизовали наши зависимости, чтобы зависеть от интерфейсов, а не от конкретных классов. Наш класс PersistenceManager зависит от InvoicePersistence, а не от классов, реализующих этот интерфейс.

Заключение

В этой статье мы познакомились с историей принципов SOLID, после чего попытались разобраться в «почему?» и «как?» каждого принципа. Мы даже провели рефакторинг простенького приложения для выставления счетов, чтобы его код подчинялся принципам SOLID.

Не забывайте об этих принципах, когда проектируете, пишете и изменяете свой код. Благодаря этому он станет более чистым, расширяемым и тестируемым.

1 КОММЕНТАРИЙ

  1. Принцип единственной ответственности
    The Single Responsibility Principle
    Класс должен отвечать только за что-то одно и всё нужное в себя инкапсулировать.
    Пример: Не надо пихать методы из класса «Животное» в класс «Самолёт».

    Принцип открытости/закрытости
    The Open Closed Principle
    Класс должен быть открыт для расширения, но закрыт для модификации.
    Пример: Если раз написали метод, то больше его нельзя править т.к. классы-потомки уже его используют.

    Принцип подстановки Барбары Лисков
    The Liskov Substitution Principle
    Объекты должны быть заменяемыми на экземпляры их подтипов.
    Пример: Экземпляр класса «Животное» может быть заменим «Кошкой».

    Принцип разделения интерфейса
    The Interface Segregation Principle
    Много мелких специфичных интерфейсов лучше чем один большой общий.
    Пример: Интерфейсы «Поёт», «Говорит», «Крякаяет», «Орёт» лучше чем «Издаёт звук».

    Принцип инверсии зависимостей
    The Dependency Inversion Principle
    Модули или абстракции верхних уровней не должны зависеть от модулей нижних уровней или деталей этих абстракций.
    Пример: Модуль подключения к базе данных не должен зависеть от модуля комментариев. Модуль комментариев должен зависеть от модуля подключения к базе данных.
    Пример: Абстракция «Животное» не должно зависеть от детали «Издаёт звук», должно быть «Издаёт звук» зависит от «Животное»

ОСТАВЬТЕ ОТВЕТ

Please enter your comment!
Please enter your name here