Aller au contenu

Rust pour les développeurs Python : guide pratique

Si vous connaissez Python, vous connaissez déjà beaucoup d’idées de Rust. Rust a des boucles for sur des itérateurs, des tuples, des closures (les lambdas de Python, en plus utiles), une instruction match qui fonctionne comme le match de Python, et des chaînes d’itérateurs qui font le travail des compréhensions de liste. Rust partage même certains goûts de Python : une seule façon évidente de formater le code, un outil de paquets agréable, et des messages d’erreur qui font tout pour vous aider.

Ce qui change, c’est surtout le compilateur. Python vérifie les types pendant l’exécution du programme ; Rust les vérifie tous avant. Python libère la mémoire avec un ramasse-miettes (garbage collector) ; Rust utilise l’ownership (possession) et le borrowing (emprunt), vérifiés à la compilation. Python a None et les exceptions ; Rust a Option et Result. C’est sur ces trois points que vous passerez le plus de temps d’apprentissage.

Alors pourquoi les développeurs Python apprennent-ils Rust ? Voici les raisons, en toute honnêteté :

  • La vitesse. Rust se compile en code machine natif. Le travail gourmand en CPU qui est lent en Python pur (analyse de texte, calcul numérique, grosses boucles) est en général bien plus rapide en Rust, sans astuce d’optimisation.
  • De vrais threads. Dans CPython standard, le GIL ne laisse qu’un seul thread exécuter du code Python à la fois. Les threads Rust tournent sur tous les cœurs de votre processeur en même temps, et le compilateur empêche les accès concurrents aux données (data races) avant même l’exécution.
  • Des bugs trouvés plus tôt. Une faute de frappe dans un nom d’attribut, un None oublié, une fonction appelée avec le mauvais type : en Python, vous les découvrez quand la ligne s’exécute. En Rust, le programme ne compile pas tant qu’ils ne sont pas corrigés.
  • Pas besoin de choisir. Avec PyO3 et maturin, vous pouvez écrire la partie lente d’un projet Python en Rust et l’importer (import) depuis Python comme n’importe quel autre module. Des outils Python populaires comme Polars, Ruff et uv sont écrits en Rust.

Si vous cherchez une comparaison plus générale de Rust avec Python, Go et C++, 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.

Python Rust Pour en savoir plus
Types dynamiques, vérifiés à l’exécution Types statiques, vérifiés à la compilation. Rust en déduit la plupart, donc vous écrivez moins de types que vous ne le pensez Types de données
Annotations de type (def f(x: int) -> str) Les types sont obligatoires dans la signature des fonctions, et le compilateur les fait respecter Fonctions
x = 5 let x = 5; (non modifiable). let mut x = 5; si vous voulez le modifier Variables
int (grandit autant que nécessaire) Entiers de taille fixe : i32, i64, u64, usize, i128… Ils peuvent déborder Types de données, Dépassement d’entier
float f64 (ou f32) Types de données
str String (possédée, qui peut grandir) et &str (une vue empruntée sur du texte) Chaînes
f-strings : f"Hi {name}" format!("Hi {name}") et println!("Hi {name}") Afficher du texte
print(repr(x)) / __repr__ println!("{x:?}") avec #[derive(Debug)] Afficher du texte, Traits courants
__str__ Implémenter Display (et .to_string() marche aussi) Traits courants
__eq__, __hash__, __lt__ Dériver PartialEq, Eq, Hash, PartialOrd, Ord Traits courants
None, Optional[T] Option<T> : soit Some(value), soit None Option
x or default x.unwrap_or(default) Option
raise, try/except Result<T, E> + l’opérateur ? Result, Erreurs personnalisées
Une exception pour un vrai bug (assert, IndexError) panic! (arrête le thread) Panic
class avec __init__ et des méthodes struct pour les données + un bloc impl pour les méthodes ; new est une fonction ordinaire Structs
@dataclass struct avec #[derive(Debug, Clone, PartialEq)] Structs
self &self (lecture), &mut self (modification) ou self (prise d’ownership) Structs
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
Duck typing, Protocol, ABC trait Traits
Une liste d’objets de classes différentes Vec<Box<dyn Trait>> (un trait object) Trait objects
TypeVar / fonctions génériques Génériques avec des contraintes de trait : fn largest<T: Ord>(items: &[T]) Génériques
Classe Enum enum dont chaque variante peut porter ses propres données Enums
match / case (3.10+) match (le compilateur vérifie que vous avez couvert tous les cas) match, Pattern matching
if my_list: (valeur de vérité) Soyez explicite : if !my_list.is_empty(). Seul un bool peut aller dans un if If…Else
for i in range(10) for i in 0..10 Boucles for
enumerate(xs), zip(a, b) xs.iter().enumerate(), a.iter().zip(b.iter()) Itérateurs
lambda x: x + 1 Closures : |x| x + 1 (et elles peuvent tenir sur plusieurs lignes) Closures
Compréhensions de liste, map, filter Adaptateurs d’itérateurs : filter, map, puis collect() Itérateurs
Générateurs et yield Votre propre type qui implémente Iterator, ou std::iter::from_fn Itérateurs personnalisés
with open(...) as f: Rien à écrire : les fichiers et les verrous se ferment quand leur propriétaire sort de la portée (Drop) Portée, Lire et écrire des fichiers
Arguments par défaut, *args, **kwargs Non pris en charge. Utilisez des paramètres Option, des slices (&[T]), une struct avec Default, ou un builder Fonctions, Patrons de conception
Décorateurs Pas d’équivalent direct. Enveloppez avec des closures, ou utilisez des macros d’attribut (comme #[tokio::main]) Closures, Macros procédurales
import et modules mod et use Modules
Paquet sur PyPI Crate sur crates.io Cargo et les crates
if __name__ == "__main__": fn main() Débuter avec Rust
threading (avec le GIL) std::thread : de vrais threads parallèles Concurrence
multiprocessing, concurrent.futures Des threads, ou la crate rayon pour des itérateurs parallèles Concurrence, État partagé
asyncio, async def, await async fn et .await, avec un runtime comme tokio Async/Await
7 // 2, 2 ** 10 7 / 2 (division entière sur des entiers), 2_i64.pow(10) Opérateurs
int("42"), str(42) "42".parse::<i32>() (renvoie un Result), 42.to_string() Conversion de types, Conversions

Toutes les collections intégrées de Python ont leur place dans la bibliothèque standard de Rust. Les plus grandes différences : une collection Rust contient un seul type (un Vec<i32> ne contient que des i32), et une HashMap ne retient pas l’ordre d’insertion.

Python Rust Pour en savoir plus
list Vec<T> Vecteurs
tuple Tuples (i32, String), ou une petite struct avec des champs nommés Tuples
dict HashMap<K, V> (pas d’ordre fixe !) HashMap
dict quand il vous faut des clés triées BTreeMap<K, V> HashMap
dict quand il vous faut l’ordre d’insertion La crate indexmap
set, frozenset HashSet<T>, BTreeSet<T> HashSet
collections.Counter, defaultdict HashMap avec l’API entry : *map.entry(k).or_insert(0) += 1 HashMap
collections.deque VecDeque<T> VecDeque et BinaryHeap
heapq (le plus petit d’abord) BinaryHeap<T> (le plus grand d’abord ; enveloppez les éléments dans Reverse pour avoir le plus petit d’abord) VecDeque et BinaryHeap
namedtuple struct Structs
bytes, bytearray &[u8], Vec<u8> Slices
Découpage : xs[1:3] Slices : &xs[1..3] (pas d’indices négatifs ; utilisez xs.len() - 1) Slices

C’est là que beaucoup de développeurs Python se détendent un peu. Rust a un seul outil officiel, Cargo, qui fait le travail de pip, venv, setuptools, pytest et d’autres encore. Pas de débat sur « quel outil de packaging faut-il utiliser ? ».

Python Rust Remarques
Interpréteur python Compilateur rustc, en général appelé via cargo Vous compilez d’abord, puis vous exécutez un binaire natif. Voir Débuter avec Rust
pyenv rustup (et un fichier rust-toolchain.toml pour fixer une version) Voir Les outils de Rust
pip, poetry, uv cargo Compile, exécute, teste, ajoute des paquets, publie. Voir Cargo et les crates
pip install requests cargo add reqwest Ajoute la crate au Cargo.toml de ce projet uniquement
pyproject.toml / requirements.txt Cargo.toml
poetry.lock / uv.lock Cargo.lock Créé pour vous
venv / virtualenv Inutile Chaque projet a ses propres dépendances et se compile dans son propre dossier target/
PyPI crates.io La documentation de l’API de chaque crate publiée est sur docs.rs
python main.py cargo run (cargo run --release pour la pleine vitesse) Les builds debug sont beaucoup plus lents à l’exécution ; mesurez les performances en release
pipx install ruff cargo install ripgrep Installe un outil en ligne de commande
PyInstaller cargo build --release Vous obtenez un seul binaire natif ; vos utilisateurs n’ont pas besoin d’installer Rust
black cargo fmt Un seul style standard pour tout le monde
ruff, pylint, flake8 cargo clippy Des centaines de lints utiles, officiels
mypy, pyright Le compilateur lui-même La vérification des types n’est ni optionnelle ni une étape à part
pytest, unittest #[test] et cargo test intégrés (cargo nextest est un lanceur plus rapide très populaire) Voir Les tests
Fixtures pytest et parametrize rstest
hypothesis proptest Tests basés sur les propriétés. Voir Tests avancés
unittest.mock mockall, ou de fausses implémentations écrites à la main qui implémentent votre trait
timeit, pytest-benchmark criterion Voir Performance
cProfile, py-spy cargo flamegraph, perf, ou n’importe quel profileur natif
Docstrings + Sphinx Commentaires de doc /// en Markdown + cargo doc Les exemples de code dans la doc sont exécutés comme des tests. Voir Documentation
Le REPL Python, Jupyter Le Rust Playground ; evcxr_repl et evcxr_jupyter Rust n’a pas de REPL intégré. Pour expérimenter, on crée en général de petits projets cargo run
VS Code + Pylance, PyCharm 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

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

[package]
name = "weather-cli"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = { version = "0.13", features = ["blocking", "json"] }
serde = { version = "1", features = ["derive"] }
clap = { version = "4", features = ["derive"] }

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

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

« J’utilise requests, FastAPI et pandas. C’est quoi, la version Rust ? » Voici les équivalents les plus proches. L’écosystème Python est bien plus ancien et plus vaste : dans certains domaines (la data science surtout), Rust a des options moins nombreuses et plus jeunes.

Python Rust Remarques
Flask, FastAPI axum (nous en construisons une dans Projet : API web avec Axum), actix-web Les handlers sont de simples async fn ; les types de requête et de réponse viennent de vos structs, un peu comme FastAPI avec pydantic
Django Pas d’équivalent complet. Loco (loco-rs) est un framework « tout compris » ; la plupart des équipes combinent axum + un ORM + un moteur de templates Pas de site d’administration intégré
requests, httpx reqwest (async, avec un client bloquant en option), ureq (simple et bloquant) Voir Réseau en Rust
aiohttp reqwest + tokio
Jinja2 minijinja (par l’auteur de Jinja lui-même), tera, askama (templates vérifiés à la compilation)
BeautifulSoup scraper
Python Rust Remarques
ORM SQLAlchemy Diesel (requêtes vérifiées par le système de types à la compilation), SeaORM (async, à base d’entités) Les bases de données en Rust montre les deux avec du code qui fonctionne
psycopg, SQL brut sqlx : du SQL brut, vérifié à la compilation contre votre vraie base de données Async, très populaire, pas un ORM
sqlite3 rusqlite
redis-py redis
Celery apalis pour les files de tâches ; tokio::spawn pour des tâches en arrière-plan dans le processus
Python Rust Remarques
pandas Polars Écrit en Rust ; son API Python, que vous connaissez peut-être déjà, repose sur la même bibliothèque Rust
NumPy ndarray Tableaux à N dimensions avec découpage et broadcasting ; bien moins d’extras que NumPy
scikit-learn linfa, smartcore Moins d’algorithmes et des communautés bien plus petites
PyTorch candle (crate candle-core), burn Bien pour exécuter des modèles ; l’entraînement se fait encore surtout en Python
matplotlib plotters
Module csv Crate csv + serde
Python Rust Remarques
json serde + serde_json La référence ; gère aussi TOML, YAML, et d’autres formats. Voir JSON avec Serde
pydantic serde pour l’analyse + validator ou garde pour les règles de validation Votre struct est le schéma
argparse, click, typer clap Définissez vos arguments sous forme de struct. Voir Projet : outil CLI avec clap
logging tracing, ou log + env_logger Voir Journalisation
re regex Pas de lookaround ni de références arrière, mais un temps linéaire garanti ; fancy-regex les propose. Voir Expressions régulières
datetime chrono ou jiff Voir Dates et heures
random rand Voir Nombres aléatoires
asyncio tokio Voir Async/Await
multiprocessing.Pool.map rayon : remplacez .iter() par .par_iter()
itertools L’essentiel est intégré à Iterator ; la crate itertools ajoute le reste
os, pathlib, subprocess std::env, std::path, std::fs, std::process Voir Lire et écrire des fichiers, Environnement et processus

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

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

Voici une petite classe Python :

class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
def __str__(self):
return f"{self.owner} a {self.balance}"
account = Account("Ada", 100)
account.deposit(50)
print(account)

Et la même chose en Rust :

use std::fmt;
struct Account {
owner: String,
balance: i64,
}
impl Account {
// `new` est juste une convention, pas un mot-clé. Pas d'arguments par défaut :
// l'appelant passe donc toujours les deux valeurs.
fn new(owner: &str, balance: i64) -> Self {
Account { owner: owner.to_string(), balance }
}
// `&mut self` : cette méthode modifie le compte.
fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
}
// Voici le __str__ de Rust.
impl fmt::Display for Account {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{} a {}", self.owner, self.balance)
}
}
fn main() {
let mut account = Account::new("Ada", 100);
account.deposit(50);
println!("{account}");
}

Sortie :

Ada a 150
▶ Essayez par vous-même

Quelques différences sautent aux yeux. Les champs sont déclarés d’emblée avec leur type : vous ne pouvez donc pas ajouter un nouvel attribut plus tard, comme Python vous laisse écrire account.nickname = "A". Les données (la struct) et le comportement (l’impl) vivent dans deux blocs séparés. Chaque méthode annonce si elle se contente de lire (&self) ou si elle modifie (&mut self) la valeur, et comme deposit modifie le compte, la variable doit être déclarée avec let mut. Rust n’a pas d’arguments par défaut : si vous voulez un compte qui démarre à zéro, ajoutez une deuxième fonction, comme fn empty(owner: &str) -> Self.

Côte à côte : le duck typing devient des traits

Section intitulée « Côte à côte : le duck typing devient des traits »

Python ne s’intéresse pas à la classe d’un objet, seulement au fait qu’il a la bonne méthode :

class Duck:
def speak(self):
return "Coin-coin"
class Robot:
def speak(self):
return "Bip bop"
def introduce(thing):
print(f"Il dit : {thing.speak()}")
for thing in [Duck(), Robot()]:
introduce(thing)
introduce(42) # AttributeError, mais seulement quand cette ligne s'exécute

En Rust, « a une méthode speak » s’écrit noir sur blanc sous la forme d’un trait, et chaque type y adhère avec un bloc impl :

trait Speak {
fn speak(&self) -> String;
}
struct Duck;
struct Robot;
impl Speak for Duck {
fn speak(&self) -> String {
"Coin-coin".to_string()
}
}
impl Speak for Robot {
fn speak(&self) -> String {
"Bip bop".to_string()
}
}
// Accepte n'importe quel type qui implémente Speak.
fn introduce(thing: &dyn Speak) {
println!("Il dit : {}", thing.speak());
}
fn main() {
let things: Vec<Box<dyn Speak>> = vec![Box::new(Duck), Box::new(Robot)];
for thing in &things {
introduce(thing.as_ref());
}
// introduce(&42); // erreur de compilation : `{integer}` n'implémente pas Speak
}

Sortie :

Il dit : Coin-coin
Il dit : Bip bop
▶ Essayez par vous-même

C’est toujours du duck typing dans l’esprit, mais vérifié par le compilateur : introduce(&42) est une erreur avant même que le programme ne s’exécute, pas une AttributeError en production. Si vous avez utilisé typing.Protocol, les traits vous sembleront naturels. Une liste de types mélangés a besoin de Box<dyn Speak>, parce que Rust doit savoir comment appeler la méthode sur chaque élément (voir Trait objects). Et vous pouvez implémenter votre propre trait pour des types que vous n’avez pas écrits, comme String ou i32.

En Python, n’importe quelle valeur peut être None en douce, et vous le découvrez au moment de l’utiliser :

def find_email(user_id):
emails = {1: "[email protected]", 2: "[email protected]"}
return emails.get(user_id) # renvoie None si la clé manque
print(find_email(1).upper())
print(find_email(3).upper()) # AttributeError: 'NoneType' object has no attribute 'upper'

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> {
match user_id {
1 => Some("[email protected]".to_string()),
2 => Some("[email protected]".to_string()),
_ => None,
}
}
fn main() {
for id in [1, 3] {
match find_email(id) {
Some(email) => println!("{id} : {}", email.to_uppercase()),
None => println!("{id} : aucune adresse e-mail enregistrée"),
}
}
// Comme `find_email(3) or "inconnue"` en Python.
let email = find_email(3).unwrap_or("inconnue".to_string());
println!("{email}");
// Transformer la valeur à l'intérieur, s'il y en a une.
let upper = find_email(2).map(|e| e.to_uppercase());
println!("{upper:?}");
}

Sortie :

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

Le compilateur Rust refuse que vous appeliez .to_uppercase() sur une Option<String> : l’erreur « 'NoneType' object has no attribute » ne peut donc tout simplement pas se produire. Une String Rust est toujours une vraie chaîne. Découvrez les méthodes utiles sur la page Option, et la forme courte if let Some(email) = ... sur If Let / While Let.

Côte à côte : les exceptions deviennent Result et ?

Section intitulée « Côte à côte : les exceptions deviennent Result et ? »

En Python, int() lève une exception, et vous l’attrapez quelque part plus haut dans la pile d’appels :

def parse_port(text):
port = int(text) # peut lever ValueError
if not 1 <= port <= 65535:
raise ValueError(f"le port {port} est hors limites")
return port
for text in ["8080", "http", "70000"]:
try:
print(f"port {parse_port(text)}")
except ValueError as e:
print(f"erreur : {e}")

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 :

fn parse_port(text: &str) -> Result<u32, String> {
let port: u32 = text
.parse()
.map_err(|e| format!("{text:?} n'est pas un nombre ({e})"))?;
if !(1..=65535).contains(&port) {
return Err(format!("le port {port} est hors limites"));
}
Ok(port)
}
fn main() {
for text in ["8080", "http", "70000"] {
match parse_port(text) {
Ok(port) => println!("port {port}"),
Err(e) => println!("erreur : {e}"),
}
}
}

Sortie :

port 8080
erreur : "http" n'est pas un nombre (invalid digit found in string)
erreur : le port 70000 est hors limites
▶ 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 Python, n’importe quelle fonction peut lever une exception, et rien dans sa signature ne vous le dit. En Rust, Result fait partie de la signature, donc les appelants ne peuvent pas l’oublier. Une String comme type d’erreur convient très bien pendant l’apprentissage ; les vrais projets utilisent leurs propres enums d’erreur (Erreurs personnalisées) ou la crate anyhow, qui ressemble beaucoup à « lever n’importe quelle exception » (anyhow et thiserror).

Côte à côte : compréhensions et dicts deviennent itérateurs et HashMap

Section intitulée « Côte à côte : compréhensions et dicts deviennent itérateurs et HashMap »

Les compréhensions de liste et le comptage dans un dict font partie du Python de tous les jours :

text = "the cat sat on the mat the end"
loud = [w.upper() for w in text.split() if len(w) == 3]
print(loud)
counts = {}
for word in text.split():
counts[word] = counts.get(word, 0) + 1
top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:3]
for word, n in top:
print(f"{word}: {n}")

En Rust, une compréhension devient une chaîne d’itérateurs qui se termine par collect(), et le dict devient une HashMap :

use std::collections::HashMap;
fn main() {
let text = "the cat sat on the mat the end";
// [w.upper() for w in text.split() if len(w) == 3]
let loud: Vec<String> = text
.split_whitespace()
.filter(|w| w.len() == 3)
.map(|w| w.to_uppercase())
.collect();
println!("{loud:?}");
// counts[word] = counts.get(word, 0) + 1
let mut counts: HashMap<&str, u32> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
// Les plus fréquents d'abord ; en cas d'égalité, par ordre alphabétique.
let mut top: Vec<(&str, u32)> = counts.into_iter().collect();
top.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
for (word, n) in top.iter().take(3) {
println!("{word}: {n}");
}
}

Sortie :

["THE", "CAT", "SAT", "THE", "MAT", "THE", "END"]
the: 3
cat: 1
end: 1
▶ Essayez par vous-même

La phrase reste en anglais ici, parce que l’exemple filtre les mots selon leur longueur en octets et les trie par ordre alphabétique : des mots accentués changeraient le résultat. La chaîne se lit de gauche à droite au lieu de l’intérieur vers l’extérieur, mais l’idée est la même. Comme une expression génératrice, elle est paresseuse : rien ne se passe tant que collect(), sum(), count() ou une boucle for ne fait pas défiler les valeurs. collect() doit savoir quoi construire, c’est pourquoi loud a le type Vec<String>. Et remarquez l’étape de tri : un dict Python retient l’ordre d’insertion, mais pas une HashMap Rust, donc on trie toujours avant d’afficher. Le tri est expliqué sur la page Trier et rechercher.

Dans CPython standard, le Global Interpreter Lock (GIL) ne laisse qu’un seul thread exécuter du bytecode Python à la fois. Les threads aident quand on attend le réseau, mais un travail gourmand en CPU comme celui-ci ne va pas plus vite avec plus de threads ; les développeurs Python se tournent donc vers multiprocessing :

from concurrent.futures import ThreadPoolExecutor
def is_prime(n):
if n < 2:
return False
d = 2
while d * d <= n:
if n % d == 0:
return False
d += 1
return True
def count_primes(lo, hi):
return sum(1 for n in range(lo, hi) if is_prime(n))
ranges = [(0, 25_000), (25_000, 50_000), (50_000, 75_000), (75_000, 100_000)]
with ThreadPoolExecutor(4) as pool:
counts = list(pool.map(lambda r: count_primes(*r), ranges))
print("total :", sum(counts))

Rust n’a pas de GIL. Chaque thread::spawn est un vrai thread du système d’exploitation, et les threads tournent sur des cœurs différents en même temps :

use std::thread;
fn is_prime(n: u32) -> bool {
if n < 2 {
return false;
}
let mut d = 2;
while d * d <= n {
if n % d == 0 {
return false;
}
d += 1;
}
true
}
fn count_primes(lo: u32, hi: u32) -> usize {
(lo..hi).filter(|&n| is_prime(n)).count()
}
fn main() {
let ranges = [(0, 25_000), (25_000, 50_000), (50_000, 75_000), (75_000, 100_000)];
// Chaque intervalle a son propre thread système. Ils tournent vraiment en même temps.
let counts: Vec<usize> = thread::scope(|s| {
let handles: Vec<_> = ranges
.iter()
.map(|&(lo, hi)| s.spawn(move || count_primes(lo, hi)))
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
for ((lo, hi), count) in ranges.iter().zip(&counts) {
println!("{lo}..{hi} : {count} nombres premiers");
}
println!("total : {}", counts.iter().sum::<usize>());
}

Sortie :

0..25000 : 2762 nombres premiers
25000..50000 : 2371 nombres premiers
50000..75000 : 2260 nombres premiers
75000..100000 : 2199 nombres premiers
total : 9592
▶ Essayez par vous-même

thread::scope attend la fin de chaque thread avant de rendre la main : les threads peuvent donc emprunter des données locales sans danger. Sans GIL, vous craignez peut-être que deux threads modifient la même valeur en même temps. Rust règle ça à la compilation : les données partagées modifiables doivent être enveloppées dans quelque chose comme Arc<Mutex<T>>, sinon le programme ne compile pas (voir L’état partagé). Dans les vrais projets, la crate rayon rend tout ça encore plus court, un peu comme multiprocessing.Pool.map, mais avec des threads :

use rayon::prelude::*;
let total = (0..100_000u32).into_par_iter().filter(|&n| is_prime(n)).count();

Python évolue aussi sur ce point : Python 3.13 a ajouté une version optionnelle « free-threaded », sans GIL. Mais le CPython standard que la plupart des gens installent et déploient a toujours le GIL, et beaucoup d’extensions C ne sont pas encore prêtes pour le free threading.

Écrire des extensions Python en Rust (PyO3 + maturin)

Section intitulée « Écrire des extensions Python en Rust (PyO3 + maturin) »

Vous n’êtes pas obligé de quitter Python pour utiliser Rust. PyO3 vous permet d’écrire un module Python en Rust, et maturin le compile en un wheel normal que pip peut installer. C’est ainsi que sont faites Polars, pydantic-core et bien d’autres bibliothèques Python rapides.

Créez un projet (dans un environnement virtuel activé) :

Terminal window
pip install maturin
maturin new --bindings pyo3 fastmath
cd fastmath

maturin écrit le Cargo.toml et le pyproject.toml pour vous. Placez ensuite vos fonctions Rust dans src/lib.rs :

use pyo3::prelude::*;
/// Un module Python écrit en Rust.
#[pymodule]
mod fastmath {
use pyo3::prelude::*;
/// Additionne les carrés d'une liste de nombres.
#[pyfunction]
fn sum_of_squares(numbers: Vec<i64>) -> i64 {
numbers.iter().map(|n| n * n).sum()
}
/// Compte les mots d'un texte.
#[pyfunction]
fn word_count(text: &str) -> usize {
text.split_whitespace().count()
}
}

Compilez-le dans votre environnement virtuel avec maturin develop (ajoutez --release quand la vitesse compte), puis utilisez-le depuis Python comme n’importe quel autre module :

import fastmath
print(fastmath.sum_of_squares([1, 2, 3, 4])) # 30
print(fastmath.word_count("the quick brown fox")) # 4
fastmath.sum_of_squares(["a"]) # TypeError: 'str' object cannot be interpreted as an integer

PyO3 convertit les types pour vous : une list Python d’entiers devient un Vec<i64>, une str devient un &str, et les commentaires de doc deviennent des docstrings Python. Les mauvais types se transforment en TypeError Python ordinaire. Quand vous êtes prêt à le partager, maturin build --release produit un wheel, et maturin publish l’envoie sur PyPI. Une bonne règle : mesurez d’abord, puis ne déplacez en Rust que la boucle critique. Passer de Python à Rust a un petit coût : un seul appel qui fait beaucoup de travail vaut mieux qu’un million de petits appels.

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

En Python, b = a vous donne deux noms pour la même liste. En Rust, let b = a; déplace (move) l’ownership d’un Vec ou d’une String vers b, et a ne peut plus être utilisé :

fn main() {
let a = vec![1, 2, 3];
let mut b = a;
b.push(4);
println!("{a:?}");
}
error[E0382]: borrow of moved value: `a`
--> src/main.rs:5:16
|
2 | let a = vec![1, 2, 3];
| - move occurs because `a` has type `Vec<i32>`, which does not implement the `Copy` trait
3 | let mut b = a;
| - value moved here
4 | b.push(4);
5 | println!("{a:?}");
| ^ value borrowed here after move
|
help: consider cloning the value if the performance cost is acceptable
|
3 | let mut b = a.clone();
| ++++++++

Le compilateur vous dit (en anglais) que a a été déplacé à la ligne 3, puis emprunté à la ligne 5 alors qu’il n’est plus valide, et il propose même de le cloner. La solution : emprunter (let b = &a;, si vous avez seulement besoin de lire) ou faire volontairement une vraie copie (a.clone()). Cette seule règle, l’ownership, est la nouvelle idée la plus importante pour vous. Lisez cette page et Le borrowing sans vous presser.

  • Les entiers ont une taille. L’int de Python ne déborde jamais. Un i32 Rust s’arrête à 2 147 483 647 ; aller au-delà provoque un panic dans un build debug. Choisissez i64 ou u64 pour les grands nombres, et voyez Le dépassement d’entier.
  • La division et le reste fonctionnent différemment. 7 / 2 vaut 3 en Rust, parce que les deux côtés sont des entiers (le // de Python). Écrivez 7.0 / 2.0 pour obtenir 3.5. Autre chose : -7 % 3 vaut -1 en Rust mais 2 en Python ; utilisez (-7_i32).rem_euclid(3) pour obtenir la réponse de Python.
  • Pas de mélange des types numériques. let x: f64 = 2.5 * 3; ne compile pas, parce que 3 est un entier. Écrivez 3.0, ou convertissez avec as f64 (voir Conversion de types).
  • On ne peut pas indexer une chaîne. word[0] est une erreur de compilation, parce que les chaînes Rust sont en UTF-8 et qu’un « caractère » peut occuper plusieurs octets. Utilisez word.chars().next() ou word.chars().nth(i). Voir Les chaînes.
  • String contre &str. Python a un seul type str. 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.
  • Les variables sont immuables par défaut. Écrivez let mut quand une valeur doit changer. Le compilateur vous prévient si vous l’avez oublié.
  • Les accolades et les points-virgules. L’indentation ne compte pas pour Rust ; les { }, si. Et la dernière ligne d’une fonction sans point-virgule est sa valeur de retour : fn double(x: i32) -> i32 { x * 2 }.
  • Chaque if et chaque match doit être complet et bien typé. Pas de valeur de vérité implicite : if count ne compile pas, vous écrivez if count > 0. Un match doit couvrir tous les cas.
  • Une HashMap n’a pas d’ordre. Le dict de Python retient l’ordre d’insertion ; la HashMap de Rust, non. Triez avant d’afficher, utilisez une BTreeMap, ou ajoutez la crate indexmap.
  • Il y a une étape de compilation. Pas de REPL, et une compilation complète d’un projet avec beaucoup de crates prend du temps. Utilisez cargo check pendant que vous modifiez votre code (il saute la génération de code et va bien plus vite), et cargo run --release quand vous mesurez les performances.
  • Le borrow checker va vous résister. Des schémas normaux en Python, comme plusieurs objets qui détiennent et modifient tous un même objet partagé, ou des objets qui pointent vers leur parent, demandent des pointeurs intelligents ou une conception différente. Attendez-vous à quelques semaines frustrantes. Ça devient beaucoup plus facile ensuite.

Un parcours d’apprentissage pour les développeurs Python

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

Vous n’êtes pas obligé de lire ce site dans l’ordre. En tant que développeur Python, ce parcours prend son temps là où Rust diffère le plus de ce que vous connaissez :

  1. Débuter avec Rust et Les outils de Rust : installez Rust et lancez cargo new.
  2. Les variables, Les types de données et Le dépassement d’entier : les types sont la première nouvelle habitude.
  3. Les fonctions, If…Else et Les boucles : vite lus ; remarquez les expressions et les points-virgules absents.
  4. L’ownership, puis Le borrowing, puis Les slices : les pages les plus importantes pour vous. Prenez votre temps.
  5. Les chaînes : String contre &str, et pourquoi on ne peut pas indexer une chaîne.
  6. Les structs et Les enums, puis Match et Le pattern matching.
  7. Option et Result, puis Erreurs personnalisées.
  8. Les vecteurs, HashMap et HashSet.
  9. Les closures et Les itérateurs : vos compréhensions et vos générateurs.
  10. Les traits, Les génériques, Les traits courants et Trait objects.
  11. Les modules, Cargo et les crates et Les tests.
  12. Les lifetimes et Pointeurs intelligents.
  13. La concurrence, L’état partagé et Async/Await.
  14. JSON avec Serde, puis construisez un outil CLI avec clap ou une API web.

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

Portez ce code Python. Indice : pour afficher les noms dans l’ordre, collectez-les dans un Vec<&str>, appelez .sort(), puis .join(", "). Pour la moyenne, convertissez avec as f64 avant de diviser.

scores = {"Ada": 92, "Bob": 47, "Cy": 75, "Dee": 38}
passed = sorted(name for name, score in scores.items() if score >= 50)
print("reçus :", ", ".join(passed))
odd_squares = [n * n for n in range(1, 10) if n % 2 == 1]
print(odd_squares)
average = sum(scores.values()) / len(scores)
print(f"moyenne : {average:.1f}")
Voir la solution
use std::collections::HashMap;
fn main() {
let scores: HashMap<&str, u32> =
HashMap::from([("Ada", 92), ("Bob", 47), ("Cy", 75), ("Dee", 38)]);
let mut passed: Vec<&str> = scores
.iter()
.filter(|(_, score)| **score >= 50)
.map(|(name, _)| *name)
.collect();
passed.sort();
println!("reçus : {}", passed.join(", "));
let odd_squares: Vec<u32> = (1..10).filter(|n| n % 2 == 1).map(|n| n * n).collect();
println!("{odd_squares:?}");
let total: u32 = scores.values().sum();
let average = total as f64 / scores.len() as f64;
println!("moyenne : {average:.1}");
}

Sortie :

reçus : Ada, Cy
[1, 9, 25, 49, 81]
moyenne : 63.0
▶ Essayez par vous-même

Parcourir une HashMap vous donne des paires de références, c’est pourquoi vous voyez **score et *name : ils suivent les références jusqu’aux valeurs. Le sort() n’est pas optionnel ici : sans lui, les noms pourraient sortir dans n’importe quel ordre. Et total as f64 est nécessaire, parce que Rust ne transforme jamais un entier en flottant dans votre dos.

Portez ce code Python. Chaque ligne doit avoir la forme nom: âge. Indice : line.split_once(':') renvoie une Option<(&str, &str)> ; transformez-la en Result avec .ok_or(...), puis utilisez ?. Faites renvoyer Result<(String, u32), String> à votre fonction.

def parse_line(line):
if ":" not in line:
raise ValueError("':' manquant")
name, age = line.split(":", 1)
try:
return name.strip(), int(age.strip())
except ValueError as e:
raise ValueError(f"âge invalide : {e}")
ages = {}
for line in ["Ada: 36", "Grace 45", "Linus: quarante"]:
try:
name, age = parse_line(line)
ages[name] = age
print(f"ok : {name} a {age} ans")
except ValueError as e:
print(f"ignorée {line!r} : {e}")
print(len(ages), "âge(s) enregistré(s)")
Voir la solution
use std::collections::HashMap;
fn parse_line(line: &str) -> Result<(String, u32), String> {
let (name, age) = line.split_once(':').ok_or("':' manquant")?;
let age: u32 = age.trim().parse().map_err(|e| format!("âge invalide : {e}"))?;
Ok((name.trim().to_string(), age))
}
fn main() {
let mut ages = HashMap::new();
for line in ["Ada: 36", "Grace 45", "Linus: quarante"] {
match parse_line(line) {
Ok((name, age)) => {
println!("ok : {name} a {age} ans");
ages.insert(name, age);
}
Err(e) => println!("ignorée {line:?} : {e}"),
}
}
println!("{} âge(s) enregistré(s)", ages.len());
}

Sortie :

ok : Ada a 36 ans
ignorée "Grace 45" : ':' manquant
ignorée "Linus: quarante" : âge invalide : invalid digit found in string
1 âge(s) enregistré(s)
▶ Essayez par vous-même

Les deux raise sont devenus des valeurs Err, et le try/except est devenu un match. Le ? après ok_or("':' manquant") fonctionne même si l’erreur est un &str alors que la fonction renvoie des erreurs de type String, parce que ? convertit l’erreur avec From. Remarquez l’ordre dans la branche Ok : on affiche d’abord, puis insert déplace name dans la map. Inversez ces deux lignes et le compilateur vous dira que name a été déplacé.

  • Une grande partie de Python se retrouve en Rust : les boucles sur des itérateurs, les tuples, les closures, match, et le traitement de données à la manière des compréhensions (sous forme de chaînes d’itérateurs).
  • Les grands changements : des types statiques vérifiés avant l’exécution, l’ownership au lieu d’un ramasse-miettes, Option au lieu de None, et Result + ? au lieu des exceptions.
  • Les classes deviennent une struct + impl, le duck typing devient des traits, et les blocs with deviennent un Drop automatique. Une HashMap n’a pas d’ordre, et les entiers ont une taille fixe.
  • Rust n’a pas de GIL : les threads s’exécutent en parallèle, et le compilateur empêche les accès concurrents aux données.
  • Cargo remplace pip, venv et compagnie ; cargo fmt, cargo clippy et cargo test remplacent black, ruff et pytest.
  • Vous pouvez mélanger les deux langages : PyO3 et maturin transforment du code Rust en module Python.

Petit quiz

1. Qu'est-ce que Rust utilise à la place du None de Python ?

2. En Python, b = a donne deux noms à la même liste. Que fait let b = a; avec un Vec en Rust ?

3. Que vaut 7 / 2 en Rust, quand les deux nombres sont des entiers ?

4. Vous voulez accélérer une fonction lente d'un projet Python avec Rust. Quels outils utilisez-vous ?

Score : 0 / 4

  • The Rust Programming Language (en anglais) : le livre officiel. Les chapitres 4 (ownership), 6 (enums et Option), 9 (erreurs) et 13 (closures et itérateurs) sont les plus importants pour les développeurs Python.
  • PyO3 user guide (en anglais) : tout sur l’appel de Rust depuis Python (et de Python depuis Rust).
  • Maturin user guide (en anglais) : compiler, tester et publier des paquets Python propulsés par Rust.
  • Rust by Example (en anglais) : de courts exemples exécutables, parfaits si vous apprenez mieux en lisant du code.
  • Are We Learning Yet? (en anglais) : un panorama des crates Rust pour le machine learning et les données.