Типичные ошибки в ООП на Python
Знать синтаксис — одно. Писать хороший ООП-код — другое. В этом уроке разберём 10 самых частых ловушек, в которые попадают начинающие разработчики. Для каждой ошибки покажем: как выглядит плохой код, почему он сломан и как написать правильно.
Когда начинающий разработчик только изучает ООП, он сосредоточен на том, как написать код — чтобы он хотя бы работал. Когда он набирает опыт, фокус смещается: как написать хорошо. Плохой ООП-код часто работает, но через месяц его невозможно поддерживать, расширять и понимать.
💡 Как читать этот урок: каждая ошибка — отдельный раздел с двумя примерами. Красный блок — плохой код. Зелёный блок — правильный код. После примера — объяснение почему. Читайте внимательно: некоторые ловушки очень коварны и внешне выглядят нормально.
🙈 Ошибка 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.