RAII et pointeurs intelligents en C ++

193

En pratique avec C ++, qu'est-ce que RAII , quels sont les pointeurs intelligents , comment sont-ils mis en œuvre dans un programme et quels sont les avantages d'utiliser RAII avec des pointeurs intelligents?

Rob Kam
la source

Réponses:

317

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 que nous fermons le fichier une fois que nous en avons fini avec lui. Cela a deux inconvénients - premièrement, partout où 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 instruction 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 en fermant le fichier dans le destructeur de File. Tant que l'objet File est détruit au bon moment (ce qu'il devrait être quand même), 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 en Java car il n'y a aucune garantie quand l'objet sera détruit, donc nous ne pouvons pas garantir quand 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 que se passe-t-il 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? 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 pourrions vouloir éviter le coût de la copie. Par conséquent, nous pourrions avoir l'idée de retourner 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 renvoyons un pointeur à str - mais str a été créé sur la pile, donc nous sommes supprimés une fois que nous quittons foo (). En d'autres termes, au moment où l'appelant obtient le pointeur, il est inutile (et sans doute pire qu'inutile car son utilisation pourrait provoquer toutes sortes d'erreurs géniales)

Alors, quelle est la solution? Nous pourrions créer str sur le tas en utilisant new - de cette façon, 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 non plus parfaite. 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 en a fini avec lui. L'inconvénient est que l'appelant doit gérer la mémoire, ce qui ajoute une complexité supplémentaire et peut se tromper, conduisant à une fuite de mémoire, c'est-à-dire à la suppression de l'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 de regarder les différents types de pointeurs intelligents pour savoir ce que vous voulez 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. Une fois qu'il n'y aura plus de références à str, il sera supprimé. En tant que tel, vous n'avez plus à vous soucier de le supprimer vous-même.

Édition rapide: comme certains des 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 de la valeur de retour nommée, le retour par valeur peut ne pas être coûteux car le compilateur peut faire preuve d'intelligence pour accélérer les choses.

Essayons donc un autre exemple 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 horriblement - 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 sur file à la fois 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 des deux fichiers n'est supprimé, nous avons à la fois une fuite de mémoire et de ressources. Nous ne savons pas si foo ou bar finira avec le fichier en premier, nous ne pouvons donc pas nous attendre à supprimer le fichier lui-même. Par exemple, si foo supprime le fichier avant que la barre ne soit terminée, la barre a maintenant un pointeur non valide.

Ainsi, comme vous l'avez peut-être deviné, nous pourrions utiliser des pointeurs 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);
}

Maintenant, personne n'a à s'inquiéter de la suppression du fichier - une fois que foo et bar sont terminés et n'ont plus de références au fichier (probablement en raison de la destruction de foo et bar), le fichier sera automatiquement supprimé.

Michael Williamson
la source
7
Il convient de noter que de nombreuses implémentations de chaînes sont implémentées en termes de pointeur compté par référence. Ces sémantiques de copie sur écriture rendent le retour d'une chaîne par valeur très peu coûteux.
7
Même pour ceux qui ne le sont pas, de nombreux compilateurs implémentent l'optimisation NRV qui prendrait en charge les frais généraux. En général, je trouve shared_ptr rarement utile - restez avec RAII et évitez la propriété partagée.
Nemanja Trifunovic
27
renvoyer une chaîne n'est pas vraiment une bonne raison d'utiliser des pointeurs intelligents. L'optimisation de la valeur de retour peut facilement optimiser le retour, et la sémantique de déplacement c ++ 1x éliminera complètement une copie (lorsqu'elle est utilisée correctement). Montrez un exemple du monde réel (par exemple lorsque nous partageons la même ressource) à la place :)
Johannes Schaub - litb
1
Je pense que votre conclusion au début sur la raison pour laquelle Java ne peut pas faire cela manque de clarté. La façon la plus simple de décrire cette limitation en Java ou en C # est qu'il n'y a aucun moyen d'allouer sur la pile. C # permet l'allocation de pile via un mot-clé spécial, mais vous perdez la sécurité de type.
ApplePieIsGood
4
@Nemanja Trifunovic: Par RAII dans ce contexte, vous entendez retourner des copies / créer des objets sur la pile? Cela ne fonctionne pas si vous avez des objets return / accept de types qui peuvent être sous-classés. Ensuite, vous devez utiliser un pointeur pour éviter de trancher l'objet, et je dirais qu'un pointeur intelligent est souvent meilleur qu'un brut dans ces cas.
Frank Osterfeld
141

RAII C'est un nom étrange pour un concept simple mais génial. Mieux vaut le nom Scope Bound Resource Management (SBRM). L'idée est que vous allouez souvent des ressources au début d'un bloc et que vous devez les libérer à la sortie d'un bloc. La sortie du bloc peut se produire par un contrôle de flux normal, en sautant et même par une exception. Pour couvrir tous ces cas, le code devient plus compliqué et redondant.

Juste un exemple de le faire sans SBRM:

void o_really() {
     resource * r = allocate_resource();
     try {
         // something, which could throw. ...
     } catch(...) {
         deallocate_resource(r);
         throw;
     }
     if(...) { return; } // oops, forgot to deallocate
     deallocate_resource(r);
}

Comme vous le voyez, il existe de nombreuses façons de se faire pwned. L'idée est que nous encapsulons la gestion des ressources dans une classe. L'initialisation de son objet acquiert la ressource ("L'acquisition de ressources est une initialisation"). Au moment où nous quittons le bloc (portée du bloc), la ressource est à nouveau libérée.

struct resource_holder {
    resource_holder() {
        r = allocate_resource();
    }
    ~resource_holder() {
        deallocate_resource(r);
    }
    resource * r;
};

void o_really() {
     resource_holder r;
     // something, which could throw. ...
     if(...) { return; }
}

C'est bien si vous avez des classes qui ne sont pas uniquement destinées à allouer / désallouer des ressources. L'allocation ne serait qu'une préoccupation supplémentaire pour faire leur travail. Mais dès que vous souhaitez simplement allouer / désallouer des ressources, ce qui précède devient peu pratique. Vous devez écrire une classe enveloppante pour chaque type de ressource que vous acquérez. Pour faciliter cela, des pointeurs intelligents vous permettent d'automatiser ce processus:

shared_ptr<Entry> create_entry(Parameters p) {
    shared_ptr<Entry> e(Entry::createEntry(p), &Entry::freeEntry);
    return e;
}

Normalement, les pointeurs intelligents sont de minces enveloppes autour de new / delete qui n'apparaissent que deletelorsque la ressource qu'ils possèdent sort du domaine. Certains pointeurs intelligents, comme shared_ptr, vous permettent de leur dire un soi-disant délétère, qui est utilisé à la place de delete. Cela vous permet, par exemple, de gérer les descripteurs de fenêtre, les ressources d'expression régulière et d'autres éléments arbitraires, tant que vous indiquez à shared_ptr le bon suppresseur.

Il existe différents pointeurs intelligents à des fins différentes:

unique_ptr

est un pointeur intelligent qui possède exclusivement un objet. Ce n'est pas en boost, mais il apparaîtra probablement dans la prochaine norme C ++. Il n'est pas copiable mais prend en charge le transfert de propriété . Un exemple de code (C ++ suivant):

Code:

unique_ptr<plot_src> p(new plot_src); // now, p owns
unique_ptr<plot_src> u(move(p)); // now, u owns, p owns nothing.
unique_ptr<plot_src> v(u); // error, trying to copy u

vector<unique_ptr<plot_src>> pv; 
pv.emplace_back(new plot_src); 
pv.emplace_back(new plot_src);

Contrairement à auto_ptr, unique_ptr peut être placé dans un conteneur, car les conteneurs pourront contenir des types non copiables (mais mobiles), comme les flux et unique_ptr également.

scoped_ptr

est un pointeur intelligent boost qui n'est ni copiable ni mobile. C'est la chose parfaite à utiliser lorsque vous voulez vous assurer que les pointeurs sont supprimés lorsque vous sortez du champ d'application.

Code:

void do_something() {
    scoped_ptr<pipe> sp(new pipe);
    // do something here...
} // when going out of scope, sp will delete the pointer automatically. 

shared_ptr

est pour la propriété partagée. Pour cela, il est à la fois copiable et mobile. Plusieurs instances de pointeur intelligent peuvent posséder la même ressource. Dès que le dernier pointeur intelligent possédant la ressource est hors de portée, la ressource sera libérée. Un exemple concret de l'un de mes projets:

Code:

shared_ptr<plot_src> p(new plot_src(&fx));
plot1->add(p)->setColor("#00FF00");
plot2->add(p)->setColor("#FF0000");
// if p now goes out of scope, the src won't be freed, as both plot1 and 
// plot2 both still have references. 

Comme vous le voyez, la source de tracé (fonction fx) est partagée, mais chacune a une entrée distincte, sur laquelle nous définissons la couleur. Il existe une classe faible_ptr qui est utilisée lorsque le code doit faire référence à la ressource appartenant à un pointeur intelligent, mais n'a pas besoin de posséder la ressource. Au lieu de passer un pointeur brut, vous devez ensuite créer un faiblesse_ptr. Il lèvera une exception lorsqu'il remarquera que vous essayez d'accéder à la ressource par un chemin d'accès faible_ptr, même s'il n'y a plus de shared_ptr propriétaire de la ressource.

Johannes Schaub - litb
la source
Autant que je sache, les objets non copiables ne sont pas du tout utiles à utiliser dans les conteneurs stl car ils reposent sur la sémantique des valeurs - que se passe-t-il si vous souhaitez trier ce conteneur? trier copie les éléments ...
fmuecke
Les conteneurs C ++ 0x seront modifiés afin qu'il respecte les types de déplacement uniquement comme unique_ptr, et sortseront également modifiés également.
Johannes Schaub - litb
Vous rappelez-vous où vous avez entendu le terme SBRM pour la première fois? James essaie de le retrouver.
GManNickG
quels en-têtes ou bibliothèques dois-je inclure pour les utiliser? d'autres lectures à ce sujet?
atoMerz
Un conseil ici: s'il y a une réponse à une question C ++ par @litb, c'est la bonne réponse (peu importe les votes ou la réponse signalée comme "correcte") ...
fnl
32

Le principe et les raisons sont simples, dans leur concept.

RAII est le paradigme de conception pour garantir 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 toute l'initialisation et le nettoyage à 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 lorsqu'elles entrent et sortent de la portée, que ce soit par le biais du flux de code normal ou par le déroulement de pile déclenché par une exception. C'est un cadeau en C ++.

En liant toute l'initialisation et le nettoyage à ces mécanismes, vous êtes assuré que C ++ se chargera également de ce travail pour vous.

Parler de RAII en C ++ mène généralement à la discussion des pointeurs intelligents, car les pointeurs sont particulièrement fragiles en ce qui concerne le nettoyage. Lors de la gestion de la mémoire allouée en tas acquise à partir de malloc ou de 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 garantir que les objets alloués en tas sont détruits à chaque fois que la variable de pointeur est détruite.

Drew Dormann
la source
De plus - les pointeurs sont l'application la plus courante de RAII - vous allouerez probablement des milliers de fois plus de pointeurs que toute autre ressource.
Eclipse
8

Le pointeur intelligent est une variante du RAII. RAII signifie que l'acquisition de ressources est l'initialisation. Le pointeur intelligent acquiert une ressource (mémoire) avant utilisation, puis la jette automatiquement dans un destructeur. Deux choses se produisent:

  1. Nous allouons de la mémoire avant de l'utiliser, toujours, même lorsque nous n'en avons pas envie - il est difficile de faire autrement avec un pointeur intelligent. Si cela ne se produit pas, vous tenterez d'accéder à la mémoire NULL, ce qui entraînera un plantage (très douloureux).
  2. Nous libérons de la mémoire même en cas d'erreur. Aucun souvenir n'est suspendu.

Par exemple, un autre exemple est le socket réseau RAII. Dans ce cas:

  1. Nous ouvrons la prise réseau avant de l'utiliser, toujours, même lorsque nous n'avons pas envie - il est difficile de le faire d'une autre manière avec RAII. Si vous essayez de le faire sans RAII, vous pouvez ouvrir un socket vide pour, par exemple, une connexion MSN. Ensuite, un message comme «laisse faire ce soir» pourrait ne pas être transféré, les utilisateurs ne seront pas licenciés et vous pourriez risquer d'être viré.
  2. Nous fermons la prise réseau même en cas d'erreur. Aucune prise n'est laissée suspendue, car cela pourrait empêcher le message de réponse "sûr d'être en bas" de renvoyer l'expéditeur.

Maintenant, comme vous pouvez le voir, RAII est un outil très utile dans la plupart des cas car il aide les gens à se faire baiser.

Les sources C ++ de pointeurs intelligents sont des millions sur le net, y compris les réponses au-dessus de moi.

mannicken
la source
2

Boost en possède un certain nombre, y compris ceux de Boost.Interprocess pour la mémoire partagée. Il simplifie considérablement la gestion de la mémoire, en particulier dans les situations provoquant des maux de tête comme lorsque 5 processus partagent la même structure de données: lorsque tout le monde a fini avec un morceau de mémoire, vous voulez qu'il soit automatiquement libéré et ne pas avoir à rester là à essayer de comprendre qui devrait être responsable de l'appel deleteà un morceau de mémoire, de peur de vous retrouver avec une fuite de mémoire, ou un pointeur qui est libéré par erreur deux fois et peut corrompre le tas entier.

Jason S
la source
0
void foo ()
{
   std :: string bar;
   //
   // plus de code ici
   //
}

Quoi qu'il arrive, la barre sera correctement supprimée une fois que la portée de la fonction foo () aura été abandonnée.

En interne, les implémentations std :: string utilisent souvent des pointeurs comptés par référence. La chaîne interne ne doit donc être copiée que lorsque l'une des copies des chaînes a changé. Par conséquent, un pointeur intelligent compté par référence permet de copier uniquement quelque chose lorsque cela est nécessaire.

De plus, le comptage des 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.


la source
1
void f () {Obj x; } Obj x est supprimé au moyen de la création / destruction de trames de pile (déroulement) ... ce n'est pas lié au comptage des références.
Hernán
Le comptage des références est une caractéristique de l'implémentation interne de la chaîne. RAII est le concept derrière la suppression d'objet lorsque l'objet sort du domaine. La question portait sur RAII et également sur les pointeurs intelligents.
1
"Peu importe ce qui se passe" - que se passe-t-il si une exception est levée avant le retour de la fonction?
titaniumdecoy
Quelle fonction est renvoyée? Si une exception est levée dans foo, la barre est supprimée. Le constructeur par défaut de barre lançant une exception serait un événement extraordinaire.