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

Предисловие

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

Сочетается ли с принципами SOLID функциональное программирование?

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

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

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

Докажем это с помощью:

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

Имеются и другие концепции функционального программирования, но пока сконцентрируемся на этих.

Как принципы SOLID трансформируются в функциональном программировании?

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

Интерпретация принципов SOLID: ООП против функционального программирования

Нарушение и соблюдение принципов SOLID на примерах

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

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

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

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

Примеры

Создадим банковский счет для простых задач, вот его ООП-версия:

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

from typing import Tuple

def process_customer_money(account_number: int,
balance: int,
operation: str,
amount: int=0) -> Tuple[int, int]:

if operation == "deposit":
balance += amount
print(f'New balance: {balance} ')


elif operation == "withdraw":
if amount > balance:
raise ValueError("Unfortunately your balance is insufficient for any withdrawals right now...")
balance -= amount
print(f'New balance: {balance} ')


elif operation == "print":
print(f'Account no:{account_number}, Balance: {balance}')


elif operation == "change_account_number":
account_number = amount
print(f'Your account number has changed to "{account_number}" ')

return account_number, balance



process_customer_money(account_number=123, balance=510, operation="withdraw", amount=100)

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

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

Приведем код в соответствие принципу:

from typing import Tuple

def deposit_money(account_number: int, balance: float, amount: int) -> Tuple[int, int]:
return account_number, balance + amount


def withdraw_money(account_number: int, balance: float, amount: int) -> Tuple[int, int]:
if amount > balance:
raise ValueError("Unfortunately your balance is insufficient for any withdrawals right now...")
return account_number, balance - amount


def print_balance(account_number: int, balance: float) -> str:
return f"Account no: {account_number}, New balance: {balance}"


def change_account_number(current_account_number: int, new_account_number: int) -> str:
return f'Your account number has changed to "{new_account_number}" '


# Отображаем результаты
my_account_details = print_balance(account_number=12345678, balance=540.00)
print(my_account_details)

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

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

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

def transfer_money(account_no1:    int, 
balance1: float,
account_no2: int,
balance2: float,
amount: float) -> Tuple[ Tuple[int, float], Tuple[int, float] ]:

account_no1, balance1 = withdraw_money(account_no1, balance1, amount)
account_no2, balance2 = deposit_money(account_no2, balance2, amount)

return (account_no1, balance1), (account_no2, balance2)

А вот и сам перевод:

# Задаем счета
account_1 = (12345678, 850.00)
account_2 = (87654321, 400.00)



# Переводим 100,00 с «account_1» на «account_2»
account_1, account_2 = transfer_money(account_1[0], account_1[1],
account_2[0], account_2[1],
100.00
)



# Отображаем сведения о переводе
print(print_balance(account_1[0], account_1[1]))
print(print_balance(account_2[0], account_2[1]))

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

Account no: 12345678, New balance: 750.0
Account no: 87654321, New balance: 500.0

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

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

Примеры

Создадим робота для обнаружения различных объектов с помощью датчиков, вот его ООП-версия:

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

def detect_object(sensor_type: str) -> None:

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


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


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

detect_object("infrared")

Датчик в операцию робота detect_object добавляется изменением имеющегося кода, то есть этим подходом принцип открытости/закрытости не соблюдается.

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

from typing import Callable

# Создаем функцию высшего порядка, которой принимаются различные датчики
def detect_with_sensor(*sensors: Callable) -> None:
for i, sensor in enumerate(sensors):
print(f'Sensor {i + 1}:')
sensor()


# Обозначаем датчики как функции
def use_temperature_sensor() -> None:
print("Detecting objects using temperature sensor ...")


def use_ultrasonic_sensor() -> None:
print("Detecting objects using ultrasonic sensor ...")


def use_infrared_sensor() -> None:
print("Detecting objects using infrared sensor ...")



# Обнаруживаем объекты с помощью различных датчиков
detect_with_sensor(use_ultrasonic_sensor, use_temperature_sensor)

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

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

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

Добавляем роботу два новых датчика  —  датчик с камерой и датчик обнаружения:

def use_camera_sensor() -> None:
print("Detecting objects using camera sensor ...")


def use_proximity_sensor() -> None:
print("Detecting objects using proximity sensor ...")

И добавляем это к функции высшего порядка:

# Обнаруживаем объекты с помощью различных датчиков 
detect_with_sensor(use_ultrasonic_sensor,
use_temperature_sensor,
use_camera_sensor, # новый датчик с камерой
use_proximity_sensor # новый датчик обнаружения
)

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

Sensor 1:
Detecting objects using ultrasonic sensor ...
Sensor 2:
Detecting objects using temperature sensor ...
Sensor 3:
Detecting objects using camera sensor ...
Sensor 4:
Detecting objects using proximity sensor ...

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

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

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

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

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

Примеры

Вот ООП-версия этого примера:

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

from typing import Callable

def use_household_item(turn_on: Callable[ [], None ],
turn_off: Callable[ [], None ],
change_temperature: Callable[ [], None ]) -> None:
turn_on()
change_temperature()
turn_off()


def turn_on_fridge() -> None:
print("Refrigerator turned on.")


def turn_off_fridge() -> None:
print("Refrigerator turned off.")


def change_temperature_fridge() -> None:
print("Refrigerator temperature changed.")


def turn_on_laptop() -> None:
print("Laptop turned on.")


def turn_off_laptop() -> None:
print("Laptop turned off.")



use_household_item(turn_on_fridge, turn_off_fridge, change_temperature_fridge)


# Принцип нарушается здесь, так как температуру ноутбука поменять нельзя
use_household_item(turn_on_laptop, turn_off_laptop, change_temperature_fridge)

Принцип подстановки Лисков нарушен: чтобы поменять температуру ноутбука, функция change_temperature_fridge передана в функцию use_household_item в качестве третьего аргумента. Хотя температура ноутбуков, в отличие от холодильников, не регулируется. Это чревато ошибкой, ведь функция change_temperature_fridge не запрограммирована на настройку температуры ноутбука, итог  —  неожиданное поведение.

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

from typing import Callable


def use_temperature_controlled_item(turn_on: Callable[ [], None ],
turn_off: Callable[ [], None ],
change_temperature: Callable[ [], None ]) -> None:
turn_on()
change_temperature()
turn_off()


def turn_on_fridge() -> None:
print("Refrigerator turned on.")


def turn_off_fridge() -> None:
print("Refrigerator turned off.")


def change_temperature_fridge() -> None:
print("Refrigerator temperature changed.")


use_temperature_controlled_item(turn_on_fridge, turn_off_fridge, change_temperature_fridge)

Чтобы соблюсти принцип подстановки Лисков, мы создали функцию высшего порядка use_temperature_controlled_item  —  ею принимаются только бытовые приборы, у которых температура регулируется  —  с соответствием сигнатуре функции.

Разберем сигнатуру этой функции.

Функцией use_temperature_controlled_item в качестве аргументов принимаются три другие функции: turn_on_fridge, turn_off_fridge и change_temperature_fridge. Тип каждой из них  —  Callable[ [], None ], то есть:

  1. Ими не принимаются собственные входные аргументы: [].
  2. И не возвращается никаких выходных данных: None.

Принцип подстановки Лисков этой структурой соблюдается: любая заменяемая на turn_on_fridge, turn_off_fridge и change_temperature_fridge функция передается в функцию use_temperature_controlled_item без появления некорректного поведения.

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

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

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

Еще один элемент для регулирования температуры добавляем, не трогая функцию use_temperature_controlled_item:

def turn_on_oven() -> None:
print("Oven turned on.")


def turn_off_oven() -> None:
print("Oven turned off.")


def change_temperature_oven() -> None:
print("Oven temperature changed.")


use_temperature_controlled_item(turn_on_oven, turn_off_oven, change_temperature_oven)

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

Oven turned on.
Oven temperature changed.
Oven turned off.

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

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

  • других функций;
  • переменных;
  • входных параметров.

Примеры

Показателен пример различного поведения животных, вот его ООП-версия:

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

from typing import Callable 

# Создаем функцию высшего порядка для взаимодействия с животными
def interact_with_animal(make_sound: Callable[ [], None ],
swim: Callable[ [], None ],
fly: Callable[ [], None ]) -> None:
make_sound()
swim()
fly()



# 1. Создаем функции для взаимодействия с утками
def make_duck_sound() -> None:
print("Quack! Quack!")


def make_duck_swim() -> None:
print("Duck is now swimming in the water...")


def make_duck_fly() -> None:
print("Duck is now flying in the air...")



# 2. Создаем функции для взаимодействия с котом
def make_cat_sound() -> None:
print("Meow! Meow!")


def make_cat_swim() -> None:
raise NotImplementedError("Cats do not swim!")


def make_cat_fly() -> None:
raise NotImplementedError("Cats do not fly!")




# Взаимодействуем с животными
interact_with_animal(make_duck_sound, make_duck_swim, make_duck_fly)


##### Здесь заставляем кота плавать и летать
interact_with_animal(make_cat_sound, make_cat_swim, make_cat_fly)

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

ERROR!
Quack! Quack!
Duck is now swimming in the water...
Duck is now flying in the air...
Meow! Meow!
Traceback (most recent call last):
File "<string>", line 39, in <module>
File "<string>", line 5, in interact_with_animal
File "<string>", line 30, in make_cat_swim
NotImplementedError: Cats do not swim!

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

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

from typing import Callable 


# Создаем функцию высшего порядка для каждого поведения
def make_animal_sound(make_sound: Callable[ [], None]) -> None:
make_sound()

def make_animal_swim(swim: Callable[ [], None]) -> None:
swim()

def make_animal_fly(fly: Callable[ [], None]) -> None:
fly()



# 1. Создаем функции для взаимодействия с утками
def make_duck_sound() -> None:
print("Quack! Quack!")

def make_duck_swim() -> None:
print("Duck is now swimming in the water...")

def make_duck_fly() -> None:
print("Duck is now flying in the air...")



# 2. Создаем функции для взаимодействия с котом
def make_cat_sound() -> None:
print("Meow! Meow!")



# Взаимодействуем с животными

# 1. Утка
make_animal_sound(make_duck_sound)
make_animal_swim(make_duck_swim)
make_animal_fly(make_duck_fly)

# 2. Кот
make_animal_sound(make_cat_sound)

Теперь разделим функцию interact_with_animal на три отдельных поведения:

  1. make_animal_sound.
  2. make_animal_swim.
  3. make_animal_fly.

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

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

Добавим в кодовую базу собаку:

def make_dog_sound() -> None:
print("Woof! Woof!")


make_animal_sound(make_dog_sound)

Получается такой код:

Woof! Woof!

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

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

В контексте функционального программирования:

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

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

Примеры

Вот ООП-версия этого примера:

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

# Задаем состояние проигрывателя
music_player_state = False



# Выводим состояние музыкального проигрывателя
def display_music_player_state() -> None:
if music_player_state:
print("ON: Music player switched on.")
else:
print("OFF: Music player switched off.")



# Создаем переключатель проигрывателя
def press_music_player_switch() -> None:
global music_player_state
music_player_state = not music_player_state
display_music_player_state()



# Нажимаем на переключатель проигрывателя
press_music_player_switch()
press_music_player_switch()
press_music_player_switch()

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

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

В этом коде display_music_player_state  —  конкретная реализация включения/выключения проигрывателя.

Другая конкретная реализация  —  глобальная переменная music_player_state с логическим значением текущего состояния проигрывателя.

Функция press_music_player_switch зависит от обоих этих объектов, являясь прямым нарушением принципа инверсии зависимостей.

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

from typing import Callable


# Переключаем состояние
def toggle_state(state: bool) -> bool:
return not state



# Отображаем состояние
def display_state(state: bool) -> None:
if state:
print("ON: Music player switched on.")
else:
print("OFF: Music player switched off.")



# Обрабатываем нажатие переключателя
def press_switch(state: bool,
toggle: Callable[ [bool], bool],
display: Callable[ [bool], None] ) -> bool:
new_state = toggle(state)
display(new_state)

return new_state



# Задаем исходное состояние
music_player_state = False



# Нажимаем переключатель
### Здесь принцип инверсии зависимостей соблюдается только в зависимости от внедренных зависимостей
music_player_state = press_switch(music_player_state, toggle_state, display_state)
music_player_state = press_switch(music_player_state, toggle_state, display_state)
music_player_state = press_switch(music_player_state, toggle_state, display_state)

Вот что делается в этом коде:

  • В toggle_state переключается состояние проигрывателя: он включается или выключается. Принимается логическое значение, возвращается противоположное. Например, если задано False, возвращается True, и наоборот.
  • В display_state выводится текущее состояние проигрывателя: тоже принимается логическое значение, а выводится сообщение с указанием, включен проигрыватель или выключен.
  • press_switch  —  кнопка включения/выключения проигрывателя. Принимается три аргумента: текущее состояние проигрывателя, функция переключения, функция отображения. Текущее состояние собирается для передачи в функцию переключения toggle для создания нового состояния, которое затем отображается с помощью функции отображения display.

После инициализации в коде возвращается этот вывод:

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

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

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

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

Вот код, добавленный в текущий скрипт проигрывателя:

from typing import Callable, Tuple

# Добавляем функционал изменения громкости
def change_volume(volume: int, increment_counter: int) -> int:
new_volume = volume + increment_counter

if new_volume < 0:
new_volume = 0
elif new_volume > 100:
new_volume = 100
print(f'New volume: {new_volume} ')

return new_volume


# Добавляем функционал использования проигрывателя
def use_music_player(state: bool,
volume: int,
operations: Callable[ [bool, int], Tuple[bool, int] ]) -> Tuple[bool, int]:
new_state, new_volume = operations(state, volume)

return new_state, new_volume




# Задаем исходные константы
current_state = False
current_volume = 0
# а) увеличиваем операции проигрывателя
def increase_operations(state: bool, volume: int) -> Tuple[bool, int]:
new_state = toggle_state(state)
display_state(new_state)
new_volume = change_volume(volume, 35)

return new_state, new_volume

current_state, current_volume = use_music_player(current_state, current_volume, increase_operations)



# б) уменьшаем операции проигрывателя
def decrease_operations(state: bool, volume: int) -> Tuple[bool, int]:
new_state = toggle_state(state)
display_state(new_state)
new_volume = change_volume(volume, -20)

return new_state, new_volume



# Используем проигрыватель
current_state, current_volume = use_music_player(current_state, current_volume, decrease_operations)

Кода многовато, но он простой:

  • В change_volume меняется громкость от 0 до 100 в зависимости от заданного инкремента, затем выводится новое значение громкости. Значение громкости по умолчанию  —  0, если возвращается отрицательное значение new_volume. То же значение по умолчанию  —  100, если возвращается значение больше 100.
  • use_music_player  —  это функция высшего порядка для использования проигрывателя. Ею также принимается три аргумента, где значениями текущего состояния и громкости заняты первые два параметра, а операции operations  —  чтобы использовать значения текущего состояния и громкости для создания значений нового состояния и громкости  —  передаются в третий.
  • В increase_operations увеличивается текущая громкость, переключается состояние проигрывателя.
  • В decrease_operations уменьшается текущая громкость, переключается состояние проигрывателя.

Реализуя функцию use_music_player с соответственными параметрами, получаем такой вывод:

ON: Music player switched on.
New volume: 35
OFF: Music player switched off.
New volume: 15

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

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

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

Вот преимущества сочетания принципов SOLID с функциональным программированием в инженерии данных:

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

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

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

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

Вот недостатки этого подхода:

  • Снижение производительности и памяти. Рабочими процессами обработки данных снижается производительность и объем памяти при использовании неизменяемых структур данных из-за роста затрат на сопровождение нескольких копий данных.
  • Ограниченные ресурсы. Функциональное программирование  —  по-прежнему молодая парадигма программирования, ее применение с принципами SOLID менее популярно. В итоге здесь возможна нехватка инструментов и ресурсов по сравнению с ООП, особенно в ситуациях, связанных с инженерией данных на Python.

Заключение

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

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

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

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


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

Предыдущая статья4 аспекта, упущенных в большинстве программ по науке о данных.
Следующая статьяКак использовать агенты Hugging Face для решения задач NLP