第 93 讲:C# 3 之匿名类型
今天我们来说一个看起来相当简单简陋的语法特性:匿名类型(Anonymous Type)。这个语法特性简单到你听我说一下你自己就会用了。
Part 1 语法
我们定义下面这样的东西叫匿名类型:
这样看起来不好理解,我们看下用法:
是的,就是一个 new
关键字后直接跟上一个对象初始化器的语法。然后整个 new { ... }
的语法里,new
后没有了真正的数据类型和构造器的参数信息,然后直接将这个表达式给赋值到了一个新变量上去。
语法其实很简单,这三个变量 obj1
、obj2
和 obj3
我们都成为匿名类型的变量。下面我们来说说细节和这个语法到底是啥意思。
Part 2 实现细节
有人会说,这定义了有啥用?单纯这么三个变量定义了之后,确实没有任何用,因为这样的语句,猜都猜得到就相当于有了属性赋值的这么一个过程,而这些赋值过程会自动被编译器改写代码,变为实际的数据类型。
是的,实际上确实如此,而且和我们的想法没有任何的偏差。编译器会直接将这些匿名类型的实例化过程(就是 new { ... }
的这个表达式)给改写成一个实际类型的实例化操作,而里面包含和书写的属性名称,会被编译器自动改写为实际的属性。
我们拿 obj1
的赋值来举例。这个语句会被翻译为这样:
说说细节吧。
2-1 类型的修饰符
这个类型是编译器通过匿名类型实例化语句进行实例化后的实体类型。这个类型因为是自动生成的,因此不便于其它时候使用。所以,可以从这样的实现机制和模式看出,匿名类型是一种“用了就丢”的数据类型。
正是因为这样的形式,所以这个类型并不重要,编译器生成的代码就没必要让用户还能交互起来,因此这个数据类型被标记为 internal
,表示无法被外界访问。显然,一个类型既然不是嵌套类型,就肯定不可能是 private
、protected
甚至 protected internal
的。所以,这种类型最低只能修饰为 internal
了,这也是一种让别人尽量少知道和了解的一种设计模式原则:最小知道原则(Law of Demeter,简称 LoD,也叫迪米特原则,Least Knowledge Principle,简称 LKP)。当然了,这些内容不太重要,就不在这里提了。
接着,类型肯定也不允许派生,因此 sealed
关键字密封一波避免误用产生奇怪的派生内容。
最后,类型是用 class
的,因为大家都知道,struct
和 class
在 C# 里都可以表示类型,而且基本可以实现近乎一致的代码内容,但 struct
仍然有很多限制,它的出现也是为了底层优化和性能提升而从 C/C++ 那边抄过来并保留下来的,所以 struct
的使用难度和灵活运用的水平要比 class
高一些,因此大家都建议在 C# 里尽量使用 class
。所以这个匿名类型也不例外。
所以,这个类型被修饰为 internal sealed class
。
2-2 类型头上的特性
可以注意到,这个类型头上有两个特性:[ComplierGenerated]
和 [DebuggerDisplay]
。第一个都不必多说了,老熟人了。下面我们详细说第二个特性标记。
[DebuggerDisplay]
特性标记,对应的特性原名称是 DebuggerDisplayAttribute
(这不废话吗),表示这个类型在调试阶段,调试工具和模块对这个类型怎么处理和显示。这个特性实例化需要一个构造器参数,是在调试工具里,如何呈现这个数据类型的文本表示内容,里面写字符串信息,字符串里可以带有大括号,类似于占位符一样的概念。
仔细观察这个字符串:@"\{ Name = {Name}, Age = {Age} }"
,开头的 \{
要看成一组,它表示转义。因为大括号在输出的时候有占位符的概念,会被误认为是去匹配闭大括号 }
,因此这个地方用一次转义来避免它去匹配大括号、当作普通的大括号字符 {
。接着里面写了 Name = {Name}, Age = {Age}
的内容。因为字符串是原封不动的内容,因此只有这里的占位符会被替代为具体属性数值的结果。换句话说,比如 Name = "Sunnie", Age = 25
是我们最开始对 obj1
的赋值,那么这里就好比是显示这样的内容,把 "Sunnie"
字符串替换到 {Name}
占位符上去,而把 25 作为结果替换到 {Age}
占位符上去。最后有一个 }
,因为前面最开始转义了开头的开大括号 {
,所以这里大括号并不成对:整个序列里开大括号有两个匹配上了占位符语法,而闭大括号也有两个匹配上了占位符的语法,因此多出来的这个闭大括号没有匹配,所以它只能被视为普通字符。
不要钻牛角尖。请特别注意,这个占位符语法(即把属性名称嵌套放在字符串里可以当占位符用的语法)只是这个构造器参数里允许的书写规则。换句话说,这个语法是不能用于
string.Format
、Console.Write
之类的方法里当占位符使用的。那些方法里的模式字符串和这里[DebuggerDisplay]
的构造器参数不是一个实现体系,只是用到了类似的处理机制和算法罢了,它们并不统一。所以不要想着是完全一样的内容而去把这里的字符串生搬硬套放到那些方法里当模式字符串。
接着是 Type
这个命名参数。这个命名参数表示我在调试工具里,对这个类型的名称怎么显示。有些时候我们完全不必非得把这个名称完整地显示出来。比如这里我们命名为 AnonymousType_obj1<T1, T2>
,而很显然下划线后的 obj1
就没必要显示出来,因为是哪个变量的实例化过程我们并不关心,我们只关心里面的存储数值;另外,泛型参数也不必显示。
哦对,顺带一说。这个类型会被编译器翻译为一个泛型类型,而不是直接抄
string
和int
两个实际数据类型上去。这样是考虑到代码的可复用性。如果我又来一个新的变量赋值,属性名也都是完全一致的的话,就不必单独再一次生成一个新的类型出来了,这里只需要通过泛型强大的类型替代机制,就可以实现两个变量使用同一个具体类型的效果。
所以总的来说,这个特性标记其实就是控制一下调试工具和模块到底怎么显示,如何显示,以及显示什么的规则。
2-3 两个字段
这两个字段厉害了,它们的类型竟然是泛型参数的类型 T1
和 T2
。泛型参数类型前面有说过,是为了可以复用,避免创建特别多的匿名类型对应的实体类型。
其它的,也没啥可说的,字段封装肯定得定义为 private
修饰的;另外字段只在匿名类型的实例化的时候,才会调用构造器,而期间并不允许修改,因此匿名类型是不让修改里面的数值的。
实际上,你也没办法修改。你已经定义了类型后,就无法通过 C# 的语法来改变了:
2-4 两个属性
属性就不多说了。属性是字段的封装,因为字段是只读的,所以属性也只用于取值,所以它没有 setter。
2-5 构造器
构造器也不必多说。用来实例化的。
2-6 Equals
方法和 EqualityComparer<>
类型
下面我们来聊聊 Equals
方法和 GetHashCode
两个方法里面的 EqualityComparer<>
泛型类型。
这个泛型数据类型是之前没说过的,它表示“路由”(Route)到具体类型的 Equals
和 GetHashCode
方法的特殊类型。这个方法有一个静态属性 Default
,用来获取 EqualityComparer<T>
类型的这个 T
的相等性比较和哈希码求值操作的过程。
不论你的代码有多复杂,只要你重写了 Equals(object)
方法和 GetHashCode()
方法,EqualityComparer<T>.Default
都能找到它们;如果你没有重写,那么它就会按照它的基类型的实现规则(也就是比如说 T
是引用类型,就看 T
的基类型有没有重写 Equals
和 GetHashCode
方法;如果没有就继续往上找基类型)去查找对应的实现。
这就很方便了。如果我们以前来写代码的话,我们还很难通过反射去调取一个对象是否真的包含自己的 Equals
和 GetHashCode
方法。就 Equals
方法来说,很多人会愿意去重载 ==
和 !=
运算符,但问题是有些时候也不一定有重载,于是运算符有些时候并不奏效。举个例子,我们要比较两个学生的数据是否一致,我们的办法是:
==
运算符,因此比较起来很方便;可总有一些时候我们不一定能知道对象的类型是否有 ==
运算符;没有我们还得去看有没有自己已经重写了 Equals
方法。很显然,这样的处理很复杂,反射机制是可以帮助我们做到这些内容的,但确实复杂了一些。这个 EqualityComparer<>
虽然说代码复杂了,但是这个 Default
静态属性相当好用,一劳永逸。
我们回到原始的代码里。匿名类型生成的 Equals
方法和这个地方我们自己实现的写法有异曲同工之妙。只不过我们这里用的是 as
运算符代替了 if (!(other is T)) return;
的判断。我们说过,as
运算符会同时判断和获取结果。如果类型不匹配,会返回 null
;否则会返回对象转换后的结果。
注意这个 Defualt
静态属性,我们后面又多了一个 Equals
。这是 Default
类型里自带的一个成员。它可以用来获取计算得到两个对象按照这个 EqualityComparer<T>
的 T
类型下的比较过程,来看是否两个对象一致。
可以发现,编译器生成代码里会大量用到这个 Default
静态属性。因为编译器的代码生成是想做到一劳永逸,而这个类型的 Default
属性刚好可以一劳永逸(或者说,这个 Default
属性就是为了编译器生成代码一劳永逸而发明设计出来的),所以编译器会大量用到它。
2-7 GetHashCode
方法
方便就方便在,EqualityComparer<T>
类型的 Default
静态属性不仅提供了 Equals
计算两个对象在当前 T
类型下的相等性比较操作,还提供了一个 GetHashCode
方法。这个方法传入一个参数,表示计算这个实例在这个 T
类型里给出的 GetHashCode
方法的计算过程运算的结果。
可以看到匿名类型生成的代码,多了几个乱七八糟的数据。它们的存在也就是为了混乱数据数值,使得哈希码的结果更加“凌乱”,避免用户从哈希码反推对象。
2-8 ToString
方法
这个不必多说了,它实现的代码就是在拼凑字符串,使得这个匿名类型输出的结果更加好看一些。如果没有重写的话,我们都知道,ToString
会自动调用的是基类型 object
的 ToString
方法。它的结果是产生一个字符串,但这个字符串是这个类型的 BCL 全名。显然这个名称就没有任何意义,因为它没有体现和表示出对象包含的各个数据信息的结果。所以 ToString
在匿名类型里,也被编译器重写了。
2-9 [DebuggerHidden]
特性标记
这个特性有必要简单说说。它标记一个成员,表示这个成员在调试期间不被调试器发现。啥意思呢?就是说,按照基本的实现规则来看,默认情况是,调试器会在调试数据栏里给出所有这个类型的成员信息,并给出对应的数值(即使是非 public
修饰的)。但是,这个标记可以允许调试器在调试数据栏里不显示它。
Part 3 嵌套匿名类型
匿名类型是允许嵌套的,你可以这么写代码:
这里的 a
是匿名类型的变量,而它的 A
属性,就是一个嵌套的匿名类型。
Part 4 匿名类型的对象只建议当成临时变量使用
匿名类型提供了一种非常方便的思路来创建一个类型。但问题是,这样的类型名称我们都没有办法得到,我们到现在都只是在写实例化一个匿名类型的实例的时候,用 var
关键字来代替。真实的类型名称是编译器给出的。因此我们也无从知道。
有人说前面介绍文字里给的那个 AnonymousType_obj1<T1, T2>
不是吗?实际上,并不是。这个名字也是我为了帮助大家理解把编译器的生成代码魔改了过后,简化后的样子。实际上,你看到的编译器生成的原始代码真就跟天书一般,标识符甚至都不遵守规则,类型名里还可以带有各种各样的符号,什么竖线啊、什么尖括号啊之类的。这些东西都是我们没有讲到的内容,因为我们也不关心编译器为什么非得这么生成代码,所以我们没有刻意去提及这一块的内容。只是说,我们用到了类型名的时候,把它们都写成我们看得懂的写法,比较好理解。因此这个 var
并非直接代替的这里的 AnonymousType_obj1<T1, T2>
这个东西。
而从这个角度来说,你就不可能把它拿来当参数用于方法上。因为你连类型名称都不知道,怎么可能写得出来合适的代码?而别的地方更不可能了:属性?字段?它们都是要在名字前面带上类型名称的;运算符?索引器?这些更不可能了。所以,一顿排除法过后,我们发现,也就只有临时变量可以用一下匿名类型的机制。
Part 5 匿名类型实例当方法参数的小技巧
那么,到底可不可以使用匿名类型当方法参数呢?我们这里有一个小技巧可以做到。匿名类型是没有任何的显式基类型的,因此我们没办法知道它的具体类型。要想知道具体类型,只能借助泛型机制。
我们给需要使用匿名类型当参数的方法定义为泛型方法,带有一个泛型参数 T
表示匿名类型本身。接着,我们把匿名类型的对象用 T
表示并传入。
即这么写。
可以看到,这样的代码,我们可以调用,比如写成 F(new { A = 1, B = 2 })
之类的语法,C# 是允许的。不过,方法内部怎么调取数值呢?这就只能用反射了。
我们稍加改动。假设返回值表示这个匿名类型的两个属性的数值之和,那么我们可以像上面这样的方式来写代码。我们使用 typeof
表达式获取类型 T
的类型信息,并通过这个类型信息实例,得到 A
和 B
属性的数值(通过反射机制)。aProperty
和 bProperty
是 PropertyInfo
类型的实例,它并不是原值。这个类型表示和封装了一组跟当前属性 A
绑定起来的存取数值的操作。比如这里把 aProperty
当成实例,调用 GetValue
方法,就是在取值。注意这里要传入一个参数。因为原始的 A
和 B
属性都是实例属性,没有修饰 static
,所以它在概念上是绑定了一个具体的实例的,因此我们要取值必须要绑定上对应的实例对象。换句话说,我们大概要想等价 i.A
的写法,肯定得有 i
,然后才能有 A
的数值。这个 GetValue
方法传入的参数,就是这里我们说的 i
。
最后,我们直接加起来即可。注意类型是 int?
而不是 int
,因此加法运算会先确保都不为 null
才会相加。但凡两个 int?
实例里有一方是 null
,结果都为 null
,这个是 C# 2 的可空值类型里介绍过的一个现象。
调用这个 F
方法的机制也很简单:
是的,我们巧妙利用上泛型方法的类型推断机制,避免了我们明确给出匿名类型的全名这个我们无法做到的问题。这就是我们怎么完成匿名类型实例传参的一个巧妙的办法。
Part 6 “用了就丢”用在哪里?
匿名类型提供了一种“即用即丢”的思路,可以让我们更加方便快捷地做到,可问题是,这个使用场合太窄了,临时变量可用的话,我们很少会接触到这样的匿名类型的机制。那么它用在哪里呢?
先别急,之后我们会介绍一个 C# 3 的新语法:查询表达式(Query Expression)和 LINQ。这是一个超大的语法体系,在那个时候,我们会广泛用到匿名类型机制。