Aller au contenu

Rust pour les développeurs C#

Si vous écrivez du C# au quotidien, bonne nouvelle : une bonne partie de Rust va vous sembler familière. Rust a des génériques avec contraintes, async/await, un pattern matching (filtrage par motif) très puissant, des chaînes d’itérateurs qui ressemblent beaucoup à LINQ, et un excellent outil en ligne de commande (Cargo) qui fait ce que fait dotnet. Vous savez déjà raisonner en types, en interfaces et en lambdas. Tout ça vous servira.

Le grand changement concerne la mémoire et les erreurs. Rust n’a pas de ramasse-miettes (garbage collector). À la place, chaque valeur a un seul propriétaire, et le compilateur vérifie l’ownership (possession) et le borrowing (emprunt) avant même que votre programme ne s’exécute. Rust n’a pas non plus de null (vous utilisez Option) ni d’exceptions (vous utilisez Result). C’est sur ces trois changements que vous passerez le plus de temps. Cette page fait le lien entre ce que vous connaissez de C# et .NET et Rust, pour que vous puissiez consacrer ce temps à ce qui est vraiment nouveau.

Si vous cherchez plutôt une comparaison générale avec C++, Go et Python, voyez Rust vs C++, Go et Python.

Utilisez ce tableau comme un guide de conversation. La dernière colonne renvoie à la leçon de ce site qui explique le côté Rust.

C# Rust Pour en savoir plus
class avec des champs et des méthodes struct pour les données + un bloc impl pour les méthodes Structs
interface trait Traits
Classe abstraite, héritage Pas d’héritage. Utilisez la composition (une struct dans une struct) + des traits avec des méthodes par défaut Traits, Patrons de conception
List<IShape> de classes différentes Vec<Box<dyn Shape>> (un trait object) Trait objects
Méthodes d’extension Votre propre trait, implémenté pour un type que vous n’avez pas écrit (comme String) Traits
Génériques avec where T : IComparable<T> Génériques avec des contraintes de trait : T: Ord ou where T: Ord Génériques
null, Nullable<int>, types référence nullables Option<T> : soit Some(value), soit None Option
?? et ?. .unwrap_or(...), .map(...), .and_then(...) et ? Option
Exceptions, throw, try/catch Result<T, E> + l’opérateur ? Result, Erreurs personnalisées
Une exception pour un vrai bug (comme InvalidOperationException) panic! (arrête le thread) Panic
IDisposable + using Drop, appelé automatiquement quand le propriétaire sort de la portée (RAII) Portée, Pointeurs intelligents
Ramasse-miettes Ownership et borrowing, vérifiés à la compilation Ownership, Borrowing
record struct avec #[derive(Debug, Clone, PartialEq)] Structs, Traits courants
Expression with sur un record Syntaxe de mise à jour de struct : Order { total: 5, ..old } Structs
enum (uniquement des entiers nommés) enum dont chaque variante peut porter ses propres données Enums
Expressions switch, motifs is match (le compilateur vérifie que vous avez couvert tous les cas) match, Pattern matching
Tuples (int, string) Tuples (i32, String) Tuples
Paramètres ref / out &mut T, ou renvoyer un tuple ou une Option Borrowing
Lambdas, Func<T>, Action<T>, délégués Closures, décrites par les traits Fn, FnMut, FnOnce Closures, Fn, FnMut et FnOnce
event Un canal (channel), ou une liste de closures dans des Box (callbacks) Canaux, Fn, FnMut et FnOnce
LINQ : Where, Select, Sum, OrderBy, ToList Adaptateurs d’itérateurs : filter, map, sum, sort_by (sur un Vec), collect Itérateurs, Trier et rechercher
IEnumerable<T> et yield return impl Iterator<Item = T>, ou votre propre type qui implémente Iterator Itérateurs personnalisés
async Task<T> / await async fn renvoie un Future ; il faut un runtime comme tokio pour l’exécuter Async/Await
Task.Run, Thread, lock std::thread::spawn ou tokio::spawn ; Mutex<T> enveloppe les données qu’il protège Concurrence, État partagé
Propriétés (get; set;) Pas de propriétés. Utilisez des champs pub, ou des méthodes getter et setter Structs
string et StringBuilder &str (une vue empruntée sur du texte) et String (possédée, qui peut grandir) Chaînes
Interpolation de chaînes $"Hi {name}" format!("Hi {name}") et println!("Hi {name}") Afficher du texte
var let (immuable par défaut ; écrivez let mut pour autoriser les modifications) Variables
const, readonly const, et un simple let (déjà en lecture seule) Variables
Champ static readonly static, ou LazyLock pour des valeurs construites au démarrage Variables
namespace et directives using Modules (mod) et use Modules
Assembly / projet Crate (une bibliothèque ou un binaire) Cargo et les crates
Attributs comme [Serializable] Attributs comme #[derive(Serialize)] et macros derive Macros, JSON avec Serde
Réflexion Quasiment rien à l’exécution. Les macros et derive génèrent du code à la compilation à la place Macros
unsafe et pointeurs Blocs unsafe et pointeurs bruts Unsafe en Rust
Span<T>, ReadOnlySpan<char> Slices : &[T] et &str Slices
Redéfinition de ToString(), IFormattable Implémenter Display (et .to_string() marche gratuitement) Traits courants
IEquatable<T>, IComparable<T>, GetHashCode PartialEq/Eq, PartialOrd/Ord, Hash (en général dérivés) Traits courants
Opérateurs de conversion implicit / explicit From/Into (et TryFrom quand la conversion peut échouer) Conversions
Surcharge d’opérateurs (operator +) Implémenter des traits de std::ops, comme Add Traits avancés
Arithmétique checked / unchecked checked_add, wrapping_add, saturating_add Dépassement d’entier
decimal Pas de type décimal intégré ; utilisez la crate rust_decimal, ou stockez des centimes dans un entier Types de données

La bibliothèque standard couvre presque tout ce que contient System.Collections.Generic. Les noms changent, les idées restent.

C# Rust Pour en savoir plus
List<T> Vec<T> Vecteurs
T[] (longueur fixe) [T; N] quand la longueur est connue à la compilation, sinon Vec<T> Tableaux
Dictionary<K, V> HashMap<K, V> HashMap
SortedDictionary<K, V> BTreeMap<K, V> (les clés restent triées) HashMap
HashSet<T> HashSet<T> HashSet
SortedSet<T> BTreeSet<T> HashSet
Queue<T> VecDeque<T> (push_back / pop_front) VecDeque et BinaryHeap
Stack<T> Vec<T> avec push / pop Vecteurs
PriorityQueue<TElement, TPriority> BinaryHeap<T> (le plus grand d’abord ; enveloppez dans Reverse pour avoir le plus petit d’abord) VecDeque et BinaryHeap
LinkedList<T> LinkedList<T> existe, mais vous en aurez rarement besoin ; VecDeque est généralement plus rapide VecDeque et BinaryHeap
ConcurrentDictionary<K, V> Mutex<HashMap<K, V>>, ou la crate dashmap État partagé

Un point d’attention : comme Dictionary, une HashMap Rust n’a pas d’ordre fixe. Si vous affichez son contenu, triez-le d’abord ou utilisez une BTreeMap.

C’est la partie que la plupart des développeurs C# apprécient tout de suite. Cargo fait le travail de la CLI dotnet, de MSBuild et de NuGet en un seul outil.

.NET Rust Remarques
CLI dotnet cargo Compile, exécute, teste, ajoute des paquets, publie. Voir Cargo et les crates
.csproj Cargo.toml Court et facile à modifier à la main
packages.lock.json Cargo.lock Créé pour vous
.sln avec plusieurs projets Un workspace Cargo Un Cargo.toml à la racine liste les crates membres
NuGet / nuget.org crates.io La documentation de l’API de chaque crate publiée est sur docs.rs
dotnet add package Serilog cargo add tracing
dotnet new console cargo new my_app (cargo new --lib pour une bibliothèque)
dotnet run / build / test cargo run / cargo build / cargo test
-c Release --release Les builds debug sont beaucoup plus lents à l’exécution ; mesurez les performances en release
dotnet watch cargo watch ou bacon (installés avec cargo install)
dotnet tool install -g cargo install
global.json (version du SDK) rustup et un fichier rust-toolchain.toml
Analyseurs Roslyn cargo clippy Des centaines de lints utiles, officiels
dotnet format cargo fmt Un seul style standard pour tout le monde
Visual Studio / Rider VS Code + rust-analyzer, ou JetBrains RustRover rust-analyzer offre la complétion, l’accès à la définition et les erreurs dans le code
Commentaires de doc XML /// Commentaires de doc /// en Markdown + cargo doc Les exemples de code dans la doc sont exécutés comme des tests. Voir Documentation
xUnit / NUnit / MSTest #[test] et cargo test intégrés (cargo nextest est un lanceur plus rapide très populaire) Voir Les tests
Moq mockall, ou de fausses implémentations écrites à la main qui implémentent votre trait
Coverlet (couverture de code) cargo llvm-cov ou cargo tarpaulin
BenchmarkDotNet criterion Voir Performance
dotnet-trace, profileurs cargo flamegraph, perf, ou n’importe quel profileur natif

Voici un petit Cargo.toml, pour que vous puissiez le comparer à un .csproj :

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

Bibliothèques et frameworks : « on utilise quoi à la place de… ? »

Section intitulée « Bibliothèques et frameworks : « on utilise quoi à la place de… ? » »

C’est la première question que pose chaque équipe .NET. « On utilise Entity Framework. Qu’est-ce qu’on a en Rust ? » Voici les équivalents les plus proches. Rust n’a pas une seule entreprise derrière une « stack par défaut », comme .NET avec Microsoft : vous choisirez donc souvent entre deux ou trois bonnes crates de la communauté.

.NET Rust Remarques
Entity Framework Core Diesel : ORM et générateur de requêtes ; vos requêtes sont vérifiées par le système de types à la compilation. Migrations avec diesel_cli Mature (2.x), synchrone par défaut (diesel-async existe)
Entity Framework Core (async, avec entités) SeaORM : ORM async avec entités, relations et migrations (sea-orm-cli) Le plus proche d’EF ; construit sur sqlx
Dapper sqlx : vous écrivez du SQL brut, et la macro query! le vérifie à la compilation contre votre vraie base de données. Migrations avec sqlx-cli Async, très populaire, pas un ORM
ADO.NET sqlx, tokio-postgres, rusqlite (SQLite) Pilotes de plus bas niveau
StackExchange.Redis redis

Si vous aimiez EF, essayez d’abord SeaORM. Si vous aimiez Dapper et écrire votre propre SQL, essayez sqlx. Si vous voulez les garanties les plus fortes à la compilation, essayez Diesel. La page Les bases de données en Rust montre les trois avec du code qui fonctionne.

.NET Rust Remarques
ASP.NET Core Web API axum (nous en construisons une dans Projet : API web avec Axum), actix-web, Rocket axum et actix-web sont très utilisés en production
Minimal APIs axum : les routes sont de simples handlers async fn Le style est très proche
Pipeline de middlewares Les layers tower et la crate tower-http (CORS, compression, traçage, timeouts) axum utilise tower directement
Injection de dépendances intégrée State dans axum, de simples constructeurs, et des traits Voir la ligne sur l’injection de dépendances plus bas
Swashbuckle / OpenAPI utoipa
SignalR Les WebSockets dans axum (la feature ws), ou tokio-tungstenite Pas de couche de protocole à la SignalR intégrée
gRPC (Grpc.AspNetCore) tonic (avec prost pour protobuf)
HttpClient reqwest Async par défaut, avec un client bloquant en option
Authentification JWT bearer jsonwebtoken
.NET Rust Remarques
System.Text.Json / Newtonsoft.Json serde + serde_json La référence ; gère aussi TOML, YAML, et d’autres formats. Voir JSON avec Serde
ILogger / Serilog tracing (+ tracing-subscriber), ou log + env_logger Journalisation structurée avec des spans. Voir Journalisation
IConfiguration / appsettings.json config ou figment Superposez des fichiers et des variables d’environnement, puis désérialisez avec serde
Conteneur d’injection de dépendances (IServiceCollection) En général rien : passez les dépendances aux constructeurs, et utilisez des traits pour ce que vous voulez remplacer dans les tests shaku existe, mais peu de projets utilisent un conteneur
AutoMapper Écrivez des implémentations de From / Into Explicite et vérifié par le compilateur. Voir Conversions
FluentValidation / DataAnnotations validator ou garde (validation par derive)
Polly (nouvelles tentatives) backon, ou le middleware retry de tower
MediatR Pas d’équivalent direct De simples traits, ou des canaux entre les tâches
Hangfire / tâches en arrière-plan tokio::spawn pour des tâches dans le processus ; apalis pour des files de tâches persistantes
CancellationToken Abandonner (drop) le future, ou utiliser CancellationToken de tokio-util
.NET Rust Remarques
Blazor WebAssembly Leptos, Yew, Dioxus Tous avant la version 1.0 ; les API changent encore. Voir Rust et WebAssembly
WPF / WinForms / MAUI Tauri (interface web + backend Rust), Slint, egui, Iced Tauri 2 et Slint 1.x sont les plus stables. Pas de toolkit graphique officiel
Unity Bevy, ou Rust dans Godot avec la crate godot Bevy est avant la 1.0 et casse son API d’une version à l’autre
ML.NET candle, burn, linfa Beaucoup plus jeunes que les outils de ML de Python
Azure SDK for .NET Le SDK Azure officiel pour Rust : azure_core, azure_identity, azure_storage_blob, … Couvre moins de services que le SDK .NET

Côte à côte : une classe devient une struct + impl

Section intitulée « Côte à côte : une classe devient une struct + impl »

Passons au vrai code. Voici un petit compte bancaire en C# :

var account = new BankAccount("Ada", 100);
account.Deposit(50);
Console.WriteLine($"{account.Owner} a {account.Balance}");
public class BankAccount
{
public string Owner { get; }
private decimal balance;
public BankAccount(string owner, decimal balance)
{
Owner = owner;
this.balance = balance;
}
public void Deposit(decimal amount) => balance += amount;
public decimal Balance => balance;
}

Et en Rust :

struct BankAccount {
owner: String,
balance: i64,
}
impl BankAccount {
// `new` est juste une convention, pas un mot-clé.
fn new(owner: &str, balance: i64) -> Self {
BankAccount { owner: owner.to_string(), balance }
}
// `&mut self` : cette méthode modifie le compte.
fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// `&self` : cette méthode ne fait que lire.
fn balance(&self) -> i64 {
self.balance
}
}
fn main() {
let mut account = BankAccount::new("Ada", 100);
account.deposit(50);
println!("{} a {}", account.owner, account.balance());
}

Sortie :

Ada a 150
▶ Essayez par vous-même

Les données et le comportement vivent dans deux blocs séparés. Il n’y a pas de constructeurs, seulement des fonctions qui renvoient Self. Chaque méthode annonce d’emblée si elle lit (&self) ou modifie (&mut self) la valeur. Et comme deposit modifie le compte, la variable doit être déclarée avec let mut. En C#, n’importe quelle méthode peut modifier n’importe quel champ ; en Rust, les modifications se voient dans la signature.

En C#, vous pourriez avoir une interface IShape avec deux classes et une List<IShape> :

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

En Rust, l’interface est un trait, et chaque struct l’implémente dans son propre bloc impl :

trait Shape {
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
fn main() {
// Box<dyn Shape> joue le rôle de « une référence vers un IShape quelconque ».
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 1.0 }),
Box::new(Rectangle { width: 2.0, height: 3.0 }),
];
for shape in &shapes {
println!("{:.2}", shape.area());
}
}

Sortie :

3.14
6.00
▶ Essayez par vous-même

En C#, chaque appel via une interface passe par un appel virtuel. En Rust, vous avez le choix : Box<dyn Shape> utilise un appel virtuel (voir Trait objects), alors qu’une fonction générique comme fn print_area<T: Shape>(s: &T) est compilée séparément pour chaque type, sans aucun appel virtuel. Petit bonus : vous pouvez implémenter votre propre trait pour des types que vous n’avez pas écrits, même String ou i32. C’est comme ça que Rust fait ce que font les méthodes d’extension en C#.

Avec les types référence nullables, C# vous avertit à propos de null, mais null existe toujours à l’exécution :

string? FindEmail(int userId) => userId == 1 ? "[email protected]" : null;
var email = FindEmail(2);
if (email is not null) Console.WriteLine(email.ToUpper());
else Console.WriteLine("aucune adresse e-mail enregistrée");
Console.WriteLine(FindEmail(2) ?? "inconnue");

En Rust, une valeur qui peut manquer a le type Option<T>. Vous ne pouvez pas l’utiliser comme une valeur ordinaire tant que vous n’avez pas traité le cas None :

fn find_email(user_id: u32) -> Option<String> {
if user_id == 1 {
Some("[email protected]".to_string())
} else {
None
}
}
fn main() {
match find_email(1) {
Some(email) => println!("{}", email.to_uppercase()),
None => println!("aucune adresse e-mail enregistrée"),
}
// `if let` est la forme courte quand un seul cas vous intéresse.
if let Some(email) = find_email(2) {
println!("trouvée : {email}");
} else {
println!("aucune adresse e-mail enregistrée");
}
// unwrap_or fonctionne comme l'opérateur ?? de C#.
let email = find_email(2).unwrap_or("inconnue".to_string());
println!("{email}");
}

Sortie :

aucune adresse e-mail enregistrée
inconnue
▶ Essayez par vous-même

Il n’y a pas de NullReferenceException en Rust, parce qu’il n’y a pas de null à déréférencer. Le compilateur refuse tout simplement que vous appeliez .to_uppercase() sur une Option<String>. Découvrez les méthodes utiles sur la page Option, et les formes courtes sur If Let / While Let.

En C#, l’analyse d’une chaîne peut lever une exception, et vous l’attrapez quelque part plus haut dans la pile d’appels :

static int TotalSeats(string a, string b) => int.Parse(a) + int.Parse(b);
try
{
Console.WriteLine(TotalSeats("12", "30"));
Console.WriteLine(TotalSeats("12", "trente"));
}
catch (FormatException e)
{
Console.WriteLine($"Erreur : {e.Message}");
}

En Rust, une fonction qui peut échouer le dit dans son type de retour. L’opérateur ? veut dire « si ça a échoué, renvoie l’erreur à mon appelant tout de suite ». Il fait le même travail qu’une exception qui remonte, mais vous le voyez dans le code :

use std::num::ParseIntError;
fn total_seats(a: &str, b: &str) -> Result<i32, ParseIntError> {
let first: i32 = a.parse()?;
let second: i32 = b.parse()?;
Ok(first + second)
}
fn main() {
for (a, b) in [("12", "30"), ("12", "trente")] {
match total_seats(a, b) {
Ok(total) => println!("Total : {total}"),
Err(e) => println!("Erreur : {e}"),
}
}
}

Sortie :

Total : 42
Erreur : invalid digit found in string
▶ Essayez par vous-même

Le message « invalid digit found in string » (chiffre invalide trouvé dans la chaîne) vient de la bibliothèque standard de Rust : il reste donc en anglais. La grande différence : en C#, n’importe quelle méthode peut lever une exception, et sa signature ne vous le dit pas. En Rust, Result fait partie de la signature, donc les appelants ne peuvent pas oublier de le traiter. Pour vos propres types d’erreur, voyez Erreurs personnalisées. Dans les applications, la crate anyhow vous donne quelque chose de proche de « attraper n’importe quelle exception » (voir anyhow et thiserror).

Côte à côte : LINQ devient une chaîne d’itérateurs

Section intitulée « Côte à côte : LINQ devient une chaîne d’itérateurs »

Une requête LINQ typique :

var orders = new List<Order> { new("Ada", 120), new("Grace", 80), new("Linus", 200), new("Ken", 150) };
var bigSpenders = orders
.Where(o => o.Total > 100)
.OrderByDescending(o => o.Total)
.Select(o => o.Customer)
.ToList();
Console.WriteLine(string.Join(", ", bigSpenders));
Console.WriteLine($"Chiffre d'affaires : {orders.Sum(o => o.Total)}");
record Order(string Customer, int Total);

La version Rust utilise des adaptateurs d’itérateurs et des closures :

struct Order {
customer: String,
total: u32,
}
fn main() {
let orders = vec![
Order { customer: "Ada".to_string(), total: 120 },
Order { customer: "Grace".to_string(), total: 80 },
Order { customer: "Linus".to_string(), total: 200 },
Order { customer: "Ken".to_string(), total: 150 },
];
// Where(...).ToList()
let mut big: Vec<&Order> = orders.iter().filter(|o| o.total > 100).collect();
// OrderByDescending(...) : le tri se fait sur un Vec, pas sur l'itérateur.
big.sort_by(|a, b| b.total.cmp(&a.total));
// Select(...)
let names: Vec<&str> = big.iter().map(|o| o.customer.as_str()).collect();
println!("{}", names.join(", "));
// Sum(...)
let revenue: u32 = orders.iter().map(|o| o.total).sum();
println!("Chiffre d'affaires : {revenue}");
}

Sortie :

Linus, Ken, Ada
Chiffre d'affaires : 550
▶ Essayez par vous-même

Les itérateurs Rust sont paresseux, exactement comme l’exécution différée de LINQ : rien ne s’exécute tant que vous n’appelez pas quelque chose comme collect(), sum() ou une boucle for. collect(), c’est votre ToList(). Contrairement à LINQ, les chaînes d’itérateurs sont en général compilées vers le même code machine qu’une boucle écrite à la main : ce style agréable ne vous coûte rien. Le tri est expliqué sur la page Trier et rechercher.

En C#, vous devez penser à using pour libérer une ressource au bon moment :

using (var conn = new Connection("orders-db"))
{
Console.WriteLine($"requête sur {conn.Name}");
}
Console.WriteLine("après le bloc");
class Connection : IDisposable
{
public string Name { get; }
public Connection(string name) => Name = name;
public void Dispose() => Console.WriteLine($"fermeture de la connexion à {Name}");
}

En Rust, le nettoyage est automatique. Quand le propriétaire d’une valeur sort de la portée, Rust appelle son implémentation de Drop. Il n’y a rien à oublier :

struct Connection {
name: String,
}
impl Drop for Connection {
fn drop(&mut self) {
println!("fermeture de la connexion à {}", self.name);
}
}
fn main() {
{
let conn = Connection { name: "orders-db".to_string() };
println!("requête sur {}", conn.name);
} // `conn` sort de la portée ici, donc drop() s'exécute.
println!("après le bloc");
}

Sortie :

requête sur orders-db
fermeture de la connexion à orders-db
après le bloc
▶ Essayez par vous-même

C’est ce qu’on appelle le RAII, et c’est ainsi que Rust gère tout : les fichiers, les sockets, les verrous et la mémoire elle-même. Un fichier se ferme quand sa valeur File est libérée (dropped). Un Mutex se déverrouille quand son guard est libéré. Il n’y a pas de finaliseur ni de pause du GC ; le nettoyage a lieu à un moment prévisible.

Tous les développeurs qui viennent de C# butent sur les mêmes obstacles. Les connaître à l’avance aide.

  • L’affectation déplace la valeur, elle ne copie pas une référence. En C#, var b = a; avec une classe vous donne deux références vers un même objet. En Rust, let b = a; déplace (move) l’ownership d’une String ou d’un Vec vers b, et a ne peut plus être utilisé :

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

    Le compilateur vous dit (en anglais) que owner a été déplacé à la ligne 3, puis emprunté à la ligne 4 alors qu’il n’est plus valide. La solution : emprunter (let copy = &owner;) ou cloner volontairement (owner.clone()). Lisez d’abord L’ownership ; cette page explique cette règle.

  • Le borrow checker. Vous pouvez avoir plusieurs lecteurs (&T) ou un seul rédacteur (&mut T) à la fois, jamais les deux. Des schémas normaux en C#, comme des objets qui pointent vers leur parent, ou plusieurs objets qui modifient un même objet partagé, demandent des pointeurs intelligents ou une conception différente. Attendez-vous à quelques semaines frustrantes. Ça devient beaucoup plus facile ensuite.

  • String contre &str. C# a un seul type string. Rust en a deux : String possède son texte, &str l’emprunte. Les fonctions prennent en général un &str et les structs stockent en général une String. Voir Les chaînes.

  • Pas d’héritage. Vous ne pouvez pas écrire une classe de base avec des champs communs et des méthodes redéfinies. Utilisez des traits (avec des méthodes par défaut) pour le comportement commun, et placez les données communes dans une struct contenue par les autres structs.

  • Pas d’exceptions. Les erreurs sont des valeurs. Vous écrirez souvent ?, et votre main peut aussi renvoyer un Result. Appeler .unwrap() est acceptable pendant l’apprentissage, mais c’est la version Rust de « ignorer l’exception et planter ».

  • Pas de null. Au début, mettre des Option partout semble demander plus de travail. Au bout d’un moment, vous remarquerez que vous n’avez plus de bugs de référence nulle.

  • Des clones explicites. Rust ne copie jamais une valeur du tas dans votre dos. Si vous voyez .clone(), une vraie copie est faite. C’est voulu : le coût est visible.

  • Les temps de compilation. Une compilation complète d’un projet avec beaucoup de crates prend plus de temps qu’un build .NET. Les compilations incrémentales et cargo check (qui saute la génération de code) sont bien plus rapides ; utilisez cargo check pendant que vous modifiez votre code.

  • L’async a besoin d’un runtime. En .NET, le runtime et le pool de threads sont toujours là. En Rust, une async fn ne fait rien tant qu’un exécuteur ne la lance pas : vous ajoutez donc tokio et mettez #[tokio::main] sur main. Voir Async/Await.

Un parcours d’apprentissage pour les développeurs C#

Section intitulée « Un parcours d’apprentissage pour les développeurs C# »

Vous n’êtes pas obligé de lire ce site dans l’ordre. En tant que développeur C#, ce parcours saute ce que vous savez déjà et prend son temps là où Rust est différent :

  1. Débuter avec Rust : installez Rust et lancez cargo new.
  2. Les variables et Les types de données : survolez-les, surtout pour apprendre let mut et les types numériques.
  3. L’ownership, puis Le borrowing, puis Les slices : les pages les plus importantes pour vous. Prenez votre temps.
  4. Les chaînes : String contre &str.
  5. Les structs et Les enums, puis Match et Le pattern matching.
  6. Option et Result, puis Erreurs personnalisées.
  7. Les traits, Les génériques, Les traits courants et Trait objects.
  8. Les closures et Les itérateurs : votre LINQ.
  9. Les vecteurs, HashMap et VecDeque et BinaryHeap.
  10. Les modules, Cargo et les crates et Les tests.
  11. Les lifetimes et Pointeurs intelligents.
  12. La concurrence, L’état partagé et Async/Await.
  13. JSON avec Serde et Journalisation, puis construisez le projet d’API web.

Les deux exercices vous donnent un extrait de code C#. Portez-le en Rust.

Portez ce code C#. Il doit afficher le nom des produits qui sont en stock et coûtent moins de 20. Indice : créez une struct avec #[derive(Debug, Clone, PartialEq)], puis utilisez filter, map et collect vers un Vec<&str>. Vec<&str> a une méthode .join(", ").

var products = new List<Product>
{
new("Cahier", 5, true),
new("Casque", 45, true),
new("Stylo", 2, false),
new("Tasse", 12, true),
};
var cheap = products.Where(p => p.InStock && p.Price < 20).Select(p => p.Name);
Console.WriteLine(string.Join(", ", cheap));
record Product(string Name, int Price, bool InStock);
Voir la solution
#[derive(Debug, Clone, PartialEq)]
struct Product {
name: String,
price: u32,
in_stock: bool,
}
fn product(name: &str, price: u32, in_stock: bool) -> Product {
Product { name: name.to_string(), price, in_stock }
}
fn main() {
let products = vec![
product("Cahier", 5, true),
product("Casque", 45, true),
product("Stylo", 2, false),
product("Tasse", 12, true),
];
let cheap: Vec<&str> = products
.iter()
.filter(|p| p.in_stock && p.price < 20)
.map(|p| p.name.as_str())
.collect();
println!("{}", cheap.join(", "));
}

Sortie :

Cahier, Tasse
▶ Essayez par vous-même

Une petite fonction utilitaire joue le rôle du constructeur positionnel du record. Les derives vous donnent ce qu’un record C# offre gratuitement : l’affichage avec {:?}, la copie avec .clone() et la comparaison avec ==.

Ce code C# retire un article d’un dictionnaire de stock et lève une exception quand quelque chose ne va pas. Portez-le pour que take renvoie un Result<u32, String> au lieu de lever une exception. Indice : stock.get_mut(item) renvoie une Option<&mut u32>. Transformez-la en Result avec .ok_or(...), puis utilisez ?.

var stock = new Dictionary<string, int> { ["pommes"] = 3, ["poires"] = 0 };
int Take(string item)
{
if (!stock.TryGetValue(item, out var count))
throw new KeyNotFoundException($"aucun article nommé {item}");
if (count == 0)
throw new InvalidOperationException($"{item} en rupture de stock");
stock[item] = count - 1;
return count - 1;
}
foreach (var item in new[] { "pommes", "poires", "kiwis" })
{
try { Console.WriteLine($"{item} : une unité retirée, il en reste {Take(item)}"); }
catch (Exception e) { Console.WriteLine($"erreur : {e.Message}"); }
}
Voir la solution
use std::collections::HashMap;
fn take(stock: &mut HashMap<String, u32>, item: &str) -> Result<u32, String> {
let count = stock
.get_mut(item)
.ok_or(format!("aucun article nommé {item}"))?;
if *count == 0 {
return Err(format!("{item} en rupture de stock"));
}
*count -= 1;
Ok(*count)
}
fn main() {
let mut stock = HashMap::new();
stock.insert("pommes".to_string(), 3);
stock.insert("poires".to_string(), 0);
for item in ["pommes", "poires", "kiwis"] {
match take(&mut stock, item) {
Ok(left) => println!("{item} : une unité retirée, il en reste {left}"),
Err(e) => println!("erreur : {e}"),
}
}
}

Sortie :

pommes : une unité retirée, il en reste 2
erreur : poires en rupture de stock
erreur : aucun article nommé kiwis
▶ Essayez par vous-même

TryGetValue avec un paramètre out devient get_mut, qui renvoie une Option. Les deux throw deviennent des valeurs Err, et le try/catch devient un match. Le &mut stock dans l’appel montre clairement que take modifie le dictionnaire.

  • Une grande partie de C# se retrouve en Rust : les génériques, async/await, les lambdas, le pattern matching et les itérateurs à la LINQ ont tous des équivalents proches.
  • Les grands changements : l’ownership au lieu d’un ramasse-miettes, Option au lieu de null, et Result + ? au lieu des exceptions.
  • Les classes deviennent une struct + impl, les interfaces deviennent des traits, et l’héritage devient de la composition plus des traits. using/IDisposable devient un Drop automatique.
  • Cargo remplace la CLI dotnet, MSBuild et NuGet ; clippy et rustfmt remplacent les analyseurs et dotnet format ; les tests sont intégrés.
  • Pour EF Core, regardez SeaORM ou Diesel ; pour Dapper, sqlx ; et pour ASP.NET Core, axum ou actix-web. Les options pour les interfaces graphiques, les jeux et le ML sont plus jeunes.

Petit quiz

1. Qu'est-ce que Rust utilise à la place de null ?

2. Votre équipe utilise Dapper et aime écrire son SQL à la main. Quelle crate Rust s'en rapproche le plus ?

3. Quel est l'équivalent Rust le plus proche d'un bloc using avec IDisposable en C# ?

4. En C#, var b = a; avec une classe copie une référence. Que fait let b = a; avec une String en Rust ?

Score : 0 / 4