类是引用类型的一般情况,占了框架中的大多情况,类的流行归于它支持面向对象的特征,以及它的普遍的适用性,基类和抽象类是两个特殊的逻辑分组,它们与扩张性有关。
由于CLR不支持多继承,接口类型可以用来模拟多继承,既能被引用类型实现,也能被值类型实现。
结构是值类型的一般情况,应该用于小而简单的类型,就像编程语言的基本类型一样。
枚举是值类型的一个特例,它用来定义一小组值。
静态类是那些用来容纳静态成员的类型,常用来提供对其他操作的快速访问。
委托、异常、Attribute、数据、集合都是引用类型的特例,各有各自的用途。
-
√
要
确保每个类型由一组定义明确、相互关联的成员组成,而不要仅仅是一些无关功能的随机集合。
4.1.类型和名字空间
在设计大型框架之前,应该决定如何将功能划分到一组功能域中,这些功能域由名字空间表示,为了确保一组有条理的名字空间包含的类型能很好的集成,不发生冲突,以及不会重复,自顶向下的设计很有必要。导致了下面的规范:
-
√
要
用名字空间把类型组织成一个相关的特性域的层次结构。 -
×
避免
非常深的名字空间层次(难于浏览,需要经常回溯) -
×
避免
有太多的名字空间 -
×
避免
把为高级方案而设计的类型和常见的编程任务而设计的类型放在同一个名字空间中。
(方便用户更容易理解框架的基本概念,而且更容易在常见的场景中使用框架) -
×
不要
不指定类型的名字空间就定义类型。
标准子名字空间的命名
很少使用的类型应该放在子名字空间中,以免扰乱主名字空间,我们确定了几组类型,应该把它们从主名字空间中区分离出来。
- .Design 子名字空间
仅用于设计时的类型应该放在名为.Design
的子名字空间。
如:System.Windows.Forms.Design;
System.Messaging.Design;
- .Permissions子名字空间
权限类型应该放在.Permissions
子名字空间。 - .Interop子命名空间
许多框架需要支持与旧系统的互操作性(interoperability)。
4.2 类和结构之间的选择
引用类型在堆上分配,由垃圾收集器管理;而值类型要么在栈上分配并在栈展开时释放,要么内联在容纳它的类型中并在容纳它的类型被释放时释放。因此,与引用类型的分配与释放相比,值类型的分配与释放开销更低。
引用类型的数组不是非内联分配的,意为数组元素只是一些引用,指向那些位于堆中的引用类型的实例。而值类型的分配是内联的,数组的元素就是值类型的真正实例。因此值类型的分配和释放的开销要比引用类型的大的多,在大多情况下,值类型数组具有更好的局部性。
值类型在被强制转换为对象或装箱。因为装箱和对象是在堆上分配的,且由垃圾收集器管理,所以太多的装拆箱操作会对堆、垃圾收集器,并对系统性能造成影响。相比之下,在对引用类型执行转换操作,不会发生装箱操作。
引用类型的赋值是复制引用,而值类型的赋值复制整个值,对大的引用类型复制开销要比值类型小的多。
引用类型是引用传递,值类型是值传递。改变引用类型的一个实例会影响其他的实例,改变值类型的实例,不会影响到它的副本。
框架中的大多数类型应该是类,但是在某些特殊情况下,由于值类型所具有的特征,使用结构更合适。
√
考虑
定义结构而不是类 —— 如果该类型的实例比较小,生命周期比较短,经常被内嵌在其他对象中。-
×
避免
定义结构,除非该类型具有以下特征:它在逻辑上代表一个独立的值,与基本类型相似(int)
它的实例大小小于16字节
它是不可变的
它不需要被经常装箱
在所有的其他情况下,应该将类型定义为类。
4.3 类和接口之间的选择
一般来说,类是用来暴露抽象的优先选择。
接口的缺点在于当需要允许API不断演化时,它的灵活性不如类,一旦你发布了一个接口,它的成员就永远固定了,给接口添加任何东西都会破坏已经实现该接口的已有类型。
类提供了更多的灵活性,你可以给一个已发布的类添加成员。只要添加的方法不是抽象的,任何已有的派生类无需改变仍能继续使用。
√ `要 优先采用类而不是接口
与基于接口的API相比,基于类的API容易演化得多,因为可以给类型添加成员而不会破坏已有的代码。√ `要 用抽象类而不是接口来解除协定与实现之间的耦合。
抽象类经过正确的设计,同样能够解除协定与实现之间的耦合,与接口能达到的程度不相上下。√ `要 定义接口,如果需要提供一个多态的值类型层次结构的话。
值类型不能自其它类型继承,但是她们可以实现接口。
public struct Int32 : IComparable,IFormattable,IConvertible {...}
- √ `考虑 通过定义接口来达到与多重继承相类似的效果。
4.4 抽象类的设计
-
×
不要
在抽象类型中定义公有的或内部受保护的构造函数。
只有当用户需要创建一个类型的实例时,该类型的构造参数才是公有的,由于你无法创建一个抽象类的实例,因此如果抽象类型具有公有构造函数,那么这样的设计不仅错误,而且会误导用户。
//错误设计
public abstract class Claim {
public Claim(){}
}
//好设计
public abstract class Claim {
protected Claim(){}
}
-
√
要
为抽象类定义受保护的构造函数或内部构造函数。
更常见的情况是受保护的构造函数,唯一的目的是允许子类型被创建时,基类能够做自己的初始化。
public abstract class Claim {
protected Claim(){
...
}
}
内部构造函数可以用来把该抽象类的具体实现限制在定义该抽象类的程序集中。
public abstract class Claim {
internal Claim(){
...
}
}
-
√
要
为发布的抽象类提供至少一个继承自该类的具体类型。
这有助于验证该抽象类的设计是否正确。例如,System.IO.FileStream
是System.IO.FileStream
抽象类的一个实现。
4.5 静态类的设计
静态类定义为一个只包含静态成员的类。
如果一个类被定义为静态,那么它就是密封的、抽象的,不能覆盖或者声明任何实例成员。
静态类是在纯面向对象设计和简单性之间的一个权衡,它们被广泛用来提供一下访问其他操作(比如System.IO.File)的快捷方式,存放扩展方法,或者以一种不完全面向对象的方式来提供一些功能。(System.Enviroment)
√
要
尽量少用静态类
静态类仅被用作辅助类,来支持框架的面向对象的核心。×
不要
把静态类当做杂物箱。
每一个静态类都应该有其明确的目的。×
不要
声明或覆盖(override)静态类中的实例成员。√
要
把静态类定义为密封的、抽象的,并添加一个私有的实例构造函数。
4.6 接口的设计
虽然大多数情况下API用类或结构来构建最好,但是在有些情况下,接口更合适。甚至某些情况接口是唯一的选择。
CLR 不支持多继承,但允许类型实现一个或多个接口,因此通常用接口来实现多继承。
另一种适合定义接口的情况是,为多种类型(包括值类型)创建一个公共接口。虽然值类型无法继承除了 System.ValueType
之外的其他类型,但他们可以实现接口,所以提供了一个公共的基类型,使用接口是唯一的选择。
public struct Boolean : IComparable {...}
√
要
定义接口,如果你需要包括值类型在内的一组类型支持一些公共的API。√
考虑
定义接口,如果需要让已经继承自其它类型的类型支持该接口提供的功能。×
避免
使用记号接口(没有成员的接口)
//避免
public interface IImmutable {} //空接口
public class Key : IImmutable{...}
//考虑
[IImmutable]
public class Key{...}
√
要
为接口提供至少一个实现该接口的类型。√
要
为你定义的每个接口提供至少一个使用该接口的API(一个以接口为参数的方法或是一个类型为该接口的属性)
例如,List<T>.Sort
使用了IComparer<T>
接口。×
不要
给已发行的接口再添加成员。
这样做会破坏该接口的实现,为了避免版本的问题,应该创建一个新的接口。
一般来说,在为托管代码设计可重用的程序库时,你应该选择类而不是接口。
4.7 结构的设计
通用目的的值类型通常称为 struct
(结构)。
×
不要
为结构提供默认的构造函数。(C#不允许结构有默认的构造函数)×
不要
定义可变的值类型。√
要
确保所有的实例数据都为0,false,或null时,结构仍处于有效状态。(可以防止在创建一个结构时创建出无效的实例)√
要
为值类型实现IEquatable<T>
。
值类型的Object.Equals
方法会导致装箱,默认的实现并不高效,因为使用了反射,IEquatable<T>.Equals
性能好的多,不会导致装箱。×
不要
显示的扩展System.ValueType
,实施上大多数编程语言步允许这么做。
4.8 枚举的设计
枚举是一种特殊的值类型,有两种类型的枚举:简单枚举
和 标记枚举
(flag enum)。
简单枚举 代表小型的、闭合的一组选择。例如(一组颜色):
Public enumColor{
Red,
Green,
Blue,
……
}
标记枚举 的设计是为了支持对枚举值进行按位操作。标记枚举的常见例子是一个选择列表,
[Flags]
Public enumAttributeTargets
{
Assembly=0x0001,
Module=0x0002,
Cass=0x0004,
Struct=0x0008
}
√
要
用枚举来加强那些表示值的集合的参数、属性以及返回值的类型性。√
要
优先使用枚举而不要使用静态常量。(枚举是一个包含一组静态常量的结构)×
不要
把枚举用于开放的集合(比如操作系统版本、朋友的名字等)×
不要
提供为了今后使用而保留的枚举值。×
避免
显示的暴露只有一个值的枚举。×
不要
把sentinel
值包含在枚举值中。√
要
为简单枚举类型提供零值。(应该考虑把该值称为None
之类的东西,如果这样的值不适合用于某个特定的枚举,那么应该把该枚举中最常用的默认值赋值为0)
public enum Compression{
None = 0,
Gzip,
Deflate,
}
√
考虑
以Int32
作为枚举的基本实现类型。√
要
用复数名词或者名词短语来命名标记枚举
,用单数名词或者名词短语来命名简单枚举
。×
不要
直接扩充System.Enum
。
System.Enum
是一个特殊的类型,被 CLR 用来创建用户定义的枚举。
4.8.1 标记枚举的设计
-
√
要
对标记枚举使用System.FlagsAttribute
,不要把该attribute
用于简单枚举。
[Flags]
Public enum AttributeTargets
{...}
-
√
要
用2的幂次方作为标记枚举的值,这样就可以通过按位或操作自由组合他们。
[Flags]
Public enum WatcherChangeTypes
{
Created=0x0002,
Deleted=0x0004,
Changed=0x0008,
Renamed=0x00010,
}
-
√
考虑
为常用的标记组合提供特殊的枚举值。
位操作是一个高级概念,对应简单任务来说不是必须的,FileAccess.ReadWrite
就是这样一个例子
[Flags]
Public enum FileAccess
{
Read=1,
Write=2,
ReadWrite= Read | Write,
}
×
避免
让创建的标记枚举包含某些无效的组合。×
避免
把0用作标记枚举的值,除非该值表示“所有标记都被清除“,而且按下一条规范进行了适当的命名。
C#中字面常量0可以隐式地转换为任何枚举类型,因此你可以编写这样的代码:
if (Foo.SomeFlag == 0) ...
CLR规定任何值类型的默认值“所有的位都清零“。
-
√
要
把标记枚举的零值命名为None
,对其标枚举来说,该值必须始终意味着“所有标记均被清除”。
[Flags]
Public enum BorderStyle
{
Fixed3D=0x1,
FixedSingle=0x2,
None=0x0
}
if(foo.BorderStyle == BorderStyle.None) ...
但是,该规则只适用于标记枚举,对于非标记枚举的情况,避免使用0值实际上是不利的,所有的枚举类型一开始都为零值。
4.8.2 给枚举添加值
常会发现在需要在程序发行之后需要给一个枚举添加值。如果新添加的值是一个已有API的返回值,那么就存在潜在的应用程序兼容性问题。
-
√
考虑
给枚举添加值,尽管有那么一点兼容性的风险。
如果有实际数据,表明给枚举添加值会导致应用程序的不兼容,可以考虑添加一个新的API来返回新老枚举值,这样就能确保仍然兼容现有的应用程序。
4.9 嵌套类型
嵌套类型是一个定义在另一个类型的作用域内的类型。另一个类型被称为外层类型。嵌套类型能够访问外层类型的所有成员。可以访问定义在外层类型的私有字段以及定义在外层类型的所有父类的受保护字段。
一般来说,尽量少用嵌套类型,嵌套类型与外层类型紧密耦合,不适合将它们作为通用类型。嵌套类型适合用来对它们的外层类型的实现细节建模。
√
要
在想让一个类型能够访问外层类型的成员时才使用嵌套类型。×
不要
用嵌套类型进行逻辑分组,应该用名字空间来达到此目的。×
避免
公开的暴露嵌套类型,唯一的例外是如果只需要在极少数的场景中声明嵌套类型的变量,比如派生子类,或者其他高级自定义场景中。
一般避免使用嵌套类型,只有在开发人员几乎不需要声明该类型的变量时才使用嵌套类型。(例如集合的枚举器)×
不要
使用嵌套类型,如果该类型可能会被除了它的外层类型之外的类型引用。×
不要
使用嵌套类型,如果它们需要被客户代码实例化。×
不要
把嵌套类型定义为接口的成员。
一般来说尽量少用嵌套类型,而且应该避免将嵌套类型公开暴露给外界。