C# 筆記:Expression Trees

本文要介紹的是 expression trees,屬於進階議題,是寫給喜歡「往下挖深一點」的朋友看的。在進入正題之前,會先簡短複習一下 lambda expressions 的基本觀念。

如果你對匿名函式、匿名方法、lambda 表示式三者間的關係還不是很清楚,在另一篇文章<C# 學習筆記-委派與 Lambda 表示式>裡面有一張匿名函式的概念圖,應該會有些幫助。

Lambda Expressions

Lambda expressions 有兩種寫法,而且這兩種寫法都有正式的名稱,一種叫做陳述式lambda(statement lambda),另一種是運算式 lambda(expression lambda)。

這兩種寫法的主要差異是:
  • 陳述式 lambda 一定有一對大括弧,用來包住任意行數的程式碼(通常只有兩三行)。
  • 運算式 lambda 由於只有一行敘述,故無需大括弧。如果有傳回值,陳述式 lambda 必須使用關鍵字 return 來傳回值。運算式 lambda 則不用。
使用這兩種寫法所寫出來的程式敘述,我傾向叫它們「lambda 陳述式」和「lambda 運算式」。當然這只是取捨的結果,不見得最好。

基本上,lambda 運算式大都可以很容易轉換成 lambda 陳述式,例如:

(x, y) => x + y

這行運算式只要加上一對大括弧和關鍵字 return,就成了 lambda 陳述式:

(x, y) => { return x + y; }

運算式樹

其實,陳述式 lambda運算式 lambda 還有一個不同之處:陳述式 lambda 只能被轉換成委派物件,而運算式 lambda除了可以轉換成委派物件,還可以轉換成運算式樹(expression tree)。或者用比較長的稱呼:運算式樹狀結構。往後碰到這個術語時,我大部分會直接使用英文。

先來看一個例子。這裡我直接使用 .NET Framework 內建的泛型委派 Func<T, TResult> 來示範。程式碼如下:

Func<int, int> fn = n => n * n;
Console.WriteLine(fn(10));    // 印出 100.

此範例建立了一個泛型委派物件,其委派方法需要傳入一個整數,且傳回值也是整數。委派方法的實作,是將傳入的整數做平方運算,然後傳回運算的結果。到目前為止所用到的語法,應該都是你所熟悉的。接著我們把它稍微修改一下:

Expression<Func<int, int>> expr = n => n * n;  // 將運算式轉換成樹狀資料結構
Func<int, int> fn = expr.Compile();  // 將樹狀結構逆向轉為程式碼,並存入委派物件.
Console.WriteLine(fn(10));    // 印出 100.

哇!這是什麼寫法?原本的泛型委派 Func<int, int> 又被當作泛型的型別參數傳入另一個泛型委派 Expression<T> 了!(此範例程式碼必須引用命名空間 System.Linq.Expressions)。

Expression<T> 的原型宣告是:

public sealed class Expression<TDelegate> : LambdaExpression

我們從它的型別參數的命名就能看得出來,此泛型委派所要處理的對象就是委派物件。當編譯器看到你將一段 lambda運算式丟給(指派給)Expression<T> 的時候,例如範例中的這行程式碼:

Expression<Func<int, int>> expr = n => n * n;  // 將運算式轉換成樹狀資料結構

編譯器會產生一串挺複雜的程式碼,來將你提供的運算式轉換成 Expression<T> 所能接受的運算格式──樹狀結構的資料,也就是本節的主角:expression tree。剛才這句話隱約透露一個重點:你所撰寫的 lambda 運算式(匿名函式),現在變成了一種資料結構。簡單地說,就是把程式碼當成資料(code as data,或 function as data)來處理了。

把程式碼轉換成樹狀結構的運算式資料之後,便可以對這些運算式進行修改(修改 expression tree 的部分稍後會介紹),然後等到要執行運算式時,只要呼叫 Expression<T> 的 Compile 方法,就能夠將運算式資料結構逆向轉為程式碼(匿名函式),並呼叫之。例如先前範例的最後兩行程式碼:

Func<int, int> fn = expr.Compile();  // 將樹狀結構逆向轉為程式碼,並存入委派物件.
Console.WriteLine(fn(10));


將程式碼轉換成樹狀結構

剛才提到,當編譯器看到你將 lambda運算式丟給(指派給)Expression<T> 的時候,就會產生一段程式碼來將運算式轉成樹狀結構。這裡要進一步探索其背後的細節,看看編譯器到底產生了哪些程式碼。同樣使用先前的範例:

Expression<Func<int, int>> expr = n => n * n;  // 將運算式轉換成樹狀資料結構

這行程式碼若使用 ILDASM 工具查看反組譯出來的 IL 代碼,會有點複雜。我用 .NET Reflector 工具把反組譯之後的 C# 程式碼再稍微加工一下,改成比較易讀的版本,如下所示:

ParameterExpression pe = Expression.Parameter(typeof(int), "n");
Expression<Func<int, int>> expr =
    Expression<Func<int, int>>.Lambda<Func<int, int>>(
        Expression.Multiply(pe, pe),
        new ParameterExpression[] { pe }
    );
Console.WriteLine(expr.Compile()(10));  // 印出 100.

你可以將這段程式碼直接貼到你的程式裡,它是可以通過編譯的(記得要引用命名空間 System.Linq.Expressions),而且在某些應用場合也的確需要使用這種繁複的語法(例如透過 expression tree 來建立動態查詢)。

此範例程式的第一行是在建立一個運算式參數,型別是整數,參數名稱是 “n”。你可以看到,編譯器是根據你提供的 lambda 運算式來產生對應的參數物件。下一行程式碼的作用是建立一個兩數相乘的運算式,它的寫法比較複雜一點,我們逐一來拆解。首先要看的是:

Expression.Multiply(pe, pe)

此方法呼叫會建立一個乘法運算式,其中傳入的兩個參數,即代表用來相乘的那兩個運算元。如果你原先的 lambda 運算式是兩數相加(例如:n+n),那麼編譯器就會改用 Expression.Add 方法。

Expression.Multiply 和 Expression.Add 方法各代表乘法和加法運算式。它們都是二元運算式,亦即需要兩個運算元(所以都要傳入兩個參數)。實際上,這兩個方法都會建立 BinaryExpression 的物件實體。這是一種工廠方法(factory method)  的寫法──你無法利用 new 來建立 BinaryExpression 實體,而必須藉由特定的工廠方法來建立物件實體。


建立 BinaryExpression 物件之後,接著就是要將運算式的參數(n)跟我們的運算式(n*n)透過 Expression<Func<int, int>> 綁在一起。這個繫結動作是藉由呼叫泛型方法 Expression<T>.Lambda<T> 來達成。此方法呼叫的程式碼如下:

Expression<Func<int, int>>.Lambda<Func<int, int>>(
    Expression.Multiply(pe, pe),
    new ParameterExpression[] { pe }
);

Expression<T>.Lambda<T> 方法的用途就是將一個 lambda 運算式轉換成一棵對應的 expression tree。第一個傳入參數代表運算式的物件,第二個參數則是代表該運算式所需之運算元的參數陣列。

此範例程式所建立之 expression tree 的物件結構如圖 5所示,其中粗體字的部分是物件的屬性名稱,屬性名稱的右邊、接在冒號之後的是該屬性的型別,下方則為屬性值。



經過此番挖掘,對於「 lamdba 運算式可以轉換為 expression tree」這句話的涵義,應該就會更清楚了。

結語

對一般的應用程式來說,可能很少會需要將 lambda 運算式轉換成 expression tree 的資料結構。不過,如果有用到 LINQ 的話,可能就碰得到了。

無論如何,對其背後運作的機制有個概念,還是挺好的。如本文開頭所說,是寫給喜歡「往下挖深一點」的朋友看的。

沒有留言:

技術提供:Blogger.
回頂端⬆️