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.
Les concepts du langage : de Go à Rust
Section intitulée « Les concepts du langage : de Go à 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.
| 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 |
Les collections
Section intitulée « Les collections »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.
L’outillage : de go à cargo
Section intitulée « L’outillage : de go à cargo »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.
Web et réseau
Section intitulée « Web et réseau »| 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 |
Desktop, jeux et embarqué
Section intitulée « Desktop, jeux et embarqué »| 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 : 150solde : 150La 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.14aire totale : 9.14Ê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.
Côte à côte : nil devient Option
Section intitulée « Côte à côte : nil devient Option »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 : AdaAda, responsable : aucunEve : utilisateur inconnuLa 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:8080erreur : port invalide "abc" : invalid digit found in stringerreur : port invalide "70000" : number too large to fit in target typeerreur : le port 0 est réservé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 : 14Plusieurs 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 : 14Soyons 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.
Côte à côte : defer devient Drop
Section intitulée « Côte à côte : defer devient Drop »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 fichierverrouillage du mutextravail en coursdéverrouillage du mutexfermeture du fichierterminé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.
Les pièges quand on vient de Go
Section intitulée « Les pièges quand on vient de Go »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
Vecle 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` trait7 | let sum = total(prices);| ------ value moved here8 | println!("{sum} from {} items", prices.len());| ^^^^^^ value borrowed here after moveLe compilateur vous dit (en anglais) que
pricesa été déplacé danstotalà 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 avectotal(&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 commeRc<RefCell<T>>ouArc<Mutex<T>>. Attendez-vous à quelques semaines frustrantes. Ça devient beaucoup plus facile ensuite. -
Pas de valeurs zéro.
let x: i32;suivi deprintln!("{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érivezDefaultet écrivezConfig { port: 8080, ..Default::default() }. -
Implémenter un trait est explicite. Avoir une méthode
areane fait pas de votre type unShape. Il vous fautimpl Shape for Circle. L’avantage : vous pouvez retrouver chaque implémentation par une simple recherche. -
Stringcontre&str. Go a un seul typestring. Rust en a deux :Stringpossède son texte,&strl’emprunte. Les fonctions prennent en général un&str, les structs stockent en général uneString. Et vous ne pouvez pas indexer une chaîne avecs[0]comme en Go ; utilisezs.chars()ous.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_addousaturating_addquand un dépassement est possible. Les littéraux entiers sont desi32par défaut, et les indices sont desusize. 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 aveccargo 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 fnne 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 checkpendant 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 buildmarche tout seul. En Rust, vous ajoutez la cible avec rustup, et il faut souvent aussi un éditeur de liens pour elle. Des outils commecrosss’en chargent pour vous.
Go ou Rust ? Quand choisir l’un ou l’autre
Section intitulée « Go ou Rust ? Quand choisir l’un ou l’autre »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 :
- Débuter avec Rust et Les outils de Rust : installez Rust, configurez rust-analyzer et 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. Les enums Rust avec données remplacent beaucoup de type switches Go.
- Option et Result, puis Erreurs personnalisées et anyhow et thiserror.
- Les traits, Les génériques, Les traits courants et Trait objects.
- Les closures et Les itérateurs : Rust s’appuie bien plus que Go sur les chaînes d’itérateurs.
- Les vecteurs, HashMap et HashSet.
- Les modules, Cargo et les crates et Les tests.
- Les lifetimes et Pointeurs intelligents.
- La concurrence, Les canaux, L’état partagé, puis Async/Await.
- JSON avec Serde et Journalisation, puis construisez le projet d’API web.
Exercices
Section intitulée « Exercices »Les deux exercices vous donnent un extrait de code Go. Portez-le en Rust.
1. Porter un compteur de mots
Section intitulée « 1. Porter un compteur de mots »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: 2bird: 1cat: 1dog: 1the: 3Il 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.
2. Porter une erreur sentinelle
Section intitulée « 2. Porter une erreur sentinelle »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 70erreur : montant invalide 0refusé : fonds insuffisantsL’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.
Récapitulatif
Section intitulée « Récapitulatif »- 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,
Optionau lieu denil, etResult+?au lieu deif err != nil. - Les interfaces deviennent des traits, et en implémenter un est explicite (
impl Trait for Type).deferdevient unDropautomatique à la fin d’une portée. - Les goroutines et les canaux deviennent
thread::spawn+mpscpour 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 testetgo mod; clippy et rustfmt remplacentgo vetetgofmt. Pour Gin, regardez axum ou actix-web ; pour GORM, SeaORM ou Diesel ; pour pgx et sqlc, sqlx ; et pour cobra, clap.
Petit quiz
Section intitulée « Petit quiz »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
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 (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.