Продвинутый Python Оператор точки

Расширение функциональности Python оператор точки

Оператор, который позволяет объектно-ориентированный подход в Python

Оператор точки является одним из фундаментов объектно-ориентированного подхода в Python. Фото от Madeline Pere на Unsplash

На этот раз я напишу о чем-то кажущемся тривиальным. Это «оператор точки». Большинство из вас уже много раз использовали этот оператор, не зная или не задаваясь вопросом, что происходит за кулисами. И по сравнению с концепцией метаклассов, о которых я говорил в прошлый раз, этот оператор более применим для повседневных задач. Шучу, вы практически каждый раз, когда используете Python для чего-то более серьезного, уже используете его. Вот почему я подумал, что вам может быть интересно копнуть глубже, и я хочу быть вашим гидом. Приступим к путешествию!

Я начну с одного тривиального вопроса: «Что такое оператор точки?»

Вот пример:

hello = 'Hello world!'print(hello.upper())# HELLO WORLD!

Это, конечно же, пример «Hello World», но я с трудом могу себе представить, чтобы кто-то начал учить вас Python именно так. В любом случае, «оператор точки» – это часть «.» вhello.upper(). Давайте попробуем посмотреть на более подробный пример:

class Person:    num_of_persons = 0    def __init__(self, name):        self.name = name    def shout(self):        print(f"Привет! Я {self.name}")p = Person('John')p.shout()# Привет, я Джон.p.num_of_persons# 0p.name# 'John'

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

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

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

p.shout# <bound method Person.shout of <__main__.Person object at 0x1037d3a60>>id(p.shout)# 4363645248Person.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"Привет! Я {self.name}.")p = Person('John')vars(p)# {'name': 'John'}def shout_v2(self):    print("Привет, что нового?")p.shout_v2 = shout_v2vars(p)# {'name': 'John', 'shout_v2': <function __main__.shout_v2(self)>}p.shout()# Привет, я Джон.p.shout_v2()# TypeError: shout_v2() missing 1 required positional argument: 'self'

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

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

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

Так почему они различаются, и почему мы получаем ошибку? Ну, самый быстрый ответ заключается в том, как работает “оператор точки”. Длинный ответ заключается в том, что для вас есть механизм, выступающий в качестве (атрибутного) имени резолюции, происходящий за кадром. Этот механизм состоит из dua-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"Привет! Я {self.name}.")            def __getattribute__(self, name):        print(f'получение имени атрибута: {name}')        return super().__getattribute__(name)        def __getattr__(self, name):        print(f'этого атрибута не существует: {name}')        raise AttributeError()p = Person('Джон')p.name# получение имени атрибута: name# 'Джон'p.name1# получение имени атрибута: name1# этого атрибута не существует: name1## ... трассировка стека исключений# AttributeError:

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

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

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

p.shout# <bound method Person.shout of <__main__.Person object at 0x1037d3a60>>Person.shout# <function __main__.Person.shout(self)>

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

Первая часть происходит при интерпретации тела класса. В этом процессе происходит довольно много вещей, таких как определение пространства имен класса, добавление значений атрибутов к нему, определение функций (классовых) и их привязка к именам. Теперь, когда эти функции определяются, они оборачиваются. Обернуты в объект, концептуально называемый дескриптором. Этот дескриптор позволяет изменять идентификацию и поведение классовых функций, которое мы видели ранее. Я обязательно напишу отдельный пост в блоге о дескрипторах, но пока знайте, что этот объект является экземпляром класса, который реализует предопределенный набор методов двоеточия. Это также называется протоколом. Как только они реализованы, говорят, что объекты этого класса следуют определенному протоколу и поэтому ведут себя ожидаемым образом. Есть разница между данными и неданными дескрипторами. Первый реализует методы двоеточия __get__, __set__ и/или __delete__. Второй реализует только метод __get__. В любом случае, каждая функция в классе оборачивается в так называемый неданный дескриптор.

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

  1. вернуть данные дескриптора с нужным именем (на уровне класса), или
  2. вернуть атрибут экземпляра с нужным именем (на уровне экземпляра), или
  3. вернуть неданный дескриптор с нужным именем (на уровне класса), или
  4. вернуть атрибут класса с нужным именем (на уровне класса), или
  5. вызвать исключение AttributeError, которое фактически вызывает метод __getattr__.

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

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

def object_getattribute(obj, name):    "Эмуляция PyObject_GenericGetAttr() в объектах Objects/object.c"    # Создаем обычный объект для последующего использования.    null = object()    """    obj - это объект, созданный из нашего пользовательского класса. Здесь мы пытаемся     найти имя класса, от которого он был создан.    """    objtype = type(obj)     """    name представляет собой имя классовой функции, атрибута экземпляра     или любого атрибута класса. Здесь мы пытаемся найти его и сохранить     ссылку на него. MRO - это сокращение от порядка разрешения методов, и он     связан с наследованием классов. На самом деле это пока что не очень     важно. Допустим, что этот механизм оптимально находит имя через все родительские классы.    """    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)  # data descriptor    """    В случаях, когда имя не ссылается на данный дескриптор, мы     проверяем, ссылается ли оно на переменную в словаре объекта, и если     да, мы возвращаем ее значение.    """    if hasattr(obj, '__dict__') and name in vars(obj):        return vars(obj)[name]  # instance variable    """    В случаях, когда имя не ссылается на переменную в словаре     объекта, мы пытаемся узнать, ссылается ли оно на неданный     дескриптор, и возвращаем ссылку на него.        """    if descr_get is not null:        return descr_get(cls_var, obj, objtype)  # non-data descriptor    """    Если имя не ссылается ни на что из вышеперечисленного, пытаемся     узнать, ссылается ли оно на атрибут класса и возвращаем его значение.    """    if cls_var is not null:        return cls_var                                  # class variable    """    Если разрешение имени было неудачным, мы генерируем исключение AttriuteError,     и вызывается метод __getattr__.    """    raise AttributeError(name)

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

Здесь важно то, что каждая функция класса (которая является объектом) оборачивается в немодифицируемый дескриптор (который является объектом класса function), и это означает, что этот объект-обертка имеет метод двойного подчеркивания __get__. Что делает этот метод? Он возвращает новый вызываемый объект (представьте его как новую функцию), где первым аргументом является ссылка на объект, на котором выполняется операция “точечного оператора”. Я сказал представить его как новую функцию, потому что это вызываемый объект типа MethodType. Взгляните на это:

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

Одна интересная вещь – это класс function. Это и есть объект-обертка, определяющий метод двойного подчеркивания __get__. Однако, при попытке получить доступ к нему в виде метода shout с помощью “точечного оператора”, __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 в нашем примере.

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

class Person:    num_of_persons = 0    def __init__(self, name):        self.name = name    def shout(self):        print(f"Hey! Я {self.name}.")        p = Person('Джон')Person.shout(p) # Hey! Я Джон.

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

Так что, прежде чем закончить, я хочу рассмотреть несколько примеров разрешения атрибутов, чтобы сделать это более понятным. Давайте возьмем предыдущий пример и разберемся, как работает оператор «точка».

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 и нашего объекта p. В конце, когда вы вызываете p.shout('Hey'), фактически вызывается обертка Function с объектом p и 'Hey' в качестве одного из позиционных аргументов."""Person.shout(p)"""Прежде чем перейти к шагам разрешения имени, имейте в виду, что Person.shout - это экземпляр класса функции. Фактически, он обертывается в него. И этот объект можно вызывать, поэтому вы можете вызвать его с помощью Person.shout(...). С точки зрения разработчика все работает точно так же, как если бы оно было определено в теле класса. Но на самом деле это не так. Здесь все то же самое. Следующие шаги отличаются. Проверьте.1. __getattribute__ вызывается с Person и "shout". 2. objtype является type. Этот механизм описан в моей статье о метаклассах. 3. Person.shout на самом деле обернут и является недескриптором данных, поэтому этот обертка имеет реализованный метод __get__, на который ссылается descr_get. 4. Объект-обертка является недескриптором данных, поэтому первый блок условия if пропускается. 5. "shout" существует в словаре объекта, потому что Person - это объект в любом случае. Поэтому возвращается функция "shout".Когда вызывается Person.shout, фактически вызывается экземпляр класса Function, который также является вызываемым и оберткой вокруг исходной функции, определенной в теле класса. Таким образом, вызывается исходная функция со всеми позиционными и ключевыми аргументами."""

Заключение

Если прочтение этой статьи за один раз было не очень простым, не волнуйтесь! Вся механика оператора «точка» не так проста, как кажется. Есть по крайней мере две причины, одна из которых заключается в том, как __getattribute__ выполняет разрешение имени, а другая – в том, как функции класса оборачиваются при интерпретации тела класса. Поэтому убедитесь, что вы несколько раз прочитаете статью и поиграете с примерами. Экспериментирование – вот что побудило меня начать серию под названием «Продвинутый Python».

Еще одна вещь! Если вам нравится то, как я объясняю вещи, и вы хотите прочитать о чем-то продвинутом в мире Python, крикните!

Предыдущие статьи в серии Advanced Python:

Advanced Python: Функции

После прочтения заголовка, вы, вероятно, задаете себе что-то вроде “Функции в Python – продвинутая тема…

towardsdatascience.com

Advanced Python: Метаклассы

Краткое введение в объект класса Python и способы его создания

towardsdatascience.com

Ссылки