第4章 类型设计准则
-
DO
:类应该由一组定义明确、相互关联的成员组成。一个类,如果能用一句话描述清楚它的用途,那么它的设计是优秀的。
1 类型(class、struct)和 namespace
-
DO
:namespace 用于组织类,通过 namespace 将相关功能按层次铺开,但不要有过深的层次、过多的数量。要做到让开发人员很容易地浏览框架并找到想要的 API,过深的层次、过多的数量都不便于开发者浏览。
namespace 的主要目的不是解决同名类型的名字冲突,而是为了把类型组织成一个有条理的、易于浏览、易于理解的层次结构。
如果单个框架中出现类型名冲突,意味着框架设计得很烂。名字相同的类型要么应该合并起来,要么重命名以改进代码的可读性和可搜索性。
-
AVOID
:高级方案的 API 应和常见方案的 API 分隔开,放在不同的 namespace 中。用户能更容易理解框架基本概念,也更容易在常见场景中使用框架。
高级的类应该放在简单类所在 namespace 的子 namespace 中,如:
System.Mail
存放着简单类,System.Mail.Advanced
存放着高级类。
-
DON'T
:定义类型前要先指定类型的 namespace。不要每个类都使用默认的命名空间,这有助于可能存在的类型名冲突。不过,这不意味着可以引入这一的冲突,具体原因第 3 章已经讲过。
标准子 namespace 的命名
很少使用的类型应放在子 namespace 中,以下是我们过去的命名经验,可以参考
.Design 子命名空间
-
DO
:为基本类型提供设计功能的类型应放在. Design 后缀的 namespace 中。System.Windows.Forms.Design System.Messageing.Design System.Diagnostics.Design
.Permissions 子名字空间
-
DO
:权限类型应放在. Permissions 后缀的 namespace 中。
.Interop 子名字空间
-
DO
:为基本 namespace 提供互操作功能的类型应放在 . Interop 后缀的 namespace 中。
-
DO
:所有位于主互操作程序集中的代码应放在 . Interop 后缀的 namespace 中。
2 class 和 struct 之间的选择
-
CONSIDER
:一个类型,如果它实例较 小 且生命周期较 短 ,或经常被 内嵌 在其他对象中(尤其是 数组 ),可以使用结构体。
-
AVOID
:尽可能少使用结构体,使用结构体应遵循如下原则:- 它在逻辑上表示单个值,类似于基础类型(int、double 等)。
- 它的实例大小小于 24 byte。
- 它是不可变的。
- 它不会被频繁装箱。
除此以外,所有类型应该被定义为类。
3 class 和 interface 之间的选择
-
DO
:优先使用 类 ,而非 接口 。基于类的 API 更容易被改进,它可以在不破坏已有代码的情况下向类中添加成员。
-
DO
:要使用 抽象类 来分离协议与实现,而非 接口 。抽象类经过正确的设计,同样能够解除契约与实现之间的耦合,与接口达到的效果不相上下。
当然,这不是说接口一无是处。如果契约不会随着时间改变,接口就非常合适;如果为一个族类型定义公共基类,抽象类更好。
-
DO
:如果结构体(struct)需要实现多态,应使用 接口 。值类型不能继承,但可以实现接口。如
IComparable
、IFormattable
、IConvertible
:public struct Int32 : IComparable, IFormattable, IConvertible { ... } public struct Int64 : IComparable, IFormattable, IConvertible { ... }
定义良好的接口的标志:
- 它不会限定于类型,更像一个类型的“ 属性 ”;
- 每个接口只做一件事。
-
CONSIDER
:可以通过接口实现多重继承的效果。如
System.Drawing.Image
实现了System.IDisposable
和System.ICloneable
接口,因此它是可以处置的(disposable)、可复制的(clonable),还继承了MarshalByRefObject
:public class Image : MarshalByRefObject, IDisposable, ICloneable { ... }
4 abstract class 的设计
-
DON'T
:不要在抽象类中定义访问类型为 public
、 protected-internal
的构造函数。抽象类禁止进行实例化,因此不应该为抽象类定义访问类型为 public、protected-internal 的构造函数。这么做不光是错误的,还会误导用户。
// 坏设计 public abstract class Claim {public Claim() { ... } }// 好设计 public abstract class Claim {protected Claim() { ... } }
-
DO
:抽象类应定义访问类型为 protected 或 internal 的构造函数。protected 构造函数更常见,可以做到仅允许子类调用它。
public abstract class Claim {protected Claim() { ... } }
internal 构造函数,可以做到仅程序集内的子类调用它,我们可以在该构造函数中反向操作子类,以实现一些特殊的设计模式,构造函数又不会因其他程序集实现该抽象类而遭到滥用。
public abstract class Claim {internal Claim() { ... } }
Tips
即使我们未定义构造函数,CLR 也会为我们隐式创建一个 protected 无参构造函数。
-
DO
:public
/protected
抽象类应至少有一个实现。这有助于验证抽象类的设计是否正确。如:
System.IO.FileStream
是System.IO.Stream
抽象类的一个实现。
5 static class 的设计
-
DO
:少用静态类。静态类应作为辅助类,用于辅助框架的面向对象的核心。
-
DON'T
:不要把静态类当作杂物桶。每个静态类都应该有其明确的角色划分。如果你对类的描述包含了“和”这样的连词,或包含一个全新的句子,说明你需要另外一个类。
6 interface 的设计
-
DO
:如果值类型需要支持一些公共 API,要通过 接口 实现。如
int
实现了IComparable
接口。
-
CONSIDER
:需要多重继承时,可以通过 接口 实现。
-
AVOID
==: 避免==使用记号接口(没有成员的接口)。最好使用自定义
Attribute
而非记号接口:// 避免 public interface IImmutable {} // 记号接口public class Key : IImmutable { ... }// 考虑 [IMutable] public class Key { ... }
使用 Attribute 也有缺点:
- 开销更大;
- 编译时无法发现 class 是否标注了 Attribute。
如果我们需要编译时检查,则可以使用记号接口:
public interface ITestSerializable {} // 记号接口 public void Serialize (ITextSerializable item){// 通过反射序列号公有属性 }
-
DO
:接口应至少有一个实现,有一个调用(一个以该接口为参数的方法,或一个该类型的属性)这有助于验证接口的设计和实现。
-
DON'T
: 禁止 给已发行的接口再添加成员。这样会破坏接口的实现。为避免版本问题,应该创建一个新的接口。
7 struct 的设计
-
DON'T
: 不要 为结构体提供默认构造函数。否则会造成
default(SomeStruct)
和new SomeStruct()
行为不同。附注:.NET Framework 中 C# 禁止自定义默认构造函数
-
DO
:要定义不可变的值类型,并使用 readonly 修饰符声明。可变值类型在传值时会 传递副本(副本隐式创建) ,使用者可能意识不到他们修改的是副本,而非源值。
readonly 修饰符在某些操作上能够避免 防御性拷贝 。
// 坏设计 public struct ZipCode {public int FiveDigitCode { get; set; } // get、set都有public int PludsFourExtension { get; set; } }// 好设计 public readonly struct ZipCode {public ZipCode(int fiveDigitCode, int plusFourExtension) { ... }public ZipCode(int fiveDigitCode) : this(fiveDigitCode, 0) { }public int FiveDigitCode { get; } // 只有getpublic int PludsFourExtension { get; }public override string ToString() {...} }public partial class Other{private readonly ZipCode _zipCode;...private void Work(){// 因ZipCode是readonly struct,调用ToString()不会带来防御性拷贝string zip = _zipCode.ToString();} }
-
DO
:对于可变值类型,声明不可变方法。调用值类型的方法时,可以避免 防御性拷贝 :
// 可变值类型 public struct ZipCode {private int _plusFour;public int FiveDigitCode { get; set; }// 显式只读,调用get时得到的不再是拷贝public int PludsFourExtension {readonly get => _plusFour;set { ... }}// 不可变方法,调用它时不再进行拷贝public override readonly string ToString() { ... } }
使用
readonly
修饰struct
时,C# 编译器会自动将readonly
应用到每个方法和属性的get
方法。
-
DO
:当结构实例为 默认 值(如 0、false、null)时,结构仍处于有效状态// 坏设计 public struct PositiveInteger {int value;public PositiveInteger(int value) {if (value <= 0) throw new ArgumentException(...);this.value = value;}public override string ToString() {return value.ToString();} }// 好设计 public struct PositiveInteger {int value; // 逻辑值是 value+1public PositiveInteger(int value) {if (value <= 0) throw new ArgumentException(...);this.value = value - 1;}public override string ToString() {return (value+1).ToString();} }
-
DON'T
:不要定义类似于ref struct
类型的值类型,除非是在性能至关重要的特定底层使用场景中。
ref struct
只允许存在于栈中,不能被装箱至堆中。因此,它不能被作为其他类型中字段的类型使用(除非该类型也是ref struct
),也不能用于 async 声明的异步方法中
-
DO
:值类型需要实现 IEquatable<T>
。
IEquatable<T>
可以避免以下问题:- 值类型的
Object.Equals
方法会导致装箱, -
Object.Equals
使用了反射,它的默认实现效率不高。
- 值类型的
8 枚举的设计
-
DO
:要使用 枚举 来加强那些表示值的集合的参数、属性以及返回值的类型。
-
DO
:要使用枚举代替静态常量。// 避免 public static class Color {public static const int Red = 0;public static const int Green = 1;public static const int Blue = 2;... }// 推荐 public enum Color {Red,Green,Blue,... }
-
DON'T
:不要使用枚举来定义开放集合。比如操作系统的版本、朋友的名字等。
-
DON'T
:不要设保留值。保留值只会污染实际值的集合,还会误导用户。
public enum DeskType{Circular,Oblong,Rectangular,// 以下保留值不应该存在ReservedForFutureUse1,ReservedForFutureUse2, }
-
AVOID
:避免创建只有一个值的枚举。我们可以用方法重载在日后添加参数,而非用单值枚举做占位.
// 坏设计 public enum SomeOption {DefaultOption// 我们日后将添加其他值 } ...// option不是必须的。未来任何时候都可以通过重载实现该方法 public void SomeMethod(SomeOption option) {... }
-
DON'T
:禁止在枚举中包含 sentinel(哨兵)值sentinel 值用来跟踪枚举的状态,却不属于枚举所表示值的集合。
// 坏做法 public enum DeskType{Circular = 1,Oblong = 2,Rectangular = 3,LastValue = 3, // 不应包含sentinel值 }public void OrderDesk(DeskType desk){if (desk > DeskType.LastValue) throw new ArgumentOutOfRangeException(...);... }// 好做法 public void OrderDesk(DeskType desk){if (desk > DeskType.Rectangular || desk < DeskType.Circular) throw new ArgumentOutOfRangeException(...);... }
-
DO
:要为简单枚举提供一个零值。应该把枚举中 最常用的默认值 赋值为零。
public enum Compression {None = 0,GZip,Deflate, } public enum EventType {Error = 0,Warning,Information,... }
-
CONSIDER
:以Int32
为载体实现枚举。例外:
-
该枚举是标记枚举,且超过 32 个标记,或预计今后会有更多的标记。
-
需要与非托管代码进行交互,且非托管代码使用非 Int32 的枚举(即非 4 字节)。
-
为了节省内存:
- 枚举用作 struct 或 class 的字段,且会被频繁实例化;
- 枚举用于创建大型数组或集合;
- 枚举的大量实例用于序列化。
-
-
DO
:标记枚举用复数名词命名;简单枚举用单数名词命名。
1 标记枚举的设计
-
DO
:标记枚举应使用 System.FlagsAttribute
标记。[Flags] public enum AttributeTargets {... }
-
DO
:用 2 的幂次方 作为标记枚举的值。这样可以使用位操作符自由的组合。
[Flags] public enum WatcherChangeTypes {None = 0,Created = 0x0002,Deleted = 0x0004,Changed = 0x0008,Renamed = 0x0010, }
我们还可以这样做:
[Flags] public enum WatcherChangeTypes {None = 0,Created = 1 << 1,Deleted = 1 << 2,Changed = 1 << 3,Renamed = 1 << 4, }
-
CONSIDER
:为常用的组合标记提供特殊的枚举值。[Flags] public enum FileAccess {Read = 1,Write = 2,ReadWrite = Read | Write, }
-
AVOID
:标记枚举不应包含某些无效组合。
System.Reflection.BindingFlags
枚举就是这种错误设计的例子。该枚举试图表示许多不同的概念,如可见性、静态性、成员类型等:// 原设计 [Flags] public enum BindingFlags {Default = 0,Instance = 0x4,Static = 0x8,Public = 0x10,NonPublic = 0x20,CreateInstance = 0x200,GetField = 0x400,SetField = 0x800,GetProperty = 0x1000,SetProperty = 0x2000,InvokeMethod = 0x100,... }
其中一些枚举值的组合是无效的,如
Type.GetMembers
方法以该枚举为参数,但必须指定BindingFlags.Instance
或BindingFlags.Static
。好的做法是将该枚举值分成两个或更多个枚举或其他类型:
// 好设计 [Flags] public enum Visibilities {None = 0,Public = 0x10,NonPublic = 0x20, }[Flags] public enum MemberScopes {None = 0,Instance = 0x4,Static = 0x8, }[Flags] public enum MemberKinds{None = 0,Constructor = 1 << 0,Field = 1 << 1,PropertyGetter = 1 << 2,PropertySetter = 1 << 3,Method = 1 << 4, }public class Type {public MemberInfo[] GetMembers(MemberKinds members, Visibilities visibility, MemberScopes scope);) }
-
AVOID
: 0 不能作为标记枚举的值。(表示“清除标记”除外)
-
DO
:标记枚举的 0 值应命名为 None ,该值必须始终表示“ 所有标记均被清除 ”.[Flags] public enum BorderStyle {Fixed3D = 0x1,FixedSingle = 0x2,None = 0x0 }
0 值之所以特殊,是因为不进行赋值操作时,枚举成员默认为 0。因此在设计时要考虑到这一点。特别是,0 值应该是以下两者之一:
- 常用的默认值
- 表示错误的值,且 API 会检查这个错误值
2 给枚举添加值
-
CONSIDER
:建议为枚举添加新值,尽管要冒一点兼容性风险。如果添加新值会导致应用程序不兼容,则可以考虑添加一个新的 API 来返回新老枚举值,同时要求用户停止使用老 API(仍返回老枚举值)。
9 嵌套类型
-
DO
:当嵌套类型与其外部类型之间的关系需要 成员可访问性语义 时,才使用嵌套类型。
-
DON'T
:不要使用嵌套类型进行逻辑分组,应使用 namespace 分组。
-
AVOID
:避免公开暴漏嵌套类型。唯一的例外是:在极少数情况下,比如在子类化或其他高级的自定义场景下,需要声明嵌套类型的变量。
-
DON'T
:如果一个类不仅外层类会用,其他类也会使用,则不应该定义为嵌套类型。
-
DON'T
:不要将嵌套类型定义为 接口 的成员。许多语言不支持这种构造模式。
10 类型和程序集元数据
-
DO
:在包含公共类型的程序集中使用 CLSCompliant(true)
特性。该特性用于声明该程序集中的类型是符合 CLS 规范的,可以为所有.NET 编程语言所使用。
[assembly:ClSCompliant(true)]
-
DO
:在包含 公共 类型的程序集中使用AssemblyVersionAttribute
特性。[assembly:AssemblyVersion(...)]
-
DO
:要将下列信息特性应用到程序集中。Visual Studio 会识别这些信息,告知用户程序集内容:
[assembly:AssemblyTitle("System.Core.dll")] [assembly:AssemblyCompany("Microsoft Corporation")] [assembly:AssemblyProduct("Microsoft .NET Framework")] [assembly:AssemblyDescription(...)]
-
CONSIDER
:在程序集版号中使用、 、、的格式。 V:主版本号
S:服务版本号
B:构建号
R:构建修订号
[assembly:AssmeblyVersion("3.5.21022.8")]
附注:现如今更常用的版本控制是语义化版本 2.0.0 | Semantic Versioning (semver.org)
-
CONSIDER
:使用 ComVisible(false)
标注不允许 COM 调用。可供 COM 调用的 API 需要特别设计,不应该将.NET 程序集暴漏给 COM。如果 COM 确实需要调用该 API,可以在该 API 中或整个程序集中使用
ComVisible(true)
。
-
CONSIDER
:在程序集中使用AssemblyFileVersionAttribute
和AssemblyCopyrightAttribute
。
11 强类型字符串
-
CONSIDER
:当基类所支持的一组固定输入参数(枚举),不能满足 派生 类需要的参数时,建议使用强类型字符串。
-
DO
:要将强类型字符串声明成带有 字符串 构造函数的不可变 值 类型( readonly struct )。强类型字符串要遵循 不可变值 类型的准则
-
DO
:要允许 构造函数接收空白输入。
强类型字符串是 struct,而 struct 可以零初始化(即使用默认构造函数初始化)。构造函数接收空白输入应等效于零初始化。
-
DO
:对于已知选项,应通过 static readonly 属性 声明至该类型中。这样可以提供类似于枚举的 InteliSense 体验。
-
DO
:要覆写 ToString() 方法返回隐含的字符串值。便于调试。
-
CONSIDER
:建议通过一个 只读 属性来暴露强类型字符串所隐含的字符串值。因为
ToString()
是为调试服务的,如果开发者确实需要知道该值,使用属性获取更好。属性名没有惯例/准则,可以使用“ Value ”作为属性名。如果开发者很少或从来不用该属性,不定义属性会让这个类看起来 更像枚举 。
-
DO
:要覆写 相等 运算符。通过覆写
operator
==
,可以让强类型字符串看起来像 字符串 或 枚举 。通常,强类型字符串应利用字符串相等性来比较相等,如果强调不区分大小写,则可以通过覆写 相等 运算实现。
-
AVOID
:避免提供强类型字符串和 System.String
的重载。这种重载虽然便于调用(省去 new 一个对象的功夫),但会让新手开发者困惑。
建议:如果在原有 API 上新定义了强类型字符串,用于辅助有效输入,可以考虑将原先基于
System.String
的重载方法标记为 [EditorBrowsable(EditorBrowsableState.Advanced)]
、 [EditorBrowsable(EditorBrowsableState.Never)]
或 [Obsolete]