Объектно-ориентированное программирование

Наследование, super() и переопределение методов

Содержание курса

Антипаттерн: наследование ради данных вместо атрибута

Разобравшись с тем, как наследование работает в правильных случаях, стоит посмотреть на типичную ошибку — когда оно применяется не потому, что есть реальное is-a отношение, а просто чтобы «прикрепить» данные к объекту.

Классический пример — администратор в системе пользователей. Допустим, нужно добавить к пользователю роль. Соблазн выглядит так:

class AdminUser(str):
    pass

admin = AdminUser("alice")
print(admin)         # alice
print(type(admin))   # <class '__main__.AdminUser'>

Здесь AdminUser наследуется от встроенного str — видимо, чтобы сам объект нёс строковое значение (имя или роль). Технически это работает. Но подставим фразу: «AdminUser является строкой» — и сразу становится неловко. AdminUser — это пользователь, а не строка. Строка — это тип данных, которым описывается роль или имя. Связь между AdminUser и str — не is-a, а has-a: пользователь имеет имя, имеет роль.

Правильное решение — обычный класс с атрибутами:

class User:
    def __init__(self, name: str, role: str = "user") -> None:
        self.name = name
        self.role = role

class AdminUser(User):
    def __init__(self, name: str) -> None:
        super().__init__(name, role="admin")

Теперь AdminUser является разновидностью User — это честное is-a отношение. Роль хранится в атрибуте, а не зашита в тип через наследование от str.

Почему исходный вариант хуже на практике:

  • Поменять роль у объекта AdminUser(str) нельзя — str иммутабелен, значение фиксируется при создании. В варианте с атрибутом это одна строчка: user.role = "moderator".
  • isinstance(admin, str) возвращает True — объект ведёт себя как строка везде, где ожидается строка. Это создаёт неожиданные побочные эффекты: функции, принимающие str, молча примут AdminUser, хотя логически это разные вещи.
  • Добавить новые данные или поведение к AdminUser(str) значительно сложнее: str.__init__ не принимает произвольные параметры, и расширение через super() там нетривиально.

Один вопрос помогает сразу поставить диагноз: «Я наследуюсь от X, потому что мой класс является X, или потому что мне нужно хранить X?» Если ответ второй — нужен атрибут, а не родительский класс.

Этот антипаттерн чаще всего возникает при работе со встроенными типами: list, dict, str. Иногда наследование от них действительно оправдано — например, class SortedList(list) расширяет список специфическим поведением вставки, и объект действительно является списком. Но если единственная причина — хранить одно значение определённого типа, атрибут всегда чище и безопаснее.