Minimiser les copies
Dans le chapitre 3, nous avons vu comment choisir le type des paramètres d’une fonction. Maintenant qu’on sait comment déplacer les objets, on peut faire mieux !
Un puzzle à résoudre
Nous allons considérer la classe BoxedString
en dessous.
Notre but est d’implémenter un constructeur qui prend un argument une chaîne de caractères pour initialiser l’attribut _str
, et ce en minimisant le nombre de copies.
class BoxedString
{
std::string _str;
};
Pour commencer, évaluons notre objectif: combien de copies peut-on espérer faire au minimum dans les cas typiques.
Les variables b1
, b2
, b3
et b4
(en dessous) montrent les quatre façons dont on va usuellement construire une BoxedString
à partir d’une std::string
.
int main() {
std::string some_str{"Hello world!"};
BoxedString b1{some_str};
BoxedString b2{std::move(some_str)};
BoxedString b3{std::string{"Hello Universe!"}};
BoxedString b4{"Hello Universe!"};
}
Pour chacun de ces quatre cas, combien de copies de la std::string
passée en argument peut-on espérer au minimum?
- Pour
b1
, on ne peut pas espérer faire moins qu’une copie. - Pour
b2
, on peut espérer faire zéro copie car on déplacesome_str
dans l’attribut_str
duBoxedString
construit. - Pour
b3
, on peut espérer faire zéro copie car on peut espérer que l’objet temporaire passé en argument soit déplacé dans_str
duBoxedString
construit (ou construit directement à cet emplacement mémoire si le compilateur y arrive). - Le cas
b4
est en fait équivalent àb3
. Dans la suite, on considère les casb1
etb2
. Les casb3
etb4
seront toujours équivalent àb2
.
Passage par valeur
On peut naïvement passer la std::string
par valeur.
class BoxedString
{
std::string _str;
public:
BoxedString(std::string str) : _str{str} {}
};
Pour chacun des cas b1
et b2
(voir le main
en haut de la page), combien de copies vont avoir lieu?
- La construction de
b1
provoque 2 copies consécutives. 😭 - La construction de
b2
provoque 1 copie (destr
vers_str
). 😭
Passage par référence constante
Imaginons qu’on décide de passer la std::string
par const-ref.
class BoxedString
{
std::string _str;
public:
BoxedString(const std::string& str) : _str{str} {}
};
Notez que le main
donné plus haut compile, car les R-value
peuvent être transtypées en références constantes (on ne fait que perdre des droits sur elles).
Pour chacun des cas b1
et b2
(voir le main
en haut de la page), combien de copies vont avoir lieu?
- La construction de
b1
provoque 1 copie. 😀 - La construction de
b2
provoque 1 copie (destr
vers_str
). 😭
Utiliser deux constructeurs
On peut aussi faire deux constructeurs différents.
class BoxedString
{
std::string _str;
public:
BoxedString(const std::string& str) : _str{str} {}
BoxedString(std::string&& str) : _str{std::move(str)} {}
};
Pour chacun des cas b1
et b2
(voir le main
en haut de la page), combien de copies vont avoir lieu?
- La construction de
b1
provoque 1 copie car on utilise le premier constructeur. 😀 - La construction de
b2
provoque 0 copie: on utilise le second constructeur et donc_some_str
est déplacé versstr
, puis celle-ci est déplacée vers_str
. 😀
Super, on a atteint notre objectif de copie! 🥳
Cette implémentation crée un autre problème 😱.
Voyez-vous lequel?
Que se passe-t-il si on a deux attributs std::string _str1
et std::string _str2
dans la classe?
Si on a plusieurs attributs, on va devoir démultiplier les constructeurs. Par exemple, avec deux chaines, il nous faut 4 constructeurs.
class TwoBoxedStrings
{
std::string _str1;
std::string _str2;
public:
TwoBoxedStrings (const std::string& str1, const std::string& str2)
: _str1{str1}, _str2{str2}
{}
TwoBoxedStrings (std::string&& str1, const std::string& str2)
: _str1{std::move(str1)}, _str2{str2}
{}
TwoBoxedStrings (const std::string& str1, std::string&& str2)
: _str1{str1}, _str2{std::move(str2)}
{}
TwoBoxedStrings (std::string&& str1, std::string&& str2)
: _str1{std::move(str1)}, _str2{std::move(str2)}
{}
};
C’est parfaitement illisible hein?
Et notez bien qu’avec 3 attributs, il nous faut 8 constructeurs; avec 4 attributs, 16 constructeurs; etc.
Passage par valeur puis déplacement
Comment faire alors? Voici une autre implémentation possible.
class BoxedString
{
std::string _str;
public:
BoxedString(std::string str) : _str{std::move(str)} {}
};
Pour chacun des cas b1
et b2
(voir le main
en haut de la page), combien de copies vont avoir lieu?
- La construction de
b1
provoque 1 copie. 😀 - La construction de
b2
provoque 0 copie:_some_str
est déplacé versstr
, puis celle-ci est déplacée vers_str
. 😀
Synthèse: choix du type d’un paramètre v2
Notez que le problème illustré sur cette page peut se poser pour une fonction qui n’est pas un constructeur. On peut donc mettre à jour notre petit schéma pour choisir comment on passe un argument.
%%{init: {"flowchart": {"htmlLabels": false}} }%% flowchart TD; Q1(Est-ce que la fonction est censée avoir un effet de bord sur le paramètre ?) Ref[Passage par référence non-constante] Q2("Est-ce que la copie du paramètre est couteuse?<br/>(allocation dynamique, appels-système, calculs complexes, ...)") Q3("Est-ce la fonction va avoir besoin de sa propre copie du paramètre ?<br/>(attributs,...)") Move[Passage par valeur, puis déplacement dans la fonction] CRef[Passage par reference constante] Value[Passage par valeur] Q1 -->|Oui| Ref Q1 -->|Non| Q2 Q2 -->|Oui| Q3 Q3 -->|Oui| Move Q3 -->|Non| CRef Q2 -->|Non| Value