原则38:理解动态(Dynamic)的利与弊
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 接口到这些类型。你也不能保证你所有使用的第三方类库符合这个新接口的功能。构建基于已存在类型的特定成员的方法的最好的方式是编写动态方法并且在运行时推断具体的选择。动态实现要找到一个恰当的实现,使用并缓存有助于更好的性能。它比单纯的静态类型解决方法更耗时,却比表达式树的解析更简单。
小结:
这个原则是第五章的开头,介绍了动态的利与弊,比较泛,没有提多精华,还是得看后面的几篇原则。
欢迎各种不爽,各种喷,写这个纯属个人爱好,秉持”分享“之德!