Постмортем: Это довольно неловкая и слишком негативная статья.
За последние десять лет появилось не столь много новых языков программирования. Один из самых обсуждаемых — Rust. Исследования сообществ языков программирования показали, что растовцы самые счастливые, что мы тут тоже обсудим. Это язык без cборки мусора, с zero-runtime, типобезопасный и с мощными средствами абстракции. На нём даже написали операционную систему! Так в чём подвох? Почему весь мир не перешёл на Rust, несмотря на призывы его фанатов? Почему даже Go — язык с GC — часто опережает Rust по скорости? Далее я приведу свои наблюдения по этой теме.
Начнём с базового синтаксиса, чтобы прежде всего понять, как вообще выглядит код на Rust. Вот пример вывода строки:
fn main() { println!("Hello World!"); }
Почти идентично аналогичной программе на Си мы объявляем функцию main, и можно подумать, что мы вызываем функцию печати строки. На самом деле это не функция, а макрос, схожий по поведению с printf из Си, но разбирающий форматную строку во время компиляции. Уже это может показаться вам слишком сложным для базового примера, но именно он приведён на официальном сайте языка Rust.
На самом же деле этот пример даже хуже чем кажется, ведь c
println!
всё не так просто. Заглянув в исходники Rust,
вы увидите, что он определён
через макрос format_args_nl!
,
который в свою очередь имеет буквально следующее определение:
macro_rules! format_args_nl { ($fmt:expr) => {{ /* compiler built-in */ }}; ($fmt:expr, $($args:tt)*) => {{ /* compiler built-in */ }}; }
То есть на самом деле разбор форматной строки является не просто частью
подключаемой по умолчанию библиотеки, что можно было бы предположить в случае
println!
, использованного в примере вывода Hello World,
а и вовсе частью компилятора Rust. Сказки о невероятных удобствах и
возможностях макросов, якобы позволяющих перенести в компайлтайм
любое вычисление, рушатся уже на
этапе написания первой программы на этом удивительном языке!
Более того, с тем же успехом объяснить базовый
пример можно было так, что непосредственно в языке есть конструкция
вывода — и не городить огород с макросами.
Rust уникален не только в способе вывода строки. Приведём пример с использованием переменного и неизмянеямого значений, ведь именно так называются эти сущности в терминологии Rust:
fn main() { let mut y = 73; let x = 37; y = y + x; println!("{}", y); }
При этом невозможно занести сумму в x
,
ведь это неизменяемое значение. Почему не константа?
Константами в Rust именуется ещё одна, третья сущность,
обозначающая константы времени компиляции, те что в Си обычно определяются
через define
:
const c = 13; fn main() { println!("{}", c); }
Отметим, что до сих пор мы ни разу не увидели ни одного типа, а ведь язык
типобезопасен, что значит типы в нём есть. Они и правда есть, просто во всех
приведённых примерах их вывод производится неявно, автоматически.
Явное определение типа в Rust принято использовать, только если автоматическое
так или иначе не справляется — как следствие, необходимо знать,
понимать и помнить правила вывода типа, которые невероятно сложны,
в силу чего задача определения, может ли тип быть выведен и какой тип будет
выведен, также сложна. Так, пример выше не компилируется, нужно указать тип
c
через двоеточие после имени:
const c: i32 = 13;
В случае функций возвращаемый тип указывается через стрелочку:
fn sumtwo(mut a: i32, b: i32) -> i32 { a += b; return a; }
Подход к изменяемости данных в Rust формулируется так: всё, что не меняется,
должно быть объявлено как неизменяемое. Чтобы принудить программистов
соблюдать этот подход, введение переменных было затруднено дополнительным
словом mut
. Как следствие, термин "константная переменная"
стал неадекватен и пришлось изобретать ещё более изощрённое
"переменное значение". Сама природа подобных переменных или значений
при этом — ячейки оперативной памяти,
и работа программы заключается в их изменении. Судя по всему,
синтаксис просто сопротивляется работе программиста,
при этом всё равно возможно случайное введение лишних переменных.
Вернёмся к простеньким программам. Как говорил Алан Перлис, нет ни одной сколько-нибудь стоящей программы без циклов и условных конструкций. Вот пример, использующий и то, и то:
fn main() { let mut n = 1; while n < 101 { if n % 15 == 0 { println!("fizzbuzz"); } else if n % 3 == 0 { println!("fizz"); } else if n % 5 == 0 { println!("buzz"); } else { println!("{}", n); } n += 1; } }
Всё выглядит вполне привычно и понятно, но гораздо выше шанс столкнуться с иной записью цикла, вроде этой:
fn main() { for n in 1..101 { println!("{}", n); } }
Можно подумать, что 1..101
— это часть синтаксиса конструкции
for
, которая обозначает для n
стартовое значение 1
и недостигаемое конечное 101.
На самом деле это конструктор объекта итерируемого типа из стандартной
библиотеки. Вновь мы наблюдаем привязку языка к стандартной библиотеке,
которая в таком случае теряет своё значение как библиотека и становится
просто-напросто частью языка —
вновь рушатся иллюзии о нулевом рантайме.
Более того, такое поведение, разумеется, приводит к затруднению понимания и
генерации кода — вряд ли возможно объяснить начинающему программисту такую конструкцию
иначе чем как чистую магию.
Отметим, что в языке Rust многие конструкции являются выражениями. Так, на самом деле условные конструкции — это условные выражения, а потому их можно присваивать и передавать. Более того, сами блоки кода являются выражениями, а потому можно встретить такую запись:
fn main() { for n in 1..101 { println!("{}", if n % 2 == 0 { println!("Even"); n } else { -n }); } }
Сложность разбора подобных программ — из которых приведённая одна из самых простых! — оставлю без комментариев, замечу только, что знакомая программистам на Си тернарная операция тут мимикрирует под обычный условный оператор, чем провоцирует ещё более частое её использование.
Ещё одна особенность базового синтаксиса: объявления верхнего уровня в Rust могут идти в любом порядке, то есть мы можем использовать функцию, определённую позже в коде программы. Это требует многопроходности, а с учётом обильного использования макросов и статического анализа делает процесс компиляции неожиданно медленным: даже небольшие программы порой компилируются часами.
Наконец, нельзя не упомянуть волшебное слово unsafe, только благодаря которому Rust и работает. Внутри блока подобного типа отключаются все проверки кода, а также допускаются ассемблерные вставки и применение указателей. Обильным использованием таких блоков Rust связывается с реальным миром — будь то библиотеки языка Си или устройства компьютера, доступ к которым требует ассемблерной точности. Поэтому каждый раз, говоря о безопасных программах на Rust, знайте, что где-то в глубинах они держатся на unsafe-блоках, пусть даже сами их не используют — это делают их зависимости. Также unsafe-блоки часто используются, даже когда строгой необходимости в них нет, а так просто удобней. Итак, вместо того, чтобы сделать хороший язык программирования, разработчики Rust дают невероятно неудобную модель, из которой все в итоге сбегают в мир полного хаоса.
Во имя безопасности и принципов Rust делает программирование на себе
решительно невозможным.
Прежде всего он ставит своей задачей наказание за написание
недостойного (по мнению его создателей) кода, а не поощрение за написание
хорошего кода. Вспомните пример let mut
—
аналогичные проблемы возникают и в глубинах языка.
Теперь предлагаю в них погрузиться.
Начнём с владения, одной из ключевых концепций Rust. Напишем две программы, иллюстрирующие разницу в передаче примитивных и сложных (в смысле типов) параметров, начав с примитивных:
fn sumtwo(a: i32, b: i32) -> i32 { return a + b; } fn main() { let v = vec![1, 2, 3]; let res = sumtwo(v[0], v[1]); println!("{} + {} = {}", v[0], v[1], res); }
Эта программа выведет ожидаемые 1 + 2 = 3. А вот следующая программа внезапно не откомпилируется:
fn sumtop(v: Vec<i32>) -> i32 { return v[0] + v[1]; } fn main() { let v = vec![1, 2, 3]; let res = sumtop(v); println!("{} + {} = {}", v[0], v[1], res); }
Что произошло? В Rust параметры примитивных типов передаются копированием,
а все остальные перемещаются (move), при этом передающая их функция теряет
их из своего владения. Таким образом, передав v
в sumtop
, мы потеряли его из своего владения,
и дальнейшие обращения к v
вызовут ошибку.
Можно написать функцию, которая, как кажется, ничего не делает, но на самом деле лишает нас доступа к переменной:
fn drop(s: String) {} fn main() { let s = String::from("Hi guys!"); drop(s); }
По сути техника владения и перемещения была придумана,
чтобы не копировать сложные значения, для которых это может быть очень
трудоёмкой задачей. С помощью владения также устанавливается, когда будет
вызван деструктор значения — это происходит при выходе из
функции-владельца — в примере выше такой функцией будет
drop
. Иначе говоря, владение и перемещение позволяют динамически
определять области видимости переменных — тем самым практически полностью
уничтожая смысл блоков, то есть составных операторов.
Однако довольно часто мы хотим передать значение в функцию и продолжить его использовать. Первое решение в рамках Rust — скопировать это значение. Но тогда теряется вся идея владения и перемещения, поэтому были придуманы заимствования, ещё одна изощрённая техника:
fn sumtop(v: &Vec<i32>) -> i32 { return v[0] + v[1]; } fn main() { let v = vec![1, 2, 3]; let res = sumtop(&v); println!("{} + {} = {}", v[0], v[1], res); }
Заимствования чем-то напоминают указатели языка Си, только они намного слабее.
Как и для взятия адреса в Си, в Rust нужно специально
заимствовать значение символом &
. Но при выходе из функции
заимствование теряется, его нельзя никуда сохранить или даже вернуть. Например:
fn longest(a: &str, b: &str) -> &str { if a.len() > b.len() { a } else { b } } fn main() { let a = "Hi".to_string(); let b = "guys".to_string(); let res = longest(&a, &b); println!("Longest: {}", res); }
В Rust такая программа не скомпилируется, ведь для res не будет известно
его время жизни. Подождите, но за время жизни же отвечало владение? —
скажет разумный человек. Но в Rust на самом деле есть отдельная сущность
времени жизни, и ею можно управлять из программы. Мы можем переписать
longest
, и тогда компиляция произойдёт успешно:
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { if a.len() < b.len() { a } else { b } }
Здесь мы говорим компилятору, что все три значения — a
,
b
и результат — связаны временем жизни;
теперь компилятор сможет освободить эти значения только одновремено.
Более гибкое указание связки времени жизни, например такое, что результат
будет существовать, пока существует хотя бы один из двух параметров — невозможно.
По сути, время жизни — это хак, внутренняя кухня компилятора,
к которой программисту дали доступ
в тот момент, когда разработчики языка поняли, что писать на предложенной ими
модели невозможно.
Наконец, мы можем заимствовать одно и то же значение много раз — но только на чтение, неизменяемо. Или же лишь один раз, но на запись, изменяемо. Отметим, что можно заимствовать части массивов и строк — и тогда можно заимствовать несколько раз на изменение, лишь бы части не пересекались. Легко узнать в этих ограничениях задачу о читателях и писателях, и вновь видна помешанность разработчиков языка на параллелизме. В последовательных программах всё это не имеет смысла и только запутывает.
Но кроме столь запутанного и хрупкого механизма заимствований и владений Rust обладает ещё одним способом управлять значениями. Пускай его фанаты и маркетологи это отрицают, но в Rust есть и весьма распространён сборщик мусора — причём ладно бы он был один, но их даже несколько. Впрочем, давайте разбираться.
Мы можем написать такую программу, отдав строку во власть встроенного в стандартную библиотеку сборщика мусора методом подсчёта ссылок:
use std::rc::Rc; struct Person { name: Rc<String>; } impl Person { fn new(name: Rc<String>) -> Person { Person { name: name } } } fn main() { let name = Rc::new("Aleksey".to_string()); let person = Person::new(name.clone()); println!("{}", name); }
Особо стоит подчеркнуть, что сборщик мусора встроен в стандартную библиотеку, которая неотделима от самого языка, а потому заявления, что Rust — это якобы язык без сборки мусора, — лживы. С таким же успехом это можно сказать про язык D. Да, вы можете писать программы без сборки мусора, если очень захотите. Но, во-первых, в случае Rust это будет тяжко, ведь язык накладывает на вас многочисленные ограничения. Во-вторых, даже если ваша программа не будет его использовать — ваши зависимости будут, и почти наверняка, ведь использовать его очень легко. Наконец, сборщик мусора просто-напросто встроен в Rust, он есть в вашей программе независимо от того, используете вы его или нет.
Но это, конечно, не волнует любителя Rust. Любителя Rust волнует то, что приведённый мной сборщик мусора работает только для последовательных программ. Как мы помним, Rust не предназначен для последовательных программ, ведь это язык будущего. И гениальные разработчики этого языка встроили асинхронную версию сборщика мусора, с тем подвохом, что его содержимое должно быть неизменяемым. Итого в Rust уже даже не один, а два сборщика мусора!
Каноническим способом менять что-то, хранимое под руководством такого сборщика
мусора, является использование Mutex
. Так, строку из примера выше
мы могли бы представить как: Arc<Mutex<String>>
Тогда давайте определим ещё для мьютексной версии программы такую функцию, что бы показать особенности работы с этим типом:
fn clearname(&self) { let mut status = self.status.lock().unwrap(); status.clear(); status.push_string("Unknown"); }
Приведу цитату
из выступления с продемонстрированным кодом: «есть ещё unlock,
но он вам не нужен» — впрочем, про сообщество растовцев поговорим позже.
Можно заменить Mutex
на RwLock
,
тогда можно будет получать доступ к значению
по системе читатели-писатели.
Теперь зададимся вопросом: а что в Rust вместо указателей? Как построить,
скажем, собственный связный список? Ответом служит тип Box
,
его объект ведёт себя как содержимое, при инициализации создаёт копию
оборачиваемого объекта на куче. Вот пример определения связного списка:
enum List { Cons(i32, Box<List>), Nil }
Приведу цитату из того же выступления: «в Rust можно использовать указатели, а именно в unsafe-коде, но вы можете и должны этого избегать». Иначе говоря, в Rust самом по себе, как том самом безопасном языке, про который нам любят говорить, приведённый выше код — самый простой способ определить список.
Обсудив саму модель вычислений на языке Rust, мы пока не затрагивали макросы,
если не считать обсуждения println!
. Отметим, что макросы Rust
подобны таковым из Лиспа в том, что изменяют само синтаксическое дерево,
представляющее программу в компиляторе; при этом, в отличие от макросов
языка Лисп, макросы Rust пишутся на ином, отличном от базового языке —
поэтому для их написания надо изучить по сути ещё один язык вдобавок
к базовому языку Rust. Из того, что даже базовый println!
по сути
зашит в компилятор, читателю предлагается сделать вывод об удобстве и
возможностях этого макроязыка. Дальше рассматриваться макросы тут не будут.
Теперь оценим работу компилятора и попробуем ответить, почему же программы на Rust порой оказываются медленней аналогов на Go. Далее будут приведены варианты одной и той же программы на Rust с комментарием о примерном количестве получаемых строк ассемблера в скомпилированном машинном коде.
pub fn magic() { /* 4300 */ let v: Vec<i32> = vec![1, 2, 3] .into_iter().map(|x| x + 1).collect(); }
pub fn magic() { /* 1600 */ let mut v = vec![1, 2, 3]; for i in 0..v.len() { v[i] = v[i] + 1; } }
pub fn magic() { /* 1300 */ let mut v = vec![1, 2, 3]; let mut i = 0; while i != v.len() { v[i] = v[i] + 1; i = i + 1; } }
pub fn magic() { /* 440 */ let mut v = [1, 2, 3]; for i in 0..v.len() { v[i] = v[i] + 1; } }
pub fn magic() { /* 110 */ let mut v = [1, 2, 3]; let mut i = 0; while i != v.len() { v[i] = v[i] + 1; i = i + 1; } }
Теперь приведём аналогичный пример на языке Си:
void magic() { /* 20 */ int v[] = {1, 2, 3}; for (int i = 0; i != 3; ++i) { v[i]++; } }
Даже выполнение базовых вещей в Rust требует больших объёмов машинного кода. При этом при использовании макросов и прочих "продвинутых" техник, активно поощрямых сообществом, генерируемый код возрастает в разы и на порядки. Например, следующий код:
let xs = vec!["lorem", "ipsum", "dolor"]; for i in 0..xs.len() { if xs[i].ends_with('m') { return Some(xs[i]); } } return None;
исправили на выступлении с более чем 50 тысячами просмотров на ютюбе на это:
let xs = vec!["lorem", "ipsum", "dolor"]; xs.iter().filter(|item| item.ends_with('m')).nth(0)
Да, исходный код стал значительно меньше, вот только
получаемый машинный код на порядки вырос, да ещё и стал существенно медленней.
Предлагаемая тут сущность, передаваемая в filter
, и вовсе является
замыканием из мира функционального программирования и представляется в машине
весьма нетривиально; желающим предлагается посмотреть
получаемый машинный код.
Как видим, сообщество Rust поощряет своих разработчиков писать медленные и громоздкие программы, обильно использовать запутывающие абстракции, что они на самом деле вынуждены делать из-за карающей природы самого языка — писать на нём буквально больно. Этот язык пропитан ложью: он пытается быть низкоуровневым, несмотря на невероятно высокоуровневую природу; пока он претендует, что не включает в себя сборщики мусора, его сообщество активно их использует; он забывает о последовательных программах в погоне за параллельными, и в итоге весь код становится писать сложней; он заявляет о том, что даcт прекрасные макросы, а на деле подсовывает лишь неудобное подобие, при этом существенно замедляющее время компиляции всех программ. Это язык-монстр, но ещё страшней его сообщество.
Сообщество Rust — это мир фанатиков. Ровно как самые счастливые люди на Земле — религиозные фанатики, самые счастливые программисты — фанатики своего языка. При обсуждении Rust они будут без конца описывать, какой этот язык гениальный, и как в нём решены все проблемы; они будут говорить, что он идеален как для низкого, так и для высокого уровня; они будут утверждать, что их программы самые безопасные и правильные на свете. И в самом деле, на безопасности у них особый пунктик — вы можете услышать, как этот язык сделал их бесстрашными (fearless). А теперь зададимся вопросом: способен ли писать безопасный код бесстрашный человек? Фанатики Rust во всём полагаются на компилятор, ведь на самом деле не умеют и не любят программировать, им хочется, чтобы всё было сделано за них то ли компилятором, то ли чистой магией. Никто из них не понимает, как работает этот язык, да это и невозможно понять.
Несмотря ни на что, на Rust можно писать хорошо работающие программы. Но делать это будет крайне неприятно, а сами программы будут очень мало кому понятны, да их ещё и будет критиковать токсичное сообщество. Каким-то чудом в сообществе Rust умудряются выжить единицы адекватных программистов, и мне больно видеть, как их разъедает ржавчина. Не давайте себе и своему железу заржаветь!
Подводя итоги: не зря Mozilla сократила команду, работавшую над Rust.