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

Допустим, мы пишем класс банковского счёта:

Python 3.13
class BankAccount:
    def __init__(self, balance):
        self.balance = balance

account = BankAccount(1000)
print(account.balance)        # 1000
account.balance = -1_000_000  # упс

Класс не возражает: balance — публичный атрибут, и присвоить ему можно что угодно, включая такое, чего не должно быть никогда. Класс не гарантирует свои инварианты (правила, которые должны выполняться всегда, например «баланс не отрицательный»).

Инкапсуляция — это идея «у объекта есть публичный интерфейс (через что с ним общаются снаружи) и внутреннее состояние (внутрь не лазать)». Внешний код вызывает методы, а класс внутри сам следит, чтобы данные оставались валидными.

Соглашение об одном подчёркивании

В Python нет ключевого слова private. Вместо него используется соглашение: атрибут или метод, который начинается с подчёркивания, считается «внутренним», то есть снаружи трогать не следует:

Python 3.13
class BankAccount:
    def __init__(self, balance):
        self._balance = balance   # подчёркивание = «внутреннее»

    def get_balance(self):
        return self._balance

    def deposit(self, amount):
        if amount > 0:
            self._balance += amount

    def withdraw(self, amount):
        if 0 < amount <= self._balance:
            self._balance -= amount

account = BankAccount(1000)
account.deposit(500)
account.withdraw(2000)        # запрошено больше баланса, игнорируется
print(account.get_balance())
1500

Снаружи доступны только deposit, withdraw и get_balance: всё, что нужно для работы со счётом. _balance менять снаружи технически можно (Python не запрещает), но соглашение говорит: не надо, иначе вы обходите проверки класса.

Эту философию в Python-сообществе формулируют так: «мы все взрослые здесь» (we're all consenting adults here). Язык не запрещает — он сигналит, что трогать не стоит. Ответственность на программисте.

Иллюстрация: класс BankAccount как коробка, внутри хранится _balance, снаружи только три метода-входа: deposit(), withdraw(), get_balance() — это публичный интерфейс. Прямой доступ извне к _balance перечёркнут красным

Свойства: атрибут снаружи, метод внутри

Часто хочется, чтобы снаружи account.balance выглядело как обычный атрибут, но при этом внутри был метод (например, с проверкой). В Java для этого пишут getBalance() и setBalance(). В Python для этого есть @property:

Python 3.13
class Account:
    def __init__(self, balance):
        self._balance = balance

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("Баланс не может быть отрицательным")
        self._balance = value

account = Account(1000)

# Используется как обычный атрибут:
print(account.balance)
1000
account.balance = 500   # вызовет setter с проверкой
print(account.balance)
500
# Попытка установить отрицательное значение:
try:
    account.balance = -100
except ValueError as e:
    print(f"Ошибка: {e}")
Ошибка: Баланс не может быть отрицательным

Декоратор @property превращает метод в «вычисляемый атрибут». @balance.setter определяет, что произойдёт при присваивании. Снаружи всё выглядит как account.balance = 500, но внутри класса срабатывает проверка.

В примере встречаются raise и try/except — это механизм ошибок: raise прерывает операцию и сообщает, что значение недопустимо, а try/except перехватывает это в вызывающем коде. Им посвящён отдельный урок дальше в курсе; здесь достаточно понимать, что проверка в сеттере не даёт записать некорректное значение.

В Java и C++ часто пишут get_x() и set_x() методы. В Python так делать не принято: используйте @property.

Свойство только для чтения

Если у @property нет сеттера, атрибут получается доступным только для чтения. Это удобно для вычисляемых значений, которые не имеет смысла «устанавливать» извне:

Python 3.13
import math

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value <= 0:
            raise ValueError("Радиус должен быть положительным")
        self._radius = value

    @property
    def area(self):
        return math.pi * self._radius ** 2

circle = Circle(5)
print(f"Радиус: {circle.radius}, площадь: {circle.area:.2f}")
Радиус: 5, площадь: 78.54
# Меняем радиус, площадь пересчитывается автоматически
circle.radius = 7
print(f"Радиус: {circle.radius}, площадь: {circle.area:.2f}")
Радиус: 7, площадь: 153.94
# area нельзя присвоить (нет сеттера)
try:
    circle.area = 100
except AttributeError as e:
    print(f"Ошибка: {e}")
Ошибка: property 'area' of 'Circle' object has no setter

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

А что насчёт двух подчёркиваний?

Иногда можно встретить атрибуты с двумя подчёркиваниями в начале: self.__balance. Это запускает в Python механизм name mangling: атрибут переименовывается внутри объекта в _ClassName__balance. Это нужно в редких случаях (например, чтобы атрибут точно не пересёкся с одноимённым атрибутом в дочернем классе) и в обычном коде почти не встречается. По умолчанию используйте одно подчёркивание.

Что даёт инкапсуляция

Главное — класс становится ответственным за свои данные. Снаружи невозможно (по соглашению) обойти его проверки и оставить объект в невалидном состоянии. Если потом нужно поменять способ хранения (например, _balance стал словарём с историей операций), внешний код не сломается, потому что он общается с классом через тот же интерфейс — deposit, withdraw, balance.

Проверка понимания

Какой уровень доступа наиболее подходит для атрибута, который должен быть доступен для чтения, но не должен изменяться вне класса?


В следующем уроке возьмём третий принцип ООП — полиморфизм: как через единый интерфейс работать с объектами разных классов и почему это делает код проще.