Продвинутый Python: оператор dot

Казалось бы, что может быть тривиальнее оператора dot? Большинство из вас многократно пользовались этим оператором, не задаваясь вопросом, как именно он действует. Этот оператор очень удобен для решения повседневных задач. Вы обращаетесь к нему практически каждый раз, когда используете Python для чего-то большего, чем “Hello World”. Именно поэтому вам наверняка хочется копнуть глубже, и я готов стать вашим гидом. 


Начнем с банального вопроса: что такое оператор dot?

Вот пример:

hello = 'Hello world!'

print(hello.upper())
# HELLO WORLD!

Конечно, это пример простейшего “Hello World”, хотя я с трудом представляю, что кто-то начнет учить Python именно с этого примера. В любом случае, оператор dot  —  это часть “.” в строке hello.upper(). Вот более сложный пример:

class Person:

num_of_persons = 0

def __init__(self, name):
self.name = name
def shout(self):
print(f"Hey! I'm {self.name}")

p = Person('John')
p.shout()
# Hey I'm John.

p.num_of_persons
# 0

p.name
# 'John'

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

  • для доступа к атрибутам объекта или класса;
  • для доступа к функциям, заданным в определении класса.

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

p.shout
# <bound method Person.shout of <__main__.Person object at 0x1037d3a60>>

id(p.shout)
# 4363645248

Person.shout
# <function __main__.Person.shout(self)>

id(Person.shout)
# 4364388816

Почему-то p.shout не ссылается на ту же функцию, что и Person.shout, хотя вы ожидали именно этого. А ведь p.shout  —  это даже не функция! Прежде чем приступить к обсуждению, рассмотрим следующий пример:

class Person:

num_of_persons = 0

def __init__(self, name):
self.name = name

def shout(self):
print(f"Hey! I'm {self.name}.")

p = Person('John')

vars(p)
# {'name': 'John'}

def shout_v2(self):
print("Hey, what's up?")

p.shout_v2 = shout_v2

vars(p)
# {'name': 'John', 'shout_v2': <function __main__.shout_v2(self)>}

p.shout()
# Hey, I'm John.

p.shout_v2()
# TypeError: shout_v2() не хватает 1 необходимого позиционного аргумента: 'self'

Для тех, кто не знаком с функцией vars, сообщу: она возвращает словарь, содержащий атрибуты экземпляра. Если вы выполните vars(Person), то получите немного другой ответ, но картина будет понятна. Здесь будут как атрибуты с их значениями, так и переменные, содержащие определения функций класса. Очевидно, что существует разница между объектом, являющимся экземпляром класса, и самим объектом класса. Поэтому и ответ функции vars будет разным.

Вполне допустимо дополнительное определение функции после создания объекта. Это происходит в строке p.shout_v2 = shout_v2, что вводит еще одну пару “ключ-значение” в словарь экземпляров. Казалось бы, все хорошо, и можно спокойно работать, как если бы функция shout_v2 была указана в определении класса. Но что-то действительно не так. Мы не можем вызвать ее так же, как и метод shout.

Внимательный читатель уже должен был заметить, насколько осторожно я использую термины функция и метод. В конце концов, есть разница и в том, как Python их выводит. Взгляните на предыдущие примеры. shout  —  это метод, shout_v2  —  функция. По крайней мере, если рассматривать их с точки зрения объекта p. Если рассматривать их с точки зрения класса Person, то shout  —  это функция, а функции shout_v2 не существует. Она определена только в словаре (пространстве имен) объекта. Поэтому если вы действительно собираетесь полагаться на объектно-ориентированные парадигмы и механизмы, такие как инкапсуляция, наследование, абстракция и полиморфизм, то не будете определять функции в объектах, как это сделано в нашем примере с p. Вам нужно будет определять функции в определении (теле) класса.

Почему же есть такое различие и почему мы получаем ошибку? Самый быстрый ответ  —  из-за того, что так работает оператор dot. Более подробное объяснение звучит следующим образом: существует механизм, который выполняет разрешение имен (атрибутов) за вас. Этот механизм состоит из dunder-методов __getattribute__ и __getattr__.

Получение атрибутов

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

  • возвращает вычисленное значение атрибута;
  • явно вызывает __getattr__ или инициирует AttributeError (в этом случае __getattr__ вызывается по умолчанию).

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

Если нужно обрабатывать случаи, когда в словаре объекта нет атрибута, то можно сразу реализовать метод __getattr__. Он вызывается, когда __getattribute__ не удается получить доступ к имени атрибута. Если этот метод не может найти атрибут или справиться с отсутствующим атрибутом, то он также инициирует исключение AttributeError. Поэкспериментируем:

class Person:

num_of_persons = 0

def __init__(self, name):
self.name = name

def shout(self):
print(f"Hey! I'm {self.name}.")

def __getattribute__(self, name):
print(f'getting the attribute name: {name}')
return super().__getattribute__(name)

def __getattr__(self, name):
print(f'this attribute doesn\'t exist: {name}')
raise AttributeError()

p = Person('John')

p.name
# получение имени атрибута: name
# 'John'

p.name1
# получение имени атрибута: name1
# этот атрибут не существует: name1
#
# ... трассировка стека исключений
# AttributeError:

Очень важно вызывать super().__getattribute__(...) в реализации __getattribute__, поскольку в стандартной реализации Python происходит много всего. И это именно то место, откуда оператор dot черпает свою магию. По крайней мере, половина магии находится там. Другая часть заключается в том, как создается объект класса после интерпретации определения класса.

Функции класса

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

p.shout
# <bound method Person.shout of <__main__.Person object at 0x1037d3a60>>

Person.shout
# <function __main__.Person.shout(self)>

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

Первая часть происходит во время интерпретации тела класса. Этот процесс включает довольно много элементов, таких как определение пространства имен класса, добавление в него значений атрибутов, определение функций (класса) и привязка их к именам. Когда эти функции определены, они определенным образом обернуты. Обернуты в объект, который концептуально называется дескриптором. Этот дескриптор и обеспечивает то изменение в идентификации и поведении функций класса, которое мы наблюдали ранее. Дескриптор является экземпляром класса, который реализует предопределенный набор dunder-методов. Он также называется Protocol (протоколом). Когда объекты этого класса реализованы, говорят, что они следуют определенному протоколу, а значит, ведут себя ожидаемым образом.

Существует разница между дескрипторами данных и дескрипторами без данных. Первые реализуют dunder-методы __get__, __set__ и/или __delete__. Вторые реализуют только метод __get__. В любом случае каждая функция в классе оказывается обернутой в так называемый дескриптор без данных.

После инициирования поиска атрибутов с помощью оператора dot, вызывается метод __getattribute__ и начинается весь процесс разрешения имени. Этот процесс останавливается при успешном разрешении, и выглядит он примерно так.

  1. Возвращение дескриптора данных с нужным именем (на уровне класса) или…
  2. Возвращение атрибута экземпляра с нужным именем (уровень экземпляра) или…
  3. Возвращение дескриптора без данных с нужным именем (уровень класса) или…
  4. Возвращение атрибута класса с нужным именем (уровень класса) или…
  5. Инициация исключения AttributeError, вызывающего метод __getattr__.

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

В следующем фрагменте кода помещу в комментарии необходимые описания, чтобы было проще читать и понимать код. Вот он:

def object_getattribute(obj, name):
"Emulate PyObject_GenericGetAttr() in Objects/object.c"
# Создание "ванильного" объекта для последующего использования.
null = object()

"""
obj - это объект, инстанцированный из пользовательского класса.
Здесь мы пытаемся найти имя класса, из которого он был инстанцирован.
"""
objtype = type(obj)

"""
name представляет собой имя функции класса, атрибута экземпляра,
или любого атрибута класса. Здесь мы пытаемся найти его и сохранить на него
ссылку. MRO - это сокращение от Method Resolution Order
(порядок разрешения метода).
Он имеет отношение к наследованию классов. На самом деле это не так важно.
Скажем так: этот механизм оптимально находит имя, пройдя через все родительские классы.
"""
cls_var = find_name_in_mro(objtype, name, null)

"""
Здесь мы проверяем, является ли данный атрибут класса объектом,
у которого реализован метод __get__.
Если да, то мы имеем дело с дескриптором, не содержащим данных.
Это важно для дальнейших шагов.
"""
descr_get = getattr(type(cls_var), '__get__', null)

"""
Теперь либо атрибут класса ссылается на дескриптор
(в этом случае проверяем, является ли он дескриптором данных,
и возвращаем ссылку на метод __get__),
либо переходим к следующему if-блоку кода.
"""
if descr_get is not null:
if (hasattr(type(cls_var), '__set__')
or hasattr(type(cls_var), '__delete__')):
return descr_get(cls_var, obj, objtype) # дескриптор данных

"""
В тех случаях, когда имя не ссылается на дескриптор данных,
проверяем, ссылается ли оно на переменную в словаре объекта,
и если да, то возвращаем ее значение.
"""
if hasattr(obj, '__dict__') and name in vars(obj):
return vars(obj)[name] # переменная экземпляра

"""
В тех случаях, когда имя не ссылается на переменную в словаре объекта,
проверяем, не ссылается ли оно на дескриптор,
не содержащий данных, и возвращаем ссылку на него.
"""
if descr_get is not null:
return descr_get(cls_var, obj, objtype) # дескриптор без данных

"""
В случае если имя не ссылается ни на что из вышеперечисленного,
проверяем, ссылается ли оно на атрибут класса, и возвращаем его значение.
"""
if cls_var is not null:
return cls_var # переменная класса

"""
Если разрешение имени прошло неудачно,
выбрасывается исключение AttriuteError и
вызывается __getattr__.
"""
raise AttributeError(name)

Следует помнить, что данная реализация на языке Python приведена для того, чтобы задокументировать и описать логику, реализованную в методе __getattribute__. На самом деле она реализована на языке C. Просто взглянув на нее, можно понять, что лучше не экспериментировать с повторной реализацией всего этого. Лучше всего попробовать выполнить часть разрешения самостоятельно, а затем вернуться к реализации CPython с return super().__getattribute__(name), как показано в примере выше.

Важно отметить здесь, что каждая функция класса (которая является объектом) оборачивается в дескриптор без данных (который является объектом класса function). Следовательно, в объекте-обертке определен dunder-метод __get__. Этот метод возвращает новый вызываемый объект (считайте, что это новая функция), где первым аргументом является ссылка на объект, к которому применяется оператор dot. Я сказал, что нужно считать этот метод новой функцией, поскольку он является вызываемым. По сути, это еще один объект под названием MethodType. Взгляните:

type(p.shout)
# получение имени атрибута: shout
# метод

type(Person.shout)
# функция

Одним из интересных моментов, безусловно, является класс function. Он как раз и является объектом-оберткой, определяющим метод __get__. Однако при попытке обратиться к нему как к методу shout через оператор dot, __getattribute__ перебирает список и останавливается на третьем случае (возврат дескриптора без данных). Этот метод __get__ содержит дополнительную логику, которая принимает ссылку на объект и создает MethodType со ссылкой на function и объект.

Вот макет официальной документации:

class Function:
...

def __get__(self, obj, objtype=None):
if obj is None:
return self
return MethodType(self, obj)

Не обращайте внимания на разницу в названии классов. Я использовал function вместо Function, чтобы было проще понять, но в дальнейшем буду использовать имя Function, так как оно соответствует объяснению в официальной документации.

В любом случае одного взгляда на этот макет может быть достаточно, чтобы понять, как этот класс function вписывается в картину. Добавим пару строк кода, что, возможно, еще более прояснит ситуацию. В этом примере я добавлю еще две функции класса, а именно:

class Function:
...

def __init__(self, fun, *args, **kwargs):
...
self.fun = fun

def __get__(self, obj, objtype=None):
if obj is None:
return self
return MethodType(self, obj)

def __call__(self, *args, **kwargs):
...
return self.fun(*args, **kwargs)

Зачем я добавил эти функции? Теперь вы можете легко представить, какую роль играет объект Function во всем этом сценарии связывания метода. Этот новый объект Function хранит исходную функцию в качестве атрибута. Этот объект также является вызываемым, следовательно, его можно вызывать как функцию. В этом случае он работает так же, как и обернутая им функция. Помните, что в Python все является объектами, даже функции. А MethodType “оборачивает” объект Function вместе со ссылкой на объект, для которого вызывается метод (в нашем случае shout).

Как MethodType это делает? Хранит ссылки и реализует протокол вызова. Вот макет официальной документации по классу MethodType:

class MethodType:

def __init__(self, func, obj):
self.__func__ = func
self.__self__ = obj

def __call__(self, *args, **kwargs):
func = self.__func__
obj = self.__self__
return func(obj, *args, **kwargs)

Опять же, для краткости: func ссылается на начальную функцию класса (shout), obj  —  на экземпляр (p), а затем передаются аргументы и именованные аргументы. self в объявлении shout ссылается на этот “obj”, который в нашем примере является p.

В конце концов, должно быть понятно, почему мы делаем различие между функциями и методами и как функции становятся связанными, когда к ним обращаются через объекты с помощью оператора dot. Если задуматься, то вполне можно было бы вызывать функции класса следующим образом:

class Person:

num_of_persons = 0

def __init__(self, name):
self.name = name
def shout(self):
print(f"Hey! I'm {self.name}.")

p = Person('John')

Person.shout(p)
# Hey! I'm John.

Однако это не совсем правильный и не очень красивый способ. Обычно в коде этого делать не приходится.

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

p.name
"""
1. Вызов __getattribute__ осуществляется с аргументами p и "name".

2. objtype - это Person.

3. descr_get равен null, поскольку класс Person не имеет в словаре "name"
(пространстве имен).

4. Поскольку descr_get не существует, пропускаем первый if-блок.

5. "name" существует в словаре объекта, поэтому мы получаем его значение.
"""

p.shout('Hey')
"""
Прежде чем перейти к шагам по разрешению имен, следует помнить,
что Person.shout - это экземпляр класса функции.
По сути, он обернут. И этот объект является вызываемым,
поэтому его можно вызвать командой Person.shout(...).
С точки зрения разработчика, все работает так же, как если бы было определено
в теле класса. Но в фоновом режиме это, конечно, не так.

1. Вызов __getattribute__ осуществляется с аргументами p и "shout".

2. objtype - это Person.

3. Person.shout фактически обернут и является дескриптором,
не содержащим данных. Поэтому в этой обертке реализован метод __get__,
и на него ссылается descr_get.

4. Объект-обертка не является дескриптором данных,
поэтому первый if-блок пропускается.

5. "shout" не существует в словаре объекта, так как он является частью
определения класса. Второй if-блок пропускается.

6. "shout" - это дескриптор, не содержащий данных,
и его метод __get__ возвращается из третьего if-блока кода.

Здесь мы попытались обратиться к p.shout('Hey'), но получили
метод p.shout.__get__. Он возвращает объект MethodType.
Поэтому p.shout(...) работает, но в итоге вызывается экземпляр класса MethodType.
Этот объект является оберткой
вокруг обертки `Function`, и он содержит ссылку на обертку `Function`
и наш объект p. В итоге, когда вы вызываете p.shout('Hey'),
вызывается обертка `Function` с объектом p и
'Hey' в качестве одного из позиционных аргументов.

Person.shout(p)
"""
Прежде чем перейти к шагам по разрешению имен,
следует помнить, что Person.shout - это экземпляр класса функции.
По сути, он обернут. И этот объект является вызываемым,
поэтому его можно вызвать командой Person.shout(...).
С точки зрения разработчика, все работает так же, как если бы было определено
в теле класса. Но в фоновом режиме это, конечно, не так.

Эта часть такая же, как и предыдущая. Разница только в следующих шагах. Взляните.

1. Вызов __getattribute__ осуществляется с Person и "shout".

2. objtype - тип. Этот механизм описан в моей статье о
метаклассах.

3. Person.shout фактически обернут и является дескриптором,
не содержащим данных, поэтому в этой обертке реализован метод __get__,
и на него ссылается descr_get.

4. Объект-обертка не является дескриптором данных,
поэтому первый if-блок пропускается.

5. "shout" существует в словаре объекта, потому что Person - это объект.
Поэтому возвращается функция "shout".

Когда вызывается Person.shout, на самом деле вызывается экземпляр класса
`Function`, который также является вызываемым и оборачивается вокруг
исходной функции, определенной в теле класса.
Таким образом, вызывается исходная функция со всеми позиционными
и именованными аргументами.
"""

Заключение

Если усвоить эту статью с первого раза оказалось нелегко, не расстраивайтесь! Механизм, лежащий в основе оператора dot, не так-то просто понять. На это есть как минимум две причины: одна из них заключается в том, как __getattribute__ выполняет разрешение имен, а другая  —  в том, как функции класса оборачиваются при интерпретации тела класса. Поэтому обязательно прочтите статью несколько раз и поэкспериментируйте с примерами.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Ilija Lazarevic: Advanced Python: Dot Operator

Предыдущая статьяКлятва Гиппократа для дизайнеров в эпоху искусственного интеллекта
Следующая статья10 рекомендаций, которые повысят производительность разработки на Flutter в 2023 году