- Принципы SOLID и их применение
- Объекты и их использование
- Использование функций и классов
- Разделение грязной и чистой работы
- Выводы
В современной разработке программного обеспечения важно не только написать рабочий код. Еще нужно сделать так, чтобы его было удобно поддерживать, расширять и масштабировать. Для этого были разработаны принципы SOLID, которые позволяют упорядочить и структурировать код и делать его более понятным и простым в поддержке.
В этом уроке мы рассмотрим несколько примеров применения принципов SOLID и других подходов ООП в реальных задачах.
Принципы SOLID и их применение
SOLID — это акроним, который состоит из пяти основных принципов организации и написания кода:
- SRP — принцип единственной ответственности
- OCP — принцип открытости/закрытости
- LSP — принцип подстановки Барбары Лисков
- ISP — принцип разделения интерфейса
- DIP — принцип инверсии зависимостей
В этом уроке мы сфокусируемся на первом принципе — SRP. Он гласит: «Должна быть только одна причина для изменения класса». Этот принцип является полезным инструментом для улучшения качества и читаемости кода.
Возьмем для примера библиотеку для работы с датами и временем. С чего нужно начать ее проектирование?
Правильно начинать с вариантов использования. Представить себе как будто библиотека уже написана и мы пробуем ей воспользоваться (TDD толкает именно к этому, поэтому оно так мощно работает). Прежде чем мы перейдем к коду, попробуйте ответить на вопрос, так ли нужны классы и ООП для реализации этой библиотеки?
Получение текущего времени — это операция, у которой есть конец и начало. Вопрос, который мы должны себе задать: «Действительно ли нам нужны классы и ООП для реализации этой библиотеки?». Нет, конечно. Для операций достаточно функций. Поэтому наша библиотека в самом простом случае может выглядеть так:
from datetime_lib import get_current_time
current_time = get_current_time()
print(current_time) # => 2023-06-01 13:15:00.001000+00:00
В этом коде мы создаем интерфейс для нашей будущей библиотеки. Здесь не требуются объекты для ее реализации. Операция получения текущего времени выражена через функцию.
Теперь, когда интерфейс библиотеки готов, можно приступать к ее реализации. При этом не важно, как она выполнена внутри. Внутренности останутся внутренностями, и никто про них не узнает. А их размер никогда не станет слишком большим, так как это библиотека для работы с датами и временем.
Это значит, что мы в любой момент можем их переписать. И делать это лучше после, когда накопится опыт поддержки и опыт использования. Только в этом случае появится настоящее понимание того, как лучше структурировать библиотеку внутри.
При этом даже с пониманием принципов SOLID и других методологий ООП, может быть непросто применить их на практике. Поэтому рассмотрим несколько примеров, которые помогут лучше понять, как применять эти принципы в реальных задачах.
Объекты и их использование
В Python, как и во многих других языках, принято использовать классы. Поэтому перепишем наш интерфейс выше на объектный:
from datetime import datetime
class DateTime:
def __init__(self, time=None):
self.time = time
def now(self)
return self.__class__(datetime.now())
def to_iso(self):
return self.time.isoformat()
datetime_instance = DateTime()
print(datetime_instance.now().to_iso()) # => 2023-06-07T20:13:40.432+05:00
Здесь мы используем класс DateTime
для хранения времени. Метод to_iso
преобразует это время в строку в формате ISO. Важно помнить, что применение классов полезно тогда, когда они помогают упростить работу с кодом. Например, когда они позволяют удобно управлять связанными данными и методами.
Однако важно понимать, что дизайн и внутренняя структура класса должны быть обдуманы. Всегда следует применять принцип минимального усилия: если функциональность может быть достигнута с меньшим количеством классов или методов, то следует использовать их. Это помогает уменьшить сложность кода и упрощает его поддержку и расширение.
Например, при создании класса клиента API, состояние класса — это конфигурация, а не конкретные запросы и ответы. Состояние класса обычно меняется редко, поэтому его лучше хранить внутри класса.
Какими принципами нужно руководствоваться, чтобы понять внутреннюю архитектуру и количество классов? Для старта достаточно здравого смысла. У нас есть сам клиент, который представлен объектом (его состояние – это конфигурация), и есть результат функции получения времени.
Дальнейшее разбиение не нужно. Возможно, это не понадобится никогда. А если и понадобится, то сначала нужно почувствовать такую необходимость, а затем уже реализовывать ее. Причем главное основание для такого разделения – это не абстрактная единственная ответственность, а выделение чистого кода, который не связан с побочными эффектами.
Внутри нашей библиотеки может быть код, который взаимодействует с системой, проверяет текущее время, а есть код, который работает с данными, приводит их в нормальный вид, чистит и как-то структурирует. В первую очередь, нужно отслеживать такой код и отделять его на уровне функций или методов. Любая операция, которая может быть чисто вычислительной, потенциальный кандидат на вынесение.
Важно также понимать, что не всегда необходимо делить класс на более мелкие части. Иногда это может усложнить код. Если такое разделение становится необходимым, оно должно быть обосновано конкретными потребностями и проблемами, а не только идеями абстрактных принципов.
Использование функций и классов
Пример выше может быть реальной библиотекой. Например, возьмем библиотеку luxon для работы с датами и временем в JavaScript. Это действительно набор функций:
import { DateTime } from 'luxon';
console.log(DateTime.now().toISO()); // => 2023-04-14T20:13:40.432+05:00
console.log(DateTime.now().toFormat('MMMM dd, yyyy')); // => April 14, 2023
Здесь мы видим два основных подхода к работе со временем. Мы вызываем функцию DateTime.now()
, которая возвращает текущую дату и время. Затем применяем к результату методы toISO()
или toFormat()
, чтобы форматировать результат.
Но если нужно, она позволяет создать объект, но только чтобы мы могли запомнить конфигурацию внутри для избежания дублирования:
import { DateTime } from 'luxon';
const dateTime = new DateTime({ zoneName: 'Asia/Singapore', valid: true });
console.log(dateTime.toISO()); // => 2023-04-14T20:13:40.442+05:00
Здесь мы создаем новый объект DateTime
с определенной конфигурацией, что позволяет избежать дублирования кода. При этом мы создаем объект только тогда, когда это действительно необходимо — для хранения и использования определенной конфигурации.
Такой подход отражает общую идею использования функций и классов: функции используются для выполнения конкретных операций, а классы — для управления связанными данными и методами, включая конфигурацию.
Но в Python не принято создавать классы на все подряд. Вместо этого мы стараемся использовать функции там, где это возможно. И переходим к использованию классов, когда это действительно необходимо. Например, когда нужно управлять состоянием или конфигурацией.
В противном случае использование функций обычно является более простым и эффективным решением.
Когда мы используем классы, важно правильно разделять различные виды работ в рамках одного класса. Это помогает оптимально организовать работу внутри классов.
Разделение грязной и чистой работы
Класс может одновременно выполнять «грязную работу» — с побочными эффектами, и «чистую работу» — обработка данных.
Предположим, у нас есть класс, который отвечает за генерацию отчета. Этот класс одновременно выполняет чтение файла с диска и обрабатывает данные для формирования отчета:
class ReportGenerator:
def generate(self, path):
with open(path, "r") as file:
data = file.read()
report = self._process_data(data)
return report
Здесь мы имеем класс ReportGenerator
, который отвечает за генерацию отчета. Этот класс выполняет две основные функции: он читает данные из файла и затем обрабатывает эти данные для формирования отчета.
В одном классе смешиваются две разные области ответственности: чтение данных из файла и их обработка.
Если мы разделим эти операции, то класс станет более универсальным и более легко тестируемым:
class ReportGenerator:
def generate(self, data):
report = self._process_data(data)
return report
reporter = ReportGenerator()
with open('/path/to/report', 'r') as file:
data = file.read()
report = reporter.generate(data)
В этом примере мы разделяем ответственности класса ReportGenerator
. Теперь он принимает данные напрямую и обрабатывает их для формирования отчета. Загрузка данных теперь происходит вне этого класса. Это делает его более универсальным — он может работать с любыми данными, необязательно загруженными из файла.
Выводы
В этом уроке мы рассмотрели, как использовать абстракции и классы для эффективного структурирования кода. Также мы разобрали важность соблюдения принципа единственной ответственности для обеспечения удобства поддержки и масштабирования кода.
Важно помнить, что здравый смысл и опыт играют ключевую роль в эффективном проектировании кода и правильном применении принципов SOLID.
Остались вопросы? Задайте их в разделе «Обсуждение»
Вам ответят команда поддержки Хекслета или другие студенты
- Статья «Как учиться и справляться с негативными мыслями»
- Статья «Ловушки обучения»
- Статья «Сложные простые задачи по программированию»
- Вебинар «Как самостоятельно учиться»
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.