Лекция 7 · День 2

ООП в Python

Свойства, инструменты уровня класса и магические методы

Повторение

Что мы уже изучили

ООП держится на нескольких базовых концепциях. Три из них мы уже разобрали на прошлых занятиях.

Наследование

Класс-наследник переиспользует код родителя: class Child(Parent) и super().

Полиморфизм

Один вызов работает для разных классов: переопределение, Duck Typing, MRO.

Инкапсуляция

Внутренние данные скрыты от прямого доступа: соглашение _ и приватность __.

Повторение · Полиморфизм

Полиморфизм

1

Переопределение

Наследник задаёт свою версию метода. Один вызов gateway.process() даёт разное поведение.

2

Duck Typing

Наследование не требуется, достаточно нужного метода. «Крякает как утка, значит утка».

3

MRO

При множественном наследовании Python ищет метод по __mro__ слева направо.

Полиморфизм даёт единый интерфейс для разных реализаций. Вызывающий код не зависит от конкретного класса.

Повторение · Инкапсуляция

Инкапсуляция: что мы уже умеем

ИнструментСмысл
_имяСоглашение: «поле внутреннее, снаружи не трогать»
__имяName Mangling: Python переименовывает поле в _Класс__имя

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

План

План занятия

1

Свойства

@property, сеттеры, валидация, вычисляемые и read-only свойства

2

Уровень класса

Атрибуты класса, @classmethod, @staticmethod

3

Протоколы

Магические (дандер) методы

Между темами два практических блока в Jupyter. В конце итоговый проект «Платёжная карта».

Свойства

Проблема: чтение без контроля

Обычный публичный атрибут Python отдаёт «как есть». При чтении просто возвращается значение из памяти. Вставить проверку, запись в журнал или пересчёт некуда.

class CreditCard: def __init__(self, pan: str): self.pan = pan # обычный атрибут card = CreditCard("8600112233445566") print(card.pan) # весь номер виден целиком
8600112233445566

Хотелось бы при чтении card.pan показывать только часть номера. Но обычный атрибут так не умеет: он не выполняет код.

Свойства

Решение: декоратор @property

@property превращает метод в «виртуальное поле». Снаружи объект читают как атрибут, без скобок. Но Python при этом вызывает метод, а внутри метода можно выполнять любой код.

class CreditCard: def __init__(self, pan: str): self._pan = pan # _pan - внутреннее хранилище @property def pan(self) -> str: # это метод... return self._pan card = CreditCard("8600112233445566") print(card.pan) # ...но читаем без скобок, как поле

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

Свойства

Зачем превращать атрибут в свойство

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

1

Логика при чтении

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

2

Вычисляемые значения

Значение не хранится, а считается на лету по актуальным данным объекта.

3

Совместимость интерфейса

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

Свойства · Причина 1

Логика при чтении

Добавим в свойство код. При каждом чтении оно маскирует номер карты и записывает обращение в журнал безопасности.

class CreditCard: def __init__(self, client_name: str, raw_pan: str): self.client_name = client_name self._pan = raw_pan @property def masked_pan(self) -> str: print("[Аудит] Чтение номера карты") return f"{self._pan[:4]} **** **** {self._pan[-4:]}" card = CreditCard("Arman", "8600112233445566") print(f"Номер карты: {card.masked_pan}")
[Аудит] Чтение номера карты Номер карты: 8600 **** **** 5566

Свойства · Причина 2

Вычисляемые свойства

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

class LoanPortfolio: def __init__(self, principal: float, interest_rate: float): self.principal = principal self.interest_rate = interest_rate @property def total_debt(self) -> float: """Пересчитывается при каждом обращении""" return self.principal * (1 + self.interest_rate) portfolio = LoanPortfolio(10_000_000, 0.20) print(f"Итого к возврату: {portfolio.total_debt} сум") portfolio.principal -= 2_000_000 print(f"После погашения части: {portfolio.total_debt} сум")
Итого к возврату: 12000000.0 сум После погашения части: 9600000.0 сум

Свойства · Причина 3

Совместимость интерфейса: Python vs Java

Java

Шаблонный код с первого дня

Каждое поле сразу оборачивают в getBalance() и setBalance(), даже когда логики внутри ещё нет.

Python

По необходимости

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

Поэтому в Python не пишут геттеры заранее. Обычный атрибут становится свойством только тогда, когда появляется логика.

Свойства · Разбор вопроса

«Зачем геттер, если поле всё равно доступно?»

В Python нет жёсткой приватности: до поля _pan можно дотянуться напрямую и даже изменить его. Подчёркивание _ ничего не запрещает технически.

card = CreditCard("Arman", "8600112233445566") print(card._pan) # 8600112233445566 - читать Python не мешает card._pan = "0000000000000000" # изменить тоже даёт

Подчёркивание _ работает как соглашение, а не замок. Оно говорит: «поле внутреннее, снаружи его трогать не нужно». Но физически доступ открыт.

Свойства · Разбор вопроса

Геттер задаёт главный путь

Смысл геттера не в том, чтобы сделать доступ невозможным. Смысл в том, чтобы правильный путь был самым удобным. Тогда обычный код идёт через него сам собой.

Геттер card.masked_pan

Главный вход

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

Поле card._pan

Служебная дверь

Технически открыта. Но обращаться к ней снаружи значит осознанно нарушать договорённость, и на ревью кода это заметно.

Свойства · Разбор вопроса

Геттер скрывает внутреннее устройство

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

# Было: номер хранится как есть self._pan = raw_pan # Стало: номер хранится зашифрованным self._encrypted_pan = encrypt(raw_pan) # Геттер меняем в одном месте, имя свойства прежнее: @property def masked_pan(self) -> str: real = decrypt(self._encrypted_pan) return f"{real[:4]} **** **** {real[-4:]}"

Код, который читал card.masked_pan, продолжит работать без единой правки. Код, который брал card._pan напрямую, сломался бы везде: поля больше нет.

Свойства

Контроль записи: сеттер

Пока @property управляет только чтением. Чтобы перехватить запись, то есть присвоение через =, к свойству добавляют сеттер: метод с декоратором @имя.setter.

class Account: def __init__(self, balance: float): self._balance = balance @property def balance(self): # чтение: acc.balance return self._balance @balance.setter def balance(self, value): # запись: acc.balance = ... self._balance = value

Геттер и сеттер носят одно имя balance. Теперь поле можно и читать, и присваивать, но запись пройдёт через метод.

Свойства

Сеттер с проверкой данных

Главная польза сеттера: проверить значение перед сохранением. Если оно недопустимо, сеттер поднимает ошибку, и поле остаётся прежним.

class CreditLimitManager: def __init__(self, current_limit: float): self._limit = current_limit @property def limit(self) -> float: return self._limit @limit.setter def limit(self, new_value: float): if new_value < 0: raise ValueError("Лимит не может быть отрицательным") self._limit = new_value manager = CreditLimitManager(50_000_000) try: manager.limit = -100 except ValueError as error: print(f"Запись отклонена: {error}")
Запись отклонена: Лимит не может быть отрицательным

Под капотом

Геттер и сеттер: одно свойство

Геттер и сеттер вместе образуют одно свойство. Python сам выбирает нужный метод: читаем мы поле или присваиваем ему значение.

@property создаёт свойство 'limit' с логикой чтения @limit.setter к тому же свойству добавляет логику записи manager.limit Python вызывает геттер manager.limit = 700000 Python вызывает сеттер со значением 700000

Имена геттера и сеттера обязаны совпадать. Поэтому декоратор пишется как @limit.setter: он привязывается к уже созданному свойству limit.

Свойства

Пример: безопасный ПИН-код

Геттер не отдаёт реальный ПИН наружу, а сеттер проверяет формат при каждой попытке записать новое значение.

class PinValidator: def __init__(self): self._pin = None @property def pin(self) -> str: return "****" # реальное значение не отдаём @pin.setter def pin(self, value: str): if not value.isdigit() or len(value) != 4: raise ValueError("ПИН-код: ровно 4 цифры") self._pin = value print("ПИН-код обновлён") card = PinValidator() card.pin = "1234" print("ПИН на экране:", card.pin)
ПИН-код обновлён ПИН на экране: ****

Свойства

Свойство без сеттера: только для чтения

Если у свойства нет сеттера, присвоить ему значение нельзя: Python поднимет ошибку. Так защищают поля, которые не должны меняться после создания.

class Contract: def __init__(self, contract_id: str, client: str): self._contract_id = contract_id self.client = client @property def contract_id(self) -> str: return self._contract_id # сеттера нет contract = Contract("UZ-2024-001", "Тимур") print(contract.contract_id) try: contract.contract_id = "UZ-2024-999" except AttributeError: print("Защита сработала: номер контракта менять нельзя")
UZ-2024-001 Защита сработала: номер контракта менять нельзя

Свойства

Эталонный класс BankAccount

Соберём всё вместе: защищённый баланс, чтение через свойство и пополнение с проверкой.

class BankAccount: def __init__(self, owner: str, initial_balance: float = 0.0): self.owner = owner self._balance = initial_balance # защищённый баланс @property def balance(self) -> float: return self._balance def deposit(self, amount: float) -> None: if amount <= 0: print("Ошибка: сумма пополнения должна быть больше нуля") return self._balance += amount

Баланс закрыт от прямой записи: читается через @property, меняется только через методы.

Свойства

BankAccount: списание средств

Тот же класс. Метод списания с двумя проверками: сумма и остаток.

def withdraw(self, amount: float) -> None: if amount <= 0: print("Ошибка: сумма списания должна быть положительной") return if amount > self._balance: print("Транзакция заблокирована: недостаточно средств") return self._balance -= amount acc = BankAccount("Дмитрий", 1_000_000.0) acc.withdraw(1_500_000.0)
Транзакция заблокирована: недостаточно средств

Метод сам проверяет сумму и остаток. Некорректная операция просто не пройдёт.

Инкапсуляция

Сводная таблица инструментов инкапсуляции

ИнструментЧто делаетКогда использовать
_nameСигнал: поле внутреннееПоля, не предназначенные для внешнего кода
__nameName Mangling: переименовывает полеКритичные данные: пароли, токены, ПИН-коды
@propertyКонтролируемое чтениеЛогика при чтении, маскировка, вычисляемые значения
@name.setterПроверка перед записьюВалидация данных перед сохранением
@property без сеттераТолько для чтенияНеизменяемые поля: номер контракта, ID счёта

Уровень класса

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

Атрибут экземпляра создаётся через self.поле отдельно для каждого объекта. Атрибут класса объявляется в теле класса - одна копия для всех экземпляров.

class NationalBankCreditProduct: base_interest_rate = 0.20 # атрибут класса - общий для всех def __init__(self, client_name: str, loan_amount: float): self.client_name = client_name # атрибут экземпляра self.loan_amount = loan_amount contract1 = NationalBankCreditProduct("Тимур", 50_000_000) contract2 = NationalBankCreditProduct("Елена", 90_000_000) print(f"Ставка для Тимура: {contract1.base_interest_rate * 100}%") print(f"Ставка для Елены: {contract2.base_interest_rate * 100}%")
Ставка для Тимура: 20.0% Ставка для Елены: 20.0%

Уровень класса

Изменение атрибута класса затрагивает всех

print("--- До изменения ставки ---") print(f"Тимур: {contract1.base_interest_rate * 100}%") print(f"Елена: {contract2.base_interest_rate * 100}%") NationalBankCreditProduct.base_interest_rate = 0.22 print("--- После изменения ставки ---") print(f"Тимур: {contract1.base_interest_rate * 100}%") print(f"Елена: {contract2.base_interest_rate * 100}%")
--- До изменения ставки --- Тимур: 20.0% Елена: 20.0% --- После изменения ставки --- Тимур: 22.0% Елена: 22.0%

При изменении через экземпляр (contract1.base_interest_rate = 0.25) Python создаст новый атрибут в этом экземпляре - атрибут класса останется прежним.

Практика · Jupyter

Практический блок 1

Откройте блокнот oop_practicum.ipynb и выполните задания на разобранные темы. Проверяйте вывод по комментарию # Ожидаемый вывод.

8

Инкапсуляция

_ и __, name mangling

9

@property

Геттер и сеттер с валидацией

10

Свойства

Вычисляемые свойства

11

Атрибуты

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

↓ Открыть практикум

Уровень класса

Зачем нужен @classmethod

У класса один конструктор, метод __init__. Он ждёт готовые поля, разложенные по местам. Но данные часто приходят в другом виде: JSON из API, строка из CSV-файла, просто текущее время.

import json # Ответ API пришёл одной JSON-строкой: raw = '{"amount": 150000, "currency": "UZS", "ts": "2024-01-15"}' # Перед созданием объекта строку приходится разбирать вручную: data = json.loads(raw) t = Transaction(data["amount"], data["currency"], data["ts"])

Если объект так создают в десяти местах проекта, разбор JSON повторяется десять раз. Логика создания «размазана» по коду.

Уровень класса

Решение: @classmethod

Метод с декоратором @classmethod работает как второй, именованный конструктор. Первым аргументом он получает не объект self, а сам класс cls.

Обычный метод

Получает self

В метод приходит конкретный объект. Метод работает с его данными, например self.amount.

Метод класса

Получает cls

В метод приходит сам класс. Вызов cls(...) создаёт новый объект, так же как Transaction(...).

Логику разбора данных прячут внутрь метода класса. Снаружи остаётся короткий вызов: Transaction.from_json(raw).

Уровень класса

Фабричный метод: из JSON

Соберём решение в коде. Метод from_json разбирает строку внутри себя и возвращает готовый объект через cls(...).

import json class Transaction: def __init__(self, amount: float, currency: str, timestamp: str): self.amount = amount self.currency = currency self.timestamp = timestamp @classmethod def from_json(cls, json_str: str) -> 'Transaction': data = json.loads(json_str) # разбираем ответ API return cls(data["amount"], data["currency"], data["ts"]) def __str__(self): return f"{self.amount} {self.currency} @ {self.timestamp}" t1 = Transaction.from_json('{"amount": 150000, "currency": "UZS", "ts": "2024-01-15"}') print(t1)
150000 UZS @ 2024-01-15

@classmethod получает cls и создаёт объект через cls(...) - это второй способ собрать Transaction.

Уровень класса

Конструкторы из CSV и из времени

Ещё два фабричных метода того же класса Transaction:

@classmethod def from_csv_row(cls, csv_line: str) -> 'Transaction': parts = csv_line.strip().split(",") # из строки CSV return cls(float(parts[0]), parts[1], parts[2]) @classmethod def create_now(cls, amount: float) -> 'Transaction': now = datetime.now().isoformat() # текущее время return cls(amount, "UZS", now) t2 = Transaction.from_csv_row("320000.0,UZS,2024-01-15T11:00:00") t3 = Transaction.create_now(75000.0) print(t2) print(t3)
320000.0 UZS @ 2024-01-15T11:00:00 75000.0 UZS @ 2024-03-20T14:22:31

Один класс - несколько точек входа: из JSON, из CSV, из текущего времени.

Уровень класса

cls vs жёсткое имя класса при наследовании

Внутри фабричного метода объект создают через cls(...), а не через имя класса напрямую. Тогда метод правильно работает и в подклассах.

class BaseCard: def __init__(self, number: str): self.number = number @classmethod def from_masked(cls, masked: str) -> 'BaseCard': fake = masked.replace("****", "0000") return cls(fake) # cls, а не BaseCard - иначе потомки сломаются def __str__(self): return f"{self.__class__.__name__}: {self.number}" class GoldCard(BaseCard): pass print(BaseCard.from_masked("8600 **** **** 5566")) print(GoldCard.from_masked("8600 **** **** 5566"))
BaseCard: 8600 0000 0000 5566 GoldCard: 8600 0000 0000 5566

С BaseCard(...) вместо cls(...) объект gold оказался бы экземпляром BaseCard, а не GoldCard.

Уровень класса

Зачем нужен @staticmethod

Бывает функция, которая логически принадлежит классу, но не работает ни с конкретным объектом, ни с самим классом. Ей не нужны ни self, ни cls.

Пример: проверка формата номера телефона рядом с классом PhoneValidator. Это чистая утилита: получает строку, возвращает строку. Её можно вынести в обычную функцию, но тогда теряется связь с классом, к которому она относится.

Декоратор @staticmethod позволяет положить такую утилиту внутрь класса. Она группируется рядом с родственным кодом, но не получает ни объект, ни класс.

Уровень класса

Статические методы: @staticmethod

Метод с @staticmethod объявляют без self и cls. Вызывают его через класс. Здесь это утилита, приводящая номер телефона к единому формату.

class PhoneValidator: @staticmethod def clean_phone(phone_str: str) -> str: """Приводит номер к стандартному формату""" cleaned = "".join(c for c in phone_str if c.isdigit()) if cleaned.startswith("8"): cleaned = "7" + cleaned[1:] return f"+{cleaned}" formatted = PhoneValidator.clean_phone("8 (90) 123-45-67") print(f"Результат: {formatted}")
Результат: +79012345667

Уровень класса

Почему не просто глобальная функция?

@staticmethod группирует утилиты внутри класса, экспортирует их вместе с ним и допускает переопределение в подклассах.

class UzbekBankValidator: @staticmethod def is_valid_pinfl(pinfl: str) -> bool: """Проверяет формат ПИНФЛ (14 цифр)""" return pinfl.isdigit() and len(pinfl) == 14 @staticmethod def mask_card_number(pan: str) -> str: """Маскирует номер карты для безопасного вывода""" digits = pan.replace(" ", "") return f"{digits[:4]} **** **** {digits[-4:]}" print(UzbekBankValidator.is_valid_pinfl("12345678901234")) print(UzbekBankValidator.mask_card_number("8600 1122 3344 5566"))
True 8600 **** **** 5566

Уровень класса

Три типа методов: обычный метод

Обычный метод принимает self и работает с данными конкретного объекта:

class BankAccount: transaction_fee = 0.01 # атрибут класса def __init__(self, owner: str, balance: float): self.owner = owner self._balance = balance def withdraw(self, amount: float) -> None: # обычный метод total = amount + amount * BankAccount.transaction_fee if total > self._balance: print("Недостаточно средств") return self._balance -= total print(f"Списано {amount} + комиссия. Остаток: {self._balance}") acc = BankAccount("Алия", 500_000.0) acc.withdraw(200_000.0)
Списано 200000.0 + комиссия. Остаток: 298000.0

Уровень класса

Три типа методов: класса и статический

Тот же класс - метод класса (получает cls) и статический метод (без контекста):

@classmethod def open_with_bonus(cls, owner: str) -> 'BankAccount': # cls создаёт объект - альтернативный конструктор return cls(owner, 100_000.0) @staticmethod def validate_transfer_amount(amount: float) -> bool: # утилита: ни self, ни cls не нужны return 0 < amount <= 1_000_000_000 bonus_acc = BankAccount.open_with_bonus("Серик") print(f"Баланс Серика: {bonus_acc._balance} сум") print(BankAccount.validate_transfer_amount(-100))
Баланс Серика: 100000.0 сум False

Уровень класса

Сводная таблица инструментов уровня класса

ДекораторПервый аргументДоступный контекстНазначение
Обычный методselfАтрибуты экземпляра и классаИзменение состояния объекта
@classmethodclsТолько атрибуты и методы классаАльтернативные конструкторы
@staticmethodотсутствуетНет доступа к контекстуУтилиты и валидаторы

Уровень класса

Дерево решений: какой тип метода выбрать?

Вопрос 1: Нужен доступ к данным конкретного объекта? (self.balance, self.owner, self._pin) ДА → обычный метод: def method(self, ...) НЕТ → к вопросу 2 Вопрос 2: Нужен доступ к атрибутам класса или создание объекта внутри метода? ДА → @classmethod: def method(cls, ...) НЕТ → к вопросу 3 Вопрос 3: Утилита, логически связанная с классом, но независимая от его состояния? ДА → @staticmethod: def method(...)

Практика · Jupyter

Практический блок 2

Продолжаем в oop_practicum.ipynb: задания на инструменты уровня класса и магические методы.

12

@classmethod

Фабричный конструктор from_application

13

@staticmethod

Валидатор validate_pan

14

Дандер-методы

__len__ для истории транзакций

↓ Открыть практикум

Протоколы

Протоколы и магические методы

Дандер-методы (от Double Underscore) позволяют интегрировать пользовательские объекты со стандартными операторами и функциями Python.

Переопределяя __add__, __eq__, __len__ и другие, вы определяете, как объекты складываются, сравниваются и ведут себя в стандартных конструкциях языка.

Протоколы

Основные магические методы

МетодЧто переопределяетПример применения
__init__КонструкторИнициализация полей счёта
__str__print(obj), str(obj)Форматированный вывод в логах
__repr__Отладочное представлениеrepr(obj) для разработчика
__eq__Оператор ==Сравнение счетов по балансу
__lt__Оператор <Сортировка счетов
__add__Оператор +Объединение портфелей
__len__Функция len(obj)Количество транзакций в истории
__contains__Оператор inПроверка наличия транзакции

Итоги

Три концепции ООП - сводная матрица

КонцепцияСтатусКакую проблему решаетКлючевые инструменты
Наследование🟢Дублирование кода в похожих классахclass Child(Parent), super()
Полиморфизм🟢Зависимость кода от конкретных реализацийПереопределение, Duck Typing, MRO
Инкапсуляция🟢Прямой доступ к данным ломает логику_, __, @property, setter
АбстракцияВысокоуровневый код смешан с деталямиABC, @abstractmethod
Наследование → создаём иерархию: UzCardGateway(PaymentGateway) Полиморфизм → единый интерфейс: gateway.process_payment() Инкапсуляция → защищаем данные: self.__pin_hash, @property

Итоги

Итоги Лекции №7

  • Полиморфизм - единый интерфейс для разных реализаций; переопределение для связанных классов, Duck Typing для независимых
  • MRO - алгоритм C3-линеаризации устраняет неоднозначность при множественном наследовании
  • Инкапсуляция - _, __, @property и сеттеры защищают данные и контролируют доступ
  • @classmethod - несколько точек входа в класс, корректно работает при наследовании через cls
  • @staticmethod - изолированные утилиты, сгруппированные внутри класса
  • Дандер-методы - интеграция объектов со стандартными операторами Python

Итоговый проект

Мини-проект «Платёжная карта»

Итоговое задание практикума объединяет все темы лекции 7 в одной системе процессинга карт.

  • Класс BankCard с приватным ПИН и @property-валидацией (ровно 4 цифры)
  • @staticmethod validate_pan и @classmethod from_application
  • Подклассы DebitCard и CreditCard: переопределение process_payment
  • Своё исключение CardBlockedError: блокировка после 3 неверных ПИН
  • Магические методы __str__, __eq__

Часть A и часть B в oop_practicum.ipynb, каждая с ячейкой-проверкой.

Материалы

Практикум занятия

Все задания по ООП лекций 6-7 в одном блокноте Jupyter.

↓ Скачать практикум

14 заданий по темам + мини-проект «Платёжная карта». Откройте в Jupyter Notebook, JupyterLab, VS Code или Google Colab.

Python · Лекция 7 · День 2
1 / 44