🔗 SOLID Principles


LSP — Liskov Substitution Principle

Принцип подстановки Барбары Лисков можно сформулировать так:

  • Производные классы должны быть заменяемы их базовыми классами.

В названии принципа фигурирует фамилия Лисков. Это связано с тем, что принцип впервые сформулировала (используя другую формулировку) Барбара Лисков.

Кажется логичным, что наследующие классы, или подклассы, должны заменять свои базовые, или родительские, классы. Но, конечно, принцип не просто утверждает очевидное. Анализируя его, мы признаём две концептуальные части: сначала речь идёт о производных и базовых классах, потом — о замене.

Производный класс — класс, который расширяет какой-либо другой, базовый класс. Базовый класс может быть конкретным или абстрактным классом, а также интерфейсом.

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

Чтобы убедиться, что мы не запутались, взглянем на простой код:

from abc import ABC, abstractmethod  
  
# Конкретный класс: все методы реализованы, но они могут быть  
# переопределены с помощью производных классов;  
class ConcreteClass:  
   def implementedMethod(self):  
       pass  
  
# Абстрактный класс: некоторые методы должны быть реализованы  
# с помощью производных классов;  
class AbstractClass(ABC):  
   @abstractmethod  
   def abstractMethod(self):  
       pass  
  
   def implementedMethod(self):  
       pass  
  
# Интерфейс: все методы должны быть реализованы  
# с помощью производных классов;  
class AnInterface(ABC):  
   @abstractmethod  
   def someMethod1(self):  
       pass  
  
   @abstractmethod  
   def someMethod2(self):  
       pass

Теперь вы знаете всё о базовых и производных классах. Но что значит «производные классы могут быть заменяемыми»?

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

Слова «вести себя так, как и ожидалось» по-прежнему довольно расплывчаты. Вот почему указывать на нарушения принципа подстановки Лисков может быть довольно сложно. Среди разработчиков даже порой возникают разногласия по поводу того, что считать нарушением этого принципа. Иногда это дело вкуса, а иногда — зависит от самого языка программирования и конструкций объектно ориентированного программирования, которые он предлагает.

Рассмотрим пример нарушения этого принципа.

class Developer:  
   def write_code(self):  
       ...  
  
class Backend(Developer):  
   def configure_server(self):  
       ...  
  
class DevOps(Developer):  
   """Представим, что наш DevOps не умеет писать код."""  
   def monitor_resources(self):  
       ...  
  
   def write_code(self):  
       """Изменяем реализацию, тем самым нарушая LSP."""  
       raise TypeError("DevOps не может писать код.")

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

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

from dataclasses import dataclass  
  
  
@dataclass  
class Position:  
   x: int = 0  
   y: int = 0  
  
   def __str__(self):  
       return f"({self.x}, {self.y})"  
  
  
class Character:  
   """Суперкласс персонажей."""  
   def __init__(self, name: str):  
       self.name = name  
       self.position = Position()  
  
   def move(self, destination: Position):  
       print("{name} двигается с {start} на {end}".format(  
        name=self.name, start=self.position, end=destination  
     ))  
       self.position = destination  
  
  
class Human(Character):  
  """Дочерний класс, соблюдающий логику родителя."""  
  def move(self, destination: Position):  
     print("{name} идёт с {start} на {end}".format(  
        name=self.name, start=self.position, end=destination  
     ))  
     self.position = destination  
  
  def buy(self):  
     """Добавляет свою логику."""  
     print("Купить предмет.")  
  
  
class Dragon(Character):  
  """Дочерний класс, соблюдающий логику родителя."""  
  def move(self, destination: Position):  
     print("{name} летит с {start} на {end}".format(  
        name=self.name, start=self.position, end=destination  
     ))  
     self.position = destination  
  
  def attack(self):  
     """Добавляет свою логику."""  
     print("Извергнуть пламя на противника.")  
  
  
def move(character: Character, destination: Position):  
   """  
   Клиент, который использует Character и его потомков,  
   не замечая разницы.  
   """  
   character.move(destination)

Как видите, функция move может без ошибок работать как с классом Character, так и с его потомками.

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

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

Создание новых классов в соответствии с LSP помогает расширять иерархию классов правильно.


📂 SOLID | Последнее изменение: 26.04.2024 10:02