Lors d'un nouveau travail, j'ai été signalé dans les revues de code pour un code comme celui-ci:
PowerManager::PowerManager(IMsgSender* msgSender)
: msgSender_(msgSender) { }
void PowerManager::SignalShutdown()
{
msgSender_->sendMsg("shutdown()");
}
On me dit que la dernière méthode devrait se lire:
void PowerManager::SignalShutdown()
{
if (msgSender_) {
msgSender_->sendMsg("shutdown()");
}
}
c'est-à-dire que je doit mettre un garde NULL
autour du msgSender_
variable, même s'il s'agit d'un membre de données privées. Il m'est difficile de me retenir d'utiliser des mots explicatifs pour décrire ce que je ressens à propos de ce morceau de "sagesse". Lorsque je demande une explication, j'obtiens une litanie d'histoires d'horreur sur la façon dont un programmeur junior, une année, s'est embrouillé sur la façon dont une classe était censée fonctionner et a accidentellement supprimé un membre qu'il n'aurait pas dû (et l'a défini sur NULL
après, apparemment), et les choses ont explosé sur le terrain juste après la sortie d'un produit, et nous avons "appris à la dure, faites-nous confiance" qu'il vaut mieux juste NULL
vérifier tout.
Pour moi, cela ressemble à programmation culte du fret , clair et simple. Quelques collègues bien intentionnés essaient sérieusement de m'aider à "comprendre" et voir comment cela m'aidera à écrire du code plus robuste, mais ... Je ne peux pas m'empêcher de penser que ce sont eux qui ne l'obtiennent pas .
Est-il raisonnable pour une norme de codage d'exiger que chaque élément le pointeur déréférencé dans une fonction soit d'abord vérifié pour NULL
, même les membres de données privées? (Remarque: pour donner un peu de contexte, nous fabriquons un appareil électronique grand public, pas un système de contrôle de la circulation aérienne ou un autre produit `` panne-égal-personnes-mourir ''.)
[~ # ~] éditez [~ # ~] : dans l'exemple ci-dessus, le msgSender_
le collaborateur n'est pas facultatif. Si c'est jamais NULL
, cela indique un bogue. La seule raison pour laquelle il est transmis au constructeur est que PowerManager
peut être testé avec une sous-classe factice IMsgSender
.
[~ # ~] résumé [~ # ~] : Il y a eu de très bonnes réponses à cette question, merci à tous. J'ai accepté celui de @aaronps principalement en raison de sa brièveté. Il semble y avoir un accord général assez large:
NULL
pour chaque single le pointeur déréférencé est exagéré, maisconst
, etassert
sont une alternative plus éclairée aux gardes NULL
pour vérifier que les conditions préalables d'une fonction sont remplies.Cela dépend du "contrat":
Si PowerManager
DOIT avoir un IMsgSender
valide, ne vérifiez jamais la valeur null, laissez-le mourir plus tôt.
Si d'autre part, il PEUT avoir un IMsgSender
, alors vous devez vérifier chaque fois que vous utilisez, aussi simple que cela.
Dernier commentaire sur l'histoire du programmeur junior, le problème est en fait le manque de procédures de test.
Je pense que le code devrait lire:
PowerManager::PowerManager(IMsgSender* msgSender)
: msgSender_(msgSender)
{
assert(msgSender);
}
void PowerManager::SignalShutdown()
{
assert(msgSender_);
msgSender_->sendMsg("shutdown()");
}
C'est en fait mieux que de garder le NULL, car il est très clair que la fonction ne doit jamais être appelée si msgSender_
Est NULL. Il s'assure également que vous remarquerez si cela se produit.
La "sagesse" partagée de vos collègues ignorera silencieusement cette erreur, avec des résultats imprévisibles.
En général, les bogues sont plus faciles à corriger s'ils sont détectés plus près de leur cause. Dans cet exemple, la garde NULL proposée entraînerait un message d'arrêt ne pas être défini, ce qui peut ou non entraîner un bogue notable. Vous auriez plus de mal à travailler en arrière vers la fonction SignalShutdown
que si l'application entière venait juste de mourir, produisant une trace pratique ou un vidage de mémoire pointant directement vers SignalShutdown()
.
C'est un peu contre-intuitif, mais planter dès que quelque chose ne va pas a tendance à rendre votre code plus robuste. C'est parce que vous trouvez les problèmes, et a également tendance à avoir des causes très évidentes.
Si msgSender ne doit jamais être null
, vous devez placer la vérification null
uniquement dans le constructeur. Cela est également vrai pour toutes les autres entrées dans la classe - placez le contrôle d'intégrité au point d'entrée dans le `` module '' - classe, fonction, etc.
Ma règle d'or consiste à effectuer des vérifications d'intégrité entre les limites du module - la classe dans ce cas. En outre, une classe doit être suffisamment petite pour qu'il soit possible de vérifier rapidement mentalement l'intégrité des durées de vie des membres de la classe - en veillant à éviter des erreurs telles que des suppressions incorrectes/des affectations nulles. La vérification null qui est effectuée dans le code de votre post suppose que toute utilisation non valide affecte réellement null au pointeur - ce qui n'est pas toujours le cas. Étant donné que `` l'utilisation non valide '' implique intrinsèquement que toutes les hypothèses sur le code normal ne s'appliquent pas, nous ne pouvons pas être sûrs de détecter tous les types d'erreurs de pointeur - par exemple une suppression non valide, un incrément, etc.
De plus - si vous êtes certain que l'argument ne peut jamais être nul, pensez à utiliser des références, selon votre utilisation de la classe. Sinon, envisagez d'utiliser std::unique_ptr
ou std::shared_ptr
au lieu d'un pointeur brut.
Non, il n'est pas raisonnable de vérifier chaque et chaque déréférence du pointeur pour le pointeur étant NULL
.
Les vérifications par pointeur nul sont utiles pour les arguments de fonction (y compris les arguments de constructeur) pour garantir que les conditions préalables sont remplies ou pour prendre les mesures appropriées si un paramètre facultatif n'est pas fourni, et elles sont utiles pour vérifier l'invariant d'une classe après avoir exposé les internes de la classe. Mais si la seule raison pour laquelle un pointeur est devenu NULL est l'existence d'un bogue, alors il n'y a aucun intérêt à vérifier. Ce bogue aurait tout aussi facilement pu placer le pointeur sur une autre valeur non valide.
Si j'étais confronté à une situation comme la vôtre, je poserais deux questions:
assert(msgSender_)
au lieu de la vérification nulle? Si vous venez de mettre la vérification nulle, vous pourriez avoir empêché un plantage, mais vous pourriez avoir créé une situation pire parce que le logiciel continue en supposant qu'une opération a eu lieu alors qu'en réalité cette opération a été ignorée. Cela pourrait entraîner l'instabilité d'autres parties du logiciel.Cet exemple semble concerner davantage la durée de vie d'un objet que la valeur nulle ou non d'un paramètre d'entrée †. Puisque vous mentionnez que le PowerManager
doit toujours avoir un IMsgSender
valide, passer l'argument par pointeur (permettant ainsi la possibilité d'un pointeur nul) me semble être une conception défaut††.
Dans des situations comme celle-ci, je préférerais changer l'interface afin que les exigences de l'appelant soient appliquées par la langue:
PowerManager::PowerManager(const IMsgSender& msgSender)
: msgSender_(msgSender) {}
void PowerManager::SignalShutdown() {
msgSender_->sendMsg("shutdown()");
}
La réécriture de cette façon indique que PowerManager
doit contenir une référence à IMsgSender
pendant toute sa durée de vie. Ceci, à son tour, établit également une exigence implicite que IMsgSender
doit vivre plus longtemps que PowerManager
, et annule la nécessité de toute vérification ou assertion de pointeur nul dans PowerManager
.
Vous pouvez également écrire la même chose en utilisant un pointeur intelligent (via boost ou c ++ 11), pour forcer explicitement IMsgSender
à vivre plus longtemps que PowerManager
:
PowerManager::PowerManager(std::shared_ptr<IMsgSender> msgSender)
: msgSender_(msgSender) {}
void PowerManager::SignalShutdown() {
// Here, we own a smart pointer to IMsgSender, so even if the caller
// destroys the original pointer, we still have a valid copy
msgSender_->sendMsg("shutdown()");
}
Cette méthode est préférable s'il est possible que la durée de vie de IMsgSender
ne puisse pas être garantie supérieure à celle de PowerManager
(c'est-à-dire x = new IMsgSender(); p = new PowerManager(*x);
).
† En ce qui concerne les pointeurs: la vérification nulle rampante rend le code plus difficile à lire et n'améliore pas la stabilité (elle améliore la apparence de stabilité, ce qui est bien pire).
Quelque part, quelqu'un a obtenu une adresse de mémoire pour contenir le IMsgSender
. Il incombe à cette fonction de s'assurer que l'allocation a réussi (vérification des valeurs de retour de la bibliothèque ou gestion correcte des exceptions std::bad_alloc
), Afin de ne pas contourner les pointeurs invalides.
Puisque le PowerManager
ne possède pas le IMsgSender
(il ne fait que l'emprunter pendant un certain temps), il n'est pas responsable de l'allocation ou de la destruction de cette mémoire. C'est une autre raison pour laquelle je préfère la référence.
†† Puisque vous êtes nouveau dans ce travail, je m'attends à ce que vous piratiez le code existant. Donc, par défaut de conception, je veux dire que le défaut est dans le code avec lequel vous travaillez. Ainsi, les personnes qui signalent votre code parce qu'il ne vérifie pas les pointeurs nuls se signalent vraiment pour écrire du code qui nécessite des pointeurs :)
Comme les exceptions, les conditions de garde ne sont utiles que si vous savez quoi faire pour récupérer de l'erreur ou si vous souhaitez donner un message d'exception plus significatif.
Avaler une erreur (qu'elle soit détectée comme une exception ou un contrôle de garde), n'est que la bonne chose à faire lorsque l'erreur n'a pas d'importance. L'endroit le plus courant pour moi de voir les erreurs avalées est dans le code de journalisation des erreurs - vous ne voulez pas planter une application parce que vous n'avez pas pu enregistrer un message d'état.
Chaque fois qu'une fonction est appelée, et ce n'est pas un comportement facultatif, elle devrait échouer bruyamment, pas silencieusement.
Edit: en pensant à votre histoire de programmeur junior, il semble que ce qui s'est passé, c'est qu'un membre privé a été défini sur null alors que cela n'était jamais censé se produire. Ils ont eu un problème d'écriture inappropriée et tentent de le corriger en validant à la lecture. C'est à l'envers. Au moment où vous l'identifiez, l'erreur s'est déjà produite. La solution exécutable de révision de code/compilateur pour cela n'est pas des conditions de garde, mais plutôt des getters et des setters ou des membres const.
Comme d'autres l'ont noté, cela dépend de la légitimité de msgSender
NULL
. Ce qui suit suppose qu'il ne doit jamais être NULL.
void PowerManager::SignalShutdown()
{
if (!msgSender_)
{
throw SignalException("Shut down failed because message sender is not set.");
}
msgSender_->sendMsg("shutdown()");
}
Le "correctif" proposé par les autres membres de votre équipe viole le principe Dead Programs Tell No Lies . Les bogues sont vraiment difficiles à trouver tels quels. Une méthode qui modifie silencieusement son comportement sur la base d'un problème antérieur, rend non seulement difficile de trouver le premier bogue, mais ajoute également un deuxième bogue qui lui est propre.
Le junior a fait des ravages en ne vérifiant pas la nullité. Que faire si ce morceau de code fait des ravages en continuant à s'exécuter dans un état indéfini (l'appareil est allumé mais le programme "pense" qu'il est éteint)? Peut-être qu'une autre partie du programme fera quelque chose qui n'est sûr que lorsque l'appareil est éteint.
L'une ou l'autre de ces approches évitera les échecs silencieux:
Utilisez les assertions comme suggéré par cette réponse , mais assurez-vous qu'elles sont activées dans le code de production. Bien sûr, cela pourrait causer des problèmes si d'autres assertions étaient écrites en supposant qu'elles seraient arrêtées en production.
Lance une exception si elle est nulle.
Je suis d'accord pour piéger le null dans le constructeur. De plus, si le membre est déclaré dans l'en-tête comme:
IMsgSender* const msgSender_;
Ensuite, le pointeur ne peut pas être changé après l'initialisation, donc s'il était bien à la construction, il le sera pour la durée de vie de l'objet qui le contient. (L'objet pointé à sera pas sera const.)
C'est carrément Dangereux!
J'ai travaillé sous un développeur senior dans une base de code C avec les "standards" les plus timides qui ont poussé pour la même chose, pour vérifier aveuglément tous les pointeurs pour null. Le développeur finirait par faire des choses comme ça:
// Pre: vertex should never be null.
void transform_vertex(Vertex* vertex, ...)
{
// Inserted by my "wise" co-worker.
if (!vertex)
return;
...
}
J'ai essayé une fois de supprimer une telle vérification de la condition préalable une fois dans une telle fonction et de la remplacer par un assert
pour voir ce qui se passerait.
À ma grande horreur, j'ai trouvé des milliers de lignes de code dans la base de code qui transmettaient des valeurs nulles à cette fonction, mais où les développeurs, probablement confus, ont travaillé et ont simplement ajouté plus de code jusqu'à ce que les choses fonctionnent.
À ma plus grande horreur, j'ai trouvé que ce problème était répandu dans toutes sortes d'endroits dans la base de code pour vérifier les valeurs nulles. La base de code s'était développée au fil des décennies pour s'appuyer sur ces vérifications afin de pouvoir violer silencieusement même les conditions préalables les plus explicitement documentées. En supprimant ces contrôles mortels au profit de asserts
, toutes les erreurs humaines logiques sur des décennies dans la base de code seraient révélées, et nous nous noyerions en elles.
Il n'a fallu que deux lignes de code apparemment innocentes comme ça + du temps et une équipe pour finir par masquer un millier de bogues accumulés.
Ce sont les types de pratiques qui font que les bogues dépendent d'autres bogues pour que le logiciel fonctionne. C'est un scénario de cauchemar . Cela fait également que chaque erreur logique liée à la violation de ces conditions préalables affiche mystérieusement un million de lignes de code loin du site réel dans lequel l'erreur s'est produite, car toutes ces vérifications nulles cachent simplement le bogue et cachent le bogue jusqu'à ce que nous atteignions un endroit qui a oublié pour masquer le bug.
Vérifier simplement aveuglément les valeurs nulles dans tous les endroits où un pointeur nul viole une condition préalable est, pour moi, une folie absolue, à moins que votre logiciel ne soit si critique à la mission contre les échecs d'assertion et les plantages de production que le potentiel de ce scénario est préférable.
Est-il raisonnable pour une norme de codage d'exiger que chaque pointeur unique déréférencé dans une fonction soit d'abord vérifié pour NULL, même les membres de données privées?
Je dirais donc que non. Ce n'est même pas "sûr". Cela peut très bien être le contraire et masquer toutes sortes de bogues dans votre base de code qui, au fil des ans, peuvent conduire aux scénarios les plus horribles.
assert
est le chemin à parcourir ici. Les violations des conditions préalables ne doivent pas passer inaperçues, sinon la loi de Murphy peut facilement entrer en vigueur.
Objective-C , par exemple, traite chaque appel de méthode sur un objet nil
comme un no-op qui évalue à une valeur zéro-ish. Il y a certains avantages à cette décision de conception dans Objective-C, pour les raisons suggérées dans votre question. Le concept théorique de null-guarding chaque appel de méthode a un certain mérite s'il est bien publicisé et appliqué de manière cohérente.
Cela dit, le code vit dans un écosystème, pas dans le vide. Le comportement sans garde serait non idiomatique et surprenant en C++, et devrait donc être considéré comme nuisible. En résumé, personne n'écrit le code C++ de cette façon, donc ne le faites pas ! Cependant, à titre de contre-exemple, notez que l'appel de free()
ou delete
sur un NULL
en C et C++ est garanti d'être un no-op.
Dans votre exemple, il vaudrait probablement la peine de placer une assertion dans le constructeur que msgSender
n'est pas nul. Si le constructeur a appelé une méthode sur msgSender
tout de suite, alors aucune assertion de ce type ne serait nécessaire, car elle se bloquerait de toute façon. Cependant, comme il s'agit simplement storingmsgSender
pour une utilisation future, il ne serait pas évident de regarder une trace de pile de SignalShutdown()
comment la valeur est arrivée be NULL
, donc une assertion dans le constructeur rendrait le débogage beaucoup plus facile.
Encore mieux, le constructeur devrait accepter un const IMsgSender&
référence, qui ne peut pas être NULL
.
L'utilisation d'un pointeur au lieu d'une référence me dirait que msgSender
est seulement une option et ici la vérification nulle serait correcte. L'extrait de code est trop lent pour en décider. Peut-être qu'il y a d'autres éléments dans PowerManager
qui sont précieux (ou testables) ...
Lors du choix entre le pointeur et la référence, j'évalue soigneusement les deux options. Si je dois utiliser un pointeur pour un membre (même pour les membres privés), je dois accepter la procédure if (x)
chaque fois que je le déréférence.
La raison pour laquelle on vous demande d'éviter les déréférences nulles est de vous assurer que votre code est robuste. Il y a très longtemps, des exemples de programmeurs juniors ne sont que des exemples. N'importe qui peut casser le code par accident et provoquer une déréférence nulle - en particulier pour les globaux et les globaux de classe. En C et C++, c'est encore plus possible accidentellement, avec la capacité de gestion directe de la mémoire. Vous pourriez être surpris, mais ce genre de chose se produit très souvent. Même par des développeurs très compétents, très expérimentés et très expérimentés.
Vous n'avez pas besoin de tout annuler, mais vous devez vous protéger contre les déréférences qui ont une probabilité décente d'être nulle. C'est généralement lorsqu'ils sont alloués, utilisés et déréférencés dans différentes fonctions. Il est possible que l'une des autres fonctions soit modifiée et casse votre fonction. Il est également possible que l'une des autres fonctions soit appelée dans le désordre (comme si vous avez un désallocateur qui peut être appelé séparément du destructeur).
Je préfère l'approche de vos collègues en combinaison avec l'utilisation de l'assert. Crash dans l'environnement de test, il est donc plus évident qu'il y a un problème à résoudre et à échouer correctement en production.
Vous devez également utiliser un outil de correction de code robuste comme la couverture ou la fortification. Et vous devriez adresser tous les avertissements du compilateur.
Edit: comme d'autres l'ont mentionné, l'échec silencieux, comme dans plusieurs exemples de code, est généralement la mauvaise chose aussi. Si votre fonction ne peut pas récupérer à partir de la valeur null, elle doit renvoyer une erreur (ou lever une exception) à son appelant. L'appelant est alors responsable de la fixation de son ordre d'appel, de la récupération ou du renvoi d'une erreur (ou du lancement d'une exception) à son appelant, etc. Finalement, soit une fonction est en mesure de récupérer et de passer sans problème, de récupérer et d'échouer sans problème (comme une base de données qui échoue à une transaction en raison d'une erreur interne pour un utilisateur mais qui ne se termine pas de manière réelle), ou la fonction détermine que l'état de l'application est corrompu et irrécupérable et l'application se ferme.
Cela dépend de ce que vous voulez faire.
Vous avez écrit que vous travaillez sur "un appareil électronique grand public". Si, d'une manière ou d'une autre, un bogue est introduit en définissant msgSender_
à NULL
, voulez-vous
SignalShutdown
mais en poursuivant le reste de son fonctionnement, ouSelon l'impact du signal d'arrêt non envoyé, l'option 1 pourrait être un choix viable. Si l'utilisateur peut continuer à écouter sa musique, mais que l'écran affiche toujours le titre de la piste précédente, cela pourrait être préférable à un crash complet de le dispositif.
Bien sûr, si vous choisissez l'option 1, un assert
(tel que recommandé par d'autres) est vital pour réduire la probabilité d'un tel bogue rampant inaperçu pendant le développement. La garde nulle if
est juste là pour atténuer les pannes lors de l'utilisation en production.
Personnellement, je préfère également l'approche "crash early" pour les builds de production, mais je développe des logiciels métier qui peuvent être corrigés et mis à jour facilement en cas de bug. Pour les appareils électroniques grand public, cela pourrait ne pas être aussi simple.