Декоратор @property
В прошлом уроке мы увидели боль Java-стиля: добавил геттер/сеттер — сломал весь код, который использовал user.age. Сегодня разбираем питоническое решение: декоратор @property. Он превращает метод в «умный атрибут» — снаружи выглядит как обычное поле, но внутри может делать что угодно.
🔮 Что такое @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:
🔄 Почему возникает рекурсия:
- Вы пишете
self.age = 25→ Python видит, чтоage— это property с сеттером - Python вызывает
age.setter(self, 25) - Внутри сеттера написано
self.age = value→ Python снова видит property с сеттером - Python снова вызывает
age.setter(self, 25)→ переходим к шагу 3 - Это повторяется тысячи раз, пока 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. Вы поймёте, чем они отличаются от обычных методов и зачем нужны в реальных проектах.