Mar 31, 2023

Diversão com referências em C# - Parte II / II

Read this post in English.

Lire cet post en Français.

Em meu último post apresentei o programa abaixo seguido de algumas perguntas...

Mesmo sendo controverso e codificado explicitamente para expor um comportamento, o mesmo não é irrealista; conforme a complexidade de um programa aumenta, aumenta a probabilidade de código como este ser introduzido. Assim sendo, sem mais delongas vamos tentar responder cada uma das questões...

 

Qual é a saída do programa ?

Infelizmente a saída deste programa não é determinística e provavelmente irá variar dependendo da combinação de processador/OS em questão; pior, ao executar o programa várias vezes na mesma máquina observei variações no resultado.

Contudo, pelo menos uma parte do resultado é completamente determinístico: a saída do método
M2() deve ser sempre algo como:

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

Por outro lado, a saída do método M3() é indefinido e se assemelha a "lixo" ou random, algo como:

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

mas, antes de condená-lo, exploremos um pouco mais a fundo as call stacks nas  execuções dos métodos 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. Pilhas (stack) são localizadas em endereços mais altos e avancam para endereços mais baixos, ou seja, a medida que dados são empilhados a pilha cresce no sentido de endereços mais baixos de memória.

  2. Por motivos de simplificação cada elemento empilhado é assumido consumir 1 entrada na pilha independente do tamanho (em bytes) real do elemento.

  3. Áreas cinza representam partes da pilha utilizadas por métodos executados anteriormente, ou seja, foram alocadas, utilizadas durante a execução do método, e desalocadas quando o método finalizou.
Mesmo não sendo 100% precisa esta representação de pilha nos servirá muito bem neste post uma vez que abstrai detalhes não importantes.

Assim, vamos começar pela execução do método
M2() :

A figura 1 representa o estado da pilha na sequência de chamada Main() -> M2() -> Instantiate() na qual Main() executou o método M2() passando o valor 0x01020304 para o parâmetro v (o qual foi armazenado na posição 98 da pilha); M2(), por sua vez, executou o método Instantiate() passando uma referência para v, em outras palavras, o endereço de v na pilha, ou seja, 98 como o valor do parâmetro i de Instantiate() o qual alocou uma instância do tipo RefStruct (representado na pilha como uma variável chamada tmp) inicializada com o valor do parâmetro i (ou seja, 98). 

Uma vez inicializada a variável tmp é retornada por valor para o método M2() (figura 2), o qual armazena este resultado na variável local rs. Observe que neste momento o valor 98 é armazenado no campo rs.value (ou em outras palavras rs.value referencia o endereço 98).

Por fim, M2() executa o método Dump() (figura 3) passando uma referência à rs, ou seja, o endereço 97; note que este último reutilisa o endereço 96 (que havia utilizado anteriormente para armazenar o parâmetro i de Instantiate()). Neste ponto Dump() interpretará o conteúdo da posição 97 como uma instância RefStruct e em seguida imprimirá  o valor referenciado pelo campo rs.value. Como este é um campo do tipo referência o valor a ser utilizado será o conteúdo do endereço 98 (valor do campo rs.value) e assim os quatro octetos representando o valor 0x01020304 serão impressos.

Agora, contraste isso com a sequência de execução do método
M3():

Na figura 1 acima temos representada a sequência de chamada  Main() -> M3() -> InstantiateAndLog() -> Instantiate(), muito similar à sequência de execução do método M2() mas com uma diferença crucial: M3() executa um método  intermediário (InstantiateAndLog()) passando 0x01020304 como valor do parâmetro v; este último, por sua vez, executa o método Instantiate() passando como parâmetro uma referência para seu próprio parâmetro v, ou seja, o endereço 96; de forma similar à execução anterior Instantiate() aloca uma instância do tipo RefStruct (armazenada no endereço  93, representada na pilha acima por uma variável chamada tmp) inicializando o campo value com a referência recebida como parâmetro, ou seja, o endereço 96.

A seguir, tmp é retornada  para InstantiateAndLog() (figura 2), e depois à M3() (figura 3) o qual armazena a mesma na variável local rs  (endereço 97) como indicado abaixo:

Observe que a estrutura retornada para M3() possui o campo value com o valor 96, ou seja, um endereço que já não é mais válido (o último endereço válido na pilha neste instante é 97) e que qualquer acesso ao mesmo apresentará um comportamento indefinido.

O comportamento observado dependerá de como este endereço será utilisado; em nosso caso específico, após retornar de
InstantiateAndLog(), M3() executa o método Dump() (figura 4) passando rs como uma referência e sobrescrevendo o valor precedente contino do endereço 96. Dump() por sua vez, ao executar o método BitConvert.GetBytes(), irá passar o valor 97 ao invés de 0x01020304 (isto porque o parâmetro rs representa uma referência para uma instância RefStruct,  assim sendo Dump() irá ler o conteúdo armazenado em tal parâmetro (97) e interpretar o contéudo daquela posição de memória (corretamente) como uma referência para RefStruct com o campo value armazendo o valor 96; como próximo passo a posição de memória referenciada por esse campo (96) será interpretado como um int e assim o valor final passado para BitConvert.GetBytes() será 97 ao invés de 0x01020304.

Como poderíamos evitar este problema ?

Resposta curta: sempre questione o código; a utilização do modificador unsafe deveria soar um alerta vermelho e levar os desenvolvedores a questionar o motivo de tal modificador ser necessário e o que poderia ser unsafe no código; removê-lo faz com que o compilador emita o seguinte erro:

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

Note que mesmo com o modificador unsafe presente o compilador fez o melhor que podia para nos alertar sobre o potencial problema emitindo a seguinte warning (que foi prontamente ignorada é claro):

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

Finalizando, para mim a forma mais eficaz de detectar e evitar problemas compreende em i) ter boas práticas de devsenvolvimento (por exemplo, configurar warnings para serem tratadas como erros, ter um processo de code review bem estabelecido, etc.), ii) ter um bom entendimento das técnologias/bibliotecas utilisados, iii) jamais ignorar warnings do compilador e finalmente iv) jamais se sentir intimidado e tentado a não questionar algum aspécto do código durante um code review.

Have fun!

Adriano


Fun with references in C# - Part II / II

Leia este post em português

Lire cet post en Français.

In my previous post I've presented the following code and posed a couple of questions.


Of course it is contrived and explicitly coded to expose a specific behavior but nonetheless it is not an unrealistic example. That said, without further ado, lets answer each of the questions...

 

What is its output ?

Unfortunately the output will probably be different in each machine/os combination and even on the same machine I've observed differences from run to run, although at least one part of the output is completely deterministic and should always produce the same result: method M2() output should always be something like:

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

By the other hand, the output of M3() is undefined and will show what looks like to be gibberish, something like (from one of my runs):

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

but before deeming it as the culprit let's explore M2()/M3() call chains a little deeper...

Can you spot/explain the issue in the code?

In order to more easily demonstrate the problem I'll present a series of simplified call stacks in which:

  1. The stack grows from bottom to top (i.e, higher to lower address)
  2. For simplicity, data stored in each stack entry is assumed to consume a single `spot` (or address), no matter the size of the data (when in reality in most platforms the amount of consumed stack space depends on the specifics of each stored data type).
  3. Gray-ed areas represents parts of the  stack that have been previously used (allocated) but are not valid anymore, i.e, they have been freed.

Even not being 100% accurate this abstraction serve the purpose of this post very well

So, lets start with the call to M2():

 

The call stack 1 represents the state when running Instantiate() for the chain Main() -> M2() -> Instantiate(); in this call chain, Main() called M2() passing 0x01020304 as the v parameter (which has been stored in entry 98 at the stack); M2() called Instantiate() passing a reference to v, i.e, v's address or in other words, 98, as the Instantiate()'s i parameter which instantiated a RefStruct (represented in the stack by a variable named tmp) passing i value (i.e, 98) to its constructor which ended up setting a ref field to 98 and then returning that instance by value.

Upon returning to M2() (call stack 2), the local variable rs is set to the returned value. Notice that at this point 98 is stored in rs.value (or in other words, rs.value points to the location at address 98). 

Finally M2() calls Dump() method (call stack 3) passing a reference to rs, i.e, the address 97; notice that Dump() method is reusing the address 96 (which were previously used to store Instantiate()'s i parameter). At this point, Dump() will simply interpret whatever is at address 97 as as RefStruct and and will print the contents referenced by the rs.value field, now taking whatever is stored at 98 (the value stored at that field) and interpreting it as an int and printing the four bytes that makes up the integer value.

Contrasting that with the M3() call chain,

call stack 1 above represents the call Main() -> M3() -> InstantiateAndLog() -> Instantiate(). Very similar to M2() call chain, but in this case M3() calls into an intermediate method (InstantiateAndLog()) passing in 0x01020304 to its v parameter (by value) which then calls into Instantiate() passing the just received v parameter by reference (i.e, Instantiate() received 96, or, in oder words the address of v in the stack) and just instantiates a RefStruct (at address 93, represented by a variable named tmp) initializing its value field with the reference it just received.

This instance is then just returned, first to InstantiateAndLog() (call stack 2), then to M3()  (call stack 3) which simply stores it in a local variable (rs , at address 97) as shown bellow:
 

Notice that in M3(), the returned struct has its value field pointing (or referencing) to address 96 which is not valid anymore (since it has already been freed) and thereafter any assignment/reading to/from that field is bound to lead to undefined behavior; 

The exact behavior will depend on how that position is further used but in our specific example, after returning from InstantiateAndLog(), M3() calls into Dump() (call stack 4) passing rs as a reference (actually that is not important; it could have been passed by value, and the behaviour would still be wrong) overwriting the previous value at address 96. Now when Dump() calls into BitConverter.GetBytes() it first deferences the RefStruct reference it has been passed which means it goes to address 97 and assumes that there's a RefStruct stored there (which is correct); the next step is to deference the field value (remember, it is a ref field) which will produce (in our example) the value 97 instead of 0x01020304.

How could one pinpoint this issue ? 

The simple answer: always question the code; the use of the unsafe should raise a big red sign and prompt developers to question what may be not safe in that code. In this specific example, removing such modifier immediately results in a compiler error:

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

Notice that even with the unsafe modifier the compiler have tried its best to warn us about a potential issue by emitting the following warning (which was promptly ignored, of course):

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

So, in my opinion, the most effective way to detect potential issues is to i) have good development practices (like, configure warnings as errors, have a code review process, etc), ii) understand the implications of any technology/approach/library you use in your code, iii) to never ignore compiler warnings and iv) never be afraid to raise questions in code reviews.

See you in the next post.

Have fun!

Adriano

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