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.
Les bases
Section intitulée « Les bases »Q : Qu’est-ce que Cargo ?
Section intitulée « Q : Qu’est-ce que Cargo ? »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 :
32Le 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 :
9Le 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.
Q : Les itérateurs Rust sont-ils paresseux ?
Section intitulée « Q : Les itérateurs Rust sont-ils paresseux ? »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 1on double 2on double 3[2, 4, 6]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.
Ownership et borrowing
Section intitulée « Ownership et borrowing »Q : Quelles sont les règles de l’ownership ?
Section intitulée « Q : Quelles sont les règles de l’ownership ? »Il y en a trois :
- Chaque valeur en Rust a un seul propriétaire (une variable).
- Il ne peut y avoir qu’un seul propriétaire à la fois.
- 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
StringetVec. Rien n’est copié, à part un petit pointeur, une longueur et une capacité. - Copy : pour les petits types simples comme
i32,f64,booletchar, l’affectation fait une copie bit à bit peu coûteuse, et les deux variables restent utilisables. - Clone :
.clone()est une copie profonde explicite. Pour uneString, 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 5salut salutPour 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 expansion2 | 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 ? »Stringest un texte possédé (owned) et extensible, stocké sur le tas (heap). Vous pouvez le modifier, par exemple avecpush_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 !Pour en savoir plus : Les chaînes de caractères en Rust, Les slices en Rust.
Q : Quelles sont les règles du borrowing ?
Section intitulée « Q : Quelles sont les règles du borrowing ? »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.
Q : Que sont les lifetimes ?
Section intitulée « Q : Que sont les lifetimes ? »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 checkerLes 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.
Types et traits
Section intitulée « Types et traits »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é : AnaAucun utilisateur avec l'id 3Un 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 :
OuafMiaouOuafMiaouRè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 Traitdé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êmeVec.
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]42quarante-deuxUn 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.
Q : Pourquoi un match doit-il être exhaustif ?
Section intitulée « Q : Pourquoi un match doit-il être exhaustif ? »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.
La gestion des erreurs
Section intitulée « La gestion des erreurs »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
Resultquand 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 dansOk/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 à unErr/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 unResultou unOption.
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 })7Dans 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.
Q : Comment créer ses propres types d’erreur ?
Section intitulée « Q : Comment créer ses propres types d’erreur ? »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.
Q : Quand utiliser anyhow plutôt que thiserror ?
Section intitulée « Q : Quand utiliser anyhow plutôt que thiserror ? »Ces deux crates résolvent chacune une moitié du même problème, donc la réponse honnête est « souvent les deux » :
thiserrorsert 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 deDisplayetError. Les appelants obtiennent un vrai type sur lequel ils peuvent faire unmatch, commeConfigError::MissingField.anyhowsert aux applications, où la plupart des erreurs doivent seulement être signalées, pas traitées une par une. Chaque fonction renvoie unanyhow::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 dematchsur 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.
Mémoire et smart pointers
Section intitulée « Mémoire et smart pointers »Q : Rust a-t-il un ramasse-miettes ?
Section intitulée « Q : Rust a-t-il un ramasse-miettes ? »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.
Q : Quand utiliser Box, Rc, Arc et RefCell ?
Section intitulée « Q : Quand utiliser Box, Rc, Arc et RefCell ? »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>: commeRc, mais le compteur est atomique, ce qui permet de le partager entre threads sans danger. Un peu plus lent queRc.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 : 2Pour 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 avecRcn’atteignent jamais un compteur à zéro. La solution consiste à faire de l’un des deux liens une référenceWeak. - Des fonctions comme
Box::leaketstd::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.
Concurrence et async
Section intitulée « Concurrence et async »Q : Que signifient Send et Sync ?
Section intitulée « Q : Que signifient Send et Sync ? »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 (&TestSend).
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 : 4000Mentionnez 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 : 4000La 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 fnrenvoie unFuture, 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),.awaitlaisse 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é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.
Conseils pour répondre en entretien
Section intitulée « Conseils pour répondre en entretien »- Commencez simple, puis approfondissez. Donnez d’abord une réponse en une phrase («
Rcest 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é
Pindirectement, 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
Section intitulée « Petit quiz »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
Pour aller plus loin
Section intitulée « Pour aller plus loin »- 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 surSend,Syncet la disposition en mémoire, pour les questions de niveau senior. - Asynchronous Programming in Rust (en anglais) : comment les futures,
async/.awaitet les runtimes s’articulent, dans le livre officiel sur l’async du projet Rust.