Принципы проектирования SOLID — это наиболее важные принципы проектирования, которые вам необходимо знать для написания чистого кода. Уверенное владение принципами SOLID — незаменимый навык для любого программиста. Они являются основой для разработки других шаблонов проектирования.
В этой статье мы рассмотрим принципы проектирования SOLID на реальных примерах и поймем их важность.
Вместе с полиморфизмом, абстракцией и наследованием принципы SOLID действительно важны для достижения успеха в объектно-ориентированном программировании.
Принципы SOLID важны по нескольким причинам:
Давайте теперь подробно рассмотрим каждый из принципов SOLID на реальных примерах.
S в принципах SOLID означает принцип единой ответственности. Принцип единой ответственности гласит, что у класса должна быть только одна причина для изменений. Это ограничивает количество мест, в которых нам необходимо внести изменения при включении дополнительных требований в наш проект.
У каждого класса должна быть ровно одна причина для изменения.
Например, предположим, что мы разрабатываем банковское приложение на Java, где у нас есть класс SavingsAccount
, который позволяет выполнять базовые операции, такие как дебет, кредит и sendUpdates
. Метод sendUpdate
принимает перечисление с именем NotificationMedium
(например, электронная почта, SMS и т. д.) и отправляет обновление на соответствующем носителе. Мы напишем для этого код, как показано ниже.
public class SavingsAccount { public int balance; public String name; public SavingsAccount(int initialBalance, String name) { this.balance = initialBalance; this.name = name; System.out.println("Created a savings account with balance = " + initialBalance); } public void debit(int amountToDebit) { // debit business logic } public void credit(int amountToCredit) { // credit business logic } public void sendNotification(NotificationMedium medium) { if (medium == NotificationMedium.SMS) { // Send SMS here } else if (medium == NotificationMedium.EMAIL) { // Send Email here } } }
public enum NotificationMedium { SMS, EMAIL }
Теперь, если вы посмотрите на класс SavingsAccount
выше, он может измениться по нескольким причинам:
Если есть какие-либо изменения в основной логике класса SavingsAccount
(например, debit
, credit
и т. д.).
Если банк решит внедрить новое средство уведомлений (скажем, WhatsApp).
Это является нарушением принципа единой ответственности, изложенного в принципах SOLID. Чтобы исправить это, мы создадим отдельный класс, который будет отправлять уведомления.
Давайте проведем рефакторинг приведенного выше кода в соответствии с принципами SOLID.
public class SavingsAccount { public int balance; public String name; public SavingsAccount(int initialBalance, String name) { this.balance = initialBalance; this.name = name; System.out.println("Created a savings account with balance = " + initialBalance); } public void debit(int amountToDebit) { // debit business logic } public void credit(int amountToCredit) { // credit business logic } public void printBalance() { System.out.println("Name: " + name+ " Account Balance: " + balance); } public void sendNotification(Medium medium) { Sender.sendNotification(medium, this); } }
public enum NotificationMedium { SMS, EMAIL }
public class Sender { public static void sendNotification(NotificationMedium medium, SavingsAccount account) { // extract account data from the account object if (medium == NotificationMedium.SMS) { //logic to send SMS here } else if (medium == NotificationMedium.EMAIL) { // logic to send Email here } } }
Теперь, поскольку мы провели рефакторинг кода, если будут какие-либо изменения в NotificationMedium
или формате, мы изменим класс Sender
. Однако если произойдут изменения в базовой логике SavingsAccount
, изменения произойдут и в классе SavingsAccount
.
Это исправляет нарушение, которое мы наблюдали в первом примере.
Принцип Open Close гласит, что мы должны проектировать классы таким образом, чтобы они были открыты для расширения (в случае добавления дополнительных функций), но закрыты для модификации. Закрытие для внесения изменений помогает нам в двух отношениях:
Чтобы увидеть пример принципа открытия и закрытия, давайте рассмотрим пример корзины покупок (например, реализованной на веб-сайтах электронной коммерции).
Я собираюсь создать класс под названием Cart
, который будет содержать список Item
, которые вы можете добавить в него. В зависимости от типа товара и налогообложения мы хотим создать метод, который рассчитывает общую стоимость корзины внутри класса Cart
.
Классы должны быть открыты для расширения и закрыты для модификации.
import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; public Cart() { this.items = new ArrayList<>(); } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { if (item.getItemType() == GIFT) { // 8% tax on gift + 2% gift wrap cost value += (item.getValue()*1.08) + item.getValue()*0.02 } else if (item.getItemType() == ItemType.ELECTRONIC_ITEM) { value += (item.getValue()*1.11); } else { value += item.getValue()*1.10; } } return value; } }
@Getter @Setter public abstract class Item { protected double price; private ItemType itemType; public double getValue() { return price; } }
public enum ItemType { ELECTRONIC, GIFT }
В приведенном выше примере метод calculateCartValue
вычисляет стоимость корзины, перебирая все элементы внутри корзины и вызывая логику в зависимости от типа элемента.
Хотя этот код выглядит правильно, он нарушает принципы SOLID.
Допустим, нам нужно добавить новое правило для другого типа товара (скажем, бакалеи) при расчете стоимости корзины. В этом случае нам придется изменить исходный класс Cart
и написать внутри него еще одно условие else if
, которое проверяет наличие товаров типа Grocery
.
Однако при небольшом рефакторинге мы можем заставить код придерживаться принципа открытия/закрытия. Давайте посмотрим, как это сделать.
Сначала мы сделаем класс Item
абстрактным и создадим конкретные классы для разных типов Item
(ов), как показано ниже.
public abstract class Item { protected double price; public double getValue() { return price; } }
public class ElectronicItem extends Item { public ElectronicItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.11; } }
public class GiftItem extends Item { public GiftItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.08 + super.getValue()*0.02; } }
public class GroceryItem extends Item { public GroceryItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.03; } }
Внутри каждого конкретного класса Item, такого как GroceryItem
, GiftItem
и ElectronicItem
, реализуется метод getValue()
, который содержит бизнес-логику для налогообложения и расчета стоимости.
Теперь мы сделаем класс Cart
зависимым от абстрактного класса Item
и вызовем метод getValue()
для каждого элемента, как показано ниже.
import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; public Cart(Payment paymentOption) { this.items = new ArrayList<>(); } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { value += item.getValue(); } return value; } }
Теперь в этом рефакторинге кода, даже если вводятся новые типы Item
(ов), класс Cart
остаётся неизменным. Из-за полиморфизма, каким бы ни был фактический тип Item
внутри items
ArrayList
, будет вызван метод getValue()
этого класса.
Принцип замены Лискова гласит, что в данном коде, даже если мы заменим объект суперкласса объектом дочернего класса, код не должен сломаться. Другими словами, когда подкласс наследует суперкласс и переопределяет его методы, он должен поддерживать согласованность с поведением метода в суперклассе.
Например, если мы создадим следующие классы Vehicle
и два класса Car
и Bicycle
class. Теперь предположим, что мы создаем метод startEngine()
в классе Vehicle. Его можно переопределить в классе Car
, но он не будет поддерживаться в классе Bicycle
, поскольку Bicycle
нет двигателя (см. пример кода ниже).
Подкласс должен поддерживать согласованность с поведением суперкласса при переопределении методов.
class Vehicle { public void startEngine() { // start engine of the vehicle } } class Car extends Vehicle { @Override public void startEngine() { // Start Engine } } class Bicycle extends Vehicle { @Override public void startEngine(){ throw new UnsupportedOperationException("Bicycle doesn't have engine"); } }
Теперь предположим, что есть некоторый код, который ожидает объект типа автомобиль и использует метод startEngine()
. Если при вызове этого фрагмента кода вместо передачи объекта типа Vehicle
мы передадим объект Bicycle
, это приведет к проблемам в коде. Поскольку метод класса Bicycle
(s) выдаст исключение при вызове метода startEngine()
. Это было бы нарушением принципов SOLID (принцип замены Лискова).
Чтобы решить эту проблему, мы можем создать два класса MotorizedVehicle
и NonMotorizedVehicle
и наследовать Car
от класса MotorizedVehicle
, а Bicycle
— от NonMotorizedVehicle
class Vehicle { } class MotorizedVehicle extends Vehicle { public void startEngine() { // start engine here } } class Car extends MotorizedVehicle { @Override public void startEngine() { // Start Engine } } class NonMotorizedVehicle extends Vehicle { public void startRiding() { // Start without engine } } class Bicycle extends NonMotorizedVehicle { @Override public void startRiding(){ // Start riding without the engine. } }
Буква «I» в «Принципах SOLID» означает принцип разделения интерфейса.
Принцип разделения интерфейсов гласит, что вместо больших интерфейсов, которые заставляют реализующие классы реализовывать неиспользуемые методы, нам следует иметь меньшие интерфейсы и реализовывать классы. Таким образом, классы реализуют только соответствующие методы и остаются чистыми.
Разделите свои интерфейсы на несколько меньших интерфейсов, а не на один большой интерфейс.
Например, давайте посмотрим на встроенную структуру коллекций в Java. Помимо других структур данных, Java также предоставляет структуры данных LinkedList
и ArrayList
.
Класс ArrayList
реализует следующие интерфейсы: Serializable
, Cloneable
, Iterable
, Collection
, List
и RandomAccess
.
Класс LinkedList
реализует Serializable
, Cloneable
, Iterable
, Collection
, Deque
, List
и Queue
.
Это довольно много интерфейсов!
Вместо того, чтобы иметь так много интерфейсов, разработчики Java могли бы объединить Serializable
, Cloneable
, Iterable
, Collecton
, List
и RandomAccess
в один интерфейс, скажем, интерфейс IList
. Теперь оба класса ArrayList
и LinkedList
могли реализовать этот новый интерфейс IList
.
Однако, поскольку LinkedList
не поддерживает произвольный доступ, он мог бы реализовать методы интерфейса RandomAccess
и выдать UnsupportedOperationException
, когда кто-то попытается его вызвать.
Однако это было бы нарушением принципа сегрегации интерфейса в принципах SOLID, поскольку это «заставило бы» класс LinkedList реализовать методы внутри интерфейса RandomAccess
, хотя это и не требуется.
Поэтому лучше разделить интерфейс на основе общего поведения и позволить каждому классу реализовывать множество интерфейсов, а не один большой интерфейс.
Принцип инверсии зависимостей гласит, что классы верхнего уровня не должны напрямую зависеть от классов нижнего уровня. Это вызывает плотную связь между двумя уровнями.
Вместо этого низшие классы должны предоставлять интерфейс, от которого должны зависеть классы верхнего уровня.
Зависите от интерфейсов, а не от классов
Например, давайте продолжим работу с примером Cart
, который мы видели выше, и улучшим его, добавив несколько вариантов оплаты. Предположим, у нас есть два типа способов оплаты DebitCard
и Paypal
. Теперь в классе Cart
мы хотим добавить метод placeOrder
, который будет рассчитывать стоимость корзины и инициировать оплату на основе предоставленного платежа. метод.
Для этого мы могли бы добавить зависимость в приведенном выше примере с Cart
, добавив два варианта оплаты в качестве полей внутри класса Cart
. Однако это будет тесно связывать класс Cart
с классами DebitCard
и Paypal
.
Вместо этого мы бы создали интерфейс Payment
, и классы DebitCard
и Paypal
реализовали бы интерфейсы Payment
. Теперь класс Cart
будет зависеть от интерфейса Payment
, а не от отдельных типов оплаты. Это сохраняет классы слабосвязанными.
См. код ниже.
public interface Payment { void doPayment(double amount); } public class PaypalPayment implements Payment { @Override public void doPayment(double amount) { // logic to initiate paypal payment } } public class DebitCardPayment implements Payment { @Override public void doPayment(double amount) { // logic to initiate payment via debit card } } import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; private Payment paymentOption; public Cart(Payment paymentOption) { this.items = new ArrayList<>(); this.paymentOption = paymentOption; } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { value += item.getValue(); } return value; } public void placeOrder() { this.paymentOption.doPayment(calculateCartValue()); } }
Если вы хотите узнать больше об объектно-ориентированном программировании, шаблонах проектирования GoF и собеседованиях по низкоуровневому дизайну, посетите мой высокорейтинговый курс «Объектно-ориентированное программирование + шаблоны проектирования Java».