«Toujours initialiser les variables» ne permet-il pas de cacher des bugs importants?

35

Les règles de base C ++ ont la règle ES.20: toujours initialiser un objet .

Évitez les erreurs used-before-set et leur comportement indéfini associé. Évitez les problèmes de compréhension de l'initialisation complexe. Simplifier le refactoring.

Mais cette règle ne permet pas de trouver des bugs, elle ne fait que les cacher.
Supposons qu'un programme ait un chemin d'exécution dans lequel il utilise une variable non initialisée. C'est un bug. Le comportement non défini mis à part, cela signifie également que quelque chose s'est mal passé et que le programme ne répond probablement pas aux exigences de son produit. Lorsqu'il sera déployé en production, il peut y avoir une perte d'argent, voire pire.

Comment pouvons-nous filtrer les bugs? Nous écrivons des tests. Cependant, les tests ne couvrent pas 100% des chemins d'exécution et ne couvrent jamais 100% des entrées du programme. Plus que cela, même un test couvre un chemin d’exécution défectueux - il peut toujours réussir. Après tout, c'est un comportement indéfini, 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 0xCDCDCD dans des variables non initialisées. Cela améliore légèrement le taux de détection des tests.
Mieux encore, il existe des outils comme Address Sanitizer, qui capturera toutes les lectures d'octets mémoire non initialisés.

Enfin, il existe des analyseurs statiques, qui peuvent consulter le programme et indiquer qu’il existe une lecture avant définition sur ce chemin d’exécution.

Nous disposons donc de nombreux outils puissants, mais si nous initialisons la variable, les assainissants 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, il doit disparaître le plus tôt possible. Inutile de le garder en vie, écrasez-le, envoyez-le aux ingénieurs pour enquête.
L'initialisation de variables inutilement a l'effet inverse - le programme est maintenu en vie, alors qu'il aurait déjà une erreur de segmentation autrement.

Abyx
la source
10
Bien que je pense que c'est une bonne question, je ne comprends pas votre exemple. Si une erreur de lecture se produit et bytes_readn’est pas modifiée (donc maintenue à zéro), pourquoi s’agit-il d’un bogue? Le programme pourrait toujours continuer de manière saine tant qu'il ne s'attend pas implicitement à bytes_read!=0après. Les désinfectants ne se plaignent donc pas. D'un autre côté, quand il bytes_readn'est pas initialisé au préalable, le programme ne pourra pas continuer normalement, donc ne pas initialiser introduit enbytes_read fait un bogue qui n'y était pas auparavant.
Doc Brown
2
@Abyx: même s'il s'agit d'un tiers, s'il ne traite pas d'un tampon commençant par \0celui-ci, c'est un buggy. S'il est documenté de ne pas traiter cela, votre code d'appel est bogué. Si vous corrigez votre code d'appel à vérifier bytes_read==0avant de l'utiliser, vous êtes de retour à votre point de départ: votre code est bogué si vous ne l'initialisez pas bytes_read, sans danger si vous le faites. ( Habituellement, les fonctions sont supposées remplir leurs paramètres de sortie même en cas d'erreur : ce n'est pas vraiment le cas. Très souvent, les sorties sont laissées seules ou non définies.)
Mat
1
Y a-t-il une raison pour laquelle ce code ignore le err_trendu par my_read()? S'il y a un bogue n'importe où dans l'exemple, c'est tout.
Blrfl
1
C'est simple: initialisez les variables uniquement si elles ont du sens. Si ce n'est pas le cas, alors ne le faites pas. Je suis cependant d’accord pour dire que l’utilisation de données «factices» pour le faire est mauvaise, car elle cache des bugs.
Pieter B
1
"Il existe une autre règle: si l'exécution d'un programme rencontre un bogue, il doit disparaître le plus tôt possible. Inutile de le garder en vie, écrasez-le, écrivez un crash, donnez-le aux ingénieurs pour enquête.": Essayez-le en vol. Logiciel de contrôle. Bonne chance pour récupérer la décharge de l'épave de l'avion.
Giorgio

Réponses:

44

Votre raisonnement va mal sur plusieurs comptes:

  1. Les erreurs de segmentation sont loin d'être certaines. L'utilisation d'une variable non initialisée entraîne un comportement indéfini . Les erreurs de segmentation sont l'un des moyens par lesquels un tel comportement peut se manifester, mais une exécution normale semble tout aussi probable.
  2. Les compilateurs ne remplissent jamais la mémoire non initialisée avec un modèle défini (comme 0xCD). C’est quelque chose que certains débogueurs font pour vous aider à trouver des endroits où les variables non initialisées sont utilisées. Si vous exécutez un tel programme en dehors d'un débogueur, la variable contiendra des ordures complètement aléatoires. Il est également probable qu'un compteur tel que le bytes_readait la valeur 10qu'il a la valeur 0xcdcdcdcd.
  3. Même si vous exécutez un débogueur qui définit la mémoire non initialisée sur un modèle fixe, ils ne le font qu'au démarrage. Cela signifie que ce mécanisme ne fonctionne de manière fiable que pour les variables statiques (et éventuellement affectées par tas). Pour les variables automatiques allouées sur la pile ou ne résidant que dans un registre, il y a de fortes chances que la variable soit stockée dans un emplacement utilisé auparavant, de sorte que la configuration de mémoire témoin a déjà été remplacée.

L’idée derrière les instructions pour toujours initialiser les variables est de permettre à ces deux situations

  1. La variable contient une valeur utile dès le tout début de son existence. Si vous combinez cela avec le guidage pour déclarer une variable uniquement lorsque 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.

  2. La variable contient une valeur définie que vous pouvez tester ultérieurement, pour indiquer si une fonction similaire my_reada mis à jour la valeur. Sans initialisation, vous ne pouvez pas savoir si bytes_readune valeur est réellement valide, car vous ne pouvez pas savoir avec quelle valeur elle a commencé.

Bart van Ingen Schenau
la source
8
1) tout est une question de probabilités, comme 1% vs 99%. 2 et 3) VC ++ génère ce code d'initialisation, également pour les variables locales. 3) les variables statiques (globales) sont toujours initialisées à 0.
Abyx
5
@Abyx: 1) D'après mon expérience, la probabilité est d'environ 80% "pas de différence comportementale immédiatement évidente", 10% "fait ce qu'il ne faut pas", 10% "segfault". En ce qui concerne (2) et (3): VC ++ ne le fait que dans les versions de débogage. S'en remettre à cela est une très mauvaise idée, car cela rompt sélectivement les versions de publication et ne figure pas dans vos tests.
Christian Aichinger
8
Je pense que "l'idée derrière les conseils" est la partie la plus importante de cette réponse. Les instructions ne vous disent absolument pas de suivre chaque déclaration de variable avec = 0;. Le but de l’avis est de déclarer la variable au point où vous aurez une valeur utile et d’affecter cette valeur immédiatement. Ceci est clairement expliqué dans les règles suivantes ES21 et ES22 qui suivent. Ces trois devraient tous être compris comme travaillant ensemble; pas en tant que règles individuelles sans rapport.
GrandOpener
1
@GrandOpener Exactement. S'il n'y a pas de valeur significative à assigner au moment où la variable est déclarée, la portée de la variable est probablement incorrecte.
Kevin Krumwiede
5
« Compilateurs jamais remplir » ne devrait pas être que pas toujours ?
CodesInChaos
25

Vous avez écrit "cette règle ne permet pas de rechercher des bogues, elle les cache seulement" - eh bien, l'objectif de la règle n'est pas d'aider à trouver des bogues, mais de les éviter . Et lorsqu'un bug est évité, rien n'est caché.

Examinons le problème à l'aide de votre exemple: supposons que la my_readfonction ait le contrat écrit pour initialiser bytes_readen toutes circonstances, mais pas en cas d'erreur. Elle est donc défectueuse, du moins dans ce cas. Votre intention est d'utiliser l'environnement d'exécution pour afficher ce bogue en n'initialisant pas le bytes_readparamètre en premier. Tant que vous savez avec certitude qu’un désinfectant d’adresse est en place, c’est un moyen efficace de détecter un tel bogue. Pour corriger le bogue, il faut changer la my_readfonction en interne.

Mais il existe un point de vue différent, qui est au moins également valable: le comportement fautif ne provient que de la combinaison non initialisation bytes_readpréalable et appelant my_readaprès (avec l'attente bytes_readest initialisée ensuite). Il s'agit d'une situation qui se produira souvent dans des composants du monde réel lorsque la spécification écrite d'une fonction similaire my_readn'est pas claire à 100%, voire erronée sur le comportement en cas d'erreur. Cependant, tant bytes_readque l'initialisation a été effectuée à 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 my_read. Il se comporte donc correctement. Dans cette combinaison, il n'y a pas de bogue dans le programme.

Donc, ma recommandation qui en découle est la suivante: utilisez l’approche de non-initialisation uniquement si

  • vous voulez tester si un bloc de fonction ou de code initialise un paramètre spécifique
  • vous êtes sûr à 100% que la fonction en jeu a un contrat dans lequel il est absolument faux de ne pas attribuer de valeur à ce paramètre
  • vous êtes sûr à 100% que l'environnement peut attraper cela

Ce sont des conditions que vous pouvez généralement organiser dans le code de test , pour un environnement d'outillage spécifique.

Cependant, dans le code de production, il est préférable d’initialiser au préalable une telle variable. C’est l’approche la plus défensive, qui évite les bugs si le contrat est incomplet ou erroné, ou si le correcteur 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’ya rien d’erreur, il n’est pas nécessaire d’arrêter l’exécution.

Doc Brown
la source
4
C'est exactement ce que je pensais quand je l'ai lu. Ce n'est pas de balayer des choses sous le tapis, mais de les jeter à la poubelle!
CorsiKa
22

Toujours initialiser vos variables

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 de l'initialiser crée un bogue bien défini et déterministe . Je ne saurais dire à quel point ces deux cas sont extrêmement différents.

Prenons un exemple hypothétique qui aurait pu arriver à un employé hypothétique participant à 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 à des besoins.

D'accord, je vais m'arrêter avec le mot injections. Je pense que vous comprenez l'idée ;-)

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é plusieurs erreurs de type "branche sur une valeur non initialisée". "Hmm, cela semble causer un non-déterminisme, ce qui rend difficile la répétition de tests lorsque nous en avons le plus besoin." Le développeur s'est adressé à la direction, mais la direction avait un calendrier très serré et elle ne pouvait pas épargner de ressources pour détecter 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 de désabonnement complet et que toute l'équipe est prête à achever toutes les tâches promises par la direction dans un budget qui, comme chaque projet jamais financé, était trop petit. Quelqu'un a remarqué qu'ils ne pouvaient pas tester une fonctionnalité essentielle car, pour une raison quelconque, la sim déterministe ne se comportait pas de manière déterministe pour déboguer.

L’ensemble de l’équipe a peut-être été arrêté et a passé la plus grande partie de sa peine, pendant deux mois, à peindre l’ensemble de la base de code de la simulation pour réparer les erreurs de valeur non initialisées au lieu de mettre en œuvre et de tester les fonctionnalités. Il va sans dire que l'employé a ignoré les "Je vous l'avais bien dit" et a directement aidé d'autres développeurs à comprendre ce que sont des 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 c'est le coup d'avertissement. C'est la balle qui a frôlé le nez. Le problème est loin beaucoup plus insidieux que vous ne le pensez même.

L'utilisation d'une valeur non initialisée est un "comportement indéfini" (à l'exception de quelques cas tels que char). Un comportement indéfini (ou UB en abrégé) est tellement insensé et complètement mauvais pour vous, que vous ne devriez jamais 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 correspond à "tout comportement du compilateur." Cela peut faire quelque chose que vous appelleriez «sain d’esprit», comme avoir une valeur non spécifiée. Il peut émettre des codes opération non valides, ce qui pourrait entraîner la corruption de votre programme. Cela peut déclencher un avertissement au moment de la compilation ou même être considéré par le compilateur comme une erreur.

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 le lier, j'ai échoué à trouver l'article à nouveau. Il y avait un problème de dépassement de tampon dans le moteur SQL lorsque vous passiez une taille de tampon plus grande à une fonction, mais uniquement sur une version particulière de Debian. Le bogue a été consigné consciencieusement 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 rendu, mais l'idée est la même. Si vous put + dataLengthenroulez, il sera plus petit que le putpointeur (ils avaient des vérifications de compilation pour s'assurer que unsigned int était de la taille d'un pointeur, pour les curieux). Si cela se produit, nous savons que les algorithmes de mémoire tampon en anneau standard peuvent être perturbés par ce débordement. Nous renvoyons donc 0. Ou le faisons-nous?

En fin de compte, le débordement sur les pointeurs n'est pas défini en C ++. Comme 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 souhaitons. Cependant, il s’agit d’ un comportement indéfini, ce qui signifie que le compilateur est autorisé à faire tout ce qu’il veut.

Dans le cas de ce bogue, Debian est arrivé à choisir d'utiliser une nouvelle version de gcc qu'aucun des autres grandes saveurs de Linux a mis à jour dans leurs versions de production. Cette nouvelle version de gcc avait un optimiseur de code mort plus agressif. Le compilateur a constaté le comportement indéfini et a décidé que le résultat de la ifdéclaration serait "tout ce qui rend l'optimisation du code optimale", ce qui est une traduction absolument légale de UB. En conséquence, il a supposé que, comme ptr+dataLengthil ne pouvait jamais être en-dessous ptrsans débordement de pointeur UB, l’ ifinstruction ne se déclencherait jamais et qu’elle optimisait la vérification du dépassement de la mémoire tampon.

L’utilisation de "sane" UB a en fait causé à un produit SQL majeur un exploit pour lequel il avait écrit du code à éviter!

Ne comptez jamais sur un comportement indéfini. Déjà.

Cort Ammon - Rétablir Monica
la source
Pour une lecture très amusante sur le comportement non défini, software.intel.com/en-us/blogs/2013/01/06/… est un article incroyablement bien écrit sur la gravité de la situation. Cependant, cet article porte sur les opérations atomiques, qui sont très déroutantes pour la plupart des gens. J'évite donc de le recommander en guise d'introduction à UB et à la manière dont cela peut mal tourner.
Cort Ammon - Rétablir Monica
1
J'aimerais que C ait des éléments intrinsèques pour définir une valeur ou un tableau de valeurs sur des valeurs indéterminées non indicibles, non initialisées, ou des valeurs non spécifiées, ou pour transformer les valeurs négatives en valeurs moins négatives (valeurs indéterminées ou non spécifiées), tout en laissant les valeurs définies seules. Les compilateurs pourraient utiliser de telles directives pour faciliter des optimisations utiles, et les programmeurs pourraient les utiliser pour éviter de devoir écrire du code inutile tout en bloquant les "optimisations" brisées lorsqu’on utilise des techniques telles que les techniques à matrice éparse.
Supercat
@supercat Ce serait une fonctionnalité intéressante, en supposant que vous visiez des plateformes pour lesquelles cette solution est valable. L'un des exemples de problèmes connus est la possibilité de créer des modèles de mémoire non seulement invalides pour le type de mémoire, mais impossibles à obtenir par des moyens ordinaires. boolest un excellent exemple de problèmes évidents, mais ils apparaissent ailleurs, à moins que vous ne supposiez que vous travaillez sur une plate-forme très utile comme x86 ou ARM ou MIPS, où tous ces problèmes sont résolus au moment du code d'opération.
Cort Ammon - Rétablir Monica
Prenons le cas où un optimiseur peut prouver qu'une valeur utilisée pour a switchest inférieur à 8, en raison de la taille de l'arithmétique entière, afin de pouvoir utiliser des instructions rapides supposant qu'il n'y a aucun risque qu'une valeur "grande" entre en entrée. Une valeur non spécifiée (qui ne pourrait jamais être construite à l'aide des règles du compilateur) apparaît, faisant quelque chose d'inattendu, et vous obtenez soudainement un saut énorme à la fin d'une table de saut. Autoriser des résultats non spécifiés ici signifie que chaque instruction switch du programme doit avoir des pièges supplémentaires pour prendre en charge ces cas qui peuvent "ne jamais se produire".
Cort Ammon - Rétablir Monica
Si les éléments intrinsèques étaient normalisés, les compilateurs pourraient être tenus de faire tout ce qui serait nécessaire pour respecter la sémantique; si, par exemple, certains chemins de code définissent une variable et d’autres pas, et qu’un élément intrinsèque dit ensuite "convertissez-le en valeur non spécifiée si non initialisé ou indéterminé; sinon laissez-le libre", un compilateur pour les plates-formes avec des registres "not-a-value" devrait insérez du code pour initialiser la variable soit avant tout chemin de code, soit sur tout chemin de code où l'initialisation serait sinon manquée, mais l'analyse sémantique requise pour ce faire est assez simple.
Supercat
5

Je travaille principalement dans un langage de programmation fonctionnel dans lequel vous n'êtes pas autorisé à réaffecter des variables. Déjà. Cela élimine complètement cette classe de bugs. Cela semblait une énorme restriction au début, mais cela vous oblige à structurer votre code de manière à correspondre à l'ordre d'apprentissage des nouvelles données, ce qui tend à simplifier votre code et à en faciliter la maintenance.

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 les outils automatisés heureux.

Votre exemple avec une API de style C est un peu plus compliqué. Dans ces cas, lorsque j'utilise la fonction, j'initialise à zéro pour empêcher le compilateur de se plaindre, mais une fois dans les my_readtests unitaires, je vais initialiser à 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.

Karl Bielefeldt
la source
5

Non, ça 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.


la source
1
Et initialiser avec -1 peut être réellement significatif. Là où "int bytes_read = 0" est incorrect, car vous pouvez réellement lire 0 octet, l'initialiser avec -1 indique très clairement qu'aucune tentative de lecture d'octets n'a réussi et vous pouvez le tester.
Pieter B
4

TL; DR: Il existe deux façons de corriger ce programme, d’initialiser vos variables et de prier. Un seul produit des résultats de manière constante.


Avant de pouvoir répondre à votre question, je devrai d'abord expliquer ce que signifie le comportement non défini. En fait, je laisserai un auteur du compilateur faire le gros du travail:

Si vous ne souhaitez pas lire ces articles, un TL; DR est:

Undefined Behavior 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 le comportement non défini.

L'archétype des "Démons qui volent de votre nez" n'a malheureusement pas réussi à expliquer les conséquences de ce fait. Bien que censé prouver que quelque chose pouvait arriver, il était si incroyable que la plupart du temps, il ait été haussé des épaules.

La vérité, cependant, est que le comportement non défini affecte la compilation elle-même, bien avant que vous n'essayiez d'utiliser le programme (instrumenté ou non, dans un débogueur ou non) et peut changer complètement 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;
}

est transformé en:

void contains_null_check(int *P) {
  *P = 4;
}

parce qu'il est évident que Pcela ne peut pas être, 0car 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 commune de supposer que le comportement non défini entraînerait une erreur d’exécution. Il peut ne pas.

Imaginons que la définition de my_readsoit:

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 inlining:

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:

  1. Aucune variable ne doit être utilisée non initialisée
  2. bytes_readserait utilisé non initialisé si resultn'était pas0
  3. Le développeur promet que resultcela ne le sera jamais 0!

Ainsi resultn'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, resultn'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 en sommes là, une transformation strictement confirmante de l'original, et aucun débogueur ne piégera une variable non initialisée car il n'y en a aucune.

Je suis sur cette voie, comprendre le problème lorsque le comportement attendu et l'assemblage ne correspondent pas n'est vraiment pas amusant.

Matthieu M.
la source
Parfois, je pense que les compilateurs devraient obtenir que le programme supprime les fichiers source lorsqu'ils exécutent un chemin UB. Les programmeurs apprendront alors ce que UB signifie pour leur utilisateur final ....
mattnz
1

Regardons 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 telle erreur, nous pouvons insérer la ligne assert(bytes_read > 0);et intercepter ce bogue au moment 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 de code, réexécutons le processus et reproduisons le bogue. Nous ne faisons qu'un pas en essayant de l'attraper.

Si nous n'avons pas initialisé bytes_read, il contient des déchets. Il ne contient pas nécessairement la même poubelle à chaque fois. Nous dépassons la ligne my_read(buffer, &bytes_read);. Maintenant, si la valeur est différente d’avant, nous ne pourrons peut-être pas reproduire notre bogue du tout! Cela pourrait fonctionner la prochaine fois, sur la même entrée, par accident complet. Si c'est toujours zéro, nous obtenons un comportement cohérent.

Nous vérifions la valeur, peut-être même sur une trace dans la même exécution. Si c'est zéro, nous pouvons voir que quelque chose ne va pas; bytes_readne devrait pas être zéro sur le succès. (Ou si c'est possible, on pourrait vouloir l'initialiser à -1.) On peut probablement attraper le bogue ici. Si toutefois bytes_readune valeur plausible est fausse, la repérerions-nous d'un coup d'œil?

Ceci est particulièrement vrai pour les pointeurs: un pointeur NULL sera toujours évident dans un débogueur, il 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 de mémoire peut provoquer ultérieurement des bogues de corruption de la mémoire non reproductibles, ce qui est presque impossible à déboguer.

Davislor
la source
1

Le PO ne s'appuie pas sur un comportement indéfini, ou du moins pas exactement. En effet, s’appuyer sur un comportement indéfini est mauvais. Dans le même temps, le comportement d'un programme dans un cas inattendu est également indéfini, mais un type différent d'indéfini. Si vous définissez une variable à zéro, mais vous ne l' avez pas l' intention d'avoir un chemin d'exécution qui utilise ce zéro initial, ce que votre programme se comportent sanely lorsque vous avez un bug et faire un tel chemin? Vous êtes maintenant dans les mauvaises herbes; vous n'aviez pas l'intention d'utiliser cette valeur, mais vous l'utilisez quand même. Peut-être que ce sera inoffensif ou que cela provoquera un crash du programme ou peut-être que le programme corrompra des données en silence. Tu ne sais pas.

Ce que le PO dit, c'est qu'il existe des outils qui vous aideront à trouver ce bogue, si vous le lui permettez. Si vous n'initialisez pas la valeur, mais que vous l'utilisez quand même, il existe des analyseurs statiques et dynamiques qui vous diront que vous avez un bogue. Un analyseur statique vous le dira avant même de commencer à tester le programme. Si, en revanche, 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 votre bogue restera non détecté. Si vous avez de la chance, c'est inoffensif ou simplement le programme est bloqué; si vous êtes malchanceux, les données sont corrompues en silence.

Le seul endroit où je suis en désaccord avec le PO est à la toute fin, où il dit "quand il y aurait déjà une faute de segmentation sinon." En effet, une variable non initialisée ne donnera pas de manière fiable une erreur de segmentation. Au lieu de cela, je dirais que vous devriez utiliser des outils d’analyse statique qui ne vous permettront pas d’essayer d’exécuter le programme.

Jordan Brown
la source
0

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 pas prédéclarer 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 les cas où l'initialisation dépend d'une condition:

Base* ptr;
if(foo()) {
    ptr = new Derived1();
} else {
    ptr = new Derived2();
}

Je crois 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. explicitement qu’une initialisation sensible de votre variable est effectuée.


Variables membres

Ici, je suis d’accord avec ce que les autres répondants ont dit: Celles-ci devraient toujours être initialisées 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.


Les tampons

C'est là où je suis en désaccord avec les autres réponses. Quand 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 préjudiciable: le seul effet de ces initialisations est qu'elles rendent des outils valgrindimpuissants. Tout code qui lit plus que nécessaire dans les tampons initialisés est très probablement un bogue. Mais avec l'initialisation, ce bug ne peut pas être exposé par valgrind. Donc, ne les utilisez pas sauf si vous vous fiez vraiment à la mémoire remplie de zéros (et dans ce cas, laissez un commentaire disant ce pour quoi vous avez besoin des zéros).

Je recommande également fortement d'ajouter une cible à votre système de génération qui exécute la suite de tests dans son intégralité valgrindou 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 valgrindcible doit être exécutée régulièrement, en particulier avant que tout code ne soit rendu public.


Variables globales

Vous ne pouvez pas avoir de variables globales non initialisées (du moins en C / C ++, etc.), assurez-vous donc que cette initialisation est celle que vous souhaitez.

cmaster
la source
Notez que vous pouvez écrire des initialisations conditionnelles avec l'opérateur ternaire, par exemple Base& b = foo() ? new Derived1 : new Derived2;
Davislor
@Lorehead Cela peut fonctionner pour les cas simples, mais pas pour les plus complexes: vous ne voulez pas le faire si vous avez trois cas ou plus, et que vos constructeurs prennent trois arguments ou plus, simplement pour des raisons de lisibilité. les raisons. Et cela ne prend même pas en compte les calculs éventuellement nécessaires, comme la recherche d'un argument pour une branche d'initialisation dans une boucle.
cmaster
Pour les cas plus complexes, vous pouvez envelopper le code d'initialisation en fonction de l' usine: Base &b = base_factory(which);. Ceci est particulièrement utile si vous devez appeler le code plusieurs fois ou s'il vous permet de rendre le résultat constant.
Davislor
@ Lorehead C'est vrai, et certainement la voie à suivre si la logique requise n'est pas simple. Néanmoins, j'estime qu'il existe une petite zone grise entre l'initialisation via ?:un PITA et qu'une fonction d'usine est toujours excessive. Ces cas sont rares, mais ils existent.
cmaster
-2

Un compilateur décent en C, C ++ ou Objective-C avec les bonnes options du compilateur vous dira au moment de la compilation si une variable est utilisée avant que sa valeur ne soit définie. Dans la mesure où, dans ces langages, le comportement indéfini de la valeur d'une variable non initialisée est défini, "définir une valeur avant de l'utiliser" n'est pas un indice, ni une ligne directrice, ni une bonne pratique, il s'agit d'une exigence de 100%; sinon votre programme est absolument en panne. Dans d'autres langages, comme Java et Swift, le compilateur ne vous autorisera jamais à utiliser une variable avant son initialisation.

Il y a une différence logique entre "initialiser" et "définir une valeur". Si je veux trouver le taux de conversion entre dollars et euros, écris "taux double = 0,0;" alors la variable a une valeur définie, mais elle n'est pas initialisée. La 0.0 stockée ici n'a rien à voir avec le résultat correct. Dans cette situation, si à cause d'un bogue, vous ne stockez jamais le taux de conversion correct, le compilateur n'a pas la possibilité de vous le dire. Si vous venez d'écrire "double taux;" et jamais stocké un taux de conversion significatif, le compilateur vous le dirait.

Donc: N'initialisez pas une variable simplement parce que le compilateur vous dit qu'elle est utilisée sans être initialisée. C'est cacher un bug. Le vrai problème est que vous utilisez une variable que vous ne devriez pas utiliser ou que, sur un chemin de code, vous n'avez pas défini de valeur. Corrigez le problème, ne le cachez pas.

N'initialisez pas une variable simplement parce que le compilateur peut vous dire qu'elle est utilisée sans être initialisée. Encore une fois, vous cachez des problèmes.

Déclarez les variables proches de l’utilisation. Cela améliore les chances que vous puissiez l'initialiser avec une valeur significative au moment de la déclaration.

Évitez de réutiliser des variables. Lorsque vous réutilisez une variable, il est fort probable qu'elle soit initialisée à une valeur inutile lorsque vous l'utilisez à des fins différentes.

Il a été signalé que certains compilateurs ont de faux négatifs et que la vérification de l'initialisation équivaut à résoudre le problème. Les deux sont en pratique hors de propos. Si un compilateur, tel que cité, ne peut pas trouver l’utilisation d’une variable non initialisée dix ans après le signalement du bogue, il est temps de chercher un autre compilateur. Java l'implémente deux fois; une fois dans le compilateur, une fois dans le vérificateur, sans aucun problème. Le moyen le plus simple de contourner le problème persistant est de ne pas exiger qu’une variable soit initialisée avant d’être utilisée, mais bien de l’initialiser avant son utilisation de manière à pouvoir être vérifiée par un algorithme simple et rapide.

gnasher729
la source
Cela semble superficiellement bon, mais repose trop sur la précision des avertissements de valeurs non initialisées. Les corriger parfaitement équivaut au problème Halting, et les compilateurs de production peuvent subir et subissent de faux négatifs (c'est-à-dire qu'ils ne diagnostiquent pas une variable non initialisée au moment opportun); voir par exemple le bug GCC 18501 , qui est non corrigé depuis plus de dix ans maintenant.
Déc
Ce que vous dites à propos de gcc vient d’être dit. Le reste est sans importance.
gnasher729
C'est triste à propos de gcc, mais si vous ne comprenez pas pourquoi le reste est pertinent, vous devez vous renseigner.
Déc