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

Атрибуты класса vs атрибуты экземпляра

Все объекты одного класса создаются по одному шаблону — но у каждого своя жизнь. Как одни данные принадлежат конкретному объекту, а другие — всему классу сразу? И почему одна невинная ошибка с общим списком может сломать сразу все объекты? Сегодня разберём это до конца.

⏱ ~40 минут 🎓 Для новичков 🐍 Python 3 ⚠️ Есть ловушка!

🗂️ Два вида атрибутов — в чём разница?

В предыдущих уроках мы создавали атрибуты через self.name = ... внутри __init__. Это один из двух видов атрибутов. В Python их ровно два:

🏫
Атрибут класса
Объявляется прямо в теле класса, вне всех методов. Один на всех — все объекты этого класса видят одно и то же значение.
👤
Атрибут экземпляра
Создаётся через self.имя = ... внутри __init__. У каждого объекта свой — независимо от других объектов.

Давайте сразу увидим оба в одном коде:

class Cat:
    # Атрибут КЛАССА — объявлен прямо в теле класса
    species = "Felis catus"   # все кошки — один вид

    def __init__(self, name, age):
        # Атрибуты ЭКЗЕМПЛЯРА — создаются у каждого объекта свои
        self.name = name
        self.age = age

luna = Cat("Луна", 3)
mars = Cat("Марс", 5)

print(luna.name)     # Луна   — атрибут экземпляра
print(mars.name)     # Марс   — свой у каждого

print(luna.species)  # Felis catus — атрибут класса
print(mars.species)  # Felis catus — тот же самый атрибут!

# Можно обратиться и напрямую через класс:
print(Cat.species)   # Felis catus

💡 Аналогия из жизни: представьте школьный класс. У каждого ученика есть своё имя, свой возраст, свой дневник — это атрибуты экземпляра. Но у всех один и тот же номер класса — «8Б» — и один учитель. Это атрибут класса: одно значение, общее для всех.

🔍 Где это всё физически хранится: __dict__

В Python каждый класс и каждый объект хранят свои атрибуты в специальном встроенном словаре — он называется __dict__. Это не магия, это самый обычный словарь Python, просто Python создаёт его автоматически.

Самый надёжный способ понять, что происходит внутри — просто посмотреть на эти словари напрямую:

class Cat:
    species = "Felis catus"

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

luna = Cat("Луна", 3)
mars = Cat("Марс", 5)

# Смотрим словарь КЛАССА
print(Cat.__dict__)
# {'species': 'Felis catus', '__init__': <function Cat.__init__>, ...}
# Здесь хранятся атрибуты класса и методы

# Смотрим словарь ОБЪЕКТА luna
print(luna.__dict__)
# {'name': 'Луна', 'age': 3}
# Только атрибуты экземпляра! species здесь НЕТ

# Смотрим словарь ОБЪЕКТА mars
print(mars.__dict__)
# {'name': 'Марс', 'age': 5}
# У каждого объекта свой словарь

Заметили? В luna.__dict__ нет species! Он хранится только в Cat.__dict__. Так как же тогда работает luna.species?

🎯 Важно: luna.__dict__ — это срез реальности. Именно там живут атрибуты экземпляра. Cat.__dict__ — там живут атрибуты класса. Когда вы пишете luna.species, Python сначала смотрит в luna.__dict__, не находит — и идёт искать в Cat.__dict__.

🔎 Порядок поиска: кто побеждает?

Когда вы пишете объект.атрибут, Python ищет его строго по порядку:

1️⃣
Словарь объекта
instance.__dict__
2️⃣
Словарь класса
Class.__dict__
3️⃣
Родительские классы
(про это — в теме наследования)

Что происходит, если у объекта и у класса есть атрибут с одним и тем же именем? Побеждает объект — потому что Python нашёл его первым и дальше не пошёл:

class Cat:
    sound = "Мяу"   # атрибут класса

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

luna = Cat("Луна")
mars = Cat("Марс")

print(luna.sound)   # Мяу — берёт из класса (у luna нет своего sound)
print(mars.sound)   # Мяу — тоже из класса

# Добавляем атрибут ЭКЗЕМПЛЯРА с тем же именем
luna.sound = "Пррр"  # теперь у luna есть СВОЯ версия

print(luna.sound)   # Пррр — берёт из ЭКЗЕМПЛЯРА (нашёл первым!)
print(mars.sound)   # Мяу  — у mars нет своего, берёт из класса
print(Cat.sound)    # Мяу  — класс не изменился!

# Посмотрим в словари:
print(luna.__dict__)  # {'name': 'Луна', 'sound': 'Пррр'}  — появился!
print(mars.__dict__)  # {'name': 'Марс'}                   — нет sound

Это очень важный момент: когда вы делаете luna.sound = "Пррр", вы не меняете атрибут класса — вы создаёте новый атрибут экземпляра, который перекрывает классовый. Класс при этом не трогается!

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

⚠️ ГЛАВНАЯ ЛОВУШКА: изменяемые атрибуты класса

Сейчас будет самый важный момент урока. Из-за этой ошибки у программистов ломались проекты, и они часами не могли понять почему. Запомните это раз и навсегда.

Никогда не используйте изменяемые объекты (списки, словари, множества) как атрибуты класса, если вы хотите, чтобы у каждого объекта был свой список.

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

Вот как делать НЕЛЬЗЯ:

# ❌ ОПАСНЫЙ КОД — список как атрибут КЛАССА
class Student:
    grades = []   # Кажется, что у каждого будет свой список... но нет!

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

    def add_grade(self, grade):
        self.grades.append(grade)   # Вот тут и зарыта проблема

alice = Student("Алиса")
bob = Student("Боб")

alice.add_grade(5)
alice.add_grade(4)
bob.add_grade(3)

print(alice.grades)  # [5, 4, 3]  — СТОП! Откуда у Алисы тройка?!
print(bob.grades)    # [5, 4, 3]  — Боб тоже видит все оценки!
print(Student.grades)# [5, 4, 3]  — Это ОДИН список для всех!

🤔 Почему это происходит? Когда Python ищет self.grades для вызова .append(), он не находит grades в alice.__dict__ и идёт в Student.__dict__. Там находит список — и вызывает append прямо на нём. Список один — оба объекта пишут в один и тот же список. Вы не создаёте новый атрибут экземпляра — вы мутируете единственный список класса!

Сравните с числами и строками: когда вы делаете luna.sound = "Пррр" — вы присваиваете новое значение, и Python создаёт новый атрибут экземпляра. Но когда вы вызываете self.grades.append() — вы не присваиваете, вы мутируете уже найденный объект в классе. Разница принципиальная!

Вот правильное решение — список создаётся в __init__:

# ✅ ПРАВИЛЬНЫЙ КОД — список как атрибут ЭКЗЕМПЛЯРА
class Student:
    def __init__(self, name):
        self.name = name
        self.grades = []   # Каждый раз при создании объекта — НОВЫЙ список!

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

alice = Student("Алиса")
bob = Student("Боб")

alice.add_grade(5)
alice.add_grade(4)
bob.add_grade(3)

print(alice.grades)  # [5, 4]  ✅ Только оценки Алисы
print(bob.grades)    # [3]     ✅ Только оценки Боба

# Убедимся что это разные списки:
print(alice.grades is bob.grades)  # False — разные объекты!

Теперь при каждом вызове Student("имя") внутри __init__ выполняется self.grades = [] — создаётся новый пустой список и сразу записывается в словарь этого конкретного экземпляра. У каждого объекта свой список.

✅ Когда атрибуты класса действительно полезны

Атрибуты класса — не зло. Просто нужно знать, где они уместны. Вот три типичных случая:

📌
Константы класса
Значения, которые не меняются и одинаковы для всех. Вид животного, максимальный размер, версия протокола.
🔢
Счётчики объектов
Сколько всего создано объектов этого класса — это знание принадлежит классу, не конкретному объекту.
⚙️
Настройки по умолчанию
Общие настройки для всех объектов, которые можно переопределить у конкретного экземпляра если нужно.

🔢 Демо: счётчик созданных объектов

Один из самых классических и полезных примеров атрибута класса — счётчик. Нам нужно знать, сколько объектов было создано. Это знание принадлежит не одному объекту, а всему классу.

Идея простая: при каждом создании нового объекта (то есть в __init__) мы увеличиваем счётчик класса на 1:

class Robot:
    count = 0   # атрибут класса — счётчик

    def __init__(self, name):
        self.name = name
        Robot.count += 1   # увеличиваем КЛАССОВЫЙ счётчик
        # Важно: пишем Robot.count, а не self.count!
        # self.count += 1 создало бы атрибут экземпляра

    def introduce(self):
        print(f"Я робот {self.name}. Всего роботов создано: {Robot.count}")

r1 = Robot("Р2-Д2")
r2 = Robot("С-3ПО")
r3 = Robot("ВАЛ-И")

r1.introduce()  # Я робот Р2-Д2. Всего роботов создано: 3
r2.introduce()  # Я робот С-3ПО. Всего роботов создано: 3
r3.introduce()  # Я робот ВАЛ-И. Всего роботов создано: 3

print(Robot.count)  # 3

Обратите внимание: мы пишем Robot.count += 1, а не self.count += 1. Почему? Потому что self.count += 1 — это присваивание нового значения, и Python создал бы атрибут экземпляра count, который перекрыл бы классовый. Счётчик класса остался бы нулём!

⚠️ Ловушка №2: self.count += 1 это сокращение от self.count = self.count + 1. Python сначала читает self.count (находит в классе — значение 0), прибавляет 1, и записывает результат через присваивание — то есть создаёт count в словаре экземпляра. Классовый счётчик не меняется. У каждого объекта будет свой count = 1, а Robot.count навсегда останется 0.

Проверим эту ловушку в деле, чтобы убедиться:

# Демо ловушки с self.count
class RobotBroken:
    count = 0

    def __init__(self, name):
        self.name = name
        self.count += 1   # ❌ Неправильно!

r1 = RobotBroken("А")
r2 = RobotBroken("Б")
r3 = RobotBroken("В")

print(r1.count)           # 1  — атрибут экземпляра
print(r2.count)           # 1  — тоже у каждого своя единица
print(RobotBroken.count)  # 0  — классовый счётчик НЕ изменился!

📊 Сравнительная таблица: быстрая шпаргалка

Критерий Атрибут класса Атрибут экземпляра
Где объявляется В теле класса, вне методов В __init__ через self.x = ...
Где хранится Class.__dict__ instance.__dict__
Доступ Class.attr или obj.attr Только obj.attr
Уникальность Один для всех объектов Своё у каждого объекта
Типичные задачи Константы, счётчики, настройки Имя, возраст, данные объекта
Изменяемые типы ⚠️ Опасно! ✅ Безопасно

🎯 Что важно запомнить из этого урока

  • Атрибут класса — объявлен в теле класса, общий для всех. Хранится в Class.__dict__.
  • Атрибут экземпляра — создаётся в __init__ через self.имя = .... Свой у каждого объекта. Хранится в instance.__dict__.
  • Порядок поиска: Python ищет сначала в объекте, потом в классе. Атрибут экземпляра затеняет атрибут класса с тем же именем.
  • ⚠️ Ловушка: списки и словари как атрибуты класса — один объект для всех. Всегда создавайте изменяемые структуры в __init__.
  • Для счётчика объектов обновляйте атрибут класса явно: ClassName.count += 1, а не self.count += 1.

🚀 В следующем уроке мы познакомимся со специальными методами — __str__, __repr__ и другими «dunder»-методами, которые позволяют объектам вести себя как встроенные типы Python.