- PVSM.RU - https://www.pvsm.ru -
В данной статье хочу поделиться своим опытом по абстрактным классам. Думаю это будет полезно как разработчикам, только начинающим свой путь, так и уже имеющим опыт.
В материале можно посмотреть, как изящно связать свойства и абстрактные классы с реализацией принципа DRY .
Для начал кратко пробежимся по определениями и терминам, и далее ответим на вопросы, которые могут логично вытекать из этих определений.
Абстрактные классы - это базовые классы, определяющие каркас с методами, обязательными для реализации в наследниках и служащими для создания интерфейсов, однако сами экземпляры таких классов создать нельзя.
Абстрактные методы - методы с декоратором @abstractmethod, которые обязаны быть реализованы в дочерних классах.
Абстрактный класс может содержать как обычные, так и абстрактные методы.
Свойство - реализуется через декораторы @property (для чтения) и @<name>.setter (для изменения и валидации) обеспечивая инкапсуляцию, делая API удобным, при этом позволяя менять внутреннюю реализацию без изменения внешнего кода.
Давайте представим что мы разрабатываем маленький сервис уведомлений, который отправляет сообщения по двум каналам: email и sms.
В самом начале наш код может выглядеть так:
class Notification(ABC):
def __init__(self, message):
self.message = message
@abstractmethod
def send_message(self):
pass
class EmailNotification(Notification):
def __init__(self, message, email):
super().__init__(message)
self.email = email
def send_message(self):
print(f"Отправка Email на {self.email} с cообщения {self.message }")
class SMSNotification(Notification):
def __init__(self, message, phone_number):
super().__init__(message)
self.phone_number = phone_number
def send_message(self):
print(f"Отправка SMS на номер {self.phone_number}: {self.message} ")
Здесь мы видим, что в абстрактном классе присутствует метод __init__ однако мы помним, что нельзя создать экземпляр абстрактного класса, и отсюда возникает вопрос: можем ли мы определять в абстрактном классе метод __init__ для выноски в него общего атрибута message? Ответ - да. И это будет хорошей практикой. Это позволяет избежать дублирования кода: вместо того чтобы инициализировать одни и те же поля в каждом дочернем классе, мы делаем это один раз в родителе. Пояснить этот момент будет скорее полезно начинающим программистам, ибо он может сбить с толку.
Теперь в нашей бизнес-задаче стоит требование: через сервис sms уведомлений отправлять сообщение длиной не более 100 символов, однако на сервис отправки уведомлений по email такого ограничения нет, и при этом в наших двух сервисах сообщение обязательно должно быть строкой (str) и строка не должна быть пустой.
Из требований выше можно сделать вывод, что для реализации атрибут message необходимо сделать управляемым, для этого нам необходимо использовать декоратор @property, который обеспечивает интерфейс для атрибутов экземпляра класса и разрешает к нему доступ только через определенные методы. Естественно, в нашем случае это будет валидация, то есть предварительная проверка перед присвоением значения.
Здесь предлагаю начать с конца, так как это будет проще. Реализуем абстрактный класс Notification.
Наш исправленный код будет выглядеть так:
class Notification(ABC):
def __init__(self, message):
self.message = message
@property
def message(self):
return self._message
@message.setter
def message (self, value):
if not value:
raise ValueError("Message cannot be empty")
if not isinstance(value, str):
raise TypeError("Message must be type str")
self._message = value
@abstractmethod
def send_message(self):
pass
Некоторые читатели могут заметить странность в коде, почему в __init__ идет присвоение self.message = message без нижнего подчеркивания, однако в @property мы возвращаем self._message с нижним подчеркиванием.
Ведь типичный пример с @property выглядит так:
class Person:
def __init__(self, name):
self._name = name # Используем _ для обозначения "приватного" атрибута
@property
def name(self):
print("Get name...")
return self._name
@name.setter
def name(self, value):
if not isinstance(value, str):
raise TypeError("Name must be type str")
self._name = value
Это будет ясно ниже, когда мы начнем реализовывать требование на ограничение sms. В таких моментах очень важно не использовать конструктор при создании экземпляра класса, если мы используем управляемые атрибуты и @property. Однако в классах наследниках мы используем конструктор. Мы вынуждены это делать. И здесь может нарушится наше бизнес-требование.
Давайте посмотрим на этот подводный камень более внимательно. Для этого сделаем небольшое отступление. Итак, сейчас возьмем наш типичный пример класса Person и создадим его экземпляр.
>>> person = Person(“Ivan”)
>>> person.name
Ivan
>>> person.name = “Alex”
>>> person.name
Alex
Вроде бы все нормально. Помним, у нас есть проверка, что атрибут должен иметь строковый тип, однако если мы воспользуемся конструктором, мы получим следующее:
>>> person = Person(3)
>>> person.name
3
Цифра 3 не может быть именем экземпляра Person, тем не менее мы видим, что объект создан. А это значит, что класс Person должен иметь следующий вид:
class Person:
def __init__(self, name=None):
self._name = name # Используем _ для обозначения "приватного" атрибута
@property
def name(self):
print("Get name...")
return self._name
@name.setter
def name(self, value):
if not isinstance(value, str):
raise TypeError("Name must be type str")
self._name = value
Экземпляр класса правильно создавать таким образом при использовании @property:
>>> person = Person()
>>> person.name = “Alex”
И присваивать атрибут name [1] только после создания объекта. Здесь наглядно показывается правило “доступ только через разрешенный метод”.
Но что нам делать, ведь у нас используется __init__:
class EmailNotification(Notification):
def __init__(self, message, email):
super().__init__(message)
self.email = email
def send_message(self):
print(f"Отправка на {self.email} cообщения: {self.message }")
И соответственно, если мы последуем хрестоматийный примеру:
class Notification(ABC):
def __init__(self, message):
self._message = message
@property
def message(self):
return self._message
@message.setter
def message (self, value):
if not value:
raise ValueError("Message cannot be empty")
if not isinstance(value, str):
raise TypeError("Message must be type str")
self._message = value
@abstractmethod
def send_message(self):
pass
и напишем в конструкторе self._message = message, то наш setter просто не сработает, и мы сможем передать в сообщение пустую строку или числовой тип, а это нарушает одно из требований.
Чтобы это исправить, нам необходимо вызвать setter прямо в конструкторе.
Таким образом, наш конструктор абстрактного класса будет иметь вид:
class Notification(ABC):
def __init__(self, message):
self._message = self.message = message
Код выглядит избыточным, однако я показываю это специально для очевидности цепочки вызовов: сообщение -> вызов message.setter -> валидация -> присвоение.
Теперь сделаем как в начале, просто уберем избыточный self._message.
class Notification(ABC):
def __init__(self, message):
self.message = message
@property
def message(self):
return self._message
@message.setter
def message (self, value):
if not value:
raise ValueError("Message cannot be empty")
if not isinstance(value, str):
raise TypeError("Message must be type str")
self._message = value
@abstractmethod
def send_message(self):
pass
Условно приватный атрибут будет существовать, однако доступ к нему в конструкторе будет дан через @setter.
Для реализации одного из главных принципов разработки Don't Repeat Yourself (не повторяйся) или сокращенно DRY предлагаю воспользоваться переопределением сеттера в родителе.
Для этого воспользуемся изящным приемом, который нам предоставляет python.
class SMSNotification(Notification):
def __init__(self, message, phone_number):
super().__init__(message)
self.phone_number = phone_number
@Notification.message.setter # Переопределяем сеттер родителя
def message(self, value):
Notification.message.fset(self, value) # Выполняем базовую проверку в родителе
if len(value) > 100:
raise ValueError("Ошибка: Текст SMS слишком длинный (макс. 100)!")
self._message = value
def send_message(self):
print(f"Отправка SMS на номер {self.phone_number}: {self.message} ")
class EmailNotification(Notification):
def __init__(self, message, email):
super().__init__(message)
self.email = email
def send_message(self):
print(f"Отправка Email на {self.email} с cообщения {self.message }")
Как видно из кода выше, мы добавили @Notification.message.setter к методу присваивания значения к атрибуту _message. С помощью функции fset мы извлекли из этого свойства ту самую функцию, которую определили в родителе как валидатор и вызвали ее, чтобы базовый класс сделал свою часть работы. Теперь класс, реализующий сервис sms-уведомлений, имеет ограничение на длину сообщения, однако класс email-уведомлений такого ограничения не имеет. При этом в двух классах будет проверка типа и проверка на пустую строку. Что и требовалось.
Благодаря такой конструкции нам не нужно заново писать проверки if not isinstance(value, str), которые уже есть в Notification.
Это можно увидеть на тесте:
if __name__ == "__main__":
print("n--- ТЕСТ 1: Проверка __init__ ---")
try:
# Создаем SMS длиннее 100 символов
long_text = "Это очень длинное сообщение, которое должно вызвать ошибку, " * 5
print(f"Длина текста: {len(long_text)} символов.")
bad_sms = SMSNotification(message =long_text, phone_number="+79911234567")
print("Результат: Объект создан (К сожалению, проверка в __init__ не сработала ❌)")
print(f"Содержимое в памяти: {bad_sms._message[:50]}...")
except ValueError as e:
print(f"Результат: Ошибка поймана! ✅ ({e})")
print("n--- ТЕСТ 2: Проверка валидации при изменении (setter) ---")
try:
# Создаем нормальное SMS
ok_sms = SMSNotification(message ="Привет!", phone_number="+79911234567")
# Пытаемся изменить его на слишком длинное через свойство .message
print("Пытаемся изменить текст через ok_sms.message = ...")
ok_sms.message = "А" * 151
except ValueError as e:
print(f"Результат: Ошибка поймана сеттером! ✅ ({e})")
print("n--- ТЕСТ 3: Проверка валидации при изменении (setter) ---")
try:
print("Пытаемся передать ok_email.message = [1, 2, 3]")
ok_email = EmailNotification(message=[1, 2, 3], email='asdfsd2343245534vsdfsd@bk.com')
except (ValueError, TypeError) as e:
print(f"Результат: Ошибка поймана сеттером! ✅ ({e})")
print("n--- ТЕСТ 4: Проверка валидации при изменении (setter) ---")
try:
print("Пытаемся передать ok_sms.message = [1, 2, 3]")
ok_email = SMSNotification(message=[1, 2, 3], phone_number="+79911234567")
except (ValueError, TypeError) as e:
print(f"Результат: Ошибка поймана сеттером! ✅ ({e})")
Лог теста покажет нам следующее:
--- ТЕСТ 1: Проверка __init__ ---
Длина текста: 300 символов.
Результат: Ошибка поймана! ✅ (Ошибка: Текст SMS слишком длинный (макс. 100)!)
--- ТЕСТ 2: Проверка валидации при изменении (setter) ---
Пытаемся изменить текст через ok_sms.message = ...
Результат: Ошибка поймана сеттером! ✅ (Ошибка: Текст SMS слишком длинный (макс. 100)!)
--- ТЕСТ 3: Проверка валидации при изменении (setter) ---
Пытаемся передать ok_email.message = [1, 2, 3]
Результат: Ошибка поймана сеттером! ✅ (Message must be type str)
--- ТЕСТ 4: Проверка валидации при изменении (setter) ---
Пытаемся передать ok_sms.message = [1, 2, 3]
Результат: Ошибка поймана сеттером! ✅ (Message must be type str)
Полный код примера можно посмотреть здесь [2].
Ради интереса предлагаю читателю самостоятельно посмотреть, что будет, если в конструкторе класса Notification написать self._message = message.
В данной публикации хотелось поделиться своим опытом связывания управляемых атрибутов в абстрактных классах и реализации принципа DRY. Показать подводные камни, которые могут привести к неправильной работе кода программы.
Автор: ASurr
Источник [3]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/python/445481
Ссылки в тексте:
[1] name: http://person.name
[2] здесь: https://pastebin.com/gwYdMN4Q
[3] Источник: https://habr.com/ru/articles/1002538/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1002538
Нажмите здесь для печати.