Les directives de base C++ ont la règle ES.20: toujours initialiser un objet .
Évitez les erreurs utilisées avant la définition et leur comportement indéfini associé. Évitez les problèmes de compréhension de l'initialisation complexe. Simplifiez le refactoring.
Mais cette règle n'aide pas à trouver les bugs, elle ne fait que les masquer.
Supposons qu'un programme ait un chemin d'exécution où il utilise une variable non initialisée. C'est un bug. Mis à part un comportement non défini, cela signifie également que quelque chose s'est mal passé et que le programme ne répond probablement pas aux exigences de ses produits. Lorsqu'il sera déployé en production, il peut y avoir une perte d'argent, voire pire.
Comment filtrons-nous les bogues? Nous écrivons des tests. Mais les tests ne couvrent pas 100% des chemins d'exécution, et les tests ne couvrent jamais 100% des entrées de programme. Plus que cela, même un test couvre un chemin d'exécution défectueux - il peut toujours passer. C'est un comportement indéfini après tout, une variable non initialisée peut avoir une valeur quelque peu valide.
Mais en plus de nos tests, nous avons les compilateurs qui peuvent écrire quelque chose comme 0xCDCDCDCD dans des variables non initialisées. Cela améliore légèrement le taux de détection des tests.
Encore mieux - il existe des outils comme Address Sanitizer, qui interceptera toutes les lectures d'octets de mémoire non initialisés.
Et enfin, il y a des analyseurs statiques, qui peuvent regarder le programme et dire qu'il y a une lecture avant définition sur ce chemin d'exécution.
Nous avons donc de nombreux outils puissants, mais si nous initialisons la variable - les désinfectants ne trouvent rien .
int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.
// Another bug: use empty buffer after read error.
use(buffer);
Il existe une autre règle - si l'exécution d'un programme rencontre un bogue, le programme doit mourir dès que possible. Pas besoin de le garder en vie, il suffit de planter, d'écrire un crashdump, de le remettre aux ingénieurs pour enquête.
L'initialisation des variables fait le contraire - le programme est maintenu en vie, alors qu'il aurait déjà un défaut de segmentation sinon.
Votre raisonnement tourne mal sur plusieurs comptes:
bytes_read
a la valeur 10
car il a la valeur 0xcdcdcdcd
.L'idée derrière le guide pour toujours initialiser les variables est de permettre ces deux situations
La variable contient une valeur utile dès le tout début de son existence. Si vous combinez cela avec les instructions pour déclarer une variable uniquement une fois que vous en avez besoin, vous pouvez éviter que les futurs programmeurs de maintenance ne tombent dans le piège de commencer à utiliser une variable entre sa déclaration et la première affectation, où la variable existerait mais ne serait pas initialisée.
La variable contient une valeur définie que vous pouvez tester ultérieurement, pour savoir si une fonction comme my_read
a mis à jour la valeur. Sans initialisation, vous ne pouvez pas dire si bytes_read
a en fait une valeur valide, car vous ne pouvez pas savoir avec quelle valeur il a commencé.
Vous avez écrit "cette règle n'aide pas à trouver les bogues, elle ne fait que les cacher" - eh bien, le but de la règle n'est pas d'aider à trouver les bogues, mais de éviter les. Et lorsqu'un bug est évité, il n'y a rien de caché.
Discutons du problème en fonction de votre exemple: supposons que le my_read
la fonction a le contrat écrit pour initialiser bytes_read
en toutes circonstances, mais ce n'est pas le cas en cas d'erreur, il est donc défectueux, au moins, pour ce cas. Votre intention est d'utiliser l'environnement d'exécution pour afficher ce bogue en n'initialisant pas le bytes_read
paramètre d'abord. Tant que vous savez avec certitude qu'il y a un désinfectant d'adresse en place, c'est en effet un moyen possible de détecter un tel bogue. Pour corriger le bogue, il faut changer le my_read
fonctionne en interne.
Mais il y a un point de vue différent, qui est au moins également valable: le comportement défectueux émerge uniquement de la combinaison de ne pas initialiser bytes_read
au préalable, et appelant my_read
après (dans l'attente bytes_read
est initialisé après cela). Il s'agit d'une situation qui se produit souvent dans les composants du monde réel lorsque la spécification écrite pour une fonction comme my_read
n'est pas clair à 100%, ni même faux sur le comportement en cas d'erreur. Cependant, tant que bytes_read
est initialisé à zéro avant l'appel, le programme se comporte de la même manière que si l'initialisation avait été effectuée à l'intérieur de my_read
, donc il se comporte correctement, dans cette combinaison il n'y a pas de bug dans le programme.
Donc, ma recommandation qui en découle est la suivante: n'utilisez l'approche non initialisante que si
Ce sont des conditions que vous pouvez généralement organiser dans le code de test , pour un environnement d'outillage spécifique.
Dans le code de production, cependant, il vaut mieux toujours initialiser une telle variable à l'avance, c'est l'approche la plus défensive, qui empêche les bugs si le contrat est incomplet ou erroné, ou si le désinfectant d'adresse ou des mesures de sécurité similaires ne sont pas activés. Et la règle "crash-early" s'applique, comme vous l'avez correctement écrit, si l'exécution du programme rencontre un bogue. Mais lorsque l'initialisation d'une variable au préalable signifie qu'il n'y a rien de mal, il n'est pas nécessaire d'arrêter l'exécution.
La différence entre les situations que vous envisagez est que le cas sans initialisation entraîne un comportement indéfini , tandis que le cas où vous avez pris le temps d'initialiser crée un puits bogue défini et déterministe . Je ne peux pas souligner à quel point ces deux cas sont extrêmement différents.
Prenons un exemple hypothétique qui aurait pu arriver à un employé hypothétique dans un programme de simulations hypothétiques. Cette équipe hypothétique essayait hypothétiquement de faire une simulation déterministe pour démontrer que le produit qu'elle vendait hypothétiquement répondait aux besoins.
D'accord, je vais arrêter avec les injections de Word. Je pense que vous obtenez le point; -)
Dans cette simulation, il y avait des centaines de variables non initialisées. Un développeur a exécuté valgrind sur la simulation et a remarqué qu'il y avait plusieurs erreurs de "branchement sur une valeur non initialisée". "Hmm, cela ressemble à cela pourrait provoquer un non-déterminisme, ce qui rend difficile de répéter les tests lorsque nous en avons le plus besoin." Le développeur est allé à la gestion, mais la gestion était sur un calendrier très serré et n'a pas pu épargner des ressources pour dépister ce problème. "Nous finissons par initialiser toutes nos variables avant de les utiliser. Nous avons de bonnes pratiques de codage."
Quelques mois avant la livraison finale, lorsque la simulation est en mode plein taux de désabonnement, et toute l'équipe sprinte pour terminer tout ce que la direction a promis avec un budget qui, comme tout projet jamais financé, était trop petit. Quelqu'un a remarqué qu'il ne pouvait pas tester une fonctionnalité essentielle parce que, pour une raison quelconque, la simulation déterministe ne se comportait pas de manière déterministe pour le débogage.
L'équipe entière a peut-être été arrêtée et a passé la majeure partie de 2 mois à peigner l'intégralité de la base de code de simulation pour corriger les erreurs de valeur non initialisées au lieu d'implémenter et de tester des fonctionnalités. Inutile de dire que l'employé a ignoré le "je vous l'avais dit" et est allé directement à aider les autres développeurs à comprendre ce que sont les valeurs non initialisées. Curieusement, les normes de codage ont été modifiées peu de temps après cet incident, encourageant les développeurs à toujours initialiser leurs variables.
Et ceci est le coup de semonce. C'est la balle qui a frôlé votre nez. Le problème réel est bien loin très loin très loin plus insidieux que vous ne l'imaginez.
L'utilisation d'une valeur non initialisée est un "comportement non défini" (à l'exception de quelques cas d'angle tels que char
). Un comportement indéfini (ou UB pour faire court) est si incroyablement et complètement mauvais pour vous, que vous ne devriez jamais croire qu'il est meilleur que l'alternative. Parfois, vous pouvez identifier que votre compilateur particulier définit l'UB, puis son utilisation en toute sécurité, mais sinon, un comportement indéfini est "tout comportement que le compilateur ressent." Il peut faire quelque chose que vous appelleriez "sain d'esprit" comme avoir une valeur non spécifiée. Il peut émettre des opcodes invalides, provoquant potentiellement la corruption de votre programme. Il peut déclencher un avertissement au moment de la compilation, ou le compilateur peut même le considérer comme une erreur pure et simple.
Ou il peut ne rien faire du tout
Mon canari dans la mine de charbon pour UB est un cas d'un moteur SQL que j'ai lu. Pardonnez-moi de ne pas l'avoir lié, je n'ai pas retrouvé l'article. Il y avait un problème de dépassement de tampon dans le moteur SQL lorsque vous avez passé une taille de tampon plus grande à une fonction, mais uniquement sur une version particulière de Debian. Le bogue obtenu consciencieusement connecté, et exploré. La partie amusante était: le dépassement de tampon a été vérifié . Il y avait du code pour gérer le dépassement de tampon en place. Cela ressemblait à ceci:
// move the pointers properly to copy data into a ring buffer.
char* putIntoRingBuffer(char* begin, char* end, char* get, char*put, char* newData, unsigned int dataLength)
{
// If dataLength is very large, we might overflow the pointer
// arithmetic, and end up with some very small pointer number,
// causing us to fail to realize we were trying to write past the
// end. Check this before we continue
if (put + dataLength < put)
{
RaiseError("Buffer overflow risk detected");
return 0;
}
...
// typical ring-buffer pointer manipulation followed...
}
J'ai ajouté plus de commentaires dans mon interprétation, mais l'idée est la même. Si put + dataLength
s'enroule, il sera plus petit que le pointeur put
(ils ont compilé des vérifications de temps pour s'assurer que int non signé était de la taille d'un pointeur, pour les curieux). Si cela se produit, nous savons que les algorithmes standard de tampon en anneau peuvent être perturbés par ce débordement, nous retournons donc 0. Ou le faisons-nous?
Il s'avère que le débordement sur les pointeurs n'est pas défini en C++. Parce que la plupart des compilateurs traitent les pointeurs comme des entiers, nous nous retrouvons avec des comportements de débordement d'entier typiques, qui se trouvent être le comportement que nous voulons. Cependant, ceci est un comportement non défini, ce qui signifie que le compilateur est autorisé à faire tout ça veut.
Dans le cas de ce bogue, Debian est arrivé à choisir d'utiliser une nouvelle version de gcc qu'aucune des autres versions majeures de Linux n'avait mise à jour dans sa production versions. Cette nouvelle version de gcc avait un optimiseur de code mort plus agressif. Le compilateur a vu le comportement indéfini et a décidé que le résultat de l'instruction if
serait "tout ce qui rend l'optimisation de code optimale", ce qui était une traduction absolument légale d'UB. En conséquence, il a supposé que, depuis ptr+dataLength
ne peut jamais être inférieur à ptr
sans débordement de pointeur UB, l'instruction if
ne déclencherait jamais et optimisait la vérification de dépassement de tampon.
L'utilisation d'UB "saine" a en fait causé à un produit SQL majeur un exploit de dépassement de tampon qu'il avait écrit du code pour éviter!
Ne vous fiez jamais à un comportement non défini. Jamais.
Je travaille principalement dans un langage de programmation fonctionnel où vous n'êtes pas autorisé à réaffecter des variables. Déjà. Cela élimine complètement cette classe de bogues. Cela semblait être une énorme restriction au début, mais cela vous oblige à structurer votre code d'une manière cohérente avec l'ordre dans lequel vous apprenez les nouvelles données, ce qui tend à simplifier votre code et à le rendre plus facile à maintenir.
Ces habitudes peuvent également être transposées dans des langues impératives. Il est presque toujours possible de refactoriser votre code pour éviter d'initialiser une variable avec une valeur fictive. C'est ce que ces directives vous disent de faire. Ils veulent que vous y mettiez quelque chose de significatif, pas quelque chose qui rendra simplement heureux les outils automatisés.
Votre exemple avec une API de style C est un peu plus délicat. Dans ces cas, quand I se la fonction je vais initialiser à zéro pour empêcher le compilateur de se plaindre, mais une fois dans le my_read
tests unitaires, je vais initialiser sur autre chose pour m'assurer que la condition d'erreur fonctionne correctement. Vous n'avez pas besoin de tester toutes les conditions d'erreur possibles à chaque utilisation.
Non, cela ne cache pas les bugs. Au lieu de cela, il rend le comportement déterministe de telle sorte que si un utilisateur rencontre une erreur, un développeur peut la reproduire.
TL; DR: Il y a deux façons de rendre ce programme correct, en initialisant vos variables et en priant. Un seul produit des résultats cohérents.
Avant de pouvoir répondre à votre question, je dois d'abord expliquer ce que Comportement indéfini signifie. En fait, je vais laisser un auteur de compilateur faire l'essentiel du travail:
Si vous ne souhaitez pas lire ces articles, un TL; DR est:
Comportement indéfini est un contrat social entre le développeur et le compilateur; le compilateur suppose avec une foi aveugle que son utilisateur ne s'appuiera jamais sur un comportement indéfini.
L'archétype des "démons volant de votre nez" n'a absolument pas réussi à transmettre les implications de ce fait, malheureusement. Bien que destiné à prouver que quelque chose pouvait arriver, c'était tellement incroyable qu'il était pour la plupart ignoré.
La vérité, cependant, est que Comportement indéfini affecte la compilation elle-même, bien avant que vous n'essayiez même d'utiliser le programme (instrumenté ou non, dans un débogueur ou non) et peut complètement changer son comportement.
Je trouve l'exemple de la partie 2 ci-dessus frappant:
void contains_null_check(int *P) { int dead = *P; if (P == 0) return; *P = 4; }
se transforme en:
void contains_null_check(int *P) { *P = 4; }
car il est évident que P
ne peut pas être 0
car il est déréférencé avant d'être vérifié.
Comment cela s'applique-t-il à votre exemple?
int bytes_read = 0; my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*); // bytes_read is not changed on read error. // It's a bug of "my_read", but detection is suppressed by initialization. buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.
Eh bien, vous avez commis l'erreur courante de supposer que Comportement indéfini provoquerait une erreur d'exécution. Ce n'est peut-être pas le cas.
Imaginons que la définition de my_read
est:
err_t my_read(buffer_t buffer, int* bytes_read) {
err_t result = {};
int blocks_read = 0;
if (!(result = low_level_read(buffer, &blocks_read))) { return result; }
*bytes_read = blocks_read * BLOCK_SIZE;
return result;
}
et procéder comme prévu d'un bon compilateur avec inline:
int bytes_read; // UNINITIALIZED
// start inlining my_read
err_t result = {};
int blocks_read = 0;
if (!(result = low_level_read(buffer, &blocks_read))) {
// nothing
} else {
bytes_read = blocks_reads * BLOCK_SIZE;
}
// end of inlining my_read
buffer.shrink(bytes_read);
Ensuite, comme attendu d'un bon compilateur, nous optimisons les branches inutiles:
bytes_read
serait utilisé non initialisé si result
n'était pas 0
result
ne sera jamais 0
!Donc result
n'est jamais 0
:
int bytes_read; // UNINITIALIZED
err_t result = {};
int blocks_read = 0;
result = low_level_read(buffer, &blocks_read);
bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);
Oh, result
n'est jamais utilisé:
int bytes_read; // UNINITIALIZED
int blocks_read = 0;
low_level_read(buffer, &blocks_read);
bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);
Oh, nous pouvons reporter la déclaration de bytes_read
:
int blocks_read = 0;
low_level_read(buffer, &blocks_read);
int bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);
Et nous y voilà, une transformation strictement confirmatrice de l'original, et aucun débogueur ne piégera une variable non initialisée car il n'y en a pas.
J'ai été sur cette voie, comprendre le problème lorsque le comportement attendu et l'assemblage ne correspondent pas n'est vraiment pas amusant.
Examinons de plus près votre exemple de code:
int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.
// Another bug: use empty buffer after read error.
use(buffer);
C'est un bon exemple. Si nous anticipons une erreur comme celle-ci, nous pouvons insérer la ligne assert(bytes_read > 0);
et intercepter ce bogue lors de l'exécution, ce qui n'est pas possible avec une variable non initialisée.
Mais supposons que non, et nous trouvons une erreur dans la fonction use(buffer)
. Nous chargeons le programme dans le débogueur, vérifions la trace et découvrons qu'il a été appelé à partir de ce code. Nous avons donc placé un point d'arrêt en haut de cet extrait, réexécutons et reproduisons le bogue. Nous essayons de l'attraper en une seule étape.
Si nous n'avons pas initialisé bytes_read
, Il contient des ordures. Il ne contient pas nécessairement les mêmes ordures à chaque fois. Nous dépassons la ligne my_read(buffer, &bytes_read);
. Maintenant, s'il s'agit d'une valeur différente qu'auparavant, nous ne pourrons peut-être pas du tout reproduire notre bogue! Cela pourrait fonctionner la prochaine fois, sur la même entrée, par accident complet. S'il est systématiquement nul, nous obtenons un comportement cohérent.
Nous vérifions la valeur, peut-être même sur une trace dans le même passage. Si c'est zéro, nous pouvons voir que quelque chose ne va pas; bytes_read
Ne doit pas être nul en cas de succès. (Ou si c'est possible, nous pourrions vouloir l'initialiser à -1.) Nous pouvons probablement attraper le bogue ici. Si bytes_read
Est une valeur plausible, cependant, il se trouve que c'est faux, le repérerions-nous en un coup d'œil?
Cela est particulièrement vrai pour les pointeurs: un pointeur NULL sera toujours évident dans un débogueur, peut être testé très facilement et devrait segfault sur du matériel moderne si nous essayons de le déréférencer. Un pointeur d'ordures peut provoquer des bogues de corruption de mémoire non reproductibles plus tard, et ceux-ci sont presque impossibles à déboguer.
L'OP ne repose pas sur un comportement indéfini, ou du moins pas exactement. En effet, s'appuyer sur un comportement non défini est mauvais. Dans le même temps, le comportement d'un programme dans un cas inattendu est également indéfini, mais un autre type d'indéfini. Si vous définissez une variable sur zéro, mais que vous n'aviez pas l'intention d'avoir un chemin d'exécution qui utilise ce zéro initial, votre programme se comportera-t-il sainement lorsque vous aurez un bogue et do aura-t-il un tel chemin? Vous êtes maintenant dans les mauvaises herbes; vous n'aviez pas prévu d'utiliser cette valeur, mais vous l'utilisez quand même. Peut-être que cela sera inoffensif, ou que cela provoquera le plantage du programme, ou peut-être que le programme corrompra silencieusement les données. Tu ne sais pas.
Ce que l'OP dit, c'est qu'il existe des outils qui vous aideront à trouver ce bogue, si vous les laissez. Si vous n'initialisez pas la valeur, mais que vous l'utilisez quand même, il existe des analyseurs statiques et dynamiques qui vous indiqueront que vous avez un bogue. Un analyseur statique vous le dira avant même de commencer à tester le programme. Si, d'autre part, vous initialisez aveuglément la valeur, les analyseurs ne peuvent pas dire que vous n'aviez pas prévu d'utiliser cette valeur initiale, et donc votre bogue n'est pas détecté. Si vous êtes chanceux, il est inoffensif ou bloque simplement le programme; si vous n'avez pas de chance, cela corrompt silencieusement les données.
Le seul endroit où je ne suis pas d'accord avec l'OP est à la toute fin, où il dit "alors qu'il aurait déjà un défaut de segmentation autrement". En effet, une variable non initialisée ne donnera pas de manière fiable un défaut de segmentation. Au lieu de cela, je dirais que vous devriez utiliser des outils d'analyse statique qui ne vous permettront pas même d'essayer d'exécuter le programme.
Une réponse à votre question doit être décomposée en différents types de variables qui apparaissent dans un programme:
Variables locales
Habituellement, la déclaration doit être juste à l'endroit où la variable obtient d'abord sa valeur. Ne prédéclarez pas les variables comme dans l'ancien style C:
//Bad: predeclared variables
int foo = 0;
double bar = 0.0;
long* baz = NULL;
bar = getBar();
foo = (int)bar;
baz = malloc(foo);
//Correct: declaration and initialization at the same place
double bar = getBar();
int foo = (int)bar;
long* baz = malloc(foo);
Cela supprime 99% du besoin d'initialisation, les variables ont leur valeur finale dès le départ. Les quelques exceptions sont où l'initialisation dépend d'une condition:
Base* ptr;
if(foo()) {
ptr = new Derived1();
} else {
ptr = new Derived2();
}
Je pense que c'est une bonne idée d'écrire ces cas comme ceci:
Base* ptr = nullptr;
if(foo()) {
ptr = new Derived1();
} else {
ptr = new Derived2();
}
assert(ptr);
C'est à dire. affirme explicitement qu'une certaine initialisation sensible de votre variable est effectuée.
Variables membres
Ici, je suis d'accord avec ce que les autres répondeurs ont dit: Ceux-ci devraient toujours être initialisés par les listes constructeurs/initialiseurs. Sinon, vous avez du mal à assurer la cohérence entre vos membres. Et si vous avez un ensemble de membres qui ne semble pas avoir besoin d'initialisation dans tous les cas, refactorisez votre classe, en ajoutant ces membres dans une classe dérivée où ils sont toujours nécessaires.
Tampons
C'est là que je suis en désaccord avec les autres réponses. Lorsque les gens deviennent religieux à propos de l'initialisation des variables, ils finissent souvent par initialiser des tampons comme celui-ci:
char buffer[30];
memset(buffer, 0, sizeof(buffer));
char* buffer2 = calloc(30);
Je pense que cela est presque toujours nocif: le seul effet de ces initialisations est qu'elles rendent les outils comme valgrind
impuissants. Tout code qui lit plus de tampons initialisés qu'il ne le devrait est très probablement un bogue. Mais avec l'initialisation, ce bogue ne peut pas être exposé par valgrind
. Donc, ne les utilisez pas sauf si vous comptez vraiment sur la mémoire remplie de zéros (et dans ce cas, déposez un commentaire indiquant à quoi servent les zéros).
Je recommanderais également fortement d'ajouter une cible à votre système de génération qui exécute la suite de tests entière sous valgrind
ou un outil similaire pour exposer les bogues d'utilisation avant l'initialisation et les fuites de mémoire. Ceci est plus précieux que toutes les préinitialisations de variables. Cette cible valgrind
doit être exécutée régulièrement, surtout avant que tout code ne devienne public.
Variables globales
Vous ne pouvez pas avoir de variables globales qui ne sont pas initialisées (au moins en C/C++, etc.), alors assurez-vous que cette initialisation est ce que vous voulez.