Урок курса

Класс, __init__, атрибуты экземпляра и методы

Python для продвинутых: ООП, типизация и тестирование

В предыдущем уроке мы разобрали *args и **kwargs — гибкие сигнатуры функций, которые принимают произвольное количество аргументов. Этот же механизм скоро пригодится в конструкторах классов, но сначала нужно понять, что такое класс и как он устроен изнутри.

Определение класса и роль init

Класс в Python объявляется ключевым словом class, после которого идёт имя и двоеточие. По соглашению имена классов пишут в CamelCase — Counter, UserProfile, OrderItem. Тело класса — это обычный блок с отступом, внутри которого определяются методы.

class Counter:
    pass

Пока это просто пустой класс. Он уже работает: Python создал новый тип Counter. Но чтобы объект при создании получал начальное состояние, нужен специальный метод __init__.

__init__ — это инициализатор экземпляра. Python вызывает его автоматически сразу после того, как создан новый объект. Важно: __init__ не выделяет память и не «конструирует» объект в низкоуровневом смысле — этим занимается __new__. Задача __init__ проще: получить только что созданный пустой объект и заполнить его данными.

Первый параметр __init__ — это self. Он ссылается на тот самый объект, который был только что создан. Именно через self метод знает, с каким объектом работает.

class Counter:
    def __init__(self, name):
        print(f"Создаётся объект Counter с именем {name}")
        print(f"self — это объект по адресу: {id(self)}")

Когда Python выполняет Counter('A'), он:

  1. Создаёт новый объект типа Counter.
  2. Передаёт этот объект в __init__ как self.
  3. Передаёт 'A' как name.
  4. Выполняет тело __init__.

Имя self — соглашение, а не синтаксическое требование. Python не проверяет, как называется первый параметр. Но отступать от этого соглашения не стоит: любой читающий код ожидает именно self.

Сам вызов Counter('A') выглядит как вызов функции, но это вызов класса как фабрики объектов. Python знает, что нужно создать экземпляр Counter и запустить __init__ с переданными аргументами. Аргумент для self не указывают явно — интерпретатор передаёт его сам.

class Counter:
    def __init__(self, name):
        print(f"__init__ вызван для объекта {id(self)}, name={name}")

c = Counter('A')
print(type(c))   # <class '__main__.Counter'>

После выполнения этого кода c — полноценный объект типа Counter. __init__ уже отработал. На следующем шаге мы запишем в self первые данные и превратим инициализатор в нечто полезное.

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

Когда __init__ запущен, у нас есть self — свежий пустой объект. Чтобы объект стал полезным, нужно записать в него данные. Делается это простым присваиванием через точку:

class Counter:
    def __init__(self, name):
        self.name = name
        self.value = 0

self.name и self.value — это атрибуты экземпляра. После того как __init__ отработал, они живут внутри объекта и доступны везде, где есть ссылка на этот объект. Прочитать их можно точно так же: объект.атрибут.

Важный момент: self.name и просто name — разные вещи. name — это локальный параметр __init__, он исчезнет, когда метод завершится. self.name — это данные, привязанные к объекту, они останутся.

c = Counter('A')
print(c.name)   # 'A'
print(c.value)  # 0

Теперь про методы. Метод экземпляра — это обычная функция внутри класса, первым параметром которой всегда идёт self. Через self метод получает доступ к атрибутам именно того объекта, на котором он вызван:

class Counter:
    def __init__(self, name):
        self.name = name
        self.value = 0

    def increment(self, step=1):
        self.value += step

    def reset(self):
        self.value = 0

    def status(self):
        return f"{self.name}: {self.value}"

Когда пишем c.increment(3), Python внутри делает вот что: берёт объект c, передаёт его как self в метод increment, передаёт 3 как step. Метод читает self.value, прибавляет 3 и записывает результат обратно — всё это происходит с атрибутом именно объекта c.

c = Counter('A')
c.increment(3)
print(c.status())  # A: 3
c.reset()
print(c.status())  # A: 0

Методы могут не только изменять атрибуты, но и читать их и возвращать результат — как status() выше. Ключевой принцип один: всё взаимодействие с данными объекта идёт через self. Нет self — нет доступа к состоянию конкретного экземпляра.

Инстанцирование и независимое состояние объектов

Каждый вызов класса как функции создаёт отдельный, полностью независимый объект. Напишем c1 = Counter('A') и c2 = Counter('B') — Python дважды выполнит __init__, оба раза передавая в self разный, только что созданный объект. В итоге c1 и c2 не знают друг о друге и никак не связаны.

class Counter:
    def __init__(self, name):
        self.name = name
        self.value = 0

    def increment(self, step=1):
        self.value += step

    def reset(self):
        self.value = 0

    def status(self):
        return f"{self.name}: {self.value}"

c1 = Counter('A')
c2 = Counter('B')

c1.increment(3)
c1.increment()

print(c1.status())  # A: 4
print(c2.status())  # B: 0

c1.increment(3) читает c1.value, изменяет его и записывает результат обратно в c1.value. c2.value при этом остаётся нулём — у него своя копия атрибута, лежащая в совершенно другом месте памяти.

Чтобы убедиться в этом напрямую, достаточно проверить id объектов:

print(id(c1), id(c2))  # разные адреса
print(c1 is c2)        # False

Оба объекта — типа Counter, оба имеют атрибуты name и value, оба понимают методы increment, reset, status. Но данные у них свои. Когда Python выполняет c2.increment(), он передаёт c2 как self — и метод работает только с c2.value.

Именно здесь проявляется разница между классом и объектом. Класс Counter — один. Он описывает структуру и поведение. Объектов Counter может быть сколько угодно, и каждый несёт своё независимое состояние.

Можно создать произвольное количество счётчиков и управлять ими независимо:

counters = [Counter(letter) for letter in 'ABC']

counters[0].increment(10)
counters[1].increment(5)
# counters[2] остаётся на 0

for c in counters:
    print(c.status())
# A: 10
# B: 5
# C: 0

Каждый счётчик в списке — самостоятельный объект. Обращение к любому из них через self внутри метода гарантирует, что изменения затронут только этот конкретный экземпляр.

Типичные ошибки: забытый self и атрибут класса вместо атрибута экземпляра

Две ошибки встречаются настолько часто, что стоит разобрать их отдельно — особенно потому что обе дают неочевидные симптомы.

Забытый self в сигнатуре метода

Предположим, в спешке написали метод без первого параметра:

class Counter:
    def __init__(self, name):
        self.name = name
        self.value = 0

    def increment():          # забыли self
        self.value += 1

При попытке вызвать c.increment() Python выдаст:

TypeError: Counter.increment() takes 0 positional arguments but 1 was given

Сообщение кажется странным: мы же вызвали c.increment() без аргументов. Но Python при вызове метода через экземпляр автоматически передаёт сам объект первым аргументом. Метод объявлен без параметров — принять этот аргумент некуда. Отсюда и ошибка.

Исправление тривиальное: всегда указывать self первым параметром в любом методе экземпляра, без исключений.

    def increment(self):      # правильно
        self.value += 1

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

Эта ошибка коварнее, потому что код не падает сразу — он просто ведёт себя неожиданно.

Если написать переменную прямо в теле класса, не внутри __init__, она становится атрибутом класса — то есть разделяется между всеми экземплярами:

class Counter:
    value = 0    # атрибут КЛАССА, не экземпляра

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

    def increment(self, step=1):
        self.value += step

Создадим два счётчика и попробуем проинкрементировать первый:

c1 = Counter('A')
c2 = Counter('B')

print(Counter.value)  # 0  — атрибут класса
print(c1.value)       # 0  — пока читается с класса
print(c2.value)       # 0

c1.increment(3)

print(c1.value)       # 3  — у c1 появился собственный атрибут-тень
print(c2.value)       # 0  — c2 всё ещё читает с класса
print(Counter.value)  # 0  — сам класс не изменился

Что произошло: при self.value += step Python сначала читает self.value. Поскольку у c1 нет своего атрибута value, он находит его на классе и берёт оттуда 0. Затем присваивает результат через self.value = ... — и теперь у c1 появляется собственный атрибут value, который «перекрывает» (shadowing) атрибут класса. Атрибут класса при этом не меняется.

Это называется shadowing: экземплярный атрибут с тем же именем скрывает классовый. Пока такого «теневого» атрибута нет, Python при чтении поднимается по цепочке до класса. Побочный эффект: поведение зависит от того, был ли метод вызван хотя бы раз, и это делает код трудноотлаживаемым.

Если атрибут должен быть индивидуальным для каждого объекта — а value счётчика очевидно должен быть таким — его нужно объявлять в __init__ через self:

class Counter:
    def __init__(self, name):
        self.name = name
        self.value = 0    # атрибут экземпляра — у каждого свой

    def increment(self, step=1):
        self.value += step

Теперь c1.value и c2.value — независимые переменные с самого начала, и никакого shadowing не возникает.

Атрибуты класса сами по себе — не ошибка. Они полезны, например, для констант или счётчика числа созданных экземпляров, который должен быть общим. Но если что-то должно быть своим у каждого объекта, место для этого — __init__ и self.

Попробуйте решить

Дан класс:

class Box:
 def __init__(self, label, capacity):
 self.label = label
 self.capacity = capacity
 self.items = []

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

Разработчик создаёт два объекта:

b1 = Box('A', 10)
b2 = Box('B', 5)
b1.add('apple')

Чему равно b2.items после выполнения этого кода?

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

Перейти к интерактивному уроку