python

Python, каким бы я хотел его видеть

  • пятница, 29 августа 2014 г. в 03:10:37
http://habrahabr.ru/company/mailru/blog/234747/

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

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

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

Язык и реализация

Этот раздел был добавлен мной после написания всей статьи. На мой взгляд, некоторые разработчики упускают из виду факт взаимосвязи Python как языка и CPython как интерпретатора, и считают, что они независимы друг от друга. Да, существует спецификация языка, однако во многих случаях она либо описывает работу интерпретатора, либо попросту умалчивает некоторые моменты.

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

Слоты (Slots)

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

Итак, что такое слот? Это побочный эффект внутренней реализации интерпретатора. Каждый программист, пишущий на Python, знает о «магических методах», таких как __add__: эти методы начинаются и заканчиваются двумя символами подчёркивания, между которыми заключено их название. Каждый разработчик знает, что если написать в коде a + b, то интерпретатором будет вызвана функция a.__add__(b).

К сожалению, это неправда.

На самом деле Python так не работает. Python внутри устроен совсем не так (по крайней мере в текущей версии). Вот как работает интерпретатор:
  1. Когда создаётся объект, интерпретатор находит все дескрипторы класса и ищет магические методы, такие как __add__.
  2. Для каждого найденного специального метода интерпретатор помещает ссылку на дескриптор в специально отведённый слот объекта, например, магический метод __add__ связан с двумя внутренними слотами: tp_as_number->nb_add и tp_as_sequence->sq_concat.
  3. Когда интерпретатор хочет выполнить a + b, он вызовет что-то вроде TYPE_OF(a)->tp_as_number->nb_add(a, b) (на самом деле там всё сложнее, потому что у метода __add__ есть несколько слотов).

Операция a + b должна представлять собой нечто вроде type(a).__add__(a, b), однако, как мы увидели из работы со слотами, это не совсем верно. Вы можете легко убедиться в этом сами, переопределив метод метакласса __getattribute__, и попытавшись реализовать собственный метод __add__, — вы заметите, что он никогда не будет вызван.

На мой взгляд, система слотов просто абсурдна. Она представляет собой оптимизацию для работы с некоторыми типами данных (например, integer), однако не несёт абсолютно никакого смысла для других объектов.

Чтобы продемонстрировать это, я написал такой бессмысленный класс (x.py):

class A(object):
    def __add__(self, other):
        return 42

Поскольку мы переопределили метод __add__, интерпретатор поместит в слот его. Но давайте проверим, насколько он быстрый? Когда мы выполним операцию a + b, мы задействуем систему слотов, и вот результаты профилирования:

$ python3 -mtimeit -s 'from x import A; a = A(); b = A()' 'a + b'
1000000 loops, best of 3: 0.256 usec per loop

Если же мы выполним операцию a.__add__(b), то система слотов использована не будет, и вместо этого интерпретатор обратится к словарю экземпляра класса (где он ничего не найдёт) и, далее, к словарю самого класса, где искомый метод будет обнаружен. Вот как выглядят замеры:

$ python3 -mtimeit -s 'from x import A; a = A(); b = A()' 'a.__add__(b)'
10000000 loops, best of 3: 0.158 usec per loop

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

Больше возможностей, спросите вы? Да, потому что классы старого типа могли делать так (Python 2.7):

>>> original = 42
>>> class FooProxy:
...  def __getattr__(self, x):
...   return getattr(original, x)
...
>>> proxy = FooProxy()
>>> proxy
42
>>> 1 + proxy
43
>>> proxy + 1
43

Сегодня, несмотря на более сложную, чем в Python 2, систему типов, мы имеем меньше возможностей. Код, приведённый выше не может быть выполнен с использованием классов нового типа. На самом деле всё ещё хуже, если принять во внимание то, насколько легковесными были классы старого типа:

>>> import sys
>>> class OldStyleClass:
...  pass
...
>>> class NewStyleClass(object):
...  pass
...
>>> sys.getsizeof(OldStyleClass)
104
>>> sys.getsizeof(NewStyleClass)
904

Откуда взялась система слотов?

Всё вышесказанное поднимает вопрос о том, откуда вообще взялись слоты. Насколько я могу судить, так повелось издавна. Когда изначально создавался интерпретатор Python, встроенные типы (например, строки) были реализованы как глобальные статические структуры, что повлекло за собой необходимость содержать им в себе все эти специальные методы, которые объект должен иметь. Это было до появления метода __add__ как такового. Если мы обратимся к самой ранней версии Python 1990 года, то увидим, как были реализованы объекты в то время.

Вот, например, как выглядел integer:

static number_methods int_as_number = {
    intadd, /*tp_add*/
    intsub, /*tp_subtract*/
    intmul, /*tp_multiply*/
    intdiv, /*tp_divide*/
    intrem, /*tp_remainder*/
    intpow, /*tp_power*/
    intneg, /*tp_negate*/
    intpos, /*tp_plus*/
};

typeobject Inttype = {
    OB_HEAD_INIT(&Typetype)
    0,
    "int",
    sizeof(intobject),
    0,
    free,       /*tp_dealloc*/
    intprint,   /*tp_print*/
    0,          /*tp_getattr*/
    0,          /*tp_setattr*/
    intcompare, /*tp_compare*/
    intrepr,    /*tp_repr*/
    &int_as_number, /*tp_as_number*/
    0,          /*tp_as_sequence*/
    0,          /*tp_as_mapping*/
};

Как мы видим, даже в самой первой версии Python уже существовал метод tp_as_number. К сожалению, некоторые старые версии Python (в частности, интерпретатор) были утеряны вследствие повреждения репозитория, поэтому обратимся к чуть более поздним версиям, чтобы увидеть, как были реализованы объекты. Так выглядел код функции add в 1993 году:

static object *
add(v, w)
    object *v, *w;
{
    if (v->ob_type->tp_as_sequence != NULL)
        return (*v->ob_type->tp_as_sequence->sq_concat)(v, w);
    else if (v->ob_type->tp_as_number != NULL) {
        object *x;
        if (coerce(&v, &w) != 0)
            return NULL;
        x = (*v->ob_type->tp_as_number->nb_add)(v, w);
        DECREF(v);
        DECREF(w);
        return x;
    }
    err_setstr(TypeError, "bad operand type(s) for +");
    return NULL;
}

Так когда же появились методы __add__ и другие? Я думаю, что они появились в версии 1.1. Мне удалось скомпилировать Python 1.1 на OS X 10.9:
$ ./python -v
Python 1.1 (Aug 16 2014)
Copyright 1991-1994 Stichting Mathematisch Centrum, Amsterdam

Конечно, эта версия не стабильна, и не всё работает как нужно, однако получить представление о Python тех дней можно. Например, существовала огромная разница между реализацией объектов на C и на Python:

$ ./python test.py
Traceback (innermost last):
  File "test.py", line 1, in ?
    print dir(1 + 1)
TypeError: dir() argument must have __dict__ attribute


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

>>> (1).__add__(2)
Traceback (innermost last):
  File "<stdin>", line 1, in ?
TypeError: attribute-less object

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

Современный PyObject

Сегодня многие поспорят с утверждением, что между встроенными типами данных Python, реализованными на C, и объектами, реализованными на чистом Python, разница незначительна. В Python 2.7 эта разница особенно явно проявляется в том, что метод __repr__ предоставляется соответствующим классом class для типов, реализованных в Python, и, соответственно, type для встроенных объектов, реализованных на C. Это различие, на самом деле, указывает на размещение объекта: статически (для type) или динамически в «куче» (для class). На практике эта разница не имела никакого значения, и в Python 3 она полностью исчезла. Специальные методы размещаются в слотах и наоборот. Казалось бы, разницы между классами Python и C больше нет.

Однако разница всё ещё есть, и очень заметная. Давайте разберёмся.

Как известно, классы в Python «открыты». Это означает, что вы можете «заглянуть» в них, увидеть содержимое, которое в них хранится, добавить или удалить методы даже после того, как объявление класса будет выполнено. Но такая гибкость не предусмотрена для встроенных классов интерпретатора. Почему так?

Нет никаких технических ограничений, чтобы добавить новый метод к, скажем, объекту dict. Причина, по которой интерпретатор не позволяет вам это сделать, имеет мало общего со здравомыслием разработчика, всё дело в том, что встроенные типы данных не располагаются в «куче». Чтобы оценить глобальные последствия этого, нужно сначала понять, как Python запускает интерпретатор.

Чёртов интерпретатор

Запуск интерпретатора в Python является очень дорогим процессом. Когда вы запускаете исполняемый файл, вы приводите в действие сложный механизм, который умеет делать чуть более, чем всё. В числе прочих вещей инициализируются встроенные типы данных, механизмы импорта модулей, импортируются некоторые обязательные модули, производится работа с операционной системой для настроийки работы с сигналами и параметрами командной строки, настраивается внутреннее состояние интерпретатора и т.д. И только после окончания всех этих процессов интерпретатор запускает ваш код и завершает свою работу. Так Python работает на протяжении вот уже 25 лет.

Вот как это выглядит в псевдокоде:

/* вызывается единожды */
bootstrap()

/* эти три строки могут быть вызваны в цикле, если хотите */
initialize()
rv = run_code()
finalize()

/* вызывается единожды */
shutdown()

Проблема в том, что у интерпретатора есть огромное количество глобальных объектов, и по факту у нас есть один интерпретатор. Гораздо лучше, с точки зрения архитектуры, было ты инициализировать интерпретатор и запускать его как-то так:

interpreter *iptr = make_interpreter();
interpreter_run_code(iptr):
finalize_interpreter(iptr);

Так работают другие динамические языки программирования, например, Lua, JavaScript и т.д. Ключевая особенность в том, что у вас может быть два интерпретатора, и в этом заключается новая концепция.

Кому вообще может быть нужно несколько интерпретаторов? Вы удивитесь, но даже для Python это нужно, или, по крайней мере, может быть полезно. Среди существующих примеров можно назвать приложения со встроенным Python, такие как веб-приложения на mod_python, — им определённо нужно запускаться в изолированном окружении. Да, в Python есть субинтерпретаторы, но они работают внутри основного интерпретатора, и только потому, что в Python так много всего завязано на внутреннее состояние. Самая большая часть кода для работы с внутренним состоянием Python является одновременно самой спорной: global interpreter lock (GIL). Python работает в концепции одного интерпретатора потому, что существует огромное количество данных, используемых всеми субинтерпретаторами совместно. Всем им нужна блокировка (lock) для единоличного доступа к этим данным, поэтому эта блокировка реализована в интерпретаторе. О каких данных идёт речь?

Если вы посмотрите на код, приведённый выше, то увидите все эти огромные структуры, объявленные как глобальные переменные. По факту интерпретатор использует эти структуры напрямую в коде на Python с помощью макроса OB_HEAD_INIT(&Typetype), который задаёт этим структурам необходимые для работы интерпретатора заголовки. Например, там есть подсчёт количества ссылок на объект.

Теперь вы видите к чему всё идёт? Эти объекты используются совместно всеми субинтерпретаторами. А теперь представьте, что мы можем изменить любой из этих объектов в коде Python: две совершенно независимые и никак не связанные между собой программы на Python, которые ничто не должно связывать, смогут влиять на состояние друг друга. Представьте себе, например, что JavaScript код во вкладке с Facebook может изменить реализацию встроенного объекта array, и во вкладке с Google эти изменения немедленно начали бы работать.

Это архитектурное решение 1990 года, которое до сих пор продолжает влиять на современную версию языка.

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

Однако есть ещё кое-что.

Что такое VTable?

Итак, в Python встроенные (реализованные на C) типы данных практически неизменяемы. Чем же ещё они отличаются? Ещё одно различие заключается в «открытости» классов Python. Методы классов, реализованных на языке программирования Python, являются «виртуальными»: не существует «настоящей» таблицы виртуальных методов, как в C++, и все методы хранятся в словаре класса, из которого делается выборка с помощью поискового алгоритма. Последствия очевидны: когда вы наследуетесь от какого-либо объекта и переопределяете его метод, велика вероятность что и другой метод будет опосредованно затронут, потому что он вызывается в процессе.

Хорошим примером являются коллекции, которые содержат удобные для работы функции. Так, словари в Python имеют два метода для получения объекта: __getitem__() и get(). Когда вы создаёте класс в Python, вы обычно реализуете один метод через другой, возвращая, например, return self.__getitem__(key) из функции get(key).

Для типов, реализуемых в интерпретаторе, всё иначе. Причина, опять же, заключается в разнице между слотами и словарями. Скажем, вы хотите создать словарь в интерпретаторе, и одним из условий является переиспользование существующего кода, поэтому вы хотите вызвать __getitem__ из get. Как вы поступите?

Метод Python в C — это просто функция со специфической сигнатурой, и это первая проблема. Главной задачей функции является обработка параметров из кода на Python и конвертирование их во что-то, что можно будет использовать на уровне C. Как минимум, вам нужно преобразовать аргументы вызова функции из кортежа или словаря Python (args и kwargs) в локальные переменные. Обычно делают так: сначала dict__getitem__ просто парсит аргументы, а затем происходит вызов dict_do_getitem с актуальными параметрами. Видите, что происходит? dict__getitem__ и dict_get обе вызывают dict_get, которая является внутренней статической функцией, и вы не можете ничего с этим поделать.

Не существует хорошего способа обойти это ограничение, и причина заключается в системе слотов. У интерпретатора не существует нормального способа сделать вызов через vtable, и причина этому — GIL. Словарь (dict) общается с «внешним миром» через API с помощью атомарных операций, и это полностью теряет весь смысл, когда такие вызовы происходят через vtable. Почему? Да потому что такой вызов может не попасть на уровень Python, и тогда он не будет обработан через GIL, и это сразу приведёт к огромным проблемам.

Представьте себе страдания при переопределении в классе, отнаследованном от словаря, внутренней функции dict_get, в которой запускается lazy import. Вы выкидываете все ваши гарантии в окно. Но опять же, быть может, нам уже давно следовало бы это сделать?

Заключение

В последние годы прослеживается явная тенденция усложнения языка Python. Я хотел бы увидеть обратное.

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

Вместо слотов и словарей в роли vtable, давайте поэкспериментируем просто со словарями. Язык Objective-C полностью основан на обмене сообщениями, и это играет решающую роль в скорости его работы: я вижу, что обработка вызовов в Objective-C происходит гораздо быстрее, чем в Python. То, что строки являются внутренним типом в Python делает их сравнение быстрым. Я готов поспорить, что предлагаемый подход будет не хуже, и даже если это немного замедлит работу внутренних типов, в итоге должна получиться гораздо более простая архитектура, которую проще будет оптимизировать.

Вам стоит изучить исходный код Python, чтобы увидеть, как много лишнего кода требуется для работы системы слотов, это просто невероятно! Я убеждён, что это была плохая идея, и нам следовало бы давно от неё отказаться. Отказ от слотов пойдёт на пользу даже PyPy, поскольку я уверен, что его авторам приходится лезть из кожи вон, чтобы их интерпретатор работал в режиме совместимости с CPython.

Перевёл Dreadatour, текст читал %username%.