$ sudo teach IT
МОДУЛЬ 12 · УРОК 2

Типичные ошибки в ООП на Python

Знать синтаксис — одно. Писать хороший ООП-код — другое. В этом уроке разберём 10 самых частых ловушек, в которые попадают начинающие разработчики. Для каждой ошибки покажем: как выглядит плохой код, почему он сломан и как написать правильно.

⏱ ~50 минут 🐛 Анти-паттерны 🐍 Python 3 ⚠️ Ловушки ✅ Лучшие практики

Когда начинающий разработчик только изучает ООП, он сосредоточен на том, как написать код — чтобы он хотя бы работал. Когда он набирает опыт, фокус смещается: как написать хорошо. Плохой ООП-код часто работает, но через месяц его невозможно поддерживать, расширять и понимать.

💡 Как читать этот урок: каждая ошибка — отдельный раздел с двумя примерами. Красный блок — плохой код. Зелёный блок — правильный код. После примера — объяснение почему. Читайте внимательно: некоторые ловушки очень коварны и внешне выглядят нормально.

🙈 Ошибка 1: God-class — «класс который делает всё»

God-class (или «класс-бог») — это класс, который разросся до огромных размеров и взял на себя слишком много ответственности. Тысяча строк, десятки методов, он и работу с базой данных делает, и письма отправляет, и интерфейс рисует. Это нарушение принципа SRP (Single Responsibility Principle) — принципа единственной ответственности: каждый класс должен делать одну вещь.

Признаки God-class:

📏 Больше 300–500 строк

Нет жёсткого правила, но файл в 1000+ строк — тревожный сигнал.

🤹 Несвязанные методы

send_email(), save_to_db(), render_html() — в одном классе.

😵 Сложно тестировать

Чтобы протестировать один метод, нужно настраивать базу данных, почту и всё остальное.

🔗 Всё зависит друг от друга

Изменить один метод — рискуешь сломать другой, совершенно не связанный.

❌ Плохо: один класс на все случаи жизни

Класс OrderManager знает про пользователей, товары, почту, базу данных и доставку — это слишком много.

class OrderManager:
    def __init__(self):
        self.db_connection = connect_to_db()   # база данных
        self.smtp_client = SMTPClient()        # почта
        self.logger = Logger()                 # логи
        self.users = []
        self.products = []
        self.orders = []

    # Работа с пользователями
    def create_user(self, name, email): ...
    def delete_user(self, user_id): ...
    def authenticate_user(self, email, password): ...
    def reset_password(self, email): ...

    # Работа с товарами
    def add_product(self, name, price): ...
    def update_stock(self, product_id, quantity): ...
    def get_recommendations(self, user_id): ...

    # Работа с заказами
    def create_order(self, user_id, items): ...
    def cancel_order(self, order_id): ...

    # Отправка писем
    def send_order_confirmation(self, order): ...
    def send_promo_email(self, user, discount): ...

    # Логистика
    def assign_courier(self, order_id): ...
    def track_delivery(self, order_id): ...
    def calculate_shipping_cost(self, address): ...

    # Отчёты
    def generate_monthly_report(self): ...
    def export_to_excel(self): ...
    # ... ещё 50 методов

✅ Хорошо: каждый класс — одна ответственность

Разбиваем на маленькие специализированные классы. Каждый делает одно дело и делает это хорошо.

class UserService:
    """Только работа с пользователями."""
    def create_user(self, name, email): ...
    def authenticate(self, email, password): ...
    def reset_password(self, email): ...


class ProductCatalog:
    """Только каталог товаров."""
    def add_product(self, name, price): ...
    def update_stock(self, product_id, quantity): ...


class OrderService:
    """Только заказы. Использует другие сервисы через инъекцию зависимостей."""
    def __init__(self, user_service, catalog, email_service):
        self.users = user_service
        self.catalog = catalog
        self.email = email_service

    def create_order(self, user_id, items):
        user = self.users.get(user_id)
        # ... создать заказ ...
        self.email.send_confirmation(user, order)


class EmailService:
    """Только отправка почты."""
    def send_confirmation(self, user, order): ...
    def send_promo(self, user, discount): ...


class ShippingService:
    """Только доставка."""
    def assign_courier(self, order_id): ...
    def calculate_cost(self, address): ...

Теперь каждый класс — 50–100 строк. Его легко читать, тестировать и изменять независимо от остальных.

🏗️ Ошибка 2: Глубокая иерархия наследования (4+ уровня)

Наследование — мощный инструмент, но как только цепочка наследования уходит на 4–5 уровней вглубь, всё начинает разваливаться. Становится невозможно понять, какой метод откуда берётся. Изменение в базовом классе «прилетает» через несколько уровней и ломает что-то неожиданное. Разработчики называют это «хрупкий базовый класс».

❌ Плохо: 5 уровней наследования

class LivingThing:           # уровень 1
    def breathe(self): ...

class Animal(LivingThing):   # уровень 2
    def eat(self): ...

class Mammal(Animal):        # уровень 3
    def feed_young(self): ...

class Predator(Mammal):      # уровень 4
    def hunt(self): ...

class Cat(Predator):         # уровень 5
    def purr(self): ...

class DomesticCat(Cat):      # уровень 6 — стоп!
    def use_litter_box(self): ...

# Чтобы понять DomesticCat, нужно читать 6 классов.
# Где определён метод eat()? В уровне 2?
# А вдруг Predator его переопределил? А Mammal?

✅ Хорошо: плоская иерархия + миксины

Оставляем максимум 2–3 уровня наследования. Если нужны дополнительные поведения — используем миксины (примеси): небольшие классы с одной конкретной способностью.

class Animal:
    """Базовый класс — только самое общее."""
    def __init__(self, name):
        self.name = name

    def eat(self): ...
    def breathe(self): ...


# Миксины — отдельные способности
class HunterMixin:
    """Умеет охотиться."""
    def hunt(self):
        return f"{self.name} охотится"


class PurrMixin:
    """Умеет мурлыкать."""
    def purr(self):
        return f"{self.name} мурлычет"


# Кот: Animal + только нужные миксины. Всего 2 уровня.
class Cat(PurrMixin, Animal):
    def use_litter_box(self): ...


class WildCat(HunterMixin, PurrMixin, Animal):
    pass


# Теперь сразу ясно, что умеет каждый класс.

Практическое правило: если иерархия глубже 3 уровней — скорее всего, нужна не иерархия, а композиция или миксины.

🕳️ Ошибка 3: Изменяемые атрибуты класса — «разделяемая ловушка»

В Python есть два вида атрибутов: атрибут класса (shared — общий для всех экземпляров) и атрибут экземпляра (отдельный у каждого объекта). Если использовать изменяемый объект (список, словарь) как атрибут класса — все экземпляры будут делить один список. Одна из самых коварных ловушек Python!

❌ Плохо: изменяемые объекты как атрибуты класса

class Student:
    grades = []          # ← АТРИБУТ КЛАССА (общий для ВСЕХ!)
    info = {}            # ← тоже общий словарь

    def __init__(self, name):
        self.name = name

    def add_grade(self, grade):
        self.grades.append(grade)   # меняет ОБЩИЙ список!


alice = Student("Alice")
bob = Student("Bob")

alice.add_grade(5)
alice.add_grade(4)

# Ожидаем: у Боба нет оценок
# На самом деле:
print(bob.grades)   # [5, 4] — у Боба те же оценки что у Алисы!
print(alice.grades is bob.grades)  # True — это ОДИН объект

✅ Хорошо: создавать изменяемые объекты в __init__

class Student:
    # Атрибуты класса — только константы и неизменяемые значения!
    school = "Школа №1"   # str — неизменяемый, это ОК
    max_grades = 10       # int — ОК

    def __init__(self, name):
        self.name = name
        self.grades = []   # ← создаём новый список для КАЖДОГО объекта
        self.info = {}     # ← новый словарь для каждого объекта

    def add_grade(self, grade):
        self.grades.append(grade)


alice = Student("Alice")
bob = Student("Bob")

alice.add_grade(5)

print(alice.grades)  # [5]
print(bob.grades)    # [] — у Боба свой пустой список
print(alice.grades is bob.grades)  # False — разные объекты

Правило: атрибуты класса — только неизменяемые значения (строки, числа, кортежи, None). Всё изменяемое (списки, словари, множества) — создавать в __init__.

👻 Ошибка 4: Забытый super().__init__()

Когда дочерний класс переопределяет __init__, он должен явно вызвать конструктор родителя через super().__init__(). Иначе родительский класс не инициализируется — его атрибуты не будут созданы, и программа упадёт с AttributeError в самый неожиданный момент.

❌ Плохо: родительский __init__ не вызван

class Animal:
    def __init__(self, name, species):
        self.name = name          # ← эти атрибуты
        self.species = species    # ← не создадутся!
        self.is_alive = True

    def describe(self):
        return f"{self.name} ({self.species})"


class Dog(Animal):
    def __init__(self, name, breed):
        # super().__init__() ЗАБЫТ!
        self.breed = breed        # создаём только атрибут breed


dog = Dog("Rex", "Лабрадор")
print(dog.breed)     # "Лабрадор" — ок
print(dog.name)      # AttributeError: 'Dog' has no attribute 'name'
print(dog.describe())  # AttributeError! — name не создан

✅ Хорошо: всегда вызывать super().__init__()

class Animal:
    def __init__(self, name, species):
        self.name = name
        self.species = species
        self.is_alive = True

    def describe(self):
        return f"{self.name} ({self.species})"


class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name, species="Собака")  # ← вызываем родителя!
        self.breed = breed   # затем добавляем свои атрибуты


dog = Dog("Rex", "Лабрадор")
print(dog.name)       # "Rex" — работает
print(dog.breed)      # "Лабрадор" — работает
print(dog.is_alive)   # True — создан родителем
print(dog.describe()) # "Rex (Собака)" — работает

Правило: если дочерний класс переопределяет __init__ — всегда вызывай super().__init__(...) первой строкой.

⚠️ Ошибка 5: Изменяемые аргументы по умолчанию в __init__

Это одна из самых известных ловушек Python. Если написать def __init__(self, items=[]), то список [] создаётся один раз при определении класса, а не при каждом создании объекта. Все объекты, использующие значение по умолчанию, будут делить один и тот же список. Это поведение удивляет даже опытных разработчиков.

❌ Плохо: список как аргумент по умолчанию

class Cart:
    def __init__(self, items=[]):   # ← ОПАСНО! Один список на всех
        self.items = items

    def add(self, item):
        self.items.append(item)


cart1 = Cart()
cart2 = Cart()   # cart2 не передаёт items → использует тот же список!

cart1.add("яблоко")
cart1.add("молоко")

print(cart1.items)   # ["яблоко", "молоко"]
print(cart2.items)   # ["яблоко", "молоко"] — что?! Это cart1!

# Доказательство: это один и тот же объект
print(cart1.items is cart2.items)   # True

✅ Хорошо: использовать None как значение по умолчанию

class Cart:
    def __init__(self, items=None):   # ← None — безопасный sentinel
        # Если items не передан — создаём НОВЫЙ список
        self.items = items if items is not None else []

    def add(self, item):
        self.items.append(item)


cart1 = Cart()
cart2 = Cart()

cart1.add("яблоко")

print(cart1.items)  # ["яблоко"]
print(cart2.items)  # [] — у cart2 свой список

print(cart1.items is cart2.items)  # False — разные объекты

# При этом можно явно передать начальное содержимое:
cart3 = Cart(items=["хлеб", "масло"])
print(cart3.items)  # ["хлеб", "масло"]

Правило: никогда не используй изменяемые объекты ([], {}, set()) как значения по умолчанию. Используй None и создавай новый объект внутри функции.

🔗 Ошибка 6: Наследование там, где нужна композиция

Начинающие разработчики часто злоупотребляют наследованием там, где лучше подошла бы композиция. Тест простой: если можно сказать «A является B» — это наследование. Если только «A имеет B» или «A использует B» — нужна композиция. Например: «Движок является Автомобилем» — нет! «Автомобиль имеет Движок» — да.

❌ Плохо: Stack наследует от list — неправильные отношения

class Stack(list):    # «Стек является списком»? Нет!
    def push(self, item):
        self.append(item)

    def pop_top(self):
        return self.pop()


s = Stack()
s.push(1)
s.push(2)
print(s.pop_top())   # 2 — работает

# НО! Пользователь получает доступ ко ВСЕМ методам list:
s.insert(0, 999)     # вставка в произвольное место — нарушает логику стека!
s.sort()             # сортировка стека?!
s.reverse()          # разворот?!
s[0] = 42            # прямое изменение по индексу?!

# Stack НЕ должен уметь это — но он наследует всё от list

✅ Хорошо: Stack содержит список (композиция)

class Stack:
    """Стек: LIFO — последним вошёл, первым вышел."""

    def __init__(self):
        self._data = []   # список скрыт внутри, не доступен снаружи

    def push(self, item):
        self._data.append(item)

    def pop(self):
        if not self._data:
            raise IndexError("Стек пуст")
        return self._data.pop()

    def peek(self):
        """Посмотреть верхний элемент без удаления."""
        if not self._data:
            raise IndexError("Стек пуст")
        return self._data[-1]

    def is_empty(self):
        return len(self._data) == 0

    def __len__(self):
        return len(self._data)

    # Пользователь не может вызвать insert(), sort(), reverse()
    # — эти методы просто недоступны. Стек ведёт себя как стек.


s = Stack()
s.push(1)
s.push(2)
print(s.pop())    # 2
print(s.peek())   # 1
# s.sort()        # AttributeError — нет такого метода

Композиция даёт контроль над интерфейсом: пользователь видит только то, что должен видеть.

☕ Ошибка 7: Геттеры и сеттеры «на каждый чих» в стиле Java

Разработчики, пришедшие с Java, привыкли к шаблону: создай приватное поле, напиши getName() и setName(). В Python это считается анти-паттерном. Python-философия: если не нужна дополнительная логика (валидация, преобразование) — атрибут должен быть публичным. Если нужна логика — используй @property, а не явные геттеры/сеттеры.

❌ Плохо: Java-стиль в Python

class Person:
    def __init__(self, name, age):
        self.__name = name   # двойное подчёркивание
        self.__age = age

    def get_name(self):      # геттер — лишний код
        return self.__name

    def set_name(self, name):  # сеттер — лишний код
        self.__name = name

    def get_age(self):
        return self.__age

    def set_age(self, age):
        self.__age = age


person = Person("Алиса", 30)
print(person.get_name())       # некрасиво и многословно
person.set_name("Боб")
person.set_age(25)

✅ Хорошо: Python-way — публичные атрибуты и @property только там, где нужна логика

class Person:
    def __init__(self, name, age):
        self.name = name    # просто публичный атрибут — никаких геттеров!
        self.age = age      # YAGNI: не усложняй без причины

# Доступ прямой — просто и красиво:
person = Person("Алиса", 30)
print(person.name)   # "Алиса"
person.name = "Боб"  # прямое присваивание


# Если нужна валидация — вот тогда @property:
class PersonWithValidation:
    def __init__(self, name, age):
        self.name = name
        self.age = age   # вызовет setter!

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if not isinstance(value, int) or value < 0:
            raise ValueError(f"Возраст должен быть неотрицательным целым, получено: {value}")
        self._age = value


p = PersonWithValidation("Алиса", 30)
print(p.age)   # 30 — обращение как к атрибуту, но работает геттер
p.age = -5     # ValueError: Возраст должен быть неотрицательным целым...

Правило: начинай с публичного атрибута. Добавляй @property только если нужна валидация или вычисляемое значение.

🪄 Ошибка 8: Злоупотребление магическими методами «потому что могу»

Магические методы (__add__, __mul__, __getitem__ и другие) — мощный инструмент. Но их нужно добавлять только если это делает код понятнее. Если оператор + для вашего класса не очевиден — не добавляйте его. Принцип наименьшего удивления: код должен работать так, как ожидает читатель.

❌ Плохо: магические методы с неочевидной семантикой

class User:
    def __init__(self, name, role):
        self.name = name
        self.role = role

    def __add__(self, other):
        # + складывает пользователей?! Что это значит?
        return User(f"{self.name}+{other.name}", "merged")

    def __mul__(self, n):
        # * копирует пользователя n раз?!
        return [User(self.name, self.role) for _ in range(n)]

    def __getitem__(self, key):
        # [] обращается к... чему? Атрибутам? Правам?
        return getattr(self, key)


admin = User("Алиса", "admin")
user = User("Боб", "user")

# Что делает этот код? Совершенно неочевидно:
merged = admin + user        # ?
copies = admin * 3           # ?
name = admin["name"]         # ?

✅ Хорошо: только оправданные магические методы + явные методы для неочевидного

class User:
    def __init__(self, name, role):
        self.name = name
        self.role = role

    # __str__ — оправдан: все объекты должны уметь описывать себя
    def __str__(self):
        return f"User({self.name!r}, role={self.role!r})"

    # __eq__ — оправдан: понятно что значит "два пользователя равны"
    def __eq__(self, other):
        if not isinstance(other, User):
            return NotImplemented
        return self.name == other.name and self.role == other.role

    # Вместо __add__ — явный, понятный метод:
    def merge_with(self, other):
        """Объединить права двух пользователей."""
        merged_role = f"{self.role}+{other.role}"
        return User(self.name, merged_role)


admin = User("Алиса", "admin")
print(admin)                       # User('Алиса', role='admin')
print(admin == User("Алиса", "admin"))  # True — понятно
result = admin.merge_with(User("Боб", "editor"))  # явный метод

Правило: добавляй магический метод только если его смысл очевиден из контекста. Для нестандартных операций — обычный метод с понятным именем.

🔒 Ошибка 9: Злоупотребление __private и name mangling

В Python двойное подчёркивание перед именем (__attr) запускает механизм name mangling: Python переименовывает атрибут в _ClassName__attr. Это создаёт видимость настоящей приватности, но на самом деле доступ всё равно возможен. Кроме того, name mangling ломает наследование: дочерний класс не может легко переопределить такой атрибут. Правило Python: если хочешь обозначить «это внутренняя деталь — не трогай» — используй одно подчёркивание _attr.

❌ Плохо: двойное подчёркивание везде — ломает наследование

class Animal:
    def __init__(self, name):
        self.__name = name   # name mangling → _Animal__name

    def get_name(self):
        return self.__name   # работает внутри Animal


class Dog(Animal):
    def bark(self):
        # Пытаемся обратиться к __name из родителя
        return f"{self.__name} лает!"  # AttributeError!
        # Python ищет _Dog__name, а не _Animal__name!


dog = Dog("Рекс")
print(dog.get_name())   # "Рекс" — работает (метод внутри Animal)
print(dog.bark())       # AttributeError: 'Dog' object has no attribute '_Dog__name'

# «Приватный» атрибут всё равно доступен через имя-мангл:
print(dog._Animal__name)  # "Рекс" — «приватность» иллюзорна

✅ Хорошо: одно подчёркивание — «не трогай», двойное — только для защиты от столкновения имён в иерархии

class Animal:
    def __init__(self, name):
        self._name = name   # одно подчёркивание: «внутренняя деталь»
        # Конвенция: «не трогай снаружи, но технически можешь»

    def get_name(self):
        return self._name


class Dog(Animal):
    def bark(self):
        return f"{self._name} лает!"   # доступ из подкласса — OK


dog = Dog("Рекс")
print(dog.bark())         # "Рекс лает!"
print(dog._name)          # "Рекс" — доступно, но конвенция говорит «не используй»

# Двойное подчёркивание оправдано ТОЛЬКО в одном случае:
# когда в иерархии несколько классов, у которых МОЖЕТ быть атрибут с одинаковым именем
class HTMLWidget:
    def __init__(self):
        self.__id = generate_id()  # защита от столкновения имён в глубокой иерархии

Правило: используй _single_underscore для «внутренних» атрибутов. __double_underscore — только для защиты от столкновения имён в сложных иерархиях. В большинстве случаев одного подчёркивания хватает.

🔍 Ошибка 10: Сравнение через is вместо == для значений

В Python == сравнивает значения (вызывает __eq__), а is сравнивает идентичность — являются ли два имени ссылками на один и тот же объект в памяти. Использовать is для сравнения строк, чисел и других значений — классическая ошибка. Иногда это «работает» из-за оптимизации Python (intern), но это случайность, на которую нельзя полагаться.

❌ Плохо: is для сравнения значений — непредсказуемо

class User:
    def __init__(self, name, role):
        self.name = name
        self.role = role

    def is_admin(self):
        # Сравниваем строки через is — ОПАСНО!
        return self.role is "admin"

    def check_name(self, name):
        return self.name is name   # тоже неправильно


user = User("Алиса", "admin")
print(user.is_admin())    # может быть True или False — зависит от интернирования строк!

# Python интернирует короткие строки, поэтому:
a = "admin"
b = "admin"
print(a is b)   # True  — повезло, Python переиспользовал объект

# Но для длинных или динамически созданных строк — нет:
x = "очень длинная строка роли"
y = "очень длинная строка роли"
print(x is y)   # False — разные объекты!
print(x == y)   # True  — значения одинаковы

✅ Хорошо: == для значений, is только для None

class User:
    def __init__(self, name, role):
        self.name = name
        self.role = role

    def is_admin(self):
        return self.role == "admin"   # == сравнивает ЗНАЧЕНИЯ — всегда корректно

    def check_name(self, name):
        return self.name == name   # тоже ==


user = User("Алиса", "admin")
print(user.is_admin())   # True — всегда предсказуемо

# Единственный случай, когда is оправдан — сравнение с None:
def process(value=None):
    if value is None:    # ← это правильно! None — синглтон
        return "нет значения"
    return value

# И проверка типа тоже через isinstance, не is:
x = 42
print(isinstance(x, int))   # True — правильный способ
print(type(x) is int)        # тоже работает, но isinstance гибче

Правило: is — только для сравнения с None (value is None). Для всего остального — ==.

📋 Быстрая шпаргалка: 10 правил

1. God-class

Класс растёт → разбей на несколько по ответственности (SRP).

2. Глубокое наследование

Глубже 3 уровней → используй миксины или композицию.

3. Изменяемые атрибуты класса

Списки и словари — только в __init__, не на уровне класса.

4. Забытый super().__init__()

Переопределяешь __init__ — вызывай super().__init__(...) первым.

5. Mutable default args

def __init__(self, items=None) → создавай [] внутри.

6. Наследование vs. композиция

«A является B» → наследование. «A имеет B» → композиция.

7. Геттеры/сеттеры

Сначала публичный атрибут. @property — только с логикой.

8. Магические методы

Только если смысл оператора очевиден. Иначе — обычный метод.

9. __private

Одно подчёркивание _attr для «внутренних». __attr — редкий спецслучай.

10. is vs ==

is — только для None. Для значений — всегда ==.

🎓 Итог урока

Хороший ООП-код — это не просто «работающий» код. Это код, который легко читать, изменять и тестировать через год. Ошибки из этого урока совершают почти все начинающие разработчики — теперь вы знаете, как их избежать.

🔑 Главный принцип: каждый класс должен делать одну вещь, делать её хорошо и доверять другим классам делать их вещи. Простой код лучше умного, явный код лучше неявного — это и есть дух Python.