philippe-dunski

philippe-dunski

Développement et qualité


Coder efficacement : Bonnes pratiques et erreurs à éviter en C++

A l'origine, il y a mon expérience d'un forum dédié aux professionnels de l'informatique, sur lequel j'ai l'impression de répéter sans cesse les mêmes choses. Au début, j'ai pensé qu'un ticket de blog qui reprendrait une série de considérations sur lesquelles il se passe rarement plus d'un mois avant que je ne doive y revenir suffirait, mais les deux ou trois pages sont devenue cinq puis dix.


J'ai alors envisagé l'écriture d'un article que j'aurais mis à disposition de la communauté du forum en question. C'est bien, un article d'une dizaine de pages, cela se lit en une grosse demi heure. Mais j'avais tellement de choses à dire que les dix pages sont devenues cinquante, puis cent, pour se transformer en un véritable livre.

Le hasard m'a mis en contact avec les éditions D-booker.fr auquel j'ai proposé le manuscrit. Elles ont été emballées. L'ouvrage que vous avez entre les mains est le résultat de notre collaboration.

Vous pourrez vous le procurer aux édition D-booker.fr et dans toutes les bonnes librairies à partir du 18 février 2014.

 

N'hésitez pas à me faire part de vos avis


13/01/2014
2 Poster un commentaire

La vraie responsabilité des références

Bonjour,

 

Je discutais ces derniers jours sur un forum avec quelqu'un qui s'étonnait de trouver une donnée membre de classe prenant la forme d'une référence (constante ou non, cela ne change rien en l'état)

Autrement dit, il s'étonnait de rencontrer une classe qui prenne la forme de

class MaClass{
public:
    MaClass(A /* const */ & a):a_(a){}
private:
    A /*const*/ & a_;
};

Il est vrai que, dans bien des cas, nous croiserons plus volontiers des pointeurs et sans doute même de préférence des pointeurs intelligents, comme les std::shared_ptr, depuis que C+ 11 est sorti.

En effet, le principal problème des pointeurs nus et des références, c'est que l'on n'a absolument aucun moyen de garantir que l'objet pointé ou référencé sera toujours valide lorsque l'on essayera d'y accéder.

 

Pour bien vous faire comprendre le problème, imaginons une classe proche de

 class Test{
public: 
    Test(A * ptr):ptr_{ptr}{}
    void foo(){
        ptr_->doSomething();
    }
private:
    A * ptr_;
};

Cette classe pose énormément de problèmes, ne serait-ce que parce que l'on n'a aucune garantie que ptr_ corresponde bien à une adresse valide.

Il n'y a en effet rien qui interdise à l'utilisateur de cette classe (en dehors de la documentation de la classe elle-même s'entend) d'avoir un code proche de

int main(){
    Test t(nullptr);
    /* ... */
    return 0;
}

En tant que développeur consciencieux de la classe Test, j'aurais donc du me poser la question de savoir ce que représente l'invalidité de ptr_. Car, de deux choses l'une:

  • Soit, il s'agit d'une erreur de la part de l'utilisateur, et j'aurais du veiller à l'en avertir le plus vite possible au travers d'une assertion
  • Soit, il y a "du sens" à permettre à ptr_ de ne pas exister; par exemple, parce qu'il s'agit d'un élément parent et que j'aurais estimé possible l'idée de ne pas avoir de parent.

Dans le deuxième cas, j'aurais normalement du m'assurer de la validité de ptr_ avant de le référencer.

Je ne sais pas moi, au minimum quelque chose qui aurait été proche de

void Test::foo(){
    if(ptr_){
        ptr->doSomething();
    }
}

L'avantage des std::shared_ptr, c'est que la propriété de l'objet pointé est partagée, si bien que cet objet ne sera pas détruit tant que la "dernière copie" du shared_ptr n'aura pas été détruite.

L'inconvénient, c'est que le comptage de référence qui permet à un std::shared_ptr de remplir son devoir, et même la taille d'un std::shared_ptr ne plaident pas en sa faveur, bien qu'il s'agisse souvent d'optimisation prématurée.

Car, hormis le fait de s'assurer que, s'il existait, l'objet pointé continuera d'exister tant que ce sera nécessaire, intelligent ou non, un pointeur se manipulera toujours de la même manière : avant d'être déréférencé, il doit d'avoir été testé au niveau de sa validité.

Et si, pour une raison ou une autre, nous devions garantir le fait que l'objet pointé par ptr_ existe forcément; qu'il ne s'agit plus d'un membre plus ou moins "facultatif", mais bel et bien d'un membre exigé ?

std::shared_ptr nous apporterait la garantie que s'il existe, l'objet pointé ne sera pas détruit.  Mais...  Cela n'apporte aucune garantie que l'objet ait bel et bien été créé avant que le pointeur ne soit déréférencé.  Car, après tout : il peut valoir nullptr parce que l'objet n'a pas encore été créé, mais il peut aussi valoir nullptr parce que l'on aurait trop trainé avec le weak_ptr avant de partager la propriété de l'objet avec une copie du shared_ptr.

Non, décidément, intelligent ou non, un pointeur ne pourra jamais fournir l'ensemble des garanties d'existence de l'objet.  Tout ce qu'il pourra faire, c'est nous permettre de tester si, à un instant T, l'objet est valide ou non.

Et, je ne sais pas pour vous, mais cela m'ennuierait très fort d'avoir à prendre des précautions pour avoir la certitude que quelque chose "qui est sensé ne pas pouvoir ne pas exister" existe réellement.

Mais, quand est-il des références ?

Bahh, les références apportent une garantie de non nullité : l'objet référencé doit impérativement exister lorsque l'on décide de créer une variable (qu'elle soit membre d'une classe ou non) sous la forme d'une référence.  C'est une obligation à laquelle le compilateur n'acceptera aucune entorse.

 D'un autre coté, mon camarade trouvait -- et il semble difficile de le blâmer -- que cela risque très clairement de poser problème si la durée de vie de l'objet "référencé" s'avérait être plus petite que celle de l'objet "référençant".

Observez, pour l'exemple, un peu le code qui vient :

class Referee{
public:
    void doSomething(){
        /* ... */
    }
}; 
class Referer{
public:
   Referer(Referee & r):r_(r){}
   void foo(){
      r_.doSomething();
   }
private:
   Referee & r_;
};
int main(){
   std::vector referees;
   /* on rempli referees */
   std::vector referers;
   /* on rempli referers, en transmettant les références vers le contenu de referees */
   referees.clear();
   for(auto & r : referers){
      r.foo();
   }
   return 0;
}

Vous l'aurez compris, chaque appel r.foo()referee qui n'existe plus, occasionnant forcément un undefined behaviour (le plus souvent, un "plantage").

J'ai donc dit à mon camarade que, si on veut éviter ce problème, il faut imposer par conception (et par construction du code) que les objets référencés aient une durée de vie supérieure à celle des objets référençants, et que, si cette garantie est donnée, cela ne me gène absolument pas d'avoir une donnée membre qui prenne la forme d'une référence. Ainsi, le même code, à peine modifier pour prendre la forme de

class Referee{
public:
    void doSomething(){
        /* ... */
    }
}; 
class Referer{
public:
   Referer(Referee & r):r_(r){}
   void foo(){
      r_.doSomething();
   }
private:
   Referee & r_;
};
int main(){
   std::vector referees;
   /* on rempli referees */
   std::vector referers;
   /* on rempli referers, en transmettant les références vers le contenu de referees */
   for(auto & r : referers){
      r.foo();
   }
   return 0; //referers est détruit en premier
             // puis, c'est referees...
             // et de toutes façons une fois que l'on n'en a plus besoin
}

Mais vous savez que je suis un véritable fanatique des principes SOLID.  Et cette discussion m'a fait m'interroger sur le SRP (Single Responsability Principle ou principe de la responsabilité unique), surtout en ce qui concerne les références et les shared_ptr (qui sont les deux aspects abordés ici).

Mon camarade estimait au départ que c'est à la référence elle-même de garantir le fait que l'objet référencé reste valide.  Mais comment le pourrait-elle?

Une référence, ce n'est jamais qu'un alias, quelque chose destiné à nous facilité la vie grâce à une garantie qui est donnée lors de sa création.

L'un dans l'autre, le SRP aidant, ne peut donc pas avoir d'autre responsabilité que celle-là et, s'il faut effectivement arriver à garantir que la durée de vie référencé sera plus grande que la référence -- quel que soit l'endroit où elle se trouve -- il est forcément nécessaire de le faire "ailleurs".

D'une certaine manière, c'est la principale raison qui fait que l'on dénigre -- à juste titre -- de plus en plus le principe du singleton : si l'on ne veut pas qu'il puisse exister plus d'une instance d'un objet particulier, il faut d'abord et avant tout s'assurer par construction que ce sera le cas : en s'assurant qu'il n'y aura qu'une seule déclaration de variable de ce type, et -- cela devient si simple depuis C++11 -- en en interdisant la copie et l'affectation.

Ceci dit, alors que j'allais valider ce texte pour la parution, j'ai eu une idée assez saugrenue.  Je viens en effet de dire que la référence n'est en mesure de donner des garanties que lors de sa création.

De leur coté, les shared_ptr ne sont en mesure de fournir des garanties qu'à l'utilisation : l'objet pointé ne sera pas détruit tant qu'il sera (susceptible d'être) utilisé.

L'un dans l'autre, ne serait il pas temps de prévoir quelque chose que nous pourrions désigner sous le terme de shared_reference, qui apporterait la garantie de non nullité que peuvent nous apporter les références et la garantie de non destruction de l'objet en cours d'utilisation (ou susceptible d'être utilisé) que peuvent nous apporter les shared_ptr ?  Bien sur, on a les references_wrapper, mais ce n'est pas pareil!

Cette idée est, vous vous en serez doutés, lancée à cause de la fièvre de la rédaction; et je ne suis peut être pas le premier à l'avoir. Et je n'ai aucune idée de la manière dont nous pourrions mettre cela en place.  Mais bon, si quelqu'un a une idée, je suis preneur ;)


10/09/2015
0 Poster un commentaire