В предыдущем уроке речь шла о самой идее объектно-ориентированного программирования — о том, зачем оно вообще появилось и какую проблему решает. Идея сама по себе абстрактна: пока класс и объект не написаны руками и не рассмотрены изнутри, вся концепция остаётся набором терминов. Поэтому в этом уроке мы переходим от идей к практике и разбираем то, с чего начинается любая работа с ООП в Python — определение класса, создание объектов на его основе и, что важнее всего, различие между тем, что принадлежит классу, и тем, что принадлежит конкретному объекту.
Это различие — центральная тема урока. На первый взгляд оно выглядит простым: класс описывает общее, объект хранит частное. Но за этой формулировкой стоит конкретный механизм — то, как Python на самом деле организует хранение данных и как он ищет атрибут, когда вы пишете объект.атрибут. Пока этот механизм не понят, программист на Python постоянно будет натыкаться на поведение, которое кажется «магическим» — атрибут будто бы меняется у одного объекта, но неожиданно меняется и у другого; или наоборот, изменение атрибута у одного объекта никак не сказывается на остальных. Разгадка всегда одна и та же, и после этого урока она перестанет быть загадкой.
Начнём с определения простейшего класса:
class Point:
passКлючевое слово class сообщает интерпретатору, что создаётся новый тип данных. Имя Point по соглашению PEP 8 пишется с заглавной буквы — это негласное правило, по которому в коде сразу отличают класс от обычной переменной или функции. Тело класса в данном случае состоит из одного оператора pass, означающего «ничего не делать» — синтаксически класс не может быть пустым, а pass заполняет это требование, не добавляя никакого содержимого.
Уже на этом этапе Point — полноценный объект в памяти Python, даже несмотря на то что внутри него пока ничего нет. В этом легко убедиться, обратившись к встроенной функции type:
type(Point) # <class 'type'>Каждый класс в Python сам является объектом — экземпляром более общего класса type. Эта деталь станет важной значительно позже, когда речь пойдёт о метаклассах, но уже сейчас полезно держать в голове: в Python классы — не какая-то особая синтаксическая конструкция, существующая отдельно от объектов, а такие же объекты, только особого рода.
Наполним класс данными:
class Point:
color = 'red'
circle = 2Здесь стоит остановиться и сформулировать идею, без понимания которой всё остальное в этом уроке не сложится в цельную картину. Класс в Python — это отдельное пространство имён. Когда интерпретатор выполняет тело класса, он создаёт для него собственный словарь, в который записывает всё, что было объявлено внутри — в данном случае color и circle. Этот словарь физически существует и доступен через атрибут __dict__:
Point.__dict__Вывод будет содержать служебные атрибуты, добавленные самим Python, а среди них — color и circle с теми значениями, которые были присвоены при объявлении класса. Обращаться к этим значениям можно напрямую через имя класса:
Point.color # 'red'
Point.circle # 2И точно так же их можно менять:
Point.color = 'black'Пока что перед нами обычная переменная, но записанная не в глобальном пространстве имён модуля, а в пространстве имён класса. Это заметно упрощает понимание того, что произойдёт дальше при создании объектов.
Объект (экземпляр класса) создаётся вызовом класса как функции:
a = Point()
b = Point()В этот момент в памяти появляются два самостоятельных объекта, каждый из которых создан по шаблону класса Point. Убедиться, что оба они действительно принадлежат этому классу, можно несколькими способами:
type(a) # <class '__main__.Point'>
type(a) == Point # True
isinstance(a, Point) # TrueКласс здесь выступает в привычной роли типа данных — точно так же, как int или str являются типами для чисел и строк, Point становится типом для созданных на его основе объектов.
Теперь — ключевой вопрос, ради которого весь урок и затевался: что на самом деле хранится внутри a и b? Интуитивно кажется, что раз класс объявлял атрибуты color и circle, то каждый созданный объект должен получить собственную копию этих данных. Проверим это напрямую:
a.__dict__ # {}
b.__dict__ # {}Оба словаря пусты. Это не ошибка и не особый случай — это нормальное поведение только что созданного объекта, у которого ещё не было ни одного собственного атрибута. При этом обращение к атрибутам работает без каких-либо проблем:
a.color # 'red'
b.circle # 2Противоречия здесь нет, но чтобы его разрешить, нужно понять правило, по которому Python ищет атрибут при обращении через точку. Когда выполняется a.color, интерпретатор сначала смотрит в собственный словарь объекта a — то есть в a.__dict__. Если атрибут там не найден, поиск продолжается в классе, экземпляром которого является a, то есть в Point.__dict__. Именно поэтому a.color возвращает 'red', хотя сам объект a этим значением не владеет — он лишь получает доступ к значению, которое хранится в классе.
Это правило удобно представлять себе как двухуровневую структуру: над каждым объектом «нависает» его класс, и если нужного имени нет на первом уровне, поиск автоматически поднимается на второй.
Из описанного правила поиска вытекает важное практическое следствие: если атрибут физически хранится в классе, а не в объекте, то изменение этого атрибута через класс отражается сразу на всех объектах, у которых нет собственного атрибута с таким же именем.
Point.circle = 1
a.circle # 1
b.circle # 1И a, и b продолжают ссылаться на один и тот же атрибут circle, физически существующий в единственном экземпляре — внутри класса Point. Сколько бы объектов ни было создано на основе этого класса, у всех них общий атрибут circle будет меняться синхронно, до тех пор пока какой-то из объектов не получит собственный, локальный атрибут с тем же именем. Именно к этому случаю мы сейчас и перейдём — он объясняет обратную сторону того же механизма.
Выполним присваивание не через класс, а через конкретный объект:
a.color = 'green'Проверим результат:
a.color # 'green'
b.color # 'red'Значение изменилось только у a, при этом b по-прежнему возвращает 'red' — значение, унаследованное от класса. Чтобы понять, что произошло, снова заглянем в __dict__ обоих объектов:
a.__dict__ # {'color': 'green'}
b.__dict__ # {}Присваивание через объект — это операция создания или изменения атрибута в пространстве имён именно этого объекта, а не класса. Как только выполняется a.color = 'green', Python создаёт в словаре a.__dict__ новую запись color. Объект b эту операцию никак не затрагивает — его собственный словарь остаётся пустым, и при обращении b.color поиск, как и раньше, поднимается до класса.
Здесь удобно сформулировать общее правило, которое стоит запомнить дословно: присваивание атрибута через объект всегда создаёт или обновляет атрибут этого конкретного объекта. Оно никогда не изменяет одноимённый атрибут класса, даже если такой атрибут уже существует и именно из-за него обращение к чтению раньше работало. После присваивания у объекта появляется собственная копия имени, которая с этого момента перекрывает («затеняет») значение из класса — но само значение в классе остаётся нетронутым, и другие объекты продолжают его видеть.
Это и есть источник большинства неожиданностей, с которыми сталкиваются новички: попытка «изменить общий атрибут» через один из объектов на деле создаёт независимый атрибут именно у него, оставляя общий атрибут класса и все остальные объекты без изменений.
Атрибуты класса не обязаны быть объявлены заранее в теле класса — их можно добавлять уже после того, как класс создан.
Point.type_pt = 'disc'Тот же результат даёт встроенная функция setattr, принимающая объект (или класс), имя атрибута и значение:
setattr(Point, 'prop', 1)Если атрибут с указанным именем уже существует, setattr просто перезапишет его значение:
setattr(Point, 'type_pt', 'square')Разница между прямым присваиванием и вызовом setattr чисто синтаксическая: setattr полезен там, где имя атрибута заранее неизвестно и формируется динамически — например, приходит в виде строки из внешнего источника.
Обращение к несуществующему атрибуту приводит к исключению:
Point.a # AttributeErrorЧтобы избежать падения программы там, где отсутствие атрибута — ожидаемая, штатная ситуация, используется функция getattr с третьим необязательным аргументом — значением по умолчанию:
getattr(Point, 'a', False) # False, атрибута нет — возвращается запасное значение
getattr(Point, 'color') # 'red', атрибут найден — возвращается его значениеЕсли третий аргумент не указан, а атрибут отсутствует, getattr ведёт себя точно так же, как обычное обращение через точку, и выбрасывает AttributeError. Проверить существование атрибута, не вызывая при этом исключение и не читая значение, позволяет функция hasattr:
hasattr(Point, 'circle') # TrueУдаление атрибута выполняется оператором del или функцией delattr — они полностью эквивалентны друг другу:
del Point.prop
delattr(Point, 'type_pt')Здесь важно то же самое правило пространства имён, что действовало и для присваивания: del удаляет атрибут именно из того пространства имён, к которому обратились. Если попытаться удалить у объекта b атрибут color, которого у него никогда не было (он существует только в классе), Python выбросит исключение:
del b.color # AttributeError: у b нет собственного атрибута colorА вот у объекта a, который ранее получил собственный color через присваивание a.color = 'green', удаление пройдёт успешно:
del a.color
a.color # 'red'После удаления локальной записи из a.__dict__ обращение a.color снова поднимается по цепочке поиска до класса и возвращает значение, хранящееся там. Это наглядно показывает, что удаление локального атрибута не «стирает» данные навсегда — оно лишь убирает то, что перекрывало значение класса, и после удаления это значение из класса снова становится видимым.
До сих пор color и circle были атрибутами, разумно живущими в классе — это общие характеристики всех точек по умолчанию. Но не все данные ведут себя так. Координаты точки на плоскости, очевидно, у каждого объекта свои, и хранить их в классе было бы ошибкой — тогда все точки делили бы одну и ту же пару координат, что лишает класс смысла.
a.x = 1
a.y = 2
b.x = 10
b.y = 20a.__dict__ # {'x': 1, 'y': 2}
b.__dict__ # {'x': 10, 'y': 20}Здесь механизм тот же, что уже был разобран для color: присваивание через объект создаёт запись в его собственном пространстве имён. Разница лишь в том, что на этот раз так и было задумано — координаты действительно должны принадлежать конкретному объекту, а не классу. Из этого вырастает базовый архитектурный принцип, на котором строится практически любой класс в Python: то, что общее для всех объектов и не должно меняться от объекта к объекту, размещается в классе; то, что индивидуально для каждого экземпляра, создаётся как атрибут объекта — как правило, во время его инициализации (об этом подробно пойдёт речь в следующем уроке, посвящённом методу __init__).
Класс, как и функция, может иметь строку документации — она указывается первым выражением в теле класса и становится доступна через атрибут __doc__:
class Point:
"""Класс для представления точки на плоскости."""
color = 'red'
circle = 2Point.__doc__ # 'Класс для представления точки на плоскости.'Эта строка не влияет на работу класса и не участвует ни в одном вычислении — её единственная задача информационная. Но в реальной разработке, особенно там, где над одним кодом работает несколько человек или где к коду приходится возвращаться спустя месяцы, docstring класса нередко оказывается единственным местом, где кратко и понятно описано назначение типа данных, прежде чем читатель начнёт разбираться в деталях реализации.
Первая и самая распространённая ошибка — это спутать присваивание через объект с изменением общего состояния. Начинающие нередко пишут что-то вроде a.circle = a.circle + 1, желая «увеличить общий счётчик для всех объектов», и удивляются, когда у других объектов значение осталось прежним. На самом деле выражение справа читает значение через обычный поиск (объект → класс), а присваивание слева создаёт новый локальный атрибут именно у a. Если действительно нужно изменить общее для всех объектов состояние, обращаться нужно не через объект, а через сам класс: Point.circle += 1.
Вторая ловушка связана с изменяемыми значениями по умолчанию, объявленными в классе. Если в качестве атрибута класса используется список или словарь, а не число или строка, ситуация меняется принципиально:
class Basket:
items = []
a = Basket()
b = Basket()
a.items.append('apple')
print(b.items) # ['apple'] — неожиданно!Здесь a.items не создаёт новый атрибут у a — присваивания не было, был лишь вызов метода append, изменяющего объект списка на месте. А поскольку и a, и b при обращении к items находят один и тот же объект списка в классе Basket, изменение через a оказывается видно и через b. Это довольно частая причина ошибок в реальном коде: изменяемые значения (списки, словари, множества) крайне редко стоит объявлять как атрибуты класса, если предполагается, что у каждого объекта должны быть собственные, независимые данные.
Третья вещь, о которой стоит помнить: hasattr под капотом фактически пытается выполнить getattr и проверяет, не возникло ли исключение. Это означает, что hasattr вернёт False не только в случае отсутствия атрибута, но и в случае, если обращение к существующему атрибуту по какой-то причине само выбрасывает исключение (например, если атрибут реализован через @property, а внутри её кода возникает ошибка). Такое поведение иногда маскирует реальные ошибки в коде, выдавая их за «атрибут отсутствует».
Класс в Python — это отдельное пространство имён, физически представленное словарём __dict__, в котором хранятся все атрибуты, объявленные в теле класса или добавленные позже. Объект, созданный на основе класса, изначально имеет собственное пространство имён пустым и получает доступ к атрибутам класса благодаря определённому порядку поиска: сначала интерпретатор ищет имя в словаре самого объекта, и только если не находит — поднимается к словарю класса.
Из этого порядка поиска следуют два симметричных факта, которые и составляют суть всего урока. Чтение атрибута через объект прозрачно достаёт значение из класса, если у объекта нет собственного одноимённого атрибута — и поэтому изменение атрибута через класс отражается сразу на всех объектах. А присваивание через объект всегда создаёт или изменяет атрибут именно этого объекта, никогда не затрагивая класс — и поэтому таким способом задаются индивидуальные, уникальные для каждого экземпляра данные.
Работа с атрибутами не ограничивается точечной нотацией: getattr, setattr, delattr и hasattr дают программный доступ к тем же операциям, что особенно важно, когда имя атрибута не известно заранее и вычисляется динамически.
В следующем уроке мы перейдём к методам класса и разберём, что в действительности представляет собой параметр self и почему он появляется первым аргументом в каждом методе — а заодно увидим, что методы, по сути, подчиняются точно тому же механизму пространств имён, который был разобран в этом уроке.
- Что физически представляет собой класс в Python и где хранятся его атрибуты?
- Почему
a.__dict__может быть пустым словарём, а обращениеa.colorпри этом успешно возвращает значение? - В чём разница между атрибутом класса и атрибутом объекта с точки зрения того, где они физически хранятся?
- Что происходит при выполнении
a.attr = value, если у класса уже есть атрибут с именемattr? - Почему изменение атрибута класса (
Класс.attr = новое_значение) отражается сразу на всех объектах? - В каком порядке Python ищет атрибут при обращении
объект.имя? - Чем
getattr(объект, 'имя', значение_по_умолчанию)отличается от обычного обращенияобъект.имя? - Почему после
del a.colorобращениеa.colorснова начинает возвращать значение из класса, а не выбрасывает ошибку? - Почему список, объявленный как атрибут класса, может привести к тому, что изменение через один объект окажется видно и через другой?
Объявите класс Book, представляющий книгу, со следующими атрибутами класса:
title—"Незнакомая книга"pages—100is_available—True
После объявления класса выведите значение Book.title и Book.__dict__, чтобы убедиться, что оба атрибута действительно хранятся в пространстве имён класса.
Используя класс Book из предыдущей задачи, создайте два объекта: b1 и b2. Не создавая у них никаких собственных атрибутов, выведите b1.__dict__ и b2.__dict__, а затем b1.title и b2.title. Объясните словами (в комментарии к коду), почему словари пусты, но обращение к title работает.
Продолжая работу с b1 и b2: измените атрибут pages только у объекта b1 на 250. Выведите b1.pages и b2.pages, а также Book.pages. Убедитесь, что изменение затронуло только b1.
Измените атрибут is_available напрямую через класс Book на False. Выведите значение is_available у объектов b1 и b2. Объясните в комментарии, почему в этот раз изменение видно у обоих объектов.
Объявите класс Car с атрибутом класса wheels = 4. Создайте объект c1. Добавьте объекту c1 атрибут brand со значением "Toyota", используя функцию setattr. Затем проверьте с помощью hasattr, есть ли у c1 атрибут brand, и отдельно — есть ли у c1 атрибут engine (которого не существует).
Объявите класс Settings с атрибутом класса theme = "light". Создайте объект s1. Используя getattr, получите значение theme у s1, а также попробуйте получить значение несуществующего атрибута language, указав значение по умолчанию "ru". Выведите оба результата.
Объявите класс Profile с атрибутом класса status = "active". Создайте объект p1 и присвойте ему собственный атрибут status = "banned". Удалите этот атрибут у p1 с помощью del. Выведите p1.status после удаления и объясните в комментарии, откуда взялось это значение.
Объявите класс Order с атрибутом класса discount_percent = 5. Создайте три объекта: o1, o2, o3. Каждому из них присвойте собственный атрибут customer_name (например, "Иван", "Мария", "Пётр"). Затем измените discount_percent через класс на 10. Выведите для каждого объекта его customer_name вместе с актуальным значением discount_percent.
Объявите класс Cart с атрибутом класса items = [] (список, изменяемый объект). Создайте два объекта cart1 и cart2. Через cart1.items.append(...) добавьте один элемент. Выведите cart2.items и объясните в комментарии, почему изменение видно и во втором объекте. Затем исправьте ситуацию: присвойте cart1 собственный список через cart1.items = [] и добавьте элемент заново — убедитесь, что теперь cart2.items остаётся пустым.