Skip to content

Latest commit

 

History

History
384 lines (283 loc) · 13.9 KB

File metadata and controls

384 lines (283 loc) · 13.9 KB

🔝 Retour au Sommaire

9.1.2 Transfert de propriété avec std::move

Le principe fondamental

Un std::unique_ptr ne peut pas être copié — c'est la garantie qui assure l'unicité de la possession. Mais dans un programme réel, les ressources doivent circuler : une fonction crée un objet et le confie à une autre, un conteneur stocke des ressources créées ailleurs, un objet est transféré d'un propriétaire à un autre.

C'est le rôle de std::move : il convertit un unique_ptr en rvalue reference, signalant au compilateur que l'objet source peut être « vidé » au profit de la destination. Après le déplacement, le unique_ptr source est dans un état valide mais vide — il vaut nullptr.

#include <memory>

auto source = std::make_unique<int>(42);

// Transfert de propriété : source → destination
auto destination = std::move(source);

// source est maintenant nul
// destination possède l'entier

Ce transfert a trois propriétés essentielles :

  • Il est explicite : std::move rend le transfert visible dans le code. Aucune surprise silencieuse (contrairement à l'ancien std::auto_ptr).
  • Il est gratuit : le déplacement d'un unique_ptr copie un pointeur (8 octets) et met l'ancien à nullptr. Pas d'allocation, pas de copie de données.
  • Il est irréversible : une fois déplacé, le unique_ptr source ne possède plus rien. Tenter de le déréférencer est un comportement indéfini.

Passer un unique_ptr à une fonction (transfert de propriété)

Quand une fonction reçoit un unique_ptr par valeur, elle prend la propriété de la ressource. L'appelant doit explicitement utiliser std::move pour signaler qu'il abandonne la possession :

class Logger {
    std::string nom_;
public:
    explicit Logger(std::string nom) : nom_(std::move(nom)) {}
    void log(const std::string& msg) {
        std::print("[{}] {}\n", nom_, msg);
    }
};

// La fonction prend la propriété du Logger
void enregistrer_logger(std::unique_ptr<Logger> logger) {
    // logger est possédé par cette fonction
    logger->log("Enregistré avec succès");
    // logger est détruit à la fin du scope
}

int main() {
    auto logger = std::make_unique<Logger>("App");
    
    logger->log("Avant transfert");  // ✅ OK — logger est encore valide

    enregistrer_logger(std::move(logger));  // Transfert de propriété

    // ⚠️ logger est maintenant nul
    // logger->log("Après");  // ❌ Comportement indéfini !
    
    if (!logger) {
        std::print("logger a été transféré\n");  // ✅ Affiché
    }
}

La signature void f(std::unique_ptr<T> p) est un contrat dans le code : elle dit « cette fonction consomme la ressource — vous ne l'aurez plus après l'appel ». Si vous oubliez std::move, le compilateur refuse :

auto p = std::make_unique<int>(42);  
enregistrer_logger(p);             // ❌ Erreur : copie impossible  
enregistrer_logger(std::move(p));  // ✅ Transfert explicite  

Quand la fonction n'a PAS besoin de la propriété

Si une fonction se contente de lire ou modifier l'objet sans en prendre la propriété, ne passez pas de smart pointer. Passez une référence ou un pointeur brut :

// ✅ BON — la fonction observe sans posséder
void afficher(const Widget& w) {
    std::print("Widget: {}\n", w.id());
}

// ❌ MAUVAIS — prend inutilement la propriété
void afficher(std::unique_ptr<Widget> w) {
    std::print("Widget: {}\n", w->id());
    // L'appelant a perdu son widget pour rien
}

// ✅ BON — pointeur brut si la nullité est possible
void traiter_si_present(Widget* w) {
    if (w) {
        w->process();
    }
}

// Utilisation
auto widget = std::make_unique<Widget>(1);  
afficher(*widget);                   // Déréférencement → référence  
traiter_si_present(widget.get());    // get() → pointeur brut non-possédant  
// widget est toujours valide et possédé par main()

Cette distinction est résumée dans les C++ Core Guidelines :

Ce que la fonction fait Type du paramètre Appel
Utilise l'objet (non nullable) const T& ou T& f(*ptr)
Utilise l'objet (nullable) T* f(ptr.get())
Prend la propriété std::unique_ptr<T> f(std::move(ptr))
Peut reseater le pointeur std::unique_ptr<T>& f(ptr)

Retourner un unique_ptr depuis une fonction

C'est le pattern le plus naturel et le plus élégant : une fonction crée une ressource et en transfère la propriété à l'appelant. Le compilateur gère le transfert automatiquement — pas besoin d'écrire std::move dans le return :

std::unique_ptr<Widget> creer_widget(int id, const std::string& nom) {
    auto w = std::make_unique<Widget>(id, nom);
    w->initialiser();
    return w;  // ✅ Déplacement implicite (NRVO/move)
}

int main() {
    auto widget = creer_widget(1, "Dashboard");
    widget->afficher();  // L'appelant possède le widget
}

Le compilateur applique ici la Named Return Value Optimization (NRVO) ou, si celle-ci n'est pas possible, un déplacement implicite. Dans les deux cas, aucune copie n'a lieu. Écrire return std::move(w) est non seulement inutile mais contre-productif : cela empêche le NRVO, qui est encore plus efficace qu'un déplacement (voir section 10.5).

return std::move(w);  // ⚠️ Inutile et contre-productif — empêche le NRVO  
return w;             // ✅ Laisse le compilateur optimiser  

Le pattern factory

Ce mécanisme est la base du factory pattern en C++ moderne. La factory crée l'objet et transfère la propriété, rendant le contrat limpide :

class Animal {  
public:  
    virtual ~Animal() = default;
    virtual std::string cri() const = 0;
};

class Chat : public Animal {  
public:  
    std::string cri() const override { return "Miaou"; }
};

class Chien : public Animal {  
public:  
    std::string cri() const override { return "Wouf"; }
};

// Factory : retourne un unique_ptr vers la classe de base
std::unique_ptr<Animal> creer_animal(std::string_view type) {
    if (type == "chat")  return std::make_unique<Chat>();
    if (type == "chien") return std::make_unique<Chien>();
    return nullptr;  // ✅ Type inconnu — unique_ptr nul
}

int main() {
    auto animal = creer_animal("chat");
    if (animal) {
        std::print("{}\n", animal->cri());  // "Miaou"
    }
}

Notez la conversion implicite de std::unique_ptr<Chat> vers std::unique_ptr<Animal>. Cette conversion fonctionne tant que Chat hérite publiquement de Animal et que Animal a un destructeur virtuel — exactement comme pour les pointeurs bruts.


Stocker des unique_ptr dans des conteneurs

Les conteneurs de la STL supportent les types move-only depuis C++11. Vous pouvez stocker des unique_ptr dans un std::vector, un std::map, une std::list, etc.

Ajout dans un vector

std::vector<std::unique_ptr<Animal>> zoo;

// emplace_back construit directement dans le vector
zoo.push_back(std::make_unique<Chat>());  
zoo.push_back(std::make_unique<Chien>());  

// Ajout d'un unique_ptr existant — std::move obligatoire
auto perroquet = std::make_unique<Perroquet>();  
zoo.push_back(std::move(perroquet));  
// perroquet est maintenant nul

// Parcours — par référence constante, pas de transfert
for (const auto& animal : zoo) {
    std::print("{}\n", animal->cri());
}

push_back et emplace_back déclenchent le constructeur de déplacement du unique_ptr. Le vector devient le propriétaire de chaque ressource. À la destruction du vector, tous les objets pointés sont automatiquement libérés.

Extraction depuis un vector

Extraire un unique_ptr d'un conteneur nécessite de le déplacer — le conteneur perd alors la possession de cet élément :

// Extraire le dernier élément
auto dernier = std::move(zoo.back());  
zoo.pop_back();  // Supprime le unique_ptr vide du vector  

// Extraire un élément à un index donné
auto deuxieme = std::move(zoo[1]);
// ⚠️ zoo[1] est maintenant nul — le vector contient un "trou"
// Il faut nettoyer : supprimer l'entrée nulle
zoo.erase(zoo.begin() + 1);

💡 Si vous avez fréquemment besoin d'extraire des éléments du milieu d'un vector de unique_ptr, c'est peut-être le signe qu'un autre conteneur serait plus adapté (std::list, std::deque), ou que la logique d'accès devrait être repensée.

Utilisation dans une map

std::map<std::string, std::unique_ptr<Config>> configurations;

// Insertion
configurations["dev"]  = std::make_unique<Config>("dev.yaml");  
configurations["prod"] = std::make_unique<Config>("prod.yaml");  

// Accès — par référence, jamais par copie
const auto& config_dev = configurations["dev"];  
if (config_dev) {  
    std::print("Port: {}\n", config_dev->port());
}

// Extraction avec extract() (C++17) — propre et efficace
auto node = configurations.extract("dev");  
if (!node.empty()) {  
    auto config = std::move(node.mapped());
    // config possède maintenant la Config "dev"
}

Transfert et polymorphisme

Le transfert de propriété s'intègre naturellement avec le polymorphisme. Les conversions unique_ptr<Derived>unique_ptr<Base> fonctionnent implicitement, comme pour les pointeurs bruts :

class Shape {  
public:  
    virtual ~Shape() = default;
    virtual double aire() const = 0;
};

class Cercle : public Shape {
    double rayon_;
public:
    explicit Cercle(double r) : rayon_(r) {}
    double aire() const override { return 3.14159 * rayon_ * rayon_; }
};

class Rectangle : public Shape {
    double largeur_, hauteur_;
public:
    Rectangle(double l, double h) : largeur_(l), hauteur_(h) {}
    double aire() const override { return largeur_ * hauteur_; }
};

// Conversion implicite Derived → Base
std::unique_ptr<Shape> s = std::make_unique<Cercle>(5.0);  //

// Dans un conteneur polymorphique
std::vector<std::unique_ptr<Shape>> formes;  
formes.push_back(std::make_unique<Cercle>(3.0));  
formes.push_back(std::make_unique<Rectangle>(4.0, 5.0));  

double total = 0.0;  
for (const auto& forme : formes) {  
    total += forme->aire();
}
std::print("Aire totale : {:.2f}\n", total);

⚠️ Rappel essentiel : la classe de base doit avoir un destructeur virtuel (virtual ~Shape() = default;). Sans cela, la destruction via un unique_ptr<Base> n'appellerait pas le destructeur de la classe dérivée — un comportement indéfini et une source de fuites de ressources. Voir section 7.2.


L'état après déplacement : valide mais indéterminé

Après un std::move, le unique_ptr source est dans un état valide mais non spécifié. En pratique, pour unique_ptr, cet état est bien défini : il vaut nullptr. C'est garanti par le standard.

Cependant, la bonne pratique est de ne plus utiliser un unique_ptr après l'avoir déplacé, sauf pour le tester ou le réaffecter :

auto p = std::make_unique<int>(42);  
auto q = std::move(p);  

// Opérations sûres sur p après déplacement :
if (p == nullptr) { /* ... */ }   // ✅ Test de nullité  
p = std::make_unique<int>(100);   // ✅ Réaffectation  
p.reset();                        // ✅ Reset (no-op si déjà nul)  

// Opérations dangereuses :
// *p;        // ❌ Comportement indéfini (déréférencement de nullptr)
// p->foo();  // ❌ Comportement indéfini

Un pattern courant et défensif consiste à limiter la portée du unique_ptr source pour que le compilateur empêche toute utilisation accidentelle :

void configurer_systeme(std::unique_ptr<Config> config);

void demarrer() {
    auto config = charger_config("app.yaml");
    
    // Validation avant transfert
    if (!config->est_valide()) {
        std::print("Configuration invalide\n");
        return;
    }

    configurer_systeme(std::move(config));
    // Après cette ligne, config est nul.
    // Tout le code qui suit ne devrait pas toucher config.
}

Transfert conditionnel : un anti-pattern

Il est tentant d'écrire du code qui déplace un unique_ptr dans certaines branches seulement. C'est techniquement valide mais dangereux car l'état du unique_ptr après le bloc conditionnel dépend du chemin d'exécution :

auto ressource = std::make_unique<Ressource>();

if (condition) {
    consommer(std::move(ressource));  // Déplacé
} else {
    modifier(*ressource);             // Pas déplacé
}

// ⚠️ Ici, ressource est-il nul ou pas ? Ça dépend de condition.
// Ce code est fragile et difficile à maintenir.
ressource->faire_quelque_chose();  // 💥 Crash potentiel

La solution est de structurer le code pour que le unique_ptr soit toujours dans un état connu après le bloc :

auto ressource = std::make_unique<Ressource>();

if (condition) {
    consommer(std::move(ressource));
    return;  // ✅ Sortie anticipée — pas d'ambiguïté
}

// Ici, ressource est garanti non-nul
modifier(*ressource);  
ressource->faire_quelque_chose();  // ✅ Sûr  

Résumé des règles de transfert

Scénario Syntaxe std::move nécessaire ?
Passer en paramètre (transfert) f(std::move(ptr)) Oui — explicite
Retourner depuis une fonction return ptr; Non — implicite (NRVO/move)
Stocker dans un conteneur vec.push_back(std::move(ptr)) Oui — explicite
Construire depuis une rvalue auto p = creer(); Non — résultat temporaire
Conversion Derived → Base unique_ptr<Base> b = std::move(d); Oui — explicite
Affecter à un autre unique_ptr autre = std::move(ptr); Oui — explicite

La règle est cohérente : chaque fois qu'un unique_ptr nommé (une lvalue) change de propriétaire, std::move est requis. Les valeurs temporaires (résultats de fonctions, make_unique directement dans un argument) sont déplacées implicitement.

⏭️ Custom deleters