Skip to content

Latest commit

 

History

History
401 lines (261 loc) · 34.4 KB

File metadata and controls

401 lines (261 loc) · 34.4 KB

Урок 7. Управление доступом к атрибутам: public, protected, private и интерфейсные методы

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

С этого урока курс переходит к модулю, посвящённому инкапсуляции — идее, что доступ к внутреннему состоянию объекта должен проходить через контролируемый интерфейс, а не быть полностью произвольным. Начнём с самого базового инструмента такого контроля — соглашений об именовании атрибутов, которые в Python выполняют роль, аналогичную модификаторам доступа public, protected и private в языках вроде Java или C++, хотя и устроены принципиально иначе.

Проблема полностью открытых атрибутов

Рассмотрим класс, уже знакомый по предыдущим урокам:

class Point:
    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y
pt = Point(1, 2)
print(pt.x, pt.y)   # 1 2

pt.x = 200
pt.y = "coord_y"

Последняя строка не вызывает никакой ошибки — с точки зрения интерпретатора это совершенно законное присваивание, ничем не отличающееся от pt.x = 200. Но с точки зрения смысла, вложенного в класс Point, эта строка ошибочна: координата — это число, а не произвольная строка, и если где-то дальше в коде выполнится арифметическая операция с pt.y, программа завершится исключением, разбираться в причинах которого придётся значительно позже и, вероятно, в совершенно другом месте кода, нежели то, где было выполнено некорректное присваивание. Проблема здесь не в самой ошибке — ошибки в коде неизбежны, — а в том, что класс Point никак не участвует в её предотвращении: он полностью доверяет любому коду, который к нему обращается, и не проверяет ничего.

Три уровня доступа в Python

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

Публичный доступ — это поведение по умолчанию, тот способ именования атрибутов, что использовался во всех предыдущих уроках: self.x. Такие атрибуты считаются частью открытого интерфейса класса — предполагается, что к ним можно свободно обращаться и изменять их из любого места программы.

Защищённый доступ (protected) обозначается одним подчёркиванием в начале имени: self._x. Это соглашение сообщает: атрибут предназначен для внутреннего использования внутри самого класса и его наследников, и код, находящийся вне класса, не должен обращаться к нему напрямую. Но важно понимать точно, что это означает технически — а означает это ровно то же самое, что и обычный публичный атрибут, с точки зрения интерпретатора:

class Point:
    def __init__(self, x=0, y=0):
        self._x = x
        self._y = y


pt = Point(1, 2)

print(pt._x, pt._y)   # 1 2 — доступ есть, никаких ограничений
pt._x = '12.22'         # тоже сработает без единой жалобы со стороны Python

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

Приватный доступ обозначается двумя подчёркиваниями в начале имени: self.__x. Здесь поведение уже заметно отличается:

class Point:
    def __init__(self, x=0, y=0):
        self.__x = x
        self.__y = y


pt = Point(1, 2)
print(pt.__x)
AttributeError: 'Point' object has no attribute '__x'

Python сообщает, что атрибута с таким именем просто не существует — хотя внутри __init__ он совершенно точно был создан. Разгадка кроется в механизме, который называется искажением имени (name mangling).

Name mangling: что на самом деле происходит с двумя подчёркиваниями

Когда Python видит внутри тела класса обращение к атрибуту, имя которого начинается с двух подчёркиваний (и не заканчивается двумя и более подчёркиваниями — иначе это выглядело бы как магический метод), он автоматически переписывает это имя, добавляя к нему спереди имя класса в виде _ИмяКласса. Атрибут self.__x внутри класса Point на деле хранится под именем _Point__x. Убедиться в этом можно, заглянув в пространство имён объекта:

print(pt.__dict__)   # {'_Point__x': 1, '_Point__y': 2}
print(dir(pt))         # среди прочего: '_Point__x', '_Point__y'

Обращение pt.__x не находит ничего, потому что реального атрибута с именем __x не существует — есть только _Point__x. А раз имя изменено, обратиться к нему всё же можно, зная точную искажённую форму:

print(pt._Point__x)   # 1

Это подтверждает важный факт: name mangling — не настоящая приватность в том смысле, в каком её понимают языки с полноценным контролем доступа на уровне компилятора. Это механизм автоматического переименования, решающий две конкретные задачи.

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

Во-вторых, он предотвращает конфликты имён при наследовании: если два класса в иерархии наследования независимо друг от друга объявят атрибут с одинаковым «человеческим» именем через двойное подчёркивание, name mangling автоматически подставит в реальное имя каждого атрибута имя того класса, где он был объявлен, и атрибуты не будут случайно затирать друг друга. Этот второй аспект станет по-настоящему значимым позже, при разборе наследования, но уже сейчас полезно знать, что защита от подобных конфликтов — не побочный эффект, а одна из основных причин, по которой name mangling вообще существует.

Работа с приватными атрибутами через геттеры и сеттеры

Раз прямое обращение pt.__x больше не работает, встаёт вопрос: как вообще предполагается взаимодействовать с приватным атрибутом снаружи класса? Ответ — через специально предусмотренные для этого методы. Метод, возвращающий значение атрибута, принято называть геттером (getter), а метод, изменяющий его значение, — сеттером (setter):

class Point:
    def __init__(self, x=0, y=0):
        self.__x = x
        self.__y = y

    def set_coord(self, x, y):
        self.__x = x
        self.__y = y

    def get_coord(self):
        return self.__x, self.__y
pt = Point(1, 2)
pt.set_coord(10, 20)
print(pt.get_coord())   # (10, 20)

Здесь важно осознать, что именно изменилось по сравнению с полностью открытым атрибутом. Технически геттер и сеттер сами по себе не добавляют никакой дополнительной логики — в приведённом коде set_coord делает то же самое присваивание, что делал бы прямой доступ к публичному атрибуту. Но теперь весь путь, по которому данные попадают внутрь объекта, сузился до одной точки входа — метода set_coord. И именно эта единственная точка входа даёт возможность добавить в неё то, ради чего вся конструкция и затевалась, — проверку данных.

Валидация: настоящая причина инкапсуляции

def set_coord(self, x, y):
    if type(x) in (int, float) and type(y) in (int, float):
        self.__x = x
        self.__y = y
    else:
        raise ValueError("Координаты должны быть числами")
pt = Point()
pt.set_coord(1, 2)      # проходит без ошибок
pt.set_coord("1", 2)    # ValueError: Координаты должны быть числами

Именно в этом и заключается практическая ценность инкапсуляции — не в том, чтобы что-то «спрятать» ради самой идеи сокрытия, а в том, чтобы гарантировать: объект не может оказаться в состоянии, нарушающем собственную логику. Пока координаты были публичными атрибутами, ничто не мешало присвоить им строку. Как только единственным способом их изменения стал метод set_coord, у класса появилась возможность отклонить некорректные данные прежде, чем они попадут внутрь объекта — и с этого момента гарантия «pt.x и pt.y всегда числа» перестаёт быть пожеланием и становится инвариантом, который поддерживается самим кодом класса.

Чтобы не дублировать одну и ту же проверку в __init__ и в set_coord, логику валидации разумно вынести в отдельный вспомогательный метод:

class Point:
    def __init__(self, x=0, y=0):
        self.__x = self.__y = 0

        if self.__check_value(x) and self.__check_value(y):
            self.__x = x
            self.__y = y

    def set_coord(self, x, y):
        if self.__check_value(x) and self.__check_value(y):
            self.__x = x
            self.__y = y
        else:
            raise ValueError("Координаты должны быть числами")

    def get_coord(self):
        return self.__x, self.__y

    @staticmethod
    def __check_value(x):
        return isinstance(x, (int, float))

Здесь стоит обратить внимание сразу на два момента, каждый из которых опирается на материал, разобранный в предыдущих уроках. Во-первых, __check_value сам объявлен через двойное подчёркивание — то есть тоже приватен, и точно так же, как и приватные атрибуты, подвергается искажению имени (_Point__check_value). Это осмысленно: сама проверка типа — деталь внутренней реализации класса, а не часть его публичного интерфейса, и внешнему коду вообще не нужно о ней знать. Во-вторых, __check_value объявлен статическим методом — потому что, как обсуждалось в прошлом уроке, для проверки значения не требуется ни доступ к конкретному объекту (self), ни доступ к атрибутам класса (cls) — это самодостаточное вычисление, зависящее только от переданного аргумента, что и есть тот самый признак, по которому статический метод оказывается предпочтительнее обычного.

Обход приватности всё же возможен — и почему этого не стоит делать

Раз name mangling — это переименование, а не настоящий запрет, обойти его технически несложно:

pt._Point__x = 999

Эта строка выполнится без единой ошибки и напрямую изменит значение, минуя весь код валидации, написанный в set_coord. Однако делать это категорически не рекомендуется, и причина проста: весь смысл приватного атрибута и сопровождающих его геттеров и сеттеров заключался именно в том, чтобы гарантировать выполнение проверок при каждом изменении данных. Обращение к искажённому имени напрямую полностью обходит эту гарантию, и объект может оказаться в некорректном состоянии — том самом, ради предотвращения которого весь механизм и был задуман. Возможность обойти защиту не означает, что её стоит использовать: в Python приватность держится на добросовестном соблюдении соглашений участниками разработки, а не на принудительном запрете со стороны языка, и это осознанный философский выбор дизайна языка — знаменитая позиция «мы все тут взрослые люди» (we're all consenting adults here), подразумевающая, что язык доверяет разработчику не нарушать явно обозначенные границы.

Сторонние решения строгого контроля доступа

Для тех случаев, когда требуется более строгий, по-настоящему принудительный контроль доступа, существуют сторонние библиотеки — например, accessify, устанавливаемая через pip install accessify и позволяющая пометить метод декоратором @private, после чего попытка вызвать его извне класса действительно приведёт к исключению. Однако на практике такие библиотеки применяются крайне редко: подавляющее большинство Python-кода, который вам предстоит читать и писать, опирается именно на стандартные соглашения об именовании — одно и два подчёркивания — а не на дополнительные внешние инструменты. Знать о существовании подобных библиотек полезно, но ожидать их в реальном коде не стоит.

Подводные камни

Первая и самая частая ошибка — спутать соглашение об именовании с реальной защитой. Одно подчёркивание не защищает атрибут вообще никак — это чисто документирующее соглашение, и полагаться на него как на технический барьер бессмысленно: Python не станет мешать никакому коду, который решит обратиться к _x напрямую.

Вторая ошибка — забыть, что name mangling применяется по правилам синтаксического анализа кода в момент его написания внутри класса, а не в момент выполнения. Это означает, что искажение имени происходит именно для тех обращений self.__имя, которые физически находятся внутри тела класса; попытка обратиться к такому атрибуту динамически, например через getattr(pt, '__x'), не сработает, потому что искажённого имени в такой строке ждать неоткуда — нужно будет явно указывать getattr(pt, '_Point__x').

Третья ошибка — размещать проверку данных не там, где она действительно нужна. Если валидация написана только в __init__, но не в set_coord, объект остаётся защищённым лишь в момент создания, а затем оказывается уязвим к тем же самым некорректным данным при любом последующем изменении. Проверка должна выполняться в каждой точке, где данные попадают внутрь объекта, а не только в одной из них.

Итоги урока

Python не располагает строгими модификаторами доступа в том виде, в каком они существуют в языках со статической типизацией, — вместо этого он опирается на соглашения об именовании. Обычное имя (self.x) обозначает публичный атрибут, открытый для свободного использования снаружи. Одно подчёркивание (self._x) сигнализирует, что атрибут предназначен для внутреннего использования, но не создаёт никакого технического барьера — это чистая договорённость между разработчиками. Два подчёркивания (self.__x) запускают механизм искажения имени: атрибут физически переименовывается в _ИмяКласса__имя, что затрудняет случайное обращение к нему извне и предотвращает конфликты имён при наследовании, но не делает атрибут абсолютно недоступным — обойти защиту всё ещё можно, обратившись к искажённому имени напрямую.

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

В следующем уроке мы разберём, как сделать работу с геттерами и сеттерами более естественной синтаксически — так, чтобы обращение к защищённым данным выглядело как обычное обращение к атрибуту, но при этом незаметно выполняло всю ту же валидацию. Этому служит встроенная функция property() и вырастающий из неё декоратор @property.


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

  1. В чём заключается основная проблема, если все атрибуты объекта остаются публичными?
  2. Чем protected-атрибут (_x) технически отличается от private-атрибута (__x) с точки зрения самого Python?
  3. Почему pt._x = 100 работает без единой ошибки, хотя _x считается «защищённым»?**
  4. Что такое name mangling и какие две задачи он решает?
  5. Как можно обратиться к приватному атрибуту __x объекта pt класса Point напрямую, минуя геттер?
  6. В чём практическая польза сеттеров и геттеров, если сами по себе они не добавляют новой функциональности?
  7. Почему валидацию значения лучше вынести в отдельный вспомогательный метод, а не дублировать её код в __init__ и в сеттере?
  8. Почему вспомогательный метод проверки значения в примере урока объявлен статическим?
  9. Почему возможность обойти приватность через pt._Point__x не означает, что приватности в Python не существует вовсе?

Задачи

Задача 1.

Создайте класс Line, объекты которого создаются как Line(x1, y1, x2, y2) и хранят координаты в приватных атрибутах __x1, __y1, __x2, __y2. Реализуйте методы set_coords(), get_coords() (возвращающий кортеж всех четырёх координат) и draw() (выводящий координаты в одну строку через пробел).

Пример использования:

line = Line(0, 0, 10, 10)
line.draw()

Задача 2.

Реализуйте класс Clock, хранящий время в приватном атрибуте __time (целое число). Реализуйте методы set_time(tm), get_time() и приватный метод проверки __check_time(tm). Значение должно быть целым числом, больше 0 и меньше 100000; проверка должна выполняться и при создании объекта, и при вызове set_time.

Пример использования:

clock = Clock(100)
clock.set_time(4530)
print(clock.get_time())  # 4530

Задача 3.

Создайте класс User, хранящий имя в защищённом атрибуте _name. Реализуйте set_name(name) и get_name(); если переданное имя не строка, set_name (и конструктор) должны выбрасывать ValueError.

Пример использования:

user = User("Alice")
print(user.get_name())  # Alice

user2 = User(212) # ValueError: Имя должно быть строкой

Задача 4.

Реализуйте класс Book, создаваемый как Book(author, title, price), с приватными атрибутами __author, __title (строки) и __price (число ≥ 0), и парами методов set_author()/get_author(), set_title()/get_title(), set_price()/get_price() с соответствующей валидацией, выбрасывающей ValueError при некорректных данных.

Пример использования:

book = Book("Толстой", "Война и мир", 500)
print(book.get_title(), book.get_price())

Задача 5.

Реализуйте класс Money с приватным атрибутом __money (целое число ≥ 0), методами set_money(money), get_money() и add_money(mn), принимающим объект класса Money. Если в add_money передано число, а не объект Money, должна выбрасываться ValueError.

Пример использования:

mn1 = Money(10)
mn2 = Money(20)

mn1.set_money(100)
mn2.add_money(mn1)

print(mn2.get_money())  # 120

mn1.add_money(55)  # ошибка

Задача 6.

Реализуйте классы Point (с приватными __x, __y и методом, возвращающим кортеж координат) и Rectangle, создаваемый либо как Rectangle(Point, Point), либо как Rectangle(x1, y1, x2, y2). Rectangle хранит две точки в приватных атрибутах __sp и __ep и предоставляет set_coords(sp, ep), get_coords() и draw().

Пример использования:

pt1 = Point(1, 2)
pt2 = Point(10, 20)

rect1 = Rectangle(0, 0, 20, 34)
rect1.draw()

rect2 = Rectangle(pt1, pt2)
rect2.draw()

Задача 7.

Реализуйте класс BankAccount с приватными атрибутами __owner и __balance. Методы: deposit(amount) (пополнение только положительной суммой), withdraw(amount) (списание не больше текущего баланса) и get_balance(). Баланс не должен изменяться напрямую извне класса.

Пример использования:

acc = BankAccount("Igor", 100)
acc.deposit(50)
acc.withdraw(30)
print(acc.get_balance())  # 120

Задача 8

Реализуйте два класса: ObjList и LinkedList.

Объекты класса ObjList представляют отдельные элементы списка.

В классе ObjList во время инициализации должен создаваться приватный атрибут:

  • __data — строка с данными элемента

Также в каждом объекте ObjList должны быть приватные атрибуты:

  • __next — ссылка на следующий элемент списка
  • __prev — ссылка на предыдущий элемент списка

Изначально ссылки на соседние элементы должны быть равны None.

В классе ObjList необходимо реализовать методы:

  • set_next(obj) — устанавливает ссылку на следующий элемент
  • set_prev(obj) — устанавливает ссылку на предыдущий элемент
  • get_next() — возвращает ссылку на следующий элемент
  • get_prev() — возвращает ссылку на предыдущий элемент
  • set_data(data) — изменяет значение __data
  • get_data() — возвращает значение __data

Класс LinkedList управляет всей структурой списка.

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

  • head — ссылка на первый элемент списка
  • tail — ссылка на последний элемент списка

Если список пуст, оба атрибута должны быть равны None.


В классе LinkedList необходимо реализовать методы:

  • add_obj(obj) — добавляет новый объект ObjList в конец списка При добавлении необходимо корректно обновить связи между элементами (next и prev)

  • remove_obj() — удаляет последний элемент списка При удалении необходимо корректно обновить связи и атрибут tail

  • get_data() — возвращает список строк, содержащий данные всех элементов списка Обход списка должен выполняться от head к tail


Объекты класса ObjList создаются следующим образом:

ob = ObjList("данные 1")

Пример использования:

lst = LinkedList()
lst.add_obj(ObjList("данные 1"))
lst.add_obj(ObjList("данные 2"))
lst.add_obj(ObjList("данные 3"))

print(lst.get_data())  # ['данные 1', 'данные 2', 'данные 3']

При реализации важно:

  • не обращаться напрямую к приватным атрибутам объектов ObjList извне

  • все связи между элементами должны устанавливаться только через методы

  • корректно обрабатывать крайние случаи:

    • пустой список
    • список из одного элемента

Предыдущий урок | Следующий урок