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

阅读数:1445 2009 年 9 月 1 日

话题:.NET编程语言C#语言 & 开发架构

在 Java 语言中,所有的方法在默认情况下都可以被子类 override,而 C# 与之相反,必须显式地将成员标记为 virtual。那么哪种做法更合理一些呢?最近,这个话题再次引发了一场讨论。

此次讨论由Ward Bell引起,他在翻阅了Roy Osherove的新书《The Art of Unit Testing》之后认为,他不同意 Roy 给出的建议“将所有的成员默认加上 virtual 修饰”。为此,他还独立开篇阐述了他的观点。在文章的一开始,他说明了 virtual 成员的优势:

(将所有成员标记为 virtual)是 Roy 在“为可测试性(testability)设计”附录中提出的第一条建议。这的确非常方便,这可以说是在测试中为一个类创建 stub 或 mock 最简单的方式了……如果每个方法都是 virtual 的,那么任何成员都可以在测试环境中替换实现……它也为各种 mock 框架提供了便利条件,mock 一个封闭的具体类要困难得多……一个包含 virtual 方法的类也容易为它创建动态代理,就像 Ayende 展示的为 POCO 对象注入 INotifyPropertyChanged 接口一样。

不过,Ward 认为将所有的方法标记为 virtual 违反了“里氏替换原则(Liskov Substitution Principle,LSP)”,它是 Bob Martin 提出的SOLID 设计原则之一,它要求“子类中的方法不能违反父类中方法制定的基础保证,它不能强化方法的前置条件,也不能削弱方法的后置条件”。Bob Martion 认为,违反了 LSP,也有极大可能违反了 SOLID 中的另一个原则“开闭原则(Open Close Principle,OCP)”,它要求系统“对扩展开放,对修改关闭”。Ward 谈到:

把每个成员标记为 virtual 相当于盲目地公开了一个类,此时没有什么是“对修改关闭”了。virutal 关键字的意义是对外部声称“这是我的扩展点”,每个方法都是 virtual 时,每个方法都可以被改变了。

Martin(即 Bob Martin)认为:“接受妥协而不是坚持完美是工程上的权衡,但是 LSP 无论如何不应该有丝毫违反”,“LSP 保证了子类可以在任何父类工作的环境中使用,这控制了系统的复杂程度,如果它被破坏,那么我们必须单独考虑每个子类”。

当我们将每个成员设为 virtual 之后,就像外部开启了一扇麻烦之门,因为我们放弃了这一保证。

同时 Ward 举了一个电梯控制程序作为例子:

我的 Elevator 类有一个 Up 方法,假设它是 virtual 的,那么一个需要增加新功能的程序员开发了一个 BetterElevator 类型,并 override 了 Up 方法。

我知道 Up 方法是让电梯向上的,但是我无法阻止别人重新实现 Up 方法让电梯下降。可能 base.Up() 会让电梯门关闭,但是子类的开发人员可能调用 base.Up 方法的时机太迟了,在门关闭之前电梯就启动了。开发人员可能会替换我的 base.Up 掩盖了关门的问题,并插入自定义的行为,但是电梯向上的行为又违反了其他一些保证。

由于缺乏细致的思考,我的开发负担提升了。由于这种不假思索的设计,我的代码错误的“开放”导致“关闭”失败。此时我就必须一一检查,手动关闭原本语言自动标为 virtual 的成员。

Ward 指出,在.NET 框架设计规范一书中,Krzysztof Cwalina 和 Brad Abram 认为:

virtual 成员……会给设计,测试和维护带来成本,因为任何对 virtual 成员的调用可以被 override 成为无法预测行为的任意代码……此时就需要更清晰地描述 virtual 成员的协议,这样设计的编写文档的成本便增加了。

最后 Ward 谈到:

把自动属性或类似的逻辑较少的方法标记为 virtual 会更安全一些,使用模板方法设计模式可以控制可扩展性,也对可测试性有帮助。基于接口的设计也不错。

在文章的评论中,Craig Cavalier发表了不同的看法

我希望我的“扩展点”不仅仅是以 virtual 方法的形式暴露出去的。正如应用程序开发人员希望可以看到一条扩展的“缝”,我更倾向于实现一个接口,而不是继承类,然后 override 它的 virtual 方法。接口比 virtual 方法更容易发现。

最后,我对 OCP 有不同的看法。我认为“对修改关闭”是指对类的代码关闭修改能力,而不是避免对类的行为作出修改。不过我也同意,把所有方法标记为 virtual 会让 LSP 原则变得脆弱。

你的论点似乎是希望保护自己,我认为有更安全的方法可以做到这一点,同时保留所有方法默认为 virtual 所带来的灵活性。

Mark Nijof 则回复道

我认为这样(代替程序员来保护他们自己)的做法是错误的,我们应该教会他们理解并正确使用合适的工具……为什么不定义一些 API 并注明这些是可以向后兼容,然后声明其他部分并不考虑兼容性呢?你应该在框架中提供清晰的扩展点,不要把会保持的行为,和可能改变的行为混合在一起。可能更好的方法是将其拆分,把不会改变的部分暴露在公开的 API 中……你也可以提供单元测试,当开发人员想要扩展你的系统时,他们可以通过单元测试来验证重要的行为并没有被打破。

Ward回应道

我并没有想要代替程序员保护他们自己,这可能很“高尚”,但是阻碍一个专家程序员的成本不能被接受。

我限制类的公开面,是为了发布高质量的代码,是为了精心设计出你可以依赖的产品。我是个框架提供者,你基于我的类型进行开发。如果你信任我的话,那么就让我来做。

这也是种封装,我关闭了部分代码,是因为它是一个纯粹的实现。通过限制访问级别,我可以尽可能地划分出我支持的扩展点,以及我自己的,以后可能会修改的实现。

你会公开一个 field 吗?当然不会,这就是“封装”。封装让你可以自由的改变,而不会影响现有的代码。

关于“默认”virtual……我关闭(seal)一个方法是因为我想保证它会按照我的说法去做事,把它设为 virtual 会破坏这种保证,让我耗费更多精力去对待它,这本可以避免。我可能会选择将其设为 virtual,但这是设计上的考虑,而不是由上层强塞给我的。

这个问题在国内社区也引发了讨论,如微软 MVP老赵在博客中认写道

对于一个可“全面扩展”的类型来说,意味着开发人员有更多的自由,进而意味着选择(即使是做同一件事情)。但是选择多,则同样意味着我们需要了解的多,一个不慎可能就会发现没有得到预期的效果。

例如,在继承了 ASP.NET 的 Control 类之后,您要改变它输出的内容,您会选择覆盖哪一个方法?看上去 Render 方法和 RenderChildren 都可以,如果随便选一个,你的类型看上去没有问题,但是如果别人希望进一步继承你写的类,补充一些实现,那么你的“选择”就会影响到他的结果了。

在.NET 中,最容易扩展扩展的抽象元素是什么呢?应该是“接口”。接口中的所有成员都是由实现方提供的,除了成员的签名之外,接口并没有作任何限制。如根据 IList 接口的隐藏协议,Add 方法调用之后,Count 必须加一。但是这个协议并无法加诸于实现之上。如果要提供这方面的约束,我们只能公开一部分的扩展点,而不是把所有的职责交给实现方。

最后,为了方便起见,我们常常会对类型中的方法给出重载,其中大部分的重载最终都委托给一个唯一的核心方法。此时那个核心方法应该成为唯一的扩展点,否则的话,用户就需要在三个方法中进行选择性的 override,并且要平衡三者的行为。在单元测试需要构造 Mock 或 Stub 时,有限制地提供扩展点则意味着“别挑了,就是这个”。 “可测试性”也是设计出来,不是语言或平台自动赋予的。

除了“设计”方面的考量之外,qiaojie 还提出virtual 之于性能的看法

性能都是从小地方体现出来的。在某些情况下不能忽视 virtual 带来的额外开销,比方说 C# 中大量使用的 property,本来因为内联的关系 property 的开销跟直接变量访问是一样的,在关键代码段里大量用 property 也没问题,但是如果是 virtual 的话,其性能要比直接变量访问差好多。

在.net 的设计原则中,性能肯定不是排第一的,但是.net 的设计又是非常实用主义的,所以我们会看到很多设计是在尽可能的兼顾到性能,比方说为了在栈上分配对象而引入 struct,甚至不惜牺牲安全性而引入指针。

我们承认 virtual 和非 virtual 成员函数都有存在的价值,所以默认是不是 virtual 只是一个无足轻重的问题,就好比讨论 C++ 的成员函数要不要默认是 const 的呢?那么这个时候如果要兼顾性能的话,显然应该默认为非 virtual 的。而 java 的设计哲学里性能是从来不被做为重要因素来考量的,所以默认为 virtual 也是可以理解的。

除了上述讨论之外,Oren Eini 也为这个话题开辟了新的战场。其中许多人发表了自己的看法,并且其中为数不少独立开篇发表了自己的看法。此外,有人也提到 C# 语言设计这Anders Hejlsberg在一次访谈中阐述他为什么不希望让所有成为都变成 virtual。

您的看法呢?