文章目录
- 第二章 C# 2
- 2.1 泛型(*)
- 2.2 default 和 typeof(*)
- 2.3 可空值类型
- 2.3.1 `Nullable<T>` 结构体(framework 支持)
- 2.3.2 装箱(CLR 支持)
- 2.3.3 “?”后缀(语法支持)
- 2.3.4 null 字面量(语法支持)
- 2.3.5 转换(语法支持)
- 2.3.6 提升运算符
- 2.3.7 可空逻辑
- 2.3.8 as 运算符与可空值类型
- 2.3.9 空合并运算符 ??
- 2.4 简化委托的创建
- 2.4.1 委托的兼容性
- 2.5 迭代器
- 2.5.1 处理 finally 块
- 2.5.2 处理 finally 块的重要性
- 2.5.3 迭代器实现机制概览
- 2.6 局部类
- 2.6.1 局部方法(C#3)
- 2.7 静态类(*)
- 2.8 属性 getter/setter 访问分离(*)
- 2.9 命名空间别名
- 2.9.1 命名空间别名限定符
- 2.9.2 全局命名空间别名
- 2.9.3 外部别名
- 2.10 编译指令(*)
- 2.11 固定大小的缓冲区(*)
- 2.12 InternalsVisibleTo(*)
第二章 C# 2
2.1 泛型(*)
2.2 default 和 typeof(*)
2.3 可空值类型
2.3.1 Nullable<T>
结构体(framework 支持)
可空值类型特性的核心要素是 Nullable<T>
结构体,其早期版本如下所示:
当 hasValue 为 false 时,访问 value 的操作会引发异常。
另外,Nullable<T>
结构体还提供了如下方法和运算符:
-
GetValueOrDefault()
返回结构体中的值,如果 hasValue 为 false,则返回默认值。
-
GetValueOrDefault(T defalutValue)
返回结构体中的值,如果 hasValue 为 false,则返回 defalutValue。
-
重写了 object 类的方法:Equals(object) / GetHashCode() / ToString()
Equals:首先比较 hasValue,均为 true 时再比较 value 是否相等。
-
提供
T --> Nullable<T>
的隐式类型转换。该转换总是会返回对应的可空值,且 hasValue 为 true。
-
提供
Nullable<T> --> T
的显式类型转换。hasValue 为 true 时,返回 value 值;
hasValue 为 false 时,抛出 InvalidOperationException 异常。
2.3.2 装箱(CLR 支持)
对比:非可空值的装箱
当非可空值类型被装箱时,返回结果的类型就是原始的装箱类型。
o 是对“装箱 int”对象的引用。C# 中,“装箱 int”和 int 之间的区别通常是不可见的。即,o.GetType() 返回的 Type 会和 typeof(int) 的结果相同。
可空值的装箱
可空值的装箱结果视 hasValue 的值而定:
- hasValue 为 true,返回 null 引用;
- hasValue 为 false,返回“装箱 T”对象的引用。
较为奇特的一点是,hasValue 为 false 的可空值装箱后,使用 GetType() 方法会引发 NullReferenceException 异常。
2.3.3 “?”后缀(语法支持)
Nullable<T>
的简化写法是在类型 T 后面添加 “?” 后缀,改写法对简版类型名(int、double 等)和全版类型名(int32 等)都适用。以下 4 个声明完全等价,它们产生的 IL 代码没有任何区别:
2.3.4 null 字面量(语法支持)
C#2 将 null 的含义进行扩展:
- 或者表示一个 null 引用;
- 或者表示一个 hasValue 为 false 的可空类型的值。
以下代码完全等价:
2.3.5 转换(语法支持)
如果存在从 S -->
T 的类型转换,则下列类型转换都是合法的:
Nullable<S> --> Nullable<T>
(依据S --> T
而决定显式转换或隐式转换)。- S
--> Nullable<T>
(同上)。 Nullable<S> -->
T 的显式类型转换。
转换的工作原理:将 S 到 T 按照要求进行转换,有必要时填充 null 值。
填充 null 值的扩展过程称为提升。
2.3.6 提升运算符
C# 允许对以下运算符进行重载:
- 一元运算符:+、++、-、–、!、~、true、false。
- 二元运算符:+、-、*、/、%、&、^、<<、>>。
- 等价运算符:==、!=。
- 关系运算符:<、>、<=、>=。
重载非可空类型 T 时,Nullable<T>
会提供对应运算符的自动重载版本,但是操作数类型和返回值类型会有所区别,我们将其称为提升运算符。提升运算符的具体规则如下:
-
true 和 false 不能被提升(很少使用,因此影响不大)。
-
只有操作数是非可空值类型的运算符才能被提升。
-
对于一元运算符和二元运算符,原运算符的返回类型必须是非可空的值类型。
-
对于等价运算符和关系运算符,原运算符的返回类型必须是 bool 类型。
-
作用于
Nullable<bool>
的 & 和 | 运算符具有单独定义的行为(见 [2.3.7 节](#2.3.7 可空逻辑))。
提升后的运算符中:
-
操作数类型都变成对应的可空等价类型,返回类型(仅对于一元运算符和二元运算符)也变为可空等价类型。
-
如果两个操作数均为非空,则执行方式与原运算符相同。
-
否则:
-
对于一元运算符和二元运算符:
- 如果任意一个操作数为 null,那么返回值也为 null。
-
对于等价运算符:
-
两个 null 被视为相等。
-
一个 null 和一个 非 null 被视为不相等。
-
-
对于关系运算符:
- 任意一个操作数为 null 时,返回 false。
-
例如,我们自定义 Test 值类型,并重载 true 和 false 运算符,但由于 Nullable<Test>
将不会提供 true 和 false 的可空值类型的重载版本,因此下面的代码会报错:
重载 == 和 != 运算符后, Nullable<Test>
会自动提供对应的可空值类型的版本,以下代码可以正常运行且结果符合预期:
上述规则看上去较为复杂,但多数情况下,执行结果会与我们理解的预期相符。以 int 为例,表2.1 列出了 Nullable<int>
自动提供的提升运算符,以及相应的举例。
2.3.7 可空逻辑
[2.3.6 节](#2.3.6 提升运算符)提及,Nullable<bool>
的 & 和 | 运算符与其他类型的行为有所不同,因为输入值除了 true 和 false,还需要加上 null。表2.2 列出了 Nullable<bool>
的全部 4 个逻辑运算符的真值表。
注意,&& 和 || 运算符不使用于 Nullable<bool>
类型。
说明:
上述所讨论的提升运算符、类型转换以及
Nullable<bool>
逻辑等特性都是由 C# 编译器提供的,其创建了所有 IL 代码来进行空值检查,并做出相应处理。而与 CLR 或 framework 本身无关。
2.3.8 as 运算符与可空值类型
在 C#2 之前,as 运算符只能用于引用类型;C#2 后,as 运算符也可以用于可空值类型。
当原始引用的类型为 null 或与目标类型不匹配时,返回 null 值;否则,返回一个有意义的值。
说明:
对可空类型使用 as 运算符,性能出奇的低。大部分情况下,比 is 运算符性能低,但还是比 I/O 操作效率高。
对目标结果是 Nullable<T>
类型的表达式而言,as 是很方便的运算符。且 C#7 对大部分可空值类型采用模式匹配(第 12 章),因此使用 as 运算符是更优的解决方案。
2.3.9 空合并运算符 ??
?? 是一个二元运算符,first ?? second 表达式的计算分为以下几个步骤:
- 计算 first 表达式;
- 如果 first 不为空,则返回结果为 first;
- 如果 first 为空,则计算 second 表达式并返回。
2.4 简化委托的创建
2.4.1 委托的兼容性
在 C#1 中创建委托实例时,创建实例的方法与委托的返回值类型和参数类型(包括 ref 和 out)必须完全一致。假设有如下委托声明和方法:
C#1 不允许将 PrintAnything 赋值给 Printer 实例,但到了 C#2,这种方式被允许。因为传入 Printer 的参数为 string 类型,必定也是 object 类型的引用。
此外,还可以使用委托来创建另外一个委托,条件是二者的签名要兼容。
同样,对于返回值类型也一样:
注意,有时上述规则并不能如我们所愿,参数或返回值之间的兼容性必须满足一致性转换规则,才能保证执行期间变量值不变。例如,下面的代码就不能通过编译:
这是因为两个委托的签名不兼容:尽管存在从 int -->
long 类型的隐式类型转换,但不符合一致性转换的要求。
说明:
虽然兼容委托看似泛型协变,但二者实际上是不同的特性。委托中的封装本质上是创建了一个新的实例,而不是将已有委托看作是不同类型的实例。
2.5 迭代器
2.5.1 处理 finally 块
using 语句是基于 finally 块实现的,二者在行为上具有一致性。
考虑上述代码的运行结果:当返回 first 时,会输出 “In finally block” 这句吗?有以下两种思考方式:
- 如果认为在执行到 yield return 语句时,执行就暂停了,逻辑上讲执行还停留在 try块中,那么就不会执行到 finally 块。
- 如果认为当执行到 yield return 时,代码实际上返回到了 MoveNext() 调用,感觉应该已经退出了块,那么就应该正常执行 finally 块的代码。
正确答案应该是第一个,这样的行为更加有效且符合我们的预期。执行下列代码并得到验证结果:
需要说明的是:
- 如果手动编写调用
IEnumerator<T>
的方法(for、while),且在遍历整个序列时中途停止,那么最终将不会执行 finally 块。 - 如果使用 foreach 循环,在序列全部迭代完成之前退出循环,那么将执行 finally 块。
下面的代码展示了第 2 种情况,加粗部分表示与上面代码不同之处。
最后一行结果说明:执行了 finally 块。当退出 foreach 循环时,finally 块将自动执行,因为 foreach 循环中隐含了一条 using 语句。上述代码等价如下:
using 语句是重点,它保证了不管采用何种方式离开循环,都会调用 IEnumerator<string>
的 Dispose 方法。在调用 Dispose 方法时,如果此时迭代器还暂停在 try 块中也没有关系,Dispose 方法会负责最终调用 finally 块。
2.5.2 处理 finally 块的重要性
虽然 finally 块的处理属于比较细枝末节的内容,但它对于迭代器的实用性而言意义重大。 这意味着迭代器可以用于那些需要释放资源的方法,比如文件处理器,它还意味着相同目的的迭代器可以链接起来使用。
说明:
1. foreach 环负责检查运行时实现是否实现了 IDisposable 接口,然后根据需要调用 Dispose 方法。
2. 泛型版的 IEnumerator<T> 扩展自 IDisposable 接口,但非泛型的 IEnumerator 接口并非扩展自 IDisposable 接口。
因此,如果是迭代泛型的 IEnumerable<T> ,如前所示使用 using 语句即可;而如果要迭代非泛型序列 IEnumerable,那就需要像编译器处理 foreach 那样自行检查接口了。
另外,如果是手动调用 MoveNext() 来进行迭代,也需要手动调用 Dispose 方法。
2.5.3 迭代器实现机制概览
来看一个迭代器方法示例代码,该代码包括以下 5 点精心设计:
- 一 个参数;
- 一个需要在 yield return 语句之间保留的局部变量;
- 一个不需要在 yield return 语句之间保留的局部变量;
- 两条 yield return 语句;
- 一个 finally 块。
虽然上述只是实现一个迭代器方法,但编译器背后会生成一个全新的类型来实现相关接口。下面展示经过调整的反编译代码,并具有如下特点:
- 能够大致体现代码的主体结构,而具体的实现细节被忽略。
- 实际编译器生成的变量名很复杂,不符合 C# 的命名规范,因此这里将变量名替换为了合法的 C# 标识符。
可以看到,编译器生成了一个状态机(私有的嵌套类 GeneratedClass)。下面介绍相关的方法:
GetEnumerator():
GetEnumerator() 方法负责检查状态机:
- 若状态机处于当前线程且为初始状态,则返回 this;
- 否则,状态机返回对应的参数。
因此,状态机需要同时实现 IEnumerable<int>
和 IEnumerator<int>
两个接口。
并且,如果 GetEnumerator() 被其他线程调用或多次被调用,这些调用会各自创建一个新的状态机实例,同时复制初始的参数值。
MoveNext():
MoveNext() 方法的大致结构如下:
以 Roslyn 编译器为例,每个状态值对应如下:
- -3:MoveNext() 当前正在执行;
- -2:GetEnumerator() 尚未被调用;
- -1:执行完成(无论成功与否);
- 0:GetEnumerator() 已被调用,但是 MoveNext() 还未被调用(方法的开始);
- 1:在第 1 条 yield return 语句;
- 2:在第 2 条 yield return 语句。
说明:
代码中的 fault 块是 IL 结构,在 C# 中没有对等形式。类似于 finally 块,在发生异常时会被执行,但并不捕获异常。
2.6 局部类
2.6.1 局部方法(C#3)
C#3 引入了局部类的一个扩展特性:局部方法默认是私有方法,返回值必须是 void 且不能使用 out 参数(可以使用 ref 参数)。局部方法。编写局部方法,可以在一个类型的局部声明中声明一个不包含方法体的方法,而在一个局部声明中定义该方法的实现(可选)。
在编译时,只会保留实现了的局部方法。这意味着如果局部方法只是声明而没有实现,那么:
- 编译器会移除该方法的所有调用代码。
- 如果局部方法具有参数并且被调用,那么调用中的实参表达式也不会被执行。
可以由生成器来负责生成可选的“钩子方法",之后可以手动为“钩子方法"添加额外的行为。下面的代码定义了两个局部方法,其中 CustomizeToString() 已实现,OnConstruction() 未实现。
强烈建议把代码生成器设计成可以生成局部类。
2.7 静态类(*)
2.8 属性 getter/setter 访问分离(*)
2.9 命名空间别名
2.9.1 命名空间别名限定符
C#1 已经支持了命名空间和命名空间别名这两个特性。当需要在同一源码文件中使用不同命名空间下的同名类型时,就可以清晰、准确地表示具体指代哪个类型。
C#2 引入了一种新的语法——命名空间别名限定符。使用一对冒号来表示冒号前的标识符是命名空间别名而不是类型名,从而避免歧义。使用新语法改写以上代码:
消除歧义不仅有助于编译器的识别工作,更重要的是区分了命名空间别名和类型名,增强了可读性。建议在使用命名空间别名时,统一使用双冒号语法。
2.9.2 全局命名空间别名
C#2 引入了 global 作为全局命名空间的一个别名。该别名除了可以指示全局命名空间中的类型,还可以用于类型完全限定名的一个“根” 命名空间。
例如在处理很多带 DateTime 参数的方法,向当前命名空间引入另外一个名为 DateTime 类型的时候,这些函数声明就无法正常工作了。相比为 System 命名空间起一个别名, 把每个System.DateTime 的位置都替换成 global: : System.DateTime 更简单一些。
2.9.3 外部别名
假设有不同的程序集,它们提供了相同的命名空间,而命名空间中左有相同的类型名,这要怎么处理呢?这属于罕见情况,但还是有可能出现。C#2 引入了外部别名来处理这种情况。在源码中声明外部别名时无须指定任何关联的命名空间: