Mar 31, 2023

Amusement avec les références en C# - Partie II / II

Leia este post em português

Read this post in English.

Dans mon dernier post j'ai présenté le code suivant et y a demande quelque questions...


C'est vrai qu'il conçu est  explicitement codé pour exposer un comportament spécifique, mais ce n'est pas un exemple irréaliste. Cela dit, sans plus tarder, répondons à chacune des questions... 

 

Quelle est sa sortie ?

Malheureusement, la sortie sera probablement différente dans chaque combinaison machine/OS et même sur la même machine, j'ai observé des différences d'une exécution à l'autre, bien qu'au moins une partie de la sortie soit complètement déterministe et devrait toujours produire le même résultat: pour le méthode M2 () la sortie doit toujours ressembler à:

---------M2----------
 4
 3
 2
 1

D'autre part, la sortie de M3 () est indéfinie et montrera ce qui ressemble à du charabia, quelque chose comme (d'après l'une de mes exécutions):

      ---------M3----------
      RefStruct created.
      fe
     7f
     0
     0

mais avant de le considérer coupable, allons nous explorer un peu plus en profondeur les chaînes d'appels M2()/M3()...

Pouvez-vous repérer/expliquer les problèmes dans le code ?

Afin de démontrer plus facilement le problème, je vais présenter une série de call stacks simplifiées dans lesquelles:

  1. Le stack grandit de bas en haut (c'est-à-dire, de l'adresse supérieure à l'adresse inférieure)
  2. Pour plus de simplicité, les données stockées dans chaque entrée de la stack  sont supposées consommer un seul « emplacement » (ou adresse), quelle que soit la taille des données (alors qu'en réalité, sur la plupart des plates-formes, la quantité d'espace de consommé dépend des spécificités de chaque type).
  3. Les zones grisées représentent les parties de la stack qui ont été précédemment utilisées (allouées) mais ne sont plus valides, c'est-à-dire qu'elles ont été libérées.
Même si elle n'est pas précise à 100 %, cette abstraction sert très bien l'objectif de cet article.

Alors, commençons par l'appel à M2() :

 

La call stack 1 représente l'état lors de l'exécution de Instantiate() pour la chaîne Main() -> M2() -> Instantiate(); dans cette call stack, Main() a appelé M2() en passant 0x01020304 comme paramètre v (qui a été stocké dans l'entrée 98 de la pile) ; M2() a appelé Instantiate() en passant une référence à v, c'est-à-dire l'adresse de v ou en d'autres termes, 98, en tant que paramètre i de Instantiate() qui a instancié un RefStruct (représenté dans la stack par une variable nommée tmp) en passant la valeur i (c'est-à-dire 98) à son constructeur qui a fini par définir un champ ref sur 98, puis de renvoyer cette instance par valeur.

Lors du retour à
M2() (call stack 2), la variable locale rs est définie sur la valeur retourné. Notez qu'à ce stade, 98 est stocké dans rs.value (ou en d'autres termes, rs.value pointe vers l'emplacement à l'adresse 98).

Enfin,
M2() appelle la méthode Dump() (call stack 3) en passant une référence à rs, c'est-à-dire l'adresse 97; notez que le méthode Dump() réutilise l'adresse 96 (qui était auparavant utilisée pour stocker le paramètre i de Instantiate()). À ce stade, Dump() interprétera simplement tout ce qui se trouve à l'adresse 97 comme RefStruct et imprimera le contenu référencé par le champ rs.value, prenant maintenant tout ce qui est stocké à 98 (la valeur stockée dans ce champ) et l'interprétant comme un int (Int32) et l'impression des quatre octets qui composent la valeur entière.

En contraste avec la chaîne d'appel
M3()),

Le call stack 1 ci-dessus représente l'appel M3() -> InstantiateAndLog() -> Instantiate(). Très similaire à la chaîne d'appels M2(), mais dans ce cas, M3() appelle une méthode intermédiaire (InstantiateAndLog()) en passant 0x01020304 à son paramètre v (par valeur) qui appelle ensuite Instantiate() en passant le paramètre v qui vient d'être reçu par référence (c'est-à-dire, Instantiate() a reçu 96, ou, en d'autres termes, l'adresse de v dans la stack) et instancie juste un RefStruct (à l'adresse 93, représenté par une variable nommée tmp) en initialisant son champ value avec la référence qu'il vient reçu.

Cette instance est ensuite simplement renvoyée, d'abord à
InstantiateAndLog() (call stack 2), puis à M3() (call stack 3) qui la stocke simplement dans une variable locale (rs , à l'adresse 97) comme indiqué ci-dessous :

 

Notez que dans M3(), la structure renvoyée a son champ value pointant (ou référençant) vers l'adresse 96 qui n'est plus valide (puisqu'elle a déjà été libérée) et par la suite toute affectation/lecture vers/depuis ce champ est liée à conduire à un comportement indéfini ;

Le comportement exact dépendra de la façon dont cette position est ensuite utilisée, mais dans notre exemple spécifique, après le retour de
InstantiateAndLog(), M3() appelle Dump() (call stack 4) en passant rs comme référence (en fait, ce n'est pas important ; il aurait pu être passé par valeur, et le comportement serait toujours erroné) en écrasant la valeur précédente à l'adresse 96. Maintenant, lorsque Dump() appelle BitConvert.GetBytes(), il défère d'abord la référence RefStruct qu'il a été passé, ce qui signifie qu'il va à l'adresse 97 et suppose qu'un RefStruct y est stocké (ce qui est correct); l'étape suivante consiste à déférence la champ value (rappelez-vous, c'est un champ ref) qui produira (dans notre exemple) la valeur 97 au lieu de 0x01020304.

Comment on pourrait-on identifier ces problèmes? 

La réponse simple : remettez toujours en question le code ; l'utilisation de l'unsafe devrait soulever un grand signe rouge et inciter les développeurs à se demander ce qui peut être dangereux dans le code. Dans cet exemple spécifique, la suppression d'un tel modificateur entraîne immédiatement une erreur du compilateur:

error CS8166: Cannot return a parameter by reference 'v' because it is not a ref parameter

Notez que même avec le modificateur unsafe, le compilateur a fait de son mieux pour nous avertir d'un problème potentiel en émettant l'avertissement suivant (qui a été rapidement ignoré, bien sûr):

warning CS9087: This returns a parameter by reference 'v' but it is not a ref parameter

Donc, à mon avis, le moyen le plus efficace de détecter les problèmes potentiels est de i) avoir de bonnes pratiques de développement (comme configurer les avertissements en tant qu'erreurs, avoir un processus de révision du code, etc.), ii) comprendre les implications de toute technologie/approche/ bibliothèque que vous utilisez dans votre code, iii) de ne jamais ignorer les avertissements du compilateur et iv) de ne jamais avoir peur de poser des questions dans un processus de révision du code.

À bientôt et amuse toi!

Adriano

No comments: