Атрибуты и методы: class vs instance, classmethod, staticmethod, property

В прошлом уроке мы создавали простые классы с инстанс-атрибутами. Но Python даёт больше: атрибуты на уровне класса, разные типы методов (instance, class, static) и @property для контролируемого доступа. Понимание этих различий критично для проектирования API.

Class attributes vs instance attributes

class User:
    species = "Human"        # class attribute

    def __init__(self, name):
        self.name = name     # instance attribute

Class attribute хранится на классе, общий для всех инстансов:

u1 = User("Alice")
u2 = User("Bob")

print(u1.species)   # Human
print(u2.species)   # Human

User.species = "Cyborg"   # изменили на классе
print(u1.species)   # Cyborg
print(u2.species)   # Cyborg

Instance attribute хранится на инстансе, своё значение у каждого:

u1.name = "Alicia"
print(u1.name)   # Alicia
print(u2.name)   # Bob - не затронут

Lookup-цепочка

Когда обращаешься к u.attr, Python ищет в порядке:

  1. u.__dict__ (instance attributes)
  2. User.__dict__ (class attributes)
  3. родители (см. урок про наследование и MRO)
  4. AttributeError если нигде нет
class Counter:
    count = 0      # class attribute

    def increment(self):
        self.count += 1   # СЮРПРИЗ - создаёт instance attribute

c1 = Counter()
c2 = Counter()
c1.increment()
print(c1.count)   # 1 - instance attribute
print(c2.count)   # 0 - всё ещё class attribute!
print(Counter.count)   # 0

self.count += 1 это self.count = self.count + 1. Чтение self.count находит class attribute (0), сложение даёт 1, присваивание создаёт instance attribute count=1. После этого на c1 instance attribute заслоняет class attribute.

Это типичная ловушка. Для счётчика на уровне класса:

class Counter:
    count = 0

    @classmethod
    def increment(cls):
        cls.count += 1

Counter.increment()
Counter.increment()
print(Counter.count)   # 2

Mutable class attributes - опасно

class User:
    permissions = []   # ОПАСНО - общий список для всех!

    def add_permission(self, perm):
        self.permissions.append(perm)

u1 = User()
u2 = User()
u1.add_permission("read")
print(u2.permissions)   # ['read'] - неожиданно!

u1.permissions.append(...) не создаёт instance attribute - модифицирует общий class attribute. Все инстансы делят один список. Те же грабли что mutable defaults у функций.

Правильно:

class User:
    def __init__(self):
        self.permissions = []   # своё у каждого инстанса

Или использовать tuple/frozenset для immutable defaults:

class User:
    default_permissions = ("read",)   # immutable - безопасно

classmethod - метод класса

@classmethod получает класс (cls) вместо инстанса. Полезен для:

  • Alternative constructors (factory methods)
  • Методов работающих с классом, не с конкретным инстансом
from datetime import date

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

    @classmethod
    def from_birth_year(cls, name, birth_year):
        return cls(name, birth_year)

    @classmethod
    def from_string(cls, s):
        # "Alice|1990"
        name, year = s.split("|")
        return cls(name, int(year))

    @classmethod
    def adult_age(cls):
        return 18

# Использование
u1 = User("Alice", 1990)
u2 = User.from_birth_year("Bob", 1985)
u3 = User.from_string("Charlie|2000")

Главное преимущество - cls это реальный класс из которого вызывают, не зашитое имя. Это критично для inheritance:

class AdminUser(User):
    pass

au = AdminUser.from_string("Dave|1988")
print(type(au))   # AdminUser, не User

Если бы в from_string использовалось User(...) вместо cls(...), alternative constructor возвращал бы User для всех подклассов. С cls каждый подкласс получает свой тип.

staticmethod - функция в namespace класса

@staticmethod не получает ни self, ни cls. Это обычная функция, живущая внутри класса для логической группировки:

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @staticmethod
    def celsius_to_fahrenheit(c):
        return c * 9 / 5 + 32

    @staticmethod
    def is_valid_celsius(value):
        return -273.15 <= value <= 1e9

t = Temperature(25)
Temperature.celsius_to_fahrenheit(100)   # 212
t.celsius_to_fahrenheit(100)              # тоже работает
Temperature.is_valid_celsius(-300)       # False

@staticmethod нужен реже чем @classmethod. Часто проще сделать обычную функцию модуля - она не требует префикса класса.

property - метод как атрибут

@property превращает метод в свойство - доступ как к атрибуту:

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return 3.14159 * self.radius ** 2

    @property
    def diameter(self):
        return self.radius * 2

c = Circle(5)
print(c.area)       # 78.539... - без скобок!
print(c.diameter)   # 10
c.area = 100        # AttributeError - read-only без setter

Полезно для:

  • Вычисляемых значений (area из radius)
  • Lazy computation (с кешированием через @cached_property)
  • Контроля над "приватным" атрибутом

property с setter

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Ниже абсолютного нуля")
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5 / 9   # вызывает celsius.setter с валидацией

t = Temperature(25)
print(t.fahrenheit)   # 77.0
t.celsius = 30
t.fahrenheit = 100    # через setter
print(t.celsius)      # 37.77...
t.celsius = -300      # ValueError

Setter получает имя через @<имя_property>.setter. Можно добавлять @property.deleter для контроля удаления.

Property удобен но добавляет неявность - читая `obj.name = x` не очевидно что выполняется setter с логикой. Это может быть медленно (вызов метода вместо прямого доступа), сложно для отладки.

Используй property когда:

  • Нужна валидация при записи
  • Значение вычисляется из других атрибутов
  • Контролируешь доступ к "приватному" полю

Иначе - обычный атрибут понятнее.

cached_property - lazy + кеш

С Python 3.8 есть @functools.cached_property - вычисляет один раз, кеширует на инстансе. Это обычный декоратор, просто из стандартной библиотеки:

from functools import cached_property

class DataAnalyzer:
    def __init__(self, data):
        self.data = data

    @cached_property
    def statistics(self):
        # Дорогое вычисление - выполнится один раз
        print("Computing...")
        return {
            "mean": sum(self.data) / len(self.data),
            "max": max(self.data),
            "min": min(self.data),
        }

a = DataAnalyzer([1, 2, 3, 4, 5])
print(a.statistics)   # Computing... {'mean': 3.0, ...}
print(a.statistics)   # без Computing - из кеша

Кеш живёт на инстансе - удаляется когда инстанс удаляется. Для invalidation - просто del a.statistics.

Приватные атрибуты - конвенция

В Python нет приватных атрибутов в строгом смысле. Есть две конвенции:

class User:
    def __init__(self, name):
        self.name = name           # public - доступен снаружи
        self._cache = {}            # "protected" - конвенция, не запрещено
        self.__secret = "shh"       # "private" - name mangling

u = User("Alice")
print(u.name)         # OK
print(u._cache)       # работает, но "не должен"
print(u.__secret)     # AttributeError!
print(u._User__secret) # работает - name mangled
  • _attr - конвенция: «приватное, не трогай извне». Просто соглашение, доступ не запрещён.
  • __attr - name mangling: Python переименовывает в _ClassName__attr. Это не для приватности, а для избежания конфликтов в иерархии наследования. Использовать редко.

В реальном коде чаще _attr или вообще без подчёркивания (Python community говорит «consenting adults»).

slots - оптимизация памяти

По умолчанию атрибуты хранятся в __dict__ (словарь). Для миллионов объектов это много памяти. __slots__ ограничивает атрибуты заранее объявленным списком, экономит память:

class Point:
    __slots__ = ("x", "y")

    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
p.x = 10        # OK
p.z = 30        # AttributeError - z не в slots

Преимущества:

  • Меньше памяти (~40-60% экономии)
  • Чуть быстрее доступ к атрибутам
  • Защита от опечаток (нельзя случайно создать новый атрибут)

Недостатки:

  • Нельзя добавлять атрибуты динамически
  • Сложнее наследование (особенно multiple)
  • Не работает с __dict__-based features

Для большинства классов не нужен. Используй в hot path или для большого количества инстансов.

Сравнение типов методов

ДекораторПервый параметрДоступ кИспользование
(нет)selfинстансу и классуОбычные методы экземпляра
@classmethodclsтолько классуAlternative constructors, factory methods
@staticmethod(ничего)ни тому ни другомуУтилитарные функции в namespace класса
@propertyselfинстансуВычисляемые/контролируемые атрибуты

Мини-задание

  1. Class attribute vs instance attribute:
class Counter:
    total_created = 0   # class attribute

    def __init__(self):
        Counter.total_created += 1   # явное обращение к классу
        self.id = Counter.total_created   # своё у каждого

c1 = Counter()
c2 = Counter()
c3 = Counter()
print(c1.id, c2.id, c3.id)              # 1 2 3
print(Counter.total_created)            # 3
  1. Alternative constructor через classmethod:
from datetime import datetime

class Event:
    def __init__(self, name, timestamp):
        self.name = name
        self.timestamp = timestamp

    @classmethod
    def now(cls, name):
        return cls(name, datetime.now())

    @classmethod
    def from_iso(cls, name, iso_string):
        return cls(name, datetime.fromisoformat(iso_string))

    def __repr__(self):
        return f"Event({self.name!r}, {self.timestamp.isoformat()})"

e1 = Event.now("login")
e2 = Event.from_iso("logout", "2026-05-27T15:00:00")
print(e1)
print(e2)
  1. Property с валидацией:
class Account:
    def __init__(self, balance=0):
        self._balance = balance

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("Баланс не может быть отрицательным")
        self._balance = value

a = Account(100)
print(a.balance)    # 100
a.balance = 200
print(a.balance)    # 200
a.balance = -50     # ValueError

Что дальше

Освоили виды атрибутов и методов. Если класс нужен только чтобы хранить поля, большую часть этого кода заменит @dataclass. В следующем уроке - dunder-методы: str, eq, hash, len, getitem, iter и другие - как сделать свои классы «правильно ведущими себя» в Python.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.