Rust: первые шаги. Todo, который всё забывает
Про Rust все слышали одно и то же. Быстрый как C. Безопасный как ничто другое. Невозможно выучить.
Первые два пункта — правда. Третий — нет, но с оговоркой: Rust не сложный. Он несговорчивый. Это разные вещи, и разница видна уже на первом часе.
Здесь не будет евангелизма и таблиц сравнения с Go. Будет вот что: ставим тулчейн, делаем проект, пишем на нём todo-list для терминала. И по дороге наступаем на все грабли, на которые наступает человек в первые два дня. Обходить я их не буду специально — половина смысла как раз в том, чтобы посмотреть, что компилятор говорит, когда вы неправы. Говорит он на удивление по-человечески.
Зависимостей — ноль. Только стандартная библиотека. Да, в реальном проекте на такой CLI навесили бы clap и serde и написали бы то же самое втрое короче. Но тогда мы бы знакомились не с Rust, а с двумя крейтами: читатель скопировал бы #[derive(Parser)] и не понял в нём ни строчки. Так что руками.
В конце будет работающая программа, которая теряет всё при выходе. Во второй части научим её помнить.
Ставим
Способ один. Он же официальный, он же правильный:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Это rustup — менеджер тулчейнов (примерно как NVM или RVM для ноды и руби соответственно). Не ставьте Rust из системного пакетного менеджера, серьёзно. Через полгода вам понадобится другая версия компилятора или лишний таргет, и вы всё равно придёте к rustup. Только уже с конфликтом в PATH.
Проверяем:
rustc --version
cargo --version
rustc 1.98.1 (48a229cea 2026-09-01)
cargo 1.98.1 (797e8a9bc 2026-08-05)
rustc — компилятор, его вы почти никогда не будете вызывать руками. cargo — всё остальное: сборка, зависимости, тесты, публикация. Дальше в статье только он.
Из редакторов — VS Code с rust-analyzer. Или любой другой редактор с тем же rust-analyzer. Это не «желательно», это обязательно: типы в Rust почти везде выводятся, и без подсказок вы буквально не видите, что лежит в переменной. Больше половины смысла в такой строке — то, чего в ней не написано.
Первый проект
cargo new todo
cd todo
Внутри:
todo/
Cargo.toml
.gitignore
src/
main.rs
Ещё .git рядом: cargo new сразу инициализирует репозиторий, если вы не внутри другого. Приятно.
Cargo.toml — манифест:
[package]
name = "todo"
version = "0.1.0"
edition = "2024"
[dependencies]
edition — это важная штука, о которой стоит сказать сразу, потому что она сбивает с толку. Эдишен — не версия языка. Это набор синтаксических соглашений: 2015, 2018, 2021, 2024. Компилятор 2026 года умеет собирать все четыре, и крейт на edition 2015 спокойно линкуется с крейтом на 2024. Поэтому Rust может менять неудобные вещи, не ломая экосистему: старый код продолжает собираться новым компилятором. Вам из этого важно ровно одно: когда гуглите ответ на Stack Overflow за 2019 год, смотрите на дату.
src/main.rs:
fn main() {
println!("Hello, world!");
}
cargo run
Compiling todo v0.1.0 (/tmp/todo)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.49s
Running `target/debug/todo`
Hello, world!
Две вещи про эту строчку.
println! с восклицательным знаком — это макрос, а не функция. Восклицательный знак после имени в Rust означает «это макрос» (сам по себе ! — ещё и отрицание, вы его увидите через несколько разделов). Нужен он тут потому, что println! проверяет формат на этапе компиляции: если вы напишете println!("{} {}", x) и забудете второй аргумент, программа не соберётся. Обычная функция так не умеет, отсюда макрос.
И cargo run собрал debug-сборку. Она медленная — иногда в десятки раз медленнее релизной. Не пугайтесь производительности до cargo build --release — это две разные программы по скорости.
Задача
Начнём с того, что такое задача. Текст и галочка:
struct Task {
title: String,
done: bool,
}
struct — это просто структура, ничего необычного. Необычное начинается со String.
В Rust два строковых типа, и это первое, обо что спотыкаются все. String — строка, которой владеет ваша переменная: она живёт в куче, её можно менять, её длину можно увеличивать. &str — «взгляд» на чужие байты: срез, ссылка, указатель с длиной. Литерал "купить молоко" в коде — это &str, он вшит прямо в бинарник и живёт всю программу.
Именно байты, а не символы, и для русского текста это не занудство. "купить молоко" — это тринадцать символов и двадцать пять байт, len() вернёт двадцать пять, а взять s[0] вам просто не дадут: индексация строки в Rust запрещена, потому что честного ответа на неё нет.
Поэтому вот это не соберётся:
let task = Task { title: "купить молоко", done: false };
error[E0308]: mismatched types
--> src/main.rs:7:28
|
7 | let task = Task { title: "купить молоко", done: false };
| ^^^^^^^^^^^^^^^ expected `String`, found `&str`
|
help: try using a conversion method
|
7 | let task = Task { title: "купить молоко".to_string(), done: false };
| ++++++++++++
Обратите внимание на help. Компилятор не просто сказал «типы не сошлись» — он предложил конкретную правку с плюсиками под тем местом, куда её вставить. Это вообще фирменная черта Rust: ошибки тут написаны так, будто их писал человек, который сам на этом спотыкался. Привыкайте читать их целиком, а не хватать первую строку.
Правим на String::from("купить молоко") (или .to_string(), разницы нет) и идём дальше.
Первые грабли: значение уезжает
Теперь список. Складываем задачу в вектор:
fn main() {
let task = Task {
title: String::from("купить молоко"),
done: false,
};
let mut tasks = Vec::new();
tasks.push(task);
println!("первая задача: {}", task.title);
println!("всего задач: {}", tasks.len());
}
Положили в вектор. Напечатали. В любом другом языке это работает.
error[E0382]: borrow of moved value: `task`
--> src/main.rs:15:33
|
7 | let task = Task {
| ---- move occurs because `task` has type `Task`, which does not implement the `Copy` trait
...
13 | tasks.push(task);
| ---- value moved here
14 |
15 | println!("первая задача: {}", task.title);
| ^^^^^^^^^^ value borrowed here after move
|
note: if `Task` implemented `Clone`, you could clone the value
Вот оно.
Это то самое ownership, ради которого Rust вообще существует. Тут стоит остановиться, потому что дальше всё будет крутиться вокруг него.
Правило одно: у каждого значения ровно один владелец. Владелец ушёл из области видимости — значение освободилось. Компилятор знает, в какой именно строке вставить это освобождение, и вставляет.
Ни сборщика мусора. Ни free() руками. Ни double free и ни одного обращения к освобождённой памяти.
Утечки, кстати, в этот список не входят — и это не оговорка, а позиция языка. Забыть освободить память в безопасном Rust можно (mem::forget, Box::leak, кольцо из Rc), и это не считается дырой в безопасности: утечка никого не роняет, она просто занимает место.
Из этого правила следует всё остальное. tasks.push(task) не копирует задачу в вектор — он передаёт владение. Теперь хозяин строки — вектор, а переменная task больше ни на что не указывает. Компилятор её не «удалил», он пометил её как выехавшую и запретил трогать.
Три способа жить с этим:
- Не трогать после переезда. Читать из вектора:
tasks[0].title. Обычно этого достаточно, и это самый правильный вариант. - Одолжить, а не отдать. Про это ниже, это
&. - Скопировать.
#[derive(Clone)]иtasks.push(task.clone()). Работает, но если вы ставите.clone()каждый раз, когда компилятор ругается, вы пишете не на Rust, а на «Rust с налогом на невнимательность». Иногда клонировать правда надо. Первые недели — почти никогда.
Берём первый вариант:
println!("первая задача: {}", tasks[0].title);
Собралось.
Кстати, let mut tasks — заметили mut? В Rust всё неизменяемо по умолчанию: let x = 5 менять нельзя, а let mut x = 5 — можно. Это не константа: const в Rust отдельная сущность, а let без mut — обычная переменная, которой просто запрещено присваивать второй раз. Первую неделю mut забывается на каждой второй строке. Потом привыкаешь. А ещё позже замечаешь, что в чужом коде его удивительно мало, и это само по себе много говорит.
Debug, или как напечатать структуру
Хочется посмотреть, что внутри. println!("{}", task) не соберётся: {} — это для типов, которые знают, как показать себя пользователю, а структура по умолчанию не знает. Зато есть {:?} — для отладки:
#[derive(Debug)]
struct Task {
title: String,
done: bool,
}
println!("{:?}", tasks[0]);
println!("{:#?}", tasks[0]);
Task { title: "купить молоко", done: false }
Task {
title: "купить молоко",
done: false,
}
#[derive(...)] — это то, что в Rust вместо наследования. Трейт (Debug) описывает поведение, а derive просит компилятор написать реализацию за вас. Debug, Clone, PartialEq, Default — большая часть вашей повседневной жизни будет состоять из этой строчки. {:#?} — то же самое, но в столбик; на двух полях выигрыша немного, а на вложенном конфиге спасает.
Кстати, вы могли заметить в выводе warning:
warning: field `done` is never read
Компилятор ворчит на неиспользуемое поле. Он вообще много ворчит, и почти всегда по делу. Не привыкайте к жёлтому тексту в выводе — он потом перестаёт восприниматься.
Список и методы
Печатать по одной задаче скучно, нужен список. И заодно первый impl:
impl Task {
fn new(title: String) -> Task {
Task { title, done: false }
}
fn mark(&self) -> char {
if self.done { 'x' } else { ' ' }
}
}
impl Task — блок с методами типа, отдельно от объявления полей. Непривычно после языков, где всё в одних фигурных скобках, но удобно: реализаций трейтов для одного типа может быть много, и все они отдельными блоками.
fn new(title: String) -> Task — ассоциированная функция, self в ней нет. Это не конструктор в смысле языка, new — просто соглашение об имени. Вызывается через :: — Task::new(...).
fn mark(&self) -> char — метод, self есть. Вызывается через точку — task.mark().
Task { title, done: false } — сокращение: если переменная называется так же, как поле, писать title: title не надо.
И if self.done { 'x' } else { ' ' } — это не оператор, а выражение: оно возвращает значение. Выражением в Rust оказывается почти всё — if, match, блок в фигурных скобках. Отсюда же и return в конце функции обычно не пишут — последнее выражение без точки с запятой и есть результат. Точка с запятой, наоборот, значение выбрасывает, и это регулярный источник ошибки «expected char, found ()».
Теперь вывод:
fn print_list(tasks: &[Task]) {
if tasks.is_empty() {
println!(" (пусто)");
return;
}
for (i, task) in tasks.iter().enumerate() {
println!("{:>3}. [{}] {}", i + 1, task.mark(), task.title);
}
}
&[Task] вместо &Vec<Task> — это как раз пункт 2 из списка выше, «одолжить». Функция не забирает вектор, она получает срез — окно в чужие данные. Владелец остаётся снаружи, и после вызова им можно пользоваться дальше. Почему срез, а не &Vec: срез принимает и вектор, и массив, и кусок вектора, а &Vec — только вектор. Привычка брать &[T] в аргументах приходит быстро.
enumerate() даёт пары «индекс, элемент». {:>3} — выравнивание по правому краю в три символа, синтаксис форматирования тут почти как в Python.
И нумерация для человека начинается с единицы, а индексы в векторе — с нуля. Запомните это место, мы на нём споткнёмся через четыре раздела.
Команды: enum и match
Приложение интерактивное: запустили — ждёт команду, написали add купить молоко — добавило. Значит, строку надо разбирать.
И вот тут начинается то, ради чего в Rust остаются.
enum Command {
Add(String),
Done(usize),
Remove(usize),
List,
Quit,
Unknown(String),
}
Это не перечисление чисел, как в C. Это алгебраический тип: «значение — ровно одно из этого списка, и каждый вариант может тащить с собой свои данные». Add несёт строку, Done — число, List не несёт ничего. Одно значение Command занимает столько, сколько самый жирный вариант плюс отметка о том, какой это вариант: String внутри Add весит 24 байта, а size_of::<Command>() даёт 32.
Разбор:
fn parse(line: &str) -> Command {
let line = line.trim();
let (word, rest) = match line.split_once(' ') {
Some((word, rest)) => (word, rest.trim()),
None => (line, ""),
};
match word {
"" | "list" | "ls" => Command::List,
"add" | "a" => Command::Add(rest.to_string()),
"done" | "d" => number(rest, Command::Done),
"rm" => number(rest, Command::Remove),
"quit" | "q" | "exit" => Command::Quit,
other => Command::Unknown(other.to_string()),
}
}
fn number(rest: &str, make: fn(usize) -> Command) -> Command {
match rest.parse::<usize>() {
Ok(n) => make(n),
Err(_) => Command::Unknown(format!("не понял номер: {rest:?}")),
}
}
Здесь много всего, по порядку.
let line = line.trim(); — да, переменная переопределяет сама себя. Это называется shadowing и в Rust абсолютно нормально: новая line имеет тип &str и просто закрывает собой старую. Особенно удобно, когда вы что-то парсите и тип по дороге меняется.
split_once(' ') возвращает Option<(&str, &str)> — либо Some(пара), либо None, если пробела нет. Option в Rust вместо null, и это одна из двух-трёх вещей, ради которых язык стоит попробовать: значения, которого может не быть, не существует незаметно. Компилятор заставит вас написать, что делать в случае None. Отсюда же — в Rust нет NullPointerException, потому что нет null.
match — это switch, который умеет всё. Он разбирает варианты enum, вытаскивает из них данные (Some((word, rest)) — и word с rest уже у вас в руках), группирует варианты через |, и главное — он исчерпывающий. Забудете ветку — не соберётся. Добавите завтра в Command новый вариант — компилятор покажет все места, где его не обработали. На рефакторинге это спасает.
rest.parse::<usize>() — парсинг строки в число. Возвращает Result<usize, ParseIntError>: либо Ok(число), либо Err(ошибка). Та же история, что с Option: ошибку нельзя не заметить, потому что значение лежит внутри и достать его можно только разобрав.
fn(usize) -> Command в аргументах number — это указатель на функцию. А Command::Done — сам по себе функция: вариант enum с данными конструируется вызовом, значит, его можно передать как значение. Поэтому number(rest, Command::Done) работает. Мелочь, но приятная.
{rest:?} внутри format! — это inline-аргумент: имя переменной прямо в фигурных скобках, без второго аргумента. Появилось в Rust 1.58, в январе 2022-го, и к эдишенам отношения не имеет — на любом из четырёх работает одинаково. Разницу стоит держать в голове: эдишен меняет синтаксис, версия компилятора добавляет возможности, и это две разные оси.
Ещё момент, о котором вы могли не подумать: пустая строка у меня разбирается в Command::List. То есть нажал Enter — увидел список. Это не из учебника, это просто удобно, и я так решил, пока тестировал.
Цикл
use std::io::{self, Write};
fn main() {
let mut tasks: Vec<Task> = Vec::new();
println!("todo. команды: add <текст>, done <N>, rm <N>, list, quit");
loop {
print!("> ");
io::stdout().flush().unwrap();
let mut line = String::new();
let read = io::stdin().read_line(&mut line).unwrap();
if read == 0 {
println!();
break;
}
// ...тут разбор команды
}
}
loop — бесконечный цикл, выход через break. Есть ещё while и for, но когда цикл правда бесконечный, пишут loop: компилятор про это знает и умеет выводить типы лучше.
print! без ln не переводит строку — и вывод в терминал буферизован, поэтому приглашение > без flush() просто не появится, пока не накопится буфер. Выглядит как «программа зависла». Ловушка на пустом месте, ловил лично.
read_line(&mut line) — обратите внимание, строка передаётся изменяемой ссылкой, и функция дописывает в неё, а не возвращает новую. Такой стиль в стандартной библиотеке встречается часто: вызывающий выделяет буфер, вызываемый его наполняет. Меньше аллокаций.
Возвращает она число прочитанных байт. Ноль означает конец потока — читай, пользователь нажал Ctrl-D.
Это надо обработать. Иначе на Ctrl-D программа улетает в бесконечный цикл с пустой строкой, печатая приглашение тысячу раз в секунду. Обнаружил я это, разумеется, сразу же.
А .unwrap() — это «мне обещали Result, дай значение, а если там ошибка — просто упади». Некрасиво. В нормальном коде так не пишут. Но Result и оператор ? — тема второй части, а тут я сознательно оставляю костыль, чтобы не вываливать всё сразу. Мы к нему вернёмся, честно.
Вторые грабли: одолжить нельзя изменить
Хочу команду «убрать все выполненные». Пишу в лоб:
for (i, task) in tasks.iter().enumerate() {
if task.done {
tasks.remove(i);
}
}
error[E0502]: cannot borrow `tasks` as mutable because it is also borrowed as immutable
--> src/main.rs:15:7
|
13 | for (i, task) in tasks.iter().enumerate() {
| ------------------------
| |
| immutable borrow occurs here
| immutable borrow later used here
14 | if task.done {
15 | tasks.remove(i);
| ^^^^^^^^^^^^^^^ mutable borrow occurs here
А вот это уже borrow checker. Вторая половина системы, после ownership.
Правил снова мало. Два:
- ссылок для чтения (
&T) можно сколько угодно одновременно; - ссылка для записи (
&mut T) может быть только одна, и в этот момент ссылок для чтения быть не должно.
Проще говоря: либо много читателей, либо один писатель. Всё.
Здесь tasks.iter() держит читающую ссылку на весь вектор, пока крутится цикл. А remove хочет пишущую. Нельзя.
И это не придирка на ровном месте. Менять коллекцию, пока вы по ней идёте, — классический баг, просто в каждом языке он выглядит по-своему. В C++ это невалидный итератор и сегфолт. Или, что гораздо хуже, не сегфолт. В Java — ConcurrentModificationException в рантайме, у клиента. В Python — молча пропущенный элемент, который вы найдёте через месяц.
Rust ловит это при компиляции. Бесплатно. Всегда.
Точнее — всегда, пока вы сами не попросили обратного: RefCell и Mutex переносят ту же проверку в рантайм, и нарушение правила там кончается паникой, а не ошибкой сборки. Но это уже осознанный шаг, а не то, во что можно вляпаться по невнимательности.
Вот здесь и начинает доходить, за что именно платишь несговорчивостью.
Кстати, эти два правила — половина того, что в докладах называют «fearless concurrency». Гонка данных и есть «два писателя» или «писатель и читатель одновременно», то есть ровно то, что borrow checker уже запрещает, и ему всё равно, в одном потоке это происходит или в двух. Вторая половина — трейты Send и Sync: они решают, что вообще можно отдать в чужой поток и чем можно поделиться. Про них будет отдельный разговор в тот день, когда вы первый раз попробуете утащить Rc в thread::spawn.
А правильный ответ на задачу тут — один метод:
tasks.retain(|task| !task.done);
retain оставляет элементы, для которых замыкание вернуло true. |task| ... — это лямбда. Внутри retain есть пишущая ссылка на вектор, и никаких других ссылок при этом не живёт — конфликта нет.
Мораль, которая приходит примерно на третий день: когда borrow checker ругается на цикл, почти всегда в стандартной библиотеке уже есть метод, который делает ровно это. retain, iter_mut, drain, split_off. Rust очень не любит, когда вы крутите индексы руками, и обычно это сигнал, что вы делаете что-то не так.
Третьи грабли: usize не умеет в минус
Собираем команду done:
Command::Done(n) => match tasks.get_mut(n - 1) {
Some(task) => {
task.done = true;
println!(" готово: {}", task.title);
}
None => println!(" нет задачи с номером {n}"),
}
Логика простая: человек считает с единицы, вектор — с нуля, вычитаем единицу. get_mut возвращает Option<&mut Task>, то есть None, если индекс уехал за границы. Значит, done 99 отработает без паники.
Я молодец.
Запускаю. Тыкаю. done 99 — «нет задачи с номером 99». Отлично.
done 0 —
thread 'main' (1393207) panicked at src/main.rs:89:47:
attempt to subtract with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Приехали.
n — это usize, беззнаковое целое размером с указатель. 0 - 1 для беззнакового не минус один. Это переполнение.
И тут Rust делает вещь, которую стоит запомнить: в debug-сборке переполнение целого — это паника. Не тихое заворачивание в 18446744073709551615, от которого get_mut вернул бы None, а программа поехала бы дальше как ни в чём не бывало. Падение, файл, номер строки.
(В release-сборке проверка снята ради скорости, и там как раз произойдёт заворачивание. Это осознанный компромисс, и ровно поэтому отлаживаться надо в debug.)
Чинится так:
Command::Done(n) => match n.checked_sub(1).and_then(|i| tasks.get_mut(i)) {
checked_sub(1) возвращает Option<usize>: Some(n-1) или None, если ушло бы в минус. and_then — «если внутри что-то есть, скорми это дальше». В итоге и ноль, и 99 честно дают None, и обе ошибки обрабатываются одной веткой.
Для rm я написал по-другому, просто проверкой диапазона:
Command::Remove(n) => {
if n >= 1 && n <= tasks.len() {
let task = tasks.remove(n - 1);
println!(" удалил: {}", task.title);
} else {
println!(" нет задачи с номером {n}");
}
}
Потому что remove не возвращает Option, он паникует на плохом индексе, и красиво через and_then его не завернуть. Да, два разных стиля в одном файле — но каждый на своём месте, и мне это кажется честнее, чем натягивать один приём на оба случая.
Заодно смотрите, что делает tasks.remove(n - 1): он возвращает удалённую задачу. Владение выехало из вектора и переехало в переменную task, поэтому её можно спокойно напечатать. То самое ownership, только в обратную сторону.
Всё вместе
use std::io::{self, Write};
#[derive(Debug)]
struct Task {
title: String,
done: bool,
}
impl Task {
fn new(title: String) -> Task {
Task { title, done: false }
}
fn mark(&self) -> char {
if self.done { 'x' } else { ' ' }
}
}
enum Command {
Add(String),
Done(usize),
Remove(usize),
List,
Quit,
Unknown(String),
}
fn parse(line: &str) -> Command {
let line = line.trim();
let (word, rest) = match line.split_once(' ') {
Some((word, rest)) => (word, rest.trim()),
None => (line, ""),
};
match word {
"" | "list" | "ls" => Command::List,
"add" | "a" => Command::Add(rest.to_string()),
"done" | "d" => number(rest, Command::Done),
"rm" => number(rest, Command::Remove),
"quit" | "q" | "exit" => Command::Quit,
other => Command::Unknown(other.to_string()),
}
}
fn number(rest: &str, make: fn(usize) -> Command) -> Command {
match rest.parse::<usize>() {
Ok(n) => make(n),
Err(_) => Command::Unknown(format!("не понял номер: {rest:?}")),
}
}
fn print_list(tasks: &[Task]) {
if tasks.is_empty() {
println!(" (пусто)");
return;
}
for (i, task) in tasks.iter().enumerate() {
println!("{:>3}. [{}] {}", i + 1, task.mark(), task.title);
}
}
fn main() {
let mut tasks: Vec<Task> = Vec::new();
println!("todo. команды: add <текст>, done <N>, rm <N>, list, quit");
loop {
print!("> ");
io::stdout().flush().unwrap();
let mut line = String::new();
let read = io::stdin().read_line(&mut line).unwrap();
if read == 0 {
println!();
break;
}
match parse(&line) {
Command::Add(title) => {
if title.is_empty() {
println!(" add что?");
} else {
println!(" добавил: {title}");
tasks.push(Task::new(title));
}
}
Command::Done(n) => match n.checked_sub(1).and_then(|i| tasks.get_mut(i)) {
Some(task) => {
task.done = true;
println!(" готово: {}", task.title);
}
None => println!(" нет задачи с номером {n}"),
},
Command::Remove(n) => {
if n >= 1 && n <= tasks.len() {
let task = tasks.remove(n - 1);
println!(" удалил: {}", task.title);
} else {
println!(" нет задачи с номером {n}");
}
}
Command::List => print_list(&tasks),
Command::Unknown(what) => println!(" не знаю такой команды: {what}"),
Command::Quit => break,
}
}
println!("всего было задач: {}", tasks.len());
}
Сто с небольшим строк, ноль зависимостей. cargo run:
todo. команды: add <текст>, done <N>, rm <N>, list, quit
> добавил: купить молоко
> добавил: выгулять кота
> добавил: написать статью
> 1. [ ] купить молоко
2. [ ] выгулять кота
3. [ ] написать статью
> готово: выгулять кота
> 1. [ ] купить молоко
2. [x] выгулять кота
3. [ ] написать статью
> удалил: купить молоко
> 1. [x] выгулять кота
2. [ ] написать статью
> нет задачи с номером 0
> нет задачи с номером 99
> не знаю такой команды: фигня
>
всего было задач: 2
Работает.
Ещё две команды, которые стоит запомнить
cargo fmt
Форматирует код. Единственный стиль, никаких обсуждений про скобки на ревью — это, пожалуй, лучшее, что Rust забрал у Go.
И раз уж вы наверняка это заметили, скажу прямо: стандарт в Rust — четыре пробела. Так форматирует rustfmt из коробки, так написана стандартная библиотека, так выглядит почти всё, что вы откроете на GitHub. В листингах здесь их два, потому что мне так читается лучше. Это мой блог, и это единственный аргумент, который у меня есть.
Делается одним файлом рядом с Cargo.toml:
# rustfmt.toml
tab_spaces = 2
После этого cargo fmt считает правильным ответом два пробела, а cargo fmt --check перестаёт ругаться в CI. Никакой магии: rustfmt.toml — просто настройки на весь проект, и живут они в репозитории, так что у всех, кто его клонирует, форматирование будет одинаковое. Собственно, ради этого оно там и лежит.
Делать так вам не обязательно, и я бы даже сказал — не надо. Не потому, что два хуже четырёх, а потому, что четыре у вас по умолчанию и в них написан весь чужой код, который вы будете читать. Отступы на компиляцию не влияют никак: наберите листинги с четырьмя пробелами, и всё соберётся ровно так же.
Что ещё туда кладут, кроме отступов:
max_width = 100— где переносить строку. Сотня по умолчанию, и это та настройка, которую чаще всего хочется подвинуть.hard_tabs = false— пробелы или табы. По умолчанию пробелы, и не надо это трогать.newline_style = "Unix"— чем кончается строка; полезно, если в команде есть Windows.
Обидная деталь, на которую все натыкаются: самое интересное в rustfmt доступно только на nightly. Сортировка импортов по группам, перенос длинных комментариев — всё это существует, но на стабильном компиляторе вам скажут ровно это:
Warning: can't set `group_imports = StdExternalCrate`, unstable features are only available in nightly channel.
Не ошибка, а предупреждение: файл прочитан, строчка проигнорирована, форматирование прошло без неё. Стабильных опций хватает на девяносто процентов случаев, но про оставшиеся десять лучше узнать сейчас, чем через полчаса поисков, почему настройка не работает.
А если какой-то кусок форматировать не надо — таблицу констант, например, где выравнивание руками осмысленно, — над ним пишут #[rustfmt::skip], и rustfmt его не трогает.
cargo clippy
Линтер. И не такой, к каким вы привыкли: clippy знает несколько сотен идиом и говорит не «тут пробел не там», а «вот это место обычно пишут вот так». В первые недели он полезнее любого туториала — просто гоняйте его и читайте, что он предлагает, это бесплатное ревью от человека, который писал на Rust дольше вас.
На финальном коде выше оба молчат. Приятно.
Что получилось и что дальше
За один вечер — рабочий CLI и ноль зависимостей. А по дороге: ownership, borrowing, String против &str, Option, Result, enum с данными, match, impl, замыкания, срезы. Если честно, это процентов семьдесят того, что нужно для повседневного Rust.
И одна проблема.
Закрыли терминал — всё пропало. Наш todo-list живёт ровно столько, сколько живёт процесс, и как хранилище задач совершенно бесполезен.
Во второй части это и чиним:
- сохраняем в файл и читаем обратно;
- выкидываем все
.unwrap()и разбираемся наконец сResultи оператором?; - режем один
main.rsна модули, потому что сто строк в одном файле — это нормально, а двести — уже спорно; - пишем тесты, которые в Rust лежат прямо рядом с кодом;
- собираем релиз и ставим бинарник себе в
PATH.
А главное, что я бы хотел оставить после первой части, — вот что.
Когда компилятор Rust на вас ругается, он почти всегда прав. И он честно объясняет почему — не отпиской из трёх слов, а с подчёркиванием, с help и с предложенной правкой.
Это не язык, который вредничает. Это язык, который задаёт вопросы. Просто в других языках их задавать было некому, и ответ вы узнавали в проде.
Комментарии 0
Комментариев пока нет.