Предисловие
В части 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 ]
, то есть:
- Ими не принимаются собственные входные аргументы:
[]
. - И не возвращается никаких выходных данных:
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
на три отдельных поведения:
make_animal_sound
.make_animal_swim
.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