Testez vos connaissances en Rust

Ce questionnaire couvre les fondamentaux du langage Rust jusqu'aux concepts avancés. Il est divisé en deux parties : les bases (30 questions) et le mode hardcore (10 questions). Les réponses sont cachées sous chaque question -- essayez d'y répondre avant de les consulter.

La plupart des développeurs Rust intermédiaires bloquent dès la 3e ou 4e question de la partie hardcore. Si vous arrivez à dérouler un raisonnement clair sur au moins la moitié, vous pouvez vous considérer comme un expert confirmé.


Partie 1 : Les fondamentaux

Bases du langage

Question 1 : Quelles sont les principales différences entre Rust et C/C++ ?

Voir la réponse

La sécurisation mémoire, ou plus précisément la sécurisation des ressources grâce à l'ownership et au borrow checker. En Rust, la mémoire est gérée à la compilation, sans garbage collector, ce qui garantit la sécurité sans sacrifier les performances.

Question 2 : Peux-tu expliquer le système de propriété (ownership) en Rust ?

Voir la réponse

La donnée ne peut être détenue que par une seule variable. Il est possible de permettre à des fonctions d'y accéder via une référence (éventuellement mutable, c'est-à-dire en écriture), mais on ne peut que "transférer" une donnée à une autre variable, faisant que la détentrice précédente n'a plus accès à cette donnée. La seule alternative, c'est la copie (ou clone).

Question 3 : Quelle est la différence entre &T et &mut T ?

Voir la réponse

On peut avoir plusieurs &T simultanément, mais un seul &mut T à la fois, et jamais en même temps qu'un &T.

Question 4 : Qu'est-ce que le borrow checker et quel problème résout-il ?

Voir la réponse

Le borrow checker est le mécanisme du compilateur qui s'assure que les règles d'emprunts sont bien respectées. Il empêche les data races, les dangling references et les accès invalides à la mémoire, le tout vérifié à la compilation.

Question 5 : Qu'est-ce qu'une slice en Rust et comment diffère-t-elle d'un Vec<T> ?

Voir la réponse

Une slice (&[T]) permet l'accès à une portion de tableau en Rust, gérée par le compilateur. Le Vec<T> est un mécanisme du runtime (lib std) qui permet en outre des allocations mémoire dans la heap. La slice est une vue sur des données existantes, le Vec est propriétaire de ses données.

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

Voir la réponse

Comme pour les slices et Vec, String est une structure qui permet des allocations dynamiques de mémoire sur la heap. &str est une référence à une chaîne statique ou à une portion de String. String est propriétaire, &str est un emprunt.

Question 7 : Que signifie le mot-cle move ?

Voir la réponse

C'est ce qui indique au compilateur que le contenu d'une variable doit être "transféré" dans une closure. La closure prend alors la propriété des variables capturées, ce qui est obligatoire par exemple pour envoyer une closure dans un autre thread.

Question 8 : A quoi sert le mot-cle unsafe ?

Voir la réponse

À permettre des opérations typiquement interdites par le borrow checker : déréférencement de pointeurs bruts, appel de fonctions unsafe, accès à des variables mutables statiques, implémentation de traits unsafe, et accès à des champs d'unions.

Question 9 : Quelle est la différence entre Box<T>, Rc<T> et Arc<T> ?

Voir la réponse

Question 10 : Comment implementer un trait sur une structure ?

Voir la réponse

Avec le bloc impl MonTrait for MaStructure { ... }. On définit alors chaque méthode requise par le trait pour la structure en question.

Gestion des erreurs

Question 11 : Quelle est la différence entre Result<T, E> et Option<T> ?

Voir la réponse

Question 12 : Comment fonctionne l'operateur ? ?

Voir la réponse

C'est l'équivalent d'un unwrap() mais qui fait un return Err(e) à la place d'un panic. Si le Result est Ok, la valeur est extraite. Si c'est un Err, la fonction retourne immédiatement avec l'erreur.

Question 13 : Pourquoi unwrap() est-il dangereux ?

Voir la réponse

unwrap() transforme un Result<T, E> en T, mais génère un panic si c'est un Err. En production, un panic peut crasher le programme entier. On préfère utiliser ?, unwrap_or, unwrap_or_else, ou du pattern matching explicite.

Génériques et traits

Question 14 : Quelle est la différence entre traits (Rust) et interfaces (autres langages) ?

Voir la réponse

En Go par exemple, une interface c'est du duck typing : tout type qui implémente les méthodes satisfait l'interface implicitement. En Rust, un trait nécessite une implémentation explicite avec impl Trait for Type. Les traits Rust peuvent aussi fournir des implémentations par défaut, des types associés, et des contraintes de génériques.

Question 15 : Quelle est la différence entre dyn Trait et impl Trait ?

Voir la réponse

Question 16 : Que signifie Sized en Rust ?

Voir la réponse

Que le type a une taille identifiable à la compilation. La plupart des types sont Sized par défaut. Les types dynamiques comme str ou dyn Trait ne sont pas Sized, ce qui impose de les manipuler derrière une référence ou un Box.

Concurrence et async

Question 17 : Comment fonctionne la concurrence en Rust ?

Voir la réponse

Il y a deux façons principales de faire de la concurrence en Rust :

Send et Sync sont des traits qui garantissent qu'une variable peut être envoyée ou partagée entre threads. Les channels (std::sync::mpsc) fournissent une implémentation de canaux de communication, similaire aux channels CSP de Go.

Question 18 : Quelle est la différence entre tokio et async-std ?

Voir la réponse

Les deux sont des runtimes asynchrones pour Rust. Tokio est le plus utilisé dans l'écosystème et offre un runtime complet avec gestion de coroutines planifiées (schedulées). async-std se veut plus proche de l'API de la bibliothèque standard. En pratique, Tokio domine largement le paysage async Rust.

Question 19 : Que signifie Pin<Box<dyn Future<Output = T>>> ?

Voir la réponse

Question 20 : Comment gère-t-on la mémoire des futures en Rust sans GC ?

Voir la réponse

Grâce au borrow checker et au système d'ownership. Quand la variable n'est plus utile (sort du scope), elle est droppée automatiquement, libérant la ressource associée. Pas besoin de garbage collector.

Mémoire et bas niveau

Question 21 : Qu'est-ce qu'un lifetime ('a) et pourquoi est-ce utile ?

Voir la réponse

C'est la durée de vie d'une référence, c'est-à-dire la période pendant laquelle elle est valide. C'est utile parce que ça permet au compilateur de déterminer quand une référence n'est plus valide et donc de garantir qu'on n'accède jamais à de la mémoire libérée.

Question 22 : Donne un cas concret de lifetimes complexes.

Voir la réponse

Typiquement, quand on passe une référence à une structure qui va embarquer la référence. C'est plus compliqué dans des routines concurrentes. Et le cas le plus déroutant, c'est quand on pense en C/C++ et qu'on essaie de faire des références cycliques -- ce que Rust interdit par construction.

Question 23 : Comment Rust garantit-il l'absence de data races ?

Voir la réponse

Grâce au borrow checker et au système d'emprunt. Une variable accessible par une référence non mutable ne peut pas être modifiée par une autre routine dans le même temps. Le compilateur garantit qu'il ne peut y avoir simultanément une référence mutable et des références immutables sur la même donnée.

Question 24 : Quand utiliser Cow<'a, str> ?

Voir la réponse

Cow signifie Copy-On-Write. C'est pour éviter les copies inutiles : on garde une référence tant qu'on n'a pas besoin de modifier la donnée. Si une modification est nécessaire, alors seulement une copie est faite. C'est utile quand on a une fonction qui parfois retourne une référence existante et parfois doit créer une nouvelle String.

Outils et écosystème

Question 25 : Que fait cargo check par rapport a cargo build ?

Voir la réponse

cargo check vérifie que le code compile sans générer d'objet binaire. C'est beaucoup plus rapide que cargo build et suffit pour la vérification pendant le développement.

Question 26 : A quoi sert cargo clippy ?

Voir la réponse

C'est un linter pour Rust. Il détecte des patterns de code non idiomatiques, des erreurs courantes, et suggère des améliorations. C'est un outil complémentaire au compilateur pour écrire du code Rust propre et performant.

Question 27 : Que permet cargo fmt ?

Voir la réponse

C'est l'outil de formatage automatique du code Rust selon les conventions standard (rustfmt). Il reformate le code pour garantir un style uniforme dans tout le projet.

Question 28 : Quels crates Rust utilises-tu le plus ?

Voir la réponse

Les crates les plus couramment utilisés dans l'écosystème Rust incluent : Tokio (async runtime), Serde (sérialisation), Anyhow/thiserror (gestion d'erreurs), Hyper (HTTP), Rayon (parallélisme), Wgpu (graphisme), Yew (frontend WASM). Le choix dépend fortement du domaine d'application.

Conception avancée

Question 29 : Comment implémenter un pattern "trait object" en Rust ?

Voir la réponse

On utilise dyn Trait derrière un pointeur (Box<dyn Trait>, &dyn Trait, Arc<dyn Trait>). C'est utile par exemple pour un système de plugins : on définit un trait Plugin avec les méthodes requises, et chaque plugin l'implémente. Le système stocke une collection de Box<dyn Plugin> et appelle les méthodes via le dispatch dynamique.

Question 30 : Comment approcherais-tu l'écriture d'un crate pour du calcul haute performance ?

Voir la réponse
  1. Commencer par implémenter le calcul de façon fiable avec des tests unitaires.
  2. Paralléliser avec Rayon pour le multi-threading ou distribuer avec des crates adaptés.
  3. S'assurer que les tests passent à chaque étape.
  4. Implémenter de l'instrumentation pour mesurer les performances.
  5. Rendre le calcul rapide tout en permettant plusieurs calculs simultanés.

Le plus important : ne pas optimiser prématurément, et mesurer avant de changer.


Partie 2 : Mode hardcore

Ces questions visent à tester la profondeur de compréhension : mémoire (ownership, pinning, UB), types avancés (variance, HRTB), bas niveau (unsafe, atomics, no-std), et écosystème (FFI, outils comme Miri).

Question H1 : Unsafe Rust

Quelles garanties restent valides en mode unsafe, et quelles sont celles que tu peux briser si tu fais une erreur ? Donne un exemple où unsafe est indispensable mais justifié.

Voir la réponse

Même en unsafe, Rust garantit toujours la sécurité mémoire au niveau du langage si le code est correct. Ce qu'on peut briser : aliasing mutable, respect des durées de vie, pointeurs dangling. L'unsafe est un contrat : le développeur promet au compilateur que les invariants sont respectés manuellement.

Exemple justifié : écrire un wrapper autour d'une API C (extern "C") ou manipuler directement de la mémoire avec std::ptr::copy_nonoverlapping.

Question H2 : Pinning et Unpin

Explique le rôle du type Pin<T> en Rust asynchrone. Pourquoi certains types (comme Future) ne peuvent pas être déplacés en mémoire après avoir commencé leur exécution ?

Voir la réponse

Pin<T> empêche de déplacer en mémoire un objet après son initialisation. C'est critique pour les Future : elles stockent des pointeurs internes vers elles-mêmes (auto-références), donc un déplacement invaliderait ces pointeurs.

Par défaut, les types sont Unpin, ce qui signifie qu'ils peuvent être déplacés librement. Seuls certains types (comme les futures contenant des auto-références, marqués !Unpin) nécessitent le pinning.

Question H3 : Trait coherence et Orphan rule

Décris la coherence rule et l'orphan rule en Rust. Pourquoi sont-elles nécessaires ?

Voir la réponse

La coherence rule garantit qu'une implémentation de trait est unique pour un type donné dans tout le programme. L'orphan rule ajoute une restriction : on ne peut implémenter un trait étranger pour un type étranger -- au moins l'un des deux doit nous appartenir.

Cela évite les conflits lors de la résolution de traits (le "diamond problem") et garantit que la compilation reste cohérente et déterministe.

Workaround : le newtype pattern. On crée struct MonType(TypeExterne) et on implémente le trait étranger sur notre newtype.

struct MyString(String);

impl std::fmt::Display for MyString {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Custom: {}", self.0)
    }
}

Question H4 : HRTB (Higher-Rank Trait Bounds)

Explique ce que signifie une contrainte comme for<'a> Fn(&'a T) -> &'a U. Donne un cas concret où ce genre de contrainte est nécessaire.

Voir la réponse

for<'a> Fn(&'a T) -> &'a U signifie : la closure doit fonctionner pour toutes les durées de vie 'a. Ce n'est pas lié à un lifetime spécifique, mais à n'importe lequel.

C'est utile dans des API génériques comme les itérateurs (Fn(&T) -> bool peu importe le lifetime) ou dans des fonctions qui acceptent des callbacks devant fonctionner avec des références de durées de vie arbitraires.

Question H5 : Variance

Qu'est-ce que la variance en Rust (covariant, contravariant, invariant) ? Donne un exemple avec des références.

Voir la réponse

La variance décrit comment les génériques réagissent aux sous-types (sous-typage de lifetimes) :

Exemple : &'static str peut être utilisé là où on attend &'a str (covariance), mais &mut &'static str ne peut PAS être utilisé là où on attend &mut &'a str (invariance).

Question H6 : Memory ordering

Dans std::sync::atomic, quelle est la différence entre Relaxed, Acquire, Release et SeqCst ? Quand choisir Relaxed plutôt que SeqCst ?

Voir la réponse

Relaxed s'utilise quand seul le compteur importe et qu'on n'a pas besoin de synchroniser d'autres données (ex. : métriques, compteurs de statistiques).

Question H7 : Zero-cost abstractions

Explique ce que Rust entend par "zero-cost abstractions". Donne un exemple concret.

Voir la réponse

Rust compile ses abstractions via LLVM en code machine aussi efficace que du C écrit à la main, sans surcoût à l'exécution.

Exemple : un for x in 0..n compile en une simple boucle add/jmp identique au C. Les itérateurs chaînés (map/filter/fold) sont aussi optimisés en une seule boucle grâce à la monomorphisation et aux inlining du compilateur.

Question H8 : FFI (Foreign Function Interface)

Comment sécuriser l'appel d'une fonction C depuis Rust via extern "C" ? Quels sont les risques ?

Voir la réponse

On déclare via extern "C" { fn foo(...); }. L'appel est obligatoirement dans un bloc unsafe. Pour sécuriser : on crée un wrapper safe avec des types Rust, on vérifie la compatibilité ABI (#[repr(C)]), et on gère la mémoire manuellement.

Risques principaux : pointeurs invalides, double-free, panic traversant la frontière FFI (comportement indéfini), et incompatibilité ABI entre Rust et C.

Question H9 : No-std environments

Que signifie compiler un crate avec #![no_std] ? Quels sont les impacts ?

Voir la réponse

#![no_std] signifie qu'on n'importe pas la bibliothèque standard, donc pas d'allocation dynamique ni de dépendance à l'OS. On garde core (types, traits fondamentaux) et éventuellement alloc (si un allocateur est disponible).

C'est utilisé pour l'embarqué et le bare-metal. Crates typiques dans cet écosystème : embedded-hal, cortex-m, heapless.

Question H10 : Miri et le comportement indéfini (UB)

Qu'est-ce que cargo miri et comment peut-il détecter des erreurs dans du code Rust pourtant compilable ? Donne un exemple d'UB possible malgré le borrow checker.

Voir la réponse

cargo miri est un interpréteur qui exécute le code Rust et vérifie les comportements indéfinis à l'exécution : accès mémoire invalides, data races, violations d'aliasing, débordements de buffer.

Contrairement au compilateur qui fait de la vérification statique, Miri simule l'exécution réelle et peut détecter des UB dans du code unsafe que rustc ne peut pas prévoir.

Exemple d'UB malgré le borrow checker : usage d'unsafe avec deux références mutables sur le même objet :

fn main() {
    let mut x = 42;
    let r: *mut i32 = &mut x;
    unsafe {
        *r = 43;
        let p = r.offset(1); // hors limites
        *p = 99; // UB non détecté par rustc, détecté par Miri
    }
}

Commandes : cargo miri setup, cargo miri run, cargo miri test.


Bonus : Closures et traits de fonction

Question B1 : Quelle est la différence fondamentale entre Fn, FnMut et FnOnce ?

Voir la réponse

Relation : Fn implémente aussi FnMut et FnOnce. Toute Fn peut être utilisée là où un FnMut ou FnOnce est attendu.

Question B2 : Pourquoi cloner avant une closure avec move ?

Voir la réponse

Quand on déclare une closure avec move, Rust déplace les variables capturées dans la closure. Si on a besoin de la variable originale après, on clone avant :

let s = String::from("hello");

let c = {
    let s = s.clone();
    move || println!("{}", s)
};

// s est toujours accessible ici
c(); // utilise la copie

C'est un pattern classique pour isoler la closure du code extérieur, notamment dans les contextes multi-threads ou async.

Question B3 : Send et Sync en une phrase ?

Voir la réponse

Send signifie qu'on peut déplacer la valeur entre threads. Sync signifie qu'on peut partager des références immutables entre threads en toute sécurité.

Point clé : Rc<T> n'est ni Send ni Sync, il faut utiliser Arc<T> pour le multi-thread. Mutex<T> est Sync si T est Send.