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


Интерпретатор языка берёт на себя всю низкоуровневую работу, освобождая программиста от необходимости ручного управления памятью. Практическая невозможность получить segmentation fault, а также удобная система исключений, снабжённая понятными сообщениями, позволяют оперативно отлаживать программы. Ситуации, когда их падения из-за возникшей ошибки требуют глубокого дебаггинга, достаточно редки.


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


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


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


Скорость


Основным среди таковых принято считать его медлительность, хотя это с определённой долей справедливости парируется тем, что скриптовому языку скорость особо и не нужна. В задачах, требующих высокой производительности, он выступает лишь как обёртка для манипуляций с API низкоуровневых библиотек, написанных на языках с поддержкой AOT-компиляции. Самыми популярными из таких языков на данный момент являются, конечно же, C и C++. На первом, например, реализована широкоиспользуемая библиотека NumPy, созданная для математических операций с массивами произвольной размерности. На втором — набирающий популярность фреймворк для обучения нейросетей PyTorch.


Как бы то ни было, что-то высокопроизводительное на чистом Питоне написать не получится. Для этого требуется прибегать к помощи других языков или использовать статически типизированные расширения, такие как, например, Cython, на которых писать, мягко говоря, неприятно.


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


  • Использование Python в качестве языка командной строки и управления взаимодействия между другими программами. Здесь он имеет определённое преимущество перед Bash, Sh и прочими языками оболочки, лишёнными удобной работы с массивами и строками и имеющими плохочитаемый синтаксис. Тем не менее описывать конвейеры в последних намного удобнее. Пример: управляющие скрипты большинства дистрибутивов Linux.
  • Конфигурация и управление базами данных. Примеры: модуль sqlite3, Django и т.д.
  • Манипуляция потоками вычислений, в которых расчёты производятся глубоко оптимизированным кодом, скомпилированным в непосредственные инструкции процессора. Python тут выступает лишь как среда для взаимодействия с API по инициализации и описанию структуры взаимодействия этих потоков. Примеры: NumPy, CuPy, PyTorch и т.д.

Почему Python медленный


Тут есть два основных фактора:


  • В нём практически всё аллоцируется на куче.
  • Перед выполнением операций с любым объектом интерпретатор проверяет его тип.

Давайте взглянем на примерную реализацию структуры в коде CPython, которая является обязательной частью всех питоновских объектов:


typedef struct _object {
    Py_ssize_t ob_refcnt;
    PyTypeObject *ob_type;
} PyObject;

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


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


Что касается языка Python, то он компилируется в байт-код, а не в инструкции процессора. Значит, предсказатель переходов тут становится бессилен.


Оптимизации векторизации тут тоже не работают. Как пример, обычный питоновский массив вида [100, 200, 300, 400, 500], на самом деле, как было показано выше, хранит не целые числа, а указатели на объекты типа int, каждый из которых хранит указатель на область памяти, в которой записано соответствующее ему число. Даже один косвенный указатель ломает векторизацию, а тут для доступа к информации о числе нужно пройтись, как минимум, по двум. Лиха беда начало, перед каждой операцией с объектом интерпретатор Питона должен перейти в область памяти с реализацией соответсвующего метода для текущего типа, и переход этот делается опять же по указателю.


Динамика


Как мы видим, безопасность, которая обеспечивается моделью памяти языка Python, сводит на нет большое число возможных процессорных оптимизаций. Тем не менее знающие читатели могут заметить, что, например, в Java все объекты, за исключением примитивных типов, так же аллоцируются на куче, но код на этом языке работает гораздо быстрее. Так почему бы интерпретатору Питона не реализовать оптимизации, присущие виртуальной машине Java, — например, копцепцию JIT-компиляции?


Несмотря на довольно успешные попытки её воплощения в различных вторичных интерпретаторах (PyPy и т.п.) и библиотке Numba, можно констатировать, что код на Питоне очень плохо поддаётся оптимизации. И причина этого кроется в следующих взаимосвязанных особенностях, которые объединяет тот факт, что Python — крайне динамичный язык:


  • Во-первых, это динамическая типизация


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


    Писать что-то подобное возможно только в динамически типизированных языках:


    >>> x = 3
    >>> x = '36'
    >>> 
    >>> def foo(a, b):
    >>>     if b:
    >>>         return [a]
    >>>     return a
    >>> 
    >>> foo(x, True)
    ['36']
    
    >>> foo(x, False)
    '36'
    
    >>> class Bar:
    >>>     __slots__ = ('baz',)
    >>> 
    >>> x = Bar()
    >>> x.baz = 332
    >>> x.baz
    332
    
    >>> x.baz = 'Some string'
    >>> x.baz
    'Some string'
    
    >>> foo(x, True)
    [<__main__.Bar at 0x10ff0d700>]

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


  • Вторая такая особенность — это пространство имён.


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


    >>> a = 5
    >>> b = True
    >>> 
    >>> if b:
    >>>     x = 100
    >>> x
    100
    
    >>> for i in range(10):
    >>>     for j in range(5):
    >>>         pass
    >>> print(i, j)
    9 4

  • Третим фактором является широкая гибкость в динамическом переопределении процесса создания объектов. В Питоне возможно такое:


    >>> class Meta(type):
    >>>     def __new__(cls, name, bases, attrs):
    >>>         if list in bases:
    >>>             return super().__new__(cls, name, (tuple,), attrs)
    >>>         return super().__new__(cls, name, (list,), attrs)
    >>> 
    >>> class Foo(list, metaclass=Meta):
    >>>     pass
    >>> 
    >>> class Bar(tuple, metaclass=Meta):
    >>>     pass
    >>> 
    >>> issubclass(Foo, list)
    False
    >>> issubclass(Foo, tuple)
    True
    >>> issubclass(Bar, tuple)
    False
    >>> issubclass(Bar, list)
    True

    Этот код требует пояснения. Дело в том, что в Питоне все сущности, существующие в рантайме, являются объектами. Объекты, в свою очередь, являются реализациями, или как их ещё называют — инстансами, классов. Но классы же ведь тоже являются объектами.


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


    В данном конкретном примере классы Foo и Bar имеют общий метакласс Meta, который подменяет тип, от которого пытается наследоваться класс. Так, при попытке отнаследоваться от типа list, класс, на самом деле, станет наследником типа tuple, а в противном случае — типа list.


    Подробнее о метаклассах вы можете почитать тут.


  • Ну и наконец, четвёртой сложностью является возможность создавать классы динамически.


    >>> from collections.abc import Iterable
    >>> 
    >>> def wrap_class(cls):
    >>>     if issubclass(cls, Iterable):
    >>>         class FooCounter(cls):
    >>>             def count_foo(self):
    >>>                 return sum(1 for item in self if item == 'foo')
    >>> 
    >>>         return FooCounter
    >>>     raise TypeError(f'Class {cls} is not an iterable type')
    >>> 
    >>> wrap_class(list)([2, 3, 'foo', 'bar', 'baz', 'foo']).count_foo()
    2


Все эти особенности негативно влияют на возможность предоставления статических гарантий относительно поведения кода на языке Python. Существующие анализаторы, основным из которых является MyPy, нельзя назвать законченными. Особенной проблемой является то, что на данный момент они имеют крайне ограниченную функциональность при анализе случаев, подобных взятым из последних двух примеров. Ситуация осложняется тем фактом, что, несмотря на все те правильные инициативы, связанные с внедрением модуля typing в предыдущих релизах, система аннотации типов в Питоне остаётся невыразительной. Например, для указания интерфейса типа возвращаемого аргумента функции wrap_class из последнего примера требуется поддержка типов-пересечений, которой на текущий момент нет.


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


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


Выводы


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


Уже сейчас в мире существуют динамически типизированные языки с опциональной компиляцией, основанной на уточнении типов. Самым интересным из них, на мой взгляд, является Julia. Возьмут ли разработчики CPython его пример на вооружение — покажет время.


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