为什么C#默认情况下将方法实现为非虚拟的?

107
与Java不同的是,为什么C#默认将方法视为非虚函数?这更可能是性能问题而不是其他可能的结果吗?
我想起了Anders Hejlsberg的一段话,他提到现有架构带来的几个优点。但是,副作用怎么样呢?默认情况下使用非虚方法真的是一个好的权衡吗?

1
提到性能原因的答案忽略了一个事实,即C#编译器大多将方法调用编译为callvirt而不是call。这就是为什么在C#中不可能有一个方法,如果this引用为空,则会表现出不同的行为。更多信息请参见此处 - Andy
真的!call IL 指令主要用于对静态方法的调用。 - RBT
1
C# 的架构师 Anders Hejlsberg 在这里和这里分享了他的想法。 - RBT
10个回答

101

类应该被设计为可以继承以便能够充分利用它。默认情况下使每个类中的函数都是virtual意味着类中的每个函数都可以被替换,这不是一件好事情。许多人甚至认为类应该默认为sealed

virtual方法也可能会有轻微的性能影响,不过这不太可能是主要原因。


7
就我个人而言,我对性能部分有所怀疑。即使是针对虚函数,编译器也非常能够确定在IL代码中何时用简单的“call”来替换“callvirt”(甚至在JITting更下游时)。Java HotSpot也是如此。答案的其余部分是非常准确的。 - Konrad Rudolph
5
我同意性能并不是最重要的,但它仍然可能会对性能产生影响。这种影响可能非常小。当然,这不是做出这个选择的唯一原因。我只是想提一下这一点。 - Mehrdad Afshari
74
密封类让我想揍婴儿。 - mxmissile
6
@mxmissile: 我也是这样想,但好的API设计本来就很难。我认为默认封闭在你拥有代码时是完全合理的。往往情况是,在你需要使用的那个类被另一个程序员实现时,他们并没有考虑继承,而默认封闭会很好地传达这个信息。 - Roman Starkov
52
许多人也是精神变态者,但这并不意味着我们应该听从他们的意见。在C#中,默认情况下应使用虚拟代码,而无需更改实现或完全重写即可扩展代码。过于保护API的人最终往往会面临API死亡或被半数使用的情况,这比某些人误用API的情况要糟糕得多。 - Chris Nicola
显示剩余13条评论

92

我很惊讶这里似乎有一个共识,即默认非虚拟是正确的方法。但我会站在另一边 - 我认为是务实的一边。

大多数理由对我来说都像是旧的“如果我们给你权力,你可能会伤害自己”的论点。来自程序员?!

对我来说,那些不了解足够多(或没有足够时间)为继承和/或可扩展性设计他们的库的程序员,正好会产生我可能需要修复或调整的库 - 正是我最需要覆盖能力的库。

由于无法覆盖,我不得不写丑陋的、绝望的解决方案代码(或放弃使用并制定自己的替代方案),这种情况远远超过了我因覆盖而遭受的次数(例如在Java中),其中设计者可能没有考虑到我可能会遇到的问题。

默认非虚拟使我的工作更加困难。

更新: 已经指出(完全正确)我实际上没有回答问题。所以 - 并且非常抱歉....

我想写一些简洁的东西,比如“C#默认实现非虚拟方法,因为做出了一个错误的决定,把程序看得比程序员更重要”。(我认为这可以在回答这个问题的其他答案中有所依据 - 比如性能(过早优化,任何人?)或保证类行为。)

然而,我意识到我只是陈述了我的观点,而不是Stack Overflow想要的明确答案。我想,在最高层次上,确定性(但无用)的答案是:

它们默认为非虚拟,因为语言设计者做出了选择。

现在我猜他们做出那个决定的确切原因我们永远不会知道....哦,等等!一段对话的文字记录!

看起来,这里有关覆盖API的危险性以及需要显式设计继承的回答和评论都是正确的,但是它们都忽略了一个重要的时间方面:Anders 的主要关注点是在版本之间保持类或API的隐含约定。他实际上更担心让.NET / C#平台在代码下变化,而不是担心用户代码在平台上方的变化。(而他的“务实”观点与我完全相反,因为他从另一方面考虑。)

(但他们难道不能默认使用 virtual,然后在代码库中添加 "final" 吗?也许这并不完全相同... Anders 显然比我聪明,所以我不想深究了。)


4
非常同意。在使用第三方 API 时,想要覆盖某些行为却无法实现,这让人非常沮丧。 - Andy
4
我完全同意。如果你要发布 API(无论是在公司内部还是对外公开),确实可以并且应该确保你的代码设计支持继承。如果你要将 API 发布给许多人使用,你应该使 API 设计得好而且完善。与良好的整体设计所需的工作量相比(包括优质内容、明确的用例、测试和文档),为继承设计并不太困难。如果你不打算发布,并默认启用虚函数,则可以节省时间,而且你总是可以修复少量出现问题的情况。 - Gravity
2
现在如果 Visual Studio 中有一个编辑器功能可以自动将所有方法/属性标记为 virtual,那该多好啊... 有没有 Visual Studio 插件呢? - kevinarpe
3
考虑到现代开发实践,例如依赖注入、模拟框架和ORM等,似乎很明显我们的C#设计师有些偏差。当你无法默认覆盖属性时,强制测试一个依赖关系非常令人沮丧。 - Jeremy Holovacs
1
Anders 可能比我们都聪明,但正如这个问题所展示的那样,他仍然是会犯错的。 - Ian Newson
显示剩余7条评论

19

由于很容易忘记某个方法可能被重写而不是为此进行设计,C# 强制你在将其设为虚方法之前先思考。我认为这是一个很好的设计决策。一些人(比如 Jon Skeet)甚至认为类默认应该是密封的。


12

总结其他人的说法,有几个原因:

1- 在C#中,许多语法和语义都来自于C++。C++中方法默认非虚拟的事实影响了C#。

2- 每个方法默认都是虚拟的,这会导致性能问题,因为每个方法调用都必须使用对象的虚拟表。此外,这严重限制了即时编译器内联方法和执行其他类型优化的能力。

3- 最重要的是,如果方法不是默认虚拟的,你就可以保证你的类的行为。当它们默认虚拟,例如在Java中,你甚至不能保证一个简单的getter方法会按预期执行,因为它可能被派生类覆盖以执行任何操作(当然你可以并且应该将方法和/或类设为final)。

正如Zifre提到的,有人可能会想知道为什么C#语言没有更进一步地默认将类设置为sealed。这是关于实现继承问题的整个辩论的一部分,这是一个非常有趣的话题。


1
我也更喜欢默认情况下类是密封的。这样就更加一致了。 - Andy

9

C#受到C++(以及其他语言)的影响。C++默认情况下不支持动态调度(虚函数)。其中一个(好的?)理由是这样的问题:“你有多频繁地实现作为类层次结构成员的类?”避免默认启用动态调度的另一个原因是内存占用。没有指向虚表的虚拟指针(vpointer)的类,当然比启用后期绑定的相应类更小。

性能问题并不容易回答“是”或“否”。这是因为Just In Time(JIT)编译是C#中的运行时优化。

关于“虚调用速度..”的另一个类似的问题。


我有点怀疑虚方法对C#的性能是否有影响,因为有JIT编译器。这是JIT比离线编译更好的领域之一,因为它们可以内联在运行时“未知”的函数调用。 - David Cournapeau
1
实际上,我认为它更受Java的影响,而不是C++默认情况下这样做。 - Mehrdad Afshari

6
简单的原因是设计和维护成本以及性能成本。相比非虚拟方法,虚拟方法有额外的开销,因为类的设计者必须计划当方法被另一个类重写时会发生什么。如果您期望某个方法更新内部状态或具有特定行为,则这会产生很大影响。现在您必须计划派生类更改该行为时会发生什么。在这种情况下编写可靠代码要困难得多。
使用非虚拟方法,您拥有完全控制权。任何出错都是原作者的责任。该代码更容易理解。

这是一篇非常老的帖子,但对于一个正在编写他的第一个项目的新手来说,我经常担心我所编写的代码的意外/未知后果。知道非虚拟方法完全是我的责任,这让我感到非常安心。 - trevorc

2
如果所有的C#方法都是虚拟的,那么vtbl会更大。
只有类定义了虚拟方法,C#对象才有虚拟方法。尽管每个对象都有包含vtbl等价物的类型信息,但如果没有定义虚拟方法,则只会存在基本Object方法。
@Tom Hawtin:更准确地说,C++、C#和Java都来自C语言家族 :)

1
为什么vtable变得更大会成为一个问题呢?每个类只有一个vtable(而不是每个实例),因此它的大小并没有太大的影响。 - Gravity

1

从 Perl 背景出发,我认为 C# 密封了每个想要通过非虚方法扩展和修改基类行为的开发人员的命运,而不强制所有新类的用户意识到可能存在的幕后细节。

考虑 List 类的 Add 方法。如果开发人员想要在特定列表“添加”时更新多个潜在的数据库之一,该怎么办?如果“Add”默认是虚拟的,开发人员可以开发一个“BackedList”类来覆盖“Add”方法,而不会强制所有客户端代码知道它是“BackedList”而不是常规的“List”。在所有实际目的上,“BackedList”可以从客户端代码中视为另一个“List”。

从可能提供对一个或多个列表组件的访问的大型主类的角度来看,这是有意义的,这些组件本身由数据库中的一个或多个模式支持。鉴于 C# 方法默认情况下不是虚拟的,主类提供的列表不能是简单的 IEnumerable,ICollection 或甚至是 List 实例,而必须作为“BackedList”向客户端广告宣传,以确保调用“Add”操作的新版本以更新正确的模式。


默认情况下,虚方法确实可以使工作变得更容易,但这是否有意义呢?有许多方法可以使生活变得更轻松。为什么不在类中使用公共字段?修改它的行为太容易了。在我看来,语言中的所有内容都应该是严格封装的,并且默认情况下应该具有刚性和抗变性。只有在必要时才进行更改。只是对设计决策进行一些哲学上的思考。继续... - nawfal
谈到当前话题,只有当BA时才应该使用继承模型。如果B需要与A不同的东西,那么它就不是A。我认为重写作为语言本身的一种能力是一个设计缺陷。如果你需要一个不同的Add方法,那么你的集合类就不是一个List。试图告诉它是假的。这里的正确方法是组合(而不是伪造)。确实整个框架都建立在重写能力上,但我就是不喜欢它。 - nawfal
我认为我理解了你的观点,但是在提供的具体示例中:'BackedList' 只需实现接口 'IList',客户端只知道该接口。对吗?我有什么遗漏吗?不过,我确实理解你试图表达的更广泛的观点。 - Vetras

0

性能。

想象一组覆盖虚拟基类方法的类:

class Base {
   public virtual int func(int x) { return 0; }
}

class ClassA: Base {
   public override int func(int x) { return x + 100; }
}

class ClassB: Base {
   public override int func(int x) { return x + 200; }
}

现在想象一下,您想调用func方法:
   Base foo;
   //...sometime later...
   int x = foo.func(42);

看看CPU实际上要做什么:

    mov   ecx, bfunc$ -- load the address of the "ClassB.func" method from the VMT
    push  42          -- push the 42 argument
    call  [eax]       -- call ClassB.func

没问题?不,有问题!

汇编代码并不难理解:

  1. mov ecx, foo$:需要访问内存,并获取对象的虚方法表(VMT)中被覆盖的 foo 方法的地址。CPU 将开始从内存中获取数据,然后继续执行:
  2. push 42:将参数 42 推入栈中以调用函数。这没有问题,可以立即运行,然后我们继续执行:
  3. call [ecx]:调用 ClassB.func 函数的地址。← !!!

这是个问题。尚未从 VMT 中获取 ClassB.func 函数的地址。这意味着 CPU 不知道下一步该去哪里。理想情况下,它会跟随一个 jump 并继续执行指令,同时等待从内存中返回 ClassB.func 的地址。但它不能这样做;所以我们只能等待。

如果我们很幸运:数据已经在 L2 缓存中了。将值从 L2 缓存中取出并放到可以使用的地方需要 12-15 个周期。CPU 无法在没有等待内存 12-15 个周期的情况下知道接下来要去哪里。

ℂℙ -

我们的程序会在 12-15 个周期内一直处于空闲状态。

CPU 核心有 7 个执行引擎。CPU 的主要工作是让这 7 条流水线保持充满任务。这意味着:

  • 将您的机器代码 JIT 成不同的顺序
  • 尽早从内存中开始提取,让我们可以继续做其他事情
  • 执行 100、200、300 条指令。它将在您的循环中执行 17 次迭代,在多个函数调用和返回之间进行
  • 它有一个分支预测器,试图“猜测”比较的走向,以便在等待时可以继续执行。如果猜错了,那么它就必须撤销所有这些工作。但分支预测器并不愚蠢——它的正确率达到了 94%。

你的CPU有如此强大的能力,但它却在15个周期内停滞不前!?

这太糟糕了。这太可怕了。而且每次调用虚方法时,无论你是否实际重写它,你都会遭受这种惩罚。

我们的程序每次方法调用都要慢12-15个周期,因为语言设计者使虚方法成为默认选择而非选择退出。

这就是为什么微软决定不将所有方法默认设置为虚拟的原因:他们从Java的错误中吸取了教训。

有人将Android移植到C#上,速度更快

2012年,Xamarin的人将Android的Dalvik(即Java)全部移植到了C#上。来自他们

性能

当C#出现时,微软对语言进行了一些重大修改,使其更容易优化。引入了值类型,以允许小对象具有低开销,并且将虚方法设置为选择加入,而不是选择退出,这使得VM更简单。

(强调是我的)


他们确实有一个非常高效的映射表。"你想调用函数 x?这是它的地址。"问题 就在于 映射表。事实上,你必须不惜一切代价拥有一个映射表 - 因为我必须跳转到我刚读取的地址。映射表的另一个问题是它必须存在于内存中,这意味着你必须从内存中读取该函数的地址。如果你很幸运,从内存中读取可以花费32个CPU周期。因此,当您可以浪费0个CPU周期时,为什么要浪费32个CPU周期或2个CPU周期呢? - Ian Boyd

0

这绝对不是性能问题。Sun的Java解释器使用相同的代码来分派(invokevirtual字节码),而HotSpot生成的代码无论是否为final都完全相同。我认为所有C#对象(但不包括结构体)都有虚方法,因此您总是需要vtbl/运行时类识别。C#是“类似Java的语言”的方言。暗示它来自C++并不完全诚实。

有一个想法,即“设计继承或禁止继承”。听起来像个好主意,直到你有一个严重的业务案例需要快速修复。也许从你无法控制的代码中继承。


热点强制进行大量优化,正是因为所有方法默认都是虚拟的,这对性能有巨大影响。而CoreCLR能够在保持简单的同时实现类似的性能。 - Yair Halberstadt
@YairHalberstadt Hotspot需要能够撤销编译代码,出于各种其他原因。自从我上次查看源代码以来已经过了多年,但是final和有效final方法之间的区别微不足道。值得注意的是,它还可以执行双态内联,即内联具有两个不同实现的方法。 - Tom Hawtin - tackline

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接