Первые шаги по Rust +48


image


Всем привет. Недавно познакомился с новым для себя языком программирования Rust. Я заметил, что он отличается от других, с которыми мне до этого доводилось сталкиваться. Поэтому решил покопать глубже. Результатами и своими впечатлениями хочу поделиться:


  • Начну с главной, на мой взгляд, особенности Rust
  • Опишу интересные детали синтаксиса
  • Объясню, почему Rust, скорее всего, не захватит мир

Сразу поясню, что я около десяти лет пишу на Java, так что рассуждать буду со своей колокольни.


Killer feature


Rust пытается занять промежуточное положение между низкоуровневыми языками типа C/C++ и высокоуровневыми Java/C#/Python/Ruby… Чем ближе язык находится к железу, тем больше контроля, легче предвидеть как код будет выполняться. Но и имея полный доступ к памяти намного проще отстрелить себе ногу. В противовес С/С++ появились Python/Java и все остальные. В них нет необходимости задумываться об очистки памяти. Самая страшная беда — это NPE, утечки не такое уж частое явление. Но чтобы это все работало необходим, как минимум, garbage collector, который в свою очередь начинает жить своей жизнью, параллельно с пользовательским кодом, уменьшая его предсказуемость. Виртуальная машина еще дает платформонезависимость, но на сколько это необходимо — спорный вопрос, не буду его сейчас поднимать.


Rust является низкоуровневым языком, на выходе компилятор выдает бинарник, для работы которого не нужны дополнительные ухищрения. Вся логика по удалению ненужных объектов интегрируется в код в момент компиляции, т.е. сборщика мусора во время выполнения тоже нет. В Rust так же нет пустых ссылок и типы являются безопасными, что делает его даже более надежным чем Java.


В основе управления памятью лежит идея владения ссылкой на объект и одалживания. Если каждым объектом владеет только одна переменная, то как только кончается срок ее жизни в конце блока, все на что она указывала можно рекурсивно очистить. Также ссылки можно одалживать для чтения или записи. Тут работает принцип один писатель и много читателей.


Эту концепцию можно продемонстрировать в следующем куске кода. Из метода main() вызывается test(), в котором создается рекурсивная структура данных MyStruct, реализующая интерфейс деструктора. Drop позволяет задать логику для выполнения, перед тем как объект будет уничтожен. Чем-то похоже на финализатор в Java, только в отличие от Java, момент вызова метода drop() вполне определен.


fn main() {
    test();
    println!("End of main")
}

fn test() {
    let a = MyStruct {
        v: 1,
        s: Box::new(
            Some(MyStruct {
                v: 2,
                s: Box::new(None),
            })
        ),
    };
    println!("End of test")
}

 struct MyStruct {
     v: i32,
     s: Box<Option<MyStruct>>,
 }

 impl Drop for MyStruct {
     fn drop(&mut self) {
         println!("Cleaning {}", self.v)
     }
 }

Вывод будет следующим:


End of test
Cleaning 1
Cleaning 2
End of main

Т.е. перед выходом из test() память была рекурсивно очищена. Позаботился об этом компилятор, вставив нужный код. Что такое Box и Option опишу чуть позже.


Таким образом Rust берет безопасность от высокоуровневых языков и предсказуемость от низкоуровневых языков программирования.


Что еще интересного


Далее перечислю черты языка по убыванию важности, на мой взгляд.


OOP


Тут Rust вообще впереди планеты всей. Если большинство языков пришли к тому, что надо отказаться от множественного наследования, то в Rust наследования нет вообще. Т.е. класс может только имплементировать интерфейсы в любом количестве, но не может наследоваться от других классов. В терминах Java это означало бы делать все классы final. Вообще синтаксическое разнообразие для поддержания OOP не так велико. Возможно, это и к лучшему.


Для объединения данных есть структуры, которые могут содержать имплементацию. Интерфейсы называются triats и тоже могут содержать имплементацию по умолчанию. До абстрактных классов они не дотягивают, т.к. не могут содержать полей, многие жалуются на это ограничение. Синтаксис выглядит следующим образом, думаю комментарии тут не нужны:


fn main() {
    MyPrinter { value: 10 }.print();
}

trait Printer {
    fn print(&self);
}

impl Printer {
    fn print(&self) {
        println!("hello!")
    }
}

struct MyPrinter {
    value: i32
}

impl Printer for MyPrinter {
    fn print(&self) {
        println!("{}", self.value)
    }
}

Из особенностей на которые я обратил внимание, стоит отметить следующее:


  • У классов нет конструкторов. Есть только инициализаторы, которые через фигурные скобки задают значения полям. Если нужен конструктор, то это делается через статические методы.
  • Метод экземпляра отличается от статического наличием ссылки &self в качестве первого аргумента.
  • Классы, интерфейсы и методы также могут быть обобщенными. Но в отличие от Java, эта информация не теряется в момент компиляции.

Еще немного безопасности


Как я уже говорил Rust уделяет большое внимание надежности кода и пытается предотвратить большинство ошибок на этапе компиляции. Для этого была исключена возможность делать ссылки пустыми. Это мне чем-то напомнило nullable типы из Kotlin. Для создания пустых ссылок используется Option. Так же как и в Kotlin, при попытке обратиться к такой переменной, компилятор будет бить по рукам, заставляя вставлять проверки. Попытка же вытащить значение без проверки может привести к ошибке. Но этого уж точно нельзя сделать случайно как, например, в Java.


Мне еще понравилось то, что все переменные и поля классов по умолчанию являются неизменяемыми. Опять привет Kotlin. Если значение может меняться, это явно надо указывать ключевым словом mut. Я думаю, стремление к неизменяемости сильно улучшает читабельность и предсказуемость кода. Хотя Option почему-то является изменяемым, этого я не понял, вот код из документации:


let mut x = Some(2);
let y = x.take();
assert_eq!(x, None);
assert_eq!(y, Some(2));

Перечисления


В Rust называются enum. Только помимо ограниченного числа значений они еще могут содержать произвольные данные и методы. Таким образом это что-то среднее между перечислениями и классами в Java. Стандартный enum Option в моем первом примере как раз принадлежит к такому типу:


pub enum Option<T> {
    None,
    Some(T),
}

Для обработки таких значений есть специальная конструкция:


fn main() {
    let a = Some(1);
    match a {
        None => println!("empty"),
        Some(v) => println!("{}", v)
    }
}

А также


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


  • Любители функционального программирования не будут разочарованы, для них есть лямбды. У итератора есть методы для обработки коллекции, например, filter и for_each. Чем-то похоже на стримы из Java.
  • Конструкция match так же может быть использована для более сложных вещей, чем обычные enum, например, для обработки паттернов
  • Есть большое количество встроенных классов, например, коллекций: Vec, LinkedList, HashMap и т.д.
  • Можно создавать макросы
  • Есть возможность добавлять методы в существующие классы
  • Поддерживается автоматическое выведение типов
  • Вместе с языком идет стандартный фреймворк для тестирования
  • Для сборки и управления зависимостями используется встроенная утилита cargo

Ложки дегтя


Этот раздел необходим для полноты картины.


Killer problem


Главный недостаток происходит из главной особенности. За все приходится платить. В Rust очень неудобно работать c изменяемыми графовыми структурами данных, т.к. на любой объект должно быть не более одной ссылки. Для обхода этого ограничения есть букет встроенных классов:


  • Box — неизменяемое значение на куче, аналог оберток для примитивов в Java
  • Cell — изменяемое значение
  • RefCell — изменяемая ссылка
  • Rc — reference counter, для нескольких ссылок на один объект

И это неполный список. Для первой пробы Rust, я опрометчиво решил написать односвязный список с базовыми методами. В конечном счете ссылка на узел получилась следующая Option<Rc<RefCell<ListNode>>>:


  • Option — для обработки пустой ссылки
  • Rc — для нескольких ссылок, т.к. на последний объект ссылаются предыдущий узел и сам лист
  • RefCell — для изменяемой ссылки
  • ListNode — сам следующий элемент

Выглядит так себе, итого три обертки вокруг одно объекта. Код для простого добавления элемента в конец списка получился очень громоздкий, и в нем есть неочевидные вещи, такие как клонирования и одалживания:


struct ListNode {
    val: i32,
    next: Node,
}

pub struct LinkedList {
    root: Node,
    last: Node,
}

type Node = Option<Rc<RefCell<ListNode>>>;

impl LinkedList {
    pub fn add(mut self, val: i32) -> LinkedList {
        let n = Rc::new(RefCell::new(ListNode { val: val, next: None }));
        if (self.root.is_none()){
            self.root = Some(n.clone());
        }
        self.last.map(|v| { v.borrow_mut().next = Some(n.clone()) });
        self.last = Some(n);
        self
    }
...

На Kotlin то же самое выглядит намного проще:


public fun add(value: Int) {
    val newNode = ListNode(null, value);
    root = root ?: newNode;
    last?.next = newNode
    last = newNode;
}

Как выяснил позже подобные структуры не являются характерными для Rust, а мой код совсем неидиоматичен. Люди даже пишут целые статьи:



Тут Rust жертвует читабельностью ради безопасности. Кроме того такие упражнения еще могут привести к зацикленным ссылкам, которые зависнут в памяти, т.к. никакой garbage collector их не уберет. Рабочий код на Rust я не писал, поэтому мне сложно сказать насколько такие трудности усложняют жизнь. Было бы интересно получить комментарии практикующих инженеров.


Сложность изучения


Долгий процесс изучения Rust во многом следует из предыдущего раздела. Перед тем как написать вообще хоть что-то придется потратить время на освоение ключевой концепции владения памятью, т.к. она пронизывает каждую строчку. К примеру, простейший список у меня занял пару вечеров, в то время как на Kotlin то же самое пишется за 10 минут, при том что это не мой рабочий язык. Помимо этого многие привычные подходы к написанию алгоритмов или структур данных в Rust будут выглядеть по другому или вообще не сработают. Т.е. при переходе на него понадобится более глубокая перестройка мышления, просто освоить синтаксис будет недостаточно. Это далеко не JavaScript, который все проглотит и все стерпит. Думаю, Rust никогда не станет тем языком, на котором учат детей в школе программирования. Даже у С/С++ в этом смысле больше шансов.


В итоге


Мне показалась очень интересной идея управления памятью на этапе компиляции. В С/С++ у меня опыта нет, поэтому не буду сравнивать со smart pointer. Синтаксис в целом приятный и нет ничего лишнего. Я покритиковал Rust за сложность реализации графовых структур данных, но, подозреваю, что это особенность всех языков программирования без GC. Может быть, сравнения с Kotlin было и не совсем честным.


TODO


В этой статье я совсем не коснулся многопоточности, думаю это отдельная большая тема. Еще есть планы написать какую-нибудь структуру данных или алгоритм посложнее списка, если есть идеи, прошу поделиться в комментариях. Интересно было бы узнать приложения каких типов вообще пишут на Rust.


Почитать


Если вас заинтересовал Rust, то вот несколько ссылок:





К сожалению, не доступен сервер mySQL