Питон моей мечты

в 9:15, , рубрики: cpython, python, python3, Компиляторы

Disclaimer: это перевод статьи Армина Ронашера «The Python I Would Like To See».

Вступление

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

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

Так что позвольте пригласить вас в путешествие, которое начнется с мелкого недочета в интерпретаторе (система слотов) и закончится огромной ошибкой в дизайне языка. Если такой формат окажется удобным, я продолжу эту серию статей.

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

Язык vs Реализация

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

В нашем случае неоднозначные детали реализации изменили стандарт языка или повлияли на формирование особенного поведения и заставили адаптироваться альтернативные интерпретаторы. Например, PyPy не реализует систему слотов(я полагаю), однако вынужден иммитировать такое поведение, как будто они являются его частью.

Слоты

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

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

К несчастью, это не так.

На самом деле, Python так не делает. То есть CPython работает СОВСЕМ не так(по крайней мере, сейчас). Грубо говоря вот как работает интерпретатор:

  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

Так как у нас реализован специальный метод, интерпретатор привяжет его к слоту. Насколько это быстро? При вычислении 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

Откуда есть пошли Слоты

Сразу же возникает вопрос: откуда вообще появились слоты и зачем они были нужны? Насколько я могу судить, слоты до сих пор существуют в основном благодаря тяжелому наследию, нежели по какой-то другой объективной причине. В первоначальной версии CPython встроенные типы, такие, как строки и другие, были реализованы как глобальные статические структуры, имеющие соответствующие типу специальные методы. Это было еще до того, как появился __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*/
};

Как видите, уже в самой ранней версии CPython были такие вещи, как tp_as_number. К сожалению, в одной из старых версий репозиторий был поврежден, поэтому для достаточно ранних версий такие важные вещи, как интерпретатор, недоступны, так что придется вернуться немного назад в будущее, чтобы посмотреть как же они были сделаны. Вот как в 1993 выглядел callback для операции add:

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 выглядел в те времена. Например, тогда была большая разница между типами, реализованными на Python и на C:

    $ ./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, минимальна. В Python 2.7 самое большое отличие — это метод __repr__, который предоставляется по умолчанию и для пользовательских классов, и для встроенных в интерпретатор типов. Разница состоит в том, что repr определяет, как объект размещен — статически, в стэке(такое размещение испоьзуется для объектов встроенных типов) или динамически, в куче(для пользовательских классов). При этом эта проверка на практике не создает различий в поведении и полностью устранена в Python 3. Специальные методы дублируются на слотах и обратно. По большей части кажется, что разница между встроенными типами и пользовательскими классами исчезла.

Однако, к сожалению, они до сих пор очень отличаются. Давайте посмотрим.

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

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

Проклятый Интерпретатор

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

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

    /** предзагрузка */
    /* вызывается только один раз */
    bootstrap()

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

    /** выключение */
    /* вызывается только один раз */
    shutdown()

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

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

Так работают множество динамических языков программирования: такова реализация Lua, так работают движки JavaScript и т.д. Очевидное преимущество такого решения в том, что вы можете получить 2 одновременно работающих интерпретатора. Какой оригинальный ход!

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

Если вы посмотрите на приведенный ранее код, вы заметите огромное количество подобных структур повсюду. В основном они представлены в виде глобальных переменных.? Интерпретатор вставляет эти структуры напрямую в код.? Они включаются макросом OB_HEAD_INIT(&Typetype), который добавляет к описанию типа заголовок, позволяющий интерпретатору выделить их и работать с ними. К примеру, так добавляется поле refcount к типу.

Теперь вы видите, к чему это ведет. Эти объекты разделяются между субинтерпретаторами. Представьте, что вы можете изменять их своим кодом на Python. Два абсолютно независимых куска кода, которым не нужно никак взаимодействовать, могут влиять друг на друга! Вообразите только, чтобы было, если бы это происходило в JavaScript'е и вкладка с Facebook'ом могла бы изменять реализацию встроенного типа массива, а вкладка с Google'ом мгновенно получала бы эти изменения.

Такое дизайнерское решение в стиле 1990 сегодня заставляет вздрагивать.

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

Хотя это еще не все.

Что такое таблица виртуальных функций

Итак, те типы Python, которые реализованы на С, в основном неизменяемы. Какие же еще есть различия между ними и пользовательскими классами? Второе большое различие также кроется в открытости обычных классов. Методы обычных классов — «виртуальные». Так как при этом классической таблицы виртульных функций в стиле С++ нет, то все методы хранятся в словаре класса и для их поиска используется такой же алгоритм, как и для обычных аттрибутов. Последствия достаточно очевидны: когда вы наследуете класс и переопределяете один из методов, есть большая вероятность неявно изменить поведение другого, который использует его в своем коде.

Хорошим примером являются коллекции. Подавляющее большинство из них предоставляет различные API для удобства использования. Например, словари Python имеют два метода для получения значения по ключу: __getitem__() и get(). Если вы будете реализовывать нечто подобное, то скорее всего будете использовать один в другом, например, как-то так:

def get(key):
return self.__getitem__(key)

Однако, для типов встроенных в интерпретатор это сделано по-другому. Причина опять же кроется разнице в между слотами и словарем класса. Скажем, вы хотите реализовать словарь в интерпретаторе. Вы заботитесь о повторном использовании кода, поэтому вы хотите вызывать __getitem__ из метода get. И как это будет реализовано?

В CPython это выглядит как С-функция со специфичной сигнатурой. Это первая проблема. Первейшая задача этой функции — принять параметры из Python-кода и конвертировать их в удобные для работы на уровне С-кода. По крайней мере необходимо поместить в локальные переменные параметры, передаваемые Python'ом в виде списков и словарей — позиционные(args) и именованные(kwargs) параметры. Так что стандартный паттерн выполнения таков: dict__getitem__ выполняет только преобразование параметров, а затем вызывает уже реальный обработчик dict_do_getitem с обработанными параметрами. Как видите, и dict__getitem__, и dict_get оба вызовут dict_do_getitem, который является внутренней статической функцией и вы никак не можете его переопределить.

При этом отсутствует какой-либо хороший способ обойти такое поведение. Причина этого — система слотов. Нет простого способа изнутри интерпретатора обратиться к какой-либо функции обходя таблицу виртуальных функций, не сойдя при этом с ума. Причина для этого — GIL. Словарь для работы с ним внешних приложений должен гарантировать атомарность отдельных методов API. Этот контракт летит ко всем чертям, когда внутренние операции проходят через таблицу виртуальных функций. Почему? Потому что этот код выполняется внутри Python-кода, которому необходимо самостоятельно управлять блокировками GIL(в противном случае, вы столкнетесь с огромным количеством проблем).

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

Предупреждая повторные вопросы

Последние годы в развитии Python заметен тренд к усложнению языка. Я бы предпочел видеть тренд в обратную сторону.

Я бы хотел видеть интерпретатор Python, спроектированным опираясь на идею о работающих независимо друг от друга субинтерпретаторах, с локальными встроенными типами и т.п., что-то вроде того, как работает JavaScript. Это тут же опять открыло бы дорогу для встраивания и распараллеливания на основе отправки сообщений. Все равно процессоры не собираются становится значительно быстрее :)

Вместо системы слотов и реализации словарей через таблицы виртуальных функций, давайте просто поэкспериментируем со словарями! Objective-C — язык, полностью построенный на отправке сообщений, и он продвинулся далеко вперед в том, чтобы сделать их вызов реально быстрым. По умолчанию, вызовы в нем быстрее, чем, насколько я могу судить, могут быть вызовы в Python в лучшем случае. Строки задействованы везде в Python, что позволяет делать быстрые сравнения. Держу пари, что это не будет медленнее, а если и будет немножко медленней, это будет гораздо более простая система, которую будет легче оптимизировать.

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

Я глубоко убежден, что система слотов была плохой идеей и должна была быть похоронена давным-давно. Удаление будет с радостью воспринято хотя бы разработчиками PyPy, потому что я уверен, что им приходится использовать различные обходные приемы, чтобы реализовать в своем интерпретаторе поведение CPython для совместимости.

Автор: kammala

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js