Термин инкапсуляция в контексте объектно-ориентированного программирования подразумевает концепцию объединения данных и соответствующих методов в единое целое внутри класса. В Python инкапсуляция реализуется как через классы, так и при работе с объектами. В некоторых других языках, таких как Java, инкапсуляция также предполагает скрытие свойств и методов, благодаря чему они становятся приватными. Это означает ограничение доступа к ним рамками одного класса или модуля.

В Python такой уровень инкапсуляции отсутствует, хотя возможна ее имитация. Прежде чем изучать методы достижения этой цели, стоит задуматься о причинах необходимости скрытия данных.

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

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

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

Часто предпочитается скрывать поля самого класса, а не его объектов. Например, если класс отслеживает количество своих экземпляров, следует предотвратить случайные изменения этого счетчика извне. Представим пример с подобным счетчиком на языке Python.

class B:
    count = 0
    def __init__(self):
        B.count += 1
    def __del__(self):
        B.count -= 1

a = B()
b = B()
print(B.count) # выведет 2
del a
print(B.count) # выведет 1

Код работает корректно, однако проблема возникает, если по ошибке поле B.count будет изменено в основной ветке:

…
B.count -= 1
print(B.count) # будет выведен 0, хотя остался объект b

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

class B:
    __count = 1
    def __init__(self):
        B.__count += 1
    def __del__(self):
        B.__count -= 1

a = B()
print(B.__count)

Попытка выполнить этот код вызовет исключение:

  File "test.py", line 9, in <module>
    print(B.__count)
AttributeError: type object 'B' has no attribute '__count'

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

Фактически сокрытие в Python не абсолютное, и доступ к счетчику возможен посредством B._B__count:

print(B._B__count)

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

Теперь, защитив поле от случайных изменений, мы можем получить его значение через метод:

class B:
    __count = 0
    def __init__(self):
        B.__count += 1
    def __del__(self):
        B.__count -= 1
    def qtyObject():
        return B.__count

a = B()
b = B()
print(B.qtyObject()) # будет выведено 2

В случае метода qtyObject() вызов осуществляется через класс, так как self у него отсутствует.

Точно так же методы можно сделать "приватными", применив двойное подчеркивание:

class DoubleList:
    def __init__(self, l):
        self.double = DoubleList.__makeDouble(l)
    def __makeDouble(old):
        new = []
        for i in old:
            new.append(i)
            new.append(i)
        return new

nums = DoubleList([1, 3, 4, 6, 12])
print(nums.double)
print(DoubleList.__makeDouble([1,2]))

Результат будет следующим:

[1, 1, 3, 3, 4, 4, 6, 6, 12, 12]
Traceback (most recent call last):
  File "test.py", line 13, in <module>
    print(DoubleList.__makeDouble([1,2]))
AttributeError: type object 'DoubleList' has no attribute '__makeDouble'

Метод __setattr__()

В Python атрибуты можно присваивать объекту за пределами его класса:

>>> class A:
...     def __init__(self, v):
...         self.field1 = v
...
>>> a = A(10)
>>> a.field2 = 20
>>> a.field1, a.field2
(10, 20)

Если необходимо предотвратить такое поведение, можно использовать метод __setattr__() для перегрузки операции присваивания атрибуту:

>>> class A:
...     def __init__(self, v):
...         self.field1 = v
...     def __setattr__(self, attr, value):
...         if attr == 'field1':
...             self.__dict__[attr] = value
...         else:
...             raise AttributeError
...
>>> a = A(15)
>>> a.field1
15
>>> a.field2 = 30
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 8, in __setattr__
AttributeError
>>> a.field2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'A' object has no attribute 'field2'
>>> a.__dict__
{'field1': 15}

Поясним, что здесь происходит. Метод __setattr__(), если он включен в класс, всегда вызывается, когда атрибуту присваивается значение. Важно отметить, что назначение новому атрибуту также означает его добавление к объекту.

Когда создается объект a, в конструктор передается число 15, и атрибут field1 добавляется объекту. Присвоение влечет за собой вызов метода __setattr__(), где проверяется соответствует ли attr значению 'field1'. В случае соответствия атрибут добавляется в словарь атрибутов объекта.

Нельзя в __setattr__() напрямую указывать self.field1 = value, так как это вызовет рекурсивный вызов метода __setattr__(). Чтобы избежать этого, поле добавляется через словарь __dict__, который хранит все атрибуты объекта.

Если attr не соответствует допустимым полям, генерируется исключение AttributeError. Это объясняет, почему попытка назначения field2 заканчивается ошибкой.

Практическая работа

Создайте класс с "полной инкапсуляцией", где доступ к атрибутам и их изменения осуществляются через методы. В объектно-ориентированном программировании принято, чтобы имена методов для получения данных начинались с get, а для установки значений — с set. Например, getField, setField.

Вопросы для самопроверки:

  1. Как в Python можно реализовать имитацию скрытия атрибутов в классе?
  2. Почему может быть полезно скрывать поля объекта в программировании?
  3. Каким образом Python позволяет получить доступ к скрытому атрибуту, который был "замаскирован" с помощью двойного подчеркивания?
  4. Как следует организовать метод __setattr__(), чтобы не возникало рекурсивного вызова?
  5. Как согласно объектно-ориентированным подходам принято называть методы для получения и изменения данных в классах?