[深入解析C#] 第3章:C# 3 LINQ 及相关特性
C# 3 LINQ及其相关特性
3.1 自动实现的属性
问题:C# 3之前,每个属性都需要手动实现,先定义一个字段,再定义属性,代码冗余
private string _name;
public string Name
{
get { return _name; }
set { _name = value; }
}
自动实现的属性(自动属性)就简化了这个环节
public string Name { get; set; }
但需注意字段仍然存在,只不过由编译器自动创建并赋予名称了
C# 6后,可以声明只包含getter的只读自动属性,省略private set的写法
public string Name { get; }
3.2 隐式类型
3.2.1 类型术语
有两种分类法:
- 弱类型和强类型,易产生歧义
- 静态类型和动态类型、显式类型和隐式类型,更具体
1. 静态类型和动态类型
绑定:决定某个表达式“具体含义”(调用哪个方法、访问哪个字段…)的过程
动态类型的语言把绝大部分绑定操作放到了执行期
C#中有些表达式在源码层面不具有类型信息,比如
null,但是编译器总是可以根据表达式所在上下文推断出其类型,然后根据该类型来检查表达式的使用是否正确。
总体上C#属于静态类型,但也有dynamic等动态类型的部分
2. 显式类型和隐式类型
C#总体属于显式类型,但也有隐式类型的身影,如泛型类型实参的类型推断,int和long的转换
3.2.2 隐式类型的局部变量
指var关键字声明的变量
var lang = "C#";
需要注意
var类型推断是编译时进行的,而非运行时
使用规则:
- 变量在声明时必须被初始化
- 用于初始化变量的表达式必须已经具备某个类型
// 没有提供初始值
var x;
x = 10;
var y = null; // 初始值没有类型
var的适用场景:
- 变量为匿名类型
- 变量类型名过长,且可根据初始化表达式轻松推断类型
- 变量的精确类型不重要,且初始化表达式可提供足够信息推断
3.2.3 隐式类型的数组
需求:创建一个数组,但是暂时不需要添加数据,所有元素保持默认值
int[] array = new int[10];
C# 3前的初始化方式
int[] array1 = { 1, 2, 3, 4, 5 };
int[] array2 = new int[] { 1, 2, 3, 4, 5 };
int[] array;
array = { 1, 2, 3, 4, 5 }; // 非法
array = new int[] { 1, 2, 3, 4, 5 }; // 合法
C# 3引入了另一种方式
array = new[] { 1, 2, 3, 4, 5 };
// 多维数组也适用
var array = new[,] { { 1, 2, 3 }, { 4, 5, 6 } };
类型推断的大致流程:
- 统计每一个元素的类型,将这些类型整合成一个类型候选集。
- 对于类型候选集中的每一个类型,检查是否所有元素都可以隐式地转换为该类型。剔除不满足该检查条件的类型,最终得到一个筛选过的类型集。
- 如果该类型集中只剩一个类型,则该类型就是推断出来的元素类型,编译器根据该类型来创建合适的数组。如果类型集中类型的数量为0或大于1,则编译时会报错。
3.3 对象和集合的初始化
此处联想到的:Unity关于使用for、foreach、LINQ的性能差异,通常for的性能最高,foreach的bug已经在2022等新版unity中修复,LINQ有插件替代方案…
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; }
}
创建和添加订单:
// 创建customer
var customer = new Customer();
customer.Name = "Jon";
customer.Address = "UK";
// 创建第一个OrderItem
var item1 = new OrderItem();
item1.ItemId = "A001";
item1.Quantity = 1;
// 创建第二个OrderItem
var item2 = new OrderItem();
item2.ItemId = "A002";
item2.Quantity = 2;
// 创建Order
var order = new Order();
order.OrderId = "O001";
order.Customer = customer;
order.Items.Add(item1);
order.Items.Add(item2);
除了使用构造器可以简化创建,还可以使用初始化器
// 创建Order
// 使用对象初始化器
var order = new Order
{
OrderId = "O001",
Customer = new Customer { Name = "Jon", Address = "UK" },
// 使用集合初始化器
Items =
{
new OrderItem { ItemId = "A001", Quantity = 1 },
new OrderItem { ItemId = "A002", Quantity = 2 }
}
};
可见使用初始化器后,代码的结构更清晰了
3.3.2 对象初始化器
🍀 对象初始化器常和属性搭配使用,这也是本章代码所采用的方式。字段没有访问器,但也可以采用类似于等价的方式:get访问器相当于字段的读操作,set访问器相当于字段的写操作。
对象初始化器只能用于构造器调用或者其他对象初始化器中
// 这两种写法是等价的
Order order = new Order() { OrderId = "xyz" };
Order order = new Order { OrderId = "xyz" };
⚠️ 只有在使用对象初始化器或者集合初始化器时,构造器的参数列表才可以省略
如果初始化值是另一个对象初始化器,则不会调用set访问器,而会调用get访问器,然后将嵌套对象初始化器得到的结果应用于由get访问器返回的属性。
HttpClient client = new HttpClient
{
// 为 DefaultRequestHeaders调用的属性 get 访问器
DefaultRequestHeaders =
{
// 为 From 调用的属性 set 访问器
From = "user@example.com",
// 为 Date 调用的属性 set 访问器
Date = DateTimeOffset.UtcNow
}
};
3.3.3 集合初始化器
var beatles = new List<string> { "John", "Paul", "Ringo", "George" };
编译器会将以上代码转换成一个构造器调用,其后紧跟一系列Add方法的调用
对于字典,写法如下
var releaseYears = new Dictionary<string, int>
{
{ "Please please me", 1963 },
{ "wowowo", 1966 }
};
添加元素的方法则是Add(key, value)
适用条件:
- 实现了
System.Collections.IEnumerable(编译器仅依赖这个来判断“可能是集合”) - 提供了适用的
Add方法(可以是实例方法,或 C# 6+ 的扩展方法)
总是用于创建新对象而非初始化已有集合变量
3.3.4 仅用单一表达式就能完成初始化的好处
好处:
- 与LINQ相关的其他特性都要求代码具备单一表达式的表达能力
- 有助于简化字段初始化器、方法实参甚至是条件表达式
?: - 支持匿名类型
3.4 匿名类型
3.4.1 基本语法和行为
var player = new
{
Name = "Rajesh",
Score = 3500
};
Console.WriteLine("Player name: {0}", player.Name);
Console.WriteLine("Player score: {0}", player.Score);
匿名类型的几个要素:
- 匿名对象创建表达式:匿名类型的语法类似于对象初始化器,但无须指定类型名称,只需要
new关键字、左大括号、属性以及右大括号 - 使用
var声明变量 - 以上代码依然属于静态类型的范畴

投射初始化器:从其他对象赋值属性/字段到新对象中的一种简化的创建表达式形式
例如对于之前的电商订单模型:
- Order——OrderId、Customer、Items
- Customer——Name、Address
- OrderItem——ItemId、Quantity
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 编译器生成类型
虽然源码中没有出现匿名类型的名称,但编译器需要为它生成一个类型
匿名类型的特点(采用微软C#编译器时)
- 它是一个类(保证)。
- 其基类是
object(保证)。 - 该类是密封的(不保证,虽然非密封的类并没有什么优势)。
- 属性是只读的(保证)。
- 构造器的参数名称与属性名称保持一致(不保证,有时对于反射有用)。
- 对于程序集是
internal的(不保证,在处理动态类型时会比较棘手)。 - 该类会覆盖
GetHashCode()和Equals()方法:两个匿名类型只有在所有属性都等价的情况下才等价。(可以正常处理null值。)只保证会覆盖这两个方法,但不保证散列值的计算方式。 - 覆盖并完善
ToString()方法,用于呈现各属性名称及其对应值。这一点不保证,但对于问题诊断来说作用重大。 - 该类型为泛型类,其类型形参会应用于每一个属性。具有相同属性名称但属性类型不同的匿名类型,会使用相同的泛型类型,但拥有不同的类型实参。这一点不保证,不同编译器的实现方式不同。
- 如果两个匿名对象创建表达式使用相同的属性名称,具有相同的属性类型以及属性顺序,并且在同一个程序集中,那么这两个对象的类型相同。
还有一点是使用匿名类型创建隐式类型数组
var players = new[]
{
new { Name = "Priti", Score = 6000 },
new { Name = "Chris", Score = 7000 }
}
请注意,两个匿名类型若要类型相同,那么两个匿名对象创建表达式中的属性名称、属性类型以及属性的顺序都必须完全一致。
var players = new[]
{
new { Name = "Priti", Score = 6000 },
new { Score = 7000, Name = "Chris" } // 错误:顺序不一致
new { Name = "Amanda", Score = 8000 },
}
3.4.3 匿名类型的局限性
局部化:指某个数据形态的适用范围限制在特定方法中。匿名类型实现数据的局部化是其优点也是缺点
匿名类型不提供任何数据封装(校验、行为…)
游戏开发中的使用场景:
| 何时使用 | 何时避开 |
|---|---|
| 方法内部的中间计算结果 | Unity Inspector / ScriptableObject 相关 |
| LINQ 数据投影和过滤 | 跨程序集传递或热更新边界 |
| 编辑器工具和数据验证脚本 | 高频执行的 Update 循环 |
| 表达式树中的动态查询构建 | 需要公开的 API 接口 |
| 不需要序列化的一次性数据 | 需要持久化存储的任何数据 |
3.5 lambda表达式
匿名函数:指代匿名方法和lambda表达式
3.5.1 lambda表达式语法简介
基本语法:
参数列表 => 主体
示例:
Action<string> action = (string message) =>
{
Console.WriteLine("In delegagte: {0}", message);
};
action("Messagge");
简化后:
Action<string> action =
message => Console.WriteLine("In delegagte: {0}", message);
action("Messagge");
3.5.2 捕获变量
变量捕获过程可理解为:匿名函数对代码上下文及变量的“引用”,而非创建时的“快照”“
在 lambda 表达式中,可以像使用普通方法那样任意使用变量。
这些变量可以是静态字段、实例字段(如果在实例方法中编写 lambda 表达式)this 变量、方法参数或者局部变量。这些都属于捕获变量的范畴,因为它们都定义在 lambda 表达式所在直接上下文之外。那些 lambda 表达式自带的参数或者定义在 lambda 表达式内部的局部变量,则不属于捕获变量。
class Program
{
static void Main()
{
// 其他代码
var demo = new CapturedVariablesDemo();
Action<string> action = demo.CreateAction("method argument");
action("lambda argument");
}
}
class CapturedVariablesDemo
{
private string instanceField = "实例字段";
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;
}
}
🍀 需要重点关注的是,这些 lambda 表达式捕获的是这些变量本身,而不是委托创建时这些变量的值副本
那么,在委托定义后 到 委托调用前,这段时期修改任何一个捕获变量,都会在输出结果中体现出来
编译器如何保证这些变量在委托调用时依然可用呢?
1. 通过生成类来实现捕获变量
- 如果没有捕获任何变量,那么编译器可以创建一个静态方法,不需要额外的上下文。
- 如果仅捕获了实例字段,那么编译器可以创建一个实例方法。在这种情况下,捕获1个实例字段和捕获100个没有什么差别,只需一个 this 便可都可以访问到。
- 如果有局部变量或参数被捕获,编译器会创建一个私有的嵌套类来保存上下文信息,然后在当前类中创建一个实例方法来容纳原 lambda 表达式的内容。原先包含 lambda 表达式的方法会被修改为使用嵌套类来访问捕获变量。
2. 局部变量的多次实例化
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();
}
这里是 5 个完全独立的 text 变量被分别捕获
message 0
message 1
message 2
message 3
message 4
编译器在每次初始化都创建一个不同的生成类型实例,CreateAction会转译为如下形式
private class LambdaContext
{
public string text;
public void Method()
{
Console.WriteLine(text);
}
}
static List<Action> CreateActions()
{
var actions = new List<Action>();
for (int i = 0; i < 5; i++)
{
var context = new LambdaContext(); // 为每次循环都创建一个新的上下文对象
context.text = string.Format("message {0}", i);
actions.Add(context.Method); // 使用上下文创建一个 action
}
return actions;
}
3. 多个作用域下的变量捕获
class Program
{
static void Main()
{
List<Action> actions = CreateCountingActions();
// 每个委托调用两次
actions[0]();
actions[0]();
actions[1]();
actions[1]();
}
static List<Action> CreateCountingActions()
{
List<Action> actions = new List<Action>();
int outerCounter = 0; // 两个委托捕获同一个变量
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;
}
}
Outer: 0, Inner: 0
Outer: 1, Inner: 1
Outer: 2, Inner: 0
Outer: 3, Inner: 1
这里outerCounter变量被两个委托共用,innerCounter为两个委托分别所有
每个委托都需要各自的上下文,各自的上下文又需要指向一个公共的上下文。编译器创建两个私有嵌套类来处理这种情况
class Program
{
static void Main()
{
}
/// <summary>
/// 外层作用域的上下文
/// </summary>
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;
}
}
🍀 如果在性能敏感的代码中使用 lambda 表达式,那么需要注意可能会因为变量捕获而创建过多对象,从而影响性能。
3.5.3 表达式树
表达式树是将代码按照数据来表示的一种形式
这里联想到了unity常用的持久化方案,如json、xml、SQLite等。xml用的比较少,json适合轻量和快速开发场景,sqlite适合数据庞大/查询复杂的场景。
以及动态的伤害计算公式如何实现?
委托的作用是提供可运行的代码,而表达式树的作用是提供可查看的代码,这有点类似于反射(reflection)机制。
using System.Linq.Expressions;
class Program
{
static void Main()
{
Expression<Func<int, int, int>> adder = (x, y) => x + y;
Console.WriteLine(adder);
}
}
这段代码表明:代码是可以进行执行期检查的。这就是表达式树的所有关键所在。
理解:
拆开来看,由Expression<TDelegate>和Func<int, int, int>组成,Func的返回值类型由最后一个参数指定
using System.Linq.Expressions;
class Program
{
static void Main()
{
ParameterExpression xParameter = Expression.Parameter(typeof(int), "x");
ParameterExpression yParameter = Expression.Parameter(typeof(int), "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);
}
}
1. 转换表达式树的局限性
只有拥有表达式主体的 lambda 表达式才能转换成表达式树
Expression<Func<int, int, int>> adder = (x, y) => { return x + y; };
lambda 表达式不能使用赋值运算符,也不能使用C#4的动态类型和C#5的异步。(虽然对象初始化器和集合初始化器也用到了=,但是在其上下文中并不算赋值运算符。)
2. 将表达式树编译成委托
using System.Linq.Expressions;
class Program
{
static void Main()
{
Expression<Func<int, int, int>> adder = (x, y) => x + y;
Func<int, int, int> executableAdder = adder.Compile(); // 表达式树编译成委托
Console.WriteLine(executableAdder(2, 3)); // 正常调用委托
}
}
3.6 扩展方法
假设有如下方法调用
ExampleClass.Method(x, y);
改成扩展方法后,就可以以实例方法的调用形式来调用
x.Method(y);
在游戏开发中,对于引擎类(大多为sealed/无法继承的密封类)可以无需修改源码拓展方法;另外对于一些频繁使用的数据操作,使用扩展方法可以更直观
3.6.1 声明扩展方法
必要:第一个参数关键字要有this;扩展方法必须声明在一个非嵌套、非泛型的静态类中;扩展方法所在类不能是泛型类,但扩展方法本身可以是泛型方法
namespace NodaTime.Extensions
{
public static class DateTimeOffsetExtensions
{
public static Instant ToInstant(this DateTimeOffset dateTimeOffset)
{
return Instant.FromDateTimeOffset(dateTimeOffset);
}
}
}
编译器干的事:给扩展方法和其所在类添加[Extension]特性
3.6.2 调用扩展方法
编译器查找扩展方法优先级顺序
- 存在同名的普通实例方法,优先选择该实例方法调用
- 查找扩展方法调用代码所在的命名空间以及所有
using指令指定的命名空间
例:
using NodaTime.Extension; // 引入 NodaTime.Extensions 命名空间
using System;
namespace CSharpInDepth.Chapter03
{
class ExtensionMethodInvocation
{
static void Main()
{
var currentInstant = DateTimeOffset.UtcNow.ToInstant(); // 调用扩展方法
Console.WriteLine(currentInstant);
}
}
}
编译器会从以下位置查找扩展方法:
CSharpInDepth.ChapterO3命名空间下的静态类;CSharpInDepth命名空间下的静态类;- 全局命名空间下的静态类;
using指令指定的命名空间下的静态类(例如using System这样的指向命名空间的命令);- (只在C#6中)
using static指定的静态类

3.6.3 扩展方法的链式调用
class Program
{
static void Main()
{
string[] words = { "keys", "coat", "laptop", "bottle" }; // 简单的数据源
// 过滤、排序、转换
IEnumerable<string> query = words
.Where(word => word.Length > 4)
.OrderBy(word => word)
.Select(word => word.ToUpper());
// 打印结果
foreach (string word in query)
{
Console.WriteLine(word);
}
}
}
由于LINQ中存在延迟和优化策略,我们很难知道具体何时会执行什么操作,但代码的阅读顺序和执行顺序是一致的。
class Program
{
static void Main()
{
string[] words = { "keys", "coat", "laptop", "bottle" };
IEnumerable<string> query =
Enumerable.Select(
Enumerable.OrderBy(
Enumerable.Where(words, word => word.Length > 4),
word => word.Length),
word => word.ToUpper());
}
}
3.7 查询表达式
对于上面使用链式调用的代码,使用查询表达式改写如下
IEnumerable<string> query = from word in words
where word.Length > 4
orderby word
select word.ToUpper();
可以感受到非常简洁,在查询表达式中,word在from子句中作为一个范围变量出现
3.7.1 从 C# 到 C# 的查询表达式转换
-
查询表达式 vs 之前特性(如lambda捕获变量)的转译差异
之前讲解捕获变量时,只是展示编译器生成的“等效”C#代码(描述结果,非实际生成)。
而查询表达式的语言规范直接将其定义为语法转译,转译发生在绑定或重载决议之前。
-
转译的具体形式
转译结果通常变成扩展方法调用,但语言规范不强制要求必须是扩展方法调用;也可以是实例方法调用,或调用
Select、Where等属性返回的委托。 -
API 要求
规范规定:使用查询表达式时,相关类型必须提供某些方法(如
Select、OrderBy、Where等),但不要求提供全部方法(例如可以没有join子句对应的支持)。
3.7.2 范围变量和隐性标识符
-
范围变量
查询表达式引入的特殊变量,充当查询语句中每个子句的输入。
- 例如
from word in words中的word就是范围变量。 - 还可以通过
let子句引入新的范围变量(如let length = word.Length)。
- 例如
from word in words
let length = word.length
where length > 4
...
-
多范围变量的表示(转译机制)
当存在多个范围变量(如
word和length)时,编译器会将原始序列转换为匿名类型的“键-值对”序列。- 示例转译:
words.Select(word => new { word, length = word.Length }) - 后续子句通过匿名类型实例(如
tmp)访问.word和.length。
- 示例转译:
-
隐形标识符
转译时生成的临时参数名称(如
tmp),语言规范中用*表示。- 该名称在编写查询时不可见,故称“隐形标识符”。
- 规范不规定参数的具体命名。
3.7.3 选择使用哪种LINQ语法
-
查询表达式的局限性
必须以
from开头,以select或group by结尾。对于简单过滤,写法显得笨拙。- 示例对比:
- 查询表达式:
from word in words where word.Length > 4 select word - 方法语法:
words.Where(word => word.Length > 4)
- 查询表达式:
- 两者编译结果相同,简单场景优先选择方法语法。
- 示例对比:
-
方法语法的优势(复杂查询时)
- LINQ 提供的很多方法没有对应的查询表达式语法(如带索引的
Select/Where重载)。 - 需要末尾调用
ToList()等方法时,方法语法直接追加调用,而查询表达式需要圆括号括起整个表达式。
- LINQ 提供的很多方法没有对应的查询表达式语法(如带索引的
-
查询表达式的优势
- 需要编译器处理隐形标识符(多范围变量)时,查询表达式更简洁。
- 手动构建匿名类型再拆解很烦琐,查询表达式自动完成。
-
建议
两种语法都要掌握。舍弃任何一种都可能丧失提升代码可读性的机会。
术语说明
- 非查询表达式的语法统称为方法语法(其他常见称呼:点式语法、流式语法、lambda语法等,无需刻意区分)。
3.8 终极形态:LINQ
Unity 有常用的ZLinq插件,相比linq来说无gc分配。使用上只需增加一个AsValueEnumerable方法,其他与传统LINQ相同
-
示例查询(Entity Framework)
var products = from product in dbContext.Products where product.StockCount > 0 orderby product.Price descending select new { product.Name, product.Price }; -
该查询用到的C# 3特性及作用
- 匿名类型(含投射初始化器)—— 只选择
Name和Price两个属性。 - var 声明 —— 因为匿名类型无法显式声明类型。
- 查询表达式 —— 复杂查询时能事半功倍(本例可改用方法语法,但查询表达式更清晰)。
- lambda 表达式 —— 作为查询表达式转译后的基础。
- 扩展方法 —— 使转译后的查询能通过
Queryable类实现(dbContext.Products实现IQueryable<T>)。 - 表达式树 —— 将查询逻辑以数据形式传给 LINQ 提供器,转换成 SQL 交给数据库执行。
- 匿名类型(含投射初始化器)—— 只选择
-
总结:各特性缺一不可
- 缺少任意一个特性,LINQ 的实用性都会大打折扣。
- 虽可用内存集合代替表达式树、专用类代替扩展方法、简单查询不用查询表达式,但这些特性组合在一起产生了全新的效果。

浙公网安备 33010602011771号