Классы и объекты: от проблемы к полноценному классу

Цели урока
  • Распознавать проблемы процедурного кода по мере роста системы и объяснять, как их решает ООП
  • Определять класс (Class) и создавать из него несколько объектов, отличать класс от объекта (Object)
  • Писать __init__, который инициализирует полноценный объект и проверяет значения, и объяснять, что такое self
  • Добавлять методы — геттеры и сеттеры — и __str__, который отображает объект
  • Различать атрибут экземпляра (Instance Attribute) и атрибут класса (Class Attribute) и использовать общий счётчик
  • Превращать словесное описание в структуру классов — что является классом, что атрибутом, а что методом
  • Построить полноценный класс, который связывает все идеи, — в мини-проекте

В этом уроке

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

  1. Глава 1 — зачем нужно ООП: проблемы процедурного кода
  2. Глава 2 — класс и объект: шаблон и экземпляры
  3. Глава 3 — __init__ и self: объект, который рождается полноценным
  4. Глава 4 — методы: объект не только знает, но и делает
  5. Глава 5 — общие данные: атрибуты класса
  6. Глава 6 — что становится классом: от словесного требования к классам
  7. Глава 7 — назад в банк: всё вместе

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

Глава 1: Зачем нужно ООП — процедурный код и объекты

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

Вся эта часть курса посвящена ООП — объектно-ориентированному программированию (Object-Oriented Programming). Прежде чем написать хоть одну строку кода в стиле ООП, ответим на самый важный вопрос: зачем?

В этой главе:

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

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

Всё, что мы изучили до сих пор, работает. Любую программу можно написать с переменными, словарями, списками и функциями. Это называется процедурным программированием (Procedural Programming): данные с одной стороны, функции, которые над ними действуют, — с другой.

Но когда система растёт до десятков видов «вещей» и сотен функций, начинаются проблемы. ООП — способ, которым строятся почти все большие системы. И инструменты, которыми вы будете пользоваться (FastAPI, библиотеки Python), тоже написаны в стиле ООП. Чтобы их читать и использовать, нужно его понимать.

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

Проблема: система банковских счетов — без ООП

Построим маленькую систему управления банковскими счетами тем, что уже знаем:

account1 = {"owner": "Dana", "balance": 1000}
account2 = {"owner": "Avi", "balance": 500}

def deposit(account, amount):
    if amount <= 0:
        raise ValueError("amount must be positive")
    account["balance"] = account["balance"] + amount

def withdraw(account, amount):
    if amount > account["balance"]:
        raise ValueError("insufficient funds")
    account["balance"] = account["balance"] - amount

deposit(account1, 200)
withdraw(account2, 100)
print(account1, account2)
{'owner': 'Dana', 'balance': 1200} {'owner': 'Avi', 'balance': 400}

Работает! Так в чём проблема? Давайте увеличим систему.

Проблема 1: кто угодно может «трогать» данные

account1["balance"] = -999999
account2["balnce"] = 100
  • Первая строка обходит все проверки, которые мы написали в withdraw. Отрицательный баланс без всякой проверки.
  • Во второй строке опечатка. Создан новый ключ "balnce", и никакой ошибки нет (мы видели это в теме структур данных и функций).

Правила (не снимать больше, чем есть, положительная сумма) существуют, только если все помнят, что нужно пользоваться функциями. Ничто их к этому не принуждает.

Проблема 2: данные и действия разбросаны

Каждое действие со счётом — отдельная функция, которая получает словарь: deposit(account, ...), withdraw(account, ...), transfer(...), monthly_fee(...), print_statement(...). В настоящей системе это десятки функций в нескольких файлах. Тот, кто получил словарь счёта, не знает, какие действия с ним существуют и где они.

Проблема 3: нет определённой «структуры»

Как выглядит счёт? Что обязано в нём быть? Возможно, в одном месте кода кто-то создал {"owner": ..., "balance": ...}, а в другом — {"name": ..., "money": ...}. Нет одного места, которое определяет: «это банковский счёт, вот его поля, а вот его действия».

Проблема 4: похожие, но разные виды

Добавили «сберегательный счёт»: как обычный, но с процентами и без возможности снять ниже 1000. Теперь withdraw должна проверять: if account["type"] == "savings": ... elif .... И с каждым новым видом счёта все функции наполняются if.

Идея ООП

ООП организует код вокруг «вещей» (объектов), а не вокруг действий. Каждая «вещь» содержит вместе:

  • Свои данные: атрибуты (Attributes). Для счёта: владелец, баланс.
  • Действия, которые с ней можно делать: методы (Methods). Для счёта: внести, снять.
PROCEDURAL                          OBJECT-ORIENTED
data:      account dict             ┌──────────────────────────┐
functions: deposit(account, x)      │ BankAccount              │
           withdraw(account, x)     │   data:    owner, balance│
           print_statement(account) │   actions: deposit()     │
                                    │            withdraw()    │
"functions act ON data"             │            statement()   │
                                    └──────────────────────────┘
                                    "the object knows its own data
                                     and its own actions"

И как это решает проблемы?

ПроблемаРешение ООПИзучим в…
Кто угодно трогает данныеОбъект защищает свои данные, и изменения проходят через его действия (инкапсуляция, Encapsulation)Следующем уроке
Данные и действия разбросаныВсё внутри класса. account. + Tab в VS Code показывает всё, что можно сделатьСегодня
Нет определённой структурыОдин класс точно определяет, какие данные есть у каждого счётаСегодня
Похожие видыНаследование и полиморфизм: сберегательный счёт «наследует» обычный и меняет только то, что отличаетсяУроках о наследовании и полиморфизме

Шаблон и конкретная вещь

Самая базовая идея ООП уже существует в реальном мире:

Шаблон (Class)Конкретные вещи, созданные по нему (Objects)
Проект квартирыКвартира Даны на 3-м этаже, квартира Ави на 5-м этаже
Анкета «данные студента»Анкета, которую заполнила Дана, анкета, которую заполнил Ави
Шаблон производства машин на конвейереКрасная машина с номером 12-345-67
Понятие «банковский счёт»Счёт Даны с 1200 шекелей, счёт Ави с 400 шекелей
  • У всех квартир, построенных по одному проекту, одинаковая структура (комнаты, кухня), но разные значения (цвет стен, кто живёт).
  • Изменение квартиры Даны не меняет квартиру Ави.
  • Сам проект не является квартирой. В проекте жить нельзя.
Главная аналогия для всей части ООП

Класс (Class) = шаблон для производства машин. Он определяет, что у каждой машины есть цвет, модель и номер (атрибуты) и что каждая машина умеет ехать, тормозить и сигналить (методы). Объект (Object) = одна конкретная машина, произведённая по шаблону: красная, модель 2024, номер 12-345-67. По нему можно произвести тысячи машин, у каждой свой цвет и номер.

Синтаксис

Класс мы пока не пишем. В следующей главе изучим полный синтаксис. Но вот «вкус», чтобы увидеть, куда мы идём. Та же банковская система в стиле ООП:

class BankAccount:
    def __init__(self, owner, balance):
        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("insufficient funds")
        self.balance = self.balance - amount

account1 = BankAccount("Dana", 1000)
account1.deposit(200)
print(account1.balance)

Не пытайтесь сейчас понять каждое слово (class, __init__, self) — каждому из них дальше в уроке посвящена целая глава. Обратите внимание только на одно: данные (owner, balance) и действия (deposit, withdraw) находятся в одном месте, и вызывают их через объект: account1.deposit(200).

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

Вы всё время пользовались объектами, сами того не зная! В Python каждое значение — объект со своими данными и методами:

name = "dana cohen"
print(name.title())
print(name.upper())
print(type(name))

numbers = [3, 1, 2]
numbers.append(5)
numbers.sort()
print(numbers)
print(type(numbers))

Вывод:

Dana Cohen
DANA COHEN
<class 'str'>
[1, 2, 3, 5]
<class 'list'>

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

КодЧто это в ООП
name = "dana cohen"Создан объект типа str. Его данные — символы.
name.title()Вызов метода title объекта. Он «умеет» работать со своим текстом.
type(name) → <class 'str'>Класс (шаблон) объекта — str.
numbers.append(5)Метод объекта list, который меняет его собственные данные.
<class 'list'>list — тоже класс. Каждый созданный вами список — объект класса list.

Ключевой момент: str и list — классы, которые Python написал для нас. В этой части мы научимся писать собственные классы: BankAccount, Student, Product, подходящие для нашей задачи.

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

Ещё одна процедурная проблема: система студентов. Возьмите следующий код и попробуйте сами найти проблемы.

student1 = {"name": "Dana", "grades": [90, 85]}
student2 = {"name": "Avi", "grades": [70]}
student3 = {"nmae": "Noa", "grades": []}

def add_grade(student, grade):
    student["grades"].append(grade)

def average(student):
    return sum(student["grades"]) / len(student["grades"])

add_grade(student1, 100)
student2["grades"].append(500)
print(average(student1))
print(average(student2))
print(average(student3))
91.66666666666667
285.0
Traceback (most recent call last):
  ...
ZeroDivisionError: division by zero

Проблемы:

  1. student3 создан с опечаткой ("nmae"). Нет места, которое определяет, что «должно» быть у студента.
  2. student2["grades"].append(500) обошёл add_grade. Даже если бы add_grade проверяла, что оценка от 0 до 100, проверка бы не сработала. Среднее 285!
  3. average падает на студенте без оценок. И тот, кто пользуется словарём студента, должен знать, что эта функция существует, и не забыть обработать этот случай.

В ООП Student будет одним классом с определённой структурой (имя и оценки, всегда), с методом add_grade, который будет единственным способом добавить оценку (и проверит её), и с методом average, который умеет обращаться с пустым списком. Правила находятся рядом с данными, которые они защищают.

Итоги

  • Процедурное программирование: данные (словари) и функции отдельно. Работает, но по мере роста системы данные открыты, действия разбросаны и нет обязательной структуры.
  • ООП: организуйте код вокруг вещей. Каждый объект хранит данные (атрибуты) и действия (методы) вместе.
  • Класс (Class) = шаблон (проект здания, шаблон производства). Объект (Object) = конкретная вещь, созданная по нему.
  • В Python каждое значение — объект: str и list — классы, а "hi".upper() — вызов метода.
  • ООП — способ мышления: какие вещи существуют, что они знают и что делают. Не всё обязано быть классом.

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

Глава 2: Класс и объект — определяем шаблон и создаём по нему объекты

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

  • Что такое класс (Class) и что такое объект (Object, его также называют экземпляром — Instance).
  • Что означает слово class.
  • Синтаксис определения класса.
  • Как создать объект из класса.
  • Почему каждый объект независим.

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

В предыдущей главе мы увидели, что проблема «словарь + функции» в том, что нет определённой структуры и нет одного места, которое собирает данные и действия. Класс — инструмент, который создаёт эту структуру. Всё остальное (__init__, self, методы) строится на нём.

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

Что такое класс?

Класс (Class) — шаблон (Blueprint), который определяет новый вид вещей: какая информация будет у каждой вещи этого вида и что она будет уметь делать.

  • Класс не содержит данных конкретной вещи. Это только определение.
  • Из одного класса можно создать сколько угодно вещей.
  • Определяя класс, вы фактически создаёте новый тип в Python, точно как str или list.

Что такое объект (экземпляр)?

Объект (Object) — конкретная вещь, созданная из класса. У него есть собственные данные.

Экземпляр (Instance) — синоним: «объект класса Student» = «экземпляр Student». Действие создания объекта из класса называется созданием экземпляра (Instantiation).

КлассОбъект / экземпляр
Что этоШаблон, определениеКонкретная вещь
СколькоОдин на каждый видСколько угодно
ДанныеОпределяет, какие поля будутХранит конкретные значения
ПримерStudentДана, Ави, Ноа
АналогияШаблон производства машинКрасная машина с номером 12-345-67

Слово class

class — зарезервированное слово (Keyword) в Python, как def, if и for. Оно говорит Python: «здесь начинается определение нового вида». После него идёт имя класса, а затем — тело с отступом.

Имена классов

По PEP 8 имена классов пишутся в PascalCase: каждое слово начинается с заглавной буквы, без подчёркиваний. Student, BankAccount, ShoppingCart. Это отличается от функций и переменных (snake_case), и так сразу видно, что перед вами класс.

Синтаксис

class ClassName:
    # class body - indented
    pass

my_object = ClassName()
ЧастьОбъяснение
classЗарезервированное слово: «начинается определение класса».
ClassNameИмя класса в PascalCase.
:Обязательно, как после def или if.
Тело с отступомТо, что включает шаблон (в следующих главах: __init__ и методы).
pass«Ничего не делать». Тело класса (или функции) не может быть пустым, поэтому pass — заполнитель.
ClassName()Создание объекта: класс вызывают со скобками, как функцию. Получается новый объект.

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

week-3/first_class.py:

class Person:
    pass

dana = Person()
avi = Person()

print(dana)
print(avi)
print(type(dana))
print(dana is avi)
print(isinstance(dana, Person))

Вывод:

<__main__.Person object at 0x000001F4A3B2C1D0>
<__main__.Person object at 0x000001F4A3B2C2E0>
<class '__main__.Person'>
False
True

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

СтрокаОбъяснение
class Person:Определяет новый вид с именем Person. Ни один человек ещё не создан.
passШаблон пока пуст.
dana = Person()Создание экземпляра: создан новый объект типа Person, и имя dana указывает на него.
avi = Person()Ещё один объект, полностью отдельный.
print(dana)У объекта нет «содержимого» для показа, поэтому Python печатает описание по умолчанию: тип и адрес в памяти (0x...). У вас адрес будет другим.
Два разных адресаДва отдельных объекта в RAM.
type(dana)<class '__main__.Person'>. Класс объекта — Person, определённый в главном файле (__main__).
dana is aviFalse: это не один и тот же объект (вспомните is!).
isinstance(dana, Person)True: «является ли dana экземпляром Person?»

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

Добавляем данные объекту — и видим, почему так делать не стоит. К объекту можно прикрепить атрибут снаружи, через точку:

class Product:
    pass

laptop = Product()
laptop.name = "Laptop"
laptop.price = 3500

mouse = Product()
mouse.name = "Mouse"
mouse.price = 80

print(laptop.name, laptop.price)
print(mouse.name, mouse.price)

mouse.price = 70
print("After update:", mouse.price, "| laptop unchanged:", laptop.price)

Вывод:

Laptop 3500
Mouse 80
After update: 70 | laptop unchanged: 3500

Разбор:

  • laptop.name = "Laptop" — создаёт атрибут с именем name у объекта laptop. Точка означает «у»: laptop.name = «имя ноутбука».
  • У каждого объекта свои атрибуты. Изменение mouse.price не трогает laptop.
  • Это уже лучше словаря: laptop.price читается лучше, чем laptop["price"].

Но здесь большая проблема. Что гарантирует, что у каждого Product есть name и price?

keyboard = Product()
keyboard.nmae = "Keyboard"
print(keyboard.name)
AttributeError: 'Product' object has no attribute 'name'
  • Опечатка (nmae) создала другой атрибут.
  • Мы забыли определить price, и предупреждения нет, пока кто-нибудь не попытается его использовать.
  • Это ровно проблема словаря из предыдущей главы! Пустой класс всё ещё не определяет структуру.

Решение: сам класс должен определять, какие данные обязаны быть у каждого объекта, и заполнять их в момент создания. Это роль __init__ — тема следующей главы.

Итоги

  • Класс (Class) = шаблон, определяющий новый вид. Объект / экземпляр (Object / Instance) = конкретная вещь, созданная по нему.
  • Синтаксис: class Name: + тело с отступом (или pass). Имена в PascalCase.
  • Создание объекта: obj = Name(), со скобками. Без скобок — это сам класс.
  • Каждый объект независим: a is b → False. Атрибуты одного не влияют на другой.
  • type(obj) возвращает класс. isinstance(obj, Name) проверяет принадлежность.
  • Добавление атрибутов «снаружи» работает, но не определяет структуру. Поэтому нужен __init__.

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

Глава 3: __init__ и self — объект, который рождается полноценным

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

  • Что такое __init__ — функция, которая выполняется автоматически при создании объекта.
  • Что такое конструктор (Constructor).
  • Что такое self — слово, которое встречается в ООП повсюду.
  • Атрибуты экземпляра (Instance Attributes): данные каждого объекта.

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

В предыдущей главе мы добавляли атрибуты «снаружи»: laptop.name = "Laptop". Мы увидели, что это опасно: можно забыть поле, сделать опечатку, и нет обязательной структуры. Мы даже написали вспомогательную функцию make_book, чтобы гарантировать, что у каждой книги есть title и pages.

__init__ — это именно тот make_book, только внутри класса. Он гарантирует, что каждый объект создаётся инициализированным: со всеми полями и со значениями — с самого первого момента.

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

__init__ — метод инициализации

__init__ (произносится «дандер инит», double underscore init) — специальная функция, определённая внутри класса. Она:

  • Вызывается автоматически в момент создания нового объекта: Student(...).
  • Инициализирует объект, то есть кладёт в него начальные данные.
  • Ничего не возвращает (return не нужен).

Имя с двумя подчёркиваниями с каждой стороны обозначает специальный метод Python (его называют Dunder Method или Magic Method). Python ищет именно это имя и вызывает его сам. Вы никогда не пишете obj.__init__() сами.

Конструктор

Конструктор (Constructor) = код, который выполняется при построении нового объекта. В Python эту роль играет __init__, поэтому его обычно называют «конструктором». (Если быть технически точным: само построение, выделение объекта в памяти, происходит на шаг раньше, а __init__ «лишь» заполняет его. Для целей курса __init__ = конструктор.)

self — «этот объект»

self — первый параметр каждого метода в классе, включая __init__. Он указывает на конкретный объект, с которым метод работает прямо сейчас.

Зачем он нужен? Класс — один шаблон, но из него создаётся много объектов. Когда код в __init__ говорит «сохрани имя», ему нужно знать, в каком объекте сохранить. self — ответ: «в объекте, который создаётся сейчас».

self.name = name

Читайте так: «в этом объекте (self) создай атрибут с именем name и положи в него значение, полученное в параметре name».

  • self.name — атрибут объекта. Он остаётся, пока существует объект.
  • name (отдельно) — параметр __init__: локальная переменная (Local, вспомните область видимости!), которая исчезает в конце __init__.

Откуда берётся self? Python передаёт его автоматически! Когда вы пишете Student("Dana", 24), вы передаёте 2 аргумента, а __init__ получает 3: Python добавляет новый объект как self в начало.

Аналогия: регистрационная анкета

Класс — это пустая анкета с полями: «Имя: ____ Возраст: ____». __init__ — сотрудник регистратуры, который заполняет новую анкету для каждого пришедшего студента. self — «анкета, которая сейчас лежит на столе»: сотрудник пишет «Имя: Дана» на этой анкете, а не на всех анкетах в шкафу. Завтра придёт Ави, будет заполнена другая анкета, но сотрудник снова назовёт её «анкетой на столе» (self).

Что происходит в строке dana = Student("Dana", 24)

  1. Python создаёт новый пустой объект типа Student в памяти.
  2. Python вызывает __init__ и передаёт ему: self = новый объект, name = "Dana", age = 24.
  3. __init__ выполняет self.name = name, то есть name нового объекта = "Dana". И то же самое для age.
  4. __init__ завершается. Локальные параметры исчезают, а атрибуты остаются на объекте.
  5. Python возвращает объект, и имя dana указывает на него.

Атрибуты экземпляра

Атрибуты экземпляра (Instance Attributes) = данные, которые принадлежат конкретному объекту. У каждого объекта свои значения. Их определяют в __init__ через self.something = ....

Синтаксис

class ClassName:
    def __init__(self, param1, param2):
        self.attribute1 = param1
        self.attribute2 = param2
        self.attribute3 = "default value"   # attributes do not have to come from parameters

obj = ClassName(arg1, arg2)    # Python calls __init__(obj, arg1, arg2) for you
obj.attribute1                 # read an instance attribute
  • __init__ пишется внутри класса, с отступом.
  • self — всегда первый в списке параметров.
  • При вызове self не передают.
  • Параметрам можно давать значения по умолчанию, как в любой функции: def __init__(self, name, age=18):.

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

week-3/student.py:

class Student:
    def __init__(self, name, age):
        self.name = name
        self.age = age
        self.grades = []

dana = Student("Dana", 24)
avi = Student("Avi", 31)

print(dana.name, dana.age, dana.grades)
print(avi.name, avi.age, avi.grades)

dana.grades.append(95)
print("Dana's grades:", dana.grades)
print("Avi's grades:", avi.grades)

Вывод:

Dana 24 []
Avi 31 []
Dana's grades: [95]
Avi's grades: []

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

СтрокаОбъяснение
class Student:Определение шаблона.
def __init__(self, name, age):Конструктор. self = создаваемый объект. name, age = то, что передают при создании.
self.name = nameАтрибут name у объекта со значением из параметра.
self.age = ageТо же для возраста.
self.grades = []Атрибут, который не приходит из параметра: каждый студент начинает с собственного пустого списка оценок.
dana = Student("Dana", 24)Python: создаёт объект, вызывает __init__(new_obj, "Dana", 24) и возвращает его.
avi = Student("Avi", 31)Второй объект. __init__ выполняется снова, с self = второй объект.
dana.grades.append(95)Обращение к списку Даны и добавление в него.
avi.grades остаётся []Каждый __init__ создал новый список, поэтому общего нет. (Сравните с багом def f(cart=[]) из урока о функциях!)

Теперь структура гарантирована: нельзя создать Student без имени и возраста, и у каждого студента всегда есть grades.

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

Product со значениями по умолчанию, вычисляемым атрибутом и первой проверкой. week-3/product.py:

class Product:
    def __init__(self, name, price, quantity=0):
        if price < 0:
            raise ValueError("price cannot be negative, got " + str(price))
        self.name = name
        self.price = price
        self.quantity = quantity
        self.in_stock = quantity > 0

laptop = Product("Laptop", 3500, 4)
cable = Product("USB Cable", 25)

print(laptop.name, laptop.price, laptop.quantity, laptop.in_stock)
print(cable.name, cable.price, cable.quantity, cable.in_stock)

try:
    broken = Product("Ghost", -10)
except ValueError as error:
    print("Could not create product:", error)

Вывод:

Laptop 3500 4 True
USB Cable 25 0 False
Could not create product: price cannot be negative, got -10

Разбор:

  • quantity=0 — значение по умолчанию. Product("USB Cable", 25) получает количество 0.
  • self.in_stock = quantity > 0 — атрибут, вычисляемый из данных: True или False.
  • Проверка в конструкторе: __init__ отказывается создавать товар с отрицательной ценой и выбрасывает ValueError (вспомните raise!). Недопустимый товар не может существовать в системе. Это ровно «держать правила рядом с данными», о чём мы говорили в первой главе.
  • Когда __init__ выбрасывает ошибку, объект не создаётся, и broken не получает значения.

Person — третий пример, для закрепления

class Person:
    def __init__(self, first_name, last_name, city="Unknown"):
        self.first_name = first_name
        self.last_name = last_name
        self.city = city
        self.full_name = first_name + " " + last_name

noa = Person("Noa", "Levi", "Haifa")
tom = Person("Tom", "Cohen")
print(noa.full_name, "-", noa.city)
print(tom.full_name, "-", tom.city)
Noa Levi - Haifa
Tom Cohen - Unknown

Итоги

  • __init__ = конструктор: вызывается автоматически при создании объекта и инициализирует его данные.
  • self = конкретный объект, с которым метод работает прямо сейчас. Всегда первый параметр, и Python передаёт его сам.
  • self.x = x: self.x = атрибут, сохранённый на объекте. x = локальный параметр, который исчезает.
  • obj = Class(a, b) → Python создаёт объект, вызывает __init__(obj, a, b) и возвращает объект.
  • В __init__ могут быть: значения по умолчанию, вычисляемые атрибуты и проверка с raise, чтобы недопустимый объект вообще не создавался.

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

Глава 4: Методы — действия объекта

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

  • Что такое метод (Method).
  • Как его определить и как вызвать.
  • Три вида методов по тому, что они делают: вычисляют, меняют состояние, печатают.
  • Метод, который вызывает другой метод.
  • __str__: как объект «представляет себя».

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

До сих пор у наших объектов были только данные. Но идея ООП — данные + действия вместе. В первой главе мы увидели, что правила (например, «нельзя снять больше баланса») должны находиться рядом с данными. Методы — это место, где живут эти правила.

И есть открытая проблема из предыдущей главы: description машины вычислялось один раз в __init__ и устарело, когда цвет изменился. Метод вычисляет заново каждый раз.

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

Что такое метод?

Метод (Method) = функция, определённая внутри класса, которая действует на конкретный объект этого класса.

Функция (обычная)Метод
Где определенаНа верхнем уровне файлаВнутри класса
Как вызыватьfunction_name(args)obj.method_name(args)
С чем работаетС тем, что ей передалиСо своим объектом (self) и с тем, что ему передали
Первый параметрЛюбойself

Методами вы уже пользовались: "hi".upper(), numbers.append(5). Сегодня пишем свои.

self в методах

Точно как в __init__: когда вы вызываете dana.add_grade(90), Python автоматически передаёт dana как self. Внутри метода self.grades = оценки Даны. Если вызвать avi.add_grade(80), выполнится тот же самый метод, но self будет Ави.

dana.add_grade(90)   →   Student.add_grade(dana, 90)
                                            ↑     ↑
                                          self  grade

Левая строка — то, что пишем мы. Правая — то, что на самом деле делает Python.

Три вида методов (по тому, что они делают)

ВидЧто делаетПример
Геттер (Getter)Вычисляет и возвращает значение из данных, ничего не меняяaverage(), is_passing()
Сеттер (Setter)Меняет состояние объектаadd_grade(90), deposit(100)
Отображение (Display)Печатает / возвращает текст для показаprint_report(), __str__()

Полезное различие из чистого кода: хороший метод обычно либо геттер, либо сеттер, но не оба сразу (SRP!).

Состояние

Состояние (State) = значения всех атрибутов объекта в определённый момент. Счёт с балансом 1000 находится в определённом состоянии. После deposit(200) — в новом состоянии. Методы-сеттеры — упорядоченный способ менять состояние.

__str__ — как объект выглядит при печати

Помните, что print(dana) печатал <__main__.Student object at 0x...>? Если определить в классе специальный метод с именем __str__ (снова Dunder!), который возвращает строку, print и str() будут использовать его для показа объекта.

Синтаксис

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

    def getter_method(self):             # returns something, changes nothing
        return ... self.data ...

    def setter_method(self, value):      # changes the object's state
        self.data = ...

    def uses_other_method(self):
        return self.getter_method() * 2  # call another method on the same object

    def __str__(self):                   # used by print() and str()
        return "readable text about " + str(self.data)

obj = ClassName(...)
obj.setter_method(5)           # no self in the call
result = obj.getter_method()
print(obj)                     # calls __str__ automatically
  • Каждый метод с отступом внутри класса, и self — первый.
  • Между методами — одна пустая строка (PEP 8).
  • Внутри метода к данным и другим методам обращаются через self..

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

week-3/student_methods.py:

PASSING_GRADE = 60

class Student:
    def __init__(self, name):
        self.name = name
        self.grades = []

    def add_grade(self, grade):
        if grade < 0 or grade > 100:
            raise ValueError("grade must be between 0 and 100, got " + str(grade))
        self.grades.append(grade)

    def average(self):
        if not self.grades:
            return 0
        return sum(self.grades) / len(self.grades)

    def is_passing(self):
        return self.average() >= PASSING_GRADE

    def __str__(self):
        return self.name + " (average " + str(round(self.average(), 1)) + ")"

dana = Student("Dana")
dana.add_grade(90)
dana.add_grade(75)

avi = Student("Avi")
avi.add_grade(50)

print(dana)
print(avi)
print("Dana passing?", dana.is_passing())
print("Avi passing?", avi.is_passing())

Вывод:

Dana (average 82.5)
Avi (average 50.0)
Dana passing? True
Avi passing? False

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

КодВидОбъяснение
def add_grade(self, grade):СеттерМеняет состояние: добавляет в self.grades. И сначала проверяет правило (0–100).
def average(self):ГеттерВычисляет по данным объекта и возвращает. Ничего не меняет. Обрабатывает пустой список (баг из первой главы!).
def is_passing(self):ГеттерВызывает другой метод того же объекта: self.average().
def __str__(self):ОтображениеВозвращает строку. print(dana) вызывает его автоматически.
dana.add_grade(90)Python: Student.add_grade(dana, 90). self = dana.
print(avi)Python: print(avi.__str__()), то есть "Avi (average 50.0)".

Важный момент: коду, который использует класс (dana.add_grade(...)), не нужно знать, как хранятся оценки (в списке), и не нужно самому проверять диапазон. За это отвечает класс.

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

Банковский счёт — проблема из первой главы решена. week-3/bank_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
        self.transactions = []

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive, got " + str(amount))
        self.balance = self.balance + amount
        self.transactions.append("deposit " + str(amount))

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("withdrawal must be positive, got " + str(amount))
        if amount > self.balance:
            raise ValueError("insufficient funds: balance " + str(self.balance) + ", requested " + str(amount))
        self.balance = self.balance - amount
        self.transactions.append("withdraw " + str(amount))

    def transfer_to(self, other_account, amount):
        self.withdraw(amount)
        other_account.deposit(amount)

    def statement(self):
        print("--- Statement for", self.owner, "---")
        for transaction in self.transactions:
            print(" ", transaction)
        print("  Balance:", self.balance)

    def __str__(self):
        return "Account of " + self.owner + ": " + str(self.balance) + " NIS"

dana = BankAccount("Dana", 1000)
avi = BankAccount("Avi")

dana.deposit(200)
dana.transfer_to(avi, 300)

try:
    avi.withdraw(5000)
except ValueError as error:
    print("Rejected:", error)

print(dana)
print(avi)
dana.statement()

Вывод:

Rejected: insufficient funds: balance 300, requested 5000
Account of Dana: 900 NIS
Account of Avi: 300 NIS
--- Statement for Dana ---
  deposit 200
  withdraw 300
  Balance: 900

Разбор:

  • transfer_to(self, other_account, amount) — метод, который получает другой объект как параметр. Он не меняет балансы напрямую. Он использует self.withdraw и other_account.deposit, и так все проверки выполняются автоматически. DRY!
  • Если withdraw не удался (не хватает денег), он выбрасывает ошибку до deposit, поэтому деньги «из воздуха» не появляются.
  • self.transactions — внутренняя история, которую обновляет каждое действие. Тому, кто пользуется счётом, не нужно помнить о записи операции.
  • statement() — метод отображения. __str__ даёт короткое описание для print.
  • Сравните с процедурной версией из первой главы: те же действия, но теперь есть одно место, которое определяет, что такое счёт и что с ним разрешено делать.

Итоги

  • Метод = функция внутри класса. Определяйте его с self первым, а вызывайте obj.method(args) без self.
  • obj.method(x) равносильно Class.method(obj, x). Так self «знает», с каким объектом работать.
  • Внутри метода — self.attribute и self.other_method().
  • Геттер возвращает и не меняет. Сеттер меняет состояние (и проверяет правила).
  • __str__ возвращает строку, и print(obj) использует её.
  • Производную информацию лучше вычислять в методе, а не хранить как атрибут, который может устареть.

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

Глава 5: Общие данные — атрибут экземпляра и атрибут класса

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

  • Атрибут и метод: как решить, что есть что.
  • Атрибут экземпляра (Instance Attribute): данные каждого объекта отдельно (то, что мы делали до сих пор).
  • Атрибут класса (Class Attribute): данные класса, общие для всех объектов.
  • Когда использовать каждый и какие есть ловушки.

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

Допустим, есть класс Product, и все товары облагаются одинаковым НДС: 17%. Где хранить НДС?

  • Как атрибут экземпляра (self.vat = 0.17)? Тогда будет тысяча копий одного и того же числа, и если НДС изменится, придётся обновлять тысячу объектов.
  • Как глобальную константу в начале файла? Можно, но она «далеко» от класса, к которому относится.

А если хочется знать, сколько всего товаров было создано? Ни один товар этого «не знает». Это информация о виде в целом. Атрибуты класса решают оба случая.

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

Атрибут или метод?

АтрибутМетод
Что этоДанные, что объект знает / хранитДействие, что объект делает / вычисляет
Часть речиСуществительное / прилагательноеГлагол
Примерыname, price, balance, is_activedeposit(), add_grade(), total()
Доступobj.priceobj.total() со скобками

Вопросы, которые помогают решить:

  1. Это данные, которые хранятся, или что-то, что вычисляется? Хранимые данные → атрибут. Вычисляемое из других данных → обычно метод (мы это видели: description, которое устарело).
  2. Меняет ли это объект? Каждое упорядоченное изменение → метод (команда).
  3. Нужны ли правила при изменении? Если да, изменение идёт через метод (и мы углубимся в это в уроке об инкапсуляции).

Атрибут экземпляра

Определяется через self.x = ..., обычно в __init__. У каждого объекта своя копия со своим значением.

Атрибут класса

Определяется прямо в теле класса, вне любого метода, без self:

class Product:
    VAT_RATE = 0.17       # class attribute - one shared value
    count = 0             # class attribute

    def __init__(self, name):
        self.name = name  # instance attribute - per object
  • Есть только одна копия, которая принадлежит самому классу.
  • Все объекты её видят.
  • Обращаются к нему через класс (Product.VAT_RATE, рекомендуемый способ) или через объект (laptop.VAT_RATE, работает для чтения).
Аналогия: класс в школе

Атрибуты экземпляра = то, что каждый ученик хранит у себя: имя, оценки, тетрадь. Атрибуты класса = то, что висит на классной доске и общее для всех: имя классного руководителя, номер кабинета и сколько учеников в классе. Когда меняется учитель, доску обновляют один раз, а не в тетради каждого ученика.

Как Python находит атрибут

Когда вы пишете obj.x, Python ищет:

  1. Сначала на объекте (атрибуты экземпляра).
  2. Если не найдено — на классе (атрибуты класса и методы).
  3. Не найдено → AttributeError.

(Обратите внимание: это похоже на LEGB из урока об области видимости — поиск по слоям.) Отсюда и ловушка в типичной ошибке.

Синтаксис

class ClassName:
    SHARED_CONSTANT = 100        # class attribute (constant style: UPPER_CASE)
    counter = 0                  # class attribute (changes, shared)

    def __init__(self, value):
        self.value = value                     # instance attribute
        ClassName.counter = ClassName.counter + 1   # update the class attribute through the CLASS

    def uses_constant(self):
        return self.value * ClassName.SHARED_CONSTANT

ClassName.SHARED_CONSTANT        # read through the class
ClassName.counter

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

week-3/products_shared.py:

class Product:
    VAT_RATE = 0.17
    created_count = 0

    def __init__(self, name, price):
        self.name = name
        self.price = price
        Product.created_count = Product.created_count + 1

    def price_with_vat(self):
        return round(self.price * (1 + Product.VAT_RATE), 2)

laptop = Product("Laptop", 1000)
mouse = Product("Mouse", 50)

print(laptop.name, laptop.price_with_vat())
print(mouse.name, mouse.price_with_vat())
print("Products created:", Product.created_count)

Product.VAT_RATE = 0.18
print("After VAT change:", laptop.price_with_vat(), mouse.price_with_vat())

Вывод:

Laptop 1170.0
Mouse 58.5
Products created: 2
After VAT change: 1180.0 59.0

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

КодВидОбъяснение
VAT_RATE = 0.17Атрибут классаОдно значение для всех товаров. Заглавные буквы = «константа» (соглашение).
created_count = 0Атрибут классаОбщий счётчик. Начинается с 0, когда класс определяется.
self.name, self.priceАтрибуты экземпляраРазные у каждого товара.
Product.created_count = Product.created_count + 1Каждый __init__ обновляет счётчик класса, поэтому он считает все объекты.
Product.VAT_RATE в методеЧтение атрибута класса.
Product.VAT_RATE = 0.18Одно изменение, и все товары затронуты сразу, потому что все читают из одного места.

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

Класс с ограничением мест и автоматическим уникальным идентификатором. week-3/students_ids.py:

class Student:
    SCHOOL_NAME = "Kodkod 6"
    MAX_STUDENTS = 3
    next_id = 1
    all_students = []

    def __init__(self, name):
        if len(Student.all_students) >= Student.MAX_STUDENTS:
            raise ValueError("class is full - cannot add " + name)
        self.name = name
        self.student_id = Student.next_id
        Student.next_id = Student.next_id + 1
        Student.all_students.append(self)

    def __str__(self):
        return "#" + str(self.student_id) + " " + self.name + " (" + Student.SCHOOL_NAME + ")"

for name in ["Dana", "Avi", "Noa", "Tom"]:
    try:
        Student(name)
    except ValueError as error:
        print("Error:", error)

for student in Student.all_students:
    print(student)
print("Next id will be:", Student.next_id)

Вывод:

Error: class is full - cannot add Tom
#1 Dana (Kodkod 6)
#2 Avi (Kodkod 6)
#3 Noa (Kodkod 6)
Next id will be: 4

Разбор:

  • next_id — счётчик, который выдаёт уникальный идентификатор каждому студенту. Каждый объект хранит свой номер в атрибуте экземпляра student_id, а общий счётчик продвигается вперёд.
  • all_students — общий список, в котором класс «регистрирует» каждый созданный объект. append(self) = «добавь этот объект в список».
  • MAX_STUDENTS — правило, действующее для всего класса. Проверка выполняется до присвоения ID, поэтому Том не «сжёг» номер.
  • Student(name) в цикле без сохранения в переменную: объект не теряется, потому что зарегистрирован в all_students.
Когда да, а когда нет

Атрибуты класса отлично подходят для констант (VAT_RATE, MAX_STUDENTS) и счётчиков. Общий список «всех объектов» — хороший инструмент для демонстрации, но в настоящих проектах коллекцию обычно лучше вести в отдельном классе (например, School, который хранит список студентов). Это мы изучим в уроках о проектировании ООП (композиция, Composition).

Итоги

  • Атрибут = что объект знает (существительное). Метод = что он делает (глагол, со скобками). Вычисляемая информация → метод.
  • Атрибут экземпляра (self.x в __init__) = у каждого объекта своё значение.
  • Атрибут класса (в теле класса, без self) = одна общая копия. Подходит для констант и счётчиков.
  • Python ищет obj.x сначала на объекте, а затем на классе.
  • Меняйте атрибут класса только через имя класса. obj.X = ... создаёт новый атрибут экземпляра.
  • Список/словарь «для каждого объекта» → в __init__, а не в теле класса.

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

Глава 6: Что становится классом — от словесного требования к классам

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

До сих пор мы говорили вам, какие классы писать. Теперь переходим на другую сторону: решаете вы.

  • Как находить классы внутри словесного описания системы.
  • Четыре вопроса, которые решают: класс или нет?
  • Когда что-то — атрибут, а когда — отдельный класс.
  • Какому классу принадлежит каждое действие.

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

Программа курса подчёркивает: цель — понимать ООП, а не только уметь скопировать класс. На работе никто не скажет вам «напиши класс с именем Order с такими полями». Вы получите требование: «нужна система, в которой клиенты заказывают товары…», — и вам придётся самим превратить его в структуру классов.

Хорошее проектирование в начале экономит недели исправлений. Плохое приводит к одному огромному классу или к десяткам пустых классов, которые ничего не делают.

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

Инструмент 1: анализ существительных и глаголов

Простая и эффективная техника: прочитайте требование и отметьте:

В текстеКандидат в
Важные существительные («клиент», «заказ», «товар»)Классы или атрибуты
Прилагательные и свойства («цена», «дата», «активный»)Атрибуты
Глаголы («заказывает», «платит», «отменяет»)Методы
Правила («нельзя…», «не более…»)Проверки / инварианты (Validation / Invariants)

Это отправная точка, а не ответ. Не каждое существительное становится классом.

Инструмент 2: четыре вопроса — это класс?

#ВопросЕсли ответ «да»
1Есть ли у него идентичность и собственные данные? (у каждого разные значения)Склоняется к классу
2Есть ли у него поведение или правила? (действия, проверки)Сильно склоняется к классу
3Есть ли несколько его экземпляров? (много клиентов, много заказов)Склоняется к классу
4Это всего лишь одно значение, без всякого поведения? («цвет», «имя»)Атрибут, а не класс
Аналогия

«Машина» — у неё есть свой номер (идентичность), она едет и тормозит (поведение), и машин много. ✓ Класс. «Цвет машины» — это всего лишь значение («красный») без собственных действий. Атрибут машины. «Двигатель» — зависит! В простой игре — атрибут (engine_size = 1.6). В гараже, который ремонтирует двигатели, — класс (у него есть серийный номер, история обслуживания и состояние). Одно и то же понятие — разное решение в зависимости от того, что нужно системе.

Инструмент 3: простой атрибут или отдельный класс?

Решающий вопрос: нужно ли хранить об этой вещи больше одного куска информации или применять к ней правила?

ПонятиеПростой атрибут, когда…Отдельный класс, когда…
АдресДостаточно одного текста: "Herzl 10, Haifa"Нужны отдельно город, улица и индекс, фильтрация по городу и проверка индекса
ЦенаОдно числоНужны валюта, конвертации и округление по валюте
ОценкаЧислоНужны ещё дата, название экзамена, вес и апелляция

Эмпирическое правило: начинайте просто (с атрибута). Когда значение «разрастается» и получает больше полей и правил, превращайте его в класс.

Инструмент 4: какому классу принадлежит метод?

Золотое правило: метод идёт в класс, который хранит нужные ему данные. (Это называется «Information Expert».)

  • «Посчитать сумму заказа» → нужны позиции заказа → Order.total().
  • «Есть ли товар в наличии?» → нужно количество товара → Product.is_in_stock().
  • «Сколько клиент потратил в этом году?» → нужны заказы клиента → Customer (если он хранит свои заказы) или класс, который управляет всеми заказами.

Тревожный сигнал: метод в классе A, который в основном состоит из other.x, other.y, other.z, → вероятно, принадлежит классу other.

Синтаксис

Нового синтаксиса нет. Результат этого этапа — проект, который можно записать как «скелет» кода, с pass и docstring:

class Order:
    """A customer's order: items, status and total."""

    def __init__(self, customer):
        self.customer = customer      # has-a: the order holds its customer
        self.items = []
        self.status = "new"

    def add_item(self, product, quantity):
        pass

    def total(self):
        pass

Такой скелет позволяет думать о структуре до написания логики.

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

Требование: «Система для кофейни. Клиенты заказывают напитки из меню. У каждого напитка есть название, размер (small/large) и цена. Большой напиток стоит на 3 шекеля дороже. Заказ может включать несколько напитков, и у него есть сумма. Клиент может отменить заказ, пока он не приготовлен.»

Шаг А — разметка:

  • Существительные: клиент, напиток, меню, заказ, название, размер, цена, сумма.
  • Глаголы: заказывать, включать, отменять, приготовлен.
  • Правила: большой = +3. Отмена только до приготовления.

Шаг Б — четыре вопроса:

КандидатИдентичность/данные?Поведение?Несколько экземпляров?Решение
Напиток✓ название, размер, цена✓ расчёт цены по размеру✓Класс Drink
Заказ✓ позиции, статус✓ сумма, отмена, приготовление✓Класс Order
Клиент✓ имяНемного✓Здесь: атрибут (имя) у Order. В требовании нет для него поведения.
МенюСписок напитковВозможно, поискОдноЗдесь: простой dict. Если добавятся правила, станет классом.
РазмерЗначение («small»/«large»)——Атрибут Drink
ЦенаЧисло——Атрибут + вычисляющий метод
СуммаПроизводная——Метод Order.total()

Шаг В — методы («кто хранит данные?»):

  • price() → Drink, потому что Drink знает базовую цену и размер.
  • total() → Order, потому что Order хранит напитки.
  • cancel(), mark_prepared() → Order, потому что Order хранит статус.

Шаг Г — код. week-3/coffee_shop.py:

LARGE_EXTRA = 3

class Drink:
    def __init__(self, name, base_price, size="small"):
        if size not in ("small", "large"):
            raise ValueError("size must be small or large, got " + size)
        self.name = name
        self.base_price = base_price
        self.size = size

    def price(self):
        if self.size == "large":
            return self.base_price + LARGE_EXTRA
        return self.base_price

class Order:
    def __init__(self, customer_name):
        self.customer_name = customer_name
        self.drinks = []
        self.status = "open"

    def add(self, drink):
        if self.status != "open":
            raise ValueError("cannot add drinks to a " + self.status + " order")
        self.drinks.append(drink)

    def total(self):
        return sum(drink.price() for drink in self.drinks)

    def mark_prepared(self):
        self.status = "prepared"

    def cancel(self):
        if self.status == "prepared":
            raise ValueError("cannot cancel - the order was already prepared")
        self.status = "cancelled"

MENU = {"espresso": 9, "latte": 14, "tea": 8}

order = Order("Dana")
order.add(Drink("latte", MENU["latte"], "large"))
order.add(Drink("espresso", MENU["espresso"]))
print(order.customer_name, "pays", order.total())

order.mark_prepared()
try:
    order.cancel()
except ValueError as error:
    print("Rejected:", error)

Вывод:

Dana pays 26
Rejected: cannot cancel - the order was already prepared

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

Проектное решениеКак это выглядит в коде
Напиток = классclass Drink с данными (название, цена, размер) и поведением (price).
Размер = атрибут с правиломself.size с проверкой в __init__.
«Большой = +3» принадлежит напиткуDrink.price(), потому что напиток знает свой размер.
Заказ = классclass Order с позициями и статусом.
Клиент = атрибут (пока)self.customer_name, строка.
«Отмена только до приготовления»Проверка в Order.cancel().
Меню = dictMENU. У него нет поведения, поэтому класс не нужен.
Сумма = методtotal() вычисляет по drinks и не хранится как данные, которые могут устареть.

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

Когда требование меняется — клиент становится классом. Добавилось требование: «У клиентов есть клубная карта: каждая покупка приносит балл за каждые 10 шекелей, а при 50 баллах полагается бесплатный напиток.»

Теперь у клиента есть данные (баллы), поведение (накопление, списание) и правила (50 баллов). По четырём вопросам это уже класс:

POINTS_PER_SHEKEL = 0.1
FREE_DRINK_POINTS = 50

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

    def earn_points(self, amount_paid):
        self.points = self.points + int(amount_paid * POINTS_PER_SHEKEL)

    def has_free_drink(self):
        return self.points >= FREE_DRINK_POINTS

    def redeem_free_drink(self):
        if not self.has_free_drink():
            raise ValueError(self.name + " has only " + str(self.points) + " points")
        self.points = self.points - FREE_DRINK_POINTS

dana = Customer("Dana")
for payment in [120, 260, 140]:
    dana.earn_points(payment)
print(dana.name, "has", dana.points, "points. Free drink?", dana.has_free_drink())
dana.redeem_free_drink()
print("After redeeming:", dana.points)

Вывод:

Dana has 52 points. Free drink? True
After redeeming: 2

И в структуре: Order теперь хранит self.customer (объект Customer) вместо customer_name, а при оплате заказа вызывает self.customer.earn_points(self.total()). Одно и то же понятие, «клиент», начиналось как атрибут и стало классом, когда требования выросли. Это естественно, и это правильный рабочий процесс.

Итоги

  • Существительные → кандидаты в классы/атрибуты. Глаголы → методы. Правила → проверки.
  • Класс — когда есть идентичность + данные, поведение или правила и несколько экземпляров. Одно значение без поведения → атрибут.
  • Одно и то же понятие может быть атрибутом в одной системе и классом в другой. Начинайте просто и повышайте, когда требования растут.
  • Метод принадлежит классу, который хранит нужные ему данные (Information Expert).
  • Иногда «действие» (кредит, заказ) на самом деле класс, когда у него есть собственные данные и логика.
  • Не класс для каждого существительного и не «божественный класс» (God Class), который делает всё.

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

Глава 7: Назад в банк — всё вместе

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

Мы возвращаемся к банковской системе из начала урока и строим её заново — на этот раз со всем, что изучили в предыдущих главах.

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

Каждая глава добавляла один инструмент и решала одну проблему. Здесь видно, как все они работают вместе в одном классе, — и именно это вы сделаете самостоятельно в мини-проекте.

Банк — со всем, что мы изучили

class BankAccount:
    accounts_opened = 0                  # shared by all accounts

    def __init__(self, owner, balance=0):
        if balance < 0:
            raise ValueError("balance cannot be negative")
        self.owner = owner               # per account
        self.balance = balance
        BankAccount.accounts_opened = BankAccount.accounts_opened + 1

    def deposit(self, amount):           # setter - changes the state
        if amount <= 0:
            raise ValueError("amount must be positive")
        self.balance = self.balance + amount

    def __str__(self):
        return self.owner + ": " + str(self.balance)

dana = BankAccount("Dana", 1000)
dana.deposit(200)
print(dana)                              # Dana: 1200
Часть кодаИз какой главы
class BankAccount — один шаблон для всех счетовГлава 2
__init__ с проверкой — каждый счёт рождается полноценным и допустимымГлава 3
deposit — сеттер, который защищает правилаГлава 4
__str__ — как счёт выглядит при печатиГлава 4
accounts_opened — счётчик, общий для всех счетовГлава 5

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

Итоги

  • Класс — шаблон, а объект — экземпляр, построенный по нему, — с собственными данными.
  • __init__ выполняется при создании и гарантирует, что объект рождается полноценным; self — текущий объект.
  • Методы — это действия: геттер возвращает информацию, сеттер меняет состояние и проверяет правила, а __str__ решает, что показывает print.
  • Атрибут класса — одно значение, общее для всех объектов, — например, счётчик.

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

Практика

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

Что вы отрабатываете на этой странице: зачем нужно ООП, создание класса с __init__ и проверкой значений, методы (геттер и сеттер) и __str__, данные, общие для всего класса, и проектирование — что является классом, что атрибутом, а что методом.

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

  • Распознавать, когда данные и действия лучше объединить в один объект
  • Писать полноценный класс: __init__, методы и __str__
  • Использовать атрибут класса как счётчик, общий для всех объектов
  • Превращать словесное описание в список классов, атрибутов и методов

Упражнение 1: Найдите проблемы

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

Код:

student1 = {"name": "Dana", "grades": [90, 85]}
student2 = {"name": "Avi", "grades": [70]}
student3 = {"nmae": "Noa", "grades": []}

def average(student):
    return sum(student["grades"]) / len(student["grades"])

Задание: найдите три проблемы, которые возникают из-за того, что данные (словари) и действия (функция) разделены. Для каждой проблемы напишите одно предложение: в чём проблема и в какой строке она проявляется.

Что сдать: три проблемы, по одному предложению на каждую.

Показать решение
  1. Опечатка создаёт новый ключ — у student3 написано "nmae" вместо "name". Нет определённой структуры, которая бы это поймала, и проблема обнаружится, только когда кто-то попробует student3["name"].
  2. Нет правил — любой код может положить в grades -50 или "abc", и никто не проверит.
  3. Данные и действия разбросаны — average — отдельная функция, которая получает словарь и не знает, студент ли это вообще. А с пустым списком (student3) она упадёт с ZeroDivisionError.

В ООП Student один раз определяет, какие данные у него есть и какие действия он умеет выполнять, — всё вместе.

Упражнение 2: Проверка при создании

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

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

  1. Класс с именем Product.
  2. __init__, который получает название, цену и количество. Если количество не передано — оно равно 0.
  3. Если цена отрицательная: выбросить ValueError с сообщением, которое есть в выводе, и ничего не сохранять.
  4. Иначе: сохранить три значения на объекте через self.

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

pen = Product("Pen", 5)
print(pen.name, pen.price, pen.quantity)
try:
    Product("Bad", -10)
except ValueError as error:
    print("error:", error)

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

Pen 5 0
error: price cannot be negative
Показать решение
class Product:
    def __init__(self, name, price, quantity=0):
        if price < 0:
            raise ValueError("price cannot be negative")
        self.name = name
        self.price = price
        self.quantity = quantity


pen = Product("Pen", 5)
print(pen.name, pen.price, pen.quantity)
try:
    Product("Bad", -10)
except ValueError as error:
    print("error:", error)
Pen 5 0
error: price cannot be negative

Проверка в __init__ гарантирует, что объекта Product с недопустимой ценой не будет никогда.

Упражнение 3: Student с методами и str

Что вы строите: студента, который сам управляет своими оценками.

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

  1. Класс с именем Student. __init__ получает имя и создаёт пустой список оценок.
  2. add_grade — получает оценку и добавляет её в список. Это сеттер: он меняет состояние.
  3. average — возвращает среднее или 0, если оценок ещё нет. Это геттер: он только возвращает.
  4. __str__ — возвращает имя и среднее точно как в последней строке вывода.

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

dana = Student("Dana")
print(dana.average())
dana.add_grade(90)
dana.add_grade(75)
print(dana.average())
print(dana)

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

0
82.5
Dana (average 82.5)
Показать решение
class Student:
    def __init__(self, name):
        self.name = name
        self.grades = []

    def add_grade(self, grade):
        self.grades.append(grade)

    def average(self):
        if len(self.grades) == 0:
            return 0
        return sum(self.grades) / len(self.grades)

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


dana = Student("Dana")
print(dana.average())
dana.add_grade(90)
dana.add_grade(75)
print(dana.average())
print(dana)
0
82.5
Dana (average 82.5)

add_grade — сеттер, он меняет состояние. average — геттер, он возвращает информацию, ничего не меняя. print вызывает __str__ автоматически.

Упражнение 4: Общий счётчик

Что вы строите: товар, который знает свою цену с НДС, — и класс, который считает, сколько товаров создано.

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

  1. Класс с именем Product с __init__, который получает название и цену.
  2. Два атрибута класса — данные, принадлежащие всему классу, а не одному объекту: VAT_RATE со значением 0.17 и created_count, который начинается с 0.
  3. Каждый раз, когда создаётся товар, увеличивайте created_count на 1.
  4. price_with_vat — возвращает цену с НДС.

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

laptop = Product("Laptop", 1000)
mouse = Product("Mouse", 50)
print(Product.created_count)
print(laptop.price_with_vat())

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

2
1170.0
Показать решение
class Product:
    VAT_RATE = 0.17
    created_count = 0

    def __init__(self, name, price):
        self.name = name
        self.price = price
        Product.created_count = Product.created_count + 1

    def price_with_vat(self):
        return round(self.price * (1 + Product.VAT_RATE), 2)


laptop = Product("Laptop", 1000)
mouse = Product("Mouse", 50)
print(Product.created_count)
print(laptop.price_with_vat())
2
1170.0
  • name и price принадлежат каждому товару отдельно — атрибуты экземпляра.
  • VAT_RATE и created_count общие для всех товаров — атрибуты класса.
  • Счётчик обновляется через Product.created_count, а не через self.

Упражнение 5: Существительные и глаголы

Что вы делаете: только проектирование, без кода — превращаете словесное описание в список классов, атрибутов и методов.

Требование:

«В кофейне клиенты заказывают напитки разных размеров. У каждого напитка есть базовая цена, а большой напиток стоит на 3 шекеля дороже. Клиент накапливает баллы за каждый заказ и может их списать.»

Задание: постройте таблицу из трёх столбцов: кандидат, что это (класс, атрибут или метод) и почему.

Подсказка: существительные — кандидаты в классы или атрибуты. Глаголы — кандидаты в методы.

Что сдать: таблицу со всеми кандидатами из требования.

Показать решение
КандидатЧто этоПочему
Клиент (Customer)КлассСуществительное с данными (имя, баллы) и поведением (накапливает, списывает)
Напиток (Drink)КлассСуществительное с данными (базовая цена, размер) и правилом (большой стоит дороже)
Базовая цена, размерАтрибуты DrinkПростые значения, которые описывают напиток
БаллыАтрибут CustomerПростое число
Накапливает баллы, списываетМетоды CustomerГлаголы — действия с данными клиента
Итоговая ценаМетод DrinkВычисляется из цены и размера

Мини-проект: Маленькая библиотека

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

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

  • Строить полноценный класс с нуля: __init__, методы и __str__
  • Проверять значения уже при создании и выбрасывать ошибку, когда они недопустимы
  • Различать сеттер, который меняет состояние, и геттер, который только возвращает информацию
  • Использовать атрибут класса как счётчик, общий для всех объектов

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

Шаг 1 — класс и общий счётчик. Напишите класс с именем Book с атрибутом класса total_books, который начинается с 0. Он будет считать, сколько книг создано.

Шаг 2 — создание книги. __init__ получает название, автора и число экземпляров и сохраняет их. В начале все экземпляры свободны — сохраните их также в available. Каждая новая книга увеличивает total_books на 1. Если число экземпляров меньше 1 — выбросьте ValueError и не создавайте книгу.

Шаг 3 — выдача и возврат (сеттеры).

  • borrow — забирает один свободный экземпляр. Если свободных экземпляров нет: ValueError.
  • return_book — возвращает один экземпляр. Если все экземпляры уже в библиотеке: ValueError.

Шаг 4 — проверка (геттер). is_available возвращает True, если есть хотя бы один свободный экземпляр, иначе False.

Шаг 5 — отображение. __str__ возвращает название, автора и сколько экземпляров свободно из скольких, как в выводе.

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

copies must be at least 1
no copies available
all copies are already in

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

books = [
    Book("Clean Code", "Martin", 3),
    Book("Python Basics", "Cohen", 1),
]

books[0].borrow()
books[1].borrow()

try:
    books[1].borrow()
except ValueError as error:
    print("Python Basics ->", error)

try:
    Book("Empty", "Nobody", 0)
except ValueError as error:
    print("Empty ->", error)

for book in books:
    print(book, "| available:", book.is_available())

print("Books in the library:", Book.total_books)

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

Python Basics -> no copies available
Empty -> copies must be at least 1
Clean Code by Martin (2/3 available) | available: True
Python Basics by Cohen (0/1 available) | available: False
Books in the library: 2

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

  • Переводить описание «вещи» из мира — книги — в класс с данными и действиями
  • Держать правила внутри объекта, чтобы он не мог попасть в недопустимое состояние
  • Понимать, почему книга Empty не посчитана счётчиком: ошибка была выброшена до увеличения

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

Показать решение
class Book:
    total_books = 0

    def __init__(self, title, author, copies):
        if copies < 1:
            raise ValueError("copies must be at least 1")
        self.title = title
        self.author = author
        self.copies = copies
        self.available = copies
        Book.total_books = Book.total_books + 1

    def borrow(self):
        if self.available == 0:
            raise ValueError("no copies available")
        self.available = self.available - 1

    def return_book(self):
        if self.available == self.copies:
            raise ValueError("all copies are already in")
        self.available = self.available + 1

    def is_available(self):
        return self.available > 0

    def __str__(self):
        return (self.title + " by " + self.author + " ("
                + str(self.available) + "/" + str(self.copies) + " available)")


books = [
    Book("Clean Code", "Martin", 3),
    Book("Python Basics", "Cohen", 1),
]

books[0].borrow()
books[1].borrow()

try:
    books[1].borrow()
except ValueError as error:
    print("Python Basics ->", error)

try:
    Book("Empty", "Nobody", 0)
except ValueError as error:
    print("Empty ->", error)

for book in books:
    print(book, "| available:", book.is_available())

print("Books in the library:", Book.total_books)
Python Basics -> no copies available
Empty -> copies must be at least 1
Clean Code by Martin (2/3 available) | available: True
Python Basics by Cohen (0/1 available) | available: False
Books in the library: 2
  • total_books — атрибут класса: один счётчик для всех книг. Книга Empty не была создана (ошибка выброшена до увеличения), поэтому счётчик равен 2.
  • borrow и return_book — сеттеры: они меняют состояние и проверяют правила до изменения.
  • is_available — геттер: возвращает информацию, ничего не меняя.

Бонус:

class Library:
    def __init__(self):
        self.books = []

    def add_book(self, book):
        self.books.append(book)

    def available_books(self):
        result = []
        for book in self.books:
            if book.is_available():
                result.append(book)
        return result


library = Library()
for book in books:
    library.add_book(book)
for book in library.available_books():
    print(book)
Clean Code by Martin (2/3 available)