本文由 CYJB 译自 Type Design Guidelines(.NET Framework 4.5)。
对 CLR 来说,只存在两种类型——引用类型和值类型。但是为了讨论框架设计,我们将类型细分为更多的逻辑组,每组有其特定的设计准则。
类是通用的引用类型,框架中的大部分类型都是类。类因其支持面向对象的大部分特性和普遍适应性而大受欢迎。基类和抽象类是与扩展性相关的特殊逻辑组。
接口是可以由引用类型和值类型实现的类型。它们可以作为引用类型和值类型的层次结构的根,或者模拟多重继承(CLR 本身并不支持多重继承)。
结构体是通用的值类型,用于表示小的简单类型,类似于语言的基本类型。
枚举是特殊的值类型,用于定义值的集合,例如星期、控制台颜色等等。
静态类是设计用来包含静态成员的类型,通常用于提供其它操作的快捷方式。
委托、异常、特性、数组和集合都是针对特定用途的特殊引用类型,它们的设计和使用准则会在本书的其它位置讨论。
√ 要确保每个类型都是相关成员的良好定义的集合,而不仅仅是无关函数的随机集合。
选择类或结构体
每个框架设计师都会面对的基本决定之一,就是将一个类型设计为类(引用类型)还是结构体(值类型),因此很有必要了解引用类型和值类型之间的行为区别。
引用类型和值类型的第一个区别,是引用类型分配在堆(heap)上,会被垃圾回收;而值类型分配在栈(stack)上,或被內联入包含类型中,在栈展开或包含类型被释放时回收。所以值类型的分配和回收通常比引用类型的开销更小。
其次,引用类型的数组并不连续分配,也就是说数组元素仅仅是对位于堆上的引用类型实例的引用。值类型的数组则是连续分配的,意味着数组元素实际上就是值类型的实例。所以,值类型数组的分配和回收也比引用类型数组的开销更少。另外,大多数情况下值类型数组比引用类型数组表现出更好的局部性。
然后是与内存使用相关的区别。值类型在被转换为引用类型或实现的接口时会被装箱,在转换回值类型时会被拆箱。由于装箱的结果是分配在堆上的对象,会被垃圾回收,太多的装箱和拆箱会对堆和垃圾回收器造成负面影响,最终会影响到应用的性能。相比之下,引用类型被转换时不会发生装箱。
接下来,引用类型赋值时会复制引用,而值类型赋值时会复制完整的值。所以,巨大的引用类型的赋值,比巨大的值类型的赋值开销更少。
最后,引用类型会按引用传递,而值类型会按值传递。对引用类型实例的改变,会影响到所有指向该实例的引用。值类型的实例会在按值传递时复制,当值类型的实例被改变时,显然不会影响到它的其它副本。由于值类型的副本不是由用户显式创建的,而是在参数传递或返回值返回时隐式创建的,可变的值类型可能会使许多用户迷惑,因此,值类型应当是不可变的。
根据经验,框架中的大部分类型应该是类。然而,还有一些情况,值类型的特性使得其更适合使用结构体。
√ 考虑定义结构体而不是类,如果类型的实例很小,而且通常生命周期短或嵌入在其它对象中。
X 不要定义结构体,除非该类型具备以下所有特点:
- 它在逻辑上表示单个值,与基元类型(int、double 等)类似。
- 它的实例大小小于 16 字节。
- 它是不可变的。
- 它不会需要频繁的被装箱。
在其它所有情况下,您都应当把您的类型定义为类。
抽象类设计
X 不要在抽象类中定义 public 或 protected internal 构造函数。
只有用户需要创建类型的实例时才需要公共构造函数。由于您不能创建抽象类型的实例,具有公共构造函数的抽象类型是错误的设计,而且会误导用户。
√ 要为抽象类定义 protected 或 internal 构造函数。
一个 protected 构造函数更加常见,而且允许基类在子类创建时完成它自己的初始化。
一个 internal 构造函数可以用来将抽象类的具体实现限制在定义该类的程序集中。
√ 要为您提供的每个抽象类,提供至少一个具体的继承类型。
这样做有助于验证抽象类的设计。例如,System.IO.FileStream 是 System.IO.Stream 抽象类的一个实现。
静态类设计
一个静态类是只包含静态成员的类(当然除了继承自 System.Object 的实例成员和可能的 private 构造函数)。一些语言提供内建的静态类支持。在 C# 2.0 以及更高版本,当一个类被定义为 static,它就是密封的、抽象的,而且没有可以声明或重写的实例成员。
静态类是在纯面向对象设计和简洁性之间的妥协,它们一般用于提供其它操作的快捷方式(例如 System.IO.File),储存扩展方法或不适合使用完全面向对象包装的功能(例如 System.Environment)。
√ 要谨慎使用静态类。
静态类应当仅用于支持框架中的面向对象核心类。
X 不要认为静态类可以无所不包。
X 不要在静态类中声明或重写实例成员。
√ 要将静态类声明为密封的、抽象的,并且添加一个私有成员构造函数,如果您使用的编程语言没有内建的静态类支持。
接口设计
尽管大部分 API 最适合使用类和结构体建模,有些情况下接口是更合适的或者是唯一的选择。
CLR 并不支持多继承(即 CLR 的类不能继承自多于一个的基类),但它允许类型在继承自一个基类之外实现一个或多个接口。因此,接口经常用于实现多重继承的效果。例如,IDisposable 是一个允许类型支持资源释放的接口,它独立于其它任何继承层次结构。
另一个适合定义接口的情形是创建可以由多个类型(包括值类型)支持的公共接口。值类型不能继承自 ValueType 以外的其它类型,但它们可以实现接口,因此使用接口就成为了能够提供公共基本类型的唯一选项。
√ 要定义接口,如果您需要一些由包含值类型的多个类型支持的公共 API。
√ 考虑定义接口,如果您在已经从其它类型继承的类型上支持其功能。
X 避免使用标记接口(没有任何成员的接口)。
如果您需要将一个类标为具有特殊的特性(标记),一般而言,使用自定义特性(Attribute)而不是接口。
√ 要为接口提供至少一个实现的类型。
这样会有助于验证接口的实现。例如,List<T> 是 IList<T> 接口的一个实现。
√ 要为您定义的每个接口,都提供至少一个用到它的 API(将接口作为参数的方法,或类型为该接口的属性)。
这样会有助于验证接口的设计。例如,List<T>.Sort 会用到 System.Collections.Generic.IComparer<T> 接口。
X 不要向已被公开的接口添加成员。
这样可能会破坏接口的现有实现。您应当创建一个新接口来避免版本问题。
在设计可重用的托管代码库时,除了上面所述的情况,一般您都应当选择使用类而不是接口。
结构体设计
通用值类型常被称作 struct(结构体),这个一个 C# 关键字。这节提供了一般的结构体设计准则。
X 不要为结构体提供默认构造函数。
遵循这一准则,允许创建结构体数组而无需为每个数组元素调用构造函数。请注意 C# 并不允许为结构体提供默认构造函数。
X 不要定义可变的结构体。
可变的结构体存在一些问题。例如,当属性的 get 访问器返回了一个值类型,调用者会得到返回值的副本。因为副本是隐式创建的,因此开发者可能并未意识到他们在修改副本,而不是原始值。此外,一些语言(特别是动态语言)在使用可变的值类型时会有问题,因为即使是局部变量,在取消引用时也会产生副本。
√ 要确保所有实例数据被设置为 0
、false
或者 null
(适用时)的状态是有效的。
这是为了防止在创建结构体数组的时候意外的创建了无效的实例。
√ 要在值类型上实现 IEquatable<T>。
值类型的 Object.Equals 方法会导致装箱操作,而且它的默认实现由于使用了反射,因而并不非常高效。实现 IEquatable<T>.Equals 方法可以具有更高的性能,而且不会导致装箱。
X 不要显示扩展 ValueType。事实上,大部分语言会阻止这样做。
通常,结构体可以非常有用,但仅应当被用于小的(译注:如上文所述,小于 16 字节)、单一的、不可变的且不会被频繁装箱的值。
枚举设计
枚举是特殊的值类型,分为简单枚举和标志枚举两种。
简单枚举表示了选择的小的闭集。一个常见的简单枚举的例子是一组颜色。
标志枚举是为使枚举值支持位运算而设计的。一个常见的标志枚举的例子是一个选项列表。
√ 要使用枚举强类型化表示值的集合的参数、属性和返回值。
√ 要优先使用枚举而不是静态常量。
X 不要对开放集(如操作系统版本,您朋友的名字等)使用枚举。
X 不要提供计划将来使用的保留枚举值。
您总是可以简单地在后期为现有枚举添加值。请参见向枚举添加值,获取向枚举添加值的更多信息。保留值只会污染了真实的值,并往往导致用户错误。
X 避免公开暴露只包含一个值的枚举。
确保 C API 的未来可扩展性的一个常见做法是为方法签名添加保留参数。这样的保留参数可以被表示含有单个默认值的枚举。在托管 API 中不应该这样做,方法重载允许在未来的版本中添加参数。
X 不要在枚举中包含哨兵值。
尽管哨兵值有时能帮助到框架开发者,但会混淆框架的用户。它们被用于跟踪枚举的状态,而不是枚举表示的集合中的一个值。
√ 要在简单枚举中提供一个零值。
考虑将零值命名为 "None"。如果 "None" 不适于这个枚举,应当将基础值零分配给枚举最常见的默认值。
√ 考虑使用 Int32(大多数编程语言的默认数据类型)作为枚举的基础类型,除非出现了以下任何一种情况:
- 枚举是标志枚举,而且您有 32 个以上的标志,或者期望在将来有更多的标志。
- 基础类型需要与 Int32 不同,以便易于与期望不同大小的枚举的非托管代码进行互操作。
- 较小的基础类型可以显著节省空间。如果您期望枚举主要用作控制流的参数,其尺寸就不太重要。在下列情况中,节省空间可能会很重要:
- 您预计枚举会被用作非常频繁地实例化的结构体或类中的字段。
- 您预计用户会创建枚举实例的大型数组或集合。
- 您预计要序列化大量枚举实例。
对于在内存中使用枚举,请注意托管对象总是按双字(DWORD)对齐的,因此您实际上最好使用多个枚举或小的结构体填满实例,因为实例的总大小总是会向上舍入到双字(DWORD)。
√ 要以复数名词或名词短语来命名标志枚举,简单枚举则使用单数名词或名词短语。
X 不要直接扩展 System.Enum。
System.Enum 是一个特殊类型,由 CLR 用来创建用户定义的枚举。大部分编程语言提供了供您使用这一功能的语言元素。例如,C# 中 enum 关联字就用于定义枚举。
设计标记枚举
√ 要为标记枚举应用 System.FlagsAttribute。不要将这个特性应用到简单枚举上。
√ 要为标志枚举的值使用 2 的幂,以便这些值可以使用按位“或”运算自由组合。
√ 考虑为常用的标志组合提供特殊的枚举值。
按位操作是高级概念,对简单的任务来说不是必须的。ReadWrite 就是这样的特殊值的示例。
X 避免创建标志枚举,当某些组合值无效时。
X 避免使用值为零的标志枚举,除非这样的值表示“所有标志都被清除”,而且按照下一条准则所述的正确命名。
√ 要将标志枚举的零值命名为 “None”。对于标志枚举,该值必须始终表示“所有标志都被清除”。
向枚举添加值
当您已经公开了一个枚举后,也经常会发现您需要向当中添加更多的值。当新加入的值被现有 API 返回时,可能产生程序兼容性问题,因为编写糟糕的应用程序可能无法正确处理这些新值。
√ 考虑向枚举添加值,即使存在小的兼容风险。
如果您确定向枚举添加值会导致程序兼容性问题,那么考虑增加一个返回新值和旧值的新 API,并将旧的 API(仍然只返回旧值)标记为已过时。这样可以确保您的现有程序能够保持兼容。
嵌套类型
嵌套类型是另一种类型(即所谓的封闭类型)的范围内定义的类型,这就是所谓的封闭类型。一个嵌套类型有权访问它的封闭类型的所有成员。例如,它有权访问在封闭类型中定义的私有字段,以及在封闭类型的所有祖先中定义的受保护的字段。
通常应当谨慎使用嵌套类型,有几个方面的原因。一些开发者并不完全熟悉嵌套类型的概念。例如,这些开发人员可能不了解声明嵌套类型变量的语法。而且嵌套类型与它的封闭类型关联非常紧密,因此不适合将其用作通用类型。
嵌套类型最适合于构造它们的封闭类型的实现细节。最终用户应当很少需要声明嵌套类型的变量,并且几乎从不应该需要显式实例化嵌套类型。例如,一个集合的迭代器可以是那个集合的嵌套类型。迭代器通常是由它们的封闭类型实例化的,而且由于很多语言都支持 foreach 语句,迭代器变量很少需要由最终用户来声明。
√ 要使用嵌套类型,当嵌套类型和它的外部类型间的关系需要成员可访问性语义。
X 不要将公共嵌套类型用作逻辑分组构造;请使用命名空间。
X 避免公开暴露嵌套类型。唯一的特例是需要声明嵌套类型的变量,例如在子类化或其他高级自定义等罕见情况下。
X 不要使用嵌套类型,如果类型可能会被包含类型的外部引用。
例如,传递给某个类定义的方法的枚举,不应当定义为该类的嵌套类型。
X 不要使用嵌套类型,如果需要由客户端代码实例化类型。如果一个类型具有公共构造函数,它最好不要被嵌套。
如果一个类型可以被实例化,那么一般认为该类型在所属框架中可以独立使用(您可以创建它、使用它和销毁它而无需使用外部类型),因此不应该被嵌套。在与外部类型没有任何关系的情况下,内部类型不应在外部类型的外部广泛重用。
X 不要将嵌套类型定义为接口的成员。许多语言不支持这样的构造。
Portions © 2005, 2009 Microsoft Corporation. All rights reserved.
Reprinted by permission of Pearson Education, Inc. from Framework Design Guidelines: Conventions, Idioms, and Patterns for Reusable .NET Libraries, 2nd Edition by Krzysztof Cwalina and Brad Abrams, published Oct 22, 2008 by Addison-Wesley Professional as part of the Microsoft Windows Development Series.