Райан Лай

Инженер-программист из Лондона. Любит всё, что связано с творчеством и инновациями. Профиль на GitHub: forreya.

Вам когда-нибудь говорили, что вы пишете плохой код? 

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

Один из лучших способов улучшить свой код — освоить принципы проектирования в объектно-ориентированном программировании. Можно сказать, что принципы программирования — это философия кода или руководство, как стать более крутым программистом.

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

Примечание: в своих примерах буду использовать Python, но эти концепции легко можно перенести на другие языки, например, на Java.

1. S в SOLID означает Single Responsibility — единственная ответственность

Принцип гласит: 

Разбивайте код на модули, каждый из которых имеет одну ответственность.

Рассмотрим класс Person, выполняющий не связанные между собой задачи — отправление электронных писем и расчёт налогов.

class Person:
  def __init__(self, name, age):
    self.name = name
    self.age = age
  
    def send_email(self, message):
        # Code to send an email to the person
        print(f"Sending email to {self.name}: {message}")

    def calculate_tax(self):
        # Code to calculate tax for the person
        tax = self.age * 100
        print(f"{self.name}'s tax: {tax}")

Согласно принципу единственной ответственности, мы должны разделить класс Person на несколько классов поменьше.

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

class EmailSender:
    def send_email(person, message):
        # Code to send an email to the person
        print(f"Sending email to {person.name}: {message}")

class TaxCalculator:
    def calculate_tax(person):
        # Code to calculate tax for the person
        tax = person.age * 100
        print(f"{person.name}'s tax: {tax}")

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

2. О или Open/Closed Principle — принцип открытости/закрытости

Принцип предполагает разработку модулей таким образом, чтобы можно было: 

добавлять новые функции, не модифицируя существующий код. 

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

Этот принцип самый трудный для понимания из-за своего противоречивого характера. Лучше рассмотрим его на примере:

class Shape:
    def __init__(self, shape_type, width, height):
        self.shape_type = shape_type
        self.width = width
        self.height = height

    def calculate_area(self):
        if self.shape_type == "rectangle":
            # Calculate and return the area of a rectangle
        elif self.shape_type == "triangle":
            # Calculate and return the area of a triangle

В этом примере класс Shape обрабатывает различные типы фигур прямо в методе calculate_area(). Такое поведение нарушает принцип открытости/закрытости, потому что мы модифицируем существующий код, а не расширяем его.

Дописывание новых условий в будущем принесёт проблемы, потому что по мере добавления новых типов фигур метод calculate_area() усложняется и становится трудным в обслуживании. Также это поведение нарушает принцип разделения ответственности, делает код менее гибким и расширяемым. Рассмотрим один из способов решения этой проблемы.

class Shape:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def calculate_area(self):
        pass

class Rectangle(Shape):
    def calculate_area(self):
        # Implement the calculate_area() method for Rectangle

class Triangle(Shape):
    def calculate_area(self):
        # Implement the calculate_area() method for Triangle

В приведённом выше примере мы определяем основной класс Shape, единственная цель которого — позволить конкретным классам форм наследовать свойства. Например, класс Triangle расширяет метод calculate_area() для вычисления и возврата площади треугольника.

Следуя принципу открытости/закрытости, мы можем добавлять новые фигуры, не изменяя существующий класс Shape. Таким образом мы расширяем функциональность кода без изменения исходного кода самой программной сущности (класса, модуля).

3. L или Liskov Substitution Principle (LSP) — принцип подстановки Лисков

Этот принцип сформулировала создатель Клу и исследователь абстракции данных Барбара Лисков в 1987 году. Его суть:

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

Рассмотрим класс Vehicle [транспортное средство] с методом start_engine() [запуск двигателя].

class Vehicle:
    def start_engine(self):
        pass

class Car(Vehicle):
    def start_engine(self):
        # Start the car engine
        print("Car engine started.")

class Motorcycle(Vehicle):
    def start_engine(self):
        # Start the motorcycle engine
        print("Motorcycle engine started.")

Согласно принципу подстановки Лисков, любой подкласс Vehicle [транспортного средства] также должен иметь возможность запустить двигатель, не вызывая прерывания программы. 

Допустим, мы добавили класс Bicycle [велосипед]. Мы не сможем запустить двигатель, потому что у велосипеда его нет. Вот неправильный способ решения этой проблемы:

class Bicycle(Vehicle):
    def ride(self):
        # Rides the bike
        print("Riding the bike.")

    def start_engine(self):
         # Raises an error
        raise NotImplementedError("Bicycle does not have an engine.")

Чтобы придерживаться принципа LSP, можно пойти двумя путями.

Решение 1. Bicycle становится отдельным классом без наследования, и все подклассы Vehicle согласованы с суперклассом.

class Vehicle:
    def start_engine(self):
        pass

class Car(Vehicle):
    def start_engine(self):
        # Start the car engine
        print("Car engine started.")

class Motorcycle(Vehicle):
    def start_engine(self):
        # Start the motorcycle engine
        print("Motorcycle engine started.")

class Bicycle():
    def ride(self):
        # Rides the bike
        print("Riding the bike.")

Решение 2. Разделим суперкласс Vehicle на два новых суперкласса: один — для транспортных средств с двигателями, а другой — для остальных. Таким образом, все подклассы могут быть заменяемыми на суперклассы без изменения ожидаемого поведения или введения исключений.

class VehicleWithEngines:
    def start_engine(self):
        pass

class VehicleWithoutEngines:
    def ride(self):
        pass

class Car(VehicleWithEngines):
    def start_engine(self):
        # Start the car engine
        print("Car engine started.")

class Motorcycle(VehicleWithEngines):
    def start_engine(self):
        # Start the motorcycle engine
        print("Motorcycle engine started.")

class Bicycle(VehicleWithoutEngines):
    def ride(self):
        # Rides the bike
        print("Riding the bike.")

4. I или Interface segregation — принцип разделения интерфейса

Общее определение гласит, что программные сущности — классы, модули — не должны зависеть от методов, которые они не используют. Звучит двусмысленно. Если говорить более конкретно, то суть такая:

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

Допустим, у нас есть интерфейс Animal [животное] с методами walk() [ходить], swim() [плавать] и fly() [летать]. 

class Animal:
    def walk(self):
        pass

    def swim(self):
        pass

    def fly(self):
        pass

Не все животные могут выполнять перечисленные действия. Например, собаки не умеют плавать или летать, поэтому оба метода swim() и fly(), унаследованные от интерфейса Animal являются избыточными. 

class Dog(Animal):
    # Dogs can only walk
    def walk(self):
        print("Dog is walking.")

class Fish(Animal):
    # Fishes can only swim
    def swim(self):
        print("Fish is swimming.")

class Bird(Animal):
    # Birds cannot swim
    def walk(self):
        print("Bird is walking.")

    def fly(self):
        print("Bird is flying.")

Нам нужно разбить интерфейс Animal на более мелкие, специфические. Их можно использовать для составления точного набора функций для каждого животного.

class Walkable:
    def walk(self):
        pass

class Swimmable:
    def swim(self):
        pass

class Flyable:
    def fly(self):
        pass

class Dog(Walkable):
    def walk(self):
        print("Dog is walking.")

class Fish(Swimmable):
    def swim(self):
        print("Fish is swimming.")

class Bird(Walkable, Flyable):
    def walk(self):
        print("Bird is walking.")

    def fly(self):
        print("Bird is flying.")

Что в итоге? Мы получаем дизайн, в котором классы полагаются только на необходимые им интерфейсы. Уменьшаются лишние зависимости. Эта процедура особенно полезна при тестировании, так как позволяет моделировать только необходимую для модуля функциональность.

5. D или Dependency Inversion — принцип инверсии зависимостей

Интуитивно понятный принцип:

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

Разберём ещё один пример. Предположим, у нас есть класс ReportGenerator, который генерирует отчёты. Чтобы выполнить это действие, ему необходимо извлечь данные из базы данных.

class SQLDatabase:
    def fetch_data(self):
        # Fetch data from a SQL database
        print("Fetching data from SQL database...")

class ReportGenerator:
    def __init__(self, database: SQLDatabase):
        self.database = database

    def generate_report(self):
        data = self.database.fetch_data()
        # Generate report using the fetched data
        print("Generating report...")

В этом примере класс ReportGenerator напрямую зависит от конкретного класса SQLDatabase. 

Пока всё работает нормально, но что, если мы захотим переключиться на другую базу данных, например, на MongoDB? Чтобы внедрить новую базу данных, потребуется внести изменения в класс ReportGenerator. 

Чтобы соблюсти принцип инверсии зависимостей, мы введём абстракцию или интерфейс, от которых зависят и классы SQLDatabase, и классы MongoDatabase.

class Database():
    def fetch_data(self):
        pass

class SQLDatabase(Database):
    def fetch_data(self):
        # Fetch data from a SQL database
        print("Fetching data from SQL database...")

class MongoDatabase(Database):
    def fetch_data(self):
        # Fetch data from a Mongo database
        print("Fetching data from Mongo database...")

Обратите внимание, что класс ReportGenerator также зависит от нового интерфейса Database через его конструктор.

class ReportGenerator:
    def __init__(self, database: Database):
        self.database = database

    def generate_report(self):
        data = self.database.fetch_data()
        # Generate report using the fetched data
        print("Generating report...")

Теперь модуль высокого уровня ReportGenerator не зависит от низкоуровневых модулей SQLDatabase или MongoDatabase. Вместо этого они зависят от интерфейса Database. 

Так работает инверсия зависимостей: модули не зависят от реализации, они знают только то, что примут входные данные и вернут выходные данные.

Заключение

Сегодня можно часто встретить дискуссии о принципах проектирования SOLID и о том, выдержат ли они испытание временем. Актуален ли SOLID в мире мультипарадигмального программирования, облачных вычислений и машинного обучения?

Я считаю, что принципы SOLID — основа хорошего построения кода. Они продвигают модульность, которая лежит в основе современной архитектуры программного обеспечения. В ближайшее время вряд ли что-то изменится.

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


Получите новую специальность или повышение с этими курсами Нетологии:

Для самых внимательных — скидка 10% по промокоду codehabr10.

Комментарии (6)


  1. elzahaggard13
    09.08.2023 13:06
    +1

    Hidden text


  1. MadL1me
    09.08.2023 13:06
    +1

    Я не понимаю, ну вот сколько можно мусолить одну и ту же уже повсюду изученную, заезженную и скучную тему?

    Говорю за себя - мне уже по горло достаточно однотипного контента про solid.

    А самое отвратительное в этом, что вы даже не старались - просто сделали перевод статьи тупо чтобы вставить рекламу курсов


  1. slbeat
    09.08.2023 13:06

    Что делать если не юзаешь ООП?


    1. alexxisr
      09.08.2023 13:06

      тогда остаются буквы S и I - разделять код на модули, минимизировать их зависимости.

      Остальные без наследования/подтипов разве что эмулировать, но тогда вы начнете использовать ООП.


    1. OwezAtajanow
      09.08.2023 13:06

      решение очень простое....учить ооп


  1. anonymous
    09.08.2023 13:06

    НЛО прилетело и опубликовало эту надпись здесь