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

No comments: