Привет, Хабр!

Знакомая история: пишешь код, всё работает, ревью прошло, поехали дальше. А через какое-то время натыкаешься в документации на метод, который делал ровно то же самое — в одну строку, без твоего велосипеда на десять. И остаётся только вздохнуть: ну где ж ты был раньше!

Со стандартной библиотекой Rust так постоянно. Она большая, и половину её мы не используем не потому, что она плохая, а потому, что просто про неё не знаем — и пишем руками то, что давно лежит готовым. Во второй части уже разбирали такие штуки: never-тип !, matches!, black_box, прозрачные обёртки repr(transparent) и transmute_copy. Подготовил для вас ещё девять.

Вы клонируете поле только чтобы вытащить его из &mut self

Знакомо: метод с &mut self, и надо забрать поле целиком — отдать наружу или поменять на новое. А компилятор не даёт вынести значение из-под &mut. Логика такая: если заберёшь, в структуре останется дырка, а тип обязан всегда быть целым. Вот и приходится клонировать поле, хотя оригинал больше не нужен, или оборачивать всё в Option ради .take().

mem::take (с 1.40) все это решит без клонов. Забирает значение, а на его место кладёт Default. Перенос бесплатный, это обычный move:

use std::mem;

struct Parser {
    tokens: Vec<String>,
}

impl Parser {
    fn drain_tokens(&mut self) -> Vec<String> {
        // отдаём Vec наружу, в self.tokens остаётся пустой вектор
        mem::take(&mut self.tokens)
    }
}

Нет Default — бери mem::replace (он в языке с самого начала): кладёт то, что дал ты, и отдаёт старое. Шикарено заходит на машины состояний, где надо забрать текущее состояние и сразу положить следующее:

enum State {
    Idle,
    Running(Job),
    Done,
}

fn advance(state: &mut State) {
    // забираем текущее состояние, временно ставим заглушку
    let current = mem::replace(state, State::Idle);
    *state = match current {
        State::Running(job) => {
            job.finish();
            State::Done
        }
        other => other,
    };
}

Кстати, Option::take, которой все пользуются, внутри — это mem::take.

Вы пишете if cond { Some(x) } else { None }

Мелочь, а попадается постоянно: из условия надо сделать Option. Особенно в filter_map, где None значит «пропусти». И почти всегда это пишут трёхстрочным if.

У bool для этого есть then (с 1.50) и then_some (с 1.62). Первый берёт замыкание, второй — готовое значение:

// было
let role = if is_admin { Some(Role::Admin) } else { None };

// стало
let role = is_admin.then(|| Role::Admin);     // через замыкание
let port = use_tls.then_some(443);            // через значение

// и где это правда красит код — внутри итераторов
let evens: Vec<i32> = (0..10)
    .filter_map(|n| (n % 2 == 0).then_some(n))
    .collect();

then ленивый: замыкание не сработает, если условие false. then_some считает значение всегда, ещё до проверки. Так что если справа что-то тяжёлое или с побочкой — берем then.

Вы режете строку через find и индексы

Разобрать key=value, снять Bearer у токена, отрезать расширение файла. Обычно это делают через find, а потом режут срезами &s[..i] и &s[i+1..]. И каждый раз думаешь, не уехал ли индекс, и рискуешь паникой на границе символа UTF-8.

split_once (с 1.52) режет по первому вхождению и отдаёт Option<(&str, &str)>:

let line = "timeout=30";
if let Some((key, value)) = line.split_once('=') {
    // key = "timeout", value = "30"
}

// "a=b=c".split_once('=')   ->  Some(("a", "b=c"))    только по первому
// "a=b=c".rsplit_once('=')  ->  Some(("a=b", "c"))    rsplit_once — по последнему

А strip_prefix и strip_suffix (с 1.45) снимают префикс или суффикс и возвращают Option (None, если не совпало):

let header = "Bearer abc123";
let token = header.strip_prefix("Bearer ");   // Some("abc123"), или None если префикса нет

let name = "archive.tar.gz";
let base = name.strip_suffix(".gz");          // Some("archive.tar")

А вот про это знают не все: аргумент — не обязательно строка. Это любой Pattern: символ, строка, набор символов &['=', ':'] или замыкание |c: char| .... То есть разделителем может быть условие:

"  hello".strip_prefix(char::is_whitespace);              // Some(" hello") — снимает один пробел
"timeout: 30".split_once(|c: char| c == '=' || c == ':'); // Some(("timeout", " 30"))

И ещё пара мелочей. split_once вернёт None, если разделителя нет вообще. И не путаем strip с trim: trim срезает все повторы и всегда отдаёт &str, а strip снимает один раз и даёт Option.

Вы держите Option рядом с итератором, чтобы заглянуть вперёд

В парсерах и токенайзерах постоянно нужно посмотреть на следующий элемент, но не съедать его — решить, что делать дальше. И народ таскает рядом с итератором Option со «следующим» и вручную его обновляет.

.peekable() (с 1.0) оборачивает итератор, а .peek() показывает следующий элемент по ссылке, не сдвигая сам итератор:

let mut it = "3 + 4".chars().peekable();

// пропускаем пробелы, подглядывая вперёд
while let Some(&c) = it.peek() {
    if c.is_whitespace() {
        it.next();        // съели пробел
    } else {
        break;
    }
}

А next_if (с 1.51) забирает следующий элемент только если он проходит проверку:

let mut nums = [1, 2, 3].into_iter().peekable();
nums.next_if(|&n| n == 1);    // съест 1
nums.next_if(|&n| n == 99);   // None, ничего не съест

Удобно тем, что не надо вручную возвращать подсмотренный элемент обратно в итератор.

Вы сортируете по убыванию через sort плюс reverse

По убыванию — и человек пишет .sort(), следом .reverse(), то есть два прохода. Или компаратор руками, |a, b| b.cmp(a), и через раз путает, где a, а где b. А с несколькими ключами компаратор превращается в лесенку из if.

cmp::Reverse (с 1.19) — обёртка, которая переворачивает сравнение. Завернул ключ — сортировка по нему идёт по убыванию, в один проход:

use std::cmp::Reverse;

struct Task {
    priority: u32,
    name: String,
}

let mut tasks: Vec<Task> = load_tasks();

// по убыванию приоритета, без отдельного .reverse()
tasks.sort_by_key(|t| Reverse(t.priority));

Для нескольких ключей — then и then_with (с 1.17): они склеивают сравнения и переходят к следующему только при равенстве на предыдущем:

// приоритет по убыванию, при равных приоритетах — имя по возрастанию
tasks.sort_by(|a, b| {
    Reverse(a.priority).cmp(&Reverse(b.priority))
        .then_with(|| a.name.cmp(&b.name))
});

then берёт готовый Ordering, then_with — замыкание (зовёт его только если первое сравнение дало Equal). Дорогой второй ключ — бери then_with.

И заодно про сами сортировки: sort стабильная, но выделяет буфер. sort_unstable быстрее и без аллокации, просто не сохраняет порядок равных элементов. Не нужна стабильность — бери sort_unstable_by_key. А Reverse ещё и превращает BinaryHeap в кучу-минимум, если класть в неё Reverse(value).

Вы фильтруете список дважды, чтобы разделить его надвое

Надо разложить элементы на «подходят» и «нет». Обычно гоняют .filter() два раза или пишут цикл с двумя Vec.

partition (с 1.0) делает это за один проход, раскидывая по двум коллекциям:

let nums = [1, 2, 3, 4, 5, 6];

// один проход: чётные в одну кучу, нечётные в другую
let (even, odd): (Vec<i32>, Vec<i32>) = nums.into_iter().partition(|&n| n % 2 == 0);
// even = [2, 4, 6], odd = [1, 3, 5]

Куда складывать — выводится из типа, не обязательно Vec, подойдёт любой FromIterator. true идёт в первую коллекцию, false — во вторую.

Вы пишете структуру и impl Iterator ради одноразового итератора

Нужна последовательность: числа по формуле, чтение до конца, что-то с состоянием. И ты заводишь отдельную структуру, реализуешь Iterator, пишешь next, держишь поля состояния. Гора кода ради одного места.

iter::successors (с 1.34) строит ряд, где следующий элемент считается из предыдущего:

use std::iter;

// Фибоначчи: следующая пара получается из текущей
let fibs: Vec<u64> = iter::successors(Some((0u64, 1u64)), |&(a, b)| Some((b, a + b)))
    .map(|(a, _)| a)
    .take(10)
    .collect();   // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

iter::from_fn (тоже с 1.34) ещё проще: итератор из замыкания, которое отдаёт Option. Some — продолжаем, None — конец. Состояние замыкание держит само, занеси его внутрь через move:

let mut count = 0;
let counter = iter::from_fn(move || {
    count += 1;
    (count <= 3).then_some(count)
});

let v: Vec<i32> = counter.collect();   // [1, 2, 3]

successors — когда следующий выводится из предыдущего. from_fn — когда логика сложнее или нужен цикл внутри. Оба ленивые и после None больше не зовутся.

Вы собираете массив через Vec, потому что не знаете как иначе

Нужен массив фиксированной длины, скажем [Vec<u8>; 8]. Через [Vec::new(); 8] не выйдет — такое требует Copy. И народ либо берёт Vec вместо массива, либо лезет в MaybeUninit с unsafe.

array::from_fn (с 1.63) зовёт замыкание для каждого индекса и собирает массив. Без unsafe и без Default:

use std::array;

// массив из 8 пустых буферов: ни Copy, ни Default не нужно
let buffers: [Vec<u8>; 8] = array::from_fn(|_| Vec::new());

// индекс приходит в замыкание — можно заполнять по позиции
let squares: [i32; 5] = array::from_fn(|i| (i * i) as i32);   // [0, 1, 4, 9, 16]

Длина берётся из типа, а индекс прилетает в замыкание — удобно для небольших таблиц и буферов.

Вы складываете два числа и надеетесь, что не переполнится

Пишешь a + b, и поведение при переполнении зависит от того, как собрана программа. В debug — паника, в release — молча оборачивается по модулю. То есть в проде число тихо станет не тем, и узнаешь об этом по последствиям.

Это не UB. Знаковое переполнение в C неопределено, и компилятор там может сделать с программой что угодно. В Rust переполнение — определённое: либо паника в debug, либо обёртка в release, и память в порядке в любом случае. Хочешь ловить переполнение и в релизе — поставь overflow-checks = true в профиле Cargo.toml.

Но полагаться на дефолт по сборке не стоит, поведение разное. У каждого целого есть четыре явных метода:

let a: u8 = 200;
let b: u8 = 100;
// 200 + 100 = 300, в u8 не помещается

a.checked_add(b);      // None — переполнилось, решай сам что делать
a.saturating_add(b);   // 255 — упёрлись в потолок типа и встали
a.wrapping_add(b);     // 44  — явная обёртка по модулю 2^8
a.overflowing_add(b);  // (44, true) — и результат, и флаг, что переполнилось

checked_* даёт Option, saturating_* упирается в границу типа, wrapping_* оборачивает осознанно, overflowing_* отдаёт результат и флаг. С любым из них поведение одинаковое.

А если весь расчёт идёт в одном режиме — есть обёртки Wrapping (давно) и Saturating (с 1.74). Они перегружают +, -, *, так что не надо звать методы на каждом шаге. Удобно в хешах, где обёртка — часть алгоритма:

use std::num::Wrapping;

let mut hash = Wrapping(0u32);
for &byte in data {
    hash = (hash << 5) ^ Wrapping(byte as u32);   // +, *, ^ тут уже wrapping
}
let result = hash.0;

Люблю Rust

Девять инструментов, и ни одного крейта, макроса или unsafe. Всё лежит в std годами, просто про это редко вспоминают.

А теперь вопрос в зал: какой инструмент из std вы дольше всего обходили стороной, а наткнувшись — поняли, какое это золото? Накидайте в комментах.

Если зайдёт — будет и третья часть, у std есть ещё много чего.


Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться