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

В материале можно посмотреть, как изящно связать свойства  и абстрактные классы с реализацией принципа 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 только после создания объекта. Здесь наглядно показывается правило “доступ только через разрешенный метод”.

Но что нам делать, ведь у нас используется __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.

Принцип DRY.

Для реализации одного из главных принципов разработки 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)

Полный код примера можно посмотреть здесь.

Ради интереса предлагаю читателю самостоятельно посмотреть, что будет, если в конструкторе класса Notification  написать self._message = message.

Заключение

В данной публикации хотелось поделиться своим опытом связывания управляемых атрибутов в абстрактных классах и реализации принципа DRY. Показать подводные камни, которые могут привести к неправильной работе кода программы.