Прототипирование на Rust

23 minute read

Перевод статьи “Прототипирование на Rust

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

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

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

Rust, хоть и кажется строгим и минималистичным, на самом деле отлично подходит для экспериментов и быстрой проверки идей. И, вопреки стереотипам, на нём вполне удобно делать прототипы.

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

Чему можно научиться из этой статьи

  • Как быстро создавать прототипы на Rust, сохраняя при этом гарантии безопасности
  • Практические методы для получения быстрой обратной связи
  • Шаблоны, которые помогут вам превратить прототипы в производственный код

Почему люди думают, что Rust не подходит для создания прототипов

Обычно всё выглядит так:

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

Я заметил, что многие разработчики, которые ещё не очень хорошо знакомы с Rust, часто сталкиваются с этим стереотипом. Они спотыкаются о строгую систему типов и проверку заимствований, пытаясь быстро набросать решение. Им кажется, что в Rust ты либо вообще ничего не сделал (0%), либо у тебя всё идеально работает (100%), а промежуточных этапов как будто не существует.

Вот несколько популярных мифов, которые часто всплывают:

  • “Безопасность памяти и прототипирование — это как огонь и вода, они несовместимы”.
  • “Владение и заимствование убивают весь кайф от прототипирования”.
  • “Тебе нужно сразу продумать всё до мелочей”.
  • “Rust всегда заставляет тебя разбираться с ошибками”.

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

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

Ну, если тебе и так комфортно с Python, зачем вообще заморачиваться с Rust? Вопрос логичный! Python ведь славится своей простотой, быстрой обратной связью и динамической типизацией. Да и вообще, всегда можно потом переписать код на Rust, если вдруг понадобится.

Согласен, Python — это отличный инструмент для создания прототипов. Но я уже достаточно поработал с Python, чтобы понять: рано или поздно я выйду за рамки этапа прототипирования, и тогда язык начнёт меня ограничивать.

Особенно меня напрягает, как сложно превратить прототип на Python в стабильную и надёжную кодовую базу. Самые неприятные баги, с которыми я сталкивался, часто связаны с типами. Например, где-то глубоко в цепочке вызовов программа падает, потому что в функцию передали не тот тип данных. Из-за этого я хочу переходить на что-то более строгое, как только прототип начинает становиться чем-то серьёзным.

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

Не было бы круто, если бы один язык подходил и для прототипирования, и для продакшена?

Что делает Rust отличным средством для создания прототипов?

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

И знаете, не нужно тут просто на слово верить. Лучше послушайте, как в Discord делились опытом о том, почему они решили перейти с Go на Rust — это реально стоит внимания:

Примечательно, что при написании версии на Rust мы уделили оптимизации лишь базовое внимание. Даже при базовой оптимизации Rust смог превзойти версию Go, тщательно настроенную вручную. Это огромное свидетельство того, насколько легко писать эффективные программы на Rust по сравнению с глубоким погружением в Go. — Источник “Почему Discord переходит с Go на Rust

Как выглядит надежный рабочий процесс прототипирования Rust

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

У Python есть куча классных фишек, которые стоит взять на заметку:

  • Быстрая обратная связь — сразу видишь результат.
  • Менять решения — легко и безболезненно.
  • Простота использования (если не лезть в дебри).
  • Минимум шаблонов и лишней сложности.
  • Удобно экспериментировать и переделывать код.
  • Можно сделать что-то полезное буквально парой строк.
  • Нет никакой компиляции — сразу в бой.

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

Советы И рекомендации По созданию Прототипов В Rust

Используйте простые типы

Даже когда ты делаешь прототип, система типов всё равно будет с тобой. Но это скорее плюс, чем минус, если подойти с умом.

На старте лучше использовать простые типы вроде i32, String или Vec. Если вдруг понадобится что-то посложнее, всегда можно будет добавить это позже. А вот упростить уже готовое решение — куда труднее.

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

Прототип Прод Когда переключаться
String &str Когда нужно избежать выделения или хранить строковые данные с четким сроком службы
Vec<T> &[T] Когда собственный вектор становится слишком дорогим для клонирования или вы не можете позволить себе кучу
Box<T> &T или &mut T Когда Box становится узким местом или нет желания иметь дело с распределением памяти в куче
Rc<T> &T Когда накладные расходы на подсчет ссылок становятся слишком дорогими или вам нужна изменчивость
Arc<Mutex<T>> &mut T Когда вы можете гарантировать эксклюзивный доступ и вам не нужна потокобезопасность

Эти типы, которые сами управляют своей памятью, помогают избежать кучи головной боли с владением и временем жизни. Правда, они делают это за счёт выделения памяти в куче — примерно как это происходит в Python или JavaScript.

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

Используйте вывод типа

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

Часто можно просто не указывать типы явно — компилятор сам догадается, что ты имел в виду, исходя из контекста. Так что можно сэкономить время и нервы.

1let x = 42;
2let y = "hello";
3let z = vec![1, 2, 3];

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

1let x: Vec<i32> = vec![1, 2, 3];
2let y: Vec<i32> = vec![4, 5, 6];
3
4// From the context, Rust knows that `z` needs to be a `Vec<i32>`
5// The `_` is a placeholder for the type that Rust will infer
6let z = x.into_iter().chain(y.into_iter()).collect::<Vec<_>>();

Вот более сложный пример, который показывает, насколько мощным может быть вывод типов в Rust:

 1use std::collections::HashMap;
 2
 3// Start with some nested data
 4let data = vec![
 5    ("fruits", vec!["apple", "banana"]),
 6    ("vegetables", vec!["carrot", "potato"]),
 7];
 8
 9// Let Rust figure out this complex transformation
10// Can you tell what the type of `categorized` is?
11let categorized = data
12    .into_iter()
13    .flat_map(|(category, items)| {
14        items.into_iter().map(move |item| (item, category))
15    })
16    .collect::<HashMap<_, _>>();
17
18// categorized is now a HashMap<&str, &str> mapping items to their categories
19println!("What type is banana? {}", categorized.get("banana").unwrap());

Песочница

Непросто представить себе структуру categorized в своей голове, но Rust поможет это сделать.

Используйте песочницу Rust

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

Лично я считаю, что это очень удобно для быстрых экспериментов — например, чтобы набросать пару функций или типов и понять, работает ли твоя идея.

Используйте unwrap

На начальных этапах проекта можно смело использовать unwrap для обработки ошибок. Это как яркий маячок, который кричит: «Эй, тут нужно будет поправить позже!» С помощью простого поиска по коду ты легко найдёшь все unwrap и заменишь их на нормальную обработку ошибок, когда придёт время. Так ты убиваешь двух зайцев: быстро двигаешься вперёд и при этом оставляешь себе чёткие указания, где нужно доработать. Кстати, есть ещё инструмент clippy, который поможет найти все unwrap в твоём коде — очень удобно!

 1use std::fs;
 2use std::path::PathBuf;
 3
 4fn main() {
 5    // Quick and dirty path handling during prototyping
 6    let home = std::env::var("HOME").unwrap();
 7    let config_path = PathBuf::from(home).join(".config").join("myapp");
 8    
 9    // Create config directory if it doesn't exist
10    fs::create_dir_all(&config_path).unwrap();
11    
12    // Read the config file, defaulting to empty string if it doesn't exist
13    let config_file = config_path.join("config.json");
14    let config_content = fs::read_to_string(&config_file)
15        .unwrap_or_default();
16    
17    // Parse the JSON config
18    let config: serde_json::Value = if !config_content.is_empty() {
19        serde_json::from_str(&config_content).unwrap()
20    } else {
21        serde_json::json!({})
22    };
23    
24    println!("Loaded config: {:?}", config);
25}

Тут много всех этих unwrap. Для тех, кто уже поднаторел в Rust, они сразу бросаются в глаза — и это отлично!

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

Когда делаешь прототип на Rust, можно спокойно забить на обработку ошибок и сосредоточиться на основном сценарии. А потом, когда будет время, вернуться и всё улучшить.

Добавляйте anyhow в свои прототипы

Мне нравится подключать anyhow на ранних этапах работы над прототипом — это помогает лучше управлять ошибками. С ним я могу использовать bail! и with_context, чтобы быстро добавлять контекст к ошибкам, не замедляя процесс. А потом, когда будет время, можно вернуться к каждому случаю и придумать, как сделать обработку ошибок более аккуратной и красивой.

 1use anyhow::{bail, Context, Result};
 2
 3// Here's how to use `with_context` to add more context to an error
 4let home = std::env::var("HOME")
 5    .with_context(|| "Could not read HOME environment variable")?;
 6
 7// ...alternatively, use `bail` to return an error immediately 
 8let Ok(home) = std::env::var("HOME") else {
 9    bail!("Could not read HOME environment variable");
10};

Плюс anyhow в том, что он отлично подходит для реального кода, который пойдёт в продакшен. Так что тебе не придётся потом переделывать всю логику обработки ошибок — всё уже готово к использованию.

Используйте хорошую IDE

Для Rust есть отличная поддержка в IDE.

IDE здорово помогает с автодополнением кода и рефакторингом, что позволяет не выпадать из рабочего потока и писать код быстрее. Автодополнение в Rust работает куда лучше, чем в динамических языках, потому что система типов даёт IDE больше информации для анализа.

Кстати, не забудь включить в своём редакторе подсказки по вложенным типам (или встроенные подсказки по типам). Это позволит тебе сразу видеть предполагаемые типы прямо в IDE и проверять, совпадают ли они с тем, что ты задумал. Такая фича есть в большинстве IDE для Rust, включая RustRover и Visual Studio Code.

Используйте bacon для быстрых циклов обратной связи

Rust — это не скриптовый язык, так что компиляция тут неизбежна!

Хотя для небольших проектов компиляция занимает совсем немного времени. Правда, придётся либо вручную запускать cargo check после каждого изменения, либо использовать rust-analyzer в редакторе, чтобы получать обратную связь сразу.

Чтобы сделать процесс удобнее, можно подключить внешние инструменты вроде bacon. Они автоматически пересобирают и запускают код при каждом изменении. Так можно получить почти такой же опыт, как при работе с REPL в Python или Ruby.

1# Install bacon
2cargo install --locked bacon
3
4# Run bacon in your project directory
5bacon

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

О, и если вам интересно, cargo-watch был ещё одним популярным инструментом для этой цели, но с тех пор он устарел.

cargo-script это потрясающе

Знаете ли вы, что cargo также может запускать скрипты?

Например, поместите это в файл под названием script.rs:

1#!/usr/bin/env cargo +nightly -Zscript
2
3fn main() {
4    println!("Hello prototyping world");
5}

Теперь можно сделать файл исполняемым с помощью chmod +x script.rs и запустить его через ./script.rs. Это позволяет скомпилировать и выполнить код прямо из файла! Так можно быстро проверять идеи, не создавая отдельный проект. И что круто — даже зависимости поддерживаются.

Пока что cargo-script — это экспериментальная фича, но скоро она появится в стабильной версии Rust. Подробности можно почитать в RFC.

Не беспокойтесь о производительности

На Rust надо очень постараться, чтобы написать медленный код. Так что пользуйся этим: на этапе прототипа пиши код максимально просто и понятно.

Я как-то делал доклад под названием «Четыре всадника плохого кода на Rust», где говорил, что преждевременная оптимизация — это чуть ли не главный грех в Rust. Особенно это касается опытных разработчиков, которые перешли с C или C++ на Java — их часто тянет оптимизировать всё и сразу.

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

Так что не поддавайся соблазну оптимизировать раньше времени! Потом ты себе за это скажешь спасибо.

Используйте println! и dbg! для отладки

Мне кажется, что при работе над прототипами очень удобно просто выводить значения на печать. Это проще, чем каждый раз запускать отладчик, и экономит время.

  • Большинство используют для этого println!, но у dbg! есть свои плюсы:
  • Он показывает имя файла и номер строки, где был вызван. Это помогает быстрее найти, откуда взялся вывод.
  • Он выводит не только значение, но и само выражение.
  • С ним меньше мороки с синтаксисом. Например, dbg!(x) против println!("{x:?}").

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

 1fn factorial(n: u32) -> u32 {
 2    // `dbg!` returns the argument, 
 3    // so you can use it in the middle of an expression
 4    if dbg!(n <= 1) {
 5        dbg!(1)
 6    } else {
 7        dbg!(n * factorial(n - 1))
 8    }
 9}
10
11dbg!(factorial(4));

Результат получается красивым и аккуратным:

1[src/main.rs:2:8] n <= 1 = false
2[src/main.rs:2:8] n <= 1 = false
3[src/main.rs:2:8] n <= 1 = false
4[src/main.rs:2:8] n <= 1 = true
5[src/main.rs:3:9] 1 = 1
6[src/main.rs:7:9] n * factorial(n - 1) = 2
7[src/main.rs:7:9] n * factorial(n - 1) = 6
8[src/main.rs:7:9] n * factorial(n - 1) = 24
9[src/main.rs:9:1] factorial(4) = 24

Имей в виду, что не стоит оставлять вызовы dbg! в финальном коде — они будут работать даже в релизной версии. Если хочешь узнать больше о том, как правильно использовать макрос dbg!, вот тебе ссылка с подробностями.

Проектирование с помощью типов

Честно говоря, система типов — это одна из главных причин, почему я так люблю Rust. Мне нравится выражать свои идеи через типы и смотреть, как они оживают в коде. Я бы советовал активно использовать систему типов даже на этапе прототипирования.

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

Сначала работа с системой типов в Rust может казаться медленнее, чем в более динамичных языках, но в итоге это часто приводит к меньшему количеству итераций. Думай об этом так: в Python каждая итерация может быть быстрее, потому что ты пропускаешь этап определения типов, но, скорее всего, понадобится больше итераций, чтобы учесть все крайние случаи и инварианты, которые не были очевидны с самого начала. В Rust система типов заставляет тебя продумывать эти связи заранее. Хотя каждая итерация занимает больше времени, в итоге ты получаешь более надёжное решение с меньшим количеством доработок.

Именно это мы увидим в следующем примере.

Допустим, ты моделируешь систему записи студентов на курсы. Начни с чего-то простого:

1struct Enrollment {
2    student: StudentId,
3    course: CourseId,
4    is_enrolled: bool,
5}

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

Ну, легко же, да? Просто добавим ещё один флаг-булевку!

1struct Enrollment {
2    student: StudentId,
3    course: CourseId,
4    is_enrolled: bool,
5    is_waitlisted: bool, // 🚩 uh oh
6}

Проблема в том, что оба флага могут быть одновременно true! Это создаёт недопустимые состояния, когда студент может быть и зачислен, и в списке ожидания одновременно.

Давай на секунду подумаем, как можно использовать систему типов Rust, чтобы сделать такие ситуации невозможными…

Вот одна из идей:

 1enum EnrollmentStatus {
 2    Active {
 3        date: DateTime<Utc>,
 4    },
 5    Waitlisted {
 6        position: u32,
 7    },
 8}
 9
10struct Enrollment {
11    student: StudentId,
12    course: CourseId,
13    status: EnrollmentStatus,
14}

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

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

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

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

Так что не бойся экспериментировать с типами и постоянно улучшать свой код по ходу работы.

Макрос todo!

Один из краеугольных камней прототипирования заключается в том, что вам не обязательно сразу знать все ответы. В Rust я использую todo! макрос, чтобы выразить эту идею.

Обычно я просто создаю каркас функций или модуля, а затем заполняю пробелы.

 1// We don't know yet how to process the data
 2// but we're pretty certain that we need a function
 3// that takes a Vec<i32> and returns an i32
 4fn process_data(data: Vec<i32>) -> i32 {
 5    todo!()
 6}
 7
 8// There exists a function that loads the data and returns a Vec<i32>
 9// How exactly it does that is not important right now
10fn load_data() -> Vec<i32> {
11    todo!()
12}
13
14fn main() {
15    // Given that we have a function to load the data
16    let data = load_data();
17    // ... and a function to process it
18    let result = process_data(data);
19    // ... we can print the result
20    println!("Result: {}", result);
21}

Мы пока сделали не так много, но у нас уже есть чёткое понимание, что должна делать программа. Теперь можно двигаться дальше и пересмотреть дизайн. Например, стоит ли process_data использовать ссылку на данные? Может, создать структуру для хранения данных и логики обработки? Или использовать итератор вместо вектора? А что, если ввести трейт для поддержки алгоритмов обработки данных?

Эти вопросы полезны, и мы можем обдумать их, не углубляясь в детали реализации. При этом наш код остаётся типобезопасным, компилируется и готов к рефакторингу.

unreachable! для недоступных ветвей

Макрос можно использовать unreachable! для обозначения ветвей кода, которые никогда не должны выполняться.

1fn main() {
2    let age: u8 = 170;
3    
4    match age {
5        0..150 => println!("Normal human age"),
6        150.. => unreachable!("Witchcraft!"),
7     }
8}

Это отличный способ зафиксировать свои предположения о коде. Результат будет таким же, как если бы ты использовал todo!, но это более явный сигнал о том, что эта ветка кода никогда не должна выполняться:

1thread 'main' panicked at src/main.rs:6:18:
2internal error: entered unreachable code: Witchcraft!

Тут видно, что мы добавили сообщение в макрос unreachable!, чтобы было понятно, на каком предположении основан этот код.

Используйте assert! для инвариантов

Ещё один способ зафиксировать свои предположения — это макрос assert!. Он особенно удобен, когда нужно убедиться, что определённые условия выполняются прямо во время работы программы.

Вот, например, как можно переписать тот код, что был выше:

1fn main() {
2    let age: u8 = 170;
3    
4    assert!(age < 150, "This is very unlikely to be a human age");
5    
6    println!("Normal human age");
7}

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

Рассмотрите возможность использования debug_assert! для дорогостоящих проверок инвариантов, которые должны выполняться только в тестовых/отладочных сборках.

Избегайте дженериков

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

Так что вместо того, чтобы писать вот так:

1fn foo<T>(x: T) -> T {
2    // ...
3}

Напиши

1fn foo(x: i32) -> i32 {
2    // ...
3}

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

Обобщения (generics) стоит применять только тогда, когда ты видишь, что одно и то же повторяется в нескольких местах. Лично я не трогаю обобщения до последнего. Мне нужно сначала почувствовать, как это неудобно — дублировать код, — и только потом я начинаю его абстрагировать. И знаешь, часто оказывается, что проблема не в том, что не хватает обобщений, а в том, что есть какой-то более крутой алгоритм или структура данных, которые решают задачу намного лучше.

И ещё: не увлекайся слишком сложными сигнатурами с обобщениями. Это может только всё усложнить.

1fn foo<T: AsRef<str>>(x: T) -> String {
2    // ...
3}

Да, это позволяет передавать &str или String, но за счёт снижения читабельности.

Просто используй собственный тип для вашей первой реализации:

1fn foo(x: String) -> String {
2    // ...
3}

Скорее всего, такая гибкость тебе и не пригодится.

Если подвести итог, то дженерики — это, конечно, мощная штука, но они могут здорово усложнить код, как его написание, так и чтение. Лучше не лезь в них, пока точно не поймёшь, зачем они тебе нужны.

Избегаем пожизненного заключения

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

 1// First attempt with references - compiler error!
 2struct Note<'a> {
 3    title: &'a str,
 4    content: &'a str,
 5}
 6
 7fn create_note() -> Note<'_> {  // ❌ lifetime error
 8    let title = String::from("Draft");
 9    let content = String::from("My first note");
10    Note {
11        title: &title,
12        content: &content
13    }
14}

Этот код не собирается, потому что ссылки не живут за пределами функции.

 1   Compiling playground v0.0.1 (/playground)
 2error[E0106]: missing lifetime specifier
 3 --> src/lib.rs:7:26
 4  |
 57 | fn create_note() -> Note<'_> {  // ❌ lifetime error
 6  |                          ^^ expected named lifetime parameter
 7  |
 8  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
 9help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`, or if you will only have owned values
10  |

Песочница

Простой способ обойти эту проблему — пока вообще не заморачиваться с временем жизни. На старте оно тебе не нужно. Используй типы, которые сами управляют владением, например, String и Vec. А если нужно передать данные, просто везде добавляй .clone(). Это не самое эффективное решение, зато быстро и без головной боли.

 1// Much simpler with owned types
 2struct Note {
 3    title: String,
 4    content: String,
 5}
 6
 7fn create_note() -> Note {  // ✓ just works
 8    Note {
 9        title: String::from("Draft"),
10        content: String::from("My first note")
11    }
12}

Если у тебя есть тип, который нужно передавать между потоками (то есть он должен быть Send), можно использовать Arc<Mutex<T>>, чтобы обойти проверку заимствования. Да, это может быть не так быстро, но если переживаешь о производительности, напомни себе, что в других языках, вроде Python или Java, это происходит под капотом, и ты даже не замечаешь. Так что не переживай — иногда удобство и безопасность стоят небольших накладных расходов.

 1use std::sync::{Arc, Mutex};
 2use std::thread;
 3
 4let note = Arc::new(Mutex::new(Note {
 5    title: String::from("Draft"),
 6    content: String::from("My first note")
 7}));
 8
 9let note_clone = Arc::clone(&note);
10thread::spawn(move || {
11    let mut note = note_clone.lock().unwrap();
12    note.content.push_str(" with additions");
13});

Песочница

Если кажется, что слишком часто используете Arc<Mutex>, возможно, проблема в дизайне. Например, можно попробовать избежать совместного использования состояния между потоками.

Четкая иерархия

Файл main.rs — верный помощник при разработке прототипа.

Просто добавляй туда свой код — без всяких модулей и сложных структур. Это дает свободу для экспериментов и быстрого перемещения элементов при необходимости.

Первый черновик: все в main.rs:

 1struct Config {
 2    port: u16,
 3}
 4fn load_config() -> Config {
 5    Config { port: 8080 }
 6}
 7struct Server {
 8    config: Config,
 9}
10impl Server {
11    fn new(config: Config) -> Self {
12        Server { config }
13    }
14    fn start(&self) {
15        println!("Starting server on port {}", self.config.port);
16    }
17}
18fn main() {
19    let config = load_config();
20    let server = Server::new(config);
21    server.start();
22}

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

Эксперименты со структурой модуля в том же файле main.rs:

 1mod config {
 2    pub struct Config {
 3        pub port: u16,
 4    }
 5    pub fn load() -> Config {
 6        Config { port: 8080 }
 7    }
 8}
 9
10mod server {
11    use crate::config;
12    pub struct Server {
13        config: config::Config,
14    }
15    impl Server {
16        pub fn new(config: config::Config) -> Self {
17            Server { config }
18        }
19        pub fn start(&self) {
20            println!("Starting server on port {}", self.config.port);
21        }
22    }
23}

Эта структура с вложенными модулями позволяет быстро проверять разные способы организации кода. Легко перемещать куски кода между разными частями проекта через копипасту и пробовать разные API и названия функций. Когда найдёте подходящую структуру, можно перенести модули в отдельные файлы.

Самое важное — не усложнять раньше времени. Лучше начинать с простого, а потом постепенно добавляйте структуру, когда лучше поймём задачу.

Кстати, почитайте статью от Матклада про большие рабочие пространства в Rust.

Начинаем с малого

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

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

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

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

Кстати, прототипирование и подход “лёгкий Rust” во многом пересекаются.

Краткие сведения

Крутость прототипирования на Rust в том, что даже твои “черновые” наброски остаются такими же безопасными и быстрыми, как и готовый код. Даже если я везде пихаю unwrap(), сваливаю всё в main.rs и использую свои типы без лишних заморочек, такой код всё равно будет надёжнее, чем прототип на Python, и при этом гораздо шустрее. Это делает Rust отличным выбором для экспериментов с реальными задачами, даже если ты пока не хочешь заморачиваться с правильной обработкой ошибок.

Давай глянем, как Rust может работать в паре с Python для создания прототипов:

Аспект Python Rust
Начальная скорость разработки ✓ Очень быстро писать код ⚠️ При первоначальной разработке немного медленнее
✓ Без этапа компиляции ✓ Помогает вывод типов
✓ Динамическая типизация ускоряет создание прототипов ✓ Такие инструменты, как bacon обеспечивают быструю обратную связь
✓ Доступны средства наблюдения за файлами  
     
Стандартная библиотека ✓ Батарейки в комплекте ❌ Стандартная библиотека меньшего размера
✓ Богатая экосистема ✓ Растущая экосистема высококачественных ящиков
     
Переход к проду ❌ Требуется тщательное тестирование для выявления ошибок в типах ✓ Требуется минимум изменений, помимо обработки ошибок
❌ Плохая производительность может потребовать дополнительной работы или переписывания на другом языке ✓ Уже имеет хорошую производительность
✓ Безопасность памяти гарантирована
     
Техническое обслуживание ❌ Ошибки типа всплывают во время выполнения ✓ Компилятор улавливает большинство проблем
❌ Рефакторинг сопряжен с риском ✓ Безопасный рефакторинг с помощью type system
     
Эволюция кода ❌ Сложно поддерживать большие базы кода ✓ Улучшения руководств по компилятору
❌ Проблемы с типом усугубляются Типы помогают управлять сложностью

Скажу честно: Rust — отличный язык для прототипирования, если использовать его сильные стороны. Да, система типов заставит тебя больше думать о дизайне на этапе разработки, но это скорее плюс! Каждая итерация может занимать чуть больше времени, чем в Python или JavaScript, но зато тебе понадобится меньше итераций, чтобы дойти от прототипа до рабочего кода.

Я заметил, что с другими языками мои прототипы часто упираются в тупик, когда нужно перейти к чему-то более надёжному. С Rust я могу начать с чего-то простого и постепенно превратить это в рабочий код, оставаясь в рамках одного языка и экосистемы.

Если у тебя есть ещё какие-то советы или идеи по прототипированию на Rust, дай знать — я добавлю их в список!

  • Опытные разработчики на Rust могут использовать impl IntoIterator<Item=T> там, где хватило бы &[T] или Vec<T>. Не усложняй без нужды!
  • В своём докладе я привёл пример, когда слишком ранняя оптимизация привела к неправильной абстракции и замедлила код. Настоящая проблема оказалась в другом месте, и её было сложно найти без профилирования.
  • Обычно я понимаю, что нашёл хорошую абстракцию, когда могу использовать все фишки Rust, вроде выражения-ориентированного программирования и сопоставления с образцом, вместе с моими собственными типами.

Ссылки