— Сколько нужно архитекторов, чтобы реализовать язык программирования?
— Сто. Один будет писать реализацию, а 99 — говорить, что могут сделать лучше.
В этой статье я хочу затронуть не столько сам язык, сколько детали реализации CPython и его стандартной библиотеки, которые гарантируют, что у вас не будет никаких простых способов сделать приложение на питоне ни многопоточным, ни быстрым, ни легко поддерживаемым, и почему было создано столько альтернативных реализаций (PyPy, Cython, Jython, IronPython, Python for .NET, Parakeet, Nuitka, Stackless, Unladen Swallow), половина из которых уже умерла, и вряд мало кто понял, почему у них не было шансов победить в борьбе за выживание против других языков. Да, есть GDScript, который призван решить проблемы с производительность, есть Nim, который призван решить вообще все проблемы, не обязывая при этом пользователя чрезмерно явно объявлять типы. Однако, учитывая огромную инертность индустрии, я осознаю, что в ближайшие 10 лет новые языки точно не займут значимой ниши. Однако, я верю в то, что питон возможно сделать эффективным, изменив стиль написания кода, по большей части сохранив оригинальный синтаксис, и полностью сохраняя возможность взаимодействия кода нового и старого стиля. Я буду концентрироваться на проблемах CPython, а не ближайшего его конкурента, PyPy, поскольку PyPy на самом деле прыгает вокруг всё тех же проблем CPython.
Я — программист с семилетним стажем, в основном занимался разработкой десктопных приложений, с неким упором на веб и многопоточные базы данных. Вы спросите «погоди, но что общего имеет питон с пользовательским интерфейсом, веб-фронтендами, и многопоточностью?». А я отвечу «вот именно — ничего». Я использовал C, Delphi, JavaScript, и SQL для моих задач. Меня эта ситуация не сильно радовала, и энное время назад я попытался участвовать в проекте Эрика Сноу по реализации поддержки множественных интерпретаторов в CPython:
https://www.python.org/dev/peps/pep-0554/
https://github.com/ericsnowcurrently/multi-core-python
К сожалению, очень быстро пришло понимание того, что:
- CPython довольно слабо поддерживается для такого популярного проекта, и имеет кучу старых проблем, которые вылазят наружу при попытке перекроить реализацию. В итоге Эрик уже несколько лет ковыряет интерпретатор с переменным прогрессом;
- даже после успешной реализации множественных интерпретаторов не совсем ясно, как дальше организовывать параллельное выполнение. В PEP предлагается использовать простые каналы, но этот инструмент становится опасным по мере усложнения задачи, с угрозами зависания и непредсказуемого поведения;
- сам язык имеет большие проблемы, не дающие интерпретаторам непосредственно обмениваться данными и давать некую гарантию предсказуемости поведения.
Теперь более детально о проблемах.
Изменяемые определения классов
Да, я понимаю, что класс в питоне объявляется во время выполнения. Но, блин, зачем в него совать переменные? Зачем в старые объекты добавлять новые методы? В какой-нибудь Java нельзя объявлять функции и переменные вне классов, но в питоне такого ограничения нет (и питон создан до джавы). Причем, попрошу обратить внимание на то, как нужно нагибаться раком для того, чтобы добавить аналогичные методы в сам объект, а не в класс — для этого нужны types.MethodType, function.__get__, functools.partial, и так далее.
Я бы хотел для начала задать странный вопрос: а зачем вообще в питоне нужны методы? Не функции, как в близком JavaScript, а именно методы класса. Один из факторов: Гвидо не придумал лучше способов сделать короткие имена функций (чтобы не было сишного gtk_button_set_focus_on_click), поскольку не ясно, как выбирать из кучи похожих функций с коротким именем нужную под этот конкретный объект. Тем не менее, в питоне появились len, iter, next, isinstance, slice, dict, dir, str, repr, hash, type — сейчас это обертки над соответствующими методами классов с подчеркиваниями в имени, а когда-то встроенные простые типы не являлись классами и работали только через эти функции. Лично я не вижу особой разницы между записью method(object) и object.method — особенно если method является статичной функцией, которой, в общем-то, все равно, какой первый аргумент (self) принимать.
Динамические определения классов в общем случае:
- не дают модульно тестировать. Правильно отработавший в тесте кусок кода может выдать ошибку при работе целой системы, и никак вы от этого не защититесь в рамках CPython;
- создают большие сложности оптимизации. Объявление класса не дает вам гарантии по поводу фактической работы класса. По этой причине единственный успешный проект оптимизатора, PyPy, использует трассировку для обнаружения фактической последовательности выполняемых действий методом пробы;
- не состыковываются с параллельным выполнением кода. Например, тот же multiprocessing работает с копиями определений классов, и если вы не дай бог измените описание классов в одной из копий, то ваше приложение рискует развалиться.
Более тонкий вариант динамических классов — это переопределение доступа к атрибутам через __getattribute__, __getattr__ и прочие. Зачастую они используются в качестве обычных геттеров-сеттеров, для делегации функций объекту-полю, и изредка — для организации DSL. Все эти функции могут быть реализованы более цивилизованным способом, без превращения класса в свалку описаний, поведение которых порой тяжело гарантировать. К слову, в случае геттеров/сеттеров такой механизм уже есть — это дескрипторы атрибутов: https://www.python.org/dev/peps/pep-0252/#id5
Горячая замена классов нужна для целей отладки и банальной кодописи, но это все-таки должен быть специализированный инструмент для разработчика, а не артефакт во время выполнения, от которого нельзя избавиться.
Компилируемые классы — это только маленький шаг, который уже сделали в Cython и Nuitka, но одного этого шага без других изменений недостаточно для получения какого-то значимого эффекта даже в плане скорости выполнения, поскольку, например, питон широко использует динамическое связывание переменных, которое никуда не девается в откомпилированном коде.
Множественное наследование
Я думаю, это лидер хейт-парада. Его нет даже на уровне C-функций в реализации самого питона и его расширений. «Но как же интерфейсы?» — возразите вы. Интерфейсы в C++ и Java нужны в роли объявления протоколов вызова методов объекта с целью последующей статической проверки этих протоколов при компиляции, а также для формирования таблиц методов, которые во время выполнения будут использованы другим кодом, ничего не знающим об исходном объекте. Эти роли почти полностью потеряны в питоне, потому нет никакого оправдания их существованию. Мне нравится то, как сделаны интерфейсы в Go — это очень похоже на питоновые ABC: https://www.python.org/dev/peps/pep-3119
Множественное наследование не является прямой проблемой для параллелизации и оптимизации, но усложняет читаемость и поддерживаемость кода — это так называемый лазанья-код (по аналогии со спагетти-кодом).
Генераторы
Это прямо-таки запущенный случай GoTo, когда выполнение не просто бесконтрольно прыгает по коду — оно прыгает по стэкам. Особенно лютая дичь происходит, когда генераторы пересекаются с менеджерами контекста (привет PEP 567). Если питоне есть общая тенденция запутывать приложение в тесный клубок связанных изменяемых состояний, не дающих пространства для маневров тестирования, параллелизации, и оптимизации программы, то генераторы — вишенка на этом торте.
Как вы думаете, какой будет результат выполнения программы:
import contextlib
@contextlib.contextmanager
def context_manager():
try:
print('Вход')
yield
finally:
print('Выход')
def gen_in_manager():
m = context_manager()
with m:
for i in range(5):
yield i
g1 = gen_in_manager()
next(g1)
print('Конец')
Это несколько переработанный пример со stackoverflow
Вход
Конец
Выход — не отобразится при выполнении в интерактивном режиме
Если этот пример показался вам слишком простым, то предлагаю с такой же легкостью предсказать выполнение немного измененной версии:
import contextlib
@contextlib.contextmanager
def context_manager():
try:
print('Вход')
yield
finally:
print('Выход')
def gen_in_manager():
m = context_manager()
with m:
for i in range(5):
yield i
def test():
g1 = gen_in_manager()
next(g1)
test()
print('Конец')
Вход
Выход
Конец
Вот так просто мы сделали выполнение нашей программы плохопредсказуемым.
Если вы думаете, что генераторы нужны только для возврата массива из функции, то спешу вас поправить: также генераторы используются в генераторных выражениях и async/await, а последнее нынче набрало популярность в веб разработке на питоне.
Интересный факт: транслятор RPython превращает генератор в класс-итератор. Это то, как генераторы и должны были быть сделаны. Ну а пока что наличие генераторов в коде не дает возможности для оптимизации, параллелизации, и тестирования.
Изменяемые значения
https://stackoverflow.com/questions/530530/python-2-x-gotchas-and-landmines
>>> a = ([42],)
>>> a[0] += [43, 44]
TypeError: 'tuple' object does not support item assignment
>>> a
([42, 43, 44],)
>>> a = ([42],)
>>> b = a[0]
>>> b += [43, 44]
>>> a
([42, 43, 44],)
>>> x = y = [1,2,3]
>>> x = x + [4]
>>> x == y
False
>>> x = y = [1,2,3]
>>> x += [4]
>>> x == y
True
>>> x = [[]]*5
>>> x
[[], [], [], [], []]
>>> x[0].append(0)
>>> x
[[0], [0], [0], [0], [0]]
Здесь мы приходим к некоему неожиданному выводу, на который особенно наводит ошибка «'tuple' object does not support item assignment» на фоне успешного выполнения операции: синтаксис питона слабо поддерживает работу с изменяемыми данными. Синтаксис адаптирован к тому, что результат операции всегда создается заново, как это сделано с числами, строками, кортежами. Однако же, по логике вещей, x[0].append(0) должно было бы создать новый или взять уникальный список из нулевого элемента и добавить в него нуль, но в CPython вместо этого неявно изменяется единственный экземпляр пустого списка, который хранится в нескольких местах. И если в трех строчках кода нам это очевидно, то в большом проекте можно очень долго вылавливать подобные проблемы.
Что же это за такой странный зверь тогда — списки [] питона? Это оптимизация кортежей, костыль, потому что операции с пересозданием кортежей на большом числе значений становятся очень медленными. К слову, создатель Clojure не согласился с таким подходом, и сделал быстрые неизменяемые списки с чем-то вроде partial-copy-on-write при выполнении операций над этими списками. Гвидо же сделал доживший до наших дней костыль, и не сделал адекватные средства работы с этими костылями.
Что делать с изменяемыми данными? К которым, кстати, также относятся и объекты в целом. Например, можно делать copy-on-write на присвоении, и явные операции изменения данных по месту без копирования: b.append.., b[].., b +=… Результат: устранение случайных связей между объектами, гарантия неизменяемости извне обладаемого объекта, а в итоге — упрощение параллелизации, оптимизации, тестирования. К сожалению, отваливается код, который опирался на старую логику работы списков, когда автору понадобилось, чтобы список/объект/ассоциативный массив таки менялся из разных мест, пусть это поведение и не очевидно из самого кода.
Почему Гвидо сразу не сделал язык с copy-on-write? Потому что та реализация оригинального интерпретатора не давала простой возможности отслеживать ссылки и копировать объекты, в частности, это требовало как минимум введения двойного указателя (вложенного), где указатель из функции на Си читает адрес объекта (который может меняться), а уже по этому адресу лежат сами данные, скопированные или общие неизмененные.
У меня все-таки не получается придумать лаконичных и совместимых способов добавить четкое разграничение изменяемых и неизменяемых объектов в рамках имеющегося языка. Ваши предложения? Например, можно было бы сделать все объекты copy-on-write, а явные ссылки делать через «({'first': 1, 'second': 2},)», то есть, такой себе боксинг, где роль контейнера выполняет кортеж, который неизменяем, и потому никогда не будет копироваться, оставаясь единой ссылкой на объект «{'first': 1, 'second': 2}».
Полиморфизм и множественная диспетчеризация
https://ru.wikipedia.org/wiki/Полиморфизм_(информатика)
«Полиморфизм в языках программирования и теории типов — способность функции обрабатывать данные разных типов»
Статья: https://en.wikipedia.org/wiki/Multiple_dispatch#Use_in_practice
Исследователи заявляют, что в исследованных системах 13–32% функций используют диспетчеризацию по одному аргументу (a.k.a. виртуальный метод), и 2.7–6.5% функций — по двум и более. Но я вам скажу ужасающую новость: когда вы просто используете арифметические операции над разными типами, или даже банально присваивание, то вы применяете операцию, полиморфную по одному и более аргументу. Полиморфизм намного ближе, чем кажется, и на самом деле все популярные языки очень активно употребляют полиморфные операции.
Например, в Си тип данных — это не сами данные, это способы работы с ними. По этой причине Си глубоко полиморфичен, то есть, позволяет однообразно обрабатывать разные типы данных. Например, вы можете превратить указатель на double в указатель на char, скопировать байты через последний указатель, потом взять эти байты как указатель на double и работать дальше с ними как с числом.
float a = 2.0;
float *src = &a;
char *dest = malloc(sizeof(float));
memcpy(dest, src, sizeof(float));
printf("%f", *(double*)dest);
При этом, язык способен сам совмещать в операциях (сложение, сравнение, присвоение) различные типы данных — эта фича давным давно возникла в самых разных ЯП и почему-то воспринимается как данность, но это весьма сложный и, порой, коварный механизм.
Питон же фундаментально полиморфичен, потому что большинство его конструкций применимы (в том числе с генерацией исключения) к произвольным типам. Язык сам по себе изначально не задавал никаких правил о реализации этого полиморфизма: у вас есть просто код «a = b + c», а вы дальше сами разбирайтесь, как вы будете это выполнять. Как же Гвидо решил это выполнять?
- явная диспетчеризация во время выполнения: «if (Py_TYPE(obj) == &PyLong_Type) {long val = PyLong_AsLong...}» на Си или аналогичное на питоне через type();
- приведение полиморфного аргумента к единому типу: «PyArg_ParseTuple()» на Си или конструкторы типов на самом питоне, вроде «this.number = int(param1); this.text = str(param2)» — в данном случае явная реализация диспетчеризации оказывается уже в конструкторе, а сам вызов конструктура является неявной диспетчеризацией. В стандартной библиотеке много подобных конструкций, правда, часто они вызваны банальной необходимостью сделать копию объект известного типа (напоминаю из прошлого раздела, что синтаксис питона слабо поддерживает операции с изменяемыми данными);
- методы/функции/поля объекта и его классов. Это уже не похоже на простые таблицы виртуальных методов в каноничных классовых ООП языках — это алгоритм поиска подходящего атрибута по сложному и не всегда ясному алгоритму.
Итак, возьмем банальную операцию сложения. Что делает питон для того, чтобы сложить два объекта разных типов? Я прочитал весь код реализации механизма сложения в CPython, но, честно говоря, до сих пор не пойму, что курил ее автор, и зачем было так это делать. Грубо говоря, опишу это так: будет вызван метод сложения одного из аргументов или встроенная быстрая функция сложения чисел. Пардон, а где здесь полиморфизм по двум аргументам? А нету его: здесь подразумевается, что оба метода у аргументов будут одинаковыми и/или эти методы будут выдавать ошибку, если обнаружат несовместимый метод сложения/тип у другого аргумента. Cложение «a = b + c» делается примерно так (в псевдокоде, краткий пересказ кода на Си):
def PyNumber_Add(b, c):
slotb = b.__add__
if not type(b) is type(c) and c.__add__:
slotc = c.__add__
if slotc is slotb:
slotc = None
if slotb:
if slotc and isinstance(c, b.__class__):
return slotc(b, c)
else:
return slotb(b, c)
else:
return slotb(b, c)
if isinstance(b, str) and isinstance(c, str):
a = unicode_concatenate(b, c)
else:
a = PyNumber_Add(b, c)
Провал здесь заключается в том, что на самом деле сложение логически не принадлежит числу, не является его методом. «Вариться в супе» не является методом собаки — это функция, которая использует собаку, плиту, котелок, и воду для реализации алгоритма. Но позиция питоничности, как и во многих других ООП языках, заключается в том, что каждая собака должна иметь методы «вариться в супе», «бежать», «лаять», и так далее; короче говоря — реализовывать все возможные и некоторые невозможные варианты взаимодействия со внешним миром.
Вернемся к нашим баранам: Гвидо не придумал, как сделать множественный полиморфизм. В языке, фундамент которого представляет собой полиформизм всех аргументов. Есть и утешительный вывод: он здесь не одинок, и куча создателей ООП языков тоже не придумали ничего годного. К сожалению, чисто классовая модель решительно провальна в этом плане, потому что дает исключительно одиночный полиморфизм — через виртуальный метод класса.
В тех же крестах и жаве для обхода ограничения существуют перегруженные методы, которые позволяют реализовывать один и тот же метод для разных аргументов типов. В этом решении есть проблема, которая привела к появлению такого уродливого подхода, как шаблон Посетитель. А именно, с перегрузкой методов класс опять-таки должен уметь всё на свете вместо того, чтобы нужные частные реализации принадлежали частными комбинациям типов.
Поскольку полиформизм на уровне ООП работает отвратительно, то целая куча функций из стандартной библиотеки питона представляют из себя отдельный интерпретатор, который самостоятельно читает программу (то есть тип и значение аргументов), и принимает решение о принимаемых действиях (приведение к единому типу или ветвление), а потом выбрасывает результат на вход следующему интерпретатору. И самое печальное здесь то, что теперь такой способ выполнения программы диктует расширениями питона и другим реализациям питона действовать точно так же, повторяя заморочки реализации всех мелких интерпретаторов (функций стандартной библиотеки). Помимо банальной производительности, это лишает предсказуемости, поскольку тип на выходе может неожидано измениться при неизвестных обстоятельствах — у нас же нет никакой гарантии типов, помните? Построение языка на мелких недостаточно предсказуемых интерпретаторах в итоге является одной из ключевых причин потери возможности оптимизации кода и статической проверки типов.
Еще один из костылей для решения проблемы множественного полиморфизма — это множественное наследование: мы засовываем два полиморфных параметра в один класс, представляющий собой комбинацию родительских классов из двух независимых иерархий. Я думаю, мне не нужно объяснять, насколько тяжело читать и поддерживать такой код. Причем, и здесь принцип оказался замаран заморочками реализации, потому что иногда иерархии все-таки пересекаются, и тогда начинается чад кутежа, читай: множественное наследование не работает в общем случае. Конкретно Гвидо серьезно поломал себе зубы об этот камень, пытаясь заставить работать то, что не может работать — в итоге мы имеем MRO:
https://www.python.org/download/releases/2.3/mro/
https://ru.wikipedia.org/wiki/C3-линеаризация
Справедливости ради нужно заметить, что начиная с 3.4 в питоне появилась так называемая «клиника аргументов» ( Argument — Monty Python ), которая призвана автоматически генерировать обертку обработки аргументов для функции с чисто сишными аргументами. Так сказать «не прошло и двадцать лет»… а не, уже прошло: 3.4 выпущен в 2014, а первый релиз питона был в 1991.
Но все эти попытки исправить горбатого питона представляют собой лишь полумеры. Питону нужна возможность распределять по модулям реализацию конкретных методов и дополнять эти реализации при появлении новых типов, примерно как это делают типажи (trait) в Rust (не важно, во время компиляции, исполнения статичного кода, интерпретации, или чего бы то ни было еще):
https://doc.rust-lang.org/1.8.0/book/traits.html
То есть, если я складываю объект А с объектом Б, то вызывается не метод объекта, и не встроенный в интерпретатор костыль, а функция, которая объявлена как функция сложения с аргументами типа А и Б. Проблемы возникают разве что с самим определением «тип». Поскольку в питоне очень распространена утиная типизация, а наследованию мы объявили войну, то класс объекта не может служить его типом в данном случае. Скорее, нужно что-то вроде интерфейса, протокола, типажей Rust-а. Например, объект имеет __iter__ — он становится автоматически обладателем типажа «итератор». Правда, если так широко загребать объекты под типажи, то очень быстро возникает ситуация, когда конфликтующие типажи представляются абсолютно одинаковыми атрибутами, с одинаковыми аргументами или типами переменных. Потому есть смысл делать явное объявление типажей, вроде «вот эти мои атрибуты принадлежат такому-то типажу, а никакому не другому». С другой стороны, не хотелось бы скатиться в крестовые обобщения с многоуровневыми объявлениями типов, как то в C++ ranges, где объявления шаблонов занимают примерно столько же, а то и больше, чем сама реализация алгоритма.
Приведу простой пример:
from collections.abc import Iterable, Container
from itertools import filterfalse
class MyList(Trait, Iterable, Container):
pass
def __sub__(a: MyList, b: object):
return list(filterfalse(lambda x: x == b, a))
def __sub__(a: MyList, b: Container):
return list(filterfalse(lambda x: x in b, a))
a = MyList([1, 2, 3, 4, 5])
print(a - [2, 5]) # То есть, print(__sub__(a, b))
# Вывод: [1, 3, 4]
print(a - 3)
# Вывод: [1, 2, 4, 5]
Здесь мы создаем оператор вычитания с типажем аргумента MyList, который как бы наследует Iterable (наличие метода __iter__) и Container (метод __contains__), которые оба поддерживаются list, и потому list можно как бы приводить к MyList, хотя ни MyList не является наследником list, ни list не является наследником MyList. Этот код в классическом питоне выглядел бы как:
from collections.abc import Container
from itertools import filterfalse
class MyList(list):
def __sub__(self, b):
if isinstance(b, Container):
return list(filterfalse(lambda x: x in b, a))
else:
return list(filterfalse(lambda x: x == b, a))
a = MyList([1, 2, 3, 4, 5])
print(a - [2, 5])
# Вывод: [1, 3, 4]
print(a - 3)
# Вывод: [1, 2, 4, 5]
Но есть большая разница: первый код можно скомпилировать статически с контролем типов, и он на самом деле не изменяет типа массива «a», а лишь оказывает влияние на методы работы с этим объектом. Это как бы «огрызочное наследование», когда наследование функционала происходит по отдельным частям-интерфейсам, а не по всей реализации в целом.
Конечно, я не предлагаю запрещать старые классы и быстро всем бежать переписывать код — скорее, я предлагаю основную толкотню отправить в стандартную библиотеку и в сам транслятор, где транслятор по правилам из библиотеки будет сидеть и лепить вызовы функций с типажами, аки конструктор лего. На самом деле, он и так сейчас их лепит, просто делает это беспорядочно и во время выполнения.
Оператор присвоения и статическая типизация
Давайте вспомним, что многие классы из стандартной библиотеки питона скомпилированы статически, в виде встроенных функций интерпретатора или расширений на Си. Очевидно, что они ждут вполне конкретный ограниченный набор входных параметров, для проверки которых применяют собственные велосипеды, и они хранят внутренние данные в фиксированном формате, который не может внезапно измениться, например, с массива на строку. Огромное количество проблем в программах на чистом питоне возникло из-за того, что присваивание питоне абсолютно слепо — оно просто берет любую ссылку-объект на вход, и присваивает эту ссылку левому выражению. Например:
>>> a = [1, 2, 3]
...
>>> a = '15'
...
>>> for i in map(lambda x: x*2, a):
>>> print(i)
11
55
Ожидается вывод
2
4
6
Удачи вам искать подобную ошибку в код на сотни тысяч строк.
Конечно, мы не можем заставлять программистов использовать только один тип в одной переменной, поскольку запихивание кучи типов в одну переменную стало уже сложившейся традицией. Даже банальный None — это уже другой тип, NoneType. Однако же, есть законченные куски кода — модули и объекты, которые в реализации на Си более-менее блюдут чистоту типов, но при написании на питоне внезапно эту чистоту теряют. Давайте рассматрим на простейшем примере теоретическую новую модель:
>>> class A():
>>> def __init__(self, value):
>>> self.val = value
>>>
>>> a = A('2')
>>> a.val = []
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: can only assign str (not "list") to str
>>> a.myattr = []
>>> a.myattr = 2
Здесь создается класс A с полиморфным типом атрибута val, который конкретизируется выводом типа из конструктора. Естественно, кто-то извне может захотеть добавить свой собственный атрибут в объект (myattr) — этот атрибут будет уже находиться на уровне экземпляра объекта, и дальше уже создающий и использующий экземпляр код будет разбираться, нужно ли проверять тип или нет.
Ожидаемо, такие финты ушами плохо состыковываются с динамически изменяемым описанием класса. Класс здесь должен быть не просто статичен — он является шаблоном, обобщением для частного класса, генерируемого в коде создания и использования объекта. Возьмем тот же класс, только теперь зададим ему несколько иной тип:
>>> class A():
>>> def __init__(self, value):
>>> self.val = value
>>>
>>> def func():
>>> a = A(None)
>>> a.val = 2
>>> print(a.__dict__)
>>>
>>> func()
{'val': 2}
Здесь вызывающий код создает как бы A<None or Int>, в результате чего функция успешно выполняется. Естественно, при строго построчной интерпретации проверить тип не представляется возможным, потому код создания и инициализации объекта завернут в функцию.
Я плавно перехожу к проблеме отсутствие времени компиляции: интерпретатор предполагает, что вы в любой момент имеете возможность перейти из любого состояния в почти любое: через двойную сплошную, овраг, сбив пасущуюся на лугу корову перетащите поезд с железной дороги на речку, и поплывете по речке всё с теми же звуками «чух-чух», которые были у класса «поезд». Глобальная изменяемость состояния — это важная особенность, которая пронизывает весь язык; из-за нее, к слову, питон отвратительно натягивается на функциональщину, потому что в своем фундаменте является языком глобального изменяемого состояния, в противовес чистым функциям без состояния. При всем при этом, однако, вы не можете банально заменить стандартный тип «list», который используется, в том числе, в list comprehension, а тот, в свою очередь, использует BUILD_LIST и LIST_APPEND (в CPython) — вы можете найти логичное оправдание такой негибкости на фоне гибкости? Как по мне, так это больше напоминает модель «груда беспорядочных фич», «лишь бы как-то заработало».
Еще замечу, что кто-то (я) может захотеть сделать приведение типа аргумента при присвоении, вроде «a.val = int(newval)». Собсна, стандартная библиотека часто делает приведение, но больше для аргументов и уже после выполнения самого присвоения, когда ссылка на значение неизвестного типа передается аргументом функции. Для перезаписи операций присваивания атрибутов в классах есть такие механизмы, как __setattr__ и __setattribute__, а начиная c 2.2 добавились еще и дескрипторы с их __set__ ( https://www.python.org/dev/peps/pep-0252/ ). Но давайте не забывать: намного проще оперировать простыми переменными, нежели создавать класс контекста и экземпляр этого класса просто для того, чтобы поработать с парой значений — в худших традициях C++/Java/C#. В принципе, можно было бы сделать через дескрипторы переопределение оператора присвоения с минимальными изменениями: достаточно начать уважать в обычных переменных методы __set__, __get__, __delete__, таким образом позволяя сделать проверку типа аргумента присвоения во время выполнения, например:
>>> a = StrictDict({'first': 1 })
>>> a = { 'dummy': 666 }
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StrictDictError: "first" key is missing in the assignment source
К слову, это один из вариантов решения двусмысленности операций со списками (из раздела «Изменяемые значения»): мы можем явно создать copy-on-write массив, который будет принимать новые значения, но не перезаписывать себя ссылкой на новый объект-массив, а делать copy-on-write ссылку на имеющиеся данные:
>>> a = COWList([1, 2, 3])
>>> b = a
>>> a.append(4)
>>> b.append(5)
>>> a
[1, 2, 3, 4]
>>> b
[1, 2, 3, 5]
Интересно, что в реализации CPython уже есть довольно обширный слой статичных атрибутов у классов, которые внутри называются «слоты»:
https://github.com/python/cpython/blob/master/Objects/typeobject.c#L5074
Это не те слоты, которые __slots__ у класса. Это сишная ссылка в структуре класса на методы, доступ к которым также можно получить через питон, и даже можно определить в новом классе на питоне методы с этим именем, и эти методы попадут в слот класса, то есть, код на Си сможет вызвать метод по указателю, методом лексического связывания, вместо запроса по имени метода, динамическим связыванием. Те же арифметические операции (сложение и вычитание) попадают в специальные слоты для операций с числами. В основном такой механизм применяется к методам, но есть также и данные: тот же __slots__ класса, который является слотом PyHeapTypeObject->ht_slots, или __dict__, который хранится в самом экземпляре объекта по смещению PyTypeObject->tp_dictoffset. Как вы видите, без статических структур реализовывать интерпретатор было не прикольно.
Вывод типов
Стоит помнить, что смысл питона был в том, чтобы избавить пользователя от мороки явного описания структуры объектов. Явные описания типов и атрибутов повсеместно в коде нужны только там, где у кода кишки торчат наружу, как то было у Си, где каждый модуль тусовался сам по себе, а потом линкер соединял их вместе, мол «это двенадцатиперстная, ее нужно будет подключать к тонким кишкам; а вот толстая кишка — она будет выводиться в тип "попа", не вздумай выводить ее в "рот"», и все типы этих подключений должны были быть описаны с обоих взаимодействующих сторон. Однако, если вы полностью контролируете алгоритмы, то вам обычно не нужны типы. Взять знаменитую строчку великих людей «Кукарек<кококо> кукарек = new Кукарек<кококо>()», которая в современной джаве приняла намного более приятную форму «var кукарек = new Кукарек<кококо>()». Также, из опыта людей по написанию фронтендов на ReasonML выяснилось, что практически единственное место, где нужны явные типы — это интерфейсы к JS либам, а во всех остальных местах работает вывод типов, который подбирает нужные типы по инициализации и доступу.
PyPy, а также аналогичные V8 для JavaScript и LuaJIT, испытывают проблемы с выводом типов до выполнения программы, потому они предпочитают конкретизировать типы уже после выполнения кода. Отсюда возникает проблема избыточного использования ресурсов из-за компиляции во время выполнения, и проблема параллелизации, которая не может происходить во время оптимизации и разоптимизации функций. По этой причине так активно развиваются проекты AOT компиляции, как то asm.js, WebAssembly, и почивший с миром PNaCl.
Давайте поверхностно пробежимся по истории развития идеи вывода типов:
- Bauer, A.M. and Saal, H.J. (1974). Does APL really need run-time checking? Software — Practice and Experience 4: 129–138.
- Kaplan, M.A. and Ullman, J.D. (1980). A scheme for the automatic inference of variable types. J. A CM 27(1): 128–145.
- Borning, A.H. and Ingalls, D.H.H. (1982). A type declaration and inference system for Smalltalk. In Conference Record of the Ninth Annual ACM Symposium on Principles of Programming Languages (pp. 133–141)
- https://ru.wikipedia.org/wiki/Standard_ML — 1984 год.
Насколько мне известно, Standard ML был первым языком, который полноценно опирался на вывод типов, а не использовал его в качестве дополнительного инструмента.
Конечно, вывод по Хиндли-Милнеру больше подходит для функциональных языков и достаточно простой системы типов, в которой полный вывод составных типов не приводит к безграничному числу справедливых сочетаний конкретных типов. К сожалению, обычно математики отвратительно программируют, а программисты — ничего не могут понять в математике, потому довольно долгое время математики прыгали на единорогах по радуге, выдавая бесполезные абстрактные модели (чем они до сих пор и занимаются), пока постепенно чудом не возникло локальное выведение типов:
- Frank Pfenning. (1988). Partial polymorphic type inference and higher-order unification. In Proceedings of the 1988 ACM Conference on Lisp and Functional Programming, pp. 153–163
- Cardelli, Luca; Martini, Simone; Mitchell, John C.; Scedrov, Andre (1994). An extension of system F with subtyping. Information and Computation, vol. 9. North Holland, Amsterdam. pp. 4–56
- Benjamin C. Pierce, and David N. Turner. (1997). Local type inference. Indiana University CSCI Technical Report #493, pp. 1-25
оно же в
(1998) Local type inference. POPL '98 Proceedings of the 25th ACM SIGPLAN-SIGACT symposium on Principles of programming languages, pp. 252-265
и
(2000). Local type inference. ACM Transactions on Programming Languages and Systems (TOPLAS). Vol. 22(1), pp. 1-44
Последний вариант вывода типов — это примерно то, вокруг чего возникла Scala, создание которой началось в 2001 году.
В 1991 году приходит Гвидо, в 1994 — Расмус, в 1995 — Юкихиро «Matz», и никто из них не слышал о выводе типов. Можно говорить о том, что отсутствие системы типов сделало обучение языку проще, благодаря чему они и получили такое распространение. Однако, сейчас пришло время, когда система типов нужна и без нее — никуда, потому что есть очень много кода на питоне, его тяжело поддерживать, он медленно работает, а для параллелизации выполнения приходится использовать внешние средства, вроде ZeroMQ, RabbitMQ, Kafka. Отсутствие же времени компиляции и статичных типов в питоне на фоне того, что полный код программы обычно известен до выполнения, в итоге отнимает очень много сил у разработчика, который потом в этом минном поле неопределенности пытается проверять ошибки в программе при помощи тестов и убогих статических анализаторов, и все равно пропускает гору простейших ошибок, потому что по мере роста количества кода все варианты ветвей выполения и типов данных удерживать под контролем становится всё сложнее и сложнее. Почему еще в начале двухтысячных язык не начал мигрировать в сторону вывода типов, когда гугл сделал на питон ставку на границе веков? Не знаю. Руби все-таки начал движение в эту сторону, которое известно как язык Crystal, но там с совместимостью дело печально обстоит, насколько мне известно.
Итог
Я — глупый и наивный максималист. У вас не составит труда показать мне моё место, или пояснить, как вы могли бы сделать лучше.
Автор: byko3y