Объекты в работе: состояние, сокрытие данных, свойства и полноценный класс

Цели урока
  • Работать со списком объектов — перебор, поиск и сортировка — и с объектом, который хранит объект
  • Объяснить, что происходит, когда два имени указывают на один объект
  • Скрывать данные за публичным интерфейсом и различать _name и __name
  • Обеспечивать соблюдение правил при каждом присваивании с помощью @property и сеттера, писать поле только для чтения и вычисляемое поле
  • Проектировать и строить полноценный класс — от ответственности в одно предложение через правила к понятному интерфейсу

В этом уроке

Урок построен как одна история, в том же порядке, что и в презентации. Каждая глава опирается на предыдущую:

  1. Глава 1 — объекты и состояние: много объектов, меняющееся состояние и объект, который хранит объект
  2. Глава 2 — сокрытие данных: любой код может испортить данные, поэтому мы прячем их за интерфейсом
  3. Глава 3 — проверка: простой синтаксис поля и проверка при каждом присваивании с @property
  4. Глава 4 — полноценный класс: соединяем всё в один класс с ответственностью, правилами и интерфейсом

В конце урока — практика и мини-проект, который связывает все главы.

Глава 1: Объекты и состояние — много объектов и меняющееся состояние

Что мы изучим?

  • Как управлять множеством объектов: списки, поиск, фильтрация и сортировка.
  • Как состояние (State) объектов меняется на протяжении программы.
  • Объект, который хранит другой объект (например, заказ, который хранит клиента).
  • Ссылки: два имени для одного объекта и два объекта.

Зачем это нужно?

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

Объяснение с основ

Объекты в списке

Список может хранить объекты точно так же, как числа или словари. Всё, что мы изучили в темах Python, работает:

ДействиеСо словарями (темы Python)С объектами
Доступ к полюitem["price"]item.price
Сортировкаsorted(items, key=lambda i: i["price"])sorted(items, key=lambda i: i.price)
Фильтрацияif item["price"] > 100if item.price > 100
Действиеdeposit(account, 50)account.deposit(50)

Состояние во времени

Состояние = значения атрибутов сейчас. Объект «живёт» на протяжении программы, и его состояние меняется через методы: заказ создаётся в состоянии "new", переходит в "paid", а затем в "shipped". Каждый объект проходит путь отдельно.

Объект, который хранит объект

Атрибут может хранить любое значение, включая другой объект:

order.customer = dana      # the order "knows" its customer

order.customer является объектом Customer, поэтому можно продолжить: order.customer.name.

Ссылки — важное напоминание

Вспомните из тем Python: переменная — наклейка на объекте. Это верно и для наших собственных объектов:

dana ──────────────►┌──────────────┐
order1.customer ───►│ Customer     │   ONE object, three names
order2.customer ───►│ name: "Dana" │
                    └──────────────┘

Если Дана меняет адрес (dana.city = "Tel Aviv"), оба заказа сразу видят новый адрес. Они хранят не копию, а ссылку на ту же Дану. Обычно именно это и нужно: в системе одна Дана.

Аналогия: контакт в телефоне

В нескольких группах WhatsApp есть «Дана». Когда Дана меняет фото профиля, оно обновляется во всех группах, потому что группы хранят не фотографию Даны, а ссылку на настоящую Дану.

Синтаксис

objects = [ClassName(...), ClassName(...)]

for obj in objects:                       # loop
    obj.method()

found = None
for obj in objects:                       # search
    if obj.attr == target:
        found = obj
        break

matching = [obj for obj in objects if obj.attr > 10]   # filter (list comprehension)

ordered = sorted(objects, key=lambda obj: obj.attr)    # sort

holder.other = some_object                # an attribute that holds another object
holder.other.attr                         # chain through it

Списковое включение (List Comprehension, третья строка снизу) — короткий способ построить отфильтрованный список. Оно равносильно циклу:

matching = []
for obj in objects:
    if obj.attr > 10:
        matching.append(obj)

Используйте ту версию, которая для вас читается лучше. Обе правильные.

Первый пример кода

week-3/team.py:

class Player:
    def __init__(self, name, score=0):
        self.name = name
        self.score = score

    def add_points(self, points):
        self.score = self.score + points

    def __str__(self):
        return self.name + ": " + str(self.score)

team = [Player("Dana"), Player("Avi"), Player("Noa")]

team[0].add_points(30)
team[1].add_points(15)
team[2].add_points(40)
team[0].add_points(20)

for player in team:
    print(player)

leader = max(team, key=lambda player: player.score)
print("Leader:", leader.name)

ranking = sorted(team, key=lambda player: player.score, reverse=True)
for place, player in enumerate(ranking, start=1):
    print(place, player)

Вывод:

Dana: 50
Avi: 15
Noa: 40
Leader: Dana
1 Dana: 50
2 Noa: 40
3 Avi: 15

Разбор кода построчно

КодОбъяснение
team = [Player("Dana"), ...]Список из 3 отдельных объектов, у каждого свой score.
team[0].add_points(30)team[0] — объект Player. Вызываем его метод. Меняется только его состояние.
team[0].add_points(20)Снова тот же объект. Его очки складываются до 50.
max(team, key=lambda p: p.score)Находит объект с наибольшим счётом и возвращает весь объект.
sorted(..., reverse=True)Новый отсортированный список. Сам team не изменился. Но объекты те же самые, отличается только их порядок в новом списке.

Ещё один пример

Клиенты и заказы — объекты, которые хранят объекты, и меняющееся состояние. week-3/orders.py:

class Customer:
    def __init__(self, name, city):
        self.name = name
        self.city = city

class Order:
    VALID_STATUSES = ["new", "paid", "shipped"]
    next_number = 1

    def __init__(self, customer, total):
        self.number = Order.next_number
        Order.next_number = Order.next_number + 1
        self.customer = customer
        self.total = total
        self.status = "new"

    def advance(self):
        position = Order.VALID_STATUSES.index(self.status)
        if position == len(Order.VALID_STATUSES) - 1:
            raise ValueError("order " + str(self.number) + " is already shipped")
        self.status = Order.VALID_STATUSES[position + 1]

    def __str__(self):
        return ("Order #" + str(self.number) + " | " + self.customer.name + " (" + self.customer.city
                + ") | " + str(self.total) + " NIS | " + self.status)

dana = Customer("Dana", "Haifa")
avi = Customer("Avi", "Eilat")

orders = [Order(dana, 120), Order(avi, 80), Order(dana, 300)]

orders[0].advance()
orders[0].advance()
orders[2].advance()

dana.city = "Tel Aviv"

for order in orders:
    print(order)

dana_orders = [order for order in orders if order.customer is dana]
print("Dana's total:", sum(order.total for order in dana_orders))

not_shipped = [order.number for order in orders if order.status != "shipped"]
print("Not shipped yet:", not_shipped)

Вывод:

Order #1 | Dana (Tel Aviv) | 120 NIS | shipped
Order #2 | Avi (Eilat) | 80 NIS | new
Order #3 | Dana (Tel Aviv) | 300 NIS | paid
Dana's total: 420
Not shipped yet: [2, 3]

Разбор:

  • Order(dana, 120) — заказ хранит ссылку на объект dana. Он не копирует имя и город.
  • self.customer.name — цепочка: от заказа к клиенту, а от него к имени клиента.
  • dana.city = "Tel Aviv" — одно изменение, и оба заказа Даны показывают новый город. Если бы мы сохранили self.city = customer.city в __init__, заказы «застряли» бы с Хайфой.
  • advance() — простой конечный автомат (State Machine): new → paid → shipped, и нельзя перепрыгнуть или вернуться. Каждый заказ продвигается отдельно.
  • order.customer is dana — здесь is правильный: мы действительно спрашиваем «это тот же объект?» (а не «у него то же имя?», ведь может быть два клиента по имени Дана).
  • sum(order.total for order in dana_orders) — сумма в одну строку (генераторное выражение, Generator Expression, как в max из тем Python).

Итоги

  • Объекты в списке работают как любое значение: перебор, поиск, sorted/max с key=lambda obj: obj.attr.
  • Каждый объект хранит своё состояние, которое меняется через его методы.
  • Атрибут может хранить другой объект (order.customer), и к нему обращаются цепочкой: order.customer.name.
  • Это ссылка, а не копия. Изменение объекта видно всем, кто его хранит.
  • b = a = ещё одно имя, а не новый объект. is проверяет «тот же объект».
  • Изменение состояния «снаружи» обходит правила. В следующей главе: инкапсуляция.

Официальная документация

Глава 2: Сокрытие данных — инкапсуляция

Что мы изучим?

  • Что такое инкапсуляция (Encapsulation) и зачем она существует.
  • Публичный интерфейс (Public Interface) и внутренняя реализация (Implementation).
  • Соглашения Python: _protected и __private.
  • Что такое искажение имён (Name Mangling).

Зачем это нужно?

До сих пор мы писали методы с хорошими проверками, например withdraw, которая проверяет, что денег достаточно. Но:

account = BankAccount("Dana", 100)
account.balance = -1_000_000

Одна строка обошла все правила. Ни ошибки, ни лога. Тщательно написанный withdraw просто не выполнился. В настоящей системе, с десятками разработчиков и сотнями файлов, кто-нибудь напишет такую строку (может быть, вы сами через месяц, «просто для проверки»).

Ещё одна проблема: допустим, мы решили хранить баланс в агорот (копейках, balance_cents), чтобы избежать проблем с округлением. Если в 30 местах кода обращаются к account.balance напрямую, все они сломаются. Если бы они пользовались методами, мы бы изменили только методы.

Инкапсуляция решает обе проблемы.

Объяснение с основ

Что такое инкапсуляция?

Инкапсуляция (Encapsulation) = упаковка данных вместе с кодом, который с ними работает, и сокрытие внутренних деталей, чтобы внешний мир работал только через определённый интерфейс.

Две идеи:

  1. Защита правил (инвариантов) — чувствительные данные меняются только через методы, которые проверяют, что они остаются допустимыми. «Баланс не отрицательный», «оценка от 0 до 100», «статус движется только вперёд».
  2. Сокрытие реализации — тому, кто пользуется классом, нужно знать, что он делает, а не как. Так можно менять «как», не ломая того, кто пользуется.

Публичный интерфейс и реализация

Публичный интерфейсРеализация
ЧтоМетоды и атрибуты, которые предназначены для использования снаружиДанные и внутренние методы, благодаря которым интерфейс работает
Пример в банковском счётеdeposit(), withdraw(), get_balance()Как хранится баланс, список операций, вспомогательные проверки
СтабильностьМеняется редко. От него зависят другиеМожно свободно менять
Аналогия: банкомат

У банкомата есть публичный интерфейс: экран, клавиши, щель для карты, отверстие для купюр. Можно «снять 200», и банкомат проверяет код, баланс и дневной лимит. Сейф внутри (реализация) заперт. Если бы кто угодно мог открыть сейф и взять деньги напрямую, все проверки потеряли бы смысл. И банк может заменить механизм подсчёта внутри так, что вы не заметите, потому что интерфейс остаётся тем же.

Как это делает Python: соглашения

В языках вроде Java есть настоящая «блокировка» (private). В Python абсолютной блокировки нет. Есть соглашения, которые уважают все разработчики на Python:

ЗаписьНазваниеСмыслЧто делает Python
namePublic (публичный)Часть интерфейса. Можно использовать снаружиНичего
_nameProtected (соглашение)«Внутреннее, не трогать снаружи»Ничего. Это только сообщение разработчикам. VS Code также не предложит его в автодополнении
__namePrivate (искажение имён)«Очень внутреннее»Меняет имя за кулисами, чтобы обращение снаружи «по ошибке» не сработало

Философия Python: «Мы все здесь взрослые люди». Язык доверяет вам. Подчёркивание говорит «это не для вас». Кто всё равно трогает — берёт ответственность на себя.

Искажение имён (Name Mangling)

Когда вы пишете self.__balance внутри класса BankAccount, Python на самом деле меняет имя на _BankAccount__balance. Поэтому account.__balance снаружи падает с AttributeError. Это не безопасность (обратиться всё ещё можно через account._BankAccount__balance), но это предотвращает доступ по ошибке и делает намерение очень ясным.

На этом курсе: мы в основном будем использовать _name (одно подчёркивание) — самый принятый способ в настоящем коде на Python. С __name знакомимся, чтобы знать, что он делает, когда встретим его.

Синтаксис

class ClassName:
    def __init__(self, value):
        self.public_value = value      # public: part of the interface
        self._internal = []            # protected by convention: "don't touch from outside"
        self.__secret = 0              # private: name-mangled to _ClassName__secret

    def public_method(self):           # public interface
        self._helper()
        return self.__secret

    def _helper(self):                 # internal helper method
        ...

Первый пример кода

week-3/encapsulated_account.py:

class BankAccount:
    def __init__(self, owner, balance=0):
        if balance < 0:
            raise ValueError("initial balance cannot be negative")
        self.owner = owner
        self._balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self._balance = self._balance + amount

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("withdrawal must be positive")
        if amount > self._balance:
            raise ValueError("insufficient funds")
        self._balance = self._balance - amount

    def get_balance(self):
        return self._balance

account = BankAccount("Dana", 100)
account.deposit(50)
account.withdraw(30)
print("Balance:", account.get_balance())

try:
    account.withdraw(1000)
except ValueError as error:
    print("Rejected:", error)
print("Balance still:", account.get_balance())

Вывод:

Balance: 120
Rejected: insufficient funds
Balance still: 120

Разбор кода построчно

КодОбъяснение
self.ownerПубличный. Можно читать, и нет особого правила, которое нужно защищать.
self._balanceProtected (соглашение). Подчёркивание говорит каждому разработчику: «баланс напрямую не меняют».
deposit / withdrawПубличный интерфейс для изменений: единственный достойный способ изменить баланс. Каждое изменение проходит проверку.
get_balance()Геттер: метод, который возвращает значение для чтения. Снаружи баланс можно читать, но нельзя менять напрямую.
account.withdraw(1000) не проходитИ баланс не изменился. Правило соблюдено.

А если кто-то всё же напишет account._balance = -999? Python его не остановит. Но подчёркивание превращает это в явную, заметную ошибку в коде: каждый, кто делает Code Review, сразу увидит «трогает _balance снаружи — нельзя». Именно в этом цель.

Ещё один пример

__private и искажение имён в действии, и внутренний вспомогательный метод. week-3/secure_vault.py:

class Vault:
    MAX_ATTEMPTS = 3

    def __init__(self, code):
        self.__code = code
        self._failed_attempts = 0
        self.is_locked = False

    def open(self, code):
        if self.is_locked:
            return "Vault is locked"
        if self._check_code(code):
            self._failed_attempts = 0
            return "Vault opened"
        self._failed_attempts = self._failed_attempts + 1
        if self._failed_attempts >= Vault.MAX_ATTEMPTS:
            self.is_locked = True
            return "Wrong code - vault is now LOCKED"
        return "Wrong code (" + str(self._failed_attempts) + "/" + str(Vault.MAX_ATTEMPTS) + ")"

    def _check_code(self, code):
        return code == self.__code

vault = Vault("1234")
print(vault.open("0000"))
print(vault.open("1234"))

try:
    print(vault.__code)
except AttributeError as error:
    print("AttributeError:", error)

print("Mangled name:", vault._Vault__code)

Вывод:

Wrong code (1/3)
Vault opened
AttributeError: 'Vault' object has no attribute '__code'
Mangled name: 1234

Разбор:

  • self.__code — внутри класса вы пишете __code, а Python на самом деле хранит его как _Vault__code.
  • vault.__code снаружи → AttributeError. «По ошибке» к нему не обратиться.
  • vault._Vault__code → работает! Искажение имён — не безопасность. Настоящий пароль никогда не хранят в коде (и уж точно не открытым текстом). Здесь это лишь пример для демонстрации.
  • _check_code — внутренний вспомогательный метод. Он часть реализации, а не интерфейса. Завтра его можно заменить зашифрованной проверкой, и никакой внешний код не сломается, потому что снаружи никто не должен его вызывать.
  • is_locked — публичный, для чтения. (Снаружи в него можно и записать, и это маленькая «дыра». В следующей главе увидим, как сделать атрибут только для чтения с помощью @property.)

Итоги

  • Инкапсуляция = данные + код, который с ними работает, вместе, с открытием только интерфейса.
  • Зачем: (1) защита правил: изменения только через методы, которые проверяют. (2) сокрытие реализации: можно менять «как», не ломая того, кто пользуется.
  • name = публичный. _name = «внутреннее, не трогать» (соглашение, самое распространённое). __name = искажение имён (_Class__name).
  • Python не блокирует. Соглашения делают намерение ясным и предотвращают ошибки. Это не безопасность.
  • Возвращайте копию внутреннего списка/словаря, а не оригинал.
  • Не прячьте всё подряд. Прячьте, когда есть правило или деталь реализации.

Официальная документация

Глава 3: Проверка — геттеры, сеттеры и @property

Что мы изучим?

  • Что такое геттер (Getter) и сеттер (Setter).
  • Проверку данных (Validation): проверку каждого нового значения до его сохранения.
  • @property: способ Python делать это элегантно.
  • Атрибут только для чтения и вычисляемый атрибут.

Зачем это нужно?

В предыдущей главе мы защитили _balance, потому что у него были естественные методы (deposit, withdraw). А как быть с данными, которые должны меняться напрямую, только в рамках правил?

  • Возраст пользователя: его можно обновить, но не на (-5) и не на 300.
  • Цена товара: её можно изменить, но не на отрицательную.
  • Email: его можно обновить, но только на адрес с @.

Если age — обычный атрибут, кто угодно может написать user.age = -5. Если его спрятать (_age), нужен упорядоченный способ читать и менять его. Это и есть геттер и сеттер.

Объяснение с основ

Геттер и сеттер — идея

  • Геттер — метод, который возвращает значение внутренних данных. get_age().
  • Сеттер — метод, который получает новое значение, проверяет его и сохраняет, только если оно допустимо. set_age(new_age).

Так у класса полный контроль: каждое чтение и каждая запись проходят через него.

Аналогия: банковский служащий

Вы не идёте сами к своей папке в архиве банка, чтобы ручкой поменять адрес. Вы обращаетесь к служащему (сеттер): он проверяет, что адрес допустим и что у вас есть право, и только потом обновляет. А тот, кто хочет увидеть адрес, спрашивает служащего (геттер). Служащий может решать, что показать, и, возможно, скрыть часть деталей.

Проблема с get_ и set_ в Python

Это работает, но громоздко:

user.set_age(user.get_age() + 1)

По сравнению с:

user.age = user.age + 1

Кроме того, если сначала age был обычным атрибутом и в 50 местах кода написано user.age, а потом вы решили, что нужна проверка, придётся поменять все 50 мест на get_/set_.

@property — решение Python

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

  • Когда вы пишете user.age, вызывается метод-геттер.
  • Когда вы пишете user.age = 30, вызывается метод-сеттер с проверкой.

Код, который пользуется классом, пишет user.age, как обычно, но за кулисами каждое обращение проходит через проверки. Лучшее из обоих миров.

@property — это декоратор, ровно то, что мы изучили в уроке о декораторах: строка, начинающаяся с @ над def, которая «оборачивает» функцию дополнительным поведением. Здесь достаточно знать шаблон @property. (В темах бэкенда и баз данных FastAPI будет использовать декораторы вроде @app.get(...).)

Синтаксис

Версия 1 — классические методы геттер/сеттер (идея):

class ClassName:
    def __init__(self, value):
        self._value = None
        self.set_value(value)          # validation also on creation

    def get_value(self):
        return self._value

    def set_value(self, new_value):
        if not valid(new_value):
            raise ValueError("...")
        self._value = new_value

Версия 2 — @property (принятый способ в Python):

class ClassName:
    def __init__(self, value):
        self.value = value             # goes through the setter below!

    @property
    def value(self):                   # getter: runs on  obj.value
        return self._value

    @value.setter
    def value(self, new_value):        # setter: runs on  obj.value = x
        if not valid(new_value):
            raise ValueError("...")
        self._value = new_value
  • У обоих методов одинаковое имя (value) — имя публичного «атрибута».
  • Настоящие данные хранятся под другим именем, с подчёркиванием (_value).
  • @property — над геттером. @value.setter (имя свойства + .setter) — над сеттером.
  • Только для чтения: определите только @property, без сеттера.

Первый пример кода

Идея — классические геттер и сеттер. week-3/user_classic.py:

class User:
    MIN_AGE = 0
    MAX_AGE = 120

    def __init__(self, name, age):
        self.name = name
        self._age = None
        self.set_age(age)

    def get_age(self):
        return self._age

    def set_age(self, new_age):
        if not isinstance(new_age, int):
            raise TypeError("age must be an int, got " + type(new_age).__name__)
        if new_age < User.MIN_AGE or new_age > User.MAX_AGE:
            raise ValueError("age must be between 0 and 120, got " + str(new_age))
        self._age = new_age

dana = User("Dana", 24)
dana.set_age(dana.get_age() + 1)
print(dana.name, dana.get_age())

for bad_value in [-5, "thirty"]:
    try:
        dana.set_age(bad_value)
    except (ValueError, TypeError) as error:
        print("Rejected:", error)

print("Age unchanged:", dana.get_age())

Вывод:

Dana 25
Rejected: age must be between 0 and 120, got -5
Rejected: age must be an int, got str
Age unchanged: 25

Разбор кода построчно

КодОбъяснение
self._age = None, а затем self.set_age(age)Даже при создании возраст проходит через сеттер, поэтому нельзя создать пользователя с недопустимым возрастом. Проверка в одном месте.
get_age()Геттер: возвращает внутреннее значение.
set_age(new_age)Сеттер: сначала проверяет тип (isinstance), затем диапазон, и только потом сохраняет.
type(new_age).__name__Имя типа чистым текстом ("str") вместо <class 'str'>.
dana.set_age(dana.get_age() + 1)Работает, но громоздко. Это мотивация для @property.
Age unchanged: 25Недопустимые значения не сохранились.

Ещё один пример

Та же идея с @property, плюс атрибут только для чтения и вычисляемый атрибут. week-3/product_property.py:

class Product:
    def __init__(self, sku, name, price):
        self._sku = sku
        self.name = name
        self.price = price

    @property
    def sku(self):
        return self._sku

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, new_price):
        if not isinstance(new_price, (int, float)):
            raise TypeError("price must be a number")
        if new_price <= 0:
            raise ValueError("price must be positive, got " + str(new_price))
        self._price = new_price

    @property
    def price_with_vat(self):
        return round(self._price * 1.17, 2)

laptop = Product("A1", "Laptop", 3000)
print(laptop.sku, laptop.name, laptop.price, laptop.price_with_vat)

laptop.price = laptop.price * 0.9
print("After discount:", laptop.price, laptop.price_with_vat)

try:
    laptop.price = -100
except ValueError as error:
    print("Rejected:", error)

try:
    laptop.sku = "HACKED"
except AttributeError as error:
    print("Rejected:", error)

try:
    Product("A2", "Ghost", 0)
except ValueError as error:
    print("Cannot create:", error)

Вывод:

A1 Laptop 3000 3510.0
After discount: 2700.0 3159.0
Rejected: price must be positive, got -100
Rejected: property 'sku' of 'Product' object has no setter
Cannot create: price must be positive, got 0

Разбор:

  • laptop.price — выглядит как атрибут, но на самом деле запускает метод-геттер. Без скобок!
  • laptop.price = laptop.price * 0.9 — совершенно естественный синтаксис, но он проходит через сеттер с проверками.
  • self.price = price в __init__ — это тоже проходит через сеттер. Поэтому Product("A2", "Ghost", 0) падает уже при создании. Одна проверка для обоих случаев.
  • sku — @property без сеттера → только для чтения. Код товара не должен меняться после создания. Попытка изменить его выбрасывает AttributeError.
  • price_with_vat — вычисляемое свойство: у него нет собственных данных, и оно вычисляется из _price при каждом обращении. Всегда актуально (помните description, которое устарело? Вот ещё более элегантное решение).
  • name — остаётся обычным атрибутом, потому что на нём нет правила. Не добавляйте свойство без причины.

Итоги

  • Геттер возвращает внутренние данные. Сеттер получает значение, проверяет его и сохраняет. Так класс контролирует каждое чтение и запись.
  • В Python используют @property (геттер) и @name.setter (сеттер): снаружи это выглядит как обычный атрибут.
  • Настоящие данные хранятся под именем с подчёркиванием (_price). То же самое имя вызывает рекурсию.
  • В __init__ пишите self.price = price, чтобы и создание проходило через проверку.
  • Свойство без сеттера = только для чтения. Вычисляемое свойство = всегда актуально.
  • Не добавляйте свойство без правила. Начинайте с обычного атрибута и повышайте, когда нужно.

Официальная документация

Глава 4: Полноценный класс — ответственность, правила и интерфейс

Что мы изучим?

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

Зачем это нужно?

Настоящая трудность ООП не в синтаксисе, а в решениях: за что отвечает класс? Что открыть? Что скрыть? Какие правила он обязан соблюдать? Новички, которые сразу бросаются писать код, получают класс, который делает всё, открывает всё и нарушает правила. Фиксированный процесс это предотвращает.

Объяснение с основ

Процесс — 6 шагов

ШагВопросРезультат
1. ОтветственностьЧто делает этот класс, одним предложением?Предложение
2. ДанныеЧто ему нужно помнить?Список атрибутов
3. Публичный интерфейсЧто внешнему миру нужно делать с ним и знать о нём?Список методов и свойств
4. Правила (инварианты)Что всегда должно оставаться верным?Список правил
5. РеализацияКак данные хранятся внутри? Какие вспомогательные методы?Код
6. ТестированиеСоблюдено ли каждое правило? А граничные случаи?Проверочный скрипт

Инвариант (Invariant) = условие, которое должно быть верным в каждый момент жизни объекта. Например, «баланс ≥ 0» или «число записанных студентов ≤ числа мест». Каждый метод, который меняет состояние, должен оставлять все инварианты верными.

«Одно предложение» — проверка на SRP: если в предложении нужно «и ещё… и ещё…», это, вероятно, два класса.

Синтаксис

Нового синтаксиса нет. Мы объединим всё, что изучили:

class Name:
    CLASS_CONSTANT = ...                 # shared rules / constants

    def __init__(self, ...):             # validation through properties
        ...

    @property                            # read access / read-only / computed
    def value(self): ...

    def command(self, ...):              # changes state, keeps invariants
        ...

    def query(self): ...                 # returns info, changes nothing

    def _helper(self): ...               # internal

    def __str__(self): ...               # human-readable

Первый пример кода

Задача: система записи на курсы. Построим класс с именем Course по 6 шагам.

Шаг 1 — ответственность: «Course управляет записью на один курс: кто записан, сколько мест осталось и лист ожидания.»

Шаг 2 — данные: код курса, название, максимальное число мест, список записанных студентов, лист ожидания.

Шаг 3 — публичный интерфейс:

Член интерфейсаВидОписание
code, title, capacityсвойства (только для чтения)Данные курса
enroll(student)СеттерЗаписывает. Если мест нет, ставит студента в лист ожидания. Возвращает "enrolled" или "waitlisted"
drop(student)СеттерОтменяет запись. Место занимает первый из листа ожидания
is_enrolled(student)Геттер
available_seatsвычисляемое свойство
is_fullвычисляемое свойство
roster()ГеттерКопия списка записанных студентов
__str__Отображение

Шаг 4 — правила:

  1. Число записанных студентов ≤ capacity, всегда.
  2. Студент не записывается дважды и не бывает одновременно записанным и ожидающим.
  3. capacity — положительное целое число.
  4. Когда освобождается место и есть ожидающие студенты, проходит первый в очереди (FIFO, First In First Out).

Шаг 5 — реализация. week-3/course.py:

class Course:
    MAX_CAPACITY = 500

    def __init__(self, code, title, capacity):
        if not isinstance(capacity, int) or capacity <= 0 or capacity > Course.MAX_CAPACITY:
            raise ValueError("capacity must be a whole number between 1 and " + str(Course.MAX_CAPACITY))
        self._code = code
        self._title = title
        self._capacity = capacity
        self._enrolled = []
        self._waitlist = []

    @property
    def code(self):
        return self._code

    @property
    def title(self):
        return self._title

    @property
    def capacity(self):
        return self._capacity

    @property
    def available_seats(self):
        return self._capacity - len(self._enrolled)

    @property
    def is_full(self):
        return self.available_seats == 0

    def is_enrolled(self, student):
        return student in self._enrolled

    def enroll(self, student):
        self._ensure_not_registered(student)
        if self.is_full:
            self._waitlist.append(student)
            return "waitlisted"
        self._enrolled.append(student)
        return "enrolled"

    def drop(self, student):
        if student in self._waitlist:
            self._waitlist.remove(student)
            return
        if student not in self._enrolled:
            raise ValueError(student + " is not registered to " + self._code)
        self._enrolled.remove(student)
        if self._waitlist:
            next_student = self._waitlist.pop(0)
            self._enrolled.append(next_student)

    def roster(self):
        return list(self._enrolled)

    def waitlist(self):
        return list(self._waitlist)

    def _ensure_not_registered(self, student):
        if student in self._enrolled or student in self._waitlist:
            raise ValueError(student + " is already registered to " + self._code)

    def __str__(self):
        return (self._code + " " + self._title + " | " + str(len(self._enrolled)) + "/"
                + str(self._capacity) + " enrolled | waitlist: " + str(len(self._waitlist)))

Разбор кода построчно

ЧастьКак она служит проекту
Проверка в __init__Правило 3: нельзя создать курс с недопустимым capacity.
_enrolled, _waitlist (внутренние)Инкапсуляция: никто снаружи не может сделать append и обойти правило 1.
code, title, capacity как свойства без сеттераТолько для чтения: данные курса не меняются после создания.
available_seats, is_full вычисляютсяНет дублирующихся данных, которые могут рассинхронизироваться. Они всегда выводятся из _enrolled.
enrollПравило 2 (через _ensure_not_registered) и правило 1 (проверка is_full до добавления).
dropПравило 4: pop(0) = первый, кто встал в очередь, выходит первым.
roster() / waitlist() возвращают копиюТот, кто получил список, не может изменить внутренний.
_ensure_not_registeredВнутренний вспомогательный метод: одно правило в одном месте.
__str__Читаемый снимок для отладки и печати.

Ещё один пример

Шаг 6 — тестирование. Скрипт, который проверяет каждое правило, включая граничные случаи. В конце course.py:

def main():
    course = Course("PY101", "Intro to Python", 2)

    print(course.enroll("Dana"))
    print(course.enroll("Avi"))
    print(course.enroll("Noa"))
    print(course.enroll("Tom"))
    print(course)

    try:
        course.enroll("Dana")
    except ValueError as error:
        print("Rule 2 holds:", error)

    course.drop("Avi")
    print("After Avi dropped:", course.roster(), "| waitlist:", course.waitlist())

    leaked = course.roster()
    leaked.append("Hacker")
    print("Roster after outside append:", course.roster())

    try:
        course.drop("Ghost")
    except ValueError as error:
        print("Drop unknown:", error)

    try:
        Course("BAD1", "Broken", 0)
    except ValueError as error:
        print("Rule 3 holds:", error)

    print("Seats:", course.available_seats, "| full?", course.is_full)

if __name__ == "__main__":
    main()

Вывод:

enrolled
enrolled
waitlisted
waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 2
Rule 2 holds: Dana is already registered to PY101
After Avi dropped: ['Dana', 'Noa'] | waitlist: ['Tom']
Roster after outside append: ['Dana', 'Noa']
Drop unknown: Ghost is not registered to PY101
Rule 3 holds: capacity must be a whole number between 1 and 500
Seats: 0 | full? True

Что доказала проверка:

  • Правило 1: никогда не больше 2 записанных студентов. Третий и четвёртый попали в лист ожидания.
  • Правило 2: Дана не записалась дважды.
  • Правило 4: когда Ави ушёл, прошла Ноа (первая в очереди), а Том остался в листе ожидания.
  • Инкапсуляция: append к копии не изменил настоящий список.
  • Правило 3: курс с 0 мест не был создан.

Итоги

  • Процесс: ответственность (одно предложение) → данные → публичный интерфейс → правила (инварианты) → реализация → тестирование.
  • Инвариант = то, что всегда должно быть верным. Каждый сеттер должен сохранять их все.
  • Внутренние данные скрыты (_x). Фиксированные данные — свойства только для чтения. Производная информация — вычисляемые свойства.
  • Возвращайте копии внутренних коллекций. Одно правило → один вспомогательный метод (_ensure_...).
  • Проверяйте каждое правило и граничные случаи, а не только «счастливый путь».

Официальная документация

Практика

Упражнения опираются только на то, что пройдено в этом уроке и в предыдущих. Сначала попробуйте сами, и только потом открывайте решение.

Что вы отрабатываете на этой странице: список объектов, объект, который хранит другой объект, сокрытие данных (инкапсуляция), свойство с проверкой и полноценный класс с правилами.

К концу страницы вы будете уметь:

  • Перебирать список объектов и находить один по атрибуту
  • Строить объект, который хранит ссылку на другой объект
  • Скрывать данные за методами, которые проверяют каждое изменение
  • Писать свойство с сеттером, который обеспечивает соблюдение правила
  • Строить полноценный класс, который сам следит за своими правилами

Упражнение 1: Список игроков

Что вы строите: таблицу игроков в игре — и находите лидера.

Что написать:

  1. Класс с именем Player. __init__ получает имя, а счёт начинается с 0.
  2. add_points — получает количество очков и добавляет их к счёту.
  3. __str__ — возвращает имя и счёт, например Dana: 50.
  4. После проверочного кода: переберите список, найдите игрока с наибольшим счётом и напечатайте его.

Проверочный код:

players = [Player("Dana"), Player("Avi"), Player("Noa")]
players[0].add_points(50)
players[1].add_points(15)
players[2].add_points(40)

Желаемый вывод:

Leader: Dana: 50
Показать решение
class Player:
    def __init__(self, name):
        self.name = name
        self.score = 0

    def add_points(self, points):
        self.score = self.score + points

    def __str__(self):
        return self.name + ": " + str(self.score)


players = [Player("Dana"), Player("Avi"), Player("Noa")]
players[0].add_points(50)
players[1].add_points(15)
players[2].add_points(40)

leader = players[0]
for player in players:
    if player.score > leader.score:
        leader = player
print("Leader:", leader)
Leader: Dana: 50

Упражнение 2: Объект, который хранит объект

Что вы строите: клиента и заказы — когда несколько заказов хранят одного и того же клиента.

Что написать:

  1. Класс с именем Customer с именем и городом.
  2. Класс с именем Order с клиентом (объектом Customer) и суммой.
  3. Создайте одного клиента и два заказа, которые получают один и тот же объект клиента.
  4. Напечатайте город через оба заказа, смените город клиента на Tel Aviv и напечатайте снова.

Желаемый вывод:

Haifa Haifa
Tel Aviv Tel Aviv

Чему это учит: заказ хранит не копию клиента, а ссылку на него.

Показать решение
class Customer:
    def __init__(self, name, city):
        self.name = name
        self.city = city


class Order:
    def __init__(self, customer, total):
        self.customer = customer
        self.total = total


dana = Customer("Dana", "Haifa")
order1 = Order(dana, 120)
order2 = Order(dana, 80)
print(order1.customer.city, order2.customer.city)

dana.city = "Tel Aviv"
print(order1.customer.city, order2.customer.city)
Haifa Haifa
Tel Aviv Tel Aviv

Заказы хранят не копию клиента, а ссылку на него. Изменение клиента видно из каждого заказа, который его хранит.

Упражнение 3: Защищённый BankAccount

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

Что написать:

  1. Класс с именем BankAccount с владельцем и балансом. Храните баланс в _balance — подчёркивание говорит «внутреннее, не трогать снаружи».
  2. deposit — добавляет сумму.
  3. withdraw — снимает сумму. Если денег не хватает: ValueError с сообщением из вывода.
  4. get_balance — возвращает баланс.

Проверочный код:

account = BankAccount("Dana", 100)
account.deposit(50)
try:
    account.withdraw(500)
except ValueError as error:
    print("error:", error)
print(account.get_balance())

Желаемый вывод:

error: not enough money
150
Показать решение
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("amount must be positive")
        self._balance = self._balance + amount

    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("not enough money")
        self._balance = self._balance - amount

    def get_balance(self):
        return self._balance


account = BankAccount("Dana", 100)
account.deposit(50)
try:
    account.withdraw(500)
except ValueError as error:
    print("error:", error)
print(account.get_balance())
error: not enough money
150

Упражнение 4: Сеттер, который проверяет

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

Что написать:

  1. Класс с именем User, __init__ которого получает возраст и сохраняет его через свойство.
  2. Геттер с @property с именем age — возвращает возраст, сохранённый в _age.
  3. Сеттер с @age.setter — проверяет, что возраст от 0 до 120. Если нет: ValueError с сообщением из вывода.
  4. Обратите внимание: присваивание в __init__ тоже проходит через проверку.

Проверочный код:

user = User(30)
user.age = user.age + 1
print(user.age)
try:
    user.age = -5
except ValueError as error:
    print("error:", error)
print(user.age)

Желаемый вывод:

31
error: age must be 0-120
31
Показать решение
class User:
    def __init__(self, age):
        self.age = age

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

    @age.setter
    def age(self, value):
        if value < 0 or value > 120:
            raise ValueError("age must be 0-120")
        self._age = value


user = User(30)
user.age = user.age + 1
print(user.age)
try:
    user.age = -5
except ValueError as error:
    print("error:", error)
print(user.age)
31
error: age must be 0-120
31
  • self.age = age в __init__ проходит через сеттер — поэтому новый объект тоже проверяется.
  • Значение сохраняется в _age, а свойство открывает его так, будто это обычный атрибут.

Упражнение 5: Запись на курс

Что вы строите: запись на курс с ограниченным числом мест и листом ожидания.

Что написать:

  1. Класс с именем Course с кодом курса, названием и вместимостью, а также двумя внутренними списками: записанные и ожидающие.
  2. enroll — получает имя студента:
    • если он уже записан или ожидает: ValueError с сообщением из вывода;
    • если есть место: добавляет его в записанные и возвращает "enrolled";
    • иначе: добавляет его в лист ожидания и возвращает "waitlisted".
  3. __str__ — код, название, сколько записано из вместимости и сколько ожидает.

Проверочный код:

course = Course("PY101", "Intro to Python", 2)
for student in ["Dana", "Avi", "Noa"]:
    print(student, course.enroll(student))
print(course)
try:
    course.enroll("Dana")
except ValueError as error:
    print("error:", error)

Желаемый вывод:

Dana enrolled
Avi enrolled
Noa waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 1
error: Dana is already registered
Показать решение
class Course:
    def __init__(self, code, name, capacity):
        if capacity <= 0:
            raise ValueError("capacity must be positive")
        self.code = code
        self.name = name
        self.capacity = capacity
        self._enrolled = []
        self._waitlist = []

    def enroll(self, student):
        if student in self._enrolled or student in self._waitlist:
            raise ValueError(student + " is already registered")
        if len(self._enrolled) < self.capacity:
            self._enrolled.append(student)
            return "enrolled"
        self._waitlist.append(student)
        return "waitlisted"

    def __str__(self):
        return (self.code + " " + self.name + " | " + str(len(self._enrolled)) + "/"
                + str(self.capacity) + " enrolled | waitlist: " + str(len(self._waitlist)))


course = Course("PY101", "Intro to Python", 2)
for student in ["Dana", "Avi", "Noa"]:
    print(student, course.enroll(student))
print(course)
try:
    course.enroll("Dana")
except ValueError as error:
    print("error:", error)
Dana enrolled
Avi enrolled
Noa waitlisted
PY101 Intro to Python | 2/2 enrolled | waitlist: 1
error: Dana is already registered

Мини-проект: Склад магазина

Какую систему вы строите: систему учёта склада для маленького магазина. У каждого товара есть название, цена и количество на складе. Можно продавать и пополнять склад, и система следит, чтобы не произошло ничего недопустимого — отрицательной цены, продажи больше, чем есть, или двух товаров с одинаковым названием. В конце она показывает товары по стоимости и стоимость всего склада.

Чему вы научитесь в проекте:

  • Скрывать данные (инкапсуляция): менять их только через методы, которые проверяют
  • Использовать свойство: с проверяющим сеттером, только для чтения и вычисляемое
  • Строить класс, который хранит список других объектов
  • Сортировать объекты по вычисляемому значению

Что построить — шаг за шагом:

Часть А — товар (Product)

Шаг 1 — создание. __init__ получает название, цену и количество (по умолчанию 0). Название сохраняется в _name, количество — в _quantity, а цена проходит через сеттер price.

Шаг 2 — цена. price — свойство с сеттером, который проверяет, что цена больше 0. Иначе: ValueError.

Шаг 3 — поля только для чтения. name и quantity — свойства без сеттера: снаружи их можно читать, но нельзя менять.

Шаг 4 — изменение количества. Количество меняется только через два метода:

  • add_stock — добавляет количество. Если сумма не положительная: ValueError.
  • sell — убавляет количество. Если на складе не хватает: ValueError с названием товара.

Шаг 5 — вычисляемое значение и отображение. total_value — вычисляемое свойство: цена, умноженная на количество. __str__ показывает товар, как в выводе.

Часть Б — склад (Inventory)

Шаг 6 — внутренний список. Inventory хранит внутренний список с именем _products.

Шаг 7 — добавление и поиск. add добавляет товар. Если уже есть товар с таким же названием: ValueError. find получает название и возвращает товар или None.

Шаг 8 — отчёты. by_value возвращает копию списка товаров, отсортированную по total_value от наибольшей к наименьшей. total_value склада — вычисляемое свойство: сумма стоимостей всех товаров.

Сообщения об ошибках — именно такие:

price must be positive
amount must be positive
not enough <product name>
<product name> already exists

Проверочный код:

shop = Inventory()
shop.add(Product("Pen", 5, 10))
shop.add(Product("Bag", 60, 2))
shop.add(Product("Notebook", 12, 5))

shop.find("Pen").sell(4)
shop.find("Bag").add_stock(1)

try:
    shop.find("Notebook").sell(50)
except ValueError as error:
    print("error:", error)

try:
    shop.add(Product("Pen", 7))
except ValueError as error:
    print("error:", error)

try:
    shop.find("Bag").price = -3
except ValueError as error:
    print("error:", error)

for product in shop.by_value():
    print(product)
print("Total:", shop.total_value)

Желаемый вывод:

error: not enough Notebook
error: Pen already exists
error: price must be positive
Bag: 3 x 60 = 180
Notebook: 5 x 12 = 60
Pen: 6 x 5 = 30
Total: 270

К концу проекта вы будете уметь:

  • Строить объект, который защищает собственные данные, — снаружи его нельзя сломать
  • Выбирать между обычным полем, свойством с проверкой, полем только для чтения и вычисляемым значением
  • Строить «менеджер», который хранит объекты, ищет среди них и подводит по ним итоги

Бонус: добавьте в Inventory метод low_stock, который получает порог и возвращает названия товаров, количество которых меньше него.

Показать решение
class Product:
    def __init__(self, name, price, quantity=0):
        self._name = name
        self.price = price
        self._quantity = quantity

    @property
    def name(self):
        return self._name

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value <= 0:
            raise ValueError("price must be positive")
        self._price = value

    @property
    def quantity(self):
        return self._quantity

    def add_stock(self, amount):
        if amount <= 0:
            raise ValueError("amount must be positive")
        self._quantity = self._quantity + amount

    def sell(self, amount):
        if amount > self._quantity:
            raise ValueError("not enough " + self._name)
        self._quantity = self._quantity - amount

    @property
    def total_value(self):
        return self._price * self._quantity

    def __str__(self):
        return (self._name + ": " + str(self._quantity) + " x " + str(self._price)
                + " = " + str(self.total_value))


class Inventory:
    def __init__(self):
        self._products = []

    def add(self, product):
        if self.find(product.name) is not None:
            raise ValueError(product.name + " already exists")
        self._products.append(product)

    def find(self, name):
        for product in self._products:
            if product.name == name:
                return product
        return None

    def by_value(self):
        return sorted(self._products, key=lambda p: p.total_value, reverse=True)

    @property
    def total_value(self):
        total = 0
        for product in self._products:
            total = total + product.total_value
        return total


shop = Inventory()
shop.add(Product("Pen", 5, 10))
shop.add(Product("Bag", 60, 2))
shop.add(Product("Notebook", 12, 5))

shop.find("Pen").sell(4)
shop.find("Bag").add_stock(1)

try:
    shop.find("Notebook").sell(50)
except ValueError as error:
    print("error:", error)

try:
    shop.add(Product("Pen", 7))
except ValueError as error:
    print("error:", error)

try:
    shop.find("Bag").price = -3
except ValueError as error:
    print("error:", error)

for product in shop.by_value():
    print(product)
print("Total:", shop.total_value)
error: not enough Notebook
error: Pen already exists
error: price must be positive
Bag: 3 x 60 = 180
Notebook: 5 x 12 = 60
Pen: 6 x 5 = 30
Total: 270
  • Inventory хранит объекты Product (объект, который хранит объекты), а by_value возвращает новый список — sorted не трогает внутренний список.
  • _name, _price, _quantity скрыты. Снаружи количество меняется только через add_stock и sell, которые проверяют правила.
  • У price есть сеттер, который проверяет, — в том числе в __init__. name и quantity — только для чтения, а total_value вычисляется при каждом чтении.

Бонус:

    def low_stock(self, limit):
        names = []
        for product in self._products:
            if product.quantity < limit:
                names.append(product.name)
        return names

(Метод внутри Inventory.) С данными выше shop.low_stock(4) возвращает ['Bag'].