Aller au contenu

Rust pour les développeurs Go : guide pratique

Si vous écrivez du Go, vous vous sentirez plus chez vous en Rust que vous ne l’imaginez. Les deux langages se compilent en un seul binaire rapide. Tous deux ont des structs avec des méthodes au lieu de classes, pas d’héritage, des erreurs renvoyées comme des valeurs au lieu d’exceptions, un formateur standard que tout le monde utilise, et un seul outil en ligne de commande qui compile, teste et récupère les paquets. Une struct Go avec quelques méthodes se traduit en Rust presque ligne par ligne.

Ce qui change concerne surtout la mémoire et le fait d’être explicite. Rust n’a pas de ramasse-miettes (garbage collector) : chaque valeur a un seul propriétaire, et le compilateur vérifie l’ownership (possession) et le borrowing (emprunt) avant que votre programme ne s’exécute. Il n’y a pas de nil (vous utilisez Option), et if err != nil devient Result et l’opérateur ?. Les interfaces existent toujours (elles s’appellent des traits), mais un type doit déclarer noir sur blanc qu’il en implémente une. Attendez-vous à passer la plus grande partie de votre temps d’apprentissage sur l’ownership. Le reste ressemblera à un Go plus strict et plus détaillé.

Pourquoi les développeurs Go apprennent-ils Rust ? En général pour l’une de ces raisons : un service où les pauses du ramasse-miettes ou la consommation mémoire commencent à compter, du travail gourmand en CPU, du code qui tourne sans système d’exploitation (embarqué) ou dans le navigateur (WebAssembly), ou simplement pour que le compilateur attrape les accès concurrents aux données (data races) et les plantages du genre nil avant la production. Pour une comparaison plus générale de Rust avec Go, C++ et Python, voyez Rust vs C++, Go et Python. Cette page va plus loin et s’adresse à vous, développeur Go.

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.

Go Rust Pour en savoir plus
Ramasse-miettes Ownership et borrowing, vérifiés à la compilation ; la mémoire est libérée quand le propriétaire sort de la portée Ownership, Borrowing
struct + méthodes struct pour les données + un bloc impl pour les méthodes Structs
Receveur par valeur func (a Account) &self (lecture, sans copie) ou self (prise d’ownership) Structs
Receveur pointeur func (a *Account) &mut self (peut modifier la valeur) Structs
Fonction constructeur NewAccount(...) Une fonction associée, par convention Account::new(...) Structs
Majuscule = exporté Le mot-clé pub Modules
Valeurs zéro (var c Config) Pas de valeurs zéro implicites ; chaque champ doit être renseigné. #[derive(Default)] + Config::default() Structs, Traits courants
Embedding de struct Pas d’embedding. Placez la struct dans un champ et écrivez de petites méthodes de délégation, ou partagez le comportement avec des traits Traits, Patrons de conception
interface (satisfaite implicitement) trait (implémenté explicitement avec impl Trait for Type) Traits
[]Shape contenant des types différents Vec<Box<dyn Shape>> (un trait object) Trait objects
any / interface{} + type switch Un enum avec une variante par cas, puis match Enums, match
Génériques func Max[T cmp.Ordered](a, b T) T Génériques avec des contraintes de trait : fn max<T: PartialOrd>(a: T, b: T) -> T Génériques, Génériques avancés
Pointeur nil, map nil, interface nil Option<T> : soit Some(value), soit None Option
v, ok := m[k] m.get(&k) renvoie une Option HashMap
(T, error) + if err != nil { return err } Result<T, E> + l’opérateur ? Result
errors.New, erreurs sentinelles, errors.Is / errors.As Un enum d’erreur sur lequel vous faites un match (la crate thiserror aide) Erreurs personnalisées, anyhow et thiserror
fmt.Errorf("...: %w", err) anyhow avec .context("..."), ou map_err anyhow et thiserror
panic / recover panic! pour les vrais bugs ; on récupère rarement (std::panic::catch_unwind) Panic
defer Drop, qui s’exécute quand une valeur sort de la portée (RAII) Portée, Pointeurs intelligents
go f() std::thread::spawn(...), ou tokio::spawn(...) pour des tâches async Concurrence, Async/Await
chan T, make(chan T, n) std::sync::mpsc::channel() (non borné) ou sync_channel(n) ; tokio::sync::mpsc dans du code async Canaux
select tokio::select! dans du code async, ou le select! de crossbeam-channel Async en profondeur
sync.Mutex à côté des données Mutex<T> enveloppe les données ; il se déverrouille tout seul quand le guard est libéré (dropped) État partagé
sync.WaitGroup JoinHandle::join(), ou std::thread::scope Concurrence
sync.Once std::sync::OnceLock / LazyLock Astuces de la bibliothèque standard
context.Context Pas d’équivalent unique : abandonner (drop) un future pour l’annuler, tokio::time::timeout, ou CancellationToken de tokio-util Async en profondeur
Valeurs func et closures Closures, décrites par les traits Fn, FnMut, FnOnce Closures, Fn, FnMut et FnOnce
for i, v := range s for (i, v) in s.iter().enumerate() Boucles for, Itérateurs
L’unique boucle for loop, while et for Boucles
Itérateurs range-over-func (iter.Seq) Le trait Iterator, avec des adaptateurs comme filter, map, sum, collect Itérateurs, Itérateurs personnalisés
switch match (le compilateur vérifie que vous avez couvert tous les cas) match, Pattern matching
const + iota Un enum (avec des nombres explicites si besoin) Enums
:= let (immuable par défaut ; let mut pour autoriser les modifications) Variables
string (des octets, en général de l’UTF-8) String (possédée) et &str (empruntée), toujours en UTF-8 valide Chaînes
rune, for _, r := range s char, for c in s.chars() Chaînes
[]byte Vec<u8> ou &[u8] Slices
fmt.Println, %v, %+v println!, {} (Display), {:?} et {:#?} (Debug, avec #[derive(Debug)]) Afficher du texte
String() string (l’interface Stringer) Implémenter Display (et .to_string() marche gratuitement) Traits courants
Tags de struct `json:"user_name"` Attributs : #[serde(rename = "user_name")] JSON avec Serde
Conversions int(x), float64(n) as pour les nombres, From/Into, et TryFrom quand la conversion peut échouer Conversion de types, Conversions
Le dépassement d’entier reboucle silencieusement Panic dans les builds debug, rebouclage en release ; checked_add, wrapping_add, saturating_add disent ce que vous voulez Dépassement d’entier
*T et &x Références &T et &mut T (jamais nulles, ne peuvent pas survivre à la valeur) ; Box<T> pour une valeur possédée sur le tas Borrowing, Pointeurs intelligents
Packages Des modules (mod) dans une crate ; use au lieu d’import Modules
go generate Les macros et les scripts de build build.rs Macros, Macros procédurales
Package unsafe Blocs unsafe et pointeurs bruts Unsafe en Rust
cgo extern "C" et FFI (bindgen génère les bindings à partir des en-têtes C) FFI
Build tags //go:build linux #[cfg(target_os = "linux")] et les features Cargo Features Cargo et cfg

Go intègre les slices et les maps au langage. Rust les a dans la bibliothèque standard, avec quelques autres en plus.

Go Rust Pour en savoir plus
[]T que vous possédez et auquel vous ajoutez des éléments Vec<T> Vecteurs
[]T passé à une fonction qui se contente de le lire &[T] (une slice empruntée à un Vec ou à un tableau) Slices
[N]T [T; N] Tableaux
append(s, x) / append(s, other...) v.push(x) / v.extend(other) Vecteurs
s[a:b] &v[a..b] Slices
len(s), cap(s), make([]T, 0, n) v.len(), v.capacity(), Vec::with_capacity(n) Vecteurs
map[K]V HashMap<K, V> HashMap
map[K]struct{} ou map[K]bool utilisé comme ensemble HashSet<T> HashSet
Récupérer et trier les clés d’une map avant d’afficher BTreeMap<K, V> (les clés restent triées) HashMap
container/list VecDeque<T> (push_back / pop_front) VecDeque et BinaryHeap
container/heap (5 méthodes à implémenter) BinaryHeap<T> : juste push et pop ; le plus grand d’abord, enveloppez dans Reverse pour avoir le plus petit d’abord VecDeque et BinaryHeap
slices.Sort, sort.Slice v.sort(), v.sort_by(...), v.sort_by_key(...) Trier et rechercher
slices.Contains, slices.Index v.contains(&x), v.iter().position(...) Vecteurs
strings.Builder Une String avec push_str, ou write! Chaînes
sync.Map Mutex<HashMap<K, V>>, RwLock, ou la crate dashmap État partagé

Deux choses à savoir. D’abord, comme les maps Go, une HashMap Rust n’a pas d’ordre fixe : triez avant d’afficher, ou utilisez une BTreeMap. Ensuite, la fameuse surprise des slices Go (deux slices qui partagent un même tableau sous-jacent, et un append qui modifie l’autre) ne peut pas arriver en Rust : tant qu’une partie de votre code détient un emprunt &mut sur un Vec, rien d’autre ne peut le consulter.

Bonne nouvelle : vous avez l’habitude d’un seul outil qui fait tout, et Rust en a un aussi. Cargo couvre go build, go run, go test, go mod et go install.

Go Rust Remarques
Commande go cargo Compile, exécute, teste, ajoute des paquets, publie. Voir Cargo et les crates
go mod init cargo new my_app (cargo new --lib pour une bibliothèque) Crée le dossier, Cargo.toml, src/main.rs et un dépôt git
go.mod Cargo.toml
go.sum Cargo.lock Créé et mis à jour pour vous
go get github.com/gin-gonic/gin cargo add axum
go get -u cargo update Met à jour dans les intervalles de versions de Cargo.toml
pkg.go.dev crates.io pour les paquets, docs.rs pour la documentation de leur API
go build / go run / go test cargo build / cargo run / cargo test Ajoutez --release pour un build optimisé ; les builds debug sont beaucoup plus lents à l’exécution
go vet, staticcheck, golangci-lint cargo clippy Officiel, avec des centaines de lints utiles
gofmt / goimports cargo fmt (rustfmt) Un seul style standard, comme gofmt
go doc cargo doc --open Les exemples de code dans les commentaires de doc sont exécutés comme des tests. Voir Documentation
go test -bench criterion (lancé avec cargo bench) Voir Performance
go test -cover cargo llvm-cov ou cargo tarpaulin
go test -race Inutile pour du code sûr : les accès concurrents aux données sont des erreurs de compilation. Miri vérifie le code unsafe
go test -fuzz cargo fuzz S’installe avec cargo install cargo-fuzz
testify assert! et assert_eq! intégrés ; pretty_assertions pour des différences plus lisibles Voir Les tests et Tests avancés
gomock / mockery mockall, ou de fausses implémentations écrites à la main qui implémentent votre trait
go install pkg@latest cargo install name
go work (workspaces) Un workspace Cargo Un Cargo.toml à la racine liste les crates membres
Ligne toolchain dans go.mod rustup et un fichier rust-toolchain.toml Voir Les outils de Rust
GOOS=linux GOARCH=arm64 go build rustup target add aarch64-unknown-linux-gnu + cargo build --target ... Demande souvent un éditeur de liens pour la cible ; cross ou cargo-zigbuild simplifient les choses
govulncheck cargo audit (avis de sécurité RustSec), cargo deny
gopls rust-analyzer Fonctionne dans VS Code, Neovim, Helix, Zed, et d’autres
GoLand JetBrains RustRover
Delve rust-gdb / rust-lldb, ou CodeLLDB dans VS Code
pprof cargo flamegraph, perf, ou n’importe quel profileur natif
air (rechargement à chaud) bacon ou cargo watch

Un petit Cargo.toml à côté du go.mod que vous connaissez :

module example.com/orders-api
go 1.23
require github.com/gin-gonic/gin v1.10.0
[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

Une différence vous sautera aux yeux tout de suite : la bibliothèque standard de Go comprend un serveur HTTP, le JSON, les templates et la cryptographie. Celle de Rust est volontairement plus petite : le JSON, le HTTP, l’async ou les nombres aléatoires viennent donc de crates. En ajouter une se fait avec un simple cargo add, et une poignée de crates (serde, tokio, clap, regex, rand) sont utilisées par presque tout le monde.

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 pratique. « On utilise Gin et GORM. C’est quoi, la version Rust ? » Voici les équivalents les plus proches. Comme en Go, Rust a en général deux ou trois bons choix de la communauté plutôt qu’une seule réponse officielle.

Go Rust Remarques
Gin, Echo, Chi, Fiber 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
Serveur net/http hyper (bas niveau), en général utilisé via axum Pas de serveur HTTP dans la bibliothèque standard
Middlewares http.Handler Les layers tower et tower-http (CORS, compression, traçage, timeouts) axum utilise tower directement
Client net/http reqwest Async par défaut, avec un client bloquant en option. Voir Réseau en Rust
grpc-go tonic (avec prost pour protobuf)
gorilla/websocket tokio-tungstenite, ou la feature ws d’axum
html/template, text/template askama (vérifié à la compilation), tera, minijinja
net (TCP/UDP) std::net, ou tokio::net en async Voir Réseau en Rust
errgroup tokio::task::JoinSet
Go Rust Remarques
encoding/json + tags de struct serde + serde_json Gère aussi TOML (crate toml) et d’autres formats. Voir JSON avec Serde
database/sql + pgx sqlx (async, SQL brut vérifié à la compilation contre votre base de données), tokio-postgres Aucun rapport avec jmoiron/sqlx de Go, malgré le nom
sqlc Les macros query! de sqlx Les deux partent du SQL que vous écrivez et vous donnent des résultats typés
GORM SeaORM (async, à base d’entités), Diesel (générateur de requêtes vérifiées par le système de types) Voir Les bases de données en Rust
go-sqlite3 rusqlite
go-redis redis

CLI, configuration, journalisation et outils du quotidien

Section intitulée « CLI, configuration, journalisation et outils du quotidien »
Go Rust Remarques
cobra, urfave/cli, flag clap (dérivez vos arguments à partir d’une struct) Voir Arguments de ligne de commande et Projet : outil CLI avec clap
viper config ou figment Superposez des fichiers et des variables d’environnement, puis désérialisez avec serde
log/slog, zap, zerolog tracing + tracing-subscriber, ou log + env_logger Journalisation structurée. Voir Journalisation
regexp regex Même idée que RE2 en Go : correspondance en temps linéaire, pas de références arrière. Voir Expressions régulières
time std::time pour les durées ; chrono ou jiff pour les dates et les fuseaux horaires Voir Dates et heures
embed include_str! / include_bytes!, ou rust-embed pour des dossiers entiers
bubbletea ratatui Interfaces en terminal
Prometheus client_golang prometheus ou metrics
sync/atomic std::sync::atomic
Go Rust Remarques
Wails Tauri (interface web + backend Rust) Idée très proche. Voir Applications graphiques
Fyne egui, Iced, Slint Pas de toolkit graphique officiel
Ebitengine macroquad, Bevy Bevy est avant la 1.0 et change son API d’une version à l’autre. Voir Développement de jeux
TinyGo embedded-hal, Embassy Rust tourne sans système d’exploitation et sans tas. Voir Rust embarqué
GOOS=js GOARCH=wasm wasm-bindgen, Leptos, Yew, Dioxus Des binaires légers, sans GC à embarquer. Voir Rust et WebAssembly

Côte à côte : structs et méthodes (presque pareil)

Section intitulée « Côte à côte : structs et méthodes (presque pareil) »

C’est ici que les développeurs Go se sentent chez eux. Voici un petit type de compte en Go :

type Account struct {
Owner string // exporté : majuscule
balance int // non exporté
}
func NewAccount(owner string, balance int) *Account {
return &Account{Owner: owner, balance: balance}
}
func (a *Account) Deposit(amount int) { a.balance += amount }
func (a *Account) Balance() int { return a.balance }
func (a *Account) String() string { return fmt.Sprintf("%s : %d", a.Owner, a.balance) }
func main() {
account := NewAccount("Ada", 100)
account.Deposit(50)
fmt.Println(account)
fmt.Println("solde :", account.Balance())
}

Et en Rust :

use std::fmt;
// `pub` fait le travail de la majuscule en Go.
pub struct Account {
pub owner: String,
balance: i64, // privé, comme un champ en minuscule en Go
}
impl Account {
// Comme NewAccount en Go : une simple fonction. `new` n'est qu'une convention.
pub fn new(owner: &str, balance: i64) -> Self {
Account { owner: owner.to_string(), balance }
}
// Comme un receveur pointeur : cette méthode modifie le compte.
pub fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// Lecture seule. Aucune copie n'est faite, contrairement à un receveur par valeur en Go.
pub fn balance(&self) -> i64 {
self.balance
}
}
// La méthode String() de Go (l'interface Stringer) devient Display.
impl fmt::Display for Account {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{} : {}", self.owner, self.balance)
}
}
fn main() {
let mut account = Account::new("Ada", 100);
account.deposit(50);
println!("{account}");
println!("solde : {}", account.balance());
}

Sortie :

Ada : 150
solde : 150
▶ Essayez par vous-même

La forme est la même : des données dans une struct, des méthodes qui y sont attachées, un constructeur qui n’est qu’une fonction. Les différences sont petites mais importantes. Les méthodes vivent ensemble dans un bloc impl. Le receveur dit exactement ce que fait la méthode : &self lit, &mut self modifie, et un simple self prend la valeur et la consomme. Et comme deposit modifie le compte, la variable doit être déclarée avec let mut. En Go, quiconque détient un *Account peut le modifier ; en Rust, le droit de modifier quelque chose se voit dans le code.

Côte à côte : les interfaces implicites deviennent des traits explicites

Section intitulée « Côte à côte : les interfaces implicites deviennent des traits explicites »

En Go, un type satisfait une interface simplement en ayant les bonnes méthodes :

type Shape interface {
Area() float64
Name() string
}
type Circle struct{ R float64 }
type Rect struct{ W, H float64 }
// Aucun "implements" nulle part : avoir les méthodes suffit.
func (c Circle) Area() float64 { return math.Pi * c.R * c.R }
func (c Circle) Name() string { return "cercle" }
func (r Rect) Area() float64 { return r.W * r.H }
func (r Rect) Name() string { return "rectangle" }
func describe[T Shape](s T) {
fmt.Printf("%s : %.2f\n", s.Name(), s.Area())
}
func main() {
describe(Circle{R: 1})
shapes := []Shape{Circle{R: 1}, Rect{W: 2, H: 3}}
total := 0.0
for _, s := range shapes {
total += s.Area()
}
fmt.Printf("aire totale : %.2f\n", total)
}

En Rust, vous écrivez le lien noir sur blanc avec impl Shape for Circle :

use std::f64::consts::PI;
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &str;
}
struct Circle {
r: f64,
}
struct Rect {
w: f64,
h: f64,
}
// Cette ligne, c'est la partie que Go omet.
impl Shape for Circle {
fn area(&self) -> f64 {
PI * self.r * self.r
}
fn name(&self) -> &str {
"cercle"
}
}
impl Shape for Rect {
fn area(&self) -> f64 {
self.w * self.h
}
fn name(&self) -> &str {
"rectangle"
}
}
// Générique, comme describe[T Shape](s T) en Go.
fn describe<T: Shape>(s: &T) {
println!("{} : {:.2}", s.name(), s.area());
}
fn main() {
describe(&Circle { r: 1.0 });
// Comme []Shape en Go : des types différents derrière un même trait.
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { r: 1.0 }),
Box::new(Rect { w: 2.0, h: 3.0 }),
];
let total: f64 = shapes.iter().map(|s| s.area()).sum();
println!("aire totale : {total:.2}");
}

Sortie :

cercle : 3.14
aire totale : 9.14
▶ Essayez par vous-même

Être explicite coûte une ligne par type, et vous apporte des choses que Go ne sait pas faire. Un trait peut avoir des méthodes par défaut : les types reçoivent donc un comportement commun gratuitement. Vous pouvez implémenter votre propre trait pour des types que vous n’avez pas écrits, même i32 ou String (Go ne vous laisse ajouter des méthodes qu’aux types de votre propre package). Et le describe générique est compilé une fois par type concret : l’appel ne fait aucune recherche à l’exécution. Box<dyn Shape>, c’est le style des interfaces Go : une valeur qui pointe vers « un type quelconque qui implémente Shape », avec une recherche de la méthode à l’exécution. Voir Les traits et Trait objects.

En Go, un pointeur qui peut manquer vaut nil, et vous devez penser à vérifier :

type User struct {
Name string
Manager *User // nil veut dire « pas de responsable »
}
func findUser(users []*User, name string) *User {
for _, u := range users {
if u.Name == name {
return u
}
}
return nil
}
func main() {
ada := &User{Name: "Ada"}
users := []*User{ada, {Name: "Bob", Manager: ada}}
for _, name := range []string{"Bob", "Ada", "Eve"} {
u := findUser(users, name)
if u == nil {
fmt.Println(name + " : utilisateur inconnu")
continue
}
boss := "aucun"
if u.Manager != nil {
boss = u.Manager.Name
}
fmt.Println(name+", responsable :", boss)
}
}

En Rust, « peut manquer » fait partie du type. Option<&User> vaut soit Some(user), soit None, et le compilateur refuse que vous utilisiez l’utilisateur tant que vous n’avez pas traité None :

struct User {
name: String,
manager: Option<String>, // Go : Manager *User, qui peut valoir nil
}
fn find_user<'a>(users: &'a [User], name: &str) -> Option<&'a User> {
users.iter().find(|u| u.name == name)
}
fn main() {
let users = vec![
User { name: "Ada".to_string(), manager: None },
User { name: "Bob".to_string(), manager: Some("Ada".to_string()) },
];
for name in ["Bob", "Ada", "Eve"] {
match find_user(&users, name) {
Some(user) => {
let boss = user.manager.as_deref().unwrap_or("aucun");
println!("{name}, responsable : {boss}");
}
None => println!("{name} : utilisateur inconnu"),
}
}
}

Sortie :

Bob, responsable : Ada
Ada, responsable : aucun
Eve : utilisateur inconnu
▶ Essayez par vous-même

La même idée remplace les recherches « comma-ok » de Go. if email, ok := emails[name]; ok { ... } devient if let Some(email) = emails.get(name) { ... }. Un point d’attention : en Go, emails["missing"] vous donne discrètement la valeur zéro "". En Rust, emails["missing"] provoque un panic : utilisez donc .get() dès que la clé risque d’être absente. Le 'a dans find_user est une lifetime (durée de vie) : elle indique au compilateur que le &User renvoyé pointe à l’intérieur de users. Vous les croiserez bientôt.

Côte à côte : if err != nil devient Result et ?

Section intitulée « Côte à côte : if err != nil devient Result et ? »

La gestion des erreurs de Go est célèbre pour son motif en trois lignes :

func parsePort(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("port invalide %q : %w", s, err)
}
if n < 1 || n > 65535 {
return 0, fmt.Errorf("port %d hors limites", n)
}
return n, nil
}
func address(host, port string) (string, error) {
p, err := parsePort(port)
if err != nil {
return "", err
}
return fmt.Sprintf("%s:%d", host, p), nil
}
func main() {
for _, port := range []string{"8080", "abc", "70000", "0"} {
addr, err := address("localhost", port)
if err != nil {
fmt.Println("erreur :", err)
continue
}
fmt.Println("à l'écoute sur", addr)
}
}

Rust garde l’idée (les erreurs sont des valeurs que vous renvoyez) mais condense le motif en un seul caractère, ? :

fn parse_port(s: &str) -> Result<u16, String> {
// Un u16 ne peut pas contenir 70000 : parse() détecte donc le « hors limites » pour nous.
let port: u16 = s.parse().map_err(|e| format!("port invalide {s:?} : {e}"))?;
if port == 0 {
return Err("le port 0 est réservé".to_string());
}
Ok(port)
}
fn address(host: &str, port: &str) -> Result<String, String> {
// `?` veut dire : si c'est une erreur, renvoie-la tout de suite.
let port = parse_port(port)?;
Ok(format!("{host}:{port}"))
}
fn main() {
for port in ["8080", "abc", "70000", "0"] {
match address("localhost", port) {
Ok(addr) => println!("à l'écoute sur {addr}"),
Err(e) => println!("erreur : {e}"),
}
}
}

Sortie :

à l'écoute sur localhost:8080
erreur : port invalide "abc" : invalid digit found in string
erreur : port invalide "70000" : number too large to fit in target type
erreur : le port 0 est réservé
▶ Essayez par vous-même

Les messages « invalid digit found in string » (chiffre invalide trouvé dans la chaîne) et « number too large to fit in target type » (nombre trop grand pour le type cible) viennent de la bibliothèque standard de Rust : ils restent donc en anglais. La différence va plus loin que les lignes économisées. En Go, n, err := strconv.Atoi(s) vous donne les deux valeurs, et rien ne vous empêche d’utiliser n quand err n’est pas nil. Un Result contient soit la valeur, soit l’erreur : vous ne pouvez donc pas atteindre la valeur sans traiter l’erreur d’abord. Et si vous ignorez complètement un Result, le compilateur vous avertit.

Dans les vrais projets, les erreurs de type String sont remplacées par quelque chose de plus riche. Un enum d’erreur (souvent écrit avec la crate thiserror) joue le rôle des erreurs sentinelles et d’errors.Is : les appelants font un match sur la variante. La crate anyhow vous donne .context("loading config")?, l’équivalent Rust de fmt.Errorf("loading config: %w", err). Voir Erreurs personnalisées et anyhow et thiserror.

Côte à côte : goroutines et canaux deviennent threads, mpsc et tâches

Section intitulée « Côte à côte : goroutines et canaux deviennent threads, mpsc et tâches »

Voici le fan-in classique en Go : lancer quelques goroutines, envoyer les résultats dans un canal, et le fermer quand elles ont fini :

func main() {
results := make(chan int)
var wg sync.WaitGroup
for _, n := range []int{1, 2, 3} {
wg.Add(1)
go func(n int) {
defer wg.Done()
results <- n * n
}(n)
}
go func() {
wg.Wait()
close(results)
}()
total := 0
for r := range results {
total += r
}
fmt.Println("total :", total)
}

La version Rust, avec des threads et un canal de la bibliothèque standard, est un peu plus courte :

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for n in [1, 2, 3] {
let tx = tx.clone(); // chaque thread reçoit son propre émetteur
thread::spawn(move || {
tx.send(n * n).unwrap();
});
}
// On libère notre propre émetteur. Quand tous les émetteurs ont disparu, le canal
// est fermé et la boucle ci-dessous se termine : pas besoin de WaitGroup.
drop(tx);
let total: i32 = rx.iter().sum();
println!("total : {total}");
}

Sortie :

total : 14
▶ Essayez par vous-même

Plusieurs choses se traduisent différemment. mpsc veut dire « multi-producer, single-consumer » (plusieurs producteurs, un seul consommateur) : vous pouvez cloner l’émetteur, mais pas le récepteur (pour des canaux à plusieurs lecteurs comme en Go, utilisez crossbeam-channel). mpsc::channel() est non borné, ce qui n’existe pas en Go ; mpsc::sync_channel(n) correspond au make(chan T, n) de Go, et sync_channel(0) est un canal sans tampon. Personne n’appelle close : le canal se ferme quand le dernier émetteur est libéré. move donne à chaque thread sa propre copie de n et son propre tx : c’est ainsi que Rust garantit que les threads ne partagent jamais de données de façon dangereuse. Voir Les canaux et La concurrence.

thread::spawn lance un vrai thread du système d’exploitation, plus lourd qu’une goroutine. Pour une poignée de workers, ça convient. Pour des milliers de connexions simultanées, Rust utilise des tâches async sur un runtime comme tokio, ce qui se rapproche beaucoup plus des goroutines :

#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for n in [1, 2, 3] {
// Comme `go func() { ... }()`, mais vous récupérez une poignée.
handles.push(tokio::spawn(async move { n * n }));
}
let mut total = 0;
for handle in handles {
total += handle.await.unwrap();
}
println!("total : {total}");
}

Sortie :

total : 14
▶ Essayez par vous-même

Soyons honnêtes : c’est un domaine où Go est plus simple. En Go, n’importe quelle fonction peut bloquer et le runtime s’en occupe. En Rust, vous marquez les fonctions async, vous les appelez avec .await, et vous choisissez un runtime. La contrepartie : le compilateur refuse de compiler du code où deux threads pourraient modifier les mêmes données en même temps, donc go test -race n’aurait rien à trouver. Voir Async/Await, Async en profondeur et L’état partagé pour Arc<Mutex<T>>, la version Rust d’une struct avec un sync.Mutex à côté de ses données.

En Go, vous planifiez le nettoyage avec defer, et les appels différés s’exécutent dans l’ordre inverse quand la fonction se termine :

func process() {
fmt.Println("ouverture du fichier")
defer fmt.Println("fermeture du fichier")
fmt.Println("verrouillage du mutex")
defer fmt.Println("déverrouillage du mutex")
fmt.Println("travail en cours")
}
func main() {
process()
fmt.Println("terminé")
}

En Rust, le nettoyage appartient à la valeur elle-même. Quand une valeur sort de la portée, son implémentation de Drop s’exécute, et les valeurs sont libérées dans l’ordre inverse, exactement comme vos defer :

struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("{}", self.0);
}
}
fn process() {
println!("ouverture du fichier");
let _file = Guard("fermeture du fichier");
println!("verrouillage du mutex");
let _lock = Guard("déverrouillage du mutex");
println!("travail en cours");
} // _lock est libéré ici, puis _file
fn main() {
process();
println!("terminé");
}

Sortie :

ouverture du fichier
verrouillage du mutex
travail en cours
déverrouillage du mutex
fermeture du fichier
terminé
▶ Essayez par vous-même

Dans du vrai code, vous écrivez rarement Drop vous-même : un File se ferme tout seul, un MutexGuard se déverrouille tout seul, un TcpStream se déconnecte tout seul. Il n’y a rien à oublier. Deux différences avec defer méritent d’être connues. Drop s’exécute à la fin du bloc, pas de la fonction : le piège Go des defer qui s’accumulent dans une boucle n’existe donc pas, chaque tour de boucle fait son propre ménage. Et méfiez-vous de let _ = Guard(...) : le _ seul ne garde pas du tout la valeur, qui est donc libérée immédiatement. Utilisez plutôt un nom comme _lock.

Tous les développeurs qui viennent de Go butent sur les mêmes obstacles. Les connaître à l’avance aide.

  • Passer une valeur peut la déplacer. En Go, passer une slice à une fonction copie un petit en-tête, et les deux côtés continuent de l’utiliser. En Rust, passer un Vec le déplace (move) dans la fonction, et vous ne pouvez plus l’utiliser ensuite :

    fn total(prices: Vec<u32>) -> u32 {
    prices.iter().sum()
    }
    fn main() {
    let prices = vec![3, 4, 5];
    let sum = total(prices);
    println!("{sum} from {} items", prices.len());
    }
    error[E0382]: borrow of moved value: `prices`
    --> src/main.rs:8:37
    |
    6 | let prices = vec![3, 4, 5];
    | ------ move occurs because `prices` has type `Vec<u32>`, which does not implement the `Copy` trait
    7 | let sum = total(prices);
    | ------ value moved here
    8 | println!("{sum} from {} items", prices.len());
    | ^^^^^^ value borrowed here after move

    Le compilateur vous dit (en anglais) que prices a été déplacé dans total à la ligne 7, puis emprunté à la ligne 8 alors qu’il n’est plus valide. La solution, c’est l’habitude Go version Rust : faites emprunter une slice à la fonction, fn total(prices: &[u32]), et appelez-la avec total(&prices). Lisez d’abord L’ownership ; cette page explique cette règle. 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. Du code Go où plusieurs structs détiennent des pointeurs vers une même struct partagée et la modifient demande une autre conception en Rust, ou des pointeurs intelligents comme Rc<RefCell<T>> ou Arc<Mutex<T>>. Attendez-vous à quelques semaines frustrantes. Ça devient beaucoup plus facile ensuite.

  • Pas de valeurs zéro. let x: i32; suivi de println!("{x}") est une erreur de compilation, et chaque champ d’une struct doit être renseigné quand vous la créez. Si vous voulez des valeurs par défaut à la Go, dérivez Default et écrivez Config { port: 8080, ..Default::default() }.

  • Implémenter un trait est explicite. Avoir une méthode area ne fait pas de votre type un Shape. Il vous faut impl Shape for Circle. L’avantage : vous pouvez retrouver chaque implémentation par une simple recherche.

  • String contre &str. Go a un seul type string. Rust en a deux : String possède son texte, &str l’emprunte. Les fonctions prennent en général un &str, les structs stockent en général une String. Et vous ne pouvez pas indexer une chaîne avec s[0] comme en Go ; utilisez s.chars() ou s.as_bytes()[0]. Voir Les chaînes.

  • Ce qui est inutilisé donne des avertissements, pas des erreurs. Go refuse de compiler avec un import ou une variable inutilisés. Rust se contente d’avertir, et vous pouvez préfixer un nom par _ pour dire « inutilisé exprès ».

  • Le dépassement d’entier. Go reboucle silencieusement. Rust provoque un panic dans les builds debug et reboucle dans les builds release : choisissez donc checked_add, wrapping_add ou saturating_add quand un dépassement est possible. Les littéraux entiers sont des i32 par défaut, et les indices sont des usize. Voir Le dépassement d’entier.

  • Une bibliothèque standard plus petite. Pas de serveur HTTP, pas de JSON, pas de runtime async dans std. Vous les ajouterez avec cargo add. Ça surprend au début, mais les crates populaires sont stables et bien documentées.

  • L’async a besoin d’un runtime et se propage. Une async fn ne fait rien tant qu’on ne l’attend pas dans un runtime comme tokio, et seul du code async peut utiliser .await. Go n’a pas cette séparation. Commencez avec de simples threads ; passez à l’async quand il vous faut beaucoup de connexions simultanées.

  • Les temps de compilation. Go a été conçu pour compiler très vite. Rust compile plus lentement, surtout lors des compilations complètes avec beaucoup de crates. Utilisez cargo check pendant que vous modifiez votre code ; il saute la génération de code et va beaucoup plus vite.

  • La compilation croisée demande un peu de configuration. GOOS=linux go build marche tout seul. En Rust, vous ajoutez la cible avec rustup, et il faut souvent aussi un éditeur de liens pour elle. Des outils comme cross s’en chargent pour vous.

Les deux sont de bons choix, et beaucoup d’équipes utilisent les deux. Voici une règle empirique honnête.

Choisissez Go quand vous voulez livrer vite des services réseau et des outils internes, que votre équipe tient à un code que n’importe qui peut lire au bout d’une semaine, que des compilations rapides comptent beaucoup, ou que vous travaillez dans le monde cloud-native (Kubernetes, Docker, Terraform), où Go est la langue commune. La bibliothèque standard de Go et sa concurrence simple rendent beaucoup de programmes serveur faciles à écrire.

Choisissez Rust quand il vous faut une latence prévisible sans pauses du ramasse-miettes, une faible consommation mémoire, ou chaque goutte de CPU (analyseurs, bases de données, moteurs de jeu, vidéo, compression). Rust convient aussi mieux au code sans système d’exploitation, à WebAssembly, et aux bibliothèques que vous voulez appeler depuis d’autres langages. Et il en vaut la peine quand un plantage dû à un accès concurrent aux données ou à une vérification de nil oubliée coûte cher. Discord a publié un article célèbre sur le passage d’un de ses services de Go à Rust (en anglais), à cause de pics de latence causés par le ramasse-miettes.

Le coût de Rust, c’est le temps d’apprentissage et des compilations plus lentes. Le coût de Go, c’est un ramasse-miettes et des bugs qui apparaissent à l’exécution plutôt qu’à la compilation. Lequel pèse le plus dépend de votre projet.

Un parcours d’apprentissage pour les développeurs Go

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

Vous n’êtes pas obligé de lire ce site dans l’ordre. En tant que développeur Go, ce parcours saute ce que vous savez déjà et prend son temps là où Rust est différent :

  1. Débuter avec Rust et Les outils de Rust : installez Rust, configurez rust-analyzer et 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. Les enums Rust avec données remplacent beaucoup de type switches Go.
  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.
  8. Les closures et Les itérateurs : Rust s’appuie bien plus que Go sur les chaînes d’itérateurs.
  9. Les vecteurs, HashMap et HashSet.
  10. Les modules, Cargo et les crates et Les tests.
  11. Les lifetimes et Pointeurs intelligents.
  12. La concurrence, Les canaux, L’état partagé, puis Async/Await.
  13. JSON avec Serde et Journalisation, puis construisez le projet d’API web.

Les deux exercices vous donnent un extrait de code Go. Portez-le en Rust.

Ce programme Go compte les mots et les affiche par ordre alphabétique. Portez-le en Rust. Indice : utilisez une HashMap<&str, u32> et *counts.entry(word).or_insert(0) += 1 pour le counts[w]++ de Go. text.split_whitespace() correspond à strings.Fields.

func main() {
text := "the cat and the dog and the bird"
counts := map[string]int{}
for _, w := range strings.Fields(text) {
counts[w]++
}
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s: %d\n", k, counts[k])
}
}
Voir la solution
use std::collections::HashMap;
fn main() {
let text = "the cat and the dog and the bird";
let mut counts: HashMap<&str, u32> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
let mut keys: Vec<&str> = counts.keys().copied().collect();
keys.sort();
for key in keys {
println!("{key}: {}", counts[key]);
}
}

Sortie :

and: 2
bird: 1
cat: 1
dog: 1
the: 3
▶ Essayez par vous-même

Il n’y a pas de valeur zéro sur laquelle s’appuyer ici, donc entry(word).or_insert(0) dit « commence à 0 si le mot est nouveau ». Indexer avec counts[key] est sans danger, parce que chaque clé vient de la map. Encore plus court : utilisez une BTreeMap au lieu d’une HashMap, et les clés sortent déjà triées, ce qui vous permet de supprimer le vecteur keys. La phrase reste en anglais parce que le résultat dépend de l’ordre alphabétique.

Ce code Go renvoie une erreur sentinelle et la vérifie avec errors.Is. Portez-le pour que withdraw renvoie un Result<(), WithdrawError>, où WithdrawError est un enum à deux variantes. Indice : implémentez fmt::Display pour l’enum afin de pouvoir l’afficher, et faites un match sur la variante là où Go utilise errors.Is.

var ErrInsufficientFunds = errors.New("fonds insuffisants")
type Account struct{ Balance int }
func (a *Account) Withdraw(n int) error {
if n <= 0 {
return fmt.Errorf("montant invalide %d", n)
}
if n > a.Balance {
return ErrInsufficientFunds
}
a.Balance -= n
return nil
}
func main() {
acc := &Account{Balance: 100}
for _, n := range []int{30, 0, 500} {
if err := acc.Withdraw(n); err != nil {
if errors.Is(err, ErrInsufficientFunds) {
fmt.Println("refusé :", err)
} else {
fmt.Println("erreur :", err)
}
continue
}
fmt.Println("ok, solde", acc.Balance)
}
}
Voir la solution
use std::fmt;
enum WithdrawError {
InvalidAmount(i64),
InsufficientFunds,
}
impl fmt::Display for WithdrawError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
WithdrawError::InvalidAmount(n) => write!(f, "montant invalide {n}"),
WithdrawError::InsufficientFunds => write!(f, "fonds insuffisants"),
}
}
}
struct Account {
balance: i64,
}
impl Account {
fn withdraw(&mut self, n: i64) -> Result<(), WithdrawError> {
if n <= 0 {
return Err(WithdrawError::InvalidAmount(n));
}
if n > self.balance {
return Err(WithdrawError::InsufficientFunds);
}
self.balance -= n;
Ok(())
}
}
fn main() {
let mut acc = Account { balance: 100 };
for n in [30, 0, 500] {
match acc.withdraw(n) {
Ok(()) => println!("ok, solde {}", acc.balance),
Err(e @ WithdrawError::InsufficientFunds) => println!("refusé : {e}"),
Err(e) => println!("erreur : {e}"),
}
}
}

Sortie :

ok, solde 70
erreur : montant invalide 0
refusé : fonds insuffisants
▶ Essayez par vous-même

L’erreur sentinelle et l’erreur fmt.Errorf deviennent deux variantes d’un même enum, et errors.Is devient une branche de match. e @ WithdrawError::InsufficientFunds correspond à la variante et vous donne aussi la valeur entière sous le nom e, pour l’afficher. Petit bonus : si vous ajoutez une troisième variante plus tard, le compilateur vous montre chaque match à mettre à jour. La crate thiserror peut écrire l’implémentation de Display pour vous.

  • Une grande partie de Go se retrouve en Rust : des structs avec des méthodes, pas d’héritage, les erreurs comme valeurs, un seul formateur standard, et un seul outil pour compiler et tester.
  • Les grands changements : l’ownership au lieu d’un ramasse-miettes, Option au lieu de nil, et Result + ? au lieu de if err != nil.
  • Les interfaces deviennent des traits, et en implémenter un est explicite (impl Trait for Type). defer devient un Drop automatique à la fin d’une portée.
  • Les goroutines et les canaux deviennent thread::spawn + mpsc pour quelques workers, ou des tâches tokio quand il y en a beaucoup ; le compilateur exclut les accès concurrents aux données.
  • Cargo remplace go build, go test et go mod ; clippy et rustfmt remplacent go vet et gofmt. Pour Gin, regardez axum ou actix-web ; pour GORM, SeaORM ou Diesel ; pour pgx et sqlc, sqlx ; et pour cobra, clap.

Petit quiz

1. En Go, un type satisfait une interface simplement en ayant les bonnes méthodes. Comment un type Rust implémente-t-il un trait ?

2. Que fait ? après un appel qui renvoie un Result ?

3. Avec std::sync::mpsc, quand est-ce que for msg in rx (ou rx.iter()) s'arrête ?

4. Votre équipe Go construit ses API HTTP avec Gin. Quelle crate Rust s'en rapproche le plus ?

Score : 0 / 4

  • The Rust Programming Language (en anglais) : le livre officiel. Les chapitres 4 (ownership), 9 (erreurs), 10 (génériques et traits) et 16 (concurrence) sont les plus importants pour les développeurs Go.
  • Tokio tutorial (en anglais) : le runtime async qu’utilisent la plupart des serveurs Rust, ce qui se rapproche le plus de l’ordonnanceur de goroutines de Go.
  • La documentation d’axum (en anglais) : le framework web à regarder en premier si vous venez de Gin, Echo ou Chi.
  • Why Discord is switching from Go to Rust (en anglais) : l’histoire d’une vraie équipe qui a fait passer un service sensible à la latence de Go à Rust.
  • Are We Web Yet? (en anglais) : un panorama de la communauté sur les bibliothèques et frameworks web de Rust.