Rust — это молодой и амбициозный язык для системного программирования. В нем реализовано автоматическое управление памятью без сборщика мусора и прочих накладных расходов времени исполнения. Кроме этого, в языке Rust используется семантика перемещения по умолчанию, имеются беспрецендентные правила обращения к изменяемым данным, а также учитываются времена жизни ссылок. Это позволяет ему гарантировать безопасность памяти и облегчает многопоточное программирование, ввиду отсутствия гонок данных.
Все это уже хорошо известно всем, кто хоть немного следит за развитием современных технологий программирования. Но что если вы не системный программист, да и многопоточного кода в ваших проектах не много, но вас все же привлекает производительность Rust'а. Получите ли вы какие-то дополнительные преимущества от его использования в прикладных задачах? Или все, что он вам даст дополнительно — это суровую борьбу с компилятором, который будет заставлять вас писать программу так, чтобы она неотступно следовала правилам языка по заимствованию и владению?
В данной статье собран десяток неочевидных и особо не рекламируемых преимуществ использования Rust, которые, я надеюсь, помогут вам определиться с выбором этого языка для ваших проектов.
1. Универсальность языка
Несмотря на то, что Rust позиционируется как язык для системного программирования, он подходит и для решения высокоуровневых прикладных задач. Вам не придется работать с сырыми указателями, если для вашей задачи это не нужно. В стандартной библиотеке языка уже реализовано большинство типов и функций, которые могут понадобиться в прикладной разработке. Также можно легко подключать внешние библиотеки и использовать их. Система типов и обобщенное программирование в Rust позволяют использовать абстракции достаточно высокого уровня, хотя прямая поддержка ООП в языке отсутствует.
Давайте рассмотрим несколько простых примеров использования Rust.
Пример совмещения двух итераторов в один итератор по парам элементов:
let zipper: Vec<_> = (1..).zip("foo".chars()).collect();
assert_eq!((1, 'f'), zipper[0]);
assert_eq!((2, 'o'), zipper[1]);
assert_eq!((3, 'o'), zipper[2]);
Примечание: вызов форматаname!(...)
— это вызов функционального макроса. Имена таких макросов в Rust всегда заканчиваются символом!
, чтобы их можно было отличить от имен функций и прочих идентификаторов. О преимуществах использования макросов еще будет сказано ниже.
Пример использования внешней библиотеки regex
для работы с регулярными выражениями:
extern crate regex;
use regex::Regex;
let re = Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap();
assert!(re.is_match("2018-12-06"));
Пример реализации типажа Add
для собственной структуры Point
, чтобы перегрузить оператор сложения:
use std::ops::Add;
struct Point {
x: i32,
y: i32,
}
impl Add for Point {
type Output = Point;
fn add(self, other: Point) -> Point {
Point { x: self.x + other.x, y: self.y + other.y }
}
}
let p1 = Point { x: 1, y: 0 };
let p2 = Point { x: 2, y: 3 };
let p3 = p1 + p2;
Пример использования обобщенного типа в структуре:
struct Point<T> {
x: T,
y: T,
}
let int_origin = Point { x: 0, y: 0 };
let float_origin = Point { x: 0.0, y: 0.0 };
На Rust вы можете писать эффективные системные утилиты, большие настольные приложения, микросервисы, веб-приложения (включая клиентскую часть, так как Rust можно скомпилировать в Wasm), мобильные приложения (хотя в этом направлении экосистема языка пока развита слабо). Такая универсальность может оказаться преимуществом для многопроектных команд, потому что она позволяет использовать одинаковые подходы и одни и те же модули во множестве разных проектов. Если вы привыкли к тому, что каждый инструмент предназначен для своей узкой области применения, то попробуйте посмотреть на Rust как на ящик с инструментами одинаковой надежности и удобства. Возможно, вам именно этого и не хватало.
2. Удобные инструменты сборки и управления зависимостями
Это явно не рекламируется, но многие замечают, что в Rust реализована одна из лучших на сегодняшний день система сборки и управления зависимостями. Если вы программировали на С или С++, и вопрос безболезненного использования внешних библиотек стоял для вас достаточно остро, то использование Rust с его инструментом сборки и менеджером зависимостей Cargo будет хорошим выбором для ваших новых проектов.
Кроме того, что Cargo будет за вас загружать зависимости и управлять их версиями, собирать и запускать ваши приложения, выполнять тесты и генерировать документацию, дополнительно он может быть расширен плагинами и для других полезных функций. Например, существуют расширения, позволяющие Cargo определять устаревшие зависимости вашего проекта, производить статический анализ исходного кода, собирать и редеплоить клиентские части веб-приложений и многое другое.
Конфигурационный файл Cargo использует для описания настроек проекта дружелюбный и минималистичный язык разметки toml. Вот пример типичного файла конфигурации Cargo.toml
:
[package]
name = "some_app"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]
[dependencies]
regex = "1.0"
chrono = "0.4"
[dev-dependencies]
rand = "*"
А ниже приведены три типичные команды использования Cargo:
$ cargo check
$ cargo test
$ cargo run
С их помощью будет произведена проверка исходного кода на ошибки компиляции, сборка проекта и запуск тестов, сборка и запуск программы на выполнение, соответственно.
3. Встроенные тесты
Модульные тесты в Rust писать настолько легко и просто, что хочется это делать снова и снова. :) Зачастую вам будет проще написать модульный тест, чем пытаться протестировать функциональность другим способом. Вот пример функций и тестов к ним:
pub fn is_false(a: bool) -> bool {
!a
}
pub fn add_two(a: i32) -> i32 {
a + 2
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn is_false_works() {
assert!(is_false(false));
assert!(!is_false(true));
}
#[test]
fn add_two_works() {
assert_eq!(1, add_two(-1));
assert_eq!(2, add_two(0));
assert_eq!(4, add_two(2));
}
}
Функции в модуле test
, помеченные атрибутом #[test]
, являются модульными тестами. Они будут выполняться параллельно при вызове команды cargo test
. Атрибут условной компиляции #[cfg(test)]
, которым помечен весь модуль с тестами, приведет к тому, что модуль будет компилироваться только при выполнении тестов, а в обычную сборку не попадет.
Очень удобно располагать тесты в том же модуле, что и тестируемый функционал, просто добавив в него подмодуль test
. А если вам нужны интеграционные тесты, то просто разместите ваши тесты в директории tests
в корне проекта, и используйте в них ваше приложение как внешний пакет. Отдельный модуль test
и директивы условной компиляции в этом случае добавлять не нужно.
Особого внимания заслуживают исполняемые как тесты примеры документации, но об этом будет сказано ниже.
Встроенные тесты производительности (бенчмарки) тоже имеются, но они пока не стабилизированы, поэтому доступны только в ночных сборках компилятора. В стабильном Rust для этого вида тестирования придется использовать внешние библиотеки.
4. Хорошая документация с актуальными примерами
Стандартная библиотека Rust очень хорошо документирована. Html-документация генерируется автоматически по исходному коду с markdown-описаниями в док-комментариях. Более того, док-комментарии в коде на Rust содержат примеры кода, которые исполняются во время запуска тестов. Этим гарантируется актуальность примеров:
/// Returns a byte slice of this `String`'s contents.
///
/// The inverse of this method is [`from_utf8`].
///
/// [`from_utf8`]: #method.from_utf8
///
/// # Examples
///
/// Basic usage:
///
/// ```
/// let s = String::from("hello");
///
/// assert_eq!(&[104, 101, 108, 108, 111], s.as_bytes());
/// ```
#[inline]
#[stable(feature = "rust1", since = "1.0.0")]
pub fn as_bytes(&self) -> &[u8] {
&self.vec
}
Здесь пример использования метода as_bytes
у типа String
let s = String::from("hello");
assert_eq!(&[104, 101, 108, 108, 111], s.as_bytes());
будет выполнен как тест во время запуска тестов.
Кроме этого, для Rust-библиотек распространена практика создания примеров их использования в виде небольших самостоятельных программ, расположенных в директории examples
в корне проекта. Эти примеры также являются важной частью документации и они также компилируются и выполняются во время прогона тестов, но их можно запускать и независимо от тестов.
5. Умное автовыведение типов
В программе на Rust можно явно не указывать тип выражения, если компилятор в состоянии его вывести автоматически, исходя из контекста использования. Причем это касается не только тех мест, где объявляются переменные. Давайте рассмотрим такой пример:
let mut vec = Vec::new();
let text = "Message";
vec.push(text);
Если мы расставим аннотации типов, то данный пример будет выглядеть так:
let mut vec: Vec<&str> = Vec::new();
let text: &str = "Message";
vec.push(text);
То есть мы имеем вектор строковых срезов и переменную типа строковый срез. Но в данном случае указывать типы совершенно излишне, так как компилятор их может вывести сам (пользуясь расширенной версией алгоритма Хиндли — Милнера). То, что vec
— это вектор, уже понятно по типу возвращаемого значения из Vec::new()
, но пока не понятно, какой будет тип его элементов. То, что тип text
— это строковый срез, понятно по тому, что ему присваивается литерал именно такого типа. Таким образом, после vec.push(text)
становится очевидным и тип элементов вектора. Обратите внимание, что полностью тип переменной vec
был определен ее использованием в потоке выполнения, а не на этапе инициализации.
Такая система автовыведения типов избавляет код от лишнего шума и делает его таким же лаконичным, как и код на каком-нибудь динамически типизированном языке программирования. И это при сохранении строгой статической типизации!
Конечно, мы не можем польностью избавиться от указания типов в статически типизированном языке. В программе должны быть точки, в которых типы объектов гарантированно известны, чтобы в других местах можно было эти типы выводить. Такими точками в Rust являются объявления пользовательских типов данных и сигнатуры функций, в которых нельзя не указывать используемые типы. Но в них можно вводить "метапеременные типов", при использовании обобщенного программирования.
6. Сопоставление с образцом в местах объявления переменных
Операция let
let p = Point::new();
на самом деле не ограничивается только объявлением новых переменных. То, что она делает на самом деле — это осуществляет сопоставление выражения справа от знака равенства с образцом слева. А новые переменные могут быть введены в составе образца (и только так). Взгляните на следующий пример, и вам станет понятнее:
let Point { x, y } = Point::new();
Здесь произведена деструктуризация: такое сопоставление введет переменные x
и y
, которые будут проинициализированы значением полей x
и y
объекта структуры Point
, который возвращается вызовом Point::new()
. При этом сопоставление корректное, так как типу выражения справа Point
соответствует образец типа Point
слева. Похожим образом можно взять, например, два первых элемента массива:
let [a, b, _] = [1, 2, 3];
И сделать много чего еще. Самое замечательное, что подобного рода сопоставления производятся во всех местах, где могут вводиться новые имена переменных в Rust, а именно: в операторах match
, let
, if let
, while let
, в заголовке цикла for
, в аргументах функций и замыканий. Вот пример элегантного использования сопоставления с образцом в цикле for
:
for (i, ch) in "foo".chars().enumerate() {
println!("Index: {}, char: {}", i, ch);
}
Метод enumerate
, вызванный у итератора, сконструирует новый итератор, который будет перебирать не исходные значения, а кортежи, пары "порядковый индекс, исходное значение". Каждый из этих кортежей при итерациях цикла будет сопоставляться с указанным образцом (i, ch)
, в результате чего переменная i
получит первое значение из кортежа — индекс, а переменная ch
— второе, то есть символ строки. Далее в теле цикла мы можем использовать эти переменные.
Другой популярный пример использования образца в цикле for
:
for _ in 0..5 {
// Тело выполняется 5 раз
}
Здесь мы просто игнорируем значение итератора, используя образец _
. Потому что номер итерации в теле цикла мы никак не используем. То же самое можно сделать, например, с аргументом функции:
fn foo(a: i32, _: bool) {
// Второй аргумент никогда не используется
}
Или при сопоставлении в операторе match
:
match p {
Point { x: 1, .. } => println!("Point with x == 1 detected"),
Point { y: 2, .. } => println!("Point with x != 1 and y == 2 detected"),
_ => (), // Ничего не делаем во всех остальных случаях
}
Сопоставление с образцом делает код весьма компактным и выразительным, а в операторе match
оно вообще незаменимо. Оператор match
— это оператор полного вариативного анализа, поэтому случайно забыть в нем проверить какое-то из возможных совпадений для анализируемого выражения у вас не получится.
7. Расширение синтаксиса и пользовательские DSL
Синтаксис языка Rust ограничен, во многом из-за сложности используемой в языке системы типов. Например, в Rust отсутствуют именованные аргументы функций и функции с переменным числом аргументов. Но можно обойти эти и другие ограничения с помощью макросов. В Rust существует два вида макросов: декларативные и процедурные. С декларативными макросами у вас никогда не будет таких же проблем, как с макросами в С, потому что они гигиеничны и работают не на уровне текстовой замены, а на уровне замены в абстрактном синтаксическом дереве. Макросы позволяют создавать абстракции на уровне синтаксиса языка. Например:
println!("Hello, {name}! Do you know about {}?", 42, name = "User");
Помимо того, что данный макрос расширияет синтаксические возможности вызова "функции" печати форматированной строки, он еще будет в своей реализации проверять соответствие входных аргументов указанной строке формата во время компиляции, а не во время выполнения. С помощью макросов вы можете вводить лаконичный синтаксис под ваши собственные проектные нужды, создавать и использовать DSL. Вот пример использования кода на JavaScript внутри Rust-программы, компилирующейся в Wasm:
let name = "Bob";
let result = js! {
var msg = "Hello from JS, " + @{name} + "!";
console.log(msg);
alert(msg);
return 2 + 2;
};
println!("2 + 2 = {:?}", result);
Макрос js!
определен в пакете stdweb
и он позволяет встраивать полноценный JavaScript-код в вашу программу (за исключением строк в одинарных кавычках и операторов, не завершенных точкой с запятой) и использовать в нем объекты из Rust-кода с помощью синтаксиса @{expr}
.
Макросы открывают огромные возможности по адаптации синтаксиса Rust-программ к специфическим задачам конкретной предметной области. Они сэкономят ваше время и внимание при разработке сложных приложений. Не за счет увеличения накладных расходов времени выполнения, но за счет увеличения времени компиляции. :)
8. Автогенерация зависимого кода
Процедурные derive-макросы в Rust широко используются для автоматической реализации типажей и прочей кодогенерации. Вот пример:
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
struct Point {
x: i32,
y: i32,
}
Так как все эти типажи (Copy
, Clone
, Debug
, Default
, PartialEq
и Eq
) из стандартной библиотеки реализованы для типа полей структуры i32
, то и для всей структуры в целом их реализация может быть выведена автоматически. Другой пример:
extern crate serde_derive;
extern crate serde_json;
use serde_derive::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct Point {
x: i32,
y: i32,
}
let point = Point { x: 1, y: 2 };
// Сериализация Point в JSON строку.
let serialized = serde_json::to_string(&point).unwrap();
assert_eq!("{\"x\":1,\"y\":2}", serialized);
// Десериализация JSON строки в Point.
let deserialized: Point = serde_json::from_str(&serialized).unwrap();
Здесь с помощью derive-макросов Serialize
и Deserialize
из библиотеки serde
для структуры Point
автоматически генерируются методы ее сериализации и десериализации. Дальше можно передавать экземпляр этой структуры в различные функции сериализации, например, преобразующие его в JSON строку.
Вы можете создавать собственные процедурные макросы, которые сгенерируют нужный вам код. Либо пользоваться множеством уже созданных макросов другими разработчиками. Помимо избавления программиста от написания шаблонного кода, у макросов есть еще то преимущество, что вам не нужно поддерживать в согласованном состоянии разные участки кода. Скажем, если в структуру Point
будет добавлено третье поле z
, то для обеспечения ее корректной сериализации в случае использования derive ничего делать больше не нужно. Если же мы будем сами реализовывать необходимые типажи для сериализации Point
, то нам придется следить за тем, чтобы эта реализация всегда была согласована с последними изменениями в структуре Point
.
9. Алгебраический тип данных
Алгебраический тип данных, говоря упрощенно — это составной тип данных, являющийся объединением структур. Более формально — это тип-сумма из типов-произведений. В Rust такой тип определяется с помощью ключевого слова enum
:
enum Message {
Quit,
ChangeColor(i32, i32, i32),
Move { x: i32, y: i32 },
Write(String),
}
Тип конкретного значения переменной типа Message
может быть только одним из перечисленных в Message
типов-структур. Это либо unit-подобная структура Quit
без полей, либо одна из кортежных структур ChangeColor
или Write
с безымянными полями, либо обычная структура Move
. Традиционный перечислимый тип может быть представлен как частный случай алгебраического типа данных:
enum Color {
Red,
Green,
Blue,
White,
Black,
Unknown,
}
Выяснить, какой действительно тип приняло значение в конкретном случае можно с помощью сопоставления с образцом:
let color: Color = get_color();
let text = match color {
Color::Red => "Red",
Color::Green => "Green",
Color::Blue => "Blue",
_ => "Other color",
};
println!("{}", text);
...
fn process_message(msg: Message) {
match msg {
Message::Quit => quit(),
Message::ChangeColor(r, g, b) => change_color(r, g, b),
Message::Move { x, y } => move_cursor(x, y),
Message::Write(s) => println!("{}", s),
};
}
В виде алгебраических типов данных в Rust реализованы такие важные типы, как Option
и Result
, которые используются для представления отсутствующего значения и корректного/ошибочного результата, соответственно. Вот как определяется Option
в стандартной библиотеке:
pub enum Option<T> {
None,
Some(T),
}
В Rust отсутствует null-значение, ровно как и досадные ошибки непредвиденного обращения к нему. Вместо этого там, где действительно необходимо указать возможность отсутствия значения, используется Option
:
fn divide(numerator: f64, denominator: f64) -> Option<f64> {
if denominator == 0.0 {
None
} else {
Some(numerator / denominator)
}
}
let result = divide(2.0, 3.0);
match result {
Some(x) => println!("Result: {}", x),
None => println!("Cannot divide by 0"),
}
Алгебраический тип данных — достаточно мощный и выразительный инструмент, который открывает дверь в Type-Driven Development. Грамотно написанная программа в этой парадигме возлагает на систему типов большую часть проверок корректности своей работы. Поэтому если вам нехватает немного Haskell в повседневном промышленном программировании, Rust может стать вашей отдушиной. :)
10. Легкий рефакторинг
Развитая строгая статическая система типов в Rust и попытка выполнить как можно больше проверок во время компиляции, приводит к тому, что дорабатывать и рефакторить код становится доcтаточно просто и безопасно. Если после изменений программа собралась, то это значит, что в ней остались только логические ошибки, не связанные с тем функционалом, проверка которого была возложена на компилятор. В сочетании с легкостью добавления модульных тестов для проверки логики, это приводит к серьезным гарантиям надежности программ и росту уверенности программиста в корректной работе своего кода после внесения изменений.
Пожалуй это все, о чем я хотел рассказать в этой статье. Конечно, у Rust есть еще много других достоинств, а также имеется ряд недостатков (некоторая сырость языка, отсутствие привычных идиом программирования, "нелитературный" синтаксис), о которых здесь не упоминается. Если вам есть, что о них рассказать — напишите в комментариях. А вообще, опробуйте Rust на практике. И может быть его достоинства для вас перевесят все его недостатки, как это произошло в моем случае. И вы, наконец, получите именно тот набор инструментов, в котором долго нуждались.