все посты

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.push(task)после push — пустоtaskвладеет строкойtasks[0]владеет строкойtaskтрогать нельзя
Владение переезжает в вектор

Три способа жить с этим:

  1. Не трогать после переезда. Читать из вектора: tasks[0].title. Обычно этого достаточно, и это самый правильный вариант.
  2. Одолжить, а не отдать. Про это ниже, это &.
  3. Скопировать. #[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

Комментариев пока нет.