Ryuu 的个人博客

一个计算机初学者

枚举类型 (enum type) 是指由一组固定的常量组成合法值的类型,例如一年中的季节、太阳系中的星星或者一副牌中的花色。[1]

在 Java 变成语言引入枚举类型之前,通常是用一组 int 常量 来表示枚举类型,其中每一个 int 表示枚举类型的一个成员:[2]

1
2
3
4
5
6
7
8
// The int enum pattern - severely deficient!
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_GRANNY_SMITH = 2;

public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 1;
public static final int ORANGE_BLOOD = 2;

这种方法称作 int 枚举模式 (int enum pattern),他存在许多不足。int 枚举模式无类型安全性,也无描述性可言。[3]

例入将 apple 传到需要 orange 的方法中,编译器也不会产生任何警告,还会用 == 操作符对 apple 与 orange 进行比较,甚至更糟:[4]

1
2
// Tasty citrus flavored applesauce!
int i = (APPLE_FUJI - ORANGE_TEMPLE) / APPLE_PIPPIN;

  1. Java Language Specification SE 21, §8.9 Enum Classes, defines an enum declaration as a restricted class that defines a small set of named class instances; Oracle Java SE 21 API, java.lang.Enum, documents it as the common base class for Java enumeration classes. ↩︎

  2. Joshua Bloch, Effective Java, 3rd Edition, Item 34, frames the recommendation to use enums instead of int constants. ↩︎

  3. Effective Java Item 34 identifies the int enum pattern and explains why it lacks the type-safety and expressiveness supplied by Java enum types. ↩︎

  4. The apple/orange example follows Effective Java Item 34: because both constants are merely int values, the Java type system cannot distinguish the two domains at compile time. ↩︎

简述

单例 (Singleton) 模式提供一个可以全局访问的实例,并保证该类仅有一个实例。[1]

设计模式类型:创建型[2]

实现

1.懒汉 多线程不安全

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Singleton {
private static Singleton instance;

private Singleton() {
}

public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

2.懒汉 多线程安全

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Singleton {
private static Singleton instance;

private Singleton() {
}

public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

3.双检锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {
private volatile static Singleton singleton;

private Singleton() {
}

public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}

4.饿汉

1
2
3
4
5
6
7
8
9
10
public class Singleton {
private static final Singleton instance = new Singleton();

private Singleton() {
}

public static Singleton getInstance() {
return instance;
}
}

5.静态内部类

1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton {
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}

private Singleton() {
}

public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}

6.枚举

1
2
3
public enum Singleton {
INSTANCE;
}

选择

不建议使用 1 和 2[3]

不考虑继承问题使用 6 枚举[4]

单例是派生类,确定会使用,建议使用 4 饿汉[5]

单例是派生类,且不确定单例是否会使用,考虑 3 双检锁 或 5 静态内部类[6]


  1. GoF Design Patterns 把 Singleton 的意图概括为保证一个类只有一个实例,并提供访问该实例的全局访问点;《Effective Java》第 3 条也把 Java Singleton 放在“实例控制”的语境下讨论:https://www.informit.com/store/design-patterns-elements-of-reusable-object-oriented-9780201633610https://www.informit.com/store/effective-java-9780134686042↩︎

  2. GoF Design Patterns 将 Singleton 归入 Creational Patterns,因为它关注对象创建与实例控制,而不是对象结构或对象间行为协作:https://www.informit.com/store/design-patterns-elements-of-reusable-object-oriented-9780201633610↩︎

  3. 未同步的懒汉式在多线程下可能让多个线程同时观察到空引用并各自创建实例;synchronized getInstance() 能串行化创建过程,但每次访问都进入同步路径。这里的“不建议”主要对应并发安全和热路径同步成本两个边界,依据 Java 同步语义、JLS 内存模型和 Singleton 创建路径推导:https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html#jls-17.4↩︎

  4. 《Effective Java》第 3 条推荐单元素枚举作为 Java Singleton 的简洁写法;JLS 规定 enum 类实例由枚举常量定义,反射 API 也禁止通过 Constructor.newInstance 创建 enum 实例,序列化规范对 enum 常量有专门规则:https://www.informit.com/store/effective-java-9780134686042https://docs.oracle.com/javase/specs/jls/se21/html/jls-8.html#jls-8.9https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/reflect/Constructor.html#newInstance(java.lang.Object...)https://docs.oracle.com/en/java/javase/21/docs/specs/serialization/serial-arch.html#serialization-of-enum-constants↩︎

  5. 饿汉式依赖类初始化期间创建 static final 实例;JLS 的类初始化规则和 static 字段规则说明静态字段属于类变量,并在类初始化过程中完成初始化,因此它适合“确定会使用”的简单单例,但也会把创建时机绑定到类初始化:https://docs.oracle.com/javase/specs/jls/se21/html/jls-12.html#jls-12.4https://docs.oracle.com/javase/specs/jls/se21/html/jls-8.html#jls-8.3.1.1↩︎

  6. 双重检查锁在 Java 中必须配合 volatile 字段,依赖可见性和 happens-before 语义避免发布不完整对象;静态内部类 holder 则利用类初始化的延迟触发,首次主动访问 holder 的静态字段时才创建实例。需要普通类形式的延迟单例时,静态 holder 通常比双重检查锁更少手写并发协议:https://docs.oracle.com/javase/specs/jls/se21/html/jls-8.html#jls-8.3.1.4https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html#jls-17.4https://docs.oracle.com/javase/specs/jls/se21/html/jls-12.html#jls-12.4↩︎

委托是类

委托是类,C# 提供了 delegate 关键字,使得用户能简单的声明委托。编译器及 CLR 做了大量的工作来隐藏委托的复杂性。[1][1]

1
internal delegate void FeedBack(int value);

编译器为如上的委托声明定义一个完整的类:

1
2
3
4
5
6
7
8
9
10
11
12
internal class FeedBack : System.MulticastDelegate
{
// 构造器(Constructor)
public FeedBack(object @object, IntPtr method);

// 委托调用 [2]
public virtual void Invoke(int value);

// 委托异步调用
public virtual IAsyncResult BeginInvoke(int value, AsyncCallback asyncCallback, object @object);
public virtual void EndInvoke(IAsyncResult result);
}

可使用 ILDasm.exe 打开生成的程序集,查看这个自动生成的类。

编译器定义了 FeedBack 类,其派生自 FCL (Framework Class Library) 中的 System.MulticastDelegate 类 (所有的委托都派生自 MulticastDelegate,MulticastDelegate 派生自 Delegate)。[1:1]

MulticastDelegate

所有的委托都派生自 MulticastDelegate,所以它们继承了 MulticastDelegate 的字段、属性与方法。在这些成员中,有三个非公共字段是最重要的:[2]

字段 类型 说明
_target (Delegate 类的字段) System.Object 若委托对象包装静态方法时,此字段为 null
若委托对象包装实例方法时,此字段引用回调方法需要操作的对象 (实例方法所在的对象)
_methodPtr (Delegate 类的字段) System.IntPtr 根据平台而定的整数类型 (所以上文 ILDasm 中的显示是 native int),CLR 使用它标记需要回调的方法
_invocationList System.Object 此字段通常为 null。构造委托链时,引用一个委托数组

委托的构造器有两个参数,一个是对象引用 (System.Object),另一个则是根据平台而定的整型 (System.IntPtr)。C# 编译器构造委托时,会分析源码以确定引用的对象及方法。对象引用被传给构造器的 object 参数,标识方法的特殊 IntPtr 值 (从 MethodDef 或 MemberRef 元数据 token 获得) 被传给构造器的 method 参数。对于静态方法,为 object 参数传递 null。构造器将这两个实参分别保存于 _target 及 _methodPtr。此外,构造器将 _invocationList 设为 null。

委托链/多播

委托链是委托对象的集合

合并

调用 Delegate.Combine(Delegate a, Delegate b) 方法对两个委托进行合并。[3]

1
fbChain = (Feedback)Delegate.Combine(fbChain, fb1);

移除

调用 Delegate.Remove(Delegate source, Delegate value) 方法对两个委托进行合并。[4]

1
fbChain = (Feedback)Delegate.Remove(fbChain, fb1);

原理

详情请参阅 Reference Source

Combine

  • 若一个委托为 null

    返回非 null 委托

  • 若两个委托为 null

    返回 Combine 的第二个参数 (当然返回的也是 null)

  • 若都不为 null

    进行委托合并

    1. 判断两委托类型是否相同,不同则抛出 ArgumentException
    2. 合并两个委托对象中的委托 (包括委托链中的委托) (Object[] : resultList) (使用 for 遍历实现)
    3. 统计两个委托对象中的委托数 (int: invocationCount) (委托链不为空则统计委托链中的委托)
    4. 根据 resultList 及 invocationCount 构建新的委托对象并返回

Remove

注意,若 Remove 的目标为委托链,则该委托链需为当前操作委托对象委托链的连续子列表。[3][4:1]

  • 若指定需要去除的委托为空,直接返回当前委托
  • 若指定需要去除的委托不为空,在当前委托及其委托链中寻找目标委托或目标委托链,剔除并返回
    • 结果是委托链则构建新委托并返回
    • 结果是单一委托则直接返回该委托

不要定义过多的委托

Microsoft 在刚开始开发 .NET Framework 的时候引入了委托的概念。开发人员在 FCL 中添加类时,凡是有回调方法的地方都定义了新的委托类型。随时间的推移,他们定义的委托越来越多。仅在 MSCorLib.dll 中,就有接近 50 个委托类型,例如:

1
2
3
4
5
6
7
public delegate void TryCode(Object userData);
public delegate void WaitCallback(Object state);
public delegate void TimerCallback(Object state);
public delegate void ContextCallback(Object state);
public delegate void SendOrPostCallback(Object state);
public delegate void ParameterizedThreadStart(Object obj);
...

以上的示例委托,实际上都是一样的:这些委托引用的方法都是获取一个 Object 返回 void。没有必要定义这么多委托,定义一个就够了。

现在的 .NET Framework 支持泛型 (C# 2.0 版本引入),只需要几个泛型委托,就能表示多达16个参数的方法:[5]

  • 从无参,到至多16个参数,返回值为 void 的 Action 委托:

    1
    2
    3
    4
    5
    public delegate void Action(); // (这个不是泛型委托)
    public delegate void Action<in T>(T obj);
    public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2);
    ...
    public delegate void Action<in T1, ... , in T16>(T1 arg1, ... , T16 arg16);
  • 从无参,到至多16个参数,返回值为 TResult 的 Func 委托:

    1
    2
    3
    4
    public delegate TResult Func<out TResult>();
    public delegate TResult Func<in T, out TResult>(T arg);
    ...
    public delegate TResult Func<in T1, ... , in T16, out TResult>(T1 arg1, ... , T16 arg16);

建议尽量使用以上的委托类型,而不是定义更多的委托类型。这样能减少系统中的类型数量,简化代码。

但若需使用 ref 或 out 关键字以传递引用的方式传递参数,可能不得自定义委托:[5:1]

1
delegate void Foo(ref int bar);

event 关键字

event 关键字用于在发布类 (publisher class) 中声明事件。这是一种特殊的多播委托,仅能从声明事件的类或结构(发布类)中对其进行调用,否则产生编译器:event 的委托仅能作为 += 或 -= 的左值 (除非在其声明的类或结构中)。 如果其他类或结构订阅该事件,则在发布类引发该事件时,将调用其事件处理程序方法。 有关详细信息和代码示例,请参阅事件委托[6]

1
public event Action action;

EventHandler

EventHandler 委托是一个预定义的委托,专门表示不生成数据的事件的事件处理程序方法。 [7]

1
public delegate void EventHandler(object? sender, EventArgs e);

如果事件生成数据,则必须使用泛型 EventHandler 委托类。[8]

1
public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);

委托的简化语法

Combine 与 Remove 的简化

C# 为委托重载 += 调用 Combine ,重载 -= 调用 Remove ,简化了委托链的构造。[4][9]

1
2
3
4
action1 = (Action) Delegate.Combine(action1, action2);
action1 += action2;
Delegate.Remove(action1, action2);
action1 -= action2;

不需要显式构造委托对象

仅仅是为了指定委托地址就构建一个对象显得有些奇怪,实际上构建委托对象是 CLR 的要求,该对象是包装器,可保证被包装的方法只能以类型安全的方式调用。C# 简化了委托的构建过程,不需要用户显示的使用 new 关键字进行委托的构造。

  • 未显式构造委托对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public static void Main(string[] args)
    {
    Action action = PrintAction;
    action.Invoke();
    }

    private static void PrintAction()
    {
    Console.WriteLine("Action");
    }
  • 显式构造委托对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // DON'T DO THIS
    public static void Main(string[] args)
    {
    Action action = new Action(PrintAction);
    action.Invoke();
    }

    private static void PrintAction()
    {
    Console.WriteLine("Action");
    }

对比显式构造与非显式构造的 IL code,他们都会构造一个 Action 委托实例。

不需要定义回调方法 (使用 lamdba 表达式)

不需要因构造委托而定义一个方法:

1
2
3
4
5
6
7
8
9
10
11
public static void Main(string[] args)
{
Action action = PrintAction;
action.Invoke();
}

// NOT NEED
private static void PrintAction()
{
Console.WriteLine("Action");
}

可以使用 lamdba 表达式简化回调:[5][10]

1
2
3
4
5
public static void Main(string[] args)
{
Action action = () => Console.WriteLine("Action");
action.Invoke();
}

局部变量不需要手动包装到类中即可传递给回调方法

有时可能希望回调代码引用类中定义的其他成员或方法中的局部参数:

1
2
3
4
5
6
7
8
9
10
11
internal class Program
{
private static int bar = 21;

public static void Main(string[] args)
{
int foo = 21;
Action action = () => Console.WriteLine(foo + bar); // Closure allocation: 'foo' variable
action.Invoke();
}
}

实际上 lamdba 表达式主体的代码在一个单独的方法中 (CLR 的要求)。C# 通过自动辅助类实现闭包 (closure) [6][11]。在辅助类中,为需要传递给回调的每个值都定义一个字段。将回调方法定义为其实例方法。

构建回调方法实际上也构造了辅助类实例,使用方法中的局部变量的值初始化该实例中的字段,最后构造委托对象并绑定到该辅助对象的实例方法。

委托与反射

开发者可以在不知道回调方法的原型时使用回调。使用 MethodInfo.CreateDelegate,可在编译期不知道委托的所有必要信息的情况下创建委托:[12]

1
2
3
4
// 构造包含静态方法的委托
public virtual Delegate CreateDelegate (Type delegateType);
// 构造包含实例方法的委托 (target 引用 this 实参)
public virtual Delegate CreateDelegate (Type delegateType, object? target);

创建完成后可用 Delegate.DynamicInvoke(Object[]) 调用它们:

1
2
// 调用委托并传递参数
public object? DynamicInvoke (params object?[]? args);

使用反射 API 获取引用了回调方法的 MethodInfo 对象,调用 CreateDelegate 构造委托 (如果是实例方法则需要传递 target 参数,指定其 this 参数)。

使用 DynamicInvoke 方法对委托对象的回调方法进行调用。DynamicInvoke 可传递一组参数,其在内部保证传递的参数与回调方法期望的参数兼容,兼容则调用回调方法,否则抛出 ArgumentException。若参数数不匹配,则抛出 TargetParameterCountException[13]

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
internal class Foo
{
public static void StaticMethod() => Console.WriteLine(42);

public void NonStaticMethod() => Console.WriteLine(42);

public static void MethodWithPara(int num) => Console.WriteLine(num);

}

internal class Program
{
public static void Main(string[] args)
{
Foo delegateReflectionTest = new Foo();
MethodInfo nonStaticMethodInfo = delegateReflectionTest.GetType().GetMethod("NonStaticMethod");
Delegate delegate1 = nonStaticMethodInfo?.CreateDelegate(typeof(Action), delegateReflectionTest);
delegate1?.DynamicInvoke();
MethodInfo staticMethodInfo = delegateReflectionTest.GetType().GetMethod("StaticMethod");
Delegate delegate2 = staticMethodInfo?.CreateDelegate(typeof(Action));
delegate2?.DynamicInvoke();
MethodInfo methodInfoWithPara = delegateReflectionTest.GetType().GetMethod("MethodWithPara");
Delegate delegate3 = methodInfoWithPara?.CreateDelegate(typeof(Action<int>));
delegate3?.DynamicInvoke(42);
}
}
}

参阅

Ildasm.exe(IL 反汇编程序)

一般,该工具位于 NETFX 4.7.2 Tools 中

C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.7.2 Tools\x64\ildasm.exe

如何合并委托(多播委托)- C# 编程指南 | Microsoft Docs

- - 和 -= 运算符 - C# 参考 | Microsoft Docs

+ 和 += 运算符 - C# 参考 | Microsoft Docs

注释

[1] 因此,可以定义类的地方,就可以定义委托。

[2] 这里把 Invoke 翻译为调用。但是要清楚 Invoke 和 Call 的区别,执行委托方法不是直接执行目标方法,而是从委托处援引 (Invoke) 目标方法执行。

[3] 实现细节请参阅 Reference Source Multicastdelegate ,算法为移除目标数组中的连续子序列

- - 和 -= 运算符 - C# 参考 | Microsoft Docs (委托删除) 中的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Action a = () => Console.Write("a");
Action b = () => Console.Write("b");

var abbaab = a + b + b + a + a + b;
var aba = a + b + a;

var first = abbaab - aba;
first(); // output: abbaab
Console.WriteLine();
Console.WriteLine(object.ReferenceEquals(abbaab, first)); // output: True

Action a2 = () => Console.Write("a");
var changed = aba - a;
changed(); // output: ab
Console.WriteLine();
var unchanged = aba - a2;
unchanged(); // output: aba
Console.WriteLine();
Console.WriteLine(object.ReferenceEquals(aba, unchanged)); // output: True

[4] 可通过查看 IL code 验证这点:

[5] 请参阅 => 运算符 - C# 参考 | Microsoft Docs (表达式主体定义)

[6] 捕获上下文的外部变量以在回调方法中使用。闭包有对外部变量的引用,所以可能导致外部变量所在的对象声明周期延长。


  1. Microsoft Learn, C# language specification - Delegates。用于支撑委托声明、委托类型派生关系、Invoke 调用语义以及委托实例封装调用列表等语言层事实。 ↩︎ ↩︎

  2. Jeffrey Richter, CLR via C#, Fourth Edition。用于支撑从 CLR 视角理解委托对象、目标方法、调用列表和运行时包装;具体内部字段仍应视为实现细节。 ↩︎

  3. Microsoft Learn, Delegate.Combine Method。用于支撑 Delegate.Combine 合并 invocation list、返回新委托以及类型不匹配异常的公开 API 语义。 ↩︎

  4. Microsoft Learn, Delegate.Remove Method。用于支撑 Delegate.Remove 从 invocation list 中移除最后一个匹配委托或匹配委托链的公开 API 语义。 ↩︎ ↩︎

  5. Microsoft Learn, Action DelegateFunc Delegate。用于支撑 .NET 提供通用 Action / Func 委托族,以及这些通用委托不覆盖 ref / out 签名的边界。 ↩︎ ↩︎

  6. Microsoft Learn, Events - C# Programming Guide。用于支撑事件的发布者/订阅者模型、事件触发限制和事件处理程序调用语义。 ↩︎

  7. Microsoft Learn, EventHandler Delegate。用于支撑无事件数据时 EventHandler 的预定义事件处理器签名。 ↩︎

  8. Microsoft Learn, EventHandler Delegate。用于支撑携带事件数据时使用泛型 EventHandler<TEventArgs> 的约定。 ↩︎

  9. Microsoft Learn, Addition operators - delegate combinationSubtraction operators - delegate removal。用于支撑 + / += 组合委托和 - / -= 移除委托的语言运算符语义。 ↩︎

  10. Microsoft Learn, Lambda expressions and anonymous functions。用于支撑 lambda 表达式可转换为委托并简化回调写法。 ↩︎

  11. Microsoft Learn, Lambda expressions - capture of outer variables。用于支撑 lambda 捕获外部变量会延长变量生命周期的说法。 ↩︎

  12. Microsoft Learn, MethodInfo.CreateDelegate Method。用于支撑从反射获得的方法创建指定委托类型,并在实例方法场景绑定目标对象的公开 API 语义。 ↩︎

  13. Microsoft Learn, Delegate.DynamicInvoke Method。用于支撑 DynamicInvoke 的后期绑定调用、参数不匹配和目标方法异常包装等公开 API 行为。 ↩︎

可能有人认为相比于 ForTest1,ForTest2 存储了数组的 Length,少了对于数组属性的频繁调用,会有更好的性能表现。[1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System;

namespace JITPropertyAccessInFor
{
internal static class Program
{
public static void Main(string[] args)
{
}

private static void Test1()
{
var a = new int[5];
}

private static void ForTest1()
{
var a = new int[5];
for (var i = 0; i < a.Length; i++)
{
Console.WriteLine(a[i]);
}
}

private static void ForTest2()
{
var a = new int[5];
int len = a.Length;
for (var i = 0; i < len; i++)
{
Console.WriteLine(a[i]);
}
}
}
}

以下 是 上段代码编译出的 IL code:(以下所述栈均为操作数栈 (Operand stack))[2]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
.method private hidebysig static void  ForTest1() cil managed
{
// Code size 30 (0x1e)
.maxstack 2 // 栈最大深度
.locals init ([0] int32[] a, // 变量声明 (局部变量表)
[1] int32 i)
IL_0000: ldc.i4.5 // int32 5 入栈 (声明的数组大小)
IL_0001: newarr [mscorlib]System.Int32 // 创建0基一维数组的对象引用入栈
IL_0006: stloc.0 // 出栈 置于局部变量表0位置 (初始化数组完毕)
IL_0007: ldc.i4.0 // int32 0 入栈 (i = 0)
IL_0008: stloc.1 // 出栈 置于局部变量表1位置 (i = 0)
IL_0009: br.s IL_0017 // 无条件地将控制转移到目标指令(短格式)(至 for 中判断开始位置)
IL_000b: ldloc.0 // 局部变量表0位置变量入栈 (数组元素入栈)
IL_000c: ldloc.1 // 局部变量表1位置变量入栈 (i 入栈)
IL_000d: ldelem.i4 // 按指令指定类型(i4),将指定数组索引中的元素入栈
IL_000e: call void [mscorlib]System.Console::WriteLine(int32) // 调用由传递的方法说明符指示的方法 (打印a[i])
IL_0013: ldloc.1 // 局部变量表1位置变量入计算栈 (i 入栈) (i++ 开始)
IL_0014: ldc.i4.1 // int32 1 入栈
IL_0015: add // 出栈两次,出栈值相加,结果入栈
IL_0016: stloc.1 // 出栈 置于局部变量表1位置 (i++ 结束)
IL_0017: ldloc.1 // 局部变量表1位置变量入栈 (i 入栈) (for 中判断开始位置)
IL_0018: ldloc.0 // 局部变量表0位置变量入栈 (a 入栈,准备获取数组长)
IL_0019: ldlen // 将0基一维数组的元素数目推送到计算栈上。(数组长入栈)
IL_001a: conv.i4 // 将栈顶元素转换为 int32 类型
IL_001b: blt.s IL_000b // 判断计算栈顶两值大小(计算栈出栈两次,后出栈的是第一个值)。若第一个值小于第二个值,将控制转移到目标指令 (短格式)。
IL_001d: ret // 从当前方法返回
} // end of method Program::ForTest1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
.method private hidebysig static void  ForTest2() cil managed
{
// Code size 32 (0x20)
.maxstack 2 // 栈最大深度
.locals init ([0] int32[] a, // 变量声明 (局部变量表)
[1] int32 len,
[2] int32 i)
IL_0000: ldc.i4.5 // int32 5 入栈 (声明的数组大小)
IL_0001: newarr [mscorlib]System.Int32 // 创建0基一维数组的对象引用入栈
IL_0006: stloc.0 // 出栈 置于局部变量表0位置 (初始化数组完毕)
IL_0007: ldloc.0 // 局部变量表1位置变量入栈 (a 入栈,准备获取数组长)
IL_0008: ldlen // 将0基一维数组的元素数目推送到计算栈上。(数组长入栈)
IL_0009: conv.i4 // 将栈顶元素转换为 int32 类型
IL_000a: stloc.1 // 出栈 置于局部变量表1位置 (len = a.Length)
IL_000b: ldc.i4.0 // int32 0 入栈 (声明的数组大小)
IL_000c: stloc.2 // 出栈 置于局部变量表2位置 (i = 0)
IL_000d: br.s IL_001b // 无条件地将控制转移到目标指令(短格式)(至 for 中判断开始位置)
IL_000f: ldloc.0 // 局部变量表0位置变量入栈 (a 入栈)
IL_0010: ldloc.2 // 局部变量表2位置变量入栈 (i 入栈)
IL_0011: ldelem.i4 // 按指令指定类型(i4),将指定数组索引中的元素入栈
IL_0012: call void [mscorlib]System.Console::WriteLine(int32) // 调用由传递的方法说明符指示的方法 (打印a[i])
IL_0017: ldloc.2 // 局部变量表2位置变量入栈 (i 入栈) (i++ 开始)
IL_0018: ldc.i4.1 // int32 1 入栈
IL_0019: add // 出栈两次,出栈值相加,结果入栈
IL_001a: stloc.2 // 出栈 置于局部变量表2位置 (i++ 结束)
IL_001b: ldloc.2 // 局部变量表2位置变量入栈 (i 入栈) (for 中判断开始位置)
IL_001c: ldloc.1 // 局部变量表1位置变量入栈 (len 入栈)
IL_001d: blt.s IL_000f // 判断计算栈顶两值大小(计算栈出栈两次,后出栈的是第一个值)。若第一个值小于第二个值,将控制转移到目标指令 (短格式)。
IL_001f: ret // 从当前方法返回
} // end of method Program::ForTest2

对比上述的 IL code,确实临时存储数组长,能够少在 for 的比较进行中少进行一定的操作,无需将数组从局部变量表(Local Variable Table)入操作数栈 (Operand stack),并执行 ldlen 获取数组长。[3] 但要注意, JIT 编译器知道 Length 是 Array 类的属性,生成的代码中只会调用该属性一次,结果会存储到临时变量中,此后的检查中调用的都是此临时变量。[4]不需要自己用局部变量做缓存,这样既没有性能提升,还可能造成可读性下降[5]

参阅

CLR via C# (第四版) 16.7 数组的内部工作原理

注释

Ildasm.exe(IL 反汇编程序)

一般,该工具位于 NETFX 4.7.2 Tools 中

C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.7.2 Tools\x64\ildasm.exe


  1. Microsoft Learn, C# arraysArray.Length Property 支撑数组实例长度是数组自身的稳定事实;因此这里的性能直觉应落在 JIT 是否识别数组循环形状,而不是把 Length 当普通可变属性。 ↩︎

  2. ECMA-335 Common Language Infrastructure 定义 CIL 指令与运行时模型;Microsoft Learn Ildasm.exe 支撑用 ILDASM 查看程序集 IL。IL 能说明编译器输出,但不能直接等同最终机器码性能。 ↩︎

  3. ECMA-335 定义 ldlen 为读取数组长度的 CIL 指令;这支撑文中从 IL 层面对 ForTest1ForTest2 的比较。 ↩︎

  4. Jeffrey Richter, CLR via C#, Fourth Edition 用数组内部工作原理解释数组长度和运行时模型;Microsoft .NET 5 performance post 也明确讨论 for (int i = 0; i < arr.Length; i++) 这类循环中 JIT 可证明索引不会越界并消除 bounds check。 ↩︎

  5. Microsoft .NET 5.NET 8.NET 9.NET 10 performance posts 对 bounds check elimination、range analysis、PGO 和 loop 优化的持续讨论说明:这类微优化依赖目标运行时、Release 构建、JIT 机器码和测量数据。普通数组循环默认保持 i < array.Length 的清晰形状;只有上界昂贵、有副作用、需要快照语义或有测量证据时,才应手动缓存。 ↩︎

文章名称不需要后缀名[1]

1
{% post_link 文章名称 %}

1
{% post_link Hello-World %}

文章链接配置位于 _config.yml 中 permalink[2]

1
2
3
4
5
6
7
8
9
# URL
## If your site is put in a subdirectory, set url as 'http://yoursite.com/child' and root as '/child/'
url: http://yoursite.com
root: /
permalink: :title/ # :year/:month/:day/:title/
permalink_defaults:
pretty_urls:
trailing_index: true # Set to false to remove trailing 'index.html' from permalinks
trailing_html: true # Set to false to remove trailing '.html' from permalinks

  1. Hexo 官方 tag plugins 文档说明,post_link 用于插入文章链接,语法按文章文件名定位目标文章;同一文档还说明 post_link / post_path 会忽略 permalink、folder、language 和 date,因此这里的“文章名称”应理解为文章文件名,不需要写 .md 后缀:https://hexo.io/docs/tag-plugins↩︎

  2. Hexo 官方 permalinks 文档说明,permalink 用于配置文章最终 URL 结构;configuration 文档也把 urlrootpermalinkpermalink_defaults 列为站点配置字段。因此文章内链应表达文章身份,最终 URL 形态交给 _config.yml 中的 permalink / url / root 配置生成:https://hexo.io/docs/permalinkshttps://hexo.io/docs/configuration↩︎

详情

Unity 对象的空检测

UnityEngine.Object 有其自定义的空检测方法[1]

因此 UnityEngine.Object 有两种空检测:

  1. 检测 Unity 原生对象是否被销毁 (使用 UnityEngine.Object 自定义空检测)
  2. 检测 Unity 对象是否初始化与正确引用 (使用 object.ReferenceEquals(monoBehaviour, null))[2]

Unity 对象的生与死

原生对象与包装对象:

Unity 是基于 C/C++ 的引擎,GameObject 的所有实际信息 (name、component list、HideFlags 等等) 都存储在 C++ 对象中。此类对象被称为**“原生对象” (native object)**。

C# GameObject 所有的仅是指向原生对象的指针 (pointer)。此类对象被称为**“包装对象” (wrapper object)**。

C# 与 C++ 有不同的内存管理方式,这意味着包装对象与其包裹的原生对象有着不同的生命周期[3]

当原生对象已被销毁,包装对象依然存在时,将包装对象其与 null 比较,UnityEngine 的 == 运算符严格执行 Unity object 底层的生命周期检查,返回 “true”[4]

Real null 与 Fake null:

在 Editor only 时,MonoBehaviour 不是 “real null” 而是 “fake null”。[1][5]

Unity 在 fake null object 中存储信息。当执行其方法 (method),或访问其属性 (property) 时,可提供更多的上下文信息:

在 fake null object 中存储信息,Unity 能够在检视窗口 (Inspector) 中高亮该 GameObject,并给出更多指引。如:“looks like you are accessing a non initialized field in this MonoBehaviour over here, use the inspector to make the field point to something” (看来您试图访问此 MonoBehaviour 的未实例化字段,请在检视窗口使其指向实例)。

若不在 fake null object 中存储信息,只能得到 NullReferenceException 与堆栈跟踪。并不知道具体是哪个带有 MonoBehaviour 的 GameObject 有字段为 null。

UnityEngine 的 == 运算符能够检测是否存在 fake null object[6]

Unity 相关代码

反编译获得,不是源码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// UnityEngine.Object
public static bool operator ==(Object x, Object y) => Object.CompareBaseObjects(x, y);

public static bool operator !=(Object x, Object y) => !Object.CompareBaseObjects(x, y);

public static implicit operator bool(Object exists) => !Object.CompareBaseObjects(exists, (Object) null);

public override bool Equals(object other)
{
Object rhs = other as Object;
return (!(rhs == (Object) null) || other == null || other is Object) && Object.CompareBaseObjects(this, rhs);
}

private static bool CompareBaseObjects(Object lhs, Object rhs)
{
bool flag1 = (object) lhs == null;
bool flag2 = (object) rhs == null;
if (flag2 & flag1)
return true;
if (flag2)
return !Object.IsNativeObjectAlive(lhs);
return flag1 ? !Object.IsNativeObjectAlive(rhs) : lhs.m_InstanceID == rhs.m_InstanceID;
}

private static bool IsNativeObjectAlive(Object o)
{
if (o.GetCachedPtr() != IntPtr.Zero)
return true;
return !(o is MonoBehaviour) && !(o is ScriptableObject) && Object.DoesObjectWithInstanceIDExist(o.GetInstanceID());
}

/// <summary>
/// <para>Returns the instance id of the object.</para>
/// </summary>
[SecuritySafeCritical]
public int GetInstanceID()
{
this.EnsureRunningOnMainThread();
return this.m_InstanceID;
}

private void EnsureRunningOnMainThread()
{
if (!Object.CurrentThreadIsMainThread())
throw new InvalidOperationException("EnsureRunningOnMainThread can only be called from the main thread");
}

private IntPtr GetCachedPtr() => this.m_CachedPtr;

[NativeMethod(IsFreeFunction = true, IsThreadSafe = true, Name = "UnityEngineObjectBindings::DoesObjectWithInstanceIDExist")]
[MethodImpl(MethodImplOptions.InternalCall)]
internal static extern bool DoesObjectWithInstanceIDExist(int instanceID);

[NativeMethod(IsFreeFunction = true, IsThreadSafe = true, Name = "CurrentThreadIsMainThread")]
[MethodImpl(MethodImplOptions.InternalCall)]
private static extern bool CurrentThreadIsMainThread();

如上所示,Unity 实现了自己的空判断,并将其应用于重载的 != 运算符、== 运算符、隐式 bool 转换运算符及重写的 System.Object 的 Equals(object obj) 中。[7]

其中涉及许多的逻辑,如确保方法调用于主线程,指定实例 id 的 UnityEngine.Object 是否存在,缓存的指针是否为 IntPtr.Zero,比较的两 UnityEngine.Object 的 实例 id 是否相同。及其他的外部方法调用。因此,相比于 object.ReferenceEquals() 的调用会被编译器优化为简单的空检查,Unity的自定义比较需要执行许多逻辑,速度较慢[8]

编写规范

上文提到了 Unity 对象的两种 null 检测,编写代码时,一定要明确表意,确定为其中的一种[9]

特别的,C# 的空合并运算符与空条件运算符将会绕过 Unity 的生命周期检查,导致表意不明:[2][10]

空合并运算符

以下示例的表意不明确:是检查 gameObject 是否正确引用,还是检查原生 Unity 引擎对象是否已销毁?

1
2
// DON'T DO THIS!
var go = gameObject ?? CreateNewGameObject();

若目的是检查底层引擎对象的生命周期,则此代码不正确,因为生命周期检查被绕过。

使用显式 null 或 boolean 比较修复代码:

1
2
3
var go = gameObject != null ? gameObject : CreateNewGameObject();
// 也可使用隐式的 bool 转换运算符进行同样的检测
go = gameObject ? gameObject : CreateNewGameObject();

若目的是确保 gameObject 变量已被初始化并分配了有效的 C# 引用,推荐使用 object.ReferenceEquals():[11]

1
return !object.ReferenceEquals(gameObject, null) ? gameObject : CreateNewGameObject();

虽然稍显冗长,但表意十分明确。

空条件运算符

以下示例的表意同样不明确:

1
2
// DON'T DO THIS!
monoBehaviour?.Invoke("Attack", 1.0f);

同样的,若目的是简单地检查 monoBehaviour 变量是否已正确初始化与引用,推荐使用 object.ReferenceEquals():

1
2
if (!object.ReferenceEquals(monoBehaviour, null))
monoBehaviour.Invoke("Attack", 1.0f);

若目的是检查底层引擎对象的生命周期,推荐使用显式的 null 或 boolean 比较:[12]

1
2
3
4
5
if (monoBehaviour != null)
monoBehaviour.Invoke("Attack", 1.0f);
// 也可使用隐式的 bool 转换运算符
if (otherBehaviour)
otherBehaviour.Invoke("Attack", 1.0f);

个人解决方案

如果只是想检测 GameObject 是否初始化与正确引用,可以考虑使用 unity 平台宏 以及 C# 扩展方法对 ReferenceEquals 进行封装。[3]

这样避免了在 editor 时 fake null object 引发的 ReferenceEquals 判断错误的问题,也提高了代码的可读性。

1
2
3
4
5
6
7
8
public static bool IsSet(this GameObject gameObject)
{
#if UNITY_EDITOR
return gameObject;
#else
return !ReferenceEquals(gameObject, null);
#endif
}

个人思考

Unity 在与 null 进行比较时判断原生对象是否存活,而是不是检测 C# 对象。这种设计是反直觉的,大多数用户未注意到这种自定义比较。Custom == operator, should we keep it? | Unity Blog Unity 自己的开发者都忘记了。[13]

C# 的引用类型,若不是"值类" (Value class),应采用默认的比较逻辑 (直接对引用进行比较),不应重载的 !=、== 及隐式 bool 转换运算符,不应重写 System.Object 的 Equals(object obj) 方法。

UnityEngine.Object 的比较逻辑有把自己的本职工作做好 (直接对引用进行比较),又做了其他的工作 (判断原生对象是否存活),这不符合单一职责原则。导致了两种空判断的存在,造成了可能的语义不明,与潜在的性能下降。这样增添的逻辑也导致其表现与 C# 的空合并运算符和空条件运算符不一致。导致在使用 UnityEngine.Object 没法很好的使用这两种运算符。若使用,则表意不明确,若不使用,则降低了代码的可读性 (见编写规范)。

更好的方法可能是在 UnityEngine.Object 中加入 destroyed 这样的字段标识原生对象的存活情况。当用户想到知道时进行调用。[4][14]

参阅

Unity 的说明 Custom == operator, should we keep it? | Unity Blog

译文 Unity-自定义==运算符,我们应该保留它吗

Resharper-unity 的说明 Possible unintended bypass of lifetime check of underlying Unity engine object · JetBrains/resharper-unity Wiki

译文 Unity-Resharper-可能意外绕过Unity引擎对象的底层生命周期检查

?? 和 ??= 运算符 - C# 参考 | Microsoft Docs

成员访问运算符和表达式 Null 条件运算符 ?. 和 ?[] - C# 参考

扩展方法 - C# 编程指南 | Microsoft Docs

Real null 与 Fake null 的测试可见我的 github 仓库:UnityEngineObjectNullCheck (分别打包运行与编辑器运行对比区别)

注释

[1] 仅在编辑器中有这种情况。这也是为什么调用GetComponent() 查询不存在的组件时,有 C# 内存分配产生,因为此时 fake null object 中正在生成自定义警告字符串。

这也是为什么测试游戏需要打包测试,而不是在编辑器测试。为了给用户提供便利,编辑器中做了很多额外的工作 (用例、安全检查等),但是牺牲了性能。

[2] 空合并运算符与空条件运算符是无法重载的,可能是因为这点,Unity 无法令其进行自定义的空检查

[3] 扩展方法 - C# 编程指南 | Microsoft Docs

[4] Unity 最终选择了不对其修改,而是修复由此带来的种种问题。


  1. Unity 官方 Object 文档和 Object.operator == 文档说明,UnityEngine.Objectnull 比较时会同时检查 managed object reference 和 native object pointer;UnityCsReference 也显示 operator == 进入 CompareBaseObjectshttps://docs.unity3d.com/6000.0/Documentation/ScriptReference/Object.htmlhttps://docs.unity3d.com/6000.0/Documentation/ScriptReference/Object-operator_eq.htmlhttps://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Export/Scripting/UnityEngineObject.bindings.cs↩︎

  2. Unity programming best practices 明确区分 Unity null check 与真实 C# null check:检测已销毁的 Unity 对象用 obj == null,检测真实 C# null 引用可用 object.ReferenceEquals 或转成 System.Object 后比较;Microsoft Object.ReferenceEquals 文档说明它判断两个 object reference 是否同一实例或都为 null:https://docs.unity.cn/6000.5/Documentation/Manual/programming-best-practices.htmlhttps://learn.microsoft.com/en-us/dotnet/api/system.object.referenceequals↩︎

  3. Unity 官方 custom operator 博客说明 C# wrapper 与 C++ native object 生命周期不同;Unity 6 Object 文档也描述 wrapper 仍存在但 native object 已销毁或卸载的 detached state:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-ithttps://docs.unity3d.com/6000.0/Documentation/ScriptReference/Object.html↩︎

  4. Unity custom operator 博客说明 native object 被销毁而 C# wrapper 仍存在时,Unity 的 custom == null 让对象表现得像 null;Object.operator == 文档也说明与 null 比较会检查 native object pointer:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-ithttps://docs.unity3d.com/6000.0/Documentation/ScriptReference/Object-operator_eq.html↩︎

  5. Unity custom operator 博客说明 Editor 中可能出现 fake null object,用来保存更多诊断上下文,帮助 Inspector 指出未初始化字段;这类行为是 Editor 诊断机制,不应直接外推为 Player 业务契约:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-it↩︎

  6. Unity custom operator 博客把 fake null 纳入 custom null check 的讨论;UnityCsReference 中 CompareBaseObjects / IsNativeObjectAlive 路径体现 Unity 比较并非普通 CLR reference comparison,而是 Unity object 生命周期语义:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-ithttps://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Export/Scripting/UnityEngineObject.bindings.cs↩︎

  7. UnityCsReference 显示 operator ==operator !=implicit operator boolEquals(object) 都围绕 CompareBaseObjects 实现;这支撑原文对 Unity 自定义比较入口的归纳:https://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Export/Scripting/UnityEngineObject.bindings.cs↩︎

  8. Unity programming best practices 提醒,在性能敏感代码中 Unity null comparison 比普通 C# 引用检查更昂贵;JetBrains Inspectopedia 也有 performance-critical code 中 Unity null comparison 的 inspection:https://docs.unity.cn/6000.5/Documentation/Manual/programming-best-practices.htmlhttps://www.jetbrains.com/help/inspectopedia/Unity.PerformanceCriticalCodeNullComparison.html↩︎

  9. JetBrains ReSharper / Rider 的 Unity null inspections 针对 ???.、pattern matching 等写法发出提示,本质是在要求开发者区分 CLR null 与 Unity native object liveness,而不是机械禁止某种语法:https://github.com/JetBrains/resharper-unity/wiki/Possible-unintended-bypass-of-lifetime-check-of-underlying-Unity-engine-objecthttps://www.jetbrains.com/help/inspectopedia/Unity.NoNullCoalescing.htmlhttps://www.jetbrains.com/help/inspectopedia/Unity.NoNullPropagation.html↩︎

  10. Microsoft C# 文档说明 ????=?.?[] 这类运算符不能重载,且 null-coalescing / null-conditional 语义基于左操作数是否为 null;因此 Unity 无法让这些语法自动进入 UnityEngine.Object.operator == 的生命周期检查:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/null-coalescing-operatorhttps://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operatorshttps://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/operator-overloading↩︎

  11. Microsoft Object.ReferenceEquals 文档说明它只判断两个 object reference 是否引用同一实例,或两者是否都为 null;Unity best practices 也把 object.ReferenceEquals 作为真实 C# null check 的推荐方式之一:https://learn.microsoft.com/en-us/dotnet/api/system.object.referenceequalshttps://docs.unity.cn/6000.5/Documentation/Manual/programming-best-practices.html↩︎

  12. Unity best practices 推荐用 obj == null 检测已销毁的 Unity 对象;JetBrains Unity inspections 也建议需要 Unity 生命周期语义时用显式 obj != null / if (obj),而不是 ???.https://docs.unity.cn/6000.5/Documentation/Manual/programming-best-practices.htmlhttps://www.jetbrains.com/help/inspectopedia/Unity.NoNullCoalescing.htmlhttps://www.jetbrains.com/help/inspectopedia/Unity.NoNullPropagation.html↩︎

  13. Unity 官方 custom operator 博客直接讨论了 UnityEngine.Object 自定义 == 是否应该保留,承认该设计有反直觉和维护成本,同时也说明移除会带来大规模兼容性问题:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-it↩︎

  14. Unity custom operator 博客讨论过显式 destroyed 状态这类更清晰的 API 设计方向,但最终因为生态兼容性与迁移成本,没有直接移除 custom == 语义:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-it↩︎

(Ryuu: 附上原文地址 : Possible unintended bypass of lifetime check of underlying Unity engine object · JetBrains/resharper-unity Wiki)[1]

这是 Unity 特定的检查。此检查仅在 Unity 项目中运行。[2]

若从 UnityEngine.Object 派生的类型使用空合并 (??) 或空传播或条件 (?.) 运算符,则会显示此警告。 这些运算符不会使用 UnityEngine.Object 上声明的自定义相等运算符,将绕过 Unity 原生(native)对象的存活检测。 为了阐明意图,最好使用显式 null 或 bool 比较,或调用 System.Object.ReferenceEquals()。[3]

详情

从 UnityEngine.Object 派生的类型是托管 .NET 对象,在 C# 脚本中用于表示与使用原生 Unity 引擎对象。这两种类型的对象具有不同的生命周期。托管的 .NET 对象在没有更多引用时被垃圾收集,而本地 Unity 引擎对象在加载新场景或通过显式调用 UnityEngine.Object.Destroy() 时被销毁。这意味着托管的 .NET 对象指向的原生对象可能已被销毁。 [4]

UnityEngine.Object 类定义了自定义相等运算符,当与 null 进行比较时,这些运算符将检查底层原生 Unity 引擎对象是否已被破坏。换句话说, myMonoBehaviour == null 将检查是否已分配 myMonoBehaviour 变量,并且还将检查原生引擎对象是否已被销毁。可以使用布尔比较执行相同的检查,例如 if (myMonoBehaviour == true) 或 if (!myMonoBehaviour) 或是 if (myMonoBehaviour)。[5]

如果使用空合并或条件运算符,则表意不明确,并且可能绕过预期的生命周期检查。如果打算进行生命周期检查,推荐使用与 null 或布尔比较的显式比较。若不打算进行生命周期检查,请调用 System.Object.ReferenceEquals() 以明确表意。注意,对 object.ReferenceEquals() 的调用被编译器优化为简单的空检查,比调用自定义相等运算符更快。[6]

空合并运算符

以下示例的表意不明确:是检查 gameObject是否正确引用,还是检查原生 Unity 引擎对象是否已销毁?

1
var go = gameObject ?? CreateNewGameObject();

若目的是检查底层引擎对象的生命周期,则此代码不正确,因为生命周期检查被绕过。使用显式 null 或 boolean 比较修复代码:[7]

1
2
3
var go = gameObject != null ? gameObject : CreateNewGameObject();
// 也可使用隐式的 bool 转换运算符进行同样的检测
go = gameObject ? gameObject : CreateNewGameObject();

若目的是确保 gameObject 变量已被初始化并分配了有效的 C# 引用,推荐使用显式调用 object.ReferenceEquals():[8]

1
return !object.ReferenceEquals(gameObject, null) ? gameObject : CreateNewGameObject();

虽然这种更改稍显冗长,但表意十分明确。

空条件运算符

以下示例的表意同样不明确:

1
monoBehaviour?.Invoke("Attack", 1.0f);

同样的,如果目的是简单地检查 monoBehaviour 变量是否已正确初始化与引用,推荐使用显式调用 object.ReferenceEquals():

1
2
if (!object.ReferenceEquals(monoBehaviour, null))
monoBehaviour.Invoke("Attack", 1.0f);

但是,如果目的是检查底层引擎对象的生命周期,推荐使用显式的 null 或 boolean 比较:[9]

1
2
3
4
5
if (monoBehaviour != null)
monoBehaviour.Invoke("Attack", 1.0f);
// 也可使用隐式的 bool 转换运算符
if (otherBehaviour)
otherBehaviour.Invoke("Attack", 1.0f);

参阅

有关此主题的更多详细信息,请参阅 Unity 博客文章 “Custom == operator, should we keep it?”. [10]

已翻译:Unity-Resharper-可能意外绕过Unity引擎对象的底层生命周期检查


  1. JetBrains 旧版 ReSharper Unity wiki 与当前 Inspectopedia 条目都将该问题归入 Unity 专用检查:https://github.com/JetBrains/resharper-unity/wiki/Possible-unintended-bypass-of-lifetime-check-of-underlying-Unity-engine-objecthttps://www.jetbrains.com/help/inspectopedia/Unity.NoNullCoalescing.html↩︎

  2. JetBrains Inspectopedia 的 Unity.NoNullCoalescingUnity.NoNullPropagation inspection 均属于 Unity inspection,目标是 UnityEngine.Object 派生类型上的 null 语义:https://www.jetbrains.com/help/inspectopedia/Unity.NoNullCoalescing.htmlhttps://www.jetbrains.com/help/inspectopedia/Unity.NoNullPropagation.html↩︎

  3. Microsoft Learn 说明 ???. / ?[] 的 null 判断语义;C# operator overloading 文档说明 x?.yx?[y]????= 不能重载。因此这些语法不会进入 UnityEngine.Object.operator == 的生命周期检查:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/null-coalescing-operatorhttps://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operatorshttps://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/operator-overloading↩︎

  4. Unity 的 .NET in Unity 文档说明 Unity 脚本运行在 managed 环境中;Unity Object.Destroy 文档说明销毁的是 GameObject、Component 或 asset 等 Unity 对象;Unity Object 文档还描述了 C# wrapper 与 native object 分离的状态:https://docs.unity.cn/2020.1/Documentation/Manual/overview-of-dot-net-in-unity.htmlhttps://docs.unity.cn/Documentation/ScriptReference/Object.Destroy.htmlhttps://docs.unity3d.com/6000.0/Documentation/ScriptReference/Object.html↩︎

  5. Unity Scripting API 的 Object.operator == 文档说明,与 null 比较时会检查 managed object reference 和 native object pointer;UnityCsReference 中 operator ==operator != 和隐式 bool 转换最终走 CompareBaseObjectshttps://docs.unity3d.com/6000.0/Documentation/ScriptReference/Object-operator_eq.htmlhttps://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Export/Scripting/UnityEngineObject.bindings.cs↩︎

  6. JetBrains 的 Unity null inspections 和 Unity Manual 的 best practices 都强调区分 Unity null 检查与真实 C# null 检查;Unity 生命周期检查用 obj == null / obj != null,真实 C# 引用检查用 object.ReferenceEquals 或等价的 CLR 引用语义:https://www.jetbrains.com/help/inspectopedia/Unity.NoNullCoalescing.htmlhttps://docs.unity.cn/6000.5/Documentation/Manual/programming-best-practices.html↩︎

  7. JetBrains Unity.NoNullCoalescing inspection 专门指出 Unity 对象上的 ?? 可能绕过底层 Unity engine object 的 lifetime check;Microsoft ?? 文档说明该运算符只按左操作数是否为 null 选择分支:https://www.jetbrains.com/help/inspectopedia/Unity.NoNullCoalescing.htmlhttps://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/null-coalescing-operator↩︎

  8. Microsoft Object.ReferenceEquals 文档说明它判断两个 object reference 是否为同一实例或是否都为 null;Unity Manual 将它列为检查真实 C# null 引用的方式,而不是 Unity native object liveness 检查:https://learn.microsoft.com/en-us/dotnet/api/system.object.referenceequalshttps://docs.unity.cn/6000.5/Documentation/Manual/programming-best-practices.html↩︎

  9. JetBrains Unity.NoNullPropagation inspection 指出 Unity 对象上的 null propagation / conditional access 可能绕过 Unity 生命周期检查;Microsoft member access operators 文档说明 ?. 只在左操作数非 null 时访问成员:https://www.jetbrains.com/help/inspectopedia/Unity.NoNullPropagation.htmlhttps://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/member-access-operators↩︎

  10. Unity 官方博客 Custom == operator, should we keep it? 解释了 fake null、C# wrapper 与 C++ native object 生命周期分离,以及移除自定义 == 的兼容性风险;当前 Unity 博客地址为:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-it↩︎

(Ryuu: 附上原文地址 : Custom == operator, should we keep it?)

正文

Unity 的 == 运算符有特殊实现 (UnityEngine.Object 重载了 == 及 != 运算符)。[1]

  1. 当一个 MonoBehaviour 有字段,在 editor only [1] 时,这些字段不是 “real null”,而是 “fake null”。UnityEngine 的 == 运算符能够检测是否存在 fake null object。[2]

    虽然这样做很奇怪,但这能让 Unity 在 fake null object 中存储信息。当执行其方法 (method),或访问其属性 (property),提供更多的上下文信息。

    若不在 fake null object 中存储信息,只能得到 NullReferenceException,堆栈跟踪。并不知道具体是哪个带有 MonoBehaviour 的 GameObject 有字段为 null。

    在 fake null object 中存储信息,Unity 能够在检视窗口 (Inspector) 中高亮该 GameObject,并给出更多指引:“looks like you are accessing a non initialized field in this MonoBehaviour over here, use the inspector to make the field point to something” (看来你在 MonoBehaviour 中试图访问未实例化字段,请在检视窗口使其指向实例)。

  2. 第二点稍加复杂。

    当你获取 GameObject 类型的 c# object [2],他几乎不包含任何信息。这是因为 Unity 是基于 C/C++ 的引擎。关于此 GameObject 的所有实际信息 (name,component list,HideFlags,等等) 都存活在 C++ 侧。C# object 所有的仅是指向原生对象 (native object) 的指针 (pointer)。我们称这样的对象为**“包装对象” (wrapper objects)**。[3]

    这些如 GameObject 的 c++ objects 及其他一切继承自 UnityEngine.Object 的生命周期都被明确的管理。当你加载新场景,或在其上调用 Object.Destroy(myObject); 时,这些 Object 将会被销毁。[4]

    C# object 的生命周期有 C# 的管理方式,其具有垃圾收集器 (garbage collector) [4]。这意味着,有可能被包裹的 C++ Object 已经被销毁,但包裹它的 C# 包装对象依然存在。将此对象与 null 比较,UnityEngine 重载的 == 会返回 true,尽管实际上的 C# 变量 (variable) 不为 null。[5]

UnityEngine 自定义的空检测 (null check) 也导致许多缺陷

  • 这种自定义十分反直觉
  • 对两个 UnityEngine.Object 比较或与 null 比较,会比想象中的要慢[6]
  • UnityEngine 重载的 == 是非线程安全 (not thread safe) 的 (这点 Unity 可在后续修复)
  • 其与 ?? 操作符的表现不一致,?? 同样进行空检测,但这是纯粹的 C# 空检测,会绕过 UnityEngine.Object 自定义空检测 [5][7]

回顾所有的这些优缺点,如果从头再构建 API,我们将不会选择自定义空检查,而是创建一个 myObject.destroyed 的属性,访问该属性以检测 object 的生死。让用户接受在空字段调用方法时不再提供更好的错误信息的事实。[8]

我们在思考我们是否应该改变此自定义运算符,我们一直在寻找 “修复,清除原始项目” 与 “不要破坏原始项目” 之间的正确的平衡。在这种情况下,我们想了解其他人的思考。

对于 Unity5,我们一直在研究 Unity 自动更新脚本的能力 (于随后的博客中对此进行了详细介绍)。不幸的是,在本文情况下,我们无法使您的脚本自动升级 (无法准确辨识 “这个旧脚本确实需要旧的 behaviour” 和 “这个新脚本确实需要新的 behaviour” 的区分)。[9]

我们倾向于 “移除自定义 == 运算符”,但还在犹豫,因为这将改变您工程中所有空检查的意义。对于对象不是 “really null” 而是已销毁对象的情况来说,空检查通常返回 true,如果我们修改了它,就会返回 false 了。若想检测变量是否指向被摧毁对象,需要把代码改成 “if (myObject.destroyed) {}”。我们对此有点紧张,无论你有没有读这篇文章,都很容易意识不到这种行为的改变,特别是大多数人根本没有意识到这种自定义空检查的存在。[3][10]

如果我们作修改,应在 Unity5 上。对于非主要发行版,我们允许用户承受的升级痛苦阈值更低。

你希望我们怎么做?以必须更改已有项目中的空检查为代价,提供更整洁的体验,或是保持现状?

再见, Lucas (@lucasmeijer) **[6]

注释

[1] 我们只在编辑器中执行此操作。这就是为什么在调用GetComponent() 查询不存在的组件时,会看到 C# 内存分配产生,因为我们正在新分配的伪空对象中生成自定义警告字符串。在打包的游戏中,这种内存分配不会发生。这就是为什么测试游戏时,应在打包出来的独立端 (standalone 如 Mac, Windows or Linux) 或移动端测试,而不是在编辑器测试,因为我们在编辑器中做了很多额外的 安全/用例检查,以使你的工作更轻松,而牺牲了一些性能。在分析性能和内存分配时,永远不要在编辑器,应始终分析构建出的游戏。

[2] 不仅适用于 GameObject,也适用于继承自 UnityEngine.Object 的所有类。

[3] 有趣的故事: 我在优化GetComponent()性能时遇到了这个问题,在为 transform 组件做一些缓存实现时,我没有看到任何性能优势。@jonasechterhoff 也研究了此问题,得出了相同的结论。缓存代码如下所示:

1
2
3
4
5
6
7
8
9
10
private Transform m_CachedTransform
public Transform transform
{
get
{
if (m_CachedTransform == null)
m_CachedTransform = InternalGetTransform();
return m_CachedTransform;
}
}

事实证明,我们自己的两位工程师都没有注意到空检查会比想象中更费时。这就是缓存没有带来速度提升的原因,“连我们自己都忘记了它 (Unity 自定义空检测),会有多少用户会忘记了它?”,这使得我写下了此篇文章 :)

[4] Ryuu: C# 托管对象的回收是 C# 垃圾回收器管理的,不能保证其生命周期和 C++ 侧对象完全一致。

[5] Ryuu: 原文是 “and cannot be bypassed to call our custom null check”(可以绕过自定义空检测) 个人认为是写错了 (也可能是我英语太差,理解错了),实际情况是,使用 ?? 或 ?. 都会绕过 UnityEngine.Object 的自定义空检测。附上 JetBrains/resharper-unity 的解释页: Possible unintended bypass of lifetime check of underlying Unity engine object

[6] Ryuu: 最后作者真香了,并没有移除自定义的空检查,而是想办法修复其产生的缺陷。


  1. Unity 官方博客 Custom == operator, should we keep it? 直接讨论 UnityEngine.Object 自定义 == / != 运算符是否应该保留;Unity Scripting API 与 UnityCsReference 也显示 Object.operator == 会进入 CompareBaseObjectshttps://unity.com/blog/engine-platform/custom-operator-should-we-keep-ithttps://docs.unity3d.com/6000.0/Documentation/ScriptReference/Object-operator_eq.htmlhttps://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Export/Scripting/UnityEngineObject.bindings.cs↩︎

  2. Unity 官方博客说明 Editor 中可能出现 fake null object,用来保存诊断上下文并帮助 Inspector 指出未初始化字段;这支撑原文对 fake null 与 custom == 检测关系的描述:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-it↩︎

  3. Unity 官方博客说明 Unity 的 C# wrapper 与 C++ native object 生命周期不同;Unity Manual 的 .NET in Unity 也说明 Unity 脚本暴露 C# 类型,但底层引擎对象不等同于普通 CLR 对象:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-ithttps://docs.unity.cn/2020.1/Documentation/Manual/overview-of-dot-net-in-unity.html↩︎

  4. Unity Object.Destroy 文档说明 Destroy 会移除 GameObject、Component 或 asset,实际销毁延迟到当前 Update loop 之后、渲染之前;Unity Object 文档也描述了 native object 已销毁或卸载但 wrapper 仍存在的 detached state:https://docs.unity.cn/Documentation/ScriptReference/Object.Destroy.htmlhttps://docs.unity3d.com/6000.0/Documentation/ScriptReference/Object.html↩︎

  5. Unity 官方博客说明 native object 已销毁而 C# wrapper 仍存在时,custom == null 会让对象表现得像 null;Object.operator == 文档说明与 null 比较会检查 managed reference 和 native pointer:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-ithttps://docs.unity3d.com/6000.0/Documentation/ScriptReference/Object-operator_eq.html↩︎

  6. Unity 官方博客把 Unity null comparison 比预期更慢列为缺点;Unity programming best practices 与 JetBrains Inspectopedia 也提醒在性能敏感代码中 Unity null comparison 比普通 C# 引用检查更昂贵:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-ithttps://docs.unity.cn/6000.5/Documentation/Manual/programming-best-practices.htmlhttps://www.jetbrains.com/help/inspectopedia/Unity.PerformanceCriticalCodeNullComparison.html↩︎

  7. Microsoft C# 文档说明 ?? / ??= 不能被重载,null-coalescing 基于左操作数是否为 null;JetBrains Unity inspection 也指出在 UnityEngine.Object 上使用 ?? 可能绕过 Unity 生命周期检查:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/null-coalescing-operatorhttps://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/operator-overloadinghttps://www.jetbrains.com/help/inspectopedia/Unity.NoNullCoalescing.html↩︎

  8. Unity 官方博客提出,如果从零设计 API,更清晰的做法可能是显式 myObject.destroyed 之类状态,而不是把 native object 生命周期检查隐藏在 == null 后面:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-it↩︎

  9. Unity 官方博客说明,自动升级脚本很难准确区分旧代码需要旧行为还是新行为;llm-wiki 归纳的边界也是移除 custom == 会改变大量旧项目中 destroyed object 检查的语义:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-it↩︎

  10. Unity 官方博客说明移除自定义 == 会改变项目中所有 null checks 的意义:destroyed native object 原本会在 == null 下返回 true,改动后会变成 false,因此兼容风险很高:https://unity.com/blog/engine-platform/custom-operator-should-we-keep-it↩︎

由于大多数程序的算法都是要在一系列元素而非单一元素上执行操作,因此开发者会使用 foreach、for 循环及 while 等结构。通常把某集合用作输入值,然后检视或修改集合本身或其中元素,最后把另一个集合作为输出值返回给调用方。

这样做的问题在于,若针对整个集合中的每个元素执行操作,程序效率很低。因为执行的操作通常不止一个,且需要多次变换才能把源集合元素转换为目标集合元素。在此过程中,需要创建一些集合保存中间结果,且集合有可能较大,必须等整个集合完成了一次变换操作后,才能继续执行下一次变换操作。要执行几次操作,就得把集合遍历几遍,因此,若执行操作较多,那么算法的执行时间会较长。[1]

另一种办法是,在方法中仅遍历一次,将序列中每个元素都处理一遍,并对其进行各种变换,得到结果。这将提高程序的效率,降低内存使用 (不用每执行一步就创建一个集合)。但这样的的代码很难复用,因为开发者复用的不是整套逻辑,而是其中的一小步。[2]

由于 C# 有迭代器 (iterator),因此,开发者可用它创建出一种方法来操作序列中的元素,这样的方法只会在调用方法真正请求获取元素是才会处理并返回该元素。这些方法以 IEnumerable 型参数表示源序列,并把要生成的目标序列也设计为 IEnumerable,而且通过 yielld return语句返回序列中的元素,使得开发者无需给整个目标序列中的元素分配空间,而是可以等调用方真正用到序列中的下一个元素时采取向源序列查询相关数据,并以此生成所需元素。将通用的 IEnumerable 或针对某种类型的 IEnumerable 设计成方法的输入及输出参数是一种比较少见的思路,因此,很多开发者都不会这样做,但这种思路能带来许多好处。与传统方法相比,这种延*迟执行 (deferred execution,见37条)*机制可以降低算法所需内存空间,使算法各部分能够更灵活的拼接复用 (见40条)。还可把不同操作放在不用的 CPU 内核中执行,进一步的提高程序性能。可创建泛型方法,扩大其使用范围。[3]

如下实例将序列中每种元素输出一次 (重复元素不输出):

1
2
3
4
5
6
7
8
9
10
11
public static void Unique(IEnumerable<int> nums)
{
var uniqueSet = new HashSet<int>();

foreach (int num in nums)
{
if (uniqueSet.Contains(num)) continue;
uniqueSet.Add(num);
Console.WriteLine(num);
}
}

此方法虽然简单,但是不能进行复用。

可以考虑改用迭代器方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static IEnumerable<int> UniqueV2(IEnumerable<int> nums)
{
var uniqueSet = new HashSet<int>();

foreach (int num in nums)
{
if (uniqueSet.Contains(num)) continue;
uniqueSet.Add(num);
yield return num;
}
}

public static void Main(string[] args)
{
int[] nums = {0, 3, 4, 5, 7, 3, 2, 7, 8, 0, 3, 1};
foreach (int i in UniqueV2(nums))
Console.WriteLine(i);

Console.WriteLine(UniqueV2(nums).First());
}

有人认为这样改差不多,没什么好处。加上一些追踪语句,能让你更清楚此方法的运作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public static IEnumerable<int> UniqueV2(IEnumerable<int> nums)
{
var uniqueSet = new HashSet<int>();
Console.WriteLine("\tEntering Unique");
foreach (int num in nums)
{
Console.WriteLine($"\tEvaluating {num.ToString()}");
if (uniqueSet.Contains(num)) continue;
uniqueSet.Add(num);
yield return num;
Console.WriteLine("\tRe-entering after yield return");
}

Console.WriteLine("\tExiting Unique");
}

public static void Main(string[] args)
{
int[] nums = {0, 3, 4, 5, 7, 3, 2, 7, 8, 0, 3, 1};

// Entering Unique
// Evaluating 0
// 0
// Re-entering after yield return
// Evaluating 3
// 3
// Re-entering after yield return
// Evaluating 4
// 4
// Re-entering after yield return
// Evaluating 5
// 5
// Re-entering after yield return
// Evaluating 7
// 7
// Re-entering after yield return
// Evaluating 3
// Evaluating 2
// 2
// Re-entering after yield return
// Evaluating 7
// Evaluating 8
// 8
// Re-entering after yield return
// Evaluating 0
// Evaluating 3
// Evaluating 1
// 1
// Re-entering after yield return
// Exiting Unique
foreach (int i in UniqueV2(nums))
Console.WriteLine(i);

// Entering Unique
// Evaluating 0
// 0
Console.WriteLine(UniqueV2(nums).First()); // Ryuu: 添加一个示例

// foreach (int num in Square(UniqueV2(nums)))
// Console.WriteLine($"Number returned from unique square: {num.ToString()}");
}

之所以有这样的效果,关键就在于 yield return 语句。此语句会返回值,并保留信息,记录当前执行的位置及内部迭代逻辑的状态。用此语句写出来的方法,输入输出值都是迭代器,其迭代逻辑可根据早前保留的信息判断当前应读取输入序列的哪一元素,据此生成并返回输出序列中的下一元素。此方法属于可从上次执行位置继续执行的方法 (continuable method),系统每次运行它时,可根据先前记录的状态信息决定继续执行的位置。[4]

将 Unique() 方法改写成连续方法 (continuation method) 有两个好处:

  1. 推迟了每个元素的求值时机,提高程序效率。
  2. 此操作可拼接,可灵活复用。[5]

反之,想用包含 foreach 循环的命令式方法进行灵活复用则较为困难。

注意,Unique() 方法还可转换为泛型方法:[6]

1
2
3
4
5
6
7
8
9
10
public static IEnumerable<T> UniqueV3<T>(IEnumerable<T> sequence)
{
var uniqueSet = new HashSet<T>();
foreach (T item in sequence)
{
if (uniqueSet.Contains(item)) continue;
uniqueSet.Add(item);
yield return item;
}
}

迭代器方法可把多个步骤拼接成一套流程。若要输出是每一种数值的平方,接上一个 Square() 即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static IEnumerable<int> Square(IEnumerable<int> nums)
{
foreach (int num in nums)
yield return num * num;
}

public static void Main(string[] args)
{
int[] nums = {0, 3, 4, 5, 7, 3, 2, 7, 8, 0, 3, 1};

foreach (int num in Square(UniqueV2(nums)))
Console.WriteLine($"Number returned from unique square: {num.ToString()}");

}

无论使用多少个迭代器方法,仅需将源序列迭代一次即可。[7]

将序列用作迭代器的输入参数。并令其输出另一序列是一种很好的思路,这使得开发者能设计更多的用法。若迭代器方法的参数不是一个序列而是两个,可用这样的迭代器方法将两个序列合并起来:[8]

1
2
3
4
5
6
7
8
9
10
11
12
13
public static IEnumerable<string> Zip(IEnumerable<string> first, IEnumerable<string> second)
{
using (var firstSequence = first.GetEnumerator())
{
using (var secondSequence = second.GetEnumerator())
{
while (firstSequence.MoveNext() && secondSequence.MoveNext())
{
yield return $"{firstSequence.Current}{secondSequence.Current}";
}
}
}
}

Zip() 从两个不同的字符串序列中分别取出一个元素,并连接成为新字符串,输出目标序列。当然,此方法也可设计成泛型方法,只不过稍复杂 (见18条)。

迭代器方法不会修改源序列本身,而是会依次产生目标序列中的元素,这些元素构成一个新序列。若源序列中的元素是引用型,那么迭代器有可能在处理元素时改动该元素内容。[9]

如果能把复杂的算法拆解成多个步骤,并将每个步骤都表示成小型的迭代器方法,那么可将这些方法拼成一条管道,使得程序仅需遍历一次源序列处理,即可对其中元素进行多种小变换。[10]


  1. Bill Wagner, Effective C# (Covers C# 6.0), 3rd Edition 在本条目中讨论了把整集合一步步物化为中间集合的成本;Microsoft Learn 的 Deferred execution and lazy evaluation in LINQ 也说明延迟执行可以避免不必要的中间集合和提前计算。 ↩︎

  2. Effective C# Item 31 用命令式单次遍历与可拼接迭代器方法对比,支撑“单次遍历可能高效但小步骤复用困难”的设计取舍。 ↩︎

  3. Microsoft Learn IEnumerable<T> Interfaceyield statementDeferred execution and lazy evaluation in LINQ 支撑 IEnumerable<T> -> IEnumerable<T>、按需产生元素和延迟执行的描述;关于多核执行,来源只支持“可组合步骤便于进一步调度或并行化”,不表示 yield return 会自动把操作放到多个 CPU 内核。 ↩︎

  4. Microsoft Learn yield statement 说明 yield return 会逐个返回元素并保留迭代状态,直到枚举继续推进。 ↩︎

  5. Microsoft Learn Deferred execution and lazy evaluation in LINQ 说明查询通常在枚举时执行,延迟执行可按需取值并组合多个查询操作;Effective C# Item 31 将这一点用于序列 API 设计。 ↩︎

  6. Microsoft Learn IEnumerable<T> Interface 将序列表达为类型安全的泛型枚举接口,支撑把 Unique 这类逐项操作推广为泛型方法。 ↩︎

  7. 对于文中 UniqueV2Square 这类逐项 yield return 方法,延迟枚举可形成流水线并减少中间集合;但 Microsoft Learn CA1851 提醒 IEnumerable 多次枚举可能重复执行工作,且排序、分组、去重等操作仍可能需要内部缓存。 ↩︎

  8. Microsoft Learn Enumerable.Zip Method 是“多个输入序列产生一个结果序列”的标准库形态,支撑文中把两个序列合并为目标序列的 API 设计。 ↩︎

  9. Microsoft Learn yield statement 支撑迭代器逐个产生目标序列元素;Reference types 说明引用类型变量保存对象引用,因此边界是:迭代器本身不必修改源集合结构,但若元素是引用类型,处理代码仍可能修改被引用对象。 ↩︎

  10. 本文的总结由 Effective C# Item 31、Microsoft Learn yield statementDeferred execution and lazy evaluation in LINQCA1851 共同支撑:序列 API 适合拆成可组合步骤,但必须同时说明延迟执行、多次枚举和物化边界。 ↩︎

这条建议可能听起来有些奇怪,因为 lambda 表达式代替方法会重复编写代码。例如:[1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var allEmployees = FindAllEmployees();

// Find the first employees:
var earlyFolks =
from e in allEmployees
where e.Classification == EmployeeType.Salary
where e.YearsOfService > 20
where e.MonthlySalary < 4000
select e;

// Find the newest people:
var newest =
from e in allEmployees
where e.Classification == EmployeeType.Salary
where e.YearsOfService < 20
where e.MonthlySalary < 4000
select e;

你可以将这些 where 合并为一条子句,然而这并不会带来太大区别。查询操作之间本就可以拼接 (见31条),而简单的 where 谓词还会有可能内联 (inline),因此,这两种写法在性能上是一样的。[2]

看到刚才那段代码,你可能会想把重复的 lambda 表达式提取到方法,以便复用:[3]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Factor out method:
private static bool LowPaidSalaried(Employee e) =>
e.MonthlySalary < 4000 && e.Classification == EmployeeType.Salary;

// else where
var allEmployees = FindAllEmployees();

var earlyFolks =
from e in allEmployees
where LowPaidSalaried(e) && e.YearsOfService > 20
select e;

// Find the newest people:
var newest =
from e in allEmployees
where e.Classification == EmployeeType.Salary
where e.YearsOfService < 20
where e.MonthlySalary < 4000
select e;

如果将来需要修改员工的类别 (Classification),或修改筛选底薪员工时所依据的最低月薪 (MonthlySalary),那么只需要改动 LowPaidSalaried() 里的逻辑即可。

但是这样的重构不能提高其复用性,这与 lambda 表达式求值、解析及执行机制有关:[4]

  • LINQ to Objects

    为执行表达式中代码,将 lambda 表达式转化成为委托

  • LINQ to SQL

    根据 lambda 表达式创建表达式树,并解析,使其能在其他环境执行[5]

LINQ to Objects 针对本地数据存储 (local data store) 来执行查询,数据通常放在泛型集合。系统根据 lambda 表达式中的逻辑建立匿名委托,并执行相关代码。LINQ to Objects 扩展方法使用 IEnumerable 来表示输入序列。[6]

LINQ to SQL 使用表达式树,根据所写查询逻辑构建表达式树,将其解析为适当的 T-SQL 查询,这种查询是针对数据库执行的,LINQ to SQL 把 T-SQL 形式的查询字符串发送给数据库引擎并执行。这种处理方式要求 LINQ to SQL引擎必须解析表达式树,并把其中每一项操作都替换成等价的 SQL,这意味着所有的方法调用都需要换成 Expression.MethodCall 节点。然而 LINQ to SQL 引擎并不能把每一种方法调用都顺利的转化为 SQL 表达式,无法转换将会引发异常。[7]

如果所写的程序库需要支持任意类型的数据源,必须考虑上述情况。需要分开编写 lambda 表达式,且内联至代码中,以保证程序库正常运行。[8]

这并不是在鼓励一味的复制代码,而是提醒,涉及查询表达式及 lambda 的地方应该用更为合理的方法去创建复用代码块。例如:[9]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static IQueryable<Employee> LowPaidSalariedFilter(this IQueryable<Employee> sequence) =>
from s in sequence
where s.Classification == EmployeeType.Salary && s.MonthlySalary < 4000
select s;

// else where
var allEmployees = FindAllEmployees();

// Find the first employees:
var salaried = allEmployees.LowPaidSalariedFilter();

var earlyFolks = salaried.Where(e => e.YearsOfService > 20);

// Find the newest people:
var newest = salaried.Where(e => e.YearsOfService < 2);

并不是每一种查询都能像这样简单的改写,可能需要沿着调用链寻找,才能发现可供复用的列表处理逻辑 (list-processing logic),从而提取相同的 lambda 表达式。31条提过,只要当程序真的需要遍历集合的时,enumerator 方法才会得以执行。可利用此特征,将查询操作分成许多小方法来写,这些小方法都能复用某一套 lambda 表达式来执行它所应该完成的那一部分查询工作。这些方法需要将待处理的序列当成输入值,并以 yield return 形式返回处理结果。这些小方法可构成较大的查询模块。避免重复的代码,使得代码结构更合理。[10]

也可按照同样的思路建立表达式树,以此拼接 IQueryable 形式的 enumerator,令查询操作能够远程执行。寻找相关员工所用的那棵表达式树可以先于其他查询拼接,然后执行。IQueryProvider 对象 (LINQ to SQL 引擎的数据源就是这种对象) 可以把全套查询操作一次执行完毕,而不必将其分解成多个部分放到本地执行。[11]

若想在复杂的查询中有效地复用 lambda 表达式,可以考虑针对封闭的泛型类型创建扩展方法。LowPaidSalariedFilter 方法就是这么写的,它接受有待筛选的 Employee 对象序列,输出经过筛选后的 Employee 对象序列。在实际工作中,还应该创建以 IEnumerable 作阐述的重载版本,以便同时支持 LINQ to Objects 及 LINQ to SQL。[12]

你可以把查询操作分成多个小方法,其中一些方法在其内部用 lambda 表达式处理序列,另一些方法以 lambda 表达式作参数。把这些小方法拼接起来,以实现整套操作。这样既可以支持 IEnumerable 与 IQueryable,又能令系统有机会构建出表达式树,以便高效执行查询。[13]


  1. Bill Wagner, Effective C# (Covers C# 6.0), 3rd Edition 在本条目中讨论查询谓词复用;Microsoft Learn Lambda expressions and anonymous functions 说明 lambda 可以转换为委托或表达式树,因此“是否抽方法”取决于后续执行模型。 ↩︎

  2. Effective C# Item 38 将多个 where 子句和合并谓词视为等价查询结构的一种写法;这里的性能判断依赖具体 provider 和运行时优化,脚注只支撑“查询操作可组合、短谓词通常不应为了抽象而破坏查询形状”这一设计边界。 ↩︎

  3. Microsoft Learn DelegatesLocal functions 支撑具名方法、局部函数适合表达可复用或有业务名的逻辑;后文的限制主要发生在 IQueryable<T> provider 需要翻译表达式树时。 ↩︎

  4. Microsoft Learn Lambda expressions and anonymous functions 说明 lambda 可转换为委托类型或表达式树类型;这正是 LINQ to Objects 与 IQueryable<T> provider 场景差异的基础。 ↩︎

  5. Microsoft Learn DelegatesIQueryable<T> InterfaceExpression trees 分别支撑“本地委托执行”和“表达式树交给 provider 分析/执行”的区别。 ↩︎

  6. Microsoft Learn IEnumerable<T> Interface 表示可枚举序列;Lambda expressionsDelegates 支撑 LINQ to Objects 中谓词通常作为本地委托执行。 ↩︎

  7. Microsoft Learn IQueryable<T> Interface 说明查询与 provider 关联,枚举会触发表达式树执行;Expression trees 说明表达式树表示代码结构,但具体 provider 不一定能翻译所有方法调用。 ↩︎

  8. Effective C# Item 38 与 Microsoft Learn IQueryable<T> / expression tree 文档共同支撑这一边界:面向任意 provider 的查询库应保留 provider 能看见的成员访问、比较和布尔组合结构,而不是只暴露一个普通 .NET 方法调用。 ↩︎

  9. Microsoft Learn IQueryable<T> InterfaceExpression trees 支撑把复用单位设计成仍返回 IQueryable<T> 的查询片段,让后续过滤继续留在同一表达式树中。 ↩︎

  10. IEnumerable<T> 场景,Microsoft Learn yield statement 支撑用迭代器逐项返回处理结果;这与前一条序列 API 可组合性结论一致,即把列表处理逻辑拆成可拼接的小步骤。 ↩︎

  11. Microsoft Learn IQueryable<T> InterfaceExpression trees 支撑 IQueryProvider 通过表达式树执行查询;组合 IQueryable<T> 片段可以把整体查询结构保留给 provider。 ↩︎

  12. Microsoft Learn Extension members 支撑用扩展方法表达查询片段;分别提供 IEnumerable<T>IQueryable<T> 版本,可以区分本地委托执行和 provider 翻译两种语义。 ↩︎

  13. 本文总结由 Effective C# Item 38、Microsoft Learn Lambda expressionsIQueryable<T>Expression treesExtension members 共同支撑:查询逻辑可以拆成小方法,但应保留 IEnumerable<T>IQueryable<T> 各自需要的执行形状。 ↩︎

0%