Uttrycksträd – data som definierar kod

Ett uttrycksträd är en datastruktur som definierar kod. Uttrycksträd baseras på samma strukturer som en kompilator använder för att analysera kod och generera kompilerade utdata. När du läser den här artikeln ser du en hel del likheter mellan uttrycksträd och de typer som används i Roslyn-API:erna för att skapa Analysverktyg och CodeFixes. (Analysverktyg och CodeFixes är NuGet-paket som utför statisk analys av kod och föreslår potentiella korrigeringar för en utvecklare.) Begreppen liknar varandra och slutresultatet är en datastruktur som gör det möjligt att undersöka källkoden på ett meningsfullt sätt. Uttrycksträd baseras dock på en annan uppsättning klasser och API:er än Roslyn-API:erna. Här är en kodrad:

var sum = 1 + 2;

Om du analyserar föregående kod som ett uttrycksträd innehåller trädet flera noder. Den yttersta noden är en variabeldeklarationssats med tilldelning (var sum = 1 + 2;) Den yttersta noden innehåller flera underordnade noder: en variabeldeklaration, en tilldelningsoperator och ett uttryck som representerar den högra sidan av likhetstecknet. Uttrycket delas ytterligare upp i uttryck som representerar adderingsoperationen samt vänster och höger operander vid additionen.

Nu ska vi gå lite djupare i de uttryck som bildar höger sida av likhetstecknet. Uttrycket är 1 + 2, ett binärt uttryck. Mer specifikt är det ett binärt additionsuttryck. Ett binärt additionsuttryck har två barn, som representerar de vänstra och högra noderna i additionsuttrycket. Här är båda noderna konstanta uttryck: Den vänstra operanden är värdet 1och den högra operanden är värdet 2.

Visuellt är hela -instruktionen ett träd: Du kan börja vid rotnoden och resa till varje nod i trädet för att se koden som utgör -instruktionen:

  • Variabeldeklarationssats med tilldelning (var sum = 1 + 2;)
    • Implicit deklaration av variabeltyp (var sum)
      • Implicit var nyckelord (var)
      • Deklaration av variabelnamn (sum)
    • Tilldelningsoperator (=)
    • Binärt tilläggsuttryck (1 + 2)
      • Vänster operand (1)
      • Additionsoperator (+)
      • Höger operand (2)

Det föregående trädet kan se komplicerat ut, men det är mycket kraftfullt. Efter samma process sönderdelas mycket mer komplicerade uttryck. Överväg det här uttrycket:

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

Föregående uttryck är också en variabeldeklaration med en tilldelning. I det här fallet är den högra sidan av tilldelningen ett mycket mer komplicerat träd. Du kommer inte att dela upp det här uttrycket, men fundera på vilka de olika noderna kan vara. Det finns metodanrop som använder det aktuella objektet som mottagare, en som har en explicit this mottagare, en som inte har det. Det finns metodanrop som använder andra mottagarobjekt, det finns konstanta argument av olika typer. Och slutligen finns det en binär additionsoperator. Beroende på returtypen av SecretSauceFunction() eller MoreSecretSauce(), kan den binära additionsoperatorn vara ett metodanrop till en åsidosatt additionsoperator, vilket resulterar i ett statiskt metodanrop till den binära additionsoperator som definierats för en klass.

Trots den upplevda komplexiteten skapar föregående uttryck en trädstruktur som navigeras lika enkelt som det första exemplet. Du fortsätter att navigera genom underordnade noder för att hitta bladnoder i uttrycket. Överordnade noder har referenser till sina underordnade noder och varje nod har en egenskap som beskriver vilken typ av nod den är.

Strukturen för ett uttrycksträd är mycket konsekvent. När du har lärt dig grunderna förstår du även den mest komplexa koden när den representeras som ett uttrycksträd. Elegansen i datastrukturen förklarar hur C#-kompilatorn analyserar de mest komplexa C#-programmen och skapar korrekta utdata från den komplicerade källkoden.

När du har bekantat dig med strukturen för uttrycksträd upptäcker du att den kunskap du har fått snabbt gör att du kan arbeta med många mer avancerade scenarier. Det finns en otrolig kraft i uttrycksträd.

Förutom att översätta algoritmer som ska köras i andra miljöer gör uttrycksträd det enklare att skriva algoritmer som inspekterar kod innan den körs. Du skriver en metod vars argument är uttryck och undersöker sedan dessa uttryck innan du kör koden. Uttrycksträdet är en fullständig representation av koden: du ser värden för alla underuttryck. Du ser metod- och egenskapsnamn. Du ser värdet för alla konstanta uttryck. Du konverterar ett uttrycksträd till ett körbart delegering och exekverar koden.

Med API:erna för uttrycksträd kan du skapa träd som representerar nästan alla giltiga kodkonstruktioner. Men för att hålla saker så enkla som möjligt kan vissa C#-idiom inte skapas i ett uttrycksträd. Ett exempel är asynkrona uttryck (med nyckelorden async och await ). Om dina behov kräver asynkrona algoritmer måste du ändra objekten Task direkt i stället för att förlita dig på kompilerarens stöd. En annan är att skapa loopar. Vanligtvis skapar du dessa loopar genom att använda for, foreach, while eller do loopar. Som du ser senare i den här serien stöder API:erna för uttrycksträd ett enda looputtryck, med break och continue uttryck som styr upprepande av loopen.

Det enda du inte kan göra är att ändra ett uttrycksträd. Uttrycksträd är oföränderliga datastrukturer. Om du vill mutera (ändra) ett uttrycksträd måste du skapa ett nytt träd som är en kopia av originalet, men med önskade ändringar.