成员设计

所谓成员,是指类型的字段和方法。在成员设计时掌握一定的原则,可以规避一些陷阱。本章将从最通用的角度阐述一些开发人员在进阶过程中会遇到的瓶颈。这些瓶颈有一部分是设计原则方面的问题,有一部分则需要大家仔细体会并练习,否则代码中就会出现Bug。部分案例可能不常用,但有助于大家加深对面向对象成员设计的理解。

建议90 : 不要为抽象类提供公开的构造方法

首先,抽象类可以有构造方法。即使没有为抽象类指定构造方法,编译器也会为我们生成一个默认的protected构造方法。

其次,抽象类的构造方法不应该是public或internal的。抽象类设计的本意只能让子类继承,而不是用于生成实例对象的。如果抽象类是public或internal的,它对于其他类型来说就是可见的,而这是不必要的,也是多余的。换句话说,抽象类只需对子类可见就行了。

建议91 : 可见字段应该重构为属性

字段和属性有本质的区别。这个本质的区别就是:属性是方法。

查看下面这个类型Person:

class Person
{
   public string Name { get; set; }
}

经过编译器的编译后,针对属性Name,实际会生成一个静态字段和两个方法(get,set).

从这里就可以知道,属性实际上是编译器抛给我们的语法糖。属性相比较于字段,具有如下优势:

  • 1)可以为属性添加代码。正因为属性是方法,所以可以在方法内对设置或获取属性的过程进行更多精细化的控制。常见的如:可以为属性添加NameChanged事件支持等。单凭字段本身是完成不了这样的功能的。
  • 2)可以让属性支持线程安全。要让属性变成线程安全的,可以让类型自身去实现。而要让字段支持线程安全,就只能靠调用者本身来实现了。
  • 3)属性得到了VS编辑器的支持,还得到了实现自动属性这种功能。自动属性的特点在LINQ中得到了广泛应用,尤其是匿名类型中,它只能实现只读的自动属性,而不支持字段。
  • 4)从设计的角度,也就是面向对象的角度来看,公开的字段也应该使用属性。改变字段的状态,类型不会被通知到:而改变属性的值,类型支持则会被通知。

综上所述,如果一个类型存在一个可见字段,那么它应该被重构为属性。当然,如果某个属性仅仅对内部可见,而且不涉及上面这4点内容,则建议使用字段。

建议92 : 谨慎将数组或集合作为属性

数组和集合作为属性存在会引起这样的一个分歧:如果属性是只读的,我们通常会认为它是不可改变的,但是如果将只读属性应用于数组和集合,而元素的内容和数量却仍旧可以随意更改。我们可以随意对Employees进行集合操作,它不变的只是自身的引用而已。

如果某个类型含有集合概念的属性,那么它的可见性应该为private或protected,并且,它更应该是一个字段。类型对外只公开必要的方法来操作这个集合。但是话又说回来,如果一定要将某个数组或集合设置为属性,那么应考虑将其置为只读,参见建议25。

建议93 : 构造方法应初始化主要属性和字段

类型的属性应该在构造方法调用完毕之前完成初始化工作。如果字段没有在初始化器中设置初始值,那么它就应该在构造方法中初始化。类型一旦被实例化,那么它应该被视为具有完整的行为和属性。就好比,一旦一只健康的猫来到这个世界上,那它就应该具备猫爪和猫尾巴,而不是在查看这只猫的尾巴时得到一个null。

Employee specialA = new Employee() { Name = "Mike" };

上面所演示的是-一个字段初始化器。实际上,初始化器也属于编译器的语法糖,它在经编译后,在构造方法的最开始处执行。也就是说,可以将初始化器理解为构造方法的一部分。类型的其他引用类型字段也应该在构造器中初始化,比如specialB,因为需要保证类型的其他地方在用到该字段的时候不会因为它是null而产生混淆。

建议94 : 区别对待override和new

override和new使类型体系因为继承而呈现出多态性。多态是判断一门语言是否是“面向对象语言”的三个重要特性之一。多态要求子类具有与基类方法同名的方法,而override和new的作用就是:

  • 如果子类中的方法前面带有new关键字,则该方法被定义为独立于基类的方法。
  • 如果子类中的方法前面带有override关键字,则子类的对象将调用该方法,而不是调用基类方法。
public class Shape
{
    public virtual void MethodVirtual()
    {
        Console.WriteLine("base MethodVirtual call");
    }

    public void Method()
    {
        Console.WriteLine("base Method call");
    }
}

class Circle : Shape
{
    public override void MethodVirtual()
    {
        Console.WriteLine("circle override MethodVirtual");
    }
}

class Rectangle : Shape
{

}

class Triangle : Shape
{
    public new void MethodVirtual()
    {
        Console.WriteLine("triangle new MethodVirtual");
    }

    public new void Method()
    {
        Console.WriteLine("triangle new Method");
    }
}

class Diamond : Shape
{
    public void MethodVirtual()
    {
        Console.WriteLine("Diamond default MethodVirtual");
    }

    public void Method()
    {
        Console.WriteLine("Diamond default Method");
    }
}

上面的继承体系中: 类型Shape是所有子类的基类.

Circle类override父类的MethodVirtual,所以即使子类转型为Shape,调用的还是子类的方法.

类型Rectangle没有对基类做任何处理,所以无论子类是否转型为Shape,调用的都是基类Shape的方法。

类型Triangle将基类Shape的virtual方法和非virtual方法都new了一遍.因为子类已经new了父类的方法,故子类方法和基类方法完全没有关系了,只要s被转型为Shape,则针对s调用的都是父类方法。

类型Diamond包含了两个和基类一模一样的方法,并且没有额外的修饰符。这在编辑器中会提出警示。但是如果选择忽略这些警示,程序一样还是可以运行。

建议95 : 避免在构造方法中调用虚成员

在构造方法中调用虚成员会带来一些意想不到的错误,虽然这种用法不常见,但还是需要注意这类陷阱。

static void Main()
{
    American american = new American();
    Console.ReadKey();
}

class Person
{
    public Person()
    {
        InitSkin();
    }

    protected virtual void InitSkin()
    {
        //省略
    }
}

class American : Person
{
    Race Race;

    public American() : base()
    {
        Race = new Race() { Name = "White" };
    }

    protected override void InitSkin()
    {
        Console.WriteLine(Race.Name);
    }
}

class Race
{
    public string Name { get; set; }
}

运行该示例,会出现异常NullReferenceException:未将对象引用设置到对象的实例。

在调用者代码中,我们需要创建一个American的实例对象american。由于发现类型还存在一个基类Person,所以运行时会首先调用基类的构造方法。在该构造方法中,Person调用了虚方法InitSkin。从上一个建议的说明我们知道,由于是虚方法,所以会在运行时调用子类的InitSkin方法。在子类的InitSkin方法中,需要打印出来种族名。而这个时候,方法的调用堆栈还一直在基类的构造方法内,也就是在子类的构造方法中下面这行代码还完全没有执行:Race = new Race() { Name = "White" };

现在我们应该已经理解了为什么这段看上去没有问题的代码在运行时会抛出异常。

基于以上的原因,建议不要在构造方法中调用虚成员。

建议96 : 成员应优先考虑公开基类型或接口

类型成员如果优先考虑公开基类型或接口,那么会让类型支持更多的应用场合。

FCL中最典型的相关例子是集合的功能操作。我们都知道在FCL中,集合根据功能划分有多种类型,比如List<T>Dictionary<TKey, TValue>HashSet<T> 等。以一个最简单的操作Empty (清空集合)为例。该功能要求我们删除集合中的所有元素,然后返回一个干净的集合。如果不返回基类型或接口的话,则要求我们为每一个集合类型都实现一个这样的方法。这是很恐怖的。

现在,微软在FCL中实现了这样一个静态类型Enumerable,我们可以找到对应的代码:

public static IEnumerable<TResult> Empty<TResult>()
{
   return EmptyEnumerable<TResult>.Instance;
}

因为使用了泛型接口IEnumerable,所以现在所有的集合子类都可以不实现自己的Empty方法了。我们应体会这种编程模式带来的好处,并在自己的项目中灵活运用。

建议97 : 优先考虑将基类型或接口作为参数传递

除了公开基类型或接口外,基于同样的道理,方法的参数也应该考虑基类型或接口。还是以Enumerable类型为例,它的成员方法中只要涉及需要操作集合对象的地方,在参数.上都要使用IEnumerable泛型接口,比如:

public static IEnumerable<TSource> Take<TSource>(this IEnumerable<TSource> source,int count)
{
   if (source == null)
   {
      throw Error.ArgumentNull (" source") ;
   }
   return TakeIterator<TSource> (source, count) ;
}

该方法用于获取集合指定数量的一个子集。正是因为存在这个扩展方法,我们才可以对所有的泛型集合类型进行Take操作。

建议98 : 用params减少重复参数

如果方法的参数数目不定,且参数类型一致,则可以使用params关键字减少重复的参数声明。代码如下所示:

void Method1 (string str, object a){}
void Method2 (string str, object a,object b){}
void Method3 (string str, object a, object b,object c){}
// 此处的三个方法可以合并成-一个方法:
void Method(string str, params object[] arga){}

建议99 : 重写时不应使用子类参数

重写时,如果使用了子类参数,可能会偏离设计者的预期目标。比如,存在一个如下继承体系:

class Employee
{
}

class Manager : Employee
{
}

现在,类型ManagerSalary中的SetSalary方法重写了Salary 中的相同方法,重写的方式是采用一个子类参数,代码如下所示:

class Salary
{
    public void SetSalary(Employee e)
    {
        Console.WriteLine("职员被设置了薪水。");
    }
}

class ManagerSalary : Salary
{
    //   public new void SetSalary(Employee m)
    public void SetSalary(Manager m)
    {
        Console.WriteLine("经理被设置了薪水。");
    }
}

调用者的代码看起来如下:

ManagerSalary m = new ManagerSalary();
m.Setsalary (new Employee());
// 码输出如下:
// 职员被设置了薪水。

设计者的本意是要设置经理的薪水,可是实际调用的代码却设置了员工的薪水.

所以,在重写时使用子类参数有一定的风险,应当避免这种设计。正确的方法应当仍旧使用Employee类型参数,这起码能让编辑器提醒我们要使用new关键字。

建议100 : 静态方法和实例方法没有区别

静态方法和实例方法有什么区别?首先我们应该坚决地、肯定地下一个结论:它们之间没有区别。

静态方法在加载时机和内存使用上与实例方法完全一致。在这里,我们先引出一个概念“类型对象”。比如类型Person,我们都知道new Person(会产生一个对象,这个对象叫做“实例对象”,它在运行时会加载到GC Heap上。而“类型对象”是指代表Person类型本身的那个对象,这个对象在第一次使用类型时被加载到LoaderHeap上。类型对象包括其自身的指针、自身的同步索引块、静态字段,以及一个方法表。在这个方法表中,无论是静态方法还是实例方法都会被存储起来,当然,存储的是方法的记录项,方法本身是在调用时由运行时编译的。类型对象和实例对象在内存中的分布情况如图7-1所示。

如果一定要说静态方法和实例方法有区别,那它们之间唯一的区别就是,当我们需要使用实例方法的时候,首先应该有实例对象。我们不能绕开实例对象,直接从类型本身去调用实例方法。所以,从设计的角度来说,如果一个方法只跟类型本身有关系,那么它就应该被设计成静态方法,如果跟类型的实例对象有关系,那它就应该被设计成实例方法。

静态方法被不少人误解的地方还有:静态方法天然就是同步方法。即便是那些有一定开发经验的程序员,有的时候也会犯这种常识性的错误。尽管微软声称FCL中大部分代码都被实现成线程安全的了,但并不意味着代码天然就是同步的,要让静态方法线程安全,必须由程序员编写同步代码,而不是让编译器或运行时为我们做这些事情。

要从设计的角度去理解静态方法和实例方法。离开了设计,它们没有区别。

建议101 : 使用扩展方法,向现有类型“添加”方法

考虑如何让一个sealed类型具备新的行为。以往,我们会创建一个包装器类,然后为其添加方法,而这看上去一点儿也不优雅。我们也许会考虑修改设计,直接修改sealed类型,然后为其发布一个新版本,但这依赖于你拥有全部的源码。更多的时候,我们会采取针对第三方公司提供的API进行编码的方式。对于我们来说,FCL是一组第三方公司(微软)提供给我们的最好的API。

包装器类的编码形式如下:

class Program
{
   static void Main(string[] args)
   {
       Student student = new Student();
       Console.WriteLine(StudentConverter.GetSexString(student));
   }
}

public static class StudentConverter
{
   public static string GetSexString(Student student)
   {
       return student.GetSex() == true ? "男" : "女";
   }
}

public class Student
 {
     public bool GetSex()
     {
         return false;
     }
 }

可以看到,Student 类型只提供了一个GetSex方法,它返回了一个布尔值的结果。我们的需求是要将该布尔值转为一个字符串,StudentConverter 就是为了满足该需求而创建的一个包装器类。调用者的代码看起来就应该是这样的:

Console.WriteLine(StudentConverter.GetSexstring(student)) ;

但是我们现在知道,可以有更优美的形式让调用者像调用Student类型的实例方法一样来调用GetSexString了。这种更好的方式就是扩展方法,代码如下所示:

class Program
{
    static void Main(string[] args)
    {
        Student student = new Student();
        Console.WriteLine(student.GetSexString());
    }
}

public static class StudentExtension
{
    public static string GetSexString(this Student student)
    {
        return student.GetSex() == true ? "男D" : "女?";
    }
}

扩展方法除了让调用者可以像调用类型自身的方法一样去调用扩展方法外(见Main方法中的调用代码),它还有一些其他的主要优点,如下所示:

  • 可以扩展密封类型:
  • 可以扩展第三方程序集中的类型;
  • 展方法可以避免不必要的深度继承体系。

当然,扩展方法还有一些必须遵循的要求:

  • 扩展方法必须在静态类中,且该类不能是一个嵌套类;
  • 扩展方法必须是静态的
  • 扩展方法的第一个参数必须是要扩展的类型,而且必须加上了this 关键字;
  • 不支持扩展属性、事件。

值得注意的一点是,扩展方法还能够扩展接口。这让接口看上去也是可以被扩展的(见建议102)。扩展方法的这个特性被广泛应用于提供LINQ查询功能的Enumerable类和Queryable类中。以Enumerable为例,针对IEnumerable<T>接口提供了非常丰富的一组方法,如方法Select:

public static IEnumerable<TResult> Select<TSource, TResulto (this I Enumerable<TSource> source, FunceTSource, int, TResult> selector)
{
}

它相当于让继承自IEnumerable<T>接口的任何子类型都拥有了Select方法,而这些Selct方法在调用者看来,就好像是IEnumerable<T>接口所声明的一样。