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#.
Les concepts du langage : de Java à Rust
Section intitulée « Les concepts du langage : de Java à Rust »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 |
Les collections
Section intitulée « Les collections »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.
L’outillage : de Maven et Gradle à Cargo
Section intitulée « L’outillage : de Maven et Gradle à Cargo »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.
Web et réseau
Section intitulée « Web et réseau »| 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 |
Données et messagerie
Section intitulée « Données et messagerie »| 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.
Les briques du quotidien
Section intitulée « Les briques du quotidien »| 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 |
Desktop, jeux, Android et interopérabilité
Section intitulée « Desktop, jeux, Android et interopérabilité »| 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 moisLinus gagne 6000 par moisUn 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.
Côte à côte : Optional devient Option
Section intitulée « Côte à côte : Optional devient Option »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) {}
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 { } 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éeenvoi à [email protected]Some("example.com")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 20erreur : il manque 30erreur : le montant doit être supérieur à zéroRemarquez 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, AdaHelsinki: 200London: 120New York: 230Les 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 octetsun bool occupe 1 octetLe 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 = 4000Voici 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é.
Les pièges quand on vient de Java
Section intitulée « Les pièges quand on vient de Java »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
Stringou unVecpar 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` trait7 | greet(name);| ---- value moved here8 | greet(name);| ^^^^ value used here after moveLe compilateur vous dit (en anglais) que
namea été déplacé dansgreetà la ligne 7, puis réutilisé à la ligne 8. La solution habituelle est de laisser la fonction emprunter :fn greet(name: &str), appelée avecgreet(&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 desuper. 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 == bsur deuxStringcompare 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. -
BinaryHeapsort le plus grand élément. LaPriorityQueuede Java vous donne le plus petit d’abord. En Rust, enveloppez les éléments dansstd::cmp::Reversepour obtenir un tas min. Voir VecDeque et BinaryHeap. -
Le dépassement d’entier n’est pas silencieux.
Integer.MAX_VALUE + 1reboucle discrètement en Java. Dans un build debug Rust, c’est un panic. Dites ce que vous voulez avecchecked_add,wrapping_addousaturating_add. Voir Le dépassement d’entier. -
Stringcontre&str, et pas decharAt. Les chaînes Rust sont en UTF-8, doncs[0]ne compile pas. Utilisezs.chars()pour parcourir les caractères. Les fonctions prennent en général un&str; les structs stockent en général uneString. Voir Les chaînes. -
Pas de surcharge de méthodes. Vous ne pouvez pas avoir deux méthodes
printavec 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
nullni d’exceptions. Au début, mettre desOptionet desResultpartout semble demander plus de travail. Au bout de quelques semaines, vous remarquerez que vous n’avez plus vu deNullPointerExceptionni 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, etcargo 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 fnne fait rien tant qu’un exécuteur ne la lance pas : les projets web ajoutent donc tokio et mettent#[tokio::main]surmain. 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 :
- Débuter avec Rust et Les outils de Rust : installez Rust, choisissez un éditeur, lancez
cargo new. - Les variables et Les types de données : survolez-les, surtout pour
let mutet les types numériques. - L’ownership, puis Le borrowing, puis Les slices : les pages les plus importantes pour vous. Prenez votre temps.
- Les chaînes :
Stringcontre&str. - Les structs et Les enums, puis Match et Le pattern matching.
- Option et Result, puis Erreurs personnalisées et anyhow et thiserror.
- Les traits, Les génériques, Les traits courants et Trait objects : vos interfaces et vos classes abstraites.
- Les closures et Les itérateurs : vos lambdas et vos streams.
- Les vecteurs, HashMap et VecDeque et BinaryHeap.
- Les modules, Cargo et les crates et Les tests.
- Les lifetimes et Pointeurs intelligents.
- La concurrence, L’état partagé, Les canaux et Async/Await.
- JSON avec Serde, Journalisation et Les bases de données en Rust, puis construisez le projet d’API web.
- Vous voulez accélérer une application Java existante ? Lisez FFI : appeler du C pour voir comment Rust expose des fonctions
extern "C".
Exercices
Section intitulée « Exercices »Les deux exercices vous donnent un peu de Java. Portez-le en Rust.
1. Porter un record et un pipeline de stream
Section intitulée « 1. Porter un record et un pipeline de stream »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, Kennote parfaite : truemeilleure note : 100Les 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.
2. Porter une exception vérifiée avec une cause
Section intitulée « 2. Porter une exception vérifiée avec une cause »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 8080erreur de configuration : numéro de port invalide : quatre-vingtserreur de configuration : port=<number> attendu, reçu 'host=localhost'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(() -> ...).
Récapitulatif
Section intitulée « Récapitulatif »- 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,
Optionau lieu denull,Resultet?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 unArc), 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
Section intitulée « Petit quiz »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
Pour aller plus loin
Section intitulée « Pour aller plus loin »- 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,BTreeMapet 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.