Про Rust

May 31, 2016 16:59

Это своего рода summary моего текущего опыта работы с новым интересным языком Rust. Давно собирался написать на эту тему, но вот подвернулся удобный случай - avva просил прокомментировать опыт использования этого языка.

Мой бекграунд: много лет программирования под JVM (Java, Groovy, Kotlin, чуть-чуть Scala), само собой JS, кое-что на Objective-C, лоу-левел C+OpenGL, игрушечные проекты на OCaml. Интересуюсь ЯП.


Раст мне интересен в трех категориях:
1) как современный general-purpose язык
2) как системный язык
3) как прагматичный частично-функциональный язык

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

1) Графическая программка под Windows, сидящая в трее, которая получает сообщения от сервера и показывает их с определенной логикой (плюс логин скрин, трей-меню итд). Графическая часть была выполнена на QT/QML, сетевая, матлитрединг и логика - на Rust. В качестве клея сначала хотел использовать библиотеку qmlrs, но она оказалась недоделанной, и перешел на custom FFI. Это был мой первый проект на Rust, и дался он с большим трудом, абсолютно несоразмерным задаче. Я старался писать идиоматично, в том числе multithreaded код. Использовал два, кажется, scoped threads (login, poller), плюс гуи, а коммуникацию между ними реализовал через каналы раста. Поскольку программка маленькая, я написал большую часть кода, и только тогда начал ее компилировать. Это привело к изнурительной битве с компилятором, который крайне неохотно соглашался признавать мой код безопасным. В энном количестве случаев, конечно, код был на самом деле опасным, а я этого просто не видел. Так или иначе, пришлось изрядно помучиться, прежде чем отсеялись non-essential проблемы и осталась одна essential. У меня было два объекта, которые хотели ссылаться друг на друга, а в безопасном Расте, как оказалось, это сделать попросту невозможно. Пришлось в этом месте вставить unsafe. Но прежде чем оно заработало, очень долго возился - сначала с идентификацией проблемы, затем с поиском безопасного решения через unsafe.

Что касается интеграции с C++ (через С), она прошла довольно гладко. Проблемы были
а) когда мы хотим передать объект, живущий на хипе, в С++, для хранения его там. Выяснилось (surprise!), что нельзя его просто так взять и освободить после использования из C++, а надо передавать обратно в Раст. В итоге пришел к тому, что все такие объекты просто аллокирую заново в С++, а не пытаюсь сохранять поинтер на растовский объект.
б) с перекодированием строк из UTF-8 (Раст работает со строками в этой кодировке) в UTF-16

Что касается сетевой части, то из-за использования каналов коммуникации вышла очень странная для меня архитектура (на Джаве я всегда писал используя примитивные конструкции синхронизации), которая выглядит громоздкой. Возможно, я выбрал over-engineered подход - простейшее решение бы раз в X секунд запускало функцию login() или poll(), а у меня LoginWorker - это агент, который спит в своем треде в ожидании команды TICK по входящему каналу, по этой команде просыпается, запускает сетевую операцию, по получении ответа посылает результат на выходящий канал. Ну и остальная машинерия в том же духе (NetworkManager работает через каналы с LoginWorker и PollWorker и exposes собственные каналы для основной программы). Но это вопросы ко мне, а не к языку. С Растом здесь все было классно.

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

В отношении memory safety Раст себя оправдал: скомпилированная программа работала без проблем (креши и memory corruption были только в районе FFI, где я ошибался внутри unsafe блоков).

2) Консольная утилита, которая логинится на сервер, запускает remote service, через N секунд/минут получает (асинхронно) ответ в виде json, парсит json, передает данные в шелл скрипт. Здесь я реюзал тот же сетевой механизм, что в первой программе. Для json использовал библиотеку, которая маппила его на стракты Раста. В процессе выяснилось, что в библиотеке забыли сделать опцию, чтобы она игнорировала по дефолту лишние поля, не существующие в стракте, что добавило мне седых волос (недавно эта опция таки появилась). Больше ничего интересного не помню, вроде, все было гладко. Тоже пришлось побороться с компилятором, но уже не так тяжело, как в первом проекте.

3) Паззл-солвер и генератор для кроссвордов. Генератор читает шаблон из файла, заполняет его буквами, записывает задачу в файл. Солвер читает задачу из файла, ищет решение используя алгоритм NRPA (на основе Монте-Карло), выдает результат (заполненный кроссворд) на экран. Вкратце: задача состоит в том, чтобы расставить максимальное число слов из заданного словаря на поле заданной формы.

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

В этом проекте я уже испытывал гораздо меньше дискомфорта от общения с компилятором. Тоже были WTF moments, но в целом кажется, что я приобрел интуицию:
а) которая с одной стороны позволяет обходить подводные камни, которые можно обойти, а с другой сразу юзать unsafe когда их обойти нельзя
б) где в стандартной библиотеке искать тот или иной фичер и как его использовать (поначалу у меня были проблемы с использованием collections API, что я считаю самым большим фейлом растовской стандартной библиотеки)

Впрочем, тот факт, что было легче по сравнению с первым проектом, объясняется еще тем, что здесь у меня не было ни global state (в отличие от gui-driven программ), ни малтитрединга с shared state.

Выработав интуицию, я стал более четко понимать ограничения, которые его модель безопасности памяти накладывает на программиста. Вот неполный список:
а) запрещены циклические указатели
б) невозможно привязать лайфтайм указателя, содержащегося в стракте, к лайфтайму куска памяти, на который этот указатель ссылается, и который сидит в том же стракте (в принципе это безопасно - н только до тех пор, пока стракт не перемещают в памяти в другое место; вот из-за этого "пока" это и не реализуемо в безопасном Расте)
в) если вы хотите передать ownership объекта, вы передаете этот объект by value. Не важно какого размера объект, он передастся именно by value и никак иначе (кроме случаев, когда LLVM удастся это оптимизировать). Поскольку передача ownership занимает в Расте важное место, у меня постоянно выходит передача объектов by value, и это сказывается на производительности, если речь идет о больших страктах. В принципе, думая о том, как это работает в C, я понимаю, что примерно так же. Поэтому этот мой комментарий скорее сравнивает происходящее в Расте с Джавой, например. Там мы всегда передаем объект, живущий на хипе, by reference, и это не проблема (потому что аллокация работает гораздо быстрее). Какие есть варианты? Разные, но у всех свои недостатки. Во-первых, можно хранить объект (или его подобъект) в Box - это указатель на T, владеющий выделенной под T памятью на хипе. Передавая Box by value, мы передаем указатель. Во-вторых, можно (если задача позволяет) в многоуровневом стракте часть внутренних хранить где-то в другом месте, а в большом стракте содержать референсы. Это накладывает ограничения на использование и удлиняет код (придется параметризовать стракт лайфтаймом, etc). В-третьих, можно хранить T в массиве, а в стракт записать индекс (я к этому вернусь ниже).

Возвращаясь к проекту... В первой части у меня, вроде бы, вышло вполне естественное выражение алгоритма в Расте. То есть гуру Раста наверняка могли бы написать лучше, но я своим кодом остался доволен. Во второй я занялся очень агрессивной оптимизацией, чтобы увидеть, насколько далеко смогу зайти и с какими проблемами столкнусь. По мере оптимизации код закономерно становился все менее красивым, и под конец я получил в некоторых местах си-подобную низкоуровневую бурду. Здесь отмечу, что во многих случаях код мог бы выглядеть менее кошмарным, если бы Раст и его стандартная библиотека захотели предоставить более читаемый синтаксис, например, для доступа к массивам без bounds checking. Для сравнения:
- let x = &mut arr[i];
- let x = unsafe { arr.get_unchecked_mut(i) };

Познакомился с доступными инструментами для работа на низком уровне. Поскольку я работаю под Windows, выбор довольно ограничен и часто встречаются баги. Так, в Rust 1.10 nightly под windows/mingw 64 вместо символов в колл стеке (который печатается в случае паники) я вижу Unknown. В предыдущих версиях у меня это работало.
а) Дебаггер в эклипсе (интегрированный через RustDT) работает через пень-колоду (подвисает на некоторых инструкциях, приходится обходить их используя брейкпоинты). В чистом gdb я дебаггить не умею и ленюсь. Возможно, существует еще какой-нибудь ГУИ к gdb, но я о нем не знаю.
б) По крайней мере, мне удалось найти довольно адекватный профайлер: CodeXL (на самом деле, в нем написано, что он и дебаггер, но я поленился смотреть). Можно скомпилировать Rust в release mode, но названия функций все равно будут в коде (с множеством лишних буковок, но будут!), что, кстати, видимо, баг в LLVM или в rustc. В том же инструменте можно открывать и читать дизассемблированный код. В части случаев профайлер ошибается - показывает вызовы функций не там, где они на самом деле происходят, а выше или ниже (скажем, А вызывает Б и В, Б вызывает Г, так вот в каких-то случаях профайлер покажет, что Г вызывается из А). В дизассемблированном коде не хватает части функций (код есть, но функции не обозначены), а часть начинается или кончается не на том адресе (это может объяснять вышеописанную проблему). Кроме того, инструкции call не аннотированы: вы видите только адрес, без названия фции, приходится искать фцию по адресу в дроп-дауне. Но зато тул практически не замедляет программу и не искажает статистику, и я в конечном счете смог из него (в сочетании с другими инструментами) получить всю необходимую информацию.
в) Можно сказать Расту распечатать код asm, llvm или (в 1.10) mir. В коде asm можно найти корректную разметку фций (когда у CodeXL проблемы) и нормальные аннотации call.

---------------

Теперь по категориям:

1) Как general-purpose язык он хороший и доставляет удовольствие. Если набить руку и освоиться в среде (найти нужные библиотеки, освоить паттерны), можно почти не воевать с компилятором. Но все-таки продуктивность труда будет ниже, чем в языке с GC. Насколько ниже - открытый вопрос. Высокий входной барьер.

2) Как системный он просто отличный.
а) Это язык, который открывает для меня мир низкоуровневого программирования. Можно написать программу на безопасном расте, и она не будет крешить! А потом уже осторожно оптимизировать critical path исходя из показаний профайлера. Как для кого, а для меня страх перед крешами и memory corruption всегда были основной помехой для программирования без GC.
б) Но необходимо отметить, что значительная часть стандартной библиотеки исходит из предположения, что ваши данные можно спокойно копировать из массива в массив, и в моем случае устранение этого было первым и главным направлением оптимизации. Оптимизируя такой код, вы как правило переходите с красивых АПИ к уродливым. В каких-то случаях, возможно, будет правильным вздохнуть и переписать фцию на С (но не на С++), т.к. опасный код на С может быть менее уродливым, чем на Расте.
в) В Расте очень любят "zero-cost abstractions". Например, есть два основных способа объявить фцию, принимающую референс на инстанс определенного интерфейса Shape (выражаясь терминологией Джавы):
fn process(shape: &S) { let x = s.x(); ... }
fn process(shape: &Shape) { let x = s.x(); ... }
В чем разница? В первом случае вызов x() пройдет через static dispatch, а во втором - dynamic dispatch. Иногда мне кажется, что they are taking it too far, но пока не определился. Показательный (и самый уродливый) случай - return types фций filter(), map() итд в стандартной библиотеке. Вместо того, чтобы возвращать какой-нибудь Box, каждая из них возвращает конкретный стракт. Новичка, незнакомого с квирками языка, это очень путает. А все потому, что в языке нет возможности вернуть абстрактную имплементацию трейта, но чтобы компилятор при этом диспатчил ее статически. Эту дырку в языке собираются залатать, но стандартная библиотека на этот поезд уже не успела и останется перегруженной многочисленными std::iter::SkipWhile. Эта и некоторые другие проблемы (возможно, Higher-Kinded Types) заставляют думать о "Расте 2.0".

3) Как прагматичный частично-функциональный язык он в основном окей.
а) Есть разделение на mutable/immutable на уровне синтаксиса. Я считаю, что это good thing.
б) Есть higher-order functions. Есть closures. Стандартная библиотека включает обычный арсенал fold/map/filter/etc.
в) Но closures в нетривиальных случаях требуют нетривиальных финтов ушами из-за того, что ассоциированные переменные живут на стеке, а требований к безопасности памяти в их отношении никто не отменял. В целом я пока что пришел к тому, чтобы обходить нетривиальные клоужуры копированием/хранением на хипе/циклами. Языки с GC здесь гораздо эргономнее.
г) Есть type classes. Это для меня было неожиданно, учитывая, что в прагматичных языках их обычно не бывает. Причем на первый взгляд type classes выглядят как ООП. Вся разница в том, что вместо монолитного класса, который бы имплементировал N интерфейсов, для каждого интерфейса вы пишете собственную имплементацию (impl MyTrait for MyStruct { ... }). Это мне трудно комментировать, т.к. раньше с тайп классами работать не приходилось. После ООП видеть такую длинную череду маленьких impl'ов в одном файле довольно странно, ломает шаблон. В стандартной библиотеке Раста есть файлы на тысячи строк, заполненные такими имплами, и это мне уже однозначно кажется нечитаемым.
д) Стандартная библиотека не имеет перекоса в сторону "все иммьютабл, и точка!", а по возможности включает поддержку мьютабл паттернов (есть всякие iter_mut(), get_mut(), итд). При необходимости можно использовать mutability на полную катушку. Я предпочитаю сначала писать идиоматичный код, а потом при необходимости его оптимизировать с использованием mutability.

Разное:
1) Понравился Cargo, менеджер пакетов и билд-система Раста. Поставляется вместе с языком. Это нечто вроде джавовского gradle, но декларативное описание пакета (он называется crate) на TOML находится отдельно от скриптовой части. В принципе мне скрипты понадобились только в одном случае (в первом проекте для запуска билда С). В качестве языка скриптинга используется тот же Раст. В целом Cargo очень удобный и понятный, практически все проекты на GitHub зарегистрированы в нем, поэтому добавить в ваш билд библиотеку, найденную на GH, - дело одной минуты.
2) На GitHub поразительное количество библиотек на Rust! Ищущий да обрящет.
3) Есть очень активный канал IRC #rust на irc.mozilla.org (НЕ на FreeNode!), где я много-много раз получал ответы и подсказки.
4) Вся разработка ведется в открытую. Очень понравилось, что кто угодно может читать и комментировать RFCs к языку. Основные места общения разработчиков - GitHub и IRC.
5) Компилятор медленный и пока не поддерживает инкрементальную компиляцию (но есть надежда, что это скоро изменится).
6) Поддержка IDE пока слабая. Главная проблема - недостаток информации о типах из редактора. Есть утилита Racer, которая дает частичную информацию о типах, но во множестве случаев она сбоит, и приходится запускать компилятор. (Разработчики обещают заняться разработкой нормальной инфраструктуры для тулов после инкрементальной компиляции). Я использую плагин RustDT под Эклипс.
7) Не хватает выделения алиасов типов в уникальные типы. То есть когда я объявляю type PlacementId = usize, хотелось бы, чтобы компилятор в сообщениях об ошибках использовал это название и не давал конвертировать из одного в другое без использования "as"
8) В performance-oriented коде часто не хватает alloca().
9) Множество фичеров языка маркированы как unstable и их... запрещено использовать в стабильных билдах компилятора. В моем третьем проекте я использовал три таких фичера: test (дает black_box, это только для дебаггинга), repr_simd (на сегодня это единственный способ to enforce memory alignment), specialization (это просто новый фичер языка). В целом это известная проблема в инфраструктуре Раста, что после релиза 1.0 осталось много таких фичеров, и разработчики работают в этом направлении, по большей части, насколько я понимаю, они ее уже решили (в основном путем перемещения кода из стандартной библиотеки во внешние).
10) Полезный паттерн: когда компилятор не дает хранить референсы на содержимое массива, хранить индексы. Да-да, это вполне очевидное решение, но полезно о нем помнить. Надо понимать, что храня индекс на элемент вместо поинтера, мы фактически обходим защиту компилятора от того, что содержимое этого элемента изменилось, а мы-то об этом и не знаем. Но все-таки это не совсем тот же случай: в отличие от поинтера, индекс не устареет если передвинуть массив в памяти.
Previous post Next post
Up