Перейти к содержимому

Rust для разработчиков на C#

Если ты зарабатываешь на жизнь C#, хорошая новость: многое в Rust покажется знакомым. В Rust есть обобщения с ограничениями, async/await, мощное сопоставление с образцом, цепочки итераторов, которые читаются почти как LINQ, и один отличный консольный инструмент (Cargo), который делает то же, что dotnet. Ты уже умеешь мыслить типами, интерфейсами и лямбдами. Эти знания пригодятся.

Главный сдвиг касается памяти и ошибок. В Rust нет сборщика мусора. Вместо этого у каждого значения один владелец, а компилятор проверяет владение и заимствование до запуска программы. Ещё в Rust нет null (вместо него Option) и нет исключений (вместо них Result). Именно на эти три изменения уйдёт большая часть времени на обучение. Эта страница сопоставляет то, что ты знаешь по C# и .NET, с Rust, чтобы ты мог потратить время на то, что действительно ново.

Если тебе нужно общее сравнение с C++, Go и Python, загляни в урок Rust против C++, Go и Python.

Пользуйся этой таблицей как разговорником. В последнем столбце — ссылка на урок этого сайта, который объясняет сторону Rust.

C# Rust Подробнее
class с полями и методами struct для данных + блок impl для методов Структуры
interface trait Трейты
Абстрактный класс, наследование Наследования нет. Используй композицию (структура внутри структуры) + трейты с методами по умолчанию Трейты, Паттерны проектирования
List<IShape> из разных классов Vec<Box<dyn Shape>> (трейт-объект) Трейт-объекты
Методы расширения Свой трейт, реализованный для чужого типа (например, String) Трейты
Обобщения с where T : IComparable<T> Обобщения с ограничениями трейтов: T: Ord или where T: Ord Обобщения
null, Nullable<int>, nullable-ссылочные типы Option<T>: либо Some(value), либо None Option
?? и ?. .unwrap_or(...), .map(...), .and_then(...) и ? Option
Исключения, throw, try/catch Result<T, E> + оператор ? Result, Свои типы ошибок
Исключение для настоящей ошибки в коде (например, InvalidOperationException) panic! (останавливает поток) Паника
IDisposable + using Drop, который срабатывает автоматически, когда владелец выходит из области видимости (RAII) Область видимости, Умные указатели
Сборщик мусора Владение и заимствование, проверяемые при компиляции Владение, Заимствование
record struct с #[derive(Debug, Clone, PartialEq)] Структуры, Популярные трейты
Выражение with для record Синтаксис обновления структуры: Order { total: 5, ..old } Структуры
enum (только именованные целые числа) enum, у которого каждый вариант может нести свои данные Перечисления
Выражения switch, шаблоны is match (компилятор проверяет, что ты учёл все случаи) match, Сопоставление с образцом
Кортежи (int, string) Кортежи (i32, String) Кортежи
Параметры ref / out &mut T, или верни кортеж либо Option Заимствование
Лямбды, Func<T>, Action<T>, делегаты Замыкания, описываемые трейтами Fn, FnMut, FnOnce Замыкания, Fn, FnMut и FnOnce
event Канал или список упакованных замыканий (колбэков) Каналы, Fn, FnMut и FnOnce
LINQ: Where, Select, Sum, OrderBy, ToList Адаптеры итераторов: filter, map, sum, sort_by (у Vec), collect Итераторы, Сортировка
IEnumerable<T> и yield return impl Iterator<Item = T> или свой тип, реализующий Iterator Свои итераторы
async Task<T> / await async fn возвращает Future; для запуска нужна среда выполнения вроде tokio Async/Await
Task.Run, Thread, lock std::thread::spawn или tokio::spawn; Mutex<T> оборачивает данные, которые защищает Многопоточность, Общее состояние
Свойства (get; set;) Свойств нет. Используй поля pub или методы-геттеры и сеттеры Структуры
string и StringBuilder &str (заимствованный взгляд на текст) и String (владеющая, растущая строка) Строки
Интерполяция строк $"Hi {name}" format!("Hi {name}") и println!("Hi {name}") Вывод на экран
var let (по умолчанию неизменяемая; пиши let mut, чтобы разрешить изменения) Переменные
const, readonly const и обычный let (и так только для чтения) Переменные
Поле static readonly static или LazyLock для значений, создаваемых при запуске Переменные
namespace и директивы using Модули (mod) и use Модули
Сборка / проект Крейт (библиотека или исполняемый файл) Cargo и крейты
Атрибуты вроде [Serializable] Атрибуты вроде #[derive(Serialize)] и derive-макросы Макросы, JSON и Serde
Рефлексия Во время работы почти нет. Вместо неё макросы и derive генерируют код при компиляции Макросы
unsafe и указатели Блоки unsafe и сырые указатели Unsafe
Span<T>, ReadOnlySpan<char> Срезы: &[T] и &str Срезы
Переопределение ToString(), IFormattable Реализуй Display (и .to_string() заработает сам собой) Популярные трейты
IEquatable<T>, IComparable<T>, GetHashCode PartialEq/Eq, PartialOrd/Ord, Hash (обычно через derive) Популярные трейты
Операторы преобразования implicit / explicit From/Into (и TryFrom, если преобразование может не удаться) Преобразования
Перегрузка операторов (operator +) Реализуй трейты из std::ops, например Add Продвинутые трейты
Арифметика checked / unchecked checked_add, wrapping_add, saturating_add Переполнение целых чисел
decimal Встроенного десятичного типа нет; используй крейт rust_decimal или храни копейки в целом числе Типы данных

Стандартная библиотека покрывает почти всё из System.Collections.Generic. Названия меняются, идеи — нет.

C# Rust Подробнее
List<T> Vec<T> Векторы
T[] (фиксированная длина) [T; N], если длина известна при компиляции, иначе Vec<T> Массивы
Dictionary<K, V> HashMap<K, V> HashMap
SortedDictionary<K, V> BTreeMap<K, V> (ключи остаются отсортированными) HashMap
HashSet<T> HashSet<T> HashSet
SortedSet<T> BTreeSet<T> HashSet
Queue<T> VecDeque<T> (push_back / pop_front) VecDeque и BinaryHeap
Stack<T> Vec<T> с push / pop Векторы
PriorityQueue<TElement, TPriority> BinaryHeap<T> (сначала наибольший; оберни в Reverse, чтобы сначала был наименьший) VecDeque и BinaryHeap
LinkedList<T> LinkedList<T> есть, но нужен редко; VecDeque обычно быстрее VecDeque и BinaryHeap
ConcurrentDictionary<K, V> Mutex<HashMap<K, V>> или крейт dashmap Общее состояние

Одна вещь, за которой стоит следить: как и у Dictionary, у HashMap в Rust нет фиксированного порядка. Если выводишь содержимое, сначала отсортируй его или используй BTreeMap.

Эта часть нравится большинству разработчиков на C# сразу. Cargo в одном инструменте делает работу CLI dotnet, MSBuild и NuGet.

.NET Rust Примечания
CLI dotnet cargo Собирает, запускает, тестирует, добавляет пакеты, публикует. См. Cargo и крейты
.csproj Cargo.toml Короткий, легко править руками
packages.lock.json Cargo.lock Создаётся автоматически
.sln с несколькими проектами Рабочее пространство Cargo (workspace) Один Cargo.toml наверху перечисляет крейты-участники
NuGet / nuget.org crates.io Документация по API каждого опубликованного крейта лежит на docs.rs
dotnet add package Serilog cargo add tracing
dotnet new console cargo new my_app (cargo new --lib для библиотеки)
dotnet run / build / test cargo run / cargo build / cargo test
-c Release --release Отладочные сборки работают намного медленнее; замеряй в release
dotnet watch cargo watch или bacon (ставятся через cargo install)
dotnet tool install -g cargo install
global.json (версия SDK) rustup и файл rust-toolchain.toml
Анализаторы Roslyn cargo clippy Сотни полезных проверок, официально
dotnet format cargo fmt Один стандартный стиль для всех
Visual Studio / Rider VS Code + rust-analyzer или JetBrains RustRover rust-analyzer даёт автодополнение, переход к определению и ошибки прямо в коде
XML-комментарии /// Doc-комментарии /// в Markdown + cargo doc Примеры кода в документации запускаются как тесты. См. Документация
xUnit / NUnit / MSTest Встроенные #[test] и cargo test (cargo nextest — популярный более быстрый раннер) См. Тестирование
Moq mockall или написанные вручную фейки, реализующие твой трейт
Coverlet (покрытие кода) cargo llvm-cov или cargo tarpaulin
BenchmarkDotNet criterion См. Производительность
dotnet-trace, профилировщики cargo flamegraph, perf или любой нативный профилировщик

Вот небольшой Cargo.toml, чтобы сравнить его с .csproj:

[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

Библиотеки и фреймворки: «Что использовать вместо…?»

Заголовок раздела «Библиотеки и фреймворки: «Что использовать вместо…?»»

Это первый вопрос, который задаёт любая команда на .NET: «Мы используем Entity Framework. А что есть в Rust?» Вот ближайшие аналоги. За Rust не стоит одна компания с «стеком по умолчанию», как Microsoft за .NET, поэтому часто приходится выбирать между двумя-тремя хорошими крейтами от сообщества.

.NET Rust Примечания
Entity Framework Core Diesel: ORM и построитель запросов; запросы проверяются на типы при компиляции. Миграции через diesel_cli Зрелый (2.x), по умолчанию синхронный (есть diesel-async)
Entity Framework Core (асинхронный, в стиле сущностей) SeaORM: асинхронный ORM с сущностями, связями и миграциями (sea-orm-cli) Ближе всего к EF; построен поверх sqlx
Dapper sqlx: ты пишешь обычный SQL, а макрос query! проверяет его на настоящей базе данных при компиляции. Миграции через sqlx-cli Асинхронный, очень популярный, не ORM
ADO.NET sqlx, tokio-postgres, rusqlite (SQLite) Низкоуровневые драйверы
StackExchange.Redis redis

Если тебе нравился EF, начни с SeaORM. Если нравился Dapper и свой SQL — попробуй sqlx. Если нужны самые сильные гарантии при компиляции — Diesel. В уроке Базы данных все три показаны на работающем коде.

.NET Rust Примечания
ASP.NET Core Web API axum (мы делаем такой в проекте веб-API), actix-web, Rocket axum и actix-web широко используются в продакшене
Minimal APIs axum: маршруты — обычные обработчики async fn Стиль очень похож
Конвейер middleware Слои tower и крейт tower-http (CORS, сжатие, трассировка, таймауты) axum использует tower напрямую
Встроенное внедрение зависимостей State в axum, обычные конструкторы и трейты См. строку про DI ниже
Swashbuckle / OpenAPI utoipa
SignalR WebSocket в axum (фича ws) или tokio-tungstenite Встроенного протокола в стиле SignalR нет
gRPC (Grpc.AspNetCore) tonic (с prost для protobuf)
HttpClient reqwest По умолчанию асинхронный, есть необязательный блокирующий клиент
JWT bearer-аутентификация jsonwebtoken
.NET Rust Примечания
System.Text.Json / Newtonsoft.Json serde + serde_json Стандарт; также умеет TOML, YAML и другое. См. JSON и Serde
ILogger / Serilog tracing (+ tracing-subscriber) или log + env_logger Структурированное логирование со спанами. См. Логирование
IConfiguration / appsettings.json config или figment Слои из файлов и переменных окружения, затем десериализация через serde
DI-контейнер (IServiceCollection) Обычно ничего: передавай зависимости в конструкторы, используй трейты для того, что хочешь подменять в тестах shaku существует, но контейнеры используют немногие проекты
AutoMapper Пиши реализации From / Into Явно и проверяется компилятором. См. Преобразования
FluentValidation / DataAnnotations validator или garde (валидация через derive)
Polly (повторы) backon или middleware повторов из tower
MediatR Прямого аналога нет Обычные трейты или каналы между задачами
Hangfire / фоновые задачи tokio::spawn для задач внутри процесса; apalis для постоянных очередей задач
CancellationToken Удали future или используй CancellationToken из tokio-util
.NET Rust Примечания
Blazor WebAssembly Leptos, Yew, Dioxus Все до версии 1.0; API ещё меняются. См. Rust и WebAssembly
WPF / WinForms / MAUI Tauri (веб-интерфейс + бэкенд на Rust), Slint, egui, Iced Самые стабильные — Tauri 2 и Slint 1.x. Официального GUI-тулкита нет
Unity Bevy или Rust внутри Godot через крейт godot Bevy ещё до 1.0 и ломает API между релизами
ML.NET candle, burn, linfa Намного моложе ML-инструментов Python
Azure SDK для .NET Официальный Azure SDK для Rust: azure_core, azure_identity, azure_storage_blob, … Охватывает меньше сервисов, чем SDK для .NET

Посмотрим на настоящий код. Вот небольшой банковский счёт на C#:

var account = new BankAccount("Ada", 100);
account.Deposit(50);
Console.WriteLine($"Владелец: {account.Owner}, баланс: {account.Balance}");
public class BankAccount
{
public string Owner { get; }
private decimal balance;
public BankAccount(string owner, decimal balance)
{
Owner = owner;
this.balance = balance;
}
public void Deposit(decimal amount) => balance += amount;
public decimal Balance => balance;
}

И на Rust:

struct BankAccount {
owner: String,
balance: i64,
}
impl BankAccount {
// `new` — просто соглашение, а не ключевое слово.
fn new(owner: &str, balance: i64) -> Self {
BankAccount { owner: owner.to_string(), balance }
}
// `&mut self`: этот метод изменяет счёт.
fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// `&self`: этот метод только читает.
fn balance(&self) -> i64 {
self.balance
}
}
fn main() {
let mut account = BankAccount::new("Ada", 100);
account.deposit(50);
println!("Владелец: {}, баланс: {}", account.owner, account.balance());
}

Вывод:

Владелец: Ada, баланс: 150
▶ Попробуй сам

Данные и поведение живут в двух отдельных блоках. Конструкторов нет — есть только функции, которые возвращают Self. Каждый метод сразу говорит, читает ли он значение (&self) или изменяет его (&mut self). А поскольку deposit изменяет счёт, переменная должна быть объявлена как let mut. В C# любой метод может поменять любое поле; в Rust изменения видны в сигнатуре.

В C# у тебя мог бы быть интерфейс IShape с двумя классами и List<IShape>:

var shapes = new List<IShape> { new Circle { Radius = 1 }, new Rectangle { Width = 2, Height = 3 } };
foreach (var s in shapes) Console.WriteLine($"{s.Area():F2}");
public interface IShape { double Area(); }
public class Circle : IShape
{
public double Radius { get; init; }
public double Area() => Math.PI * Radius * Radius;
}
public class Rectangle : IShape
{
public double Width { get; init; }
public double Height { get; init; }
public double Area() => Width * Height;
}

В Rust интерфейс — это трейт, и каждая структура реализует его в своём блоке impl:

trait Shape {
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
fn main() {
// Box<dyn Shape> играет роль «ссылки на какой-то IShape».
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 1.0 }),
Box::new(Rectangle { width: 2.0, height: 3.0 }),
];
for shape in &shapes {
println!("{:.2}", shape.area());
}
}

Вывод:

3.14
6.00
▶ Попробуй сам

В C# каждый вызов через интерфейс — виртуальный. В Rust выбираешь ты: Box<dyn Shape> использует виртуальный вызов (см. Трейт-объекты), а обобщённая функция вроде fn print_area<T: Shape>(s: &T) компилируется отдельно для каждого типа, вообще без виртуального вызова. Приятный бонус: свой трейт можно реализовать для типов, которые писал не ты, даже для String или i32. Так Rust делает то, что в C# делают методы расширения.

C# с nullable-ссылочными типами предупреждает о null, но во время работы null всё равно существует:

string? FindEmail(int userId) => userId == 1 ? "[email protected]" : null;
var email = FindEmail(2);
if (email is not null) Console.WriteLine(email.ToUpper());
else Console.WriteLine("email не указан");
Console.WriteLine(FindEmail(2) ?? "неизвестно");

В Rust значение, которого может не быть, имеет тип Option<T>. Его нельзя использовать как обычное значение, пока ты не обработал случай None:

fn find_email(user_id: u32) -> Option<String> {
if user_id == 1 {
Some("[email protected]".to_string())
} else {
None
}
}
fn main() {
match find_email(1) {
Some(email) => println!("{}", email.to_uppercase()),
None => println!("email не указан"),
}
// `if let` — короткая форма, когда важен только один случай.
if let Some(email) = find_email(2) {
println!("найден {email}");
} else {
println!("email не указан");
}
// unwrap_or работает как оператор ?? в C#.
let email = find_email(2).unwrap_or("неизвестно".to_string());
println!("{email}");
}

Вывод:

email не указан
неизвестно
▶ Попробуй сам

В Rust нет NullReferenceException, потому что нет null, который можно разыменовать. Компилятор просто не даст вызвать .to_uppercase() у Option<String>. Вспомогательные методы описаны в уроке Option, а короткие формы — в уроке if let и while let.

В C# разбор строки может бросить исключение, и ты ловишь его где-то выше по стеку вызовов:

static int TotalSeats(string a, string b) => int.Parse(a) + int.Parse(b);
try
{
Console.WriteLine(TotalSeats("12", "30"));
Console.WriteLine(TotalSeats("12", "thirty"));
}
catch (FormatException e)
{
Console.WriteLine($"Ошибка: {e.Message}");
}

В Rust функция, которая может завершиться неудачей, говорит об этом в типе возвращаемого значения. Оператор ? означает «если здесь произошла ошибка, сразу верни её вызывающему». Он делает то же, что всплытие исключения, но это видно в коде:

use std::num::ParseIntError;
fn total_seats(a: &str, b: &str) -> Result<i32, ParseIntError> {
let first: i32 = a.parse()?;
let second: i32 = b.parse()?;
Ok(first + second)
}
fn main() {
for (a, b) in [("12", "30"), ("12", "thirty")] {
match total_seats(a, b) {
Ok(total) => println!("Всего: {total}"),
Err(e) => println!("Ошибка: {e}"),
}
}
}

Вывод:

Всего: 42
Ошибка: invalid digit found in string
▶ Попробуй сам

Текст invalid digit found in string («в строке недопустимая цифра») печатает сама стандартная библиотека Rust, поэтому он на английском.

Главное отличие: в C# любой метод может бросить исключение, и сигнатура об этом не говорит. В Rust Result — часть сигнатуры, поэтому вызывающий код не сможет забыть его обработать. Для своих типов ошибок смотри урок Свои типы ошибок. В приложениях крейт anyhow даёт что-то близкое к «поймать любое исключение» (см. anyhow и thiserror).

Бок о бок: LINQ становится цепочкой итераторов

Заголовок раздела «Бок о бок: LINQ становится цепочкой итераторов»

Типичный запрос LINQ:

var orders = new List<Order> { new("Ada", 120), new("Grace", 80), new("Linus", 200), new("Ken", 150) };
var bigSpenders = orders
.Where(o => o.Total > 100)
.OrderByDescending(o => o.Total)
.Select(o => o.Customer)
.ToList();
Console.WriteLine(string.Join(", ", bigSpenders));
Console.WriteLine($"Выручка: {orders.Sum(o => o.Total)}");
record Order(string Customer, int Total);

Версия на Rust использует адаптеры итераторов и замыкания:

struct Order {
customer: String,
total: u32,
}
fn main() {
let orders = vec![
Order { customer: "Ada".to_string(), total: 120 },
Order { customer: "Grace".to_string(), total: 80 },
Order { customer: "Linus".to_string(), total: 200 },
Order { customer: "Ken".to_string(), total: 150 },
];
// Where(...).ToList()
let mut big: Vec<&Order> = orders.iter().filter(|o| o.total > 100).collect();
// OrderByDescending(...): сортировка делается у Vec, а не у итератора.
big.sort_by(|a, b| b.total.cmp(&a.total));
// Select(...)
let names: Vec<&str> = big.iter().map(|o| o.customer.as_str()).collect();
println!("{}", names.join(", "));
// Sum(...)
let revenue: u32 = orders.iter().map(|o| o.total).sum();
println!("Выручка: {revenue}");
}

Вывод:

Linus, Ken, Ada
Выручка: 550
▶ Попробуй сам

Итераторы в Rust ленивые, как отложенное выполнение в LINQ: ничего не происходит, пока ты не вызовешь что-то вроде collect(), sum() или не запустишь цикл for. collect() — это твой ToList(). В отличие от LINQ, цепочки итераторов обычно компилируются в тот же машинный код, что и написанный вручную цикл, так что за красивый стиль не приходится платить. Сортировка разобрана в уроке Сортировка и поиск.

В C# нужно не забыть using, чтобы вовремя освободить ресурс:

using (var conn = new Connection("orders-db"))
{
Console.WriteLine($"запрос к {conn.Name}");
}
Console.WriteLine("после блока");
class Connection : IDisposable
{
public string Name { get; }
public Connection(string name) => Name = name;
public void Dispose() => Console.WriteLine($"закрываем соединение с {Name}");
}

В Rust очистка происходит автоматически. Когда владелец значения выходит из области видимости, Rust вызывает его реализацию Drop. Забыть тут нечего:

struct Connection {
name: String,
}
impl Drop for Connection {
fn drop(&mut self) {
println!("закрываем соединение с {}", self.name);
}
}
fn main() {
{
let conn = Connection { name: "orders-db".to_string() };
println!("запрос к {}", conn.name);
} // здесь `conn` выходит из области видимости, поэтому срабатывает drop().
println!("после блока");
}

Вывод:

запрос к orders-db
закрываем соединение с orders-db
после блока
▶ Попробуй сам

Это называется RAII, и так Rust управляет всем: файлами, сокетами, блокировками и самой памятью. Файл закрывается, когда удаляется его значение File. Mutex разблокируется, когда удаляется его guard. Нет финализаторов и пауз сборщика мусора; очистка происходит в предсказуемый момент.

Все, кто приходит из C#, натыкаются на одни и те же кочки. Знать их заранее полезно.

  • Присваивание перемещает, а не копирует ссылку. В C# var b = a; для класса даёт две ссылки на один объект. В Rust let b = a; перемещает владение String или Vec в b, и a больше нельзя использовать:

    fn main() {
    let owner = String::from("Ada");
    let copy = owner;
    println!("{owner} and {copy}");
    }
    error[E0382]: borrow of moved value: `owner`
    --> src/main.rs:4:16
    |
    2 | let owner = String::from("Ada");
    | ----- move occurs because `owner` has type `String`, which does not implement the `Copy` trait
    3 | let copy = owner;
    | ----- value moved here
    4 | println!("{owner} and {copy}");
    | ^^^^^ value borrowed here after move

    Компилятор говорит, что значение owner уже перемещено. Исправить можно заимствованием (let copy = &owner;) или осознанным клонированием (owner.clone()). Сначала прочитай урок Владение — там объясняется это правило.

  • Анализатор заимствований. Одновременно может быть много читателей (&T) или один писатель (&mut T), но никогда и то и другое. Обычные для C# схемы — например, объекты, которые ссылаются обратно на родителя, или много объектов, меняющих один общий объект, — требуют умных указателей или другой архитектуры. Приготовься к нескольким неделям раздражения. Потом станет намного легче.

  • String и &str. В C# один тип string. В Rust два: String владеет своим текстом, &str заимствует его. Функции обычно принимают &str, а структуры обычно хранят String. См. Строки.

  • Нет наследования. Нельзя написать базовый класс с общими полями и переопределяемыми методами. Для общего поведения используй трейты (с методами по умолчанию), а общие данные клади в структуру, которую содержат другие структуры.

  • Нет исключений. Ошибки — это значения. Ты будешь часто писать ?, и твой main тоже может возвращать Result. Вызывать .unwrap() во время обучения нормально, но это версия Rust для «проигнорировать исключение и упасть».

  • Нет null. Поначалу Option повсюду кажется лишней работой. Через какое-то время заметишь, что ошибки с null-ссылками пропали.

  • Явные клоны. Rust никогда не копирует значение в куче у тебя за спиной. Если видишь .clone(), значит, делается настоящая копия. Это фича: стоимость видна.

  • Время компиляции. Чистая сборка проекта с множеством крейтов занимает больше времени, чем сборка .NET. Инкрементальные сборки и cargo check (который пропускает генерацию кода) намного быстрее; используй cargo check, пока редактируешь.

  • Для async нужна среда выполнения. В .NET среда выполнения и пул потоков есть всегда. В Rust async fn ничего не делает, пока её не запустит исполнитель, поэтому ты добавляешь tokio и пишешь #[tokio::main] над main. См. Async/Await.

Этот сайт не обязательно читать по порядку. Для разработчика на C# этот маршрут пропускает то, что ты уже знаешь, и тратит время там, где Rust отличается:

  1. Первые шаги: установи Rust и запусти cargo new.
  2. Переменные и типы данных: пробеги глазами, в основном чтобы узнать let mut и числовые типы.
  3. Владение, затем заимствование, затем срезы: самые важные для тебя страницы. Не торопись.
  4. Строки: String и &str.
  5. Структуры и перечисления, затем match и сопоставление с образцом.
  6. Option и Result, затем свои типы ошибок.
  7. Трейты, обобщения, популярные трейты и трейт-объекты.
  8. Замыкания и итераторы: твой LINQ.
  9. Векторы, HashMap и VecDeque и BinaryHeap.
  10. Модули, Cargo и крейты и тестирование.
  11. Времена жизни и умные указатели.
  12. Многопоточность, общее состояние и async/await.
  13. JSON и Serde и логирование, а потом собери проект веб-API.

В обоих упражнениях дан фрагмент на C#. Перенеси его на Rust.

Перенеси этот код на C#. Он должен вывести названия товаров, которые есть в наличии и стоят меньше 20. Подсказка: сделай struct с #[derive(Debug, Clone, PartialEq)], затем используй filter, map и collect в Vec<&str>. У Vec<&str> есть метод .join(", ").

var products = new List<Product>
{
new("Блокнот", 5, true),
new("Наушники", 45, true),
new("Ручка", 2, false),
new("Кружка", 12, true),
};
var cheap = products.Where(p => p.InStock && p.Price < 20).Select(p => p.Name);
Console.WriteLine(string.Join(", ", cheap));
record Product(string Name, int Price, bool InStock);
Показать решение
#[derive(Debug, Clone, PartialEq)]
struct Product {
name: String,
price: u32,
in_stock: bool,
}
fn product(name: &str, price: u32, in_stock: bool) -> Product {
Product { name: name.to_string(), price, in_stock }
}
fn main() {
let products = vec![
product("Блокнот", 5, true),
product("Наушники", 45, true),
product("Ручка", 2, false),
product("Кружка", 12, true),
];
let cheap: Vec<&str> = products
.iter()
.filter(|p| p.in_stock && p.price < 20)
.map(|p| p.name.as_str())
.collect();
println!("{}", cheap.join(", "));
}

Вывод:

Блокнот, Кружка
▶ Попробуй сам

Небольшая вспомогательная функция играет роль позиционного конструктора record. Атрибуты derive дают то, что record в C# даёт бесплатно: вывод через {:?}, копирование через .clone() и сравнение через ==.

Этот код на C# берёт один товар из словаря остатков и бросает исключение, если что-то не так. Перенеси его так, чтобы take возвращала Result<u32, String>, а не бросала исключение. Подсказка: stock.get_mut(item) возвращает Option<&mut u32>. Преврати его в Result с помощью .ok_or(...), а затем используй ?.

var stock = new Dictionary<string, int> { ["яблоки"] = 3, ["груши"] = 0 };
int Take(string item)
{
if (!stock.TryGetValue(item, out var count))
throw new KeyNotFoundException($"нет товара «{item}»");
if (count == 0)
throw new InvalidOperationException($"{item} закончились");
stock[item] = count - 1;
return count - 1;
}
foreach (var item in new[] { "яблоки", "груши", "киви" })
{
try { Console.WriteLine($"взяли: {item}, осталось {Take(item)}"); }
catch (Exception e) { Console.WriteLine($"ошибка: {e.Message}"); }
}
Показать решение
use std::collections::HashMap;
fn take(stock: &mut HashMap<String, u32>, item: &str) -> Result<u32, String> {
let count = stock
.get_mut(item)
.ok_or(format!("нет товара «{item}»"))?;
if *count == 0 {
return Err(format!("{item} закончились"));
}
*count -= 1;
Ok(*count)
}
fn main() {
let mut stock = HashMap::new();
stock.insert("яблоки".to_string(), 3);
stock.insert("груши".to_string(), 0);
for item in ["яблоки", "груши", "киви"] {
match take(&mut stock, item) {
Ok(left) => println!("взяли: {item}, осталось {left}"),
Err(e) => println!("ошибка: {e}"),
}
}
}

Вывод:

взяли: яблоки, осталось 2
ошибка: груши закончились
ошибка: нет товара «киви»
▶ Попробуй сам

TryGetValue с параметром out превращается в get_mut, который возвращает Option. Два throw становятся значениями Err, а try/catch — выражением match. &mut stock в вызове ясно показывает, что take изменяет словарь.

  • Многое из C# переносится: обобщения, async/await, лямбды, сопоставление с образцом и итераторы в стиле LINQ — у всего этого есть близкие аналоги в Rust.
  • Главные сдвиги: владение вместо сборщика мусора, Option вместо null и Result + ? вместо исключений.
  • Классы становятся struct + impl, интерфейсы — трейтами, а наследование — композицией плюс трейты. using/IDisposable превращается в автоматический Drop.
  • Cargo заменяет CLI dotnet, MSBuild и NuGet; clippy и rustfmt заменяют анализаторы и dotnet format; тестирование встроено.
  • Вместо EF Core смотри на SeaORM или Diesel, вместо Dapper — на sqlx, вместо ASP.NET Core — на axum или actix-web. Варианты для GUI, игр и ML моложе.

Проверь себя

1. Что Rust использует вместо null?

2. Твоя команда использует Dapper и любит писать SQL вручную. Какой крейт Rust ближе всего?

3. Что в Rust ближе всего к блоку using с IDisposable из C#?

4. В C# var b = a; для класса копирует ссылку. Что делает let b = a; со String в Rust?

Результат: 0 / 4

  • Rust for C#/.NET Developers — Microsoft (на английском): собственное руководство Microsoft, с гораздо большим количеством примеров на C# и Rust бок о бок.
  • Язык программирования Rust: официальная книга (перевод на русский). Для разработчиков на C# важнее всего главы 4 (владение), 9 (ошибки) и 10 (обобщения и трейты).
  • Getting Started with Diesel и документация SeaORM (на английском): два главных ORM, если ты ищешь замену Entity Framework.
  • sqlx на docs.rs (на английском): выбор в стиле Dapper, с SQL, проверяемым при компиляции.
  • Are We Web Yet? и Are We GUI Yet? (на английском): обзоры веб- и GUI-библиотек Rust от сообщества.