Udostępnij za pośrednictwem


Drzewa wyrażeń — dane definiujące kod

Drzewo wyrażeń to struktura danych, która definiuje kod. Drzewa wyrażeń są oparte na tych samych strukturach, których kompilator używa do analizowania kodu i generowania skompilowanych danych wyjściowych. Podczas czytania tego artykułu zauważysz sporo podobieństwa między drzewami wyrażeń i typami używanymi w interfejsach API Roslyn do kompilowania analizatorów i poprawek kodu. (Analizatory i poprawki kodu to pakiety NuGet, które wykonują analizę statyczną kodu i sugerują potencjalne poprawki dla dewelopera). Koncepcje są podobne, a wynik końcowy to struktura danych, która umożliwia analizowanie kodu źródłowego w zrozumiały sposób. Jednak drzewa wyrażeń są oparte na innym zestawie klas i interfejsów API niż interfejsy API Roslyn. Oto wiersz kodu:

var sum = 1 + 2;

Jeśli analizujesz powyższy kod jako drzewo wyrażeń, drzewo zawiera kilka węzłów. Węzeł najbardziej oddalony jest instrukcją deklaracji zmiennej z przypisaniem (var sum = 1 + 2;) Ten najbardziej zewnętrzny węzeł zawiera kilka węzłów podrzędnych: deklarację zmiennej, operator przypisania i wyrażenie reprezentujące prawą stronę znaku równości. To wyrażenie jest dalej podzielone na wyrażenia, które reprezentują operację dodawania, oraz lewe i prawe operandy dodawania.

Przejdźmy nieco bardziej do wyrażeń, które tworzą prawą stronę znaku równości. Wyrażenie to 1 + 2, wyrażenie binarne. Mówiąc dokładniej, jest to wyrażenie dodawania binarnego. Wyrażenie dodawania binarnego ma dwie elementy podrzędne reprezentujące lewe i prawe węzły wyrażenia dodawania. W tym przypadku oba węzły są wyrażeniami stałymi: lewy operand jest wartością 1, a prawy operand jest wartością 2.

Wizualnie cała instrukcja jest drzewem: możesz rozpocząć od węzła głównego i podróżować do każdego węzła w drzewie, aby zobaczyć kod tworzący instrukcję :

  • Deklaracja zmiennej z przypisaniem (var sum = 1 + 2;)
    • Niejawna deklaracja typu zmiennej (var sum)
      • Niejawne słowo kluczowe var (var)
      • Deklaracja nazwy zmiennej (sum)
    • Operator przypisania (=)
    • Wyrażenie dodawania binarnego (1 + 2)
      • Lewy operand (1)
      • Operator dodawania (+)
      • Prawy operand (2)

Poprzednie drzewo może wyglądać skomplikowane, ale jest bardzo potężne. Po wykonaniu tego samego procesu rozkładasz znacznie bardziej skomplikowane wyrażenia. Rozważ następujące wyrażenie:

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

Powyższe wyrażenie jest również deklaracją zmiennej z przypisaniem. W tym przypadku prawa strona przypisania jest o wiele bardziej skomplikowanym drzewem. Nie zamierzasz rozkładać tego wyrażenia, ale zastanów się, jakie mogą być różne węzły. Są wywołania metod, które używają bieżącego obiektu jako odbiornika: jedne mają jawnego odbiornika this, a inne go nie mają. Istnieją wywołania metod używające innych obiektów odbiornika, istnieją stałe argumenty różnych typów. Na koniec jest binarny operator dodawania. W zależności od typu zwracanego przez SecretSauceFunction() lub MoreSecretSauce(), operator dodawania binarnego może być wywołaniem metody przeciążonego operatora dodawania, przekształconego na wywołanie metody statycznej dla operatora dodawania binarnego zdefiniowanego dla klasy.

Pomimo tej postrzeganej złożoności, poprzednie wyrażenie tworzy strukturę drzewa nawigowaną tak łatwo, jak w pierwszej próbce. Przechodzisz przez węzły podrzędne, aby znaleźć węzły liści w wyrażeniu. Węzły nadrzędne mają odwołania do swoich elementów podrzędnych, a każdy węzeł ma właściwość opisującą, jakiego rodzaju węzłem jest.

Struktura drzewa wyrażeń jest bardzo spójna. Po zapoznaniu się z podstawami rozumiesz nawet najbardziej złożony kod, gdy jest reprezentowany jako drzewo wyrażeń. Elegancja w strukturze danych wyjaśnia, w jaki sposób kompilator języka C# analizuje najbardziej złożone programy w języku C# i tworzy odpowiednie dane wyjściowe na podstawie tego skomplikowanego kodu źródłowego.

Gdy zapoznasz się ze strukturą drzew wyrażeń, przekonasz się, że zdobyta wiedza szybko umożliwia pracę z wieloma bardziej zaawansowanymi scenariuszami. Wyrażeniowe drzewa mają niesamowitą moc.

Oprócz tłumaczenia algorytmów do wykonywania w innych środowiskach drzewa wyrażeń ułatwiają pisanie algorytmów sprawdzających kod przed jego wykonaniem. Należy napisać metodę, której argumenty są wyrażeniami, a następnie zbadać te wyrażenia przed wykonaniem kodu. Drzewo wyrażeń jest pełną reprezentacją kodu: zobaczysz wartości dowolnego podexpressionu. Zobaczysz nazwy metod i właściwości. Zobaczysz wartość dowolnych wyrażeń stałych. Drzewo wyrażeń można przekonwertować na delegata wykonywalnego i wykonać kod.

Interfejsy API drzew wyrażeń umożliwiają tworzenie drzew reprezentujących niemal dowolną prawidłową konstrukcję kodu. Jednak aby zachować tak proste rzeczy, jak to możliwe, niektórych idiomów języka C# nie można utworzyć w drzewie wyrażeń. Jednym z przykładów są wyrażenia asynchroniczne (przy użyciu async słów kluczowych i await ). Jeśli twoje potrzeby wymagają algorytmów asynchronicznych, należy bezpośrednio manipulować Task obiektami, a nie polegać na obsłudze kompilatora. Kolejnym aspektem jest tworzenie pętli. Zazwyczaj te pętle są tworzone przy użyciu pętli for, foreach, while lub do. Jak widać w dalszej części tej serii, interfejsy API drzew wyrażeń obsługują wyrażenie pojedynczej pętli z wyrażeniami break i continue , które kontrolują powtarzanie pętli.

Jedyną rzeczą, której nie można zrobić, jest zmodyfikowanie drzewa wyrażeń. Drzewa wyrażeń to niezmienne struktury danych. Jeśli chcesz modyfikować (zmieniać) drzewo wyrażeń, musisz utworzyć nowe drzewo, które jest kopią oryginału, ale z żądanymi zmianami.