Dec 28, 2022

Um pequeno puzzle sobre o C# 10 - Solução (Parte III)

Read this post in English.

Lire cet post en Français.

Nos últimos posts, lancei o desafio para alterar a saída do programa a seguir para mostrar apenas linhas com números pares (leia as partes I e II se ainda não o fez).

Eu também disse que se você olhasse com cuidado no código, poderia encontrar algumas pistas sobre a direção da solução com base em trechos de código que não eram estritamente necessários naquela versão que basicamente contém 2 desses pedaços de código:
  1. O using System.Runtime.CompilerServices que não é necessário, pois não estamos fazendo referência a nenhum tipo declarado nesse namespace.
     
  2. o if  na linha 5 do método Foo() claramente não é necessário, pois o programa passa apenas strings não nulas/vazias para esse método.

Como sugeri, a solução que criei explora C# string interpolation handlers, um recurso introduzido no C# 10.

Munido com essas informações, uma maneira de atingir nosso objetivo consiste em simplesmente substituir o tipo de parâmetro msg por um custom string interpolation handler (InterpolatedHandlerTrick no código abaixo) da seguinte forma:

Se você executar o programa acima, verá que ele realmente funciona, ou seja, você deverá ver apenas as linhas com números pares na saída. Antes de mergulhar nos detalhes, vamos dar uma olhada no que é necessário para implementar custom interpolated string handlers:
  1. Declare um tipo e marque o mesmo com  InterpolatedStringHandlerAttribute  (linha #14)
  2. Implemente um construtor com pelo menos dois inteiros (literalLength e formattedCount) (nossa implementação declara alguns parâmetros extras para aproveitar alguns recursos mais avançados, que serão discutidos posteriormente)
  3. Implemente os métodos:
    1. void AppendLiteral(string msg)
    2. void AppendFormatted<T>(T value)
e pronto!
 
Isso é muito legal mas a pergunta interessante é: por que isso funciona?

Se você colar este código no sharplab.io e selecionar C# no menu suspenso Results,  você verá que o compiladorC# reescreveu seu programa (ou para manter a terminologia técnica desta transformação, o compilador Lowered o cópdigo), mais especificamente a chamada ao método Foo() para algo como:

O código real será um pouco diferente (basicamente ele converterá o loop for em um while e introduzirá algumas variáveis locais extras), mas decidi ignorar isso e mantê-lo o mais próximo possível do original para fins de comparação.

Observe que o compilador introduziu uma variável local do tipo do nosso handler  (linha 4) e fez algumas chamadas a métodos passando as várias partes da string interpolada; basicamente ele separou a string nos limites `{...}` e para cada parte, executou o método handler.AppendLiteral(part); (linha 7) seguido de handler.AppendFormatted(conteúdo da expressão {...}) (liha 8) .

Observe também que essas chamadas são agrupadas em um if (linha 5) controlado por uma variável que foi inicializada pelo construtor de nosso manipulador de string interpolado e voilà, temos todas as partes necessárias: o método que usa nosso manipulador especifica que um ou mais de seus parâmetros (neste caso apenas um) deve ser passado para o construtor do manipulador de string (através do InterpolatedStringHandlerArgumentAttribute) que ele usa para decidir se aquela string deve ser processada ou não, definindo um parâmetro ref bool (declarado como o último parâmetro do construtor) levando o código reduzido a pular as chamadas para os métodos AppendX()  e, portanto, a instância do manipulador de string passado para Foo() produz uma string vazia que falha a condição (if linha 5 do programa inicial) ignorando a chamado ao método Console.WriteLine()!

Legal, mas antes de finalizar, aqui estão algumas melhorias/considerações de desempenho/usabilidade que esse recurso traz:

  1. DefaultInterpolatedStringHandler é, como o próprio nome indica, usado sempre que uma string interpolada é passada para um método que usa strings como parâmetro, portanto, simplesmente recompilar seu código baseado em C# 9 com C# 10 deve trazer pelo menos algumas melhorias de desempenho/alocações.

  2. Uma vez que DefaultInterpolatedStringHandler é declarado como uma ref struct, sua instanciação não causará uma alocação no heap (em geral, todos os Interpolated String Handlers devem ser uma ref struct para evitar tais alocações).

  3. Uma vez que String.Format() não esa mais sendo usado, nenhum array é alocado.

  4. As alocações podem ser completamente evitadas quando o argumento especificado por meio de InterpolatedStringHandlerArgumentAttribute define  que a string resultante não será utilisada (muito útil em código do tipo logging/assertions)

  5. Esteja ciente que no caso do handler reportar que a string resultante não será utilizada (veja ponto anterior) side effects podem não ser observados; por exemplo, se nós modfificarmos o conteúdo da string interpolada (linha 10) de $"Test {i}" para $"Test {SomeMethod(i)}", o método SomeMethod() não será executado para valores ímpares  de o que não é óbvio apenas olhando a chamada ao método Foo() no código fonte to programa.

  6. Para minimizar ainda mais possíveis alocações no heap, considere implementar  ISpanFormattable em seus próprios tipos, se eles puderem ser usados como expressões em strings interpoladas (varios tipos da BLC o fazem).
Por fim, se você deseja saber mais sonbre esse recurso recomendo fortemente assistir este video e ler este tutorial da MS.

Divirta-se!

Adriano

Une petit casse-têtes C# 10 - La solution (Part III)

Leia este post em português

Read this post in English.

Dans les derniers messages, j'ai lancé un défi pour modifier la sortie du programme suivant pour afficher uniquement les lignes avec des nombres pairs (allez lire les parties I et II si vous ne l'avez pas déjà fait).

J'ai également dit que si vous regardez attentivement dans le code, vous pourrez trouver des indices sur la direction de la solution basée sur des morceaux de code qui n'étaient pas essentiels dans cette version du code qui contient essentiellement 2 de ces morceaux de code :

  1. l'utilisation de System.Runtime.CompilerServices qui n'est pas nécessaire car nous ne référençons aucun type déclaré dans cet espace de noms.

  2. le if sur la ligne 5 de la méthode Foo() qui, clairement n'est pas nécessaire car le programme ne transmet que des chaînes non nulles/vides à cette méthode.

Comme je l'ai laissé entendre, la solution que j'ai trouvée explore C# interpolated string handlers, une fonctionnalité introduite dans C#10.

Avec cet information la, nous pouvons atteindre notre objectif en simplement remplacent le type de paramètre msg par un interpolated string handler personnalisé (InterpolatedHandlerTrick dans le code ci-dessous) comme suit:

Si vous exécutez le programme ci-dessus, vous verrez qu'il fonctionne effectivement, c'est-à-dire que vous ne devriez voir que les lignes avec des nombres pairs dans la sortie. Avant de plonger dans les détails, examinons ce qui est nécessaire pour implémenter des interpolated string handler personnalisé :
  1. Déclarez un type et ajoutez-y l'InterpolatedStringHandlerAttribute (ligne #14)

  2. Implémenter un constructeur prenant au moins deux integers (literalLength et formattedCount) (notre implémentation déclare des paramètres supplémentaires pour profiter de certaines fonctionnalités  plus avancées, qui seront discutées plus tard)

  3. Mettre en œuvre les méthodes 
    1. void AppendLiteral(string msg)
    2. void AppendFormatted<T>(valeur T)
et c'est tout.

C'est tout belle et bien mais la question intéressante est : pourquoi ça marche ?

Si vous collez ce code dans
sharplab.io et sélectionnez C# dans le menu déroulant Résultats, vous verriez que le compilateur C# a réécrit votre programme (ou, pour s'en tenir à la terminologie technique de cette transformation, le compilateur a réduit le code), plus précisément l'appel à la méthode Foo() à quelque chose comme :

Le code réel sera un peu différent (essentiellement, il convertira la boucle for en un while et introduira des variables locales supplémentaires) mais j'ai décidé de l'ignorer et de le garder aussi proche que possible de l'original, ce qui simplifie le raisonnement.

Notez que le compilateur a introduit une variable locale typée comme notre interpolated string handler (ligne 4) et a effectué des appels de méthode dessus en passant les différentes parties de la chaîne interpolée ; fondamentalement, il l'a divise sur les limites `{...}` et pour chaque partie, il a appelé handler.AppendLiteral(part); (ligne 7) suivi pour handler.AppendFormatted(contents of {...}) (ligne 8) .

Notez également que ces appels sont enveloppés dans un if (ligne 5) contrôlé par une variable qui a été initialisée par le constructeur de notre handler et voilà, nous avons tous les éléments dont nous avons besoin pour implémenter le comportement souhaité ; la méthode prenant notre interpolated string handler spécifie qu'un ou plusieurs de ses paramètres (dans ce cas un seul) doivent être passés au constructeur de notre gestionnaire de chaîne (via InterpolatedStringHandlerArgumentAttribute) qu'il utilise pour décider si cette chaîne doit être traitée ou non par définir un paramètre ref bool (déclaré comme dernier paramètre du constructeur) conduisant le code réduit à ignorer les appels aux méthodes AppendX() et ainsi l'instance du gestionnaire de chaîne passé à Foo() produit une chaîne vide qui échoue la condition et saute Méthode Console.WriteLine() !

Cool, mais avant de vous quitter, voici quelques améliorations/considérations de performances que cette fonctionnalité apporte à la table :
  1. DefaultInterpolatedStringHandler est, comme son nom l'indique, utilisé chaque fois qu'une chaîne interpolée est transmise à une méthode qui prend une string comme paramètre, donc, simplement en recompilent votre code basé sur C# 9 avec C# 10 devrait vous apporter au moins quelques améliorations de performances/allocations.

  2. Étant donné que DefaultInterpolatedStringHandler est déclaré en tant que structure ref, son instanciation ne provoquera pas d'allocation de tas (en général, tous les gestionnaires de chaînes interpolées doivent être une structure ref pour éviter de telles allocations).

  3. Étant que String.Format() n'est plus utilisé, aucun array n'est alloué.

  4. Les allocations peuvent être complètement évitées lorsque l'argument spécifié via InterpolatedStringHandlerArgumentAttribute définit que la chaîne résultante ne sera pas utilisée (très utile dans la journalisation et le code d'assertion)

  5. Sachez que si le gestionnaire de chaîne interpolée signale que la chaîne résultante ne sera pas utilisée (voir le point ci-dessus), aucun effet secondaire lié à l'expression dans cette partie de la chaîne interpolée ne sera observé ; par exemple, si nous changeons la valeur interpolée dans le puzzle (ligne 10) de $"Test {i}" à $"Test {SomeMethod(i)}", SomeMethod() ne sera pas invoquée lorsque i est impair, ce qui peut ne pas être le cas être évident uniquement en inspectant l'appel à Foo()

  6. Pour minimiser les allocations, envisagez d'implémenter ISpanFormattable sur vos propres types s'ils peuvent être utilisés dans des chaînes interpolées (divers types de BLC implémentent cella).
Enfin, si vous souhaitez en savoir plus sur cette fonctionnalité, je vous recommande fortement de regarder cette vidéo et de lire ce tutoriel MS.

Have fun!

Adriano

A small C# 10 programming puzzle - Answer (Part III)

Leia este post em português

Lire cet post en Français.

In the last posts I've posed a challenge to change the output of the following program to show only lines with even numbers (go read parts I & II if you haven't done it yet).

I've also said that if you look carefully in the code you would be able to find some clues on the direction of the solution based on pieces of code that were not strictly  required in that version of the code which basically contains 2 such pieces of code:
  1. the using System.Runtime.CompilerServices which  is not required as we are not referencing any types declared in that namespace.
     
  2. the if on line 5 of Foo() method is clearly not necessary as the program only passes non null/empty strings to that method.

As I hinted, the solution I came up explores C# string interpolation handlers, a feature introduced in C#10.

With that information, one way to achieve our goal consists in simply replacing the type of msg parameter with a custom string interpolation handler (InterpolatedHandlerTrick in the code below) as follows:

If you run the above program, you'll see that it indeed works, i.e, you should see only the lines with even numbers in the output. Before diving into the details lets take a look on what is needed to implement custom interpolated string handlers:
  1. Declare a type and add the InterpolatedStringHandlerAttribute to it (line #14)
  2. Implement a constructor taking  at least two integers (literalLength and formattedCount) (our implementation declares some extra parameters to take advantage of some more advanced features, which will be discussed later)
  3. Implement the methods
    1. void AppendLiteral(string msg)
    2. void AppendFormatted<T>(T value)
and that is it.
 
That is all nice and cool but the interesting question is: why does it work?

If you paste that code in sharplab.io and select C# from the Results  drop down, you can see that the C# compiler re-wrote your program (or, to stick to the technical terminology for this transformation, the compiler lowered the code), more specifically the call to Foo() method to something like:

The actual code will be a little different (basically it will convert the for loop into a while and introduce some extra local variables) but I decided to ignore that and keep it as close to the original as possible making it simpler to reason about.

Notice that the compiler introduced a local variable typed as our interpolated string handler (line 4) and made some method calls on it passing the various parts of the interpolated string; basically it split it on `{...}` boundaries and for each part it called handler.AppendLiteral(part); (line 7) followed by handler.AppendFormatted(contents of {...}) (line 8) .

Notice also that those calls are wrapped in an if (line 5) controlled by a variable that has been initialized by the constructor of our interpolated string handler and voilà, we have all the required pieces: the method taking our string handler specifies that one or more of its parameters (in this case only one) must be passed to the constructor of our string handler (through the InterpolatedStringHandlerArgumentAttribute) which it uses to decide whether that string should be processed or not by setting a ref bool parameter (declared as the last constructor parameter) leading the lowered code to skip the calls to the AppendX() methods and so the instance of the string handler passed to Foo() produces and empty string which fails the condition and skips Console.WriteLine() method!

Cool, but before I leave you, here are some performance improvements/considerations this feature brings to the table:

  1. DefaultInterpolatedStringHandler is, at its name implies, used whenever an interpolated string is passed to a method that takes strings as a parameter, so, simply recompiling your C# 9 based code with C# 10 should bring you at least some performance/allocations improvements.

  2. Since DefaultInterpolatedStringHandler is declared as a ref struct its instantiation will not cause a heap allocation (in general all interpolated string handlers should be a ref struct to avoid such allocations).

  3. Since  String.Format() is not being used anymore no arrays are allocated.

  4. Allocations may be completely avoided when the argument specified through InterpolatedStringHandlerArgumentAttribute defines that the resulting string will not be used (very useful in logging and assertion code)

  5. Be aware that if the interpolated string handler reports that the resulting string will not be used (see point above), any related side effects of the expression in that part of the interpolated string will not be observed; for instance, if we change the interpolated value in the puzzle (line 10) from $"Test {i}" to $"Test {SomeMethod(i)}", SomeMethod() will not be invoked when i is odd which may not be obvious only inspecting the call to Foo()

  6. To minimize allocations even further, consider implementing ISpanFormattable on your own types if they may be used as expressions in interpolated strings (various types from BLC does implement that).
Finally, If you want to learn more about this feature I highly recommend to watch this video and read this MS tutorial.

Have fun!

Adriano