Skip to content

Latest commit

 

History

History
342 lines (216 loc) · 32.9 KB

File metadata and controls

342 lines (216 loc) · 32.9 KB

Урок 2. Классы и объекты. Атрибуты классов и объектов

В предыдущем уроке речь шла о самой идее объектно-ориентированного программирования — о том, зачем оно вообще появилось и какую проблему решает. Идея сама по себе абстрактна: пока класс и объект не написаны руками и не рассмотрены изнутри, вся концепция остаётся набором терминов. Поэтому в этом уроке мы переходим от идей к практике и разбираем то, с чего начинается любая работа с ООП в 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 = 20
a.__dict__   # {'x': 1, 'y': 2}
b.__dict__   # {'x': 10, 'y': 20}

Здесь механизм тот же, что уже был разобран для color: присваивание через объект создаёт запись в его собственном пространстве имён. Разница лишь в том, что на этот раз так и было задумано — координаты действительно должны принадлежать конкретному объекту, а не классу. Из этого вырастает базовый архитектурный принцип, на котором строится практически любой класс в Python: то, что общее для всех объектов и не должно меняться от объекта к объекту, размещается в классе; то, что индивидуально для каждого экземпляра, создаётся как атрибут объекта — как правило, во время его инициализации (об этом подробно пойдёт речь в следующем уроке, посвящённом методу __init__).

Документирование класса

Класс, как и функция, может иметь строку документации — она указывается первым выражением в теле класса и становится доступна через атрибут __doc__:

class Point:
    """Класс для представления точки на плоскости."""
    color = 'red'
    circle = 2
Point.__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 и почему он появляется первым аргументом в каждом методе — а заодно увидим, что методы, по сути, подчиняются точно тому же механизму пространств имён, который был разобран в этом уроке.


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

  1. Что физически представляет собой класс в Python и где хранятся его атрибуты?
  2. Почему a.__dict__ может быть пустым словарём, а обращение a.color при этом успешно возвращает значение?
  3. В чём разница между атрибутом класса и атрибутом объекта с точки зрения того, где они физически хранятся?
  4. Что происходит при выполнении a.attr = value, если у класса уже есть атрибут с именем attr?
  5. Почему изменение атрибута класса (Класс.attr = новое_значение) отражается сразу на всех объектах?
  6. В каком порядке Python ищет атрибут при обращении объект.имя?
  7. Чем getattr(объект, 'имя', значение_по_умолчанию) отличается от обычного обращения объект.имя?
  8. Почему после del a.color обращение a.color снова начинает возвращать значение из класса, а не выбрасывает ошибку?
  9. Почему список, объявленный как атрибут класса, может привести к тому, что изменение через один объект окажется видно и через другой?

Задачи

Задача 1.

Объявите класс Book, представляющий книгу, со следующими атрибутами класса:

  • title"Незнакомая книга"
  • pages100
  • is_availableTrue

После объявления класса выведите значение Book.title и Book.__dict__, чтобы убедиться, что оба атрибута действительно хранятся в пространстве имён класса.


Задача 2.

Используя класс Book из предыдущей задачи, создайте два объекта: b1 и b2. Не создавая у них никаких собственных атрибутов, выведите b1.__dict__ и b2.__dict__, а затем b1.title и b2.title. Объясните словами (в комментарии к коду), почему словари пусты, но обращение к title работает.


Задача 3.

Продолжая работу с b1 и b2: измените атрибут pages только у объекта b1 на 250. Выведите b1.pages и b2.pages, а также Book.pages. Убедитесь, что изменение затронуло только b1.


Задача 4.

Измените атрибут is_available напрямую через класс Book на False. Выведите значение is_available у объектов b1 и b2. Объясните в комментарии, почему в этот раз изменение видно у обоих объектов.


Задача 5.

Объявите класс Car с атрибутом класса wheels = 4. Создайте объект c1. Добавьте объекту c1 атрибут brand со значением "Toyota", используя функцию setattr. Затем проверьте с помощью hasattr, есть ли у c1 атрибут brand, и отдельно — есть ли у c1 атрибут engine (которого не существует).


Задача 6.

Объявите класс Settings с атрибутом класса theme = "light". Создайте объект s1. Используя getattr, получите значение theme у s1, а также попробуйте получить значение несуществующего атрибута language, указав значение по умолчанию "ru". Выведите оба результата.


Задача 7.

Объявите класс Profile с атрибутом класса status = "active". Создайте объект p1 и присвойте ему собственный атрибут status = "banned". Удалите этот атрибут у p1 с помощью del. Выведите p1.status после удаления и объясните в комментарии, откуда взялось это значение.


Задача 8.

Объявите класс Order с атрибутом класса discount_percent = 5. Создайте три объекта: o1, o2, o3. Каждому из них присвойте собственный атрибут customer_name (например, "Иван", "Мария", "Пётр"). Затем измените discount_percent через класс на 10. Выведите для каждого объекта его customer_name вместе с актуальным значением discount_percent.


Задача 9.

Объявите класс Cart с атрибутом класса items = [] (список, изменяемый объект). Создайте два объекта cart1 и cart2. Через cart1.items.append(...) добавьте один элемент. Выведите cart2.items и объясните в комментарии, почему изменение видно и во втором объекте. Затем исправьте ситуацию: присвойте cart1 собственный список через cart1.items = [] и добавьте элемент заново — убедитесь, что теперь cart2.items остаётся пустым.


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