# 原則38:理解動態(Dynamic)的利與弊
**By D.S.Qiu**
**尊重他人的勞動,支持原創,轉載請注明出處:[http://dsqiu.iteye.com](http://dsqiu.iteye.com)**
C# 支持的動態類型為提供了到其他地方的橋梁。這不是意味著鼓勵你使用動態語言編程,而是提供了 C# 強靜態類型到那些動態類型模型的平滑過渡。
然而,這也不會現在你使用動態類型和其他環境交互。C# 類型可以強制轉為為動態對象并當做動態對象。和其他事物一樣,把 C# 對象當做動態對象具有兩面性有好也也有壞。我們通過一個例子看下發生了什么好的和壞的。
C# 泛型的一個局限是為了方法參數不是 System.Object ,你需要制定約束。而且,約束必須是基類,接口,引用類型,值類型,或存在 public 無參構造函數其中的一個。你不能具體到某些已知的方法。當你需要創建依賴像操作符+這樣的泛型方法,這就會是限制。動態調用可以修復這個問題。使用的成員在運行時可訪問。下面的方法是將兩個動態對象相加,只要在運行時操作符+是可訪問的:
```
public static dynamic Add(dynamic left, dynamic right)
{
return left + right;
}
```
這是第一次討論動態,我們看下這會發生什么。動態可以認為是“ System.Object 的運行時綁定”。在編譯時,動態變量只有那些定義在 System.Object 的方法。然而,編譯器會增加代碼使得每個成員的訪問實現為動態尋址調用。在運行時,代碼執行檢查對象而且決定是否請求的方法是可訪問的。(查看原則41實現動態對象。)經常被作為“鴨類型”引用:如果它走起來像鴨子和像鴨子一樣說話,它可能就是鴨子。你不需要聲明特殊的接口,或者提供任何編譯時類型操作。只要成員在運行時可訪問,它就會工作。
上面的方法,動態尋址調用會檢查兩個對象的實際運行時類型是否有操作符+。下面的調用都是正確的:
```
dynamic answer = Add(5, 5);
answer = Add(5.5, 7.3);
answer = Add(5, 12.3);
```
注意 answer 必須聲明為動態對象。因為調用是動態的,便器不能知道返回值的類型。它只能在運行時解析。返回值類型要在運行時解析只有聲明為動態對象。返回值的靜態類型是 dynamic 。它會在運行時解析。
當然,這個動態 Add 方法不光局限于 數字類型。你可以將字符串相加(因為 string 有操作符+的定義):
```
dynamic label = Add("Here is ", "a label");
```
你還可以將 TimeSpan 和 Date 相加:
```
dynamic tomorrow = Add(DateTime.Now, TimeSpan.FromDays(1));
```
只要操作符+可訪問,Add 的動態版本就會工作。
上面開頭的解釋可能會導致過度使用動態編程。我還只是討論了動態編程的優點。是時候也該考慮下缺點。你已經拋棄了類型系統的安全,這樣,你也就限制了編譯器對你的幫助。任何解釋類型的錯誤都只會在運行時才被發現。
任何有一個操作數(包括可能的 this 引用)的操作的結果是動態的即本身是動態的。有時,你會把動態對象轉換為你最多使用的靜態類型。這就需要強制類型轉換或轉換操作:
```
answer = Add(5, 12.3);
int value = (int)answer;
string stringLabel = System.Convert.ToString(answer);
```
強制類型轉換操作只有當動態對象的實際類型是目標類型或可以轉為為目標類型才會工作。你需要知道任何動態操作的正確類型,才能給它強類型。否則,轉換就會在運行時失敗,并且拋出異常。
當你不知道類型但又不得不在運行時解析方法,動態類型就是正確的工具。當你知道編譯時類型,你可以使用 lambda 表達式和函數編程構造你需要的解決方案。你可以使用 lambda 表達式重寫 Add 方法:
```
public static TResult Add<T1, T2, TResult>(T1 left, T2 right,Func<T1, T2, TResult> AddMethod)
{
return AddMethod(left, right);
}
```
每個調用都需要提供具體的方法,所有前面的例子都可以使用這個策略實現:
```
var lambdaAnswer = Add(5, 5, (a, b) => a + b);
var lambdaAnswer2 = Add(5.5, 7.3, (a, b) => a + b);
var lambdaAnswer3 = Add(5, 12.3, (a, b) => a + b);
var lambdaLabel = Add("Here is ", "a label",(a, b) => a + b);
dynamic tomorrow = Add(DateTime.Now, TimeSpan.FromDays(1));
var finalLabel = Add("something", 3,(a,b) => a + b.ToString());
```
你可以看到最后一個方法需要具體將 int 轉換為 string 。它比其他 lambda 方法更不優雅。不幸的是,只有這樣方法才能工作。你不得不在應用 lambda 的地方推斷出類型。這意味著相當不部分的代碼看起來跟手動一樣重復因為代碼對于編譯器是不一樣的。當然,定義并實現 Add 方法看起來很傻。實踐中,你使用的 lambda 方法不會簡單地執行。 .NET 類庫使用 Enumerable.Aggregate().Aggregate() 枚舉整個隊列并計算相加(或者執行其他操作)的結果:
```
var accumulatedTotal = Enumerable.Aggregate(sequence, (a, b) => a + b);
```
這仍看起來你是在重復代碼。避免這樣重復代碼是使用表達式樹。另一種運行時編譯代碼。 System.Linq.Expression 類和它的子類提供 API ,你可以構建表達式樹。如果你構建表達式樹,你會將它轉化為 lambda 表達式,然后編譯最后的 lambda 表達式為委托。例如,下面這段構建并執行三個類型值的相加:
```
// Naive Implementation. Read on for a better version
public static T AddExpression<T>(T left, T right)
{
ParameterExpression leftOperand = Expression.Parameter( typeof(T), "left");
ParameterExpression rightOperand = Expression.Parameter( typeof(T), "right");
BinaryExpression body = Expression.Add( leftOperand, rightOperand);
Expression<Func<T, T, T>> adder = Expression.Lambda<Func<T, T, T>>(body, leftOperand, rightOperand);
Func<T, T, T> theDelegate = adder.Compile();
return theDelegate(left, right);
}
```
最有趣的的工作涉及類型信息,而不是使用 var ,為了讓代碼清晰,我特別命名所有的類型。
開始兩行為類型 T 的變量“left”,“right”創建了參數表達式。下面兩行就是使用這兩個參數創建 Add 表達式。 Add 表達式繼承自 BinaryExpress 。你也可以創建其他類似的二元操作符。
下面,你需要將帶有兩個參數的表達式構建為 lambda 表達式。最后你編譯并創建 Func<T,T,T> 委托。一旦編譯好,你就可以執行它并返回結果。當然,你可以像其他泛型方法一樣調用這個方法:
```
int sum = AddExpression(5, 7);
```
添加在上面例子的注釋說明這是樸素的實現。不要復制這段代碼到你應用中。這個版本有兩個問題。第一,有很多情況 Add() 可以工作但這個卻不可以。有很多例子的 Add() 方法的參數是不同的: int 和 double ,DateTime 和 TimeSpan 等等。這些情況這個方法都不能工作。我們對此進行修復。你必須再增加兩個泛型參數。然后,你可以指定左右兩個參數為不同類型。同時,我用 var 聲明一些局部變量。這掩蓋了類型信息,但它確實幫助使邏輯的方法更清晰。
```
// A little better.
public static TResult AddExpression<T1, T2, TResult> (T1 left, T2 right)
{
var leftOperand = Expression.Parameter(typeof(T1), "left");
var rightOperand = Expression.Parameter(typeof(T2), "right");
var body = Expression.Add(leftOperand, rightOperand);
var adder = Expression.Lambda<Func<T1, T2, TResult>>( body, leftOperand, rightOperand);
return adder.Compile()(left, right);
}
```
這個版本跟前面的很類似;它只是可以讓你調用左右參數不同的類型。唯一的缺點是你需要指定三個參數的類型,無論你怎么調用:
```
int sum2 = AddExpression<int, int, int>(5, 7);
```
因為你指定三個不同參數的類型,不同類型表達式就可以工作:
```
DateTime nextWeek= AddExpression<DateTime, TimeSpan,DateTime>(DateTime.Now, TimeSpan.FromDays(7));
```
是時候該解決讓人煩惱的問題了。我前面展示的代碼,每次你調用 AddExpression() 方法就會編譯表達式為委托。這是相當低效的,特別是你重復執行相同的表達式。編譯表達式是非常耗性能的,所以你應該為你后面的調用緩存編譯好的委托。下面是這個類的第一個初稿:
```
// dangerous but working version
public static class BinaryOperator<T1, T2, TResult>
{
static Func<T1, T2, TResult> compiledExpression;
public static TResult Add(T1 left, T2 right)
{
if (compiledExpression == null)
createFunc();
return compiledExpression(left, right);
}
private static void createFunc()
{
var leftOperand = Expression.Parameter(typeof(T1), "left");
var rightOperand = Expression.Parameter(typeof(T2), "right");
var body = Expression.Add(leftOperand, rightOperand);
var adder = Expression.Lambda<Func<T1, T2, TResult>>(body, leftOperand, rightOperand);
compiledExpression = adder.Compile();
}
}
```
在這點上,你可能想知道應該使用哪種技術:動態或表達式。這個決定因情況而定。表達式版本更適合簡單的計算。很多情況會更快些。而且,表達式比動態調用會少些動態。記得使用動態調用,你可以添加更多不同的類型:int 和 double ,short 和 float 。只要它是合法的 C# 代碼,它就是合法的編譯的版本。你甚至可以將字符串和數字相加。如果這些情況使用表達式版本,就會拋出 InvalidOprationException 異常。即使有類型轉換,你構建的表達式不會編譯為類型轉換的 lambda 表達式。動態調用做了更多工作,因此支持更多類型的操作。例如,假設你想更新 AddExpression 添加不同類型和進行適當的轉換。好的,你只需要
更新參數和結果類型轉換的代碼。就是下面這樣的:
```
// A fix for one problem causes another
public static TResult AddExpressionWithConversion
<T1, T2, TResult>(T1 left, T2 right)
{
var leftOperand = Expression.Parameter(typeof(T1),
"left");
Expression convertedLeft = leftOperand;
if (typeof(T1) != typeof(TResult))
{
convertedLeft = Expression.Convert(leftOperand, typeof(TResult));
}
var rightOperand = Expression.Parameter(typeof(T2),"right");
Expression convertedRight = rightOperand;
if (typeof(T2) != typeof(TResult))
{
convertedRight = Expression.Convert(rightOperand, typeof(TResult));
}
var body = Expression.Add(convertedLeft, convertedRight);
var adder = Expression.Lambda<Func<T1, T2, TResult>>(body, leftOperand, rightOperand);
return adder.Compile()(left, right);
}
```
這就修復了像 double 和 int 相加,或 string 加上 double 返回 string 的需要轉換的問題。然而,當參數和結果不相同的使用就變得無效了。特別地,這個版本針對上面 TimeSpane 和 DataTiem 的例子就不能工作。添加更多的代碼,你就可以修復這個問題。然而,在這點上,你已經很漂亮實現 C# 動態調度的代碼(查看原則41)。使用動態,而不是做所有更多工作。
當操作數和結果的類型是一樣的時候,你應該使用表達式版本。你使用泛型參數接口,并且更少的情況會在運行時失敗。下面的版本是我推薦在運行時調度使用的表達式版本的實現:
```
public static class BinaryOperators<T>
{
static Func<T, T, T> compiledExpression;
public static T Add(T left, T right)
{
if (compiledExpression == null)
createFunc();
return compiledExpression(left, right);
}
private static void createFunc()
{
var leftOperand = Expression.Parameter(typeof(T), "left");
var rightOperand = Expression.Parameter(typeof(T), "right");
var body = Expression.Add(leftOperand, rightOperand);
var adder = Expression.Lambda<Func<T, T, T>>(body, leftOperand, rightOperand);
compiledExpression = adder.Compile();
}
}
```
當你調用 Add 時,你仍需要指定一個參數的類型。這么做的優勢是編譯器可以在調用時進行類型轉換。編譯器會將 int 提升為 double ,等。
使用動態和運行時構建表達式都會耗性能。和任何動態類型系統一樣,你的程序在運行時需要做更多工作,因為編譯器沒有執行檢查使用的類型。編譯器必須在產生在運行時檢查的指令。我并不打算夸大,因為 C# 編譯器產生高效的運行時檢查類型的代碼。很多情況下,使用動態會比你自己寫的反射來產生晚綁定更快。然而,運行時的工作量是不可忽略的,它花費的時間也是不能忽略的。如果你可以使用靜態類型解決這個問題,那毫無疑問比使用動態類型更高效。
當你掌握所有涉及的類型,你可以創建接口而不是使用動態編程,那是更好的解決方案。你可以定義接口,面向接口編程,并且讓所有類實現這個接口就可以有接口定義的行為。 C# 類型系統會嚴格檢查引入的類型錯誤,而且編譯器會產生更高效的代碼,因為它可以假定某些類型的錯誤是不可能出現的。
很多情況,你可以使用泛型 lambda 創建泛型 API 并且強制調用者定義在動態算法執行的代碼。
第二個選擇是使用表達式。如果你類型的組合情況相對較少和很少的類型轉換,這是合適的選擇。你可以控制表達式的創建,因此控制運行時的開銷。
當你使用動態,底層的動態實現會盡可能使構造工作合法,無論運行花費多大的消耗。
然而,我在開頭演示的 Add() 方法,是不完善的。 Add() 應該能對 .NET 類框架定義的很多類型工作。你不能往回并添加 IAdd 接口到這些類型。你也不能保證你所有使用的第三方類庫符合這個新接口的功能。構建基于已存在類型的特定成員的方法的最好的方式是編寫動態方法并且在運行時推斷具體的選擇。動態實現要找到一個恰當的實現,使用并緩存有助于更好的性能。它比單純的靜態類型解決方法更耗時,卻比表達式樹的解析更簡單。
小結:
這個原則是第五章的開頭,介紹了動態的利與弊,比較泛,沒有提多精華,還是得看后面的幾篇原則。
歡迎各種不爽,各種噴,寫這個純屬個人愛好,秉持”分享“之德!
有關本書的其他章節翻譯請[點擊查看](/category/297763),轉載請注明出處,尊重原創!
如果您對D.S.Qiu有任何建議或意見可以在文章后面評論,或者發郵件(gd.s.qiu@gmail.com)交流,您的鼓勵和支持是我前進的動力,希望能有更多更好的分享。
轉載請在**文首**注明出處:[http://dsqiu.iteye.com/blog/2090155](/blog/2090155)
更多精彩請關注D.S.Qiu的博客和微博(ID:靜水逐風)
- 第一章 C# 語言習慣
- 原則1:使用 屬性(Poperty)代替可直接訪問的數據成員(Data Member)
- 原則2:偏愛 readonly 而不是 const
- 原則3:選擇 is 或 as 而不是強制類型轉換
- 原則4:使用條件特性(conditional attribute)代替 #if
- 原則5:總是提供 ToString()
- 原則6:理解幾個不同相等概念的關系
- 原則7:明白 GetHashCode() 的陷阱
- 原則8:優先考慮查詢語法(query syntax)而不是循環結構
- 原則9:在你的 API 中避免轉換操作
- 原則10:使用默認參數減少函數的重載
- 原則11:理解小函數的魅力
- 第二章 .NET 資源管理
- 原則12:選擇變量初始化語法(initializer)而不是賦值語句
- 原則13:使用恰當的方式對靜態成員進行初始化
- 原則14:減少重復的初始化邏輯
- 原則15:使用 using 和 try/finally 清理資源
- 原則16:避免創建不需要的對象
- 原則17:實現標準的 Dispose 模式
- 原則17:實現標準的 Dispose 模式
- 原則18:值類型和引用類型的區別
- 原則19:確保0是值類型的一個有效狀態
- 原則20:更傾向于使用不可變原子值類型
- 第三章 用 C# 表達設計
- 原則21:限制你的類型的可見性
- 原則22:選擇定義并實現接口,而不是基類
- 原則23:理解接口方法和虛函數的區別
- 原則24:使用委托來表達回調
- 原則25:實現通知的事件模式
- 原則26:避免返回類的內部對象的引用
- 原則27:總是使你的類型可序列化
- 原則28:創建大粒度的網絡服務 APIs
- 原則29:讓接口支持協變和逆變
- 第四章 和框架一起工作
- 原則30:選擇重載而不是事件處理器
- 原則31:用 IComparable&lt;T&gt; 和 IComparer&lt;T&gt; 實現排序關系
- 原則32:避免 ICloneable
- 原則33:只有基類更新處理才使用 new 修飾符
- 原則34:避免定義在基類的方法的重寫
- 原則35:理解 PLINQ 并行算法的實現
- 原則36:理解 I/O 受限制(Bound)操作 PLINQ 的使用
- 原則37:構造并行算法的異常考量
- 第五章 雜項討論
- 原則38:理解動態(Dynamic)的利與弊
- 原則39:使用動態對泛型類型參數的運行時類型的利用
- 原則40:使用動態接收匿名類型參數