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

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

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

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

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

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

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

class Person:
    def __init__(self, name):
        self.name = name        # устанавливаем имя
        self.age = 1            # устанавливаем возраст

    def display_info(self):
        print("Имя:", self.name, "\tВозраст:", self.age)

tom = Person("Tom")
tom.name = "Человек-паук"       # изменяем атрибут name
tom.age = -129                  # изменяем атрибут age
tom.display_info()              # Имя: Человек-паук     Возраст: -129
1
2
3
4
5
6
7
8
9
10
11
12

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

С данной проблемой тесно связано понятие инкапсуляции. Инкапсуляция является фундаментальной концепцией объектно-ориентированного программирования. Она предотвращает прямой доступ к атрибутам объект из вызывающего кода.

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

Изменим выше определенный класс, определив в нем свойства:

class Person:
    def __init__(self, name):
        self.__name = name      # устанавливаем имя
        self.__age = 1          # устанавливаем возраст

    def set_age(self, age):
        if age in range(1, 100):
            self.__age = age
        else:
            print("Недопустимый возраст")

    def get_age(self):
        return self.__age

    def get_name(self):
        return self.__name

    def display_info(self):
        print("Имя:", self.__name, "\tВозраст:", self.__age)

tom = Person("Tom")
tom.__age = 43              # Атрибут age не изменится
tom.display_info()          # Имя: Tom  Возраст: 1
tom.set_age(-3486)          # Недопустимый возраст
tom.set_age(25)
tom.display_info()          # Имя: Tom  Возраст: 25
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

Для создания приватного атрибута в начале его наименования ставится двойной прочерк: self.__name. К такому атрибуту мы сможем обратиться только из того же класса. Но не сможем обратиться вне этого класса. Например, присвоение значения этому атрибуту ничего не даст:

tom.__age = 43
1

А попытка получить его значение приведет к ошибке выполнения:

print(tom.__age)
1

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

def get_age(self):
    return self.__age
1
2

Данный метод еще часто называют геттер или аксессор.

Для изменения возраста определено другое свойство:

def set_age(self, value):
    if value in range(1, 100):
        self.__age = value
    else:
        print("Недопустимый возраст")
1
2
3
4
5

Здесь мы уже можем решить в зависимости от условий, надо ли переустанавливать возраст. Данный метод еще называют сеттер или мьютейтор (mutator).

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

Аннотации свойств

Выше мы рассмотрели, как создавать свойства. Но Python имеет также еще один - более элегантный способ определения свойств. Этот способ предполагает использование аннотаций, которые предваряются символом @.

Для создания свойства-геттера над свойством ставится аннотация @property.

Для создания свойства-сеттера над свойством устанавливается аннотация имя_свойства_геттера.setter.

Перепишем класс Person с использованием аннотаций:

class Person:
    def __init__(self, name):
        self.__name = name  # устанавливаем имя
        self.__age = 1      # устанавливаем возраст

    @property
    def age(self):
        return self.__age

    @age.setter
    def age(self, age):
        if age in range(1, 100):
            self.__age = age
        else:
            print("Недопустимый возраст")

    @property
    def name(self):
        return self.__name

    def display_info(self):
        print("Имя:", self.__name, "\tВозраст:", self.__age)

tom = Person("Tom")
tom.display_info()      # Имя: Tom  Возраст: 1
tom.age = -3486         # Недопустимый возраст
print(tom.age)          # 1
tom.age = 36
tom.display_info()      # Имя: Tom  Возраст: 36
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

Во-первых, стоит обратить внимание, что свойство-сеттер определяется после свойства-геттера.

Во-вторых, и сеттер, и геттер называются одинаково - age. И поскольку геттер называется age, то над сеттером устанавливается аннотация @age.setter.

После этого, что к геттеру, что к сеттеру, мы обращаемся через выражение tom.age.

Рассмотрим ещё один пример инкапсуляции

Часто намеренно скрываются поля самого класса, а не его объектов. Например, если класс имеет счетчик своих объектов, то необходимо исключить возможность его случайного изменения из вне. Рассмотрим пример с таким счетчиком на языке 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
1
2
3
4
5
6
7
8
9
10
11
12

Все работает. В чем тут может быть проблема? Проблема в том, что если в основной ветке где-то по ошибке или случайно произойдет присвоение полю B.count, то счетчик будет испорчен:

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

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

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

a = B()
print(B.__count)
1
2
3
4
5
6
7
8
9

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

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

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

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

print(B._B__count)
1
2

Таково соглашение. Если в классе есть атрибут с двумя первыми подчеркиваниями, то для доступа извне к имени атрибута добавляется имя класса с одним впереди стоящим подчеркиванием. В результате атрибут как он есть (в данном случае __count) оказывается замаскированным. Вне класса такого атрибута просто не существует. Для программиста же наличие двух подчеркиваний перед атрибутом должно сигнализировать, что трогать его вне класса не стоит вообще, даже через _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
1
2
3
4
5
6
7
8
9
10
11
12

В данном случае метод 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
2
3
4
5
6
7
8
9
10
11
12
13

Результат:

[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'
1
2
3
4
5

Метод setattr()

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

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

a = A(10)
a.field2 = 20
print(a.field1, a.field2)       # 10 20
1
2
3
4
5
6
7

Если такое поведение нежелательно, его можно запретить с помощью метода перегрузки оператора присваивания атрибуту __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)
print(a.field1)                 # 15
1
2
3
4
5
6
7
8
9
10
11

Добавление несуществующего поля:

a.field2 = 30
1

Выведет ошибку в консоль:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 8, in __setattr__
AttributeError
1
2
3
4

Обращение к несуществующему полю класса:

a.field2
1

Так же выведет ошибку в консоль:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'A' object has no attribute 'field2'
1
2
3

Узнать о существовании полей класса можно командой dict - это объект дескриптора, который возвращает внутренний словарь атрибутов для конкретного экземпляра:

print(a.__dict__)               # {'field1': 15}
1

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

Когда создается объект a, в конструктор передается число 15. Здесь для объекта заводится атрибут field1. Факт попытки присвоения ему значения тут же отправляет интерпретатор в метод __setattr__(), где проверяется соответствует ли имя атрибута строке "field1". Если так, то атрибут и соответствующее ему значение добавляется в словарь атрибутов объекта.

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

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

Упражнения

  1. Напишите класс «Стол» с «полной инкапсуляцией», доступ к атрибутам которого и изменение данных реализуются через вызовы методов гет и сет. В объектно-ориентированном программировании принято имена методов для извлечения данных начинать со слова get (взять), а имена методов, в которых свойствам присваиваются значения, – со слова set (установить). Например, getField, setField.

  2. Напишите класс «Авто» с «полной инкапсуляцией», с доступом через аннотацию свойств и проверкой условий правильности вводимых данных.