Rust моей мечты — несостоявшийся язык
В одном недавнем подкасте о том, кто сейчас главный в Rust, вновь всплыл вопрос о том, кому быть BDFL (великодушным пожизненным диктатором), и Джереми Соллер сказал (это был чемпионский заход на приз «за преуменьшение века»): «Я считаю, Грейдон забраковал бы некоторые вещи, которые всем нам сейчас нравятся». Этим он вторит другой дискуссии на reddit, в которой мне напомнили, что я собирался как-нибудь расписать, каким образом «я сделал бы всё по-другому». Вероятно, это бы крайне не понравилось всем причастным, и эти идеи далеко бы не распространились.
Ну и ну. Я понял, что следующий момент не вполне очевиден и он, пожалуй, заостряет вопрос, «а действительно ли в проекте нужен BDFL». Так вот, озвучу его: Rust Нашего Времени далеко, далеко отстоит от Rust Моей Мечты. Главное, не поймите меня неправильно: мне нравится, что у нас получилось. Получилось отлично. Я воодушевлён, что теперь есть столь жизнеспособная альтернатива C++, в особенности такая, которую другие люди уже начинают воспринимать как норму, как реальный вариант для повседневной работы. Я пользуюсь Rust и очень доволен, что могу отдавать ему предпочтение перед C++. Но!
Я столько всего сделал бы в Rust по-другому, если бы всё это время «отвечал» за его развитие.
Доработки, которые радуют
Я сам не вполне понимаю, как именно правильно делаются те вещи, о которых здесь пойдёт разговор. Более того, если «правильный» способ и выработан, меня он может не устраивать. Такого много. Когда я впервые представил язык широкой публике, и к сообществу стали присоединяться люди, он был в лучшем случае полусырым. Многих вещей я не понимал, либо наугад подобрал не лучшие настройки, а какие-то элементы ещё не вполне работали. Конечно, некоторые пункты из следующего списка легко критиковать с позиций «один ум хорошо, а много — лучше»:
Семантика перемещения предоставлялась как вариант, а не по умолчанию.
Существовала система эффектов, которая в основном не работала (не было соответствующего полиморфизма).
Предусматривалась система модулей (как сущностей первого класса), которая в основном не работала из-за множества вязких технических проблем.
Существовала система статически проверяемых состояний типов (typestate system), которая оказывается избыточной, если у вас есть аффинные типы, номинальные типы и параметры фантомных типов.
Вся конкурентность существовала только в виде Erlang-подобных неструктурированных акторов, без непосредственного параллелизма, который использовал бы потоки или блокировки.
Пожалуй, большую часть всего вышеперечисленного можно было бы довести до ума, если бы люди из сообщества активно включились в работу. Не уверен на 100% насчёт акторов — я был до странности зациклен на этой модели, на практике имевшей множество недостатков — но, при прочих равных, я определённо принял бы помощь в проектировании некоторых из этих аспектов. Если бы мне тогда помогли, то, вероятно, эти разработки удалось бы подтолкнуть в таком направлении, которое все сочли бы положительным.
Расхождения, которые совсем не радуют
Но есть и масса других вопросов, по которым нет согласия, что в данном случае «лучше», а я не могу полностью согласиться с теми вариантами, которые были выбраны. Думаю, эти расхождения шли по четырём основным векторам:
Я набросал X (или только планировал), кто-то ещё горячо отстаивал вариант Y, проявив такую настойчивость и задействовав рычаги влияния, что в какой-то момент альтернатива просто была продавлена, а я проиграл.
Я набросал X, кто-то другой предпочёл Y и выписал этот вариант во всех деталях, и получившийся прототип Y оказался достаточно убедительным, чтобы заместить им X и позволить сообществу оценить Y. Время поджимало, поэтому мы так и не пересмотрели ситуацию, а были вынуждены иметь дело с Y и со всеми его недостатками.
Я набросал X, но обстоятельства (обычно связанные с портированием на LLVM) потребовали работать более в духе Y. Мы временно остановились на Y, но у нас вновь кончилось время или доступные варианты для пересмотра, поэтому мы решили и далее обходиться Y.
Я набросал X и был в этом настолько неправ, что мы просто снесли этот вариант, чтобы не приучать людей к плохому. Образовался вакуум, время шло, в языке ничего официально не предлагалось для его заполнения, поэтому постепенно экосистема сама заполнила эту лакуну (возможно, множеством конкурирующих вариантов).
Вот всего несколько аспектов, которых я либо прямо не хотел допускать, и/или мне не нравится, как они оказались реализованы в Rust:
Межконтейнерное встраивание и мономорфизация. Я хотел, чтобы внутри контейнеров было разрешено встраивание, но на поверхности контейнеров имелись стабильные входные точки. В Swift до этого почти дошли, с технической точки зрения это огромная головная боль, но именно нереализованность этой фичи в Rust во многом объясняет, почему он так ужасно долго компилируется, и почему в нём отсутствует стабильный ABI. Я в своё время этому сопротивлялся и с тех самых пор противлюсь этому варианту. Вероятно, в современном Rust необходимость этой фичи уже назрела, учитывая следующий пункт.
Контейнеры, определяемые в библиотеках, итерация и умные указатели. Контейнеры, итерация и операции непрямого доступа (наряду с арифметическими) — вот из чего состоят внутренние циклы большинства программ. Поэтому оптимизировать производительность этих элементов чрезвычайно важно. Если настроить их все на медленную диспетчеризацию в пользовательском коде, то быстрого языка у вас никогда не получится. Если пользовательский код вообще вовлечён в работу, то его приходится агрессивно встраивать. Я выступал за другой вариант, а именно, чтобы применялись компиляторные вставки, запрограммированные в открытом виде там, где их будут использовать. Так можно было бы не брать этот код из библиотек. Вообще никакого «пользовательского кода» (ни даже stdlib). Именно так изначально обстояли дела в Rust: все операции vec и str выделялись кодом, специально предусмотренным в компиляторе для этих случаев. Здесь бушуют жаркие споры о том, как это сказывается на сложности и выразительности языка, и здесь пришлось бы слишком много оспаривать, но… эту разработку я потерял и по-прежнему в основном не согласен с тем, что мы имеем в итоге.
Внешняя итерация. Обычно итерация выполняется на уровне стека/изолирующих корутин, и такая итерация также называется «внутренней» в противовес «внешней», реализуемой через указателеподобные сущности, заключённые в продвигаемых вами переменных. Такие корутины сейчас наконец-то поддерживаются в LLVM (так было не всегда) и представляют собой достаточно старый и надёжный механизм по созданию итерационных абстракций. Данная абстракция располагает к связыванию ссылками и не требует встраивать целые простыни библиотечного кода. Для этого существуют такие инструменты как BLISS и Modula-2 и им подобные. Вообще очень нужные штуки в арсенале, и в молодом Rust такие вещи были, но по множеству причин от них было решено отказаться. Причины — во многом из разряда «мы спорили, и я проиграл». Не скажу, что сегодня я активно против нынешнего состояния дел, но я хотел бы, чтобы таким инструментам в Rust нашлось место. Может быть, когда-нибудь это случится!
Async/await. Я хотел, чтобы в языке была стандартная среда выполнения с зелёными потоками и наращиваемыми стеками; в сущности, речь просто «о корутинах, которые могут при необходимости сработать как изолирующие». Пожалуй, должна допускаться возможность как-нибудь встраивать внешний цикл событий/библиотеку для управления вводом-выводом, но такие вещи всегда были немного замысловатыми. Go с этого начал, но на этом и остановился, но там пришлось пойти на кучу жёстких компромиссов, чтобы заставить FFI работать. В таком случае значительная доля производительности не добирается, и при этом торпедируется масса возможностей, связанных со встраиванием. В Rust всё тоже началось именно с таких позиций, механизм неоднократно переписывался, и, в конце концов, от него было решено отказаться по сумме причин. Тем не менее, потребность в async/await так полностью исключена и не была (что подтверждается регенерацией Async/Await и Tokio). Я смягчил мою позицию по этому вопросу и, скрепя сердце, воздаю должное тому, чего Rust добился в этом отношении (особенно во внедрении гетерогенных выборок, которые, на мой взгляд, относятся по выразительности к тому же престижному классу, что и Concurrent ML). Но, если честно, я никогда не согласился бы развивать язык в этом направлении, «будь я BDFL». Я и представить не мог, что такой подход заработает, и по-прежнему не могу сказать, вполне ли оправдывает себя достигнутый результат.
& как сущность первого класса. Я хотел отрядить & во «второй класс», сделать из него режим передачи параметров, а не тип первого класса, и по-прежнему считаю, что именно во втором классе ему самое место. Иными словами, не думаю, что у вас должна быть возможность возвращать & от функции или вставлять в структуру. Думаю, голову этим грузит сильно, и достигаемый выигрыш того не стоит. Особенно с учётом того, что описано в следующем пункте.
Явные времена жизни. &-типы как сущности второго класса в раннем Rust анализировались псевдонимные отношения, призванные обеспечить конфигурации один пишущий/много читающих (сегодня в таком качестве выступают заимствования). Но этот анализ был основан на непересекаемости типов и путей и (при необходимости) устранении двусмысленности при сравнении адресов (предоставляется пользователем). В данном случае ничего не говорилось ни о совместимости времён жизни, ни о представлении времён жизни как переменных. Я выступал против этой фичи и по-прежнему считаю, что она себя не оправдывает. Предполагалось, что все эти вещи должны логически выводиться, а сейчас это не так, и, «если бы я был BDFL», то, вероятно, я бы остановил этот эксперимент, как только убедился бы, что, фактически, никакого логического вывода не происходит. (Обратите внимание, как этот пункт связан с предыдущими: в наиболее частотных практических ситуациях приходится иметь дело с такими вещами, как внешние итераторы и контейнеры, предоставляемые из библиотек).
Захват окружения. Часто я говорю (в порядке провокации), что «ненавижу лямбды», но «лямбдами» называют (как минимум) две отдельные языковые возможности. Во-первых, так называют нотацию для литералов анонимных функций. Во-вторых — снабжение таких литералов анонимных функций захватом окружения. Ни в первом, ни во втором смысле меня не смущает работать с таким синтаксисом литералов, но мне категорически не нравится присовокуплять сюда захват окружения. Думаю, захват окружения — это ошибка, особенно в системном языке с наблюдаемыми изменениями. В раннем Rust были так называемые «слепые выражения», которые я скопировал из Sather и которые, как мне кажется, лучше (корявее, но лучше). Rust приобрёл «лямбды с захватом окружения» в виде одного пакета, и я не слишком против этого возражаю — например, как против попыток заставить внутреннюю итерацию работать на LLVM без корутин. Кстати, впоследствии это возражение стало неактуальным, когда мы перешли к внешней итерации. Но, «будь я BDFL», я, вероятно, откатил бы ситуацию с захватом окружения (мне легче потерпеть неизолирующие замыкания, которые во множестве встречаются, например, в неизолирующих корутинах/теле итераторов стека, но… эх, это сложная тема).
Типажи. Вообще мне не нравятся типажи/классы типов. На мой взгляд, они слишком заточены на работу с типами, слишком глобальные и слишком тяготеют к программированию и отладке в распределённом множестве невидимых правил вывода. В результате возникает слишком сильная связь между библиотеками, касающаяся того, что разрешено и что не разрешено делать для поддержания согласованности. Я хотел сделать систему модулей как сущностей первого класса, в той же традиции, что и в ML (и даже приступил к её созданию). Многие коллеги по команде мне в этом возражали, так как такие системы получаются более многословными, иногда до боли, и этот спор я проиграл. Но результат меня по-прежнему не устраивает, и, пожалуй, «будь я BDFL», я бы отказался от экспериментов.
Неполноценные экзистенциальные типы. Механизм динамически диспетчеризуемых типажей (dyn Trait) в Rust допускает полиморфизм во время исполнения и гетерогенный полиморфизм. Всё это делается при помощи экзистенциальных типов. Эти типы полезны во многих отношениях: и чтобы разобраться в случаях с гетерогенным представлениями, и для выборочного отступления от мономорфизации или встраивания (при наличии таковых), чтобы поправить ситуацию с размером кода / временем компиляции, либо обеспечить динамическое связывание или расширение во время исполнения. В молодом Rust мы пытались активно пользоваться этими возможностями (см. также «модули первого класса»). Там даже был промежуточно-жёсткий тип под названием obj, всегда остававшийся своеобразным Cecil-подобным расширяемым во время исполнения экзистенциальным типом. Он склеивался с self-записью, и такая конструкция позволяла прямо во время исполнения переопределять метод за методом (почти как в системе прототип-ОО). Сегодня в Rust категорически не одобряется любая подобная динамическая диспетчеризация; цикл обратной связи, возникающий из-за объективных технических ограничений, сопутствующих как самим таким типам, так и работе с библиотечной экосистемой предопределяют отказ когда-либо задействовать такие типы.
Сложный логический вывод. Давным-давно сайт Rust представлял собой просто список фич, и там было две записи, гласивших: «Вывод типов: да, только для локальных переменных» и «Обобщённые типы: да, только простая подстановка, без полноты по Тьюрингу». Первое утверждение должно было сопровождаться оговоркой «на данный момент», но, очевидно, что у каждого утверждения есть свой срок. Мне не нравится «строить из типов тетрис» и предпочёл бы, чтобы по этой позиции мы отошли назад. Но при подстановке потребовалось бы, как минимум, поддерживать наложение границ на модульно-типизируемые параметры. Думаю, здесь также есть пространство для проектирования, и можно избавить пользователя от продумывания унификации или хотя бы рекурсии в пользовательских типах. Как бы то ни было, найдётся много тех, кто размышляет «в контексте системы типов» и думает, что сложные типы — вещь полезная или хотя бы позволительная. Этот спор я тоже вчистую проиграл.
Номинальные типы. Есть в этом списке и ещё один элемент, в котором заявляется, что система типов «структурная». В целом я считаю, что структурные типы предпочтительны, и в раннем Rust мы ими активно пользовались. Есть причины, по которым в структурной системе нужно выделить несколько специальных номинальных мест (например, для моделирования состояния типов), но в целом я считаю, что Rust слишком далеко ушёл в лагерь номиналистов (отчасти — для поддержки типажей) и это обеднило имевшиеся в языке возможности компонуемости и полиморфизма.
Недостающие квазицитаты. Предполагалось, что в языке будут предоставляться своеобразные цитаты, и это вовремя сделано не было. Вся система статического метапрограммирования, в самом деле, была сдана в полуготовом виде.
Недостающая рефлексия. В духе модели, предложенной в статье «зеркало для Rust», в языке исходно были выпускаемые компилятором «дескрипторы типов» (и я надеюсь, что они будут возвращены), в которых пользователь мог вызывать оператор рефлексии. Если достаточно усовершенствовать выполнение во время компиляции, то такая операция могла бы обходиться дёшево. Таким образом мы могли бы выпускать код времени исполнения, и именно поэтому такую операцию пришлось исключить. Слишком много времени тратилось на генерацию склеивающего кода в LLVM, притом, что большую часть времени никто этим кодом не пользовался. Но вряд ли это оправдывало удаление такой фичи с концами!
Недостающие ошибки. Система обработки ошибок, сданная в версии 1.0, в сущности, представляла собой пустоту на месте пары более ранних забракованных попыток. Заполнить её удалось лишь позже (в версии 2018), скопировав оператор ? из Swift. Правда, копия всё равно получилась половинчатой: синтаксис `throws`, выделенный ABI для переноса ошибок и не обязательно выделяемый экзистенциальный тип Error из Swift также помогли бы дополнить эту модель с «явным объединением ошибок». И, опять же, «будь я BDFL», я бы, возможно, вообще не захотел двигаться в этом направлении, так как несколько иначе воспринимал работу с ошибками. Как бы то ни было, мы пришли к результату, которого я явно никогда не желал, и я не знаю, куда бы мы двинулись от такого результата.
Недостающий auto-bignum. Ещё одна штука, которую хорошо было бы иметь в открытом компилятору коде — это целочисленный тип, перетекающий в находящийся во владении или подсчитываемый по ссылкам длинный арифметический тип (bignum). Разработать эту фичу до состояния эффективной реализации в библиотеках крайне трудно (даже если вы дойдёте до стабильного встраиваемого ассемблерного кода, он всё равно не будет работать так быстро, как в компиляторе) и ... в Rust решили просто без него обойтись. Я хотел сделать такой тип, но проиграл и в этом споре. Целые числа при перетекании либо прерываются, либо переходят на новую строку. Отлично. Может быть, ещё через десять лет удастся общими силами решить, что ошибки такого класса также достаточно важны, и их стоит отлавливать? (В Swift, как минимум, по умолчанию предусмотрено прерывание при высвобождении; хотел бы я, чтобы и Rust пошёл по этому пути).
Отсутствие десятичных чисел с плавающей точкой. Это уже мелочь, но, в принципе, любому языку приходится проделать долгий путь к пониманию специфики финансовой математики. Рано или поздно в этот язык добавляется десятичный тип. Я бы хотел, чтобы в Rust это было сделано заранее, но эту возможность вечно откладывали, полагаясь на библиотеки. Несколько таких библиотек есть, но я предпочёл бы, чтобы такой тип был встроенным (чтобы можно было пользоваться литералами, обеспечить интероперабельность и так далее).
Сложная грамматика. Я снискал несколько дурную славу, желая удержать язык на уровне LL(1), но тот факт, что на сегодняшний день не существует лёгкого способа распарсить Rust, не говоря уж о том, чтобы его структурно распечатать (то есть, автоматически отформатировать), и это конкретный (и достаточно частый) источник проблем. В Rust это делается легче, чем в C++, но это слабое утешение. Почти во всех спорах на эту тему меня одолели, от угловых скобок для параметров типов до неоднозначности при связывании с шаблонами, правил расстановки точек с запятой и скобок до… ох, лучше не спрашивайте. Грамматика не такая, как мне бы хотелось. Извините.
Хвостовые вызовы. На самом деле, я их очень хотел! Считаю, что они просто отличные. И я ввязался в споры о том, почему их не должно быть, поскольку при обсуждении проекта в целом постулировалась стратегия «конкурировать с C++ и превзойти этот язык в производительности». В итоге я написал грустный пост об отказе от них, и это был один из самых печальных постов, которые есть на эту тему. При нынешних приоритетах в развитии Rust я сомневаюсь, что хвостовые вызовы вообще получится реализовывать на межконтейнерном уровне (может быть, внутри контейнеров), но, ИМХО, в сочетании с итераторами стеков они превращаются в отличный примитив, который стоило бы иметь в языке. В данном случае он пригодился бы для написания простых и поддающихся компоновке конечных автоматов и, «будь я BDFL», я, вероятно, направил бы развитие языка в направлении, которое позволило бы их сохранить. В раннем Rust они были, из-за работы с LLVM от них пришлось в основном отказаться, а из-за одержимости идеей «добиться производительности лучше, чем в C++» они застыли в статусе WONTFIX.
Темы
Может быть, вы согласились с одним-двумя из вышеизложенных пунктов и сочли, что это неплохие идеи, а также подумали: «а что, может в Rust и стоило бы назначить BDFL!» — но гораздо вероятнее, что они показались вам ужасными. Но суть этого поста заключалась не в том, чтобы высказать ворох предложений по поводу текущего развития проекта, либо поныть, либо испортить себе репутацию на публике.
Суть была в том, чтобы указать расхождение по темам. Те приоритеты, которых я придерживался в ходе работы над языком, в широком смысле не те, которые избрало для себя сообщество, которое с тех пор годами занимается дальнейшим развитием языка. Я бы пожертвовал производительностью и выразительностью ради простоты. Как ради снижения когнитивной нагрузки для конечного пользователя, так и для упрощения реализации на уровне компилятора. Делая так, я направил бы развитие языка в сторону, фактически противоположную той, куда хотят направить развитие языка большинство работающих с ним людей.
Производительность: многие представители сообщества Rust считают, что важнейший обещанный плюс этого языка — «бесплатные абстракции». Я никогда за это не топил и до сих пор не считаю такие абстракции большим плюсом. Это идея из C++, и она, на мой взгляд, неоправданно ограничивает пространство решений при проектировании. Думаю, большинство абстракций чего-то стоят и сопряжены с компромиссами, и я не раз и не два согласился бы на постоянное мелкое урезание производительности ради получения более простых и надёжных версий многих абстракций. Тогда у нас получился бы более медленный язык. Он бы остался в нише «компилируемых языков программирования с надёжными паттернами доступа к памяти», но, пожалуй, был бы одним из лучших в пелотоне, где находятся Ada и Pascal.
Выразительность: аналогично, я в основном пожертвовал бы большей частью выразительности, и из-за этого большинство современных Rust-программистов рассуждали бы о языке в таком тоне, в каком сегодня выражаются его критики. Язык казался бы неуклюжим, забюрократизированным. Говорили бы, что с ним приходится нянчиться, многие фичи вообще не допускается реализовывать в библиотечном коде, а программисту не доверяют даже при работе с простыми конструкциями, например, с затенением типов, захватом окружения или встраиваемыми функциями. Поспрашивайте людей, каково им было работать над компилятором на ранней стадии проекта. В этом не было ни интереса, ни лёгкости, а я упрямо не позволял ни добавлять когнитивную нагрузку, ни усложнять реализацию, пусть это и упростило язык на какие-нибудь проценты, добавило бы ему интересности. Я не собирался вынуждать пользователей раз за разом писать одно и то же, либо целиком выписывать объявления, либо писать, например, множество отдельных инструкций с разными переменными вместо одного сложного вложенного выражения.
Действительно, развитие ветвится, и многое зависит от вкусов. У меня странные вкусы, вам они, вероятно, не понравились бы. Посмотрите, какие цели проектирования ставились в ранних версиях мануала или даже более поздних. Если бы я остался во главе (или даже, в более резком смысле, настаивал, что хочу остаться), результаты моей работы, пожалуй, получились бы очень непопулярными. Пожалуй, у «Rust моей мечты» не было будущего, или оно и близко не получилось бы таким светлым, как у «Rust, который имеем сейчас». Тот факт, что у языка вообще нашёлся путь развития, позволивший ему выйти на наблюдаемый сегодня уровень, кажется мне чудом. Не сглазьте Rust и поверьте, что лучше я бы точно не справился.