Questions diverses sur un projet

a marqué ce sujet comme résolu.

@Saroupille: si j'ai donné le lien vers les confs de CppCon, c'est que j'ai déjà vu les vidéos. Sutter est spécialiste de la prog concurrente, je n'ai pas de doute qu'il connait très bien les problématiques liées à l'utilisation des unique_ptr dans ce contexte.

Il est difficile d'avoir un consensus sur le pointeur intelligent "par défaut" (pour autant que cela ait un sens). Les shared sont plus safe en multi thread (la durée de vie, pas l'accès concurrent…), mais plus lourds que les unique (compteur interne inutile si tu as qu'un seul responsable). De plus, on a plus tendance à ne pas définir correctement qui est propriétaire et quelle est la durée de vie des objets managés avec les shared qu'avec les unique (phénomène de "tout le monde est propriétaire", qui mène à avoir aucun vrai propriétaire)

(HS : je crois que cette idée vient du fait que l'on présente souvent les weak comme solution aux références circulaires, alors qu'a mon sens, les weak doivent être utilisés aussi pour les observateurs sur une ressource. Donc Avec unique et shared, on ne doit avoir qu'un seul propriétaire par défaut)

Par défaut, je recommande le shared, pour le côté safe, mais avec qu'un seul propriétaire (donc qu'un seul shared sur une ressource, les autres utilisateurs de la ressources utilisent weak)

Cf mon article pour plus de détails

+0 -0

Regarder toutes les videos demandent un temps considérable. La référence que je faisais était par rapport aux raw pointers. Il donne clairement une règle sur comment les utiliser.

Merci pour les informations complémentaires, et oui j'ai lu ton article. Mais je crois que ce qui me pose problème c'est la notion de propriétaire. Comment on définit le propriétaire d'un pointeur, ça me parait flou mais peut-être que je manque de pratique.

Le propriétaire, c'est celui qui est chargé de libérer la mémoire. Dans un programme C, généralement ça se passe comme ça :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    void* creerRessource(); // Fonction chargée d'allouer de la mémoire.

    // Le code "client"
    maRessource = creerRessource();

    ... // Utilisation de maRessource

    // Libération de maRessource
    // deux possibilitées

    // La première
    free(maRessource);

    // La deuxième
    libererRessource(maRessource); // Une fonction définis par la bibliothèque que tu utilises

Dans ce cas, le propriétaire de maRessource, c'est plus ou moins toi. Tu es chargé, par toi même de libérer la mémoire. Si tu n'appelles pas explicitement free ou libererRessource, personne ne le fera à ta place.

En C++98 c'est plus ou moins la même approche, si tu alloues de la mémoire via new, tu vas devoir la libérer via delete (ou new[] et delete[]).

Avec C++11, c'est mieux. La fonction creerRessource du dessus aura une autre signature :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
std::unique_ptr<ptrSurRessource> creerRessource();

// Et le code client

{
   auto maRessource(creerRessource());

   ...
} // On sort de la portée de maRessource, le destructeur de unique_ptr est appelé
  // et la mémoire est libérée

Il y aura toujours une allocation dynamique (un appel à new), donc il faudra toujours libérer la mémoire. Mais dans ce cas, c'est unique_ptr qui est le propriétaire, c'est à dire que c'est lui même qui va, à sa destruction, libérer la mémoire. Le cas du unique_ptr est simple, comme son nom l'indique, il y a un unique propriétaire de la ressource, du coup il n'y a qu'un seul gars qui à la charge de libérer la mémoire.

Le cas de shared_ptr est plus complexe, puisque il y "plusieurs propriétaires".

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  auto p1(make_shared(Type)); // Allocation d'un objet de type Type

  {
    auto p2(p1); // Constructeur de copie de shared_ptr
                 // Il y a maintenant deux propriétaires

   ... // du code
  } // Destruction de p2. Il n'y a plus qu'un propriétaire, donc la mémoire n'est pas libérée

} // Destruction de p1, il n'y a plus de propriétaire, et la mémoire est libérée

C'est à peu près ça le principe. Le propriétaire, c'est celui à qui appartiens la mémoire, et c'est donc le seule à devoir la libérer. Quand tu créer un pointeur nu, il n'y a pas de notion de propriétaire. Dans un unique_ptr c'est ce dernier qui libère la mémoire (pas d'appel à delete directement par l'utilisateur). Dans un shared_ptr, idem.

J'espère que c'est suffisamment clair :S

Le code pour libérer la mémoire se fait toujours dans une fonction, mais celle ci est appelée par le destructeur du propriétaire de la ressource (ou du dernier propriétaire), qui est appelé automatiquement. Donc c'est transparent pour l'utilisateur.

Je suis d'accord avec la définition ("Le propriétaire, c'est celui qui est chargé de libérer la mémoire"), mais a mon sens, cela s'applique pas forcement a la classe qui appelle le delete, mais a la classe qui va détruire, directement ou indirectement la ressource. Donc avec les pointeurs nus, c'est la classe qui appelle delete. Mais pour les pointeurs intelligents, cela va être la classe qui contient le pointeur vers la ressources. Il y a donc toujours (comprendre "il doit toujours y avoir") un propriétaire.

Il y a pas mal de concepts transverses, ce qui explique qu'il y a souvent des confusions.

  • propriétaire (ownership) : celui qui est charge de détruire la ressources (libérer la mémoire).
  • libération manuelle ou automatique : pointeurs nus vs RAII (pointeurs intelligents, string, vector, etc)
  • durée de vie d'une ressource (quand elle est crée et quand elle est détruite). Remarque : la durée de vie de la ressource n'est pas forcement la même que celle du propriétaire.
  • observateur : ce qui garde un œil sur une ressource (dans le but de l'utiliser a un moment donné)

Le problème est qu'il est assez courant que la propriété et la durée de vie des ressources ne soient pas clairement définie. On peut observer les problèmes suivants (non exhaustive) :

  • lorsque celui qui crée une ressource n'est pas celui qui est charge de la détruire (par exemple avec une factory). On parle de transfert de propriété. La différence entre pointeurs nus et pointeurs intelligents ici est que dans le premier cas, on avait des fuites mémoire (personne n'est propriétaire), alors que dans le second, la ressource n'est pas libérée (trop de propriétaires, qui fait que la ressource est toujours "utilisée")
  • garantir qu'une ressource n'a pas été libérée avant qu'un observateur l'utilise. C'est le rôle de weak_ptr expired/lock, qui va permettre de prendre la responsabilité temporaire de la ressource (pour éviter qu'elle soit détruite pendant qu'elle est utilisée par l'observateur). Par contre, il n'y a pas d’équivalent pour les raw pointeurs. Pour unique_ptr, il y a l’opérateur bool(), mais cela présente un risque en multi-thread (on peut vérifier que la ressource n'a pas été libérer avant de l'utiliser, mais comme on n'a pas de moyen de prendre la responsabilité, on ne peut pas apporter cette garantie pendant toute la durée de l'utilisation).

Donc, que l'on utilise le RAII ou non, il faut clairement définir la propriété et la durée de vie (+ l’accès concurrent selon le contexte, mais c'est pas le boulot des pointeurs intelligents).

+0 -0

J'en profite pour modifier mon code, et évidemment je me pose quelques questions.

En parsant les options, je peux déterminer le choix qui est fait pour stocker les données récupérées avec la classe Storage. Puis la classe Agent utilise l'agent pour pour modifier cette base de données. Cependant, il me semble que Agent n'a pas à prendre le ownership puisqu'il ne fait que l'utiliser (et probablement il va tourner à l'infini vu le programme). Du coup, voici mon code dans le main :

1
2
3
4
5
6
7
8
9
    Options options(argc, argv);

    std::unique_ptr<Storage> storage;
    if(options.storage_mode()== Options::StorageMode::SQLITE)
        storage = std::make_unique<Storage>(StorageDB(options.db_name()));
    else //Not handle yet
        exit(EXIT_FAILURE);

    Agent agent(storage, peer); 

Je pense que c'est bien le main qui a le ownership. Pourtant l'agent a comme attribut une variable de type Storage. Ma question c'est : quel est le type du constructeur et de l'attribut ?

Si je m'en réfère à Herb Sutter, un pointeur nu suffirait. Mais alors selon "the rule of five" je devrais implémenter 5 autres constructeurs/déconstructeurs. Bref, que faire et pourquoi ? Passer tout simplement un unique_ptr sans transférer l'ownership ?

Je pense que c'est bien le main qui a le ownership. Pourtant l'agent a comme attribut une variable de type Storage. Ma question c'est : quel est le type du constructeur et de l'attribut ?

Si je m'en réfère à Herb Sutter, un pointeur nu suffirait.

Saroupille

Et pourquoi pas une référence ? Si la portée de ton Agent est nécessairement plus courte que celle d'un Storage (à moins bien sûr qu'un Agent ait un sens sans Storage), c'est le plus simple et le plus safe.

EDIT : Accessoirement, question à 200 balles. Pourquoi l'agent serait copiable ? Je connais pas l'idée exacte que tu caches derrière mais le nom évoque plus une entité.

Pourquoi pas. Donc une référence sur un unique_ptr . Mais alors dans quels cas s'appliquent le conseil d'Herb Sutter ? Quand la fonction n'est pas une méthode ? Je crois que je me pose trop de questions…

Et je ne comprend pas ta question en fait. L'agent il est copiable avec le constructeur par défaut.

+0 -0

Tout a fait d'accord avec Kass Peuk, si l'on ne veut pas transférer l'ownership passer storage par référence est une bonne idée.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Agent {
Storage& storage;
public:
Agent(Storage& storage, const Peer& peer) : storage(storage) {
 ...
};
// ...
std::unique_ptr<Storage> storage;
// ...
Agent agent(*storage, peer);

Attention cependant, la classe Agent n'est désormais plus assigniable car celle ci à une référence en attribut. Il n'est plus possible de faire ceci :

1
agent2 = agent1; // error: non-static reference member ‘Storage& Agent::storage’, can’t use default assignment operator

Ceci n'est pas nécessairement un problème et si cela a du sens on pourrait rendre la classe Agent non copiable pour enfoncer le clou, exemple :

1
2
3
4
5
6
7
class Agent {
Storage& storage;
public:
Agent(Storage& storage, const Peer& peer) : storage(storage) { ... }
Agent(const Agent&) = delete;
Agent& operator(const Agent&) = delete;
};

Jusqu'ici, seul l'opérateur d'assignation était "implicitement supprimé". Maintenant le constructeur de copie l'est également ce qui est un peu plus consistent : on a désormais une classe clairement non copiable.

Si l'on veut rendre la classe Agent copiable malgré tout (encore une fois si cela a du sens), pour contourner le problème posé par la référence en attribut, on peut stocker un pointeur dans la classe Agent à la place. Exemple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Agent {
Storage* storage; // invariant de la classe storage != nullptr
public:
Agent(Storage& storage, const Peer& peer) : storage(&storage) {
 ...
};
// ...
std::unique_ptr<Storage> storage1, storage2;
// ...
Agent agent1(*storage1, peer);
Agent agent2(*storage2, peer);
agent1 = agent2; // OK, la classe est désormais copiable (les constructeur de copie et opérateur d'affectation sont générés implicitement et tout va pour le mieux).

Maintenant, si jamais on souhaitait transférer l'ownership du main vers l'agent. Si cela a du sens bien entendu, il faudrait alors déplacer l'unique_ptr dans l'agent comme ceci :

1
2
3
4
5
Agent::Agent(std::unique_ptr<Storage> storage, const Peer& peer)
...
Agent agent(std::move(storage), peer);
// Ne plus utiliser storage dans la suite du code sans le réinitialiser avec un autre pointeur.
// Dans le cas particulier d'un unique_ptr, le standard garantie que storage == nullptr après le déplacement.

La question que je me pose maintenant c'est : Pourquoi utiliser un unique_ptr en premier lieu ??? Pourquoi ne pas faire ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Agent {
Storage& storage;
public:
Agent(Storage& storage, const Peer& peer) : storage(storage) { ... }
Agent(const Agent&) = delete;
Agent& operator(const Agent&) = delete;
};
//...
    if(options.storage_mode()== Options::StorageMode::SQLITE) {
        auto storage = StorageDB(options.db_name());
        Agent agent(storage, peer);
    } // storage est libéré ici!
    else //Not handle yet
        exit(EXIT_FAILURE);

L'utilisation de unique_ptr permettrait de factoriser le code si jamais on avait réellement plusieurs types de stockage à sélectionner à partir de la ligne de commande. Ce n'est pas le cas ici et on pourrait entièrement se passer de pointeurs.

Si je m'en réfère à Herb Sutter, un pointeur nu suffirait. Mais alors selon "the rule of five" je devrais implémenter 5 autres constructeurs/déconstructeurs. Bref, que faire et pourquoi ? Passer tout simplement un unique_ptr sans transférer l'ownership ?

J'ai toujours trouvé la règle des 3 et encore d'avantage la règle des 5 particulièrement confusantes. A mon avis il ne faut pas trop focaliser dessus car on en vient à perdre le fil de ce que l'on cherche à faire. Ici, si tu crée un constructeur prenant en paramètre un pointeur nu sur un Storage tu n'écris aucune des 5 fonctions spéciales de ta classe (constructeur/opérateur copie/déplacement, destructeur). Donc la règle des 5 est hors propos :)

Loin de moi l'idée de vouloir ajouter encore plus de confusion mais je trouve le lien suivant intéressant : http://flamingdangerzone.com/cxx11/2012/08/15/rule-of-zero.html

Ce que j'en retiens :

  1. Les règles des 3 et des 5 s'appliquent à des classes qui détiennent une ressource.
  2. Ces règles s'appliquent dés lors que l'on a l'une des 5 fonctions spéciales personnalisée par de développeur de la classe. C'est à dire au moins l'une de ces fonctions différente de l'implémentation par défaut (= default ou {}) ou de la suppression (=delete). Quand on y réfléchi seules les classes qui détiennent une ressources ont intérêt à définir au moins l'une de ces 5 fonction. Dés lors il est nécessaire de définir au minimum 3 de ces 5 fonctions!
  3. On peut très bien ne jamais avoir à se préoccuper de ces règles si l'on délègue la gestion des ressources à des classes expressément prévue pour (unique_ptr ?). On peut alors systématiquement laisser le compilateur générer les fonctions par défaut pour nous. D'où la règle du zéro! :)
+0 -0

Il est difficile d'avoir un consensus sur le pointeur intelligent "par défaut" (pour autant que cela ait un sens)

gbdivers

Il est difficile d'avoir une conception "par défaut". Si on respecte a 100% tous les principes de programmation, on se retrouve vite avec une architecture très complète, mais souvent sur-dimensionnée par rapport au problème (et cela prend du temps a mettre en place).

Du coup, il est facile de voir les problèmes poses par un choix de conception, mais seul le développeur peut savoir quel(s) chemin(s) il pense orienter l'application dans le future.

Pourquoi je dis cela ? Tout simplement parce que ta solution n'est pas plus mauvaise qu'une autre. Tout dépend du future…

  • qui est propriétaire ?

Est-ce que Storage est utilise ailleurs ? Dans plusieurs classes ? Comme singleton ?

Si ce n'est pas le cas, Tu peux peut-être transmettre la responsabilité de Storage a Agent.

1
2
3
Agent::Agent(std::unique_ptr<Storage>, ...)

Agent agent(std::move(storage), ...); 
  • conserver ou non un pointeur ou une référence dans Agent vers storage

Si tu fais cela, si détruis l'objet (parce que le propriétaire pense que plus personne l'utilise ou parce que tu crées un nouvel objet), il faut que l'information soit transmise a Agent. Avec une référence ou un pointeur nu, ça ne sera pas le cas. Avec shared_ptr ou weak_ptr, pas de problème.

Tu peux aussi ne pas stocker Stockage dans Agent. Par exemple avec un singleton (oui, oui, c'est moi qui conseille cela…) ou avec un DP observer (quelque chose de similaire avec les connexions de Qt) :

1
connect(agent, &Agent::somethingToSave, storage, &Storage::functionToSaveSomething);
  • thread safe

Tu peux choisir a terme de séparer agent et storage dans deux threads. Dans ce cas, tu dois garantir que Storage ne soit pas détruit pendant que Agent l'utilise. Et s'il ne l'utilise pas, qu'il puisse vérifier que son pointeur/référence est valide.

Une référence sur un unique_ptr te permettra par exemple de vérifier que le pointer est valide. Par contre, tu ne peux pas le locker. weak et shared te permettent de faire cela.

Tu peux aussi choisir de régler la question avec le design, en garantissant que storage a une durée de vie plus longue que agent. Par exemple en faisant que storage ait la même durée de vie que l'application, ou en utilisant thread.join() pour finir l'agent avant de détruit storage.

  • pleins de chose auxquelles je n'ai pas penser

C'est le plus casse-pied a prendre en compte dans son design :)

HS : pour la règle des 3/5, avec les smarts ptr, elle devient très facilement la règle du 0. Cf "Coder proprement"

+0 -0

Et je ne comprend pas ta question en fait. L'agent il est copiable avec le constructeur par défaut.

Saroupille

Dans la grande majorité des cas, les classes que l'on crée ont une sémantique d'entité, ce qui veut dire qu'elles doivent être non-copiable, non assignable (à la différence des classes à sémantique de valeur). Comme ta classe Agent est (apparemment) une classe polymorphique, elle a nécessairement une sémantique d'entité, tu dois donc en interdire la copie/affectation.

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte