Международная конференция разработчиков
и пользователей свободного программного обеспечения

Введение в ЯП Rust. Ключевые принципы и инженерные идеи.

Vitaly Shukela, Minsk, Belarus

LVEE 2016

Core ideas, features, engineering ideas, pros and cons of Mozilla's Rust programming language.

Введение

Rust – язык общего назначения для системного программирования, конкурент C++. Создан в том числе для того, чтобы снизить необходимый уровень квалификации для написания правильного, надёжного системного кода по сравнению с C++. В C++ легко написать работающий, но полагающийся на неопределённое поведение код. Rust предлагает разобраться со сложностями безопасного управления памятью и
многопоточности до того, как программа начнёт работать.

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

ЯП Rust разрабатывается сообществом под началом Mozilla. Несмотря на то, что у языка нет одного центрального главного автора, ощущение эклектичности и “design by commitee” при знакомстве с языком меньше, что можно ожидать (хотя и присутствует).

Три ключевых “X без Y” принципа ЯП Rust:

  1. Безопасность по отношению к памяти без сборки мусора;
  2. Поддержка многопоточности без состояний гонки (race condition);
  3. Абстракция без накладных расходов;
  4. Стабильность языка без стагнации.

Каждый из этих принципов к сожалению имеет и негативную сторону, усложняющую язык.

Rust черпает идеи из многих других ЯП. Можно сказать, что утверждение “не придумывать своё, сделать правильно уже придуманное” взято как один из принципов разработки языка. Неполный список языков-“доноров”:

  • Ocaml
  • C++
  • Haskell
  • Erlang
  • Swift
  • Scheme
  • C#

Rust – императивный язык. Функциональное программирование на нём не очень популярно. Есть макросы и плагины к компилятору.

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

  • Сложность уровня C++. Высокий порог вхождения.
    “Была проблема, решил использовать Rust. Теперь у меня
    &‘a mut Проблема<’a, T>, которую я немогу переместить из заимствованного контекста.”
    Даже после некоторого знакомства с языком, следует ожидать двукратно более медленного программирование по сравнению с, например, C++.
  • Молодой язык:
    • Неполная поддержка IDE
    • Не все библиотеки написаны
    • Медленныя компиляция, нереализованные оптимизации
    • Отсутсвуют некоторые возможности языка
  • ABI нестабилизировано и несовместимо между версиями языка (как в C++, но не как в C).

Управление памятью в Rust.

В Rust есть три режима доступа к объекту:

  • Владение: x
  • Доступ на запить: &mut x
  • Доступ на чтение: &x

Эта тройственность бывает заметна в разных местах языка и стандартной библиотеки.

У каждого режима есть свои особенности:

  • Владелец отвечает за освобождение памяти и выход деструктора. Может “раздавать” ссылки & и &mut.
  • Доступ на запить означает, что объект можно изменять, а не только читать. Но после сеанса редактирования объект должен остаться на месте, и ссылку (заимстование) нужно “вернуть на место” владельцу.
  • Доступ только на чтение. Это единственный режим с разделяемым доступом на уровне языка. В двух предыдущих режимах доступ монопольный.

Естественно, есть ещё специальный “небезопасный” режим с настоящими указателями в стиле C, без “приставленного к ним маленького милиционера, который следит за доступом”. В этом спецрежиме (“Unsafe Rust”) реализовывается связь с библиотеками на других ЯП (в частности, на С) и структуры данных. Это позволяет реализовавыть умные указатели со своими режимами доступа в библиотеках.

Вне этого спецрежима действуют гарантии языка по надёжности.

Следует обратить внимание на список ситуаций, которые не входят в эти гарантии:

  • утечки памяти, невызовы деструкторов, нарушение RAII, например, из-за циклов в указателях с подсчётом ссылок;
  • взаимоблокировки нитей;
  • целочисленные переполнения (когда контроль переполнений отключен);
  • переполнение стека (аварийное завершение программы, но без неопределённого поведения);
  • вмешательсово в работу программы со стороны (отладчиком и т.д.);
  • игнорирование некоторых труднообрабатываемых с RAII ошибок (системных вызов close).

Пример срабатывания контроля заимстований:

   fn eat_box(boxed_int: Box<i32>) {
        println!("Объект, содержаций внутри {} освобождается из памяти", boxed_int);
    }
    fn peep_inside_box(borrowed_int: &i32) {
        println!("Заглянули в объект - внутри {}", borrowed_int);
    }
    fn main() {
        let boxed_int = Box::new(5);
        peep_inside_box(&boxed_int);
        peep_inside_box(&boxed_int);
        {   let _ref_to_int: &i32 = &boxed_int;
            eat_box(boxed_int); /* не компилируется */ 
        } // reference goes out of scope;
        eat_box(boxed_int);
    }

У каждого заимствования (ссылки на объект) есть время жизни. Эти времена жизни,
о которых иногда идёт речь и при описании других ЯП, выражены в Rust’е явно и входят
в синтаксис языка (lifetimes). На этапе компиляции они проверяются. Функции,
могующие оперировать со ссылками с разными временами жизни считаются обобщёнными
(generic) и имеют специальный дополнительный параметр.

Пример:

fn choose<'a,'b>(j:&'a i32, k:&'b i32) -> &'a i32 { j }

Расшифровка примера:

fn Определяем функцию
choose “choose”
< с двумя generic-памаметрами:
'a, время жизни ’a и
'b> время жизни ’b;
( с двумя аргументами:
j: j –
& ссылка,
'a имеющая время жизни ’a,
(пустота) только для чтения
i32, на 32-разнядное число со знаком;
k: k –
&'b i32 ссылка только чтение на i32 с временем жизни ’b,
-> возвращающая
&'a i32 ссылку только чтение на i32 с временем жизни a,
{ j } а именно, свой первый аргумент.

Прослеживая lifetime’ы, компилятор Rust может рассуждать, действительно ли соблюдаятся принципы работы с памятью:

  • нет доступа на запить из нескольких мест к одному и тому же;
  • нет чтения неинициализированной памяти;
  • нет доступа к объекту, если он уже освобождён.

Это всё проверяется из типов данных и сигнатур функций. В частности,
в приведённом выше примере невозможно было бы определить, какое время жизни у возвращаемой функцией choose ссылки, без “подглядывания” в реализацию “{ j }” (которая может быть в общем случае далеко от объявления).

Когда речь идёт о безопасности памяти и ссылках, Rust предпочитает быть скорее сложным, чем нестрогим. Можно частично избежать сложностей работы со ссылками в Rust путём использования доступных в стандартной библиотеке умных указателей Rc, Arc и Cow.

Так же как типы наследуются друг от друга в других ЯП, lifetime’ы “наследуются” в Rust.

   'a : 'b

Это означает, что ‘a шире ’b (начинается не позднее начала, заканчивается не ранее конца ’b). Значит, где требуется ссылка &’b,
можно использовать и &‘a (но не наоборот). Аналогично, из ссылки &’a можно сделать ссылку &’b (но не наоборот).

   зона действия 'a {
        ...
        зона действия 'b {
            ...
        }
        ...
    }

Интерфейсы (Traits), типы и generics.

Два пользовательских составных объекта в Rust – это структуры и перечисления. Они приблизительно соответствуют конструкциям С struct и union (с тегом).

Также можно задавать набор сигнатур функций (интерфейс, trait) который можно “привязывать” к типу данных.

Для trait’ов есть наследование, для обычных типов данных его нет.

И trait’ы, и типы данных могут быть generic, то есть определять семейство интерфейсов или типов в зависимости от набора типов-пареметров.

Реализации некоторых интерфейсов может предоставить сам компилятор:

   #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
    struct SomeEntry {
        pub q : String,
        w : i32,
    };
    
    #[derive(Copy)]
    enum Q {
        Variant1,
        Variant2(usize),
    }

В отличие от C++, реализации generic-функций проверяются на правильность до инстанцирования конкретными типами.

Пример использования интерфейса:

   trait Qqq {
        fn a(&self) -> i32;
    }
    
    struct Www {
        g: isize;
    }
    
    impl Qqq for Www {
        fn a(&self) -> i32 { self.g }
    }

Помимо generic-параметров “на входе”, интерфесы могут также давать типы “на выход”

   trait MyTrait<T> {
        type Output;
        fn qqq(&self) -> Self::Output;
    }
    
    struct Lol;
    struct LolOut;
    impl MyTrait<u8> for Lol {
        type Output = LolOut;
        fn qqq(&self) -> LolOut { LolOut }
    }

Библиотеки могут оставлять интерфейсы для реализации пользователям. При этом есть специальное правило: нельзя реазизовывать (impl) чужой (из другого компонента) интерфейс для чужого типа. При помощи этого правила обестечивается сочетаемость компонентов – каждая реализация “привязана” к компоненту типом и/или интерфейсом.

Прочие возможности ЯП Rust

Ниже приведен краткий перечень других возможностей языка с примерами их использования.

Rust поддерживает макросы.

   macro_rules! o_O {
        (  !http://www.codecogs.com/png.latex?\textstyle%20(!x:expr; [ !http://www.codecogs.com/png.latex?\textstyle%20(!y:expr ),* ]
            );*   ) => {
            &[ !http://www.codecogs.com/png.latex?\textstyle%20(!( !http://www.codecogs.com/png.latex?\textstyle%20x%20+!y ),*),* ]
        }
    }
    
    fn main() {
        let a: &[i32] = 
            o_O!(10; [1, 2, 3];
                 20; [4, 5, 6]);
    
        assert_eq!(a, [11, 12, 13, 24, 25, 26]); 
    }

В Rust широко используются итераторы:

   let a = [1, 4, 2, 3, 8, 9, 6];
    let sum: i32 = a.iter()
                    .map(|x| *x)
                    .inspect(|&x| println!("filtering {}", x))
                    .filter(|&x| x % 2 == 0)
                    .inspect(|&x| println!("{} seen", x))
                    .fold(0, |sum, i| sum + i);
    println!("{}", sum);

Тесты можно включать прямо в документацию к библиотеке:

   /// Clears the map, removing all values.
    ///
    /// # Examples
    ///
    /// ```
    /// use std::collections::BTreeMap;
    ///
    /// let mut a = BTreeMap::new();
    /// a.insert(1, "a");
    /// a.clear();
    /// assert!(a.is_empty());
    /// ```

Помимо дженериков можно использовать также и type erasure.

Также предусмотрены две системы обработки ошибок: panic/unwind и Result.

Заключение.

Изучение ЯП Rust – хорошая идея для системных программистов независимо от использования или неиспользования языка в реальных проектах. Язык сравнивают с аппаратом Илизарова для небрежных программистов на C/C++ – после Rust “руки выпрямляются” и код получается более качественным в т.ч. за его пределами.

Источники.

1 Публичный протокол чата Rust

2 Тред ‘цитаты о Rust’

3 https://vi-server.org/pub/rust.pdf

Abstract licensed under Creative Commons Attribution-ShareAlike 3.0 license

Назад