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

Под инкапсуляцией в объектно-ориентированном программировании понимается упаковка данных и методов для их обработки вместе, то есть в классе. В 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 = 0

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

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


a = B()
print(B.__count)

Попытка выполнить этот код приведет к выбросу исключения:

  ...
        print(B.__count)
AttributeError: type object 'B' has no attribute '__count'. Did you mean: '_B__count'?

То есть атрибут __count за пределами класса становится невидимым, хотя внутри класса он вполне себе видимый.

На самом деле сокрытие в Python не настоящее и доступ к счетчику мы получить все же можем. Но для этого надо написать B._B__count:

...
print(B._B__count)

Таково соглашение. Если в классе есть атрибут с двумя первыми подчеркиваниями, то для доступа извне к имени атрибута добавляется имя класса с одним впереди стоящим подчеркиванием. В результате атрибут как он есть (в данном случае __count) оказывается замаскированным. Вне класса такого атрибута просто не существует. Для программиста же наличие двух подчеркиваний перед атрибутом должно сигнализировать, что трогать его вне класса не стоит вообще, даже через _B__count, разве что при крайней необходимости.

Хорошо, мы защитили поле от случайных изменений. Но как теперь получить его значение? Сделать это можно с помощью добавления метода:

class B:
    __count = 0

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

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

    def qty_objects():
        return B.__count


a = B()
b = B()
print(B.qty_objects())  # выведет 2

В данном случае метод qty_object() не принимает объект (нет self), поэтому вызывать его надо через класс. Хотя правильнее такие методы делать статическими (рассматривается в одном из следующих уроков).

Приватными можно делать не только свойства, также методы:

class Natural:
    def __init__(self, n):
        self.__origin = n
        self.number = self.__test()

    def __test(self):
        if type(self.__origin) is int and self.__origin > 0:
            return self.__origin
        else:
            print(f"Значение {self.__origin} было преобразовано к 1")
            return 1


a = Natural(34)
b = Natural(-250)
c = Natural("Hello")

print(a.number, b.number, c.number)

Результат:

Значение -250 было преобразовано к 1
Значение Hello было преобразовано к 1
34 1 1

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

class A:
    def __init__(self, value):
        self.__field = value


a = A(10)
# print(a.__field)  # Здесь будет ошибка

a.__field = 25
print(a.__field)  # Будет выведено 25

То есть получается, что при присваивании скрытым полям за пределами класса они становятся открытыми?

На самом деле в данном примере поле экземпляра __field, определенное за пределами класса, – это совсем другое поле. Не тот __field, который находится в классе и обращаться к которому извне надо с помощью _A__field. В этом можно убедиться, если вывести на экран содержимое атрибута __dict__:

print(a.__dict__)  # Результат:
#  {'_A__field': 10, '__field': 25}

Метод __setattr__

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

class A:
    def __init__(self, value):
        self.a = value


first = A(10)
second = A(25)

first.b = 'Hello'

print(first.__dict__)   # {'a': 10, 'b': 'Hello'}
print(second.__dict__)  # {'a': 25}

Если такое поведение нежелательно, его можно запретить с помощью __setattr__ – метода, перегружающего оператор присваивания атрибуту:

class A:
    def __init__(self, value):
        self.a = value

    def __setattr__(self, key, value):
        if key == 'a':
            self.__dict__['a'] = value
        else:
            raise AttributeError


first = A(10)

first.b = 'Hello'

Результат выполнения:

Traceback (most recent call last):
  File "test_setattr.py", line 14, in <module>
    first.b = "Hello"
  File "test_setattr.py", line 9, in __setattr__
    raise AttributeError
AttributeError

Метод __setattr__, если он присутствует в классе, вызывается всегда, когда какому-либо атрибуту выполняется присваивание. Обратите внимание, что присвоение несуществующему атрибуту также обозначает его добавление к объекту.

В примере выше когда создается объект first, в конструктор передается число 10. Здесь для объекта заводится поле a. Попытка присвоения ему значения приводит к автоматическому вызову __setattr__(), в теле которого в данном случае проверяется, соответствует ли имя атрибута строке 'a'. Если это так, то поле с соответствующим ему значением добавляются в словарь атрибутов объекта.

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

Если параметр key не соответствует допустимым полям, то искусственно возбуждается исключение AttributeError. Мы это видим, когда в основной ветке пытаемся обзавестись полем b.

Если объект содержит скрытые поля и к ним происходит обращение из __setattr__, то делать это надо так, как будто обращение происходит не из класса.

class A:
    def __init__(self, n):
        self.a = n
        self.__x = 100 - n

    def __setattr__(self, attr, value):
        if attr in ('a', "_A__x"):
            self.__dict__[attr] = value
        else:
            raise AttributeError


a = A(5)

В методе __setattr__ параметр attr – это имя свойства экземпляра в том виде, в котором оно находится в словаре __dict__. Если свойство скрытое, то в __dict__ оно будет записано через имя класса.

Метод __setattr__ можно использовать одновременно как для проверки/корректировки значений, так и для защиты от назначения экземпляру нежелательных атрибутов:

class A:
    field = None
    def __init__(self, v):
        self.field = v

    def __setattr__(self, key, value):
        if key == 'field':
            if type(value) in (int, float) and value > 0:
                self.__dict__[key] = value
        else:
            raise AttributeError


a = A('red')
print(a.field)  # None
a.field = -12
print(a.field)  # None
a.field = 12
print(a.field)  # 12
a.prop = 35  # Здесь будет выброшено исключение
print(a.prop)  # До этой строчки поток выполнения не дойдет

В примере выше экземпляр использует поле класса, если ему так и не удается обзавестись своим из-за попытки присвоения некорректного значения. В теле __setattr__ мы не можем объединить условные выражения из двух if в одно, так как должны обработать два разных случая: когда атрибут не field и когда field. Проверка корректности значения реализует уже другое поведение.

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

Задание 1

Разработайте класс с полной инкапсуляцией, доступ к атрибутам которого и изменение данных реализуются через вызовы методов. В объектно-ориентированном программировании принято имена методов для извлечения данных начинать со слова get (взять), а имена методов, в которых свойствам присваиваются значения, – со слова set (установить). Например, get_field, set_field.

Задание 2

В уроке пример с классом Natural имеет недостаток. Так при инициации объекта значение поле number корректируется, однако ничего не мешает позже за пределами класса присвоить этому полю некорректное значение, минуя проверку методом __test. Исправьте этот недочет, оставив возможность присвоения полю number за пределами класса, а также не запрещая объектам заводит другие поля.

PDF-версия курса с примерами решений практических работ


Объектно-ориентированное программирование на Python




Все разделы сайта