I. Problèmes▲
I-A. Question Junior▲
Considérez la fonction suivante, qui illustre une erreur courante quand on utilise auto_ptr :
template
<
class
T>
void
f( size_t n ) {
auto_ptr<
T>
p1( new
T );
auto_ptr<
T>
p2( new
T[n] );
//
// ... more processing ...
//
}
Qu'est-ce qui ne va pas avec ce code ? Expliquez.
I-B. Question Guru▲
Comment résoudriez-vous le problème ? Envisagez autant d'options que possible, y compris : le patron de conception Adaptateur, les alternatives à la construction problématique et les alternatives à auto_ptr.
II. Solutions▲
II-A. Question Junior▲
Qu'est-ce qui ne va pas avec ce code ? Expliquez.
II-A-1. Problème : ne pas mélanger les tableaux et auto_ptr▲
Tous les delete doivent correspondre à la forme de son new. Si vous utilisez un new à objet unique, vous devez utiliser un delete à objet unique ; si vous utilisez la forme de new pour les tableaux, vous devez utiliser la forme de delete pour les tableaux. Faire autrement entraîne un comportement non défini, comme illustré dans le code suivant, légèrement modifié :
T*
p1 =
new
T;
// delete[] p1; // erreur
delete
p1; // ok - c'est ce que fait auto_ptr
T*
p2 =
new
T[10
];
delete
[] p2; // ok
// delete p2; // ok - c'est ce que fait auto_ptr
Le problème avec p2 est que auto_ptr est destiné à ne contenir que des objets uniques, aussi appelle-t-il toujours delete – et non pas delete[] – sur son pointeur. Cela signifie que p1 sera nettoyé correctement, mais pas p2.
Ce qui va vraiment se produire quand vous utiliserez la mauvaise forme de delete dépend de votre compilateur. Le mieux que vous puissiez espérer est une perte de ressources, mais un résultat plus typique sera une corruption de mémoire, bientôt suivie d'un core dump. Pour observer cet effet, essayez le programme complet suivant sur votre compilateur préféré :
#include
<iostream>
#include
<memory>
#include
<string>
using
namespace
std;
int
c =
0
;
struct
X {
X() : s( "1234567890"
) {
++
c; }
~
X() {
--
c; }
string s;
}
;
template
<
class
T>
void
f( size_t n ) {
{
auto_ptr<
T>
p1( new
T );
auto_ptr<
T>
p2( new
T[n] );
}
cout <<
c <<
" "
; // reporte n° de X objets
}
// qui existent actuellement
int
main() {
while
( true
) {
f<
X>
(100
);
}
}
Soit vous aurez un crash, soit vous aurez une actualisation du nombre d'objets perdus (si vous voulez vous amuser encore plus, essayez de faire tourner un outil de contrôle système dans une autre fenêtre qui montre l'utilisation totale de la mémoire de votre système. Cela vous aidera à apprécier combien la fuite peut-être néfaste si le programme ne se contente pas de simplement crasher tout de suite).
II-A-2. Non-problème : les tableaux de taille nulle, ça marche▲
Que se passe-t-il si le paramètre est zéro (par exemple, dans l'appel f<int>(0)) ? Alors le deuxième new deviendra new T[0] et souvent les programmeurs se demanderont : « hmm, est-ce que ça va ? On peut avoir un tableau de taille nulle ? »
La réponse est oui, les tableaux de taille nulle sont corrects. Le résultat de new T[0] est seulement un pointeur vers un tableau avec zéro élément et ce pointeur se comporte comme n'importe quel autre utilisation de new T[n], y compris sur le fait que vous ne pouvez pas tenter d'accéder à plus de n éléments dans le tableau… Dans ce cas présent, vous ne pouvez accéder à aucun élément, puisqu'il n'y en a pas.
Extrait du chapitre 5.3.4 [expr.new], paragraphe 7 :
Quand la valeur de l'expression utilisée dans new est nulle, la fonction d'allocation est appelée pour allouer un tableau sans élément. Le pointeur retourné par l'expression new est non-nul. [Note : si l'on appelle la fonction d'allocation de la bibliothèque, le pointeur retourné est différent des pointeurs vers tous les autres objets.]
« Maintenant, si vous ne pouvez rien faire avec des tableaux de taille nulle (à part vous rappeler leur adresse) » vous seriez en droit de vous demander « pourquoi les allouer ? » Une raison importante est qu'ils facilitent l'écriture de code avec allocation de tableaux dynamiques. Par exemple, la fonction f ci-dessus serait inutilement plus complexe s'il fallait vérifier la valeur de son paramètre n avant d'appeler new T[n].
II-B. Question Guru▲
Comment résoudriez-vous le problème ? Envisagez autant d'options que possible, y compris : adapter pattern, les alternatives à la construction problématique, et les alternatives à auto_ptr.
Il y a plusieurs options (certaines meilleures, certaines moins bonnes). En voici quatre :
II-B-1. Option 1 : utiliser son propre auto_array▲
Cela peut-être à la fois plus facile et plus difficile qu'il n'y paraît :
II-B-1-a. Option 1a : en s'inspirant de auto_ptr (Score : 0/10)▲
Mauvaise idée. Par exemple, vous allez beaucoup vous amuser à reproduire toute la sémantique de propriété et de classe d'assistance. Seuls de vrais gourous pourraient essayer, mais les vrais gourous n'essaieraient jamais, parce qu'il y a des moyens plus faciles.
Avantage :
- peu.
Inconvénient :
- trop pour les compter.
II-B-1-b. Option 1b : en clonant auto_ptr (Score : 8/10)▲
Ici, l'idée est de prendre le code auto_ptr à partir de l'implémentation de votre bibliothèque, de le cloner (en le renommant auto_array ou quelque chose comme ça), et de changer les déclarations de delete en delete[].
Avantages :
- facile à mettre en œuvre (une fois). On n'a pas besoin de coder à la main notre propre auto_array et on garde toute la sémantique de auto_ptr automatiquement, ce qui permettra aux développeurs qui feront les futures maintenances et qui connaissent déjà auto_ptr de ne pas être surpris ;
- pas de perte significative d'espace ni de temps.
Inconvénient :
- la maintenance sera difficile. Vous voudrez probablement garder l'implémentation de votre auto_array similaire à auto_ptr de votre bibliothèque quand vous actualiserez une nouvelle version de compilateur/bibliothèque ou changerez de compilateur/bibliothèque.
II-B-2. Option 2 : utiliser le schéma d'adaptation (score : 7/10)▲
Cette option est apparue lors d'une discussion que j'ai eu avec Henrik Nordberg après une de mes conférences. La première réaction d'Henrik au code du problème a été de se demander s'il serait plus facile d'écrire un adaptateur pour faire fonctionner le auto_ptr standard correctement au lieu de réécrire auto_ptr ou d'utiliser quelque chose d'autre. Cette idée a quelques avantages réels et mérite d'être étudiée malgré ses quelques inconvénients.
L'idée est la suivante : au lieu d'écrire :
auto_ptr<
T>
p2( new
T[n] );
on écrit :
auto_ptr<
ArrDelAdapter<
T>
>
p2( new
ArrDelAdapter<
T>
(new
T[n]) );
où ArrDelAdapter (adaptateur d'effacement de tableau) comporte un constructeur qui crée un T* et un destructeur qui appelle delete[] sur ce pointeur :
template
<
class
T>
class
ArrDelAdapter {
public
:
ArrDelAdapter( T*
p ) : p_(p) {
}
~
ArrDelAdapter() {
delete
[] p_; }
// operators like "->" "T*" and other helpers
private
:
T*
p_;
}
;
Puisqu'il n'y a qu'un seul objet ArrDelAdapter<T>, la déclaration d'effacement d'objet unique dans ~auto_ptr va marcher ; et puisque ~ArrDelAdapter<T> appelle correctement delete[] sur le tableau, le problème d'origine est résolu.
Assurément, ce n'est peut-être pas l'approche la plus élégante ni la plus belle qui soit, mais au moins nous n'avons pas eu à entrer à la main le code de notre propre auto_array !
Avantage :
- facile à mettre en œuvre (au départ). On n'a pas besoin d'écrire un auto_array. En fait, on garde toute la sémantique de auto_ptr automatiquement, ce qui permettra aux développeurs qui feront les futures maintenances et qui connaissent déjà auto_ptr de ne pas être surpris.
Inconvénients :
- difficile à écrire. Cette solution est plutôt verbeuse ;
- (peut-être) difficile à utiliser. Par la suite, pour tout code dans f qui utilisera la valeur de p2, auto_ptr aura besoin de changements de syntaxe, ce que des indirections supplémentaires rendront souvent plus gênantes ;
- perte d'espace et de temps. Ce code nécessite de l'espace supplémentaire pour enregistrer l'objet adaptateur nécessaire pour chaque tableau. Cela nécessite aussi du temps supplémentaire parce qu'il effectue deux fois plus d'allocations mémoire (cela peut s'améliorer en utilisant un opérateur new surchargé) et ensuite une indirection supplémentaire chaque fois qu'un code client accède au tableau contenu.
Cela étant dit : même si les autres possibilités s'avèrent meilleures dans ce cas particulier, j'ai été ravi de voir les gens penser immédiatement à utiliser le schéma d'adaptation. L'adaptateur est largement applicable et c'est l'un des schémas fondamentaux que tout programmeur devrait connaître.
Note finale à propos de l'option 2 : notez bien qu'écrire :
auto_ptr<
ArrDelAdapter<
T>
>
p2( new
ArrDelAdapter<
T>
(new
T[n]) );
est très différent d'écrire :
auto_ptr<
vector<
T>
>
p2( new
vector<
T>
(n) );
Pensez-y un moment – par exemple, demandez-vous : « est-ce que je gagne quelque chose en allouant le vecteur de façon dynamique que je n'aurais pas en écrivant juste vector p2(n); » ? Ensuite, reportez-vous à l'option 4.
II-B-3. Option 3 : remplacer auto_ptr par une EH Logic codée à la main (score : 1/10)▲
La fonction f utilise auto_ptr pour la libération automatique et probablement pour l'exception safety. Au lieu de cela, nous pourrions laisser tomber auto_ptr pour le tableau p2 et coder à la main notre propre logique de gestion d'exception.
L'idée est la suivante : plutôt que d'écrire :
auto_ptr<
T>
p2( new
T[n] );
//
// ... d'autres traitements ...
//
on écrit quelque chose comme :
T*
p2( new
T[n] );
try
{
//
// ... d'autres traitements ...
//
}
delete
[] p2;
Avantages :
- facile à utiliser. Cette solution a peu d'impact sur le code contenu dans « d'autres traitements » qui utilise p2 ; la seule chose à faire probablement est de supprimer les appels à get() partout où c'est utilisé ;
- pas de perte significative d'espace ni de temps.
Inconvénients :
- difficile à mettre en œuvre. Cette solution implique probablement plus de changements de code que ce qui est suggéré plus haut. La raison est que, alors que auto_ptr utilisé avec p1 libère automatiquement le nouvel objet T quel que soit la façon dont on sort de la fonction, pour libérer p2, on doit à présent écrire du code pour libérer la mémoire en suivant tous les chemins possibles pour sortir de la fonction. Par exemple, considérez les cas dans lesquels le code contenu dans « d'autres traitements » inclut plusieurs branchements supplémentaires, dont certains finissent par return; ;
- fragile. Voyez le premier inconvénient : avons-nous entré le bon code de libération sur tous les branchements possibles ?
- difficile à lire. Voyez le premier inconvénient : la logique de libération supplémentaire va probablement obscurcir la logique normale de la fonction.
II-B-4. Option 4 : utiliser vector<> au lieu d'un tableau (score : 9,5/10)▲
La plupart des problèmes que nous avons rencontrés sont dus à l'utilisation de tableaux de style C. Si ça convient – et ça convient presque toujours – il vaudrait mieux utiliser un vecteur plutôt qu'un tableau de style C. Après tout, une raison majeure pour laquelle les vecteurs existent dans la bibliothèque standard est de fournir une alternative plus sûre et plus facile à utiliser aux tableaux de style C !
L'idée est la suivante : plutôt que d'écrire :
auto_ptr<
T>
p2( new
T[n] );
on écrit :
vector<
T>
p2( n );
Avantages :
- facile à mettre en œuvre (au départ). On n'a toujours pas besoin d'écrire un auto_array ;
- facile à lire. Les gens qui sont familiers avec les conteneurs standards (et ça devrait être le cas de tout le monde dès maintenant !) comprendront immédiatement ce qui se passe ;
- moins fragile. Puisque nous masquons les détails de gestion de la mémoire, notre code est (généralement) plus simple. Nous n'aurons pas besoin de gérer la mémoire de T objets… c'est le travail de vector<T> ;
- pas de perte significative d'espace ni de temps.
Inconvénients :
- changements syntaxiques. Par la suite, tout code dans f qui utilisera la valeur de p2 avec auto_ptr aura besoin de changements syntaxiques, bien que ces changements seront relativement simples et pas aussi drastiques que ceux exigés par l'option 2 ;
- (parfois) changements d'utilisabilité. Vous ne pouvez pas instancier un conteneur standard (y compris un vecteur) de T si les objets T ne sont pas constructibles par copie et ne sont pas assignables. La plupart des types sont à la fois constructibles par copie et assignables, mais s'ils ne le sont pas, cette solution ne marchera pas.
Notez que passer ou retourner un vecteur par valeur demande beaucoup plus de travail que de passer ou retourner un auto_ptr. Je considère néanmoins cette objection comme une sorte de diversion, puisque que c'est une comparaison injuste… si vous vouliez obtenir le même effet, il vous suffirait de passer un pointeur ou une référence du vecteur.
Conclusion, à ajouter dans les normes de codage de GotW : préférez utiliser vector<> plutôt que des tableaux fondamentaux (de style C).
III. Remerciements▲
Cet article est une traduction en français par l'équipe de la rubrique C++ de l'article de Herb Sutter publié sur Guru of the Week. Vous pouvez retrouver cet article dans sa version originale sur le site de Guru of the Week : Using auto_ptrUsing auto_ptr.
Merci à _Max_ pour sa relecture orthographique.