第3章 C#3:LINQ 及相关特性

第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 推断流程

编译器的推断流程大致如下:

  1. 统计每一个元素的 类型 ,将这些 类型 整合成一个类型候选集。
  2. 对于类型候选集中的每一个类型,检查是否所有元素都可以 隐式地转换为该类型 。剔除不满足该检查条件的类型,最终得到一个筛选过的类型集。
  3. 如果该类型集中 只剩一个类型 ,则该类型就是推断出来的元素类型,编译器根据该类型来创建合适的数组。如果类型集中类型的数量为 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()​ 调用,它的工作逻辑如下:

  1. 如果元素初始化器没有 大括号 ,将其作为单个元素传递给 Add()​ 方法。

  2. 如果元素初始化器带有 大括号 ,将大括号中的每个表达式当作一个参数,并执行 重载 决议:查找最合适的 Add()​ 方法。

    如果是泛型 Add()​ 方法,还需要执行类型推断

自定义集合类型想使用集合初始化器,需要满足以下两个条件:

  • 实现了 IEnumerable ​ 接口;
  • 具有 Add() ​ 方法。

C# 语言设计团队限制必须实现 IEnumerable ​ 接口,是为了区分集合类型和非集合类型,实际上 IEnumerable ​ 接口无需实现。为此,自定义集合类的 IEnumerable.GetEnumerator()​ 方法可以直接抛出 NotImplementedException​ 异常(仅推荐测试项目中如此使用,产品代码不建议)。

3.3.4 仅用单一表达式就能完成初始化的好处

  1. 适用于 LINQ

    用于 LINQ 的语句都要求具备的单一表达式的表达能力

  2. 可以简化字段初始化器、方法实参的传入、条件表达式的使用(三目运算符 ?:.​)、静态字段初始化器构建查找表

Eureka

我认为最重要的一点,是初始化时发生了异常,不会创造出一个残破的实例。见:3.1.4 对象初始化器

此处使用了临时变量,是为了确保在初始化过程中如果 抛出异常 ,不会得到一个 部分初始化的 对象。

3.4 匿名类型

3.4.1 基本语法和行为

我们以如下代码为例,讲解匿名类型的几个要素:

var player = new        // 
{                       // 创建一个匿名类型对象,
    Name = "Rajesh",    // 包含 Name 和 Source
    Score = 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);
};

接下来我们一步步进行简化:

  1. 简化 主体部分

    如果主体只包含一条 return 语句或一个 表达式 ,它就可以简化成只有这一条语句(不带大括号,被称为“具有表达式主体”):

    Action<string> action =
        (string message) => Console.WriteLine("In delegate: {0}", message);
    
  2. 简化 参数列表

    编译器(部分时候)可以根据 lambda 表达式转化后的类型推断参数类型:

    Action<string> action =
        (message) => Console.WriteLine("In delegate: {0}", message);
    
  3. 简化 圆括号

    如果 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 调用扩展方法

扩展方法可以像实例方法那样调用还有一个前提:编译器可以 查找到 这个扩展方法。方法的调用遵循如下优先级:

  1. 如果存在 同名普通实例 方法,优先调用 实例 方法;
  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 words
                            where word.Length > 4
                            orderby word
                            select 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 语法

本书统一采用方法语法代称。

posted @ 2025-03-29 09:21  hihaojie  阅读(21)  评论(0)    收藏  举报