Árvores de expressão – Dados que definem o código

Uma Árvore de expressão é uma estrutura de dados que define o código. As árvores de expressão se baseiam nas mesmas estruturas que um compilador usa para analisar o código e gerar a saída compilada. Ao ler este artigo, você notará certa semelhança entre as árvores de expressão e os tipos usados nas APIs Roslyn para criar Analyzers e CodeFixes. (Os Analyzers e os CodeFixes são pacotes NuGet que executam análise estática no código e sugerem possíveis correções para um desenvolvedor.) Os conceitos são semelhantes e o resultado final é uma estrutura de dados que permite o exame do código-fonte de maneira significativa. No entanto, as árvores de expressão são baseadas em um conjunto de classes e APIs totalmente diferentes das APIs Roslyn. Aqui está uma linha de código:

var sum = 1 + 2;

Ao analisar o código anterior como uma árvore de expressão, é possível perceber que a árvore contém vários nós. O nó mais externo é uma instrução de declaração de variável com atribuição (var sum = 1 + 2;). Esse nó mais externo contém vários nós filho: uma declaração de variável, um operador de atribuição e uma expressão que representa o lado direito do sinal de igual. Essa expressão é ainda subdividida em expressões que representam a operação de adição e os operandos esquerdo e direito da adição.

Vamos detalhar um pouco mais as expressões que compõem o lado direito do sinal de igual. A expressão é 1 + 2, uma expressão binária. Mais especificamente, ela é uma expressão de adição binária. Uma expressão de adição binária tem dois filhos, que representam os nós esquerdo e direito da expressão de adição. Aqui, ambos os nós são expressões constantes: o operando esquerdo é o valor 1 e o operando direito é o valor 2.

Visualmente, a declaração inteira é uma árvore: você pode começar no nó raiz e viajar até cada nó da árvore para ver o código que constitui a instrução:

  • Instrução de declaração de variável com atribuição (var sum = 1 + 2;)
    • Declaração de tipo de variável implícita (var sum)
      • Palavra-chave var implícita (var)
      • Declaração de nome de variável (sum)
    • Operador de atribuição (=)
    • Expressão de adição binária (1 + 2)
      • Operando esquerdo (1)
      • Operador de adição (+)
      • Operando direito (2)

A árvore anterior pode parecer complicada, mas é muito eficiente. Seguindo o mesmo processo, é possível decompor expressões muito mais complicadas. Considere esta expressão:

var finalAnswer = this.SecretSauceFunction(
    currentState.createInterimResult(), currentState.createSecondValue(1, 2),
    decisionServer.considerFinalOptions("hello")) +
    MoreSecretSauce('A', DateTime.Now, true);

A expressão anterior também é uma declaração de variável com uma atribuição. Neste exemplo, o lado direito da atribuição é uma árvore muito mais complicada. Você não vai decompor essa expressão, mas considere quais seriam os diferentes nós. Há chamadas de método usando o objeto atual como receptor, uma com um receptor this explícito e outra não. Há chamadas de método usando outros objetos receptores, há argumentos constantes de tipos diferentes. E, por fim, há um operador de adição binário. Dependendo do tipo de retorno de SecretSauceFunction() ou MoreSecretSauce(), esse operador de adição binária pode ser uma chamada de método para um operador de adição substituído, resolvendo em uma chamada de método estático ao operador de adição binária definido para uma classe.

Apesar da complexidade, a expressão anterior cria uma estrutura de árvore tão fácil de se navegar quanto a do primeiro exemplo. Você continua percorrendo os nós filho para encontrar os nós folha na expressão. Os nós pai terão referências aos filhos, sendo que cada nó tem uma propriedade que descreve o tipo de nó.

A estrutura de uma árvore de expressão é muito consistente. Depois de aprender os conceitos básicos, você entenderá até mesmo o código mais complexo, quando ele for representado como uma árvore de expressão. A elegância na estrutura de dados explica como o compilador C# analisa os programas em C# mais complexos e cria a saída apropriada desse código-fonte complicado.

Depois de se familiarizar com a estrutura das árvores de expressão, descobrirá que o conhecimento adquirido permitirá que você trabalhe com muitos outros cenários ainda mais avançados. O potencial das árvores de expressão é incrível.

Além de converter algoritmos para serem executados em outros ambientes, com as árvores de expressão, você pode escrever facilmente algoritmos que inspecionam o código antes de executá-lo. Você escreverá um método cujos argumentos são expressões e, depois, examinará essas expressões antes de executar o código. A árvore de expressão é uma representação completa do código: é possível ver os valores de qualquer subexpressão. Você vê os nomes dos métodos e das propriedades. Você vê o valor de qualquer expressão de constante. Você converte uma árvore de expressão em um delegado executável e executa o código.

As APIs para árvores de expressão permitem criar árvores que representam quase todos os constructos de código válidos. No entanto, para manter as coisas o mais simples possível, não é possível criar algumas expressões em C# em uma árvore de expressão. Um exemplo são as expressões assíncronas (usando as palavras-chave async e await). Se suas necessidades requerem algoritmos assíncronos, você precisa manipular diretamente os objetos Task, em vez de contar com o suporte do compilador. Outro exemplo é na criação de loops. Normalmente, você cria esses loops usando for, foreach, while ou do. Como você verá mais adiante nesta série, as APIs para árvores de expressão dão suporte a uma expressão de loop individual, com expressões break e continue controlando a repetição do loop.

A única coisa que você não pode fazer é modificar uma árvore de expressão. As árvores de expressão são estruturas de dados imutáveis. Se quiser modificar (alterar) uma árvore de expressão, você deverá criar uma nova árvore, que seja uma cópia da original, com as alterações desejadas.