Статьи

Python Internals: как работают Callables

[Версия Python, описанная в этой статье, — 3.x, точнее — альфа-версия CPython 3.3.]

Концепция вызываемого является фундаментальной в Python. Размышляя о том, что можно «назвать», сразу очевидным ответом являются функции. Будь то пользовательские функции (написанные вами) или встроенные функции (наиболее вероятно реализованные в C внутри интерпретатора CPython), функции должны были вызываться, верно?

Ну, есть и методы, но они не очень интересны, потому что это просто специальные функции, которые связаны с объектами. Что еще можно назвать? Вы можете или не можете быть знакомы со способностью вызывать объекты , если они принадлежат классам, которые определяют магический метод __call__. Таким образом, объекты могут действовать как функции. И если подумать об этом, классы тоже могут быть вызваны. В конце концов, вот как мы создаем новые объекты:

class Joe:
  ... [contents of class]

joe = Joe()

Здесь мы «зовем» Джо, чтобы создать новый экземпляр. Так что классы также могут выступать в качестве функций!

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

Компиляция звонков

CPython выполняет нашу программу в два основных этапа:

  1. Исходный код Python скомпилирован в байт-код.
  2. Виртуальная машина выполняет этот байт-код, используя набор инструментов из встроенных объектов и модулей, чтобы помочь ей выполнить свою работу.

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

Вкратце, компилятор Python идентифицирует все, что следует (аргументы …) внутри выражения, как вызов [1] . Узлом AST для этого является Call. Компилятор испускает код для Call в функции compiler_call в Python / compile.c. В большинстве случаев инструкция байт-кода CALL_FUNCTION будет отправлена. Есть некоторые варианты, которые я собираюсь игнорировать для целей статьи. Например, если у вызова есть «звездные аргументы» — func (a, b, * args), есть специальная инструкция для обработки этого — CALL_FUNCTION_VAR. Это и другие специальные инструкции — просто вариации на одну и ту же тему.

call_function

Таким образом, CALL_FUNCTION — это инструкция, на которой мы собираемся сосредоточиться. Вот что он делает :

Call_function (ARGC)

Вызывает функцию. Младший байт argc указывает количество позиционных параметров, старший байт — число ключевых параметров. В стеке код операции сначала находит параметры ключевого слова. Для каждого ключевого аргумента значение находится над ключом. Ниже параметров ключевого слова позиционные параметры находятся в стеке, причем самый правый параметр находится сверху. Ниже параметров вызываемый объект функции находится в стеке. Выдает все аргументы функции и саму функцию из стека и возвращает возвращаемое значение.

Байт-код CPython оценивается функцией мамонта PyEval_EvalFrameEx в Python / ceval.c. Функция пугающая, но это не более чем модный диспетчер кодов операций. Он читает инструкции из объекта кода данного фрейма и выполняет их. Вот, например, обработчик для CALL_FUNCTION (немного очищен для удаления макросов трассировки и синхронизации):

TARGET(CALL_FUNCTION)
{
    PyObject **sp;
    sp = stack_pointer;
    x = call_function(&sp, oparg);
    stack_pointer = sp;
    PUSH(x);
    if (x != NULL)
        DISPATCH();
    break;
}

Не так уж плохо — это на самом деле очень читабельно. call_function выполняет фактический вызов (мы его немного рассмотрим), oparg — это числовой аргумент инструкции, а stack_pointer указывает на вершину стека [2] . Значение, возвращаемое call_function, помещается обратно в стек, а DISPATCH — это просто некоторая магия макроса для вызова следующей инструкции.

call_function также находится в Python / ceval.c. Это реализует фактическую функциональность инструкции. В 80 строк это не очень долго, но достаточно долго, поэтому я не буду вставлять его целиком. Вместо этого я объясню поток в целом и вставлю небольшие фрагменты, где это уместно; Вы можете следовать за кодом, открытым в вашем любимом редакторе.

Любой вызов — это просто вызов объекта

Самый важный первый шаг в понимании того, как вызовы работают в Python, — это игнорировать большую часть того, что делает call_function. Да, я имею в виду Подавляющее большинство кода в этой функции имеет дело с оптимизацией для различных распространенных случаев. Его можно удалить, не повредив правильности интерпретатора, только его работоспособности. Если мы пока игнорируем все оптимизации, все, что делает call_function, декодирует количество аргументов и количество аргументов ключевого слова из единственного аргумента CALL_FUNCTION и перенаправляет его в do_call. Мы вернемся к оптимизациям позже, поскольку они интересны, но пока давайте посмотрим, каков основной поток.

do_call загружает аргументы из стека в объекты PyObject (кортеж для позиционных аргументов, dict для аргументов ключевого слова), самостоятельно выполняет трассировку и оптимизацию, но в конечном итоге вызывает PyObject_Call.

PyObject_Call — это очень важная функция. Он также доступен для расширений в Python C API. Вот оно во всей красе:

PyObject *
PyObject_Call(PyObject *func, PyObject *arg, PyObject *kw)
{
    ternaryfunc call;

    if ((call = func->ob_type->tp_call) != NULL) {
        PyObject *result;
        if (Py_EnterRecursiveCall(" while calling a Python object"))
            return NULL;
        result = (*call)(func, arg, kw);
        Py_LeaveRecursiveCall();
        if (result == NULL && !PyErr_Occurred())
            PyErr_SetString(
                PyExc_SystemError,
                "NULL result without error in PyObject_Call");
        return result;
    }
    PyErr_Format(PyExc_TypeError, "'%.200s' object is not callable",
                 func->ob_type->tp_name);
    return NULL;
}

Помимо глубокой защиты от рекурсии и обработки ошибок [3] , PyObject_Call извлекает атрибут tp_call [4] типа объекта и вызывает его. Это возможно, поскольку tp_call содержит указатель на функцию.

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

  • Все в Python является объектом [5] .
  • Каждый объект имеет тип; тип объекта определяет, что можно сделать с / с объектом.
  • Когда объект вызывается, вызывается атрибут его типа tp_call.

Как пользователь Python, вы можете напрямую взаимодействовать с tp_call только тогда, когда хотите, чтобы ваши объекты вызывались. Если вы определяете свой класс в Python, вы должны реализовать для этого метод __call__. Этот метод напрямую отображается в tp_call с помощью CPython. Если вы определяете свой класс как расширение C, вы должны назначить tp_call в объекте типа вашего класса вручную.

Но помните, что сами классы «вызываются» для создания новых объектов, поэтому tp_call также играет здесь свою роль. Еще более существенно то, что когда вы определяете класс, в него также входит вызов — в метаклассе класса. Это интересная тема, и я расскажу о ней в следующей статье.

Дополнительный кредит: Оптимизация в CALL_FUNCTION

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

Как я упоминал ранее, мы могли бы просто использовать PyObject_Call для каждого CALL_FUNCTION и покончить с этим. В действительности имеет смысл провести некоторые оптимизации, чтобы охватить общие случаи, когда это может быть излишним. PyObject_Call — очень универсальная функция, которая нуждается во всех своих аргументах в специальных объектах кортежей и словарей (для позиционных и ключевых аргументов соответственно). Эти аргументы должны быть взяты из стека и расположены в контейнерах, ожидаемых PyObject_Call. В некоторых распространенных случаях мы можем избежать многих из этих накладных расходов, и это то, что касается оптимизации в call_function.

Первый адрес особого случая call_function:

/* Always dispatch PyCFunction first, because these are
   presumed to be the most frequent callable object.
*/
if (PyCFunction_Check(func) && nk == 0) {

Это обрабатывает объекты типа builtin_function_or_method (представленные типом PyCFunction в реализации C). В Python их много, как отмечается в комментарии выше. Все функции и методы, реализованные в C, будь то интерпретатор CPython или расширения C, попадают в эту категорию. Например:

>>> type(chr)
<class 'builtin_function_or_method'>
>>> type("".split)
<class 'builtin_function_or_method'>
>>> from pickle import dump
>>> type(dump)
<class 'builtin_function_or_method'>

Есть дополнительное условие: если количество аргументов ключевого слова, переданных функции, равно нулю. Это позволяет некоторые важные оптимизации. Если рассматриваемая функция не принимает никаких аргументов (помеченных флагом METH_NOARGS при создании функции) или только один аргумент объекта (флаг METH_0), call_function не проходит обычную упаковку аргументов и может напрямую вызывать указатель на базовую функцию. Чтобы понять, как это возможно, настоятельно рекомендуется прочитать о PyCFunction и флагах METH_ в этой части документации .

Далее, есть некоторая специальная обработка для методов классов, написанных на Python:

else {
  if (PyMethod_Check(func) && PyMethod_GET_SELF(func) != NULL) {

PyMethod — это внутренний объект, используемый для представления связанных методов . Особенность методов заключается в том, что они содержат ссылку на объект, к которому они привязаны. call_function извлекает этот объект и помещает его в стек, готовясь к тому, что будет дальше.

Вот остальная часть кода вызова (после него в call_object есть только некоторая очистка стека):

if (PyFunction_Check(func))
    x = fast_function(func, pp_stack, n, na, nk);
else
    x = do_call(func, pp_stack, na, nk);

do_call мы уже встречали — он реализует наиболее общую форму вызова. Однако есть еще одна оптимизация — если func — это PyFunction (объект, используемый внутри для представления функций, определенных в коде Python), используется отдельный путь — fast_function.

Чтобы понять, что делает fast_function, важно сначала рассмотреть, что происходит, когда выполняется функция Python. Проще говоря, его объект кода оценивается (с самим PyEval_EvalCodeEx). Этот код ожидает, что его аргументы находятся в стеке. Следовательно, в большинстве случаев нет смысла упаковывать аргументы в контейнеры и распаковывать их снова. С некоторой осторожностью их можно просто оставить в стеке и сэкономить много драгоценных циклов ЦП.

Все остальное возвращается к do_call. Это, кстати, включает в себя объекты PyCFunction , что делать есть аргументы ключевых слов. Любопытный аспект этого факта заключается в том, что несколько эффективнее не передавать аргументы ключевых слов в функции C, которые либо принимают их, либо подходят только для позиционных аргументов. Например, [6] :

$ ~/test/python_src/33/python -m timeit -s's="a;b;c;d;e"' 's.split(";")'
1000000 loops, best of 3: 0.3 usec per loop
$ ~/test/python_src/33/python -m timeit -s's="a;b;c;d;e"' 's.split(sep=";")'
1000000 loops, best of 3: 0.469 usec per loop

Это большая разница, но вход очень маленький. Для больших строк разница практически не заметна:

$ ~/test/python_src/33/python -m timeit -s's="a;b;c;d;e"*1000' 's.split(";")'
10000 loops, best of 3: 98.4 usec per loop
$ ~/test/python_src/33/python -m timeit -s's="a;b;c;d;e"*1000' 's.split(sep=";")'
10000 loops, best of 3: 98.7 usec per loop

Резюме

Цель этой статьи состояла в том, чтобы обсудить, что значит быть вызываемым в Python, подходя к этой концепции с самого низкого возможного уровня — подробностей реализации виртуальной машины CPython. Лично я считаю эту реализацию очень элегантной, поскольку она объединяет несколько концепций в одну. Как показал дополнительный раздел о кредитах, сущности Python, которые мы обычно не рассматриваем как объекты — функции и методы — на самом деле являются объектами и также могут обрабатываться одинаково. Как я и обещал, будущие статьи будут глубже погружаться в значение tp_call для создания новых объектов и классов Python.

 

http://eli.thegreenplace.net/wp-content/uploads/hline.jpg

 

[1] Это намеренное упрощение — () выполняют другие роли, такие как определения классов (для перечисления базовых классов), определения функций (для перечисления аргументов), декораторы и т. Д. — их нет в выражениях. Я также специально игнорирую выражения генератора.
[2] Виртуальная машина CPython — это стековая машина .
[3] Py_EnterRecursiveCall необходим, когда код C может в конечном итоге вызвать код Python, чтобы позволить CPython отслеживать свой уровень рекурсии и выручать, когда он слишком глубокий. Обратите внимание, что функции, написанные на C, не должны соблюдать этот предел рекурсии. Вот почему до вызова PyObject_Call в особых случаях делайте PyCFunction.
[4] Под «атрибутом» здесь я подразумеваю поле структуры (иногда также называемое «слот» в документации). Если вы совершенно не знакомы с тем, как определяются расширения Python C, перейдите по этой странице .
[5] Когда я говорю, что все является объектом — я имею в виду это. Вы можете думать об объектах как об экземплярах классов, которые вы определили. Однако глубоко на уровне C CPython создает и манипулирует множеством объектов от вашего имени. Типы (классы), встроенные функции, функции, модули — все это представлено объектами.
[6] Этот пример будет работать только на Python 3.3, так как аргумент sep для разделения на разделы является новым в этой версии. В предыдущих версиях Python split принимал только позиционные аргументы.