文章目录
- 第三章 C#3:LINQ及相关特性
- 3.1 自动实现属性(*)
- 3.2 隐式类型 var(*)
- 3.3 对象和集合初始化
- 3.3.1 对象初始化器
- 3.3.2 集合初始化器
- 3.4 匿名类型
- 3.4.1 基本语法和行为
- 3.4.2 编译器生成类型
- 3.4.3 匿名类型的局限性
- 3.5 lambda 表达式
- 3.5.1 捕获变量
- 3.5.2 表达式树
- 3.6 扩展方法
- 3.6.1 声明扩展方法
- 3.6.2 调用扩展方法
- 3.6.3 扩展方法的链式调用
- 3.7 查询表达式
- 3.7.1 从 C# 到 C# 的查询表达式转换
- 3.7.2 范围变量和隐形标识符
- 3.7.3 选择使用哪种 LINQ 语法
- 3.8 终极形态:LINQ
第三章 C#3:LINQ及相关特性
3.1 自动实现属性(*)
3.2 隐式类型 var(*)
3.3 对象和集合初始化
3.3.1 对象初始化器
对象初始化器的作用只是表达应该如何初始化每个属性。
注意,只有在使用对象初始化器或者集合初始化器时,构造器的参数列表才再以省略。
- 如果初始化值(“=” 右边的内容)是一个普通的表达式,那么会先计算该表达式的值,然后将结果传给属性对应的 set 访问器。
- 如果初始化值是另一个对象初始化器,则不会调用 set 访问器,而会调用 get 访问器,然后将嵌套对象初始化器得到的结果应用于由 get 访问器返回的属性。
上述代码等同于以下代码:
3.3.2 集合初始化器
集合初始化器多用于创建新集合。下面这行代码创建了一个字符串集合并为其添加初始值:
编译器会将以上代码转换成一个构造器调用,其后紧跟一系列 Add 方法的调用:
对于 Dictionary<TKey, TValue>,添加元素的方法是 Add (key, value):
编译器把每个元素初始化器都看作一个 Add 调用。如果元素初始化器没有大括号,则将其作为单个参数传递给 Add 方法。上述字典的例子等同于如下代码:
只有实现了 IEnumerable 接口的类型才能够使用集合初始化器。
3.4 匿名类型
3.4.1 基本语法和行为
使用匿名类型可以更精练地表达“一次性”的类型需求,同时还不失静态类型的优势:
-
匿名类型的语法类似于对象初始化器,但无须指定类型名称,只需要 new 关键字、左大括号、属性以及右大括号。这一形式称为匿名对象创建表达式。
-
声明 player 变量使用了 var 关键字,因为所创建的类型是匿名类型,所以只能用 var 来声明(也可以使用 object 来声明,不过意义不大)。
-
以上代码依然属于静态类型的范畴。Visual Studio 会为 player 变量自动设置 Name 和 Score 属性。如果要访问一个不存在的属性(比如 player.Points),则编译器会报错。
-
属性的类型是根据赋值的类型进行推断的:player.Name 是 string 类型,player. Score 是 int 类型。
投射初始化器:
可以从其他对象复制属性或字段到新对象中,并且二者的属性或字段名称相同。
上述例子中,除了 CustomerName,其他属性都使用了投射初始化器。以上代码的运行结果和下面这种显式写出每个属性名称得到的结果是相同的:
如果目标属性或字段的名称与源名称一致,那么可以交由编译器来推断名称,如以下代码:
可以直接简化为:
说明:
尽管以上两种形式的代码结果相同,但不是所有行为都相同。
例如,在项目中将 Address 属性重命名为 CustomerAddress,若使用投射初始化器,那么 flattenedItem.Address 也将变为 flattenedItem.CustomerAddress。
3.4.2 编译器生成类型
虽然源码中没有出现匿名类型的名称,但编译器需要为它生成一个类型。
- 它在执行期没有任何特殊之处,对于执行期来说也只是一个普通的类型而已。
- 该类型的名称不是一个有效的 C# 名称。
关于该类型,还有几个比较有意思的特征(其中一些得到了语言规范层面的保证):
- 它是一个类(保证)。
- 其基类是 object (保证)。
- 该类是密封的(不保证,虽然非密封的类并没有什么优势)。
- 属性是只读的(保证)。
- 构造器的参数名称与属性名称保持一致(不保证,有时对于反射有用)。
- 对于程序集是 internal 的(不保证,在处理动态类型时会比较棘手)。
- 该类会覆盖 GetHashCode() 和Equals() 方法:两个匿名类型只有在所有属性都等价的情况下才等价(可以正常处理 null 值)。只保证会覆盖这两个方法,但不保证散列值的计算方式。
- 覆盖并完善 ToString() 方法,用于呈现各属性名称及其对应值。这一点不保证,但对于问题诊断来说作用重大。
- 该类型为泛型类,其类型形参会应用于每一个属性。具有相同属性名称但属性类型不同的匿名类型,会使用相同的泛型类型,但拥有不同的类型实参。这一点不保证,不同编译器的实现方式不同。
- 如果两个匿名对象创建表达式使用相同的属性名称,具有相同的属性类型以及属性顺序, 并且在同一个程序集中,那么这两个对象的类型相同。
可以利用第 10 点使用匿名类型来创建隐式类型数组:
3.4.3 匿名类型的局限性
- 难以应用于方法签名中。即, 难以在多处使用同一个匿名类型。
- 匿名类型不提供任何数据封装。即,匿名类型中不能有校验,也不能添加任何行为。
3.5 lambda 表达式
3.5.1 捕获变量
给出如下设计好的代码示例:
- instanceField 是 CapturedVariablesDemo 类的一个实例字段,被 lambda 表达式所捕获。
- methodParameter 是 CreateAction 方法的一个参数,被 lambda 表达式所捕获。
- methodLocal 是 CreateAction 方法中的一个局部变量,被 lambda 表达式所捕获。
- uncaptured 是 CreateAction 方法中的一个局部变量,因为没有被 lambda 表达式使用,所以不属于捕获变量。
- lambdaParameter 是 lambda 表达式自己的参数,不属于捕获变量。
- lambdaLocal 是 lambda 表达式内部的局部变量,不属于捕获变量。
通过生成类来实现捕获变量
- 没有捕获任何变量,编译器会创建一个静态方法,不需要额外的上下文。
- 仅捕获了实例字段,编译器会创建一个实例方法。实例字段的捕获数目没有影响,只需要一个 this 便都可以访问。
- 捕获了局部变量或者参数,编译器会创建一个私有的嵌套类用于保存上下文信息,在该类中创建一个实例方法用于容纳原 lambda 表达式内容,并使用嵌套类来访问捕获变量。
应用上述规则,编译器转义后的代码如下:
说明:
具体实现细节因编译器而异。例如对于没有捕获变量的 lambda 表达式,编译器可能会创建一个包含一个实例方法的嵌套类,而不是创建一个静态方法。委托的执行效率会因创建方式的不同而略有差异。这里只描述编译器为访问捕获变量所做的那些必要、基本的工作, 其复杂度可能根据实际需要而增加。
局部变量的多次实例化
简单起见,下列代码不捕获参数和实例字段,只捕获一个局部变量:
在这段代码中,每次声明 text 时,该变量就完成一次实例化,因此每个 lambda 表达式捕获的都是不同的变量实例,于是 5 个完全独立的 text 变量被分别捕获。虽然这段代码中变量初始化后没有任何修改操作, 但编译器的做法是:每次初始化都创建一个不同的生成类型实例。编译器转义后的代码如下:
多个作用域下的变量捕获
循环的每次迭代都要实例化一次变量,是因为变量作用域的缘故。一个方法内部可能存在多个作用域,每个作用域都可能包含局部变量的声明,而一个 lambda 表达式可以从多个作用域捕获变量,给出如下示例代码:
其执行结果如下:
其中,outercounter 变量被两个委托共用,而 innerCounter 为画个委托分别所有。每个委托都需要各自的上下文,但是各自的上下文还需要指向一个公共的上下文。编译器会为这种情况创建两个私有嵌套类,转义后的结果如下:
大多数情况很少需要查看这样的代码,但编译器生成代码的方式会对程序性能有不小的影响。如果在性能敏感的代码中使用 lambda 表达式,那么需要注意可能会因为变量捕获而创建过多对象,从而影响性能。
3.5.2 表达式树
lambda 表达式可以由编译器转换成表达式树。表达式树是将代码按照数据来表示的一种形式。这项特性是 LINQ 能够有效处理 SQL 数据库的核心秘诀所在。通过表达式树,C# 的代码可以在执行期被分析并转换成 SQL。 委托的作用是提供可运行的代码,而表达式树的作用是提供可查看的代码(这有点类似于反射机制)。虽然也可以在代码中直接构建表达式树,但更普遍的做法是让编译器负责把 lambda 表达式转换成表达式树。
以下面的 lambda 表达式为例:
编译器并未在任何地方生成一个硬编码的字符串。以上字符串是通过表达式树动态构建出来的。这段代码表明:代码是可以进行执行期检查的。这就是表达式树的所有关键所在。
首先看 adder 的类型:Expression<Func<int, int, int>>。把它拆解成两部分: Expression<TDelegate> 和 Func<int, int, int>。Func<int, int, int> 是 Expression<TDelegate> 的类型实参,它是一个代理类型,由两个 int 参数和一个 int 返回值构成。
Expression<TDelegate>是处理 TDelegate 类型的表达式树类型。其中 TDelegate 必须是委托类型。委托类型仅仅是表达式树相关的诸多类型之一,它们均位于 Systarn.Linq.Expressions 命名空间下。非泛型的 Expression 类是所有表达式类型的抽象基类。
adder 变量是一个表示接收两个整型值并返回一个整型值方法的表达式树表示,之后可以用 lambda 表达式来为该变量赋值。编译器负责生成适用于执行期的表达式树。示例代码如下:
转换表达式树的局限性
只有拥有表达式主体的 lambda 表达式才能转换成表达式树。下面这句代码会编译报错:
- 从 .NET 3.5 开始,表达式树 API 就已经扩展支持代码块和其他构建了,但 C# 编译器依然保留了该限制,而且对于 LINQ 使用的表达式树也有同样的限制。
- 这是对象初始化器和集合初始化器很重要的原因:可以在一个表达式内完成初始化,以供表达式树使用。
- 另外,lambda 表达式不能使用赋值运算符,也不能使用 C# 4 的动态类型和 C# 5 的异步。
将表达式树编译成委托
表达式树可用于在执行期动态构建委托。这种方式一般需要手动编写部分代码,而不是使用 lambda 表达式进行转化。
Expression<TDelegate> 有一个 Compile() 方法,该方法返回一个委托类型。该委托类型与普通的委托类型无异。
以上述代码为例,构建出 adder 表达式树,将其编译成一个委托,然后调用该委托并打印出结果:
3.6 扩展方法
3.6.1 声明扩展方法
- 扩展方法必须声明在一个非嵌套、非泛型的静态类中。
- 在 C#7.2 之前箕一个参数不能是 ref 参数。
- 扩展方法所在的类不能是泛型类,但扩展方法自身可以是泛型方法。
- 扩展方法的第一个参数有时称为扩展目标或扩展类型。
编译器唯一需要做的就是为扩展方法及其所在类添加[Extension]特性。该特性在命名空间 System.Runtime.CompilerServices 下。其本质上是一个标记,标记 ToInstant() 方法可以按照 DateTimeOffset 的实例方法那样凋用。
3.6.2 调用扩展方法
扩展方法可以在其第一个参数的类型实例上以实例方法的调用方式进行调用,但还需要一个前提:让编译器可以查找到这个扩展方法。
优先级问题
-
如果存在一个与该类同名的普通实例方法,那么编译器总是会优先选择该实例方法来调用。
- 在此过程中,无所谓扩展方法是否具有更匹配的形参。如果编译器查找到有可调用的实例方法,就不会再去查找扩展方法了。
-
如果编译器没有找到可调用的实例方法,那么会开始查找扩展方法。首先查找扩展方法调用代码所在的命名空间以及所有 using 指令指定的命名空间。
编译器会从以下位置查找扩展方法:
- CSharpInDepth.Chapter03 命名空间下的静态类;
- CSharpInDepth 命名空间下的静态类;
- 全局命名空间下的静态类;
- using 指令指定的命名空间下的静态类(例如 using System 这样的指向命名空间的命令);
- (只在 C#6 中)using static 指定的静态类,10.1节还会介绍。
补充:
- 编译器会从最内层的命名空间一路向外查找至全局命名空间。在查找的每条路径上,都要查找当前命名空间下的静态类,或者查找 using 指令指定的命名空间中的类。
- 查找的顺序并不重要。如果调整 using 指令的顺序后影响了扩展方法的查找结果,建议将扩展方法重新命名。
- 查找的每一步中都有可能找到多个适合调用的扩展方法。此时编译器会对当前所有候选方法执行常规的重载决议。
- 在决策完成与,编译器为调用扩展方法所生成的 IL 代码和调用普通静态方法所生成的 IL 代码是完全相同的。
说明:
x.Method(y);
如果 Method 是实例方法,x 为 null,就会抛出 NulLReferenceException;
而如果 Method 是一个扩展方法,那么即便 x 为 null,也会将 x 作为其首个参数进行方法调用。
3.6.3 扩展方法的链式调用
下面示例代码是一个简单查询:现有一个单词序列,按照单词长度进行筛选,并将其按字母顺序排序,然后全部转换为大写。该查询只用到了 C#3 中的 lambda 表达式和扩展方法这两个特性。
注意:以上代码中 Where、OrderBy 和 Select 三个调用的顺序就是操作实际发生的顺序。由于 LINQ 中存在延退和优化策略,很难知道具体何时会执行什么操作,但代码的阅读顺序和执行顺序是一致的。
下列代码实现了上述相同的查询功能,但没有使用扩展方法。
对比之下可以发现明显的缺陷:代码阅读起来很困难。代码中方法调用的顺序和实际执行的顺序刚好相反:Where 方法是第一个被调用的,却放在了末尾。lambda 表达式 word => word.ToUpper() 究竟属于哪个方法调用很不明确。它本属于 Select 方法, 但和 Select 中间隔了一堆代码。
还有一个解决方法是将每个方法调用的结果都赋给一个局部变量,然后通过上一个变量再继续调用下一个方法。但大量额外的局部变量容易造成混淆且会分散注意力。
由上可见,方法的链式调用带来的好处不仅仅限于 LINQ。一个方法调用的结果用作另一个方法调用的开始。扩展方法能让我们以可读性强的方式编码任何类型,而且不局限于那些已经支持链式调用的类型。
3.7 查询表达式
虽然几乎 C# 3 的所有特性都对 LINQ 有所贡献,但只有查询表达式是专门为 LINQ 设计的。 使用查询表达式,我们可以通过查询专用语句(select, where、let、group by 等)编写简洁的查询代码。由编译器负责把查询表达式翻译成非查询语句的形式,并进行常规编译。回顾一下 3.6.3 节的代码:
使用查询表达式改写的功能相同的查询代码如下所示,其中加粗的部分为查询表达式:
3.7.1 从 C# 到 C# 的查询表达式转换
语言规范直接将查询表达式定义为一种语法转译,且该转译过程发生在绑定或重载决议之前。即,查询表达式会首先被编译器转义为可执行的 C# 代码。很多时候,转译的结果就是使其变成对应的扩展方法调用,不过语言规范并没有强制要求该行为。
3.7.2 范围变量和隐形标识符
查询表达式引入了范围变量的概念。范围变量与普通变量不同,范围变量充当了查询语句中每条子句中的输入。
在上一个例子中,位于查询表达式起始位置的 from 子句引入了范围变量(加粗部分):
子句中引入范围变量的最简单方式应该是使用 let 关键字。假设需要在查询中多次使用单词长度这个变量,但又不想每次都调用 Length 属性。如果需要就单词长度进行琲序,并且在输出结果中使用长度变量,那么使用 let 子句的查询如下所示:
在对查询进行转译时,该如何表示 length 和 word 呢?这需要把原始的单词序列转换成“单词-长度”对。在需要访问范围变量的子句中,再通过变量对来访问其中的某个变量:
这里的 tmp 不属于查询转译的一部分,语言规范中是用 * 符号表示的。在语言规范并没有规定为查询构建表达式树时,参数应当使用什么名称。这个名称本身不重要,因为在编写查询时它是不可见的,因此把它称为隐形标识符。
3.7.3 选择使用哪种 LINQ 语法
- 查询表达式:更适合大规模查询,表现出众,可读性强。
- 必须以 from 子句开始,以 select 或者 group by 子句结尾。
- 方法语法:更适合简单查询,简单明了。
例如:
对比采用扩展方法的写法,就显得有些笨拙了:
说明:
对于采用非查询表达式的语法,目前没有统一的术语,而有方法语法、点式语法、流式语法、lambda 语法等名称,之后会统一采用方法语法来代称。
当查询变得更复杂时,方法语法依然可以从容应对:
-
LINQ 中提供的很多方法,并没有与之对应的查询表达式语法。
- 例如 Select 和 Where 的某些重载方法,返回的是元素以及元素对应的索引值。
-
如果想在查词的结尾执行一个方法调用(例如调用 ToList() 来把结果转换成 List<T> 对象),就要把整个查询表达式用圆括号括起来;
如果使用方法语法,只需在末尾直接添加方法调用即可。
在很多情况下(包括上述例子在内),两种方式难分高下。
3.8 终极形态:LINQ
下面介绍 C#3 特性是如何成就 LINQ 的。假设有一个查询从 Entity Framework 获取数据,代码如下所示(假设已存在某数据库和相应的表结构):
短短 4 行代码,应用了所有新特性。
-
匿名类型.
包括投射初始化器(只选择 name 和 price 这两个属性)。
-
使用 var 声明的匿名类型.
因为无法声明 products 变量的有效类型。
-
查询表达式。
当然对于本例可以不使用查询表达式,但对于更复杂的情况,使用查询表达式能事半功倍。
-
lambda 表达式。
lambda 表达式在这里作为查询表达式转译之后的结果。
-
扩展方法。
使得转译后的查询可以通过 Queryable 类实现,因为 dbContext.Products 实现了 IQueryable<Product>接口。
-
表达式树。
使得查询逻辑可以按照数据的方式传给 LINQ 提供器,然后转换成 SQL 语句并交由数据库执行。