Aller au contenu

Questions d'entretien Rust

Voici des questions qui reviennent sans cesse dans les entretiens d’embauche pour des postes Rust, du niveau junior au niveau senior. Chaque réponse est assez courte pour être dite à voix haute, et renvoie vers la leçon qui explique le sujet en détail.

N’apprenez pas ces réponses par cœur. Les recruteurs s’en rendent compte, et ils posent généralement une question de suivi pour voir si vous avez vraiment compris. Servez-vous de cette page pour faire le point sur ce que vous savez, puis allez voir la leçon correspondante pour tout ce qui vous semble fragile. Si vous savez expliquer une réponse avec vos propres mots et écrire un petit exemple, vous êtes prêt.

Cargo est l’outil de compilation et le gestionnaire de paquets officiel de Rust. Il est installé avec Rust. Avec un seul outil, vous pouvez :

  • créer un projet (cargo new) ;
  • le compiler et le lancer (cargo build, cargo run) ;
  • lancer les tests (cargo test) ;
  • ajouter des bibliothèques, appelées crates, depuis crates.io (cargo add serde).

Les réglages et les dépendances d’un projet se trouvent dans Cargo.toml. Les versions exactes sont verrouillées dans Cargo.lock, ce qui rend les compilations reproductibles. Un bon point à ajouter : dans d’autres langages, il faut souvent plusieurs outils pour tout cela, alors qu’en Rust tout le monde utilise Cargo. Chaque projet a donc l’air familier.

Pour en savoir plus : Cargo et les crates en Rust, Débuter avec Rust.

Q : Quelle est la différence entre le shadowing et mut ?

Section intitulée « Q : Quelle est la différence entre le shadowing et mut ? »

mut permet de changer la valeur de la même variable. Le type doit rester le même.

Le shadowing (masquage) consiste à réécrire let avec le même nom. Cela crée une nouvelle variable qui cache l’ancienne. La nouvelle peut même avoir un type différent.

fn main() {
let spaces = " ";
let spaces = spaces.len(); // shadowing : une nouvelle variable, maintenant un nombre
println!("{spaces}");
let mut count = 1;
count += 1; // mut : la même variable reçoit une nouvelle valeur
println!("{count}");
}

Sortie :

3
2
▶ Essayez par vous-même

Le shadowing est pratique pour transformer une valeur étape par étape (par exemple, convertir un texte en nombre) sans inventer des noms comme input_str et input_num. Et la variable finale reste immuable.

Pour en savoir plus : Les variables en Rust.

Q : Quelle est la différence entre une macro et une fonction ?

Section intitulée « Q : Quelle est la différence entre une macro et une fonction ? »

Une fonction est appelée à l’exécution, avec un nombre fixe d’arguments typés. Une macro s’exécute à la compilation : elle prend du code et écrit d’autre code avant que le compilateur ne le vérifie. On reconnaît les macros à leur !, comme println!, vec! et format!.

Les macros peuvent faire des choses impossibles pour une fonction, comme accepter un nombre variable d’arguments (println!("{} {}", a, b)) ou vérifier une chaîne de format à la compilation. Les macros Rust travaillent aussi sur la structure du code, pas sur du texte brut, ce qui leur évite les bugs classiques des macros C :

macro_rules! square {
($x:expr) => {
$x * $x
};
}
fn main() {
// En C, une macro textuelle donnerait 2 + 1 * 2 + 1 = 5.
println!("{}", square!(2 + 1));
}

Sortie :

9
▶ Essayez par vous-même

Le compromis, honnêtement : les macros sont plus difficiles à lire, à écrire et à déboguer. Utilisez une fonction quand une fonction suffit.

Pour en savoir plus : Les macros en Rust.

Q : Que veut dire « abstractions à coût zéro » ?

Section intitulée « Q : Que veut dire « abstractions à coût zéro » ? »

Cela veut dire que les fonctionnalités de haut niveau ne doivent pas rendre votre programme plus lent que le code de bas niveau que vous écririez à la main. « Vous ne payez pas pour ce que vous n’utilisez pas, et ce que vous utilisez, vous ne pourriez pas l’écrire mieux vous-même. »

Par exemple, une chaîne d’itérateurs comme .iter().filter(...).map(...).sum() se compile généralement en un code machine identique à celui d’une boucle écrite à la main. Les génériques sont un autre exemple : le compilateur crée une copie spécialisée pour chaque type utilisé (on appelle ça la monomorphisation), donc il n’y a aucune recherche à l’exécution.

Soyez honnête sur la partie « coût » : c’est un coût nul à l’exécution. Vous payez quand même en temps de compilation, et les génériques peuvent faire grossir le fichier du programme.

Pour en savoir plus : Les itérateurs en Rust, Les génériques en Rust.

Oui. Des méthodes comme .map() et .filter() ne font aucun travail par elles-mêmes. Elles construisent une description du travail à faire. Rien ne se passe tant que quelque chose ne consomme pas l’itérateur, comme .collect(), .sum() ou une boucle for.

fn main() {
let numbers = vec![1, 2, 3];
let doubled = numbers.iter().map(|n| {
println!("on double {n}");
n * 2
});
println!("Rien ne s'est encore exécuté.");
let result: Vec<i32> = doubled.collect();
println!("{:?}", result);
}

Sortie :

Rien ne s'est encore exécuté.
on double 1
on double 2
on double 3
[2, 4, 6]
▶ Essayez par vous-même

C’est aussi pour cela que le compilateur vous avertit si vous écrivez numbers.iter().map(...); sans jamais utiliser le résultat : cette ligne ne ferait rien.

Pour en savoir plus : Les itérateurs en Rust.

Il y en a trois :

  1. Chaque valeur en Rust a un seul propriétaire (une variable).
  2. Il ne peut y avoir qu’un seul propriétaire à la fois.
  3. Quand le propriétaire sort de sa portée, la valeur est libérée (dropped) : sa mémoire est rendue.

Grâce à ces règles, Rust libère la mémoire exactement au bon moment, sans ramasse-miettes (garbage collector) et sans que vous appeliez free à la main. Le compilateur les vérifie : des erreurs comme « use after free » (utiliser une mémoire déjà libérée) ou « double free » (la libérer deux fois) sont impossibles dans du code sûr.

Pour en savoir plus : L’ownership en Rust, La portée en Rust.

Q : Quelle est la différence entre move, copy et clone ?

Section intitulée « Q : Quelle est la différence entre move, copy et clone ? »
  • Move : affecter une valeur ou la passer à une fonction transfère l’ownership (la possession). L’ancienne variable n’est plus utilisable. C’est le comportement par défaut pour des types comme String et Vec. Rien n’est copié, à part un petit pointeur, une longueur et une capacité.
  • Copy : pour les petits types simples comme i32, f64, bool et char, l’affectation fait une copie bit à bit peu coûteuse, et les deux variables restent utilisables.
  • Clone : .clone() est une copie profonde explicite. Pour une String, elle copie tout le texte dans une nouvelle zone mémoire. Cela peut coûter cher, et c’est pour cela que Rust vous oblige à l’écrire.
fn main() {
let x = 5;
let y = x; // Copy : x reste utilisable
println!("{x} {y}");
let a = String::from("salut");
let b = a.clone(); // Clone : une copie complète du texte
let c = a; // Move : `a` n'est plus utilisable après cette ligne
println!("{b} {c}");
}

Sortie :

5 5
salut salut
▶ Essayez par vous-même

Pour en savoir plus : L’ownership en Rust.

Q : Qu’est-ce que le trait Copy, et quels types l’implémentent ?

Section intitulée « Q : Qu’est-ce que le trait Copy, et quels types l’implémentent ? »

Copy est un trait marqueur qui dit « ce type peut être dupliqué en copiant simplement ses bits ». Les types Copy sont copiés au lieu d’être déplacés lors d’une affectation.

Les entiers, les flottants, bool, char, les références partagées (&T), ainsi que les tuples et les tableaux composés uniquement de types Copy, sont tous Copy. String, Vec et Box ne le sont pas, car ils possèdent de la mémoire ailleurs : copier le pointeur donnerait deux propriétaires.

Vous pouvez le dériver pour vos propres types, mais seulement si chaque champ est Copy :

#[derive(Clone, Copy)]
struct User {
name: String,
age: u32,
}
error[E0204]: the trait `Copy` cannot be implemented for this type
--> src/main.rs:2:8
|
1 | #[derive(Clone, Copy)]
| ---- in this derive macro expansion
2 | struct User {
| ^^^^
3 | name: String,
| ------------ this field does not implement `Copy`

Le compilateur refuse : le trait Copy ne peut pas être implémenté pour ce type, car le champ name n’est pas Copy.

Copy exige toujours Clone aussi. Et un type qui implémente Drop ne peut pas être Copy.

Pour en savoir plus : Les traits courants en Rust, L’ownership en Rust.

Q : Quelle est la différence entre String et &str ?

Section intitulée « Q : Quelle est la différence entre String et &str ? »
  • String est un texte possédé (owned) et extensible, stocké sur le tas (heap). Vous pouvez le modifier, par exemple avec push_str.
  • &str (une « string slice ») est une vue empruntée sur un texte. C’est juste un pointeur et une longueur. Les littéraux comme "bonjour" sont des &str.

Une bonne règle : les fonctions qui se contentent de lire du texte devraient prendre un &str. Ainsi, elles acceptent les deux, car un &String se transforme automatiquement en &str.

fn greet(name: &str) {
println!("Bonjour, {name} !");
}
fn main() {
let owned: String = String::from("Ana");
let literal: &str = "Ben";
greet(&owned); // &String fonctionne là où on attend un &str
greet(literal);
let mut full = owned;
full.push_str(" Martin"); // seule une String peut grandir
greet(&full);
}

Sortie :

Bonjour, Ana !
Bonjour, Ben !
Bonjour, Ana Martin !
▶ Essayez par vous-même

Pour en savoir plus : Les chaînes de caractères en Rust, Les slices en Rust.

Le borrowing (emprunt) consiste à utiliser une valeur à travers une référence, sans en devenir propriétaire. À tout moment, pour une valeur donnée, vous pouvez avoir soit :

  • autant de références partagées (&T) que vous voulez, soit
  • exactement une référence mutable (&mut T),

mais pas les deux. Et chaque référence doit toujours pointer vers des données valides.

Pourquoi ? Si quelqu’un pouvait modifier des données pendant qu’un autre les lit, vous auriez des bugs comme un vecteur qui déplace sa mémoire alors que vous gardez un pointeur dessus. Avec des threads, c’est exactement la définition d’une data race (accès concurrent non synchronisé). Rust l’empêche dès la compilation. Depuis l’édition 2018, le compilateur sait finement quand un emprunt se termine (on parle de non-lexical lifetimes) : un emprunt ne dure que jusqu’à sa dernière utilisation, pas jusqu’à la fin du bloc.

Pour en savoir plus : Le borrowing en Rust.

Une lifetime (durée de vie), c’est le nom que le compilateur donne à « combien de temps une référence reste valide ». Chaque référence en a une. La plupart du temps, le compilateur les déduit tout seul (on parle d’élision des lifetimes), et vous ne les voyez jamais.

Vous ne les écrivez que lorsque le compilateur ne peut pas savoir comment la référence renvoyée est liée aux entrées, comme dans une fonction qui renvoie l’une de deux chaînes empruntées :

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}
fn main() {
let first = String::from("borrow checker");
let second = String::from("lifetimes");
println!("{}", longest(&first, &second));
}

Sortie :

borrow checker
▶ Essayez par vous-même

Les deux chaînes restent en anglais : ce sont des termes que les développeurs Rust francophones utilisent tels quels, et le résultat dépend de leur longueur en octets (.len()).

Un point important à dire en entretien : les annotations de lifetime ne changent pas la durée de vie de quoi que ce soit. Elles décrivent seulement des relations, pour que le compilateur puisse vérifier qu’aucune référence ne vit plus longtemps que les données vers lesquelles elle pointe.

Pour en savoir plus : Les lifetimes en Rust.

Q : Rust n’a pas de null. Qu’utilise-t-il à la place ?

Section intitulée « Q : Rust n’a pas de null. Qu’utilise-t-il à la place ? »

Option<T>, un enum à deux variantes : Some(valeur) ou None. Comme c’est un type différent de T, le compilateur vous oblige à traiter le cas None avant de pouvoir utiliser la valeur. Impossible d’oublier un test de null : le code ne compilerait pas.

fn find_user(id: u32) -> Option<&'static str> {
match id {
1 => Some("Ana"),
2 => Some("Ben"),
_ => None,
}
}
fn main() {
for id in [1, 3] {
match find_user(id) {
Some(name) => println!("Trouvé : {name}"),
None => println!("Aucun utilisateur avec l'id {id}"),
}
}
}

Sortie :

Trouvé : Ana
Aucun utilisateur avec l'id 3
▶ Essayez par vous-même

Un joli détail en plus : Option<&T> et Option<Box<T>> n’occupent pas plus de mémoire qu’un simple pointeur. En interne, Rust utilise la valeur « nulle » pour représenter None : ici, la sécurité ne coûte rien.

Pour en savoir plus : Option en Rust, If Let et While Let en Rust.

Q : Qu’est-ce qu’un trait ? Est-ce la même chose qu’une interface ?

Section intitulée « Q : Qu’est-ce qu’un trait ? Est-ce la même chose qu’une interface ? »

Un trait définit un ensemble de méthodes qu’un type peut implémenter. C’est proche d’une interface en Java, en C# ou en Go, et c’est une bonne façon de commencer votre réponse. Mentionnez ensuite les différences :

  • Les traits peuvent avoir des implémentations par défaut de leurs méthodes.
  • Vous pouvez implémenter un trait pour un type après que le type a été écrit, même pour des types que vous n’avez pas écrits, comme implémenter votre propre trait pour i32. (La « règle de l’orphelin », orphan rule, impose que le trait ou le type vous appartienne.)
  • Les traits servent de contraintes sur les génériques (T: Display), résolues à la compilation, et aussi de trait objects (dyn Display) à l’exécution.
  • Rust n’a pas d’héritage de classes. Les traits combinés à la composition le remplacent.

Pour en savoir plus : Les traits en Rust, Les traits courants en Rust.

Q : Quelle est la différence entre les génériques et les trait objects ?

Section intitulée « Q : Quelle est la différence entre les génériques et les trait objects ? »

Les deux permettent à une même fonction de travailler avec beaucoup de types. La différence, c’est quand le type est choisi :

  • Les génériques (fn f<T: Speak>(x: &T)) utilisent le dispatch statique. Le compilateur crée une copie séparée de la fonction pour chaque type. Les appels sont aussi rapides que des appels de fonction normaux, mais le programme peut grossir.
  • Les trait objects (&dyn Speak, Box<dyn Speak>) utilisent le dispatch dynamique. Il n’y a qu’une copie de la fonction, et la bonne méthode est cherchée à l’exécution dans une table (une « vtable »). C’est un tout petit peu plus lent, mais cela permet de mélanger des types différents dans une même collection.
trait Speak {
fn speak(&self) -> String;
}
struct Dog;
struct Cat;
impl Speak for Dog {
fn speak(&self) -> String {
String::from("Ouaf")
}
}
impl Speak for Cat {
fn speak(&self) -> String {
String::from("Miaou")
}
}
fn speak_static<T: Speak>(animal: &T) -> String {
animal.speak()
}
fn speak_dynamic(animal: &dyn Speak) -> String {
animal.speak()
}
fn main() {
println!("{}", speak_static(&Dog));
println!("{}", speak_dynamic(&Cat));
// Seuls les trait objects peuvent mélanger des types dans un même Vec :
let animals: Vec<Box<dyn Speak>> = vec![Box::new(Dog), Box::new(Cat)];
for animal in &animals {
println!("{}", animal.speak());
}
}

Sortie :

Ouaf
Miaou
Ouaf
Miaou
▶ Essayez par vous-même

Règle pratique : commencez par les génériques. Passez aux trait objects quand vous avez besoin d’une collection de types différents, ou quand vous voulez limiter le temps de compilation et la taille du binaire.

Pour en savoir plus : Les génériques en Rust, Les trait objects (dyn) en Rust.

Q : Quelle est la différence entre Box<dyn Trait> et impl Trait ?

Section intitulée « Q : Quelle est la différence entre Box<dyn Trait> et impl Trait ? »

Les deux permettent de dire « un type qui implémente ce trait » sans le nommer. La différence, c’est le nombre de types concrets autorisés :

  • impl Trait désigne un seul type précis, que le compilateur connaît mais qu’il ne vous oblige pas à écrire. C’est du dispatch statique : pas d’allocation sur le tas, pas de vtable, aussi rapide que si vous nommiez le type vous-même. En position d’argument (fn f(x: impl Display)), c’est simplement une façon plus courte d’écrire un générique. En position de retour, c’est ce qui permet de renvoyer des valeurs dont le type est long ou impossible à nommer, comme les closures et les chaînes d’itérateurs.
  • Box<dyn Trait> est un trait object sur le tas. Le type réel peut être différent à chaque fois, choisi à l’exécution, et la méthode est trouvée via une vtable. Cela coûte une allocation et un appel indirect, mais c’est le seul moyen de renvoyer un type parmi plusieurs, ou de les ranger dans un même Vec.
use std::fmt::Display;
// impl Trait : un seul type caché, fixé à la compilation.
fn evens(limit: u32) -> impl Iterator<Item = u32> {
(0..limit).filter(|n| n % 2 == 0)
}
// Box<dyn Trait> : le vrai type peut changer à l'exécution.
fn label(as_number: bool) -> Box<dyn Display> {
if as_number {
Box::new(42)
} else {
Box::new("quarante-deux")
}
}
fn main() {
let v: Vec<u32> = evens(10).collect();
println!("{v:?}");
println!("{}", label(true));
println!("{}", label(false));
}

Sortie :

[0, 2, 4, 6, 8]
42
quarante-deux
▶ Essayez par vous-même

Un bon détail à mentionner : si vous modifiez label pour qu’elle renvoie impl Display et que vous retirez les Box, le code ne compile plus. Le if renvoie un i32 et le else un &str, alors qu’impl Trait doit être un seul et même type. Règle pratique : utilisez impl Trait par défaut, et passez à Box<dyn Trait> quand vous avez vraiment besoin de choisir entre plusieurs types à l’exécution.

Pour en savoir plus : Les trait objects (dyn) en Rust, Les génériques en Rust.

Un match doit traiter toutes les valeurs possibles, sinon le code ne compile pas. Conséquence : quand vous ajoutez une nouvelle variante à un enum, le compilateur vous montre tous les endroits à mettre à jour. Dans d’autres langages, un case oublié dans un switch ne fait tout simplement rien, sans prévenir.

Vous pouvez utiliser _ comme cas « fourre-tout », mais attention : il cache aussi les variantes que vous ajouterez plus tard, et vous perdez ce filet de sécurité.

Pour en savoir plus : Match en Rust, Le pattern matching en Rust, Les enums en Rust.

Q : Comment Rust gère-t-il les erreurs sans exceptions ?

Section intitulée « Q : Comment Rust gère-t-il les erreurs sans exceptions ? »

Les erreurs récupérables sont des valeurs ordinaires de type Result<T, E> : soit Ok(valeur), soit Err(erreur). Une fonction qui peut échouer l’annonce dans son type de retour, et l’appelant doit s’en occuper. Rien ne remonte la pile de façon invisible, comme le ferait une exception.

Les avantages : la signature d’une fonction vous dit qu’elle peut échouer, et le compilateur vous avertit si vous ignorez un Result. Le prix à payer, c’est un peu plus de code à écrire, que l’opérateur ? fait presque entièrement disparaître.

Pour en savoir plus : Result en Rust.

Q : Quand faut-il utiliser panic! plutôt que renvoyer un Result ?

Section intitulée « Q : Quand faut-il utiliser panic! plutôt que renvoyer un Result ? »
  • Renvoyez un Result quand l’échec est prévisible et que l’appelant peut raisonnablement le gérer : un fichier manquant, une saisie incorrecte, un délai réseau dépassé.
  • Déclenchez une panic quand il s’agit d’un bug, c’est-à-dire quand le programme arrive dans un état « censé être impossible » et que continuer serait une erreur. Accéder à un indice au-delà de la fin d’un vecteur provoque une panic pour cette raison.

Les panics sont aussi acceptables dans les tests, les exemples et les prototypes rapides. Dans une bibliothèque, évitez de paniquer sur des entrées que l’appelant contrôle : renvoyez une erreur et laissez-le décider.

Pour en savoir plus : Panic en Rust, Result en Rust.

Q : Quelle est la différence entre unwrap, expect et ? ?

Section intitulée « Q : Quelle est la différence entre unwrap, expect et ? ? »
  • unwrap() vous donne la valeur contenue dans Ok/Some, ou panique avec un message générique.
  • expect("msg") fait la même chose, mais panique avec votre message. Utilisez-le pour expliquer pourquoi vous pensez que l’opération ne peut pas échouer.
  • ? ne panique pas. Face à un Err/None, il renvoie l’erreur depuis la fonction en cours, pour que l’appelant puisse la gérer. La fonction doit elle-même renvoyer un Result ou un Option.
use std::num::ParseIntError;
fn double(text: &str) -> Result<i32, ParseIntError> {
let n: i32 = text.parse()?; // en cas d'erreur, on la renvoie à l'appelant
Ok(n * 2)
}
fn main() {
println!("{:?}", double("21"));
println!("{:?}", double("abc"));
let n: i32 = "7".parse().expect("un nombre écrit en dur doit toujours se convertir");
println!("{n}");
}

Sortie :

Ok(42)
Err(ParseIntError { kind: InvalidDigit })
7
▶ Essayez par vous-même

Dans du code de production, préférez ?. Utilisez expect quand un échec serait un bug, et gardez unwrap pour les tests et les essais rapides.

Pour en savoir plus : Result en Rust, Option en Rust.

En général, avec un enum qui liste les façons dont votre code peut échouer, plus des implémentations de std::fmt::Display et de std::error::Error. Implémenter From pour d’autres types d’erreur permet à ? de les convertir automatiquement.

En pratique, beaucoup de projets utilisent des crates d’aide : thiserror pour définir des types d’erreur dans les bibliothèques, et anyhow pour les applications qui ont seulement besoin de faire remonter les erreurs avec du contexte. Pour les petits programmes, Box<dyn Error> peut contenir n’importe quelle erreur.

Pour en savoir plus : Les erreurs personnalisées en Rust.

Ces deux crates résolvent chacune une moitié du même problème, donc la réponse honnête est « souvent les deux » :

  • thiserror sert aux bibliothèques, ou à tout code dont les appelants doivent réagir à des erreurs précises. Vous écrivez votre propre enum d’erreurs, et #[derive(Error)] génère le code répétitif de Display et Error. Les appelants obtiennent un vrai type sur lequel ils peuvent faire un match, comme ConfigError::MissingField.
  • anyhow sert aux applications, où la plupart des erreurs doivent seulement être signalées, pas traitées une par une. Chaque fonction renvoie un anyhow::Result<T>, ? accepte presque n’importe quel type d’erreur, et .context("pendant la lecture de la config") ajoute une explication lisible par un humain. Le revers : les appelants ne peuvent plus facilement faire de match sur l’erreur.

Une organisation courante : thiserror dans les crates de bibliothèque, et anyhow dans main et le reste du binaire. Aucune des deux crates n’est magique. Elles retirent seulement du code répétitif autour du trait standard std::error::Error : vous pouvez toujours écrire la même chose à la main.

Pour en savoir plus : Gérer les erreurs avec anyhow et thiserror en Rust, Les erreurs personnalisées en Rust.

Non. La mémoire est libérée quand son propriétaire sort de sa portée, à un moment que le compilateur connaît à l’avance (on appelle ça le RAII, et la méthode de nettoyage s’appelle Drop). Vous obtenez des performances prévisibles, sans les pauses d’un ramasse-miettes (garbage collector, ou GC).

Quand vous avez vraiment besoin d’une possession partagée, vous l’activez explicitement avec le comptage de références (Rc ou Arc), qui libère la valeur quand le dernier propriétaire disparaît.

Pour en savoir plus : L’ownership en Rust, Les pointeurs intelligents en Rust.

  • Box<T> : un seul propriétaire, valeur sur le tas. À utiliser pour les types récursifs (comme un arbre ou une liste chaînée), les grosses valeurs ou les trait objects (Box<dyn Trait>).
  • Rc<T> : plusieurs propriétaires dans un seul thread. Il compte les références et libère la valeur quand le compteur tombe à zéro. Les données sont en lecture seule.
  • Arc<T> : comme Rc, mais le compteur est atomique, ce qui permet de le partager entre threads sans danger. Un peu plus lent que Rc.
  • RefCell<T> : permet de modifier des données à travers une référence partagée (« mutabilité intérieure », interior mutability). Les règles d’emprunt sont vérifiées à l’exécution au lieu de la compilation, et les enfreindre provoque une panic.

Ils se combinent. Rc<RefCell<T>> veut dire « partagé et modifiable, dans un seul thread ». Arc<Mutex<T>> en est la version thread-safe.

use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let shared = Rc::new(RefCell::new(vec![1, 2]));
let other_owner = Rc::clone(&shared);
other_owner.borrow_mut().push(3);
println!("{:?}", shared.borrow());
println!("propriétaires : {}", Rc::strong_count(&shared));
}

Sortie :

[1, 2, 3]
propriétaires : 2
▶ Essayez par vous-même

Pour en savoir plus : Les pointeurs intelligents en Rust.

Q : Peut-on avoir des fuites de mémoire en Rust sûr ?

Section intitulée « Q : Peut-on avoir des fuites de mémoire en Rust sûr ? »

Oui, et c’est honnête de le reconnaître. Rust garantit la sécurité mémoire (pas d’utilisation après libération, pas de pointeurs pendants, pas de data races), mais une fuite de mémoire n’est pas considérée comme dangereuse (unsafe). Deux cas courants :

  • Un cycle de Rc : deux valeurs qui pointent l’une vers l’autre avec Rc n’atteignent jamais un compteur à zéro. La solution consiste à faire de l’un des deux liens une référence Weak.
  • Des fonctions comme Box::leak et std::mem::forget, qui provoquent une fuite volontairement.

Dans le code de tous les jours, les fuites sont rares, car l’ownership libère presque tout automatiquement.

Pour en savoir plus : Les pointeurs intelligents en Rust.

Q : Qu’est-ce que unsafe, et quand l’utilise-t-on ?

Section intitulée « Q : Qu’est-ce que unsafe, et quand l’utilise-t-on ? »

unsafe marque un bloc (ou une fonction) dans lequel vous pouvez faire cinq choses supplémentaires que le compilateur ne sait pas vérifier, comme déréférencer des pointeurs bruts, appeler des fonctions unsafe (y compris du code C via la FFI) ou accéder à des variables statiques mutables. À l’intérieur, c’est vous qui promettez que les règles sont respectées.

Important : unsafe ne désactive pas le borrow checker ni les autres vérifications. Il débloque seulement ces quelques opérations supplémentaires.

On l’utilise pour dialoguer avec des bibliothèques C, écrire des structures de données de bas niveau, et gagner en performance là où le compilateur ne peut pas prouver qu’une opération est sûre. La bibliothèque standard elle-même utilise unsafe à l’intérieur d’API sûres comme Vec. Bonne pratique : gardez les blocs unsafe petits, enveloppez-les dans une API sûre et écrivez un commentaire // SAFETY: qui explique pourquoi c’est correct. La plupart du code applicatif n’en a jamais besoin.

Pour en savoir plus : Unsafe en Rust.

Ce sont des traits marqueurs que le compilateur utilise pour garantir la sécurité entre threads :

  • Send : une valeur de ce type peut être déplacée vers un autre thread.
  • Sync : une valeur de ce type peut être partagée par référence entre threads (&T est Send).

La plupart des types sont les deux, et le compilateur les implémente automatiquement. L’exception célèbre est Rc, dont le compteur n’est pas thread-safe. Essayez d’en envoyer un à un thread, et le code ne compile pas :

use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(5);
let handle = thread::spawn(move || {
println!("{data}");
});
handle.join().unwrap();
}
error[E0277]: `Rc<i32>` cannot be sent between threads safely
...
= help: within `{closure@src/main.rs:6:32: 6:39}`, the trait `Send` is not implemented for `Rc<i32>`

Le message dit que Rc<i32> ne peut pas être envoyé d’un thread à l’autre en toute sécurité, car il n’implémente pas le trait Send. Remplacez Rc par Arc et tout fonctionne. C’est en grande partie ce qu’on appelle la « concurrence sans peur » (fearless concurrency) de Rust : les erreurs de sécurité entre threads deviennent des erreurs de compilation.

Pour en savoir plus : La concurrence en Rust.

Q : Comment partager des données modifiables entre threads ?

Section intitulée « Q : Comment partager des données modifiables entre threads ? »

La réponse classique est Arc<Mutex<T>>. Arc permet à plusieurs threads de posséder la valeur. Mutex garantit qu’un seul thread à la fois peut la modifier : vous appelez .lock(), et vous obtenez un garde (guard) qui déverrouille automatiquement quand il sort de sa portée.

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("Total : {}", *counter.lock().unwrap());
}

Sortie :

Total : 4000
▶ Essayez par vous-même

Mentionnez aussi l’alternative : au lieu de partager de la mémoire, envoyez des messages entre threads avec des canaux (std::sync::mpsc). Et pour de simples compteurs, les types atomiques comme AtomicUsize évitent le verrou.

Pour en savoir plus : La concurrence en Rust.

Q : Comment les canaux se comparent-ils à l’état partagé avec Mutex ?

Section intitulée « Q : Comment les canaux se comparent-ils à l’état partagé avec Mutex ? »

Ce sont deux façons de répartir le travail entre threads :

  • Les canaux (channels, std::sync::mpsc) transmettent des messages. Une valeur est déplacée dans le canal et ressort de l’autre côté, donc un seul thread la possède à la fois. Ils conviennent aux chaînes de traitement et aux groupes de workers : « les workers produisent des résultats, un thread les rassemble ». Il est généralement plus facile de raisonner avec eux, car deux threads ne touchent jamais les mêmes données.
  • L’état partagé (Arc<Mutex<T>>) signifie que plusieurs threads utilisent les mêmes données, chacun son tour grâce à un verrou. Il convient quand beaucoup de threads doivent lire et mettre à jour une même structure, comme un cache ou une table de compteurs. Les risques : les interblocages (deadlocks), et les ralentissements quand les threads passent leur temps à attendre le verrou.

Voici le compteur de la réponse précédente, réécrit avec un canal. Chaque worker compte de son côté, sans verrou, et envoie son résultat :

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for _ in 0..4 {
let tx = tx.clone();
thread::spawn(move || {
let mut count = 0;
for _ in 0..1000 {
count += 1;
}
tx.send(count).unwrap();
});
}
drop(tx); // libérer l'émetteur d'origine, sinon rx.iter() ne se termine jamais
let total: i32 = rx.iter().sum();
println!("Total : {total}");
}

Sortie :

Total : 4000
▶ Essayez par vous-même

La ligne drop(tx) est un excellent détail à mentionner : rx.iter() attend jusqu’à ce que tous les émetteurs aient disparu, donc l’oublier fait bloquer le programme indéfiniment. Dans les deux styles, Rust vérifie la sécurité entre threads à la compilation. Le choix dépend de la conception la plus claire pour votre problème, et beaucoup de programmes utilisent les deux.

Pour en savoir plus : Les canaux (channels) en Rust, La concurrence en Rust.

Q : Rust empêche-t-il tous les bugs de concurrence ?

Section intitulée « Q : Rust empêche-t-il tous les bugs de concurrence ? »

Non, et le dire montre une vraie compréhension. Le Rust sûr empêche les data races (deux threads qui accèdent à la même mémoire en même temps, dont au moins un en écriture). Il n’empêche pas :

  • Les interblocages (deadlocks), par exemple deux threads qui attendent chacun un verrou détenu par l’autre.
  • Les situations de compétition dans votre logique (race conditions), par exemple vérifier une valeur puis agir en conséquence alors qu’un autre thread l’a modifiée entre-temps.

Rust élimine donc toute une catégorie de bugs difficiles, mais vous devez quand même concevoir vos verrous avec soin.

Pour en savoir plus : La concurrence en Rust.

Q : Quelle est la différence entre async et les threads ?

Section intitulée « Q : Quelle est la différence entre async et les threads ? »
  • Les threads sont gérés par le système d’exploitation. Chacun a sa propre pile, et le système passe de l’un à l’autre. Ils sont parfaits pour le travail qui sollicite beaucoup le processeur. Mais des milliers de threads consomment beaucoup de mémoire.
  • Les tâches async sont bien plus légères. Une async fn renvoie un Future, et un runtime (le plus souvent Tokio) exécute de nombreux futures sur quelques threads. Quand une tâche attend (le réseau, un minuteur, un fichier), .await laisse les autres tâches s’exécuter. C’est idéal pour les programmes qui attendent beaucoup, comme les serveurs web qui gèrent des milliers de connexions.
use tokio::time::{sleep, Duration};
async fn fetch(name: &str, ms: u64) -> String {
sleep(Duration::from_millis(ms)).await; // on fait semblant d'attendre le réseau
format!("{name} a terminé")
}
#[tokio::main]
async fn main() {
// Les deux s'exécutent en même temps : cela prend environ 200 ms, pas 300 ms.
let (a, b) = tokio::join!(fetch("premier", 200), fetch("second", 100));
println!("{a}");
println!("{b}");
}

Sortie :

premier a terminé
second a terminé
▶ Essayez par vous-même

Deux détails honnêtes que les recruteurs apprécient : les futures Rust sont paresseux et ne font rien tant qu’on ne les attend pas avec .await, et la bibliothèque standard ne contient pas de runtime, c’est donc à vous d’en choisir un. Enfin, du code bloquant (comme un long calcul ou std::thread::sleep) dans une tâche async bloque les autres tâches du même thread.

Pour en savoir plus : Async/Await en Rust, La concurrence en Rust.

  • Commencez simple, puis approfondissez. Donnez d’abord une réponse en une phrase (« Rc est un pointeur à comptage de références pour la possession partagée dans un seul thread »). Ajoutez des détails si votre interlocuteur semble intéressé.
  • Expliquez le pourquoi. Les règles de Rust existent pour empêcher des bugs précis. Dire « les règles d’emprunt empêchent les data races et les pointeurs pendants » est plus convaincant que de réciter les règles.
  • Utilisez un petit exemple. Écrire trois lignes de code, ou en décrire une, explique souvent plus qu’un long discours.
  • Soyez honnête sur les compromis. Rust a un coût : des compilations plus lentes, une courbe d’apprentissage, plus de code pour gérer les erreurs. Le reconnaître montre de l’expérience, pas une faiblesse.
  • Dites « je ne sais pas » quand vous ne savez pas, puis réfléchissez à voix haute. « Je n’ai jamais utilisé Pin directement, mais je sais que ça concerne les futures qui ne doivent pas bouger en mémoire » est une excellente réponse.
  • Préparez une anecdote. Les recruteurs demandent souvent : « Racontez-moi une fois où le borrow checker vous a bloqué. » Pensez à un exemple réel tiré de votre propre code, et à ce que vous en avez appris.
  • Entraînez-vous en écrivant du code. La page Exercices Rust et les projets jeu de devinettes et liste de tâches sont de bons échauffements.

Petit quiz

1. Quel smart pointer utiliseriez-vous pour partager des données en lecture seule entre threads ?

2. Que fait l'opérateur ? quand il rencontre un Err ?

3. Lequel de ces problèmes le Rust sûr empêche-t-il à la compilation ?

4. Les annotations de lifetime changent-elles la durée de vie d'une valeur ?

Score : 0 / 4

  • The Rust Programming Language (le Rust Book) (en anglais) : le livre officiel. La plupart des réponses de cette page viennent directement de ses chapitres sur l’ownership, les traits, les erreurs et la concurrence.
  • Rust by Example (en anglais) : de courts exemples exécutables, pratiques pour se rafraîchir la mémoire sur la syntaxe juste avant un entretien.
  • The Rust Reference (en anglais) : les règles précises du langage, pour le jour où un recruteur demande « mais exactement, quand est-ce que ça se produit ? ».
  • The Rustonomicon (en anglais) : le guide du Rust unsafe, avec des explications approfondies sur Send, Sync et la disposition en mémoire, pour les questions de niveau senior.
  • Asynchronous Programming in Rust (en anglais) : comment les futures, async/.await et les runtimes s’articulent, dans le livre officiel sur l’async du projet Rust.