L'optimisation RVO, ou Return Value Optimization, permet à un compilateur d'éliminer des copies lorsque la valeur retournée par une fonction est utilisée pour initialiser un objet. Par exemple :
vector<int> creer() {
return { 2, 3, 5, 7, 11 };
};
int main() {
vector<int> v0; // constructeur par défaut
v0 = creer(); // remplace le contenu de v0 par ce que retourne creer()
auto v1 = creer(); // pas besoin de copier un vector<int>, il est possible
// de construire v1 directement et éviter une temporaire
}
Notez, car c'est important, que RVO est particulier en ce sens qu'il s'agit d'une des rares optimisations permises en C++ qui ne soit pas as-if, au sens où sa mise en application a un effet reconnaissable (autre que la simple accélération de l'exécution du programme) du fait que certaines méthodes (un constructeur, un destructeur, parfois une affectation) ne seront pas appelées avec cette optimisation et le seraient sans elle. Cette forme de Copy Elision est malgré tout permise, mais n'est pas obligatoire avant C++ 17; à partir de C++ 17 toutefois, il deviendra possible d'écrire du code portable reposant sur cette optimisation : https://wg21.link/p0135
Ce qui suit découle d'une conversation que j'ai tenue avec Félix-Antoine Ouellet, brillant étudiant à la maîtrise à l'Université de Sherbrooke, et qui se demandait s'il était préférable d'écrire « return std::move(x); » ou « return x; » en pratique.
En pratique, « return x; » est la chose à faire. La raison est que recourir à std::move() force le mouvement, qui est typiquement efficace, mais moins que l'optimisation RVO que le compilateur peut (et, avec C++ 17, doit) parfois réaliser. Concrètement, tous les compilateurs pertinents appliquent déja RVO depuis longtemps, mais ce n'était pas imposé par le standard alors ça en faisait une optimisation qui brisait la règle du as if, car son impact sur la sémantique du code était observable.
À titre d'exemple, soit la classe Noisy suivante : ../Sujets/TrucsScouts/Noisy.html. Son rôle est, essentiellement, de faire du bruit. Avec cette classe, il devient possible d'observer le déroulement de la vie des objets dans un programme.
Ainsi, ce programme :
Noisy f(Noisy n) {
Noisy m = n;
return m;
}
int main() {
Noisy n;
n = f(n);
}
... affichera ceci :
Noisy::Noisy()
Noisy::Noisy(const Noisy&)
Noisy::Noisy(const Noisy&)
Noisy::~Noisy()
Noisy::operator=(Noisy&&)
Noisy::~Noisy()
Noisy::~Noisy()
...donc beaucoup d'action. Si on raffine pour obtenir ceci :
Noisy f(Noisy n) {
return n;
}
int main() {
Noisy n;
n = f(n);
}
...on obtiendra cela :
Noisy::Noisy()
Noisy::Noisy(const Noisy&)
Noisy::Noisy(Noisy&&)
Noisy::~Noisy()
Noisy::operator=(Noisy&&)
Noisy::~Noisy()
Noisy::~Noisy()
...où une copie a été remplacée silencieusement par un mouvement (le return, en fait). On peut faire mieux :
Noisy f(Noisy n) {
return n;
}
int main() {
Noisy n;
n = f(std::move(n));
}
...ce qui nous donne :
Noisy::Noisy()
Noisy::Noisy(Noisy&&)
Noisy::Noisy(Noisy&&)
Noisy::~Noisy()
Noisy::operator=(Noisy&&)
Noisy::~Noisy()
Noisy::~Noisy()
...où toutes les copies sont disparues. Ajouter un retour par mouvement ici ne donne rien; en effet :
Noisy f(Noisy n) {
return std::move(n);
}
int main() {
Noisy n;
n = f(std::move(n));
}
...nous donnera encore :
Noisy::Noisy()
Noisy::Noisy(Noisy&&)
Noisy::Noisy(Noisy&&)
Noisy::~Noisy()
Noisy::operator=(Noisy&&)
Noisy::~Noisy()
Noisy::~Noisy()
Si on simplifie un peu pour obtenir ceci :
Noisy f(Noisy n) {
return n;
}
int main() {
Noisy n = f(Noisy{});
}
...on voit l'optimiseur s'amuser avec les variables anonymes et nous donner cela :
Noisy::Noisy()
Noisy::Noisy(Noisy&&)
Noisy::~Noisy()
Noisy::~Noisy()
...ce qui commence à être sympathique. D'ailleurs, on aurait simplement pu écrire ceci :
Noisy f(Noisy n) {
return n;
}
int main() {
Noisy n = f({});
}
...et voir encore cela, car l'appel à f() n'est pas ambigü :
Noisy::Noisy()
Noisy::Noisy(Noisy&&)
Noisy::~Noisy()
Noisy::~Noisy()
Pour s'amuser vraiment, nous pouvons aller jusqu'à :
Noisy f() {
return{};
}
int main() {
Noisy n = f();
}
...ce qui donnera :
Noisy::Noisy()
Noisy::~Noisy()
...et c'est là que l'on voit qu'il vaut mieux laisser le compilateur s'amuser. Ici, si nous avions une variable nommée et un mouvement explicite, nous perdrions au change. Ainsi, ceci :
Noisy f() {
Noisy n;
return std::move(n);
}
int main() {
Noisy n = f();
}
...affichera cela :
Noisy::Noisy()
Noisy::Noisy(Noisy&&)
Noisy::~Noisy()
Noisy::~Noisy()
...ce qui est le mieux qu'il puisse faire dans ce cas. De manière amusante, ceci :
Noisy f() {
Noisy n;
return n;
}
int main() {
Noisy n = f();
}
...donnera cela :
Noisy::Noisy()
Noisy::~Noisy()
... ce qui est plus rapide que la version avec un std::move() (on voit ici la variante NRVO, pour Named Return Value Optimization). En effet, le compilateur applique le mouvement implicitement quand c'est possible, et fait RVO (qui escamote carrément une temporaire) quand il le peut. Mieux vaut le laisser faire son travail.
Quelques liens pour enrichir le propos.