Объекты в работе: состояние, сокрытие данных, свойства и полноценный класс
- Работать со списком объектов — перебор, поиск и сортировка — и с объектом, который хранит объект
- Объяснить, что происходит, когда два имени указывают на один объект
- Скрывать данные за публичным интерфейсом и различать
_nameи__name - Обеспечивать соблюдение правил при каждом присваивании с помощью
@propertyи сеттера, писать поле только для чтения и вычисляемое поле - Проектировать и строить полноценный класс — от ответственности в одно предложение через правила к понятному интерфейсу
В этом уроке
Урок построен как одна история, в том же порядке, что и в презентации. Каждая глава опирается на предыдущую:
- Глава 1 — объекты и состояние: много объектов, меняющееся состояние и объект, который хранит объект
- Глава 2 — сокрытие данных: любой код может испортить данные, поэтому мы прячем их за интерфейсом
- Глава 3 — проверка: простой синтаксис поля и проверка при каждом присваивании с
@property - Глава 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"] > 100 | if item.price > 100 |
| Действие | deposit(account, 50) | account.deposit(50) |
Состояние во времени
Состояние = значения атрибутов сейчас. Объект «живёт» на протяжении программы, и его состояние меняется через методы: заказ создаётся в состоянии "new", переходит в "paid", а затем в "shipped". Каждый объект проходит путь отдельно.
Объект, который хранит объект
Атрибут может хранить любое значение, включая другой объект:
order.customer = dana # the order "knows" its customerorder.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) = упаковка данных вместе с кодом, который с ними работает, и сокрытие внутренних деталей, чтобы внешний мир работал только через определённый интерфейс.
Две идеи:
- Защита правил (инвариантов) — чувствительные данные меняются только через методы, которые проверяют, что они остаются допустимыми. «Баланс не отрицательный», «оценка от 0 до 100», «статус движется только вперёд».
- Сокрытие реализации — тому, кто пользуется классом, нужно знать, что он делает, а не как. Так можно менять «как», не ломая того, кто пользуется.
Публичный интерфейс и реализация
| Публичный интерфейс | Реализация | |
|---|---|---|
| Что | Методы и атрибуты, которые предназначены для использования снаружи | Данные и внутренние методы, благодаря которым интерфейс работает |
| Пример в банковском счёте | deposit(), withdraw(), get_balance() | Как хранится баланс, список операций, вспомогательные проверки |
| Стабильность | Меняется редко. От него зависят другие | Можно свободно менять |
У банкомата есть публичный интерфейс: экран, клавиши, щель для карты, отверстие для купюр. Можно «снять 200», и банкомат проверяет код, баланс и дневной лимит. Сейф внутри (реализация) заперт. Если бы кто угодно мог открыть сейф и взять деньги напрямую, все проверки потеряли бы смысл. И банк может заменить механизм подсчёта внутри так, что вы не заметите, потому что интерфейс остаётся тем же.
Как это делает Python: соглашения
В языках вроде Java есть настоящая «блокировка» (private). В Python абсолютной блокировки нет. Есть соглашения, которые уважают все разработчики на Python:
| Запись | Название | Смысл | Что делает Python |
|---|---|---|---|
name | Public (публичный) | Часть интерфейса. Можно использовать снаружи | Ничего |
_name | Protected (соглашение) | «Внутреннее, не трогать снаружи» | Ничего. Это только сообщение разработчикам. VS Code также не предложит его в автодополнении |
__name | Private (искажение имён) | «Очень внутреннее» | Меняет имя за кулисами, чтобы обращение снаружи «по ошибке» не сработало |
Философия 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._balance | Protected (соглашение). Подчёркивание говорит каждому разработчику: «баланс напрямую не меняют». |
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 — правила:
- Число записанных студентов ≤
capacity, всегда. - Студент не записывается дважды и не бывает одновременно записанным и ожидающим.
capacity— положительное целое число.- Когда освобождается место и есть ожидающие студенты, проходит первый в очереди (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: Список игроков
Что вы строите: таблицу игроков в игре — и находите лидера.
Что написать:
- Класс с именем
Player.__init__получает имя, а счёт начинается с 0. add_points— получает количество очков и добавляет их к счёту.__str__— возвращает имя и счёт, напримерDana: 50.- После проверочного кода: переберите список, найдите игрока с наибольшим счётом и напечатайте его.
Проверочный код:
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: Объект, который хранит объект
Что вы строите: клиента и заказы — когда несколько заказов хранят одного и того же клиента.
Что написать:
- Класс с именем
Customerс именем и городом. - Класс с именем
Orderс клиентом (объектомCustomer) и суммой. - Создайте одного клиента и два заказа, которые получают один и тот же объект клиента.
- Напечатайте город через оба заказа, смените город клиента на 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
Что вы строите: банковский счёт, который нельзя сломать, — баланс меняется только через методы, которые его проверяют.
Что написать:
- Класс с именем
BankAccountс владельцем и балансом. Храните баланс в_balance— подчёркивание говорит «внутреннее, не трогать снаружи». deposit— добавляет сумму.withdraw— снимает сумму. Если денег не хватает:ValueErrorс сообщением из вывода.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: Сеттер, который проверяет
Что вы строите: пользователя, у которого возраст всегда допустим, — со свойством, которое проверяет каждое присваивание.
Что написать:
- Класс с именем
User,__init__которого получает возраст и сохраняет его через свойство. - Геттер с
@propertyс именемage— возвращает возраст, сохранённый в_age. - Сеттер с
@age.setter— проверяет, что возраст от 0 до 120. Если нет:ValueErrorс сообщением из вывода. - Обратите внимание: присваивание в
__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
31self.age = ageв__init__проходит через сеттер — поэтому новый объект тоже проверяется.- Значение сохраняется в
_age, а свойство открывает его так, будто это обычный атрибут.
Упражнение 5: Запись на курс
Что вы строите: запись на курс с ограниченным числом мест и листом ожидания.
Что написать:
- Класс с именем
Courseс кодом курса, названием и вместимостью, а также двумя внутренними списками: записанные и ожидающие. enroll— получает имя студента:- если он уже записан или ожидает:
ValueErrorс сообщением из вывода; - если есть место: добавляет его в записанные и возвращает
"enrolled"; - иначе: добавляет его в лист ожидания и возвращает
"waitlisted".
- если он уже записан или ожидает:
__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: 270Inventoryхранит объекты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'].