Практика: класс BankAccount
В прошлых уроках мы разобрали приватные атрибуты и @property. Сейчас применим всё это вместе в реальном проекте — напишем класс банковского счёта с валидацией баланса, историей транзакций и переводами между счетами.
🏦 Что будем строить
Представьте, что вас попросили написать класс для банковского счёта. Это классический пример из реальной разработки — именно на таких задачах в полную силу проявляется смысл инкапсуляции и валидации данных.
Требования к нашему классу:
Баланс приватный
Никто не может напрямую выставить баланс в отрицательное число — только через проверенные методы.
Пополнение и снятие
Нельзя пополнить на отрицательную сумму. Нельзя снять больше, чем есть на счёте.
История операций
Каждая операция записывается в лог. Можно в любой момент посмотреть все транзакции.
Переводы
Перевод суммы с одного счёта на другой — атомарная операция: либо всё прошло, либо ничего.
💡 Почему это важно: в реальном банковском ПО некорректные данные — это деньги. Класс должен гарантировать целостность данных сам, а не надеяться, что «снаружи всё сделают правильно».
🗺️ Проектирование: что нужно классу
Прежде чем писать код, подумаем: какие атрибуты и методы нам нужны? Это называется проектирование — и опытные разработчики всегда начинают именно с него.
Атрибуты:
_balance— приватный атрибут для хранения баланса (float)_owner— имя владельца счёта (строка)_history— список строк с записями о каждой операции
Методы и свойства:
balance— @property (геттер) для чтения балансаbalance.setter— сеттер с проверкой: нельзя установить отрицательный балансdeposit(amount)— пополнение счётаwithdraw(amount)— снятие средствtransfer(other, amount)— перевод на другой счётget_history()— вывод истории операций__str__— удобное строковое представление
🎯 Ключевое решение: баланс хранится в _balance, а снаружи доступен через balance (property). Это защищает данные — никто не сможет написать account._balance = -9999... ну, технически может, но это будет явное нарушение соглашения об одном подчёркивании.
🧱 Шаг 1: Базовая структура + _balance + @property
Начнём с основы: создадим класс, пропишем __init__ и добавим property для баланса. Пока методов пополнения/снятия ещё нет — просто скелет.
class BankAccount:
def __init__(self, owner: str, initial_balance: float = 0.0):
self._owner = owner
self._balance = 0.0 # Начинаем с нуля
self._history = [] # Пустой список операций
# Устанавливаем начальный баланс через сеттер — чтобы прошла валидация!
if initial_balance != 0.0:
self.balance = initial_balance
@property
def balance(self):
"""Геттер: возвращает текущий баланс."""
return self._balance
@balance.setter
def balance(self, value: float):
"""Сеттер: проверяет, что баланс не отрицательный."""
if value < 0:
raise ValueError(f"Баланс не может быть отрицательным: {value}")
self._balance = value
def __str__(self):
return f"BankAccount(owner='{self._owner}', balance={self._balance:.2f})"
Проверим, что уже работает:
acc = BankAccount("Алиса", 1000.0)
print(acc) # BankAccount(owner='Алиса', balance=1000.00)
print(acc.balance) # 1000.0
# Попытка выставить отрицательный баланс напрямую через сеттер
acc.balance = -500 # ValueError: Баланс не может быть отрицательным: -500
💡 Почему мы устанавливаем начальный баланс через self.balance = initial_balance, а не через self._balance = initial_balance? Потому что хотим, чтобы проверка сработала сразу при создании объекта. Если кто-то напишет BankAccount("Алиса", -100) — сразу получит ошибку.
💳 Шаг 2: Метод deposit() с валидацией
Метод пополнения счёта. Кажется простым, но нам нужна валидация: нельзя пополнить счёт на ноль или отрицательную сумму — это не имеет смысла.
def deposit(self, amount: float) -> None:
"""Пополнение счёта.
Args:
amount: сумма пополнения (должна быть строго положительной)
Raises:
ValueError: если amount <= 0
"""
if amount <= 0:
raise ValueError(f"Сумма пополнения должна быть положительной, получено: {amount}")
self._balance += amount
self._history.append(f"Пополнение: +{amount:.2f} (баланс: {self._balance:.2f})")
Тест:
acc = BankAccount("Алиса", 1000.0)
acc.deposit(500.0)
print(acc.balance) # 1500.0
acc.deposit(0) # ValueError: Сумма пополнения должна быть положительной, получено: 0
acc.deposit(-100) # ValueError: Сумма пополнения должна быть положительной, получено: -100
🎯 Почему мы пишем прямо в self._balance, а не через сеттер? Потому что в deposit мы уже проверили, что amount > 0, и итоговый баланс гарантированно останется неотрицательным. Вызывать лишний раз сеттер — избыточно. Но если бы логика была сложнее, можно было бы использовать и сеттер.
💸 Шаг 3: Метод withdraw() с проверкой средств
Метод снятия — сложнее: нужно проверить и положительность суммы, и достаточность средств на счёте.
def withdraw(self, amount: float) -> None:
"""Снятие средств со счёта.
Args:
amount: сумма снятия (должна быть строго положительной)
Raises:
ValueError: если amount <= 0 или на счёте недостаточно средств
"""
if amount <= 0:
raise ValueError(f"Сумма снятия должна быть положительной, получено: {amount}")
if amount > self._balance:
raise ValueError(
f"Недостаточно средств: запрошено {amount:.2f}, доступно {self._balance:.2f}"
)
self._balance -= amount
self._history.append(f"Снятие: -{amount:.2f} (баланс: {self._balance:.2f})")
Тест:
acc = BankAccount("Боб", 500.0)
acc.withdraw(200.0)
print(acc.balance) # 300.0
acc.withdraw(1000.0) # ValueError: Недостаточно средств: запрошено 1000.00, доступно 300.00
acc.withdraw(-50) # ValueError: Сумма снятия должна быть положительной, получено: -50
⚠️ Порядок проверок важен: сначала проверяем, что amount > 0, и только потом — достаточность средств. Если проверять в обратном порядке, сообщение об ошибке при amount = -50 было бы «недостаточно средств», что сбивало бы с толку.
📋 Шаг 4: Лог транзакций _history
Мы уже добавляем записи в self._history в каждой операции. Осталось написать метод, который красиво выводит всю историю. Также добавим фиксацию начального взноса:
def __init__(self, owner: str, initial_balance: float = 0.0):
self._owner = owner
self._balance = 0.0
self._history = []
if initial_balance != 0.0:
if initial_balance < 0:
raise ValueError("Начальный баланс не может быть отрицательным")
self._balance = initial_balance
self._history.append(
f"Открытие счёта: начальный баланс {initial_balance:.2f}"
)
def get_history(self) -> list:
"""Возвращает список всех операций."""
return list(self._history) # Возвращаем КОПИЮ, чтобы никто не мог изменить оригинал
Тест истории:
acc = BankAccount("Алиса", 1000.0)
acc.deposit(500.0)
acc.withdraw(200.0)
for record in acc.get_history():
print(record)
# Открытие счёта: начальный баланс 1000.00
# Пополнение: +500.00 (баланс: 1500.00)
# Снятие: -200.00 (баланс: 1300.00)
💡 Почему get_history() возвращает list(self._history), а не self._history напрямую? Потому что возвращая оригинальный список, мы даём внешнему коду возможность изменить его: acc.get_history().clear(). Возвращая копию — защищаем данные класса.
🔄 Шаг 5: Метод transfer() между счетами
Перевод — это снятие с одного счёта и пополнение другого. Важно: эта операция должна быть атомарной — то есть либо оба действия выполняются, либо ни одно. Нельзя допустить ситуацию, где деньги списались, но не поступили.
def transfer(self, other_account: "BankAccount", amount: float) -> None:
"""Перевод средств на другой счёт.
Args:
other_account: счёт получателя (объект BankAccount)
amount: сумма перевода
Raises:
ValueError: если amount <= 0 или недостаточно средств
TypeError: если other_account не является объектом BankAccount
"""
if not isinstance(other_account, BankAccount):
raise TypeError("Получатель должен быть объектом BankAccount")
if amount <= 0:
raise ValueError(f"Сумма перевода должна быть положительной, получено: {amount}")
if amount > self._balance:
raise ValueError(
f"Недостаточно средств для перевода: запрошено {amount:.2f}, доступно {self._balance:.2f}"
)
# Атомарность: сначала снимаем у себя, потом зачисляем получателю
self._balance -= amount
other_account._balance += amount
# Записываем в историю обоих счетов
self._history.append(
f"Перевод → {other_account._owner}: -{amount:.2f} (баланс: {self._balance:.2f})"
)
other_account._history.append(
f"Перевод ← {self._owner}: +{amount:.2f} (баланс: {other_account._balance:.2f})"
)
Тест перевода:
alice = BankAccount("Алиса", 1000.0)
bob = BankAccount("Боб", 200.0)
alice.transfer(bob, 300.0)
print(alice.balance) # 700.0
print(bob.balance) # 500.0
# В истории Алисы:
# Перевод → Боб: -300.00 (баланс: 700.00)
# В истории Боба:
# Перевод ← Алиса: +300.00 (баланс: 500.00)
💡 Обратите внимание: внутри метода transfer мы обращаемся к other_account._balance напрямую, а не через deposit(). Это сделано намеренно: метод deposit добавил бы запись в историю сам, но мы хотим записать более точное описание («Перевод ← Алиса», а не просто «Пополнение»).
🏆 Финальный код целиком
Собираем всё вместе в один готовый класс:
class BankAccount:
"""Банковский счёт с валидацией и историей транзакций."""
def __init__(self, owner: str, initial_balance: float = 0.0):
self._owner = owner
self._balance = 0.0
self._history = []
if initial_balance != 0.0:
if initial_balance < 0:
raise ValueError("Начальный баланс не может быть отрицательным")
self._balance = initial_balance
self._history.append(
f"Открытие счёта: начальный баланс {initial_balance:.2f}"
)
# --- Свойство balance ---
@property
def balance(self):
"""Текущий баланс счёта (только для чтения через прямое присвоение)."""
return self._balance
@balance.setter
def balance(self, value: float):
"""Устанавливает баланс; запрещает отрицательные значения."""
if value < 0:
raise ValueError(f"Баланс не может быть отрицательным: {value}")
self._balance = value
# --- Пополнение ---
def deposit(self, amount: float) -> None:
"""Пополнение счёта на amount."""
if amount <= 0:
raise ValueError(f"Сумма пополнения должна быть положительной, получено: {amount}")
self._balance += amount
self._history.append(f"Пополнение: +{amount:.2f} (баланс: {self._balance:.2f})")
# --- Снятие ---
def withdraw(self, amount: float) -> None:
"""Снятие amount со счёта."""
if amount <= 0:
raise ValueError(f"Сумма снятия должна быть положительной, получено: {amount}")
if amount > self._balance:
raise ValueError(
f"Недостаточно средств: запрошено {amount:.2f}, доступно {self._balance:.2f}"
)
self._balance -= amount
self._history.append(f"Снятие: -{amount:.2f} (баланс: {self._balance:.2f})")
# --- Перевод ---
def transfer(self, other_account: "BankAccount", amount: float) -> None:
"""Перевод amount на счёт other_account."""
if not isinstance(other_account, BankAccount):
raise TypeError("Получатель должен быть объектом BankAccount")
if amount <= 0:
raise ValueError(f"Сумма перевода должна быть положительной, получено: {amount}")
if amount > self._balance:
raise ValueError(
f"Недостаточно средств для перевода: запрошено {amount:.2f}, "
f"доступно {self._balance:.2f}"
)
self._balance -= amount
other_account._balance += amount
self._history.append(
f"Перевод → {other_account._owner}: -{amount:.2f} (баланс: {self._balance:.2f})"
)
other_account._history.append(
f"Перевод ← {self._owner}: +{amount:.2f} (баланс: {other_account._balance:.2f})"
)
# --- История ---
def get_history(self) -> list:
"""Возвращает копию списка всех операций."""
return list(self._history)
def __str__(self):
return f"BankAccount(owner='{self._owner}', balance={self._balance:.2f})"
def __repr__(self):
return f"BankAccount(owner={self._owner!r}, initial_balance={self._balance})"
Демонстрация работы:
# Создаём два счёта
alice = BankAccount("Алиса", 5000.0)
bob = BankAccount("Боб")
# Пополняем счёт Боба
bob.deposit(1000.0)
print(bob) # BankAccount(owner='Боб', balance=1000.00)
# Алиса снимает деньги
alice.withdraw(500.0)
print(alice) # BankAccount(owner='Алиса', balance=4500.00)
# Перевод от Алисы к Бобу
alice.transfer(bob, 2000.0)
print(alice) # BankAccount(owner='Алиса', balance=2500.00)
print(bob) # BankAccount(owner='Боб', balance=3000.00)
# Просматриваем историю Алисы
print("\n--- История Алисы ---")
for record in alice.get_history():
print(record)
# Открытие счёта: начальный баланс 5000.00
# Снятие: -500.00 (баланс: 4500.00)
# Перевод → Боб: -2000.00 (баланс: 2500.00)
# --- Тест на ошибки ---
try:
alice.balance = -1000 # Попытка выставить отрицательный баланс
except ValueError as e:
print(f"Ошибка: {e}")
# Ошибка: Баланс не может быть отрицательным: -1000
try:
bob.withdraw(9999) # Снять больше, чем есть
except ValueError as e:
print(f"Ошибка: {e}")
# Ошибка: Недостаточно средств: запрошено 9999.00, доступно 3000.00
✅ Итоги урока
_balance— приватный атрибут;balance— публичное свойство через@property. Они всегда идут в паре.- Сеттер — это привратник: он проверяет данные перед записью. Попытка установить отрицательный баланс сразу даёт
ValueError. - Валидация в методах —
depositиwithdrawзащищают от некорректных сумм и овердрафта. - Лог транзакций
_history— простой список строк. Методget_history()возвращает копию, защищая оригинал. - Атомарность transfer: сначала проверяем все условия, потом — единым блоком — меняем оба баланса.
- Порядок проверок в методах имеет значение: от «бессмысленного ввода» к «невозможной операции».
🎯 Задание для самостоятельной работы
Расширьте класс BankAccount двумя улучшениями:
- Атрибут
account_number— уникальный номер счёта. Задаётся при создании, доступен только для чтения (readonly property). Попытка изменить —AttributeError. - Метод
get_balance_str()— возвращает баланс в формате"1 500,00 ₽"(с разделением тысяч пробелом и запятой в десятичных). Подсказка: используйте f-строку с форматом{value:,.2f}.
Бонус: добавьте проверку в transfer — нельзя переводить самому себе (self is other_account).
👉 В следующем уроке мы переходим к наследованию — одному из столпов ООП. Вы увидите, как из одного базового класса можно создавать специализированные версии, не переписывая логику заново.