Compartilhar via


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

Uma árvore de expressão é uma estrutura de dados que define o código. As árvores de expressão são baseadas nas mesmas estruturas que um compilador usa para analisar o código e gerar a saída compilada. Ao ler este artigo, você observará um pouco de similaridade entre árvores de expressão e os tipos usados nas APIs Roslyn para criar Analisadores e CodeFixes. (Analisadores e 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 examinar o código-fonte de forma significativa. No entanto, as árvores de expressão são baseadas em um conjunto diferente de classes e APIs que as APIs Roslyn. Aqui está uma linha de código:

var sum = 1 + 2;

Se você analisar o código anterior como uma árvore de expressão, a árvore conterá 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 + 2uma expressão binária. Mais especificamente, é uma expressão de adição binária. Uma expressão de adição binária tem dois filhos, representando 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 1e o operando à direita é o valor 2.

Visualmente, a instrução inteira é uma árvore: você pode começar no nó raiz e viajar para cada nó na árvore para ver o código que compõe 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 poderosa. Após o mesmo processo, você decompõe 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 podem ser os diferentes componentes. Há chamadas de método que utilizam o objeto atual como receptor: algumas possuem um receptor explícito this, enquanto outras não. Há chamadas de método usando outros objetos receptores, há argumentos constantes de diferentes tipos. Por fim, há um operador de adição binária. 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 dessa complexidade percebida, a expressão anterior cria uma estrutura de árvore que pode ser navegada tão facilmente quanto o 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 as noções básicas, você entenderá até mesmo o código mais complexo quando ele é representado como uma árvore de expressão. A elegância na estrutura de dados explica como o compilador C# analisa os programas C# mais complexos e cria uma saída adequada desse código-fonte complicado.

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

Além de traduzir algoritmos a serem executados em outros ambientes, as árvores de expressão facilitam a gravação de algoritmos que inspecionam o código antes de executá-lo. Você escreve um método cujos argumentos são expressões e, em seguida, examina essas expressões antes de executar o código. A Árvore de Expressão é uma representação completa do código: você vê valores de qualquer subexpressão. Você vê os nomes dos métodos e das propriedades. Você vê o valor de qualquer expressão 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 qualquer construção de código válida. No entanto, para manter as coisas o mais simples possível, alguns idiomas C# não podem ser criados em uma árvore de expressão. Um exemplo é expressões assíncronas (usando as palavras-chave async e await). Se suas necessidades exigirem algoritmos assíncronos, você precisará manipular os Task objetos diretamente, em vez de contar com o suporte do compilador. Outro exemplo é na criação de loops. Normalmente, você cria esses loops usando for, foreachwhile ou do loops. Como você vê posteriormente nesta série, as APIs para árvores de expressão dão suporte a uma única expressão de loop, com break e continue expressões que controlam a repetição do loop.

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