第3章 C#3:LINQ 及相关特性
3.1 自动实现的属性
C#3 增加了自动属性,由编译器负责实现原先的访问器部分(编译器会自动创建后台字段)。试比较如下两段代码:
// C#3 之前
private string name;
public string Name
{get { return name; }set { name = value; }
}
// C#3
public string Name { get; set; }
C#6 对自动属性进一步进行了优化(见8.2 自动实现属性的升级):
- 自动属性可以进行赋值
- 支持只读自动属性
3.2 隐式类型
3.2.1 类型术语
下面对静态类型、动态类型、显式类型、隐式类型进行讲解
3.2.1.1 静态类型和动态类型
-
静态类型语言:典型的面向 编译 的语言:所有表达式的类型都由 编译器 来决定,并由 编译器 来检查类型的使用是否合法。
这个“决定”过程称之为“绑定”
-
动态类型语言:把绝大部分甚至所有绑定操作放到了 执行 期。
C# 总体上属于 静 态类型语言(不过 C#4 引入了动态绑定,第4章 C#4:互操作性提升将详述)。尽管具体调用虚函数的哪个实现取决于执行期调用对象的类型,但是执行方法签名绑定的整个过程都发生在编译时。
3.2.1.2 显式类型和隐式类型
- 显式类型语言:源码会显式地给出所有相关的类型信息,包括局部变量、字段、方法参数或者返回类型。
- 隐式类型语言:允许开发人员不给出具体的类型信息,而是通过其他机制(编译器或者执行期的其他方式)根据 上下文 推断出类型。
C# 总体上属于 显式 类型,不过在 C#3 之前,就已经出现了隐式类型的身影,例如2.1.4 方法类型实参的类型推断提到的对泛型类型实参的类型推断机制。另外,隐式类型转换的出现(比如从 int
到 long
的转换),也削弱了 C# 的显式类型特征。
3.2.2 隐式类型的局部变量
隐式类型的局部变量:指使用上下文关键字 var
声明的变量。如下两段代码等价:
var language = "C#";
string language = "C#";
隐式类型局部变量的使用有两(三)条重要的规则:
- 变量在 声明 时就必须被初始化;
- 用于初始化变量的表达式必须已经具备 某个类型 ;
- 只能用于局部变量。
以如下代码为例,它们违背了前两条规则,无法通过编译:
var x;
x = 10;var y = null;
隐式类型常用于如下 3 个场景:
- 变量为 匿名 类型,不能为其指定类型。(见3.4 匿名类型)
- 变量类型名 过长,并且根据其初始化表达式可以轻松推断出类型。
试比较:
Dictionary<string, List<decimal>> mapping =new Dictionary<string, List<decimal>>();
var mapping = new Dictionary<string, List<decimal>>();
- 变量的精确类型并不重要,并且初始化表达式可以提供足够的信息来推断。
3.2.3 隐式类型的数组
随着 C# 发展,我们对数组赋初值的方式也变得多样化。
C#3 之前
C#3 之前有两种方式初始化数组:
int[] array1 = { 1, 2, 3, 4, 5};
int[] array2 = new int[] { 1, 2, 3, 4, 5};
第 1 种方式要求必须在变量声明中指定数组类型。以下形式 非 法:
int[] array;
array = { 1, 2, 3, 4, 5 };
第 2 种初始化方式 则不 受此规则限制:
array = new int[] { 1, 2, 3, 4, 5 };
C#3
C#3 引入了第 3 种方式:数组为隐式类型,其类型由元素的类型决定,只要编译器可以根据元素的类型推断数组的类型。
如下代码是 合 法的:
int[] array;
array = new[] { 1, 2, 3, 4, 5 };
多维数组也可以这样使用:
var array = new[,] { { 1, 2, 3 }, { 4, 5, 6 } };
3.2.3.1 推断流程
编译器的推断流程大致如下:
- 统计每一个元素的 类型 ,将这些 类型 整合成一个类型候选集。
- 对于类型候选集中的每一个类型,检查是否所有元素都可以 隐式地转换为该类型 。剔除不满足该检查条件的类型,最终得到一个筛选过的类型集。
- 如果该类型集中 只剩一个类型 ,则该类型就是推断出来的元素类型,编译器根据该类型来创建合适的数组。如果类型集中类型的数量为 0 或大于 1,则编译时会 报错 。
下表展示了这一规则:
表达式 | 结果 | 备注 |
---|---|---|
new[] { 10, 20 } |
int[] |
所有元素均为 int 类型 |
new[] { null, null } |
Error | 所有元素都不具有类型 |
new[] { "xyz", null } |
string[] |
string 是唯一候选类型,并且 null 可以转换为 string 类型 |
new[] { "abc", new object() } |
object[] |
候选类型有两个:string 和 object , string 可以隐式转换为 object 类型,反之则不成立 |
new[] { 10, new DateTime() } |
Error | 候选类型有两个: int 和 DateTime ,但是两个类型不能相互转换 |
new[] { 10, null } |
Error | int 是唯一候选类型,但 null 不能转换为 int 类型 |
3.3 对象和集合的初始化
3.3.1 对象初始化器和集合初始化器简介
假设我们有如下若干类型:
public class Order
{private readonly List<OrderItem> items = new List<OrderItem>();public string OrderId { get; set; }public Customer Customer { get; set; }public List<OrderItem> Items { get { return items; } }
}public class Customer
{public string Name { get; set; }public string Address { get; set; }
}public class OrderItem
{public string ItemId { get; set; }public int Quantity { get; set; }
}
是比较如下两段代码,可以看到,使用对象初始化器和集合初始化器,整体更加简洁:
var customer = new Customer(); //
customer.Name = "Jon"; // 创建 Customer
customer.Address = "UK"; //var item1 = new OrderItem(); //
item1.ItemId = "abcd123"; // 创建第1个 OrderItem
item1.Quantity = 1; //var item2 = new OrderItem(); //
item2.ItemId = "fghi456"; // 创建第2个 OrderItem
item2.Quantity = 2; //var order = new Order(); //
order.OrderId = "xyz"; //
order.Customer = customer; // 创建 Order
order.Items.Add(item1); //
order.Items.Add(item2); //
var order = new Order
{OrderId = "xyz",Customer = new Customer { Name = "Jon", Address = "UK" },Items ={new OrderItem { ItemId = "abcd123", Quantity = 1 },new OrderItem { ItemId = "fghi456", Quantity = 2 }}
};
3.3.2 对象初始化器
对象初始化器只能用于构造器调用或其他对象初始化器中。如果构造器不需要指定参数,其 参数列表 ()
可以省略。以下两种写法等价:
Order order = new Order() { OrderId = "xyz" };
Order order = new Order { OrderId = "xyz" };
使用对象初始化器为对象中的引用成员赋值,有两种方式。试比较如下两段代码:
class Student
{public string Name { get; set; }public School School { get; set; }
}class School
{public string Name { get; set; }
}
class Student
{public string Name { get; set; }public School School { get; set; } = new School();
}class School
{public string Name { get; set; }
}
以下使用方式,上述类型定义方式,第 一 段代码会抛出 NullReferenceException
异常
Student student = new Student
{Name = "John Smith",School ={Name = "Kailua Intermediate School"}
};
如下使用方式则不会抛出异常:
Student student = new Student
{Name = "John Smith",School = new School{Name = "Kailua Intermediate School"}
};
这是因为:不 new 一个实例,直接使用大括号时,对象初始化器会调用 get 访问器 ,将嵌套对象初始化器得到的结果应用于由 get 访问器返回的实例上。以下两段代码等价:
Student student = new Student {Name = "John Smith",School ={Name = "Kailua Intermediate School"} };
Student student = new Student();
student.Name = "John Smith";
var school = student.School;
school.Name = "Kailua Intermediate School";
3.3.3 集合初始化器
集合初始化器只能用于构造器调用或者对象初始化器中。集合初始化器多用于创建新集合,编译器会将其转化为构造器和 Add()
方法的调用。如下两段代码等价:
var beatles = new List<string> { "John", "Paul", "Ringo", "George" };
var beatles = new List<string>();
beatles.Add("John");
beatles.Add("Paul");
beatles.Add("Ringo");
beatles.Add("George");
上述 List<T>
支持单参数的 Add()
方法,对于需要多参数的 Add()
方法(如 Dictionary<TKey, TValue>
),每个初始化元素需要 大括号 包围。如下两段代码等价:
var releaseYears = new Dictionary<string, int>
{{ "Please please me", 1963 },{ "Revolver", 1966 },{ "Sgt. Pepper's Lonely Hearts Club Band", 1967 },{ "Abbey Road", 1970 }
};
var releaseYears = new Dictionary<string, int>();
releaseYears.Add("Please please me", 1963);
releaseYears.Add("Revolver", 1966);
releaseYears.Add("Sgt. Pepper's Lonely Hearts Club Band", 1967);
releaseYears.Add("Abbey Road", 1970);
3.3.3.1 工作流程
对于集合初始化器,编译器将每个元素初始化器看作一个 Add()
调用,它的工作逻辑如下:
-
如果元素初始化器没有 大括号 ,将其作为单个元素传递给
Add()
方法。 -
如果元素初始化器带有 大括号 ,将大括号中的每个表达式当作一个参数,并执行 重载 决议:查找最合适的
Add()
方法。如果是泛型
Add()
方法,还需要执行类型推断
自定义集合类型想使用集合初始化器,需要满足以下两个条件:
- 实现了
IEnumerable
接口; - 具有
Add()
方法。
C# 语言设计团队限制必须实现 IEnumerable
接口,是为了区分集合类型和非集合类型,实际上 IEnumerable
接口无需实现。为此,自定义集合类的 IEnumerable.GetEnumerator()
方法可以直接抛出 NotImplementedException
异常(仅推荐测试项目中如此使用,产品代码不建议)。
3.3.4 仅用单一表达式就能完成初始化的好处
-
适用于 LINQ
用于 LINQ 的语句都要求具备的单一表达式的表达能力
-
可以简化字段初始化器、方法实参的传入、条件表达式的使用(三目运算符
?:.
)、静态字段初始化器构建查找表
Eureka
我认为最重要的一点,是初始化时发生了异常,不会创造出一个残破的实例。见:3.1.4 对象初始化器
此处使用了临时变量,是为了确保在初始化过程中如果 抛出异常 ,不会得到一个 部分初始化的 对象。
3.4 匿名类型
3.4.1 基本语法和行为
我们以如下代码为例,讲解匿名类型的几个要素:
var player = new //
{ // 创建一个匿名类型对象,Name = "Rajesh", // 包含 Name 和 SourceScore = 3500 // 两个属性
}; //Console.WriteLine("Player name: {0}", player.Name); // 打印
Console.WriteLine("Player score: {0}", player.Score); // 属性
-
匿名类型的语法类似于对象初始化器,但无须指定 类型 名称,只需要 new 关键字、左大括号、属性以及右大括号。
这一形式称为匿名对象创建表达式。其中属性部分可以继续嵌套匿名对象创建表达式。
-
声明变量需使用 var 关键字。
也可以使用
object
来声明,不过意义不大(如果使用object
声明,变量的属性将难以消费)。 -
上述代码依然属于静态类型范畴。
Visual Studio 会为
player
变量自动设置Name
和Score
属性。如果要访问一个不存在的属性(比如player.Points
),编译器会 报错 。属性的类型是根据 赋值的类型 进行推断的:player.Name
是 string
类型,player.Score
是 int
类型。
3.4.1.1 投射初始化器
通过其他对象的属性 or 字段为匿名类型成员赋值时,匿名类型可以将** 源对象的成员名称 作为属性名。该语法被称为投射初始化器**。
以前文的电子商务类为例:
public class Order {private readonly List<OrderItem> items = new List<OrderItem>();public string OrderId { get; set; }public Customer Customer { get; set; }public List<OrderItem> Items { get { return items; } } }public class Customer {public string Name { get; set; }public string Address { get; set; } }public class OrderItem {public string ItemId { get; set; }public int Quantity { get; set; } }
假设我们现有 Order
实例,则如下两段代码等价:
var flattenedItem = new
{order.OrderId,CustomerName = customer.Name,customer.Address,item.ItemId,item.Quantity
};
var flattenedItem = new
{OrderId = order.OrderId,CustomerName = customer.Name,Address = customer.Address,ItemId = item.ItemId,Quantity = item.Quantity
};
在复制多个属性时,使用投射初始化器可以大幅减少代码冗余。
3.4.2 编译器生成类型
编译器会为匿名类型 生成一个类型 。对于 Runtime 来说只是一个普通的类型,只是这个类型的名称不是一个有效的 C# 名称。
采用微软的 C# 编译器时,匿名类型具备以下特点(除了附注“不保证”的项外,其余项都保证):
-
它是一个类。
-
基类是
object
。 -
该类是密封的。
不保证,虽然非密封的类并没有什么优势。
-
属性是 只读 的。
-
构造器的参数名称与属性名称保持一致。
不保证,有时对于反射有用。
Eureka
是的,匿名类型有构造器!并且因为属性是 只读 的,属性只能通过构造器赋值。
-
对于程序集的访问等级是 internal 的。
不保证,在处理动态类型时会比较棘手。
-
该类会覆盖
GetHashCode()
和 Equals()
方法。两个匿名类型只有在所有属性都等价的情况下才等价。(可以正常处理 null 值。)只保证会覆盖这两个方法,但不保证散列值的计算方式。
-
覆盖并完善
ToString()
方法,用于呈现各属性名称及其对应值。不保证,但对于问题诊断来说作用重大。
-
该类型为 泛型 类,其类型形参会应用于每一个属性。具有相同属性名称但属性类型不同的匿名类型,会使用相同的泛型类型,但拥有不同的类型实参。
不保证,不同编译器的实现方式不同。
-
如果两个匿名对象创建表达式使用相同的属性 名称 ,具有相同的属性 类型 以及属性 顺序 ,并且在同一个程序集中,那么这两个对象的类型相同。
基于最后一条特点,对匿名类型实例二次赋值的行为是合法的:
var player = new { Name = "Pam", Score = 4000 };
player = new { Name = "James", Score = 5000 };
进一步推演,如下两段使用匿名类型声明数组的代码,第 2 段是非法的,因为数组中的属性顺序出现了不同:
var players = new[]
{new { Name = "Priti", Score = 6000 },new { Name = "Chris", Score = 7000 },new { Name = "Amanda", Score = 8000 },
};
var players = new[]
{new { Name = "Priti", Score = 6000 },new { Score = 7000, Name = "Chris" },new { Name = "Amanda", Score = 8000 },
};
3.4.3 匿名类型的局限性
匿名类型在需要实现数据的局部化表示时能够发挥作用。
如果需要在多处使用同一个数据形态,匿名类型将无能为力。虽然匿名类型的实例可以用于方法返回值或者方法参数,但是必须使用泛型或者 object
类型。因为匿名类型不具名的特性,所以很难应用于方法签名之中。
Tips
局部化:指某个数据形态的使用范围限制在特定方法中。
3.5 lambda 表达式
3.5.1 lambda 表达式语法简介
lambda 表达式的基本语法形式如下:
其中参数列表和主题(body)都可以有多种呈现方式。以如下最完整的 lambda 表达式代码为例(带有大括号,被称为“具有语句主体”):
Action<string> action = (string message) =>
{Console.WriteLine("In delegate: {0}", message);
};
接下来我们一步步进行简化:
-
简化 主体部分
如果主体只包含一条 return 语句或一个 表达式 ,它就可以简化成只有这一条语句(不带大括号,被称为“具有表达式主体”):
Action<string> action =(string message) => Console.WriteLine("In delegate: {0}", message);
-
简化 参数列表
编译器(部分时候)可以根据 lambda 表达式转化后的类型推断参数类型:
Action<string> action =(message) => Console.WriteLine("In delegate: {0}", message);
-
简化 圆括号
如果 lambda 表达式只有一个参数,并且可以推断出参数类型,那么参数列表的 圆括号 也可以省略:
Action<string> action =message => Console.WriteLine("In delegate: {0}", message);
3.5.2 捕获变量
如下代码展示了 lambda 表达式捕获各种变量(除静态字段未包含,包含了实例字段、this 变量、方法参数、局部变量):
class CapturedVariablesDemo
{private string instanceField = "instance field";public Action<string> CreateAction(string methodParameter){string methodLocal = "method local";string uncaptured = "uncaptured local";Action<string> action = lambdaParameter =>{string lambdaLocal = "lambda local";Console.WriteLine("Instance field: {0}", instanceField);Console.WriteLine("Method parameter: {0}", methodParameter);Console.WriteLine("Method local: {0}", methodLocal);Console.WriteLine("Lambda parameter: {0}", lambdaParameter);Console.WriteLine("Lambda local: {0}", lambdaLocal);};methodLocal = "modified method local";return action;}
}// 其他代码
var demo = new CapturedVariablesDemo();
Action<string> action = demo.CreateAction("method argument");
action("lambda argument");
lambda 表达式捕获的是这些变量本身,而非委托创建时这些变量的值(另见8.4.2 捕获变量)!lambda 也能修改这些捕获到的变量,编译器是如何做到的呢?
3.5.2.1 通过生成类来实现捕获变量
一般来说捕获变量有 3 种情形:
-
没有捕获任何变量:编译器可以创建一个 静态 方法,不需要额外上下文;
-
仅捕获了实例字段:编译器可以创建一个 实例 方法;
这种情况下捕获多少个实例字段都可以,只需 this 便可访问
-
捕获局部变量或参数:编译器会创建一个访问级别为 private 的 嵌套 类保存上下文信息,在当前类创建一个实例方法容纳原 lambda 表达式,原先包含 lambda 表达式的方法会被修改为使用嵌套类来访问捕获变量。
Tips
实际情况可能和上述表述不同,如第一种情形也可能创建实例方法。
第三种情况最复杂。我们以上一节的示例代码为例,编译器为它生成的私有嵌套类如下:
private class LambdaContext // 生成的私有嵌套类
{public CapturedVariablesDemoImpl originalThis; //public string methodParameter; // 捕获的变量public string methodLocal; //public void Method(string lambdaParameter) // lambda 表达式体变成{ // 嵌套类中的一个实例方法string lambdaLocal = "lambda local";Console.WriteLine("Instance field: {0}",originalThis.instanceField);Console.WriteLine("Method parameter: {0}", methodParameter);Console.WriteLine("Method local: {0}", methodLocal);Console.WriteLine("Lambda parameter: {0}", lambdaParameter);Console.WriteLine("Lambda local: {0}", lambdaLocal);}
}public Action<string> CreateAction(string methodParameter)
{LambdaContext context = new LambdaContext(); //context.originalThis = this; //context.methodParameter = methodParameter; // 生成类用于所有context.methodLocal = "method local"; // 捕获的变量string uncaptured = "uncaptured local"; //Action<string> action = context.Method; //context.methodLocal = "modified method local"; //return action;
}
3.5.2.2 局部变量的多次实例化
上一节的示例为捕获变量仅创建了一个上下文。下面是一个更复杂的例子,通过循环创建多个 Action
,每个 Action
都会捕获 text
变量:
static List<Action> CreateActions()
{List<Action> actions = new List<Action>();for (int i = 0; i < 5; i++){string text = string.Format("message {0}", i); // 循环内部声明局部变量actions.Add(() => Console.WriteLine(text)); // 在 lambda 表达式中捕获该变量}return actions;
}// 其他代码
List<Action> actions = CreateActions();
foreach (Action action in actions)
{action();
}
上述代码中,text
在循环中声明,每次声明 text
都会进行一次实例化,因此每个 lambda 表达式捕获的都是不同的变量实例。
编译器的做法是:每次初始化都创建一个不同的 生成类型实例 ,上述代码的 CreateAction()
方法会转译成如下形式:
private class LambdaContext
{public string text;public void Method(){Console.WriteLine(text);}
}static List<Action> CreateActions()
{List<Action> actions = new List<Action>();for (int i = 0; i < 5; i++){LambdaContext context = new LambdaContext(); // 为每次循环都创建一个型的“上下文”context.text = string.Format("message {0}", i);actions.Add(context.Method); // 使用上下文创建一个 action}return actions;
}
3.5.2.3 多个作用域下的变量捕获
下面这个例子涉及多个 作用域 ,因此更为复杂:
static List<Action> CreateCountingActions()
{List<Action> actions = new List<Action>();int outerCounter = 0; // 下文通过 for 循环创建的两个委托,都会捕获该变量for (int i = 0; i < 2; i++){int innerCounter = 0; // 每次循环都创建一个新变量Action action = () =>{Console.WriteLine("Outer: {0}; Inner: {1}",outerCounter, innerCounter);outerCounter++;innerCounter++;};actions.Add(action);}return actions;
}// 其他代码
List<Action> actions = CreateCountingActions();
actions[0](); //
actions[0](); // 每个委托
actions[1](); // 调用两次
actions[1](); //
执行结果:
Outer: 0; Inner: 0
Outer: 1; Inner: 1
Outer: 2; Inner: 0
Outer: 3; Inner: 1
上述代码中的两个委托,每个委托都需要各自的上下文,各自的上下文还需要指向一个公共的上下文。编译器的解决方案是: 创建两个私有嵌套类 。如下是编译器处理后的代码:
private class OuterContext //
{ // 外层作用域的public int outerCounter; // 上下文
} //private class InnerContext //
{ // 包含外层上下public OuterContext outerContext; // 文引用的内层public int innerCounter; // 作用域上下文public void Method(){Console.WriteLine("Outer: {0}; Inner: {1}",outerContext.outerCounter, innerCounter);outerContext.outerCounter++;innerCounter++;}
}static List<Action> CreateCountingActions()
{List<Action> actions = new List<Action>();OuterContext outerContext = new OuterContext(); // 创建一个外层上下文outerContext.outerCounter = 0;for (int i = 0; i < 2; i++){InnerContext innerContext = new InnerContext(); //innerContext.outerContext = outerContext; // 每次循环都创建innerContext.innerCounter = 0; // 一个内层上下文Action action = innerContext.Method;actions.Add(action);}return actions;
}
Question
如果上述委托在不同线程调用,岂不是会造成“竞态条件”吗?
3.5.3 表达式树
表达式树:将代码按照数据来表示的一种形式。
委托的作用是提供可运行的代码,而表达式树的作用是提供可 查看 的代码。试比较如下两段代码:
Expression<Func<int, int, int>> adder = (x, y) => x + y;
Console.WriteLine(adder);
// 输出 (x, y) => (x + y)
Func<int, int, int> adder = (x, y) => x + y;
Console.WriteLine(adder);
// 输出 System.Func`3[System.Int32,System.Int32,System.Int32]
从第一段代码的输出是表达式树动态构建得到的,它表明“代码是可以进行执行期检查的”。这是表达式树的所有关键所在。
接下来我们拆解 Expression<Func<int, int, int>> adder = (x, y) => x + y;
这句代码的各个部分:
-
Func<int, int, int>
:委托类型 -
Expression<TDelegate>
:用于处理TDelegate
类型的 表达式树 类型,TDelegate
必须是 委托 类型,由 Runtime 强制保证。
Expression<TDelegate>
仅是表达式树相关的诸多类型之一,它们均位于 System.Linq.Expressions 命名空间,非泛型的Expression
类是所有表达式类型的 抽象基 类 -
adder
变量:接收两个整型值并返回一个整型值方法的表达式树表示。可以用 lambda 表达式为该变量赋值
上述表达式树比较简单,我们也可以手动创建:
ParameterExpression xParameter = Expression.Parameter(typeof(int), "x"); // x 参数
ParameterExpression yParameter = Expression.Parameter(typeof(int), "y"); // y 参数
Expression body = Expression.Add(xParameter, yParameter); // 表达式体
ParameterExpression[] parameters = new[] { xParameter, yParameter };
Expression<Func<int, int, int>> adder = Expression.Lambda<Func<int, int, int>>(body, parameters);
Console.WriteLine(adder);
3.5.3.1 转换表达式树的局限性
-
只有拥有 表达式主体 的 lambda 表达式才能转换成表达式树
如果主体只包含一条 return 语句或一个 表达式 ,它就可以简化成只有这一条语句(不带大括号,被称为“具有表达式主体”):
(x, y) => x + y
符合该规则,但下面这句代码会编译报错:
Expression<Func<int, int, int>> adder = (x, y) => { return x + y; };
3.5.3.2 将表达式树编译成委托
表达式树除了转为 SQL 语句进行查询,还可以在执行期动态构建委托(这种情况一般需要手动编写部分代码)。
Expression<TDelegate>
的 Compile()
方法用于执行这一操作,该方法返回一个委托类型,且与普通的委托类型无异。下面是该方法的简单应用:
Expression<Func<int, int, int>> adder = (x, y) => x + y;
Func<int, int, int> executableAdder = adder.Compile(); // 将表达式树编译成委托
Console.WriteLine(executableAdder(2, 3)); // 正常调用委托
该功能可以和反射特性搭配使用,用于访问属性、调用方法来生成并缓存委托,其结果与手动编写委托结果相同。
Question
表达式树的具体用法我仍然不清楚。
3.6 扩展方法
3.6.1 声明扩展方法
扩展方法必须声明在一个非嵌套、非泛型的 静态 类中,而且在 C#7.2 之前第一个参数不能是 ref 参数(见13.5 使用 ref 参数或者 in 参数的扩展方法(C# 7.2))。扩展方法所在的类不能是泛型类,但扩展方法自身 可以 是泛型方法。
下面是一个扩展方法的简单示例:
using System;namespace NodaTime.Extensions
{public static class DateTimeOffsetExtensions{public static Instant ToInstant(this DateTimeOffset dateTimeOffset){return Instant.FromDateTimeOffset(dateTimeOffset);}}
}
编译器会为扩展方法添加 ExtensionAttribute
特性(位于 System.Runtime.CompilerServices),用于标记扩展方法可以像 实例 方法那样调用。
3.6.2 调用扩展方法
扩展方法可以像实例方法那样调用还有一个前提:编译器可以 查找到 这个扩展方法。方法的调用遵循如下优先级:
- 如果存在 同名普通实例 方法,优先调用 实例 方法;
- 未找到可调用的实例方法,查找扩展方法
编译器会从最 内 层的命名空间一路向 外 查找至 全局 命名空间。在查找的每条路径上,都要查找当前命名空间下的 静态 类,或者查找 using 指令指定的命名空间中的类。查找的每一步中都有可能找到多个适合调用的扩展方法。此时编译器会对当前所有候选方法执行常规的重载决议。在决策完成后,编译器为调用扩展方法所生成的 IL 代码和调用普通静态方法所生成的 IL 代码是完全相同的。
以如下代码为例,编译器会在如下位置查找扩展方法:
- CSharpInDepth.Chapter03 命名空间下的静态类;
- CSharpInDepth 命名空间下的静态类;
- 全局命名空间下的静态类;
- using 指令指定的命名空间下的静态类(例如 using System 这样的指向命名空间的命令);
- (只在 C# 6 中)using static 指定的静态类(见10.1 using static 指令)。
using NodaTime.Extensions;
using System;namespace CSharpInDepth.Chapter03
{class ExtensionMethodInvocation{static void Main(){var currentInstant = DateTimeOffset.UtcNow.ToInstant();Console.WriteLine(currentInstant);}}
}
Info
关于扩展方法同签名的情况,见4.8.2.3 同名的扩展方法
Tips
null 值也可以调用扩展方法,以如下代码为例,如果
Method()
的扩展方法,即便 x 为 null,也会将 x 作为首个参数进行方法调用。x.Method(y);
参考《框架设计指南》,对于扩展方法应有:
-
DO
:当扩展方法中的this
参数为null
时,要抛出 ArgumentNullException
异常。
3.6.3 扩展方法的链式调用
扩展方法的链式调用极大的改善了代码的可读性。以如下 LINQ 调用为例,试比较两段代码的简洁程度:
string[] words = { "keys", "coat", "laptop", "bottle" };
IEnumerable<string> query = words.Where(word => word.Length > 4).OrderBy(word => word).Select(word => word.ToUpper());
string[] words = { "keys", "coat", "laptop", "bottle" };
IEnumerable<string> query =Enumerable.Select(Enumerable.OrderBy(Enumerable.Where(words, word => word.Length > 4),word => word),word => word.ToUpper());
可以明显感受到第二段代码可读性较差:
- 方法调用顺序和实际执行顺序刚好 相反 ;
- lambda 表达式
word => word.ToUpper()
究竟属于哪个方法调用很不明确。
当然我们可以定义若干局部变量改善代码的可读性,但仍然不如链式表达式来的简洁:
string[] words = { "keys", "coat", "laptop", "bottle" };
var tmp1 = Enumerable.Where(words, word => word.Length > 4);
var tmp2 = Enumerable.OrderBy(tmp1, word => word);
var query = Enumerable.Select(tmp2, word => word.ToUpper());
3.7 查询表达式
查询表达式转为 LINQ 设计,其语法更加简洁,编译器负责将查询表达式翻译为链式语法并进行编译。试比较如下两段代码:
string[] words = { "keys", "coat", "laptop", "bottle" }; IEnumerable<string> query = words.Where(word => word.Length > 4).OrderBy(word => word).Select(word => word.ToUpper());
string[] words = { "keys", "coat", "laptop", "bottle" };
IEnumerable<string> query = from word in wordswhere word.Length > 4orderby wordselect word.ToUpper();
3.7.1 从 C# 到 C# 的查询表达式转换
我们前面提到的诸多特性编译器会将其直接转为 IL 代码,只有查询表达式最为特殊:编译器会将其转为 C# 的 方法语法(扩展方法的调用) 再进行编译(被称为语法转译),且转译发生在绑定或重载之前。
3.7.2 范围变量和隐形标识符
- 范围变量:因查询表达式引入,充当查询语句中每条子句中的输入。
引入范围变量的方式有二:
- 通过
from
关键字 - 通过
let
关键字
from word in words // from 子句引入范围变量
where word.Length > 4 //
orderby word // 后续子句中使用
select word.ToUpper(); // 范围变量
from word in words
let length = word.Length // 通过 let 关键字引入新的范围变量
where length > 4
orderby length
select string.Format("{0}: {1}", length, word.ToUpper());
对于 let 关键字会有一个新的疑问:上述查询表达式可以同时使用 word
和 length
变量,那转为方法语法时,这两个变量以什么形式存在?
答案是:查询表达式会为这两个变量创建一个 匿名类型 实例。转化得到的代码如下:
words.Select(word => new { word, length = word.Length }).Where(tmp => tmp.length > 4).OrderBy(tmp => tmp.length).Select(tmp =>string.Format("{0}: {1}", tmp.length, tmp.word.ToUpper()));
上述 tmp
变量名是我们演示使用的,在语言规范中使用符号 * 表示。这个名称并不重要,在编写查询时它是不可见的,因此被称为隐形标识符。
3.7.3 选择使用哪种 LINQ 语法
采用哪种语法,应遵循可读性强、代码简洁、易于编写的原则。查询表达式要求:
查询表达式一般以
from
子句开始,最后以 select
或者 group
子句结束。
对于一些简单的筛选,使用查询表达式就显得笨拙:
from word in words
where word.Length > 4
select word
words.Where(word => word.Length > 4)
此外部分 LINQ 方法只有方法语法支持,如:
-
Select 支持两种 Func(L2S 和 EF 仅支持一种):
Func<TSource, TResult>
和 Func<TSource, TResult, int>
,其int
参数标注当前TSource
元素是第几个。例如: -
Where 支持两种 Func(L2S 和 EF 只支持第一种):
Func<TSource>
和 Func<TSource, int>
,其int
参数标注当前TSource
元素是第几个。例如:
当我们需要手动创建匿名类型,或查询较为复杂,查询语法来的更为简便。
建议开发者掌握两种方式,根据需要自行选择。
Tips
非查询标傲世的语法目前没有统一术语,常见的叫法有:
- 方法语法
- 点式语法
- 流式语法
- lambda 语法
- 等
本书统一采用方法语法代称。