成员设计
建议90:不要为抽象类提供公开的构造方法
首先,抽象类可以有构造方法。即便没有为抽象类指定构造方法,编译器也会为我们生成一个默认的protected的
构造方法。下面是一个标准的最简单的抽象类:
|
|
其次,抽象类的方法不应该是public或internal
的。抽象类设计的本意是让子类去继承,而不是用于生成实例对象的。如果抽象类是public或者internal
的,它对于其他类型来说就是可见的,而这时是不必要的,也是多余的。简而言之,抽象类只需要对子类可见就行了;
建议91:可见字段应该重构为属性
字段和属性的本质区别就是,属性是方法;
以下面这段代码为例, 是一个Person类型:
|
|
经过编译器编译后,针对属性Name实际会生成一个private字段和两个public方法:
使用dnspy
进行反编译并进行查看:
可见属性实际上是编译器给我们的语法糖。
属性比字段具有以下优势:
- 可以为属性添加代码。正是因为属性是方法,所以可以在方法内对设置或获取属性的过程进行更多精细化控制。例如:为属性添加
NameChanged
事件等。单凭字段是完成不了这样的功能的。 - 可以让属性支持线程安全。要让属性变成线程安全的可以让类型自身去实现。而要让字段支持线程安全,就只能靠调用者本身来实现了。
- 属性得到VS编译器的支持,还得到了实现自动属性这种功能。自动属性的特点在LINQ中得到了广泛应用,尤其是在匿名类中,它只能实现只读的自动属性,而不支持字段。
- 从设计角度,也就是面向对象角度来看,公开的字段也应该使用属性。改变字段的状态,类型不会被通知到;而改变属性的值,类型支持则会被通知。
综上所述,如果一个类型存在一个可见字段,那么它应该被重构为属性。当然,如果某个属性仅仅对内部可见,而不涉及以上4点内容,则建议使用字段。
建议92:谨慎地将数组或者集合作为属性
数组或集合作为属性会引起这样一个问题:如果属性是只读的,我们通常会认为它是不可以改变的,但是如果将只读属性应用于数组或者集合,而元素的内容和数量却仍旧可以随意改变;
|
|
在上面的代码中,我们可以随意对Employees进行集合操作,它不改变的只是自身的引用而已。
如果某个类型含有集合概念的属性,那么他的可见性应该是private或protected,并且,它更应该是一个字段。类型对外只公开必要的方法来操作这个集合。
建议93:构造方法应初始化主要属性和字段
类型的属性应该在构造方法调用完毕前完成其初始化工作。如果字段没有在初始化器中设置初始值,那么它就应该在构造方法里面初始化。类型一旦被初始化,那么就应该被认为具有完整的行为和属性。
|
|
在构造方法中,必须首先为CEO赋值;因为只要存在公司实体,就应该做到存在一名CEO;specialA
的初始化如下所示:
Employee specialA = new Employee() {Name = "Mike"};
上面演示了一个字段初始化。实际上,初始化器也属于编译器的语法糖,它在经编译后,在构造方法的最开始处执行。也就是说,可以将初始化器理解为构造方法的一部分。
类型的其他引用类型字段也应该在构造器中初始化,比如specialB
,因为需要保证类型的其他地方用到该字段的时候不会因为它是null而产生混淆。
建议94:区别对待override和new
override和new使类型体系因为继承而呈现出多态性。多态要求子类具有与基类同名的方法,override和new的作用就是:
- 如果子类中的方法前面带有new关键字,则该方法被定义为独立于基类的方法;
- 如果子类中的方法带有override关键字,则子类的对象将调用该方法,而不调用基类的方法;
我们先来看一下继承体系的Demo:
|
|
Shape是所有子类的父类,Circle类override父类的MethodVirtual,所以即便子类转型为Shape, 调用的还是子类的方法:
|
|
输出也为:r
circle override MethodVirtual base method call
|
|
circle override MethodVirtual base method call
类型Rectangle没有对基类做任何处理,所以无论是否转型为Shape,调用的都是基类Shape的方法。 类型Triangle将基类Shape的virtual方法和非virtual方法都new了一般,所以第一种方法为:
|
|
因为子类通过new了父类的方法,故子类方法和基类方法完全没有关系了,只要s被转型为Shape,针对s调用的都是父类方法。
|
|
调用的都是子类的方法,输出为:
triangle new MethodVirtual triangle new Method
类型Diamond包含了两个和基类一模一样的方法,并且没有额外的修饰符。这在编译器中会提出警示。但是如果选择忽略这些警示,程序还是一样可以运行。
|
|
编译器会默认new的效果,所以输出和显示设置为new时一样。
输出为:
base MethodVirtual call base Method call
|
|
输出为:
Diamond default MethodVirtual Diamond default Method
建议95:避免在构造方法中调用虚成员
在构造方法中调用虚方法会带来一些意想不到的错误,虽然这种方法不常见,但是还是需要注意这类陷阱;
|
|
Object reference not set to an instance of an object.
运行会出现异常NullReferenceException:未将对象引用设置到对象的实例。
在调用者代码中,我们需要创建一个American的实例对象american。由于发现实例还存在一个基类Person,所以运行时会首先调用基类的构造方法。在构造方法中Person调用了虚方法InitSkin。由于是虚方法,所以会在运行时调用子类的InitSkin方法。子类的InitSkin方法中,需要打印出名字。而这个时候,方法的调用堆栈还一直在基类的构造方法内,也就是在子类的构造方法中的代码还完全没有执行:
Race = new Race() { Name = "White" };
所以会抛出异常。
基于以上原因,建议不要在构造方法中调用虚成员。
建议96:成员应优先考虑公开基类型或接口
类型成员如果优先考虑公开基类型或接口,那么会让类型支持更多的应用场合。
FCL
中最典型的例子是集合的功能操作。集合根据功能划分有多种类型,比如List<T>、Dictionary<TKey,TValue>、HashSet<T>
等。以一个最简单的操作Empty(清空集合)为例。该功能要求我们删除集合中的所有元素,然后返回一个干净的集合。如果不返回基类型或接口的话,则要求我们为每一个集合类型都实现一个这样的方法。
微软在FCL
中实现了这样一个静态类型Enumerable,它有个静态方法:
|
|
因为使用了泛型接口IEnumerable
,所以现在所有的集合子类都可以实现自己的Empty方法了。我们应该体会这种编程模式带来的好处,并在自己的项目中灵活运用。
建议97:优先考虑将基类型或接口作为参数传递
除了公开基类型或接口外,方法的参数也应该考虑基类型或接口。
以Enumerable类型为例,它的成员方法中只要涉及需要操作集合对象的地方,都要使用IEnumerable
泛型接口,比如:
|
|
该方法用于获取集合指定数量的一个子集,正是因为存在这个扩展方法,我们才可以对所有的泛型集合进行Take操作;
建议98:用params
减少重复参数
如果方法的参数数目不定,且参数类型一致,则可以使用params
关键字减少重复参数的声明;
|
|
这里的3个方法可以合并成一个方法:
void Method(string str, params object[] a) { }
建议99:重写时不应使用子类参数
重写时,如果使用了子类参数,可能会偏离设计者的预期目标。比如,存在一个如下继承体系:
|
|
现在,类型ManagerSalary
中的SetSalary
方法重写了Salary中的相同方法,重写的方法采用一个子类参数:
|
|
调用者的代码看起来如下:
|
|
设计者的本意是要设置经理的薪水,可是实际调用的代码却是设置了员工的薪水。
输出为:职员被设置了薪水。
所以,在重写时,使用子类参数有一定风险,应当避免这种设计。正确的方法应当仍旧使用Employee类型参数,这起码能让编译器提醒我们要使用new关键字。
建议100:静态方法和实例方法没有区别
静态方法在加载时机和内存使用上和实例方法完全一致。
在这里,我们先引出一个概念“类型对象”。比如类型Person,我们都知道new Person() 会产生一个对象,这个对象叫做“实例对象”,它在运行时会加载到GC Heap
上。而“类型对象”是指代表Person类型本身的那个对象,这个对象在第一次使用类型时被加载到Loader Heap
上。类型对象包括其自身的指针、自身的同步索引块、静态字段,以及一个方法表。在这个方法表中,无论是静态方法还是实例方法都会被存储起来,当然,存储的是方法的记录项,方法本身是在调用时由运行时编译的。类型对象和实例对象在内存中的分布如下:
如果一定要说静态方法和实例方法的区别,那它们之间唯一的区别就是,当我们需要使用实例方法的时候,首先应该有实例对象。我们不能绕开实例对象,直接从类型本身去调用实例方法。所以,从设计的角度来说,如果一个方法只跟类型本身有关系,那么它就应该被设计成静态方法,如果跟类型的实例对象有关系,那它就应该被设计成实例方法。
静态方法被不少人误解的地方有:静态方法天然就是同步方法。即使是那些有一定开发经验的程序员,有时候也会犯这种常识性的错误。尽管微软声称FCL中大部分代码都被实现成线程安全了,但并不意味着代码天然就是同步的,要让静态方法线程安全,必须由程序员编写同步代码,而不是让编译器或运行时为我们做这些事情。
要从设计的角度去理解静态方法和实例方法。离开了设计,它们没有区别。
建议101:使用扩展方法,向现有类型“添加”方法
考虑如何让一个sealed类型具备新的行为。以往我们会创建一个包装器类,然后为其添加方法,而这看上去一点儿也不优雅。我们也许会考虑修改设计,直接修改sealed类型,然后为其发布一个新的版本,但这依赖于你拥有全部的源码。更多的时候,我们会采取针对第三方公司提供的API进行编程的方式。对于我们来说,FCL是一组第三方公司(微软)提供给我们的最好的API。
包装类的编码形式如下:
|
|
可以看到,Student类型只提供了一个GetSex
方法,它返回了一个bool
值的结果。我们需要的是要将一个bool
值转换为一个字符串,StudentConverter
就是为了满足需求而创建的一个包装器类。调用者的代码看起来就应该是这样的:
Console.WriteLine(StudentConverter.GetSexString(student));
但是我们知道,可以有更优美的形式让调用者像调用Student类型的实例方法一样来调用GetSexString
了。这种更好的方式就是扩展方法:
|
|
扩展方法除了让调用着可以像调用类型自身的方法一样去调用扩展方法外,它还有一些其他的主要优点:
- 可以扩展密封类型;
- 可以扩展第三方程序集中的类型;
- 扩展方法可以避免不必要的深度继承体系。
扩展方法还有一些必须遵循的要求:
- 扩展方法必须在静态类中,而且该类不能是一个嵌套类;
- 扩展方法必须是静态的;
- 扩展方法的第一个参数必须是要扩展的类型,而且必须加上this关键字;
- 不支持扩展属性、事件。
值得注意的一点是,扩展方法还能够扩展接口。这让接口看上去也是可以扩展的。扩展方法的这个特性被广泛应用于提供LINQ
查询功能的Enumerable
类和Queryable
类中。以Enumerable为例,针对IEnumerable<T>
接口提供了非常丰富的一种方法,如Select:
|
|
它相当于让继承自IEnumerable<T>
接口的任何子类都拥有了Select方法,而这些Select方法在用者看来,就好像是IEnumerable<T>
接口所声明的一样。