NET 3.5中新增的運算式樹狀架構(Expression Tree)特性,第一次在.NET平台中引入了“邏輯即資料”的概念。也就是說,我們可以在代碼裡使用進階語言的形式編寫一段邏輯,但是這段邏輯最終會被儲存為資料。正因為如此,我們可以使用各種不同的方法對它進行處理。例如,您可以將其轉化為一個SQL查詢,或者外部服務調用等等,這便是LINQ to Everything在技術實現上的重要基石之一。
實事求是地說,.NET 3.5中的運算式樹狀架構的能力較為有限,只能用來表示一個“運算式”,而不能表示“語句”。也就是說,我們可以用它來表示一次“方法調用”或“屬性訪問”,但不能用它來表示一段“邏輯”。不過,微軟在.NET 4.0中增強了這一特性。在.NET 4.0中,我們可以使用運算式樹狀架構構建一段帶有“變數聲明”,“判斷”,“迴圈”的邏輯。當“邏輯”成為“資料”時,我們就擁有了更廣闊的空間來發揮創造力。例如,我們可以將一段使用C#編寫的順序型邏輯,轉化為包含非同步呼叫的用戶端JavaScript代碼,以此快速構建帶有複雜用戶端邏輯的Web應用程式。
不過,即便是.NET 3.5中運算式樹狀架構的“半吊子”特性,也已經顯著加強了.NET平台的能力,甚至改變了我們對於一些事物的使用方式。
運算式樹狀架構的優勢
由於.NET 3.5中的語言(如C# 3.0,VB.NET 9.0)都在文法層面上整合了運算式樹狀架構的構建,因此API設計者可以充分利用運算式樹狀架構的優勢來提供更強大易用的API。優勢主要有三個方面:
強型別
語義清晰
簡化API開發
強型別
就以.NET平台中著名的Mock架構NMock來說,以下代碼將會構造一個ICalculator介面的Mock對象,並指定Sum方法的一組輸入和輸出:
var mocks = new Mockery();
var mockCalculator = mock.NewMock<ICalculator>();
Expect.Once.On(mockCalculator)
.Method("Sum")
.With(1, 2)
.Will(Return.Value(3));與此形成鮮明對比的是,作為.NET平台中Mock架構的後起之秀Moq,充分利用了C# 3.0中的Lambda運算式特性改進了API。因此,以上代碼在Moq中的近似實現便是:
Mock<ICalculator> mock = new Mock<ICalculator>();
mock.Setup(c => c.Sum(1, 2)).Returns(3);
NMock使用字串表示“方法”,使用object數組來表示參數,用object存放傳回值的做法,在Moq中完全變成了強型別的“方法調用”。這樣,開發人員在使用Moq使便可以獲得更好的工具支援,如編輯器的智能提示(Intellisense),編譯器的靜態檢查等等。
語義清晰
從文法上看,使用Lambda運算式構建運算式樹狀架構,與進階語言中最常見的語句並無二致。由於運算式樹狀架構在使用時僅僅是“構建”,而不會真正“執行”,因此API設計者可以把它作為一種天然的DSL。例如在Moq中,我們便可以靈活指定ICalculator對象的行為:
Mock<ICalculator> mock = new Mock<ICalculator>();
mock.Setup(c => c.Divide(It.IsAny<int>(), 0)).Throws(new DivideByZeroException());
mock.Setup(c => c.Divide(0, It.Is<int>(i => i != 0))).Returns(0);
簡化API開發
嚴格說來,“清晰語義”與API設計有關,並非運算式樹狀架構的專利。例如同為.NET平台下的Mock架構,RhinoMocks使用如下的文法來定義Mock對象的行為:
var mocks = new MockRepository();
var mockCalculator = mocks.CreateMock<ICalculator>();
Expect.Call(mockCalculator.Sum(1, 2)).Return(3);這樣的文法可謂不輸於Lambda運算式所體現出來的語義。可是,使用Lambda運算式與否大大影響了實現此類API的難度。在RhinoMocks中,語句執行之時會切切實實地調用Sum方法,於是我們就必須使用動態類型等.NET進階技術來實現這樣的文法。而在Moq架構中,c => c.Sum(1, 2)這樣的代碼會被構建為一顆運算式樹狀架構,成為“資料”,並不會對Sum方法產生任何調用。而API設計者所要做的,僅僅是對這些資料進行分析,以擷取API使用者所希望表現出的含義而已。
運算式樹狀架構的計算
對錶達式樹進行計算,是處理運算式樹狀架構時中最常見的工作了。幾乎可以這麼說,任何處理運算式樹狀架構的工作都無法迴避這個問題。在這裡,“運算式樹狀架構的計算”是指將一個複雜的運算式樹狀架構轉化為一個常量。例如,中左側的運算式樹狀架構,便可以轉化為右側的常量。
請注意,右側的結果是一個常量,而不是一個ConstantExpression對象。當然,我們在必要的時候,也可以重新構造一個ConstantExpression對象,以便組成新的運算式樹狀架構供後續分析。這個例子非常簡單,而在實際的使用過程中遇到的運算式往往會複雜的多,他們可能包含“物件建構”、“下標訪問”、“方法調用”、“屬性讀取”以及“?:”三目運算子等各種成員。它們的共同點,便是繼承於Expression這一基類,並且最終都可以被計算為一個常量。
傳統的運算式樹狀架構的計算方式,是將其進行Compile為一個強型別的委派物件並加以執行,如下:
Expression<Func<DateTime>> expr = () => DateTime.Now.AddDays(1);
Func<DateTime> tomorrow = expr.Compile();
Console.WriteLine(tomorrow());
如果是要計算一個類型不明確的運算式樹狀架構,那麼我們便需要要寫一個通用的Eval方法,如下:
static object Eval(Expression expr)
{
LambdaExpression lambda = Expression.Lambda(expr);
Delegate func = lambda.Compile();
return func.DynamicInvoke(null);
}
static void Main(string[] args)
{
Expression<Func<DateTime>> expr = () => DateTime.Now.AddDays(1);
Console.WriteLine(Eval(expr.Body));
}
簡單說來,計算運算式樹的通用方法會分三步走:
將運算式樹狀架構封裝在一個LambdaExpression對象
調用LambdaExpression的Compile方法動態產生一個委派物件
使用DynamicInvoke方法調用該委派物件,擷取其傳回值
Compile方法在內部使用了Emit,而DynamicInvoke方法其本質與反射調用差不多,因此這種通用的運算式計算方法會帶來相對較為可觀的開銷。尤其是在某些情境中,很容易出現大量運算式樹狀架構的計算操作。例如,在開發ASP.NET MVC應用程式的視圖時,“最佳實務”之一便是使用支援運算式樹狀架構的輔助方法來構造連結,例如:
<h2>Article List</h2> <% foreach (var article in Model.Articles) { %><div> <%= Html.ActionLink<ArticleController>(c => c.Detail(article.ArticleID, 1), article.Title) %>
<% for (var page = 2; page <= article.MaxPage; page++) { %> <small> <%= Html.ActionLink<ArticleController>(c => c.Detail(article.ArticleID, page), page.ToString()) %> </small> <% } %>
</div><% } %>上述代碼的作用,是在文章列表頁上產生一系列指向文章詳細頁的連結。那麼在上面的代碼中,將會出現多少次運算式樹狀架構的計算呢?
Html.ActionLink<ArticleController>(c => c.Detail(article.ArticleID, 1), article.Title)
Html.ActionLink<ArticleController>(c => c.Detail(article.ArticleID, page), article.Title)可以看出,每篇文章將進行(2 * MaxPage – 1)次計算,對於一個擁有數十篇文章的列表頁,計算次數很可能逾百次。此外,再加上頁面上的各種其它元素,如分類列表,Tag Cloud等等,每產生一張略為複雜的頁面便會造成數百次的運算式樹狀架構計算。從Simone Chiaretta的效能測試上來看,使用運算式樹狀架構產生連結所花時間,大約為直接使用字串的30倍。而根據我的本地測試結果,在一台P4 2.0 GHz的伺服器上,單線程連續計算一萬個簡單的四則運算運算式便要花費超過1秒鐘時間。這並非是一個可以忽略的效能開銷,引入一種效能更好的運算式樹狀架構計算方法勢在必行。
減少Compile開銷
如果您仔細比較Compile方法和DynamicInvoke方法的開銷,您會發現前者佔據了總耗時的90-95%。這意味著傳統計算方式的效能瓶頸在於其編譯過程,這也是我們首要進行最佳化的目標。
減少編譯次數,就意味著複用編譯的結果,便是緩衝。如果使用鍵/值對的緩衝方式,其“值”自然是編譯的結果,即是委派物件。那麼“鍵”呢?我們很容易得知“鍵”肯定是一個運算式樹狀架構。不過,有個問題必須思考,什麼樣的運算式樹狀架構適合作為“鍵”?例如,“(5 + 2) * 3”這樣的運算式是否可以直接作為“鍵”來使用?
很顯然,當我們再次遇上“(5 + 2) * 3”這樣的運算式,我們便可直接獲得之前編譯所得的委派物件。如果兩個運算式樹狀架構“全等”自然不在話下——在這裡“全等”的定義是“兩個運算式樹狀架構的結構完全相同,其中各個常量的值也對應相等”。但是,這一點在實際使用過程中的價值並不大,因為它至少存在以下幾點問題:
複用性不高。例如之前舉出的例子,迴圈內部每次使用的Article對象或page參數的值都各不相同,每次計算運算式樹時還是需要重新編譯。
常量對應相等,並不是複用編譯結果的必要條件。例如還是那個例子,其實只要Article對象的ArticleID屬性相等即可複用,而我們運算式中的常量是一個完整的article對象。
由於需要判斷兩個對象是否相等,這要求每個需要參與計算的常量都必須正確實現GetHashCode和Equals方法。這是個代價很高的副作用。
既然是要緩衝,則必須要考慮到緩衝的命中率。“全等”的最大問題還是緩衝的命中率過於低下,甚至會導致“還不如不緩衝”的情況發生。不過,當我們仔細分析各種情況後會發現,其實我們可以有更好的方式來複用編譯結果。
在一個項目中,只要不是動態構建運算式樹狀架構,那麼其中可能會出現的運算式樹狀架構的“結構”肯定是有限的。還是拿之前的例子來說,我們雖然有許多次迴圈,但是需要計算的運算式只有兩種不同的結構:article.ArticleID和page——而不同的計算,只是使用不同的“值”去填充常量的位置而已。同樣道理,運算式“(5 + 2) * 3”與“(4 + 6) * 7”的結構完全相同。因此,我們可以在對一棵運算式樹狀架構進行計算時,可以先將其“結構化”,如:
如果我們把運算式樹狀架構的所有常量替換成同類型的參數(ParameterExpression)對象,那麼系統中所有的運算式樹狀架構都可以變為有限的幾種結構。它們之間的區別,只是在替換的過程中提取到的“常量序列”不同。如果我們把包含參數的運算式樹狀架構編譯為委派物件,再把它緩衝起來,不就可以多次複用了嗎?因此,我們在計算運算式樹時設法減少編譯次數的解決方案可以分三步走:
提取運算式樹狀架構中所有常量
從緩衝中提取,或重新構造一個委派物件
把常量作為參數執行委派物件
第3步自不必多說,下面我們來分析前兩步的做法。動作表達式樹的傳統手段還是使用ExpressionVisitor。首先,我們為第1步工作實現一個ConstantExtrator,如下:
public class ConstantExtractor : ExpressionVisitor{
private List<object> m_constants;
public List<object> Extract(Expression exp)
{
this.m_constants = new List<object>();
this.Visit(exp);
return this.m_constants;
}
protected override Expression VisitConstant(ConstantExpression c)
{
this.m_constants.Add(c.Value);
return c;
}
}
由於我們的目標僅僅是常量,因此只需要重寫VisitConstant方法,並收集其Value即可。接著,我們便要將一個Expression編譯為一個Delegate對象,為此我們實現一個WeakTypeDelegateGenerator,它自然也是一個ExpressionVisitor的子類:
public class WeakTypeDelegateGenerator : ExpressionVisitor{
private List<ParameterExpression> m_parameters;
public Delegate Generate(Expression exp)
{
this.m_parameters = new List<ParameterExpression>();
var body = this.Visit(exp);
var lambda = Expression.Lambda(body, this.m_parameters.ToArray());
return lambda.Compile();
}
protected override Expression VisitConstant(ConstantExpression c)
{
var p = Expression.Parameter(c.Type, "p" + this.m_parameters.Count);
this.m_parameters.Add(p);
return p;
}
}WeakTypeDelegateGenerator會將所有的ConstantExpression轉變成同類型的ParameterExpression,並進行收集。在訪問了整個運算式樹狀架構之後,將會把含有ParameterExpression的運算式使用LambdaExpression封裝起來,再調用Compile方法進行編譯,並將結果返回。
public class CacheEvaluator: IEvaluator{
private static IExpressionCache<Delegate> s_cache = new HashedListCache<Delegate>();
private WeakTypeDelegateGenerator m_delegateGenerator = new WeakTypeDelegateGenerator();
private ConstantExtractor m_constantExtrator = new ConstantExtractor();
private IExpressionCache<Delegate> m_cache;
private Func<Expression, Delegate> m_creatorDelegate;
public CacheEvaluator()
: this(s_cache)
{ }
public CacheEvaluator(IExpressionCache<Delegate> cache)
{
this.m_cache = cache;
this.m_creatorDelegate = (key) => this.m_delegateGenerator.Generate(key);
}
public object Eval(Expression exp)
{
if (exp.NodeType == ExpressionType.Constant)
{
return ((ConstantExpression)exp).Value;
}
var parameters = this.m_constantExtrator.Extract(exp);
var func = this.m_cache.Get(exp, this.m_creatorDelegate);
return func.DynamicInvoke(parameters.ToArray());
}
}
IEvaluator介面中定義了Eval方法,目的是把一個Expression對象“計算”為一個常量。CacheEvaluator在實現Eval方法時利用了ConstantExtrator和WeakTypeDelegateGenerator,分別用於提取常量及構造委派物件。在得到委派物件之後,我們會使用DynamicInvoke方法,將常量作為參數進行調用。值得注意的是,這樣做的必要條件之一,便是傳入的常量與委託的參數順序必須一致。由於ContstantExtrator和WeakTypeDelegateGenerator都是基於相同的ExpressionVisitor實現,因此它們對於同一運算式樹狀架構的節點遍曆順序也完全相同,我們對此可以完全放心。
這裡自然還離不開最重要的組件:緩衝容器。把運算式樹狀架構作為緩衝容器的“鍵”並不像普通對象那麼容易,為此我在部落格上連載了7篇文章專門討論了這個問題。這幾篇文章提出了多種解決方案,並進行了對比和分析。最終,我們在這裡選擇了時間及空間上表現都比較優秀的HashedListCache。如果您有更好(或者在您的情境中表現更佳)的實現,您也可以在此替換預設的緩衝容器。
下面我們來進行一個簡單的實驗,實驗資料為運算子數量為1-3的四則運算運算式各10個,每個運算式分別計算1000次的結果。
從中看來,傳統方法對於每種長度的運算式計算耗時普遍超過了1.2秒,而啟用了緩衝的計算方式則將時間控制在了100毫秒左右。這無疑是一個顯著的效能提升。
減少反射開銷
在傳統的調用方式中,編譯操作佔了95%的開銷。而現在經過對編譯操作的最佳化,總開銷變成了原來的10%,這意味著目前編譯和執行的差不多各佔50%的時間。如果我們可以最佳化反射調用的過程,那麼效能便可以得到進一步的提高。而且,目前的最佳化方式還有一個重要的問題,使我們不得不對其進行修改。您知道為什麼在上面的樣本中,只測試了最多3個運算子的四則運算運算式嗎?這是因為目前的做法無法支援更多的運算子——其實是參數的數量。
在一個四則運算運算式中,常數的個數總是比操作符要多一個。也就是說,3個運算子的四則運算運算式,其中有4個常數。在目前的解決方案中,所有的常數都會被替換為參數。這就是現在的問題:LambdaExpression.Compile(ParameterExpression[])方法只支援最多4個參數。Compile方法還有一個重載允許我們指定一個新的委託類型,它要求匹配源運算式的參數個數,參數類型以及其傳回值類型。如果沒有指定特定的委託類型,架構便會選用以下委派物件中的一種作為編譯目標:
namespace System
{
public delegate TResult Func<TResult>();
public delegate TResult Func<T, TResult>(T a);
public delegate TResult Func<T1, T2, TResult>(T1 a1, T2 a2);
public delegate TResult Func<T1, T2, T3, TResult>(T1 a1, T2 a2, T3 a3);
public delegate TResult Func<T1, T2, T3, T4, TResult>(T1 a1, T2 a2, T3 a3, T4 a4);
}當參數數量超過4個的時候,Compile方法便會拋出異常(在.NET 4.0中則增加到16個)。如果要徹底解決這個問題,似乎唯一的方法便是根據需求,動態產生各種參數長度的委託類型。但是這麼做大大增加瞭解決方案的複雜程度,對於效能最佳化也沒有任何協助。那麼有沒有什麼辦法,可以“統一”地處理任意簽名的運算式呢?答案是肯定的,因為.NET架構中的“反射”特性給了我們一個很好的參考:
public class MethodInfo{
public object Invoke(object instance, object[] parameters);
}
System.MethodInfo類中的Invoke方法便支援任意的方法簽名,因為它把一個簽名轉化成為“執行個體”,“參數列表”和“傳回值”三個部分,而每個部分又都使用了object類型,因此可以存放任意類型的對象。由此,我們不妨也嘗試著將不同運算式樹狀架構歸納成同樣的形式——即將其“標準化”。例如,運算式“(5 + 2) * 3”便可以轉化為:
一個List<object>對象,其中存放5,2,3三個元素。
一個新的運算式:(object)((int)p[0] + (int)p[1]) * (int)p[2]。其中p為List<object>類型的參數對象。
這樣的“標準化”操作主要有兩個好處:
只要是結構相同的運算式樹狀架構,在“標準化”後得到的新運算式樹狀架構則完全相同,這大大提高了快取命中率。
無論何種運算式樹狀架構,標準化後的結果永遠只有一個List<object>參數,由此避免了常數過多而導致的編譯失敗。
我們得到了標準化之後的運算式樹狀架構,便可以將其編譯為相同的委派物件。這部分功能由DelegateGenerator類進行:
public class DelegateGenerator : ExpressionVisitor{
private static readonly MethodInfo s_indexerInfo = typeof(List<object>).GetMethod("get_Item");
private int m_parameterCount;
private ParameterExpression m_parametersExpression;
public Func<List<object>, object> Generate(Expression exp)
{
this.m_parameterCount = 0;
this.m_parametersExpression =
Expression.Parameter(typeof(List<object>), "parameters");
var body = this.Visit(exp); // normalize if (body.Type != typeof(object))
{
body = Expression.Convert(body, typeof(object));
}
var lambda = Expression.Lambda<Func<List<object>, object>>(body, this.m_parametersExpression);
return lambda.Compile();
}
protected override Expression VisitConstant(ConstantExpression c)
{
Expression exp = Expression.Call(
this.m_parametersExpression,
s_indexerInfo,
Expression.Constant(this.m_parameterCount++));
return c.Type == typeof(object) ? exp : Expression.Convert(exp, c.Type);
}
}與WeakTypeDelegateGenerator一樣,DelegateGenerator也是拿ConstantExpression開刀。只不過後者並不是直接將其替換為建立的ParameterExpression,而是轉化為對List<object>型別參數的元素下標訪問(get_Item)——必要時再配合一次類型轉換。Visit的過程也就是一次標準化的過程,最終得到的運算式樹狀架構會被編譯為一個接受List<object>作為參數,並返回object類型的委派物件。至於提取將運算式樹狀架構的常量提取為List<object>類型的參數列表,已經由之前的ConstantExtractor實現了,我們直接使用即可。
將DelegateGenerator、ConstantExtractor及ExpressionCache三者加以組合,便可得出計算運算式樹的新組件FastEvaluator:
public class FastEvaluator : IEvaluator{
private static IExpressionCache<Func<List<object>, object>> s_cache =
new HashedListCache<Func<List<object>, object>>();
private DelegateGenerator m_delegateGenerator = new DelegateGenerator();
private ConstantExtractor m_constantExtrator = new ConstantExtractor();
private IExpressionCache<Func<List<object>, object>> m_cache;
private Func<Expression, Func<List<object>, object>> m_creatorDelegate;
public FastEvaluator()
: this(s_cache)
{ }
public FastEvaluator(IExpressionCache<Func<List<object>, object>> cache)
{
this.m_cache = cache;
this.m_creatorDelegate = (key) => this.m_delegateGenerator.Generate(key);
}
public object Eval(Expression exp)
{
if (exp.NodeType == ExpressionType.Constant)
{
return ((ConstantExpression)exp).Value;
}
var parameters = this.m_constantExtrator.Extract(exp);
var func = this.m_cache.Get(exp, this.m_creatorDelegate);
return func(parameters);
}
}我們再進行一次簡單的實驗,將運算子數量為1-20的四則運算運算式各10個,分別計算1000次。三種實現耗時對比如下:
FastEvaluator的主要開銷在於從ExpressionCache中提取資料,它隨著運算式的長度線性增加。擁有n個運算子的四則運算運算式樹狀架構,其常量節點的數量為n + 1,因此總結節點數量為2n + 1。根據我的個人經驗,項目中所計算的運算式樹狀架構的節點數量一般都在10個以內。,在這個資料範圍內,FastEvaluator的計算耗時僅為傳統方法的1/20,並且隨著節點數量的減少,兩者差距進一步增大。此外,由於節省了反射調用的開銷,即使在CacheEvaluator可以正常工作的範圍內(1-3個運算子),FastEvaluator相對前者也有明顯的效能提升。
總結
運算式樹狀架構擁有語義清晰,強型別等諸多優勢,可以預見,越來越多的項目會採取這種方式來改進自己的API。在這種情況下,運算式樹狀架構的計算對於程式效能的影響也會越來越大。本文提出了一種運算式樹狀架構計算操作的最佳化方式,將不同運算式樹狀架構“標準化”為幾種有限的結構,並複用其編譯結果。由於減少了編譯操作和反射操作的次數,運算式計算所需開銷大大降低。
本文所有代碼都公佈於MSDN Code Gallary中的FastLambda項目中,您可以根據需要隨意修改使用。此外,FastLambda項目中還包含了可以將運算式樹狀架構的多個常量部分進行簡化的組件(如將5 + 2 + 3 * 4 * x簡化為7 + 12 * x),這對於處理原本就包含ParameterExpression的運算式樹狀架構非常有用(如編寫LINQ Provider時)。如果您對此感興趣,可以關注項目中的PartialEvaluator和FastPartialEvaluator類,它們的區別在於前者利用Evaluator,而後者利用FastEvaluator進行運算式樹狀架構的局部計算