web-dev-qa-db-fra.com

Éviter la copie d'objets avec l'instruction "return"

J'ai une question très basique en C++. Comment éviter la copie lors du retour d'un objet?

Voici un exemple :

std::vector<unsigned int> test(const unsigned int n)
{
    std::vector<unsigned int> x;
    for (unsigned int i = 0; i < n; ++i) {
        x.Push_back(i);
    }
    return x;
}

Si je comprends comment C++ fonctionne, cette fonction créera 2 vecteurs: le fichier local (x) et la copie de x qui sera renvoyée. Y a-t-il un moyen d'éviter la copie? (et je ne veux pas renvoyer un pointeur sur un objet, mais sur l'objet lui-même)


Quelle serait la syntaxe de cette fonction en utilisant "move sémantique" (ce qui était indiqué dans les commentaires)?

28
Vincent

Ce programme peut tirer parti de l'optimisation des valeurs de retour nommées (NRVO). Voir ici: http://en.wikipedia.org/wiki/Copy_elision

En C++ 11, il existe des constructeurs de déménagement et des affectations qui sont également peu coûteux. Vous pouvez lire un tutoriel ici: http://thbecker.net/articles/rvalue_references/section_01.html

17
Pubby

Il semble y avoir une certaine confusion quant au fonctionnement de l’optimisation de la valeur de retour (RVO).

Un exemple simple:

#include <iostream>

struct A {
    int a;
    int b;
    int c;
    int d;
};

A create(int i) {
    A a = {i, i+1, i+2, i+3 };
    std::cout << &a << "\n";
    return a;
}

int main(int argc, char*[]) {
    A a = create(argc);
    std::cout << &a << "\n";
}

Et sa sortie sur ideone :

0xbf928684
0xbf928684

Surprenant?

En réalité, c’est l’effet de RVO: l’objet à retourner est construit directement sur place dans l’appelant.

Comment?

Traditionnellement, l’appelant (main ici) réservera de l’espace sur la pile pour la valeur de retour: le emplacement de retour; l'appelé (create ici) est passé (en quelque sorte) l'adresse du logement de retour dans lequel copier sa valeur de retour. L'appelé alloue ensuite son propre espace pour la variable locale dans laquelle il construit le résultat, comme pour toute autre variable locale, puis le copie dans l'emplacement de retour de l'instruction return.

RVO est déclenché lorsque le compilateur déduit du code que la variable peut être construite directement dans le emplacement de retour avec une sémantique équivalente (la règle as-if).

Notez qu'il s'agit d'une optimisation si commune qu'elle figure explicitement dans la liste blanche du standard et que le compilateur n'a pas à s'inquiéter des éventuels effets secondaires du constructeur de copie (ou de déplacement).

Quand?

Le compilateur est le plus susceptible d'utiliser des règles simples, telles que:

// 1. works
A unnamed() { return {1, 2, 3, 4}; }

// 2. works
A unique_named() {
    A a = {1, 2, 3, 4};
    return a;
}

// 3. works
A mixed_unnamed_named(bool b) {
    if (b) { return {1, 2, 3, 4}; }

    A a = {1, 2, 3, 4};
    return a;
}

// 4. does not work
A mixed_named_unnamed(bool b) {
    A a = {1, 2, 3, 4};

    if (b) { return {4, 3, 2, 1}; }

    return a;
}

Dans ce dernier cas (4), l'optimisation ne peut pas être appliquée lorsque A est renvoyé car le compilateur ne peut pas construire a dans le slot de retour, car il peut en avoir besoin pour autre chose (selon la condition booléenne b).

Une règle simple est donc que:

RVO doit être appliqué si aucun autre candidat pour le créneau de retour n'a été déclaré avant l'instruction return.

38
Matthieu M.

Optimisation de la valeur de retour nommée fera le travail à votre place depuis le compilateur tente d'éliminer les appels redondants de constructeur de copie et de destructeur tout en l'utilisant.

std::vector<unsigned int> test(const unsigned int n){
    std::vector<unsigned int> x;
    return x;
}
...
std::vector<unsigned int> y;
y = test(10);

avec optimisation de la valeur de retour:

  1. y est créé
  2. x est créé
  3. x est assigné à y
  4. x est détruit

(Si vous voulez essayer vous-même pour une compréhension plus profonde, regardez cet exemple de moi )

ou même mieux, tout comme Matthieu M. a souligné, si vous appelez testdans la même ligne où yest déclaré, vous pouvez également éviter la construction d’objets redondants et d’affectations redondantes (xsera également construit dans la mémoire où yseront stockés):

std::vector<unsigned int> y = test(10);

vérifiez sa réponse pour mieux comprendre cette situation (vous découvrirez également que ce type d'optimisation ne peut pas toujours être appliqué).

OUvous pouvez modifier votre code pour passer la référence du vecteur à votre fonction, ce qui serait sémantiquement plus correct tout en évitant la copie:

void test(std::vector<unsigned int>& x){
    // use x.size() instead of n
    // do something with x...
}
...
std::vector<unsigned int> y;
test(y);
14
LihO

Les compilateurs peuvent souvent optimiser la copie supplémentaire pour vous (ceci est connu sous le nom de return value optimization ). Voir https://isocpp.org/wiki/faq/ctors#return-by-value-optimization

2
jamesdlin

Référencer cela fonctionnerait.

Void(vector<> &x) {

}
1
Jake Runzer

Le constructeur de déplacements est garanti si NRVO ne se produit pas

Par conséquent, si vous retournez un objet avec le constructeur de déplacement (tel que std::vector) par valeur, il est garanti de ne pas effectuer de copie vectorielle complète, même si le compilateur ne parvient pas à optimiser l'optimisation NRVO facultative.

Ceci est mentionné par deux utilisateurs qui semblent influents dans la spécification C++ elle-même:

Pas satisfait par mon appel à la célébrité?

D'ACCORD. Je ne comprends pas tout à fait la norme C++, mais je peux comprendre ses exemples! ;-)

Citation du projet de norme C++ 17 n4659 15.8.3 [class.copy.elision] "Copier/déplacer une décision"

3 Dans les contextes d'initialisation de copie suivants, une opération de déplacement peut être utilisée à la place d'une opération de copie:

  • (3.1) - Si l'expression dans une instruction return (9.6.3) est une expression id (éventuellement entre parenthèses) qui nomme Un objet à durée de stockage automatique déclarée dans le corps ou une clause de déclaration de paramètre de fonction ou expression lambda la plus interne, ou
  • (3.2) - si l'opérande d'une expression-cible (8.17) est le nom d'un objet automatique non volatile (autre que Une fonction ou un paramètre de clause de blocage) dont la portée ne s'étend pas au-delà de la fin de le bloc d'essai le plus proche (s'il y en a un),

la résolution de surcharge pour sélectionner le constructeur de la copie est d'abord effectuée comme si l'objet était désigné par une valeur rvalue. Si la résolution de la première surcharge échoue ou n'a pas été effectuée, ou si le type du premier paramètre Du constructeur sélectionné n'est pas une référence rvalue au type de l'objet (éventuellement qualifié cv), surcharge la résolution est effectuée à nouveau, en considérant l'objet comme une lvalue. [Remarque: cette résolution de surcharge en deux étapes Doit être exécutée, que la copie soit effectuée ou non. Il détermine le constructeur à appeler si la décision N'est pas effectuée et le constructeur sélectionné doit être accessible même si l'appel est supprimé. - fin [note.]

4 [Exemple:

class Thing {
public:
  Thing();
  ~ Thing();
  Thing(Thing&&);
private:
  Thing(const Thing&);
};

Thing f(bool b) {
  Thing t;
  if (b)
    throw t;          // OK: Thing(Thing&&) used (or elided) to throw t
  return t;           // OK: Thing(Thing&&) used (or elided) to return t
}

Thing t2 = f(false);  // OK: no extra copy/move performed, t2 constructed by call to f

struct Weird {
  Weird();
  Weird(Weird&);
};

Weird g() {
  Weird w;
  return w;           // OK: first overload resolution fails, second overload resolution selects Weird(Weird&)
}

- fin exemple

Je n'aime pas le libellé "pourrait être utilisé", mais je pense que l'intention est de vouloir dire que si "3.1" ou "3.2" tiennent, le retour de la valeur doit avoir lieu.

C'est assez clair sur les commentaires de code pour moi.

Passer par référence + std::vector.resize(0) pour plusieurs appels

Si vous passez plusieurs appels à test, j'estime que cela serait légèrement plus efficace car cela économise quelques appels malloc() + copies de relocalisation lorsque le vecteur double de taille:

void test(const unsigned int n, std::vector<int>& x) {
    x.resize(0);
    x.reserve(n);
    for (unsigned int i = 0; i < n; ++i) {
        x.Push_back(i);
    }
}

std::vector<int> x;
test(10, x);
test(20, x);
test(10, x);

étant donné que https://en.cppreference.com/w/cpp/container/vector/resize dit:

La capacité de vecteur n'est jamais réduite lors du redimensionnement à une taille inférieure car cela invaliderait tous les itérateurs, plutôt que seulement ceux qui seraient invalidés par la séquence équivalente d'appels pop_back ().

et je ne pense pas que les compilateurs soient capables d’optimiser la version retour par valeur pour éviter les mallocs supplémentaires.

D'autre part, ceci:

  • rend l'interface plus laide
  • utilise plus de mémoire que nécessaire lorsque vous réduisez la taille du vecteur

donc il y a un compromis.