Ownership et emprunt

Le sujet central de Rust. L’objectif ici est de te donner un modèle mental clair plutôt qu’une liste de règles.

La règle fondamentale : un seul propriétaire

En Rust, chaque valeur a exactement un propriétaire à la fois. Quand le propriétaire sort de portée, la valeur est libérée (mémoire désallouée). C’est ça qui remplace le garbage collector.

{
    let s = String::from("hello");  // s est propriétaire
}                                    // s sort de portée → mémoire libérée

Tout le système de Rust découle de cette règle. Les références, les moves, Clone, c’est juste trois façons différentes de partager ou transférer cette propriété.

Les 3 mécaniques, dans l’ordre de “coût”

1. Référence (&T ou &mut T) — gratuite, à privilégier

Une référence dit : “je veux regarder la valeur sans la posséder”. Le propriétaire reste le même. C’est l’équivalent d’un pointeur en C, mais avec des garanties à la compilation.

fn print_name(name: &String) {       // emprunte
    println!("{name}");
}

let s = String::from("Alice");
print_name(&s);                       // on prête s
println!("{s}");                      // s est toujours utilisable !

Deux variantes :

  • &T — référence partagée, lecture seule. Tu peux en avoir autant que tu veux simultanément
  • &mut T — référence mutable, lecture/écriture. Tu peux en avoir une seule à la fois, et aucune &T en parallèle

Cette règle (XOR entre lecture multiple et écriture unique) est ce qui élimine les data races à la compilation.

Dans ton code : find_all(db: &DatabaseConnection) — le repository emprunte la connexion, il ne la possède pas. Plusieurs fonctions peuvent partager &db en même temps.

2. Move (transfert de propriété) — gratuit, mais consomme

Quand tu passes une valeur sans &, tu transfères la propriété. L’ancien nom n’est plus utilisable.

let s1 = String::from("hello");
let s2 = s1;                          // move : s1 → s2
println!("{s1}");                     // ❌ erreur : s1 ne possède plus rien

C’est le comportement par défaut en Rust. Pas de copie cachée, pas de pointeur partagé : la propriété passe d’une variable à l’autre.

Dans ton code :

impl From<user::Model> for UserDto {
    fn from(model: user::Model) -> Self {       // model est moved
        Self {
            name: model.name,                    // String moved depuis model
            // ...
        }
    }
}

Pas de & devant user::Model → la fonction consomme le model. Dans users.into_iter().map(UserDto::from), chaque model est consommé pour produire un dto. Plus de doubles allocations.

3. Clone — explicite, coûteux

.clone() te donne une copie indépendante de la valeur. Nouvelle allocation, deux propriétaires distincts.

let s1 = String::from("hello");
let s2 = s1.clone();                  // copie complète
println!("{s1} et {s2}");              // les deux marchent

Rust te force à écrire .clone() explicitement, justement pour que tu voies où il y a un coût. C’est pas un anti-pattern, mais ça doit être un choix conscient.

Dans ton code : #[derive(Clone)] pub struct AppState { ... } — on l’a marqué Clone parce qu’Axum clone l’état à chaque requête pour le distribuer aux handlers. C’est OK ici parce que DatabaseConnection clone juste un pointeur en interne (Arc).

Cas spécial : les types Copy

Les types simples (i32, bool, f64, les références &T, les tuples de Copy…) sont marqués Copy. Pour eux, l’assignation est une copie automatique, pas un move.

let x = 5;
let y = x;                            // copie automatique
println!("{x} et {y}");                // les deux marchent

C’est pour ça que dans ton DTO, id: model.id ne consomme pas model.id — c’est un i32, donc Copy.

Règle simple : une valeur est Copy si elle tient entièrement sur la pile et ne possède aucune ressource (pas de tas, pas de fichier, pas de socket). String, Vec, Box ne sont pas Copy.

Le décideur : quand utiliser quoi ?

Voici un arbre de décision quand tu écris une fonction :

Tu reçois une valeur en paramètre.

┌─ Tu dois la modifier sur place ?
│   └─ OUI → &mut T
│
├─ Tu dois la lire seulement, et l'appelant doit pouvoir la réutiliser ?
│   └─ OUI → &T          ← LE PLUS COURANT
│
├─ Tu dois la "consommer" (la transformer, la stocker quelque part) ?
│   └─ OUI → T (move)    ← typique des conversions, des constructeurs
│
└─ Tu en as besoin et l'appelant aussi ?
    └─ OUI → &T puis .clone() en interne, ou demander T et laisser l'appelant cloner

Heuristique simple : commence toujours par &T. Si le compilateur râle parce que tu dois consommer la valeur, passe à T. Recoure à .clone() seulement quand tu n’as pas le choix (et envisage Arc<T> si la même chose doit être partagée par beaucoup de monde).

Les pièges classiques (et comment les lire)

“value borrowed here after move”

let s = String::from("hi");
let s2 = s;                           // move
println!("{s}");                      // ❌

→ Tu utilises s après l’avoir donné. Solutions : ne pas le donner (&s), ou cloner (s.clone() au lieu de s).

“cannot borrow as mutable because it is also borrowed as immutable”

let mut v = vec![1, 2, 3];
let r = &v[0];
v.push(4);                            // ❌ : `r` existe encore
println!("{r}");

→ Tu as un & actif et tu essaies un &mut. Solution : raccourcir la durée du & (le sortir de portée avant le &mut).

“borrowed value does not live long enough”

fn dangling() -> &String {
    let s = String::from("hi");
    &s                                // ❌ : s meurt à la fin de la fonction
}

→ Tu retournes une référence vers une variable locale qui va être détruite. Solution : retourner String (la valeur), pas &String.

Le formatage emprunte toujours

Une source de confusion : pourquoi peut-on logger ou afficher une valeur sans la consommer, alors que toute autre opération sans & est un move ?

AppError::Database(err) => {
    tracing::error!("Failed: {err}");                  // err semble "passé" à la macro
    (StatusCode::INTERNAL_SERVER_ERROR, msg)           // mais on n'utilise plus err
}
//   ^^^   err sort de portée, dropped naturellement

Le trait Display

Display est le trait standard qui dit “voici comment me formater pour un humain”. C’est ce qui se passe quand tu écris println!("{x}"), format!("{x}"), ou tracing::info!("{x}").

Sa signature :

pub trait Display {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result;
    //     ^^^^^   <-- &self : emprunt, jamais move
}

Toutes les traits de formatage (Display, Debug pour {:?}, LowerHex pour {:x}, etc.) prennent &self. C’est cohérent : afficher quelque chose ne devrait pas le détruire.

{x} se désucre en &x

Quand tu écris :

tracing::error!("Failed: {err}");

Le compilateur transforme ça (en très simplifié) en :

tracing::error!("Failed: {}", &err);
//                            ^^^^   référence, pas un move

Les formatages {}, {var}, {var:?}, etc. prennent toujours leur argument par référence. Tu n’as pas besoin d’écrire &err toi-même — la macro le fait pour toi. C’est du sucre syntaxique.

Le mental model à retenir

En Rust, tout est emprunt par défaut quand tu lis (formatage, comparaison, hash…) et tout est move par défaut quand tu donnes (passage à une fonction qui prend T, retour, assignation).

Les macros println!, format!, tracing::*! tombent dans la première catégorie : elles lisent. Donc elles empruntent. Tu ne consommes jamais une valeur en l’affichant.

Cas pratiques

let msg = format!("Failed: {err}");       // emprunte, err survit
let s = err.to_string();                  // méthode &self, emprunte aussi
println!("{err}");                         // emprunte
let inner = err.into_something();         // ← celle-ci consommerait (méthode self)

Sur ton code, vraiment concrètement

pub async fn find_all(db: &DatabaseConnection) -> Result<Vec<user::Model>, DbErr> {
//                        ^^^^^^^^^^^^^^^^^^^^   emprunt : on ne possède pas la DB
//                                              on retourne par valeur : Vec est moved au caller
    user::Entity::find().all(db).await
}
pub async fn list_users(
    State(state): State<AppState>,           // state est moved depuis l'extracteur
) -> Result<Json<Vec<UserDto>>, StatusCode> {
    let users = repositories::user::find_all(&state.db).await?;
    //                                       ^^^^^^^^   on prête db (state reste utilisable)
    let dtos = users.into_iter().map(UserDto::from).collect();
    //              ^^^^^^^^^^^   consomme users (chaque Model est moved dans From)
    Ok(Json(dtos))                           // dtos est moved dans Json puis dans Ok
}