Aller au contenu

Rust pour les développeurs Java : guide pratique

Si vous avez passé des années avec Java, vous avez déjà presque tout ce qu’il faut pour apprendre Rust. Vous raisonnez en types. Vous connaissez les génériques, les interfaces, les lambdas et les pipelines de streams. Rust a tout cela : les traits ressemblent beaucoup à des interfaces avec méthodes par défaut, les itérateurs se lisent comme des streams, et Cargo fait le travail de Maven ou Gradle, avec beaucoup moins de configuration.

Ce qui change, c’est tout ce que la JVM faisait pour vous. Rust n’a pas de ramasse-miettes (garbage collector) ni de machine virtuelle. Votre code se compile en binaire natif, et le compilateur décide quand la mémoire est libérée en vérifiant l’ownership (possession) et le borrowing (emprunt) avant l’exécution du programme. Rust n’a pas non plus de null (vous utilisez Option), pas d’exceptions (vous utilisez Result) et pas d’héritage de classes. C’est sur ces quatre changements que vous passerez le plus de temps d’apprentissage.

Pourquoi les développeurs Java se mettent-ils à Rust ? En général pour l’une de ces raisons : des services qui doivent consommer peu de mémoire, de façon stable et sans pauses du GC ; des outils en ligne de commande et des fonctions serverless qui doivent démarrer instantanément, sans JVM ; un chemin critique d’une application Java que vous voulez passer en code natif (Rust peut être appelé depuis Java via JNI ou la nouvelle Foreign Function & Memory API) ; ou simplement la curiosité de comprendre pourquoi Rust arrive toujours en tête des sondages du « langage le plus apprécié ». Cette page fait le lien entre ce que vous connaissez de Java et Rust, pour que vous puissiez vous concentrer sur ce qui est vraiment différent.

Si vous cherchez une comparaison générale avec C++, Go et Python, voyez Rust vs C++, Go et Python. Si vous connaissez aussi .NET, il existe une page dédiée : Rust pour les développeurs C#.

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.

Java Rust Pour en savoir plus
class avec des champs et des méthodes struct pour les données + un bloc impl pour les méthodes Structs
Constructeurs et new Une simple fonction, appelée new par convention, qui renvoie Self Structs
interface trait Traits
Méthodes default dans une interface Méthodes par défaut dans un trait (elles fonctionnent de la même façon) Traits
abstract class, extends, super Pas d’héritage. Utilisez des traits avec des méthodes par défaut pour le comportement commun, et la composition (une struct dans une struct) pour les données communes Traits, Patrons de conception
List<Shape> contenant des classes différentes Vec<Box<dyn Shape>> (un trait object) Trait objects
sealed interface + des record + switch avec motifs (Java 17/21) Un enum dont les variantes portent des données, plus match Enums, match
enum avec des champs et des constructeurs enum dont chaque variante peut contenir des données différentes Enums
record Point(int x, int y) #[derive(Debug, Clone, PartialEq)] struct Point { x: i32, y: i32 } Structs, Traits courants
toString(), equals(), hashCode(), compareTo() Display / Debug, PartialEq / Eq, Hash, PartialOrd / Ord (en général dérivés) Traits courants
== (même objet) contre .equals() (même valeur) == compare toujours des valeurs. Pas de piège d’« égalité de référence » Opérateurs
null et Optional<T> Option<T> : soit Some(value), soit None Option
orElse, orElseGet, orElseThrow, map, flatMap, ifPresent unwrap_or, unwrap_or_else, ok_or / expect, map, and_then, if let Some(...) Option, If Let / While Let
Exceptions vérifiées et throws Result<T, E> dans le type de retour, plus l’opérateur ? Result, Erreurs personnalisées
Exceptions non vérifiées pour de vrais bugs (IllegalStateException) panic! (arrête le thread) Panic
Chaînage d’exceptions (new ConfigException(msg, cause)) map_err, ou le #[source] de thiserror et le .context(...) d’anyhow anyhow et thiserror
try-with-resources et AutoCloseable Drop, qui s’exécute automatiquement quand le propriétaire sort de la portée Portée, Pointeurs intelligents
Ramasse-miettes Ownership et borrowing, vérifiés à la compilation Ownership, Borrowing
Variables locales final Un simple let (immuable par défaut) ; écrivez let mut pour autoriser les modifications Variables
Constantes static final const Variables
Champs static modifiables, singletons static avec LazyLock ou OnceLock, enveloppé dans un Mutex s’il doit changer État partagé
Génériques avec <T extends Comparable<T>> Génériques avec des contraintes de trait : T: Ord ou where T: Ord Génériques
Effacement de type, List<Integer> au lieu de List<int> Monomorphisation : le code générique est compilé pour chaque type, et un Vec<i32> stocke de simples nombres Génériques, Génériques avancés
Lambdas, Function<T, R>, Supplier<T>, Consumer<T> Closures, décrites par les traits Fn, FnMut, FnOnce Closures, Fn, FnMut et FnOnce
Streams : filter, map, reduce, collect Adaptateurs d’itérateurs : filter, map, fold, sum, collect Itérateurs
Iterable<T> et Iterator<T> IntoIterator et Iterator (une seule méthode : next, qui renvoie une Option) Itérateurs personnalisés
parallelStream() par_iter() de la crate rayon Concurrence
Thread, ExecutorService std::thread::spawn et std::thread::scope ; rayon ou tokio pour les pools Concurrence
synchronized, ReentrantLock Mutex<T>, qui enveloppe les données qu’il protège État partagé
AtomicInteger, AtomicLong AtomicI32, AtomicI64, AtomicUsize État partagé
BlockingQueue entre threads Canaux (std::sync::mpsc) Canaux
CompletableFuture, threads virtuels async fn et .await, exécutés par un runtime comme tokio Async/Await
String (immuable) et StringBuilder &str (une vue empruntée sur du texte) et String (possédée, qui peut grandir) Chaînes
char (une unité UTF-16) char (n’importe quel caractère Unicode, 4 octets) ; les chaînes sont en UTF-8 Chaînes
int, long, double et Integer en boîte i32, i64, f64, plus les non signés de u8 à u128. Jamais mis en boîte Types de données
Dépassement d’entier silencieux Un panic dans les builds debug ; choisissez avec checked_add, wrapping_add, saturating_add Dépassement d’entier
Casts comme (int) x as pour les nombres, From / TryFrom pour des conversions sûres Conversion de types, Conversions
BigDecimal, BigInteger Les crates rust_decimal et num-bigint Types de données
Packages et import Modules (mod) et use Modules
public, private, protected, visibilité package Privé par défaut ; pub et pub(crate). Il n’y a pas de protected Modules
Un JAR / un artefact Maven Une crate (une bibliothèque ou un binaire) Cargo et les crates
Annotations (@Override, @Data de Lombok) Des attributs comme #[derive(Debug)] et #[test] Macros
Réflexion, traitement des annotations à l’exécution Quasiment rien à l’exécution. Les macros génèrent du code à la compilation à la place Macros procédurales
Surcharge de méthodes Pas de surcharge. Utilisez des noms différents, ou un paramètre générique Fonctions
Tests instanceof et downcasts match sur un enum (le downcast avec Any existe, mais il est rare) Pattern matching
JNI, Foreign Function & Memory API Fonctions extern "C" FFI

Le Java Collections Framework se retrouve très bien dans std::collections. La plus grande différence : les collections Rust ne renvoient jamais null. Une recherche vous donne une Option.

Java Rust Pour en savoir plus
ArrayList<T> Vec<T> Vecteurs
int[], String[] [T; N] quand la longueur est connue à la compilation, sinon Vec<T>. Passez l’un ou l’autre sous forme de slice &[T] Tableaux, Slices
List.of(...) (non modifiable) Un tableau, ou un Vec que vous ne transmettez que sous forme de &Vec<T> / &[T] (personne ne peut le modifier via une référence partagée) Borrowing
HashMap<K, V> HashMap<K, V> (get renvoie une Option<&V>) HashMap
map.merge(word, 1, Integer::sum) *map.entry(word).or_insert(0) += 1 HashMap
TreeMap<K, V> BTreeMap<K, V> (les clés restent triées) HashMap
LinkedHashMap<K, V> (ordre d’insertion) IndexMap de la crate indexmap
HashSet<T>, TreeSet<T> HashSet<T>, BTreeSet<T> HashSet
ArrayDeque<T> VecDeque<T> VecDeque et BinaryHeap
Stack<T>, ou un Deque utilisé comme pile Vec<T> avec push / pop Vecteurs
PriorityQueue<T> (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
LinkedList<T> LinkedList<T> existe, mais Vec ou VecDeque est presque toujours plus rapide VecDeque et BinaryHeap
ConcurrentHashMap<K, V> Mutex<HashMap<K, V>>, ou la crate dashmap État partagé
list.sort(Comparator.comparing(...)) v.sort_by_key(...) ou v.sort_by(...) Trier et rechercher
list.removeIf(...) v.retain(...) (garde les éléments qui correspondent) Vecteurs

Comme la HashMap de Java, une HashMap Rust n’a pas d’ordre fixe, et l’ordre peut changer d’une exécution à l’autre. S’il vous faut un affichage trié, utilisez une BTreeMap ou triez d’abord les clés.

La plupart des développeurs Java adoptent Cargo très vite. Un seul outil, un seul format de configuration, et pas de XML de plugins. Compilation, dépendances, tests, documentation, formatage et lints : tout vient du même endroit.

Java Rust Remarques
Maven / Gradle cargo Compile, exécute, teste, ajoute des dépendances, publie. Voir Cargo et les crates
pom.xml / build.gradle.kts Cargo.toml Court, et sans script de build pour les projets ordinaires
Verrouillage des dépendances Gradle Cargo.lock Créé et mis à jour pour vous
Build multi-modules Un workspace Cargo Un Cargo.toml à la racine liste les crates membres
Maven Central crates.io La documentation de l’API de chaque crate publiée est sur docs.rs
Ajouter une <dependency> cargo add serde
mvn archetype:generate / gradle init cargo new my_app (cargo new --lib pour une bibliothèque)
mvn compile / mvn test / mvn package cargo build / cargo test / cargo build --release Les builds debug sont beaucoup plus lents à l’exécution ; mesurez les performances en release
java -jar app.jar ./target/release/app Un seul binaire natif. Pas de JVM à installer sur le serveur
Versions du JDK, SDKMAN!, toolchains rustup et un fichier rust-toolchain.toml Voir Les outils de Rust
javac rustc Cargo l’appelle pour vous ; vous le lancez rarement vous-même
JShell Le Rust Playground dans votre navigateur, ou evcxr_repl
Checkstyle, SpotBugs, PMD, Error Prone cargo clippy Des centaines de lints officiels
google-java-format, Spotless cargo fmt Un seul style standard ; personne ne se dispute sur les accolades
IntelliJ IDEA, Eclipse JetBrains RustRover, ou VS Code + rust-analyzer rust-analyzer offre la complétion, l’accès à la définition et les erreurs dans le code
Javadoc Commentaires de doc /// en Markdown + cargo doc Les exemples de code dans la doc sont exécutés comme des tests. Voir Documentation
JUnit 5 #[test] et cargo test intégrés (cargo nextest est un lanceur plus rapide très populaire) Voir Les tests
@ParameterizedTest rstest
Mockito mockall, ou de fausses implémentations écrites à la main qui implémentent votre trait Voir Tests avancés
AssertJ / Hamcrest assert!, assert_eq!, et pretty_assertions pour des différences lisibles
WireMock, Testcontainers wiremock, testcontainers Mêmes idées, versions Rust
JaCoCo cargo llvm-cov ou cargo tarpaulin
JMH criterion Voir Performance
JFR, async-profiler, VisualVM cargo flamegraph (installé avec cargo install flamegraph), perf Pas de GC ni de JIT à régler
GraalVM native-image Inutile Chaque build Rust est déjà du code natif

Voici un petit Cargo.toml. Comparez-le avec le pom.xml qu’il vous faudrait pour les trois mêmes dépendances :

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

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

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

C’est la première question que pose chaque équipe Java : « On est sur Spring Boot et Hibernate. C’est quoi, la version Rust ? » Rust n’a pas de Spring, pas de framework unique qui apporte tout. On combine plutôt quelques crates ciblées. Voici les équivalents les plus proches.

Java Rust Remarques
Spring Boot (Spring MVC / WebFlux) axum (nous en construisons une dans Projet : API web avec Axum), actix-web, Rocket axum et actix-web sont très utilisés en production
@RestController + @GetMapping Des routes axum qui pointent vers de simples handlers async fn Pas d’annotations ni d’analyse du classpath ; vous câblez les routes dans le code
Filtres de servlet, intercepteurs Spring Les layers tower et la crate tower-http (CORS, compression, traçage, timeouts) axum utilise tower directement
Injection de dépendances Spring (@Autowired) State partagé dans axum, de simples constructeurs, et des traits Voir la ligne sur l’injection de dépendances plus bas
springdoc-openapi utoipa
Thymeleaf askama (templates vérifiés à la compilation) ou tera
Spring WebSocket Les WebSockets dans axum (la feature ws), ou tokio-tungstenite
Spring for GraphQL async-graphql
gRPC Java tonic (avec prost pour protobuf)
java.net.http.HttpClient, OkHttp, WebClient reqwest Async par défaut, avec un client bloquant en option
Netty tokio + hyper
Spring Security (JWT, hachage de mots de passe) jsonwebtoken, argon2 Pas de framework de sécurité tout-en-un
Java Rust Remarques
Hibernate / JPA / Spring Data JPA SeaORM : ORM async avec entités, relations et migrations Le plus proche de JPA ; construit sur sqlx
Hibernate, en visant un typage strict Diesel : ORM et générateur de requêtes ; vos requêtes sont vérifiées par le système de types à la compilation Mature (2.x), synchrone par défaut (diesel-async existe)
JDBC, JdbcTemplate, jOOQ, MyBatis sqlx : du SQL brut, et la macro query! le vérifie à la compilation contre votre vraie base de données Async, très populaire, pas un ORM
Flyway / Liquibase sqlx migrate (sqlx-cli), diesel migration, ou refinery
Jedis / Lettuce (Redis) redis
Clients Kafka, Spring Kafka rdkafka (basé sur librdkafka)
Client Java RabbitMQ lapin
AWS SDK for Java Le SDK AWS officiel pour Rust : aws-config, aws-sdk-s3, …

Si vous aimez les entités JPA, essayez d’abord SeaORM. Si vous préférez écrire du SQL (les adeptes de jOOQ et MyBatis), essayez sqlx. Si vous voulez les garanties les plus fortes à la compilation, essayez Diesel. La page Les bases de données en Rust montre les trois avec du code qui fonctionne.

Java Rust Remarques
Jackson / Gson serde + serde_json La référence. Gère aussi TOML, YAML, et d’autres formats. Voir JSON avec Serde
JAXB, Jackson XML quick-xml (avec sa feature serialize pour serde)
SLF4J + Logback / Log4j 2 tracing (+ tracing-subscriber), ou log + env_logger Voir Journalisation
Lombok Les derives intégrés (Debug, Clone, PartialEq, Default, …) ; bon ou derive_builder pour les builders Pas d’astuces de bytecode ; ce ne sont que des macros ordinaires
MapStruct Des implémentations de From / Into Écrites à la main, vérifiées par le compilateur. Voir Conversions
Conteneur IoC de Spring En général rien : passez les dépendances aux constructeurs, et utilisez des traits pour ce que vous remplacez dans les tests Les projets Rust utilisent rarement un conteneur d’injection de dépendances
Bean Validation (@NotNull, @Size) validator ou garde (validation par derive) @NotNull est inutile : n’utilisez Option que là où une valeur peut manquer
Resilience4j backon, ou les middlewares retry et timeout de tower
Caffeine (cache) moka
Guava, Apache Commons Surtout la bibliothèque standard, plus itertools
java.time chrono ou jiff Voir Dates et heures
picocli clap Voir Projet : outil CLI avec clap
@Scheduled, Quartz tokio-cron-scheduler
Micrometer metrics, ou opentelemetry
Jakarta Mail lettre
Java Rust Remarques
JavaFX / Swing Tauri (interface web + backend Rust), Slint, egui, Iced Tauri 2 et Slint 1.x sont les plus stables. Voir Applications graphiques
LibGDX, jMonkeyEngine Bevy, macroquad Bevy est avant la 1.0 et change son API d’une version à l’autre. Voir Développement de jeux
Code d’application Android Gardez Kotlin ou Java pour l’interface ; placez la logique commune dans une bibliothèque Rust compilée avec le NDK Android uniffi, de Mozilla, génère les bindings Kotlin
Appeler du code natif depuis Java Exposez des fonctions extern "C" et appelez-les via l’API FFM (JDK 22+), ou écrivez des fonctions JNI avec la crate jni Voir FFI

Côte à côte : une classe abstraite devient un trait

Section intitulée « Côte à côte : une classe abstraite devient un trait »

Voici une hiérarchie Java classique : une classe de base abstraite avec une méthode abstraite et une méthode commune.

abstract class Employee {
protected final String name;
Employee(String name) { this.name = name; }
abstract int monthlyPay();
String payslip() { return name + " gagne " + monthlyPay() + " par mois"; }
}
class Salaried extends Employee {
private final int yearly;
Salaried(String name, int yearly) { super(name); this.yearly = yearly; }
@Override int monthlyPay() { return yearly / 12; }
}
class Contractor extends Employee {
private final int hours, rate;
Contractor(String name, int hours, int rate) { super(name); this.hours = hours; this.rate = rate; }
@Override int monthlyPay() { return hours * rate; }
}
List<Employee> staff = List.of(new Salaried("Ada", 60000), new Contractor("Linus", 120, 50));
for (Employee e : staff) System.out.println(e.payslip());

Rust n’a pas d’extends. La méthode commune passe dans un trait sous forme de méthode par défaut, et chaque struct garde ses propres données :

trait Employee {
fn name(&self) -> &str;
fn monthly_pay(&self) -> u32;
// Une méthode par défaut : comme le payslip() concret de la classe abstraite.
fn payslip(&self) -> String {
format!("{} gagne {} par mois", self.name(), self.monthly_pay())
}
}
struct Salaried {
name: String,
yearly: u32,
}
struct Contractor {
name: String,
hours: u32,
rate: u32,
}
impl Employee for Salaried {
fn name(&self) -> &str {
&self.name
}
fn monthly_pay(&self) -> u32 {
self.yearly / 12
}
}
impl Employee for Contractor {
fn name(&self) -> &str {
&self.name
}
fn monthly_pay(&self) -> u32 {
self.hours * self.rate
}
}
fn main() {
// Box<dyn Employee> joue le rôle de « une référence vers un Employee quelconque ».
let staff: Vec<Box<dyn Employee>> = vec![
Box::new(Salaried { name: "Ada".to_string(), yearly: 60_000 }),
Box::new(Contractor { name: "Linus".to_string(), hours: 120, rate: 50 }),
];
for e in &staff {
println!("{}", e.payslip());
}
}

Sortie :

Ada gagne 5000 par mois
Linus gagne 6000 par mois
▶ Essayez par vous-même

Un trait ne peut pas contenir de champs : le protected name devient donc une petite méthode name() que chaque struct implémente. Au début, ça semble demander plus de code, mais ça élimine toute une famille de problèmes : pas de classes de base fragiles, pas d’appels à super à oublier, et pas de hiérarchies profondes à parcourir. Si vous connaissez les méthodes default des interfaces de Java 8, vous savez déjà comment Rust partage le comportement. Et quand l’ensemble des types est fixe (comme une sealed interface en Java 17), un enum avec un match est souvent plus simple qu’un trait.

L’Optional de Java a été un grand pas en avant, mais on ne l’utilise que pour les valeurs de retour, et map.get() ou n’importe quel champ peut encore valoir null :

static Optional<String> findEmail(int userId) {
return userId == 1 ? Optional.of("[email protected]") : Optional.empty();
}
for (int id : new int[] {1, 2}) {
System.out.println(findEmail(id).map(String::toUpperCase).orElse("aucune adresse e-mail enregistrée"));
}
findEmail(1).ifPresent(email -> System.out.println("envoi à " + email));

L’Option de Rust a presque la même API, mais elle est utilisée partout où une valeur peut manquer, y compris dans les recherches dans les collections. Et aucun null ne se cache derrière :

fn find_email(user_id: u32) -> Option<String> {
if user_id == 1 {
Some("[email protected]".to_string())
} else {
None
}
}
fn main() {
for id in [1, 2] {
// map + orElseGet
let shown = find_email(id)
.map(|email| email.to_uppercase())
.unwrap_or_else(|| "aucune adresse e-mail enregistrée".to_string());
println!("{shown}");
}
// ifPresent
if let Some(email) = find_email(1) {
println!("envoi à {email}");
}
// flatMap s'appelle and_then
let domain = find_email(1).and_then(|email| email.split_once('@').map(|(_, d)| d.to_string()));
println!("{domain:?}");
}

Sortie :

aucune adresse e-mail enregistrée
Some("example.com")
▶ Essayez par vous-même

Le compilateur refuse que vous appeliez .to_uppercase() sur une Option<String> tant que vous n’avez pas traité None : la NullPointerException n’existe donc tout simplement pas. Et Option ne coûte rien de plus : une Option<&T> a la même taille qu’une simple référence. Découvrez les méthodes utiles sur la page Option.

Côte à côte : les exceptions vérifiées deviennent Result

Section intitulée « Côte à côte : les exceptions vérifiées deviennent Result »

Les exceptions vérifiées partaient d’une bonne idée : la signature vous dit ce qui peut mal tourner. Voici une version Java typique :

class InsufficientFundsException extends Exception {
InsufficientFundsException(int needed) { super("il manque " + needed); }
}
static int withdraw(int balance, int amount) throws InsufficientFundsException {
if (amount > balance) throw new InsufficientFundsException(amount - balance);
return balance - amount;
}
static int pay(int balance, int amount) throws InsufficientFundsException {
int left = withdraw(balance, amount);
System.out.println("payé " + amount + ", reste " + left);
return left;
}
try { pay(100, 80); pay(50, 80); }
catch (InsufficientFundsException e) { System.out.println("erreur : " + e.getMessage()); }

Rust garde cette bonne idée et laisse tomber le reste. L’erreur fait partie du type de retour (Result<u32, BankError>), et ? fait ce que fait throws : « si ça a échoué, transmets l’erreur à mon appelant ». Les erreurs sont en général un enum, si bien qu’un seul type peut lister toutes les façons dont une opération peut échouer :

use std::fmt;
#[derive(Debug)]
enum BankError {
InvalidAmount,
InsufficientFunds { needed: u32 },
}
// Display joue le rôle de getMessage().
impl fmt::Display for BankError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
BankError::InvalidAmount => write!(f, "le montant doit être supérieur à zéro"),
BankError::InsufficientFunds { needed } => write!(f, "il manque {needed}"),
}
}
}
fn withdraw(balance: u32, amount: u32) -> Result<u32, BankError> {
if amount == 0 {
return Err(BankError::InvalidAmount);
}
if amount > balance {
return Err(BankError::InsufficientFunds { needed: amount - balance });
}
Ok(balance - amount)
}
fn pay(balance: u32, amount: u32) -> Result<u32, BankError> {
let left = withdraw(balance, amount)?; // comme l'appel d'une méthode qui « lève » une exception
println!("payé {amount}, reste {left}");
Ok(left)
}
fn main() {
for (balance, amount) in [(100, 80), (50, 80), (100, 0)] {
if let Err(e) = pay(balance, amount) {
println!("erreur : {e}");
}
}
}

Sortie :

payé 80, reste 20
erreur : il manque 30
erreur : le montant doit être supérieur à zéro
▶ Essayez par vous-même

Remarquez ce qui a disparu : pas de blocs try, pas de déroulement de pile, pas d’exceptions enveloppées dans une RuntimeException juste pour traverser une lambda. Les erreurs sont des valeurs ordinaires : elles fonctionnent donc très bien dans les closures et les chaînes d’itérateurs. Le ? est un marqueur visible à chaque endroit où la fonction peut s’arrêter plus tôt. Écrire Display à la main devient vite lassant ; la crate thiserror le génère pour vous, et anyhow donne aux applications un type d’erreur fourre-tout (voir anyhow et thiserror). Pour les vrais bugs, ceux que vous signaleriez avec une exception non vérifiée, Rust a panic!.

Côte à côte : les streams deviennent des itérateurs

Section intitulée « Côte à côte : les streams deviennent des itérateurs »

Un pipeline de stream avec un collecteur groupingBy :

record Order(String customer, String city, int total) {}
List<Order> orders = List.of(
new Order("Ada", "London", 120), new Order("Grace", "New York", 80),
new Order("Linus", "Helsinki", 200), new Order("Ken", "New York", 150));
List<String> big = orders.stream()
.filter(o -> o.total() > 100)
.sorted(Comparator.comparingInt(Order::total).reversed())
.map(Order::customer)
.toList();
System.out.println(String.join(", ", big));
Map<String, Integer> byCity = orders.stream()
.collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.summingInt(Order::total)));
byCity.forEach((city, sum) -> System.out.println(city + ": " + sum));

En Rust, .iter() joue le rôle de .stream(), et les adaptateurs ont des noms familiers :

use std::cmp::Reverse;
use std::collections::BTreeMap;
struct Order {
customer: String,
city: String,
total: u32,
}
fn order(customer: &str, city: &str, total: u32) -> Order {
Order { customer: customer.to_string(), city: city.to_string(), total }
}
fn main() {
let orders = vec![
order("Ada", "London", 120),
order("Grace", "New York", 80),
order("Linus", "Helsinki", 200),
order("Ken", "New York", 150),
];
// filter + toList
let mut big: Vec<&Order> = orders.iter().filter(|o| o.total > 100).collect();
// sorted(...reversed()) : le tri se fait sur un Vec, pas dans l'itérateur.
big.sort_by_key(|o| Reverse(o.total));
// map
let names: Vec<&str> = big.iter().map(|o| o.customer.as_str()).collect();
println!("{}", names.join(", "));
// groupingBy(city, TreeMap::new, summingInt(total))
let mut by_city: BTreeMap<&str, u32> = BTreeMap::new();
for o in &orders {
*by_city.entry(o.city.as_str()).or_insert(0) += o.total;
}
for (city, sum) in &by_city {
println!("{city}: {sum}");
}
}

Sortie :

Linus, Ken, Ada
Helsinki: 200
London: 120
New York: 230
▶ Essayez par vous-même

Les noms de villes restent en anglais, parce qu’une BTreeMap les trie par ordre alphabétique. Comme les streams, les itérateurs Rust sont paresseux et ne s’exécutent que lorsque quelque chose les consomme (collect, sum, une boucle for). Contrairement aux streams, il n’y a pas de classe Collectors : collect() regarde le type que vous demandez (Vec, HashMap, String, …) et le construit. Pour regrouper, la réponse habituelle est une simple boucle avec l’API entry ; la crate itertools ajoute des fonctions utiles si groupingBy vous manque. Et les chaînes d’itérateurs se compilent vers le même code machine qu’une boucle écrite à la main, sans aucune mise en boîte d’int en chemin. Pour en savoir plus : Les itérateurs et Trier et rechercher.

Côte à côte : effacement de type contre monomorphisation

Section intitulée « Côte à côte : effacement de type contre monomorphisation »

Les génériques Java n’existent qu’à la compilation. À l’exécution, T est effacé : vous ne pouvez donc pas écrire new T(), ni mettre un int dans une List sans le mettre en boîte :

static <T> List<T> filled(int n) {
List<T> list = new ArrayList<>();
for (int i = 0; i < n; i++) list.add(new T()); // ne compile pas : T est effacé
return list;
}
List<int> ids; // ne compile pas non plus : il faut List<Integer>

Rust compile une copie séparée de chaque fonction générique pour chaque type avec lequel vous l’utilisez (c’est ce qu’on appelle la monomorphisation). La fonction sait donc exactement ce qu’est T, et peut appeler T::default() :

use std::mem::size_of;
// « T doit avoir une valeur par défaut et pouvoir être cloné »
fn filled<T: Default + Clone>(n: usize) -> Vec<T> {
vec![T::default(); n]
}
fn main() {
let ids: Vec<i32> = filled(3);
let names: Vec<String> = filled(2);
let flags: Vec<bool> = filled(2);
println!("{ids:?} {names:?} {flags:?}");
// Pas de mise en boîte : un Vec<i32> stocke de simples nombres de 4 octets côte à côte.
println!("un i32 occupe {} octets", size_of::<i32>());
println!("un bool occupe {} octet", size_of::<bool>());
}

Sortie :

[0, 0, 0] ["", ""] [false, false]
un i32 occupe 4 octets
un bool occupe 1 octet
▶ Essayez par vous-même

Le compromis : plus de copies du code, c’est des binaires plus gros et des compilations plus longues, mais chaque appel est direct et peut être inliné. Quand vous voulez une seule copie partagée (comme le fait Java), utilisez un trait object comme Box<dyn Trait>. Les contraintes après les deux-points (T: Default + Clone) sont la version Rust de <T extends ...>. Voir Les génériques et Génériques avancés.

Côte à côte : synchronized devient Arc et Mutex

Section intitulée « Côte à côte : synchronized devient Arc et Mutex »

Quatre threads incrémentent un compteur partagé. En Java, le verrou vit sur l’objet, et c’est à vous de penser à synchronized :

class Counter {
private int count = 0;
synchronized void increment() { count++; }
synchronized int get() { return count; }
}
Counter counter = new Counter();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 4; i++) {
Thread t = new Thread(() -> { for (int j = 0; j < 1000; j++) counter.increment(); });
t.start();
threads.add(t);
}
for (Thread t : threads) t.join();
System.out.println("count = " + counter.get());

En Rust, c’est le Mutex qui possède le nombre. Impossible d’y accéder sans verrouiller d’abord. Arc (un pointeur à comptage de références atomique) permet à plusieurs threads de partager le mutex :

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 {
// Chaque thread reçoit sa propre poignée vers le même Mutex.
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
// lock() renvoie un guard ; le verrou est libéré quand le guard est libéré (dropped).
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("count = {}", *counter.lock().unwrap());
}

Sortie :

count = 4000
▶ Essayez par vous-même

Voici la partie que les développeurs Java adorent : si vous oubliez synchronized en Java, le code compile et donne de temps en temps de mauvais nombres. En Rust, si vous essayez de partager un simple i32 ou un Vec entre threads pour le modifier, le programme ne compile pas. Les accès concurrents aux données (data races) sont détectés par le système de types (grâce aux traits Send et Sync). Pour un simple compteur, un AtomicUsize serait plus léger qu’un mutex, exactement comme AtomicInteger. Voir La concurrence et L’état partagé.

Tous les développeurs Java butent sur les mêmes obstacles. Les connaître à l’avance les rend plus petits.

  • Passer une valeur à une fonction la déplace. En Java, passer un objet passe une référence, et vous pouvez continuer à utiliser votre variable. En Rust, passer une String ou un Vec par valeur le déplace (move) dans la fonction :

    fn greet(name: String) {
    println!("Bonjour, {name} !");
    }
    fn main() {
    let name = String::from("Ada");
    greet(name);
    greet(name);
    }
    error[E0382]: use of moved value: `name`
    --> src/main.rs:8:11
    |
    6 | let name = String::from("Ada");
    | ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
    7 | greet(name);
    | ---- value moved here
    8 | greet(name);
    | ^^^^ value used here after move

    Le compilateur vous dit (en anglais) que name a été déplacé dans greet à la ligne 7, puis réutilisé à la ligne 8. La solution habituelle est de laisser la fonction emprunter : fn greet(name: &str), appelée avec greet(&name). Lisez d’abord L’ownership ; cette page explique cette règle, et E0382 détaille cette erreur.

  • Le borrow checker. Vous pouvez avoir plusieurs lecteurs (&T) ou un seul rédacteur (&mut T) à la fois, jamais les deux. Des graphes d’objets normaux en Java, comme un enfant qui pointe vers son parent, ou plusieurs objets qui détiennent et modifient un même objet partagé, demandent des pointeurs intelligents ou une conception différente. Les premières semaines peuvent être frustrantes. Ça devient vraiment plus facile.

  • Pas d’héritage, pas de protected, pas de super. Vous serez tenté de recréer des hiérarchies de classes avec des traits. Essayez d’abord les enums et la composition ; ils conviennent en général mieux.

  • == compare des valeurs. Pour une fois, une bonne nouvelle : a == b sur deux String compare le texte, donc le bug classique == contre .equals() ne peut pas arriver. Pour vos propres structs, == ne fonctionne qu’après avoir dérivé ou implémenté PartialEq.

  • BinaryHeap sort le plus grand élément. La PriorityQueue de Java vous donne le plus petit d’abord. En Rust, enveloppez les éléments dans std::cmp::Reverse pour obtenir un tas min. Voir VecDeque et BinaryHeap.

  • Le dépassement d’entier n’est pas silencieux. Integer.MAX_VALUE + 1 reboucle discrètement en Java. Dans un build debug Rust, c’est un panic. Dites ce que vous voulez avec checked_add, wrapping_add ou saturating_add. Voir Le dépassement d’entier.

  • String contre &str, et pas de charAt. Les chaînes Rust sont en UTF-8, donc s[0] ne compile pas. Utilisez s.chars() pour parcourir les caractères. Les fonctions prennent en général un &str ; les structs stockent en général une String. Voir Les chaînes.

  • Pas de surcharge de méthodes. Vous ne pouvez pas avoir deux méthodes print avec des paramètres différents. Utilisez des noms différents (print, print_with_color), ou un paramètre générique.

  • Pas de magie à l’exécution. Pas d’analyse du classpath, pas de @Autowired, pas de proxies qui ajoutent des transactions autour de vos méthodes. Tout est câblé explicitement dans le code, et les macros s’exécutent à la compilation. Ça fait plus de code à écrire, mais vous voyez toujours ce qui se passe.

  • Pas de null ni d’exceptions. Au début, mettre des Option et des Result partout semble demander plus de travail. Au bout de quelques semaines, vous remarquerez que vous n’avez plus vu de NullPointerException ni de stack trace surprise. Appeler .unwrap() est acceptable pendant l’apprentissage, mais dans du vrai code, c’est la version Rust de « laisser planter ».

  • Les temps de compilation. Une compilation complète récupère et compile toutes les dépendances depuis leur code source : c’est donc plus lent que javac. Les compilations incrémentales sont bien plus rapides, et cargo check (qui saute la génération de code) est ce que vous lancerez le plus souvent pendant que vous modifiez votre code. La bonne nouvelle, à l’autre bout : le programme démarre instantanément, sans phase de chauffe de la JVM.

  • L’async a besoin d’un runtime. Il n’y a pas de threads virtuels. Une async fn ne fait rien tant qu’un exécuteur ne la lance pas : les projets web ajoutent donc tokio et mettent #[tokio::main] sur main. Voir Async/Await.

Un parcours d’apprentissage pour les développeurs Java

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

Vous n’êtes pas obligé de lire ce site dans l’ordre. Ce parcours saute ce que Java vous a déjà appris et prend son temps là où Rust est différent :

  1. Débuter avec Rust et Les outils de Rust : installez Rust, choisissez un éditeur, lancez cargo new.
  2. Les variables et Les types de données : survolez-les, surtout pour let mut et les types numériques.
  3. L’ownership, puis Le borrowing, puis Les slices : les pages les plus importantes pour vous. Prenez votre temps.
  4. Les chaînes : String contre &str.
  5. Les structs et Les enums, puis Match et Le pattern matching.
  6. Option et Result, puis Erreurs personnalisées et anyhow et thiserror.
  7. Les traits, Les génériques, Les traits courants et Trait objects : vos interfaces et vos classes abstraites.
  8. Les closures et Les itérateurs : vos lambdas et vos streams.
  9. Les vecteurs, HashMap et VecDeque et BinaryHeap.
  10. Les modules, Cargo et les crates et Les tests.
  11. Les lifetimes et Pointeurs intelligents.
  12. La concurrence, L’état partagé, Les canaux et Async/Await.
  13. JSON avec Serde, Journalisation et Les bases de données en Rust, puis construisez le projet d’API web.
  14. Vous voulez accélérer une application Java existante ? Lisez FFI : appeler du C pour voir comment Rust expose des fonctions extern "C".

Les deux exercices vous donnent un peu de Java. Portez-le en Rust.

Ce code affiche les élèves reçus, indique si quelqu’un a obtenu une note parfaite, et affiche la meilleure note. Indice : les itérateurs ont any (comme anyMatch) et max (qui renvoie une Option, comme OptionalInt). Pour Collectors.joining, collectez dans un Vec<&str> et appelez .join(", ").

record Student(String name, int score) {}
List<Student> students = List.of(
new Student("Ada", 91), new Student("Brian", 42),
new Student("Grace", 100), new Student("Ken", 67));
String passed = students.stream()
.filter(s -> s.score() >= 50)
.map(Student::name)
.collect(Collectors.joining(", "));
boolean perfect = students.stream().anyMatch(s -> s.score() == 100);
int best = students.stream().mapToInt(Student::score).max().orElse(0);
System.out.println("reçus : " + passed);
System.out.println("note parfaite : " + perfect);
System.out.println("meilleure note : " + best);
Voir la solution
#[derive(Debug, Clone, PartialEq)]
struct Student {
name: String,
score: u32,
}
fn student(name: &str, score: u32) -> Student {
Student { name: name.to_string(), score }
}
fn main() {
let students = vec![
student("Ada", 91),
student("Brian", 42),
student("Grace", 100),
student("Ken", 67),
];
let passed: Vec<&str> = students
.iter()
.filter(|s| s.score >= 50)
.map(|s| s.name.as_str())
.collect();
let perfect = students.iter().any(|s| s.score == 100);
let best = students.iter().map(|s| s.score).max().unwrap_or(0);
println!("reçus : {}", passed.join(", "));
println!("note parfaite : {perfect}");
println!("meilleure note : {best}");
}

Sortie :

reçus : Ada, Grace, Ken
note parfaite : true
meilleure note : 100
▶ Essayez par vous-même

Les derives vous donnent ce qu’un record Java offre gratuitement : une représentation de débogage ({:?}), la copie (.clone()) et l’égalité de valeur (==). La fonction utilitaire student joue le rôle du constructeur du record. Contrairement à un stream Java, students.iter() peut être appelé autant de fois que vous voulez, parce que chaque appel crée un nouvel itérateur sur le même Vec.

Cette méthode lit une ligne port=<number> dans un fichier de configuration. Elle lève une ConfigException vérifiée, et enveloppe la NumberFormatException qu’elle reçoit d’Integer.parseInt. Portez-la pour que read_port renvoie un Result<u16, String>. Indice : line.split_once('=') renvoie une Option<(&str, &str)> ; transformez-la en Result avec .ok_or_else(...). .parse::<u16>() renvoie un Result, et .map_err(...) remplace son erreur par votre propre message, exactement comme quand on enveloppe une exception dans une autre.

class ConfigException extends Exception {
ConfigException(String message) { super(message); }
}
static int readPort(String line) throws ConfigException {
String[] parts = line.split("=", 2);
if (parts.length != 2 || !parts[0].trim().equals("port"))
throw new ConfigException("port=<number> attendu, reçu '" + line + "'");
try {
return Integer.parseInt(parts[1].trim());
} catch (NumberFormatException e) {
throw new ConfigException("numéro de port invalide : " + parts[1].trim());
}
}
for (String line : List.of("port = 8080", "port=quatre-vingts", "host=localhost")) {
try { System.out.println("le port est " + readPort(line)); }
catch (ConfigException e) { System.out.println("erreur de configuration : " + e.getMessage()); }
}
Voir la solution
fn read_port(line: &str) -> Result<u16, String> {
let expected = || format!("port=<number> attendu, reçu '{line}'");
let (key, value) = line.split_once('=').ok_or_else(expected)?;
if key.trim() != "port" {
return Err(expected());
}
let value = value.trim();
value
.parse::<u16>()
.map_err(|_| format!("numéro de port invalide : {value}"))
}
fn main() {
for line in ["port = 8080", "port=quatre-vingts", "host=localhost"] {
match read_port(line) {
Ok(port) => println!("le port est {port}"),
Err(e) => println!("erreur de configuration : {e}"),
}
}
}

Sortie :

le port est 8080
erreur de configuration : numéro de port invalide : quatre-vingts
erreur de configuration : port=<number> attendu, reçu 'host=localhost'
▶ Essayez par vous-même

La clause throws ConfigException est devenue le type de retour Result, le try/catch autour de parseInt est devenu un seul map_err, et le throw est devenu Err(...). Utiliser u16 pour le port est un petit bonus : un nombre supérieur à 65535 est refusé par parse lui-même, ce que ne ferait pas un int en Java. La petite closure expected ne construit le message d’erreur que lorsqu’on en a besoin, comme un fournisseur orElseThrow(() -> ...).

  • Une grande partie de Java se retrouve en Rust : les génériques, les interfaces (sous forme de traits), les lambdas (sous forme de closures) et les pipelines de streams (sous forme d’itérateurs).
  • Les grands changements : l’ownership au lieu d’un ramasse-miettes, Option au lieu de null, Result et ? au lieu des exceptions, et les traits plus la composition au lieu de l’héritage.
  • Les génériques sont compilés pour chaque type (monomorphisation) : pas d’effacement de type, et pas de mise en boîte des nombres.
  • Les données partagées entre threads vivent dans un Mutex (enveloppé dans un Arc), et oublier de verrouiller est une erreur de compilation, pas un accès concurrent.
  • Cargo remplace Maven ou Gradle ; clippy et rustfmt remplacent Checkstyle, SpotBugs et les formateurs ; les tests sont intégrés.
  • Pour Spring Boot, regardez axum ou actix-web ; pour JPA, SeaORM ou Diesel ; pour JDBC et jOOQ, sqlx ; et pour Jackson, utilisez serde.

Petit quiz

1. Une classe abstraite Java a une méthode abstraite et une méthode concrète qui l'appelle. Par quoi la remplace-t-on en général en Rust ?

2. La PriorityQueue de Java renvoie d'abord le plus petit élément. Que renvoie BinaryHeap::pop en Rust ?

3. Pourquoi une fonction générique Rust peut-elle appeler T::default(), alors que Java ne peut pas écrire new T() ?

4. Vous partagez un simple Vec entre plusieurs threads et essayez d'y ajouter des éléments sans Mutex. Que se passe-t-il en Rust ?

Score : 0 / 4

  • The Rust Programming Language (en anglais) : le livre officiel. Les chapitres 4 (ownership), 9 (gestion des erreurs), 10 (génériques et traits) et 16 (concurrence) sont les plus importants pour les développeurs Java.
  • std::collections (en anglais) : le guide de la bibliothèque standard elle-même pour choisir entre Vec, VecDeque, HashMap, BTreeMap et les autres.
  • JEP 454: Foreign Function & Memory API (en anglais) : la fonctionnalité du JDK 22 qui permet d’appeler des bibliothèques natives, y compris écrites en Rust, depuis Java sans écrire de code JNI.
  • La crate jni sur docs.rs (en anglais) : des bindings Rust pour JNI, si vous devez appeler Rust depuis des JDK plus anciens ou rappeler du code Java.
  • Are We Web Yet? (en anglais) : un panorama de la communauté sur les frameworks web, les bibliothèques de bases de données et plus encore en Rust, si vous cherchez votre équivalent de la stack Spring Boot.