代码改变世界

所有的成员都应该是virtual的吗?

2009-08-28 10:56  Jeffrey Zhao  阅读(...)  评论(... 编辑 收藏

这是一个由来已久的讨论,由于Java默认所有的方法都是可以被override的(除非手动写成final),因此从C#语言设计起初就有此番争论,甚至让Anders都出来解释了一下。最近又有人在讨论这方面话题了,虽然我的看法并没有超出这些人所涉及的范畴,但是我还是打算谈一下我的理解。退几步说,就当补充一些“实例”吧。

此次的话题是由Ward Bell引起的,他在review了Roy Osherove的新书《The Art of Unit Testing》之后认为,他不同意Roy给出的建议“将所有的成员默认为virtual”,为此他还独立开篇解释了他的观点。这篇文章引起的讨论较为热烈,我也打算在详细总结一番。与Ward观点对应的是,著名的Jeremy Miller希望.NET中所有的成员默认就是virtual的,而“月写博客80篇”的Oren Eini甚至认为所有的成员都应该标为virtual

继承一个类并override掉其中的成员,是面向对象编程中最常用的方式之一。这是一种扩展方式,而能够被override的方法便是“扩展点”。所以我认为,是否把成员标记为virtual,其实涉及到的概念便是“是否把它开放为一个扩展点”。Oren认为“所有成员都应该virtual”则意味着“任何成员都是可扩展点”,而对于“默认为virtual”的观点来说,则意味着“倾向于打开更多的扩展点”——其实除了Oren有些极端外,“倾向性”代表的更多是一种“口味”,因为无论是Java还是.NET,都可以标记一个成员能否被override。

我不想讨论“口味”问题,不过我的观点与Ward类似,即使在C#出现之前,我也一直不太喜欢Java的这个特性(不过当时相关体会比较少,所以感觉并不强烈)。Oren认为打开更多扩展点,有助于从各方面进行扩展,他说他的这个做法也过于也得到了较多的“实惠”。不过我认为,这是由于Oren的能力过于厉害,并且知道该做什么不该做什么,并且可以对自己作的事情所负责决定的。

对于一个可“全面扩展”的类型来说,意味着开发人员有更多的自由,进而意味着选择(即使是做同一件事情)。但是选择多,则同样意味着我们需要了解的多,一个不慎可能就会发现没有得到预期的效果。例如,在继承了ASP.NET的Control类之后,您要改变它输出的内容,您会选择覆盖哪一个方法?

protected internal virtual void Render(HtmlTextWriter writer)
{
    this.RenderChildren(writer);
}

protected internal virtual void RenderChildren(HtmlTextWriter writer)
{
    ...
}

您可能会说,覆盖哪个都可以。但是,它们其实都有不同的语义,只是因为在Control基类中Render自身就是Render所有的子控件。但是到了子类中,Render自身可能就会涉及到边框等其他内容了。如果您随便选一个,您的类型看上去没有问题,但是如果别人希望继承你写的类,补充一些实现,那么你的“选择”就会影响到他的结果了。当然我并不是说Control类设计的不对,它的设计我觉得是正确的,我只是想说明,如果每个成员都可扩展,那么用户在真正需要扩展的时候就会比较麻烦了。

架构是一种扩展,也是一种约束,限制了别人可以怎么做,也必须怎么做。我们虽然无法避免别人的恶意行为,但是良好的扩展点也可以给别人更好的指导。

再举一个例子。在.NET中,最容易扩展扩展的抽象元素是什么呢?应该是“接口”。接口中的所有成员都是由实现方提供的,除了成员的签名之外,接口并没有作任何限制。正如我在之前写过的一篇文章里提到,别人完全可以实现出外强中干的对象:

public interface IList<T>
{
    void Add(T item);
    int Count { get; }
 
    ...
}

根据接口中隐含的协议,Add方法调用之后,Count必须加一。但是这个协议并无法加诸于实现之上。如果要提供这方面的约束,我们只能公开一部分的扩展点,而不是把所有的职责交给实现方:

public abstract class ListBase<T>
{ 
    public int Count { get; private set; }
 
    public void Add(T item)
    {
        this.Count++;
        this.AddCore(item);
    }
 
    protected abstract void AddCore(T item);
 
    ...
}

Ward也举了一个“电梯”的例子。电梯有一个Up方法,调用它则意味着电梯上升。但是如果把Up这个关键行为扩展出去,那么别人在“修改电路”(即override这个Up方法)的时候,可能就会把电梯搞乱套了,例如原本应该先关门再启动,现在可能先启动再关门,甚至一旦Up电梯就下降了。Oren认为扩展方应该为自己的扩展负责,但是我还是认为,扩展点应该和成员访问级别等东西一样,只给出必要的,控制住关键的。

最后的例子也是常见的:

public class SomeClass
{
    public void SomeMethed()
    {
        this.SomeMethed(String.Empty);
    }

    public void SomeMethed(string s)
    {
        this.SomeMethod(s, 0);
    }

    public virtual void SomeMethod(string s, int i)
    {
        // ...
    }
}

为了方便起见,我们常常会对类型中的方法给出重载,其中大部分的重载最终都委托给一个唯一的核心方法。当用户继承SomeClass类之后,他便拥有了一个唯一的扩展点,这样便可以确保这个类的行为按照一定的“准则”在正常开展。否则的话,用户就需要在三个方法中进行选择性的override,并且要平衡三者的行为。因为在扩展SomeClass的时候,并不知道SomeClass的使用者会调用SomeMethod的哪个重载。

这对于单元测试一样。如果三个方法都可以Mock,那么在测试时我们可能就会去“猜测”用户究竟调用了哪个SomeMethod重载,而这是不确定的,也是容易变化的。如果我们只有一个重载可以Mock,那么则意味着“别挑了,就是这个”。所以,我有时候也不太喜欢Type Mock如此强大有力的Mock框架,因为它可能会破坏了被测试方的设计,把一切都变成了扩展点——虽然这对于测试来说的确很方便,几乎不输给动态语言了。

“可测试性”也是设计出来,不是语言或平台自动赋予的。这就是“design for testability”的体现之一吧。

您的观点呢?