Ryuu 的个人博客

一个计算机初学者

“思维主词”和”思维谓词”是哲学和逻辑学中的两个重要概念,用于讨论关于思维、概念和语言的语义结构。

  1. 思维主词(Subject of Thought):
    • 它代表了一个思维、论述或陈述的中心或主题。
    • 思维主词通常是陈述的主语,它描述或指代一个人、事物、概念或现象。思维主词是一个思维的中心,它是关于什么或者谁的思考。
    • 例如,在陈述 “狗是哺乳动物” 中,”狗” 是思维主词,因为它是这个陈述的主题,我们在这个陈述中思考的是狗这个概念。
  2. 思维谓词(Predicate of Thought):
    • 它代表了一个陈述或论述中关于思维主词的性质、状态或关系。
    • 思维谓词通常是陈述的谓语,它提供了有关思维主词的信息或描述,指示思维主词如何与其他概念或陈述相互关联。
    • 继续上面的例子,”是哺乳动物” 是思维谓词,因为它描述了思维主词 “狗” 的性质或分类,即它是哺乳动物。

在逻辑学和语义学中,思维主词和思维谓词是分析语句和命题结构的重要元素。理解这两个概念有助于更清晰地理解语言和思维中的论证和推理,以及逻辑学中的范畴论和语义分析。这两个概念也有助于理解陈述句子的语法和语义构造。

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

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

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 枚举模式无类型安全性,也无描述性可言。

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

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

简述

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

设计模式类型:创建型

实现

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

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

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

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

委托是类

委托是类,C# 提供了 delegate 关键字,使得用户能简单的声明委托。编译器及 CLR 做了大量的工作来隐藏委托的复杂性。[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)。

MulticastDelegate

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

字段 类型 说明
_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) 方法对两个委托进行合并。

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

移除

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

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]

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

不要定义过多的委托

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个参数的方法:

  • 从无参,到至多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 关键字以传递引用的方式传递参数,可能不得自定义委托:

1
delegate void Foo(ref int bar);

event 关键字

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

1
public event Action action;

EventHandler

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

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

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

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

委托的简化语法

Combine 与 Remove 的简化

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

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]

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]。在辅助类中,为需要传递给回调的每个值都定义一个字段。将回调方法定义为其实例方法。

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

委托与反射

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

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

示例:

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] 捕获上下文的外部变量以在回调方法中使用。闭包有对外部变量的引用,所以可能导致外部变量所在的对象声明周期延长。

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

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))

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 获取数组长。 但要注意, JIT 编译器知道 Length 是 Array 类的属性,生成的代码中只会调用该属性一次,结果会存储到临时变量中,此后的检查中调用的都是此临时变量。不需要自己用局部变量做缓存,这样既没有性能提升,还可能造成可读性下降

参阅

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
{% post_link 文章名称 %}

1
{% post_link Hello-World %}

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

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

详情

Unity 对象的空检测

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

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

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

Unity 对象的生与死

原生对象与包装对象:

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

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

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

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

Real null 与 Fake null:

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

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

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) 中。

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

编写规范

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

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

空合并运算符

以下示例的表意不明确:是检查 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():

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 比较:

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 自己的开发者都忘记了。

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

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

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

参阅

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 最终选择了不对其修改,而是修复由此带来的种种问题。

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

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

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

详情

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

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

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

空合并运算符

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

1
var go = gameObject ?? CreateNewGameObject();

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

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

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

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 比较:

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?”.

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

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

正文

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

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

    虽然这样做很奇怪,但这能让 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)**。

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

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

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

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

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

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

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

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

如果我们作修改,应在 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: 最后作者真香了,并没有移除自定义的空检查,而是想办法修复其产生的缺陷。

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

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

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

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

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

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),系统每次运行它时,可根据先前记录的状态信息决定继续执行的位置。

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

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

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

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

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()}");

}

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

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

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条)。

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

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

0%