📖 Теория: инкапсуляция

⚡ Кратко

Инкапсуляция скрывает детали реализации объекта и предоставляет безопасный интерфейс. Python использует соглашения об именах: name — публичное, _name — защищённое, __name — приватное (манглится). Для доступа к скрытым полям применяют геттеры/сеттеры или декоратор @property — но только если и __init__ идёт через сеттер, иначе проверка не сработает. Когда одинаковая проверка нужна нескольким полям, её выносят в дескриптор (__get__/__set__/__set_name__).

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

Инкапсуляция — это подход, при котором внутреннее устройство объекта скрывается, а взаимодействие с ним осуществляется через понятный и контролируемый интерфейс.

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

  • контролировать поведение объекта через ограниченные точки входа;
  • защитить внутренние данные от прямого доступа и случайных изменений;
  • упростить использование объектов без знания их внутренней структуры.

Уровни доступа к атрибутам

В Python существуют три уровня доступа, реализованные через соглашения об именовании:

Публичные атрибуты

Атрибуты без префикса. Доступны отовсюду: изнутри класса, снаружи и в наследниках.

class Book:
    def __init__(self, title: str, author: str) -> None:
        self.title = title     # публичное поле
        self.author = author   # публичное поле


book = Book("1984", "George Orwell")
print(book.title)            # доступ извне
book.title = "Animal Farm"   # изменение извне
print(book.title)

Публичные атрибуты — часть открытого интерфейса объекта. Они не скрывают детали реализации.

Защищённые атрибуты

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

class Book:
    def __init__(self, title: str) -> None:
        self._title = title  # защищённый атрибут

    def show_title(self) -> None:
        print(self._title)


class SpecialBook(Book):
    def show_title(self) -> None:
        print(self._title.upper())  # доступ из наследника


book = SpecialBook("Brave New World")
print(book._title)        # доступ извне технически возможен
book.show_title()
  • Могут использоваться в дочерних классах.
  • Не предназначены для прямого внешнего использования.

Приватные атрибуты

Обозначаются двумя подчёркиваниями перед именем: __name. Python автоматически меняет имя такого атрибута внутри класса, чтобы затруднить случайное использование извне и избежать конфликтов имён в наследниках.

class Book:
    def __init__(self, title: str) -> None:
        self.__title = title  # приватный атрибут

    def show_title(self) -> None:
        print(self.__title)


class SpecialBook(Book):
    def print_title(self) -> None:
        print(self.__title.upper())  # ошибка: имя манглировано


book = SpecialBook("Brave New World")
book.show_title()     # доступ через метод базового класса
# book.__title        # AttributeError
# book.print_title()  # AttributeError

Манглирование имён

Манглирование (name mangling) — механизм, при котором Python преобразует имя приватного атрибута __value в _ClassName__value внутри класса. Это работает только для имён, начинающихся с двух подчёркиваний и не заканчивающихся двумя подчёркиваниями.

class Book:
    def __init__(self, title: str) -> None:
        self.__title = title


book = Book("Brave New World")
# print(book.__title)      # AttributeError
print(book._Book__title)   # доступ через манглированное имя (не рекомендуется)
⚠️ Важно: обращаться через _ClassName__attr технически возможно, но нарушает принцип инкапсуляции. Делайте так только в исключительных случаях, например при отладке.

Уровни доступа к методам

Соглашения распространяются и на методы:

  • method() — публичный метод.
  • _method() — защищённый метод, внутренняя логика.
  • __method() — приватный метод, строго скрытая логика.
class User:
    def __init__(self, name: str) -> None:
        self.name = name
        self.__id = self.__generate_id()

    def show_info(self) -> None:
        print(f"{self.name} — ID: {self.__id}")

    def __generate_id(self) -> str:
        from random import randint
        return f"user-{randint(1000, 9999)}"


user = User("Alice")
user.show_info()
# user.__generate_id()        # AttributeError
# user._User__generate_id()   # возможно, но нарушает инкапсуляцию

Геттеры и сеттеры

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

class Temperature:
    def __init__(self) -> None:
        self.__celsius = 0

    def get_celsius(self) -> float:
        return self.__celsius

    def set_celsius(self, value: float) -> None:
        if value < -273.15:
            raise ValueError("Температура не может быть ниже абсолютного нуля")
        self.__celsius = value


temp = Temperature()
temp.set_celsius(25)
print(temp.get_celsius())
# temp.set_celsius(-300)  # ValueError

Преимущества:

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

Декоратор @property

Декоратор @property позволяет управлять доступом к полю, сохраняя естественный синтаксис обращения к атрибуту.

class Temperature:
    def __init__(self, value: float) -> None:
        self.__celsius = 0
        self.celsius = value  # вызов сеттера внутри __init__

    @property
    def celsius(self) -> float:
        return self.__celsius

    @celsius.setter
    def celsius(self, value: float) -> None:
        if value < -273.15:
            raise ValueError("Температура не может быть ниже абсолютного нуля")
        self.__celsius = value


t = Temperature(25)
print(t.celsius)    # вызов геттера
t.celsius = 15      # вызов сеттера
print(t.celsius)
# t.celsius = -500  # ValueError
  • @property создаёт геттер.
  • @name.setter создаёт сеттер для того же свойства.
  • Имя метода становится именем «виртуального» атрибута.

Read-only свойства

Если объявить только геттер без сеттера, свойство становится доступным только для чтения.

class Temperature:
    def __init__(self, celsius: float) -> None:
        self.__celsius = celsius

    @property
    def celsius(self) -> float:
        return self.__celsius

    @property
    def fahrenheit(self) -> float:
        return self.__celsius * 9 / 5 + 32


t = Temperature(25)
print(t.celsius)      # 25
print(t.fahrenheit)   # 77.0
# t.celsius = 100     # AttributeError
# t.fahrenheit = 100  # AttributeError

Ловушка: __init__ в обход сеттера

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

class Person:
    def __init__(self, age: int) -> None:
        self.__age = age  # ❌ присвоили напрямую — сеттер не вызван

    @property
    def age(self) -> int:
        return self.__age

    @age.setter
    def age(self, value: int) -> None:
        if value < 0:
            raise ValueError("Возраст не может быть отрицательным")
        self.__age = value


p = Person(-10)
print(p.age)  # -10 — объект создан с некорректными данными!

Правильный вариант — присваивать «виртуальный» атрибут, тогда сеттер сработает уже при создании объекта:

class Person:
    def __init__(self, age: int) -> None:
        self.age = age  # ✅ доступ строго через @age.setter

    @property
    def age(self) -> int:
        return self.__age

    @age.setter
    def age(self, value: int) -> None:
        if value < 0:
            raise ValueError("Возраст не может быть отрицательным")
        self.__age = value


p = Person(-10)  # ValueError уже на этапе создания

Правило: если у поля есть сеттер с проверкой, обращайтесь к полю только через него — включая __init__.

Дескрипторы

@property — это частный случай дескриптора. Дескриптор — это класс, который управляет чтением, записью и удалением атрибута другого класса через методы __get__, __set__ и __delete__.

class Age:
    def __get__(self, instance, owner):
        if instance is None:   # обращение через класс: Person.age
            return self
        return instance._age

    def __set__(self, instance, value: int) -> None:
        if value < 0:
            raise ValueError("Возраст не может быть отрицательным")
        instance._age = value


class Person:
    age = Age()  # дескриптор управляет атрибутом age

    def __init__(self, age: int) -> None:
        self.age = age  # Age.__set__ вызывается всегда


p = Person(25)
print(p.age)     # 25 — сработал Age.__get__
# p.age = -10    # ValueError — сработал Age.__set__

Что есть что в методах дескриптора:

  • self — экземпляр самого дескриптора (объект Age());
  • instance — объект, у которого читают атрибут (p);
  • owner — класс этого объекта (Person).

Фактически p.age = 25 превращается в вызов Age.__set__(дескриптор, p, 25) — а внутрь этого метода можно поместить любую логику проверки.

Две обязательные детали. Имя атрибута в классе (age = Age()) должно совпадать с именем, по которому обращаются (p.age) — иначе Python просто не найдёт дескриптор. Внутри дескриптора значение хранится под другим именем (_age): если написать instance.age = value, снова вызовется __set__ и получится бесконечная рекурсия.

Универсальный дескриптор: __set_name__

В примере выше имя _age зашито в код, поэтому дескриптор годится только для одного поля. Метод __set_name__ получает имя атрибута автоматически — и один класс подходит для любого количества полей:

class PositiveNumber:
    def __set_name__(self, owner, name: str) -> None:
        self.private_name = "_" + name  # age → _age

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return getattr(instance, self.private_name)

    def __set__(self, instance, value: float) -> None:
        if value < 0:
            raise ValueError("Значение не может быть отрицательным")
        setattr(instance, self.private_name, value)

    def __delete__(self, instance) -> None:
        delattr(instance, self.private_name)


class Person:
    age = PositiveNumber()
    balance = PositiveNumber()

    def __init__(self, name: str, age: int, balance: float = 0) -> None:
        self.name = name
        self.age = age
        self.balance = balance


person = Person("Viktor", 48, 1000)
print(person.age, person.balance)   # 48 1000
print(person.__dict__)              # {'name': 'Viktor', '_age': 48, '_balance': 1000}

person.balance = 1500               # проверка выполняется
# person.age = -10                  # ValueError

Одна проверка «значение неотрицательно» описана один раз и работает сразу для двух полей — и будет работать в любом другом классе, где объявлен такой же дескриптор.

Почему значение нельзя хранить в самом дескрипторе

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

class BrokenNumber:
    def __set__(self, instance, value: float) -> None:
        self.value = value          # ❌ значение осело в самом дескрипторе

    def __get__(self, instance, owner):
        return self.value


class Box:
    size = BrokenNumber()


first = Box()
second = Box()

first.size = 1
second.size = 2

print(first.size)   # 2 — значение second перезаписало значение first!

Правильно всегда складывать данные в instance — у каждого объекта своё хранилище:

setattr(instance, self.private_name, value)   # или instance._age = value

Проверка типа и запрет удаления

Дескриптор — удобное место и для проверки типа. Разные нарушения полезно разделять: неподходящий тип — это TypeError, недопустимое значение — ValueError:

class PositiveNumber:
    def __set_name__(self, owner, name: str) -> None:
        self.name = name
        self.private_name = "_" + name

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return getattr(instance, self.private_name)

    def __set__(self, instance, value: float) -> None:
        if not isinstance(value, (int, float)) or isinstance(value, bool):
            raise TypeError(f"{self.name} должно быть числом")
        if value <= 0:
            raise ValueError(f"{self.name} должно быть больше нуля")
        setattr(instance, self.private_name, value)

    def __delete__(self, instance) -> None:
        raise AttributeError("Удаление размера запрещено")


class Rectangle:
    width = PositiveNumber()
    height = PositiveNumber()

    def __init__(self, width: float, height: float) -> None:
        self.width = width
        self.height = height


rectangle = Rectangle(4, 5)

# Rectangle(0, 5)          # ValueError: width должно быть больше нуля
# rectangle.height = "пять" # TypeError: height должно быть числом
# del rectangle.width       # AttributeError: Удаление размера запрещено

__delete__ нужен только тогда, когда для del требуется особое поведение — удалить значение иначе или, как здесь, запретить удаление совсем.

Когда @property, а когда дескриптор

СитуацияЧто выбрать
Логика нужна одному полю одного класса@property — короче и понятнее
Одинаковая проверка у нескольких полей или классовдескриптор — логика пишется один раз
Нужны валидаторы «на все случаи» (положительное число, длина строки, диапазон)дескриптор
Логирование, кэширование, ленивые вычисления при доступе к полюдескриптор
Требуется отделить управление атрибутом от бизнес-логики классадескриптор