Разбираемся с доступом к атрибутам в Python

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Перевод статьи опубликован специально для будущих студентов курса "Python Developer. Professional".


Интересно, сколько людей понимают, что в Python много синтаксического сахара? Я не говорю, что он похож на Lisp-подобные языки, где синтаксис настолько голый, насколько это возможно (хотя и сравнение с Lisp не совсем обосновано), но большая часть синтаксиса Python технически не нужна, поскольку под капотом в основном вызовы функций.

Ну и что с того? Зачем думать о том, как Python за меньший синтаксис делает больше вызовов функций? На самом деле для этого есть две причины. Во-первых, полезно знать, как на самом деле работает Python, чтобы лучше понимать/отлаживать код, когда что-то идет не так как надо. Во-вторых, так можно выявить минимум, необходимый для реализации языка.

Именно поэтому, чтобы заняться самообразованием и заодно подумать, что может понадобиться для реализации Python под WebAssembly или API bare bones на C, я решил написать эту статью о том, как выглядит доступ к атрибутам и что скрывается за синтаксисом.

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

В начале статьи вам встретится немного кода на С, но я не жду, что вы досконально поймете, что там происходит. Я напишу о том, что нужно будет из него понять, поэтому если у вас нет ни малейших знаний в С, то ничего страшного, вы все равно поймете все то, о чем я говорю. 

Смотрим в байткод

Итак, давайте разберемся со следующим выражением:

obj.attr

Наверное, самое простая отправная точка в изучении – это байткод. Посмотрим на эту строку и разберемся, что делает компилятор:

>>> def example(): 
...     obj.attr
... 
>>> import dis
>>> dis.dis(example)
  2           0 LOAD_GLOBAL              0 (obj)
              2 LOAD_ATTR                1 (attr)
              4 POP_TOP
              6 LOAD_CONST               0 (None)
              8 RETURN_VALUE

Самый важный код операции здесь — LOADATTR. Если интересно, он заменяет объект на вершине стека результатом доступа к именованному атрибуту, как указано в conames[i].

Цикл интерпретатора CPython лежит в Python/ceval.c. В его основе лежит массивный оператор switch, который ветвится в зависимости от выполняемого кода операции. Если заглянуть поглубже, то вы найдете следующие строки на С для LOADATTR:

case TARGET(LOAD_ATTR): {
            PyObject *name = GETITEM(names, oparg);
            PyObject *owner = TOP();
            PyObject *res = PyObject_GetAttr(owner, name);
            Py_DECREF(owner);
            SET_TOP(res);
            if (res == NULL)
                goto error;
            DISPATCH();
        }

Источник

Большая часть этого кода – это просто работа со стеком, его мы можем опустить. Ключевой бит – это вызов PyObject_GetAttr(), который и обеспечивает доступ к атрибутам.

Имя этой функции выглядит знакомо

Теперь это имя выглядит прямо как getattr(), только в соглашении об именовании функций в С, которое используется в CPython. Покопавшись в Python/bltinmodule.c, где лежат все встроенные модули Python, можем проверить, верна ли наша догадка. Поискав по «getattr» в файле, вы найдете строку, которая связывает имя «getattr» с функцией «builtin_getattr()»

static PyObject *
builtin_getattr(PyObject *self, PyObject *const *args, Py_ssize_t nargs)
{
    PyObject *v, *name, *result;


    if (!_PyArg_CheckPositional("getattr", nargs, 2, 3))
        return NULL;


    v = args[0];
    name = args[1];
    if (!PyUnicode_Check(name)) {
        PyErr_SetString(PyExc_TypeError,
                        "getattr(): attribute name must be string");
        return NULL;
    }
    if (nargs > 2) {
        if (_PyObject_LookupAttr(v, name, &result) == 0) {
            PyObject *dflt = args[2];
            Py_INCREF(dflt);
            return dflt;
        }
    }
    else {
        result = PyObject_GetAttr(v, name);
    }
    return result;
}

Источник

Есть куча вещей, которые относятся к параметрам, но не интересуют нас, однако вы точно заметите, что когда вы передаете два аргумента в getattr(), будет вызван PyObjectGetAttr()

Что это означает? Ну, это означает, что вы можете «распаковать» obj.attr как getattr(obj, "attr"). А еще это означает, что если мы поймем как работает PyObjectGetAttr(), то поймем, как работает эта функция и, следовательно, как организован доступ к атрибутам в Python.

Разбираемся с getattr()

На этом я прекращу вставлять код на С, поскольку его сложность только растет, и он уже не настолько хорошо демонстрирует, что obj.attr это вариант написания getattr(obj, "attr"). Однако в комментариях псевдокода я продолжу на него ссылаться для тех, кто решил глубоко окунуться в CPython. Также обратите внимание, что код на Python следует рассматривать как псевдокод, поскольку в коде, реализующем доступ к атрибутам, есть сам по себе доступ к ним, но на уровне С он не проходит через обычный механизм доступа к атрибутам. Так что пока вы встречаете символ «.», который используется в псевдокоде как синтаксис, знайте, что на уровне С доступ к атрибутам не рекурсивный и фактически функционирует так, как вы наивно можете предположить самостоятельно.

Что мы уже знаем

На данный момент о getattr() мы знаем три вещи. Во-первых, эта функция требует, как минимум, два атрибута. Во-вторых, второй аргумент должен быть подклассом str, в противном случае выпадет TypeError со статическим строковым аргументом (который, вероятно, статический из соображений производительности). 

def getattr(obj: Any, attr: str, default: Any) -> Any:
    if not isinstance(attr, str):
        raise TypeError("getattr(): attribute name must be string")

    ...  # Fill in with PyObject_GetAttr().

Запись функции для getattr()

Поиск атрибутов с помощью специальных методов

Доступ к атрибутам объекта осуществляется с помощью двух специальных методов. Первый метод – это getattribute(), который вызывается при попытке получить доступ ко всем атрибутам. Второй – это getattr(), который вызывает AttributeError. Первый метод (на сегодняшний день) всегда должен быть определен, тогда как второй метод является необязательным.

Python ищет специальные методы для типа объекта, а не для самого объекта. Чтобы внести ясность, скажу, что я очень специфически использую здесь слово «тип»: тип экземпляра – это его класс, тип класса – это его тип. К счастью, очень легко получить тип чего-либо благодаря конструктору type, возвращающему тип объекта: type(obj)

Также нам нужно знать порядок разрешения метода (method resolution order, MRO). Так мы определим порядок иерархии типов для объекта. Алгоритм, который используется в Python пришел из языка Dylan и называется C3. Из кода на Python MRO раскрывается с помощью type(obj).mro().

Обработка типа объекта осуществляется специально, поскольку это позволяет ускорить поиск и доступ. В целом, это исключает дополнительный поиск, пропуская экземпляр каждый раз, когда мы что-то ищем. На уровне CPython это позволяет заводить специальные методы, которые находятся в поле struct для ускорения поиска. Поэтому несмотря на то, что кажется немного странным игнорировать объект, а вместо него использовать тип, это имеет определенный смысл. 

Теперь во имя простоты я немного схитрю и заставлю getattr() обрабатывать методы getattribute() и getattr() явно, в то время как CPython производит некоторые манипуляции под капотом, чтобы заставить объект обрабатывать оба метода самостоятельно. В конечном счете, семантика наших целей получается одинаковой.

# Based on https://github.com/python/cpython/tree/v3.8.3.
from __future__ import annotations
import builtins

NOTHING = builtins.object()  # C: NULL


def getattr(obj: Any, attr: str, default: Any = NOTHING) -> Any:
    """Implement attribute access via  __getattribute__ and __getattr__."""
    # Python/bltinmodule.c:builtin_getattr
    if not isinstance(attr, str):
        raise TypeError("getattr(): attribute name must be string")

    obj_type_mro = type(obj).mro()
    attr_exc = NOTHING
    for base in obj_type_mro:
        if "__getattribute__" in base.__dict__:
            try:
                return base.__dict__["__getattribute__"](obj, attr)
            except AttributeError as exc:
                attr_exc = exc
                break
    # Objects/typeobject.c:slot_tp_getattr_hook
    # It is cheating to do this here as CPython actually rebinds the tp_getattro
    # slot with a wrapper that handles __getattr__() when present.
    for base in obj_type_mro:
        if "__getattr__" in base.__dict__:
            return base.__dict__["__getattr__"](obj, attr)

    if default is not NOTHING:
        return default
    elif attr_exc is not NOTHING:
        raise attr_exc
    else:
        raise AttributeError(f"{self.__name__!r} object has no attribute {attr!r}")

Псевдокод, реализующий getattr()

Разбираемся с object.getattribute()

Несмотря на то, что мы можем получить реализацию getattr(), она, к сожалению, не расскажет нам много о работе Python и поиске атрибутов, поскольку очень большая часть обрабатывается в методе getattribute() объекта. Поэтому я расскажу как работает object.getattribute().

В поисках дескриптора данных

Первая важная вещь, которую мы собираемся сделать в object.getattribute() – это поиск дескриптора данных для типа. Если вы никогда не слышали о дескрипторах, то расскажу – это способ программно управлять тем, как работает отдельный атрибут. Возможно, вы вообще никогда о них не слышали, но, если вы некоторое время уже используете Python, я подозреваю, что вы уже использовали дескрипторы: свойства, classmethod и staticmethod – все это дескрипторы.

Есть два типа дескрипторов: дескрипторы данных и дескрипторы без данных (non-data). Оба типа дескрипторов определяют метод get для получения того, каким должен быть атрибут. Дескрипторы данных также определяют методы set и del, в то время как дескрипторы без данных этого не делают. Свойство – это дескриптор данных, classmethod и staticmethod — это дескрипторы без данных. 

Если мы не можем найти дескриптор данных для атрибута в типе, следующее место, где мы будем искать – это сам объект. Все оказывается просто благодаря объектам, имеющим атрибут dict, который хранит атрибуты самого объекта в словаре.

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

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

  • Дескриптор данных ищется по типам;

  • Поиск по объекту;

  • Дескриптор без данных ищется по типам;

  • Что угодно ищется по типам.

Вы заметите, что сначала мы ищем какой-то дескриптор, затем, если нам это не удалось, мы ищем обычный объект, который соответствует виду дескриптора, который мы искали. Сначала мы ищем данные, потом уже что-то другое. Все это имеет смысл, если думать о том как метод self.attr = val в init() хранит данные об объекте. Скорее всего, если вы столкнулись с этим, то хотите, чтобы это стояло перед методом или чем-то подобным. И вам в первую очередь нужны дескрипторы, поскольку, если вы программно определили атрибут, то вероятно, хотели бы, чтобы он использовался всегда.

def _mro_getattr(type_: Type, attr: str) -> Any:
    """Get an attribute from a type based on its MRO."""
    for base in type_.mro():
        if attr in base.__dict__:
            return base.__dict__[attr]
    else:
        raise AttributeError(f"{type_.__name__!r} object has no attribute {attr!r}")


class object:
    def __getattribute__(self, attr: str, /) -> Any:
        """Attribute access."""
        # Objects/object.c:PyObject_GenericGetAttr
        self_type = type(self)
        if not isinstance(attr, str):
            raise TypeError(
                f"attribute name must be string, not {type(attr).__name__!r}"
            )

        type_attr = descriptor_type_get = NOTHING
        try:
            type_attr = _mro_getattr(self_type, attr)
        except AttributeError:
            pass  # Hopefully an instance attribute.
        else:
            type_attr_type = type(type_attr)
            try:
                descriptor_type_get = _mro_getattr(type_attr_type, "__get__")
            except AttributeError:
                pass  # At least a class attribute.
            else:
                # At least a non-data descriptor.
                for base in type_attr_type.mro():
                    if "__set__" in base.__dict__ or "__delete__" in base.__dict__:
                        # Data descriptor.
                        return descriptor_type_get(type_attr, self, self_type)

        if attr in self.__dict__:
            # Instance attribute.
            return self.__dict__[attr]
        elif descriptor_type_get is not NOTHING:
            # Non-data descriptor.
            return descriptor_type_get(type_attr, self, self_type)
        elif type_attr is not NOTHING:
            # Class attribute.
            return type_attr
        else:
            raise AttributeError(f"{self.__name__!r} object has no attribute {attr!r}")

Реализация object.getattribute()

Заключение

Как видите, во время поиска атрибутов в Python происходит много интересного. Несмотря на то, что я бы сказал, что ни одна из частей не является концептуально сложной, в сумме мы получаем множество операций. Именно поэтому некоторые программисты пытаются минимизировать доступ к атрибутам в Python, чтобы избегать всего этого механизма, если речь идет о важности производительности.

Так исторически сложилось, что почти вся эта семантика пришла в Python как часть классов нового стиля, а не «классических». Это различие исчезло в Python 3, когда классические классы остались в прошлом, так что если вы ничего о них не слышали, то это и хорошо, наверное.

Другие статьи из этой серии можно найти по тегу «syntactic sugar» в этом блоге. Код из этой статьи вы найдете здесь.


Узнать подробнее о курсе "Python Developer. Professional".

Источник: https://habr.com/ru/company/otus/blog/528304/


Интересные статьи

Интересные статьи

Я сильно верю в обучение через практику, через создание чего-то нового. А для того чтобы что-то создавать, нужно чтобы работа приносила бы удовольствие. Я начну рассказ о моём ново...
Новая подборка советов про Python и программирование из моего авторского канала @pythonetc. ← Предыдущие публикации Очевидно, что разные asyncio-задачи используют разные стеки. Можно ...
Представляем вашему вниманию вторую часть перевода материала, посвящённого особенностям работы с модулями в Python-проектах Instagram. В первой части перевода был дан обзор ситуации и показаны дв...
Принято считать, что персонализация в интернете это магия, которая создается сотнями серверов на основе БигДата и сложного семантического анализа контента.
Некоторое время назад мне довелось пройти больше десятка собеседований на позицию php-программиста (битрикс). К удивлению, требования в различных организациях отличаются совсем незначительно и...