Принципы SOLID в инженерии данных. Часть 1

SOLID  —  это набор основных принципов процесса разработки ПО, направленных на упрощение чтения, тестирования и сопровождения кода.

Эту концепцию объектно-ориентированного программирования популяризировал Роберт Мартин, которого в сообществе разработчиков называют дядей Бобом.

Как расшифровывается SOLID

Акроним SOLID расшифровывается так:

  • Single responsibility principle («Принцип единственной ответственности»).
  • Open/close principle («Принцип открытости/закрытости»).
  • Liskov substitution principle («Принцип подстановки Лисков»).
  • Interface segregation principle («Принцип разделения интерфейса»).
  • Dependency inversion principle («Принцип инверсии зависимостей).

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

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

Примеры

Продемонстрируем нарушение и соблюдение принципа единственной ответственности, создав простой класс для банковского счета:

а) нарушение принципа:

class BankAccount:
def __init__(self, account_number: int, balance: float):
self.account_number = account_number
self.balance = balance

def deposit_money(self, amount: float):
self.balance += amount

def withdraw_money(self, amount: float):
if amount > self.balance:
raise ValueError("Unfortunately your balance is insufficient for any withdrawals right now ... ")
self.balance -= amount

def print_balance(self):
print(f'Account no: {self.account_number}, Balance: {self.balance} ')

def change_account_number(self, new_account_number: int):
self.account_number = new_account_number
print(f'Your account number has changed to "{self.account_number}" ')

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

б) соблюдение принципа:

А вот пример соблюдения принципа:

class DepositManager:
def deposit_money(self, account, amount):
account.balance += amount


class WithdrawalManager:
def withdraw_money(self, account, amount):
if amount > account.balance:
raise ValueError("Unfortunately your balance is insufficient for any withdrawals right now ... ")
account.balance -= amount


class BalancePrinter:
def print_balance(self, account):
print(f'Account no: {account.account_number}, Balance: {account.balance} ')


class AccountNumberManager:
def change_account_number(self, account, new_account_number):
account.account_number = new_account_number
print(f'Your account number has changed to "{account.account_number}" ')


class BankAccount:
def __init__(self, account_number: int, balance: float):
self.account_number = account_number
self.balance = balance
self.deposit_manager = DepositManager()
self.withdrawal_manager = WithdrawalManager()
self.balance_printer = BalancePrinter()
self.account_number_manager = AccountNumberManager()

def deposit_money(self, amount: float):
self.deposit_manager.deposit_money(self, amount)

def withdraw_money(self, amount: float):
self.withdrawal_manager.withdraw_money(self, amount)

def print_balance(self):
self.balance_printer.print_balance(self)

def change_account_number(self, new_account_number: int):
self.account_number_manager.change_account_number(self, new_account_number)

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

в) пример расширения кодовой базы:

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

class BalancePrinter:
def print_balance(self, account):
print(f'Account no: {account.account_number}, Balance: ${account.balance} ')

....

bank_account = BankAccount(12345678, 100.75)
bank_account.print_balance()

В итоге получается:

Account no: 12345678, Balance: $100.75

2. Принцип открытости/закрытости

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

Проясним это на примерах.

Примеры

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

а) нарушение принципа:

Продемонстрируем нарушение принципа открытости/закрытости:

class Robot:
def __init__(self, sensor_type):
self.sensor_type = sensor_type

def detect(self):
if self.sensor_type == "temperature":
print("Detecting objects using temperature sensor ... ")

elif self.sensor_type == "ultrasonic":
print("Detecting objects using ultrasonic sensor ... ")

elif self.sensor_type == "infrared":
print("Detecting objects using infrared sensor ... ")

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

Для добавления роботу датчиков оптимизации функции обнаружения объектов, согласно этому подходу, требуется отредактировать класс Robot. Это сложно, если в классе содержится несколько строк кода. Ведь, чтобы исключить неожиданности в работе робота, нужно выполнить несколько модульных тестов для класса Robot, в чем легко ошибиться или пропустить тест, особенно когда в нем содержатся тысячи строк кода, которые со временем постоянно изменяются.

Поэтому расширение кода без появления багов  —  сложная задача при таком подходе.

б) соблюдение принципа:

Рассмотрим возможное решение:

from abc import ABC, abstractmethod

class Sensor(ABC):
@abstractmethod
def detect(self):
pass


class TemperatureSensor(Sensor):
def detect(self):
print("Detecting objects using temperature sensor ... ")


class UltrasonicSensor(Sensor):
def detect(self):
print("Detecting objects using ultrasonic sensor ... ")


class InfraredSensor(Sensor):
def detect(self):
print("Detecting objects using infrared sensor ... ")

Мы сделали абстрактный объект Sensor с помощью декоратора @abstractmethod для создания производных классов или подклассов, то есть различных типов добавляемых роботу датчиков:

  1. Температурного TemperatureSensor.
  2. Ультразвукового UltrasonicSensor.
  3. Инфракрасного InfraredSensor.

Чтобы внедрить функционал главного класса Sensor для выражения уникальных реализаций того же метода detect() на основе их отличительных поведений, в этих подклассах применяются наследование и полиморфизм.

Создаем класс Robot:

class Robot:
def __init__(self, *sensor_types):
self.sensor_types = sensor_types


def detect(self):
for sensor_type in self.sensor_types:
sensor_type.detect()



temperature_sensor = TemperatureSensor()
ultrasonic_sensor = UltrasonicSensor()
infrared_sensor = InfraredSensor()

robot = Robot(temperature_sensor, ultrasonic_sensor, infrared_sensor)
robot.detect()

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

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

в) пример расширения кодовой базы:

Заменим имеющуюся сенсорную технологию популярными в отрасли новейшими датчиками с камерой и датчиками обнаружения, добавив датчики в качестве подклассов родительского класса Sensor:

class CameraSensor(Sensor):
def detect(self):
print("Detecting objects using new camera sensor ...")

class ProximitySensor(Sensor):
def detect(self):
print("Detecting objects using new proximity sensor ...")

...

camera_sensor = CameraSensor()
proximity_sensor = ProximitySensor()

robot = Robot(camera_sensor, proximity_sensor)
robot.detect()

В итоге получается:

Detecting objects using new camera sensor ...
Detecting objects using new proximity sensor ...

Вот и все. Не пришлось удалять или менять какой-либо код: для соответствия этим требованиям просто добавили два новых класса, CameraSensor и ProximitySensor, затем для выполнения той же задачи обнаружения объектов  —  на этот раз с новыми датчиками  —  считали их в переменную robot.

3. Принцип подстановки Лисков

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

Примеры

Нарушение и соблюдение этого принципа продемонстрируем на бытовых приборах:

а) нарушение принципа:

class HouseholdItem:
def __init__(self):
pass

def turn_on(self):
pass

def turn_off(self):
pass

def change_temperature(self):
pass



class Oven(HouseholdItem):
def __init__(self):
pass

def turn_on(self):
print("Oven turned on. ")

def turn_off(self):
print("Oven turned off. ")

def change_temperature(self):
print("Oven temperature changed. ")



class Lamp(HouseholdItem):
def __init__(self):
pass

def turn_on(self):
print("Lamp turned on. ")

def turn_off(self):
print("Lamp turned off. ")

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

б) соблюдение принципа:

Код выше исправляется так:

from abc import ABC, abstractmethod


class HouseholdItem(ABC):
def __init__(self):
pass

@abstractmethod
def turn_on(self):
pass

@abstractmethod
def turn_off(self):
pass


class TemperatureControlledHouseholdItem(HouseholdItem):
@abstractmethod
def change_temperature(self):
pass

Вот что мы создали:

  • Абстрактный класс HouseholdItem с определением внутри него двух абстрактных методов, turn_on и turn_off, которые должны быть у всех бытовых приборов.
  • Подкласс для бытовых приборов TemperatureControlledHouseholdItem, спроектированный с настройками температурного режима, которыми наследуются абстрактные методы из класса HouseholdItem. Чтобы и дальше соблюдался принцип подстановки Лисков, внутри подкласса добавляется пользовательский абстрактный метод change_temperature.

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

class Oven(TemperatureControlledHouseholdItem):
def __init__(self):
pass

def turn_on(self):
print("Oven turned on. ")

def turn_off(self):
print("Oven turned off. ")

def change_temperature(self):
print("Oven temperature changed. ")


class Lamp(HouseholdItem):
def __init__(self):
pass

def turn_on(self):
print("Lamp turned on. ")

def turn_off(self):
print("Lamp turned off. ")


appliances = [Oven(), Lamp()]
for appliance in appliances:
appliance.turn_on()
if isinstance(appliance, TemperatureControlledHouseholdItem):
appliance.change_temperature()
appliance.turn_off()

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

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

в) пример расширения кодовой базы:

Добавим холодильник и ноутбук:

class Refrigerator(TemperatureControlledHouseholdItem):
def __init__(self):
pass

def turn_on(self):
print("Refrigerator turned on. ")

def turn_off(self):
print("Refrigerator turned off. ")

def change_temperature(self):
print("Refrigerator temperature changed. ")



class Laptop(HouseholdItem):
def __init__(self):
pass

def turn_on(self):
print("Laptop turned on. ")

def turn_off(self):
print("Laptop turned off. ")


...
appliances = [Oven(), Lamp(), Refrigerator(), Laptop()]
...

Температура холодильника регулируется, поэтому его интерфейсом наследуется подкласс TemperatureControlledHouseholdItem. А вот у ноутбука этот функционал в современном мире не обязательно доступен, поэтому пока им унаследуется простой класс HouseholdItem.

Вот вывод:

Oven turned on. 
Oven temperature changed.
Oven turned off.
Lamp turned on.
Lamp turned off.
Refrigerator turned on.
Refrigerator temperature changed.
Refrigerator turned off.
Laptop turned on.
Laptop turned off.

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

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

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

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

Примеры

а) нарушение принципа:

Вот пример кода с нарушением этого принципа:

class Animal:
def swim(self):
pass

def fly(self):
pass

def make_sound(self):
pass



class Duck(Animal):
def swim(self):
print("Duck is now swimming in the water...")

def fly(self):
print("Duck is now flying in the air...")

def make_sound(self):
print("Quack! Quack!")



class Dog(Animal):
def swim(self):
raise NotImplementedError("Dogs can't swim ... ")

def fly(self):
raise NotImplementedError("Dogs can't fly ....")

def make_sound(self):
print("Woof! Woof!")

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

б) соблюдение принципа:

Вот как стоит поступить с такими разными животными:

from abc import ABC, abstractmethod

class SwimmingAnimal:
@abstractmethod
def swim(self):
pass


class FlyingAnimal:
@abstractmethod
def fly(self):
pass


class VocalAnimal:
@abstractmethod
def make_sound(self):
pass

Мы разделили их на три абстрактных класса, или интерфейса: плавающие SwimmingAnimal, летающие FlyingAnimal и издающие звук VocalAnimal, обозначив каждый внутренний метод как абстрактный @abstractmethod.

class Duck(SwimmingAnimal, FlyingAnimal, VocalAnimal):
def swim(self):
print("Duck is now swimming in the water...")

def fly(self):
print("Duck is now flying in the air...")

def make_sound(self):
print("Quack! Quack!")


class Dog(VocalAnimal):
def make_sound(self):
print("Woof! Woof!")

Подклассом Duck от родительских классов SwimmingAnimal, FlyingAnimal и VocalAnimal наследуются абстрактные методы. Внутри унаследованных методов объекта Duck ими явно определяются поведения, свойственные уткам.

Та же логика применяется и к классу Dog, но класс VocalAnimal здесь наследуется вместе со встроенным методом make_sound.

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

в) пример расширения кодовой базы:

Без проблем добавляем и других животных  —  кошек, дельфинов, лебедей:

class Cat(VocalAnimal):
def make_sound(self):
print("Meow! Meow!")


class Dolphin(SwimmingAnimal, VocalAnimal):
def swim(self):
print("Dolphin is now swimming in the water...")

def make_sound(self):
print("Whistle! Squeak!")


class Swan(SwimmingAnimal, FlyingAnimal, VocalAnimal):
def swim(self):
print("Swan is now swimming in the water...")

def fly(self):
print("Swan is now flying in the air...")

def make_sound(self):
print("Honk? Hiss?")


...
cat = Cat()
dolphin = Dolphin()
swan = Swan()


cat.make_sound()
dolphin.swim()
swan.fly()
swan.make_sound()

В итоге получается:

Meow! Meow!
Dolphin is now swimming in the water...
Swan is now flying in the air...
Honk? Hiss?

При их добавлении в кодовую базу имеющийся код не изменился.

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

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

Примеры

Создадим в демонстрационных целях экземпляр электромобиля и его двигателя.

а) нарушение принципа:

Вот пример кода с нарушением этого принципа:

class ElectricCar:
def switch_on(self):
print("ON: Car switched on.")

def switch_off(self):
print("OFF: Car switched off.")


class ElectricVehicleEngine:
def __init__(self, vehicle: ElectricCar):
self.vehicle = vehicle
self.engine_active = False

def press_engine_switch(self):
if self.engine_active:
self.vehicle.switch_off()
self.engine_active = False
else:
self.vehicle.switch_on()
self.engine_active = True

б) соблюдение принципа:

В этом случае принцип соблюдается так:

from abc import ABC, abstractmethod

class SwitchableObject(ABC):
@abstractmethod
def press_switch(self):
pass


class ElectricCar(SwitchableObject):
def __init__(self):
self.switch_state = False

def press_switch(self):
if self.switch_state:
self.switch_state = False
print("OFF: Car switched off.")
else:
self.switch_state = True
print("ON: Car switched on.")


class ElectricVehicleEngine(SwitchableObject):
def __init__(self, switchable: SwitchableObject):
self.switchable = switchable
self.engine_active = False

def press_switch(self):
if self.engine_active:
self.switchable.press_switch()
self.engine_active = False
else:
self.switchable.press_switch()
self.engine_active = True
  • Абстрактным классом SwitchableObject представлены все объекты с переключателем или кнопкой для включения/выключения. Им создается единственный абстрактный метод press_switch, готовый к реализации в последующих производных классах.
  • Производный класс ElectricCar  —  конкретная реализация класса SwitchableObject для электромобилей.
  • Другой производный класс ElectricVehicleEngine  —  конкретная реализация класса SwitchableObject для двигателей электромобилей, в которой принимается аргумент конструктора SwitchableObject. То есть переключаемый объект необходимо включить во входной параметр при инициализации класса ElectricVehicleEngine в объект. Например, вот так:
electric_car           =   ElectricCar()
electric_car_engine = ElectricVehicleEngine(electric_car)


electric_car_engine.press_switch()
electric_car_engine.press_switch()
electric_car_engine.press_switch()

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

в) пример расширения кодовой базы:

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

Повторим эту логику, добавив в автомобиль проигрыватель:

class MusicPlayer(SwitchableObject):
def __init__(self):
self.switch_state = False


def press_switch(self):
if self.switch_state:
self.switch_state = False
print("OFF: Music player switched off.")
else:
self.switch_state = True
print("ON: Music player switched on.")


class MusicPlayerSwitch(SwitchableObject):
def __init__(self, switchable: SwitchableObject):
self.switchable = switchable
self.music_player_active = False

def press_switch(self):
if self.music_player_active:
self.switchable.press_switch()
self.music_player_active = False
else:
self.switchable.press_switch()
self.music_player_active = True


...
music_player = MusicPlayer()
music_player_switch = MusicPlayerSwitch(music_player)
...


music_player_switch.press_switch()
music_player_switch.press_switch()
music_player_switch.press_switch()

Здесь MusicPlayer и MusicPlayerSwitch аналогичны логике интерфейсов ElectricCar и ElectricVehicleEngine, в программе возвращается следующее:

ON: Car switched on.
OFF: Car switched off.
ON: Car switched on.
ON: Music player switched on.
OFF: Music player switched off.
ON: Music player switched on.

Благодаря добавленным интерфейсам, код по мере роста программы становится удобным для восприятия и простым в сопровождении.

Преимущества: почему в инженерии данных нужны принципы SOLID

Стоит подумать об их применении, если создаете платформы обработки данных или конвейеры данных со:

  1. сложным управлением состояниями;
  2. требованиями частых обновлений.

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

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

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

Недостатки: почему принципы SOLID в инженерии данных не нужны

Принципы SOLID  —  плохая идея, если создаете только конвейеры данных, особенно небольшие или быстрые прототипы.

Чтобы внедрение этих принципов проектирования было стоящим, необходимо более широкое видение рабочих процессов обработки данных. И вот почему:

  • Принципы SOLID сопряжены с излишними сложностями, которые легко избежать применением реализаций попроще, но столь же эффективных.
  • В некоторых шаблонах проектирования производительность приоритетнее долгосрочной сопровождаемости, отчего концепции SOLID менее применимы.
  • В экспериментальных средах предпочтение отдается свободе быстрых итераций, которая в ООП в краткосрочной перспективе может не предоставляться.

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

Заключение

Понимая достоинства и недостатки каждого из этих принципов, вы сможете применять ООП и функциональное программирование в одних и тех же проектах, но в разных ситуациях.

В части 2 рассмотрим реальный конвейер данных, созданный на принципах проектирования SOLID в Python.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Stephen David-Williams: SOLID Principles in Data Engineering — Part 1

Предыдущая статья5 неочевидных истин науки о данных
Следующая статьяБольшой языковой модели недостаточно: внедрение Context Fusion & Toolkit в корпоративные решения. Часть 1