Rust для программистов C#: полное учебное руководство
Подробное руководство по изучению Rust для разработчиков, которые уже знакомы с C#. Книга охватывает всё: от базового синтаксиса до продвинутых паттернов. Основной акцент сделан на концептуальных сдвигах и практических различиях между двумя языками.
Обзор курса
- Аргументы в пользу Rust — зачем Rust важен для разработчиков C#: производительность, безопасность и корректность
- Начало работы — установка, инструменты и первая программа
- Базовые строительные блоки — типы, переменные, управление потоком выполнения
- Структуры данных — массивы, кортежи, структуры, коллекции
- Сопоставление с образцом и перечисления — алгебраические типы данных и исчерпывающее сопоставление
- Владение и заимствование — модель управления памятью Rust
- Модули и крейты — организация кода и зависимости
- Обработка ошибок — распространение ошибок на основе Result
- Трейты и обобщения — система типов Rust
- Замыкания и итераторы — паттерны функционального программирования
- Конкурентность — конкурентность без страха благодаря гарантиям системы типов, глубокое погружение в async/await
- Unsafe Rust и FFI — когда и как выходить за рамки безопасного Rust
- Паттерны миграции — реальные паттерны перехода с C# на Rust и постепенное внедрение
- Лучшие практики — идиоматичный Rust для разработчиков C#
Руководство для самостоятельного изучения
Материал подходит и для занятий с преподавателем, и для самостоятельного изучения. Если вы работаете над ним самостоятельно, вот как извлечь из него максимум пользы.
Рекомендации по темпу:
| Главы | Тема | Примерное время | Контрольная точка |
|---|---|---|---|
| 1–4 | Настройка, типы, управление потоком выполнения | 1 день | Вы можете написать CLI-конвертер температур на Rust |
| 5–6 | Структуры данных, перечисления, сопоставление с образцом | 1–2 дня | Вы можете определить перечисление с данными и исчерпывающе разобрать его через match |
| 7 | Владение и заимствование | 1–2 дня | Вы можете объяснить, почему let s2 = s1 делает s1 недействительным |
| 8–9 | Модули, обработка ошибок | 1 день | Вы можете создать многофайловый проект, который передаёт ошибки дальше с помощью ? |
| 10–12 | Трейты, обобщения, замыкания, итераторы | 1–2 дня | Вы можете перевести цепочку LINQ в итераторы Rust |
| 13 | Конкурентность и async | 1 день | Вы можете написать потокобезопасный счётчик с Arc<Mutex<T>> |
| 14 | Unsafe Rust, FFI, тестирование | 1 день | Вы можете вызвать функцию Rust из C# через P/Invoke |
| 15–16 | Миграция, лучшие практики, инструменты | В своём темпе | Справочный материал — обращайтесь к нему при написании реального кода |
| 17 | Итоговый проект | 1–2 дня | У вас есть работающая CLI-утилита, которая получает данные о погоде |
Как работать с упражнениями:
- В главах есть практические упражнения в сворачиваемых блоках
<details>с решениями - Всегда сначала попробуйте решить упражнение и только потом раскрывайте решение. Борьба с проверщиком заимствований — часть обучения, а сообщения компилятора — ваш учитель
- Если вы застряли больше чем на 15 минут, раскройте решение, изучите его, затем закройте и попробуйте снова с нуля
- Rust Playground позволяет запускать код без локальной установки
Индикаторы сложности:
- 🟢 Начальный — прямой перевод концепций из C#
- 🟡 Средний — требует понимания владения или трейтов
- 🔴 Продвинутый — времена жизни, внутреннее устройство async или unsafe-код
Если вы упёрлись в стену:
- Внимательно читайте сообщение компилятора: ошибки Rust исключительно полезны
- Перечитайте нужный раздел. Концепции вроде владения (гл. 7) часто становятся понятны при втором прочтении
- Документация стандартной библиотеки Rust отличная: ищите любой тип или метод
- Более глубокие асинхронные паттерны описаны в сопутствующем курсе Async Rust
Оглавление
Часть I — Основы
1. Введение и мотивация 🟢
- Аргументы в пользу Rust для разработчиков C#
- Типичные болевые точки C#, которые решает Rust
- Когда выбирать Rust вместо C#
- Сравнение философий языков
- Краткая справка: Rust против C#
2. Начало работы 🟢
- Установка и настройка
- Первая программа на Rust
- Cargo против NuGet/MSBuild
- Чтение ввода и аргументов командной строки
- Ключевые слова Rust (необязательный справочник — обращайтесь по необходимости)
3. Встроенные типы и переменные 🟢
- Переменные и изменяемость
- Сравнение примитивных типов
- Типы строк: String и &str
- Вывод и форматирование строк
- Приведение типов и преобразования
- Истинная неизменяемость против иллюзий record
4. Управление потоком выполнения 🟢
5. Структуры данных и коллекции 🟢
- Кортежи и деструктуризация
- Массивы и срезы
- Структуры против классов
- Паттерны конструкторов
Vec<T>противList<T>- HashMap против Dictionary
6. Перечисления и сопоставление с образцом 🟡
- Алгебраические типы данных против объединений C#
- Исчерпывающее сопоставление с образцом
Option<T>для null-безопасности- Защитные условия и продвинутые паттерны
7. Владение и заимствование 🟡
- Понимание владения
- Семантика перемещения против семантики ссылок
- Основы заимствования
- Глубокое погружение в безопасность памяти
- Глубокое погружение во времена жизни 🔴
- Умные указатели, Drop и Deref 🔴
8. Крейты и модули 🟢
- Модули Rust против пространств имён C#
- Крейты против сборок .NET
- Управление пакетами: Cargo против NuGet
9. Обработка ошибок 🟡
- Исключения против
Result<T, E> - Оператор
? - Пользовательские типы ошибок
- Типы ошибок уровня крейта и псевдонимы Result
- Паттерны восстановления после ошибок
10. Трейты и обобщения 🟡
- Трейты против интерфейсов
- Наследование против композиции
- Ограничения обобщений: where и ограничения трейтов
- Распространённые трейты стандартной библиотеки
11. Трейты From и Into 🟡
12. Замыкания и итераторы 🟡
Часть II — Конкурентность и системное программирование
13. Конкурентность 🔴
- Потокобезопасность: соглашение против гарантий системы типов
- async/await: Task в C# против Future в Rust
- Отмена операций: CancellationToken против Drop и select!
- Pin и tokio::spawn
14. Unsafe Rust, FFI и тестирование 🟡
- Когда нужен unsafe
- Взаимодействие с C# через FFI
- Тестирование в Rust и C#
- Property-тестирование и моки
Часть III — Миграция и лучшие практики
15. Паттерны миграции и разборы случаев 🟡
- Распространённые паттерны C# в Rust
- Основные крейты для разработчиков C#
- Стратегия постепенного внедрения
16. Лучшие практики и справочник 🟡
- Идиоматичный Rust для разработчиков C#
- Сравнение производительности: управляемый и нативный код
- Частые ошибки и решения
- Путь обучения и следующие шаги
- Экосистема инструментов Rust
Итоговый проект
17. Итоговый проект 🟡
- Создание CLI-утилиты для погоды — объединяет структуры, трейты, обработку ошибок, async, модули, serde и тестирование в одном работающем приложении
1. Введение и мотивация
О курсе и общий подход
- О курсе
- Курс основан на оригинальных материалах Rust Training (лицензии MIT и CC-BY-4.0)
- Русский перевод и адаптация выполнены участниками проекта RustTraining-Rus
- Этот курс задуман как можно более интерактивный
- Предположение: вы знаете C# и разработку на .NET
- Примеры намеренно сопоставляют концепции C# с их аналогами в Rust
- Задавайте уточняющие вопросы в любой момент
Аргументы в пользу Rust для разработчиков C#
Что вы узнаете: почему Rust важен для разработчиков C# — разрыв в производительности между управляемым и нативным кодом, как Rust на этапе компиляции устраняет ошибки NullReferenceException и скрытые потоки управления, а также ключевые сценарии, в которых Rust дополняет C# или заменяет его.
Сложность: 🟢 Начальный
Производительность без налога на рантайм
// C# — отличная продуктивность, но накладные расходы рантайма
public class DataProcessor
{
private List<int> data = new List<int>();
public void ProcessLargeDataset()
{
// Выделения памяти запускают GC
for (int i = 0; i < 10_000_000; i++)
{
data.Add(i * 2); // Нагрузка на GC
}
// Непредсказуемые паузы GC во время обработки
}
}
// Время выполнения: переменное (50–200 мс из-за GC)
// Память: ~80 МБ (включая накладные расходы GC)
// Предсказуемость: низкая (паузы GC)
#![allow(unused)]
fn main() {
// Rust — та же выразительность, нулевые накладные расходы рантайма
struct DataProcessor {
data: Vec<i32>,
}
impl DataProcessor {
fn process_large_dataset(&mut self) {
// Абстракции с нулевой стоимостью
for i in 0..10_000_000 {
self.data.push(i * 2); // Нет нагрузки на GC
}
// Детерминированная производительность
}
}
// Время выполнения: стабильное (~30 мс)
// Память: ~40 МБ (точное выделение)
// Предсказуемость: высокая (нет GC)
}
Безопасность памяти без проверок во время выполнения
// C# — безопасность во время выполнения с накладными расходами
public class RuntimeCheckedOperations
{
public string? ProcessArray(int[] array)
{
// Проверка границ во время выполнения при каждом обращении
if (array.Length > 0)
{
return array[0].ToString(); // Безопасно — int это значимый тип, он никогда не бывает null
}
return null; // Nullable-возвращаемое значение (string? с nullable-ссылочными типами C# 8+)
}
public void ProcessConcurrently()
{
var list = new List<int>();
// Гонки данных возможны, нужна аккуратная синхронизация
Parallel.For(0, 1000, i =>
{
lock (list) // Накладные расходы рантайма
{
list.Add(i);
}
});
}
}
#![allow(unused)]
fn main() {
// Rust — безопасность на этапе компиляции без затрат во время выполнения
struct SafeOperations;
impl SafeOperations {
// Null-безопасность на этапе компиляции, без проверок во время выполнения
fn process_array(array: &[i32]) -> Option<String> {
array.first().map(|x| x.to_string())
// Null-ссылки здесь невозможны
// Проверка границ убирается оптимизатором, когда безопасность доказуема
}
fn process_concurrently() {
use std::sync::{Arc, Mutex};
use std::thread;
let data = Arc::new(Mutex::new(Vec::new()));
// Гонки данных предотвращаются на этапе компиляции
let handles: Vec<_> = (0..1000).map(|i| {
let data = Arc::clone(&data);
thread::spawn(move || {
data.lock().unwrap().push(i);
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
}
}
}
Типичные болевые точки C#, которые решает Rust
1. «Миллиардная ошибка»: null-ссылки
// C# — исключения NullReferenceException это мины замедленного действия во время выполнения
public class UserService
{
public string GetUserDisplayName(User user)
{
// Любое из этих обращений может бросить NullReferenceException
return user.Profile.DisplayName.ToUpper();
// ^^^^^ ^^^^^^^ ^^^^^^^^^^^ ^^^^^^^
// Во время выполнения любое из них может оказаться null
}
// Nullable-ссылочные типы (C# 8+) помогают, но null всё равно может проскочить
public string GetDisplayName(User? user)
{
return user?.Profile?.DisplayName?.ToUpper() ?? "Unknown";
// Эта конкретная строка безопасна благодаря ?. и ??,
// но NRT носят рекомендательный характер — компилятор можно обойти с помощью `!`
}
}
#![allow(unused)]
fn main() {
// Rust — null-безопасность гарантируется на этапе компиляции
struct UserService;
impl UserService {
fn get_user_display_name(user: &User) -> Option<String> {
user.profile.as_ref()?
.display_name.as_ref()
.map(|name| name.to_uppercase())
// Компилятор заставляет обработать случай None
// Null-указатели и их исключения здесь невозможны
}
fn get_display_name_safe(user: Option<&User>) -> String {
user.and_then(|u| u.profile.as_ref())
.and_then(|p| p.display_name.as_ref())
.map(|name| name.to_uppercase())
.unwrap_or_else(|| "Unknown".to_string())
// Явная обработка, никаких сюрпризов
}
}
}
2. Скрытые исключения и управление потоком
// C# — исключения могут бросаться откуда угодно
public async Task<UserData> GetUserDataAsync(int userId)
{
// Каждый из этих вызовов может бросить разные исключения
var user = await userRepository.GetAsync(userId); // SqlException
var permissions = await permissionService.GetAsync(user); // HttpRequestException
var preferences = await preferenceService.GetAsync(user); // TimeoutException
return new UserData(user, permissions, preferences);
// Вызывающий код понятия не имеет, какие исключения ожидать
}
#![allow(unused)]
fn main() {
// Rust — все ошибки явно указаны в сигнатурах функций
#[derive(Debug)]
enum UserDataError {
DatabaseError(String),
NetworkError(String),
Timeout,
UserNotFound(i32),
}
async fn get_user_data(user_id: i32) -> Result<UserData, UserDataError> {
// Все ошибки явные и обработаны
let user = user_repository.get(user_id).await
.map_err(UserDataError::DatabaseError)?;
let permissions = permission_service.get(&user).await
.map_err(UserDataError::NetworkError)?;
let preferences = preference_service.get(&user).await
.map_err(|_| UserDataError::Timeout)?;
Ok(UserData::new(user, permissions, preferences))
// Вызывающий код точно знает, какие ошибки возможны
}
}
3. Корректность: система типов как движок доказательств
Система типов Rust отлавливает целые классы логических ошибок на этапе компиляции — тогда как C# может поймать их только во время выполнения, или не поймать вовсе.
ADT против обходных путей через sealed-классы
// C# — дискриминируемые объединения требуют шаблонного кода с sealed-классами.
// Компилятор предупреждает о пропущенных случаях (CS8524) ТОЛЬКО если нет catch-all `_`.
// На практике большинство C#-кода использует `_` по умолчанию, и это заглушает предупреждение.
public abstract record Shape;
public sealed record Circle(double Radius) : Shape;
public sealed record Rectangle(double W, double H) : Shape;
public sealed record Triangle(double A, double B, double C) : Shape;
public static double Area(Shape shape) => shape switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle r => r.W * r.H,
// Забыли Triangle? Паттерн `_` заглушает любое предупреждение компилятора.
_ => throw new ArgumentException("Unknown shape")
};
// Через полгода добавляете новый вариант — паттерн `_` скрывает пропущенный случай.
// Ни одно предупреждение компилятора не скажет о 47 switch-выражениях, которые нужно обновить.
#![allow(unused)]
fn main() {
// Rust — ADT + исчерпывающее сопоставление = доказательство на этапе компиляции
enum Shape {
Circle { radius: f64 },
Rectangle { w: f64, h: f64 },
Triangle { a: f64, b: f64, c: f64 },
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { w, h } => w * h,
// Забыли Triangle? ОШИБКА: сопоставление не исчерпывающее
Shape::Triangle { a, b, c } => {
let s = (a + b + c) / 2.0;
(s * (s - a) * (s - b) * (s - c)).sqrt()
}
}
}
// Добавляете новый вариант → компилятор покажет ВСЕ сопоставления, которые нужно обновить.
}
Неизменяемость по умолчанию против неизменяемости по выбору
// C# — всё изменяемо по умолчанию
public class Config
{
public string Host { get; set; } // Изменяемо по умолчанию
public int Port { get; set; }
}
// "readonly" и "record" помогают, но не предотвращают глубокую мутацию:
public record ServerConfig(string Host, int Port, List<string> AllowedOrigins);
var config = new ServerConfig("localhost", 8080, new List<string> { "*.example.com" });
// Records «неизменяемы», но поля ссылочных типов — НЕТ:
config.AllowedOrigins.Add("*.evil.com"); // Компилируется и мутирует! ← баг
// Компилятор не выдаёт ни одного предупреждения.
#![allow(unused)]
fn main() {
// Rust — неизменяемо по умолчанию, мутация явная и видимая
struct Config {
host: String,
port: u16,
allowed_origins: Vec<String>,
}
let config = Config {
host: "localhost".into(),
port: 8080,
allowed_origins: vec!["*.example.com".into()],
};
// config.allowed_origins.push("*.evil.com".into()); // ОШИБКА: нельзя заимствовать как изменяемое
// Мутация требует явного согласия:
let mut config = config;
config.allowed_origins.push("*.safe.com".into()); // OK — мутация видна
// «mut» в сигнатуре говорит каждому читателю: «эта функция изменяет данные»
fn add_origin(config: &mut Config, origin: String) {
config.allowed_origins.push(origin);
}
}
Функциональное программирование: полноправная возможность или запоздалая мысль
// C# — ФП прикручено сбоку; LINQ выразителен, но язык сопротивляется
public IEnumerable<Order> GetHighValueOrders(IEnumerable<Order> orders)
{
return orders
.Where(o => o.Total > 1000) // Func<Order, bool> — делегат в куче
.Select(o => new OrderSummary // Анонимный тип или дополнительный класс
{
Id = o.Id,
Total = o.Total
})
.OrderByDescending(o => o.Total);
// Нет исчерпывающего сопоставления результатов
// Null может проскользнуть куда угодно в конвейере
// Нельзя обеспечить чистоту — любая лямбда может иметь побочные эффекты
}
#![allow(unused)]
fn main() {
// Rust — ФП является полноправной частью языка
fn get_high_value_orders(orders: &[Order]) -> Vec<OrderSummary> {
orders.iter()
.filter(|o| o.total > 1000) // Замыкание с нулевой стоимостью, без выделения в куче
.map(|o| OrderSummary { // Структура с проверкой типов
id: o.id,
total: o.total,
})
.sorted_by(|a, b| b.total.cmp(&a.total)) // itertools
.collect()
// Нигде в конвейере нет null
// Замыкания мономорфизируются — нулевые накладные расходы по сравнению с написанными вручную циклами
// Чистота обеспечивается: &[Order] означает, что функция НЕ может изменить orders
}
}
Наследование: элегантно в теории, хрупко на практике
// C# — проблема хрупкого базового класса
public class Animal
{
public virtual string Speak() => "...";
public void Greet() => Console.WriteLine($"I say: {Speak()}");
}
public class Dog : Animal
{
public override string Speak() => "Woof!";
}
public class RobotDog : Dog
{
// Какой Speak() вызовет Greet()? Что если Dog изменится?
// Ромбовидная проблема с интерфейсами + методами по умолчанию
// Жёсткая связанность: изменение Animal может тихо сломать RobotDog
}
// Распространённые антипаттерны C#:
// - «Божественные» базовые классы с 20 виртуальными методами
// - Глубокие иерархии (5+ уровней), в которых никто не может разобраться
// - Поля "protected", создающие скрытую связанность
// - Изменения базового класса, которые молча меняют поведение производных
#![allow(unused)]
fn main() {
// Rust — композиция вместо наследования, обеспечивается самим языком
trait Speaker {
fn speak(&self) -> &str;
}
trait Greeter: Speaker {
fn greet(&self) {
println!("I say: {}", self.speak());
}
}
struct Dog;
impl Speaker for Dog {
fn speak(&self) -> &str { "Woof!" }
}
impl Greeter for Dog {} // Использует greet() по умолчанию
struct RobotDog {
voice: String, // Композиция: владеет собственными данными
}
impl Speaker for RobotDog {
fn speak(&self) -> &str { &self.voice }
}
impl Greeter for RobotDog {} // Понятное и явное поведение
// Нет проблемы хрупкого базового класса — базовых классов вообще нет
// Нет скрытой связанности — трейты это явные контракты
// Нет ромбовидной проблемы — правила согласованности трейтов исключают неоднозначность
// Добавляете метод в Speaker? Компилятор покажет все места, где его нужно реализовать.
}
Ключевая мысль: в C# корректность — это дисциплина: вы надеетесь, что разработчики следуют соглашениям, пишут тесты и ловят пограничные случаи на код-ревью. В Rust корректность — свойство системы типов: целые классы ошибок (разыменование null, забытые варианты, случайная мутация, гонки данных) структурно невозможны.
4. Непредсказуемая производительность из-за GC
// C# — GC может приостановить работу в любой момент
public class HighFrequencyTrader
{
private List<Trade> trades = new List<Trade>();
public void ProcessMarketData(MarketTick tick)
{
// Выделения памяти могут запустить GC в худший возможный момент
var analysis = new MarketAnalysis(tick);
trades.Add(new Trade(analysis.Signal, tick.Price));
// GC может приостановить работу прямо здесь, в критический момент рынка
// Длительность паузы: 1–100 мс в зависимости от размера кучи
}
}
#![allow(unused)]
fn main() {
// Rust — предсказуемая, детерминированная производительность
struct HighFrequencyTrader {
trades: Vec<Trade>,
}
impl HighFrequencyTrader {
fn process_market_data(&mut self, tick: MarketTick) {
// Извлекаем поле Copy до того, как переместим `tick` в analysis
let price = tick.price;
// Нулевые выделения, предсказуемая производительность
let analysis = MarketAnalysis::from(tick);
self.trades.push(Trade::new(analysis.signal(), price));
// Никаких пауз GC, стабильная задержка на уровне субмикросекунд
// Производительность гарантируется системой типов
}
}
}
Когда выбирать Rust вместо C#
✅ Выбирайте Rust, когда:
- Важна корректность: конечные автоматы, реализации протоколов, финансовая логика — там, где пропущенный случай становится инцидентом в продакшене, а не провалом теста
- Критична производительность: системы реального времени, высокочастотная торговля, игровые движки
- Важно потребление памяти: встраиваемые системы, облачные затраты, мобильные приложения
- Требуется предсказуемость: медицинское оборудование, автомобильная отрасль, финансовые системы
- Безопасность превыше всего: криптография, сетевая безопасность, низкоуровневый код
- Долгоживущие сервисы: там, где паузы GC создают проблемы
- Ограниченные ресурсы: IoT, edge-вычисления
- Системное программирование: CLI-утилиты, базы данных, веб-серверы, операционные системы
✅ Оставайтесь на C#, когда:
- Быстрая разработка приложений: бизнес-приложения, CRUD-приложения
- Большая существующая кодовая база: когда стоимость миграции непомерна
- Экспертиза команды: когда кривая обучения Rust не окупается выгодами
- Корпоративные интеграции: сильные зависимости от .NET Framework/Windows
- GUI-приложения: экосистемы WPF, WinUI, Blazor
- Скорость выхода на рынок: когда скорость разработки важнее производительности
🔄 Рассмотрите оба варианта (гибридный подход):
- Компоненты, критичные к производительности, на Rust: вызываются из C# через P/Invoke
- Бизнес-логика на C#: знакомая и продуктивная разработка
- Постепенная миграция: начните с новых сервисов на Rust
Влияние на практике: почему компании выбирают Rust
Dropbox: инфраструктура хранения
- До (Python): высокая загрузка CPU, накладные расходы памяти
- После (Rust): прирост производительности в 10 раз, снижение потребления памяти на 50%
- Результат: миллионы экономии на инфраструктуре
Discord: бэкенд голоса и видео
- До (Go): паузы GC, приводившие к обрывам аудио
- После (Rust): стабильно низкая задержка
- Результат: лучший пользовательский опыт, меньше затрат на серверы
Microsoft: компоненты Windows
- Rust в Windows: компоненты файловой системы и сетевого стека
- Преимущество: безопасность памяти без потери производительности
- Эффект: меньше уязвимостей безопасности при той же производительности
Почему это важно для разработчиков C#:
- Взаимодополняющие навыки: Rust и C# решают разные задачи
- Карьерный рост: экспертиза в системном программировании всё более ценна
- Понимание производительности: изучение абстракций с нулевой стоимостью
- Мышление о безопасности: применяйте мышление о владении в любом языке
- Облачные затраты: производительность напрямую влияет на расходы на инфраструктуру
Сравнение философий языков
Философия C#
- Продуктивность прежде всего: богатые инструменты, обширный фреймворк, «яма успеха» (pit of success)
- Управляемый рантайм: сборщик мусора автоматически управляет памятью
- Ориентация на корпоративный сегмент: строгая типизация с рефлексией, обширная стандартная библиотека
- Объектно-ориентированность: классы, наследование, интерфейсы как основные абстракции
Философия Rust
- Производительность без компромиссов: абстракции с нулевой стоимостью, без накладных расходов рантайма
- Безопасность памяти: гарантии на этапе компиляции предотвращают сбои и уязвимости безопасности
- Системное программирование: прямой доступ к оборудованию с высокоуровневыми абстракциями
- Функциональность + системность: неизменяемость по умолчанию, управление ресурсами на основе владения
graph TD
subgraph "Модель разработки C#"
CS_CODE["Исходный код C#<br/>Классы, методы, свойства"]
CS_COMPILE["Компилятор C#<br/>(csc.exe)"]
CS_IL["Промежуточный язык<br/>(байт-код IL)"]
CS_RUNTIME[".NET Runtime<br/>(CLR)"]
CS_JIT["Just-In-Time компилятор"]
CS_NATIVE["Машинный код"]
CS_GC["Сборщик мусора<br/>(управление памятью)"]
CS_CODE --> CS_COMPILE
CS_COMPILE --> CS_IL
CS_IL --> CS_RUNTIME
CS_RUNTIME --> CS_JIT
CS_JIT --> CS_NATIVE
CS_RUNTIME --> CS_GC
CS_BENEFITS["[OK] Быстрая разработка<br/>[OK] Богатая экосистема<br/>[OK] Автоматическое управление памятью<br/>[ERROR] Накладные расходы рантайма<br/>[ERROR] Паузы GC<br/>[ERROR] Зависимость от платформы"]
end
subgraph "Модель разработки Rust"
RUST_CODE["Исходный код Rust<br/>Структуры, перечисления, функции"]
RUST_COMPILE["Компилятор Rust<br/>(rustc)"]
RUST_NATIVE["Машинный код<br/>(прямая компиляция)"]
RUST_ZERO["Нулевой рантайм<br/>(нет VM, нет GC)"]
RUST_CODE --> RUST_COMPILE
RUST_COMPILE --> RUST_NATIVE
RUST_NATIVE --> RUST_ZERO
RUST_BENEFITS["[OK] Максимальная производительность<br/>[OK] Безопасность памяти<br/>[OK] Нет зависимостей рантайма<br/>[ERROR] Более крутая кривая обучения<br/>[ERROR] Более долгая сборка<br/>[ERROR] Более явный код"]
end
style CS_BENEFITS fill:#e3f2fd,color:#000
style RUST_BENEFITS fill:#e8f5e8,color:#000
style CS_GC fill:#fff3e0,color:#000
style RUST_ZERO fill:#e8f5e8,color:#000
Краткая справка: Rust против C#
| Концепция | C# | Rust | Ключевое отличие |
|---|---|---|---|
| Управление памятью | Сборщик мусора | Система владения | Нулевая стоимость, детерминированная очистка |
| Null-ссылки | null повсюду | Option<T> | Null-безопасность на этапе компиляции |
| Обработка ошибок | Исключения | Result<T, E> | Явно, без скрытого управления потоком |
| Изменяемость | Изменяемо по умолчанию | Неизменяемо по умолчанию | Мутация по явному выбору |
| Система типов | Ссылочные/значимые типы | Типы владения | Семантика перемещения, заимствование |
| Сборки | GAC, домены приложений (.NET Framework); side-by-side (.NET 5+) | Крейты | Статическая линковка, без рантайма |
| Пространства имён | using System.IO | use std::fs | Система модулей |
| Интерфейсы | interface IFoo | trait Foo | Реализации по умолчанию |
| Обобщения | List<T> (необязательные ограничения через where) | Vec<T> (ограничения трейтов вида T: Clone) | Абстракции с нулевой стоимостью |
| Многопоточность | Блокировки, async/await | Владение + Send/Sync | Предотвращение гонок данных |
| Производительность | JIT-компиляция | AOT-компиляция | Предсказуемо, без пауз GC |
2. Начало работы
Установка и настройка
Что вы узнаете: как установить Rust и настроить IDE, чем система сборки Cargo отличается от MSBuild/NuGet, как выглядит первая программа на Rust по сравнению с C#, и как читать ввод из командной строки.
Сложность: 🟢 Начальный
Установка Rust
# Установка Rust (работает на Windows, macOS, Linux)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# В Windows можно также скачать с: https://rustup.rs/
Инструменты Rust против инструментов C#
| Инструмент C# | Аналог в Rust | Назначение |
|---|---|---|
dotnet new | cargo new | Создание нового проекта |
dotnet build | cargo build | Компиляция проекта |
dotnet run | cargo run | Запуск проекта |
dotnet test | cargo test | Запуск тестов |
| NuGet | Crates.io | Репозиторий пакетов |
| MSBuild | Cargo | Система сборки |
| Visual Studio | VS Code + rust-analyzer | IDE |
Настройка IDE
-
VS Code (рекомендуется для начинающих)
- Установите расширение «rust-analyzer»
- Установите «CodeLLDB» для отладки
-
Visual Studio (Windows)
- Установите расширение с поддержкой Rust
-
JetBrains RustRover (полноценная IDE)
- Похожа на Rider для C#
Первая программа на Rust
Hello World на C#
// Program.cs
using System;
namespace HelloWorld
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
}
}
Hello World на Rust
// main.rs
fn main() {
println!("Hello, World!");
}
Ключевые отличия для разработчиков C#
- Классы не требуются — функции могут существовать на верхнем уровне
- Пространств имён нет — вместо них используется система модулей
println!— это макрос — обратите внимание на!- Точка с запятой важна — отсутствие завершающей точки с запятой превращает инструкцию в возвращаемое выражение
- Явного типа возвращаемого значения нет —
mainвозвращает()(тип unit)
Создание первого проекта
# Создание нового проекта (аналог 'dotnet new console')
cargo new hello_rust
cd hello_rust
# Созданная структура проекта:
# hello_rust/
# ├── Cargo.toml (аналог файла .csproj)
# └── src/
# └── main.rs (аналог Program.cs)
# Запуск проекта (аналог 'dotnet run')
cargo run
Cargo против NuGet/MSBuild
Конфигурация проекта
C# (.csproj)
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="3.0.1" />
</Project>
Rust (Cargo.toml)
[package]
name = "hello_rust"
version = "0.1.0"
edition = "2021"
[dependencies]
serde_json = "1.0" # Аналог Newtonsoft.Json
log = "0.4" # Аналог Serilog
Основные команды Cargo
# Создание нового проекта
cargo new my_project
cargo new my_project --lib # Создать библиотечный проект
# Сборка и запуск
cargo build # Аналог 'dotnet build'
cargo run # Аналог 'dotnet run'
cargo test # Аналог 'dotnet test'
# Управление пакетами
cargo add serde # Добавить зависимость (аналог 'dotnet add package')
cargo update # Обновить зависимости
# Релизная сборка
cargo build --release # Оптимизированная сборка
cargo run --release # Запуск оптимизированной версии
# Документация
cargo doc --open # Сгенерировать и открыть документацию
Workspace против Solution
Решение C# (.sln)
MySolution/
├── MySolution.sln
├── WebApi/
│ └── WebApi.csproj
├── Business/
│ └── Business.csproj
└── Tests/
└── Tests.csproj
Рабочее пространство Rust (Cargo.toml)
[workspace]
members = [
"web_api",
"business",
"tests"
]
Чтение ввода и аргументов командной строки
Каждый разработчик C# знает Console.ReadLine(). Вот как в Rust обрабатывать пользовательский ввод, переменные окружения и аргументы командной строки.
Ввод с консоли
// C# — чтение пользовательского ввода
Console.Write("Введите ваше имя: ");
string? name = Console.ReadLine(); // Возвращает string? начиная с .NET 6+
Console.WriteLine($"Привет, {name}!");
// Разбор ввода
Console.Write("Введите число: ");
if (int.TryParse(Console.ReadLine(), out int number))
{
Console.WriteLine($"Вы ввели: {number}");
}
else
{
Console.WriteLine("Это не число.");
}
use std::io::{self, Write};
fn main() {
// Чтение строки ввода
print!("Введите ваше имя: ");
io::stdout().flush().unwrap(); // flush нужен, потому что print! не сбрасывает буфер автоматически
let mut name = String::new();
io::stdin().read_line(&mut name).expect("Не удалось прочитать строку");
let name = name.trim(); // убираем завершающий перенос строки
println!("Привет, {name}!");
// Разбор ввода
print!("Введите число: ");
io::stdout().flush().unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).expect("Не удалось прочитать");
match input.trim().parse::<i32>() {
Ok(number) => println!("Вы ввели: {number}"),
Err(_) => println!("Это не число."),
}
}
Аргументы командной строки
// C# — чтение аргументов CLI
static void Main(string[] args)
{
if (args.Length < 1)
{
Console.WriteLine("Использование: program <filename>");
return;
}
string filename = args[0];
Console.WriteLine($"Обработка {filename}");
}
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
// args[0] = имя программы (аналог имени сборки в C#)
// args[1..] = собственно аргументы
if args.len() < 2 {
eprintln!("Использование: {} <filename>", args[0]); // eprintln! → stderr
std::process::exit(1);
}
let filename = &args[1];
println!("Обработка {filename}");
}
Переменные окружения
// C#
string dbUrl = Environment.GetEnvironmentVariable("DATABASE_URL") ?? "localhost";
#![allow(unused)]
fn main() {
use std::env;
let db_url = env::var("DATABASE_URL").unwrap_or_else(|_| "localhost".to_string());
// env::var возвращает Result<String, VarError> — никаких null!
}
Промышленные CLI-приложения с clap
Для всего, что выходит за рамки тривиального разбора аргументов, используйте крейт clap — это аналог System.CommandLine или библиотек вроде CommandLineParser в Rust.
# Cargo.toml
[dependencies]
clap = { version = "4", features = ["derive"] }
use clap::Parser;
/// Простой обработчик файлов — этот doc-комментарий станет текстом справки
#[derive(Parser, Debug)]
#[command(name = "processor", version, about)]
struct Args {
/// Входной файл для обработки
#[arg(short, long)]
input: String,
/// Выходной файл (по умолчанию — stdout)
#[arg(short, long)]
output: Option<String>,
/// Включить подробное логирование
#[arg(short, long, default_value_t = false)]
verbose: bool,
/// Количество рабочих потоков
#[arg(short = 'j', long, default_value_t = 4)]
threads: usize,
}
fn main() {
let args = Args::parse(); // автоматически разбирает, проверяет и генерирует --help
if args.verbose {
println!("Вход: {}", args.input);
println!("Выход: {:?}", args.output);
println!("Потоки: {}", args.threads);
}
// Используйте args.input, args.output и т.д.
}
# Автоматически сгенерированная справка:
$ processor --help
Простой обработчик файлов
Usage: processor [OPTIONS] --input <INPUT>
Options:
-i, --input <INPUT> Входной файл для обработки
-o, --output <OUTPUT> Выходной файл (по умолчанию — stdout)
-v, --verbose Включить подробное логирование
-j, --threads <THREADS> Количество рабочих потоков [default: 4]
-h, --help Print help
-V, --version Print version
// Эквивалент на C# с System.CommandLine (больше шаблонного кода):
var inputOption = new Option<string>("--input", "Входной файл") { IsRequired = true };
var verboseOption = new Option<bool>("--verbose", "Включить подробное логирование");
var rootCommand = new RootCommand("Простой обработчик файлов");
rootCommand.AddOption(inputOption);
rootCommand.AddOption(verboseOption);
rootCommand.SetHandler((input, verbose) => { /* ... */ }, inputOption, verboseOption);
await rootCommand.InvokeAsync(args);
// Подход с derive-макросом в clap короче и безопаснее по типам
| C# | Rust | Примечания |
|---|---|---|
Console.ReadLine() | io::stdin().read_line(&mut buf) | Нужно передать буфер, возвращает Result |
int.TryParse(s, out n) | s.parse::<i32>() | Возвращает Result<i32, ParseIntError> |
args[0] | env::args().nth(1) | В Rust args[0] — имя программы |
Environment.GetEnvironmentVariable | env::var("KEY") | Возвращает Result, а не nullable-значение |
System.CommandLine | clap | Основан на derive, автоматически генерирует справку |
Справочник по ключевым словам (необязательно)
Ключевые слова Rust для разработчиков C#
Что вы узнаете: краткое соответствие ключевых слов Rust их аналогам в C# — модификаторы видимости, ключевые слова владения, управление потоком, определения типов и синтаксис сопоставления с образцом.
Сложность: 🟢 Начальный
Понимание ключевых слов Rust и их назначения помогает разработчикам C# эффективнее осваивать язык.
Ключевые слова видимости и контроля доступа
Модификаторы доступа C#
public class Example
{
public int PublicField; // Доступно везде
private int privateField; // Только внутри этого класса
protected int protectedField; // Этот класс и его наследники
internal int internalField; // Внутри этой сборки
protected internal int protectedInternalField; // Комбинация
}
Ключевые слова видимости Rust
#![allow(unused)]
fn main() {
// pub - делает элементы публичными (как public в C#)
pub struct PublicStruct {
pub public_field: i32, // Публичное поле
private_field: i32, // По умолчанию приватное (без ключевого слова)
}
pub mod my_module {
pub(crate) fn crate_public() {} // Публично внутри текущего крейта (как internal)
pub(super) fn parent_public() {} // Публично для родительского модуля
pub(self) fn self_public() {} // Публично внутри текущего модуля (то же, что и приватное)
pub use super::PublicStruct; // Реэкспорт (как псевдоним через using)
}
// Прямого аналога protected в C# нет — используйте композицию
}
Ключевые слова памяти и владения
Ключевые слова памяти C#
// ref - передача по ссылке
public void Method(ref int value) { value = 10; }
// out - выходной параметр
public bool TryParse(string input, out int result) { /* */ }
// in - ссылка только для чтения (C# 7.2+)
public void ReadOnly(in LargeStruct data) { /* Нельзя изменить data */ }
Ключевые слова владения Rust
#![allow(unused)]
fn main() {
// & - неизменяемая ссылка (как in-параметр в C#)
fn read_only(data: &Vec<i32>) {
println!("Длина: {}", data.len()); // Можно читать, нельзя изменять
}
// &mut - изменяемая ссылка (как ref-параметр в C#)
fn modify(data: &mut Vec<i32>) {
data.push(42); // Можно изменять
}
// move - принудительный захват перемещением в замыканиях
let data = vec![1, 2, 3];
let closure = move || {
println!("{:?}", data); // data перемещается в замыкание
};
// data здесь больше недоступен
// Box - выделение в куче (как new для ссылочных типов в C#)
let boxed_data = Box::new(42); // Выделяем в куче
}
Ключевые слова управления потоком
Управление потоком C#
// return - выход из функции со значением
public int GetValue() { return 42; }
// yield return - паттерн итератора
public IEnumerable<int> GetNumbers()
{
yield return 1;
yield return 2;
}
// break/continue - управление циклом
foreach (var item in items)
{
if (item == null) continue;
if (item.Stop) break;
}
Ключевые слова управления потоком Rust
#![allow(unused)]
fn main() {
// return - явный возврат (обычно не нужен)
fn get_value() -> i32 {
return 42; // Явный возврат
// ИЛИ просто: 42 (неявный возврат)
}
// break/continue - управление циклом с необязательными значениями
fn find_value() -> Option<i32> {
loop {
let value = get_next();
if value < 0 { continue; }
if value > 100 { break None; } // Выход с значением
if value == 42 { break Some(value); } // Выход с успешным результатом
}
}
// loop - бесконечный цикл (как while(true))
loop {
if condition { break; }
}
// while - условный цикл
while condition {
// код
}
// for - цикл по итератору
for item in collection {
// код
}
}
Ключевые слова определения типов
Ключевые слова типов C#
// class - ссылочный тип
public class MyClass { }
// struct - значимый тип
public struct MyStruct { }
// interface - определение контракта
public interface IMyInterface { }
// enum - перечисление
public enum MyEnum { Value1, Value2 }
// delegate - указатель на функцию
public delegate void MyDelegate(int value);
Ключевые слова типов Rust
#![allow(unused)]
fn main() {
// struct - структура данных (объединяет class и struct из C#)
struct MyStruct {
field: i32,
}
// enum - алгебраический тип данных (гораздо мощнее enum из C#)
enum MyEnum {
Variant1,
Variant2(i32), // Может хранить данные
Variant3 { x: i32, y: i32 }, // Вариант в виде структуры
}
// trait - определение интерфейса (как interface в C#, но мощнее)
trait MyTrait {
fn method(&self);
// Реализация по умолчанию (как default-методы интерфейсов в C# 8+)
fn default_method(&self) {
println!("Реализация по умолчанию");
}
}
// type - псевдоним типа (как псевдоним using в C#)
type UserId = u32;
type Result<T> = std::result::Result<T, MyError>;
// impl - блок реализации (аналога в C# нет — методы определяются отдельно)
impl MyStruct {
fn new() -> MyStruct {
MyStruct { field: 0 }
}
}
impl MyTrait for MyStruct {
fn method(&self) {
println!("Реализация");
}
}
}
Ключевые слова определения функций
Ключевые слова функций C#
// static - метод класса
public static void StaticMethod() { }
// virtual - может быть переопределён
public virtual void VirtualMethod() { }
// override - переопределение базового метода
public override void VirtualMethod() { }
// abstract - обязан быть реализован
public abstract void AbstractMethod();
// async - асинхронный метод
public async Task<int> AsyncMethod() { return await SomeTask(); }
Ключевые слова функций Rust
#![allow(unused)]
fn main() {
// fn - определение функции (как метод в C#, но существует самостоятельно)
fn regular_function() {
println!("Привет");
}
// const fn - функция времени компиляции (как const в C#, но для функций)
const fn compile_time_function() -> i32 {
42 // Может быть вычислена на этапе компиляции
}
// async fn - асинхронная функция (как async в C#)
async fn async_function() -> i32 {
some_async_operation().await
}
// unsafe fn - функция, которая может нарушить безопасность памяти
unsafe fn unsafe_function() {
// Может выполнять небезопасные операции
}
// extern fn - интерфейс внешних функций (FFI)
extern "C" fn c_compatible_function() {
// Может вызываться из C
}
}
Ключевые слова объявления переменных
Ключевые слова переменных C#
// var - вывод типа
var name = "John"; // Выводится как string
// const - константа времени компиляции
const int MaxSize = 100;
// readonly - константа времени выполнения (только для полей, не для локальных переменных)
// readonly DateTime createdAt = DateTime.Now;
// static - переменная уровня класса
static int instanceCount = 0;
Ключевые слова переменных Rust
#![allow(unused)]
fn main() {
// let - привязка переменной (как var в C#)
let name = "John"; // По умолчанию неизменяемая
// let mut - изменяемая привязка переменной
let mut count = 0; // Может быть изменена
count += 1;
// const - константа времени компиляции (как const в C#)
const MAX_SIZE: usize = 100;
// static - глобальная переменная (как static в C#)
static INSTANCE_COUNT: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
}
Ключевые слова сопоставления с образцом
Сопоставление с образцом C# (C# 8+)
// switch-выражение
string result = value switch
{
1 => "Один",
2 => "Два",
_ => "Другое"
};
// паттерн is
if (obj is string str)
{
Console.WriteLine(str.Length);
}
Ключевые слова сопоставления с образцом Rust
#![allow(unused)]
fn main() {
// match - сопоставление с образцом (как switch в C#, но гораздо мощнее)
let result = match value {
1 => "Один",
2 => "Два",
3..=10 => "От 3 до 10", // Диапазонные паттерны
_ => "Другое", // Подстановочный паттерн (как _ в C#)
};
// if let - условное сопоставление с образцом
if let Some(value) = optional {
println!("Получено значение: {}", value);
}
// while let - цикл с сопоставлением с образцом
while let Some(item) = iterator.next() {
println!("Элемент: {}", item);
}
// let с паттернами - деструктуризация
let (x, y) = point; // Деструктуризация кортежа
let Some(value) = optional else {
return; // Ранний выход, если паттерн не совпал
};
}
Ключевые слова безопасности памяти
Ключевые слова памяти C#
// unsafe - отключение проверок безопасности
unsafe
{
int* ptr = &variable;
*ptr = 42;
}
// fixed - закрепление управляемой памяти
unsafe
{
fixed (byte* ptr = array)
{
// Использовать ptr
}
}
Ключевые слова безопасности Rust
#![allow(unused)]
fn main() {
// unsafe - отключение проверок заимствований (используйте осторожно!)
unsafe {
let ptr = &variable as *const i32;
let value = *ptr; // Разыменование сырого указателя
}
// Типы сырых указателей (аналога в C# нет — обычно не нужны)
let ptr: *const i32 = &42; // Неизменяемый сырой указатель
let ptr: *mut i32 = &mut 42; // Изменяемый сырой указатель
}
Распространённые ключевые слова Rust, которых нет в C#
#![allow(unused)]
fn main() {
// where - ограничения обобщений (гибче, чем where в C#)
fn generic_function<T>()
where
T: Clone + Send + Sync,
{
// T должен реализовывать трейты Clone, Send и Sync
}
// dyn - динамические трейт-объекты (как object в C#, но с проверкой типов)
let drawable: Box<dyn Draw> = Box::new(Circle::new());
// Self - ссылка на реализующий тип (как this в C#, но для типов)
impl MyStruct {
fn new() -> Self { // Self = MyStruct
Self { field: 0 }
}
}
// self - получатель метода
impl MyStruct {
fn method(&self) { } // Неизменяемое заимствование
fn method_mut(&mut self) { } // Изменяемое заимствование
fn consume(self) { } // Принять владение
}
// crate - ссылка на корень текущего крейта
use crate::models::User; // Абсолютный путь от корня крейта
// super - ссылка на родительский модуль
use super::utils; // Импорт из родительского модуля
}
Сводка ключевых слов для разработчиков C#
| Назначение | C# | Rust | Ключевое отличие |
|---|---|---|---|
| Видимость | public, private, internal | pub, по умолчанию приватное | Более тонкая настройка через pub(crate) |
| Переменные | var, readonly, const | let, let mut, const | Неизменяемость по умолчанию |
| Функции | method() | fn | Функции могут существовать самостоятельно |
| Типы | class, struct, interface | struct, enum, trait | Перечисления — алгебраические типы |
| Обобщения | <T> where T : IFoo | <T> where T: Foo | Более гибкие ограничения |
| Ссылки | ref, out, in | &, &mut | Проверка заимствований на этапе компиляции |
| Паттерны | switch, is | match, if let | Требуется исчерпывающее сопоставление |
3. Встроенные типы и переменные
Переменные и изменяемость
Что вы узнаете: модель объявления переменных и изменяемости в Rust в сравнении с
var/constв C#, соответствие примитивных типов, важнейшее различие междуStringи&str, вывод типов, а также то, как Rust по-другому обрабатывает приведение и преобразования типов.Сложность: 🟢 Начальный
Объявление переменных в C#
// C# — переменные по умолчанию изменяемы
int count = 0; // Изменяемая
count = 5; // ✅ Работает
// readonly-поля (только на уровне класса, не для локальных переменных)
// readonly int maxSize = 100; // Неизменяемо после инициализации
const int BUFFER_SIZE = 1024; // Константа времени компиляции (работает как локальная переменная или поле)
Объявление переменных в Rust
#![allow(unused)]
fn main() {
// Rust — переменные по умолчанию неизменяемы
let count = 0; // Неизменяемая по умолчанию
// count = 5; // ❌ Ошибка компиляции: нельзя дважды присвоить неизменяемой переменной
let mut count = 0; // Явно изменяемая
count = 5; // ✅ Работает
const BUFFER_SIZE: usize = 1024; // Константа времени компиляции
}
Ключевой сдвиг мышления для разработчиков C#
#![allow(unused)]
fn main() {
// Думайте о 'let' как о семантике readonly-поля, применённой ко всем переменным
let name = "John"; // Как readonly-поле: после присваивания изменить нельзя
let mut age = 30; // Как: int age = 30;
// Затенение переменных (особенность Rust)
let spaces = " "; // Строка
let spaces = spaces.len(); // Теперь это число (usize)
// Это не мутация — мы создаём новую переменную
}
Практический пример: счётчик
// Версия на C#
public class Counter
{
private int value = 0;
public void Increment()
{
value++; // Мутация
}
public int GetValue() => value;
}
#![allow(unused)]
fn main() {
// Версия на Rust
pub struct Counter {
value: i32, // По умолчанию приватное
}
impl Counter {
pub fn new() -> Counter {
Counter { value: 0 }
}
pub fn increment(&mut self) { // &mut нужен для мутации
self.value += 1;
}
pub fn get_value(&self) -> i32 {
self.value
}
}
}
Сравнение типов данных
Примитивные типы
| Тип C# | Тип Rust | Размер | Диапазон |
|---|---|---|---|
byte | u8 | 8 бит | от 0 до 255 |
sbyte | i8 | 8 бит | от -128 до 127 |
short | i16 | 16 бит | от -32 768 до 32 767 |
ushort | u16 | 16 бит | от 0 до 65 535 |
int | i32 | 32 бита | от -2³¹ до 2³¹-1 |
uint | u32 | 32 бита | от 0 до 2³²-1 |
long | i64 | 64 бита | от -2⁶³ до 2⁶³-1 |
ulong | u64 | 64 бита | от 0 до 2⁶⁴-1 |
float | f32 | 32 бита | IEEE 754 |
double | f64 | 64 бита | IEEE 754 |
bool | bool | 1 бит | true/false |
char | char | 32 бита | Скалярное значение Unicode |
Типы размера (важно!)
// C# — int всегда 32-битный
int arrayIndex = 0;
long fileSize = file.Length;
#![allow(unused)]
fn main() {
// Rust — типы размера совпадают с размером указателя (32 или 64 бита)
let array_index: usize = 0; // Как size_t в C
let file_size: u64 = file.len(); // Явно 64-битный
}
Вывод типов
// C# — ключевое слово var
var name = "John"; // string
var count = 42; // int
var price = 29.99; // double
#![allow(unused)]
fn main() {
// Rust — автоматический вывод типов
let name = "John"; // &str (срез строки)
let count = 42; // i32 (целое по умолчанию)
let price = 29.99; // f64 (вещественное по умолчанию)
// Явные аннотации типов
let count: u32 = 42;
let price: f32 = 29.99;
}
Обзор массивов и коллекций
// C# — ссылочные типы, размещаются в куче
int[] numbers = new int[5]; // Фиксированный размер
List<int> list = new List<int>(); // Динамический размер
#![allow(unused)]
fn main() {
// Rust — несколько вариантов
let numbers: [i32; 5] = [1, 2, 3, 4, 5]; // Массив на стеке, фиксированный размер
let mut list: Vec<i32> = Vec::new(); // Вектор в куче, динамический размер
}
Типы строк: String и &str
Это одна из самых запутанных концепций для разработчиков C#, поэтому разберём её внимательно.
Работа со строками в C#
// C# — простая модель строк
string name = "John"; // Строковый литерал
string greeting = "Hello, " + name; // Конкатенация строк
string upper = name.ToUpper(); // Вызов метода
Строковые типы Rust
#![allow(unused)]
fn main() {
// Rust — два основных строковых типа
// 1. &str (срез строки) — похож на ReadOnlySpan<char> в C#
let name: &str = "John"; // Строковый литерал (неизменяемый, заимствованный)
// 2. String — похож на StringBuilder или изменяемую строку
let mut greeting = String::new(); // Пустая строка
greeting.push_str("Hello, "); // Добавление
greeting.push_str(name); // Добавление
// Или создаём сразу
let greeting = String::from("Hello, John");
let greeting = "Hello, John".to_string(); // Преобразование &str в String
}
Когда что использовать?
| Сценарий | Использовать | Аналог в C# |
|---|---|---|
| Строковые литералы | &str | Строковый литерал string |
| Параметры функций (только для чтения) | &str | string или ReadOnlySpan<char> |
| Владеемые, изменяемые строки | String | StringBuilder |
| Возврат владеемых строк | String | string |
Практические примеры
// Функция, принимающая любой строковый тип
fn greet(name: &str) { // Принимает и String, и &str
println!("Hello, {}!", name);
}
fn main() {
let literal = "John"; // &str
let owned = String::from("Jane"); // String
greet(literal); // Работает
greet(&owned); // Работает (заимствуем String как &str)
greet("Bob"); // Работает
}
// Функция, возвращающая владеемую строку
fn create_greeting(name: &str) -> String {
format!("Hello, {}!", name) // Макрос format! возвращает String
}
Разработчикам C# — смотрите так
#![allow(unused)]
fn main() {
// &str — это как ReadOnlySpan<char>: представление данных строки
// String — это как char[], которым вы владеете и которое можете изменять
let borrowed: &str = "Я не владею этими данными";
let owned: String = String::from("Я владею этими данными");
// Преобразования между ними
let owned_copy: String = borrowed.to_string(); // Копия во владение
let borrowed_view: &str = &owned; // Заимствование из owned
}
Вывод и форматирование строк
Разработчики C# активно используют Console.WriteLine и интерполяцию строк ($""). Система форматирования Rust не менее мощная, но основана на макросах и спецификаторах формата.
Базовый вывод
// Вывод в C#
Console.Write("без перевода строки");
Console.WriteLine("с переводом строки");
Console.Error.WriteLine("в stderr");
// Интерполяция строк (C# 6+)
string name = "Alice";
int age = 30;
Console.WriteLine($"{name} is {age} years old");
#![allow(unused)]
fn main() {
// Вывод в Rust — всё через макросы (обратите внимание на !)
print!("без перевода строки"); // → stdout, без перевода строки
println!("с переводом строки"); // → stdout + перевод строки
eprint!("в stderr"); // → stderr, без перевода строки
eprintln!("в stderr с переводом строки"); // → stderr + перевод строки
// Форматирование строк (аналог интерполяции $"")
let name = "Alice";
let age = 30;
println!("{name} is {age} years old"); // Захват переменной прямо в строке (Rust 1.58+)
println!("{} is {} years old", name, age); // Позиционные аргументы
// format! возвращает String вместо вывода
let msg = format!("{name} is {age} years old");
}
Спецификаторы формата
// Спецификаторы формата в C#
Console.WriteLine($"{price:F2}"); // Фиксированная запись: 29.99
Console.WriteLine($"{count:D5}"); // Дополнение нулями: 00042
Console.WriteLine($"{value,10}"); // Выравнивание по правому краю, ширина 10
Console.WriteLine($"{value,-10}"); // Выравнивание по левому краю, ширина 10
Console.WriteLine($"{hex:X}"); // Шестнадцатеричный: FF
Console.WriteLine($"{ratio:P1}"); // Проценты: 85.0%
#![allow(unused)]
fn main() {
// Спецификаторы формата в Rust
println!("{price:.2}"); // 2 знака после запятой: 29.99
println!("{count:05}"); // Дополнение нулями, ширина 5: 00042
println!("{value:>10}"); // Выравнивание по правому краю, ширина 10
println!("{value:<10}"); // Выравнивание по левому краю, ширина 10
println!("{value:^10}"); // Выравнивание по центру, ширина 10
println!("{hex:#X}"); // Шестнадцатеричный с префиксом: 0xFF
println!("{hex:08X}"); // Шестнадцатеричный с нулями: 000000FF
println!("{bits:#010b}"); // Двоичный с префиксом: 0b00001010
println!("{big}", big = 1_000_000); // Именованный параметр
}
Вывод Debug и Display
#![allow(unused)]
fn main() {
// {:?} — трейт Debug (для разработчиков, реализуется автоматически)
// {:#?} — Debug с красивым форматированием (с отступами, в несколько строк)
// {} — трейт Display (для пользователей, нужно реализовать вручную)
#[derive(Debug)] // Автоматически генерирует вывод Debug
struct Point { x: f64, y: f64 }
let p = Point { x: 1.5, y: 2.7 };
println!("{:?}", p); // Point { x: 1.5, y: 2.7 } — компактный debug
println!("{:#?}", p); // Point { — debug с форматированием
// x: 1.5,
// y: 2.7,
// }
// println!("{}", p); // ❌ ОШИБКА: Point не реализует Display
// Реализуем Display для вывода, понятного пользователю:
use std::fmt;
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
println!("{}", p); // (1.5, 2.7) — удобно для пользователя
}
// Эквивалент в C#:
// {:?} ≈ object.GetType().ToString() или дамп через рефлексию
// {} ≈ object.ToString()
// В C# вы переопределяете ToString(); в Rust вы реализуете Display
Краткая справка
| C# | Rust | Вывод |
|---|---|---|
Console.WriteLine(x) | println!("{x}") | Форматирование Display |
$"{x}" (интерполяция) | format!("{x}") | Возвращает String |
x.ToString() | x.to_string() | Требует трейт Display |
Переопределение ToString() | impl Display | Вывод для пользователя |
| Просмотр в отладчике | {:?} или dbg!(x) | Вывод для разработчика |
String.Format("{0:F2}", x) | format!("{x:.2}") | Форматированная String |
Console.Error.WriteLine | eprintln!() | Запись в stderr |
Приведение типов и преобразования
В C# есть неявные преобразования, явные приведения (int)x и Convert.To*(). Rust строже — неявных числовых преобразований здесь нет.
Числовые преобразования
// C# — неявные и явные преобразования
int small = 42;
long big = small; // Неявное расширение: OK
double d = small; // Неявное расширение: OK
int truncated = (int)3.14; // Явное сужение: 3
byte b = (byte)300; // Тихое переполнение: 44
// Безопасное преобразование
if (int.TryParse("42", out int parsed)) { /* ... */ }
#![allow(unused)]
fn main() {
// Rust — ВСЕ числовые преобразования явные
let small: i32 = 42;
let big: i64 = small as i64; // Расширение: явно через 'as'
let d: f64 = small as f64; // Целое в вещественное: явно
let truncated: i32 = 3.14_f64 as i32; // Сужение: 3 (отбрасывается дробная часть)
let b: u8 = 300_u16 as u8; // Переполнение: оборачивается до 44 (как unchecked в C#)
// Безопасное преобразование через TryFrom
use std::convert::TryFrom;
let safe: Result<u8, _> = u8::try_from(300_u16); // Err — вне диапазона
let ok: Result<u8, _> = u8::try_from(42_u16); // Ok(42)
// Разбор строки — возвращает Result, а не bool + out-параметр
let parsed: Result<i32, _> = "42".parse::<i32>(); // Ok(42)
let bad: Result<i32, _> = "abc".parse::<i32>(); // Err(ParseIntError)
// С синтаксисом turbofish:
let n = "42".parse::<f64>().unwrap(); // 42.0
}
Преобразования строк
// C#
int n = 42;
string s = n.ToString(); // "42"
string formatted = $"{n:X}";
int back = int.Parse(s); // 42 или исключение
bool ok = int.TryParse(s, out int result);
#![allow(unused)]
fn main() {
// Rust — to_string() через Display, parse() через FromStr
let n: i32 = 42;
let s: String = n.to_string(); // "42" (использует трейт Display)
let formatted = format!("{n:X}"); // "2A"
let back: i32 = s.parse().unwrap(); // 42 или panic
let result: Result<i32, _> = s.parse(); // Ok(42) — безопасная версия
// Преобразования &str ↔ String (самое частое преобразование в Rust)
let owned: String = "hello".to_string(); // &str → String
let owned2: String = String::from("hello"); // &str → String (то же самое)
let borrowed: &str = &owned; // String → &str (бесплатно, это просто заимствование)
}
Преобразования ссылочных типов (никакого приведения по наследованию!)
// C# — приведение вверх и вниз по иерархии
Animal a = new Dog(); // Приведение вверх (неявное)
Dog d = (Dog)a; // Приведение вниз (явное, может бросить исключение)
if (a is Dog dog) { /* ... */ } // Безопасное приведение вниз через сопоставление с образцом
#![allow(unused)]
fn main() {
// Rust — нет наследования, нет приведения вверх и вниз
// Для полиморфизма используйте трейт-объекты:
let animal: Box<dyn Animal> = Box::new(Dog);
// «Приведение вниз» требует трейта Any (нужно редко):
use std::any::Any;
if let Some(dog) = animal_any.downcast_ref::<Dog>() {
// Используем dog
}
// На практике вместо приведения вниз используйте перечисления:
enum Animal {
Dog(Dog),
Cat(Cat),
}
match animal {
Animal::Dog(d) => { /* используем d */ }
Animal::Cat(c) => { /* используем c */ }
}
}
Краткая справка
| C# | Rust | Примечания |
|---|---|---|
(int)x | x as i32 | Усечение/оборачивание при приведении |
| Неявное расширение | Нужно использовать as | Неявных числовых преобразований нет |
Convert.ToInt32(x) | i32::try_from(x) | Безопасно, возвращает Result |
int.Parse(s) | s.parse::<i32>().unwrap() | Паника при ошибке |
int.TryParse(s, out n) | s.parse::<i32>() | Возвращает Result<i32, _> |
(Dog)animal | Недоступно | Используйте перечисления или Any |
as Dog / is Dog | downcast_ref::<Dog>() | Через трейт Any; предпочтительны перечисления |
Комментарии и документация
Обычные комментарии
// Комментарии в C#
// Однострочный комментарий
/* Многострочный
комментарий */
/// <summary>
/// XML-комментарий документации
/// </summary>
/// <param name="name">Имя пользователя</param>
/// <returns>Строка приветствия</returns>
public string Greet(string name)
{
return $"Hello, {name}!";
}
#![allow(unused)]
fn main() {
// Комментарии в Rust
// Однострочный комментарий
/* Многострочный
комментарий */
/// Комментарий документации (аналог C# ///)
/// Эта функция приветствует пользователя по имени.
///
/// # Аргументы
///
/// * `name` - Имя пользователя в виде среза строки
///
/// # Возвращает
///
/// `String`, содержащую приветствие
///
/// # Примеры
///
/// ```
/// let greeting = greet("Alice");
/// assert_eq!(greeting, "Hello, Alice!");
/// ```
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
}
Генерация документации
# Генерация документации (аналог XML-документации в C#)
cargo doc --open
# Запуск тестов документации
cargo test --doc
Упражнения
🏋️ Упражнение: температура с безопасными типами (нажмите, чтобы раскрыть)
Напишите программу на Rust, которая:
- Объявляет
constдля абсолютного нуля в градусах Цельсия (-273.15) - Объявляет
static-счётчик количества выполненных преобразований (используйтеAtomicU32) - Пишет функцию
celsius_to_fahrenheit(c: f64) -> f64, которая отвергает температуры ниже абсолютного нуля, возвращаяf64::NAN - Демонстрирует затенение: разбирает строку
"98.6"вf64, а затем преобразует её
🔑 Решение
use std::sync::atomic::{AtomicU32, Ordering};
const ABSOLUTE_ZERO_C: f64 = -273.15;
static CONVERSION_COUNT: AtomicU32 = AtomicU32::new(0);
fn celsius_to_fahrenheit(c: f64) -> f64 {
if c < ABSOLUTE_ZERO_C {
return f64::NAN;
}
CONVERSION_COUNT.fetch_add(1, Ordering::Relaxed);
c * 9.0 / 5.0 + 32.0
}
fn main() {
let temp = "98.6"; // &str
let temp: f64 = temp.parse().unwrap(); // затеняем как f64
let temp = celsius_to_fahrenheit(temp); // затеняем как Фаренгейт
println!("{temp:.1}°F");
println!("Преобразований: {}", CONVERSION_COUNT.load(Ordering::Relaxed));
}
Истинная неизменяемость и иллюзии record
Истинная неизменяемость против иллюзий record
Что вы узнаете: почему типы
recordв C# не являются по-настоящему неизменяемыми (изменяемые поля, обход через рефлексию), как Rust обеспечивает настоящую неизменяемость на этапе компиляции и когда использовать паттерны внутренней изменяемости.Сложность: 🟡 Средний
Records в C# — театр неизменяемости
// Records в C# выглядят неизменяемыми, но у них есть лазейки
public record Person(string Name, int Age, List<string> Hobbies);
var person = new Person("John", 30, new List<string> { "reading" });
// Все эти операции «выглядят» как создание новых экземпляров:
var older = person with { Age = 31 }; // Новый record
var renamed = person with { Name = "Jonathan" }; // Новый record
// Но ссылочные типы всё ещё изменяемы!
person.Hobbies.Add("gaming"); // Меняет исходный объект!
Console.WriteLine(older.Hobbies.Count); // 2 — older тоже затронут!
Console.WriteLine(renamed.Hobbies.Count); // 2 — renamed тоже затронут!
// Свойства с init-доступом всё равно можно установить через рефлексию
typeof(Person).GetProperty("Age")?.SetValue(person, 25);
// Коллекционные выражения помогают, но не решают фундаментальную проблему
public record BetterPerson(string Name, int Age, IReadOnlyList<string> Hobbies);
var betterPerson = new BetterPerson("Jane", 25, new List<string> { "painting" });
// Всё равно изменяемо через приведение типов:
((List<string>)betterPerson.Hobbies).Add("hacking the system");
// Даже «неизменяемые» коллекции не являются по-настоящему неизменяемыми
using System.Collections.Immutable;
public record SafePerson(string Name, int Age, ImmutableList<string> Hobbies);
// Это лучше, но требует дисциплины и имеет накладные расходы на производительность
Rust — настоящая неизменяемость по умолчанию
#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
struct Person {
name: String,
age: u32,
hobbies: Vec<String>,
}
let person = Person {
name: "John".to_string(),
age: 30,
hobbies: vec!["reading".to_string()],
};
// Этот код просто не скомпилируется:
// person.age = 31; // ОШИБКА: нельзя присвоить значение неизменяемому полю
// person.hobbies.push("gaming".to_string()); // ОШИБКА: нельзя заимствовать как изменяемое
// Для изменения нужно явно включить 'mut':
let mut older_person = person.clone();
older_person.age = 31; // Теперь ясно, что это мутация
// Или используйте паттерн обновления через структурное выражение:
let renamed = Person {
name: "Jonathan".to_string(),
..person // Копирует остальные поля (применяется семантика перемещения)
};
// Исходный объект гарантированно не меняется (пока не перемещён):
println!("{:?}", person.hobbies); // Всегда ["reading"] — неизменяемо
// Структурное разделение с эффективными неизменяемыми структурами данных
use std::rc::Rc;
#[derive(Debug, Clone)]
struct EfficientPerson {
name: String,
age: u32,
hobbies: Rc<Vec<String>>, // Общая, неизменяемая ссылка
}
// Создание новых версий эффективно разделяет данные
let person1 = EfficientPerson {
name: "Alice".to_string(),
age: 30,
hobbies: Rc::new(vec!["reading".to_string(), "cycling".to_string()]),
};
let person2 = EfficientPerson {
name: "Bob".to_string(),
age: 25,
hobbies: Rc::clone(&person1.hobbies), // Общая ссылка, без глубокого копирования
};
}
graph TD
subgraph "Records C# — поверхностная неизменяемость"
CS_RECORD["record Person(...)"]
CS_WITH["выражения with"]
CS_SHALLOW["⚠️ Неизменяем только верхний уровень"]
CS_REF_MUT["❌ Ссылочные типы всё ещё изменяемы"]
CS_REFLECTION["❌ Рефлексия может обойти ограничения"]
CS_RUNTIME["❌ Сюрпризы во время выполнения"]
CS_DISCIPLINE["😓 Требуется дисциплина команды"]
CS_RECORD --> CS_WITH
CS_WITH --> CS_SHALLOW
CS_SHALLOW --> CS_REF_MUT
CS_RECORD --> CS_REFLECTION
CS_REF_MUT --> CS_RUNTIME
CS_RUNTIME --> CS_DISCIPLINE
end
subgraph "Rust — настоящая неизменяемость"
RUST_STRUCT["struct Person { ... }"]
RUST_DEFAULT["✅ Неизменяемо по умолчанию"]
RUST_COMPILE["✅ Принудительная проверка на этапе компиляции"]
RUST_MUT["🔒 Требуется явный 'mut'"]
RUST_MOVE["🔄 Семантика перемещения"]
RUST_ZERO["⚡ Нулевые накладные расходы рантайма"]
RUST_SAFE["🛡️ Безопасность памяти"]
RUST_STRUCT --> RUST_DEFAULT
RUST_DEFAULT --> RUST_COMPILE
RUST_COMPILE --> RUST_MUT
RUST_MUT --> RUST_MOVE
RUST_MOVE --> RUST_ZERO
RUST_ZERO --> RUST_SAFE
end
style CS_REF_MUT fill:#ffcdd2,color:#000
style CS_REFLECTION fill:#ffcdd2,color:#000
style CS_RUNTIME fill:#ffcdd2,color:#000
style RUST_COMPILE fill:#c8e6c9,color:#000
style RUST_ZERO fill:#c8e6c9,color:#000
style RUST_SAFE fill:#c8e6c9,color:#000
Упражнения
🏋️ Упражнение: докажите неизменяемость (нажмите, чтобы раскрыть)
Ваш коллега на C# утверждает, что его record неизменяем. Переведите этот код на Rust и объясните, почему версия на Rust действительно неизменяема:
public record Config(string Host, int Port, List<string> AllowedOrigins);
var config = new Config("localhost", 8080, new List<string> { "example.com" });
// «Неизменяемый» record... но:
config.AllowedOrigins.Add("evil.com"); // Компилируется! List изменяем.
- Создайте эквивалентную структуру на Rust, которая действительно неизменяема
- Покажите, что попытка изменить
allowed_origins— это ошибка компиляции - Напишите функцию, которая создаёт изменённую копию (с новым хостом) без мутации
🔑 Решение
#[derive(Debug, Clone)]
struct Config {
host: String,
port: u16,
allowed_origins: Vec<String>,
}
impl Config {
fn with_host(&self, host: impl Into<String>) -> Self {
Config {
host: host.into(),
..self.clone()
}
}
}
fn main() {
let config = Config {
host: "localhost".into(),
port: 8080,
allowed_origins: vec!["example.com".into()],
};
// config.allowed_origins.push("evil.com".into());
// ❌ ОШИБКА: нельзя заимствовать `config.allowed_origins` как изменяемое
let production = config.with_host("prod.example.com");
println!("Dev: {:?}", config); // исходный объект не изменился
println!("Prod: {:?}", production); // новая копия с другим хостом
}
Ключевая мысль: в Rust let config = ... (без mut) делает всё дерево значений неизменяемым — включая вложенный Vec. Records в C# делают неизменяемой только ссылку, но не содержимое.
4. Управление потоком выполнения
Функции и методы
Что вы узнаете: функции и методы в Rust по сравнению с C#, важнейшее различие между выражениями и инструкциями, синтаксис
if/match/loop/while/for, а также то, как ориентированный на выражения дизайн Rust делает ненужными тернарные операторы.Сложность: 🟢 Начальный
Объявление функций в C#
// C# — методы внутри классов
public class Calculator
{
// Метод экземпляра
public int Add(int a, int b)
{
return a + b;
}
// Статический метод
public static int Multiply(int a, int b)
{
return a * b;
}
// Метод с параметром ref
public void Increment(ref int value)
{
value++;
}
}
Объявление функций в Rust
// Rust — самостоятельные функции
fn add(a: i32, b: i32) -> i32 {
a + b // Ключевое слово 'return' не нужно для последнего выражения
}
fn multiply(a: i32, b: i32) -> i32 {
return a * b; // Явный return тоже допустим
}
// Функция с изменяемой ссылкой
fn increment(value: &mut i32) {
*value += 1;
}
fn main() {
let result = add(5, 3);
println!("5 + 3 = {}", result);
let mut x = 10;
increment(&mut x);
println!("После increment: {}", x);
}
Выражение против инструкции (важно!)
graph LR
subgraph "C# — инструкции"
CS1["if (cond)"] --> CS2["return 42;"]
CS1 --> CS3["return 0;"]
CS2 --> CS4["Значение выходит через return"]
CS3 --> CS4
end
subgraph "Rust — выражения"
RS1["if cond"] --> RS2["42 (без точки с запятой)"]
RS1 --> RS3["0 (без точки с запятой)"]
RS2 --> RS4["Блок И ЕСТЬ значение"]
RS3 --> RS4
end
style CS4 fill:#bbdefb,color:#000
style RS4 fill:#c8e6c9,color:#000
// C# — инструкции и выражения
public int GetValue()
{
if (condition)
{
return 42; // Инструкция
}
return 0; // Инструкция
}
#![allow(unused)]
fn main() {
// Rust — почти всё может быть выражением
fn get_value(condition: bool) -> i32 {
if condition {
42 // Выражение (без точки с запятой)
} else {
0 // Выражение (без точки с запятой)
}
// Сам блок if-else — это выражение, которое возвращает значение
}
// Или ещё проще
fn get_value_ternary(condition: bool) -> i32 {
if condition { 42 } else { 0 }
}
}
Параметры функций и типы возвращаемых значений
// Без параметров и без возвращаемого значения (возвращает тип unit ())
fn say_hello() {
println!("Привет!");
}
// Несколько параметров
fn greet(name: &str, age: u32) {
println!("{} — {} лет", name, age);
}
// Несколько возвращаемых значений через кортеж
fn divide_and_remainder(dividend: i32, divisor: i32) -> (i32, i32) {
(dividend / divisor, dividend % divisor)
}
fn main() {
let (quotient, remainder) = divide_and_remainder(10, 3);
println!("10 ÷ 3 = {} остаток {}", quotient, remainder);
}
Основы управления потоком
Условные инструкции
// Инструкции if в C#
int x = 5;
if (x > 10)
{
Console.WriteLine("Большое число");
}
else if (x > 5)
{
Console.WriteLine("Среднее число");
}
else
{
Console.WriteLine("Маленькое число");
}
// Тернарный оператор C#
string message = x > 10 ? "Большое" : "Маленькое";
#![allow(unused)]
fn main() {
// Выражения if в Rust
let x = 5;
if x > 10 {
println!("Большое число");
} else if x > 5 {
println!("Среднее число");
} else {
println!("Маленькое число");
}
// if как выражение (аналог тернарного оператора)
let message = if x > 10 { "Большое" } else { "Маленькое" };
// Несколько условий
let message = if x > 10 {
"Большое"
} else if x > 5 {
"Среднее"
} else {
"Маленькое"
};
}
Циклы
// Циклы в C#
// Цикл for
for (int i = 0; i < 5; i++)
{
Console.WriteLine(i);
}
// Цикл foreach
var numbers = new[] { 1, 2, 3, 4, 5 };
foreach (var num in numbers)
{
Console.WriteLine(num);
}
// Цикл while
int count = 0;
while (count < 3)
{
Console.WriteLine(count);
count++;
}
#![allow(unused)]
fn main() {
// Циклы в Rust
// Цикл по диапазону
for i in 0..5 { // От 0 до 4 (правая граница не включается)
println!("{}", i);
}
// Перебор коллекции
let numbers = vec![1, 2, 3, 4, 5];
for num in numbers { // Забирает владение
println!("{}", num);
}
// Перебор ссылок (встречается чаще)
let numbers = vec![1, 2, 3, 4, 5];
for num in &numbers { // Заимствует элементы
println!("{}", num);
}
// Цикл while
let mut count = 0;
while count < 3 {
println!("{}", count);
count += 1;
}
// Бесконечный цикл с break
let mut counter = 0;
loop {
if counter >= 3 {
break;
}
println!("{}", counter);
counter += 1;
}
}
Управление циклом
// Управление циклом в C#
for (int i = 0; i < 10; i++)
{
if (i == 3) continue;
if (i == 7) break;
Console.WriteLine(i);
}
#![allow(unused)]
fn main() {
// Управление циклом в Rust
for i in 0..10 {
if i == 3 { continue; }
if i == 7 { break; }
println!("{}", i);
}
// Метки циклов (для вложенных циклов)
'outer: for i in 0..3 {
'inner: for j in 0..3 {
if i == 1 && j == 1 {
break 'outer; // Выход из внешнего цикла
}
println!("i: {}, j: {}", i, j);
}
}
}
🏋️ Упражнение: конвертер температур (нажмите, чтобы раскрыть)
Задача: переведите эту программу на C# на идиоматичный Rust. Используйте выражения, сопоставление с образцом и правильную обработку ошибок.
// C# — переведите это на Rust
public static double Convert(double value, string from, string to)
{
double celsius = from switch
{
"F" => (value - 32.0) * 5.0 / 9.0,
"K" => value - 273.15,
"C" => value,
_ => throw new ArgumentException($"Unknown unit: {from}")
};
return to switch
{
"F" => celsius * 9.0 / 5.0 + 32.0,
"K" => celsius + 273.15,
"C" => celsius,
_ => throw new ArgumentException($"Unknown unit: {to}")
};
}
🔑 Решение
#[derive(Debug, Clone, Copy)]
enum TempUnit { Celsius, Fahrenheit, Kelvin }
fn parse_unit(s: &str) -> Result<TempUnit, String> {
match s {
"C" => Ok(TempUnit::Celsius),
"F" => Ok(TempUnit::Fahrenheit),
"K" => Ok(TempUnit::Kelvin),
_ => Err(format!("Неизвестная единица: {s}")),
}
}
fn convert(value: f64, from: TempUnit, to: TempUnit) -> f64 {
let celsius = match from {
TempUnit::Fahrenheit => (value - 32.0) * 5.0 / 9.0,
TempUnit::Kelvin => value - 273.15,
TempUnit::Celsius => value,
};
match to {
TempUnit::Fahrenheit => celsius * 9.0 / 5.0 + 32.0,
TempUnit::Kelvin => celsius + 273.15,
TempUnit::Celsius => celsius,
}
}
fn main() -> Result<(), String> {
let from = parse_unit("F")?;
let to = parse_unit("C")?;
println!("212°F = {:.1}°C", convert(212.0, from, to));
Ok(())
}
Ключевые выводы:
- Перечисления заменяют «магические» строки — исчерпывающее сопоставление ловит пропущенные единицы на этапе компиляции
Result<T, E>заменяет исключения — вызывающий код видит возможные ошибки в сигнатуреmatch— это выражение, которое возвращает значение, — инструкцииreturnне нужны
5. Структуры данных и коллекции
Кортежи и деструктуризация
Что вы узнаете: кортежи Rust против
ValueTupleв C#, массивы и срезы, структуры против классов, паттерн newtype для моделирования предметной области с безопасностью типов без накладных расходов, а также синтаксис деструктуризации.Сложность: 🟢 Начальный
В C# есть ValueTuple (начиная с C# 7). Кортежи Rust похожи на них, но более глубоко интегрированы в язык.
Кортежи в C#
// ValueTuple в C# (C# 7+)
var point = (10, 20); // (int, int)
var named = (X: 10, Y: 20); // Именованные элементы
Console.WriteLine($"{named.X}, {named.Y}");
// Кортеж как возвращаемый тип
public (int Quotient, int Remainder) Divide(int a, int b)
{
return (a / b, a % b);
}
var (q, r) = Divide(10, 3); // Деконструкция
Console.WriteLine($"{q} remainder {r}");
// Пропуски (discards)
var (_, remainder) = Divide(10, 3); // Игнорируем частное
Кортежи в Rust
#![allow(unused)]
fn main() {
// Кортежи Rust — неизменяемы по умолчанию, без именованных элементов
let point = (10, 20); // (i32, i32)
let point3d: (f64, f64, f64) = (1.0, 2.0, 3.0);
// Доступ по индексу (с нуля)
println!("x={}, y={}", point.0, point.1);
// Кортеж как возвращаемый тип
fn divide(a: i32, b: i32) -> (i32, i32) {
(a / b, a % b)
}
let (q, r) = divide(10, 3); // Деструктуризация
println!("{q} remainder {r}");
// Пропуск через _
let (_, remainder) = divide(10, 3);
// Тип unit () — «пустой кортеж» (аналог void в C#)
fn greet() { // неявный возвращаемый тип — ()
println!("hi");
}
}
Ключевые различия
| Характеристика | ValueTuple в C# | Кортеж Rust |
|---|---|---|
| Именованные элементы | (int X, int Y) | Не поддерживаются — используйте структуры |
| Максимальная арность | ~8 (для большего — вложенность) | Без ограничений (практически ~12) |
| Сравнение | Автоматически | Автоматически для кортежей до 12 элементов |
| Использование как ключ словаря | Да | Да (если элементы реализуют Hash) |
| Возврат из функций | Часто | Часто |
| Изменяемые элементы | Всегда изменяемы | Только при let mut |
Кортежные структуры (newtype)
#![allow(unused)]
fn main() {
// Когда обычного кортежа недостаточно, используйте кортежную структуру:
struct Meters(f64); // Обёртка «newtype» с одним полем
struct Celsius(f64);
struct Fahrenheit(f64);
// Компилятор считает их РАЗНЫМИ типами:
let distance = Meters(100.0);
let temp = Celsius(36.6);
// distance == temp; // ❌ ОШИБКА: нельзя сравнивать Meters и Celsius
// Паттерн newtype предотвращает ошибки перепутанных единиц на этапе компиляции!
// В C# для той же безопасности понадобился бы полноценный класс или структура.
}
// В C# аналог требует больше церемоний:
public readonly record struct Meters(double Value);
public readonly record struct Celsius(double Value);
// Они не взаимозаменяемы, но records добавляют накладные расходы по сравнению с newtype в Rust, которые не несут затрат
Паттерн newtype в деталях: моделирование предметной области без затрат
Newtype’ы гораздо полезнее, чем защита от путаницы единиц измерения. Это основной инструмент Rust для кодирования бизнес-правил в системе типов — он заменяет шаблоны «защитного условия» и «класса валидации», распространённые в C#.
Подход C# к валидации: проверки во время выполнения
// C# — валидация происходит во время выполнения, каждый раз
public class UserService
{
public User CreateUser(string email, int age)
{
if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
throw new ArgumentException("Invalid email");
if (age < 0 || age > 150)
throw new ArgumentException("Invalid age");
return new User { Email = email, Age = age };
}
public void SendEmail(string email)
{
// Нужно перепроверить — или доверять вызывающему коду?
if (!email.Contains('@')) throw new ArgumentException("Invalid email");
// ...
}
}
Подход Rust с newtype: доказательство на этапе компиляции
#![allow(unused)]
fn main() {
/// Проверенный адрес электронной почты — сам тип является доказательством корректности.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Email(String);
impl Email {
/// ЕДИНСТВЕННЫЙ способ создать Email — валидация выполняется один раз, при создании.
pub fn new(raw: &str) -> Result<Self, &'static str> {
if raw.contains('@') && raw.len() > 3 {
Ok(Email(raw.to_lowercase()))
} else {
Err("invalid email format")
}
}
/// Безопасный доступ к внутреннему значению
pub fn as_str(&self) -> &str { &self.0 }
}
/// Проверенный возраст — невозможно создать некорректный.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Age(u8);
impl Age {
pub fn new(raw: u8) -> Result<Self, &'static str> {
if raw <= 150 { Ok(Age(raw)) } else { Err("age out of range") }
}
pub fn value(&self) -> u8 { self.0 }
}
// Теперь функции принимают ДОКАЗАННЫЕ типы — повторная валидация не нужна!
fn create_user(email: Email, age: Age) -> User {
// email ГАРАНТИРОВАННО корректен — это инвариант типа
User { email, age }
}
fn send_email(to: &Email) {
// Валидация не нужна — тип Email доказывает корректность
println!("Sending to: {}", to.as_str());
}
}
Типичные применения newtype для разработчиков C#
| Паттерн в C# | Newtype в Rust | Что предотвращает |
|---|---|---|
string для UserId, Email и т. д. | struct UserId(Uuid) | Передачу неверной строки в неверный параметр |
int для Port, Count, Index | struct Port(u16) | Port и Count нельзя перепутать |
| Защитные условия повсюду | Однократная валидация в конструкторе | Повторную проверку, пропущенную валидацию |
decimal для USD, EUR | struct Usd(Decimal) | Случайное сложение USD с EUR |
TimeSpan с разной семантикой | struct Timeout(Duration) | Передачу таймаута соединения как таймаута запроса |
#![allow(unused)]
fn main() {
// Без затрат: newtype компилируется в тот же машинный код, что и внутренний тип.
// Этот код на Rust:
struct UserId(u64);
fn lookup(id: UserId) -> Option<User> { /* ... */ }
// Генерирует ТОТ ЖЕ машинный код, что и:
fn lookup(id: u64) -> Option<User> { /* ... */ }
// Но с полной безопасностью типов на этапе компиляции!
}
Массивы и срезы
Понимание различий между массивами, срезами и векторами крайне важно.
Массивы в C#
// Массивы в C#
int[] numbers = new int[5]; // Фиксированный размер, размещается в куче
int[] initialized = { 1, 2, 3, 4, 5 }; // Литерал массива
// Доступ
numbers[0] = 10;
int first = numbers[0];
// Длина
int length = numbers.Length;
// Массив как параметр (ссылочный тип)
void ProcessArray(int[] array)
{
array[0] = 99; // Изменяет исходный массив
}
Массивы, срезы и векторы в Rust
#![allow(unused)]
fn main() {
// 1. Массивы — фиксированный размер, размещаются на стеке
let numbers: [i32; 5] = [1, 2, 3, 4, 5]; // Тип: [i32; 5]
let zeros = [0; 10]; // 10 нулей
// Доступ
let first = numbers[0];
// numbers[0] = 10; // ❌ Ошибка: массивы по умолчанию неизменяемы
let mut mut_array = [1, 2, 3, 4, 5];
mut_array[0] = 10; // ✅ Работает с mut
// 2. Срезы — представления массивов или векторов
let slice: &[i32] = &numbers[1..4]; // Элементы 1, 2, 3
let all_slice: &[i32] = &numbers; // Весь массив как срез
// 3. Векторы — динамический размер, размещаются в куче (рассмотрены ранее)
let mut vec = vec![1, 2, 3, 4, 5];
vec.push(6); // Может расти
}
Срезы как параметры функций
// C# — метод, работающий с массивами
public void ProcessNumbers(int[] numbers)
{
for (int i = 0; i < numbers.Length; i++)
{
Console.WriteLine(numbers[i]);
}
}
// Работает только с массивами
ProcessNumbers(new int[] { 1, 2, 3 });
// Rust — функция, работающая с любой последовательностью
fn process_numbers(numbers: &[i32]) { // Параметр-срез
for (i, num) in numbers.iter().enumerate() {
println!("Индекс {}: {}", i, num);
}
}
fn main() {
let array = [1, 2, 3, 4, 5];
let vec = vec![1, 2, 3, 4, 5];
// Одна и та же функция работает с обоими!
process_numbers(&array); // Массив как срез
process_numbers(&vec); // Вектор как срез
process_numbers(&vec[1..4]); // Частичный срез
}
Строковые срезы (&str) — повторно
#![allow(unused)]
fn main() {
// Связь между String и &str
fn string_slice_example() {
let owned = String::from("Hello, World!");
let slice: &str = &owned[0..5]; // "Hello"
let slice2: &str = &owned[7..]; // "World!"
println!("{}", slice); // "Hello"
println!("{}", slice2); // "World!"
// Функция, принимающая любой строковый тип
print_string("String literal"); // &str
print_string(&owned); // String как &str
print_string(slice); // Срез &str
}
fn print_string(s: &str) {
println!("{}", s);
}
}
Современный C#: Span<T> и встроенные массивы
C# ушёл далеко от традиционных массивов. Span<T> предоставляет типобезопасные представления непрерывной памяти, которые могут находиться на стеке, а встроенные массивы (C# 12) дают буферы фиксированного размера на стеке.
// Span<T> в C# — представление непрерывной памяти
Span<int> span = stackalloc int[] { 1, 2, 3, 4, 5 };
span[0] = 10;
ReadOnlySpan<char> text = "Hello".AsSpan();
// Метод, принимающий любое представление непрерывной памяти
void ProcessSpan(ReadOnlySpan<int> data)
{
for (int i = 0; i < data.Length; i++)
Console.WriteLine(data[i]);
}
// Встроенные массивы (C# 12) — буфер фиксированного размера на стеке
[InlineArray(5)]
struct IntBuffer
{
private int _element;
}
#![allow(unused)]
fn main() {
// Rust &[T] / &mut [T] — заимствованное представление непрерывной памяти
let mut array = [1, 2, 3, 4, 5];
let slice: &mut [i32] = &mut array;
slice[0] = 10;
let slice: &[i32] = &array;
let text: &str = "Hello";
// Функция, принимающая любые последовательные данные
fn process_slice(data: &[i32]) {
for (i, num) in data.iter().enumerate() {
println!("Индекс {}: {}", i, num);
}
}
// Массивы фиксированного размера (размещаются на стеке)
let buffer: [i32; 5] = [0; 5];
}
| C# | Rust |
|---|---|
Span<T> (ref struct, только на стеке) | &mut [T] / &[T] (заимствованный срез) |
ReadOnlySpan<T> | &[T] (неизменяемый срез) |
ReadOnlySpan<char> / string.AsSpan() | &str (строковый срез) |
Структура [InlineArray(N)] (C# 12) | [T; N] (массив фиксированного размера) |
stackalloc T[] с Span<T> | let arr: [T; N] = ... (локальный массив) |
Ключевая мысль:
&[T]в Rust совмещает ролиArraySegment<T>,Span<T>иReadOnlySpan<T>из C# — это «толстый» указатель (указатель + длина), который работает с массивами, векторами и подсрезами. Встроенные массивы C# естественно соответствуют массивам[T; N]в Rust, которые тоже по умолчанию размещаются на стеке.
Структуры против классов
Структуры в Rust похожи на классы C#, но имеют несколько ключевых отличий, связанных с владением и методами.
graph TD
subgraph "Класс C# (куча)"
CObj["Заголовок объекта<br/>+ указатель на vtable"] --> CFields["Name: ссылка на string<br/>Age: int<br/>Hobbies: ссылка на List"]
CFields --> CHeap1["#quot;Alice#quot; в куче"]
CFields --> CHeap2["List<string> в куче"]
end
subgraph "Структура Rust (стек)"
RFields["name: String<br/> ptr | len | cap<br/>age: i32<br/>hobbies: Vec<br/> ptr | len | cap"]
RFields --> RHeap1["#quot;Alice#quot; буфер в куче"]
RFields --> RHeap2["Буфер Vec в куче"]
end
style CObj fill:#bbdefb,color:#000
style RFields fill:#c8e6c9,color:#000
Ключевая мысль: классы C# всегда живут в куче за ссылкой. Структуры Rust по умолчанию живут на стеке — в куче находятся только данные динамического размера (например, содержимое
String). Это устраняет накладные расходы GC для небольших часто создаваемых объектов.
Определение класса в C#
// Класс C# со свойствами и методами
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public List<string> Hobbies { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
Hobbies = new List<string>();
}
public void AddHobby(string hobby)
{
Hobbies.Add(hobby);
}
public string GetInfo()
{
return $"{Name} is {Age} years old";
}
}
Определение структуры в Rust
#![allow(unused)]
fn main() {
// Структура Rust с ассоциированными функциями и методами
#[derive(Debug)] // Автоматически реализует трейт Debug
pub struct Person {
pub name: String, // Публичное поле
pub age: u32, // Публичное поле
hobbies: Vec<String>, // Приватное поле (без pub)
}
impl Person {
// Ассоциированная функция (аналог статического метода)
pub fn new(name: String, age: u32) -> Person {
Person {
name,
age,
hobbies: Vec::new(),
}
}
// Метод (принимает &self, &mut self или self)
pub fn add_hobby(&mut self, hobby: String) {
self.hobbies.push(hobby);
}
// Метод, который заимствует неизменяемо
pub fn get_info(&self) -> String {
format!("{} is {} years old", self.name, self.age)
}
// Геттер для приватного поля
pub fn hobbies(&self) -> &Vec<String> {
&self.hobbies
}
}
}
Создание и использование экземпляров
// Создание и использование объекта в C#
var person = new Person("Alice", 30);
person.AddHobby("Reading");
person.AddHobby("Swimming");
Console.WriteLine(person.GetInfo());
Console.WriteLine($"Hobbies: {string.Join(", ", person.Hobbies)}");
// Прямое изменение свойств
person.Age = 31;
#![allow(unused)]
fn main() {
// Создание и использование структуры в Rust
let mut person = Person::new("Alice".to_string(), 30);
person.add_hobby("Reading".to_string());
person.add_hobby("Swimming".to_string());
println!("{}", person.get_info());
println!("Hobbies: {:?}", person.hobbies());
// Прямое изменение публичных полей
person.age = 31;
// Вывод всей структуры в отладочном формате
println!("{:?}", person);
}
Паттерны инициализации структур
// Инициализация объекта в C#
var person = new Person("Bob", 25)
{
Hobbies = new List<string> { "Gaming", "Coding" }
};
// Анонимные типы
var anonymous = new { Name = "Charlie", Age = 35 };
#![allow(unused)]
fn main() {
// Инициализация структуры в Rust
let person = Person {
name: "Bob".to_string(),
age: 25,
hobbies: vec!["Gaming".to_string(), "Coding".to_string()],
};
// Синтаксис обновления структуры (аналог spread для объектов)
let older_person = Person {
age: 26,
..person // Берём остальные поля из person (перемещает person!)
};
// Кортежные структуры (аналог анонимных типов)
#[derive(Debug)]
struct Point(i32, i32);
let point = Point(10, 20);
println!("Point: ({}, {})", point.0, point.1);
}
Методы и ассоциированные функции
Понимание различий между методами и ассоциированными функциями — ключ к успеху.
Виды методов в C#
public class Calculator
{
private int memory = 0;
// Метод экземпляра
public int Add(int a, int b)
{
return a + b;
}
// Метод экземпляра, который использует состояние
public void StoreInMemory(int value)
{
memory = value;
}
// Статический метод
public static int Multiply(int a, int b)
{
return a * b;
}
// Статический фабричный метод
public static Calculator CreateWithMemory(int initialMemory)
{
var calc = new Calculator();
calc.memory = initialMemory;
return calc;
}
}
Виды методов в Rust
#[derive(Debug)]
pub struct Calculator {
memory: i32,
}
impl Calculator {
// Ассоциированная функция (аналог статического метода) — без параметра self
pub fn new() -> Calculator {
Calculator { memory: 0 }
}
// Ассоциированная функция с параметрами
pub fn with_memory(initial_memory: i32) -> Calculator {
Calculator { memory: initial_memory }
}
// Метод, который заимствует неизменяемо (&self)
pub fn add(&self, a: i32, b: i32) -> i32 {
a + b
}
// Метод, который заимствует изменяемо (&mut self)
pub fn store_in_memory(&mut self, value: i32) {
self.memory = value;
}
// Метод, который забирает владение (self)
pub fn into_memory(self) -> i32 {
self.memory // Calculator потребляется
}
// Метод-геттер
pub fn memory(&self) -> i32 {
self.memory
}
}
fn main() {
// Ассоциированные функции вызываются через ::
let mut calc = Calculator::new();
let calc2 = Calculator::with_memory(42);
// Методы вызываются через .
let result = calc.add(5, 3);
calc.store_in_memory(result);
println!("Memory: {}", calc.memory());
// Метод, потребляющий значение
let memory_value = calc.into_memory(); // calc больше нельзя использовать
println!("Final memory: {}", memory_value);
}
Виды получателей методов (receiver)
#![allow(unused)]
fn main() {
impl Person {
// &self — неизменяемое заимствование (самый частый вариант)
// Используйте, когда нужно только читать данные
pub fn get_name(&self) -> &str {
&self.name
}
// &mut self — изменяемое заимствование
// Используйте, когда нужно изменять данные
pub fn set_name(&mut self, name: String) {
self.name = name;
}
// self — забрать владение (встречается реже)
// Используйте, когда структура должна быть потреблена
pub fn consume(self) -> String {
self.name // Person перемещён, больше недоступен
}
}
fn method_examples() {
let mut person = Person::new("Alice".to_string(), 30);
// Неизменяемое заимствование
let name = person.get_name(); // person по-прежнему можно использовать
println!("Name: {}", name);
// Изменяемое заимствование
person.set_name("Alice Smith".to_string()); // person по-прежнему можно использовать
// Забираем владение
let final_name = person.consume(); // person больше нельзя использовать
println!("Final name: {}", final_name);
}
}
Упражнения
🏋️ Упражнение: скользящее среднее по срезу (нажмите, чтобы раскрыть)
Задача: напишите функцию, которая принимает срез значений f64 и размер окна, и возвращает Vec<f64> со скользящими средними. Например, [1.0, 2.0, 3.0, 4.0, 5.0] с окном 3 → [2.0, 3.0, 4.0].
fn rolling_average(data: &[f64], window: usize) -> Vec<f64> {
// Ваша реализация здесь
todo!()
}
fn main() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let avgs = rolling_average(&data, 3);
println!("{avgs:?}"); // [2.0, 3.0, 4.0]
}
🔑 Решение
fn rolling_average(data: &[f64], window: usize) -> Vec<f64> {
data.windows(window)
.map(|w| w.iter().sum::<f64>() / w.len() as f64)
.collect()
}
fn main() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let avgs = rolling_average(&data, 3);
assert_eq!(avgs, vec![2.0, 3.0, 4.0]);
println!("{avgs:?}");
}
Ключевая мысль: у срезов есть мощные встроенные методы вроде .windows(), .chunks() и .split(), которые заменяют ручную арифметику индексов. В C# вы бы использовали Enumerable.Range или LINQ .Skip().Take().
🏋️ Упражнение: мини-адресная книга (нажмите, чтобы раскрыть)
Создайте небольшую адресную книгу с использованием структур, перечислений и методов:
- Определите перечисление
PhoneType { Mobile, Home, Work } - Определите структуру
Contactс полямиname: Stringиphones: Vec<(PhoneType, String)> - Реализуйте
Contact::new(name: impl Into<String>) -> Self - Реализуйте
Contact::add_phone(&mut self, kind: PhoneType, number: impl Into<String>) - Реализуйте
Contact::mobile_numbers(&self) -> Vec<&str>, который возвращает только мобильные номера - В
mainсоздайте контакт, добавьте два телефона и выведите мобильные номера
🔑 Решение
#[derive(Debug, PartialEq)]
enum PhoneType { Mobile, Home, Work }
#[derive(Debug)]
struct Contact {
name: String,
phones: Vec<(PhoneType, String)>,
}
impl Contact {
fn new(name: impl Into<String>) -> Self {
Contact { name: name.into(), phones: Vec::new() }
}
fn add_phone(&mut self, kind: PhoneType, number: impl Into<String>) {
self.phones.push((kind, number.into()));
}
fn mobile_numbers(&self) -> Vec<&str> {
self.phones
.iter()
.filter(|(kind, _)| *kind == PhoneType::Mobile)
.map(|(_, num)| num.as_str())
.collect()
}
}
fn main() {
let mut alice = Contact::new("Alice");
alice.add_phone(PhoneType::Mobile, "+1-555-0100");
alice.add_phone(PhoneType::Work, "+1-555-0200");
alice.add_phone(PhoneType::Mobile, "+1-555-0101");
println!("Мобильные номера {}: {:?}", alice.name, alice.mobile_numbers());
}
Паттерны конструкторов
Паттерны конструкторов
Что вы узнаете: как создавать структуры Rust без традиционных конструкторов — соглашения
new(), трейтDefault, фабричные методы и паттерн builder для сложной инициализации.Сложность: 🟢 Начальный
Паттерны конструкторов в C#
public class Configuration
{
public string DatabaseUrl { get; set; }
public int MaxConnections { get; set; }
public bool EnableLogging { get; set; }
// Конструктор по умолчанию
public Configuration()
{
DatabaseUrl = "localhost";
MaxConnections = 10;
EnableLogging = false;
}
// Конструктор с параметрами
public Configuration(string databaseUrl, int maxConnections)
{
DatabaseUrl = databaseUrl;
MaxConnections = maxConnections;
EnableLogging = false;
}
// Фабричный метод
public static Configuration ForProduction()
{
return new Configuration("prod.db.server", 100)
{
EnableLogging = true
};
}
}
Паттерны конструкторов в Rust
#[derive(Debug)]
pub struct Configuration {
pub database_url: String,
pub max_connections: u32,
pub enable_logging: bool,
}
impl Configuration {
// Конструктор по умолчанию
pub fn new() -> Configuration {
Configuration {
database_url: "localhost".to_string(),
max_connections: 10,
enable_logging: false,
}
}
// Конструктор с параметрами
pub fn with_database(database_url: String, max_connections: u32) -> Configuration {
Configuration {
database_url,
max_connections,
enable_logging: false,
}
}
// Фабричный метод
pub fn for_production() -> Configuration {
Configuration {
database_url: "prod.db.server".to_string(),
max_connections: 100,
enable_logging: true,
}
}
// Метод в стиле builder
pub fn enable_logging(mut self) -> Configuration {
self.enable_logging = true;
self // Возвращаем self для цепочки вызовов
}
pub fn max_connections(mut self, count: u32) -> Configuration {
self.max_connections = count;
self
}
}
// Реализация трейта Default
impl Default for Configuration {
fn default() -> Self {
Self::new()
}
}
fn main() {
// Разные способы создания
let config1 = Configuration::new();
let config2 = Configuration::with_database("localhost:5432".to_string(), 20);
let config3 = Configuration::for_production();
// Паттерн builder
let config4 = Configuration::new()
.enable_logging()
.max_connections(50);
// Использование трейта Default
let config5 = Configuration::default();
println!("{:?}", config4);
}
Реализация паттерна Builder
// Более сложный паттерн builder
#[derive(Debug)]
pub struct DatabaseConfig {
host: String,
port: u16,
username: String,
password: Option<String>,
ssl_enabled: bool,
timeout_seconds: u64,
}
pub struct DatabaseConfigBuilder {
host: Option<String>,
port: Option<u16>,
username: Option<String>,
password: Option<String>,
ssl_enabled: bool,
timeout_seconds: u64,
}
impl DatabaseConfigBuilder {
pub fn new() -> Self {
DatabaseConfigBuilder {
host: None,
port: None,
username: None,
password: None,
ssl_enabled: false,
timeout_seconds: 30,
}
}
pub fn host(mut self, host: impl Into<String>) -> Self {
self.host = Some(host.into());
self
}
pub fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
pub fn username(mut self, username: impl Into<String>) -> Self {
self.username = Some(username.into());
self
}
pub fn password(mut self, password: impl Into<String>) -> Self {
self.password = Some(password.into());
self
}
pub fn enable_ssl(mut self) -> Self {
self.ssl_enabled = true;
self
}
pub fn timeout(mut self, seconds: u64) -> Self {
self.timeout_seconds = seconds;
self
}
pub fn build(self) -> Result<DatabaseConfig, String> {
let host = self.host.ok_or("Host is required")?;
let port = self.port.ok_or("Port is required")?;
let username = self.username.ok_or("Username is required")?;
Ok(DatabaseConfig {
host,
port,
username,
password: self.password,
ssl_enabled: self.ssl_enabled,
timeout_seconds: self.timeout_seconds,
})
}
}
fn main() {
let config = DatabaseConfigBuilder::new()
.host("localhost")
.port(5432)
.username("admin")
.password("secret123")
.enable_ssl()
.timeout(60)
.build()
.expect("Failed to build config");
println!("{:?}", config);
}
Упражнения
🏋️ Упражнение: builder с валидацией (нажмите, чтобы раскрыть)
Создайте EmailBuilder, который:
- Требует
toиsubject(builder не должен компилироваться без них — используйте typestate или проверяйте вbuild()) - Имеет необязательные
bodyиcc(Vec адресов) build()возвращаетResult<Email, String>— отвергает пустыеtoилиsubject- Напишите тесты, доказывающие, что некорректные входные данные отвергаются
🔑 Решение
#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Email {
to: String,
subject: String,
body: Option<String>,
cc: Vec<String>,
}
#[derive(Default)]
struct EmailBuilder {
to: Option<String>,
subject: Option<String>,
body: Option<String>,
cc: Vec<String>,
}
impl EmailBuilder {
fn new() -> Self { Self::default() }
fn to(mut self, to: impl Into<String>) -> Self {
self.to = Some(to.into()); self
}
fn subject(mut self, subject: impl Into<String>) -> Self {
self.subject = Some(subject.into()); self
}
fn body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into()); self
}
fn cc(mut self, addr: impl Into<String>) -> Self {
self.cc.push(addr.into()); self
}
fn build(self) -> Result<Email, String> {
let to = self.to.filter(|s| !s.is_empty())
.ok_or("'to' is required")?;
let subject = self.subject.filter(|s| !s.is_empty())
.ok_or("'subject' is required")?;
Ok(Email { to, subject, body: self.body, cc: self.cc })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_email() {
let email = EmailBuilder::new()
.to("alice@example.com")
.subject("Hello")
.build();
assert!(email.is_ok());
}
#[test]
fn missing_to_fails() {
let email = EmailBuilder::new().subject("Hello").build();
assert!(email.is_err());
}
}
}
Коллекции — Vec, HashMap и итераторы
Vec<T> против List<T>
Что вы узнаете:
Vec<T>противList<T>,HashMapпротивDictionary, безопасные паттерны доступа (почему Rust возвращаетOptionвместо генерации исключения), а также последствия владения для коллекций.Сложность: 🟢 Начальный
Vec<T> — аналог List<T> из C# в Rust, но с семантикой владения.
List<T> в C#
// List<T> в C# — ссылочный тип, размещается в куче
var numbers = new List<int>();
numbers.Add(1);
numbers.Add(2);
numbers.Add(3);
// Передача в метод — ссылка копируется
ProcessList(numbers);
Console.WriteLine(numbers.Count); // По-прежнему доступен
void ProcessList(List<int> list)
{
list.Add(4); // Изменяет исходный список
Console.WriteLine($"Count in method: {list.Count}");
}
Vec<T> в Rust
#![allow(unused)]
fn main() {
// Vec<T> в Rust — владеемый тип, размещается в куче
let mut numbers = Vec::new();
numbers.push(1);
numbers.push(2);
numbers.push(3);
// Метод, который забирает владение
process_vec(numbers);
// println!("{:?}", numbers); // ❌ Ошибка: numbers было перемещено
// Метод, который заимствует
let mut numbers = vec![1, 2, 3]; // Макрос vec! для удобства
process_vec_borrowed(&mut numbers);
println!("{:?}", numbers); // ✅ По-прежнему доступен
fn process_vec(mut vec: Vec<i32>) { // Забирает владение
vec.push(4);
println!("Count in method: {}", vec.len());
// vec освобождается здесь
}
fn process_vec_borrowed(vec: &mut Vec<i32>) { // Заимствует изменяемо
vec.push(4);
println!("Count in method: {}", vec.len());
}
}
Создание и инициализация векторов
// Инициализация List в C#
var numbers = new List<int> { 1, 2, 3, 4, 5 };
var empty = new List<int>();
var sized = new List<int>(10); // Начальная ёмкость
// Из других коллекций
var fromArray = new List<int>(new[] { 1, 2, 3 });
#![allow(unused)]
fn main() {
// Инициализация Vec в Rust
let numbers = vec![1, 2, 3, 4, 5]; // Макрос vec!
let empty: Vec<i32> = Vec::new(); // Для пустого нужна аннотация типа
let sized = Vec::with_capacity(10); // Предварительное выделение ёмкости
// Из итератора
let from_range: Vec<i32> = (1..=5).collect();
let from_array = vec![1, 2, 3];
}
Сравнение распространённых операций
// Операции с List в C#
var list = new List<int> { 1, 2, 3 };
list.Add(4); // Добавить элемент
list.Insert(0, 0); // Вставить по индексу
list.Remove(2); // Удалить первое вхождение
list.RemoveAt(1); // Удалить по индексу
list.Clear(); // Удалить все
int first = list[0]; // Доступ по индексу
int count = list.Count; // Получить количество
bool contains = list.Contains(3); // Проверка наличия
#![allow(unused)]
fn main() {
// Операции с Vec в Rust
let mut vec = vec![1, 2, 3];
vec.push(4); // Добавить элемент
vec.insert(0, 0); // Вставить по индексу
vec.retain(|&x| x != 2); // Удалить элементы (в функциональном стиле)
vec.remove(1); // Удалить по индексу
vec.clear(); // Удалить все
let first = vec[0]; // Доступ по индексу (паника, если выход за границы)
let safe_first = vec.get(0); // Безопасный доступ, возвращает Option<&T>
let count = vec.len(); // Получить количество
let contains = vec.contains(&3); // Проверка наличия
}
Паттерны безопасного доступа
// C# — проверка границ через исключения
public int SafeAccess(List<int> list, int index)
{
try
{
return list[index];
}
catch (ArgumentOutOfRangeException)
{
return -1; // Значение по умолчанию
}
}
// Rust — безопасный доступ через Option
fn safe_access(vec: &[i32], index: usize) -> Option<i32> {
vec.get(index).copied() // Возвращает Option<i32>
}
fn main() {
let vec = vec![1, 2, 3];
// Паттерны безопасного доступа
match vec.get(10) {
Some(value) => println!("Value: {}", value),
None => println!("Индекс вне границ"),
}
// Или через unwrap_or
let value = vec.get(10).copied().unwrap_or(-1);
println!("Value: {}", value);
}
HashMap против Dictionary
HashMap — аналог Dictionary<K,V> из C# в Rust.
Dictionary в C#
// Dictionary<TKey, TValue> в C#
var scores = new Dictionary<string, int>
{
["Alice"] = 100,
["Bob"] = 85,
["Charlie"] = 92
};
// Добавление/обновление
scores["Dave"] = 78;
scores["Alice"] = 105; // Обновление существующего
// Безопасный доступ
if (scores.TryGetValue("Eve", out int score))
{
Console.WriteLine($"Eve's score: {score}");
}
else
{
Console.WriteLine("Eve not found");
}
// Итерация
foreach (var kvp in scores)
{
Console.WriteLine($"{kvp.Key}: {kvp.Value}");
}
HashMap в Rust
#![allow(unused)]
fn main() {
use std::collections::HashMap;
// Создание и инициализация HashMap
let mut scores = HashMap::new();
scores.insert("Alice".to_string(), 100);
scores.insert("Bob".to_string(), 85);
scores.insert("Charlie".to_string(), 92);
// Или из итератора
let scores: HashMap<String, i32> = [
("Alice".to_string(), 100),
("Bob".to_string(), 85),
("Charlie".to_string(), 92),
].into_iter().collect();
// Добавление/обновление
let mut scores = scores; // Делаем изменяемой
scores.insert("Dave".to_string(), 78);
scores.insert("Alice".to_string(), 105); // Обновление существующего
// Безопасный доступ
match scores.get("Eve") {
Some(score) => println!("Eve's score: {}", score),
None => println!("Eve not found"),
}
// Итерация
for (name, score) in &scores {
println!("{}: {}", name, score);
}
}
Операции с HashMap
// Операции с Dictionary в C#
var dict = new Dictionary<string, int>();
dict["key"] = 42; // Вставка/обновление
bool exists = dict.ContainsKey("key"); // Проверка наличия
bool removed = dict.Remove("key"); // Удаление
dict.Clear(); // Очистка
// Получение со значением по умолчанию
int value = dict.GetValueOrDefault("missing", 0);
#![allow(unused)]
fn main() {
use std::collections::HashMap;
// Операции с HashMap в Rust
let mut map = HashMap::new();
map.insert("key".to_string(), 42); // Вставка/обновление
let exists = map.contains_key("key"); // Проверка наличия
let removed = map.remove("key"); // Удаление, возвращает Option<V>
map.clear(); // Очистка
// Entry API для продвинутых операций
let mut map = HashMap::new();
map.entry("key".to_string()).or_insert(42); // Вставить, если не существует
map.entry("key".to_string()).and_modify(|v| *v += 1); // Изменить, если существует
// Получение со значением по умолчанию
let value = map.get("missing").copied().unwrap_or(0);
}
Владение ключами и значениями HashMap
#![allow(unused)]
fn main() {
// Понимание владения в HashMap
fn ownership_example() {
let mut map = HashMap::new();
// Строковые ключи и значения перемещаются в map
let key = String::from("name");
let value = String::from("Alice");
map.insert(key, value);
// println!("{}", key); // ❌ Ошибка: key было перемещено
// println!("{}", value); // ❌ Ошибка: value было перемещено
// Доступ через ссылки
if let Some(name) = map.get("name") {
println!("Name: {}", name); // Заимствуем значение
}
}
// Использование ключей &str (без передачи владения)
fn string_slice_keys() {
let mut map = HashMap::new();
map.insert("name", "Alice"); // Ключи и значения типа &str
map.insert("age", "30");
// Со строковыми литералами проблем с владением нет
println!("Name exists: {}", map.contains_key("name"));
}
}
Работа с коллекциями
Паттерны итерации
// Паттерны итерации в C#
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// Цикл for с индексом
for (int i = 0; i < numbers.Count; i++)
{
Console.WriteLine($"Index {i}: {numbers[i]}");
}
// Цикл foreach
foreach (int num in numbers)
{
Console.WriteLine(num);
}
// Методы LINQ
var doubled = numbers.Select(x => x * 2).ToList();
var evens = numbers.Where(x => x % 2 == 0).ToList();
#![allow(unused)]
fn main() {
// Паттерны итерации в Rust
let numbers = vec![1, 2, 3, 4, 5];
// Цикл for с индексом
for (i, num) in numbers.iter().enumerate() {
println!("Index {}: {}", i, num);
}
// Цикл for по значениям
for num in &numbers { // Заимствуем каждый элемент
println!("{}", num);
}
// Методы итераторов (как LINQ)
let doubled: Vec<i32> = numbers.iter().map(|x| x * 2).collect();
let evens: Vec<i32> = numbers.iter().filter(|&x| x % 2 == 0).cloned().collect();
// Или эффективнее — потребляющий итератор
let doubled: Vec<i32> = numbers.into_iter().map(|x| x * 2).collect();
}
Iterator против IntoIterator против Iter
#![allow(unused)]
fn main() {
// Понимание различных способов итерации
fn iteration_methods() {
let vec = vec![1, 2, 3, 4, 5];
// 1. iter() — заимствует элементы (&T)
for item in vec.iter() {
println!("{}", item); // item имеет тип &i32
}
// vec здесь по-прежнему доступен
// 2. into_iter() — забирает владение (T)
for item in vec.into_iter() {
println!("{}", item); // item имеет тип i32
}
// vec здесь больше недоступен
let mut vec = vec![1, 2, 3, 4, 5];
// 3. iter_mut() — изменяемые заимствования (&mut T)
for item in vec.iter_mut() {
*item *= 2; // item имеет тип &mut i32
}
println!("{:?}", vec); // [2, 4, 6, 8, 10]
}
}
Сбор результатов
// C# — обработка коллекций с возможными ошибками
public List<int> ParseNumbers(List<string> inputs)
{
var results = new List<int>();
foreach (string input in inputs)
{
if (int.TryParse(input, out int result))
{
results.Add(result);
}
// Молча пропускаем некорректные значения
}
return results;
}
// Rust — явная обработка ошибок через collect
fn parse_numbers(inputs: Vec<String>) -> Result<Vec<i32>, std::num::ParseIntError> {
inputs.into_iter()
.map(|s| s.parse::<i32>()) // Возвращает Result<i32, ParseIntError>
.collect() // Собирает в Result<Vec<i32>, ParseIntError>
}
// Альтернатива: отфильтровать ошибки
fn parse_numbers_filter(inputs: Vec<String>) -> Vec<i32> {
inputs.into_iter()
.filter_map(|s| s.parse::<i32>().ok()) // Оставляем только значения Ok
.collect()
}
fn main() {
let inputs = vec!["1".to_string(), "2".to_string(), "invalid".to_string(), "4".to_string()];
// Вариант, который завершается при первой ошибке
match parse_numbers(inputs.clone()) {
Ok(numbers) => println!("All parsed: {:?}", numbers),
Err(error) => println!("Parse error: {}", error),
}
// Вариант, который пропускает ошибки
let numbers = parse_numbers_filter(inputs);
println!("Successfully parsed: {:?}", numbers); // [1, 2, 4]
}
Упражнения
🏋️ Упражнение: из LINQ в итераторы (нажмите, чтобы раскрыть)
Переведите этот LINQ-запрос на C# в идиоматичные итераторы Rust:
var result = students
.Where(s => s.Grade >= 90)
.OrderByDescending(s => s.Grade)
.Select(s => $"{s.Name}: {s.Grade}")
.Take(3)
.ToList();
Используйте эту структуру:
#![allow(unused)]
fn main() {
struct Student { name: String, grade: u32 }
}
Верните Vec<String> из трёх лучших студентов с оценкой ≥ 90, отформатированных как "Name: Grade".
🔑 Решение
#[derive(Debug)]
struct Student { name: String, grade: u32 }
fn top_students(students: &mut [Student]) -> Vec<String> {
students.sort_by(|a, b| b.grade.cmp(&a.grade)); // сортировка по убыванию
students.iter()
.filter(|s| s.grade >= 90)
.take(3)
.map(|s| format!("{}: {}", s.name, s.grade))
.collect()
}
fn main() {
let mut students = vec![
Student { name: "Alice".into(), grade: 95 },
Student { name: "Bob".into(), grade: 88 },
Student { name: "Carol".into(), grade: 92 },
Student { name: "Dave".into(), grade: 97 },
Student { name: "Eve".into(), grade: 91 },
];
let result = top_students(&mut students);
assert_eq!(result, vec!["Dave: 97", "Alice: 95", "Carol: 92"]);
println!("{result:?}");
}
Ключевое отличие от C#: итераторы Rust ленивы (как LINQ), но .sort_by() — энергичная операция, которая сортирует на месте; ленивого OrderBy здесь нет. Сначала сортируете, затем строите цепочку ленивых операций.
6. Перечисления и сопоставление с образцом
Алгебраические типы данных против объединений C#
Что вы узнаете: алгебраические типы данных Rust (перечисления с данными) в сравнении с ограниченными дискриминируемыми объединениями C#, выражения
matchс проверкой исчерпываемости, защитные условия (guards) и деструктуризацию вложенных паттернов.Сложность: 🟡 Средний
Дискриминируемые объединения в C# (ограниченные)
// C# — ограниченная поддержка объединений через наследование
public abstract class Result
{
public abstract T Match<T>(Func<Success, T> onSuccess, Func<Error, T> onError);
}
public class Success : Result
{
public string Value { get; }
public Success(string value) => Value = value;
public override T Match<T>(Func<Success, T> onSuccess, Func<Error, T> onError)
=> onSuccess(this);
}
public class Error : Result
{
public string Message { get; }
public Error(string message) => Message = message;
public override T Match<T>(Func<Success, T> onSuccess, Func<Error, T> onError)
=> onError(this);
}
// Records с сопоставлением с образцом в C# 9+ (лучше)
public abstract record Shape;
public record Circle(double Radius) : Shape;
public record Rectangle(double Width, double Height) : Shape;
public static double Area(Shape shape) => shape switch
{
Circle(var radius) => Math.PI * radius * radius,
Rectangle(var width, var height) => width * height,
_ => throw new ArgumentException("Unknown shape") // [ERROR] Возможна ошибка времени выполнения
};
Алгебраические типы данных Rust (перечисления)
#![allow(unused)]
fn main() {
// Rust — настоящие алгебраические типы данных с исчерпывающим сопоставлением с образцом
#[derive(Debug, Clone)]
pub enum Result<T, E> {
Ok(T),
Err(E),
}
#[derive(Debug, Clone)]
pub enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { base: f64, height: f64 },
}
impl Shape {
pub fn area(&self) -> f64 {
match self {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
Shape::Triangle { base, height } => 0.5 * base * height,
// [OK] Ошибка компиляции, если какой-либо вариант пропущен!
}
}
}
// Продвинутый пример: перечисления могут хранить значения разных типов
#[derive(Debug)]
pub enum Value {
Integer(i64),
Float(f64),
Text(String),
Boolean(bool),
List(Vec<Value>), // Рекурсивные типы!
}
impl Value {
pub fn type_name(&self) -> &'static str {
match self {
Value::Integer(_) => "integer",
Value::Float(_) => "float",
Value::Text(_) => "text",
Value::Boolean(_) => "boolean",
Value::List(_) => "list",
}
}
}
}
graph TD
subgraph "Дискриминируемые объединения C# (обходные пути)"
CS_ABSTRACT["abstract class Result"]
CS_SUCCESS["class Success : Result"]
CS_ERROR["class Error : Result"]
CS_MATCH["Ручной метод Match<br/>или switch-выражения"]
CS_RUNTIME["[ERROR] Исключения во время выполнения<br/>для пропущенных случаев"]
CS_HEAP["[ERROR] Выделение в куче<br/>для наследования классов"]
CS_ABSTRACT --> CS_SUCCESS
CS_ABSTRACT --> CS_ERROR
CS_SUCCESS --> CS_MATCH
CS_ERROR --> CS_MATCH
CS_MATCH --> CS_RUNTIME
CS_ABSTRACT --> CS_HEAP
end
subgraph "Алгебраические типы данных Rust"
RUST_ENUM["enum Shape { ... }"]
RUST_VARIANTS["Circle { radius }<br/>Rectangle { width, height }<br/>Triangle { base, height }"]
RUST_MATCH["match shape { ... }"]
RUST_EXHAUSTIVE["[OK] Проверка исчерпываемости<br/>Гарантия на этапе компиляции"]
RUST_STACK["[OK] Размещение на стеке<br/>Эффективное использование памяти"]
RUST_ZERO["[OK] Абстракция с нулевой стоимостью"]
RUST_ENUM --> RUST_VARIANTS
RUST_VARIANTS --> RUST_MATCH
RUST_MATCH --> RUST_EXHAUSTIVE
RUST_ENUM --> RUST_STACK
RUST_STACK --> RUST_ZERO
end
style CS_RUNTIME fill:#ffcdd2,color:#000
style CS_HEAP fill:#fff3e0,color:#000
style RUST_EXHAUSTIVE fill:#c8e6c9,color:#000
style RUST_STACK fill:#c8e6c9,color:#000
style RUST_ZERO fill:#c8e6c9,color:#000
Перечисления и сопоставление с образцом
Перечисления Rust намного мощнее перечислений C# — они могут хранить данные и составляют основу типобезопасного программирования.
Ограничения перечислений C#
// Перечисление C# — всего лишь именованные константы
public enum Status
{
Pending,
Approved,
Rejected
}
// Перечисление C# с базовыми значениями
public enum HttpStatusCode
{
OK = 200,
NotFound = 404,
InternalServerError = 500
}
// Для сложных данных нужны отдельные классы
public abstract class Result
{
public abstract bool IsSuccess { get; }
}
public class Success : Result
{
public string Value { get; }
public override bool IsSuccess => true;
public Success(string value)
{
Value = value;
}
}
public class Error : Result
{
public string Message { get; }
public override bool IsSuccess => false;
public Error(string message)
{
Message = message;
}
}
Мощь перечислений Rust
#![allow(unused)]
fn main() {
// Простое перечисление (как enum в C#)
#[derive(Debug, PartialEq)]
enum Status {
Pending,
Approved,
Rejected,
}
// Перечисление с данными (здесь Rust раскрывается!)
#[derive(Debug)]
enum Result<T, E> {
Ok(T), // Вариант успеха, хранящий значение типа T
Err(E), // Вариант ошибки, хранящий ошибку типа E
}
// Сложное перечисление с данными разных типов
#[derive(Debug)]
enum Message {
Quit, // Без данных
Move { x: i32, y: i32 }, // Вариант в виде структуры
Write(String), // Вариант в виде кортежа
ChangeColor(i32, i32, i32), // Несколько значений
}
// Пример из практики: HTTP-ответ
#[derive(Debug)]
enum HttpResponse {
Ok { body: String, headers: Vec<String> },
NotFound { path: String },
InternalError { message: String, code: u16 },
Redirect { location: String },
}
}
Сопоставление с образцом через match
// Оператор switch в C# (ограниченный)
public string HandleStatus(Status status)
{
switch (status)
{
case Status.Pending:
return "Waiting for approval";
case Status.Approved:
return "Request approved";
case Status.Rejected:
return "Request rejected";
default:
return "Unknown status"; // Всегда нужен default
}
}
// Сопоставление с образцом в C# (C# 8+)
public string HandleResult(Result result)
{
return result switch
{
Success success => $"Success: {success.Value}",
Error error => $"Error: {error.Message}",
_ => "Unknown result" // Всё равно нужен перехватывающий случай
};
}
#![allow(unused)]
fn main() {
// match в Rust — исчерпывающий и мощный
fn handle_status(status: Status) -> String {
match status {
Status::Pending => "Waiting for approval".to_string(),
Status::Approved => "Request approved".to_string(),
Status::Rejected => "Request rejected".to_string(),
// Default не нужен — компилятор гарантирует исчерпываемость
}
}
// Сопоставление с образцом с извлечением данных
fn handle_result<T, E>(result: Result<T, E>) -> String
where
T: std::fmt::Debug,
E: std::fmt::Debug,
{
match result {
Result::Ok(value) => format!("Success: {:?}", value),
Result::Err(error) => format!("Error: {:?}", error),
// Исчерпывающе — default не нужен
}
}
// Сложное сопоставление с образцом
fn handle_message(msg: Message) -> String {
match msg {
Message::Quit => "Goodbye!".to_string(),
Message::Move { x, y } => format!("Move to ({}, {})", x, y),
Message::Write(text) => format!("Write: {}", text),
Message::ChangeColor(r, g, b) => format!("Change color to RGB({}, {}, {})", r, g, b),
}
}
// Обработка HTTP-ответа
fn handle_http_response(response: HttpResponse) -> String {
match response {
HttpResponse::Ok { body, headers } => {
format!("Success! Body: {}, Headers: {:?}", body, headers)
},
HttpResponse::NotFound { path } => {
format!("404: Path '{}' not found", path)
},
HttpResponse::InternalError { message, code } => {
format!("Error {}: {}", code, message)
},
HttpResponse::Redirect { location } => {
format!("Redirect to: {}", location)
},
}
}
}
Защитные условия и продвинутые паттерны
#![allow(unused)]
fn main() {
// Сопоставление с образцом с защитными условиями (guards)
fn describe_number(x: i32) -> String {
match x {
n if n < 0 => "negative".to_string(),
0 => "zero".to_string(),
n if n < 10 => "single digit".to_string(),
n if n < 100 => "double digit".to_string(),
_ => "large number".to_string(),
}
}
// Сопоставление с диапазонами
fn describe_age(age: u32) -> String {
match age {
0..=12 => "child".to_string(),
13..=19 => "teenager".to_string(),
20..=64 => "adult".to_string(),
65.. => "senior".to_string(),
}
}
// Деструктуризация структур и кортежей
}
🏋️ Упражнение: парсер команд (нажмите, чтобы раскрыть)
Задача: смоделируйте систему CLI-команд с помощью перечислений Rust. Разберите строку ввода в перечисление Command и выполните каждый вариант. Обработайте неизвестные команды с правильной обработкой ошибок.
#![allow(unused)]
fn main() {
// Стартовый код — заполните пропуски
#[derive(Debug)]
enum Command {
// TODO: Добавьте варианты Quit, Echo(String), Move { x: i32, y: i32 }, Count(u32)
}
fn parse_command(input: &str) -> Result<Command, String> {
let parts: Vec<&str> = input.splitn(2, ' ').collect();
// TODO: сопоставьте parts[0] и разберите аргументы
todo!()
}
fn execute(cmd: &Command) -> String {
// TODO: сопоставьте каждый вариант и верните описание
todo!()
}
}
🔑 Решение
#![allow(unused)]
fn main() {
#[derive(Debug)]
enum Command {
Quit,
Echo(String),
Move { x: i32, y: i32 },
Count(u32),
}
fn parse_command(input: &str) -> Result<Command, String> {
let parts: Vec<&str> = input.splitn(2, ' ').collect();
match parts[0] {
"quit" => Ok(Command::Quit),
"echo" => {
let msg = parts.get(1).unwrap_or(&"").to_string();
Ok(Command::Echo(msg))
}
"move" => {
let args = parts.get(1).ok_or("move requires 'x y'")?;
let coords: Vec<&str> = args.split_whitespace().collect();
let x = coords.get(0).ok_or("missing x")?.parse::<i32>().map_err(|e| e.to_string())?;
let y = coords.get(1).ok_or("missing y")?.parse::<i32>().map_err(|e| e.to_string())?;
Ok(Command::Move { x, y })
}
"count" => {
let n = parts.get(1).ok_or("count requires a number")?
.parse::<u32>().map_err(|e| e.to_string())?;
Ok(Command::Count(n))
}
other => Err(format!("Unknown command: {other}")),
}
}
fn execute(cmd: &Command) -> String {
match cmd {
Command::Quit => "Goodbye!".to_string(),
Command::Echo(msg) => msg.clone(),
Command::Move { x, y } => format!("Moving to ({x}, {y})"),
Command::Count(n) => format!("Counted to {n}"),
}
}
}
Ключевые выводы:
- Каждый вариант перечисления может хранить разные данные — иерархии классов не нужны
matchзаставляет обработать каждый вариант, что предотвращает забытые случаи- Оператор
?аккуратно связывает распространение ошибок — никаких вложенных try-catch
Исчерпывающее сопоставление и null-безопасность
Исчерпывающее сопоставление с образцом: гарантии компилятора против ошибок времени выполнения
Что вы узнаете: почему выражения
switchв C# молча пропускают случаи, тогда какmatchв Rust ловит их на этапе компиляции,Option<T>противNullable<T>для null-безопасности, а также собственные типы ошибок сResult<T, E>.Сложность: 🟡 Средний
Выражения switch в C# — всё ещё неполные
// Выражения switch в C# выглядят исчерпывающими, но это не гарантировано
public enum HttpStatus { Ok, NotFound, ServerError, Unauthorized }
public string HandleResponse(HttpStatus status) => status switch
{
HttpStatus.Ok => "Success",
HttpStatus.NotFound => "Resource not found",
HttpStatus.ServerError => "Internal error",
// Нет варианта Unauthorized — компилируется с предупреждением CS8524, но это НЕ ошибка!
// Во время выполнения: SwitchExpressionException, если status равен Unauthorized
};
// Даже с предупреждениями о nullable этот код компилируется:
public class User
{
public string Name { get; set; }
public bool IsActive { get; set; }
}
public string ProcessUser(User? user) => user switch
{
{ IsActive: true } => $"Active: {user.Name}",
{ IsActive: false } => $"Inactive: {user.Name}",
// Нет случая для null — предупреждение компилятора CS8655, но НЕ ошибка!
// Во время выполнения: SwitchExpressionException, если user равен null
};
// Добавление варианта в перечисление позже не ломает компиляцию существующих switch
public enum HttpStatus
{
Ok,
NotFound,
ServerError,
Unauthorized,
Forbidden // Добавление этого даёт ещё одно предупреждение CS8524, но не ломает компиляцию!
}
Сопоставление с образцом в Rust — настоящая исчерпываемость
#![allow(unused)]
fn main() {
#[derive(Debug)]
enum HttpStatus {
Ok,
NotFound,
ServerError,
Unauthorized,
}
fn handle_response(status: HttpStatus) -> &'static str {
match status {
HttpStatus::Ok => "Success",
HttpStatus::NotFound => "Resource not found",
HttpStatus::ServerError => "Internal error",
HttpStatus::Unauthorized => "Authentication required",
// Ошибка компилятора, если пропущен любой случай!
// Этот код буквально не скомпилируется
}
}
// Добавление нового варианта ломает компиляцию везде, где он используется
#[derive(Debug)]
enum HttpStatus {
Ok,
NotFound,
ServerError,
Unauthorized,
Forbidden, // Добавление этого ломает компиляцию в handle_response()
}
// Компилятор заставляет обработать ВСЕ случаи
// Сопоставление с образцом для Option<T> тоже исчерпывающее
fn process_optional_value(value: Option<i32>) -> String {
match value {
Some(n) => format!("Got value: {}", n),
None => "No value".to_string(),
// Пропуск любого из случаев = ошибка компиляции
}
}
}
graph TD
subgraph "Ограничения сопоставления с образцом в C#"
CS_SWITCH["switch-выражение"]
CS_WARNING["⚠️ Только предупреждения компилятора"]
CS_COMPILE["✅ Компилируется успешно"]
CS_RUNTIME["💥 Исключения во время выполнения"]
CS_DEPLOY["❌ Ошибки попадают в продакшен"]
CS_SILENT["😰 Тихие сбои при изменении перечислений"]
CS_SWITCH --> CS_WARNING
CS_WARNING --> CS_COMPILE
CS_COMPILE --> CS_RUNTIME
CS_RUNTIME --> CS_DEPLOY
CS_SWITCH --> CS_SILENT
end
subgraph "Исчерпывающее сопоставление в Rust"
RUST_MATCH["match-выражение"]
RUST_ERROR["🛑 Компиляция падает"]
RUST_FIX["✅ Нужно обработать все случаи"]
RUST_SAFE["✅ Никаких сюрпризов во время выполнения"]
RUST_EVOLUTION["🔄 Изменения перечислений ломают компиляцию"]
RUST_REFACTOR["🛠️ Принудительный рефакторинг"]
RUST_MATCH --> RUST_ERROR
RUST_ERROR --> RUST_FIX
RUST_FIX --> RUST_SAFE
RUST_MATCH --> RUST_EVOLUTION
RUST_EVOLUTION --> RUST_REFACTOR
end
style CS_RUNTIME fill:#ffcdd2,color:#000
style CS_DEPLOY fill:#ffcdd2,color:#000
style CS_SILENT fill:#ffcdd2,color:#000
style RUST_SAFE fill:#c8e6c9,color:#000
style RUST_REFACTOR fill:#c8e6c9,color:#000
Null-безопасность: Nullable<T> против Option<T>
Эволюция работы с null в C#
// C# — традиционная работа с null (подвержена ошибкам)
public class User
{
public string Name { get; set; } // Может быть null!
public string Email { get; set; } // Может быть null!
}
public string GetUserDisplayName(User user)
{
if (user?.Name != null) // Оператор null-условного доступа
{
return user.Name;
}
return "Unknown User";
}
// Nullable-ссылочные типы C# 8+
public class User
{
public string Name { get; set; } // Не допускает null
public string? Email { get; set; } // Явно допускает null
}
// Nullable<T> в C# для значимых типов
int? maybeNumber = GetNumber();
if (maybeNumber.HasValue)
{
Console.WriteLine(maybeNumber.Value);
}
Система Option<T> в Rust
#![allow(unused)]
fn main() {
// Rust — явная работа с null через Option<T>
#[derive(Debug)]
pub struct User {
name: String, // Никогда не null
email: Option<String>, // Явно необязательное
}
impl User {
pub fn get_display_name(&self) -> &str {
&self.name // Проверка на null не нужна — значение гарантированно существует
}
pub fn get_email_or_default(&self) -> String {
self.email
.as_ref()
.map(|e| e.clone())
.unwrap_or_else(|| "no-email@example.com".to_string())
}
}
// Сопоставление с образцом заставляет обработать случай None
fn handle_optional_user(user: Option<User>) {
match user {
Some(u) => println!("User: {}", u.get_display_name()),
None => println!("No user found"),
// Ошибка компилятора, если случай None не обработан!
}
}
}
graph TD
subgraph "Эволюция работы с null в C#"
CS_NULL["Традиционно: string name<br/>[ERROR] Может быть null"]
CS_NULLABLE["Nullable<T>: int? value<br/>[OK] Явно для значимых типов"]
CS_NRT["Nullable-ссылочные типы<br/>string? name<br/>[WARNING] Только предупреждения компилятора"]
CS_RUNTIME["Runtime NullReferenceException<br/>[ERROR] Всё ещё может упасть"]
CS_NULL --> CS_RUNTIME
CS_NRT -.-> CS_RUNTIME
CS_CHECKS["Ручные проверки на null<br/>if (obj?.Property != null)"]
end
subgraph "Система Option<T> в Rust"
RUST_OPTION["Option<T><br/>Some(value) | None"]
RUST_FORCE["Компилятор заставляет обработать<br/>[OK] Нельзя проигнорировать None"]
RUST_MATCH["Сопоставление с образцом<br/>match option { ... }"]
RUST_METHODS["Богатый API<br/>.map(), .unwrap_or(), .and_then()"]
RUST_OPTION --> RUST_FORCE
RUST_FORCE --> RUST_MATCH
RUST_FORCE --> RUST_METHODS
RUST_SAFE["Null-безопасность на этапе компиляции<br/>[OK] Нет исключений null-указателя"]
RUST_MATCH --> RUST_SAFE
RUST_METHODS --> RUST_SAFE
end
style CS_RUNTIME fill:#ffcdd2,color:#000
style RUST_SAFE fill:#c8e6c9,color:#000
style CS_NRT fill:#fff3e0,color:#000
style RUST_FORCE fill:#c8e6c9,color:#000
#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
fn describe_point(point: Point) -> String {
match point {
Point { x: 0, y: 0 } => "начало координат".to_string(),
Point { x: 0, y } => format!("на оси Y при y={}", y),
Point { x, y: 0 } => format!("на оси X при x={}", x),
Point { x, y } if x == y => format!("на диагонали в ({}, {})", x, y),
Point { x, y } => format!("точка в ({}, {})", x, y),
}
}
}
Option и Result
// Nullable-ссылочные типы C# (C# 8+)
public class PersonService
{
private Dictionary<int, string> people = new();
public string? FindPerson(int id)
{
return people.TryGetValue(id, out string? name) ? name : null;
}
public string GetPersonOrDefault(int id)
{
return FindPerson(id) ?? "Unknown";
}
// Обработка ошибок через исключения
public void SavePerson(int id, string name)
{
if (string.IsNullOrEmpty(name))
throw new ArgumentException("Name cannot be empty");
people[id] = name;
}
}
use std::collections::HashMap;
// Rust использует Option<T> вместо null
struct PersonService {
people: HashMap<i32, String>,
}
impl PersonService {
fn new() -> Self {
PersonService {
people: HashMap::new(),
}
}
// Возвращает Option<T> — никакого null!
fn find_person(&self, id: i32) -> Option<&String> {
self.people.get(&id)
}
// Сопоставление с образцом по Option
fn get_person_or_default(&self, id: i32) -> String {
match self.find_person(id) {
Some(name) => name.clone(),
None => "Unknown".to_string(),
}
}
// Использование методов Option (более функциональный стиль)
fn get_person_or_default_functional(&self, id: i32) -> String {
self.find_person(id)
.map(|name| name.clone())
.unwrap_or_else(|| "Unknown".to_string())
}
// Result<T, E> для обработки ошибок
fn save_person(&mut self, id: i32, name: String) -> Result<(), String> {
if name.is_empty() {
return Err("Name cannot be empty".to_string());
}
self.people.insert(id, name);
Ok(())
}
// Цепочка операций
fn get_person_length(&self, id: i32) -> Option<usize> {
self.find_person(id).map(|name| name.len())
}
}
fn main() {
let mut service = PersonService::new();
// Обработка Result
match service.save_person(1, "Alice".to_string()) {
Ok(()) => println!("Person saved successfully"),
Err(error) => println!("Error: {}", error),
}
// Обработка Option
match service.find_person(1) {
Some(name) => println!("Found: {}", name),
None => println!("Person not found"),
}
// Функциональный стиль с Option
let name_length = service.get_person_length(1)
.unwrap_or(0);
println!("Name length: {}", name_length);
// Оператор вопросительного знака для раннего возврата
fn try_operation(service: &mut PersonService) -> Result<String, String> {
service.save_person(2, "Bob".to_string())?; // Ранний возврат при ошибке
let name = service.find_person(2).ok_or("Person not found")?; // Преобразуем Option в Result
Ok(format!("Hello, {}", name))
}
match try_operation(&mut service) {
Ok(message) => println!("{}", message),
Err(error) => println!("Operation failed: {}", error),
}
}
Собственные типы ошибок
#![allow(unused)]
fn main() {
// Определяем собственное перечисление ошибок
#[derive(Debug)]
enum PersonError {
NotFound(i32),
InvalidName(String),
DatabaseError(String),
}
impl std::fmt::Display for PersonError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PersonError::NotFound(id) => write!(f, "Person with ID {} not found", id),
PersonError::InvalidName(name) => write!(f, "Invalid name: '{}'", name),
PersonError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
}
}
}
impl std::error::Error for PersonError {}
// Расширенный PersonService с собственными ошибками
impl PersonService {
fn save_person_enhanced(&mut self, id: i32, name: String) -> Result<(), PersonError> {
if name.is_empty() || name.len() > 50 {
return Err(PersonError::InvalidName(name));
}
// Имитация операции с базой данных, которая может завершиться ошибкой
if id < 0 {
return Err(PersonError::DatabaseError("Negative IDs not allowed".to_string()));
}
self.people.insert(id, name);
Ok(())
}
fn find_person_enhanced(&self, id: i32) -> Result<&String, PersonError> {
self.people.get(&id).ok_or(PersonError::NotFound(id))
}
}
fn demo_error_handling() {
let mut service = PersonService::new();
// Обработка разных типов ошибок
match service.save_person_enhanced(-1, "Invalid".to_string()) {
Ok(()) => println!("Success"),
Err(PersonError::NotFound(id)) => println!("Not found: {}", id),
Err(PersonError::InvalidName(name)) => println!("Invalid name: {}", name),
Err(PersonError::DatabaseError(msg)) => println!("DB Error: {}", msg),
}
}
}
Упражнения
🏋️ Упражнение: комбинаторы Option (нажмите, чтобы раскрыть)
Перепишите этот глубоко вложенный C#-код с проверками на null, используя комбинаторы Option в Rust (and_then, map, unwrap_or):
string GetCityName(User? user)
{
if (user != null)
if (user.Address != null)
if (user.Address.City != null)
return user.Address.City.ToUpper();
return "UNKNOWN";
}
Используйте такие типы Rust:
#![allow(unused)]
fn main() {
struct User { address: Option<Address> }
struct Address { city: Option<String> }
}
Запишите это как одно выражение без if let и match.
🔑 Решение
struct User { address: Option<Address> }
struct Address { city: Option<String> }
fn get_city_name(user: Option<&User>) -> String {
user.and_then(|u| u.address.as_ref())
.and_then(|a| a.city.as_ref())
.map(|c| c.to_uppercase())
.unwrap_or_else(|| "UNKNOWN".to_string())
}
fn main() {
let user = User {
address: Some(Address { city: Some("seattle".to_string()) }),
};
assert_eq!(get_city_name(Some(&user)), "SEATTLE");
assert_eq!(get_city_name(None), "UNKNOWN");
let no_city = User { address: Some(Address { city: None }) };
assert_eq!(get_city_name(Some(&no_city)), "UNKNOWN");
}
Ключевая мысль: and_then — это аналог оператора ?. из Rust для Option. Каждый шаг возвращает Option, а цепочка прерывается на None — ровно как оператор null-условного доступа ?. в C#, но явно и с проверкой типов.
7. Владение и заимствование
Понимание владения
Что вы узнаете: систему владения Rust — почему
let s2 = s1делаетs1недействительным (в отличие от копирования ссылок в C#), три правила владения, типыCopyиMove, заимствование через&и&mut, а также то, как проверщик заимствований заменяет сборку мусора.Сложность: 🟡 Средний
Владение — самая уникальная особенность Rust и самый большой концептуальный сдвиг для разработчиков C#. Разберём её шаг за шагом.
Модель памяти C# (повторение)
// C# — автоматическое управление памятью
public void ProcessData()
{
var data = new List<int> { 1, 2, 3, 4, 5 };
ProcessList(data);
// data здесь по-прежнему доступен
Console.WriteLine(data.Count); // Работает нормально
// GC очистит память, когда не останется ссылок
}
public void ProcessList(List<int> list)
{
list.Add(6); // Изменяет исходный список
}
Правила владения в Rust
- У каждого значения ровно один владелец (если вы не выберете разделяемое владение через
Rc<T>/Arc<T>— см. Умные указатели) - Когда владелец выходит из области видимости, значение уничтожается (детерминированная очистка — см. Drop)
- Владение можно передать (переместить)
#![allow(unused)]
fn main() {
// Rust — явное управление владением
fn process_data() {
let data = vec![1, 2, 3, 4, 5]; // data владеет вектором
process_list(data); // Владение передано функции
// println!("{:?}", data); // ❌ Ошибка: data здесь больше не владеет значением
}
fn process_list(mut list: Vec<i32>) { // list теперь владеет вектором
list.push(6);
// list уничтожается здесь, при завершении функции
}
}
Понятие «перемещения» для разработчиков C#
// C# — ссылки копируются, объекты остаются на месте
// (так работают только ссылочные типы — классы;
// значимые типы C#, такие как struct, ведут себя иначе)
var original = new List<int> { 1, 2, 3 };
var reference = original; // Обе переменные указывают на один объект
original.Add(4);
Console.WriteLine(reference.Count); // 4 — тот же объект
#![allow(unused)]
fn main() {
// Rust — владение передаётся
let original = vec![1, 2, 3];
let moved = original; // Владение передано
// println!("{:?}", original); // ❌ Ошибка: original больше не владеет данными
println!("{:?}", moved); // ✅ Работает: moved теперь владеет данными
}
Типы Copy и типы Move
#![allow(unused)]
fn main() {
// Типы Copy (как значимые типы в C#) — копируются, а не перемещаются
let x = 5; // i32 реализует Copy
let y = x; // x копируется в y
println!("{}", x); // ✅ Работает: x по-прежнему действителен
// Типы Move (как ссылочные типы в C#) — перемещаются, а не копируются
let s1 = String::from("hello"); // String не реализует Copy
let s2 = s1; // s1 перемещается в s2
// println!("{}", s1); // ❌ Ошибка: s1 больше не действителен
}
Практический пример: обмен значениями
// C# — простой обмен ссылками
public void SwapLists(ref List<int> a, ref List<int> b)
{
var temp = a;
a = b;
b = temp;
}
#![allow(unused)]
fn main() {
// Rust — обмен с учётом владения
fn swap_vectors(a: &mut Vec<i32>, b: &mut Vec<i32>) {
std::mem::swap(a, b); // Встроенная функция обмена
}
// Или ручной вариант
fn manual_swap() {
let mut a = vec![1, 2, 3];
let mut b = vec![4, 5, 6];
let temp = a; // Перемещаем a во временную переменную
a = b; // Перемещаем b в a
b = temp; // Перемещаем temp в b
println!("a: {:?}, b: {:?}", a, b);
}
}
Основы заимствования
Заимствование похоже на получение ссылки в C#, но с гарантиями безопасности на этапе компиляции.
Ссылочные параметры в C#
// C# — параметры ref и out
public void ModifyValue(ref int value)
{
value += 10;
}
public void ReadValue(in int value) // ссылка только для чтения
{
Console.WriteLine(value);
}
public bool TryParse(string input, out int result)
{
return int.TryParse(input, out result);
}
Заимствование в Rust
// Rust — заимствование через & и &mut
fn modify_value(value: &mut i32) { // Изменяемое заимствование
*value += 10;
}
fn read_value(value: &i32) { // Неизменяемое заимствование
println!("{}", value);
}
fn main() {
let mut x = 5;
read_value(&x); // Заимствуем неизменяемо
modify_value(&mut x); // Заимствуем изменяемо
println!("{}", x); // x по-прежнему принадлежит нам
}
Правила заимствования (проверяются на этапе компиляции!)
#![allow(unused)]
fn main() {
fn borrowing_rules() {
let mut data = vec![1, 2, 3];
// Правило 1: несколько неизменяемых заимствований допустимы
let r1 = &data;
let r2 = &data;
println!("{:?} {:?}", r1, r2); // ✅ Работает
// Правило 2: одновременно — только одно изменяемое заимствование
let r3 = &mut data;
// let r4 = &mut data; // ❌ Ошибка: нельзя заимствовать изменяемо дважды
// let r5 = &data; // ❌ Ошибка: нельзя заимствовать неизменяемо, пока есть изменяемое заимствование
r3.push(4); // Используем изменяемое заимствование
// r3 выходит из области видимости здесь
// Правило 3: можно заимствовать снова после окончания предыдущих заимствований
let r6 = &data; // ✅ Теперь работает
println!("{:?}", r6);
}
}
C# против Rust: безопасность ссылок
// C# — возможны ошибки времени выполнения
public class ReferenceSafety
{
private List<int> data = new List<int>();
public List<int> GetData() => data; // Возвращает ссылку на внутреннее состояние
public void UnsafeExample()
{
var reference = GetData();
// Другой поток может изменить data прямо здесь!
Thread.Sleep(1000);
// reference может быть недействительным или изменённым
reference.Add(42); // Возможное состояние гонки
}
}
#![allow(unused)]
fn main() {
// Rust — безопасность на этапе компиляции
pub struct SafeContainer {
data: Vec<i32>,
}
impl SafeContainer {
// Возвращаем неизменяемое заимствование — вызывающий код не может изменить данные
// Предпочитайте &[i32] вместо &Vec<i32> — принимайте самый широкий тип
pub fn get_data(&self) -> &[i32] {
&self.data
}
// Возвращаем изменяемое заимствование — эксклюзивный доступ гарантирован
pub fn get_data_mut(&mut self) -> &mut Vec<i32> {
&mut self.data
}
}
fn safe_example() {
let mut container = SafeContainer { data: vec![1, 2, 3] };
let reference = container.get_data();
// container.get_data_mut(); // ❌ Ошибка: нельзя заимствовать изменяемо, пока есть неизменяемое заимствование
println!("{:?}", reference); // Используем неизменяемую ссылку
// reference выходит из области видимости здесь
let mut_reference = container.get_data_mut(); // ✅ Теперь можно
mut_reference.push(4);
}
}
Семантика перемещения
Значимые и ссылочные типы в C#
// C# — значимые типы копируются
struct Point
{
public int X { get; set; }
public int Y { get; set; }
}
var p1 = new Point { X = 1, Y = 2 };
var p2 = p1; // Копирование
p2.X = 10;
Console.WriteLine(p1.X); // По-прежнему 1
// C# — ссылочные типы разделяют объект
var list1 = new List<int> { 1, 2, 3 };
var list2 = list1; // Копирование ссылки
list2.Add(4);
Console.WriteLine(list1.Count); // 4 — тот же объект
Семантика перемещения в Rust
#![allow(unused)]
fn main() {
// Rust — по умолчанию перемещение для типов, не реализующих Copy
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
fn move_example() {
let p1 = Point { x: 1, y: 2 };
let p2 = p1; // Перемещение (не копирование)
// println!("{:?}", p1); // ❌ Ошибка: p1 перемещён
println!("{:?}", p2); // ✅ Работает
}
// Чтобы включить копирование, реализуйте трейт Copy
#[derive(Debug, Copy, Clone)]
struct CopyablePoint {
x: i32,
y: i32,
}
fn copy_example() {
let p1 = CopyablePoint { x: 1, y: 2 };
let p2 = p1; // Копирование (потому что реализован Copy)
println!("{:?}", p1); // ✅ Работает
println!("{:?}", p2); // ✅ Работает
}
}
Когда значения перемещаются
#![allow(unused)]
fn main() {
fn demonstrate_moves() {
let s = String::from("hello");
// 1. Присваивание перемещает
let s2 = s; // s перемещается в s2
// 2. Вызовы функций перемещают
take_ownership(s2); // s2 перемещается в функцию
// 3. Возврат из функций перемещает
let s3 = give_ownership(); // Возвращаемое значение перемещается в s3
println!("{}", s3); // s3 действителен
}
fn take_ownership(s: String) {
println!("{}", s);
// s уничтожается здесь
}
fn give_ownership() -> String {
String::from("yours") // Владение перемещается к вызывающему коду
}
}
Избегаем перемещений с помощью заимствования
#![allow(unused)]
fn main() {
fn demonstrate_borrowing() {
let s = String::from("hello");
// Заимствуем вместо перемещения
let len = calculate_length(&s); // s заимствуется
println!("'{}' has length {}", s, len); // s по-прежнему действителен
}
fn calculate_length(s: &String) -> usize {
s.len() // s не принадлежит нам, поэтому не уничтожается
}
}
Управление памятью: GC против RAII
Сборка мусора в C#
// C# — автоматическое управление памятью
public class Person
{
public string Name { get; set; }
public List<string> Hobbies { get; set; } = new List<string>();
public void AddHobby(string hobby)
{
Hobbies.Add(hobby); // Память выделяется автоматически
}
// Явная очистка не нужна — GC с ней справляется
// Но для ресурсов существует паттерн IDisposable
}
using var file = new FileStream("data.txt", FileMode.Open);
// 'using' гарантирует вызов Dispose()
Владение и RAII в Rust
#![allow(unused)]
fn main() {
// Rust — управление памятью на этапе компиляции
pub struct Person {
name: String,
hobbies: Vec<String>,
}
impl Person {
pub fn add_hobby(&mut self, hobby: String) {
self.hobbies.push(hobby); // Управление памятью отслеживается на этапе компиляции
}
// Трейт Drop реализуется автоматически — очистка гарантирована
// Сравните с IDisposable в C#:
// C#: using var file = new FileStream(...) // Dispose() вызывается в конце блока using
// Rust: let file = File::open(...)? // drop() вызывается в конце области видимости — 'using' не нужен
}
// RAII — Resource Acquisition Is Initialization (получение ресурса есть инициализация)
{
let file = std::fs::File::open("data.txt")?;
// Файл автоматически закрывается, когда 'file' выходит из области видимости
// Оператор using не нужен — это обеспечивает система типов
}
}
graph TD
subgraph "Управление памятью в C#"
CS_ALLOC["Выделение объекта<br/>new Person()"]
CS_HEAP["Управляемая куча"]
CS_REF["Ссылки указывают на кучу"]
CS_GC_CHECK["GC периодически проверяет<br/>недостижимые объекты"]
CS_SWEEP["Сборка по принципу<br/>mark and sweep"]
CS_PAUSE["[ERROR] Паузы GC"]
CS_ALLOC --> CS_HEAP
CS_HEAP --> CS_REF
CS_REF --> CS_GC_CHECK
CS_GC_CHECK --> CS_SWEEP
CS_SWEEP --> CS_PAUSE
CS_ISSUES["[ERROR] Недетерминированная очистка<br/>[ERROR] Давление на память<br/>[ERROR] Сложность финализации<br/>[OK] Легко использовать"]
end
subgraph "Система владения Rust"
RUST_ALLOC["Создание значения<br/>Person { ... }"]
RUST_OWNER["Единственный владелец<br/>на стеке или в куче"]
RUST_BORROW["Система заимствований<br/>&T, &mut T"]
RUST_SCOPE["Очистка по области видимости<br/>трейт Drop"]
RUST_COMPILE["Проверка на этапе компиляции"]
RUST_ALLOC --> RUST_OWNER
RUST_OWNER --> RUST_BORROW
RUST_BORROW --> RUST_SCOPE
RUST_SCOPE --> RUST_COMPILE
RUST_BENEFITS["[OK] Детерминированная очистка<br/>[OK] Нулевые накладные расходы рантайма<br/>[OK] Никаких утечек памяти<br/>[ERROR] Кривая обучения"]
end
style CS_ISSUES fill:#ffebee,color:#000
style RUST_BENEFITS fill:#e8f5e8,color:#000
style CS_PAUSE fill:#ffcdd2,color:#000
style RUST_COMPILE fill:#c8e6c9,color:#000
🏋️ Упражнение: исправьте ошибки проверщика заимствований (нажмите, чтобы раскрыть)
Задача: в каждом фрагменте ниже есть ошибка проверщика заимствований. Исправьте их, не меняя вывод.
#![allow(unused)]
fn main() {
// 1. Перемещение после использования
fn problem_1() {
let name = String::from("Alice");
let greeting = format!("Hello, {name}!");
let upper = name.to_uppercase(); // подсказка: заимствуйте вместо перемещения
println!("{greeting} — {upper}");
}
// 2. Пересечение изменяемого и неизменяемого заимствований
fn problem_2() {
let mut numbers = vec![1, 2, 3];
let first = &numbers[0];
numbers.push(4); // подсказка: переупорядочьте операции
println!("first = {first}");
}
// 3. Возврат ссылки на локальную переменную
fn problem_3() -> String {
let s = String::from("hello");
s // подсказка: верните владеемое значение, а не &str
}
}
🔑 Решение
#![allow(unused)]
fn main() {
// 1. format! уже заимствует — исправление в том, что format! принимает ссылку.
// Исходный код на самом деле компилируется! Но если бы было `let greeting = name;`,
// исправили бы через &name:
fn solution_1() {
let name = String::from("Alice");
let greeting = format!("Hello, {}!", &name); // заимствование
let upper = name.to_uppercase(); // name по-прежнему действителен
println!("{greeting} — {upper}");
}
// 2. Используем неизменяемое заимствование до изменяющей операции:
fn solution_2() {
let mut numbers = vec![1, 2, 3];
let first = numbers[0]; // копируем значение i32 (i32 реализует Copy)
numbers.push(4);
println!("first = {first}");
}
// 3. Возвращаем владеемую String (уже правильно — частая путаница у новичков):
fn solution_3() -> String {
let s = String::from("hello");
s // владение передаётся вызывающему коду — это правильный паттерн
}
}
Ключевые выводы:
format!()заимствует свои аргументы — он их не перемещает- Примитивные типы вроде
i32реализуютCopy, поэтому индексация копирует значение - Возврат владеемого значения передаёт владение вызывающему коду — проблем с временами жизни нет
Глубокое погружение в безопасность памяти
Ссылки против указателей
Что вы узнаете: ссылки Rust против указателей и небезопасных контекстов C#, основы времён жизни, а также почему доказательства безопасности на этапе компиляции надёжнее проверок времени выполнения в C# (проверка границ, защита от null).
Сложность: 🟡 Средний
Указатели в C# (небезопасный контекст)
// Небезопасные указатели в C# (используются редко)
unsafe void UnsafeExample()
{
int value = 42;
int* ptr = &value; // Указатель на value
*ptr = 100; // Разыменование и изменение
Console.WriteLine(value); // 100
}
Ссылки в Rust (безопасны по умолчанию)
#![allow(unused)]
fn main() {
// Ссылки в Rust (всегда безопасны)
fn safe_example() {
let mut value = 42;
let ptr = &mut value; // Изменяемая ссылка
*ptr = 100; // Разыменование и изменение
println!("{}", value); // 100
}
// Ключевое слово "unsafe" не нужно — проверщик заимствований гарантирует безопасность
}
Основы времён жизни для разработчиков C#
// C# — можно вернуть ссылки, которые могут стать недействительными
public class LifetimeIssues
{
public string GetFirstWord(string input)
{
return input.Split(' ')[0]; // Возвращает новую строку (безопасно)
}
public unsafe char* GetFirstChar(string input)
{
// Это было бы опасно — возврат указателя на управляемую память
fixed (char* ptr = input)
return ptr; // ❌ Плохо: ptr становится недействительным после выхода из метода
}
}
#![allow(unused)]
fn main() {
// Rust — проверка времён жизни предотвращает висячие ссылки
fn get_first_word(input: &str) -> &str {
input.split_whitespace().next().unwrap_or("")
// ✅ Безопасно: возвращаемая ссылка имеет то же время жизни, что и input
}
fn invalid_reference() -> &str {
let temp = String::from("hello");
&temp // ❌ Ошибка компиляции: temp живёт недостаточно долго
// temp будет уничтожена в конце функции
}
fn valid_reference() -> String {
let temp = String::from("hello");
temp // ✅ Работает: владение передаётся вызывающему коду
}
}
Безопасность памяти: проверки во время выполнения против доказательств на этапе компиляции
C# — страховка времени выполнения
// C# полагается на проверки во время выполнения и GC
public class Buffer
{
private byte[] data;
public Buffer(int size)
{
data = new byte[size];
}
public void ProcessData(int index)
{
// Проверка границ во время выполнения
if (index >= data.Length)
throw new IndexOutOfRangeException();
data[index] = 42; // Безопасно, но проверяется во время выполнения
}
// Утечки памяти всё равно возможны через события и статические ссылки
public static event Action<string> GlobalEvent;
public void Subscribe()
{
GlobalEvent += HandleEvent; // Может создать утечку памяти
// Забыли отписаться — объект не будет собран сборщиком мусора
}
private void HandleEvent(string message) { /* ... */ }
// Исключения NullReferenceException по-прежнему возможны
public void ProcessUser(User user)
{
Console.WriteLine(user.Name.ToUpper()); // NullReferenceException, если user.Name равен null
}
// Доступ к массиву может завершиться ошибкой во время выполнения
public int GetValue(int[] array, int index)
{
return array[index]; // Возможно IndexOutOfRangeException
}
}
Rust — гарантии на этапе компиляции
#![allow(unused)]
fn main() {
struct Buffer {
data: Vec<u8>,
}
impl Buffer {
fn new(size: usize) -> Self {
Buffer {
data: vec![0; size],
}
}
fn process_data(&mut self, index: usize) {
// Проверку границ компилятор может убрать, если безопасность доказана
if let Some(item) = self.data.get_mut(index) {
*item = 42; // Безопасный доступ, доказанный на этапе компиляции
}
// Или используйте индексирование с явной проверкой границ:
// self.data[index] = 42; // Паника в debug-сборке, но память в безопасности
}
// Утечки памяти невозможны — система владения их предотвращает
fn process_with_closure<F>(&mut self, processor: F)
where F: FnOnce(&mut Vec<u8>)
{
processor(&mut self.data);
// Когда processor выходит из области видимости, он автоматически очищается
// Создать висячие ссылки или утечку памяти нельзя
}
// Разыменование null-указателей невозможно — null-указателей здесь нет!
fn process_user(&self, user: &User) {
println!("{}", user.name.to_uppercase()); // user.name не может быть null
}
// Доступ к массиву либо проверяется на границы, либо явно небезопасен
fn get_value(array: &[i32], index: usize) -> Option<i32> {
array.get(index).copied() // Возвращает None, если индекс вне границ
}
// Или явно небезопасно, если вы точно знаете, что делаете:
/// # Safety
/// `index` должен быть меньше `array.len()`.
unsafe fn get_value_unchecked(array: &[i32], index: usize) -> i32 {
*array.get_unchecked(index) // Быстро, но границы нужно доказать вручную
}
}
struct User {
name: String, // String в Rust не может быть null
}
// Владение предотвращает использование после освобождения
fn ownership_example() {
let data = vec![1, 2, 3, 4, 5];
let reference = &data[0]; // Заимствуем data
// drop(data); // ОШИБКА: нельзя уничтожить, пока есть заимствование
println!("{}", reference); // Это гарантированно безопасно
}
// Заимствование предотвращает гонки данных
fn borrowing_example(data: &mut Vec<i32>) {
let first = &data[0]; // Неизменяемое заимствование
// data.push(6); // ОШИБКА: нельзя заимствовать изменяемо, пока есть неизменяемое заимствование
println!("{}", first); // Гарантированно нет гонки данных
}
}
graph TD
subgraph "Безопасность во время выполнения в C#"
CS_RUNTIME["Проверки во время выполнения"]
CS_GC["Сборщик мусора"]
CS_EXCEPTIONS["Обработка исключений"]
CS_BOUNDS["Проверка границ во время выполнения"]
CS_NULL["Исключения null-ссылок"]
CS_LEAKS["Возможны утечки памяти"]
CS_OVERHEAD["Накладные расходы производительности"]
CS_RUNTIME --> CS_BOUNDS
CS_RUNTIME --> CS_NULL
CS_GC --> CS_LEAKS
CS_EXCEPTIONS --> CS_OVERHEAD
end
subgraph "Безопасность на этапе компиляции в Rust"
RUST_OWNERSHIP["Система владения"]
RUST_BORROWING["Проверщик заимствований"]
RUST_TYPES["Система типов"]
RUST_ZERO_COST["Абстракции с нулевой стоимостью"]
RUST_NO_NULL["Нет null-указателей"]
RUST_NO_LEAKS["Нет утечек памяти"]
RUST_FAST["Оптимальная производительность"]
RUST_OWNERSHIP --> RUST_NO_LEAKS
RUST_BORROWING --> RUST_NO_NULL
RUST_TYPES --> RUST_ZERO_COST
RUST_ZERO_COST --> RUST_FAST
end
style CS_NULL fill:#ffcdd2,color:#000
style CS_LEAKS fill:#ffcdd2,color:#000
style CS_OVERHEAD fill:#fff3e0,color:#000
style RUST_NO_NULL fill:#c8e6c9,color:#000
style RUST_NO_LEAKS fill:#c8e6c9,color:#000
style RUST_FAST fill:#c8e6c9,color:#000
Упражнения
🏋️ Упражнение: найдите ошибку безопасности (нажмите, чтобы раскрыть)
В этом C#-коде есть тонкая ошибка безопасности. Найдите её, затем напишите эквивалент на Rust и объясните, почему версия на Rust не скомпилируется:
public List<int> GetEvenNumbers(List<int> numbers)
{
var result = new List<int>();
foreach (var n in numbers)
{
if (n % 2 == 0)
{
result.Add(n);
numbers.Remove(n); // Ошибка: изменение коллекции во время перебора
}
}
return result;
}
🔑 Решение
Ошибка в C#: изменение numbers во время перебора приводит к InvalidOperationException во время выполнения. Её легко пропустить на код-ревью.
fn get_even_numbers(numbers: &mut Vec<i32>) -> Vec<i32> {
let mut result = Vec::new();
for &n in numbers.iter() {
if n % 2 == 0 {
result.push(n);
// numbers.retain(|&x| x != n);
// ❌ ОШИБКА: нельзя заимствовать `*numbers` как изменяемое, потому что
// оно уже заимствовано как неизменяемое (итератором)
}
}
result
}
// Идиоматичный Rust: используйте retain или partition
fn get_even_numbers_idiomatic(numbers: &mut Vec<i32>) -> Vec<i32> {
let evens: Vec<i32> = numbers.iter().copied().filter(|n| n % 2 == 0).collect();
numbers.retain(|n| n % 2 != 0); // удаляем чётные после перебора
evens
}
fn main() {
let mut nums = vec![1, 2, 3, 4, 5, 6];
let evens = get_even_numbers_idiomatic(&mut nums);
assert_eq!(evens, vec![2, 4, 6]);
assert_eq!(nums, vec![1, 3, 5]);
}
Ключевая мысль: проверщик заимствований Rust предотвращает целую категорию ошибок «изменение во время перебора» на этапе компиляции. C# ловит это во время выполнения; многие языки не ловят вообще.
Глубокое погружение во времена жизни
Времена жизни: как сообщить компилятору, сколько живут ссылки
Что вы узнаете: зачем нужны времена жизни (без GC компилятору нужно доказательство), синтаксис аннотаций времён жизни, правила элизии, времена жизни в структурах, время жизни
'static, а также типичные ошибки проверщика заимствований и способы их исправить.Сложность: 🔴 Продвинутый
Разработчики C# никогда не думают о временах жизни ссылок — достижимостью занимается сборщик мусора. В Rust компилятору нужно доказательство того, что каждая ссылка действительна столько, сколько она используется. Времена жизни и есть это доказательство.
Зачем нужны времена жизни
#![allow(unused)]
fn main() {
// Этот код не скомпилируется — компилятор не может доказать, что возвращаемая ссылка действительна
fn longest(a: &str, b: &str) -> &str {
if a.len() > b.len() { a } else { b }
}
// ОШИБКА: пропущен specifier времени жизни — компилятор не знает,
// заимствует ли возвращаемое значение из `a` или из `b`
}
Аннотации времён жизни
// Время жизни 'a означает: «возвращаемое значение живёт не меньше, чем ОБА входных параметра»
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
fn main() {
let result;
let string1 = String::from("long string");
{
let string2 = String::from("xyz");
result = longest(&string1, &string2);
println!("Longest: {result}"); // ✅ обе ссылки здесь ещё действительны
}
// println!("{result}"); // ❌ ОШИБКА: string2 живёт недостаточно долго
}
Сравнение с C#
// C# — GC держит объекты живыми, пока существует хотя бы одна ссылка
string Longest(string a, string b) => a.Length > b.Length ? a : b;
// Проблем с временами жизни нет — GC сам отслеживает достижимость
// Но: паузы GC, непредсказуемое потребление памяти, нет доказательств на этапе компиляции
Правила элизии времён жизни
В большинстве случаев аннотации времён жизни писать не нужно. Компилятор применяет три правила автоматически:
| Правило | Описание | Пример |
|---|---|---|
| Правило 1 | Каждый параметр-ссылка получает собственное время жизни | fn foo(x: &str, y: &str) → fn foo<'a, 'b>(x: &'a str, y: &'b str) |
| Правило 2 | Если есть ровно одно входное время жизни, оно присваивается всем выходным | fn first(s: &str) -> &str → fn first<'a>(s: &'a str) -> &'a str |
| Правило 3 | Если один из входов — &self или &mut self, его время жизни присваивается всем выходным | fn name(&self) -> &str → работает благодаря &self |
#![allow(unused)]
fn main() {
// Эти записи эквивалентны — компилятор добавляет времена жизни автоматически:
fn first_word(s: &str) -> &str { /* ... */ } // с элизией
fn first_word<'a>(s: &'a str) -> &'a str { /* ... */ } // явно
// А вот здесь нужна явная аннотация — два входа, из какого из них заимствует выход?
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { /* ... */ }
}
Времена жизни в структурах
// Структура, которая заимствует данные (а не владеет ими)
struct Excerpt<'a> {
text: &'a str, // заимствует из String, которая должна пережить эту структуру
}
impl<'a> Excerpt<'a> {
fn new(text: &'a str) -> Self {
Excerpt { text }
}
fn first_sentence(&self) -> &str {
self.text.split('.').next().unwrap_or(self.text)
}
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let excerpt = Excerpt::new(&novel); // excerpt заимствует из novel
println!("First sentence: {}", excerpt.first_sentence());
// novel должен жить, пока существует excerpt
}
// Аналог в C# — проблем с временами жизни нет, но и гарантий на этапе компиляции нет
class Excerpt
{
public string Text { get; }
public Excerpt(string text) => Text = text;
public string FirstSentence() => Text.Split('.')[0];
}
// Что если строку изменят где-то в другом месте? Сюрприз во время выполнения.
Время жизни 'static
#![allow(unused)]
fn main() {
// 'static означает «живёт всё время работы программы»
let s: &'static str = "I'm a string literal"; // хранится в бинарнике, всегда действителен
// Типичные места, где встречается 'static:
// 1. Строковые литералы
// 2. Глобальные константы
// 3. Thread::spawn требует 'static (поток может пережить вызывающий код)
std::thread::spawn(move || {
// Замыкания, передаваемые в потоки, должны владеть своими данными или использовать ссылки 'static
println!("{s}"); // OK: &'static str
});
// 'static НЕ означает «бессмертный» — это значит «МОЖЕТ жить вечно, если потребуется»
let owned = String::from("hello");
// owned не имеет времени жизни 'static, но его можно переместить в поток (передача владения)
}
Типичные ошибки проверщика заимствований и их исправление
| Ошибка | Причина | Исправление |
|---|---|---|
missing lifetime specifier | Несколько входных ссылок, неоднозначный выход | Добавьте <'a>, связав выход с нужным входом |
does not live long enough | Ссылка переживает данные, на которые указывает | Расширьте область видимости данных или верните владеемые данные |
cannot borrow as mutable | Неизменяемое заимствование ещё активно | Используйте неизменяемую ссылку до изменения или перестройте код |
cannot move out of borrowed content | Попытка забрать владение заимствованными данными | Используйте .clone() или перестройте код, чтобы избежать перемещения |
lifetime may not live long enough | Заимствование структуры переживает источник | Убедитесь, что область видимости исходных данных охватывает использование структуры |
Визуализация областей видимости времён жизни
graph TD
subgraph "Визуализация областей видимости"
direction TB
A["fn main()"] --> B["let s1 = String::from("hello")"]
B --> C["{ // внутренняя область"]
C --> D["let s2 = String::from("world")"]
D --> E["let r = longest(&s1, &s2)"]
E --> F["println!("{r}") ✅ обе живы"]
F --> G["} // s2 уничтожается здесь"]
G --> H["println!("{r}") ❌ s2 уже нет!"]
end
style F fill:#c8e6c9,color:#000
style H fill:#ffcdd2,color:#000
Несколько параметров времени жизни
Иногда ссылки приходят из разных источников с разными временами жизни:
// Два независимых времени жизни: результат заимствует только из 'a, а не из 'b
fn first_with_context<'a, 'b>(data: &'a str, _context: &'b str) -> &'a str {
// Результат заимствуется только из 'data' — у 'context' может быть более короткое время жизни
data.split(',').next().unwrap_or(data)
}
fn main() {
let data = String::from("alice,bob,charlie");
let result;
{
let context = String::from("user lookup"); // более короткое время жизни
result = first_with_context(&data, &context);
} // context уничтожается — но result заимствует из data, а не из context ✅
println!("{result}");
}
// C# — без отслеживания времён жизни нельзя выразить «заимствует из A, но не из B»
string FirstWithContext(string data, string context) => data.Split(',')[0];
// Для языков с GC это нормально, но Rust может доказать безопасность без GC
Типичные паттерны из практики
Паттерн 1: итератор, возвращающий ссылки
#![allow(unused)]
fn main() {
// Парсер, который отдаёт заимствованные срезы из входной строки
struct CsvRow<'a> {
fields: Vec<&'a str>,
}
fn parse_csv_line(line: &str) -> CsvRow<'_> {
// '_ говорит компилятору «выведи время жизни из входного параметра»
CsvRow {
fields: line.split(',').collect(),
}
}
}
Паттерн 2: «если сомневаешься — возвращай владеемые данные»
#![allow(unused)]
fn main() {
// Когда времена жизни становятся сложными, практичное решение — вернуть владеемые данные
fn format_greeting(first: &str, last: &str) -> String {
// Возвращаем владеемую String — аннотация времени жизни не нужна
format!("Hello, {first} {last}!")
}
// Заимствуйте только тогда, когда:
// 1. Важна производительность (избегаем выделения памяти)
// 2. Связь между временем жизни входа и выхода очевидна
}
Паттерн 3: ограничения времён жизни для обобщений
#![allow(unused)]
fn main() {
// «T должен жить не меньше, чем 'a»
fn store_reference<'a, T: 'a>(cache: &mut Vec<&'a T>, item: &'a T) {
cache.push(item);
}
// Часто встречается в трейт-объектах: Box<dyn Display + 'a>
fn make_printer<'a>(text: &'a str) -> Box<dyn std::fmt::Display + 'a> {
Box::new(text)
}
}
Когда использовать 'static
| Сценарий | Использовать 'static? | Альтернатива |
|---|---|---|
| Строковые литералы | ✅ Да — они всегда 'static | — |
Замыкание для thread::spawn | Часто — поток переживает вызывающий код | Используйте thread::scope для заимствованных данных |
| Глобальная конфигурация | ✅ lazy_static! или OnceLock | Передавайте ссылки через параметры |
| Трейт-объекты, которые хранятся долго | Часто — Box<dyn Trait + 'static> | Параметризуйте контейнер через 'a |
| Временные заимствования | ❌ Никогда — излишнее ограничение | Используйте реальное время жизни |
🏋️ Упражнение: аннотации времён жизни (нажмите, чтобы раскрыть)
Задача: добавьте правильные аннотации времён жизни, чтобы код скомпилировался:
#![allow(unused)]
fn main() {
struct Config {
db_url: String,
api_key: String,
}
// TODO: Добавьте аннотации времён жизни
fn get_connection_info(config: &Config) -> (&str, &str) {
(&config.db_url, &config.api_key)
}
// TODO: Эта структура заимствует из Config — добавьте параметр времени жизни
struct ConnectionInfo {
db_url: &str,
api_key: &str,
}
}
🔑 Решение
#![allow(unused)]
fn main() {
struct Config {
db_url: String,
api_key: String,
}
// Правило 3 не применяется (нет &self), применяется правило 2 (один вход → выход)
// Поэтому компилятор справляется сам — аннотация не нужна!
fn get_connection_info(config: &Config) -> (&str, &str) {
(&config.db_url, &config.api_key)
}
// Аннотация времени жизни для структуры нужна:
struct ConnectionInfo<'a> {
db_url: &'a str,
api_key: &'a str,
}
fn make_info<'a>(config: &'a Config) -> ConnectionInfo<'a> {
ConnectionInfo {
db_url: &config.db_url,
api_key: &config.api_key,
}
}
}
Ключевая мысль: элизия времён жизни часто избавляет от необходимости писать аннотации у функций, но структуры, которые заимствуют данные, всегда требуют явного <'a>.
Умные указатели — не только единоличное владение
Умные указатели: когда единоличного владения недостаточно
Что вы узнаете:
Box<T>,Rc<T>,Arc<T>,Cell<T>,RefCell<T>иCow<'a, T>— когда что использовать, как они соотносятся со ссылками, которыми управляет GC в C#,Dropкак аналогIDisposableв Rust, приведение черезDeref, а также дерево решений для выбора подходящего умного указателя.Сложность: 🔴 Продвинутый
В C# каждый объект по сути подсчитывается сборщиком мусора. В Rust по умолчанию действует единоличное владение, но иногда нужны разделяемое владение, выделение в куче или внутренняя изменяемость. Для этого и существуют умные указатели.
Box<T> — простое выделение в куче
#![allow(unused)]
fn main() {
// Выделение на стеке (по умолчанию в Rust)
let x = 42; // на стеке
// Выделение в куче через Box
let y = Box::new(42); // в куче, как `new int(42)` в C# (упаковка)
println!("{}", y); // автоматическое разыменование: выводит 42
// Типичное применение: рекурсивные типы (размер неизвестен на этапе компиляции)
#[derive(Debug)]
enum List {
Cons(i32, Box<List>), // Box даёт указатель известного размера
Nil,
}
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
}
// C# — всё уже в куче (ссылочные типы)
// Box<T> нужен в Rust только потому, что стек — это умолчание
var list = new LinkedListNode<int>(1); // всегда выделяется в куче
Rc<T> — разделяемое владение (один поток)
#![allow(unused)]
fn main() {
use std::rc::Rc;
// Несколько владельцев одних и тех же данных — как несколько ссылок в C#
let shared = Rc::new(vec![1, 2, 3]);
let clone1 = Rc::clone(&shared); // счётчик ссылок: 2
let clone2 = Rc::clone(&shared); // счётчик ссылок: 3
println!("Count: {}", Rc::strong_count(&shared)); // 3
// Данные уничтожаются, когда выходит из области видимости последний Rc
// Типичное применение: общая конфигурация, узлы графа, древовидные структуры
}
Arc<T> — разделяемое владение (потокобезопасное)
#![allow(unused)]
fn main() {
use std::sync::Arc;
use std::thread;
// Arc = Atomic Reference Counting — безопасно разделяется между потоками
let data = Arc::new(vec![1, 2, 3]);
let handles: Vec<_> = (0..3).map(|i| {
let data = Arc::clone(&data);
thread::spawn(move || {
println!("Thread {i}: {:?}", data);
})
}).collect();
for h in handles { h.join().unwrap(); }
}
// C# — все ссылки потокобезопасны по умолчанию (этим занимается GC)
var data = new List<int> { 1, 2, 3 };
// Можно свободно разделять между потоками (но мутация по-прежнему небезопасна!)
Cell<T> и RefCell<T> — внутренняя изменяемость
#![allow(unused)]
fn main() {
use std::cell::RefCell;
// Иногда нужно изменять данные за разделяемой ссылкой.
// RefCell переносит проверку заимствований из времени компиляции во время выполнения.
struct Logger {
entries: RefCell<Vec<String>>,
}
impl Logger {
fn new() -> Self {
Logger { entries: RefCell::new(Vec::new()) }
}
fn log(&self, msg: &str) { // &self, а не &mut self!
self.entries.borrow_mut().push(msg.to_string());
}
fn dump(&self) {
for entry in self.entries.borrow().iter() {
println!("{entry}");
}
}
}
// ⚠️ RefCell вызывает панику во время выполнения, если нарушены правила заимствования
// Используйте осторожно — по возможности предпочитайте проверку на этапе компиляции
}
Cow<’a, str> — копирование при записи (Clone on Write)
#![allow(unused)]
fn main() {
use std::borrow::Cow;
// Иногда есть &str, который МОЖЕТ понадобиться превратить в String
fn normalize(input: &str) -> Cow<'_, str> {
if input.contains('\t') {
// Выделяем память, только когда нужно изменить данные
Cow::Owned(input.replace('\t', " "))
} else {
// Заимствуем исходные данные — без выделения памяти
Cow::Borrowed(input)
}
}
let clean = normalize("hello"); // Cow::Borrowed — без выделения памяти
let dirty = normalize("hello\tworld"); // Cow::Owned — выделено
// Оба можно использовать как &str через Deref
println!("{clean} / {dirty}");
}
Drop: аналог IDisposable в Rust
В C# очисткой ресурсов занимаются IDisposable и using. Аналог в Rust — трейт Drop, но он автоматический, а не включаемый по желанию:
// C# — нужно не забыть использовать 'using' или вызвать Dispose()
using var file = File.OpenRead("data.bin");
// Dispose() вызывается в конце области видимости
// Забыть 'using' — значит получить утечку ресурса!
var file2 = File.OpenRead("data.bin");
// GC в итоге финализирует объект, но момент непредсказуем
// Rust — Drop выполняется автоматически, когда значение выходит из области видимости
{
let file = File::open("data.bin")?;
// работаем с file...
} // file.drop() вызывается ЗДЕСЬ, детерминированно — 'using' не нужен
// Собственный Drop (аналог реализации IDisposable)
struct TempFile {
path: std::path::PathBuf,
}
impl Drop for TempFile {
fn drop(&mut self) {
// Гарантированно выполняется, когда TempFile выходит из области видимости
let _ = std::fs::remove_file(&self.path);
println!("Cleaned up {:?}", self.path);
}
}
fn main() {
let tmp = TempFile { path: "scratch.tmp".into() };
// ... используем tmp ...
} // scratch.tmp удаляется автоматически здесь
Ключевое отличие от C#: в Rust любой тип может иметь детерминированную очистку. Вы никогда не забудете using, потому что забывать нечего — Drop выполняется, когда владелец выходит из области видимости. Этот паттерн называется RAII (Resource Acquisition Is Initialization — получение ресурса есть инициализация).
Правило: если ваш тип хранит ресурс (дескриптор файла, сетевое соединение, охранник блокировки, временный файл), реализуйте
Drop. Система владения гарантирует, что он выполнится ровно один раз.
Приведение через Deref: автоматическое разворачивание умных указателей
Rust автоматически «разворачивает» умные указатели, когда вы вызываете методы или передаёте их в функции. Это называется приведением через Deref (Deref coercion):
#![allow(unused)]
fn main() {
let boxed: Box<String> = Box::new(String::from("hello"));
// Цепочка приведения через Deref: Box<String> → String → str
println!("Length: {}", boxed.len()); // вызывается str::len() — автоматическое разыменование!
fn greet(name: &str) {
println!("Hello, {name}");
}
let s = String::from("Alice");
greet(&s); // &String → &str через приведение Deref
greet(&boxed); // &Box<String> → &String → &str — два уровня!
}
// В C# аналога нет — понадобились бы явные приведения или .ToString()
// Ближайшее — неявные операторы преобразования, но их нужно явно определить
Почему это важно: вы можете передать &String там, где ожидается &str, &Vec<T> там, где ожидается &[T], и &Box<T> там, где ожидается &T, — и всё это без явного преобразования. Поэтому API на Rust обычно принимают &str и &[T], а не &String и &Vec<T>.
Rc против Arc: что выбрать
Rc<T> | Arc<T> | |
|---|---|---|
| Потокобезопасность | ❌ Только один поток | ✅ Потокобезопасен (атомарные операции) |
| Накладные расходы | Ниже (неатомарный счётчик ссылок) | Выше (атомарный счётчик ссылок) |
| Проверка компилятором | Не скомпилируется при передаче в thread::spawn | Работает везде |
| Сочетается с | RefCell<T> для изменения | Mutex<T> или RwLock<T> для изменения |
Эмпирическое правило: начинайте с Rc. Компилятор подскажет, если понадобится Arc.
Дерево решений: какой умный указатель выбрать?
graph TD
START["Нужно разделяемое владение<br/>или выделение в куче?"]
HEAP["Нужно только выделение в куче?"]
SHARED["Нужно разделяемое владение?"]
THREADED["Разделяется между потоками?"]
MUTABLE["Нужна внутренняя изменяемость?"]
MAYBE_OWN["Иногда заимствуется,<br/>иногда владеется?"]
BOX["Используйте Box<T>"]
RC["Используйте Rc<T>"]
ARC["Используйте Arc<T>"]
REFCELL["Используйте RefCell<T><br/>(или Rc<RefCell<T>>)"]
MUTEX["Используйте Arc<Mutex<T>>"]
COW["Используйте Cow<'a, T>"]
OWN["Используйте владеемый тип<br/>(String, Vec и т. д.)"]
START -->|Да| HEAP
START -->|Нет| OWN
HEAP -->|Да| BOX
HEAP -->|Разделяемое| SHARED
SHARED -->|Один поток| RC
SHARED -->|Несколько потоков| THREADED
THREADED -->|Только чтение| ARC
THREADED -->|Чтение и запись| MUTEX
RC -->|Нужна мутация?| MUTABLE
MUTABLE -->|Да| REFCELL
MAYBE_OWN -->|Да| COW
style BOX fill:#e3f2fd,color:#000
style RC fill:#e8f5e8,color:#000
style ARC fill:#c8e6c9,color:#000
style REFCELL fill:#fff3e0,color:#000
style MUTEX fill:#fff3e0,color:#000
style COW fill:#e3f2fd,color:#000
style OWN fill:#f5f5f5,color:#000
🏋️ Упражнение: выберите подходящий умный указатель (нажмите, чтобы раскрыть)
Задача: для каждого сценария выберите подходящий умный указатель и объясните почему.
- Рекурсивная древовидная структура данных
- Общий объект конфигурации, который читают несколько компонентов (один поток)
- Счётчик запросов, разделяемый между обработчиками HTTP-запросов в разных потоках
- Кэш, который может возвращать заимствованные или владеемые строки
- Буфер логов, который нужно изменять через разделяемую ссылку
🔑 Решение
Box<T>— рекурсивным типам нужна косвенная адресация, чтобы размер был известен на этапе компиляцииRc<T>— разделяемый доступ только для чтения, один поток, накладные расходыArcне нужныArc<Mutex<u64>>— разделяется между потоками (Arc) с изменением (Mutex)Cow<'a, str>— иногда возвращает&str(попадание в кэш), иногдаString(промах кэша)RefCell<Vec<String>>— внутренняя изменяемость за&self(один поток)
Эмпирическое правило: начинайте с владеемых типов. Берите Box, когда нужна косвенная адресация, Rc/Arc, когда нужно разделение, RefCell/Mutex, когда нужна внутренняя изменяемость, и Cow, когда хотите работать без копирования в типичном случае.
8. Крейты и модули
Модули и крейты: организация кода
Что вы узнаете: систему модулей Rust в сравнении с пространствами имён и сборками C#, модификаторы видимости
pub/pub(crate)/pub(super), организацию модулей по файлам и то, как крейты соответствуют сборкам .NET.Сложность: 🟢 Начальный
Понимание системы модулей Rust необходимо для организации кода и управления зависимостями. Разработчикам C# это аналогично пониманию пространств имён, сборок и пакетов NuGet.
Модули Rust против пространств имён C#
Организация пространств имён в C#
// Файл: Models/User.cs
namespace MyApp.Models
{
public class User
{
public string Name { get; set; }
public int Age { get; set; }
}
}
// Файл: Services/UserService.cs
using MyApp.Models;
namespace MyApp.Services
{
public class UserService
{
public User CreateUser(string name, int age)
{
return new User { Name = name, Age = age };
}
}
}
// Файл: Program.cs
using MyApp.Models;
using MyApp.Services;
namespace MyApp
{
class Program
{
static void Main(string[] args)
{
var service = new UserService();
var user = service.CreateUser("Alice", 30);
}
}
}
Организация модулей в Rust
// Файл: src/models.rs
pub struct User {
pub name: String,
pub age: u32,
}
impl User {
pub fn new(name: String, age: u32) -> User {
User { name, age }
}
}
// Файл: src/services.rs
use crate::models::User;
pub struct UserService;
impl UserService {
pub fn create_user(name: String, age: u32) -> User {
User::new(name, age)
}
}
// Файл: src/lib.rs (или main.rs)
pub mod models;
pub mod services;
use models::User;
use services::UserService;
fn main() {
let service = UserService;
let user = UserService::create_user("Alice".to_string(), 30);
}
Иерархия модулей и видимость
graph TD
Crate["crate (корень)"] --> ModA["mod data"]
Crate --> ModB["mod api"]
ModA --> SubA1["pub struct Repo"]
ModA --> SubA2["fn helper (приватная)"]
ModB --> SubB1["pub fn handle()"]
ModB --> SubB2["pub(crate) fn internal()"]
ModB --> SubB3["pub(super) fn parent_only()"]
style SubA1 fill:#c8e6c9,color:#000
style SubA2 fill:#ffcdd2,color:#000
style SubB1 fill:#c8e6c9,color:#000
style SubB2 fill:#fff9c4,color:#000
style SubB3 fill:#fff9c4,color:#000
🟢 Зелёный = публичный везде | 🟡 Жёлтый = ограниченная видимость | 🔴 Красный = приватный
Модификаторы видимости в C#
namespace MyApp.Data
{
// public — доступен откуда угодно
public class Repository
{
// private — только внутри этого класса
private string connectionString;
// internal — внутри этой сборки
internal void Connect() { }
// protected — этот класс и его наследники
protected virtual void Initialize() { }
// public — доступен откуда угодно
public void Save(object data) { }
}
}
Правила видимости в Rust
#![allow(unused)]
fn main() {
// В Rust всё по умолчанию приватно
mod data {
struct Repository { // Приватная структура
connection_string: String, // Приватное поле
}
impl Repository {
fn new() -> Repository { // Приватная функция
Repository {
connection_string: "localhost".to_string(),
}
}
pub fn connect(&self) { // Публичный метод
// Доступен только внутри этого модуля и его потомков
}
pub(crate) fn initialize(&self) { // Публичен на уровне крейта
// Доступен из любого места этого крейта
}
pub(super) fn internal_method(&self) { // Публичен для родительского модуля
// Доступен в родительском модуле
}
}
// Публичная структура — доступна извне модуля
pub struct PublicRepository {
pub data: String, // Публичное поле
private_data: String, // Приватное поле (без pub)
}
}
pub use data::PublicRepository; // Реэкспорт для внешнего использования
}
Организация файлов модулей
Структура проекта C#
MyApp/
├── MyApp.csproj
├── Models/
│ ├── User.cs
│ └── Product.cs
├── Services/
│ ├── UserService.cs
│ └── ProductService.cs
├── Controllers/
│ └── ApiController.cs
└── Program.cs
Структура файлов модулей в Rust
my_app/
├── Cargo.toml
└── src/
├── main.rs (или lib.rs)
├── models/
│ ├── mod.rs // Объявление модуля
│ ├── user.rs
│ └── product.rs
├── services/
│ ├── mod.rs // Объявление модуля
│ ├── user_service.rs
│ └── product_service.rs
└── controllers/
├── mod.rs
└── api_controller.rs
Паттерны объявления модулей
#![allow(unused)]
fn main() {
// src/models/mod.rs
pub mod user; // Объявляет user.rs как подмодуль
pub mod product; // Объявляет product.rs как подмодуль
// Реэкспорт часто используемых типов
pub use user::User;
pub use product::Product;
// src/main.rs
mod models; // Объявляет models/ как модуль
mod services; // Объявляет services/ как модуль
// Импорт конкретных элементов
use models::{User, Product};
use services::UserService;
// Или импорт всего модуля
use models::user::*; // Импортирует все публичные элементы модуля user
}
Крейты против сборок .NET
Понятие крейта
В Rust крейт — это базовая единица компиляции и распространения кода, похожая на сборку (assembly) в .NET.
Модель сборок C#
// MyLibrary.dll — скомпилированная сборка
namespace MyLibrary
{
public class Calculator
{
public int Add(int a, int b) => a + b;
}
}
// MyApp.exe — исполняемая сборка, которая ссылается на MyLibrary.dll
using MyLibrary;
class Program
{
static void Main()
{
var calc = new Calculator();
Console.WriteLine(calc.Add(2, 3));
}
}
Модель крейтов Rust
# Cargo.toml для библиотечного крейта
[package]
name = "my_calculator"
version = "0.1.0"
edition = "2021"
[lib]
name = "my_calculator"
#![allow(unused)]
fn main() {
// src/lib.rs — библиотечный крейт
pub struct Calculator;
impl Calculator {
pub fn add(&self, a: i32, b: i32) -> i32 {
a + b
}
}
}
# Cargo.toml для бинарного крейта, который использует библиотеку
[package]
name = "my_app"
version = "0.1.0"
edition = "2021"
[dependencies]
my_calculator = { path = "../my_calculator" }
// src/main.rs — бинарный крейт
use my_calculator::Calculator;
fn main() {
let calc = Calculator;
println!("{}", calc.add(2, 3));
}
Сравнение типов крейтов
| Концепция C# | Аналог в Rust | Назначение |
|---|---|---|
| Библиотека классов (.dll) | Библиотечный крейт | Повторно используемый код |
| Консольное приложение (.exe) | Бинарный крейт | Исполняемая программа |
| Пакет NuGet | Опубликованный крейт | Единица распространения |
| Сборка (.dll/.exe) | Скомпилированный крейт | Единица компиляции |
| Solution (.sln) | Workspace | Организация нескольких проектов |
Workspace против Solution
Структура Solution в C#
<!-- Структура MySolution.sln -->
<Solution>
<Project Include="WebApi/WebApi.csproj" />
<Project Include="Business/Business.csproj" />
<Project Include="DataAccess/DataAccess.csproj" />
<Project Include="Tests/Tests.csproj" />
</Solution>
Структура Workspace в Rust
# Cargo.toml в корне workspace
[workspace]
members = [
"web_api",
"business",
"data_access",
"tests"
]
[workspace.dependencies]
serde = "1.0" # Общие версии зависимостей
tokio = "1.0"
# web_api/Cargo.toml
[package]
name = "web_api"
version = "0.1.0"
edition = "2021"
[dependencies]
business = { path = "../business" }
serde = { workspace = true } # Использовать версию из workspace
tokio = { workspace = true }
Упражнения
🏋️ Упражнение: спроектируйте дерево модулей (нажмите, чтобы раскрыть)
Дана такая структура проекта на C#. Спроектируйте эквивалентное дерево модулей на Rust:
// C#
namespace MyApp.Services { public class AuthService { } }
namespace MyApp.Services { internal class TokenStore { } }
namespace MyApp.Models { public class User { } }
namespace MyApp.Models { public class Session { } }
Требования:
AuthServiceи обе модели должны быть публичнымиTokenStoreдолжен быть приватным для модуляservices- Приведите структуру файлов и объявления
mod/pubвlib.rs
🔑 Решение
Структура файлов:
src/
├── lib.rs
├── services/
│ ├── mod.rs
│ ├── auth_service.rs
│ └── token_store.rs
└── models/
├── mod.rs
├── user.rs
└── session.rs
// src/lib.rs
pub mod services;
pub mod models;
// src/services/mod.rs
mod token_store; // приватный — как internal в C#
pub mod auth_service; // публичный
// src/services/auth_service.rs
use super::token_store::TokenStore; // виден внутри модуля
pub struct AuthService;
impl AuthService {
pub fn login(&self) { /* использует TokenStore внутри */ }
}
// src/services/token_store.rs
pub(super) struct TokenStore; // виден только родительскому модулю (services)
// src/models/mod.rs
pub mod user;
pub mod session;
// src/models/user.rs
pub struct User {
pub name: String,
}
// src/models/session.rs
pub struct Session {
pub user_id: u64,
}
Управление пакетами — Cargo против NuGet
Управление пакетами: Cargo против NuGet
Что вы узнаете:
Cargo.tomlпротив.csproj, спецификаторы версий,Cargo.lock, флаги функций для условной компиляции, а также распространённые команды Cargo и их аналоги в NuGet/dotnet.Сложность: 🟢 Начальный
Объявление зависимостей
Зависимости NuGet в C#
<!-- MyApp.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="3.0.1" />
<PackageReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="../MyLibrary/MyLibrary.csproj" />
</Project>
Зависимости Cargo в Rust
# Cargo.toml
[package]
name = "my_app"
version = "0.1.0"
edition = "2021"
[dependencies]
serde_json = "1.0" # Из crates.io (как NuGet)
serde = { version = "1.0", features = ["derive"] } # С функциями
log = "0.4"
tokio = { version = "1.0", features = ["full"] }
# Локальные зависимости (как ProjectReference)
my_library = { path = "../my_library" }
# Зависимости из git
my_git_crate = { git = "https://github.com/user/repo" }
# Зависимости для разработки (как тестовые пакеты)
[dev-dependencies]
criterion = "0.5" # Бенчмарки
proptest = "1.0" # Property-тестирование
Управление версиями
Версионирование пакетов в C#
<!-- Централизованное управление пакетами (Directory.Packages.props) -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Serilog" Version="3.0.1" />
</Project>
<!-- packages.lock.json для воспроизводимых сборок -->
Управление версиями в Rust
# Cargo.toml — семантическое версионирование
[dependencies]
serde = "1.0" # Совместимо с 1.x.x (>=1.0.0, <2.0.0)
log = "0.4.17" # Совместимо с 0.4.x (>=0.4.17, <0.5.0)
regex = "=1.5.4" # Точная версия
chrono = "^0.4" # Требования с кареткой (по умолчанию)
uuid = "~1.3.0" # Требования с тильдой (>=1.3.0, <1.4.0)
# Cargo.lock — точные версии для воспроизводимых сборок (генерируется автоматически)
[[package]]
name = "serde"
version = "1.0.163"
# ... точное дерево зависимостей
Источники пакетов
Источники пакетов в C#
<!-- nuget.config -->
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="MyCompanyFeed" value="https://pkgs.dev.azure.com/company/_packaging/feed/nuget/v3/index.json" />
</packageSources>
</configuration>
Источники пакетов в Rust
# .cargo/config.toml
[source.crates-io]
replace-with = "my-awesome-registry"
[source.my-awesome-registry]
registry = "https://my-intranet:8080/index"
# Альтернативные реестры
[registries]
my-registry = { index = "https://my-intranet:8080/index" }
# В Cargo.toml
[dependencies]
my_crate = { version = "1.0", registry = "my-registry" }
Сравнение распространённых команд
| Задача | Команда C# | Команда Rust |
|---|---|---|
| Восстановить пакеты | dotnet restore | cargo fetch |
| Добавить пакет | dotnet add package Newtonsoft.Json | cargo add serde_json |
| Удалить пакет | dotnet remove package Newtonsoft.Json | cargo remove serde_json |
| Обновить пакеты | dotnet update | cargo update |
| Список пакетов | dotnet list package | cargo tree |
| Аудит безопасности | dotnet list package --vulnerable | cargo audit |
| Чистая сборка | dotnet clean | cargo clean |
Features: условная компиляция
Условная компиляция в C#
#if DEBUG
Console.WriteLine("Debug mode");
#elif RELEASE
Console.WriteLine("Release mode");
#endif
// Функции в файле проекта
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
<DefineConstants>DEBUG;TRACE</DefineConstants>
</PropertyGroup>
Feature-флаги в Rust
# Cargo.toml
[features]
default = ["json"] # Функции по умолчанию
json = ["serde_json"] # Функция, включающая serde_json
xml = ["serde_xml"] # Альтернативная сериализация
advanced = ["json", "xml"] # Составная функция
[dependencies]
serde_json = { version = "1.0", optional = true }
serde_xml = { version = "0.4", optional = true }
#![allow(unused)]
fn main() {
// Условная компиляция на основе функций
#[cfg(feature = "json")]
use serde_json;
#[cfg(feature = "xml")]
use serde_xml;
pub fn serialize_data(data: &MyStruct) -> String {
#[cfg(feature = "json")]
return serde_json::to_string(data).unwrap();
#[cfg(feature = "xml")]
return serde_xml::to_string(data).unwrap();
#[cfg(not(any(feature = "json", feature = "xml")))]
return "No serialization feature enabled".to_string();
}
}
Использование внешних крейтов
Популярные крейты для разработчиков C#
| Библиотека C# | Крейт Rust | Назначение |
|---|---|---|
| System.Text.Json / Newtonsoft.Json | serde_json | Сериализация JSON |
| HttpClient | reqwest | HTTP-клиент |
| Entity Framework | diesel / sqlx | ORM / инструментарий SQL |
| NLog/Serilog | log + env_logger | Логирование |
| xUnit/NUnit | Встроенный #[test] | Модульное тестирование |
| Moq | mockall | Моки |
| Flurl | url | Работа с URL |
| Polly | tower | Паттерны отказоустойчивости |
Пример: миграция HTTP-клиента
// Использование HttpClient в C#
public class ApiClient
{
private readonly HttpClient _httpClient;
public async Task<User> GetUserAsync(int id)
{
var response = await _httpClient.GetAsync($"/users/{id}");
var json = await response.Content.ReadAsStringAsync();
return System.Text.Json.JsonSerializer.Deserialize<User>(json);
}
}
#![allow(unused)]
fn main() {
// Использование reqwest в Rust
use reqwest;
use serde::Deserialize;
#[derive(Deserialize)]
struct User {
id: u32,
name: String,
}
struct ApiClient {
client: reqwest::Client,
}
impl ApiClient {
async fn get_user(&self, id: u32) -> Result<User, reqwest::Error> {
let user = self.client
.get(&format!("https://api.example.com/users/{}", id))
.send()
.await?
.json::<User>()
.await?;
Ok(user)
}
}
}
9. Обработка ошибок
Исключения против Result<T, E>
Что вы узнаете: почему Rust заменяет исключения на
Result<T, E>иOption<T>, оператор?для краткого распространения ошибок, и как явная обработка ошибок устраняет скрытый поток управления, который осложняет код сtry/catchв C#.Сложность: 🟡 Средний
См. также: Собственные типы ошибок уровня крейта — производственные паттерны с
thiserrorиanyhow, и Основные крейты — экосистема крейтов для ошибок.
Обработка ошибок через исключения в C#
// C# — обработка ошибок через исключения
public class UserService
{
public User GetUser(int userId)
{
if (userId <= 0)
{
throw new ArgumentException("User ID must be positive");
}
var user = database.FindUser(userId);
if (user == null)
{
throw new UserNotFoundException($"User {userId} not found");
}
return user;
}
public async Task<string> GetUserEmailAsync(int userId)
{
try
{
var user = GetUser(userId);
return user.Email ?? throw new InvalidOperationException("User has no email");
}
catch (UserNotFoundException ex)
{
logger.Warning("User not found: {UserId}", userId);
return "noreply@company.com";
}
catch (Exception ex)
{
logger.Error(ex, "Unexpected error getting user email");
throw; // Повторно выбрасываем
}
}
}
Обработка ошибок через Result в Rust
#![allow(unused)]
fn main() {
use std::fmt;
#[derive(Debug)]
pub enum UserError {
InvalidId(i32),
NotFound(i32),
NoEmail,
DatabaseError(String),
}
impl fmt::Display for UserError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
UserError::InvalidId(id) => write!(f, "Invalid user ID: {}", id),
UserError::NotFound(id) => write!(f, "User {} not found", id),
UserError::NoEmail => write!(f, "User has no email address"),
UserError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
}
}
}
impl std::error::Error for UserError {}
#[derive(Debug, Clone)]
pub struct User {
pub name: String,
pub email: Option<String>,
}
pub struct UserService {
users: Vec<User>, // Имитация базы данных
}
impl UserService {
fn database_find_user(&self, user_id: i32) -> Option<User> {
self.users.get(user_id as usize).cloned()
}
pub fn get_user(&self, user_id: i32) -> Result<User, UserError> {
if user_id <= 0 {
return Err(UserError::InvalidId(user_id));
}
// Имитация поиска в базе данных
self.database_find_user(user_id)
.ok_or(UserError::NotFound(user_id))
}
pub fn get_user_email(&self, user_id: i32) -> Result<String, UserError> {
let user = self.get_user(user_id)?; // Оператор ? пробрасывает ошибку
user.email
.ok_or(UserError::NoEmail)
}
pub fn get_user_email_or_default(&self, user_id: i32) -> String {
match self.get_user_email(user_id) {
Ok(email) => email,
Err(UserError::NotFound(_)) => {
log::warn!("User not found: {}", user_id);
"noreply@company.com".to_string()
}
Err(err) => {
log::error!("Error getting user email: {}", err);
"error@company.com".to_string()
}
}
}
}
}
graph TD
subgraph "Модель исключений C#"
CS_CALL["Вызов метода"]
CS_SUCCESS["Путь успеха"]
CS_EXCEPTION["throw Exception"]
CS_STACK["Раскрутка стека<br/>(затраты во время выполнения)"]
CS_CATCH["Блок try/catch"]
CS_HIDDEN["[ERROR] Скрытый поток управления<br/>[ERROR] Затраты на производительность<br/>[ERROR] Легко проигнорировать"]
CS_CALL --> CS_SUCCESS
CS_CALL --> CS_EXCEPTION
CS_EXCEPTION --> CS_STACK
CS_STACK --> CS_CATCH
CS_EXCEPTION --> CS_HIDDEN
end
subgraph "Модель Result в Rust"
RUST_CALL["Вызов функции"]
RUST_OK["Ok(value)"]
RUST_ERR["Err(error)"]
RUST_MATCH["match result"]
RUST_QUESTION["Оператор ?<br/>(ранний возврат)"]
RUST_EXPLICIT["[OK] Явная обработка ошибок<br/>[OK] Нулевые затраты во время выполнения<br/>[OK] Ошибки нельзя проигнорировать"]
RUST_CALL --> RUST_OK
RUST_CALL --> RUST_ERR
RUST_OK --> RUST_MATCH
RUST_ERR --> RUST_MATCH
RUST_ERR --> RUST_QUESTION
RUST_MATCH --> RUST_EXPLICIT
RUST_QUESTION --> RUST_EXPLICIT
end
style CS_HIDDEN fill:#ffcdd2,color:#000
style RUST_EXPLICIT fill:#c8e6c9,color:#000
style CS_STACK fill:#fff3e0,color:#000
style RUST_QUESTION fill:#c8e6c9,color:#000
Оператор ?: краткое распространение ошибок
// C# — распространение исключений (неявное)
public async Task<string> ProcessFileAsync(string path)
{
var content = await File.ReadAllTextAsync(path); // Бросает исключение при ошибке
var processed = ProcessContent(content); // Бросает исключение при ошибке
return processed;
}
#![allow(unused)]
fn main() {
// Rust — распространение ошибок через ?
fn process_file(path: &str) -> Result<String, ConfigError> {
let content = read_config(path)?; // ? пробрасывает ошибку, если это Err
let processed = process_content(&content)?; // ? пробрасывает ошибку, если это Err
Ok(processed) // Оборачиваем успешное значение в Ok
}
fn process_content(content: &str) -> Result<String, ConfigError> {
if content.is_empty() {
Err(ConfigError::InvalidFormat)
} else {
Ok(content.to_uppercase())
}
}
}
Option<T> для значений, которые могут отсутствовать
// C# — ссылочные типы с поддержкой null
public string? FindUserName(int userId)
{
var user = database.FindUser(userId);
return user?.Name; // Возвращает null, если пользователь не найден
}
public void ProcessUser(int userId)
{
string? name = FindUserName(userId);
if (name != null)
{
Console.WriteLine($"User: {name}");
}
else
{
Console.WriteLine("User not found");
}
}
#![allow(unused)]
fn main() {
// Rust — Option<T> для необязательных значений
fn find_user_name(user_id: u32) -> Option<String> {
// Имитация поиска в базе данных
if user_id == 1 {
Some("Alice".to_string())
} else {
None
}
}
fn process_user(user_id: u32) {
match find_user_name(user_id) {
Some(name) => println!("User: {}", name),
None => println!("User not found"),
}
// Или используйте if let (сокращённая форма сопоставления с образцом)
if let Some(name) = find_user_name(user_id) {
println!("User: {}", name);
} else {
println!("User not found");
}
}
}
Комбинирование Option и Result
fn safe_divide(a: f64, b: f64) -> Option<f64> {
if b != 0.0 {
Some(a / b)
} else {
None
}
}
fn parse_and_divide(a_str: &str, b_str: &str) -> Result<Option<f64>, ParseFloatError> {
let a: f64 = a_str.parse()?; // Возвращаем ошибку разбора, если значение некорректно
let b: f64 = b_str.parse()?; // Возвращаем ошибку разбора, если значение некорректно
Ok(safe_divide(a, b)) // Возвращаем Ok(Some(result)) или Ok(None)
}
use std::num::ParseFloatError;
fn main() {
match parse_and_divide("10.0", "2.0") {
Ok(Some(result)) => println!("Result: {}", result),
Ok(None) => println!("Division by zero"),
Err(error) => println!("Parse error: {}", error),
}
}
🏋️ Упражнение: создайте тип ошибок уровня крейта (нажмите, чтобы раскрыть)
Задача: создайте перечисление AppError для приложения обработки файлов, которое может завершиться ошибкой из-за ошибок ввода-вывода, ошибок разбора JSON и ошибок валидации. Реализуйте преобразования From для автоматического распространения через ?.
#![allow(unused)]
fn main() {
// Стартовый код
use std::io;
// TODO: Определите AppError с вариантами:
// Io(io::Error), Json(serde_json::Error), Validation(String)
// TODO: Реализуйте трейты Display и Error
// TODO: Реализуйте From<io::Error> и From<serde_json::Error>
// TODO: Определите псевдоним типа: type Result<T> = std::result::Result<T, AppError>;
fn load_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)?; // io::Error → AppError
let config: Config = serde_json::from_str(&content)?; // ошибка serde → AppError
if config.name.is_empty() {
return Err(AppError::Validation("name cannot be empty".into()));
}
Ok(config)
}
}
🔑 Решение
#![allow(unused)]
fn main() {
use std::io;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Validation: {0}")]
Validation(String),
}
pub type Result<T> = std::result::Result<T, AppError>;
#[derive(serde::Deserialize)]
struct Config {
name: String,
port: u16,
}
fn load_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)?;
let config: Config = serde_json::from_str(&content)?;
if config.name.is_empty() {
return Err(AppError::Validation("name cannot be empty".into()));
}
Ok(config)
}
}
Ключевые выводы:
thiserrorгенерирует реализацииDisplayиErrorна основе атрибутов#[from]генерирует реализацииFrom<T>, что позволяет автоматически преобразовывать ошибки через?- Псевдоним
Result<T>убирает шаблонный код во всём крейте - В отличие от исключений C#, тип ошибки виден в каждой сигнатуре функции
Типы ошибок уровня крейта и псевдонимы Result
Типы ошибок уровня крейта и псевдонимы Result
Что вы узнаете: производственный паттерн определения перечисления ошибок для каждого крейта с помощью
thiserror, создание псевдонима типаResult<T>, а также когда выбиратьthiserror(библиотеки) илиanyhow(приложения).Сложность: 🟡 Средний
Ключевой паттерн для производственного кода на Rust: определить перечисление ошибок для каждого крейта и псевдоним типа Result, чтобы убрать шаблонный код.
Паттерн
#![allow(unused)]
fn main() {
// src/error.rs
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Validation error: {message}")]
Validation { message: String },
#[error("Not found: {entity} with id {id}")]
NotFound { entity: String, id: String },
}
/// Псевдоним Result для всего крейта — каждая функция возвращает именно его
pub type Result<T> = std::result::Result<T, AppError>;
}
Использование во всём крейте
#![allow(unused)]
fn main() {
use crate::error::{AppError, Result};
// Предполагается, что пул соединений с базой доступен, например:
// async fn get_user(pool: &PgPool, id: Uuid) -> Result<User>
// Здесь показан паттерн, где `pool` — сокращение.
pub async fn get_user(id: Uuid) -> Result<User> {
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_optional(&pool)
.await?; // sqlx::Error → AppError::Database через #[from]
user.ok_or_else(|| AppError::NotFound {
entity: "User".into(),
id: id.to_string(),
})
}
pub async fn create_user(req: CreateUserRequest) -> Result<User> {
if req.name.trim().is_empty() {
return Err(AppError::Validation {
message: "Name cannot be empty".into(),
});
}
// ...
}
}
Сравнение с C#
// Аналогичный паттерн в C#
public class AppException : Exception
{
public string ErrorCode { get; }
public AppException(string code, string message) : base(message)
{
ErrorCode = code;
}
}
// Но в C# вызывающий код не знает, какие исключения ожидать!
// В Rust тип ошибки указан в сигнатуре функции.
Почему это важно
thiserrorавтоматически генерирует реализацииDisplayиError#[from]позволяет оператору?автоматически преобразовывать ошибки библиотек- Псевдоним
Result<T>делает каждую сигнатуру функции чистой:fn foo() -> Result<Bar> - В отличие от исключений C# вызывающий код видит все возможные варианты ошибок в типе
thiserror против anyhow: что выбрать
Обработку ошибок в Rust определяют два крейта. Выбор между ними — первое решение, которое вам предстоит принять:
thiserror | anyhow | |
|---|---|---|
| Назначение | Определение структурированных типов ошибок для библиотек | Быстрая обработка ошибок для приложений |
| Результат | Собственное перечисление, которым вы управляете | Непрозрачная обёртка anyhow::Error |
| Что видит вызывающий код | Все варианты ошибок в типе | Просто anyhow::Error — непрозрачный |
| Лучше всего для | Библиотечных крейтов, API, любого кода с потребителями | Бинарников, скриптов, прототипов, CLI-утилит |
| Приведение типов | match по вариантам напрямую | error.downcast_ref::<MyError>() |
#![allow(unused)]
fn main() {
// thiserror — для БИБЛИОТЕК (вызывающему коду нужно сопоставлять варианты ошибок)
use thiserror::Error;
#[derive(Error, Debug)]
pub enum StorageError {
#[error("File not found: {path}")]
NotFound { path: String },
#[error("Permission denied: {0}")]
PermissionDenied(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
pub fn read_config(path: &str) -> Result<String, StorageError> {
std::fs::read_to_string(path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => StorageError::NotFound { path: path.into() },
std::io::ErrorKind::PermissionDenied => StorageError::PermissionDenied(path.into()),
_ => StorageError::Io(e),
})
}
}
// anyhow — для ПРИЛОЖЕНИЙ (просто пробрасываем ошибки, не определяя типов)
use anyhow::{Context, Result};
fn main() -> Result<()> {
let config = std::fs::read_to_string("config.toml")
.context("Failed to read config file")?;
let port: u16 = config.parse()
.context("Failed to parse port number")?;
println!("Listening on port {port}");
Ok(())
}
// anyhow::Result<T> = Result<T, anyhow::Error>
// .context() добавляет понятный для человека контекст к любой ошибке
// Сравнение с C#:
// thiserror ≈ определение собственных классов исключений с конкретными свойствами
// anyhow ≈ перехват Exception и оборачивание с сообщением:
// throw new InvalidOperationException("Failed to read config", ex);
Правило: если ваш код — библиотека (его вызывает другой код), используйте thiserror. Если ваш код — приложение (финальный бинарник), используйте anyhow. Многие проекты используют оба: thiserror для публичного API библиотечного крейта, anyhow в бинарнике в main().
Паттерны восстановления после ошибок
Разработчики C# привыкли к блокам try/catch, которые восстанавливаются после конкретных исключений. В Rust для того же используются комбинаторы над Result:
#![allow(unused)]
fn main() {
use std::fs;
// Паттерн 1: восстановление через значение по умолчанию
let config = fs::read_to_string("config.toml")
.unwrap_or_else(|_| String::from("port = 8080")); // значение по умолчанию, если файла нет
// Паттерн 2: восстанавливаемся после конкретных ошибок, остальные пробрасываем
fn read_or_create(path: &str) -> Result<String, std::io::Error> {
match fs::read_to_string(path) {
Ok(content) => Ok(content),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let default = String::from("# new file");
fs::write(path, &default)?;
Ok(default)
}
Err(e) => Err(e), // пробрасываем ошибки доступа и прочие
}
}
// Паттерн 3: добавляем контекст перед распространением
use anyhow::Context;
fn load_config() -> anyhow::Result<Config> {
let text = fs::read_to_string("config.toml")
.context("Failed to read config.toml")?;
let config: Config = toml::from_str(&text)
.context("Failed to parse config.toml")?;
Ok(config)
}
// Паттерн 4: преобразуем ошибки в доменный тип
fn parse_port(s: &str) -> Result<u16, AppError> {
s.parse::<u16>()
.map_err(|_| AppError::Validation {
message: format!("Invalid port: {s}"),
})
}
}
// Аналоги в C#:
try { config = File.ReadAllText("config.toml"); }
catch (FileNotFoundException) { config = "port = 8080"; } // Паттерн 1
try { /* ... */ }
catch (FileNotFoundException) { /* создать файл */ } // Паттерн 2
catch { throw; } // остальные пробрасываем
Когда восстанавливаться, а когда пробрасывать:
- Восстанавливайтесь, когда у ошибки есть разумное значение по умолчанию или стратегия повтора
- Пробрасывайте через
?, когда решение должен принимать вызывающий код - Добавляйте контекст (
.context()) на границах модулей, чтобы построить цепочку ошибок
Упражнения
🏋️ Упражнение: спроектируйте тип ошибок крейта (нажмите, чтобы раскрыть)
Вы создаёте сервис регистрации пользователей. Спроектируйте тип ошибок с помощью thiserror:
- Определите
RegistrationErrorс вариантами:DuplicateEmail(String),WeakPassword(String),DatabaseError(#[from] sqlx::Error),RateLimited { retry_after_secs: u64 } - Создайте псевдоним
type Result<T> = std::result::Result<T, RegistrationError>; - Напишите
register_user(email: &str, password: &str) -> Result<()>, которая демонстрирует распространение через?и явное создание ошибок
🔑 Решение
#![allow(unused)]
fn main() {
use thiserror::Error;
#[derive(Error, Debug)]
pub enum RegistrationError {
#[error("Email already registered: {0}")]
DuplicateEmail(String),
#[error("Password too weak: {0}")]
WeakPassword(String),
#[error("Database error")]
Database(#[from] sqlx::Error),
#[error("Rate limited — retry after {retry_after_secs}s")]
RateLimited { retry_after_secs: u64 },
}
pub type Result<T> = std::result::Result<T, RegistrationError>;
pub fn register_user(email: &str, password: &str) -> Result<()> {
if password.len() < 8 {
return Err(RegistrationError::WeakPassword(
"must be at least 8 characters".into(),
));
}
// Этот ? преобразует sqlx::Error → RegistrationError::Database автоматически
// db.check_email_unique(email).await?;
// Это явное создание ошибки для доменной логики
if email.contains("+spam") {
return Err(RegistrationError::DuplicateEmail(email.to_string()));
}
Ok(())
}
}
Ключевой паттерн: #[from] включает ? для ошибок библиотек; явный Err(...) — для доменной логики. Псевдоним Result делает каждую сигнатуру чистой.
10. Трейты и обобщения
Трейты — интерфейсы Rust
Что вы узнаете: трейты против интерфейсов C#, реализации методов по умолчанию, трейт-объекты (
dyn Trait) против ограничений обобщений (impl Trait), производные трейты, распространённые трейты стандартной библиотеки, ассоциированные типы и перегрузку операторов через трейты.Сложность: 🟡 Средний
Трейты — способ Rust описывать общее поведение. Они похожи на интерфейсы C#, но мощнее.
Сравнение с интерфейсами C#
// Определение интерфейса в C#
public interface IAnimal
{
string Name { get; }
void MakeSound();
// Реализация по умолчанию (C# 8+)
string Describe()
{
return $"{Name} makes a sound";
}
}
// Реализация интерфейса в C#
public class Dog : IAnimal
{
public string Name { get; }
public Dog(string name)
{
Name = name;
}
public void MakeSound()
{
Console.WriteLine("Woof!");
}
// Можно переопределить реализацию по умолчанию
public string Describe()
{
return $"{Name} is a loyal dog";
}
}
// Ограничения обобщений
public void ProcessAnimal<T>(T animal) where T : IAnimal
{
animal.MakeSound();
Console.WriteLine(animal.Describe());
}
Определение и реализация трейтов в Rust
// Определение трейта
trait Animal {
fn name(&self) -> &str;
fn make_sound(&self);
// Реализация по умолчанию
fn describe(&self) -> String {
format!("{} makes a sound", self.name())
}
// Реализация по умолчанию, использующая другие методы трейта
fn introduce(&self) {
println!("Hi, I'm {}", self.name());
self.make_sound();
}
}
// Определение структуры
#[derive(Debug)]
struct Dog {
name: String,
breed: String,
}
impl Dog {
fn new(name: String, breed: String) -> Dog {
Dog { name, breed }
}
}
// Реализация трейта
impl Animal for Dog {
fn name(&self) -> &str {
&self.name
}
fn make_sound(&self) {
println!("Woof!");
}
// Переопределяем реализацию по умолчанию
fn describe(&self) -> String {
format!("{} is a loyal {} dog", self.name, self.breed)
}
}
// Ещё одна реализация
#[derive(Debug)]
struct Cat {
name: String,
indoor: bool,
}
impl Animal for Cat {
fn name(&self) -> &str {
&self.name
}
fn make_sound(&self) {
println!("Meow!");
}
// Используем describe() по умолчанию
}
// Обобщённая функция с ограничениями трейтов
fn process_animal<T: Animal>(animal: &T) {
animal.make_sound();
println!("{}", animal.describe());
animal.introduce();
}
// Несколько ограничений трейтов
fn process_animal_debug<T: Animal + std::fmt::Debug>(animal: &T) {
println!("Debug: {:?}", animal);
process_animal(animal);
}
fn main() {
let dog = Dog::new("Buddy".to_string(), "Golden Retriever".to_string());
let cat = Cat { name: "Whiskers".to_string(), indoor: true };
process_animal(&dog);
process_animal(&cat);
process_animal_debug(&dog);
}
Трейт-объекты и динамическая диспетчеризация
// Динамический полиморфизм в C#
public void ProcessAnimals(List<IAnimal> animals)
{
foreach (var animal in animals)
{
animal.MakeSound(); // Динамическая диспетчеризация
Console.WriteLine(animal.Describe());
}
}
// Использование
var animals = new List<IAnimal>
{
new Dog("Buddy"),
new Cat("Whiskers"),
new Dog("Rex")
};
ProcessAnimals(animals);
// Трейт-объекты Rust для динамической диспетчеризации
fn process_animals(animals: &[Box<dyn Animal>]) {
for animal in animals {
animal.make_sound(); // Динамическая диспетчеризация
println!("{}", animal.describe());
}
}
// Альтернатива: использование ссылок
fn process_animal_refs(animals: &[&dyn Animal]) {
for animal in animals {
animal.make_sound();
println!("{}", animal.describe());
}
}
fn main() {
// Использование Box<dyn Trait>
let animals: Vec<Box<dyn Animal>> = vec![
Box::new(Dog::new("Buddy".to_string(), "Golden Retriever".to_string())),
Box::new(Cat { name: "Whiskers".to_string(), indoor: true }),
Box::new(Dog::new("Rex".to_string(), "German Shepherd".to_string())),
];
process_animals(&animals);
// Использование ссылок
let dog = Dog::new("Buddy".to_string(), "Golden Retriever".to_string());
let cat = Cat { name: "Whiskers".to_string(), indoor: true };
let animal_refs: Vec<&dyn Animal> = vec![&dog, &cat];
process_animal_refs(&animal_refs);
}
Производные трейты
// Автоматически выводим распространённые трейты
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Person {
name: String,
age: u32,
}
// Что генерируется (упрощённо):
impl std::fmt::Debug for Person {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Person")
.field("name", &self.name)
.field("age", &self.age)
.finish()
}
}
impl Clone for Person {
fn clone(&self) -> Self {
Person {
name: self.name.clone(),
age: self.age,
}
}
}
impl PartialEq for Person {
fn eq(&self, other: &Self) -> bool {
self.name == other.name && self.age == other.age
}
}
// Использование
fn main() {
let person1 = Person {
name: "Alice".to_string(),
age: 30,
};
let person2 = person1.clone(); // Трейт Clone
println!("{:?}", person1); // Трейт Debug
println!("Equal: {}", person1 == person2); // Трейт PartialEq
}
Распространённые трейты стандартной библиотеки
use std::collections::HashMap;
// Трейт Display для удобного для пользователя вывода
impl std::fmt::Display for Person {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} (age {})", self.name, self.age)
}
}
// Трейт From для преобразований
impl From<(String, u32)> for Person {
fn from((name, age): (String, u32)) -> Self {
Person { name, age }
}
}
// Трейт Into реализуется автоматически, когда реализован From
fn create_person() {
let person: Person = ("Alice".to_string(), 30).into();
println!("{}", person);
}
// Реализация трейта Iterator
struct PersonIterator {
people: Vec<Person>,
index: usize,
}
impl Iterator for PersonIterator {
type Item = Person;
fn next(&mut self) -> Option<Self::Item> {
if self.index < self.people.len() {
let person = self.people[self.index].clone();
self.index += 1;
Some(person)
} else {
None
}
}
}
impl Person {
fn iterator(people: Vec<Person>) -> PersonIterator {
PersonIterator { people, index: 0 }
}
}
fn main() {
let people = vec![
Person::from(("Alice".to_string(), 30)),
Person::from(("Bob".to_string(), 25)),
Person::from(("Charlie".to_string(), 35)),
];
// Используем наш собственный итератор
for person in Person::iterator(people.clone()) {
println!("{}", person); // Использует трейт Display
}
}
🏋️ Упражнение: система рисования на основе трейтов (нажмите, чтобы раскрыть)
Задача: реализуйте трейт Drawable с методом area() и методом draw() по умолчанию. Создайте структуры Circle и Rect. Напишите функцию, которая принимает &[Box<dyn Drawable>] и выводит суммарную площадь.
🔑 Решение
use std::f64::consts::PI;
trait Drawable {
fn area(&self) -> f64;
fn draw(&self) {
println!("Drawing shape with area {:.2}", self.area());
}
}
struct Circle { radius: f64 }
struct Rect { w: f64, h: f64 }
impl Drawable for Circle {
fn area(&self) -> f64 { PI * self.radius * self.radius }
}
impl Drawable for Rect {
fn area(&self) -> f64 { self.w * self.h }
}
fn total_area(shapes: &[Box<dyn Drawable>]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
fn main() {
let shapes: Vec<Box<dyn Drawable>> = vec![
Box::new(Circle { radius: 5.0 }),
Box::new(Rect { w: 4.0, h: 6.0 }),
Box::new(Circle { radius: 2.0 }),
];
for s in &shapes { s.draw(); }
println!("Total area: {:.2}", total_area(&shapes));
}
Ключевые выводы:
dyn Traitдаёт полиморфизм времени выполнения (какIDrawableв C#)Box<dyn Trait>размещается в куче и нужен для коллекций разнородных типов- Методы по умолчанию работают точно так же, как методы интерфейсов по умолчанию в C# 8+
Ассоциированные типы: трейты с типовыми членами
Интерфейсы C# не имеют ассоциированных типов — трейты Rust имеют. Так устроен Iterator:
#![allow(unused)]
fn main() {
// Трейт Iterator имеет ассоциированный тип 'Item'
trait Iterator {
type Item; // Каждая реализация определяет, что такое Item
fn next(&mut self) -> Option<Self::Item>;
}
struct Counter { max: u32, current: u32 }
impl Iterator for Counter {
type Item = u32; // Этот Counter выдаёт значения u32
fn next(&mut self) -> Option<u32> {
if self.current < self.max {
self.current += 1;
Some(self.current)
} else {
None
}
}
}
}
В C# для этой цели IEnumerator<T> использует параметр обобщения (T). Ассоциированные типы Rust устроены иначе: у Iterator один тип Item на каждую реализацию, а не параметр обобщения на уровне трейта. Это упрощает ограничения трейтов: impl Iterator<Item = u32> против IEnumerable<int> в C#.
Перегрузка операторов через трейты
В C# вы определяете public static MyType operator+(MyType a, MyType b). В Rust каждый оператор соответствует трейту из std::ops:
#![allow(unused)]
fn main() {
use std::ops::Add;
#[derive(Debug, Clone, Copy)]
struct Vec2 { x: f64, y: f64 }
impl Add for Vec2 {
type Output = Vec2;
fn add(self, rhs: Vec2) -> Vec2 {
Vec2 { x: self.x + rhs.x, y: self.y + rhs.y }
}
}
let a = Vec2 { x: 1.0, y: 2.0 };
let b = Vec2 { x: 3.0, y: 4.0 };
let c = a + b; // вызывает <Vec2 as Add>::add(a, b)
}
| C# | Rust | Примечания |
|---|---|---|
operator+ | impl Add | self передаётся по значению — для типов, не реализующих Copy, это потребление |
operator== | impl PartialEq | Обычно #[derive(PartialEq)] |
operator< | impl PartialOrd | Обычно #[derive(PartialOrd)] |
ToString() | impl fmt::Display | Используется в println!("{}", x) |
| Неявное преобразование | Аналога нет | В Rust нет неявных преобразований — используйте From/Into |
Согласованность: правило сироты
Трейт можно реализовать, только если вы владеете либо трейтом, либо типом. Это предотвращает конфликтующие реализации между крейтами:
#![allow(unused)]
fn main() {
// ✅ OK — вы владеете MyType
impl Display for MyType { ... }
// ✅ OK — вы владеете MyTrait
impl MyTrait for String { ... }
// ❌ ОШИБКА — вы не владеете ни Display, ни String
impl Display for String { ... }
}
В C# такого ограничения нет — любой код может добавить методы расширения к любому типу, что может привести к неоднозначности.
impl Trait: возврат трейтов без упаковки в Box
Интерфейсы C# всегда можно использовать как возвращаемые типы. В Rust возврат трейта требует выбора: статическая диспетчеризация (impl Trait) или динамическая (dyn Trait).
impl Trait в позиции аргумента (сокращённая запись обобщения)
#![allow(unused)]
fn main() {
// Эти две записи эквивалентны:
fn print_animal(animal: &impl Animal) { animal.make_sound(); }
fn print_animal<T: Animal>(animal: &T) { animal.make_sound(); }
// impl Trait — это просто синтаксический сахар для параметра обобщения
// Компилятор генерирует специализированную копию для каждого конкретного типа (мономорфизация)
}
impl Trait в позиции возвращаемого значения (ключевое отличие)
// Возвращаем итератор, не раскрывая конкретный тип
fn even_squares(limit: u32) -> impl Iterator<Item = u32> {
(0..limit)
.filter(|n| n % 2 == 0)
.map(|n| n * n)
}
// Вызывающий код видит «какой-то тип, реализующий Iterator<Item = u32>»
// Фактический тип (Filter<Map<Range<u32>, ...>>) не имеет имени — impl Trait решает эту проблему
fn main() {
for n in even_squares(20) {
print!("{n} ");
}
// Вывод: 0 4 16 36 64 100 144 196 256 324
}
// C# — возврат интерфейса (всегда динамическая диспетчеризация, объект итератора в куче)
public IEnumerable<int> EvenSquares(int limit) =>
Enumerable.Range(0, limit)
.Where(n => n % 2 == 0)
.Select(n => n * n);
// Тип возвращаемого значения скрывает конкретный итератор за интерфейсом IEnumerable
// В отличие от Box<dyn Trait> в Rust, C# не упаковывает явно — выделением памяти занимается рантайм
Возврат замыканий: impl Fn против Box<dyn Fn>
#![allow(unused)]
fn main() {
// Возвращаем замыкание — тип замыкания назвать нельзя, поэтому impl Fn необходим
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
move |y| x + y
}
let add5 = make_adder(5);
println!("{}", add5(3)); // 8
// Если нужно условно возвращать РАЗНЫЕ замыкания, понадобится Box:
fn choose_op(add: bool) -> Box<dyn Fn(i32, i32) -> i32> {
if add {
Box::new(|a, b| a + b)
} else {
Box::new(|a, b| a * b)
}
}
// impl Trait требует ОДНОГО конкретного типа; разные замыкания — это разные типы
}
// C# — делегаты решают это естественно (всегда выделяются в куче)
Func<int, int> MakeAdder(int x) => y => x + y;
Func<int, int, int> ChooseOp(bool add) => add ? (a, b) => a + b : (a, b) => a * b;
Решение о диспетчеризации: impl Trait против dyn Trait против обобщений
С этим архитектурным решением разработчики C# сталкиваются в Rust сразу. Вот полное руководство:
graph TD
START["Функция принимает или возвращает<br/>тип на основе трейта?"]
POSITION["Позиция аргумента или возврата?"]
ARG_SAME["Все вызывающие передают<br/>один и тот же тип?"]
RET_SINGLE["Всегда возвращается один и тот же<br/>конкретный тип?"]
COLLECTION["Хранится в коллекции<br/>или как поле структуры?"]
GENERIC["Используйте обобщения<br/><code>fn foo<T: Trait>(x: T)</code>"]
IMPL_ARG["Используйте impl Trait<br/><code>fn foo(x: impl Trait)</code>"]
IMPL_RET["Используйте impl Trait<br/><code>fn foo() -> impl Trait</code>"]
DYN_BOX["Используйте Box<dyn Trait><br/>Динамическая диспетчеризация"]
DYN_REF["Используйте &dyn Trait<br/>Динамическая диспетчеризация по ссылке"]
START --> POSITION
POSITION -->|Аргумент| ARG_SAME
POSITION -->|Возврат| RET_SINGLE
ARG_SAME -->|"Да (синтаксический сахар)"| IMPL_ARG
ARG_SAME -->|"Сложные ограничения/несколько использований"| GENERIC
RET_SINGLE -->|Да| IMPL_RET
RET_SINGLE -->|"Нет (условные типы)"| DYN_BOX
RET_SINGLE -->|"Коллекция разнородных типов"| COLLECTION
COLLECTION -->|Владеемая| DYN_BOX
COLLECTION -->|Заимствованная| DYN_REF
style GENERIC fill:#c8e6c9,color:#000
style IMPL_ARG fill:#c8e6c9,color:#000
style IMPL_RET fill:#c8e6c9,color:#000
style DYN_BOX fill:#fff3e0,color:#000
style DYN_REF fill:#fff3e0,color:#000
| Подход | Диспетчеризация | Выделение памяти | Когда использовать |
|---|---|---|---|
fn foo<T: Trait>(x: T) | Статическая (мономорфизация) | Стек | Несколько ограничений трейтов, нужен turbofish, один и тот же тип используется повторно |
fn foo(x: impl Trait) | Статическая (мономорфизация) | Стек | Простые ограничения, более чистый синтаксис, разовые параметры |
fn foo() -> impl Trait | Статическая | Стек | Один конкретный тип возвращаемого значения, итераторы, замыкания |
fn foo() -> Box<dyn Trait> | Динамическая (таблица виртуальных методов) | Куча | Разные типы возвращаемых значений, трейт-объекты в коллекциях |
&dyn Trait / &mut dyn Trait | Динамическая (таблица виртуальных методов) | Без выделения | Заимствованные ссылки на разнородные типы, параметры функций |
#![allow(unused)]
fn main() {
// Сводка: от самого быстрого к самому гибкому
fn static_dispatch(x: impl Display) { /* быстрее всего, без выделения */ }
fn generic_dispatch<T: Display + Clone>(x: T) { /* быстрее всего, несколько ограничений */ }
fn dynamic_dispatch(x: &dyn Display) { /* поиск в таблице виртуальных методов, без выделения */ }
fn boxed_dispatch(x: Box<dyn Display>) { /* поиск в таблице виртуальных методов + выделение в куче */ }
}
Ограничения обобщений
Ограничения обобщений: where и ограничения трейтов
Что вы узнаете: ограничения трейтов Rust в сравнении с ограничениями
whereв C#, синтаксис предложенияwhere, условные реализации трейтов, ассоциированные типы и границы трейтов высокого ранга (HRTB).Сложность: 🔴 Продвинутый
Ограничения обобщений в C#
// Ограничения обобщений в C# через предложение where
public class Repository<T> where T : class, IEntity, new()
{
public T Create()
{
return new T(); // Ограничение new() позволяет конструктор без параметров
}
public void Save(T entity)
{
if (entity.Id == 0) // Ограничение IEntity даёт свойство Id
{
entity.Id = GenerateId();
}
// Сохранение в базу данных
}
}
// Несколько параметров типа с ограничениями
public class Converter<TInput, TOutput>
where TInput : IConvertible
where TOutput : class, new()
{
public TOutput Convert(TInput input)
{
var output = new TOutput();
// Логика преобразования через IConvertible
return output;
}
}
// Вариантность в обобщениях
public interface IRepository<out T> where T : IEntity
{
IEnumerable<T> GetAll(); // Ковариантность — может возвращать более производные типы
}
public interface IWriter<in T> where T : IEntity
{
void Write(T entity); // Контравариантность — может принимать более базовые типы
}
Ограничения обобщений Rust через трейт-границы
#![allow(unused)]
fn main() {
use std::fmt::{Debug, Display};
use std::clone::Clone;
// Базовые трейт-границы
pub struct Repository<T>
where
T: Clone + Debug + Default,
{
items: Vec<T>,
}
impl<T> Repository<T>
where
T: Clone + Debug + Default,
{
pub fn new() -> Self {
Repository { items: Vec::new() }
}
pub fn create(&self) -> T {
T::default() // Трейт Default предоставляет значение по умолчанию
}
pub fn add(&mut self, item: T) {
println!("Adding item: {:?}", item); // Трейт Debug для вывода
self.items.push(item);
}
pub fn get_all(&self) -> Vec<T> {
self.items.clone() // Трейт Clone для копирования
}
}
// Несколько трейт-границ с разным синтаксисом
pub fn process_data<T, U>(input: T) -> U
where
T: Display + Clone,
U: From<T> + Debug,
{
println!("Processing: {}", input); // Трейт Display
let cloned = input.clone(); // Трейт Clone
let output = U::from(cloned); // Трейт From для преобразования
println!("Result: {:?}", output); // Трейт Debug
output
}
// Ассоциированные типы (аналог ограничений обобщений в C#)
pub trait Iterator {
type Item; // Ассоциированный тип вместо параметра обобщения
fn next(&mut self) -> Option<Self::Item>;
}
pub trait Collect<T> {
fn collect<I: Iterator<Item = T>>(iter: I) -> Self;
}
// Границы трейтов высокого ранга (продвинутый уровень)
fn apply_to_all<F>(items: &[String], f: F) -> Vec<String>
where
F: for<'a> Fn(&'a str) -> String, // Функция работает с любым временем жизни
{
items.iter().map(|s| f(s)).collect()
}
// Условные реализации трейтов
impl<T> PartialEq for Repository<T>
where
T: PartialEq + Clone + Debug + Default,
{
fn eq(&self, other: &Self) -> bool {
self.items == other.items
}
}
}
graph TD
subgraph "Ограничения обобщений в C#"
CS_WHERE["where T : class, IInterface, new()"]
CS_RUNTIME["[ERROR] Проверка типов частично во время выполнения<br/>Диспетчеризация виртуальных методов"]
CS_VARIANCE["[OK] Ковариантность/контравариантность<br/>ключевые слова in/out"]
CS_REFLECTION["[ERROR] Возможна рефлексия во время выполнения<br/>операторы typeof(T), is, as"]
CS_BOXING["[ERROR] Упаковка значимых типов<br/>для ограничений интерфейсов"]
CS_WHERE --> CS_RUNTIME
CS_WHERE --> CS_VARIANCE
CS_WHERE --> CS_REFLECTION
CS_WHERE --> CS_BOXING
end
subgraph "Трейт-границы Rust"
RUST_WHERE["where T: Trait + Clone + Debug"]
RUST_COMPILE["[OK] Разрешение на этапе компиляции<br/>Мономорфизация"]
RUST_ZERO["[OK] Абстракции с нулевой стоимостью<br/>Без накладных расходов рантайма"]
RUST_ASSOCIATED["[OK] Ассоциированные типы<br/>Гибче, чем обобщения"]
RUST_HKT["[OK] Границы трейтов высокого ранга<br/>Продвинутые связи между типами"]
RUST_WHERE --> RUST_COMPILE
RUST_WHERE --> RUST_ZERO
RUST_WHERE --> RUST_ASSOCIATED
RUST_WHERE --> RUST_HKT
end
subgraph "Сравнение гибкости"
CS_FLEX["Гибкость C#<br/>[OK] Вариантность<br/>[OK] Информация о типах во время выполнения<br/>[ERROR] Затраты на производительность"]
RUST_FLEX["Гибкость Rust<br/>[OK] Нулевая стоимость<br/>[OK] Безопасность на этапе компиляции<br/>[ERROR] Вариантности пока нет"]
end
style CS_RUNTIME fill:#fff3e0,color:#000
style CS_BOXING fill:#ffcdd2,color:#000
style RUST_COMPILE fill:#c8e6c9,color:#000
style RUST_ZERO fill:#c8e6c9,color:#000
style CS_FLEX fill:#e3f2fd,color:#000
style RUST_FLEX fill:#c8e6c9,color:#000
Упражнения
🏋️ Упражнение: обобщённый репозиторий (нажмите, чтобы раскрыть)
Переведите этот обобщённый интерфейс репозитория C# на трейты Rust:
public interface IRepository<T> where T : IEntity, new()
{
T GetById(int id);
IEnumerable<T> Find(Func<T, bool> predicate);
void Save(T entity);
}
Требования:
- Определите трейт
Entityс методомfn id(&self) -> u64 - Определите трейт
Repository<T>, гдеT: Entity + Clone - Реализуйте
InMemoryRepository<T>, который хранит элементы вVec<T> - Метод
findдолжен приниматьimpl Fn(&T) -> bool
🔑 Решение
trait Entity: Clone {
fn id(&self) -> u64;
}
trait Repository<T: Entity> {
fn get_by_id(&self, id: u64) -> Option<&T>;
fn find(&self, predicate: impl Fn(&T) -> bool) -> Vec<&T>;
fn save(&mut self, entity: T);
}
struct InMemoryRepository<T> {
items: Vec<T>,
}
impl<T: Entity> InMemoryRepository<T> {
fn new() -> Self { Self { items: Vec::new() } }
}
impl<T: Entity> Repository<T> for InMemoryRepository<T> {
fn get_by_id(&self, id: u64) -> Option<&T> {
self.items.iter().find(|item| item.id() == id)
}
fn find(&self, predicate: impl Fn(&T) -> bool) -> Vec<&T> {
self.items.iter().filter(|item| predicate(item)).collect()
}
fn save(&mut self, entity: T) {
if let Some(pos) = self.items.iter().position(|e| e.id() == entity.id()) {
self.items[pos] = entity;
} else {
self.items.push(entity);
}
}
}
#[derive(Clone, Debug)]
struct User { user_id: u64, name: String }
impl Entity for User {
fn id(&self) -> u64 { self.user_id }
}
fn main() {
let mut repo = InMemoryRepository::new();
repo.save(User { user_id: 1, name: "Alice".into() });
repo.save(User { user_id: 2, name: "Bob".into() });
let found = repo.find(|u| u.name.starts_with('A'));
assert_eq!(found.len(), 1);
}
Ключевые отличия от C#: ограничения new() нет (вместо него используется трейт Default). Fn(&T) -> bool заменяет Func<T, bool>. Вместо генерации исключения возвращается Option.
Наследование против композиции
Наследование против композиции
Что вы узнаете: почему в Rust нет наследования классов, как трейты и структуры заменяют глубокие иерархии классов, и практические паттерны для достижения полиморфизма через композицию.
Сложность: 🟡 Средний
// C# — наследование на основе классов
public abstract class Animal
{
public string Name { get; protected set; }
public abstract void MakeSound();
public virtual void Sleep()
{
Console.WriteLine($"{Name} is sleeping");
}
}
public class Dog : Animal
{
public Dog(string name) { Name = name; }
public override void MakeSound()
{
Console.WriteLine("Woof!");
}
public void Fetch()
{
Console.WriteLine($"{Name} is fetching");
}
}
// Контракты на основе интерфейсов
public interface IFlyable
{
void Fly();
}
public class Bird : Animal, IFlyable
{
public Bird(string name) { Name = name; }
public override void MakeSound()
{
Console.WriteLine("Tweet!");
}
public void Fly()
{
Console.WriteLine($"{Name} is flying");
}
}
Модель композиции в Rust
#![allow(unused)]
fn main() {
// Rust — композиция вместо наследования, через трейты
pub trait Animal {
fn name(&self) -> &str;
fn make_sound(&self);
// Реализация по умолчанию (как виртуальные методы в C#)
fn sleep(&self) {
println!("{} is sleeping", self.name());
}
}
pub trait Flyable {
fn fly(&self);
}
// Отделяем данные от поведения
#[derive(Debug)]
pub struct Dog {
name: String,
}
#[derive(Debug)]
pub struct Bird {
name: String,
wingspan: f64,
}
// Реализуем поведение для типов
impl Animal for Dog {
fn name(&self) -> &str {
&self.name
}
fn make_sound(&self) {
println!("Woof!");
}
}
impl Dog {
pub fn new(name: String) -> Self {
Dog { name }
}
pub fn fetch(&self) {
println!("{} is fetching", self.name);
}
}
impl Animal for Bird {
fn name(&self) -> &str {
&self.name
}
fn make_sound(&self) {
println!("Tweet!");
}
}
impl Flyable for Bird {
fn fly(&self) {
println!("{} is flying with {:.1}m wingspan", self.name, self.wingspan);
}
}
// Несколько трейт-границ (как несколько интерфейсов)
fn make_flying_animal_sound<T>(animal: &T)
where
T: Animal + Flyable,
{
animal.make_sound();
animal.fly();
}
}
graph TD
subgraph "Иерархия наследования C#"
CS_ANIMAL["Animal (абстрактный класс)"]
CS_DOG["Dog : Animal"]
CS_BIRD["Bird : Animal, IFlyable"]
CS_VTABLE["Диспетчеризация виртуальных методов<br/>Затраты во время выполнения"]
CS_COUPLING["[ERROR] Жёсткая связанность<br/>[ERROR] Ромбовидная проблема<br/>[ERROR] Глубокие иерархии"]
CS_ANIMAL --> CS_DOG
CS_ANIMAL --> CS_BIRD
CS_DOG --> CS_VTABLE
CS_BIRD --> CS_VTABLE
CS_ANIMAL --> CS_COUPLING
end
subgraph "Модель композиции Rust"
RUST_ANIMAL["trait Animal"]
RUST_FLYABLE["trait Flyable"]
RUST_DOG["struct Dog"]
RUST_BIRD["struct Bird"]
RUST_IMPL1["impl Animal for Dog"]
RUST_IMPL2["impl Animal for Bird"]
RUST_IMPL3["impl Flyable for Bird"]
RUST_STATIC["Статическая диспетчеризация<br/>Нулевая стоимость"]
RUST_FLEXIBLE["[OK] Гибкая композиция<br/>[OK] Нет ограничений иерархии<br/>[OK] Свободное комбинирование трейтов"]
RUST_DOG --> RUST_IMPL1
RUST_BIRD --> RUST_IMPL2
RUST_BIRD --> RUST_IMPL3
RUST_IMPL1 --> RUST_ANIMAL
RUST_IMPL2 --> RUST_ANIMAL
RUST_IMPL3 --> RUST_FLYABLE
RUST_IMPL1 --> RUST_STATIC
RUST_IMPL2 --> RUST_STATIC
RUST_IMPL3 --> RUST_STATIC
RUST_ANIMAL --> RUST_FLEXIBLE
RUST_FLYABLE --> RUST_FLEXIBLE
end
style CS_COUPLING fill:#ffcdd2,color:#000
style RUST_FLEXIBLE fill:#c8e6c9,color:#000
style CS_VTABLE fill:#fff3e0,color:#000
style RUST_STATIC fill:#c8e6c9,color:#000
Упражнения
🏋️ Упражнение: замените наследование трейтами (нажмите, чтобы раскрыть)
Этот C#-код использует наследование. Перепишите его на Rust, используя композицию трейтов:
public abstract class Shape { public abstract double Area(); }
public abstract class Shape3D : Shape { public abstract double Volume(); }
public class Cylinder : Shape3D
{
public double Radius { get; }
public double Height { get; }
public Cylinder(double r, double h) { Radius = r; Height = h; }
public override double Area() => 2.0 * Math.PI * Radius * (Radius + Height);
public override double Volume() => Math.PI * Radius * Radius * Height;
}
Требования:
- Трейт
HasAreaс методомfn area(&self) -> f64 - Трейт
HasVolumeс методомfn volume(&self) -> f64 - Структура
Cylinder, реализующая оба трейта - Функция
fn print_shape_info(shape: &(impl HasArea + HasVolume))— обратите внимание на композицию трейт-границ (наследование не нужно)
🔑 Решение
use std::f64::consts::PI;
trait HasArea {
fn area(&self) -> f64;
}
trait HasVolume {
fn volume(&self) -> f64;
}
struct Cylinder {
radius: f64,
height: f64,
}
impl HasArea for Cylinder {
fn area(&self) -> f64 {
2.0 * PI * self.radius * (self.radius + self.height)
}
}
impl HasVolume for Cylinder {
fn volume(&self) -> f64 {
PI * self.radius * self.radius * self.height
}
}
fn print_shape_info(shape: &(impl HasArea + HasVolume)) {
println!("Area: {:.2}", shape.area());
println!("Volume: {:.2}", shape.volume());
}
fn main() {
let c = Cylinder { radius: 3.0, height: 5.0 };
print_shape_info(&c);
}
Ключевая мысль: C# требует трёхуровневой иерархии (Shape → Shape3D → Cylinder). Rust использует плоскую композицию трейтов — impl HasArea + HasVolume объединяет возможности без глубины наследования.
11. Трейты From и Into
Преобразования типов в Rust
Что вы узнаете: трейты
From/Intoпротив неявных и явных операторов C#,TryFrom/TryIntoдля преобразований, которые могут завершиться ошибкой,FromStrдля разбора строк, а также идиоматичные паттерны преобразования строк.Сложность: 🟡 Средний
В C# используются неявные и явные преобразования и операторы приведения. В Rust для безопасных и явных преобразований применяются трейты From и Into.
Паттерны преобразований в C#
// Неявные и явные преобразования в C#
public class Temperature
{
public double Celsius { get; }
public Temperature(double celsius) { Celsius = celsius; }
// Неявное преобразование
public static implicit operator double(Temperature t) => t.Celsius;
// Явное преобразование
public static explicit operator Temperature(double d) => new Temperature(d);
}
double temp = new Temperature(100.0); // неявно
Temperature t = (Temperature)37.5; // явно
From и Into в Rust
#[derive(Debug)]
struct Temperature {
celsius: f64,
}
impl From<f64> for Temperature {
fn from(celsius: f64) -> Self {
Temperature { celsius }
}
}
impl From<Temperature> for f64 {
fn from(temp: Temperature) -> f64 {
temp.celsius
}
}
fn main() {
// From
let temp = Temperature::from(100.0);
// Into (доступен автоматически, когда реализован From)
let temp2: Temperature = 37.5.into();
// Работает и в аргументах функций
fn process_temp(temp: impl Into<Temperature>) {
let t: Temperature = temp.into();
println!("Temperature: {:.1}°C", t.celsius);
}
process_temp(98.6);
process_temp(Temperature { celsius: 0.0 });
}
graph LR
A["impl From<f64> for Temperature"] -->|"генерирует автоматически"| B["impl Into<Temperature> for f64"]
C["Temperature::from(37.5)"] -->|"явно"| D["Temperature"]
E["37.5.into()"] -->|"неявно через Into"| D
F["fn process(t: impl Into<Temperature>)"] -->|"принимает оба варианта"| D
style A fill:#c8e6c9,color:#000
style B fill:#bbdefb,color:#000
Эмпирическое правило: реализуйте
From— иIntoполучите бесплатно. Вызывающий код может использовать тот вариант, который читается лучше.
TryFrom для преобразований, которые могут завершиться ошибкой
use std::convert::TryFrom;
impl TryFrom<i32> for Temperature {
type Error = String;
fn try_from(value: i32) -> Result<Self, Self::Error> {
if value < -273 {
Err(format!("Temperature {}°C is below absolute zero", value))
} else {
Ok(Temperature { celsius: value as f64 })
}
}
}
fn main() {
match Temperature::try_from(-300) {
Ok(t) => println!("Valid: {:?}", t),
Err(e) => println!("Error: {}", e),
}
}
Преобразования строк
#![allow(unused)]
fn main() {
// ToString через трейт Display
impl std::fmt::Display for Temperature {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:.1}°C", self.celsius)
}
}
// Теперь .to_string() работает автоматически
let s = Temperature::from(100.0).to_string(); // "100.0°C"
// FromStr для разбора
use std::str::FromStr;
impl FromStr for Temperature {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim_end_matches("°C").trim();
let celsius: f64 = s.parse().map_err(|e| format!("Invalid temp: {}", e))?;
Ok(Temperature { celsius })
}
}
let t: Temperature = "100.0°C".parse().unwrap();
}
Упражнения
🏋️ Упражнение: конвертер валют (нажмите, чтобы раскрыть)
Создайте структуру Money, которая демонстрирует всю экосистему преобразований:
Money { cents: i64 }(хранит значение в центах, чтобы избежать проблем с плавающей точкой)- Реализуйте
From<i64>(входное значение — целые доллары →cents = dollars * 100) - Реализуйте
TryFrom<f64>— отвергайте отрицательные суммы, округляйте до ближайшего цента - Реализуйте
Display, чтобы выводить формат"$1.50" - Реализуйте
FromStr, чтобы разбирать"$1.50"или"1.50"обратно вMoney - Напишите функцию
fn total(items: &[impl Into<Money> + Copy]) -> Money, которая суммирует значения
🔑 Решение
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy)]
struct Money { cents: i64 }
impl From<i64> for Money {
fn from(dollars: i64) -> Self {
Money { cents: dollars * 100 }
}
}
impl TryFrom<f64> for Money {
type Error = String;
fn try_from(value: f64) -> Result<Self, Self::Error> {
if value < 0.0 {
Err(format!("negative amount: {value}"))
} else {
Ok(Money { cents: (value * 100.0).round() as i64 })
}
}
}
impl fmt::Display for Money {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "${}.{:02}", self.cents / 100, self.cents.abs() % 100)
}
}
impl FromStr for Money {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim_start_matches('$');
let val: f64 = s.parse().map_err(|e| format!("{e}"))?;
Money::try_from(val)
}
}
fn main() {
let a = Money::from(10); // $10.00
let b = Money::try_from(3.50).unwrap(); // $3.50
let c: Money = "$7.25".parse().unwrap(); // $7.25
println!("{a} + {b} + {c}");
}
12. Замыкания и итераторы
Замыкания в Rust
Что вы узнаете: замыкания с захватом с учётом владения (
Fn/FnMut/FnOnce) в сравнении с лямбдами C#, итераторы Rust как замену LINQ с нулевой стоимостью, ленивые и энергичные вычисления, а также параллельную итерацию сrayon.Сложность: 🟡 Средний
Замыкания в Rust похожи на лямбды и делегаты C#, но захватывают переменные с учётом владения.
Лямбды и делегаты C#
// C# — лямбды захватывают по ссылке
Func<int, int> doubler = x => x * 2;
Action<string> printer = msg => Console.WriteLine(msg);
// Замыкание, захватывающее внешние переменные
int multiplier = 3;
Func<int, int> multiply = x => x * multiplier;
Console.WriteLine(multiply(5)); // 15
// LINQ активно использует лямбды
var evens = numbers.Where(n => n % 2 == 0).ToList();
Замыкания в Rust
#![allow(unused)]
fn main() {
// Замыкания Rust — с учётом владения
let doubler = |x: i32| x * 2;
let printer = |msg: &str| println!("{}", msg);
// Замыкание, захватывающее по ссылке (по умолчанию для неизменяемых)
let multiplier = 3;
let multiply = |x: i32| x * multiplier; // заимствует multiplier
println!("{}", multiply(5)); // 15
println!("{}", multiplier); // по-прежнему доступен
// Замыкание, захватывающее перемещением
let data = vec![1, 2, 3];
let owns_data = move || {
println!("{:?}", data); // data перемещается в замыкание
};
owns_data();
// println!("{:?}", data); // ОШИБКА: data было перемещено
// Использование замыканий с итераторами
let numbers = vec![1, 2, 3, 4, 5];
let evens: Vec<&i32> = numbers.iter().filter(|&&n| n % 2 == 0).collect();
}
Виды замыканий
// Fn — заимствует захваченные значения неизменяемо
fn apply_fn(f: impl Fn(i32) -> i32, x: i32) -> i32 {
f(x)
}
// FnMut — заимствует захваченные значения изменяемо
fn apply_fn_mut(mut f: impl FnMut(i32), values: &[i32]) {
for &v in values {
f(v);
}
}
// FnOnce — забирает владение захваченными значениями
fn apply_fn_once(f: impl FnOnce() -> Vec<i32>) -> Vec<i32> {
f() // можно вызвать только один раз
}
fn main() {
// Пример Fn
let multiplier = 3;
let result = apply_fn(|x| x * multiplier, 5);
// Пример FnMut
let mut sum = 0;
apply_fn_mut(|x| sum += x, &[1, 2, 3, 4, 5]);
println!("Sum: {}", sum); // 15
// Пример FnOnce
let data = vec![1, 2, 3];
let result = apply_fn_once(move || data); // перемещает data
}
LINQ против итераторов Rust
LINQ в C# (Language Integrated Query)
// LINQ в C# — декларативная обработка данных
var numbers = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var result = numbers
.Where(n => n % 2 == 0) // Отбираем чётные числа
.Select(n => n * n) // Возводим в квадрат
.Where(n => n > 10) // Отбираем > 10
.OrderByDescending(n => n) // Сортируем по убыванию
.Take(3) // Берём первые 3
.ToList(); // Материализуем
// LINQ со сложными объектами
var users = GetUsers();
var activeAdults = users
.Where(u => u.IsActive && u.Age >= 18)
.GroupBy(u => u.Department)
.Select(g => new {
Department = g.Key,
Count = g.Count(),
AverageAge = g.Average(u => u.Age)
})
.OrderBy(x => x.Department)
.ToList();
// Асинхронный LINQ (с дополнительными библиотеками)
var results = await users
.ToAsyncEnumerable()
.WhereAwait(async u => await IsActiveAsync(u.Id))
.SelectAwait(async u => await EnrichUserAsync(u))
.ToListAsync();
Итераторы Rust
#![allow(unused)]
fn main() {
// Итераторы Rust — ленивые абстракции с нулевой стоимостью
let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let result: Vec<i32> = numbers
.iter()
.filter(|&&n| n % 2 == 0) // Отбираем чётные числа
.map(|&n| n * n) // Возводим в квадрат
.filter(|&n| n > 10) // Отбираем > 10
.collect::<Vec<_>>() // Собираем в Vec
.into_iter()
.rev() // Меняем порядок обхода
.take(3) // Берём первые 3
.collect(); // Материализуем
// Сложные цепочки итераторов
use std::collections::HashMap;
#[derive(Debug, Clone)]
struct User {
name: String,
age: u32,
department: String,
is_active: bool,
}
fn process_users(users: Vec<User>) -> HashMap<String, (usize, f64)> {
users
.into_iter()
.filter(|u| u.is_active && u.age >= 18)
.fold(HashMap::new(), |mut acc, user| {
let entry = acc.entry(user.department.clone()).or_insert((0, 0.0));
entry.0 += 1; // количество
entry.1 += user.age as f64; // сумма возрастов
acc
})
.into_iter()
.map(|(dept, (count, sum))| (dept, (count, sum / count as f64))) // среднее
.collect()
}
// Параллельная обработка с rayon
use rayon::prelude::*;
fn parallel_processing(numbers: Vec<i32>) -> Vec<i32> {
numbers
.par_iter() // Параллельный итератор
.filter(|&&n| n % 2 == 0)
.map(|&n| expensive_computation(n))
.collect()
}
fn expensive_computation(n: i32) -> i32 {
// Имитация тяжёлых вычислений
(0..1000).fold(n, |acc, _| acc + 1)
}
}
graph TD
subgraph "Особенности LINQ в C#"
CS_LINQ["Выражение LINQ"]
CS_EAGER["Часто энергичные вычисления<br/>(ToList(), ToArray())"]
CS_REFLECTION["[ERROR] Часть рефлексии во время выполнения<br/>Деревья выражений"]
CS_ALLOCATIONS["[ERROR] Промежуточные коллекции<br/>Нагрузка на сборщик мусора"]
CS_ASYNC["[OK] Поддержка async<br/>(с дополнительными библиотеками)"]
CS_SQL["[OK] Интеграция LINQ to SQL/EF"]
CS_LINQ --> CS_EAGER
CS_LINQ --> CS_REFLECTION
CS_LINQ --> CS_ALLOCATIONS
CS_LINQ --> CS_ASYNC
CS_LINQ --> CS_SQL
end
subgraph "Особенности итераторов Rust"
RUST_ITER["Цепочка итераторов"]
RUST_LAZY["[OK] Ленивые вычисления<br/>Никакой работы до .collect()"]
RUST_ZERO["[OK] Абстракции с нулевой стоимостью<br/>Компилируются в оптимальные циклы"]
RUST_NO_ALLOC["[OK] Без промежуточных выделений<br/>Обработка на стеке"]
RUST_PARALLEL["[OK] Простая параллелизация<br/>(крейт rayon)"]
RUST_FUNCTIONAL["[OK] Функциональное программирование<br/>Неизменяемость по умолчанию"]
RUST_ITER --> RUST_LAZY
RUST_ITER --> RUST_ZERO
RUST_ITER --> RUST_NO_ALLOC
RUST_ITER --> RUST_PARALLEL
RUST_ITER --> RUST_FUNCTIONAL
end
subgraph "Сравнение производительности"
CS_PERF["Производительность LINQ в C#<br/>[ERROR] Накладные расходы на выделение памяти<br/>[ERROR] Виртуальная диспетчеризация<br/>[OK] Достаточно для большинства случаев"]
RUST_PERF["Производительность итераторов Rust<br/>[OK] Скорость уровня ручной оптимизации<br/>[OK] Без выделений памяти<br/>[OK] Оптимизация на этапе компиляции"]
end
style CS_REFLECTION fill:#ffcdd2,color:#000
style CS_ALLOCATIONS fill:#fff3e0,color:#000
style RUST_ZERO fill:#c8e6c9,color:#000
style RUST_LAZY fill:#c8e6c9,color:#000
style RUST_NO_ALLOC fill:#c8e6c9,color:#000
style CS_PERF fill:#fff3e0,color:#000
style RUST_PERF fill:#c8e6c9,color:#000
🏋️ Упражнение: перевод из LINQ в итераторы (нажмите, чтобы раскрыть)
Задача: переведите этот конвейер LINQ на C# в идиоматичные итераторы Rust.
// C# — переведите на Rust
record Employee(string Name, string Dept, int Salary);
var result = employees
.Where(e => e.Salary > 50_000)
.GroupBy(e => e.Dept)
.Select(g => new {
Department = g.Key,
Count = g.Count(),
AvgSalary = g.Average(e => e.Salary)
})
.OrderByDescending(x => x.AvgSalary)
.ToList();
🔑 Решение
#![allow(unused)]
fn main() {
use std::collections::HashMap;
struct Employee { name: String, dept: String, salary: u32 }
#[derive(Debug)]
struct DeptStats { department: String, count: usize, avg_salary: f64 }
fn department_stats(employees: &[Employee]) -> Vec<DeptStats> {
let mut by_dept: HashMap<&str, Vec<u32>> = HashMap::new();
for e in employees.iter().filter(|e| e.salary > 50_000) {
by_dept.entry(&e.dept).or_default().push(e.salary);
}
let mut stats: Vec<DeptStats> = by_dept
.into_iter()
.map(|(dept, salaries)| {
let count = salaries.len();
let avg = salaries.iter().sum::<u32>() as f64 / count as f64;
DeptStats { department: dept.to_string(), count, avg_salary: avg }
})
.collect();
stats.sort_by(|a, b| b.avg_salary.partial_cmp(&a.avg_salary).unwrap());
stats
}
}
Ключевые выводы:
- В Rust нет встроенного
group_byдля итераторов — идиоматичный паттерн этоHashMap+fold/for - Крейт
itertoolsдобавляет.group_by()для синтаксиса, более похожего на LINQ - Цепочки итераторов не имеют накладных расходов — компилятор превращает их в простые циклы
itertools: недостающие операции LINQ
Стандартные итераторы Rust покрывают map, filter, fold, take и collect. Но разработчики C#, привыкшие к GroupBy, Zip, Chunk, SelectMany и Distinct, сразу заметят пробелы. Крейт itertools их восполняет.
# Cargo.toml
[dependencies]
itertools = "0.12"
Бок о бок: LINQ и itertools
// C# — GroupBy
var byDept = employees.GroupBy(e => e.Department)
.Select(g => new { Dept = g.Key, Count = g.Count() });
// C# — Chunk (пакетирование)
var batches = items.Chunk(100); // IEnumerable<T[]>
// C# — Distinct / DistinctBy
var unique = users.DistinctBy(u => u.Email);
// C# — SelectMany (уплощение)
var allTags = posts.SelectMany(p => p.Tags);
// C# — Zip
var pairs = names.Zip(scores, (n, s) => new { Name = n, Score = s });
// C# — скользящее окно
var windows = data.Zip(data.Skip(1), data.Skip(2))
.Select(triple => (triple.First + triple.Second + triple.Third) / 3.0);
#![allow(unused)]
fn main() {
use itertools::Itertools;
// Rust — group_by (требует отсортированный ввод)
let by_dept = employees.iter()
.sorted_by_key(|e| &e.department)
.group_by(|e| &e.department);
for (dept, group) in &by_dept {
println!("{}: {} employees", dept, group.count());
}
// Rust — chunks (пакетирование)
let batches = items.iter().chunks(100);
for batch in &batches {
process_batch(batch.collect::<Vec<_>>());
}
// Rust — unique / unique_by
let unique: Vec<_> = users.iter().unique_by(|u| &u.email).collect();
// Rust — flat_map (аналог SelectMany — встроен в std!)
let all_tags: Vec<&str> = posts.iter().flat_map(|p| &p.tags).collect();
// Rust — zip (встроен в std!)
let pairs: Vec<_> = names.iter().zip(scores.iter()).collect();
// Rust — tuple_windows (скользящее окно)
let moving_avg: Vec<f64> = data.iter()
.tuple_windows::<(_, _, _)>()
.map(|(a, b, c)| (*a + *b + *c) as f64 / 3.0)
.collect();
}
Краткая справка по itertools
| Метод LINQ | Аналог в itertools | Примечания |
|---|---|---|
GroupBy(key) | .sorted_by_key().group_by() | Требует отсортированный ввод (в отличие от LINQ) |
Chunk(n) | .chunks(n) | Возвращает итератор итераторов |
Distinct() | .unique() | Требует Eq + Hash |
DistinctBy(key) | .unique_by(key) | |
SelectMany() | .flat_map() | Встроен в std — крейт не нужен |
Zip() | .zip() | Встроен в std |
Aggregate() | .fold() | Встроен в std |
Any() / All() | .any() / .all() | Встроены в std |
First() / Last() | .next() / .last() | Встроены в std |
Skip(n) / Take(n) | .skip(n) / .take(n) | Встроены в std |
OrderBy() | .sorted() / .sorted_by() | itertools (в std сортировки нет) |
ThenBy() | .sorted_by(|a,b| a.x.cmp(&b.x).then(a.y.cmp(&b.y))) | Цепочка через Ordering::then |
Intersect() | Пересечение HashSet | Прямого метода итератора нет |
Concat() | .chain() | Встроен в std |
| Скользящее окно | .tuple_windows() | Кортежи фиксированного размера |
| Декартово произведение | .cartesian_product() | itertools |
| Чередование | .interleave() | itertools |
| Перестановки | .permutations(k) | itertools |
Пример из практики: конвейер анализа логов
#![allow(unused)]
fn main() {
use itertools::Itertools;
use std::collections::HashMap;
#[derive(Debug)]
struct LogEntry { level: String, module: String, message: String }
fn analyze_logs(entries: &[LogEntry]) {
// Топ-5 самых «шумных» модулей (как LINQ GroupBy + OrderByDescending + Take)
let noisy: Vec<_> = entries.iter()
.into_group_map_by(|e| &e.module) // itertools: сразу группируем в HashMap
.into_iter()
.sorted_by(|a, b| b.1.len().cmp(&a.1.len()))
.take(5)
.collect();
for (module, entries) in &noisy {
println!("{}: {} entries", module, entries.len());
}
// Доля ошибок в окне из 100 записей (скользящее окно)
let error_rates: Vec<f64> = entries.iter()
.map(|e| if e.level == "ERROR" { 1.0 } else { 0.0 })
.collect::<Vec<_>>()
.windows(100) // метод среза из std
.map(|w| w.iter().sum::<f64>() / 100.0)
.collect();
// Удаляем подряд идущие одинаковые сообщения
let deduped: Vec<_> = entries.iter().dedup_by(|a, b| a.message == b.message).collect();
println!("Deduped {} → {} entries", entries.len(), deduped.len());
}
}
Введение в макросы
Макросы: код, который пишет код
Что вы узнаете: зачем Rust нужны макросы (нет перегрузки и вариативных аргументов), основы
macro_rules!, соглашение о суффиксе!, распространённые производные макросы иdbg!()для быстрой отладки.Сложность: 🟡 Средний
В C# нет прямого аналога макросам Rust. Понимание того, зачем они нужны и как работают, устраняет крупный источник путаницы для разработчиков C#.
Зачем в Rust нужны макросы
graph LR
SRC["vec![1, 2, 3]"] -->|"на этапе компиляции"| EXP["{
let mut v = Vec::new();
v.push(1);
v.push(2);
v.push(3);
v
}"]
EXP -->|"компилируется в"| BIN["машинный код"]
style SRC fill:#fff9c4,color:#000
style EXP fill:#c8e6c9,color:#000
// В C# есть возможности, которые делают макросы ненужными:
Console.WriteLine("Hello"); // Перегрузка методов (1–16 параметров)
Console.WriteLine("{0}, {1}", a, b); // Вариативность через массив params
var list = new List<int> { 1, 2, 3 }; // Синтаксис инициализатора коллекции
#![allow(unused)]
fn main() {
// В Rust НЕТ перегрузки функций, НЕТ вариативных аргументов, НЕТ особого синтаксиса.
// Макросы закрывают эти пробелы:
println!("Hello"); // Макрос — принимает 0 и более аргументов на этапе компиляции
println!("{}, {}", a, b); // Макрос — типы проверяются на этапе компиляции
let list = vec![1, 2, 3]; // Макрос — раскрывается в Vec::new() + push()
}
Распознавание макросов: суффикс !
Каждый вызов макроса заканчивается на !. Если вы видите !, это макрос, а не функция:
#![allow(unused)]
fn main() {
println!("hello"); // макрос — генерирует код форматирования на этапе компиляции
format!("{x}"); // макрос — возвращает String, проверка формата на этапе компиляции
vec![1, 2, 3]; // макрос — создаёт и заполняет Vec
todo!(); // макрос — паникует с сообщением "not yet implemented"
dbg!(expression); // макрос — выводит файл:строку + выражение + значение, возвращает значение
assert_eq!(a, b); // макрос — паникует с дифом, если a ≠ b
cfg!(target_os = "linux"); // макрос — определение платформы на этапе компиляции
}
Простой макрос на macro_rules!
// Определяем макрос, который создаёт HashMap из пар ключ-значение
macro_rules! hashmap {
// Шаблон: пары ключ => значение, разделённые запятыми
( $( $key:expr => $value:expr ),* $(,)? ) => {{
let mut map = std::collections::HashMap::new();
$( map.insert($key, $value); )*
map
}};
}
fn main() {
let scores = hashmap! {
"Alice" => 100,
"Bob" => 85,
"Carol" => 92,
};
println!("{scores:?}");
}
Производные макросы: автоматическая реализация трейтов
#![allow(unused)]
fn main() {
// #[derive] — это процедурный макрос, который генерирует реализации трейтов
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct User {
name: String,
age: u32,
}
// Компилятор автоматически генерирует Debug::fmt, Clone::clone, PartialEq::eq и т. д.,
// анализируя поля структуры.
}
// Аналога в C# нет — пришлось бы вручную реализовывать IEquatable, ICloneable и т. д.
// Или использовать records: public record User(string Name, int Age);
// Records автоматически генерируют Equals, GetHashCode, ToString — похожая идея!
Распространённые производные макросы
| Derive | Назначение | Аналог в C# |
|---|---|---|
Debug | Вывод в формате {:?} | Переопределение ToString() |
Clone | Глубокое копирование через .clone() | ICloneable |
Copy | Неявное побитовое копирование (.clone() не нужен) | Семантика значимого типа (struct) |
PartialEq, Eq | Сравнение == | IEquatable<T> |
PartialOrd, Ord | Сравнение <, > и сортировка | IComparable<T> |
Hash | Хеширование для ключей HashMap | GetHashCode() |
Default | Значения по умолчанию через Default::default() | Конструктор без параметров |
Serialize, Deserialize | JSON/TOML и т. д. (serde) | Атрибуты [JsonProperty] |
Эмпирическое правило: начинайте с
#[derive(Debug)]на каждом типе. ДобавляйтеClone,PartialEq, когда это нужно. ДобавляйтеSerialize, Deserializeдля любого типа, который пересекает границу (API, файл, база данных).
Процедурные и атрибутные макросы (на уровне понимания)
Производные макросы — это один из видов процедурных макросов: кода, который выполняется на этапе компиляции и генерирует код. Вы встретите ещё две формы:
Атрибутные макросы — присоединяются к элементам через #[...]:
#[tokio::main] // превращает main() в точку входа асинхронного рантайма
async fn main() { }
#[test] // помечает функцию как модульный тест
fn it_works() { assert_eq!(2 + 2, 4); }
#[cfg(test)] // условно компилирует этот модуль только во время тестирования
mod tests { /* ... */ }
Функциональные макросы — выглядят как вызовы функций:
#![allow(unused)]
fn main() {
// sqlx::query! проверяет ваш SQL по базе данных на этапе компиляции
let users = sqlx::query!("SELECT id, name FROM users WHERE active = $1", true)
.fetch_all(&pool)
.await?;
}
Ключевая мысль для разработчиков C#: процедурные макросы вы редко пишете — это инструмент для авторов библиотек продвинутого уровня. Но вы используете их постоянно (
#[derive(...)],#[tokio::main],#[test]). Думайте о них как об источниках генерации кода (source generators) в C#: вы получаете выгоду, не реализуя их сами.
Условная компиляция через #[cfg]
Атрибуты #[cfg] в Rust похожи на директивы препроцессора #if DEBUG в C#, но с проверкой типов:
#![allow(unused)]
fn main() {
// Компилируем эту функцию только для Linux
#[cfg(target_os = "linux")]
fn platform_specific() {
println!("Running on Linux");
}
// Проверки только для отладки (как Debug.Assert в C#)
#[cfg(debug_assertions)]
fn expensive_check(data: &[u8]) {
assert!(data.len() < 1_000_000, "data unexpectedly large");
}
// Feature-флаги (как #if FEATURE_X в C#, но объявляются в Cargo.toml)
#[cfg(feature = "json")]
pub fn to_json<T: Serialize>(val: &T) -> String {
serde_json::to_string(val).unwrap()
}
}
// Аналог в C#
#if DEBUG
Debug.Assert(data.Length < 1_000_000);
#endif
dbg!() — ваш лучший друг при отладке
#![allow(unused)]
fn main() {
fn calculate(x: i32) -> i32 {
let intermediate = dbg!(x * 2); // выводит: [src/main.rs:3] x * 2 = 10
let result = dbg!(intermediate + 1); // выводит: [src/main.rs:4] intermediate + 1 = 11
result
}
// dbg! пишет в stderr, включает файл:строку и возвращает значение
// Гораздо полезнее, чем Console.WriteLine для отладки!
}
🏋️ Упражнение: напишите макрос min! (нажмите, чтобы раскрыть)
Задача: напишите макрос min!, который принимает два или более аргументов и возвращает наименьший.
#![allow(unused)]
fn main() {
// Должно работать так:
let smallest = min!(5, 3, 8, 1, 4); // → 1
let pair = min!(10, 20); // → 10
}
🔑 Решение
macro_rules! min {
// Базовый случай: одно значение
($x:expr) => ($x);
// Рекурсивно: сравниваем первое со минимумом остальных
($x:expr, $($rest:expr),+) => {{
let first = $x;
let rest = min!($($rest),+);
if first < rest { first } else { rest }
}};
}
fn main() {
assert_eq!(min!(5, 3, 8, 1, 4), 1);
assert_eq!(min!(10, 20), 10);
assert_eq!(min!(42), 42);
println!("All assertions passed!");
}
Ключевая мысль: macro_rules! выполняет сопоставление с образцом по деревьям токенов — это как match, только для структуры кода, а не значений.
13. Конкурентность
Потокобезопасность: соглашение против гарантий системы типов
Что вы узнаете: как Rust обеспечивает потокобезопасность на этапе компиляции в отличие от подхода C# на основе соглашений,
Arc<Mutex<T>>противlock, каналы противConcurrentQueue, трейтыSend/Sync, ограниченные по области потоки и переход к async/await.Сложность: 🔴 Продвинутый
Глубокое погружение: о производственных асинхронных паттернах (обработка потоков, корректное завершение, пулы соединений, безопасность отмены) см. сопутствующее руководство Async Rust Training.
Предварительные требования: Владение и заимствование и Умные указатели (дерево решений Rc против Arc).
C# — потокобезопасность по соглашению
// Коллекции в C# по умолчанию не потокобезопасны
public class UserService
{
private readonly List<string> items = new();
private readonly Dictionary<int, User> cache = new();
// Это может привести к гонкам данных:
public void AddItem(string item)
{
items.Add(item); // Не потокобезопасно!
}
// Приходится вручную использовать блокировки:
private readonly object lockObject = new();
public void SafeAddItem(string item)
{
lock (lockObject)
{
items.Add(item); // Безопасно, но есть накладные расходы рантайма
}
// Легко забыть о блокировке в другом месте
}
// ConcurrentCollection помогает, но возможности ограничены
private readonly ConcurrentBag<string> safeItems = new();
public void ConcurrentAdd(string item)
{
safeItems.Add(item); // Потокобезопасно, но операции ограничены
}
// Сложное управление разделяемым состоянием
private readonly ConcurrentDictionary<int, User> threadSafeCache = new();
private volatile bool isShutdown = false;
public async Task ProcessUser(int userId)
{
if (isShutdown) return; // Возможна гонка!
var user = await GetUser(userId);
threadSafeCache.TryAdd(userId, user); // Нужно помнить, какие коллекции безопасны
}
// Локальное для потока хранилище требует аккуратного управления
private static readonly ThreadLocal<Random> threadLocalRandom =
new ThreadLocal<Random>(() => new Random());
public int GetRandomNumber()
{
return threadLocalRandom.Value.Next(); // Безопасно, но управление вручную
}
}
// Обработка событий с возможными гонками
public class EventProcessor
{
public event Action<string> DataReceived;
private readonly List<string> eventLog = new();
public void OnDataReceived(string data)
{
// Гонка: обработчик может стать null между проверкой и вызовом
if (DataReceived != null)
{
DataReceived(data);
}
// Современный C# (6+) смягчает гонку на null через: DataReceived?.Invoke(data);
// но модель событий-делегатов по-прежнему допускает гонки на списке ниже
// Ещё одна гонка — список не потокобезопасен
eventLog.Add($"Processed: {data}");
}
}
Rust — потокобезопасность гарантируется системой типов
#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex, RwLock};
use std::thread;
use std::collections::HashMap;
use tokio::sync::{mpsc, broadcast};
// Rust предотвращает гонки данных на этапе компиляции
pub struct UserService {
items: Arc<Mutex<Vec<String>>>,
cache: Arc<RwLock<HashMap<i32, User>>>,
}
impl UserService {
pub fn new() -> Self {
UserService {
items: Arc::new(Mutex::new(Vec::new())),
cache: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn add_item(&self, item: String) {
let mut items = self.items.lock().unwrap();
items.push(item);
// Блокировка автоматически освобождается, когда `items` выходит из области видимости
}
// Несколько читателей, один писатель — обеспечивается автоматически
pub async fn get_user(&self, user_id: i32) -> Option<User> {
let cache = self.cache.read().unwrap();
cache.get(&user_id).cloned()
}
pub async fn cache_user(&self, user_id: i32, user: User) {
let mut cache = self.cache.write().unwrap();
cache.insert(user_id, user);
}
// Клонируем Arc для совместного использования между потоками
pub fn process_in_background(&self) {
let items = Arc::clone(&self.items);
thread::spawn(move || {
let items = items.lock().unwrap();
for item in items.iter() {
println!("Processing: {}", item);
}
});
}
}
// Взаимодействие через каналы — разделяемое состояние не нужно
pub struct MessageProcessor {
sender: mpsc::UnboundedSender<String>,
}
impl MessageProcessor {
pub fn new() -> (Self, mpsc::UnboundedReceiver<String>) {
let (tx, rx) = mpsc::unbounded_channel();
(MessageProcessor { sender: tx }, rx)
}
pub fn send_message(&self, message: String) -> Result<(), mpsc::error::SendError<String>> {
self.sender.send(message)
}
}
// Этот код не скомпилируется — Rust не даёт небезопасно разделять изменяемые данные:
fn impossible_data_race() {
let mut items = vec![1, 2, 3];
// Не скомпилируется — нельзя переместить `items` в несколько замыканий
/*
thread::spawn(move || {
items.push(4); // ОШИБКА: использование перемещённого значения
});
thread::spawn(move || {
items.push(5); // ОШИБКА: использование перемещённого значения
});
*/
}
// Безопасная параллельная обработка данных
use rayon::prelude::*;
fn parallel_processing() {
let data = vec![1, 2, 3, 4, 5];
// Параллельная итерация — потокобезопасность гарантирована
let results: Vec<i32> = data
.par_iter()
.map(|&x| x * x)
.collect();
println!("{:?}", results);
}
// Асинхронная конкурентность с передачей сообщений
async fn async_message_passing() {
let (tx, mut rx) = mpsc::channel(100);
// Задача-производитель
let producer = tokio::spawn(async move {
for i in 0..10 {
if tx.send(i).await.is_err() {
break;
}
}
});
// Задача-потребитель
let consumer = tokio::spawn(async move {
while let Some(value) = rx.recv().await {
println!("Received: {}", value);
}
});
// Ждём обе задачи
let (producer_result, consumer_result) = tokio::join!(producer, consumer);
producer_result.unwrap();
consumer_result.unwrap();
}
#[derive(Clone)]
struct User {
id: i32,
name: String,
}
}
graph TD
subgraph "Проблемы потокобезопасности в C#"
CS_MANUAL["Ручная синхронизация"]
CS_LOCKS["Инструкции lock"]
CS_CONCURRENT["Потокобезопасные коллекции"]
CS_VOLATILE["Поля volatile"]
CS_FORGET["😰 Легко забыть блокировки"]
CS_DEADLOCK["💀 Возможны взаимные блокировки"]
CS_RACE["🏃 Гонки данных"]
CS_OVERHEAD["⚡ Накладные расходы рантайма"]
CS_MANUAL --> CS_LOCKS
CS_MANUAL --> CS_CONCURRENT
CS_MANUAL --> CS_VOLATILE
CS_LOCKS --> CS_FORGET
CS_LOCKS --> CS_DEADLOCK
CS_FORGET --> CS_RACE
CS_LOCKS --> CS_OVERHEAD
end
subgraph "Гарантии системы типов Rust"
RUST_OWNERSHIP["Система владения"]
RUST_BORROWING["Проверщик заимствований"]
RUST_SEND["Трейт Send"]
RUST_SYNC["Трейт Sync"]
RUST_ARC["Arc<Mutex<T>>"]
RUST_CHANNELS["Передача сообщений"]
RUST_SAFE["✅ Гонки данных невозможны"]
RUST_FAST["⚡ Абстракции с нулевой стоимостью"]
RUST_OWNERSHIP --> RUST_BORROWING
RUST_BORROWING --> RUST_SEND
RUST_SEND --> RUST_SYNC
RUST_SYNC --> RUST_ARC
RUST_ARC --> RUST_CHANNELS
RUST_CHANNELS --> RUST_SAFE
RUST_SAFE --> RUST_FAST
end
style CS_FORGET fill:#ffcdd2,color:#000
style CS_DEADLOCK fill:#ffcdd2,color:#000
style CS_RACE fill:#ffcdd2,color:#000
style RUST_SAFE fill:#c8e6c9,color:#000
style RUST_FAST fill:#c8e6c9,color:#000
🏋️ Упражнение: потокобезопасный счётчик (нажмите, чтобы раскрыть)
Задача: реализуйте потокобезопасный счётчик, который могут одновременно инкрементировать 10 потоков. Каждый поток увеличивает его 1000 раз. Итоговое значение должно быть ровно 10 000.
🔑 Решение
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0u64));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
let mut count = counter.lock().unwrap();
*count += 1;
}
}));
}
for h in handles { h.join().unwrap(); }
assert_eq!(*counter.lock().unwrap(), 10_000);
println!("Final count: {}", counter.lock().unwrap());
}
Или с атомарными типами (быстрее, без блокировок):
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
fn main() {
let counter = Arc::new(AtomicU64::new(0));
let handles: Vec<_> = (0..10).map(|_| {
let counter = Arc::clone(&counter);
thread::spawn(move || {
for _ in 0..1000 {
counter.fetch_add(1, Ordering::Relaxed);
}
})
}).collect();
for h in handles { h.join().unwrap(); }
assert_eq!(counter.load(Ordering::SeqCst), 10_000);
}
Ключевая мысль: Arc<Mutex<T>> — общий паттерн. Для простых счётчиков AtomicU64 полностью избавляет от накладных расходов на блокировки.
Почему Rust предотвращает гонки данных: Send и Sync
Rust использует два маркерных трейта, чтобы обеспечивать потокобезопасность на этапе компиляции — аналога в C# нет:
Send: тип можно безопасно передать в другой поток (например, переместить в замыкание, переданное вthread::spawn)Sync: тип можно безопасно разделять (через&T) между потоками
Большинство типов автоматически реализуют Send + Sync. Заметные исключения:
Rc<T>не реализует ни Send, ни Sync — компилятор не даст передать его вthread::spawn(используйтеArc<T>)Cell<T>иRefCell<T>не реализуют Sync — для потокобезопасной внутренней изменяемости используйтеMutex<T>илиRwLock<T>- Сырые указатели (
*const T,*mut T) не реализуют ни Send, ни Sync
В C# List<T> не потокобезопасен, но компилятор не помешает разделить его между потоками. В Rust такая же ошибка — это ошибка компиляции, а не гонка во время выполнения.
Ограниченные по области потоки: заимствование из стека
thread::scope() позволяет порождённым потокам заимствовать локальные переменные — Arc не нужен:
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4, 5];
// Потоки в области видимости могут заимствовать 'data' — scope ждёт завершения всех потоков
thread::scope(|s| {
s.spawn(|| println!("Thread 1: {data:?}"));
s.spawn(|| println!("Thread 2: sum = {}", data.iter().sum::<i32>()));
});
// 'data' здесь по-прежнему действителен — потоки гарантированно завершились
}
Это похоже на Parallel.ForEach в C# тем, что вызывающий код ждёт завершения, но проверщик заимствований Rust доказывает отсутствие гонок данных на этапе компиляции.
Переход к async/await
Разработчики C# обычно используют Task и async/await, а не потоки напрямую. В Rust есть обе парадигмы:
| C# | Rust | Когда использовать |
|---|---|---|
Thread | std::thread::spawn | Задачи, ограниченные CPU; один поток ОС на задачу |
Task.Run | tokio::spawn | Асинхронная задача в рантайме |
async/await | async/await | Конкурентность, ограниченная вводом-выводом |
lock | Mutex<T> | Синхронная взаимная блокировка |
SemaphoreSlim | tokio::sync::Semaphore | Ограничение асинхронной конкурентности |
Interlocked | std::sync::atomic | Атомарные операции без блокировок |
CancellationToken | tokio_util::sync::CancellationToken | Кооперативная отмена |
Следующая глава (Глубокое погружение в async/await) подробно описывает асинхронную модель Rust — в том числе то, чем она отличается от модели C# на основе
Task.
Глубокое погружение в async/await
Асинхронное программирование: Task в C# против Future в Rust
Что вы узнаете: ленивый
FutureRust против энергичногоTaskв C#, модель исполнителя (tokio), отмену черезDrop+select!противCancellationToken, а также практические паттерны для конкурентных запросов.Сложность: 🔴 Продвинутый
Разработчики C# хорошо знакомы с async/await. Rust использует те же ключевые слова, но модель выполнения принципиально иная.
Модель исполнителя
// C# — рантайм предоставляет встроенный пул потоков и планировщик задач
// async/await «просто работает» из коробки
public async Task<string> FetchDataAsync(string url)
{
using var client = new HttpClient();
return await client.GetStringAsync(url); // Планируется пулом потоков .NET
}
// .NET управляет пулом потоков, планированием задач и контекстом синхронизации
// Rust — встроенного асинхронного рантайма нет. Исполнитель выбираете вы.
// Самый популярный — tokio.
async fn fetch_data(url: &str) -> Result<String, reqwest::Error> {
let body = reqwest::get(url).await?.text().await?;
Ok(body)
}
// Чтобы выполнить асинхронный код, нужен рантайм:
#[tokio::main] // Этот макрос настраивает рантайм tokio
async fn main() {
let data = fetch_data("https://example.com").await.unwrap();
println!("{}", &data[..100]);
}
Future против Task
C# Task<T> | Rust Future<Output = T> | |
|---|---|---|
| Выполнение | Запускается сразу при создании | Ленивый — ничего не делает, пока его не await-ят |
| Рантайм | Встроенный (пул потоков CLR) | Внешний (tokio, async-std и т. д.) |
| Отмена | CancellationToken | Уничтожить Future (или tokio::select!) |
| Конечный автомат | Генерируется компилятором | Генерируется компилятором |
| Размещение | В куче | На стеке, пока не упаковано в Box |
#![allow(unused)]
fn main() {
// ВАЖНО: Future в Rust ленивые!
async fn compute() -> i32 { println!("Computing!"); 42 }
let future = compute(); // Ничего не выведено! Future ещё не опрошен.
let result = future.await; // ТЕПЕРЬ выводится "Computing!"
}
// Task в C# запускаются сразу!
var task = ComputeAsync(); // "Computing!" выводится немедленно
var result = await task; // Просто ждёт завершения
Отмена: CancellationToken против Drop / select!
// C# — кооперативная отмена через CancellationToken
public async Task ProcessAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(1000, ct); // Бросает исключение, если отменено
DoWork();
}
}
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await ProcessAsync(cts.Token);
#![allow(unused)]
fn main() {
// Rust — отмена через уничтожение future или через tokio::select!
use tokio::time::{sleep, Duration};
async fn process() {
loop {
sleep(Duration::from_secs(1)).await;
do_work();
}
}
// Паттерн таймаута с select!
async fn run_with_timeout() {
tokio::select! {
_ = process() => { println!("Completed"); }
_ = sleep(Duration::from_secs(5)) => { println!("Timed out!"); }
}
// Когда select! выбирает ветку таймаута, future process() УНИЧТОЖАЕТСЯ
// — автоматическая очистка, CancellationToken не нужен
}
}
Практический паттерн: конкурентные запросы с таймаутом
// C# — конкурентные HTTP-запросы с таймаутом
public async Task<string[]> FetchAllAsync(string[] urls, CancellationToken ct)
{
var tasks = urls.Select(url => httpClient.GetStringAsync(url, ct));
return await Task.WhenAll(tasks);
}
#![allow(unused)]
fn main() {
// Rust — конкурентные запросы через tokio::join! или futures::join_all
use futures::future::join_all;
async fn fetch_all(urls: &[&str]) -> Vec<Result<String, reqwest::Error>> {
let futures = urls.iter().map(|url| reqwest::get(*url));
let responses = join_all(futures).await;
let mut results = Vec::new();
for resp in responses {
results.push(resp?.text().await);
}
results
}
// С таймаутом:
async fn fetch_all_with_timeout(urls: &[&str]) -> Result<Vec<String>, &'static str> {
tokio::time::timeout(
Duration::from_secs(10),
async {
let futures: Vec<_> = urls.iter()
.map(|url| async { reqwest::get(*url).await?.text().await })
.collect();
let results = join_all(futures).await;
results.into_iter().collect::<Result<Vec<_>, _>>()
}
)
.await
.map_err(|_| "Request timed out")?
.map_err(|_| "Request failed")
}
}
🏋️ Упражнение: паттерн асинхронного таймаута (нажмите, чтобы раскрыть)
Задача: напишите асинхронную функцию, которая запрашивает два URL конкурентно, возвращает тот результат, который пришёл первым, а второй запрос отменяет. (В C# это Task.WhenAny.)
🔑 Решение
use tokio::time::{sleep, Duration};
// Имитация асинхронного запроса
async fn fetch(url: &str, delay_ms: u64) -> String {
sleep(Duration::from_millis(delay_ms)).await;
format!("Response from {url}")
}
async fn fetch_first(url1: &str, url2: &str) -> String {
tokio::select! {
result = fetch(url1, 200) => {
println!("URL 1 won");
result
}
result = fetch(url2, 500) => {
println!("URL 2 won");
result
}
}
// Future проигравшей ветки автоматически уничтожается (отменяется)
}
#[tokio::main]
async fn main() {
let result = fetch_first("https://fast.api", "https://slow.api").await;
println!("{result}");
}
Ключевая мысль: tokio::select! — это аналог Task.WhenAny в Rust. Он гонит несколько future, завершается, когда первое из них готово, и уничтожает (отменяет) остальные.
Запуск независимых задач через tokio::spawn
В C# Task.Run запускает работу, которая выполняется независимо от вызывающего кода. Аналог в Rust — tokio::spawn:
#![allow(unused)]
fn main() {
use tokio::task;
async fn background_work() {
// Выполняется независимо — даже если future вызывающего кода уничтожен
let handle = task::spawn(async {
tokio::time::sleep(Duration::from_secs(2)).await;
42
});
// Делаем другую работу, пока порождённая задача выполняется...
println!("Doing other work");
// Ждём результат, когда он понадобится
let result = handle.await.unwrap(); // 42
}
}
// Аналог в C#
var task = Task.Run(async () => {
await Task.Delay(2000);
return 42;
});
// Делаем другую работу...
var result = await task;
Ключевое отличие: обычный блок async {} ленивый — он ничего не делает, пока его не await-ят. tokio::spawn сразу запускает его в рантайме, как Task.Run в C#.
Pin: понятие, которого в C# нет
Разработчики C# никогда не сталкиваются с Pin — сборщик мусора CLR свободно перемещает объекты и автоматически обновляет все ссылки. В Rust сборщика мусора нет. Когда компилятор превращает async fn в конечный автомат, эта структура может содержать внутренние указатели на собственные поля. Перемещение структуры сделало бы эти указатели недействительными.
Pin<T> — это обёртка, которая говорит: «это значение не будет перемещено в памяти».
#![allow(unused)]
fn main() {
// Pin встречается в таких контекстах:
trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
// ^^^^^^^^^^^^^^ закреплено — внутренние ссылки остаются действительными
}
// Возврат future в упаковке Box из трейта:
fn make_future() -> Pin<Box<dyn Future<Output = i32> + Send>> {
Box::pin(async { 42 })
}
}
На практике вы почти никогда не пишете Pin сами. Синтаксис async fn и .await справляется с этим. С Pin вы столкнётесь только в таких случаях:
- Сообщения об ошибках компилятора (следуйте подсказке)
tokio::select!(используйте макросpin!())- Методы трейтов, возвращающие
dyn Future(используйтеBox::pin(async { ... }))
Хотите глубже? Сопутствующее руководство Async Rust Training подробно рассматривает Pin, Unpin, самоссылающиеся структуры и структурное закрепление.
14. Unsafe Rust и FFI
Небезопасный Rust (unsafe Rust)
Что вы узнаете: что разрешает
unsafe(сырые указатели, FFI, непроверяемые приведения), паттерны безопасных обёрток, P/Invoke в C# против FFI в Rust для вызова нативного кода, а также чек-лист безопасности для блоковunsafe.Сложность: 🔴 Продвинутый
Unsafe Rust позволяет выполнять операции, которые проверщик заимствований не может верифицировать. Используйте его осторожно и с понятной документацией.
Расширенное описание: о безопасных абстракциях поверх unsafe-кода (арена-аллокаторы, структуры без блокировок, пользовательские таблицы виртуальных методов) см. Паттерны Rust.
Когда нужен unsafe
#![allow(unused)]
fn main() {
// 1. Разыменование сырых указателей
let mut value = 42;
let ptr = &mut value as *mut i32;
// SAFETY: ptr указывает на действительную, живую локальную переменную.
unsafe {
*ptr = 100; // Должно находиться в блоке unsafe
}
// 2. Вызов небезопасных функций
unsafe fn dangerous() {
// Внутренняя реализация, которая требует от вызывающего кода соблюдения инвариантов
}
// SAFETY: для этой примерной функции инвариантов нет.
unsafe {
dangerous(); // Ответственность берёт на себя вызывающий код
}
// 3. Доступ к изменяемым статическим переменным
static mut COUNTER: u32 = 0;
// SAFETY: однопоточный контекст; параллельного доступа к COUNTER нет.
unsafe {
COUNTER += 1; // Не потокобезопасно — вызывающий код должен обеспечить синхронизацию
}
// 4. Реализация небезопасных трейтов
unsafe trait UnsafeTrait {
fn do_something(&self);
}
}
Сравнение с C#: ключевое слово unsafe
// unsafe в C# — похожая идея, но другой масштаб
unsafe void UnsafeExample()
{
int value = 42;
int* ptr = &value;
*ptr = 100;
// unsafe в C# — это про арифметику указателей
// unsafe в Rust — это про ослабление правил владения и заимствования
}
// fixed в C# — закрепление управляемых объектов
unsafe void PinnedExample()
{
byte[] buffer = new byte[100];
fixed (byte* ptr = buffer)
{
// ptr действителен только внутри этого блока
}
}
Безопасные обёртки
#![allow(unused)]
fn main() {
/// Ключевой паттерн: оборачиваем небезопасный код в безопасный API
pub struct SafeBuffer {
data: Vec<u8>,
}
impl SafeBuffer {
pub fn new(size: usize) -> Self {
SafeBuffer { data: vec![0; size] }
}
/// Безопасный API — доступ с проверкой границ
pub fn get(&self, index: usize) -> Option<u8> {
self.data.get(index).copied()
}
/// Быстрый доступ без проверки — небезопасный, но безопасно обёрнутый проверкой границ
pub fn get_unchecked_safe(&self, index: usize) -> Option<u8> {
if index < self.data.len() {
// SAFETY: мы только что проверили, что index находится в границах
Some(unsafe { *self.data.get_unchecked(index) })
} else {
None
}
}
}
}
Взаимодействие с C# через FFI
Rust может экспортировать функции с C-совместимым интерфейсом, которые C# вызывает через P/Invoke.
graph LR
subgraph "Процесс C#"
CS["Код C#"] -->|"P/Invoke"| MI["Слой маршалинга<br/>UTF-16 → UTF-8<br/>раскладка структур"]
end
MI -->|"Вызов по C ABI"| FFI["Граница FFI"]
subgraph "Rust cdylib (.so / .dll)"
FFI --> RF["extern #quot;C#quot; fn<br/>#[no_mangle]"]
RF --> Safe["Безопасные внутренности<br/>на Rust"]
end
style FFI fill:#fff9c4,color:#000
style MI fill:#bbdefb,color:#000
style Safe fill:#c8e6c9,color:#000
Библиотека Rust (собирается как cdylib)
#![allow(unused)]
fn main() {
// src/lib.rs
#[no_mangle]
pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 {
a + b
}
#[no_mangle]
pub extern "C" fn process_string(input: *const std::os::raw::c_char) -> i32 {
// SAFETY: input проверяется на null внутри, а вызывающий код гарантирует null-терминированность.
let c_str = unsafe {
if input.is_null() {
return -1;
}
std::ffi::CStr::from_ptr(input)
};
match c_str.to_str() {
Ok(s) => s.len() as i32,
Err(_) => -1,
}
}
}
# Cargo.toml
[lib]
crate-type = ["cdylib"]
Потребитель на C# (P/Invoke)
using System.Runtime.InteropServices;
public static class RustInterop
{
[DllImport("my_rust_lib", CallingConvention = CallingConvention.Cdecl)]
public static extern int add_numbers(int a, int b);
[DllImport("my_rust_lib", CallingConvention = CallingConvention.Cdecl)]
public static extern int process_string(
[MarshalAs(UnmanagedType.LPUTF8Str)] string input);
}
// Использование
int sum = RustInterop.add_numbers(5, 3); // 8
int len = RustInterop.process_string("Hello from C#!"); // 15
Чек-лист безопасности FFI
При экспорте функций Rust в C# эти правила предотвращают большинство частых ошибок:
-
Всегда используйте
extern "C"— без него Rust применяет собственное (нестабильное) соглашение о вызовах. P/Invoke в C# ожидает C ABI. -
#[no_mangle]— не даёт компилятору Rust менять имя функции. Без него C# не сможет найти символ. -
Никогда не пускайте панику через границу FFI — раскрутка паники Rust внутрь C# является неопределённым поведением. Перехватывайте паники на входах в FFI:
#![allow(unused)] fn main() { #[no_mangle] pub extern "C" fn safe_ffi_function() -> i32 { match std::panic::catch_unwind(|| { // реальная логика здесь 42 }) { Ok(result) => result, Err(_) => -1, // Возвращаем код ошибки вместо паники внутрь C# } } } -
Непрозрачные и прозрачные структуры — если C# хранит только указатель (непрозрачный дескриптор),
#[repr(C)]не нужен. Если C# читает поля структуры черезStructLayout,#[repr(C)]обязателен:#![allow(unused)] fn main() { // Непрозрачная — C# хранит только IntPtr. #[repr(C)] не нужен. pub struct Connection { /* поля только для Rust */ } // Прозрачная — C# маршалирует поля напрямую. ОБЯЗАТЕЛЬНО #[repr(C)]. #[repr(C)] pub struct Point { pub x: f64, pub y: f64 } } -
Проверки на null-указатели — всегда проверяйте указатели перед разыменованием. C# может передать
IntPtr.Zero. -
Кодировка строк — C# использует UTF-16 внутренне.
MarshalAs(UnmanagedType.LPUTF8Str)конвертирует строку в UTF-8 дляCStrв Rust. Этот контракт нужно явно документировать.
Сквозной пример: непрозрачный дескриптор с управлением жизненным циклом
Этот паттерн распространён в продакшене: Rust владеет объектом, C# хранит непрозрачный дескриптор, а явные функции создания и уничтожения управляют жизненным циклом.
Сторона Rust (src/lib.rs):
#![allow(unused)]
fn main() {
use std::ffi::{c_char, CStr};
pub struct ImageProcessor {
width: u32,
height: u32,
pixels: Vec<u8>,
}
/// Создаёт новый обработчик. Возвращает null при некорректных размерах.
#[no_mangle]
pub extern "C" fn processor_new(width: u32, height: u32) -> *mut ImageProcessor {
if width == 0 || height == 0 {
return std::ptr::null_mut();
}
let proc = ImageProcessor {
width,
height,
pixels: vec![0u8; (width * height * 4) as usize],
};
Box::into_raw(Box::new(proc)) // Выделяем в куче, возвращаем сырой указатель
}
/// Применяет фильтр оттенков серого. Возвращает 0 при успехе, -1 при null-указателе.
#[no_mangle]
pub extern "C" fn processor_grayscale(ptr: *mut ImageProcessor) -> i32 {
// SAFETY: ptr был создан через Box::into_raw (не null), всё ещё действителен.
let proc = match unsafe { ptr.as_mut() } {
Some(p) => p,
None => return -1,
};
for chunk in proc.pixels.chunks_exact_mut(4) {
let gray = (0.299 * chunk[0] as f64
+ 0.587 * chunk[1] as f64
+ 0.114 * chunk[2] as f64) as u8;
chunk[0] = gray;
chunk[1] = gray;
chunk[2] = gray;
}
0
}
/// Уничтожает обработчик. Безопасно вызывать с null.
#[no_mangle]
pub extern "C" fn processor_free(ptr: *mut ImageProcessor) {
if !ptr.is_null() {
// SAFETY: ptr был создан через processor_new с помощью Box::into_raw
unsafe { drop(Box::from_raw(ptr)); }
}
}
}
Сторона C#:
using System.Runtime.InteropServices;
public sealed class ImageProcessor : IDisposable
{
[DllImport("image_rust", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr processor_new(uint width, uint height);
[DllImport("image_rust", CallingConvention = CallingConvention.Cdecl)]
private static extern int processor_grayscale(IntPtr ptr);
[DllImport("image_rust", CallingConvention = CallingConvention.Cdecl)]
private static extern void processor_free(IntPtr ptr);
private IntPtr _handle;
public ImageProcessor(uint width, uint height)
{
_handle = processor_new(width, height);
if (_handle == IntPtr.Zero)
throw new ArgumentException("Invalid dimensions");
}
public void Grayscale()
{
if (processor_grayscale(_handle) != 0)
throw new InvalidOperationException("Processor is null");
}
public void Dispose()
{
if (_handle != IntPtr.Zero)
{
processor_free(_handle);
_handle = IntPtr.Zero;
}
}
}
// Использование — IDisposable гарантирует освобождение памяти Rust
using var proc = new ImageProcessor(1920, 1080);
proc.Grayscale();
// proc.Dispose() вызывается автоматически → processor_free() → Rust освобождает Vec
Ключевая мысль: это аналог паттерна
SafeHandleв C#.Box::into_raw/Box::from_rawпередают владение через границу FFI, а обёрткаIDisposableв C# обеспечивает очистку.
Упражнения
🏋️ Упражнение: безопасная обёртка над сырым указателем (нажмите, чтобы раскрыть)
Вы получаете сырой указатель из C-библиотеки. Напишите безопасную обёртку на Rust:
#![allow(unused)]
fn main() {
// Имитация C API
extern "C" {
fn lib_create_buffer(size: usize) -> *mut u8;
fn lib_free_buffer(ptr: *mut u8);
}
}
Требования:
- Создайте структуру
SafeBuffer, которая оборачивает сырой указатель - Реализуйте
Drop, который вызываетlib_free_buffer - Предоставьте безопасное представление
&[u8]черезas_slice() - Убедитесь, что
SafeBuffer::new()возвращаетNone, если указатель null
🔑 Решение
struct SafeBuffer {
ptr: *mut u8,
len: usize,
}
impl SafeBuffer {
fn new(size: usize) -> Option<Self> {
// SAFETY: lib_create_buffer возвращает действительный указатель или null (проверяется ниже).
let ptr = unsafe { lib_create_buffer(size) };
if ptr.is_null() {
None
} else {
Some(SafeBuffer { ptr, len: size })
}
}
fn as_slice(&self) -> &[u8] {
// SAFETY: ptr не null (проверено в new()), len — это выделенный размер,
// и мы владеем буфером монопольно.
unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
}
}
impl Drop for SafeBuffer {
fn drop(&mut self) {
// SAFETY: ptr был выделен через lib_create_buffer
unsafe { lib_free_buffer(self.ptr); }
}
}
// Использование: весь unsafe сосредоточен внутри SafeBuffer
fn process(buf: &SafeBuffer) {
let data = buf.as_slice(); // полностью безопасный API
println!("First byte: {}", data[0]);
}
Ключевой паттерн: инкапсулируйте unsafe в небольшом модуле с комментариями // SAFETY:. Открывайте наружу полностью безопасный публичный API. Так устроена стандартная библиотека Rust — Vec, String, HashMap внутри содержат unsafe-код, но предоставляют безопасные интерфейсы.
Тестирование
Тестирование в Rust и C#
Что вы узнаете: встроенный
#[test]против xUnit, параметризованные тесты сrstest(аналог[Theory]), property-тестирование сproptest, моки сmockallи паттерны асинхронных тестов.Сложность: 🟡 Средний
Модульные тесты
// C# — xUnit
using Xunit;
public class CalculatorTests
{
[Fact]
public void Add_ReturnsSum()
{
var calc = new Calculator();
Assert.Equal(5, calc.Add(2, 3));
}
[Theory]
[InlineData(1, 2, 3)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
public void Add_Theory(int a, int b, int expected)
{
Assert.Equal(expected, new Calculator().Add(a, b));
}
}
#![allow(unused)]
fn main() {
// Rust — встроенное тестирование, внешний фреймворк не нужен
pub fn add(a: i32, b: i32) -> i32 { a + b }
#[cfg(test)] // Компилируется только при `cargo test`
mod tests {
use super::*; // Импорт из родительского модуля
#[test]
fn add_returns_sum() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn add_negative_numbers() {
assert_eq!(add(-1, 1), 0);
}
#[test]
#[should_panic(expected = "overflow")]
fn add_overflow_panics() {
let _ = add(i32::MAX, 1); // паникует в debug-режиме
}
}
}
Параметризованные тесты (аналог [Theory])
#![allow(unused)]
fn main() {
// Для параметризованных тестов используйте крейт `rstest`
use rstest::rstest;
#[rstest]
#[case(1, 2, 3)]
#[case(0, 0, 0)]
#[case(-1, 1, 0)]
fn test_add(#[case] a: i32, #[case] b: i32, #[case] expected: i32) {
assert_eq!(add(a, b), expected);
}
// Фикстуры — аналог методов настройки тестов
#[rstest]
fn test_with_fixture(#[values(1, 2, 3)] x: i32) {
assert!(x > 0);
}
}
Сравнение утверждений
| C# (xUnit) | Rust | Примечания |
|---|---|---|
Assert.Equal(expected, actual) | assert_eq!(expected, actual) | При провале выводит diff |
Assert.NotEqual(a, b) | assert_ne!(a, b) | |
Assert.True(condition) | assert!(condition) | |
Assert.Contains("sub", str) | assert!(str.contains("sub")) | |
Assert.Throws<T>(() => ...) | #[should_panic] | Или используйте std::panic::catch_unwind |
Assert.Null(obj) | assert!(option.is_none()) | Нет null — используйте Option |
Организация тестов
my_crate/
├── src/
│ ├── lib.rs # Модульные тесты в #[cfg(test)] mod tests { }
│ └── parser.rs # У каждого модуля может быть свой тестовый модуль
├── tests/ # Интеграционные тесты (каждый файл — отдельный крейт)
│ ├── parser_test.rs # Тестирует публичный API как внешний потребитель
│ └── api_test.rs
└── benches/ # Бенчмарки (с крейтом criterion)
└── my_benchmark.rs
#![allow(unused)]
fn main() {
// tests/parser_test.rs — интеграционный тест
// Имеет доступ только к ПУБЛИЧНОМУ API (как тестирование извне сборки)
use my_crate::parser;
#[test]
fn test_parse_valid_input() {
let result = parser::parse("valid input");
assert!(result.is_ok());
}
}
Асинхронные тесты
// C# — асинхронный тест в xUnit
[Fact]
public async Task GetUser_ReturnsUser()
{
var service = new UserService();
var user = await service.GetUserAsync(1);
Assert.Equal("Alice", user.Name);
}
#![allow(unused)]
fn main() {
// Rust — асинхронный тест с tokio
#[tokio::test]
async fn get_user_returns_user() {
let service = UserService::new();
let user = service.get_user(1).await.unwrap();
assert_eq!(user.name, "Alice");
}
}
Моки с mockall
#![allow(unused)]
fn main() {
use mockall::automock;
#[automock] // Генерирует структуру MockUserRepo
trait UserRepo {
fn find_by_id(&self, id: u32) -> Option<User>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn service_returns_user_from_repo() {
let mut mock = MockUserRepo::new();
mock.expect_find_by_id()
.with(mockall::predicate::eq(1))
.returning(|_| Some(User { name: "Alice".into() }));
let service = UserService::new(mock);
let user = service.get_user(1).unwrap();
assert_eq!(user.name, "Alice");
}
}
}
// C# — эквивалент с Moq
var mock = new Mock<IUserRepo>();
mock.Setup(r => r.FindById(1)).Returns(new User { Name = "Alice" });
var service = new UserService(mock.Object);
Assert.Equal("Alice", service.GetUser(1).Name);
🏋️ Упражнение: напишите полноценные тесты (нажмите, чтобы раскрыть)
Задача: для этой функции напишите тесты, которые покрывают: основной сценарий, пустой ввод, числовые строки и Unicode.
#![allow(unused)]
fn main() {
pub fn title_case(input: &str) -> String {
input.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str().to_lowercase()),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
}
🔑 Решение
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn happy_path() {
assert_eq!(title_case("hello world"), "Hello World");
}
#[test]
fn empty_input() {
assert_eq!(title_case(""), "");
}
#[test]
fn single_word() {
assert_eq!(title_case("rust"), "Rust");
}
#[test]
fn already_title_case() {
assert_eq!(title_case("Hello World"), "Hello World");
}
#[test]
fn all_caps() {
assert_eq!(title_case("HELLO WORLD"), "Hello World");
}
#[test]
fn extra_whitespace() {
// split_whitespace обрабатывает несколько пробелов
assert_eq!(title_case(" hello world "), "Hello World");
}
#[test]
fn unicode() {
assert_eq!(title_case("café résumé"), "Café Résumé");
}
#[test]
fn numeric_words() {
assert_eq!(title_case("hello 42 world"), "Hello 42 World");
}
}
}
Ключевая мысль: встроенный тестовый фреймворк Rust покрывает большинство потребностей в модульном тестировании. Используйте rstest для параметризованных тестов и mockall для моков — большой тестовый фреймворк вроде xUnit не нужен.
Property-тестирование: доказательство корректности в масштабе
Разработчикам C#, знакомым с FsCheck, property-тестирование будет понятно сразу: вместо написания отдельных тест-кейсов вы описываете свойства, которые должны выполняться для всех возможных входных данных, а фреймворк генерирует тысячи случайных входов, чтобы их опровергнуть.
Зачем нужно property-тестирование
// C# — написанные вручную модульные тесты проверяют конкретные случаи
[Fact]
public void Reverse_Twice_Returns_Original()
{
var list = new List<int> { 1, 2, 3 };
list.Reverse();
list.Reverse();
Assert.Equal(new[] { 1, 2, 3 }, list);
}
// Но что насчёт пустых списков? Одиночных элементов? 10 000 элементов? Отрицательных чисел?
// Понадобились бы десятки тестов, написанных вручную.
#![allow(unused)]
fn main() {
// Rust — proptest автоматически генерирует тысячи входов
use proptest::prelude::*;
fn reverse<T: Clone>(v: &[T]) -> Vec<T> {
v.iter().rev().cloned().collect()
}
proptest! {
#[test]
fn reverse_twice_is_identity(ref v in prop::collection::vec(any::<i32>(), 0..1000)) {
let reversed_twice = reverse(&reverse(v));
prop_assert_eq!(v, &reversed_twice);
}
// proptest запускает это на сотнях случайных значений Vec<i32>:
// [], [0], [i32::MIN, i32::MAX], [42; 999], случайные последовательности...
// Если тест падает, он СЖИМАЕТСЯ до наименьшего падающего входа!
}
}
Начало работы с proptest
# Cargo.toml
[dev-dependencies]
proptest = "1.4"
Распространённые паттерны для разработчиков C#
#![allow(unused)]
fn main() {
use proptest::prelude::*;
// 1. Свойство «туда и обратно»: сериализация → десериализация = тождество
// (Как тестирование JsonSerializer.Serialize → Deserialize)
proptest! {
#[test]
fn json_roundtrip(name in "[a-zA-Z]{1,50}", age in 0u32..150) {
let user = User { name: name.clone(), age };
let json = serde_json::to_string(&user).unwrap();
let parsed: User = serde_json::from_str(&json).unwrap();
prop_assert_eq!(user, parsed);
}
}
// 2. Свойство-инвариант: результат всегда удовлетворяет условию
proptest! {
#[test]
fn sort_output_is_sorted(ref v in prop::collection::vec(any::<i32>(), 0..500)) {
let mut sorted = v.clone();
sorted.sort();
// Каждая соседняя пара должна быть в порядке
for window in sorted.windows(2) {
prop_assert!(window[0] <= window[1]);
}
}
}
// 3. Свойство-оракул: сравниваем две реализации
proptest! {
#[test]
fn fast_path_matches_slow_path(input in "[0-9a-f]{1,100}") {
let result_fast = parse_hex_fast(&input);
let result_slow = parse_hex_slow(&input);
prop_assert_eq!(result_fast, result_slow);
}
}
// 4. Пользовательские стратегии: генерируем данные, специфичные для предметной области
fn valid_email() -> impl Strategy<Value = String> {
("[a-z]{1,20}", "[a-z]{1,10}", prop::sample::select(vec!["com", "org", "io"]))
.prop_map(|(user, domain, tld)| format!("{}@{}.{}", user, domain, tld))
}
proptest! {
#[test]
fn email_parsing_accepts_valid_emails(email in valid_email()) {
let result = Email::new(&email);
prop_assert!(result.is_ok(), "Failed to parse: {}", email);
}
}
}
Сравнение proptest и FsCheck
| Возможность | C# FsCheck | Rust proptest |
|---|---|---|
| Генерация случайных входов | Arb.Generate<T>() | any::<T>() |
| Пользовательские генераторы | Arb.Register<T>() | impl Strategy<Value = T> |
| Сжатие при падении (shrinking) | Автоматическое | Автоматическое |
| Строковые шаблоны | Вручную | Стратегия "[regex]" |
| Генерация коллекций | Gen.ListOf | prop::collection::vec(strategy, range) |
| Композиция генераторов | Gen.Select | .prop_map(), .prop_flat_map() |
| Конфигурация (число случаев) | Config.MaxTest | #![proptest_config(ProptestConfig::with_cases(10000))] внутри блока proptest! |
Когда использовать property-тестирование, а когда модульное
| Используйте модульные тесты, когда | Используйте proptest, когда |
|---|---|
| Проверяются конкретные пограничные случаи | Проверяются инварианты на всех входах |
| Проверяются сообщения об ошибках/коды | Свойства «туда и обратно» (разбор ↔ форматирование) |
| Интеграционные и тесты с моками | Сравнение двух реализаций |
| Поведение зависит от точных значений | «Для всех X выполняется свойство P» |
Интеграционные тесты: каталог tests/
Модульные тесты живут внутри src/ с #[cfg(test)]. Интеграционные тесты находятся в отдельном каталоге tests/ и проверяют публичный API крейта — так же, как интеграционные тесты в C# ссылаются на проект как на внешнюю сборку.
my_crate/
├── src/
│ ├── lib.rs // публичный API
│ └── internal.rs // приватная реализация
├── tests/
│ ├── smoke.rs // каждый файл — отдельный тестовый бинарник
│ ├── api_tests.rs
│ └── common/
│ └── mod.rs // общие вспомогательные функции для тестов
└── Cargo.toml
Написание интеграционных тестов
Каждый файл в tests/ компилируется как отдельный крейт, который зависит от вашей библиотеки:
#![allow(unused)]
fn main() {
// tests/smoke.rs — имеет доступ только к pub-элементам my_crate
use my_crate::{process_order, Order, OrderResult};
#[test]
fn process_valid_order_returns_confirmation() {
let order = Order::new("SKU-001", 3);
let result = process_order(order);
assert!(matches!(result, OrderResult::Confirmed { .. }));
}
}
Общие вспомогательные функции для тестов
Общий код настройки кладите в tests/common/mod.rs (а не tests/common.rs, который будет считаться отдельным тестовым файлом):
#![allow(unused)]
fn main() {
// tests/common/mod.rs
use my_crate::Config;
pub fn test_config() -> Config {
Config::builder()
.database_url("sqlite::memory:")
.build()
.expect("test config must be valid")
}
}
#![allow(unused)]
fn main() {
// tests/api_tests.rs
mod common;
use my_crate::App;
#[test]
fn app_starts_with_test_config() {
let config = common::test_config();
let app = App::new(config);
assert!(app.is_healthy());
}
}
Запуск отдельных типов тестов
cargo test # запустить все тесты (модульные + интеграционные)
cargo test --lib # только модульные тесты (как dotnet test --filter Category=Unit)
cargo test --test smoke # запустить только tests/smoke.rs
cargo test --test api_tests # запустить только tests/api_tests.rs
Ключевое отличие от C#: файлы интеграционных тестов видят только pub-API вашего крейта. Приватные функции невидимы — это заставляет тестировать через публичный интерфейс, что обычно даёт лучший дизайн тестов.
15. Паттерны миграции и разборы случаев
Распространённые паттерны C# в Rust
Что вы узнаете: как переводить с C# на идиоматичный Rust паттерн Repository, паттерн Builder, внедрение зависимостей, цепочки LINQ, запросы Entity Framework и паттерны конфигурации.
Сложность: 🟡 Средний
graph LR
subgraph "Паттерн C#"
I["interface IRepo<T>"] --> DI["Контейнер DI"]
EX["try / catch"] --> LOG["ILogger"]
LINQ["LINQ .Where().Select()"] --> LIST["List<T>"]
end
subgraph "Аналог в Rust"
TR["trait Repo<T>"] --> GEN["Generic<R: Repo>"]
RES["Result<T, E> + ?"] --> THISERR["thiserror / anyhow"]
ITER[".iter().filter().map()"] --> VEC["Vec<T>"]
end
I -->|"становится"| TR
EX -->|"становится"| RES
LINQ -->|"становится"| ITER
style TR fill:#c8e6c9,color:#000
style RES fill:#c8e6c9,color:#000
style ITER fill:#c8e6c9,color:#000
Паттерн Repository
// Паттерн Repository в C#
public interface IRepository<T> where T : IEntity
{
Task<T> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task<T> AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(int id);
}
public class UserRepository : IRepository<User>
{
private readonly DbContext _context;
public UserRepository(DbContext context)
{
_context = context;
}
public async Task<User> GetByIdAsync(int id)
{
return await _context.Users.FindAsync(id);
}
// ... остальные реализации
}
#![allow(unused)]
fn main() {
// Паттерн Repository в Rust с трейтами и обобщениями
use async_trait::async_trait;
use std::fmt::Debug;
#[async_trait]
pub trait Repository<T, E>
where
T: Clone + Debug + Send + Sync,
E: std::error::Error + Send + Sync,
{
async fn get_by_id(&self, id: u64) -> Result<Option<T>, E>;
async fn get_all(&self) -> Result<Vec<T>, E>;
async fn add(&self, entity: T) -> Result<T, E>;
async fn update(&self, entity: T) -> Result<T, E>;
async fn delete(&self, id: u64) -> Result<(), E>;
}
#[derive(Debug, Clone)]
pub struct User {
pub id: u64,
pub name: String,
pub email: String,
}
#[derive(Debug)]
pub enum RepositoryError {
NotFound(u64),
DatabaseError(String),
ValidationError(String),
}
impl std::fmt::Display for RepositoryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RepositoryError::NotFound(id) => write!(f, "Entity with id {} not found", id),
RepositoryError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
RepositoryError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
}
}
}
impl std::error::Error for RepositoryError {}
pub struct UserRepository {
// пул соединений с базой данных и т. д.
}
#[async_trait]
impl Repository<User, RepositoryError> for UserRepository {
async fn get_by_id(&self, id: u64) -> Result<Option<User>, RepositoryError> {
// Имитация поиска в базе данных
if id == 0 {
return Ok(None);
}
Ok(Some(User {
id,
name: format!("User {}", id),
email: format!("user{}@example.com", id),
}))
}
async fn get_all(&self) -> Result<Vec<User>, RepositoryError> {
// Реализация здесь
Ok(vec![])
}
async fn add(&self, entity: User) -> Result<User, RepositoryError> {
// Валидация и вставка в базу данных
if entity.name.is_empty() {
return Err(RepositoryError::ValidationError("Name cannot be empty".to_string()));
}
Ok(entity)
}
async fn update(&self, entity: User) -> Result<User, RepositoryError> {
// Реализация здесь
Ok(entity)
}
async fn delete(&self, id: u64) -> Result<(), RepositoryError> {
// Реализация здесь
Ok(())
}
}
}
Паттерн Builder
// Паттерн Builder в C# (текучий интерфейс)
public class HttpClientBuilder
{
private TimeSpan? _timeout;
private string _baseAddress;
private Dictionary<string, string> _headers = new();
public HttpClientBuilder WithTimeout(TimeSpan timeout)
{
_timeout = timeout;
return this;
}
public HttpClientBuilder WithBaseAddress(string baseAddress)
{
_baseAddress = baseAddress;
return this;
}
public HttpClientBuilder WithHeader(string name, string value)
{
_headers[name] = value;
return this;
}
public HttpClient Build()
{
var client = new HttpClient();
if (_timeout.HasValue)
client.Timeout = _timeout.Value;
if (!string.IsNullOrEmpty(_baseAddress))
client.BaseAddress = new Uri(_baseAddress);
foreach (var header in _headers)
client.DefaultRequestHeaders.Add(header.Key, header.Value);
return client;
}
}
// Использование
var client = new HttpClientBuilder()
.WithTimeout(TimeSpan.FromSeconds(30))
.WithBaseAddress("https://api.example.com")
.WithHeader("Accept", "application/json")
.Build();
#![allow(unused)]
fn main() {
// Паттерн Builder в Rust (потребляющий builder)
use std::collections::HashMap;
use std::time::Duration;
#[derive(Debug)]
pub struct HttpClient {
timeout: Duration,
base_address: String,
headers: HashMap<String, String>,
}
pub struct HttpClientBuilder {
timeout: Option<Duration>,
base_address: Option<String>,
headers: HashMap<String, String>,
}
impl HttpClientBuilder {
pub fn new() -> Self {
HttpClientBuilder {
timeout: None,
base_address: None,
headers: HashMap::new(),
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn with_base_address<S: Into<String>>(mut self, base_address: S) -> Self {
self.base_address = Some(base_address.into());
self
}
pub fn with_header<K: Into<String>, V: Into<String>>(mut self, name: K, value: V) -> Self {
self.headers.insert(name.into(), value.into());
self
}
pub fn build(self) -> Result<HttpClient, String> {
let base_address = self.base_address.ok_or("Base address is required")?;
Ok(HttpClient {
timeout: self.timeout.unwrap_or(Duration::from_secs(30)),
base_address,
headers: self.headers,
})
}
}
// Использование
let client = HttpClientBuilder::new()
.with_timeout(Duration::from_secs(30))
.with_base_address("https://api.example.com")
.with_header("Accept", "application/json")
.build()?;
// Альтернатива: трейт Default для типичных случаев
impl Default for HttpClientBuilder {
fn default() -> Self {
Self::new()
}
}
}
Сопоставление концепций C# и Rust
Внедрение зависимостей → внедрение через конструктор и трейты
// C# с контейнером DI
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IUserService, UserService>();
public class UserService
{
private readonly IUserRepository _repository;
public UserService(IUserRepository repository)
{
_repository = repository;
}
}
#![allow(unused)]
fn main() {
// Rust: внедрение через конструктор с трейтами
pub trait UserRepository {
async fn find_by_id(&self, id: Uuid) -> Result<Option<User>, Error>;
async fn save(&self, user: &User) -> Result<(), Error>;
}
pub struct UserService<R>
where
R: UserRepository,
{
repository: R,
}
impl<R> UserService<R>
where
R: UserRepository,
{
pub fn new(repository: R) -> Self {
Self { repository }
}
pub async fn get_user(&self, id: Uuid) -> Result<Option<User>, Error> {
self.repository.find_by_id(id).await
}
}
// Использование
let repository = PostgresUserRepository::new(pool);
let service = UserService::new(repository);
}
LINQ → цепочки итераторов
// LINQ в C#
var result = users
.Where(u => u.Age > 18)
.Select(u => u.Name.ToUpper())
.OrderBy(name => name)
.Take(10)
.ToList();
#![allow(unused)]
fn main() {
// Rust: цепочки итераторов (с нулевой стоимостью!)
let mut result: Vec<String> = users
.iter()
.filter(|u| u.age > 18)
.map(|u| u.name.to_uppercase())
.collect();
result.sort();
result.truncate(10);
// Или с крейтом itertools для более LINQ-подобной цепочки
use itertools::Itertools;
let result: Vec<String> = users
.iter()
.filter(|u| u.age > 18)
.map(|u| u.name.to_uppercase())
.sorted()
.take(10)
.collect();
}
Entity Framework → SQLx и миграции
// Entity Framework в C#
public class ApplicationDbContext : DbContext
{
public DbSet<User> Users { get; set; }
}
var user = await context.Users
.Where(u => u.Email == email)
.FirstOrDefaultAsync();
#![allow(unused)]
fn main() {
// Rust: SQLx с запросами, проверяемыми на этапе компиляции
use sqlx::{PgPool, FromRow};
#[derive(FromRow)]
struct User {
id: Uuid,
email: String,
name: String,
}
// Запрос, проверяемый на этапе компиляции
let user = sqlx::query_as!(
User,
"SELECT id, email, name FROM users WHERE email = $1",
email
)
.fetch_optional(&pool)
.await?;
// Или динамический запрос
let user = sqlx::query_as::<_, User>(
"SELECT id, email, name FROM users WHERE email = $1"
)
.bind(email)
.fetch_optional(&pool)
.await?;
}
Конфигурация → крейты конфигурации
// Конфигурация в C#
public class AppSettings
{
public string DatabaseUrl { get; set; }
public int Port { get; set; }
}
var config = builder.Configuration.Get<AppSettings>();
#![allow(unused)]
fn main() {
// Rust: конфигурация через serde
use config::{Config, ConfigError, Environment, File};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct AppSettings {
database_url: String,
port: u16,
}
impl AppSettings {
pub fn new() -> Result<Self, ConfigError> {
let s = Config::builder()
.add_source(File::with_name("config/default"))
.add_source(Environment::with_prefix("APP"))
.build()?;
s.try_deserialize()
}
}
// Использование
let settings = AppSettings::new()?;
}
Разборы случаев
Случай 1: миграция CLI-утилиты (csvtool)
Контекст: команда поддерживала консольное приложение на C# (CsvProcessor), которое читало большие CSV-файлы, применяло преобразования и записывало результат. При файлах в 500 МБ потребление памяти подскакивало до 4 ГБ, а паузы GC вызывали зависания на 30 секунд.
Подход к миграции: переписали на Rust за 2 недели, по одному модулю за раз.
| Шаг | Что изменилось | C# → Rust |
|---|---|---|
| 1 | Разбор CSV | CsvHelper → крейт csv (потоковый Reader) |
| 2 | Модель данных | class Record → struct Record (размещается на стеке, #[derive(Deserialize)]) |
| 3 | Преобразования | LINQ .Select().Where() → .iter().map().filter() |
| 4 | Файловый ввод-вывод | StreamReader → BufReader<File> с распространением ошибок через ? |
| 5 | Аргументы CLI | System.CommandLine → clap с derive-макросами |
| 6 | Параллельная обработка | Parallel.ForEach → .par_iter() из rayon |
Результаты:
- Память: 4 ГБ → 12 МБ (потоковая обработка вместо загрузки всего файла)
- Скорость: 45 с → 3 с для файла в 500 МБ
- Размер бинарника: один исполняемый файл 2 МБ, без зависимости от рантайма
Главный урок: основной выигрыш был не в самом Rust — а в том, что модель владения Rust вынуждала к потоковому дизайну. В C# было легко сделать .ToList() и загрузить всё в память. В Rust проверщик заимствований естественно подталкивал к обработке на основе Iterator.
Случай 2: замена микросервиса (auth-gateway)
Контекст: шлюз аутентификации на C# ASP.NET Core отвечал за валидацию JWT и ограничение частоты запросов для более чем 50 бэкенд-сервисов. При 10 000 запросов в секунду p99-задержка достигала 200 мс из-за всплесков GC.
Подход к миграции: заменили на сервис на Rust с использованием axum + tower, сохранив тот же контракт API.
#![allow(unused)]
fn main() {
// Было (C#): services.AddAuthentication().AddJwtBearer(...)
// Стало (Rust): слой middleware из tower
use axum::{Router, middleware};
use tower::ServiceBuilder;
let app = Router::new()
.route("/api/*path", any(proxy_handler))
.layer(
ServiceBuilder::new()
.layer(middleware::from_fn(validate_jwt))
.layer(middleware::from_fn(rate_limit))
);
}
| Метрика | C# (ASP.NET Core) | Rust (axum) |
|---|---|---|
| p50-задержка | 5 мс | 0,8 мс |
| p99-задержка | 200 мс (всплески GC) | 4 мс |
| Память | 300 МБ | 8 МБ |
| Docker-образ | 210 МБ (рантайм .NET) | 12 МБ (статический бинарник) |
| Холодный старт | 2,1 с | 0,05 с |
Ключевые уроки:
- Сохраните тот же контракт API — изменения на стороне клиентов не нужны. Сервис на Rust стал прямой заменой.
- Начните с горячего пути — узким местом была валидация JWT. Миграция только этого middleware дала бы 80% выигрыша.
- Используйте middleware
tower— он повторяет конвейер middleware ASP.NET Core, поэтому разработчикам C# архитектура на Rust показалась знакомой. - Улучшение p99-задержки пришло от устранения пауз GC, а не от более быстрого кода — установившаяся пропускная способность Rust была лишь примерно в 2 раза выше, но отсутствие GC сделало «хвост» задержек предсказуемым.
Упражнения
🏋️ Упражнение: мигрируйте сервис на C# (нажмите, чтобы раскрыть)
Переведите этот сервис на C# на идиоматичный Rust:
public interface IUserService
{
Task<User?> GetByIdAsync(int id);
Task<List<User>> SearchAsync(string query);
}
public class UserService : IUserService
{
private readonly IDatabase _db;
public UserService(IDatabase db) { _db = db; }
public async Task<User?> GetByIdAsync(int id)
{
try { return await _db.QuerySingleAsync<User>(id); }
catch (NotFoundException) { return null; }
}
public async Task<List<User>> SearchAsync(string query)
{
return await _db.QueryAsync<User>($"SELECT * WHERE name LIKE '%{query}%'");
}
}
Подсказки: используйте трейт, Option<User> вместо null, Result вместо try/catch, и устраните уязвимость SQL-инъекции.
🔑 Решение
#![allow(unused)]
fn main() {
use async_trait::async_trait;
#[derive(Debug, Clone)]
struct User { id: i64, name: String }
#[async_trait]
trait Database: Send + Sync {
async fn get_user(&self, id: i64) -> Result<Option<User>, sqlx::Error>;
async fn search_users(&self, query: &str) -> Result<Vec<User>, sqlx::Error>;
}
#[async_trait]
trait UserService: Send + Sync {
async fn get_by_id(&self, id: i64) -> Result<Option<User>, AppError>;
async fn search(&self, query: &str) -> Result<Vec<User>, AppError>;
}
struct UserServiceImpl<D: Database> {
db: D, // Arc не нужен — владение Rust справляется с этим само
}
#[async_trait]
impl<D: Database> UserService for UserServiceImpl<D> {
async fn get_by_id(&self, id: i64) -> Result<Option<User>, AppError> {
// Option вместо null; Result вместо try/catch
Ok(self.db.get_user(id).await?)
}
async fn search(&self, query: &str) -> Result<Vec<User>, AppError> {
// Параметризованный запрос — НИКАКИХ SQL-инъекций!
// (sqlx использует плейсхолдеры $1, а не интерполяцию строк)
self.db.search_users(query).await.map_err(Into::into)
}
}
}
Ключевые изменения по сравнению с C#:
null→Option<User>(null-безопасность на этапе компиляции)try/catch→Result+?(явное распространение ошибок)- SQL-инъекция устранена: параметризованные запросы вместо интерполяции строк
IDatabase _db→ обобщениеD: Database(статическая диспетчеризация, без упаковки)
Основные крейты для разработчиков C#
Основные крейты для разработчиков C#
Что вы узнаете: аналоги крейтов Rust для распространённых библиотек .NET — serde (JSON.NET), reqwest (HttpClient), tokio (Task/async), sqlx (Entity Framework), а также подробный разбор системы атрибутов serde в сравнении с
System.Text.Json.Сложность: 🟡 Средний
Аналоги основной функциональности
#![allow(unused)]
fn main() {
// Зависимости Cargo для разработчиков C#
[dependencies]
Сериализация (как Newtonsoft.Json или System.Text.Json)
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
HTTP-клиент (как HttpClient)
reqwest = { version = "0.11", features = ["json"] }
Асинхронный рантайм (как Task.Run, async/await)
tokio = { version = "1.0", features = ["full"] }
Обработка ошибок (как собственные исключения)
thiserror = "1.0"
anyhow = "1.0"
Логирование (как ILogger, Serilog)
log = "0.4"
env_logger = "0.10"
Дата и время (как DateTime)
chrono = { version = "0.4", features = ["serde"] }
UUID (как System.Guid)
uuid = { version = "1.0", features = ["v4", "serde"] }
Коллекции (как List<T>, Dictionary<K,V>)
Встроены в std, но для продвинутых коллекций:
indexmap = "2.0" # HashMap с сохранением порядка
Конфигурация (как IConfiguration)
config = "0.13"
База данных (как Entity Framework)
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono"] }
Тестирование (как xUnit, NUnit)
Встроено в std, но для расширенных возможностей:
rstest = "0.18" # Параметризованные тесты
Моки (как Moq)
mockall = "0.11"
Параллельная обработка (как Parallel.ForEach)
rayon = "1.7"
}
Примеры использования
use serde::{Deserialize, Serialize};
use reqwest;
use tokio;
use thiserror::Error;
use chrono::{DateTime, Utc};
use uuid::Uuid;
// Модели данных (как POCO с атрибутами в C#)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: Uuid,
pub name: String,
pub email: String,
#[serde(with = "chrono::serde::ts_seconds")]
pub created_at: DateTime<Utc>,
}
// Собственные типы ошибок (как собственные исключения)
#[derive(Error, Debug)]
pub enum ApiError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("Serialization failed: {0}")]
Serialization(#[from] serde_json::Error),
#[error("User not found: {id}")]
UserNotFound { id: Uuid },
#[error("Validation failed: {message}")]
Validation { message: String },
}
// Аналог класса сервиса
pub struct UserService {
client: reqwest::Client,
base_url: String,
}
impl UserService {
pub fn new(base_url: String) -> Self {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("Failed to create HTTP client");
UserService { client, base_url }
}
// Асинхронный метод (как async Task<User> в C#)
pub async fn get_user(&self, id: Uuid) -> Result<User, ApiError> {
let url = format!("{}/users/{}", self.base_url, id);
let response = self.client
.get(&url)
.send()
.await?;
if response.status() == 404 {
return Err(ApiError::UserNotFound { id });
}
let user = response.json::<User>().await?;
Ok(user)
}
// Создание пользователя (как async Task<User> в C#)
pub async fn create_user(&self, name: String, email: String) -> Result<User, ApiError> {
if name.trim().is_empty() {
return Err(ApiError::Validation {
message: "Name cannot be empty".to_string(),
});
}
let new_user = User {
id: Uuid::new_v4(),
name,
email,
created_at: Utc::now(),
};
let response = self.client
.post(&format!("{}/users", self.base_url))
.json(&new_user)
.send()
.await?;
let created_user = response.json::<User>().await?;
Ok(created_user)
}
}
// Пример использования (как метод Main в C#)
#[tokio::main]
async fn main() -> Result<(), ApiError> {
// Инициализация логирования (как настройка ILogger)
env_logger::init();
let service = UserService::new("https://api.example.com".to_string());
// Создание пользователя
let user = service.create_user(
"John Doe".to_string(),
"john@example.com".to_string(),
).await?;
println!("Created user: {:?}", user);
// Получение пользователя
let retrieved_user = service.get_user(user.id).await?;
println!("Retrieved user: {:?}", retrieved_user);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test] // Как [Test] или [Fact] в C#
async fn test_user_creation() {
let service = UserService::new("http://localhost:8080".to_string());
let result = service.create_user(
"Test User".to_string(),
"test@example.com".to_string(),
).await;
assert!(result.is_ok());
let user = result.unwrap();
assert_eq!(user.name, "Test User");
assert_eq!(user.email, "test@example.com");
}
#[test]
fn test_validation() {
// Синхронный тест
let error = ApiError::Validation {
message: "Invalid input".to_string(),
};
assert_eq!(error.to_string(), "Validation failed: Invalid input");
}
}
Глубокое погружение в serde: сериализация JSON для разработчиков C#
Разработчики C# активно используют System.Text.Json или Newtonsoft.Json. В Rust serde (serialize/deserialize) — универсальный фреймворк для этого. Понимание его системы атрибутов открывает большинство сценариев работы с данными.
Базовый derive: отправная точка
#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
struct User {
name: String,
age: u32,
email: String,
}
let user = User { name: "Alice".into(), age: 30, email: "alice@co.com".into() };
let json = serde_json::to_string_pretty(&user)?;
let parsed: User = serde_json::from_str(&json)?;
}
// Аналог в C#
public class User
{
public string Name { get; set; }
public int Age { get; set; }
public string Email { get; set; }
}
var json = JsonSerializer.Serialize(user, new JsonSerializerOptions { WriteIndented = true });
var parsed = JsonSerializer.Deserialize<User>(json);
Атрибуты уровня полей (аналог [JsonProperty])
#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
struct ApiResponse {
// Переименование поля в JSON (как [JsonPropertyName("user_id")])
#[serde(rename = "user_id")]
id: u64,
// Разные имена при сериализации и десериализации
#[serde(rename(serialize = "userName", deserialize = "user_name"))]
name: String,
// Полностью пропустить поле (как [JsonIgnore])
#[serde(skip)]
internal_cache: Option<String>,
// Пропустить только при сериализации
#[serde(skip_serializing)]
password_hash: String,
// Значение по умолчанию, если поля нет в JSON (как значения в конструкторе по умолчанию)
#[serde(default)]
is_active: bool,
// Пользовательское значение по умолчанию
#[serde(default = "default_role")]
role: String,
// Развернуть вложенную структуру в родительскую (как [JsonExtensionData])
#[serde(flatten)]
metadata: Metadata,
// Пропустить, если значение None (не выводить null-поля)
#[serde(skip_serializing_if = "Option::is_none")]
nickname: Option<String>,
}
fn default_role() -> String { "viewer".into() }
#[derive(Serialize, Deserialize, Debug)]
struct Metadata {
created_at: String,
version: u32,
}
}
// Эквивалентные атрибуты в C#
public class ApiResponse
{
[JsonPropertyName("user_id")]
public ulong Id { get; set; }
[JsonIgnore]
public string? InternalCache { get; set; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Metadata { get; set; }
}
Представления перечислений (ключевое отличие от C#)
В serde для перечислений поддерживаются четыре разных представления в JSON — понятие, которому в C# нет прямого аналога, поскольку перечисления C# всегда целочисленные или строковые.
#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
// 1. Внешне тегированное (ПО УМОЛЧАНИЮ) — самое распространённое
#[derive(Serialize, Deserialize)]
enum Message {
Text(String),
Image { url: String, width: u32 },
Ping,
}
// Вариант Text: {"Text": "hello"}
// Вариант Image: {"Image": {"url": "...", "width": 100}}
// Вариант Ping: "Ping"
// 2. Внутренне тегированное — похоже на дискриминируемые объединения в других языках
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Event {
Created { id: u64, name: String },
Deleted { id: u64 },
Updated { id: u64, fields: Vec<String> },
}
// {"type": "Created", "id": 1, "name": "Alice"}
// {"type": "Deleted", "id": 1}
// 3. Смежно тегированное — тег и содержимое в отдельных полях
#[derive(Serialize, Deserialize)]
#[serde(tag = "t", content = "c")]
enum ApiResult {
Success(UserData),
Error(String),
}
// {"t": "Success", "c": {"name": "Alice"}}
// {"t": "Error", "c": "not found"}
// 4. Без тега — serde пробует варианты по порядку
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum FlexibleValue {
Integer(i64),
Float(f64),
Text(String),
Bool(bool),
}
// 42, 3.14, "hello", true — serde сам определяет вариант
}
Пользовательская сериализация (аналог JsonConverter)
#![allow(unused)]
fn main() {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
// Пользовательская сериализация для конкретного поля
#[derive(Serialize, Deserialize)]
struct Config {
#[serde(serialize_with = "serialize_duration", deserialize_with = "deserialize_duration")]
timeout: std::time::Duration,
}
fn serialize_duration<S: Serializer>(dur: &std::time::Duration, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u64(dur.as_millis() as u64)
}
fn deserialize_duration<'de, D: Deserializer<'de>>(d: D) -> Result<std::time::Duration, D::Error> {
let ms = u64::deserialize(d)?;
Ok(std::time::Duration::from_millis(ms))
}
// JSON: {"timeout": 5000} ↔ Config { timeout: Duration::from_millis(5000) }
}
Атрибуты уровня контейнера
#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] // Все поля в JSON становятся camelCase
struct UserProfile {
first_name: String, // → "firstName"
last_name: String, // → "lastName"
email_address: String, // → "emailAddress"
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)] // Отвергать JSON с лишними полями (строгий разбор)
struct StrictConfig {
port: u16,
host: String,
}
// serde_json::from_str::<StrictConfig>(r#"{"port":8080,"host":"localhost","extra":true}"#)
// → Error: unknown field `extra`
}
Краткая справка по атрибутам serde
| Атрибут | Уровень | Аналог в C# | Назначение |
|---|---|---|---|
#[serde(rename = "...")] | Поле | [JsonPropertyName] | Переименование в JSON |
#[serde(skip)] | Поле | [JsonIgnore] | Полностью пропустить |
#[serde(default)] | Поле | Значение по умолчанию | Использовать Default::default(), если поля нет |
#[serde(flatten)] | Поле | [JsonExtensionData] | Слить вложенную структуру в родительскую |
#[serde(skip_serializing_if = "...")] | Поле | JsonIgnoreCondition | Условный пропуск |
#[serde(rename_all = "camelCase")] | Контейнер | JsonSerializerOptions.PropertyNamingPolicy | Соглашение об именовании |
#[serde(deny_unknown_fields)] | Контейнер | — | Строгая десериализация |
#[serde(tag = "type")] | Перечисление | Паттерн дискриминатора | Внутреннее тегирование |
#[serde(untagged)] | Перечисление | — | Пробовать варианты по порядку |
#[serde(with = "...")] | Поле | [JsonConverter] | Пользовательская сериализация и десериализация |
За пределами JSON: serde работает везде
#![allow(unused)]
fn main() {
// ТОТ ЖЕ derive работает для ВСЕХ форматов — нужно лишь сменить крейт
let user = User { name: "Alice".into(), age: 30, email: "a@b.com".into() };
let json = serde_json::to_string(&user)?; // JSON
let toml = toml::to_string(&user)?; // TOML (конфигурационные файлы)
let yaml = serde_yaml::to_string(&user)?; // YAML
let cbor = serde_cbor::to_vec(&user)?; // CBOR (бинарный, компактный)
let msgpk = rmp_serde::to_vec(&user)?; // MessagePack (бинарный)
// Один #[derive(Serialize, Deserialize)] — каждый формат бесплатно
}
Стратегия постепенного внедрения
Стратегия постепенного внедрения
Что вы узнаете: поэтапный подход к внедрению Rust в организации, работающие на C#/.NET — от учебных упражнений (недели 1–4) через замену компонентов, критичных к производительности (недели 5–8), до новых микросервисов (недели 9–12), с конкретными сроками освоения для команды.
Сложность: 🟡 Средний
Этап 1: обучение и эксперименты (недели 1–4)
// Начинаем с инструментов командной строки и утилит
// Пример: анализатор файлов логов
use std::fs;
use std::collections::HashMap;
use clap::Parser;
#[derive(Parser)]
#[command(author, version, about)]
struct Args {
#[arg(short, long)]
file: String,
#[arg(short, long, default_value = "10")]
top: usize,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
let content = fs::read_to_string(&args.file)?;
let mut word_count = HashMap::new();
for line in content.lines() {
for word in line.split_whitespace() {
let word = word.to_lowercase();
*word_count.entry(word).or_insert(0) += 1;
}
}
let mut sorted: Vec<_> = word_count.into_iter().collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1));
for (word, count) in sorted.into_iter().take(args.top) {
println!("{}: {}", word, count);
}
Ok(())
}
Этап 2: замена компонентов, критичных к производительности (недели 5–8)
// Замена ресурсоёмкой обработки данных
// Пример: микросервис обработки изображений
use image::{DynamicImage, ImageBuffer, Rgb};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use warp::Filter;
#[derive(Serialize, Deserialize)]
struct ProcessingRequest {
image_data: Vec<u8>,
operation: String,
parameters: serde_json::Value,
}
#[derive(Serialize)]
struct ProcessingResponse {
processed_image: Vec<u8>,
processing_time_ms: u64,
}
async fn process_image(request: ProcessingRequest) -> Result<ProcessingResponse, Box<dyn std::error::Error + Send + Sync>> {
let start = std::time::Instant::now();
let img = image::load_from_memory(&request.image_data)?;
let processed = match request.operation.as_str() {
"blur" => {
let radius = request.parameters["radius"].as_f64().unwrap_or(2.0) as f32;
img.blur(radius)
}
"grayscale" => img.grayscale(),
"resize" => {
let width = request.parameters["width"].as_u64().unwrap_or(100) as u32;
let height = request.parameters["height"].as_u64().unwrap_or(100) as u32;
img.resize(width, height, image::imageops::FilterType::Lanczos3)
}
_ => return Err("Unknown operation".into()),
};
let mut buffer = Vec::new();
processed.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageOutputFormat::Png)?;
Ok(ProcessingResponse {
processed_image: buffer,
processing_time_ms: start.elapsed().as_millis() as u64,
})
}
#[tokio::main]
async fn main() {
let process_route = warp::path("process")
.and(warp::post())
.and(warp::body::json())
.and_then(|req: ProcessingRequest| async move {
match process_image(req).await {
Ok(response) => Ok(warp::reply::json(&response)),
Err(e) => Err(warp::reject::custom(ProcessingError(e.to_string()))),
}
});
warp::serve(process_route)
.run(([127, 0, 0, 1], 3030))
.await;
}
#[derive(Debug)]
struct ProcessingError(String);
impl warp::reject::Reject for ProcessingError {}
Этап 3: новые микросервисы (недели 9–12)
// Строим новые сервисы с нуля на Rust
// Пример: сервис аутентификации
use axum::{
extract::{Query, State},
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey};
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Postgres};
use uuid::Uuid;
use bcrypt::{hash, verify, DEFAULT_COST};
#[derive(Clone)]
struct AppState {
db: Pool<Postgres>,
jwt_secret: String,
}
#[derive(Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
}
#[derive(Deserialize)]
struct LoginRequest {
email: String,
password: String,
}
#[derive(Serialize)]
struct LoginResponse {
token: String,
user_id: Uuid,
}
async fn login(
State(state): State<AppState>,
Json(request): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, StatusCode> {
// Примечание: sqlx::query!() проверяется на этапе компиляции и требует, чтобы DATABASE_URL
// указывал на работающую базу данных во время сборки. Для запросов, проверяемых во время выполнения,
// используйте sqlx::query() или sqlx::query_as().
let user = sqlx::query!(
"SELECT id, password_hash FROM users WHERE email = $1",
request.email
)
.fetch_optional(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let user = user.ok_or(StatusCode::UNAUTHORIZED)?;
if !verify(&request.password, &user.password_hash)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
return Err(StatusCode::UNAUTHORIZED);
}
let claims = Claims {
sub: user.id.to_string(),
exp: (chrono::Utc::now() + chrono::Duration::hours(24)).timestamp() as usize,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(state.jwt_secret.as_ref()),
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(LoginResponse {
token,
user_id: user.id,
}))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = std::env::var("DATABASE_URL")?;
let jwt_secret = std::env::var("JWT_SECRET")?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(20)
.connect(&database_url)
.await?;
let app_state = AppState {
db: pool,
jwt_secret,
};
let app = Router::new()
.route("/login", post(login))
.with_state(app_state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
Календарь освоения для команды
Месяц 1: основы
Недели 1–2: синтаксис и владение
- Базовые синтаксические отличия от C#
- Понимание владения, заимствования и времён жизни
- Небольшие упражнения: CLI-утилиты, обработка файлов
Недели 3–4: обработка ошибок и типы
Result<T, E>против исключенийOption<T>против nullable-типов- Сопоставление с образцом и проверка исчерпываемости
Рекомендуемые упражнения:
#![allow(unused)]
fn main() {
// Недели 1–2: обработчик файлов
fn process_log_file(path: &str) -> Result<Vec<String>, std::io::Error> {
let content = std::fs::read_to_string(path)?;
let errors: Vec<String> = content
.lines()
.filter(|line| line.contains("ERROR"))
.map(|line| line.to_string())
.collect();
Ok(errors)
}
// Недели 3–4: обработчик JSON с обработкой ошибок
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Debug)]
struct LogEntry {
timestamp: String,
level: String,
message: String,
}
fn parse_log_entries(json_str: &str) -> Result<Vec<LogEntry>, Box<dyn std::error::Error>> {
let entries: Vec<LogEntry> = serde_json::from_str(json_str)?;
Ok(entries)
}
}
Месяц 2: практическое применение
Недели 5–6: трейты и обобщения
- Система трейтов против интерфейсов
- Ограничения обобщений и границы
- Распространённые паттерны и идиомы
Недели 7–8: асинхронное программирование и конкурентность
- Сходства и различия
async/await - Каналы для коммуникации
- Гарантии потокобезопасности
Рекомендуемые проекты:
#![allow(unused)]
fn main() {
// Недели 5–6: обобщённый обработчик данных
trait DataProcessor<T> {
type Output;
type Error;
fn process(&self, data: T) -> Result<Self::Output, Self::Error>;
}
struct JsonProcessor;
impl DataProcessor<&str> for JsonProcessor {
type Output = serde_json::Value;
type Error = serde_json::Error;
fn process(&self, data: &str) -> Result<Self::Output, Self::Error> {
serde_json::from_str(data)
}
}
// Недели 7–8: асинхронный веб-клиент
async fn fetch_and_process_data(urls: Vec<&str>) -> Result<(), Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let tasks: Vec<_> = urls
.into_iter()
.map(|url| {
let client = client.clone();
tokio::spawn(async move {
let response = client.get(url).send().await?;
let text = response.text().await?;
println!("Fetched {} bytes from {}", text.len(), url);
Ok::<(), reqwest::Error>(())
})
})
.collect();
for task in tasks {
task.await??;
}
Ok(())
}
}
Месяц 3 и далее: интеграция в продакшен
Недели 9–12: работа над реальным проектом
- Выберите некритичный компонент для переписывания
- Реализуйте полноценную обработку ошибок
- Добавьте логирование, метрики и тестирование
- Профилирование производительности и оптимизация
Постоянно: командный ревью и менторство
- Код-ревью с фокусом на идиомах Rust
- Парное программирование
- Сессии обмена знаниями
16. Лучшие практики
Лучшие практики для разработчиков C#
Что вы узнаете: пять ключевых сдвигов мышления (GC→владение, исключения→Result, наследование→композиция), идиоматичную организацию проекта, стратегию обработки ошибок, паттерны тестирования и самые частые ошибки разработчиков C# в Rust.
Сложность: 🟡 Средний
1. Сдвиги мышления
- От GC к владению: думайте о том, кому принадлежат данные и когда их освобождают
- От исключений к Result: делайте обработку ошибок явной и видимой
- От наследования к композиции: используйте трейты для композиции поведения
- От null к Option: делайте отсутствие значения явным в системе типов
2. Организация кода
#![allow(unused)]
fn main() {
// Структурируем проекты, как решения (solutions) C#
src/
├── main.rs // Аналог Program.cs
├── lib.rs // Точка входа библиотеки
├── models/ // Аналог папки Models/ в C#
│ ├── mod.rs
│ ├── user.rs
│ └── product.rs
├── services/ // Аналог папки Services/
│ ├── mod.rs
│ ├── user_service.rs
│ └── product_service.rs
├── controllers/ // Аналог Controllers/ (для веб-приложений)
├── repositories/ // Аналог Repositories/
└── utils/ // Аналог Utilities/
}
3. Стратегия обработки ошибок
#![allow(unused)]
fn main() {
// Создаём общий тип Result для приложения
pub type AppResult<T> = Result<T, AppError>;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Validation error: {message}")]
Validation { message: String },
#[error("Business logic error: {message}")]
Business { message: String },
}
// Используем во всём приложении
pub async fn create_user(data: CreateUserRequest) -> AppResult<User> {
validate_user_data(&data)?; // Возвращает AppError::Validation
let user = repository.create_user(data).await?; // Возвращает AppError::Database
Ok(user)
}
}
4. Паттерны тестирования
#![allow(unused)]
fn main() {
// Структурируем тесты, как модульные тесты C#
#[cfg(test)]
mod tests {
use super::*;
use rstest::*; // Для параметризованных тестов, как [Theory] в C#
#[test]
fn test_basic_functionality() {
// Arrange
let input = "test data";
// Act
let result = process_data(input);
// Assert
assert_eq!(result, "expected output");
}
#[rstest]
#[case(1, 2, 3)]
#[case(5, 5, 10)]
#[case(0, 0, 0)]
fn test_addition(#[case] a: i32, #[case] b: i32, #[case] expected: i32) {
assert_eq!(add(a, b), expected);
}
#[tokio::test] // Для асинхронных тестов
async fn test_async_functionality() {
let result = async_function().await;
assert!(result.is_ok());
}
}
}
5. Частые ошибки, которых стоит избегать
#![allow(unused)]
fn main() {
// [ERROR] Не пытайтесь реализовать наследование
// Вместо:
// struct Manager : Employee // В Rust такого нет
// [OK] Используйте композицию через трейты
trait Employee {
fn get_salary(&self) -> u32;
}
trait Manager: Employee {
fn get_team_size(&self) -> usize;
}
// [ERROR] Не используйте unwrap() везде (это как игнорирование исключений)
let value = might_fail().unwrap(); // Может вызвать панику!
// [OK] Обрабатывайте ошибки правильно
let value = match might_fail() {
Ok(v) => v,
Err(e) => {
log::error!("Operation failed: {}", e);
return Err(e.into());
}
};
// [ERROR] Не клонируйте всё подряд (как лишнее копирование объектов)
let data = expensive_data.clone(); // Дорого!
// [OK] Используйте заимствование, когда возможно
let data = &expensive_data; // Всего лишь ссылка
// [ERROR] Не используйте RefCell повсюду (как превращение всего в изменяемое)
struct Data {
value: RefCell<i32>, // Внутренняя изменяемость — используйте осторожно
}
// [OK] Предпочитайте владеемые или заимствованные данные
struct Data {
value: i32, // Просто и понятно
}
}
Это руководство даёт разработчикам C# цельное представление о том, как их существующие знания переносятся на Rust, подчёркивая как сходства, так и принципиальные различия в подходе. Ключевое — понимать, что ограничения Rust (например, владение) призваны предотвратить целые классы ошибок, возможных в C#, ценой некоторой первоначальной сложности.
6. Избегаем избыточного clone() 🟡
Разработчики C# инстинктивно клонируют данные, потому что стоимость берёт на себя GC. В Rust каждый .clone() — это явное выделение памяти. Большинство из них можно убрать с помощью заимствования.
#![allow(unused)]
fn main() {
// [ERROR] Привычка из C#: клонировать строки, чтобы передавать их дальше
fn greet(name: String) {
println!("Hello, {name}");
}
let user_name = String::from("Alice");
greet(user_name.clone()); // ненужное выделение памяти
greet(user_name.clone()); // и снова
// [OK] Заимствуйте вместо этого — без выделений памяти
fn greet(name: &str) {
println!("Hello, {name}");
}
let user_name = String::from("Alice");
greet(&user_name); // заимствуем
greet(&user_name); // заимствуем снова — без затрат
}
Когда clone уместен:
- Перемещение данных в поток или в замыкание
'static(Arc::cloneдёшев — он лишь увеличивает счётчик) - Кэширование: вам действительно нужна независимая копия
- Прототипирование: сначала заставьте работать, потом уберите лишние clone
Чек-лист решения:
- Можно передать
&Tили&str? → Так и сделайте - Вызываемой стороне нужно владение? → Передавайте перемещением, а не клоном
- Данные разделяются между потоками? → Используйте
Arc<T>(clone — это лишь увеличение счётчика ссылок) - Ничего из перечисленного? →
clone()оправдан
7. Избегаем unwrap() в продакшен-коде 🟡
Разработчики C#, которые игнорируют исключения, пишут .unwrap() везде в Rust. И то и другое одинаково опасно.
#![allow(unused)]
fn main() {
// [ERROR] Ловушка «потом поправлю»
let config = std::fs::read_to_string("config.toml").unwrap();
let port: u16 = config_value.parse().unwrap();
let conn = db_pool.get().await.unwrap();
// [OK] Пробрасываем через ? в прикладном коде
let config = std::fs::read_to_string("config.toml")?;
let port: u16 = config_value.parse()?;
let conn = db_pool.get().await?;
// [OK] Используйте expect() только тогда, когда сбой — действительно ошибка в программе
let home = std::env::var("HOME")
.expect("HOME environment variable must be set"); // фиксирует инвариант
}
Эмпирическое правило:
| Метод | Когда использовать |
|---|---|
? | Код приложения и библиотек — передать вызывающему коду |
expect("reason") | Проверки при запуске, инварианты, которые обязаны выполняться |
unwrap() | Только в тестах или после проверки is_some()/is_ok() |
unwrap_or(default) | Когда есть разумное значение по умолчанию |
| `unwrap_or_else( |
8. Борьба с проверщиком заимствований (и как перестать бороться) 🟡
Каждый разработчик C# проходит этап, когда проверщик заимствований отвергает код, который кажется правильным. Исправление обычно требует изменения структуры, а не обходного манёвра.
#![allow(unused)]
fn main() {
// [ERROR] Попытка изменять коллекцию во время перебора (паттерн foreach + изменение из C#)
let mut items = vec![1, 2, 3, 4, 5];
for item in &items {
if *item > 3 {
items.push(*item * 2); // ОШИБКА: нельзя заимствовать items как изменяемое
}
}
// [OK] Сначала собираем, потом изменяем
let extras: Vec<i32> = items.iter()
.filter(|&&x| x > 3)
.map(|&x| x * 2)
.collect();
items.extend(extras);
}
#![allow(unused)]
fn main() {
// [ERROR] Возврат ссылки на локальную переменную (в C# ссылки свободно возвращаются благодаря GC)
fn get_greeting() -> &str {
let s = String::from("hello");
&s // ОШИБКА: s уничтожается в конце функции
}
// [OK] Возвращаем владеемые данные
fn get_greeting() -> String {
String::from("hello") // вызывающий код владеет ими
}
}
Распространённые паттерны, которые снимают конфликты с проверщиком заимствований:
| Привычка из C# | Решение в Rust |
|---|---|
| Хранить ссылки в структурах | Использовать владеемые данные или добавить параметры времён жизни |
| Свободно изменять разделяемое состояние | Использовать Arc<Mutex<T>> или перестроить код, чтобы не разделять данные |
| Возвращать ссылки на локальные переменные | Возвращать владеемые значения |
| Изменять коллекцию во время перебора | Сначала собрать изменения, потом применить |
| Несколько изменяемых ссылок | Разбить структуру на независимые части |
9. Устраняем «пирамиды» присваиваний 🟢
Разработчики C# пишут цепочки вида if (x != null) { if (x.Value > 0) { ... } }. Конструкции match, if let и ? в Rust позволяют выровнять такой код.
#![allow(unused)]
fn main() {
// [ERROR] Вложенный стиль проверок на null из C#
fn process(input: Option<String>) -> Option<usize> {
match input {
Some(s) => {
if !s.is_empty() {
match s.parse::<usize>() {
Ok(n) => {
if n > 0 {
Some(n * 2)
} else {
None
}
}
Err(_) => None,
}
} else {
None
}
}
None => None,
}
}
// [OK] Выравниваем с помощью комбинаторов
fn process(input: Option<String>) -> Option<usize> {
input
.filter(|s| !s.is_empty())
.and_then(|s| s.parse::<usize>().ok())
.filter(|&n| n > 0)
.map(|n| n * 2)
}
}
Ключевые комбинаторы, которые должен знать каждый разработчик C#:
| Комбинатор | Что делает | Аналог в C# |
|---|---|---|
map | Преобразует внутреннее значение | Select / null-условный ?. |
and_then | Связывает операции, возвращающие Option/Result | SelectMany / ?.Method() |
filter | Оставляет значение, только если предикат истинен | Where |
unwrap_or | Задаёт значение по умолчанию | ?? defaultValue |
ok() | Превращает Result в Option (отбрасывая ошибку) | — |
transpose | Меняет местами Option<Result> и Result<Option> | — |
Сравнение производительности и миграция
Сравнение производительности: управляемый и нативный код
Что вы узнаете: реальные различия в производительности между C# и Rust — время запуска, потребление памяти, бенчмарки пропускной способности, задачи, интенсивные по CPU, а также дерево решений о том, когда стоит мигрировать, а когда остаться на C#.
Сложность: 🟡 Средний
Реальные характеристики производительности
| Аспект | C# (.NET) | Rust | Влияние на производительность |
|---|---|---|---|
| Время запуска | 100–500 мс (JIT); 5–30 мс (.NET 8 AOT) | 1–10 мс (нативный бинарник) | 🚀 В 10–50 раз быстрее (относительно JIT) |
| Потребление памяти | +30–100% (накладные расходы GC и метаданных) | Базовый уровень (минимальный рантайм) | 💾 На 30–50% меньше RAM |
| Паузы GC | Периодические паузы 1–100 мс | Никогда (нет GC) | ⚡ Стабильная задержка |
| Загрузка CPU | +10–20% (накладные расходы GC и JIT) | Базовый уровень (прямое исполнение) | 🔋 На 10–20% эффективнее |
| Размер бинарника | 30–200 МБ (с рантаймом); 10–30 МБ (AOT с обрезкой) | 1–20 МБ (статический бинарник) | 📦 Компактнее развёртывание |
| Безопасность памяти | Проверки во время выполнения | Доказательства на этапе компиляции | 🛡️ Безопасность без накладных расходов |
| Производительность при конкурентности | Хорошая (при аккуратной синхронизации) | Отличная (конкурентность без страха) | 🏃 Превосходная масштабируемость |
Примечание о .NET 8+ AOT: нативная AOT-компиляция существенно сокращает разрыв во времени запуска (5–30 мс). По пропускной способности и памяти накладные расходы GC и паузы сохраняются. Оценивая миграцию, измеряйте производительность на вашей конкретной нагрузке — цифры из заголовков могут вводить в заблуждение.
Примеры бенчмарков
// C# — бенчмарк обработки JSON
public class JsonProcessor
{
public async Task<List<User>> ProcessJsonFile(string path)
{
var json = await File.ReadAllTextAsync(path);
var users = JsonSerializer.Deserialize<List<User>>(json);
return users.Where(u => u.Age > 18)
.OrderBy(u => u.Name)
.Take(1000)
.ToList();
}
}
// Типичная производительность: ~200 мс для файла 100 МБ
// Пиковое потребление памяти: ~500 МБ (накладные расходы GC)
// Размер бинарника: ~80 МБ (самодостаточный)
#![allow(unused)]
fn main() {
// Rust — эквивалентная обработка JSON
use serde::{Deserialize, Serialize};
use tokio::fs;
#[derive(Deserialize, Serialize)]
struct User {
name: String,
age: u32,
}
pub async fn process_json_file(path: &str) -> Result<Vec<User>, Box<dyn std::error::Error>> {
let json = fs::read_to_string(path).await?;
let mut users: Vec<User> = serde_json::from_str(&json)?;
users.retain(|u| u.age > 18);
users.sort_by(|a, b| a.name.cmp(&b.name));
users.truncate(1000);
Ok(users)
}
// Типичная производительность: ~120 мс для того же файла 100 МБ
// Пиковое потребление памяти: ~200 МБ (без накладных расходов GC)
// Размер бинарника: ~8 МБ (статический бинарник)
}
Задачи, интенсивные по CPU
// C# — математические вычисления
public class Mandelbrot
{
public static int[,] Generate(int width, int height, int maxIterations)
{
var result = new int[height, width];
Parallel.For(0, height, y =>
{
for (int x = 0; x < width; x++)
{
var c = new Complex(
(x - width / 2.0) * 4.0 / width,
(y - height / 2.0) * 4.0 / height);
result[y, x] = CalculateIterations(c, maxIterations);
}
});
return result;
}
}
// Производительность: ~2,3 секунды (машина с 8 ядрами)
// Память: ~500 МБ
#![allow(unused)]
fn main() {
// Rust — то же вычисление с Rayon
use rayon::prelude::*;
use num_complex::Complex;
pub fn generate_mandelbrot(width: usize, height: usize, max_iterations: u32) -> Vec<Vec<u32>> {
(0..height)
.into_par_iter()
.map(|y| {
(0..width)
.map(|x| {
let c = Complex::new(
(x as f64 - width as f64 / 2.0) * 4.0 / width as f64,
(y as f64 - height as f64 / 2.0) * 4.0 / height as f64,
);
calculate_iterations(c, max_iterations)
})
.collect()
})
.collect()
}
// Производительность: ~1,1 секунды (та же машина с 8 ядрами)
// Память: ~200 МБ
// В 2 раза быстрее при на 60% меньшем потреблении памяти
}
Когда выбирать каждый язык
Выбирайте C#, когда:
- Быстрая разработка критична — богатая экосистема инструментов
- Экспертиза команды в .NET — имеющиеся знания и навыки
- Интеграция с корпоративными системами — активное использование экосистемы Microsoft
- Умеренные требования к производительности — производительности достаточно
- Приложения с богатым UI — WPF, WinUI, Blazor
- Прототипы и MVP — быстрый выход на рынок
Выбирайте Rust, когда:
- Производительность критична — приложения, интенсивные по CPU и памяти
- Важны ограничения ресурсов — встраиваемые системы, edge-вычисления, serverless
- Долгоживущие сервисы — веб-серверы, базы данных, системные сервисы
- Системное программирование — компоненты ОС, драйверы, сетевые утилиты
- Высокие требования к надёжности — финансовые системы, системы критической безопасности
- Конкурентные и параллельные нагрузки — обработка данных с высокой пропускной способностью
Дерево решений о стратегии миграции
graph TD
START["Рассматриваете Rust?"]
PERFORMANCE["Производительность критична?"]
TEAM["У команды есть время на обучение?"]
EXISTING["Большая существующая кодовая база на C#?"]
NEW_PROJECT["Новый проект или компонент?"]
INCREMENTAL["Постепенное внедрение:<br/>• Сначала CLI-инструменты<br/>• Компоненты, критичные к производительности<br/>• Новые микросервисы"]
FULL_RUST["Полный переход на Rust:<br/>• Новые проекты с нуля<br/>• Системные сервисы<br/>• Высокопроизводительные API"]
STAY_CSHARP["Остаться на C#:<br/>• Оптимизировать существующий код<br/>• Использовать возможности .NET AOT / производительности<br/>• Рассмотреть .NET Native"]
START --> PERFORMANCE
PERFORMANCE -->|Да| TEAM
PERFORMANCE -->|Нет| STAY_CSHARP
TEAM -->|Да| EXISTING
TEAM -->|Нет| STAY_CSHARP
EXISTING -->|Да| NEW_PROJECT
EXISTING -->|Нет| FULL_RUST
NEW_PROJECT -->|Новый| FULL_RUST
NEW_PROJECT -->|Существующий| INCREMENTAL
style FULL_RUST fill:#c8e6c9,color:#000
style INCREMENTAL fill:#fff3e0,color:#000
style STAY_CSHARP fill:#e3f2fd,color:#000
Путь обучения и ресурсы
Путь обучения и следующие шаги
Что вы узнаете: структурированную дорожную карту обучения (недели 1–2, месяцы 1–3+), рекомендуемые книги и ресурсы, типичные ловушки для разработчиков C# (путаница с владением, борьба с проверщиком заимствований), а также структурированную наблюдаемость с
tracingпротивILogger.Сложность: 🟢 Начальный
Ближайшие шаги (недели 1–2)
-
Настройте окружение
- Установите Rust через rustup.rs
- Настройте VS Code с расширением rust-analyzer
- Создайте первый проект
cargo new hello_world
-
Освойте основы
- Практикуйте владение на простых упражнениях
- Пишите функции с разными типами параметров (
&str,String,&mut) - Реализуйте простые структуры и методы
-
Практика обработки ошибок
- Переведите код C# с try-catch на паттерны на основе Result
- Практикуйтесь с оператором
?и выражениямиmatch - Реализуйте собственные типы ошибок
Промежуточные цели (месяцы 1–2)
-
Коллекции и итераторы
- Освойте
Vec<T>,HashMap<K,V>иHashSet<T> - Изучите методы итераторов:
map,filter,collect,fold - Практикуйтесь: циклы
forпротив цепочек итераторов
- Освойте
-
Трейты и обобщения
- Реализуйте распространённые трейты:
Debug,Clone,PartialEq - Пишите обобщённые функции и структуры
- Разберитесь с границами трейтов и предложениями where
- Реализуйте распространённые трейты:
-
Структура проекта
- Организуйте код в модули
- Разберитесь с видимостью
pub - Работайте с внешними крейтами с crates.io
Продвинутые темы (месяцы 3 и далее)
-
Конкурентность
- Изучите трейты
SendиSync - Используйте
std::threadдля базового параллелизма - Изучите
tokioдля асинхронного программирования
- Изучите трейты
-
Управление памятью
- Разберитесь с
Rc<T>иArc<T>для разделяемого владения - Изучите, когда использовать
Box<T>для выделения в куче - Освойте времена жизни для сложных сценариев
- Разберитесь с
-
Реальные проекты
- Напишите CLI-утилиту с
clap - Создайте веб-API с
axumилиwarp - Напишите библиотеку и опубликуйте её на crates.io
- Напишите CLI-утилиту с
Рекомендуемые ресурсы для обучения
Книги
- «The Rust Programming Language» (бесплатно онлайн) — официальная книга
- «Rust by Example» (бесплатно онлайн) — примеры для практики
- «Programming Rust» Джима Блэнди — глубокое техническое изложение
Онлайн-ресурсы
- Rust Playground — попробовать код в браузере
- Rustlings — интерактивные упражнения
- Rust by Example — практические примеры
Практические проекты
- Калькулятор командной строки — практика с перечислениями и сопоставлением с образцом
- Организатор файлов — работа с файловой системой и обработкой ошибок
- Обработчик JSON — изучение serde и преобразования данных
- HTTP-сервер — понимание асинхронного программирования и сетей
- Библиотека для работы с базой данных — освоение трейтов, обобщений и обработки ошибок
Типичные ловушки для разработчиков C#
Путаница с владением
#![allow(unused)]
fn main() {
// НЕ НАДО: пытаться использовать перемещённые значения
fn wrong_way() {
let s = String::from("hello");
takes_ownership(s);
// println!("{}", s); // ОШИБКА: s было перемещено
}
// НАДО: используйте ссылки или клонируйте при необходимости
fn right_way() {
let s = String::from("hello");
borrows_string(&s);
println!("{}", s); // OK: s здесь по-прежнему принадлежит нам
}
fn takes_ownership(s: String) { /* s перемещается сюда */ }
fn borrows_string(s: &str) { /* s заимствуется сюда */ }
}
Борьба с проверщиком заимствований
#![allow(unused)]
fn main() {
// НЕ НАДО: несколько изменяемых ссылок одновременно
fn wrong_borrowing() {
let mut v = vec![1, 2, 3];
let r1 = &mut v;
// let r2 = &mut v; // ОШИБКА: нельзя заимствовать как изменяемое больше одного раза
}
// НАДО: ограничивайте область видимости изменяемых заимствований
fn right_borrowing() {
let mut v = vec![1, 2, 3];
{
let r1 = &mut v;
r1.push(4);
} // r1 выходит из области видимости здесь
let r2 = &mut v; // OK: других изменяемых заимствований нет
r2.push(5);
}
}
Ожидание значений null
#![allow(unused)]
fn main() {
// НЕ НАДО: ожидать поведения, похожего на null
fn no_null_in_rust() {
// let s: String = null; // В Rust НЕТ null!
}
// НАДО: явно используйте Option<T>
fn use_option_instead() {
let maybe_string: Option<String> = None;
match maybe_string {
Some(s) => println!("Got string: {}", s),
None => println!("No string available"),
}
}
}
Финальные советы
- Прислушивайтесь к компилятору — ошибки компилятора Rust полезны, а не враждебны
- Начинайте с малого — начните с простых программ и постепенно усложняйте
- Читайте чужой код — изучайте популярные крейты на GitHub
- Просите помощи — сообщество Rust дружелюбно и отзывчиво
- Практикуйтесь регулярно — концепции Rust становятся естественными с практикой
Помните: у Rust крутая кривая обучения, но она окупается безопасностью памяти, производительностью и конкурентностью без страха. Система владения, которая сначала кажется ограничивающей, становится мощным инструментом для написания корректных и эффективных программ.
Поздравляем! Теперь у вас есть прочная основа для перехода с C# на Rust. Начинайте с простых проектов, будьте терпеливы в процессе обучения и постепенно переходите к более сложным приложениям. Преимущества Rust в безопасности и производительности делают первоначальные инвестиции в обучение оправданными.
Структурированная наблюдаемость: tracing против ILogger и Serilog
Разработчики C# привыкли к структурированному логированию через ILogger, Serilog или NLog — где сообщения журнала несут типизированные пары ключ-значение. Крейт log в Rust даёт базовое логирование с уровнями, но tracing — производственный стандарт структурированной наблюдаемости со спанами, поддержкой асинхронности и распределённой трассировкой.
Почему tracing, а не log
| Возможность | Крейт log | Крейт tracing | Аналог в C# |
|---|---|---|---|
| Сообщения с уровнями | ✅ info!(), error!() | ✅ info!(), error!() | ILogger.LogInformation() |
| Структурированные поля | ❌ Только интерполяция строк | ✅ Типизированные пары ключ-значение | Serilog Log.Information("{User}", user) |
| Спаны (ограниченный контекст) | ❌ | ✅ #[instrument], span!() | ILogger.BeginScope() |
| Поддержка асинхронности | ❌ Контекст теряется на .await | ✅ Спаны сохраняются через .await | Activity / DiagnosticSource |
| Распределённая трассировка | ❌ | ✅ Интеграция с OpenTelemetry | System.Diagnostics.Activity |
| Несколько форматов вывода | Базовый | JSON, pretty, compact, OTLP | Приёмники (sinks) Serilog |
Начало работы
# Cargo.toml
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
Базовое использование: структурированное логирование
// Serilog в C#
Log.Information("Processing order {OrderId} for {Customer}, total {Total:C}",
orderId, customer.Name, order.Total);
// Вывод: Processing order 12345 for Alice, total $99.95
// JSON: {"OrderId": 12345, "Customer": "Alice", "Total": 99.95, ...}
#![allow(unused)]
fn main() {
use tracing::{info, warn, error, debug, instrument};
// Структурированные поля — типизированные, а не интерполированные в строку
info!(order_id = 12345, customer = "Alice", total = 99.95,
"Processing order");
// Вывод: INFO Processing order order_id=12345 customer="Alice" total=99.95
// JSON: {"order_id": 12345, "customer": "Alice", "total": 99.95, ...}
// Динамические значения
let order_id = 12345;
info!(order_id, "Order received"); // сокращение: имя поля = имя переменной
// Условные поля
if let Some(promo) = promo_code {
info!(order_id, promo_code = %promo, "Promo applied");
// ^ % означает использовать форматирование Display
// ? использовал бы форматирование Debug
}
}
Спаны: ключевая возможность для асинхронного кода
Спаны — это ограниченные контексты, которые переносят поля через вызовы функций и точки .await. Это как ILogger.BeginScope(), но безопасно для асинхронного кода.
// C# — Activity / BeginScope
using var activity = new Activity("ProcessOrder").Start();
activity.SetTag("order_id", orderId);
using (_logger.BeginScope(new Dictionary<string, object> { ["OrderId"] = orderId }))
{
_logger.LogInformation("Starting processing");
await ProcessPaymentAsync();
_logger.LogInformation("Payment complete"); // OrderId всё ещё в области видимости
}
#![allow(unused)]
fn main() {
use tracing::{info, instrument, Instrument};
// #[instrument] автоматически создаёт спан с аргументами функции в качестве полей
#[instrument(skip(db), fields(customer_name))]
async fn process_order(order_id: u64, db: &Database) -> Result<(), AppError> {
let order = db.get_order(order_id).await?;
// Динамически добавляем поле в текущий спан
tracing::Span::current().record("customer_name", &order.customer_name.as_str());
info!("Starting processing");
process_payment(&order).await?; // контекст спана сохраняется через .await!
info!(items = order.items.len(), "Payment complete");
Ok(())
}
// Каждое сообщение журнала внутри этой функции автоматически содержит:
// order_id=12345 customer_name="Alice"
// Даже во вложенных асинхронных вызовах!
// Ручное создание спана (как BeginScope)
async fn batch_process(orders: Vec<u64>, db: &Database) {
for order_id in orders {
let span = tracing::info_span!("process_order", order_id);
// .instrument(span) прикрепляет спан к future
process_order(order_id, db)
.instrument(span)
.await
.unwrap_or_else(|e| error!("Failed: {e}"));
}
}
}
Настройка подписчика (аналог приёмников Serilog)
#![allow(unused)]
fn main() {
use tracing_subscriber::{fmt, EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
fn init_tracing() {
// Разработка: читаемый человеком цветной вывод
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "my_app=debug,tower_http=info".into()))
.with(fmt::layer().pretty()) // Цветные, с отступами спаны
.init();
}
fn init_tracing_production() {
// Продакшен: вывод в JSON для агрегации логов (как JSON-приёмник Serilog)
tracing_subscriber::registry()
.with(EnvFilter::new("my_app=info"))
.with(fmt::layer().json()) // Структурированный JSON
.init();
// Вывод: {"timestamp":"...","level":"INFO","fields":{"order_id":123},...}
}
}
# Управление уровнями логирования через переменную окружения (как MinimumLevel в Serilog)
RUST_LOG=my_app=debug,hyper=warn cargo run
RUST_LOG=trace cargo run # всё подряд
Шпаргалка по переходу с Serilog на tracing
| Serilog / ILogger | tracing | Примечания |
|---|---|---|
Log.Information("{Key}", val) | info!(key = val, "message") | Поля типизированы, не интерполируются |
Log.ForContext("Key", val) | span.record("key", val) | Добавляет поля в текущий спан |
using BeginScope(...) | #[instrument] или info_span!() | Автоматически с #[instrument] |
.WriteTo.Console() | fmt::layer() | Читаемый человеком вывод |
.WriteTo.Seq() / .File() | fmt::layer().json() + перенаправление в файл | Или используйте tracing-appender |
.Enrich.WithProperty() | span!(Level::INFO, "name", key = val) | Поля спана |
LogEventLevel.Debug | tracing::Level::DEBUG | Та же концепция |
Деструктуризация {@Object} | field = ?value (Debug) или %value (Display) | ? = Debug, % = Display |
Интеграция с OpenTelemetry
# Для распределённой трассировки (как System.Diagnostics + экспортёр OTLP)
[dependencies]
tracing-opentelemetry = "0.22"
opentelemetry = "0.21"
opentelemetry-otlp = "0.14"
#![allow(unused)]
fn main() {
// Добавляем слой OpenTelemetry вместе с выводом в консоль
use tracing_opentelemetry::OpenTelemetryLayer;
fn init_otel() {
let tracer = opentelemetry_otlp::new_pipeline()
.tracing()
.with_exporter(opentelemetry_otlp::new_exporter().tonic())
.install_batch(opentelemetry_sdk::runtime::Tokio)
.expect("Failed to create OTLP tracer");
tracing_subscriber::registry()
.with(OpenTelemetryLayer::new(tracer)) // Отправляем спаны в Jaeger/Tempo
.with(fmt::layer()) // Также выводим в консоль
.init();
}
// Теперь спаны от #[instrument] автоматически становятся распределёнными трассами!
}
Экосистема инструментов Rust
Основные инструменты Rust для разработчиков C#
Что вы узнаете: инструменты разработки Rust и их аналоги в C# — Clippy (анализаторы Roslyn), rustfmt (dotnet format), cargo doc (XML-документация), cargo watch (dotnet watch) и расширения VS Code.
Сложность: 🟢 Начальный
Сравнение инструментов
| Инструмент C# | Аналог в Rust | Установка | Назначение |
|---|---|---|---|
| Анализаторы Roslyn | Clippy | rustup component add clippy | Линтинг и подсказки по стилю |
dotnet format | rustfmt | rustup component add rustfmt | Автоматическое форматирование |
| XML-комментарии к документации | cargo doc | Встроен | Генерация HTML-документации |
| OmniSharp / Roslyn | rust-analyzer | Расширение VS Code | Поддержка в IDE |
dotnet watch | cargo-watch | cargo install cargo-watch | Автоматическая пересборка при сохранении |
| — | cargo-expand | cargo install cargo-expand | Просмотр раскрытия макросов |
dotnet audit | cargo-audit | cargo install cargo-audit | Проверка уязвимостей безопасности |
Clippy: ваш автоматический код-ревьюер
# Запуск Clippy для проекта
cargo clippy
# Считать предупреждения ошибками (CI/CD)
cargo clippy -- -D warnings
# Автоматическое применение подсказок
cargo clippy --fix
#![allow(unused)]
fn main() {
// Clippy находит сотни антипаттернов:
// До Clippy:
if x == true { } // предупреждение: проверка равенства с bool
let _ = vec.len() == 0; // предупреждение: используйте .is_empty()
for i in 0..vec.len() { } // предупреждение: используйте .iter().enumerate()
// После применения подсказок Clippy:
if x { }
let _ = vec.is_empty();
for (i, item) in vec.iter().enumerate() { }
}
rustfmt: единообразное форматирование
# Форматирование всех файлов
cargo fmt
# Проверка форматирования без изменений (CI/CD)
cargo fmt -- --check
# rustfmt.toml — настройка форматирования (как .editorconfig)
max_width = 100
tab_spaces = 4
use_field_init_shorthand = true
cargo doc: генерация документации
# Генерация и открытие документации (включая зависимости)
cargo doc --open
# Запуск тестов документации
cargo test --doc
#![allow(unused)]
fn main() {
/// Вычисляет площадь круга.
///
/// # Аргументы
/// * `radius` — радиус круга (должен быть неотрицательным)
///
/// # Примеры
/// ```
/// let area = my_crate::circle_area(5.0);
/// assert!((area - 78.54).abs() < 0.01);
/// ```
///
/// # Паника
/// Паникует, если `radius` отрицательный.
pub fn circle_area(radius: f64) -> f64 {
assert!(radius >= 0.0, "radius must be non-negative");
std::f64::consts::PI * radius * radius
}
// Код внутри блоков /// ``` компилируется и запускается во время `cargo test`!
}
cargo watch: автоматическая пересборка
# Пересборка при изменении файлов (как dotnet watch)
cargo watch -x check # Только проверка типов (самый быстрый)
cargo watch -x test # Запуск тестов при сохранении
cargo watch -x 'run -- args' # Запуск программы при сохранении
cargo watch -x clippy # Линтинг при сохранении
cargo expand: просмотр того, что генерируют макросы
# Просмотр раскрытого вывода производных макросов
cargo expand --lib # Раскрыть lib.rs
cargo expand module_name # Раскрыть конкретный модуль
Рекомендуемые расширения VS Code
| Расширение | Назначение |
|---|---|
| rust-analyzer | Автодополнение кода, встроенные ошибки, рефакторинг |
| CodeLLDB | Отладчик (как отладчик Visual Studio) |
| Even Better TOML | Подсветка синтаксиса Cargo.toml |
| crates | Показ последних версий крейтов в Cargo.toml |
| Error Lens | Вывод ошибок и предупреждений прямо в строке |
Для более глубокого изучения продвинутых тем, упомянутых в этом руководстве, см. сопутствующие учебные материалы:
- Паттерны Rust — проекции Pin, пользовательские аллокаторы, арена-паттерны, структуры данных без блокировок и продвинутые паттерны unsafe
- Async Rust Training — подробно о tokio, безопасности отмены в асинхронном коде, обработке потоков и производственной архитектуре async
- Rust для программистов C/C++ — полезно, если в команде есть опыт работы с C++; рассматривает соответствие семантики перемещения, различия RAII и шаблоны против обобщений
- Rust для программистов C — актуально для сценариев взаимодействия; рассматривает паттерны FFI, отладку встраиваемого Rust и программирование с
no_std
17. Итоговый проект: CLI-утилита для погоды на Rust
Итоговый проект: CLI-утилита для погоды на Rust
Что вы узнаете: как объединить всё изученное — структуры, трейты, обработку ошибок, асинхронность, модули, serde и разбор аргументов командной строки — в работающее приложение на Rust. Это повторяет тот тип утилиты, который разработчик C# написал бы с
HttpClient,System.Text.JsonиSystem.CommandLine.Сложность: 🟡 Средний
Этот итоговый проект собирает концепции из всех частей книги. Вы создадите weather-cli — утилиту командной строки, которая получает данные о погоде из API и выводит их. Проект организован как мини-крейт с правильной структурой модулей, типами ошибок и тестами.
Обзор проекта
graph TD
CLI["main.rs<br/>разбор CLI через clap"] --> Client["client.rs<br/>reqwest + tokio"]
Client -->|"HTTP GET"| API["Погодный API"]
Client -->|"JSON → структура"| Model["weather.rs<br/>serde Deserialize"]
Model --> Display["display.rs<br/>fmt::Display"]
CLI --> Err["error.rs<br/>thiserror"]
Client --> Err
style CLI fill:#bbdefb,color:#000
style Err fill:#ffcdd2,color:#000
style Model fill:#c8e6c9,color:#000
Что вы создадите:
$ weather-cli --city "Seattle"
🌧 Seattle: 12°C, Overcast clouds
Влажность: 82% Ветер: 5.4 м/с
Используемые концепции:
| Глава книги | Концепция, используемая здесь |
|---|---|
| Гл. 05 (Структуры) | Типы данных WeatherReport, Config |
| Гл. 08 (Модули) | src/lib.rs, src/client.rs, src/display.rs |
| Гл. 09 (Ошибки) | Собственный WeatherError с thiserror |
| Гл. 10 (Трейты) | Реализация Display для форматированного вывода |
| Гл. 11 (From/Into) | Десериализация JSON через serde |
| Гл. 12 (Итераторы) | Обработка массивов из ответа API |
| Гл. 13 (Async) | reqwest + tokio для HTTP-запросов |
| Гл. 14-1 (Тестирование) | Модульные тесты и интеграционный тест |
Шаг 1: настройка проекта
cargo new weather-cli
cd weather-cli
Добавьте зависимости в Cargo.toml:
[package]
name = "weather-cli"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] } # Аргументы CLI (как System.CommandLine)
reqwest = { version = "0.12", features = ["json"] } # HTTP-клиент (как HttpClient)
serde = { version = "1", features = ["derive"] } # Сериализация (как System.Text.Json)
serde_json = "1"
thiserror = "2" # Типы ошибок
tokio = { version = "1", features = ["full"] } # Асинхронный рантайм
// Эквивалентные зависимости в C#:
// dotnet add package System.CommandLine
// dotnet add package System.Net.Http.Json
// (System.Text.Json и HttpClient входят в состав платформы)
Шаг 2: определите типы данных
Создайте src/weather.rs:
#![allow(unused)]
fn main() {
use serde::Deserialize;
/// Сырой ответ API (повторяет структуру JSON)
#[derive(Deserialize, Debug)]
pub struct ApiResponse {
pub main: MainData,
pub weather: Vec<WeatherCondition>,
pub wind: WindData,
pub name: String,
}
#[derive(Deserialize, Debug)]
pub struct MainData {
pub temp: f64,
pub humidity: u32,
}
#[derive(Deserialize, Debug)]
pub struct WeatherCondition {
pub description: String,
pub icon: String,
}
#[derive(Deserialize, Debug)]
pub struct WindData {
pub speed: f64,
}
/// Наш доменный тип (чистый, отделённый от API)
#[derive(Debug, Clone)]
pub struct WeatherReport {
pub city: String,
pub temp_celsius: f64,
pub description: String,
pub humidity: u32,
pub wind_speed: f64,
}
impl From<ApiResponse> for WeatherReport {
fn from(api: ApiResponse) -> Self {
let description = api.weather
.first()
.map(|w| w.description.clone())
.unwrap_or_else(|| "Unknown".to_string());
WeatherReport {
city: api.name,
temp_celsius: api.main.temp,
description,
humidity: api.main.humidity,
wind_speed: api.wind.speed,
}
}
}
}
// Эквивалент в C#:
// public record ApiResponse(MainData Main, List<WeatherCondition> Weather, ...);
// public record WeatherReport(string City, double TempCelsius, ...);
// Ручное маппирование или AutoMapper
Ключевое отличие: #[derive(Deserialize)] + реализация From заменяют JsonSerializer.Deserialize<T>() + AutoMapper. В Rust оба этих шага выполняются на этапе компиляции — без рефлексии.
Шаг 3: тип ошибок
Создайте src/error.rs:
#![allow(unused)]
fn main() {
use thiserror::Error;
#[derive(Error, Debug)]
pub enum WeatherError {
#[error("Ошибка HTTP-запроса: {0}")]
Http(#[from] reqwest::Error),
#[error("Город не найден: {0}")]
CityNotFound(String),
#[error("Ключ API не задан — выполните export WEATHER_API_KEY")]
MissingApiKey,
}
pub type Result<T> = std::result::Result<T, WeatherError>;
}
Шаг 4: HTTP-клиент
Создайте src/client.rs:
#![allow(unused)]
fn main() {
use crate::error::{WeatherError, Result};
use crate::weather::{ApiResponse, WeatherReport};
pub struct WeatherClient {
api_key: String,
http: reqwest::Client,
}
impl WeatherClient {
pub fn new(api_key: String) -> Self {
WeatherClient {
api_key,
http: reqwest::Client::new(),
}
}
pub async fn get_weather(&self, city: &str) -> Result<WeatherReport> {
let url = format!(
"https://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units=metric",
city, self.api_key
);
let response = self.http.get(&url).send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(WeatherError::CityNotFound(city.to_string()));
}
let api_data: ApiResponse = response.json().await?;
Ok(WeatherReport::from(api_data))
}
}
}
// Эквивалент в C#:
// var response = await _httpClient.GetAsync(url);
// if (response.StatusCode == HttpStatusCode.NotFound)
// throw new CityNotFoundException(city);
// var data = await response.Content.ReadFromJsonAsync<ApiResponse>();
Ключевые отличия:
- Оператор
?заменяетtry/catch— ошибки распространяются автоматически черезResult WeatherReport::from(api_data)использует трейтFromвместо AutoMapperIHttpClientFactoryне нужен —reqwest::Clientсам управляет пулом соединений
Шаг 5: форматирование вывода
Создайте src/display.rs:
#![allow(unused)]
fn main() {
use std::fmt;
use crate::weather::WeatherReport;
impl fmt::Display for WeatherReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let icon = weather_icon(&self.description);
writeln!(f, "{} {}: {:.0}°C, {}",
icon, self.city, self.temp_celsius, self.description)?;
write!(f, " Влажность: {}% Ветер: {:.1} м/с",
self.humidity, self.wind_speed)
}
}
fn weather_icon(description: &str) -> &str {
let desc = description.to_lowercase();
if desc.contains("clear") { "☀️" }
else if desc.contains("cloud") { "☁️" }
else if desc.contains("rain") || desc.contains("drizzle") { "🌧" }
else if desc.contains("snow") { "❄️" }
else if desc.contains("thunder") { "⛈" }
else { "🌡" }
}
}
Шаг 6: собираем всё вместе
src/lib.rs:
#![allow(unused)]
fn main() {
pub mod client;
pub mod display;
pub mod error;
pub mod weather;
}
src/main.rs:
use clap::Parser;
use weather_cli::{client::WeatherClient, error::WeatherError};
#[derive(Parser)]
#[command(name = "weather-cli", about = "Получение погоды из командной строки")]
struct Cli {
/// Название города для поиска
#[arg(short, long)]
city: String,
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let api_key = match std::env::var("WEATHER_API_KEY") {
Ok(key) => key,
Err(_) => {
eprintln!("Ошибка: {}", WeatherError::MissingApiKey);
std::process::exit(1);
}
};
let client = WeatherClient::new(api_key);
match client.get_weather(&cli.city).await {
Ok(report) => println!("{report}"),
Err(WeatherError::CityNotFound(city)) => {
eprintln!("Город не найден: {city}");
std::process::exit(1);
}
Err(e) => {
eprintln!("Ошибка: {e}");
std::process::exit(1);
}
}
}
Шаг 7: тесты
#![allow(unused)]
fn main() {
// В src/weather.rs или tests/weather_test.rs
#[cfg(test)]
mod tests {
use super::*;
fn sample_api_response() -> ApiResponse {
serde_json::from_str(r#"{
"main": {"temp": 12.3, "humidity": 82},
"weather": [{"description": "overcast clouds", "icon": "04d"}],
"wind": {"speed": 5.4},
"name": "Seattle"
}"#).unwrap()
}
#[test]
fn api_response_to_weather_report() {
let report = WeatherReport::from(sample_api_response());
assert_eq!(report.city, "Seattle");
assert!((report.temp_celsius - 12.3).abs() < 0.01);
assert_eq!(report.description, "overcast clouds");
}
#[test]
fn display_format_includes_icon() {
let report = WeatherReport {
city: "Test".into(),
temp_celsius: 20.0,
description: "clear sky".into(),
humidity: 50,
wind_speed: 3.0,
};
let output = format!("{report}");
assert!(output.contains("☀️"));
assert!(output.contains("20°C"));
}
#[test]
fn empty_weather_array_defaults_to_unknown() {
let json = r#"{
"main": {"temp": 0.0, "humidity": 0},
"weather": [],
"wind": {"speed": 0.0},
"name": "Nowhere"
}"#;
let api: ApiResponse = serde_json::from_str(json).unwrap();
let report = WeatherReport::from(api);
assert_eq!(report.description, "Unknown");
}
}
}
Итоговая структура файлов
weather-cli/
├── Cargo.toml
├── src/
│ ├── main.rs # Точка входа CLI (clap)
│ ├── lib.rs # Объявления модулей
│ ├── client.rs # HTTP-клиент (reqwest + tokio)
│ ├── weather.rs # Типы данных + реализация From + тесты
│ ├── display.rs # Форматирование вывода
│ └── error.rs # WeatherError + псевдоним Result
└── tests/
└── integration.rs # Интеграционные тесты
Сравните с эквивалентом на C#:
WeatherCli/
├── WeatherCli.csproj
├── Program.cs
├── Services/
│ └── WeatherClient.cs
├── Models/
│ ├── ApiResponse.cs
│ └── WeatherReport.cs
└── Tests/
└── WeatherTests.cs
Версия на Rust удивительно похожа по структуре. Основные различия:
- Объявления
modвместо пространств имён Result<T, E>вместо исключений- Трейт
Fromвместо AutoMapper - Явный
#[tokio::main]вместо встроенного асинхронного рантайма
Бонус: заготовка интеграционного теста
Создайте tests/integration.rs, чтобы проверить публичный API без обращения к настоящему серверу:
#![allow(unused)]
fn main() {
// tests/integration.rs
use weather_cli::weather::WeatherReport;
#[test]
fn weather_report_display_roundtrip() {
let report = WeatherReport {
city: "Seattle".into(),
temp_celsius: 12.3,
description: "overcast clouds".into(),
humidity: 82,
wind_speed: 5.4,
};
let output = format!("{report}");
assert!(output.contains("Seattle"));
assert!(output.contains("12°C"));
assert!(output.contains("82%"));
}
}
Запустите cargo test — Rust автоматически находит тесты и в src/ (модули #[cfg(test)]), и в tests/ (интеграционные тесты). Никакой настройки тестового фреймворка не требуется — сравните с настройкой xUnit/NUnit в C#.
Задания для продолжения
Когда всё заработает, попробуйте эти задания, чтобы углубить навыки:
-
Добавьте кэширование — сохраняйте последний ответ API в файл. При запуске проверяйте, не старше ли он 10 минут, и если нет — пропускайте HTTP-запрос. Это упражнение на
std::fs,serde_json::to_writerиSystemTime. -
Добавьте поддержку нескольких городов — принимайте
--city "Seattle,Portland,Vancouver"и загружайте все города конкурентно черезtokio::join!. Это упражнение на конкурентное асинхронное программирование. -
Добавьте флаг
--format json— выводите отчёт в формате JSON вместо читаемого текста с помощьюserde_json::to_string_pretty. Это упражнение на условное форматирование иSerialize. -
Напишите интеграционный тест — создайте
tests/integration.rs, который проверяет полный сценарий с имитацией HTTP-сервера черезwiremock. Это упражнение на шаблон каталогаtests/из главы 14-1.