$ sudo teach IT
МОДУЛЬ 5 · УРОК 3

Декоратор @property

В прошлом уроке мы увидели боль Java-стиля: добавил геттер/сеттер — сломал весь код, который использовал user.age. Сегодня разбираем питоническое решение: декоратор @property. Он превращает метод в «умный атрибут» — снаружи выглядит как обычное поле, но внутри может делать что угодно.

⏱ ~40 минут 🎓 Для новичков 🐍 Python 3

🔮 Что такое @property и зачем он нужен

@property — это декоратор (специальная пометка перед методом), который делает так, что метод ведёт себя как атрибут. Вы обращаетесь к нему без скобок — obj.attr, а не obj.attr() — но за кулисами вызывается функция.

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

👓

Геттер (@property)

Вызывается при чтении атрибута: x = obj.attr. Метод читает и возвращает значение.

✏️

Сеттер (@attr.setter)

Вызывается при записи: obj.attr = 5. Здесь делают валидацию, сохранение.

🗑️

Делитер (@attr.deleter)

Вызывается при удалении: del obj.attr. Нужен редко — для очистки ресурсов.

💡 Главная идея: @property позволяет добавить логику к атрибуту в любой момент, не меняя внешний интерфейс класса. Весь код, который писал obj.attr, продолжит работать — и даже не узнает, что за кулисами теперь вызывается метод.

🔒 Базовый @property: атрибут только для чтения

Самый простой случай — нам нужен атрибут, который нельзя изменить снаружи. Например, идентификатор пользователя: он задаётся при создании объекта и больше никогда не меняется.

Для этого достаточно написать только геттер — метод с @property и без соответствующего сеттера:

class User:
    def __init__(self, user_id, name):
        self._user_id = user_id  # Приватный атрибут — храним внутри
        self.name = name

    @property
    def user_id(self):
        """Возвращает идентификатор пользователя (только для чтения)."""
        return self._user_id


# Создаём пользователя
user = User(42, "Алиса")

# Читаем user_id как обычный атрибут — без скобок!
print(user.user_id)   # 42
print(user.name)      # Алиса

Заметьте: user.user_id — без скобок, как у обычного атрибута. Но на самом деле Python вызывает метод user_id(self) и возвращает результат. Это и есть магия @property.

Теперь попробуем присвоить новое значение:

# Попытка изменить readonly-атрибут
user.user_id = 99
# AttributeError: can't set attribute 'user_id'

🚫 AttributeError — Python говорит: «у этого атрибута нет сеттера, запись запрещена». Это и есть защита «только для чтения». Если вы определили @property без @attr.setter, атрибут становится read-only автоматически.

✅ @property + сеттер с валидацией возраста

Теперь добавим возможность изменять атрибут — но с проверкой. Сеттер объявляется через @имя_атрибута.setter. Посмотрим на полный пример с возрастом:

class Person:
    def __init__(self, name, age):
        self._name = name
        self._age = None        # Изначально None
        self.age = age          # Присвоение через сеттер — сразу проверит!

    @property
    def age(self):
        """Геттер: вызывается при чтении person.age"""
        return self._age

    @age.setter
    def age(self, value):
        """Сеттер: вызывается при записи person.age = что-то"""
        if not isinstance(value, int):
            raise TypeError(f"Возраст должен быть целым числом, получено: {type(value).__name__}")
        if value < 0 or value > 150:
            raise ValueError(f"Некорректный возраст: {value}. Допустимо от 0 до 150")
        self._age = value       # Сохраняем в ПРИВАТНЫЙ атрибут _age


# Создаём объект
p = Person("Боб", 30)
print(p.age)   # 30  — вызывается геттер

# Изменяем возраст — вызывается сеттер с проверкой
p.age = 31
print(p.age)   # 31

# Валидация работает!
p.age = -1     # ValueError: Некорректный возраст: -1. Допустимо от 0 до 150
p.age = "тридцать"  # TypeError: Возраст должен быть целым числом, получено: str

Давайте разберём синтаксис пошагово:

@property

Это «геттер». Пишется первым. Имя метода — это и будет имя «виртуального атрибута».

@age.setter

Это «сеттер». Пишется после геттера. Перед .setter — то же самое имя, что у геттера.

self._age

Данные хранятся в приватном атрибуте _age, не в age. Это критично — подробнее скоро!

🎯 Обратите внимание: в __init__ написано self.age = age (без подчёркивания). Это не случайно — мы намеренно вызываем сеттер, чтобы он проверил значение прямо при создании объекта. Если бы написали self._age = age, проверка при инициализации обошлась бы стороной.

🧮 Вычисляемые атрибуты: full_name

Вычисляемый атрибут — это атрибут, который не хранится в памяти, а вычисляется «на лету» каждый раз, когда к нему обращаются. Классический пример — полное имя из имени и фамилии.

Нет смысла хранить full_name отдельно: оно всегда равно first_name + " " + last_name. Если хранить все три, они могут разъехаться. Лучше вычислять:

class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    @property
    def full_name(self):
        """Вычисляется на лету — не хранится в памяти."""
        return f"{self.first_name} {self.last_name}"


p = Person("Иван", "Иванов")
print(p.full_name)      # Иван Иванов  — вызвался метод, но выглядит как атрибут

# Если изменить первую или последнюю часть — full_name тоже изменится
p.first_name = "Пётр"
print(p.full_name)      # Пётр Иванов  — актуально!

p.last_name = "Петров"
print(p.full_name)      # Пётр Петров

У этого full_name нет сеттера — он вычисляемый и только для чтения. Что если попробовать присвоить?

p.full_name = "Сергей Сергеев"
# AttributeError: can't set attribute 'full_name'

💡 Это правильно. Если вы хотите «изменить полное имя» — изменяйте его составные части: p.first_name = "Сергей". Хранить одни и те же данные в двух местах — верный путь к ошибкам.

🗑️ @deleter: удаление атрибута

Кроме геттера и сеттера есть ещё делитер — он вызывается при del obj.attr. Нужен редко, но знать полезно. Например, чтобы очистить кэш или сбросить значение в None:

class Person:
    def __init__(self, name, age):
        self._name = name
        self._age = None
        self.age = age

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

    @age.setter
    def age(self, value):
        if not isinstance(value, int) or value < 0:
            raise ValueError("Некорректный возраст")
        self._age = value

    @age.deleter
    def age(self):
        """Вызывается при del person.age"""
        print("Возраст сброшен")
        self._age = None


p = Person("Мария", 28)
print(p.age)   # 28

del p.age      # Возраст сброшен
print(p.age)   # None

⚠️ Делитер нужен редко. В реальном коде он встречается значительно реже геттера и сеттера. Главное — знайте о его существовании и синтаксисе: @имя.deleter.

📋 Когда применять @property

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

🛡️

Валидация данных

Проверить значение перед записью. Возраст ≥ 0, email содержит «@», цена > 0.

🧮

Вычисляемые поля

full_name, area (площадь прямоугольника из ширины и высоты), bmi (индекс массы тела).

🔒

Только для чтения

Атрибут, который задаётся при создании и больше никогда не меняется: user_id, created_at.

💤

Ленивые вычисления

Дорогое вычисление делается один раз при первом обращении и кэшируется в _value.

❌ Когда НЕ нужен @property: если у вас простое поле без логики — просто используйте прямой атрибут. Добавление @property ради @property — лишнее усложнение.

💀 ГЛАВНЫЙ ПОДВОДНЫЙ КАМЕНЬ: бесконечная рекурсия

Это самая частая ошибка новичков при работе с @property. Её обязательно нужно понять, иначе вы потратите часы на дебаггинг таинственного RecursionError.

Суть ошибки: в сеттере вы используете self.age = value вместо self._age = value. Звучит безобидно, но на деле — катастрофа.

Посмотрим на код с ошибкой:

# ❌ НЕПРАВИЛЬНО — бесконечная рекурсия!
class Person:
    def __init__(self, age):
        self.age = age      # Вызовет сеттер → сеттер вызовет сеттер → ...

    @property
    def age(self):
        return self.age     # ← ОП! self.age → вызывает ЭТОТ ЖЕ геттер!

    @age.setter
    def age(self, value):
        if value < 0:
            raise ValueError("Возраст не может быть отрицательным")
        self.age = value    # ← ОП! self.age = ... → вызывает ЭТОТ ЖЕ сеттер!


p = Person(25)
# RecursionError: maximum recursion depth exceeded

Что происходит пошагово в сеттере при self.age = value:

🔄 Почему возникает рекурсия:

  1. Вы пишете self.age = 25 → Python видит, что age — это property с сеттером
  2. Python вызывает age.setter(self, 25)
  3. Внутри сеттера написано self.age = value → Python снова видит property с сеттером
  4. Python снова вызывает age.setter(self, 25) → переходим к шагу 3
  5. Это повторяется тысячи раз, пока Python не упадёт с ошибкой RecursionError

Решение простое: данные нужно хранить в приватном атрибуте с подчёркиванием. Обратиться к self._age — это прямой доступ к обычному атрибуту, никаких рекурсий:

# ✅ ПРАВИЛЬНО — храним в _age, а property называется age
class Person:
    def __init__(self, age):
        self.age = age      # Вызовет сеттер → сеттер запишет в self._age → всё!

    @property
    def age(self):
        return self._age    # ← Читаем из ПРИВАТНОГО _age (обычный атрибут)

    @age.setter
    def age(self, value):
        if value < 0:
            raise ValueError("Возраст не может быть отрицательным")
        self._age = value   # ← Пишем в ПРИВАТНЫЙ _age (обычный атрибут)


p = Person(25)
print(p.age)   # 25  — работает!
p.age = 26     # Работает!
p.age = -1     # ValueError: Возраст не может быть отрицательным

🎯 Золотое правило @property:

Имя property — age (без подчёркивания) — это то, что видит пользователь класса.
Хранилище данных — _age (с подчёркиванием) — это приватный атрибут, куда реально пишутся данные. Эти два имени никогда не должны совпадать.

🏗️ Полный пример: класс Person со всеми возможностями

Соберём всё изученное в один класс. Он демонстрирует: readonly-атрибут, атрибут с валидацией и вычисляемый атрибут:

class Person:
    def __init__(self, person_id, first_name, last_name, age):
        self._person_id = person_id  # Readonly — задаётся один раз
        self.first_name = first_name
        self.last_name = last_name
        self.age = age               # Через сеттер — сразу проверит значение

    # --- Readonly атрибут ---
    @property
    def person_id(self):
        """ID нельзя изменить после создания."""
        return self._person_id

    # --- Вычисляемый атрибут ---
    @property
    def full_name(self):
        """Собирается из first_name и last_name — не хранится."""
        return f"{self.first_name} {self.last_name}"

    # --- Атрибут с валидацией ---
    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if not isinstance(value, int):
            raise TypeError("Возраст — целое число")
        if value < 0 or value > 150:
            raise ValueError(f"Некорректный возраст: {value}")
        self._age = value

    def __str__(self):
        return f"Person(id={self.person_id}, name={self.full_name}, age={self.age})"


# Используем класс
p = Person(1, "Иван", "Иванов", 30)
print(p)               # Person(id=1, name=Иван Иванов, age=30)
print(p.person_id)     # 1
print(p.full_name)     # Иван Иванов
print(p.age)           # 30

# Изменяем что можно
p.age = 31
p.first_name = "Пётр"
print(p.full_name)     # Пётр Иванов  — обновился автоматически!

# Защита работает
p.person_id = 99       # AttributeError: can't set attribute 'person_id'
p.age = 200            # ValueError: Некорректный возраст: 200
p.full_name = "Кто-то" # AttributeError: can't set attribute 'full_name'

✅ Итоги урока

  • @property превращает метод в «виртуальный атрибут» — обращаться к нему нужно без скобок: obj.attr.
  • Геттер — @property — вызывается при чтении. Сеттер — @attr.setter — при записи. Делитер — @attr.deleter — при del obj.attr.
  • Если у property нет сеттера — атрибут read-only: попытка записи даёт AttributeError.
  • Вычисляемые атрибуты — значение собирается из других полей каждый раз при обращении.
  • ⚠️ Главный подводный камень: в теле геттера/сеттера никогда не обращайтесь к self.age — только к self._age. Иначе получите RecursionError.
  • Правило: публичное имя — age, хранилище — _age. Эти имена никогда не совпадают.
  • @property применяют для: валидации, вычисляемых полей, readonly-атрибутов, ленивых вычислений.

👉 В следующем уроке мы познакомимся со статическими методами и методами класса — @staticmethod и @classmethod. Вы поймёте, чем они отличаются от обычных методов и зачем нужны в реальных проектах.