En pratique avec C++, qu'est-ce que RAII , qu'est-ce que pointeurs intelligents , comment sont-ils implémentés dans un programme et quels sont les avantages d'utiliser RAII avec des pointeurs intelligents?
Un exemple simple (et peut-être surutilisé) de RAII est une classe File. Sans RAII, le code pourrait ressembler à ceci:
File file("/path/to/file");
// Do stuff with file
file.close();
En d'autres termes, nous devons nous assurer de fermer le fichier une fois que nous en avons terminé. Cela a deux inconvénients - premièrement, chaque fois que nous utilisons File, nous devrons appeler File :: close () - si nous oublions de le faire, nous conservons le fichier plus longtemps que nécessaire. Le deuxième problème est que si une exception est levée avant de fermer le fichier?
Java résout le deuxième problème en utilisant une clause finally:
try {
File file = new File("/path/to/file");
// Do stuff with file
} finally {
file.close();
}
ou depuis Java 7, une déclaration try-with-resource:
try (File file = new File("/path/to/file")) {
// Do stuff with file
}
C++ résout les deux problèmes en utilisant RAII - c’est-à-dire la fermeture du fichier dans le destructeur de File. Tant que l'objet File est détruit au bon moment (comme il se doit de toute façon), la fermeture du fichier est prise en charge pour nous. Donc, notre code ressemble maintenant à quelque chose comme:
File file("/path/to/file");
// Do stuff with file
// No need to close it - destructor will do that for us
Cela ne peut pas être fait dans Java car il n'y a aucune garantie quant au moment où l'objet sera détruit, nous ne pouvons donc pas garantir le moment où une ressource telle qu'un fichier sera libérée.
Sur les pointeurs intelligents - la plupart du temps, nous créons simplement des objets sur la pile. Par exemple (et voler un exemple d'une autre réponse):
void foo() {
std::string str;
// Do cool things to or using str
}
Cela fonctionne bien - mais si nous voulons retourner str? Nous pourrions écrire ceci:
std::string foo() {
std::string str;
// Do cool things to or using str
return str;
}
Alors, qu'est-ce qui ne va pas avec ça? Eh bien, le type de retour est std :: string - cela signifie donc que nous retournons par valeur. Cela signifie que nous copions str et renvoyons la copie. Cela peut être coûteux et nous voudrions peut-être éviter le coût de la copie. Par conséquent, nous pourrions avoir l’idée de revenir par référence ou par pointeur.
std::string* foo() {
std::string str;
// Do cool things to or using str
return &str;
}
Malheureusement, ce code ne fonctionne pas. Nous retournons un pointeur sur str - mais str a été créé sur la pile, nous serons donc supprimés une fois que nous aurons quitté foo (). En d'autres termes, au moment où l'appelant reçoit le pointeur, c'est inutile (et sans doute pire qu'inutile, son utilisation pouvant causer toutes sortes d'erreurs géniales).
Alors, quelle est la solution? Nous pourrions créer str sur le tas en utilisant new - ainsi, lorsque foo () sera terminé, str ne sera pas détruit.
std::string* foo() {
std::string* str = new std::string();
// Do cool things to or using str
return str;
}
Bien sûr, cette solution n'est pas parfaite non plus. La raison en est que nous avons créé str, mais nous ne le supprimons jamais. Ce n'est peut-être pas un problème dans un très petit programme, mais en général, nous voulons nous assurer de le supprimer. Nous pourrions simplement dire que l'appelant doit supprimer l'objet une fois qu'il a terminé. L’inconvénient est que l’appelant doit gérer la mémoire, ce qui ajoute une complexité supplémentaire et risque de se tromper, ce qui entraînerait une fuite de mémoire, c’est-à-dire qu’il ne faut pas supprimer un objet même s’il n’est plus nécessaire.
C’est là que les pointeurs intelligents entrent en jeu. L’exemple suivant utilise shared_ptr - je vous suggère d’examiner les différents types de pointeurs intelligents pour savoir ce que vous souhaitez réellement utiliser.
shared_ptr<std::string> foo() {
shared_ptr<std::string> str = new std::string();
// Do cool things to or using str
return str;
}
Maintenant, shared_ptr comptera le nombre de références à str. Par exemple
shared_ptr<std::string> str = foo();
shared_ptr<std::string> str2 = str;
Maintenant, il y a deux références à la même chaîne. Lorsqu'il ne reste plus aucune référence à str, celle-ci sera supprimée. En tant que tel, vous n'avez plus à vous soucier de le supprimer vous-même.
Edition rapide: comme certains commentaires l’ont souligné, cet exemple n’est pas parfait pour (au moins!) Deux raisons. Tout d'abord, en raison de la mise en œuvre de chaînes, la copie d'une chaîne a tendance à être peu coûteuse. Deuxièmement, en raison de ce que l’on appelle l’optimisation des valeurs de retour nommées, le retour par valeur peut ne pas être coûteux, car le compilateur peut faire preuve d’une certaine astuce pour accélérer les choses.
Essayons donc un exemple différent en utilisant notre classe File.
Disons que nous voulons utiliser un fichier comme journal. Cela signifie que nous voulons ouvrir notre fichier en mode ajout uniquement:
File file("/path/to/file", File::append);
// The exact semantics of this aren't really important,
// just that we've got a file to be used as a log
Maintenant, définissons notre fichier comme journal pour quelques autres objets:
void setLog(const Foo & foo, const Bar & bar) {
File file("/path/to/file", File::append);
foo.setLogFile(file);
bar.setLogFile(file);
}
Malheureusement, cet exemple se termine terriblement - le fichier sera fermé dès la fin de cette méthode, ce qui signifie que foo et bar ont maintenant un fichier journal non valide. Nous pourrions construire un fichier sur le tas et passer un pointeur à fichier à foo et à bar:
void setLog(const Foo & foo, const Bar & bar) {
File* file = new File("/path/to/file", File::append);
foo.setLogFile(file);
bar.setLogFile(file);
}
Mais alors qui est responsable de la suppression du fichier? Si aucun fichier n'est supprimé, nous avons une fuite de mémoire et de ressources. Nous ne savons pas si foo ou bar finira d’abord avec le fichier, nous ne pouvons donc pas espérer supprimer le fichier eux-mêmes. Par exemple, si foo supprime le fichier avant que barre ne soit terminé, barre a maintenant un pointeur invalide.
Ainsi, comme vous l'avez peut-être deviné, nous pourrions utiliser des indicateurs intelligents pour nous aider.
void setLog(const Foo & foo, const Bar & bar) {
shared_ptr<File> file = new File("/path/to/file", File::append);
foo.setLogFile(file);
bar.setLogFile(file);
}
Désormais, plus personne ne doit s’inquiéter de la suppression d’un fichier - une fois que foo et bar sont terminés et n’ont plus aucune référence au fichier (probablement parce que foo et bar ont été détruits), le fichier est automatiquement supprimé.
RAII est le paradigme de la conception permettant de s'assurer que les variables gèrent toutes les initialisations nécessaires dans leurs constructeurs et tous les nettoyages nécessaires dans leurs destructeurs. Cela réduit toutes les initialisations et les nettoyages à une seule étape.
C++ ne nécessite pas RAII, mais il est de plus en plus admis que l’utilisation de méthodes RAII produira un code plus robuste.
La raison pour laquelle RAII est utile en C++ est que C++ gère intrinsèquement la création et la destruction des variables à leur entrée et à leur sortie de la portée, que ce soit par le biais du flux de code normal ou du déroulement de la pile déclenché par une exception. C'est un billet de faveur en C++.
En liant toute l'initialisation et le nettoyage à ces mécanismes, vous êtes assuré que C++ s'occupera également de ce travail pour vous.
Parler de RAII en C++ conduit généralement à la discussion de pointeurs intelligents, car les pointeurs sont particulièrement fragiles en termes de nettoyage. Lors de la gestion de la mémoire allouée au tas acquise auprès de malloc ou new, il est généralement de la responsabilité du programmeur de libérer ou de supprimer cette mémoire avant que le pointeur ne soit détruit. Les pointeurs intelligents utiliseront la philosophie RAII pour s'assurer que les objets alloués au tas sont détruits chaque fois que la variable de pointeur est détruite.
Le pointeur intelligent est une variante de RAII. RAII signifie que l'acquisition des ressources est une initialisation. Le pointeur intelligent acquiert une ressource (mémoire) avant utilisation, puis la jette automatiquement dans un destructeur. Deux choses se passent:
Par exemple, un autre exemple est la prise réseau RAII. Dans ce cas:
Comme vous pouvez le constater, la RAII est un outil très utile dans la plupart des cas, car elle aide les gens à s'envoyer en l'air.
Les sources C++ de pointeurs intelligents se chiffrent en millions sur le net, y compris les réponses au-dessus de moi.
Boost en contient un certain nombre, y compris ceux de Boost.Interprocess pour la mémoire partagée. Cela simplifie grandement la gestion de la mémoire, en particulier dans les situations qui provoquent des maux de tête, comme lorsque vous avez 5 processus partageant la même structure de données: lorsque tout le monde a terminé avec un bloc de mémoire, vous voulez qu'il soit libéré automatiquement et qu'il ne soit pas nécessaire de rester assis à essayer de comprendre. qui devrait être responsable d'appeler delete
sur un bloc de mémoire, de peur que vous ne vous retrouviez avec une fuite de mémoire ou un pointeur qui a été libéré deux fois par erreur et risque de corrompre tout le tas.
void foo () { std :: string bar; // // plus de code ici // }
Quoi qu'il arrive, la barre sera supprimée correctement une fois que la portée de la fonction foo () aura été laissée.
En interne, les implémentations de std :: string utilisent souvent des pointeurs comptés en référence. Ainsi, la chaîne interne n'a besoin d'être copiée que lorsque l'une des copies des chaînes a été modifiée. Par conséquent, un pointeur intelligent compté de références permet de copier quelque chose uniquement lorsque cela est nécessaire.
De plus, le comptage de références internes permet que la mémoire soit correctement supprimée lorsque la copie de la chaîne interne n'est plus nécessaire.