$ sudo teach IT
МОДУЛЬ 5 · УРОК 4

Практика: класс BankAccount

В прошлых уроках мы разобрали приватные атрибуты и @property. Сейчас применим всё это вместе в реальном проекте — напишем класс банковского счёта с валидацией баланса, историей транзакций и переводами между счетами.

⏱ ~50 минут 🎓 Для новичков 🐍 Python 3 🔨 Урок-практика

🏦 Что будем строить

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

Требования к нашему классу:

🔒

Баланс приватный

Никто не может напрямую выставить баланс в отрицательное число — только через проверенные методы.

💰

Пополнение и снятие

Нельзя пополнить на отрицательную сумму. Нельзя снять больше, чем есть на счёте.

📋

История операций

Каждая операция записывается в лог. Можно в любой момент посмотреть все транзакции.

🔄

Переводы

Перевод суммы с одного счёта на другой — атомарная операция: либо всё прошло, либо ничего.

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

🗺️ Проектирование: что нужно классу

Прежде чем писать код, подумаем: какие атрибуты и методы нам нужны? Это называется проектирование — и опытные разработчики всегда начинают именно с него.

Атрибуты:

  • _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 двумя улучшениями:

  1. Атрибут account_number — уникальный номер счёта. Задаётся при создании, доступен только для чтения (readonly property). Попытка изменить — AttributeError.
  2. Метод get_balance_str() — возвращает баланс в формате "1 500,00 ₽" (с разделением тысяч пробелом и запятой в десятичных). Подсказка: используйте f-строку с форматом {value:,.2f}.

Бонус: добавьте проверку в transfer — нельзя переводить самому себе (self is other_account).

👉 В следующем уроке мы переходим к наследованию — одному из столпов ООП. Вы увидите, как из одного базового класса можно создавать специализированные версии, не переписывая логику заново.