65.9K
CodeProject 正在变化。 阅读更多。
Home

C# 4.0 概述

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.83/5 (35投票s)

2008年11月13日

CPOL

12分钟阅读

viewsIcon

129330

讨论了C# 4.0的新特性,包括动态查找、协变/逆变泛型、命名参数和可选参数。

引言

​.NET Framework 4.0 CTP 已发布,我认为现在是时候探索 C# 4.0 的新特性了。在这篇文章中,我将介绍以下特性:动态查找、泛型协变和逆变支持、可选参数和命名参数。

动态查找

如果说 C# 3.0 中的 var 关键字是为了节省一些按键次数而引入的局部变量类型推断,那么动态查找为 C# 语言的动态性增添了更多内容。当你使用 dynamic 声明一个变量时,它的所有方法调用或成员访问都将在运行时解析。例如,让我们来看下面的代码:

public static void Main() {
    dynamic obj = “I’m statically System.String”;
    obj.NotExistingMethod(“param”);
}

在这段代码中,我们创建了一个字符串对象,并且没有将其赋给 string 类型的变量,而是赋给了一个声明为 dynamic 的变量。这基本上指示编译器不要尝试解析声明变量的任何方法调用或成员访问。相反,所有这些解析都将在运行时发生。这样做之后,下一行我们调用一个不存在的方法并带有一个任意参数的代码将能够顺利编译成 CIL。在运行时,当方法调用被解析并且运行时发现运行时类型(这是一个 System.String)中不存在该方法时,将抛出一个异常。也就是说,如果我们使用一个有效的方法,那么就不会有异常,代码将顺利运行到完成。例如:

private static void PrintID(dynamic obj) {
    Console.WriteLine(obj.ID);
}

public static void Main() {
    var person = new {ID = 111, Name = "Buu"};
    PrintID(person);

    var account = new {ID = 101, Bank = "Some Bank"};
    PrintID(account);
}

我们创建了两个匿名类型,为了方便起见,它们都拥有一个名为 ID 的属性,然后实例化了这些类型的对象。然后我们将这两个对象分别传递给 PrintID 方法,该方法接受一个动态对象并打印出 ID 属性。代码将分别打印出“111”和“101”。是的,我们刚刚看到了 C# 中的“鸭子类型”在起作用。

该示例还暗示了 dynamic 在匿名类型方面的有趣用法。现在,我们可以将匿名类型的对象传递出它们的声明范围(例如,方法),而无需诉诸冗余的 Reflection 代码,仍然能够调用它们的方法或访问它们的成员。

我们不仅仅局限于将静态类型的 .NET 对象变成动态的,我们还可以利用这种动态查找功能,方便地与 .NET Framework 4.0 中包含的动态语言运行时 (DLR) 提供的“实际”动态对象进行交互。事实上,我们可以通过实现 DLR 的一部分 System.Scripting.Actions.IDynamicObject 接口来在 C# 中实现此类动态对象。无论动态分派的实际接收者是谁,从调用者的角度来看,动态查找的使用方式完全相同。

有些人可能会想,编译器生成了什么代码,以及 CLR 是否有任何更改来支持动态查找。为了回答这个问题,让我们看一下本文开头代码片段生成的 CIL。(单击图片以查看完整尺寸。)

生成的 CIL 非常冗长,但粗略一看可以发现两件重要的事情。首先,我们的“动态变量”实际上是一个普通的 CLR System.Object 实例。其次,没有新的 CIL 指令或操作码来支持动态查找。相反,动态解析和调用完全由框架代码处理。实际上,上面的 CIL 等同于下面的 C# 代码,只是没有 dynamic 关键字。

object obj = "I'm statically System.String";
var payload = new CSharpCallPayload(
    RuntimeBinder.GetInstance(),
    false, false, "NotExistingMethod",
    typeof(object), null);
var callSite = CallSite<Action<CallSite, object, string>>.Create(payLoad);
Action<CallSite, object, string> action = callSite.Target;
action.Invoke(callSite, obj, "param");

(事实上,我为了让代码适合在一个方法中,对其进行了一些简化。实际生成的 CIL 在 IL_000c 处确实有一个检查,它基本上会查看一个嵌套类的 callSite 静态字段,该字段由编译器自动生成,以检查它是否为 null,然后再继续初始化。换句话说,callSite 会被缓存起来,以便后续调用相同的方法。)

所以,dynamic 关键字背后并没有什么魔法。编译器基本上做的是生成一些包含调用信息的数据,以便它可以在运行时进行。如果有什么魔法的话,那可能是在静态方法 CallSite.Create() 中,它使用 Reflection 来调用给定对象上的 NotExistingMethod,如果它不是 IDynamicObject 的实例的话。

现在,我们知道“动态对象”实际上是一个普通的旧对象;这应该能解释为什么一个方法可以接受动态参数。同样,也不足为奇的是,动态查找也可以应用于实例/静态方法或实例/静态字段的返回值。毕竟,与 var 关键字要求编译器在编译时推断确切类型不同,在动态查找场景中,编译器可以简单地选择 System.Object 作为类型。

泛型协变和逆变

在之前的 C# 版本中,泛型类型是不变的。也就是说,对于任何两个类型 GenericType<T>GenericType<K>,其中 TK 的子类或 KT 的子类,GenericType<T>GenericType<K> 之间没有任何继承关系。另一方面,如果 TK 的子类,并且 C# 支持协变,那么 GenericType<T> 也是 GenericType<K> 的子类;如果 C# 支持逆变,那么 GenericType<T>GenericType<K> 的超类。

为了理解为什么 4.0 之前的 C# 不允许协变和逆变,让我们看一些代码:

var strList = new List<string>();
List<object> objList = strList; // compile-error, with cast or not

第 2 行的代码可能容易出错。假设它被允许,请考虑我们在第 2 行之后可以写什么:

// BOOM: we're adding an arbitrary AnyObject to what, at runtime, is a list of strings
objList.add(new AnyObject());

另一方面,如果 C# 支持逆变,我们可能会写出以下有问题的代码:

var objList = new List<object>;
objList.add(3);
objList.add(new AnyObject());
List<string> strList = objList; 

// BOOM: we're getting an arbitrary object thinking it’s a string
string element = strList.get(0);

由于这种不变的限制,尽管目的是好的,我们无法轻松地重用变量和方法来分别赋值给或接受各种泛型类型。这有点不幸,因为关键是要意识到,只要 GenericType<T> 没有接受类型 T 参数的任何方法或成员(例如,如果我们不能将一堆对象添加到 objList 中,而它实际上是 List<string> 的实例,那么我们就安全了),协变就是没问题的。此外,如果 T 不出现在任何成员或方法的返回值中(例如,如果我们无法从 strList 中获取任何字符串,而它实际上是 List<object> 的实例,那么我们就安全了),逆变同样安全。

幸运的是,C# 4.0 为我们提供了一个选项:如果一个 **泛型接口** 或 **泛型委托** 以 **引用类型** T 作为其类型参数,并且没有任何方法或成员接受类型 T 的参数,我们可以声明它在 T 上是协变的。另一方面,如果该接口或委托没有任何方法或成员返回 T,我们可以声明它在 T 上是逆变的。(请注意强调部分,只有接口和委托支持协变和逆变,并且类型参数必须是引用类型。另一方面,C# 数组从一开始就支持协变。)

让我们看一个例子。我们有一个 Generator 委托,它基本上返回某种类型的对象的随机实例(例如,string)。它的声明如下:

delegate T Generator<out T>();

因为这个委托不接受任何 T 作为参数,所以我们可以安全地让它在 T 上是协变的。实际上,编译器将允许我们通过在类型参数前面加上修饰符 out 来做到这一点。但是,如果这个委托声明为,例如,delegate T Generator<out T>(T seed),那么编译器将报错,因为它不再对协变是安全的。现在,让我们看看它的用法:

Generator<string> strGen = new Generator<string>(StringGenerator);

// Below line is compiled now because Generator<string> is
// subclass of Generator<object> under covariant rule
Generator<object> objGen = strGen; 

// Downcast is also allowed for the same reason
strGen = (Generator<String>)objGen; 

...

private string StringGenerator()
{
    return "I'm a random string";
}

对于逆变,你需要使用 in 关键字。让我们看一个同时使用逆变和协变的例子:

delegate K Converter<in T, out K>(T param);

这个转换器接受一个类型为 T 的对象,并将其转换为类型为 K 的对象(例如,将 String 转换为 Object)。由于它不接受任何 K,所以它可以安全地声明为在 K 上是协变的。同样,由于它不公开任何 T,所以它可以安全地声明为在 T 上是逆变的。它的用法如下:

var converter = new Converter<object, string>( ConvertImpl);
Converter<string, object> string2ObjectConverter = converter;
object result = string2ObjectConverter("A"); 

...

private string ConvertImpl(object o) {
    return o.ToString();
}

请注意,尽管上面的示例展示了如何为委托类型声明协变和逆变,但对于接口来说,这样做并没有什么不同。

在结束协变和逆变之前,让我们看看编译器生成了什么代码。毕竟,我们知道编译器必须将某些内容编码到 CIL 中,以表示协变和/或逆变泛型类型,以便客户端代码能够正确地使用它们。而这就是我们的 GeneratorConverter 委托在 ILDASM 中查看时的定义:

你注意到什么了吗?那些小小的减号和加号分别用于表示逆变和协变。有趣的是,实际上 CLR 自从 .NET 2.0 引入泛型以来就一直支持这种 CIL。因此,在 .NET 2.0+ 下使用 CIL 编写协变和逆变是可能的。只有到现在,才能使用 C# 来做到这一点。

关于这个特性的最后一点思考。虽然这是对该语言的一个很好的增强,但我更喜欢 Java 中通过通配符实现的泛型协变/逆变实现,因为它更灵活。总之,就目前而言,我们就为此感到高兴吧,我们不可能拥有所有东西。

可选参数和命名参数

我们将要讨论的 C# 4.0 的最后两个特性是可选参数和命名参数。这些特性自 VB.NET 以来就一直存在,我很高兴它们终于在 C# 中实现了。

有了可选参数,我们可以为方法和构造函数的参数提供默认值。这样,我们就无需编写一堆重载的方法和构造函数。例如,我们可以这样定义一个构造函数:

public Cart(int id, String name = “default cart”, double amount = 0d) {…}

现在,你可以用以下任何一种方式调用此构造函数:

new Cart(1);
new Cart(1, “my cart”);
new Cart(1, “my cart”, 105.5);

C# 究竟是如何实现这个特性的?如果我们查看这段代码生成的 CIL,我们会发现没有任何魔法。基本上,默认值将被注入到调用站点,以便正常执行方法调用。这是编译器生成的 CIL:

有些人可能会认为编译器会获取源代码中的默认值并将其注入到调用者的代码中。然而,如果一个带有可选参数的方法被作为库发布,这样做是行不通的。在这种情况下,编译器无法获取默认值并将其注入到库客户端的代码中。因此,编译器实际上所做的是将默认参数直接编码到方法本身中。这是 Cart 构造函数的 CIL:

注意 .param 指令,它基本上告诉编译器可选参数的默认值。这些参数还带有一个 [opt] 属性。

虽然这是一个很棒的功能,但使用可选参数时有一个注意事项:由于编译器会在调用方站点内联默认值,因此对库站点中的默认值所做的更改将不会被反映出来,除非调用方被重新编译。换句话说,你应该将参数的默认值视为方法已发布 API 的一部分,并且最好第一次就将其设置正确。

现在,假设我们想调用 Cart 的构造函数,并指定 IDamount,但不指定 name,我们该怎么做?

为了避免歧义(例如,当两个可选参数都是字符串时),C# 不允许我们像这样简单地跳过参数:

new Cart(1, 15.5d); // compile-error

C# 可以采取的一种方法是允许这种语法:

new Cart(1, , 15.5d);

然而,即使只有一个参数缺失,这看起来也很糟糕,更不用说更多参数了。(你喜欢如何阅读这段代码:MethodWithManyFields(,,,15,,”param”,,)?)

对于这种情况,命名参数是一个绝佳的解决方案:

new Cart(1, amount: 15.5d);

但这并不是命名参数的唯一用途。命名参数的一个非常重要的作用是增强代码的可读性。假设你有一个类,它有很多字段需要在构造函数中初始化。没有命名参数,这样的类的构造函数会非常丑陋,并且在不查阅文档、源代码或 IntelliSense 的情况下很难理解。

一种方法是创建一个构建器来创建这样一个类的实例。例如:

new BigClass.Builder().attr1(“value”).attr2(“value”)...attrN(“value”);

这将使代码更明确地说明哪些值将被赋给哪些字段。然而,缺点是我们必须实现一个构建器类,如果我们必须为应用程序中的许多类重复执行此操作,这将是一项繁琐的任务。

有了 C# 3.0,情况并没有那么糟糕,因为我们可以使用对象初始化器来做类似的事情:

new BigClass {Attr1 = “value”, Attr2 = “value”, …, AttrN = “value”}

这看起来不错,但我们必须定义所有必需的属性才能使其工作,如果我们实际上不需要为类定义属性,或者如果类本身是不可变的,这也可能是一项繁琐的任务。

通过命名参数,我们可以获得一个非常好的解决方案,而无需编写任何构建器或属性(如果你不想的话)。(注意字段名称后的分号。)

new BigClass{attr1: “value”, attr2: “value”, …, attrN: “value”);

// The below does the same thing
new BigClass{attrN: “value”, attr1: “value”, …, attr2: “value”);

关于在 CIL 级别的实现,编译器足够智能,可以推断出正确的参数顺序,并执行一个普通的旧方法调用。

结论

就是这样。C# 4.0 的关键特性。我个人很高兴 C# 能够支持这些。有些人说 C# 变得越来越复杂,并开始失去其原有的美感。虽然我理解这种观点,但我认为情况并非如此糟糕。虽然 C# 显然有越来越多的构造函数来支持函数式和动态编程,但该语言的静态类型性质和旧构造函数仍然存在,并且没有任何开发人员被迫使用新特性,如果他们不需要的话。另一方面,这些特性为那些需要它们的人提供了更多的选择,我宁愿有更多的选择,而不是被束缚。

© . All rights reserved.