Концепции Rust, которые неплохо бы знать пораньше +20


Весь минувший месяц я глаз не мог оторвать от языка программирования Rust, ведь его конёк – создание современных программ, обеспечивающих безопасную работу с памятью. За прошедшие годы появилось несколько языков, которые позиционировались как «инструмент что надо» для написания надёжного бекенд-софта. Постепенно маятник качнулся от Java/C++ к Go и Rust, выстроенных на многолетних разработках по теории языков программирования. Суть – в создании инструментов, которые были бы эффективны именно в наш век.

Цифры Rust говорят сами за себя. Мало того, что в знаменитом опросе «ваш любимый язык» с сайта Stackoverflow язык Rust лидирует на протяжении семи лет подряд, так он ещё и недавно вошёл в состав ядра Linux – такое ранее не удавалось ни одному языку кроме C. Что особенно меня восхищает в Rust – так это подлинная новизна, которую он привносит в искусство программирования.

use std::thread;
use std::time::Duration;
use std::{collections::VecDeque, sync::Condvar, sync::Mutex};

fn main() {
    let queue = Mutex::new(VecDeque::new());

    thread::scope(|s| {
        let t = s.spawn(|| loop {
            let item = queue.lock().unwrap().pop_front();
            if let Some(item) = item {
                dbg!(item);
            } else {
                thread::park();
            }
        });

        for i in 0.. {
            queue.lock().unwrap().push_back(i);
            t.thread().unpark();
            thread::sleep(Duration::from_secs(1));
        }
    })
}

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

Я – Go-разработчик, пишу программы с высоким уровнем конкурентности и фокусируюсь на системном программировании. Мне довелось набить немало шишек, обучаясь тому, как на Rust создаются реальные программы. То есть, если бы я попытался перенести на почву Rust те задачи, над которыми работаю в настоящий момент, как думаете, насколько мне помогли бы все эти туториалы?

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

О ссылках

В Rust есть два вида ссылок: разделяемые (работа с ними также называется «заимствованием») и изменяемые (также именуемые исключительными ссылками).

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

Если бы &mut-ссылки назывались именно «исключительными», это сберегло бы мне массу времени при изучении Rust.

pub struct Foo {
    x: u64,
}

impl Foo {
    /// Любой тип, заимствующий экземпляр Foo, может
    /// вызвать этот метод, так как он требует всего лишь ссылку на Foo.
    pub fn total(&self) -> u64 {
        self.x
    }
    /// Только по исключительным ссылкам на экземпляры Foo
    /// можно вызывать этот метод, так как требуется, чтобы Foo был изменяемым
    pub fn increase(&mut self) {
        self.x += 1;
    }
}

let foo = Foo { x: 10 };
println!("{}", foo.total()) // РАБОТАЕТ
foo.increase() // ОШИБКА: Foo не объявлен как mut

Допускаются двунаправленные ссылки

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

Допустим, у нас есть структура Node, содержащая набор ссылок на дочерние узлы, а также ссылку на родительский узел. Как правило, Rust стал бы на это ругаться, но мы можем пройти проверку заимствований, обернув ссылку на родитель в так называемый Weak-указатель. Данный тип сообщает Rust, что исчезновение данного узла или каких-то из его дочерних узлов ещё не означает, что также следует отбросить их родительский узел.

use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

Так мы приобретаем удобные примитивы для построения двунаправленных ссылок. Но вскоре я обнаружил, что на Rust реально сложно писать графовые структуры без полного понимания всего, что делаешь – притом, сколько всего требуется учитывать для эффективного моделирования данных, так, чтобы компилятор остался доволен.

Реализация Deref, чтобы код стал чище

Иногда требуется обращаться с оберточными типами именно как с теми сущностями, что в них содержатся. Именно такова ситуация с такими распространёнными структурами данных как vec, с умными указателями, такими как Box, или даже со счётными ссылочными типами, например, с Rc и Arc. В стандартной библиотеке есть типажи под названием Deref и DerefMut, они помогают сообщить Rust, как должен разыменовываться данный тип.

use std::ops::{Deref, DerefMut};

struct Example<T> {
    value: T
}

impl<T> Deref for Example<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.value
    }
}

impl<T> DerefMut for Example<T> {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.value
    }
}

let mut x = Example { value: 'a' };
*x = 'b';
assert_eq!('b', x.value);

В вышеприведённом примере с *x можно обращаться как с тем значением "a”, которое им обозначено, и даже изменять его – поскольку мы определили правила, по которым должны разыменовываться заимствования или изменяемые ссылки. Очень мощный подход, позволяющий понять, почему не приходится беспокоиться об обёртывающих типах при работе с такими умными указателями как Box. Тот факт, что значение упаковывается – это деталь реализации, которую удаётся абстрагировать при помощи этих типажей.

struct Foo {
    value: u64,
}
let mut foo = Box::new(Foo { value: 10 });

// Box реализует DerefMut, поэтому всё будет отлично работать!
*foo = Foo { value: 20 };
// Точечные методы будут работать с foo, так как Box реализует Deref.
// Нам не приходится беспокоиться о той детали реализации, что
// Foo оказывается упакован.
assert_eq!(20, foo.value);

Осторожно применяйте методы с теми типами, что реализуют Deref

Вы когда-нибудь задумывались, зачем существуют такие методы как Arc::clone, если можно было бы просто клонировать .clone() значение Arc? Дело в том, как именно типы реализуют разыменование Deref, и разработчики должны в данном случае проявлять бдительность. Рассмотрим следующий пример, в котором мы пытаемся реализовать собственную версию mpsc-каналов (много производителей/один потребитель) из стандартной библиотеки:

use std::sync::{Arc, Mutex, Condvar};

pub struct Sender<T> {
    inner: Arc<Inner<T>>,
}

impl<T> Sender<T> {
    pub fn send(&mut self, t: T) {
        ...
    }
}

impl<T: Clone> Clone for Sender<T> {
    fn clone(&self) -> Self {
        Self {
            // ОШИБКА: неизвестно, клонировать ли Arc или inner!
            inner: self.inner.clone(),
        }
    }
}

struct Inner<T> {
    ...
}

impl<T: Clone> Clone for Inner<T> {
    fn clone(&self) -> Self {
        ...
    }
}

В вышеприведённом примере у нас есть тип Sender, с которым мы хотим реализовать типаж Clone. У этой структуры есть поле inner, относящееся к типу Arc<Inner<T>>. Напомню, что Arc уже реализует Clone, а также Deref. Ко всему этому остаётся добавить, что наш Inner также реализует Clone. Имея вышеприведённый код, Rust не знает, хотим ли мы клонировать Arc или действующее значение inner, поэтому вышеприведённый код не сработает. В данном случае можно было бы воспользоваться конкретным методом, предоставляемым Arc из контейнера sync.

impl<T: Clone> Clone for Sender<T> {
    fn clone(&self) -> Self {
        Self {
            // Теперь Rust знает, что нужно использовать метод Clone из Arc,
            // а не метод clone из самого inner.
            inner: Arc::clone(&self.inner),
        }
    }
}

Нужно понимать, когда следует и когда не следует использовать внутреннюю изменяемость

Иногда вам потребуется использовать в коде такие структуры как Rc или Arc, либо реализовывать структуры, обёртывающие некоторые данные – с намерением в дальнейшем изменять те данные, которые были таким образом обёрнуты. Вскоре вы упрётесь в стену: компилятор сообщит вам, что внутренняя изменяемость не разрешается, что на первый взгляд никуда не годится. Однако, в Rust можно изловчиться и разрешить внутреннюю изменяемость — такие способы даже предоставляются в стандартной библиотеке.

Один из простейших - Cell, обеспечивающий внутреннюю изменяемость данных. С его помощью можно изменять данные, находящиеся внутри Rc, при условии, что можно задёшево скопировать эти данные. Такая возможность достижима, если обернуть данные в Rc<Cell<T>>. Так предоставляются методы для установки и получения (get и set), которые даже не обязаны быть mut, поскольку они копируют данные под капотом:

// impl<T: Copy> Cell<T>
pub fn get(&self) -> T

// impl<T> Cell<T>
pub fn set(&self, val: T)

Другие типы, например, RefCell, помогают переместить определённые проверки заимствования на уровень среды выполнения, тем временем минуя некоторые жёсткие фильтры компилятора. Однако, это рискованно, так как может вызывать панику во время выполнения, если проверки заимствования не будут проходить. Относитесь к компилятору как к другу – и будете вознаграждены. Пропуская его проверки или перемещая их на уровень среды выполнения, вы словно говорите компилятору: «можешь мне доверять – я знаю, что делаю».  

Пакет std::cell даже предупреждает нас об этом, так как содержит следующий полезный пассаж:

Более распространённая наследуемая изменяемость, при которой, чтобы изменить значение, требуется уникальный доступ к нему – это один из ключевых элементов языка, позволяющий при работе с Rust уверенно судить о совмещении указателей, статически предотвращая баги, которые могли бы привести к отказу. Именно поэтому предпочтительна наследуемая изменяемость, а к внутренней изменяемости следует прибегать в крайнем случае. Поскольку в Rust ячеечные типы (cell types) разрешают изменение данных и там, где оно в иных условиях было бы запрещено, случается, что и внутренняя изменяемость бывает уместна или даже обязательна, как, например, при

 

- Введении изменяемости «внутри» некой неизменяемой сущности

- Описании деталей реализации логически неизменяемых методов

- Изменении реализаций Clone.

Методы get и get mut - вещь!

Многие типы, в том числе, vec, реализуют как методы get, так и get_mut, позволяя заимствовать и изменять элементы в структуре (первое возможно лишь в случае, если у вас есть изменяемая ссылка на коллекцию). Мне потребовалось некоторое время, чтобы понять, что такие опции доступны при работе со многими структурами данных, и это сильно упростило мне жизнь, в частности, стало гораздо легче писать чистый код!

let x = &mut [0, 1, 2];

if let Some(elem) = x.get_mut(1) {
    *elem = 42;
}
assert_eq!(x, &[0, 42, 2]);

Берите на вооружение небезопасный, но продуманный код

Мне, как Go-разработчику, пакет “unsafe” казался табу, которого я старался не касаться. Однако, в Rust небезопасность понимается намного иначе, чем в Go. Фактически, во многих местах стандартной библиотеки «небезопасный» код используется с большим успехом! Как это возможно? Хотя, неопределённое поведение в Rust и невозможно, это правило не распространяется на блоки кода, помеченные как “unsafe”. Напротив, разработчик, пишущий «небезопасный» код Rust просто должен гарантировать, что этот код используется здраво – и остаётся только собирать плюшки.

Рассмотрим приведённый ниже пример – здесь функция возвращает элемент, расположенный в массиве по указанному индексу. Чтобы оптимизировать такой поиск, можно применить небезопасную функцию Rust, которая называется get_unchecked и применима при работе с типом «массив». Если бы мы попытались получить индекс, выйдя при этом за границы, то спровоцировали бы панику, которая привела бы к неопределённому поведению. Но наша функция верно утверждает, что небезопасный вызов состоится лишь в том случае, когда значение индекса меньше длины массива. Таким образом, нижеприведённый код продуман, хотя, в нём и используется небезопасный блок.

/// Пример из Растономикона
fn item_at_index(idx: usize, arr: &[u8]) -> Option<u8> {
    if idx < arr.len() {
        unsafe {
            Some(*arr.get_unchecked(idx))
        }
    } else {
        None
    }
}

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

Как правило, unsafe используется в случаях, когда первоочередное значение отдаётся производительности, либо, когда вам известно, как легко решить поставленную задачу при помощи небезопасных блоков – при условии, что вы можете доказать продуманность вашего кода.

По возможности используйте impl-типы как аргументы, а не как обобщённые ограничители

Придя из Golang, я думал, что типажи всегда можно предоставлять просто как параметры функций. Например:  

trait Meower {
    fn meow(&self);
}

struct Cat {}

impl Meower for Cat {
    fn meow(&self) {
        println!("meow");
    }
}

// : Meower не может использоваться, так как во время компиляции 
// не имеет размера!
fn do_the_meow(meower: Meower) {
    meower.meow();
}

…но вышеприведённый код не срабатывает, так как объекты-типажи во время компиляции не имеют размера, а Rust требуется такой размер, чтобы решить задачу. Мы могли бы обойти этот момент, добавив &dyn Meower и сообщив компилятору, что размер здесь задаётся динамически, но я вскоре узнал, что это «не по-растовски». Вместо этого разработчики склонны передавать обобщённые параметры, ограниченные типажом. Например:

fn do_the_meow<M: Meower>(meower: M) {
    meower.meow();
}

…этот код скомпилируется и сработает. Но, по мере усложнения функций может получиться, что сигнатура у функции станет очень неудобочитаемой, если мы также включим другие обобщённые параметры. В данном примере нам, в принципе, и не нужен обобщённый тип, если мы всего-то и хотим один раз мяукнуть. Нас даже не волнует результат мяуканья, поэтому можно переписать код в следующем виде:

fn do_the_meow(meower: &impl Meower) {
    meower.meow();
}

Так мы сообщаем компилятору: «Мне просто нужно что-нибудь, что сделает мяу». Если это всё, что вам нужно, то такой паттерн гораздо чище, и вы, прежде всего, можете не предусматривать обобщённый возвращаемый тип для вашей функции.

Пользуйтесь iter(), когда нужно заимствовать, iter mut() с исключительными ссылками и into iter(), когда требуется владение

Авторы многих руководств сразу переходят к перебору по векторам, для чего используют показанный ниже метод into_iter:

let items = vec![1, 2, 3, 4, 5];
for item in items.into_iter() {
    println!("{}", item);
}

Но многие новички (и я в том числе) сталкиваются с трудностями, попробовав использовать этот метод-итератор внутри структур, например:

struct Foo {
    bar: Vec<u32>,
}

impl Foo {
    fn all_zeros(&self) -> bool {
        // ОШИБКА: Не могу выйти за пределы self.bar!
        self.bar.into_iter().all(|x| x == 0)
    }
}

И сразу же получим:

    error[E0507]: cannot move out of `self.bar` which is behind a shared reference
       --> src/main.rs:9:9
        |
    9   |         self.bar.into_iter().all(|x| x == 0)
        |         ^^^^^^^^ ----------- `self.bar` moved due to this method call
        |         |
        |         move occurs because `self.bar` has
        |         type `Vec<u32>`, which does not implement the `Copy` trait

Попробовав на правах нуба всевозможные варианты, я осознал, что .into_iter() принимает владение над коллекцией, а мне требовалось не это. Но вместо него в Rust нашлось два других полезных метода для работы с итераторами, о которых я хотел бы знать раньше. Первый —.iter(), заимствующий коллекцию и позволяющий вам что-либо утверждать о её значениях, но не владеть ими и не изменять их. Другой такой метод —.iter_mut(), помогающий изменять внутренние значения коллекции, пока у вас есть всего одна исключительная ссылка.

Итак: пользуйтесь .iter(), когда вас интересует простое заимствование, .into_iter(), когда вы хотите чем-то владеть и .iter_mut(), когда требуется изменять элементы итератора.

Фантомные данные нужны далеко не только для работы с сырыми указателями на типы

Когда впервые сталкиваешься с фантомными данными, они кажутся странными, но вскоре становится понятно: с их помощью компилятору сообщается, что некая сущность «владеет» определённым значением, хотя и имеет только лишь сырой указатель на него. Например:

use std::marker;

struct Foo<'a, T: 'a> {
    bar: *const T,
    _marker: marker::PhantomData<&'a T>,
}

Сообщает компилятору, что Foo владеет T, хотя и имеет всего лишь сырой указатель на T. Это полезно в прикладных случаях, когда приходится иметь дело с сырыми указателями и в то же время использовать небезопасный Rust.

Однако с их помощью также можно сообщить компилятору, что ваш тип не реализует типажи Send или Sync! Можно обернуть следующие типы в PhantomData и использовать их в ваших структурах, сообщая таким образом компилятору, что ваша структура не является ни Send, ни Sync.

pub type PhantomUnsync = PhantomData<Cell<()>>;
pub type PhantomUnsend = PhantomData<MutexGuard<'static, ()>>;

Используем rayon для постепенного наращивания параллелизма

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

Чтобы такого не случалось, просто пользуйтесь потрясающим пакетом Rayon, в котором предоставляются волшебные инструменты для бесшовного распараллеливания вычислений. Например, допустим, что у нас есть функция, вычисляющая сумму квадратов в массиве.

fn sum_of_squares(input: &[i32]) -> i32 {
    input.iter()
            .map(|i| i * i)
            .sum()
}

Вышеприведённый код распараллеливается без каких-либо проблем, такова сама природа сложения и умножения. Rayon же делает эту задачу тривиальной, предоставляя автоматический доступ к «параллельным итераторам» для работы с коллекциями – например, с массивами. Вот как это выглядит; обратите внимание, шаблонного кода тут практически ноль. Кроме того, читаемость такого кода совсем не портится.

// Импортируя rayon prelude, получаем доступ к .par_iter для работы с массивами.
use rayon::prelude::*;

fn sum_of_squares(input: &[i32]) -> i32 {
    // Можно использовать par_iter с нашим массивом, чтобы rayon мог
    // обрабатывать распараллеливание и окончательное 
    // согласование результатов
    input.par_iter()
            .map(|i| i * i)
            .sum()
}

Поймите, какова концепция расширяющих типажей при разработке библиотек Rust

Как же Rayon решает вышеописанную задачу, да ещё и так чисто? Всё дело в «расширяющих типажах» - это типажи, которые можно определять как расширения других типажей, например, Iterator. То есть, можно навешивать добавочные полезные функции на те элементы, которые обычно реализуют типаж Iterator, но они будут доступны лишь в случае, если типаж есть в области видимости – для этого можно, например, импортировать его в файл.

Это отличный подход, поскольку типажи будут доступны только при условии, что вы импортируете в ваш проект расширяющий типаж. Они – отличный инструмент для расширения обычных коллекций и типов чистыми API, которыми разработчики могут пользоваться столь же легко, как и их нормальными аналогами. Пользоваться параллельными итераторами в Rust не сложнее, чем обычными итераторами, всё благодаря расширяющим типажам Rayon.

На самом деле, есть исключительно информативная лекция о том, как можно воспользоваться расширяющими типажами и разработать библиотеку, предоставляющую индикаторы прогресса при работе с итераторами – вот здесь.

Усвойте монадическую природу типов Option и Result

Поработав в Rust с опциональными и результирующими типами, быстро замечаешь, что `.unwrap()` вынимает из них значения – и это приведёт к отказу кода, если вариант или результат входит в состав разделяемой ссылки, например, при работе со структурой. Но бывает так, что нам всего лишь нужно постулировать, что вариант совпадает со значением внутри ссылки или получить ссылку вплоть до всех её составляющих. Это можно сделать разными способами, один из них – вообще не выходить из той области, где мы работаем с вариантами.

fn check_five(x: Option<i32>) -> bool {
    // Позволяет просто проверить, есть ли в Option то, что мы хотим.
    x.contains(&5)
}

Другой пример: мы хотим заменить данные, заключённые в варианте, на значение None, например, при взаимодействии с некоторой структурой. Мы могли бы написать это в духе императивного программирования, пространно, вот так:

struct Foo {
    data: Option<T>,
}

impl<T> Foo<T> {
    // Принимает значение данных и на его месте оставляет None
    fn pop(&mut self) -> Option<T> {
        if self.data.is_none() {
            return None;
        }
        let value = self.data.unwrap();
        self.data = None;
        value
    }
}

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

// Принимает значение данных и на его месте оставляет None.
fn pop(&mut self) -> Option<T> {
    self.data.take()
}

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

fn add(x: Option<i32>, y: Option<i32>) -> Option<i32> {
    if x.is_none() || y.is_none() {
        return None;
    }
    return Some(x.unwrap() + y.unwrap());
}

Предыдущий фрагмент выглядит неуклюже, поскольку в нём нет никаких проверок того, что должно быть сделано. Ещё одна беда в том, что мы должны извлекать значения из обоих вариантов и собирать из них новый вариант. Однако, можно сделать гораздо лучше, благодаря особым свойствам, которые есть у Option! Вот что можно сделать:

fn add(x: Option<i32>, y: Option<i32>) -> Option<i32> {
    x.zip(y).map(|(a, b)| a+b)
}

Можно упаковывать одни варианты в другие и отображать их точно так, как это делается при работе с массивами и векторами. Таким свойством также обладают типы Result, и даже такие штуки, как типы `Future`. Если вам любопытно, как всё это устроено, то подробнее о монадах можно почитать здесь.

Считайте, что типы Option и Result являются монадами, не нужно просто повсюду использовать unwrap и if x.is_none() {} else. У них определяется очень много полезных методов, о которых вы можете почитать в стандартной библиотеке.

Разберитесь с Drop и с тем, как он должен реализовываться с различными структурами данных

В стандартной библиотеке типаж Drop описан так:

Когда значение больше не требуется, Rust применит к нему «деструктор». Чаще всего значение становится не нужно в том случае, когда выходит из области видимости.

pub trait Drop {
    fn drop(&mut self);
}

Drop (сброс) критически важен при написании структур данных в Rust. Нужно разумно подходить к тому, как будет высвобождаться память после того, как она уже не будет нужна. Используйте типы с подсчётом ссылок – это поможет преодолевать такие сложности, но этого не всегда достаточно. Например, при написании собственного связного списка или структур, использующих каналы, вам, как правило, придётся реализовывать собственную версию Drop. На самом деле, реализовать сброс гораздо проще, чем кажется – просто посмотрите, как это делается в стандартной библиотеке:

// Вот и всё!
fn drop<T>(t: T) {}

Функция std::mem::drop обладает пустым телом, так как использует умные правила деструкции при выходе из области видимости. Этот же фокус можете применять и в собственных реализациях Drop, если хотите перестраховаться.

Вам надоела проверка заимствований? Пользуйтесь неизменяемыми структурами данных

Поклонники функционального программирования любят говорить, что глобальное изменяемое состояние – корень зла, так зачем же им пользоваться, если можно без него обойтись? Благодаря функциональным конструктам, присутствующим в Rust, можно собирать такие структуры данных, которые вообще не требуется менять! Это особенно полезно в тех случаях, когда требуется писать чистый код, какой встречается в Haskell, OCaml или других языках.

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

use std::rc::Rc;

pub struct List<T> {
    head: Link<T>,
}

type Link<T> = Option<Rc<Node<T>>>;

struct Node<T> {
    elem: T,
    next: Link<T>,
}

impl<T> List<T> {
    pub fn new() -> Self {
        List { head: None }
    }

    pub fn prepend(&self, elem: T) -> List<T> {
        List { head: Some(Rc::new(Node {
            elem: elem,
            next: self.head.clone(),
        }))}
    }

    pub fn tail(&self) -> List<T> {
        List { head: self.head.as_ref().and_then(|node| node.next.clone()) }
    }
    ...

Это просто отлично, так как по принципу действия напоминает функциональные структуры, где мы не изменяем список, подвешивая в него элементы, а создаём список, задавая новый элемент в качестве головы, а весь имеющийся список – в качестве хвоста.

    [head] ++ tail

Обратите внимание: ни один из вышеприведённых методов не должен быть mut, так как наша структура данных неизменяемая! Это также эффективно с точки зрения памяти, поскольку структура использует подсчёт ссылок – а значит, мы не будем тратить лишние ресурсы на дублирование той памяти, в которой хранятся узлы, если эту структуру данных вызывает несколько сторон.

Чистый функциональный код в Rust аккуратен, но зачастую для того, чтобы написанный таким образом код был производителен, в нём требуется реализовать хвостовую рекурсию. Однако, будьте осторожны, так как компилятор Rust не гарантирует оптимизацию хвостовых вызовов. Подробнее об этом здесь.

Зонтичные типажи помогают сократить дублирование

Бывает, что хочется ограничить обобщённый параметр множеством различных типажей:

struct Foo<T: Copy + Clone + Ord + Bar + Baz + Nyan> {
    vals: Vec<T>,
}

Но такая ситуация может выйти из-под контроля, как только вы начинаете писать инструкции impl, или когда у вас возникает множество обобщённых параметров. Вместо этого можно определить зонтичный типаж, благодаря которому ваш код станет СУШЕ.

trait Fooer: Copy + Clone + Ord + Bar + Baz + Nyan {}

struct Foo<F: Fooer> {
    vals: Vec<F>,
}

impl<F: Fooer> Foo<F> { ... }

Зонтичные типажи позволяют сократить дублирование, но не позволяйте им чрезмерно разрастаться. Зачастую, чтобы иметь тип, приходится соблюдать столько ограничений, что код может приобретать запах в результате создания слишком большой абстракции. Нет, если заметите, что ваши ограничения без какой-либо причины разрослись – передавайте конкретные типы. В определённых прикладных случаях зонтичные типы могут пойти на пользу – как, например, при создании библиотеки, нацеленной на предоставление максимально обобщённого API.

Условия совпадения очень гибкие и структурные по природе

Вместо вложения условий совпадения можно, например, группировать значения в виде кортежей и поступать так:

fn player_outcome(player: &Move, opp: &Move) -> Outcome {
    use Move::*;
    use Outcome::*;
    match (player, opp) {
        // Ходы камня
        (Rock, Rock) => Draw,
        (Rock, Paper) => Lose,
        (Rock, Scissors) => Win,
        // Ходы бумаги
        (Paper, Rock) => Win,
        (Paper, Paper) => Draw,
        (Paper, Scissors) => Lose,
        // Ходы ножниц
        (Scissors, Rock) => Lose,
        (Scissors, Paper) => Win,
        (Scissors, Scissors) => Draw,
    }
}

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

Избегайте операторов _ => в условиях совпадения, если варианты совпадений конечны и известны

Допустим, например, у нас есть перечисление:

enum Foo {
    Bar,
    Baz,
    Nyan,
    Zab,
    Azb,
    Bza,
}

Когда мы пишем условия совпадения, приходится сопоставлять с шаблоном все до одного типы из перечисления, если нельзя удовлетвориться огульными условиями.

match f {
    Bar => { ... },
    Baz => { ... },
    Nyan => { ... },
    Zab => { ... },
    Azb => { ... },
    Bza => { ... },
}

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

Ограничители шаблонов очень мощны

Ограничители шаблонов отлично помогают, если приходится иметь дело с неизвестным или потенциально бесконечным количеством вариантов совпадения – например, при работе с диапазонами чисел. Но они вынуждают вас применять всеобщий `_ =>`, если ваш диапазон невозможно целиком охватить ограничителем. С точки зрения удобства поддержки кода это может считаться недостатком.

Ниже приведён канонический пример из растбука:

enum Temperature {
    Celsius(i32),
    Fahrenheit(i32),
}

fn main() {
    let temperature = Temperature::Celsius(35);
    match temperature {
        Temperature::Celsius(t) if t > 30 => println!("{}C is above 30 Celsius", t),
        Temperature::Celsius(t) => println!("{}C is below 30 Celsius", t),
        Temperature::Fahrenheit(t) if t > 86 => println!("{}F is above 86 Fahrenheit", t),
        Temperature::Fahrenheit(t) => println!("{}F is below 86 Fahrenheit", t),
    }
}

Приходится иметь дело с сырым ассемблером? Для этого есть макрос!

В Core asm предоставляется макрос, позволяющий встраивать в Rust ассемблерный код; делая так, можно добиваться удивительных вещей, в частности, напрямую перехватывать стек ЦП или реализовывать продвинутые оптимизации. Вот пример, в котором показано, как при помощи встроенного ассемблера заставить стек ЦП выполнить нашу функцию – нужно просто переместить на неё указатель стека! 

use core::arch::asm;

const MAX_DEPTH: isize = 48;
const STACK_SIZE: usize = 1024 * 1024 * 2;

#[derive(Debug, Default)]
#[repr(C)]
struct StackContext {
    rsp: u64,
}

fn nyan() -> ! {
    println!("nyan nyan nyan");
    loop {}
}

pub fn move_to_nyan() {
    let mut ctx = StackContext::default();
    let mut stack = vec![0u8; MAX as usize];
    unsafe {
        let stack_bottom = stack.as_mut_ptr().offset(MAX_DEPTH);
        let aligned = (stack_bottom as usize & !15) as *mut u8;
        std::ptr::write(aligned.offset(-16) as *mut u64, nyan as u64);
        ctx.rsp = aligned.offset(-16) as u64;
        switch_stack_to_fn(&mut ctx);
    }
}

unsafe fn switch_stack_to_fn(new: *const StackContext) {
    asm!(
        "mov rsp, [{0} + 0x00]",
        "ret",
        in(reg) new,
    )
}

Пользуйтесь пакетом Criterion для расстановки контрольных точек в вашем коде и измерения его пропускной способности

Пакет Criterion для расстановки контрольных точек в коде Rust – настоящий инженерный шедевр. Он открывает доступ к потрясающим возможностям бенчмаркинга, с графиками, анализом регрессии и другими чудными инструментами. Его даже можно использовать для измерения вашей функции в разных плоскостях: например, измерять сразу время и пропускную способность. Так, можно проверить, насколько быстро нам удастся сконструировать, взять и собрать сырые байты, если пользоваться методами итератора из стандартной библиотеки в разных диапазонах гистограммы.

use std::iter;

use criterion::BenchmarkId;
use criterion::Criterion;
use criterion::Throughput;
use criterion::{criterion_group, criterion_main};

fn from_elem(c: &mut Criterion) {
    static KB: usize = 1024;

    let mut group = c.benchmark_group("from_elem");
    for size in [KB, 2 * KB, 4 * KB, 8 * KB, 16 * KB].iter() {
        group.throughput(Throughput::Bytes(*size as u64));
        group.bench_with_input(BenchmarkId::from_parameter(size), size, |b, &size| {
            b.iter(|| iter::repeat(0u8).take(size).collect::<Vec<_>>());
        });
    }
    group.finish();
}

criterion_group!(benches, from_elem);
criterion_main!(benches);

А после того, как добавим следующие записи в файл Cargo.toml проекта, можно выполнить код с `cargo bench`.

[dev-dependencies]
criterion = "0.3"

[[bench]]
name = "BENCH_NAME"
harness = false

Criterion не только даёт вам в распоряжение потрясающие и очень информативные диаграммы, но и позволяет вспоминать результаты предыдущих контрольных прогонов, что позволяет судить об отклонениях производительности. Например, я делал на компьютере сразу много дел, тем временем делая контрольный прогон – конечно, поэтому результаты получились хуже, чем при последнем измерении. Тем не менее, это по-настоящему круто!

    Found 11 outliers among 100 measurements (11.00%)
      2 (2.00%) low mild
      4 (4.00%) high mild
      5 (5.00%) high severe
    from_elem/8192          time:   [79.816 ns 79.866 ns 79.913 ns]
                            thrpt:  [95.471 GiB/s 95.528 GiB/s 95.587 GiB/s]
                     change:
                            time:   [+7.3168% +7.9223% +8.4362%] (p = 0.00 < 0.05)
                            thrpt:  [-7.7799% -7.3407% -6.8180%]
                            Performance has regressed.
    Found 3 outliers among 100 measurements (3.00%)
      2 (2.00%) high mild
      1 (1.00%) high severe
    from_elem/16384         time:   [107.22 ns 107.28 ns 107.34 ns]
                            thrpt:  [142.15 GiB/s 142.23 GiB/s 142.31 GiB/s]
                     change:
                            time:   [+3.1408% +3.4311% +3.7094%] (p = 0.00 < 0.05)
                            thrpt:  [-3.5767% -3.3173% -3.0451%]
                            Performance has regressed.

Чтобы разобраться в ключевых концепциях – читайте стандартную библиотеку!

Мне нравится углубляться в стандартную библиотеку, в особенности это касается std::rc, std::iter и std::collections. Вот некоторые великолепные вещи, которые я изучил самостоятельно:

Как именно реализуется вектор

Способы достижения внутренней изменяемости различными методами из std::cell и std::rc

Как реализуются каналы в std::sync

Какова магия std::sync::Arc

Прямо от авторов Rust получил подробные разъяснения о тех или иных решениях, принятых при проектировании и разработке библиотек

Надеюсь, пост получился информативным для тех ребят, которые только приступают к работе с Rust и сталкиваются с определёнными преградами.

Ура

Громкое «ура» моим коллегам из Offchain Labs – Рэйчел и Ли Бусфилдам за их широчайшие знания языка Rust. Некоторые их советы вдохновили меня на создание этого поста.




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