Les move & smart pointers

Les objets ne peuvent pas changer de possesseur : par exemple, si on souhaite passer un std::vector<int> d'un objet qui n'en a plus besoin à un autre, on est obligé de le copier, ce qui cause des problèmes de performance et ne correspond pas à ce qu'on souhaite faire. De plus, certains objets ne peuvent pas être copiés (comme les std::thread).

Pour pallier à ce problème, un nouveau type de référence a été introduit, qui connote un objet "consommable" : son contenu peut alors être déplacé, généralement pour créer un autre objet de même type. Il s'agit des move references, ou rvalue references (les références classiques étant appelées des lvalue references) :

void use_string(std::string&& text);

int main() {
    // Cette string est temporaire, elle correspond donc à la move référence.
    use_string("hello");
    
    std::string text = "bouip";
    // Cette string n'est pas temporaire, elle ne peut donc pas être consommée.
    // use_string(text);
    // std::move va indiquer que l'objet n'est plus utilisé et peut être consommé.
    use_string(std::move(text));
}

Les objets peuvent être construits par move (T::T(T&& moved)) ou assignés par move (T& operator=(T&& moved)). Un objet pouvant être move doit, après avoir été move, avoir un destructeur valide (car son destructeur sera appelé) et ré-assignable.

Etant donné qu'un nouvel objet objet est créé, il ne sert à rien de move des objets qui ne transfèrent pas de ressources : move un int ou un std::array<int, 12> revient juste à copier l'objet.

Lorsqu'on créé une nouvelle classe, on va généralement chercher à respecter la Rule of Five, qui spécifie que si l'on implémente explicitement une des méthodes spéciales suivantes, alors on doit toutes les implémenter :

  • Constructeur par copie

  • Constructeur par move

  • Affectation par copie

  • Affectation par move

  • Destructeur

Les catégories des expressions

Les lvalue references (&) et rvalue references (&&) tiennent leurs noms des types des expressions auxquelles elles correspondent. Il existe 5 types d'expressions :

  • Les lvalues désignent les objets non temporaires.

  • Les prvalues désignent les objets temporaires (comme un objet retourné par une fonction). Il s'agit des expressions qui sont acceptées par les rvalue references.

  • Les xvalues désignent les objets consommables (le x vient de 'expire').

  • Les glvalues regroupent les lvalues et les xvalues. Il s'agit des expressions qui sont acceptées par les lvalue references.

  • Les rvalues regroupent les xvalues et les prvalues. Il s'agit des objets qui peuvent être move.

Puisque l'on souhaite plutôt faire correspondre les xvalues aux rvalue references, on les change en prvalues grâce à std::move.

Les const lvalue references (T const&) acceptent également les temporaires, puisqu'elles ne modifient pas l'objet reçu. Cela rend le code suivant valide :

MyClass value;

void make_value() {
    MyClass const var;
    // var est de type MyClass const&&.
    // L'affectation par move a comme signature :
    // MyClass& operator=(MyClass&&).
    // Comme la variable est const, c'est l'affectation par copie qui va être appelée :
    // MyClass& operator=(MyClass const&).
    value = std::move(var);
}

std::unique_ptr

Les constructeurs et affectations par move sont utilisés pour permettre à des objets (std::string, std::vector, ...) de gérer leurs ressources et de les passer à travers différents scopes. Des pointeurs utilisent ces opérations pour gérer l'objet pointé automatiquement : ils sont appelés smart pointers. La librairie standard met à disposition std::unique_ptr, std::shared_ptr et std::weak_ptr.

std::unique_ptr va gérer un objet (via un pointeur) en le détruisant dans son propre destructeur. Si on est l'unique propriétaire d'un objet, on va donc le stocker par valeur ou via un std::unique_ptr. Ce smart pointer ne fait rien d'autre et n'amène donc pas de surcoût par rapport à une gestion manuelle du pointeur.

Il s'agit du choix par défaut lorsque l'on veut stocker un objet qui n'est pas movable (il suffira de move le pointeur lui-même) ou un objet polymorphique (std::unique_ptr est covariant, et permet l'indirection pour stocker différents types sous la même appellation).

std::unique_ptr est paramétrisé par le type du function object qui va être exécuté dans le destructeur du pointeur : std::unique_ptr<T, Deleter = std::default_deleter<T>>. Il est donc possible de faire utiliser un destructeur custom à un std::unique_ptr.

La fonction template std::make_unique<T> est préférée au constructeur de std::unique_ptr<T> pour plusieurs avantages, dont certains ne sont plus valides en C++17. La déduction du type template de std::unique_ptr ne peut pas se faire via son constructeur, donc std::make_unique évite de mentionner deux fois le type.

struct Base {};
struct Derived : Base {
    int val;
};

std::unique_ptr<Base> make_object(int val) {
    // Equivalent à :
    // return std::unique_ptr<Derived>(new Derived(val));
    return std::make_unique<Derived>(val);
}

struct Holder {
    std::unique_ptr<Base> obj;
}

int main() {
    Holder h = { make_object() };
    // std::unique_ptr est détruit, ce qui va supprimer l'objet.
}

std::shared_ptr et std::weak_ptr

Si l'on n'est pas l'unique propriétaire d'un objet, std::unique_ptr ne suffit plus, car on souhaite détruire l'objet une fois que tous ses possesseurs sont détruits. std::shared_ptr remédie à ce problème en utilisant un compteur de référence thread-safe (des std::shared_ptr pointant sur le même objet peuvent être créés et détruits sur différents threads).

Par rapport à un std::unique_ptr, Il apporte un surcoût lors de sa création, de sa destruction et lors de la création du control block (stockant notamment le compteur de référence, via une allocation dynamique). De plus, il prend deux fois plus de place : il possède deux pointeurs, pour accéder à l'objet et au compteur de référence.

std::make_shared comporte les mêmes avantages que std::make_unique, et apporte de meilleures performances comparé au constructeur de std::shared_ptr car il alloue l'objet et le control block en une fois.

Un std::shared_ptr peut être créé ou assigné depuis un std::unique_ptr, ce qui permet de créer des fonctions retournant des std::unique_ptr pour tous les usages. Contrairement à celui-ci, le destructeur de std::shared_ptr n'est pas stocké dans son type mais dans le control block.

std::weak_ptr est un pointeur ne prenant pas part au comptage de référence : il est donc possible qu'il pointe vers un objet déjà supprimé. Pour l'utiliser, on doit le changer en std::shared_ptr afin d'être sûr que l'objet ne se fera pas détruire pendant son utilisation. Il est utile notamment pour régler les problèmes de références cycliques.

// Exemple tiré de cppreference.com.

std::weak_ptr<int> val;
 
void observe()
{
    std::cout << "use_count == " << val.use_count() << ": ";
    //Changement en std::shared_ptr
    if (auto spt = val.lock())
	    std::cout << *spt << "\n";
    else
        std::cout << "val is expired\n";
}
 
int main() {
    {
        auto ptr = std::make_shared<int>(42);
    	val = ptr;
        // use_count == 1, 42
    	observe();
    }
    // use_count == 0, val is expired
    observe();
}

Les forwarding references

Lorsqu'un argument template est de type T&& avec T un paramètre template, l'argument n'est pas une rvalue reference (malgré son écriture) mais une forwarding reference. Cela permet de capturer des glvalues (auquel cas T sera déduit en ValueType&) et des prvalues (auquel cas T sera égal à ValueType).

Il faut que T soit un paramètre template de la fonction, pour qu'il ne soit pas déduit autre part :

// Exemple de forwarding reference.
template <class T>
void use_object(T&&);

template <class T>
struct Hello {
    // C'est une rvalue reference et non une forwarding reference.
    Hello(T&&);
}

Ces références sont généralement utilisées avec std::forward<T>(t), qui va move la référence uniquement si elle désigne une rvalue. Sinon, la fonction renvoie simplement la référence.

// Création d'un function object template.
template <class T>
struct constructor {
    // Son opérateur prend les références des arguments passés pour construire un T.
    // Les forwarding references vont différencier les objets pouvant être move de
    // ceux qui seront copiés, ce qui est fait avec std::forward.
    template <class...Args>
    T operator()(Args&&...args) const {
        // Attention : utiliser std::forward(args) déduit mal le type.
        return T{ std::forward<Args>(args) ... };
    }
};

struct Thing {
    float f;
    char c;
    int i;
};

Thing make_thing() {
    float pi = 3.14f;
    char c = 'u';
    // pi est copié, c est move, 42 est move.
    return constructor<Thing>{}(pi, std::move(c), 42);
}

Les forwarding references sont utiles mais peuvent prêter à confusion : est-ce que T désigne un int ? un int& ? un int const&& ? Il faut donc faire attention aux types manipulés afin d'éviter des copies inattendues, qui peuvent passer inaperçues (un type copiable ne fera pas mal fonctionner le programme, mais le fera ralentir).

Pour aller plus loin

Avant C++11 (apportant les move et les rvalue references), les objets étaient move en implémentant une fonction swap(T& lhs, T& rhs)qui échange le contenu de deux objets. Cette pratique est encore utile pour des raisons d'exception safety.

Il ne faut pas move les types retournés par les fonctions en pensant optimiser la fonction (en évitant une copie) car la Return Value Optimisation (RVO, garantie en C++17) assure que l'objet, lorsqu'il est temporaire, n'est construit qu'une seule fois : il ne sera donc pas move ou copié. De plus, les compilateurs peuvent faire la même optimisation pour des variables non temporaires (Named Return Value Optimisation) et vont move l'objet si il ne peut pas être construit du côté de l'appelant de la fonction.

Attention au phénomène de reference collapsing lors de la manipulation de types template.

Des explication plus précises des catégories d'expressions existent.

Lorsqu'on souhaite accepter un objet qui peut être consommé ou copié, 3 alternatives existent :

  • Dédoubler la fonction pour accepter l'objet via une rvalue reference ou une lvalue reference.

  • Passer l'objet par valeur, puis le move dans la fonction. Cela coûte un move en plus par rapport à la première solution, mais évite de dédoubler la fonction.

  • Rendre la fonction template pour recevoir l'objet avec une forwarding reference. On a les performances de la première solution sans dédoubler la fonction, mais on a un paramètre template (pouvant initialement accepter n'importe quel type).

Les classes peuvent avoir des ref-qualifiers, ce qui contraindra la méthode à être appelée sur un temporaire ou un non-temporaire.

std::shared_ptr ne peut pas recevoir de destructeur custom via std::make_shared. Cependant, on peut utiliser std::allocate_shared pour quand même bénéficier des performances de std::make_shared.

Challenges

(*) Ecrire force_move(value) qui va cast l'objet en prvalue (comme std::move) ou lancer une erreur à la compilation si l'objet est const. Exemple :

MyClass global;

void create_global_by_move() {
    MyClass v1;
    // Equivalent
    global = std::move(v1);
    global = force_move(v1);

    MyClass const v2;
    // Le constructeur par copie sera appelé.
    // force_move arrête la compilation pour éviter la confusion.
    global = std::move(v2);
    //global = force_move(v2);
}

(***) Ecrire une classe template unique_function<T(Args...)> qui permet de manipuler n'importe quel function object pouvant être move, de manière similaire à std::function<T(Args...)>. Exemple :

// Renvoie un générateur de nombres aléatoires.
// La lambda capture un std::unique_ptr, elle ne peut donc pas être copiée.
// std::function ne peut que stocker des fonction object pouvant être copiés.
std::unique_function<int(int, int)>
make_random_generator(std::unique_ptr<std::mt19937>&& producer) {
    return [producer = std::move(producer)] (int min, int max) {
        std::uniform_int_distribution dis(min, max);
        return dis(*producer);
    }
}

Bonus : ajouter à unique_function la Small Buffer Optimisation (SBO).

Last updated