Во всех классах, разобранных до сих пор, атрибуты объектов были полностью открытыми: любой участок кода в программе мог не только прочитать их значение, но и присвоить им что угодно, не встретив никакого сопротивления со стороны класса. Это удобно, когда речь идёт об учебных примерах, но в реальной разработке подобная открытость быстро превращается в источник проблем: объект можно перевести в состояние, которое нарушает саму логику, ради которой класс был написан, и это нарушение зачастую обнаруживается далеко не сразу, а в виде труднообъяснимой ошибки где-то в совершенно другой части программы.
С этого урока курс переходит к модулю, посвящённому инкапсуляции — идее, что доступ к внутреннему состоянию объекта должен проходить через контролируемый интерфейс, а не быть полностью произвольным. Начнём с самого базового инструмента такого контроля — соглашений об именовании атрибутов, которые в Python выполняют роль, аналогичную модификаторам доступа public, protected и private в языках вроде Java или C++, хотя и устроены принципиально иначе.
Рассмотрим класс, уже знакомый по предыдущим урокам:
class Point:
def __init__(self, x=0, y=0):
self.x = x
self.y = ypt = 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 устроен иначе: он не запрещает обращение к атрибуту синтаксически, но предоставляет соглашения об именовании, которые сообщают программисту, как именно предполагается использовать тот или иной атрибут — и в одном из трёх случаев дополнительно подключает механизм, затрудняющий случайное нарушение этого соглашения.
Публичный доступ — это поведение по умолчанию, тот способ именования атрибутов, что использовался во всех предыдущих уроках: 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).
Когда 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.__ypt = 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.
- В чём заключается основная проблема, если все атрибуты объекта остаются публичными?
- Чем protected-атрибут (
_x) технически отличается от private-атрибута (__x) с точки зрения самого Python? - Почему
pt._x = 100работает без единой ошибки, хотя_xсчитается «защищённым»?** - Что такое name mangling и какие две задачи он решает?
- Как можно обратиться к приватному атрибуту
__xобъектаptклассаPointнапрямую, минуя геттер? - В чём практическая польза сеттеров и геттеров, если сами по себе они не добавляют новой функциональности?
- Почему валидацию значения лучше вынести в отдельный вспомогательный метод, а не дублировать её код в
__init__и в сеттере? - Почему вспомогательный метод проверки значения в примере урока объявлен статическим?
- Почему возможность обойти приватность через
pt._Point__xне означает, что приватности в Python не существует вовсе?
Создайте класс Line, объекты которого создаются как Line(x1, y1, x2, y2) и хранят координаты в приватных атрибутах __x1, __y1, __x2, __y2. Реализуйте методы set_coords(), get_coords() (возвращающий кортеж всех четырёх координат) и draw() (выводящий координаты в одну строку через пробел).
Пример использования:
line = Line(0, 0, 10, 10)
line.draw()Реализуйте класс 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Создайте класс User, хранящий имя в защищённом атрибуте _name. Реализуйте set_name(name) и get_name(); если переданное имя не строка, set_name (и конструктор) должны выбрасывать ValueError.
Пример использования:
user = User("Alice")
print(user.get_name()) # Alice
user2 = User(212) # ValueError: Имя должно быть строкойРеализуйте класс 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())Реализуйте класс 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) # ошибкаРеализуйте классы 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()Реализуйте класс BankAccount с приватными атрибутами __owner и __balance. Методы: deposit(amount) (пополнение только положительной суммой), withdraw(amount) (списание не больше текущего баланса) и get_balance(). Баланс не должен изменяться напрямую извне класса.
Пример использования:
acc = BankAccount("Igor", 100)
acc.deposit(50)
acc.withdraw(30)
print(acc.get_balance()) # 120Реализуйте два класса: ObjList и LinkedList.
Объекты класса ObjList представляют отдельные элементы списка.
В классе ObjList во время инициализации должен создаваться приватный атрибут:
__data— строка с данными элемента
Также в каждом объекте ObjList должны быть приватные атрибуты:
__next— ссылка на следующий элемент списка__prev— ссылка на предыдущий элемент списка
Изначально ссылки на соседние элементы должны быть равны None.
В классе ObjList необходимо реализовать методы:
set_next(obj)— устанавливает ссылку на следующий элементset_prev(obj)— устанавливает ссылку на предыдущий элементget_next()— возвращает ссылку на следующий элементget_prev()— возвращает ссылку на предыдущий элементset_data(data)— изменяет значение__dataget_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извне -
все связи между элементами должны устанавливаться только через методы
-
корректно обрабатывать крайние случаи:
- пустой список
- список из одного элемента