写点什么

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

2009 年 9 月 01 日

在 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。

您的看法呢?

2009 年 9 月 01 日 09:021685
用户头像

发布了 157 篇内容, 共 43.8 次阅读, 收获喜欢 2 次。

关注

评论

发布
暂无评论
发现更多内容

跟我一起基于 Karma 搭建一个测试环境 (中)

Jack Q

前端进阶训练营 Karma 测试框架搭建

学习总结 -- Week 10

吴炳华

Newbe.Claptrap 框架如何实现在多种框架之上运行?

newbe36524

Docker 云计算 微服务 .net core ASP.NET Core

精美前端UI(VUE)界面,ASP.NET通用工作流开发分享

雯雯写代码

工作流 可视化

TOGAF认证不只一个,您考的是哪个?

周金根

SpringCloud(Netflix)-技术专题-微服务入门介绍

李浩宇/Alex

Web 全栈开发利器: 强大的在线 Cloud IDE

华为云开发者社区

Web python3.x 全栈 编码 CloudIDE

今天给二叉树加个BGM,二叉树唱歌了!

我是程序员小贱

为什么你做的 Excel 表不好用?

Tony Wu

效率工具 产品设计 Excel ER图

对待一件事,从不喜欢再到喜欢,转变需要多大

良知犹存

程序人生

优化教育体验 智微智能高品质录播系统

InfoQ_967a83c6d0d7

k8s-client-go源码剖析(一)

LanLiang

go 开源 Kubernetes 容器 源码剖析

troubleshoot之:GC调优到底是什么

程序那些事

性能分析 jvm调优 GC调优

TypeScript 设计模式之观察者模式

pingan8787

typescript 前端 设计模式

平时开发Git常用的小技巧

zui.zhang

git rebase

webbench源码阅读

我是程序员小贱

gRPC在Spring Cloud中的应用

xcbeyond

Java gRPC SpringCloud

Spring Boot Actuator微服务服务监控

xcbeyond

Java 微服务 springboot actuator 服务监控

为什么使用Portainer,而不是Docker CLI来管理Docker环境

xcbeyond

Docker 运维 Portainer

直播技术的背后--RTMP协议

soolaugust

直播 RTMP

数字货币钱包开发方案,加密货币钱包搭建

WX13823153201

数字货币钱包开发

计算机网络基础(十九)---传输层-TCP的拥塞控制

书旅

TCP 协议栈 网络层

深挖502和504

书旅

nginx 服务器 HTTP 状态码

老张「原创小说」

瓜藤老祖

个人成长

从根上学习Git

书旅

git 工具 版本控制 版本管理工具

2020大厂web前端面试常见问题总结

华为云开发者社区

CSS 响应式 浏览器 面试题 web前端

修改系统时间,导致 sem_timedwait 一直阻塞的问题解决和分析

小林coding

Linux 编程 问题处理

瀑布模型总结

我是程序员小贱

Linux后台开发高频题目总结

我是程序员小贱

误执行 rm -fr /*,我删删删删库了,要跑路吗?

小林coding

Linux 程序人生 Shell linux命令

「C++ 篇」答应我,别再 if else 走天下了可以吗

小林coding

c++ 编程 设计模式 编程习惯 编程风格

微服务架构下如何保证事务的一致性

微服务架构下如何保证事务的一致性

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