欢迎光临散文网 会员登陆 & 注册

第 51 讲:类型良构规范(一):类型和成员的实现规范

2021-07-27 07:21 作者:SunnieShine  | 我要投稿

之前我们讲完了基本的数据类型的类别(类、结构、接口、委托、枚举),C# 目前只有这些数据类型类别。到以后学到新的语法的时候,C# 会更新拓展语法,以后就还会有更多的数据类型的类别,比如 C# 9 的记录类(Record Class)类型和 C# 10 的记录结构(Record Struct)类型。目前我们先考虑简单的、学过的数据类型类别,针对它们给大家讲解一下,如何实现一个数据类型,才是一个良好的、正确的、干净的实现模式、方式。

本节内容从类型的角度出发,给大家讲解一些危险的实现模式,以及不正确的实现模式,好让大家明白面向对象到底对于 C# 有什么样的地位、作用都有哪些。“对类型有良好的实现”在英语里称为 well-formed implementation,这个词组不是很好翻译。参照《C# 本质论》一书的介绍,这个词组将被翻译成“良构的实现”,因此,本节内容使用良构(Well-formed)作为内容给大家介绍,其中“良构”我们将其当作术语来理解,后面就不再说明这一点了。

Part 1 类型和命名空间的原则

1-1 类声明上尽量追加 sealed 修饰符

在类的声明上,我们会看到类的修饰符关键字的一些基本标记。不过大多数人都会在自己写程序的过程简单地写成 public class 或者干脆就直接写 class 了,比如这样:

这种实现不能说危险,但相当不建议这么使用。这是因为程序员可能会逮着一些完全没有意义的类型往下派生别的类型。这是非常奇怪的。

举个例子。像是上面这个叫做 Program 类型的东西,它的出现仅仅是为了提供 Main 方法的存储,它根本不拿来干别的事情。如果你对这个 Program 类型派生,显然就没有任何意义。所以,我们尽量建议你在上面类型上加上 sealed 关键字来表示类型不再派生出别的子类型,防止别人误用和乱用。

当然,如果这个类型要拿去派生的话,你肯定就得去掉 sealed 了。这不是显然的嘛。而且,其它类别的数据类型也不支持派生对象,那肯定你就不能使用 : 类型 的语法了,自然就不可能有继承关系,也自然就不存在 sealed 一说。但请注意,接口专门用于派生给类和接口,所以接口是不能有 sealed 修饰符的,因为接口都密封了,就意味着它无法派生别的类型出来;但接口的作用就是约束实现派生类型用的,显然就矛盾了,这样做没有意义。

1-2 类型名一定不要和库提供的类型名冲突

还记得命名空间这么一回事吗?这个东西我们很早就说过了,但是在前面内容讲解的时候,完全没用上这些东西,因为我说过命名空间不是让人背的,这些是用的时候去查资料去搜,用多了就记住了。而且我们之前的内容基本上只用到了 System 这一个命名空间里的类型,所以基本上也就只需要追加一句 using System; 即可。

可是问题就在于,我们时刻在不经意之间就会用上 System 命名空间里的类型。可问题就在于,如果你把你自己定义的类名取名成和这些类型名一样的类型。这样虽然语法上是同意你这么做的,但是这么做会使得命名取名很混乱。就比如说吧,我自己写了一个 String 类型的东西。但是 System 命名空间里包含了 String 类型(基本类型,虽然平时也经常用小写 string 关键字表达这个类型,但是这个类型是客观存在的),这样名字就冲突了。如果你要引用和区别开两个类型,你还得自己手动加上命名空间,比如

  • 系统类型:System.String

  • 自己写的类型:TestProject.String

显然这么做就没有必要。实际上 C# 为了规避命名空间里的类型重名,是有很多手段的,比如“写全命名空间”;甚至还有 extern alias 指令还可以指定同命名空间但引用的 dll 文件不一样的同名类型。当然这些是我们没有必要接触到的东西,这些自己查资料就可以自己学的,没有必要刻意去学它们,它们也不是特别重要。我在这里说这么多只是想告诉你 C# 很多语法是为了避免你“无法做到这一点”才产生的语法,你可能平时完全用不上它们,但在一些极端情况下,它们就会起到非常棒的作用。

但是,重名会引用全名导致书写代码极其不方便不说,还影响可读性,所以不建议这么做。要么,你的类型换一个名字,要么,嵌套到别的类型下,总之避免这个类型可以直接调用和引用。

1-3 尽量使用最简类型名

命名空间有这么一个用法:如果你不想写 using 指令的时候,类型名是可以把它的命名空间写在前面,然后跟上 . 表示出来的。举个例子,我们经常用 Console 类型输出内容。

这么写需要写 using System; 这么一句话。C# 允许我们这么写:

这样我们就不用写 using 指令了。不过这样的话,类型前面要跟上命名空间,这样会长一点。但是,我们为了追求简化代码、增加可读性的约定,因此我们并不建议时而使用全名写法,时而又使用没有前面命名空间的写法。这样代码风格会很乱。因此我们建议统一导入命名空间(即使用 using 指令),并使用类型名称的方式来书写代码。

1-4 尽量不使用嵌套类型

说起来,上面这个部分说了,嵌套类型就得提一下了。很多小伙伴很喜欢嵌套类型,它可以允许我们在某一个数据类型的里面再创建一个嵌套的数据类型。可问题在于,这样的嵌套会使得你外部引用这个类型的写法会很麻烦:先是外部类型名称,然后才是内部的这个类型名。

比如在 System 命名空间下有一个 Environment.SpecialFolder 枚举类型。它是一个嵌套数据类型,你需要先写它的所在类型名 Environment,然后才是嵌套的枚举类型名 SpecialFolder。这种实现机制实际上是不推崇的,因为你必须要这么写代码:

就会出现 Environment.SpecialFolder 这样的类型名称引用的写法。为了规避这一点,我们建议把嵌套类型从所在类型里拿出来。但是,库内的代码是我们无法动的,你就只能老老实实使用 Environment.SpecialFolder 这个写法,这也是极少存在的、我们需要直接使用和引用的库里的嵌套类型之一。

当然,不建议使用嵌套类型不等于一定就不能用。在部分情况下,嵌套类型是一种方便的数据实现机制,它可以实现基本和 C++ 里 friend 关键字差不多的效果。如果 C# 没有嵌套类型机制的话,很多功能都很难书写起来,或者访问修饰符级别会提升导致程序员误用。

1-5 不要嵌套命名空间

C# 允许我们嵌套命名空间,比如这样:

这样的写法是没有问题的,它和单层的、小数点引用的命名空间写法完全等价:

正是因为一层的结构,代码看起来很清爽,所以我们不建议嵌套命名空间。

1-6 不要使用和系统命名空间冲突的 System 重名命名空间

既然类型名称不建议和库里的类型重名,那么命名空间自然也是一样。显然你不能定义叫 System 的重名的命名空间作为你自己的项目的命名空间,除非你里面书写的全部代码都能完美避开 System 里包含的库里自带的数据类型重名的问题。

1-7 接口里尽量不要带有 object 里的那些方法

思考一个问题。object 里是不是包含 EqualsGetHashCodeToString 三个可重写的 virtual 方法?这些方法可以提供给底层重写以改变实现机制。如果 object 的这些方法被设计成 abstract 而不是 virtual 方法的话,每次你新建一个类型,都得去实现这三个方法。有些时候我们完全没有必要实现,就可以不管它们,所以大可不必搞成 abstract

那么到头来,如果我们试着把接口里带的成员也带上 object 里的这些方法会如何呢?比如这样:

结果是,没有意义。你仔细思考一下。object 里是自带这些方法的实现的,这就意味着,不管你实不实现接口,接口里的这个方法都是客观上“已经被实现了”的状态。那么,接口就没有起到接口的约束效果和作用。

正是因为如此,我们不建议让用户将 object 里的那套虚方法带入到接口里作为接口约束的成员的一部分。这是不合适的。

Part 2 构造器

类型和命名空间的基本用法就介绍完了。下面我们来说一下构造器的实现和使用规范。

2-1 为不需要初始化的非抽象类定义一个 private 无参构造器

倘若我们有一个数据类型,它可以实例化的话,我们就要思考会不会被误用。这是编程的开发人员必须考虑的问题,正是因为如此程序员的思维才大多是发散类型的

假设这个数据类型不是 abstract 修饰的类型的话,那么它可以实例化。但问题就在于,这个类型如果里面全是静态的方法,会怎么样呢?实际上,有一个类型 System.Math,它里面的成员就全部都是 static 修饰过的方法。这个类型专门用于提供一些数学上的处理过程,比如求正弦值之类的。

像是 Math 这样的数据类型,我们称为工具类(Tool Class)或者帮助类(Helper Class)。工具类一般不用来提供实例化的行为,因为它们里面的东西往往是“即取即用”的。比如求正弦值这个方法 Math.Sin。我们总不能非得去实例化一个 Math 类型,然后才开始使用 instance.Sin 这样的写法吧,显然就不够方便。既然 C# 里有静态方法,为什么我们要实例化一个 Math 这样的东西出来呢?所以,里面提供的这些“工具操作”都是不必实例化就可以直接用的方便的方法集。

正是有这样的数据类型,才能造就我们接下来要讲的内容。对于这样的类型,我们作为程序员给别人使用 Math 类型之前就会考虑到对方可能会实例化的误用行为。那么,我们就需要在这个类型里面,加上一个防止外部访问的无参构造器,修饰符用 private,比如这样:

别看这一行代码好像屁用没有,但是这防止了别人外来使用 Math 实例化的操作。因为类型用 sealed 修饰后方法是密封状态的,即无法继续派生。但问题是,这样的机制完全无法阻止实例化。要知道 C# 有一个默认的行为是:如果一个类类型里没有任何的构造器,系统会自动生成一个 public 的无参构造器。正是因为这个默认机制的存在,如果我们不对这个类定义一个私有的无参构造器的话,外部就会因为自动生成了 public 的无参构造器而导致误用和实例化 Math 类型。

加上无参构造器并追加 private 修饰符后,这样就阻止了编译器自动生成无参构造器的行为,而且恰好构造器是私有的,外部也无法访问到,因此整个类型就不会有实例化的误用行为了。

顺带一提。构造器是类型里的成员类别之一。正因为如此,所有类别的成员默认自带的访问修饰符都是 private。所以其实……这个 private Math { }private 也可以不写:

这样也可以 OK 的。我们只是广泛建议各位写代码的时候,追加访问修饰符(即使是默认的级别也写上)。

另外,你可能会说,我干脆把类型定义成抽象的不就行了?抽象类又不影响这些静态方法的直接调用。加一个 abstract 修饰符不比加 private 的无参构造器香?这个取决于你自己的书写代码的风格,其实用抽象类型也可以的,不过上面这样的实现机制是一种固定的实现模式,所以你可以这么参考着用,但没有说你必须要这么用。毕竟,抽象类确实有一个作用是防止实例化,但它还有一个真正意义上的作用,是给派生出来的子类型提供公用的数据的提取。

2-2 为抽象类提供子类实例化的基本构造器实现

实际上,“抽象类里的构造器”一点在之前的内容里完全没有提到。这是因为它的机制有点别扭。因为抽象类不能实例化,但抽象类的构造器又是编译器允许定义的,因此会让初学者一脸懵逼。这一点我就放在这里来介绍了。

首先,抽象类是用来给子类继承用的、数据提取的、仅用于提供数据的数据类型。这种数据类型的作用是给子类型提供使用和继承的服务。就好像为什么有 object 一样,因为有些时候我们无法做到明确的类型的使用,或是反复重复相同的代码,但就只是换一下数据的数据类型这样的“重复操作”。有了 object,我们可以把所有的数据类型直接转换回 object(也就是多态),这样可以让这个方法通用和普遍化,代码就不必写多份逻辑都是一毛一样的过程了。

抽象类就是这么一种存在。为了给子类型提供继承的服务,我们提取出一样的成员,这样子类型就通过继承语法完全拷贝这些成员,而不必自己再手写一遍。

抽象类型是无法实例化的,这我们已经说过很多次了。这也是抽象类本身的机制。可是,抽象类可以包含构造器,这你知道吗?

请注意里面的构造器。因为类型无法实例化,那么构造器显然就不可能通过语法 new Person(...) 的方式调用到它。那么,构造器都无法调用,为何又要写上这个构造器呢?

这是因为构造器是给子类派生服务的。构造器有两大作用:

  1. 提供实例化语法(非 abstract 修饰的类,以及非接口的其它数据类型类别);

  2. 提供给子类的 : base(参数) 的构造器调用链的语法使用。

第二点是最容易忽略的。因为初学很容易忘掉有这么一个机制。实际上,子类型在调用构造器的时候,可以直接使用 : this(参数) 调用同类型的别的构造器,以及使用 : base(参数) 调用基类的构造器。当然了,只要这个构造器可以被子类型访问到。如果你写 private,由于访问修饰级别的问题,子类型仍无法看到这个构造器。

那么,如果我们派生出 Teacher 类型的话,原本写多行的语句就只需要写一个 : base(参数) 就可以完成赋值了:

这就是为什么这个机制得以存在的原因——方便。

2-3 为子类型提供调用的基类型构造器使用 protected 修饰符

既然抽象类的构造器没办法实例化,那么它只可能用于子类型的调用。那么问题来了。既然只能给子类型调用,那么给构造器设置 public 或者 internal 访问修饰符,和直接设置 protected 有区别吗?

实际上是没有的。因为即使你设置了 publicinternal,对于别的类型,这个构造器也完全没有任何用途,毕竟你调用不了。它的唯一用途是为了子类型使用 : base(参数) 语法,所以“给子类调用”这一点就够了。因此,使用 protected 修饰符是最合适、最恰当的访问修饰符标记。

你懂我意思吧。

同样地,就算不是抽象类,那么这个构造器既然只用来子类型的派生调用的话,那么构造器还是 protected 比较好。

Part 3 字段

3-1 尽量让字段只读

这一点我就不多说明了。为了尽量细化规则规范,我们必然需要遵守这一点。只要字段后续不让改的话,我们尽量就用上 readonly 修饰符,这样可以保证字段的数值发生变动和更改。

除非,这个字段的配套属性有 set 方法(赋值器),在实例化后仍可改变它的数值。

3-2 尽量保证结构不可变

这一点我们之前说过了。还是那句话,“Mutable structs are evil”。之所以放在这里,是因为结构不可变的一个必要条件是字段(数据成员)全都是 readonly 修饰过的。

3-3 建议只读量使用 conststatic readonly 修饰

和 3-1 提及的效果类似。如果整个数据类型里需要用到一些数据完全相同的、以后不会发生变动的数据的话,就尽量用 static readonlyconst 修饰。static 表示对象不用实例化就可以使用;readonly 则是保证数据不可变。那么组合起来就是不用实例化的不变数据。如果你不加 static 的话,每次都得等到实例化了之后这个数据才能使用,显然这样是不正确的实现,也是麻烦的实现。所以能 static readonly 就不要只 readonly

另外,如果这个数据的数据类型是内置的、非 object 的数据类型的话,请使用 const 修饰。conststatic readonly 的区别是,const 数据编译器期间就可以解析和分析出结果,但 static readonly 因为复杂程度的关系,编译期间是无法或者说不一定能确定数据的确切数值的。正是因为这个特性的关系,const 有一个特别棒的效果,就是编译期替换。如果你用到了非常常用的、但数据可通用的地方的话,你肯定会优先考虑把这个数据提到字段级别存储。如果要想把数据类型推广,只需要变动这个字段的数值,所有引用了这个字段的地方都可以发生自动的变动,这不比你手写字面量要香?所以,conststatic readonly 又要“优越”一点。但是,const 能修饰的数据类型只有除了 object 以外的所有内置类型(整数啊、浮点数啊、字符和字符串啊、布尔类型和枚举类型之类的)而已。所以条件相当苛刻。

说这么多是让你知道,优先考虑 const 修饰只读数据,如果无法 const 修饰的话,就用 static readonly;实在是不能的话,最后再考虑 readonly,是这么一个顺序。当然,如果数据可变,就不属于本部分说的范畴了。

是的,没错。枚举类型的所有字段也都是常量。这是因为它们是以特征值来作为计算和使用的,只是编译之前可以通过枚举类型的机制“用取了名的信息来表达一些有意义的数据”。但是它的特征值都是以整数类型表达的,因此它们肯定也都是常量。所以如果是枚举类型的对象作为字段出现,也请使用 const 修饰,比如 const Gender DefaultGender = Gender.Male;

3-4 静态非只读字段不要使用 public 修饰

这一点我不必多说了吧。虽然静态类型的字段跟实例没有关系了,但是它跟类本身的处理机制有关。这样的字段一般只用于处理数据,很少有别的用途。正是因为如此,用于内部处理就不要公开暴露它。因此,不要出现 public 修饰的静态非只读字段。

Part 4 属性

属性也是重要的数据处理成员。它是方法的替代,也是封装字段的好伙伴。不过它的用法也很多,所以也有规范。

4-1 尽量不要在结构里出现 set 方法(赋值器)

这显然是一句废话。之前就说过不要使用可变结构,所以也就顺带约定了不要给属性添加后续更改字段的赋值器了。

4-2 尽量用只读属性表示一些高阶的数据成员

思考一个问题。假设我有一个类型表达了一个学生的学习成绩(假设存储的是“语文”、“数学”和“外语”三个科目的成绩)。如果我想快速得到这个人学习平均成绩的话,显然我们可以使用方法 GetAverageScore 来计算。不过,我们有一种更优的实现模式,就是使用只读属性这个特性。

我们经常使用属性,为其添加 getset 方法(取值器和赋值器)来为底层字段赋值。殊不知实际上这个属性本身的机制也不一定非要和单一的字段本身进行绑定。属性是可以单独使用的,它可以用来计算一些数据得到结果,比如这里的平均数。平均数是三个科目的成绩的总和再除以 3 得到的结果。这个公式也不复杂,也不必特意去用方法表示。而从面向对象层面来说,“平均成绩”本身也可以作为一个人的基本数据来表达,因此它完全可以用一个属性来表示。

可是,它这样的话是没有 set 方法(赋值器)的作用的,因为平均分难道你还想从外界赋值吗?既然有了三个科目的成绩了,那么就可以直接计算得到,何必我们单独给个赋值器往里面赋值呢?而且就算有了赋值器,也没有多大的意义,因为这个属性本身也不是跟一个单一的字段绑定起来使用的,那么我们加赋值器又“赋”往何方呢?因此,这样的属性是不需要赋值器的。

之前我们接触过一些赋值器只有 get 方法(取值器)而没有 set 方法(赋值器)的情况,以表达一些只读字段的读取。这样的属性称为只读属性(Read-only Property)。只要是只读属性,那么就没有取值器。没有取值器就意味着你的代码会长这样:

或者用底层的字段来取值:

很显然这样的实现是有道理的。我们坚信属性比方法成员更优秀,所以能用属性表达的话,就不要用方法。在以后的 C# 的新语法特性里,属性也会被广泛使用到。所以何乐而不为呢?

4-3 尽量不要使用只写属性

在 C# 里实际上也存在只写属性(Write-only Property),即只有 set 方法(赋值器)但没有 get 方法(取值器)的属性。但实际上只写属性用的机会并不多,而且“为什么我偏要往里面写东西,却又不需要读取它呢?”,是吧,你肯定会有这种困惑,所以只写属性用得并不多。只写属性一般只会出现在 C# 使用的奇技淫巧里,正常我们是用不到它们的。

只写属性在初学的时候很容易用不好,虽然它在语法是没有防止的,它也可以实现一些奇技淫巧,但是在正常实现和设计数据类型的时候,只写属性一般是没有意义的,所以不要写出只写属性。

非常不建议这么做。它的存在的唯一使用场合是在不需要通过属性取值的同时,用来触发事件。“使用属性触发事件”将在稍后提到。

4-4 尽量不要把底层集合类型的字段用属性暴露出来

这个标题是什么意思呢?后续的内容我们会接触到一个概念,叫做集合(Collection)。集合指的是“从逻辑上表达一系列数据的数据类型”,比如我们目前已经学到的数组。数组就是一个集合。

这个标题的意思是,请不要把数组这样的类型用属性的取值器返回出来。这个原因其实很简单:因为很有可能你的这个集合类型属性是配套了一个底层的字段的。这个字段也就是集合类型的。比如说我有一个存储语数外三科成绩的三元数组 int[] _scores 作为底层的字段。如果我们写了这样的代码:

这样就完完全全把底层的字段给返回了。还记得之前说数组的概念的知识点吗?数组是引用传递,这也就意味着不论你是通过赋值给别的同类型数组对象,还是直接更改这个数组对象的属性,都会改掉底层的这个字段里的信息,因为它是引用传递。

比如这样的代码:

因为是引用传递,因此你如果对赋值后的 scores 变量更改数据,也会改掉原来 student 变量里的底层字段里的第 2 个元素数值。这一点相当隐蔽,但也请你注意,不要这么使用。这么做很危险。这就是为什么标题不让你将数组这样的集合成员直接通过赋值器返回的原因。

当然了,如果你这个返回的类型是通过计算得到的集合的话,那么返回它就没有关系了。比如我有语数外三科成绩的字段,但分别是分开存储的,那么我通过数组来整合和返回它们就没有关系了,因为就算你在之后更改了它,但是是因为组合的关系,底层的三个数值又都是值类型的关系(复制副本),就完全不会产生任何问题。

比如这样。

4-5 不要使用 public 类型的字段

看起来好像这个跟字段有关,应该放在“字段”一节里,但实际上这个内容是和属性息息相关的。字段的封装靠属性来实现,正是因为如此,我们必须使用属性来表达。

字段有一个好处是存储数据信息,但如果你给出 public 类型的字段的话,用户就可能通过神操作改变你这个字段的结果。如果数据不合法,更改期间因为是直接通过赋值表达式来赋值的,所以不会有任何判定,因此可能导致后期程序出现问题。

为了避免这一点,我们必须使用属性封装来解决。将字段用 private 修饰,而配套的属性才用 public,并在 set 方法(赋值器)里书写赋值和数据校验的操作,这样可以达到严格赋值并防止滥用访问的问题。所以,尽量不要公开任何字段,就算是这个字段好像怎么改也没事。写属性是一种习惯,养成好习惯人人有责。

4-6 一般不建议使用静态属性,除非有特殊情况

C# 甚至允许你对属性使用 static 修饰符来表示一个属性是静态的。但是一般来说,属性是用来表达底层字段的封装,但底层的字段要么本来就是静态只读的,所以静态属性完全没有存在的意义。当然,它在一些极端情况下是有用的,但正常情况是遇不上这样的情况。所以我们不建议使用静态属性。

Part 5 索引器

5-1 索引器的参数尽量不要使用复杂的数据类型

索引器的参数起到索引的功能。正是因为如此,这个成员才能叫做索引器。索引器的参数应该尽量简单才能具有更好更强的可读性。假设我传入了一个 Student 类型的成员进去当参数,那么索引器又起到什么作用了呢?索引器肯定得有一个索引器的样子,别的数据类型显然就会降低可读性。

显然这样就很没有意义。

这里的“复杂的数据类型”的“复杂”有一点靠语感才能理解。因为我们并不能用一个严格的定义来说明什么数据类型叫做“复杂”。一般内置数据类型(比如字符、整数等等)都属于“不复杂”的类型,但字符串虽然复杂,但因为它常用,所以也不称为“复杂”的数据类型。这个靠语感来理解。

5-2 索引器也不建议返回数组类型的成员

这个我就不多解释了。这样容易破坏封装,和返回集合类型的属性一样的道理。

5-3 不建议使用只写索引器

这一点和只写属性完全一样的道理,因此我们也不建议使用只写索引器。

Part 6 事件

6-1 事件底层的字段一定做 null 检查

触发语句一般需要如下两个步骤完成:

  • 判断委托字段是不是为 null

  • 当不为 null 的时候才能使用 .Invoke 以触发事件。

注意,事件本身是不具备触发事件的行为的。这一点相当绕。事件成员仅仅起到的作用是封装委托字段,但自身是逻辑上的事件,而并非语法上的事件。也就是说,这个成员只是一个封装机制,而因为它能体现出的效果是事件应该有的效果,因此只能称为是逻辑上的事件。事件成员本身只能使用 +=-= 运算符来控制事件的增删回调函数的过程,本身不能 .Invoke 来触发,因此事件成员本身是不能触发事件的。触发事件还得靠的是底层字段。

因此,综合上面的写法,触发事件的标准写法是这样的:

如果对象为 null 就在触发的话,就会必然引发 NullReferenceException 异常。要知道,任何时候声明的字段在初始情况都是 null(除非有后面直接赋值)。对于委托字段来说,我们目前也没有办法让它在实例化的时候就不可能为 null。正是因为如此我们才需要去检测它是不是 null

6-2 底层的委托字段仅建议使用 protectedprivate 修饰

委托字段仅用来触发事件,而增加和删除回调函数的过程已经在封装的事件成员里完成了,因此它只有触发行为。但是,按照一般的执行过程,正常的事件触发要求是这个类型内部触发。显然,你从一个完全跟这个类型无关的外部触发内部的事件,就显得相当奇怪。不仅奇怪,而且也破坏了封装。因为既然你都能从外部触发,就一定要让委托字段公开化。哪怕是 internal,外部也可以访问了。既然字段都能直接访问了,那么何必还封装一个事件调用呢?事件的作用只有增删回调函数,这点操作委托字段就能做了,对吧。

所以,这样是相当奇怪的。所以相当不建议暴露底层的委托字段。和普通的字段还不一样,普通的字段你有可能需要使用 internal 修饰,但触发事件这样敏感的行为,还是不要从外部访问,哪怕 internal 都不行。因此,唯二建议的访问修饰符是 private 或者 protectedprotected internal 也都不建议。

6-3 一般不使用静态事件

没想到吧。C# 的灵活程度竟然出乎意料。事件也是可以使用静态的,即静态事件。既然普通的事件都对应了一个实例的委托类型字段,那么静态事件自然就对应了静态的字段,比如

不过,静态事件有什么用呢?试想一下静态字段的用途。静态字段是故意为了避免实例化才会用的一种数据存储机制。如果一个对象要想操作,但又不想按实例化后才能操作的行为来获取的话,那么我们就会使用静态字段。这样的字段往往存储了一些“跟某个实例无关的数据”。

那么,静态事件用在哪里呢?回忆一下 Console 这个类型。Console 类型里的所有方法都是跟着类名,然后小数点后才是方法名称。那么显然它们都是一些静态方法。这也就意味着这个 Console 类型代表的并不是一个单一的实例,而是对于整个系统里的命令提示符这样的东西而通用的一种表现机制。对于这样的类型,使用静态事件就比较合适。比如说我清空了命令符的内容,由于这个类型存储的都是一些静态的处理过程,所以它压根没有绑定一个单一的实例对象,因此使用普通的事件是无法完成工作的,毕竟普通的事件必须绑定一个实例才能得以执行。那么,要想奏效的话,为了传递一些处理事件的数据信息,我们只能使用静态事件来处理它们。仅此一种情况,才会用到静态事件。在一般情况下,我们都是用不到的,所以一般不会建议你使用静态事件。

另外,静态事件有一个相当麻烦的隐蔽 bug,就是内存泄漏的问题。如果一个对象已经被销毁(内存被消除了),但静态事件绑定的是静态的字段,由于静态数据存储的机制关系,它们不会消失。此时就可以导致内存泄漏的问题。

这一点我们现在还解决不了。这一点需要在后面介绍了 IDisposable 接口的使用和手动进行内存释放里才能讲到。

6-4 建议为事件封装起来的委托字段名添加 EventHandler 后缀

为了区分字段和别的字段用途不一样,委托类型的字段请使用“EventHandler”作为字段名的后缀。比如说 _testEventHandler 之类的。

顺带一提,因为字段用上了“EventHandler”后缀了,那么原始的委托类型本身名字也加上“EventHandler”吧,这样是为了和别的固有的委托类型作区分,表示这个委托类型是和事件绑定使用的。

6-5 为事件的触发的回调函数的参数追加事件处理信息数据

当数据在交互的时候,我们肯定期望事件触发的同时带点什么数据参与执行,这样我们就可以获取此时的信息,也便于我们调试代码知道里面的东西到底都是什么。我还是使用之前讲解的增加元素就触发事件的那个数据类型来给大家说明这一点。

我们暂时删去一些对本节内容讲解没有意义的代码。

首先我们可以看到,此时 Checker 这个委托类型是无参无返回值的数据类型。没有参数也没有返回值的数据类型对于我们帮助并不大,它能触发事件是真的,但是我们在调试的时候,假设出 bug 了,我们也不知道此时的数据信息,怎么办呢?这个时候我们会考虑添加量参数,一个参数表示触发事件的对象到底是谁,另外一个参数则表示触发事件的时候,一些基本的数据的信息都是什么值。

显然,Checker 这个委托类型的触发是和 _count 字段(或者 Count 属性)绑定起来的。那么,我们有必要改良一下 Checker 委托类型,并为其增加两个参数:

请注意这两个参数 sendere。其中的 sender 表示是谁(是什么实例)触发了此事件;另外一个参数 e 则是一个存储基本数据信息的事件数据类型的对象。此时我们还不知道 CheckingEventArgs 这个类型是怎么实现的,对吧。那么我们来给大家看一下,这个类型究竟是怎么实现的。

是的,其实也不复杂,短短 10 行代码,其中还有三个空行,外带一个 using 指令和一对大括号占的两行。真正有用的只有四行代码,对吧。

这个 CheckingEventArgs 类型从 EventArgs 类型派生。这里的 EventArgs 是存储在 System 命名空间里的一个类,它是一个可实例化的非抽象类类型。另外,里面只包含了一个 int 类型的所谓的 _currentCountValue 字段。这个字段其实就对应了我们刚才那个特别长的 List 类型里的 _count 的数值。

因为委托类型我们更改了写法,所以调用触发事件的地方也得更改调用了,也就是 AddElement 方法:

是的,我们仅更改了第 6 行代码。因为调用的委托字段此时发生了变更,所以参数也得配套加上。第一个参数我们传入的是 this。这个用法其实很奇妙,它不算奇技淫巧,经常使用到,但我们入门的时候,确实很少接触到。传入 this 是一种将这个实例对象本身当参数传入到方法里执行的过程。有些时候是需要这么做的,比如下面这个时候:

可以看到,前面的 Equals 是实例方法,后面的 Equals 则是静态方法。要比较两个对象内部的数值是不是一样的话,我们肯定会优先思考到一点:既然有静态的方法,那么实例版本的方法干脆直接调用这个静态方法就好了啊,为什么又要单独写一遍实现呢?

但是问题来了,实现不好写,对吧。因为现在是直接从实例转静态了,发现语法好像不够用。实际上,这里的 this 是可以当实例直接用的,因此 this 可以堂而皇之传入方法里当参数。这就是 this 直接用的其中一种场合。

回到刚才的内容。第一个参数表示的是我什么对象触发事件,那么自然是实例成员,触发事件也就是实例它自己触发的,所以传入 this 就是最合适不过的参数;而第二个参数则是传入一个 CheckingEventArgs 的对象。显然我们只传入了 _count 就可以,因为我们也只需要这一个信息。

那么,实例化这个类型后追加 Checking 事件成员的回调函数,怎么写呢?

大概是这么一种感觉。稍微注意一下这里的 !(sender is List) 的取反 is 运算。这个语法稍微有点别扭,因为之前没用过,但是这么做是合适的。因为这个方法是和事件的回调函数,那么就不应该拿来给别的地方用。正是因为如此,所以我们必须要验证这里的 sender 参数是否正常。如果它自己都不是 List 类型了,那么这调用起来不是很滑稽么?所以,我们一定要注意这一点。这里的 sender 说实话也不怎么常用,要么调试 bug 期间很常用,要么判别数据类型很常用。很少直接提取里面的数据的。因为它里面的数据其实很多时候都从外界访问不了(字段都是 private 修饰过了)。而真要看里面的数据,我们为什么不从这里所谓的 e 参数里传进去呢?

顺带一提,参数名 sendere 也是一种书写习惯。一般事件绑定的回调函数都是带有这样类型的两个参数。要表示触发事件的对象,我们都一般取名叫 sender 而不是别的;而触发事件的事件数据信息,我们都用的是 e。至于为什么是 e,因为 EventArgs 的首字母是 e。这是一种取名习惯。你可以不遵守,但还是建议你这么使用,因为这是一种规范。

Part 7 方法

7-1 不要给处理机制过于复杂的操作用属性代替方法

如果一个方法的代码比较多的时候,即使它确实可以看成一个类型的属性,也不建议写成属性。因为它的执行会消耗一定时间,但属性的执行一般不会很复杂,都是即存即取的过程。比如一个二叉树的所有子节点,用一个集合返回出来。假设它写成方法,或者写成属性有如下两种模式:

写成前者的话,你可能会在属性里写上很复杂的迭代代码,比如递归之类的东西。但是这样复杂的执行会影响属性取值的性能,这样是比较棘手的(不论是调试代码还是执行代码),数据一直出不来,就会卡在属性执行计算的过程上,这是非常难受的事情。因此,规范下属性是一般不执行笨重的操作的。

7-2 ToString 方法一般情况下一定要重写

ToString 方法是一个特别特殊的方法。它是 object 这个最终基类型里就自带的方法。但是,如果你不重写的话,这个方法最终会输出的字符串其实是这个数据类型的字符串写法。比如说 object 类型是来自 System 命名空间下的 Object 数据类型,因此最终这个类型会显示出 System.Object 这样的东西。因此,假设你的写法是这样的:

这样会输出 "System.Object" 字符串。显然,这一点对我们实际实现数据类型没有帮助,因此我们总是建议大家在写数据类型的时候顺带把 ToString 给重写了。

比如这样。string.Join 方法将一个集合类型(这里是数组)直接当成参数传入进去,它表达的是每一个元素全部输出,并且分隔符用的是第一个参数给的这个字符串。比如 _elements 里是 1、2、3 的话,那么 string.Join(", ", _elements) 的结果就是 "1, 2, 3, 0, 0, 0, 0, 0, 0, 0",因为后面数据没用上,初始化的时候数组所有元素被初始化为 0,所以剩下的数据都是 0,而前面三个数据是改动过的,所以是 1、2、3 分别输出。

显然,ToString 方法是非常有用的东西,它帮助我们计算和判别内容到底是什么,因此强烈建议重写掉它。

7-3 ToString 方法重载的一些小细节

重写 ToString 也是有讲究的。

首先,ToString 输出字符串的执行操作代码不宜过长。太长了就和属性太笨重是一样的效果。ToString 方法会起到呈现数据的效果,所以如果代码过长会导致输出显示效率特别低。

其次是不要乱返回一些东西。重写 ToString 的目的就是改掉原本的逻辑。要是你重写了之后结果还是用的 base.ToString() 或者是 return null 这样的东西的话,显然是任何帮助都没有。

接着,不要在 ToString 的里面引发任何异常。因为 ToString 是用来显示字符串信息的,这也触发异常会导致一系列的副作用,比如代码调试无法继续进行等等。

然后,也不要让 ToString 里的代码更改和修改对象本身的字段存储的信息。因为字符串显示和呈现功能只是取值和显示的操作,从 ToString 里更改对象的数据成员的话,就可能使得每次字符串显示的结果都不一样,导致副作用的出现。

最后,ToString 重写的时候,一定要记住,每一个实例都或多或少有不同之处。为了调试代码方便,我们强烈建议尽量尽可能在最短的代码里呈现和显示出每一个实例的这些不同的信息。比如说一个集合,要是长度 100 的话,没有必要整个 100 个元素都显示出来,因为太长了;但是要体现出对象的区别的话,我们可以尝试着输出这个集合类型的 Count 属性(或者底层的 _count 字段)的数值。比如说

比如这样。

string.Format 方法用来排版字符串,起到和 Console.WriteLine 方法相似的效果。只是 Console.WriteLine 是显示字符串结果,但 string.Format 是为了排版字符串,把后面的参数信息挨个替换到占位符上,最终将字符串结果返回出来,而不是直接呈现到控制台。

所以,ToString 方法重写也不是随便搞一下就完事的。

7-4 建议重写 GetHashCode 方法

实际上,GetHashCode 方法也是建议重写掉的方法,因为这个方法的底层有些时候会导致性能损失。比如说值类型,值类型的 GetHashCode 的原理是,通过高阶的代码处理机制获取到整个类型里的所有数据成员,然后挨个去取里面的数据的哈希码,然后异或起来。但是问题就在于,“通过代码来获取代码的信息”,就这一点来说就不容易;其次是获取的每个成员也不一定自带 GetHashCode 方法的重载。比如说数组。数组是包含方形数组和锯齿数组两种的,虽然都从 Array 类型派生,但是因为机制不同,所以 GetHashCode 处理肯定也没办法统一化。因此,有些数据类型是不包含重写过的 GetHashCode 方法的。因此,最终计算出来的哈希码压根就不能当成判别数据相等性的工具。

因此,我们强烈建议自己手写处理机制,然后覆盖掉基类型的 GetHashCode 方法。

再次强调说明一下哈希码的具体效果和作用。哈希码(Hash Code)也称散列码,指的是一个 int 类型的整数,用来区别区分同类型的对象是不是包含相同的数据。由于哈希码本身只能是 int 的整数,所以包含的可能情况是有限的。但是对象的灵活程度很高,这就使得很多时候哈希码并不能完整映射到每一处不同的地方。但是哈希码为了计算快捷和方便,很少出现哈希码完全相同但对象包含不同数据的情况(因为这一点是通过复杂的公式计算到的,越复杂越好),所以这样的行为特别少见。这样的行为称为哈希码碰撞(Hash Code Collision)。哈希码碰撞是暂时无法解决的问题,你也只能通过别的公式来避免这一点。哈希码有时候比 Equals 方法要好,是因为它的公式复杂程度有些时候比 Equals 的代码执行起来要简单一些,比如比较集合相等性。集合的元素如果要判断是不是完全一样的话,GetHashCode 里可能就是循环起来,挨个元素异或;但是 Equals 可能会对两个集合对位进行比较。如果集合的元素发生变动,可能位置不一定能对应上的话,可能还要考虑两次循环,因此复杂度比 GetHashCode 就要高一些。所以,哈希码有些时候是比 Equals 更优越的判断数据的模式。

7-5 建议 GetHashCode 代码的公式越简单越好

为了计算快速和性能损失最小化,我们需要保证公式计算可以尽量保证数据唯一性的基础上,代码也要最简单才行。比如计算一个数组的哈希码,那么我们可能会这么写代码:

我们故意取了一个复杂的数值 271828183 作为初始情况。因为后面存储的数据可能会很复杂也可能很简单。所以处理最开始要保证数据防止对方去猜公式来反推数据。接着,公式直接通过循环遍历整个数组序列,然后去挨个得到结果,并使用 ^= 运算符把数据记录到 result 里。之所以使用 ^= 是因为这个运算符的处理不容易看懂,但电脑操作很快。你使用 +- 这样的运算符的话,对象唯一性的辨识度很低(很容易出现哈希码碰撞)。因此使用 ^ 是最好的选择。

顺带一说,和 ToString 一样,它也是特别的方法,因此不要在里面抛异常。

7-6 要在重写了 GetHashCode 方法后重写 Equals 方法,反之亦然

这一点其实不必多说。因为要判别对象相等性的话,两种手段其实缺一不可。少一种看起来好像没有啥大问题,但代码执行起来一来是性能损失严重,二来是有些机制可能就无法使用了。在以后我们可能会接触到泛型,泛型集合里有一个叫做字典的类型,它需要大量使用对象的哈希码计算结果和对象判别相等性的地方。一旦数据少一个实现,就会启用基类型(比如 object 这些类型)里的 GetHashCode 方法。这些方法的默认执行实际上是没啥特别大用处的结果,所以一定不要这样。

顺带一提。C# 里的 object 类里有一个 Equals 实例方法,它的长相大概是这样的:

换而言之,这个方法本质上还是在调用了 ReferenceEquals 引用判断方法,所以一定要在需要对象判断相等性的时候重写掉 Equals 方法。

7-7 不要让值类型对象调用 ReferenceEquals 方法

我们之前说过,ReferenceEquals 方法是判断引用是否一致的。如果是值类型的话,因为值类型本身传入进去会因为参数是 object 类型而装箱,因此引用会发生变动。就算两个完全一样的对象,装箱后的地址也不同,所以值类型调用 ReferenceEquals 方法的返回值总是 false。一定不要让值类型去碰这个方法。

7-8 在比较相等性之前,先判断对象是否为 null(如果是引用类型的话)

别忘了这一点。如果没有判断 null,可能会导致程序出现崩溃,产生 NullReferenceException 异常。当然了,值类型又不存在 null 一说,这个另说;但是一定要注意的是默认的 Equals 方法的参数是 object,值类型需要拆箱不说,而且 object 是引用类型,所以可能传入一个 null 参与比较,因此这个参数仍需要判断是不是 null

7-9 请在需要对象判断相等性的时候,顺带实现 IEquatable 接口

IEquatable 接口可能之前没说过。这个接口是约束对象是否相等的。如果没有这个显式在继承列表里写出这个接口其实问题不大,但是有些刁钻的时候它没有就会导致无法继续写代码,以至于更改数据结构本身。

IEquatable 接口里只有一个 Equals 方法需要实现,而且是跟 object.Equals 这个实例虚方法的签名一模一样。一旦你重写了之后,这个接口请一定要追加到继承列表里,表示这个接口是顺带也实现了。

7-10 除了实现 IEquatable 接口外,还建议提供一个具体类型的 Equals 类型比较方法

这句话什么意思呢?就是假设我实现了 List 类型的、object 自带的 Equals 方法,那么我们顺带还要实现一个重载方法,长这样:

是的,参数改一下就行(记得还要去掉 override 关键字,因为方法不是重载出来的)。这个方法的好处是,为了避免潜在的装箱拆箱操作。如果有值类型的话,重写的 Equals 方法的参数是 object 类型,会导致装箱。

Part 8 运算符重载

8-1 非比较运算符重载的时候,应总是返回新的对象

这是什么意思呢?假设你实现了一个 Int128 的数据类型,这个数据类型可以表示 128 个比特的超大整数类型(比 64 比特的 long 类型表示的数据范围还要多),但问题在于这个类型要实现很多的运算符重载。

假设我要对这个类型的对象进行实现单目运算符的 ~(位取反运算),我们的实现代码建议是这样的:

而不是

这是因为,如果 Int128 这个类型是个引用类型的话,就很有可能因为这个执行行为而更改掉 current 对象里面的数据信息。如果能改掉自身的数据,那么我们用 ~ 运算符操作一番还返回它自己又有什么用呢?这不就是算起了副作用了嘛。所以,我们不建议在重载运算符期间更改参数本身的数据内容,而总是返回新的对象。

8-2 尽量用 ReferenceEquals 方法代替调用 object 自带的 ==!= 运算符

因为自己重载的运算符可能不会考虑到 null 的处理导致异常抛出,所以建议使用 ReferenceEquals 方法来规避 NullReferenceEquals 异常。

8-3 在重载了 Equals 方法后,顺带重载 ==!= 运算符

这个就不多说了。显然 ==!= 写起来就是比 Equals 要方便还要好看一点点。重载的运算符的代码,这样就可以了:

第一个 == 运算符返回 left.Equals(right) 结果。而 != 的话,直接调用 == 运算符,然后取反就可以了。

8-4 一般不重载 truefalse 运算符,除非这个对象类型本身就是一种逻辑类型

truefalse 运算符在之前介绍过,它需要依赖 truefalse&| 四个运算符,才能表达出 &&|| 的重载效果。换言之,因为 &&|| 不可直接重载的关系,所以我们只能转去重载 truefalse 运算符来达到 &&|| 重载掉后的效果。但这个运算符从理解上就不容易,所以一般不是逻辑类型的数据非常不建议去重载。

8-5 尽量不要在运算符重载期间抛异常

这个和属性的机制差不多。运算符是一种快捷书写的方式。如果代码过长,执行起来慢就不多说了;而且抛异常在运算符里会有很奇怪的副作用,因此完全不建议这样做。

Part 9 类型转换

9-1 将模糊类型转具体类型的时候应使用显式转换关键字 explicit

假设我们实现了自定义的 Int128 类型,显然我们可以期望它可以和 long 这样的数据类型进行转换。问题在于,Int128 类型从逻辑上表达的数据更多,往 long 上面转的话,数据可能会因为超出 long 的范围而产生异常。因此,我们建议对这样的类型转换使用 explicit 而不是 implicit 关键字:

9-2 将具体类型转模糊类型的时候应使用隐式转换关键字 implicit

和 9-1 内容同理。如果反过来的话,显然任何时候转换都是成功的,所以这么转换的时候没有必要随时随地都加上强制转换,没有必要。所以这样的时候使用 implicit 就比较合适。

Part 10 总结

本节将前文建议和不建议的内容作出一个总结。

命名规范

下面用一个表格列举这些信息。

  • 类型名:

    • 和事件绑定使用的委托类型:帕斯卡+EventHandler 后缀(PascalCaseEventHandler);

    • 接口类型:I 字母+帕斯卡(IPascalCase),一般使用单词的 -able/-ible 形容词形式,或者是一个名字、动词的分词形式表示出来的名词;

    • 其它:帕斯卡(PascalCase);

  • 字段:

    • static readonly 修饰:帕斯卡(PascalCase);

    • public 修饰:帕斯卡(PascalCase);

    • 委托字段:下划线+驼峰+EventHandler 后缀(_camelCaseEventHandler);

    • 其它:下划线+驼峰(_camelCase);

  • 属性:帕斯卡(PascalCase),一般和配套字段同名;

  • 事件:帕斯卡(PascalCase),一般使用动词或动词词组的过去分词和现在分词形式;

  • 方法:帕斯卡(PascalCase);

  • 嵌套类型名:帕斯卡(PascalCase)。

使用规范

  • 建议在类的声明里添加 sealed 关键字密封这个类以防止别的程序员乱用,除非这个类要往下派生别的类型;

  • 不建议自定义类型的名字和库提供的某个(某些)类型重名;

  • 建议使用不带命名空间前缀的类型名称;

  • 不建议使用嵌套类型;

  • 不建议嵌套命名空间;

  • 不建议使用 System 命名空间作为你自己程序的命名空间使用;

  • 建议给类型和内部的成员都写上访问修饰符,即使级别是默认的级别;

  • 建议抽象类里提供给子类型专门实例化用的构造器;

  • 建议给用于子类型调用的基类型构造器使用 protected 访问修饰符;

  • 建议给字段标记 readonly 修饰符,除非字段的值可变;

  • 建议所有的自定义结构不可变;

  • 建议只读量一定用 conststatic readonly 修饰(且如果能用 const 就用 const);

  • 不建议使用 public 修饰静态非只读字段;

  • 不建议给结构的属性成员加上 set 方法(赋值器);

  • 建议使用属性表达一些无法通过字段表达的、需要公式计算的高阶数据信息;

  • 不建议使用只写属性和只写索引器;

  • 不建议通过属性或索引器暴露出底层集合类型的字段;

  • 建议使用属性封装字段的方式暴露字段,而不是直接使用 public 类型的字段;

  • 不建议使用静态属性,除非有特殊情况;

  • 不建议对索引器参数使用复杂的数据类型;

  • 建议事件触发的时候,优先验证底层的委托字段是不是 null

  • 建议事件底层的委托字段的访问修饰符只能是 privateprotected

  • 不建议使用静态事件,除非事件触发起来跟任何实例都没有关系;

  • 不建议给属性使用复杂的操作和执行行为,请改成方法执行;

  • 建议重写 ToStringGetHashCode 方法;

  • 建议重写的 ToString 方法的逻辑代码不要太长;

  • 建议 ToString 返回有意义的数据,而不是 null 等无意义的东西;

  • 不建议ToString 里引发异常;

  • 不建议ToString 里更改变动对象的数据信息;

  • 建议ToString 输出显示能体现对象独一无二特性的信息;

  • 建议GetHashCode 方法重写体现出对象的唯一性;

  • 建议GetHashCode 的公式实现尽量简单;

  • 建议GetHashCode 的公式实现稍微“坑”一点,以便让对方无法反推猜到原始数据;

  • 不建议GetHashCode 里引发异常;

  • 建议在重写了 GetHashCode 方法后也重写掉 Equals 方法;

  • 建议在重写了 Equals 方法后也重写掉 GetHashCode 方法;

  • 不建议对值类型调用 ReferenceEquals 静态方法;

  • 强烈建议Equals 重写期间判断对象是不是为 null(如果对象是引用类型的话);

  • 建议重写了 Equals 方法后,顺带在类型声明后的继承列表里追加 IEquatable 接口;

  • 建议重写了 Equals 方法后还要添加一个具体类型作为参数的 Equals 方法的重载版本;

  • 建议重载运算符的代码不要修改变动传入的参数里的数据信息,并总是返回新的对象;

  • 建议ReferenceEquals  方法代替 ==!= 的运算符使用;

  • 建议重写了 GetHashCodeEquals 方法后,顺带也重载掉 ==!= 运算符;

  • 不建议对任何非真假逻辑类型重载 truefalse 运算符;

  • 不建议在运算符重载的代码里抛异常;

  • 不建议运算符重载的代码过多;

  • 建议对表示数据更多的数据类型往表示数据范围更少的数据类型方向转换的转换运算符用 explicit 关键字;

  • 建议对表示数据更少的数据类型往表示数据范围更多的数据类型方向转换的转换运算符用 implicit 关键字。



第 51 讲:类型良构规范(一):类型和成员的实现规范的评论 (共 条)

分享到微博请遵守国家法律