Инкапсуляция – это один из трёх основных ООП. И как мы уже говорили, её главной идеей является сокрытие внутренней реализации объекта от внешнего мира и предоставление контролируемого доступа к его данным.

Вспомним пример с автомобилем из предыдущего параграфа. Инкапсуляция позволяет контролировать, как и когда данные объекта могут быть изменены. Так, вызов метода продажи sell() не только изменяет статус наличия автомобиля в автосалоне, но и выполняет другие необходимые действия, например, выводит сообщение о продаже. Прямое изменение атрибута не обеспечит совершение этих действий.

Ограничение доступа к данным

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

  • Публичные (англ. – public) атрибуты и методы не начинаются с подчеркиваний, например, apply_discount(). Они доступны как внутри класса, так и вне его. Вы можете свободно изменять значения таких атрибутов и использовать методы в любом месте программы.
  • Защищённые (англ. – protected) атрибуты и методы начинаются с одного подчеркивания, например, _model. Они считаются доступными только внутри класса и во всех его дочерних классах. Однако технически Python не мешает вам обращаться к таким атрибутам и методам напрямую извне класса, так как такое обозначение является просто сигналом для других разработчиков.
  • Приватные (англ. – private) атрибуты и методы начинаются с двух нижних подчеркиваний, например, __price. Они доступны только внутри класса. Python применяет к таким именам механизм сокрытия, чтобы затруднить прямой доступ. Он автоматически меняет имя атрибута, добавляя к нему имя класса, поэтому атрибут __price становится атрибутом _Car__price.

Давайте модифицируем класс Car из предыдущей статьи, сделав атрибут model защищённым, а атрибут in_stock – приватным:

class Car:
    def __init__(self, model: str, price: int | float, in_stock: bool=True):
        self.model = model
        self._price = price
        self.__in_stock = in_stock

    def apply_discount(self, percentage: int | float) -> None:
        discount_amount = self._price * (percentage / 100)
        self._price -= discount_amount
        print(f"Новая цена автомобиля {self.model}: {self._price} руб.")

    def sell(self) -> bool:
        if self.__in_stock:
            print(f"Автомобиль {self.model} успешно продан")
            self.__in_stock = False
            return True
        
        print(f"Автомобиль {self.model} уже был продан")
        return False

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

honda_accord = Car("Honda Accord VII", 800_000)
print(honda_accord._model)
# Вывод: Honda Accord VII
print(honda_accord.__price)
# Ошибка: AttributeError: 'Car' object has no attribute '__price'

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

honda_accord.__price = 700_000
print(honda_accord.__price)
# Вывод: 700000

Однако на самом деле вы не меняете атрибут __price, который определён внутри класса Car. Вместо этого, Python создает новый атрибут, который привязан только к экземпляру honda_accord, а не к классу Car. Вы можете убедиться в этом, получив словарь __dict__, который хранит все атрибуты объекта:

print(honda_accord.__dict__)
# Вывод: {'model': 'Honda Accord VII', '_Car__price': 800000, '_Car__in_stock': True, '__price': 700000}

Как видите, объект honda_accord содержит новый атрибут __price, а также атрибут _Car__price, который как раз и является атрибутом __price, используемым в классе.

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

honda_accord._Car__price = 700_000
print(honda_accord.__dict__)
# Вывод: {'model': 'Honda Accord VII', '_Car__price': 700000, '_Car__in_stock': True, '__price': 700000}

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

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

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

  • Геттер (от англ. get – получить) – это метод, который позволяет получить значение атрибута.
  • Сеттер (от англ. set – установить) – это метод, который позволяет установить или изменить значение атрибута, но при этом позволяет добавить дополнительную логику для проверки или преобразования данных перед их сохранением.

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

Давайте напишем геттер get_price() и сеттер set_price() для получения и изменения цены автомобиля. Тогда класс Car будет выглядеть следующим образом (пропустим ненужные нам сейчас методы apply_discount() и sell()):

class Car:
    def __init__(self, model: str, price: int | float, in_stock: bool=True):
        self.model = model
        self._price = price
        self.__in_stock = in_stock

    # Геттер для цены
    def get_price(self) -> int | float:
        return self._price
    
    # Сеттер для цены
    def set_price(self, new_price: int | float) -> None:
        if new_price > 0:
            self._price = new_price
        else:
            print("Цена должна быть положительным числом")

Геттер является обычным методом, который возвращается значение нужного атрибута, поэтому метод get_price() возвращает значение приватного атрибута __price.

Сеттер же предназначен для установки значения атрибута, однако также позволяет добавить новую логику, поэтому метод set_price() изменяет значение атрибута __price только в том случае, если новое значение больше нуля.

Теперь мы можем как получить, так и изменить значение приватного атрибута __price:

chevrolet_suburban = Car("Chevrolet Suburban XII", 13_000_000)
chevrolet_suburban.set_price(12_500_000)
print(chevrolet_suburban.get_price())
# Вывод: 12500000

Также сеттер set_price() проверяет корректность данных перед их сохранением и значение не будет изменено, если новая цена меньше или равна нулю:

chevrolet_suburban.set_price(-10_000_000)
# Вывод: Цена должна быть положительным числом
print(chevrolet_suburban.get_price())
# Вывод: 12500000

Декораторы @property

Явно вызывать геттеры и сеттеры как методы – это стандартная практика, но в Python есть более изящный способ их реализации с помощью декоратора @property. Его использование позволяет обращаться к методу как к атрибуту.

Первый метод, который вы помечаете декоратором @property, становится геттером, а сеттер создается с помощью декоратора @имя_геттера.setter. При этом оба метода должны иметь одинаковое название – имя атрибута, к которому мы будем обращаться.

Тогда методы get_price() и set_price() следует переименовать просто в price(), а также декорировать геттер как @property, а сеттер – как @price.setter:

class Car:
    def __init__(self, model: str, price: int | float, in_stock: bool=True):
        self._model = model
        self.__price = price
        self.__in_stock = in_stock

    # Геттер
    @property
    def price(self) -> int | float:
        return self.__price

    # Сеттер
    @price.setter
    def price(self, new_price: int | float) -> None:
        if new_price > 0:
            self.__price = new_price
        else:
            print("Цена должна быть положительным числом")

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

hyundai_solaris = Car("Hyundai Solaris II", 1_150_000)
hyundai_solaris.price = 1_000_000
print(hyundai_solaris.price)
# Вывод: 1000000

Здесь атрибут price вызывает геттер price() с декоратором @property для получения значения атрибута и сеттер price() с декоратором price.setter() для установки нового значения атрибута.

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

Примеры

Пример 1. Изменение кредитного лимита

Для управления кредитным лимитом используется класс CreditAccount, где атрибут с кредитным лимитом __limit является приватным. Сеттер с декоратором @limit.setter проверяет, что новый кредитный лимит является положительным числом:

class CreditAccount:
    """Описывает управление кредитным лимитом."""

    def __init__(self, owner: str, limit: int):
        """Конструктор класса CreditAccount.
        
        Параметры:
            owner: Номер счёта.
            limit: Текущий кредитный лимит.
        """
        self.owner = owner
        self.__limit = limit   # Приватный атрибут

    @property
    def limit(self) -> int:
        """Геттер. Возвращает значение приватного атрибута __limit."""
        return self.__limit

    @limit.setter
    def limit(self, new_limit: int) -> None:
        """Сеттер. Устанавливает новое значение атрибута кредитного лимита
        __limit, если оно является положительным числом.

        Параметры:
            new_limit: Новое значение кредитного лимита.
        """
        if new_limit >= 0:
            self.__limit = new_limit
            print(f"Лимит установлен: {self.__limit} руб.")
        else:
            print(f"Лимит должен быть больше нуля")


account = CreditAccount("Романов П. А.", 50000)
print(f"Текущий лимит: {account.limit} руб.")  # Получение через геттер

# Попытка установить корректное значение
account.limit = 75000  # Установка через сеттер
# Попытка установить некорректное значение
account.limit = -5000  # Сеттер блокирует изменение

Вывод:

Текущий лимит: 50000 руб.
Лимит установлен: 75000 руб.
Лимит должен быть больше нуля

Пример 2. Изменение громкости в игре

Класс GameVolume предназначен для управления громкостью звука в игре. Уровень громкости должен быть целым числом и не может быть меньше 0 и больше 100. Использование сеттера с декоратором @volume.setter не разрешает установку недопустимого значения:

class GameVolume:
    """Описывает управление громкостью в игре.
    """
    def __init__(self, volume: int):
        """Конструктор класса GameVolume.
        
        Параметры:
            volume: Громкость звука в игре.
        """
        self.__volume = volume
        
    # Геттер
    @property
    def volume(self) -> int:
        """Геттер. Возвращает значение приватного атрибута __volume.
        """
        return self.__volume
    
    # Сеттер
    @volume.setter
    def volume(self, new_volume: int | float) -> None:
        """Сеттер. Устанавливает новое значение атрибута громкости __volume.
        Изменяет значение только в том случае, если новое значение
        new_volume является целым числом в диапазоне от 0 до 100.
        
        Параметры:
            new_volume: Новый уровень громкости звука.
        """
        if isinstance(new_volume, int) and 0 <= new_volume <= 100:
            self.__volume = new_volume
            print(f"Громкость изменена: {new_volume}")
        else:
            print("Уровень громкости должен быть от 0 до 100")


game_volume = GameVolume(70)
# Получение текущего значения громкости через геттер
print(f"Текущий уровень громкости: {game_volume.volume}")

# Установка корректного значения через сеттер
game_volume.volume = 75
# Установка некорректного значения через сеттер
game_volume.volume = 1000

Вывод:

Текущий уровень громкости: 70
Громкость изменена: 75
Уровень громкости должен быть от 0 до 100

Пример 3. Вычисление зарплаты сотрудника

Класс Employee описывает сотрудника и хранит приватную почасовую ставку __hourly_rate и отработанные часы hours_worked. При этом зарплата сотрудника не хранится как атрибут, а вычисляется каждый раз при обращении через геттер с декоратором @property monthly_salary, который использует приватный метод __get_salary():

class Employee:
    """Описывает сотрудника и вычисляет его зарплату на основе ставки."""
    
    def __init__(self, name: str, hourly_rate: float, hours_worked: int):
        """Конструктор класса Employee.
        
        Параметры:
            name: Имя сотрудника.
            hourly_rate: Почасовая ставка.
            hours_worked: Отработанные часы.
        """
        self.name = name
        self.hours_worked = hours_worked   # Публичные часы
        self.__hourly_rate = hourly_rate  # Приватная ставка
        
    def __get_salary(self) -> float:
        """Возвращает зарплату на основе часовой ставки и количества часов."""
        return self.__hourly_rate * self.hours_worked

    @property
    def monthly_salary(self) -> float:
        """Геттер. Возвращает зарплату, не храня её в атрибуте.
        """
        # Читает приватный атрибут __hourly_rate
        return self.__get_salary()

    @monthly_salary.setter
    def monthly_salary(self, rate: float) -> None:
        """Сеттер: Устанавливает новую почасовую ставку __hours_rate, 
        если она является положительным числом.

        Параметры:
            rate: Новое значение почасовой ставки.
        """
        if rate > 0:
            self.__hourly_rate = rate
            print(f"Ставка обновлена до {rate:.2f} руб/час.")
        else:
            print("Ставка должна быть больше нуля")


employee1 = Employee("Ломоносов М. В", 800, 160)
print(f"Зарплата {employee1.name} (до): {employee1.monthly_salary}")
employee1.monthly_salary = 850
print(f"Зарплата {employee1.name} (после): {employee1.monthly_salary}")

Вывод:

Зарплата Ломоносов М. В (до): 128000
Ставка обновлена до 850.00 руб/час.
Зарплата Ломоносов М. В (после): 136000

Итоги

  • Публичные атрибуты и методы не начинаются с подчеркиваний и доступны как внутри класса, так и вне его.
  • Защищённые атрибуты и методы начинаются с одного подчеркивания, и считаются доступными только внутри класса и во всех его дочерних классах. Однако к ним можно обращаться напрямую извне класса.
  • Приватные атрибуты и методы начинаются с двух нижних подчеркиваний, и доступны только внутри класса. Прямой доступ к ним извне класса не рекомендуется и затруднён механизмом изменения имени этого атрибута.
  • Геттер – это метод, позволяющий получить значение атрибута.
  • Сеттер – это метод, позволяющий установить или изменить значение атрибута.
  • Первый метод, который помечается декоратором @property, становится геттером, а сеттер создается с помощью декоратора @имя_геттера.setter.

Задания для самопроверки

1. Чем защищённые атрибуты отличаются от приватных?

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

2. Создайте класс для банковского счёта BankAccount, который инициализируется защищённым атрибутом с именем владельца и приватным атрибутом с текущим балансом и начальным значением 0. Назовите атрибуты по своему усмотрению. Какие особенности именования атрибутов в данном случае следует учитывать?

class BankAccount:
    def __init__(self, owner_name: str, current_balance=0.0):
        self._owner_name = owner_name 
        self.__current_balance = 0

Защищённые атрибуты начинаются с одного подчеркивания (_), а приватные – с двух (__).

3. Создайте класс пользователя User, который инициализируется приватным атрибутом __age с возрастом. Напишите геттер get_age(), который возвращает значение атрибута __age. Создайте объект класса User с произвольным возрастом. Получите значение атрибута __age с помощью геттера и выведите его на экран.

class User:
    def __init__(self, age: int):
        self.__age = age 

    def get_age(self) -> int:
        return self.__age


user = User(45)
current_age = user.get_age()
print(current_age)
# Вывод: 45

4. В классе User из предыдущего задания создайте сеттер set_age(self, new_age), который изменяет значение атрибута __age на new_age только в том случае, если new_age находится в диапазоне от 0 до 100 включительно. В противном случае, выведите на экран строку "Возраст должен быть в диапазоне от 0 до 100" и не меняйте атрибут. Измените значение атрибута __age в ранее созданном объекте с помощью сеттера сначала на число 18, затем на -21 и выведите его на экран.

class User:
    def __init__(self, age: int):
        self.__age = age 

    def get_age(self) -> int:
        return self.__age

    def set_age(self, new_age: int) -> None:
        if 0 <= new_age <= 100:
            self.__age = new_age
        else:
            print(f"Возраст должен быть в диапазоне от 0 до 100")


# Создание объекта и изменение атрибута
user = User(45)
user.set_age(18)
user.set_age(-21)
# Вывод: Возраст должен быть в диапазоне от 0 до 100
print(user.get_age())
# Вывод: 18

5. В классе User из заданий 3 и 4 перепишите геттер и сеттер с помощью декоратора @property. Создайте новый объект класса User с произвольным возрастом, измените его с помощью сеттера и выведите его на экран с помощью геттера.

class User:
    def __init__(self, age: int):
        self.age = age 

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

    @age.setter
    def age(self, new_age: int) -> None:
        if 0 <= new_age <= 100:
            self.__age = new_age
        else:
            print("Возраст должен быть в диапазоне от 0 до 100")

# Создание объекта
user_2 = User(30)
user_2.age = 13
print(user_2.age)
# Вывод: 13