文章目录
第二章 C# 2
2.1 泛型(*)
2.2 default 和 typeof(*)
2.3 可空值类型
2.3.1 Nullable<T>
结构体(framework 支持)
可空值类型特性的核心要素是 Nullable<T>
结构体,其早期版本如下所示:
![image-20240312135206445](https://i-blog.csdnimg.cn/blog_migrate/78bb6bd76e00c7737818de4ea438a485.png#pic_center)
当 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 异常。
![image-20240312144527886](https://i-blog.csdnimg.cn/blog_migrate/b0dd6016af9c46e960255d6f740e1bbb.png#pic_center)
![image-20240312144604230](https://i-blog.csdnimg.cn/blog_migrate/958c95be57d20e8b7502d6fd21e43f10.png#pic_center)
2.3.2 装箱(CLR 支持)
对比:非可空值的装箱
当非可空值类型被装箱时,返回结果的类型就是原始的装箱类型。
![image-20240312141701938](https://i-blog.csdnimg.cn/blog_migrate/0691463aaf1311e1f982c44c8cf1d325.png#pic_center)
o 是对“装箱 int”对象的引用。C# 中,“装箱 int”和 int 之间的区别通常是不可见的。即,o.GetType() 返回的 Type 会和 typeof(int) 的结果相同。
可空值的装箱
可空值的装箱结果视 hasValue 的值而定:
- hasValue 为 true,返回 null 引用;
- hasValue 为 false,返回“装箱 T”对象的引用。
![](https://i-blog.csdnimg.cn/blog_migrate/887a35d93bd80fd7d32ee64bfe01eebb.png#pic_center)
较为奇特的一点是,hasValue 为 false 的可空值装箱后,使用 GetType() 方法会引发 NullReferenceException 异常。
![image-20240312142328831](https://i-blog.csdnimg.cn/blog_migrate/ff81a7fcb45c7904c02e6a876fe396b9.png#pic_center)
![image-20240312144213625](https://i-blog.csdnimg.cn/blog_migrate/d1de2a99ed27fa2b6731ca63ffa76026.png#pic_center)
2.3.3 “?”后缀(语法支持)
Nullable<T>
的简化写法是在类型 T 后面添加 “?” 后缀,改写法对简版类型名(int、double 等)和全版类型名(int32 等)都适用。以下 4 个声明完全等价,它们产生的 IL 代码没有任何区别:
![image-20240312145059539](https://i-blog.csdnimg.cn/blog_migrate/03cf31432726f2989e5d2a9792ec0364.png#pic_center)
2.3.4 null 字面量(语法支持)
C#2 将 null 的含义进行扩展:
- 或者表示一个 null 引用;
- 或者表示一个 hasValue 为 false 的可空类型的值。
以下代码完全等价:
![image-20240312145307251](https://i-blog.csdnimg.cn/blog_migrate/a89dcbd5ae47abc89536eb7fdbc50b4b.png#pic_center)
![image-20240312145346507](https://i-blog.csdnimg.cn/blog_migrate/e6abf96441655fd5efad2c26629d7676.png#pic_center)
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 的可空值类型的重载版本,因此下面的代码会报错:
![image-20240312160120163](https://i-blog.csdnimg.cn/blog_migrate/502cd35dc522e722b1fcd34ed39e7bb6.png#pic_center)
![image-20240312160239194](https://i-blog.csdnimg.cn/blog_migrate/76cd2d6371bae4dc3aee8243ae437be6.png#pic_center)
重载 == 和 != 运算符后, Nullable<Test>
会自动提供对应的可空值类型的版本,以下代码可以正常运行且结果符合预期:
![image-20240312160457176](https://i-blog.csdnimg.cn/blog_migrate/ab1c67747f2ec3d4cabd24317e919079.png#pic_center)
![image-20240312160548116](https://i-blog.csdnimg.cn/blog_migrate/e1455442259ac82d1a3833cd04fdccb6.png#pic_center)
上述规则看上去较为复杂,但多数情况下,执行结果会与我们理解的预期相符。以 int 为例,表2.1 列出了 Nullable<int>
自动提供的提升运算符,以及相应的举例。
![image-20240312155615901](https://i-blog.csdnimg.cn/blog_migrate/1a77a83fbe926c402ee1b91a79a3f6fb.png#pic_center)
2.3.7 可空逻辑
[2.3.6 节](#2.3.6 提升运算符)提及,Nullable<bool>
的 & 和 | 运算符与其他类型的行为有所不同,因为输入值除了 true 和 false,还需要加上 null。表2.2 列出了 Nullable<bool>
的全部 4 个逻辑运算符的真值表。
![image-20240312162635478](https://i-blog.csdnimg.cn/blog_migrate/b38240429d9b6e8342d7db61f2955ec1.png#pic_center)
注意,&& 和 || 运算符不使用于 Nullable<bool>
类型。
说明:
上述所讨论的提升运算符、类型转换以及
Nullable<bool>
逻辑等特性都是由 C# 编译器提供的,其创建了所有 IL 代码来进行空值检查,并做出相应处理。而与 CLR 或 framework 本身无关。
2.3.8 as 运算符与可空值类型
在 C#2 之前,as 运算符只能用于引用类型;C#2 后,as 运算符也可以用于可空值类型。
当原始引用的类型为 null 或与目标类型不匹配时,返回 null 值;否则,返回一个有意义的值。
![image-20240312164251301](https://i-blog.csdnimg.cn/blog_migrate/dbd544ad88c47793efaff108ba0c25b8.png#pic_center)
说明:
对可空类型使用 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)必须完全一致。假设有如下委托声明和方法:
![image-20240312165646005](https://i-blog.csdnimg.cn/blog_migrate/f13ea76e89797c98913d00572922805a.png#pic_center)
C#1 不允许将 PrintAnything 赋值给 Printer 实例,但到了 C#2,这种方式被允许。因为传入 Printer 的参数为 string 类型,必定也是 object 类型的引用。
![image-20240312165912095](https://i-blog.csdnimg.cn/blog_migrate/20528c1bb9eafeac804a270dee6fdf1d.png#pic_center)
此外,还可以使用委托来创建另外一个委托,条件是二者的签名要兼容。
![image-20240312170026676](https://i-blog.csdnimg.cn/blog_migrate/271ca2333ca369164818982468723e03.png#pic_center)
![image-20240312170009662](https://i-blog.csdnimg.cn/blog_migrate/2b5ca68c5fe04e93e7495e3f019c2c07.png#pic_center)
同样,对于返回值类型也一样:
![image-20240312170117726](https://i-blog.csdnimg.cn/blog_migrate/e9be809faedc8ee716b8c250a219e94e.png#pic_center)
注意,有时上述规则并不能如我们所愿,参数或返回值之间的兼容性必须满足一致性转换规则,才能保证执行期间变量值不变。例如,下面的代码就不能通过编译:
![image-20240312170309954](https://i-blog.csdnimg.cn/blog_migrate/6f20e52065d76c98e90a6bf557192d7e.png#pic_center)
这是因为两个委托的签名不兼容:尽管存在从 int -->
long 类型的隐式类型转换,但不符合一致性转换的要求。
说明:
虽然兼容委托看似泛型协变,但二者实际上是不同的特性。委托中的封装本质上是创建了一个新的实例,而不是将已有委托看作是不同类型的实例。
2.5 迭代器
2.5.1 处理 finally 块
using 语句是基于 finally 块实现的,二者在行为上具有一致性。
![image-20240312175325706](https://i-blog.csdnimg.cn/blog_migrate/4496b9b913d71c8b46749709cf945d50.png#pic_center)
考虑上述代码的运行结果:当返回 first 时,会输出 “In finally block” 这句吗?有以下两种思考方式:
- 如果认为在执行到 yield return 语句时,执行就暂停了,逻辑上讲执行还停留在 try块中,那么就不会执行到 finally 块。
- 如果认为当执行到 yield return 时,代码实际上返回到了 MoveNext() 调用,感觉应该已经退出了块,那么就应该正常执行 finally 块的代码。
正确答案应该是第一个,这样的行为更加有效且符合我们的预期。执行下列代码并得到验证结果:
![image-20240312175628442](https://i-blog.csdnimg.cn/blog_migrate/4ede5bc89f49a37452fee48f9a7191b6.png#pic_center)
![image-20240312175647451](https://i-blog.csdnimg.cn/blog_migrate/84bdf9339f231d36b52853c7c8f404e0.png#pic_center)
需要说明的是:
- 如果手动编写调用
IEnumerator<T>
的方法(for、while),且在遍历整个序列时中途停止,那么最终将不会执行 finally 块。 - 如果使用 foreach 循环,在序列全部迭代完成之前退出循环,那么将执行 finally 块。
下面的代码展示了第 2 种情况,加粗部分表示与上面代码不同之处。
![image-20240312180123093](https://i-blog.csdnimg.cn/blog_migrate/65d1c94b5fe7f3ff6aa834f95f53df4e.png#pic_center)
![image-20240312180133120](https://i-blog.csdnimg.cn/blog_migrate/fd07d1fd61e72fa059ef08b99adc9fbc.png#pic_center)
最后一行结果说明:执行了 finally 块。当退出 foreach 循环时,finally 块将自动执行,因为 foreach 循环中隐含了一条 using 语句。上述代码等价如下:
![image-20240312180248295](https://i-blog.csdnimg.cn/blog_migrate/ee0539e66586a4b54198acb17376d3fa.png#pic_center)
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 方法。
![image-20240312181556305](https://i-blog.csdnimg.cn/blog_migrate/9717f9da0ae38b2614058e1da9cf097f.png#pic_center)
![image-20240312181621033](https://i-blog.csdnimg.cn/blog_migrate/fbed292016e5564b7d4add27669f1ff7.png#pic_center)
2.5.3 迭代器实现机制概览
来看一个迭代器方法示例代码,该代码包括以下 5 点精心设计:
- 一 个参数;
- 一个需要在 yield return 语句之间保留的局部变量;
- 一个不需要在 yield return 语句之间保留的局部变量;
- 两条 yield return 语句;
- 一个 finally 块。
![image-20240314203641572](https://i-blog.csdnimg.cn/blog_migrate/9d774333d0c511ce70f5eaf31ca512e0.png#pic_center)
![image-20240314203657841](https://i-blog.csdnimg.cn/blog_migrate/f10fb0614a937c50e2c88a5f36ca7b03.png#pic_center)
虽然上述只是实现一个迭代器方法,但编译器背后会生成一个全新的类型来实现相关接口。下面展示经过调整的反编译代码,并具有如下特点:
- 能够大致体现代码的主体结构,而具体的实现细节被忽略。
- 实际编译器生成的变量名很复杂,不符合 C# 的命名规范,因此这里将变量名替换为了合法的 C# 标识符。
![image-20240314204328747](https://i-blog.csdnimg.cn/blog_migrate/465c1aff10c30afac612199e4fa93281.png#pic_center)
![image-20240314204233452](https://i-blog.csdnimg.cn/blog_migrate/3e8f3f94b0dc3bafb18401de2562de68.png#pic_center)
可以看到,编译器生成了一个状态机(私有的嵌套类 GeneratedClass)。下面介绍相关的方法:
GetEnumerator():
GetEnumerator() 方法负责检查状态机:
- 若状态机处于当前线程且为初始状态,则返回 this;
- 否则,状态机返回对应的参数。
因此,状态机需要同时实现 IEnumerable<int>
和 IEnumerator<int>
两个接口。
并且,如果 GetEnumerator() 被其他线程调用或多次被调用,这些调用会各自创建一个新的状态机实例,同时复制初始的参数值。
MoveNext():
MoveNext() 方法的大致结构如下:
![image-20240314210138591](https://i-blog.csdnimg.cn/blog_migrate/d999ffec5a4ac4d158e81e92bc29c1a8.png#pic_center)
以 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() 未实现。
![image-20240314211135782](https://i-blog.csdnimg.cn/blog_migrate/fd68430fdd0ec3793b05eddfd6b7478d.png#pic_center)
![image-20240314211153450](https://i-blog.csdnimg.cn/blog_migrate/1e8991ca65b84a877b0fe98554b42d27.png#pic_center)
强烈建议把代码生成器设计成可以生成局部类。
2.7 静态类(*)
2.8 属性 getter/setter 访问分离(*)
2.9 命名空间别名
2.9.1 命名空间别名限定符
C#1 已经支持了命名空间和命名空间别名这两个特性。当需要在同一源码文件中使用不同命名空间下的同名类型时,就可以清晰、准确地表示具体指代哪个类型。
![image-20240314211826887](https://i-blog.csdnimg.cn/blog_migrate/48d800a62472707633316e77a96ac13a.png#pic_center)
C#2 引入了一种新的语法——命名空间别名限定符。使用一对冒号来表示冒号前的标识符是命名空间别名而不是类型名,从而避免歧义。使用新语法改写以上代码:
![image-20240314211856498](https://i-blog.csdnimg.cn/blog_migrate/c63e8cca0528abef4916207f4063e42e.png#pic_center)
消除歧义不仅有助于编译器的识别工作,更重要的是区分了命名空间别名和类型名,增强了可读性。建议在使用命名空间别名时,统一使用双冒号语法。
2.9.2 全局命名空间别名
C#2 引入了 global 作为全局命名空间的一个别名。该别名除了可以指示全局命名空间中的类型,还可以用于类型完全限定名的一个“根” 命名空间。
例如在处理很多带 DateTime 参数的方法,向当前命名空间引入另外一个名为 DateTime 类型的时候,这些函数声明就无法正常工作了。相比为 System 命名空间起一个别名, 把每个System.DateTime 的位置都替换成 global: : System.DateTime 更简单一些。
2.9.3 外部别名
假设有不同的程序集,它们提供了相同的命名空间,而命名空间中左有相同的类型名,这要怎么处理呢?这属于罕见情况,但还是有可能出现。C#2 引入了外部别名来处理这种情况。在源码中声明外部别名时无须指定任何关联的命名空间:
![image-20240314212211422](https://i-blog.csdnimg.cn/blog_migrate/64a47db514c27976c9acc831b04adaf3.png#pic_center)