0
点赞
收藏
分享

微信扫一扫

C#在企业级应用软件开发中的实践深度探讨

zmhc 03-18 09:31 阅读 5
c#算法

文章目录

第二章 C# 2

2.1 泛型(*)

2.2 default 和 typeof(*)

2.3 可空值类型

2.3.1 Nullable<T> 结构体(framework 支持)

​ 可空值类型特性的核心要素是 Nullable<T> 结构体,其早期版本如下所示:

image-20240312135206445

​ 当 hasValue 为 false 时,访问 value 的操作会引发异常。

​ 另外,Nullable<T> 结构体还提供了如下方法和运算符:

  1. GetValueOrDefault()

    返回结构体中的值,如果 hasValue 为 false,则返回默认值。

  2. GetValueOrDefault(T defalutValue)

    返回结构体中的值,如果 hasValue 为 false,则返回 defalutValue。

  3. 重写了 object 类的方法:Equals(object) / GetHashCode() / ToString()

    Equals:首先比较 hasValue,均为 true 时再比较 value 是否相等。

  4. 提供 T --> Nullable<T> 的隐式类型转换。

    该转换总是会返回对应的可空值,且 hasValue 为 true。

  5. 提供 Nullable<T> --> T 的显式类型转换。

    hasValue 为 true 时,返回 value 值;

    hasValue 为 false 时,抛出 InvalidOperationException 异常。

image-20240312144527886 image-20240312144604230
图2.1 Nullable<T> 结构体的声明
2.3.2 装箱(CLR 支持)

对比:非可空值的装箱

​ 当非可空值类型被装箱时,返回结果的类型就是原始的装箱类型。

image-20240312141701938

​ o 是对“装箱 int”对象的引用。C# 中,“装箱 int”和 int 之间的区别通常是不可见的。即,o.GetType() 返回的 Type 会和 typeof(int) 的结果相同。

可空值的装箱

​ 可空值的装箱结果视 hasValue 的值而定:

  • hasValue 为 true,返回 null 引用;
  • hasValue 为 false,返回“装箱 T”对象的引用。

​ 较为奇特的一点是,hasValue 为 false 的可空值装箱后,使用 GetType() 方法会引发 NullReferenceException 异常。

image-20240312142328831 image-20240312144213625
图2.2 hasValue 为 false 的可空值装箱后,使用 GetType() 方法会引发 NullReferenceException 异常
2.3.3 “?”后缀(语法支持)

Nullable<T> 的简化写法是在类型 T 后面添加 “?” 后缀,改写法对简版类型名(int、double 等)和全版类型名(int32 等)都适用。以下 4 个声明完全等价,它们产生的 IL 代码没有任何区别:

image-20240312145059539
2.3.4 null 字面量(语法支持)

​ C#2 将 null 的含义进行扩展:

  • 或者表示一个 null 引用;
  • 或者表示一个 hasValue 为 false 的可空类型的值。

​ 以下代码完全等价:

image-20240312145307251 image-20240312145346507
2.3.5 转换(语法支持)

​ 如果存在从 S --> T 的类型转换,则下列类型转换都是合法的:

  1. Nullable<S> --> Nullable<T> (依据 S --> T 而决定显式转换或隐式转换)。
  2. S --> Nullable<T>(同上)。
  3. Nullable<S> --> T 的显式类型转换。

​ 转换的工作原理:将 S 到 T 按照要求进行转换,有必要时填充 null 值。

​ 填充 null 值的扩展过程称为提升

2.3.6 提升运算符

​ C# 允许对以下运算符进行重载:

  • 一元运算符:+、++、-、–、!、~、true、false。
  • 二元运算符:+、-、*、/、%、&、^、<<、>>。
  • 等价运算符:==、!=。
  • 关系运算符:<、>、<=、>=。

​ 重载非可空类型 T 时,Nullable<T> 会提供对应运算符的自动重载版本,但是操作数类型和返回值类型会有所区别,我们将其称为提升运算符。提升运算符的具体规则如下:

  1. true 和 false 不能被提升(很少使用,因此影响不大)。

  2. 只有操作数是非可空值类型的运算符才能被提升。

  3. 对于一元运算符和二元运算符,原运算符的返回类型必须是非可空的值类型。

  4. 对于等价运算符和关系运算符,原运算符的返回类型必须是 bool 类型。

  5. 作用于 Nullable<bool> 的 & 和 | 运算符具有单独定义的行为(见 [2.3.7 节](#2.3.7 可空逻辑))。

​ 提升后的运算符中:

  • 操作数类型都变成对应的可空等价类型,返回类型(仅对于一元运算符和二元运算符)也变为可空等价类型。

  • 如果两个操作数均为非空,则执行方式与原运算符相同。

  • 否则:

    1. 对于一元运算符和二元运算符:

      • 如果任意一个操作数为 null,那么返回值也为 null。
    2. 对于等价运算符:

      • 两个 null 被视为相等。

      • 一个 null 和一个 非 null 被视为不相等。

    3. 对于关系运算符:

      • 任意一个操作数为 null 时,返回 false。

​ 例如,我们自定义 Test 值类型,并重载 true 和 false 运算符,但由于 Nullable<Test> 将不会提供 true 和 false 的可空值类型的重载版本,因此下面的代码会报错:

image-20240312160120163
image-20240312160239194
图2.3 true 和 false 运算符不能被提升

​ 重载 == 和 != 运算符后, Nullable<Test> 会自动提供对应的可空值类型的版本,以下代码可以正常运行且结果符合预期:

image-20240312160457176
image-20240312160548116
图2.4 == 和 == 能够被提升,且行为符合预期

​ 上述规则看上去较为复杂,但多数情况下,执行结果会与我们理解的预期相符。以 int 为例,表2.1 列出了 Nullable<int> 自动提供的提升运算符,以及相应的举例。

表2.1 向可空整数应用运算符提升的例子
image-20240312155615901
2.3.7 可空逻辑

​ [2.3.6 节](#2.3.6 提升运算符)提及,Nullable<bool> 的 & 和 | 运算符与其他类型的行为有所不同,因为输入值除了 true 和 false,还需要加上 null。表2.2 列出了 Nullable<bool> 的全部 4 个逻辑运算符的真值表。

表2.2 Nullable<bool> 运算符真值表
image-20240312162635478

​ 注意,&& 和 || 运算符不使用于 Nullable<bool> 类型。

2.3.8 as 运算符与可空值类型

​ 在 C#2 之前,as 运算符只能用于引用类型;C#2 后,as 运算符也可以用于可空值类型。

​ 当原始引用的类型为 null 或与目标类型不匹配时,返回 null 值;否则,返回一个有意义的值。

image-20240312164251301

​ 对目标结果是 Nullable<T> 类型的表达式而言,as 是很方便的运算符。且 C#7 对大部分可空值类型采用模式匹配(第 12 章),因此使用 as 运算符是更优的解决方案。

2.3.9 空合并运算符 ??

​ ?? 是一个二元运算符,first ?? second 表达式的计算分为以下几个步骤:

  1. 计算 first 表达式;
  2. 如果 first 不为空,则返回结果为 first;
  3. 如果 first 为空,则计算 second 表达式并返回。

2.4 简化委托的创建

2.4.1 委托的兼容性

​ 在 C#1 中创建委托实例时,创建实例的方法与委托的返回值类型和参数类型(包括 ref 和 out)必须完全一致。假设有如下委托声明和方法:

image-20240312165646005

​ C#1 不允许将 PrintAnything 赋值给 Printer 实例,但到了 C#2,这种方式被允许。因为传入 Printer 的参数为 string 类型,必定也是 object 类型的引用。

image-20240312165912095

​ 此外,还可以使用委托来创建另外一个委托,条件是二者的签名要兼容。

image-20240312170026676 image-20240312170009662

​ 同样,对于返回值类型也一样:

image-20240312170117726

​ 注意,有时上述规则并不能如我们所愿,参数或返回值之间的兼容性必须满足一致性转换规则,才能保证执行期间变量值不变。例如,下面的代码就不能通过编译:

image-20240312170309954

​ 这是因为两个委托的签名不兼容:尽管存在从 int --> long 类型的隐式类型转换,但不符合一致性转换的要求。

2.5 迭代器

2.5.1 处理 finally 块

​ using 语句是基于 finally 块实现的,二者在行为上具有一致性。

image-20240312175325706

​ 考虑上述代码的运行结果:当返回 first 时,会输出 “In finally block” 这句吗?有以下两种思考方式:

  1. 如果认为在执行到 yield return 语句时,执行就暂停了,逻辑上讲执行还停留在 try块中,那么就不会执行到 finally 块。
  2. 如果认为当执行到 yield return 时,代码实际上返回到了 MoveNext() 调用,感觉应该已经退出了块,那么就应该正常执行 finally 块的代码。

​ 正确答案应该是第一个,这样的行为更加有效且符合我们的预期。执行下列代码并得到验证结果:

image-20240312175628442 image-20240312175647451

​ 需要说明的是:

  1. 如果手动编写调用 IEnumerator<T> 的方法(for、while),且在遍历整个序列时中途停止,那么最终将不会执行 finally 块。
  2. 如果使用 foreach 循环,在序列全部迭代完成之前退出循环,那么将执行 finally 块。

​ 下面的代码展示了第 2 种情况,加粗部分表示与上面代码不同之处。

image-20240312180123093 image-20240312180133120

​ 最后一行结果说明:执行了 finally 块。当退出 foreach 循环时,finally 块将自动执行,因为 foreach 循环中隐含了一条 using 语句。上述代码等价如下:

image-20240312180248295

​ using 语句是重点,它保证了不管采用何种方式离开循环,都会调用 IEnumerator<string> 的 Dispose 方法。在调用 Dispose 方法时,如果此时迭代器还暂停在 try 块中也没有关系,Dispose 方法会负责最终调用 finally 块。

2.5.2 处理 finally 块的重要性

​ 虽然 finally 块的处理属于比较细枝末节的内容,但它对于迭代器的实用性而言意义重大。 这意味着迭代器可以用于那些需要释放资源的方法,比如文件处理器,它还意味着相同目的的迭代器可以链接起来使用。

image-20240312181556305
image-20240312181621033
图2.5 IEnumerator 和 IEnumerator<T> 的声明
2.5.3 迭代器实现机制概览

​ 来看一个迭代器方法示例代码,该代码包括以下 5 点精心设计:

  1. 一 个参数;
  2. 一个需要在 yield return 语句之间保留的局部变量;
  3. 一个不需要在 yield return 语句之间保留的局部变量;
  4. 两条 yield return 语句;
  5. 一个 finally 块。
image-20240314203641572 image-20240314203657841

​ 虽然上述只是实现一个迭代器方法,但编译器背后会生成一个全新的类型来实现相关接口。下面展示经过调整的反编译代码,并具有如下特点:

  1. 能够大致体现代码的主体结构,而具体的实现细节被忽略。
  2. 实际编译器生成的变量名很复杂,不符合 C# 的命名规范,因此这里将变量名替换为了合法的 C# 标识符。
image-20240314204328747 image-20240314204233452

​ 可以看到,编译器生成了一个状态机(私有的嵌套类 GeneratedClass)。下面介绍相关的方法:

GetEnumerator():

​ GetEnumerator() 方法负责检查状态机:

  1. 若状态机处于当前线程且为初始状态,则返回 this;
  2. 否则,状态机返回对应的参数。

​ 因此,状态机需要同时实现 IEnumerable<int>IEnumerator<int> 两个接口。

​ 并且,如果 GetEnumerator() 被其他线程调用或多次被调用,这些调用会各自创建一个新的状态机实例,同时复制初始的参数值。

MoveNext():

​ MoveNext() 方法的大致结构如下:

image-20240314210138591

​ 以 Roslyn 编译器为例,每个状态值对应如下:

  • -3:MoveNext() 当前正在执行;
  • -2:GetEnumerator() 尚未被调用;
  • -1:执行完成(无论成功与否);
  • 0:GetEnumerator() 已被调用,但是 MoveNext() 还未被调用(方法的开始);
  • 1:在第 1 条 yield return 语句;
  • 2:在第 2 条 yield return 语句。

2.6 局部类

2.6.1 局部方法(C#3)

​ C#3 引入了局部类的一个扩展特性:局部方法默认是私有方法,返回值必须是 void 且不能使用 out 参数(可以使用 ref 参数)。局部方法。编写局部方法,可以在一个类型的局部声明中声明一个不包含方法体的方法,而在一个局部声明中定义该方法的实现(可选)。

​ 在编译时,只会保留实现了的局部方法。这意味着如果局部方法只是声明而没有实现,那么:

  1. 编译器会移除该方法的所有调用代码。
  2. 如果局部方法具有参数并且被调用,那么调用中的实参表达式也不会被执行。

​ 可以由生成器来负责生成可选的“钩子方法",之后可以手动为“钩子方法"添加额外的行为。下面的代码定义了两个局部方法,其中 CustomizeToString() 已实现,OnConstruction() 未实现。

image-20240314211135782 image-20240314211153450

​ 强烈建议把代码生成器设计成可以生成局部类。

2.7 静态类(*)

2.8 属性 getter/setter 访问分离(*)

2.9 命名空间别名

2.9.1 命名空间别名限定符

​ C#1 已经支持了命名空间和命名空间别名这两个特性。当需要在同一源码文件中使用不同命名空间下的同名类型时,就可以清晰、准确地表示具体指代哪个类型。

image-20240314211826887

​ C#2 引入了一种新的语法——命名空间别名限定符。使用一对冒号来表示冒号前的标识符是命名空间别名而不是类型名,从而避免歧义。使用新语法改写以上代码:

image-20240314211856498

​ 消除歧义不仅有助于编译器的识别工作,更重要的是区分了命名空间别名和类型名,增强了可读性。建议在使用命名空间别名时,统一使用双冒号语法。

2.9.2 全局命名空间别名

​ C#2 引入了 global 作为全局命名空间的一个别名。该别名除了可以指示全局命名空间中的类型,还可以用于类型完全限定名的一个“根” 命名空间。

​ 例如在处理很多带 DateTime 参数的方法,向当前命名空间引入另外一个名为 DateTime 类型的时候,这些函数声明就无法正常工作了。相比为 System 命名空间起一个别名, 把每个System.DateTime 的位置都替换成 global: : System.DateTime 更简单一些。

2.9.3 外部别名

​ 假设有不同的程序集,它们提供了相同的命名空间,而命名空间中左有相同的类型名,这要怎么处理呢?这属于罕见情况,但还是有可能出现。C#2 引入了外部别名来处理这种情况。在源码中声明外部别名时无须指定任何关联的命名空间:

image-20240314212211422

2.10 编译指令(*)

2.11 固定大小的缓冲区(*)

2.12 InternalsVisibleTo(*)

举报

相关推荐

0 条评论