本文主要以 C#, JS/TS, C++ 视角讲述, 旨在了解 C# 中虚函数, 接口, 抽象类的开销和优化. 文中术语不太区分函数和方法, 基类和父类, 派生类和子类等等...
在此之前建议先了解 C++ 虚函数表的机制和去虚函数化, 参考 zhuanlan.zhihu.com/p/563418849 和 zhuanlan.zhihu.com/p/626606583, 以及最好了解逆变协变规则.
多态
什么是多态
先从这样一个非严谨的定义开始. 多态指同一个接口作用在不同对象上时, 可以有不同的解释, 产生不同的行为/运行结果. 这里同一个接口, 准确来说就是同名的函数或方法, 当然这里接口是广义上的, 它不限制函数签名的参数个数和类型保持一致. 考虑一个例子(From 龚神).
cpp
Object* obj;
std::cin >> type;
switch (type) {
case PEOPLE:
obj = new People;
break;
case MUSLIM:
obj = new Sheep;
break;
}
obj->Fucked();
这里根据用户输入的不同来实例化了不同类型的对象, 并且把对象向上转型成了 Object
对象, 最终通过 Object
对象调用了 Fucked()
方法. 毫无疑问地, obj
的运行时类型由用户输入确定, 根据运行时对象的类型不同, 调用的 Fucked()
方法也不同, 但是只通过同名的 Fucked()
方法就完成了对不同类型对象的方法调用, 这就是多态.
多态有什么用
根据上面的例子来讲, 多态就是为了抽象程度高写得爽, 写得简单, 并且需求变更的时候修改得少, 好维护.
多态的表现形式
根据定义, 只要同名函数/方法能够对不同类型对象产生不同行为就算多态, 那其实拥有运行时反射的托管语言都可以有多态 . 比如 JS 函数根据 typeof
判断参数类型来实现不同行为, 也算是多态.
javascript
function test(a) {
if (typeof a === 'string') {
...
} else if (typeof a === 'number') {
...
}
}
又或者跳转表也算多态.
javascript
const operation = {
a() {},
b() {}
};
function test(op) {
operation[op];
}
函数指针也是一种运行时多态, 通过运行时改变函数指针指向的地址, 也可以实现同一个函数名对不同类型产生不同结果, 这也是 C 和 Zig 中实现多态的方式, 参考 Zig 中 Allocator 类型 vtable
的实现.
原型链也是运行时多态, 通过运行时查找原型链来动态调用子类方法.
各个语言或多或少都提供了多态, 比如运算符重载, 经典如一些语言尽管没有对用户暴露运算符重载的能力, 但 +
依然对 int
float
string
都有效, 说明 +
也表现出多态. 而以语法形式提供的多态则有接口, 抽象类, 虚函数, override
, 委托, 函数指针, 运算符重载, Tagged Union, 泛型函数/方法(不指定泛型参数由编译期根据调用处参数类型推导), 方法重载(overload
)等, 它们都是实现多态的工具.
多态的实现
那么我们可以进一步地定义, 多态是把同一个名字的函数应用到不同类型的对象上, 能够完成不同的操作, 这里的操作可以是一个函数, 也可以是一个分支条件(如前面 JS 例子).
那如何实现(运行时)多态?
根据前面的例子, 很容易想到我们只要根据不同类型去调用不同函数就好了, 就像前面跳转表的例子一样.
也就是说, 我们需要一个类型和操作的映射关系, 这个映射关系, 对于前面 JS 的例子来说是一个跳转表, 对于 C++ (的一种编译器实现通常)则是虚函数表.
跳转表把所有的关系都集中放在了一起, 而虚函数表则是对象记住自己的类以及每个继承的类对应的函数, 关系由对象的虚表指针和类的虚函数表维护, 至于为什么不直接把虚函数表存在对象中而要用虚函数表指针, 大概一方面是为了让所有对象共享同一个虚函数表可以节省内存, 一方面虚函数表即便增加新的函数也不影响对象的内存布局, 有利于 ABI 稳定吧.
是否运行时多态只能通过虚函数表这样的方式来实现?
当然不是, 经典如 JS 原型链也是一种运行时多态, 和虚函数表一样也是运行时查找.
多态的优缺点
优点其实就前面说的, 主要是抽象程度高, 代码简洁写起来爽, 方便维护. (运行时多态)缺点则是需要虚函数表, 或运行时反射, 或其他动态查找方案, 以及无法内联, 增加运行时开销. 编译时多态没有缺点(或者我也不知道, 可能方法重载需要手动枚举算一个吧).
运行时多态和编译时多态
从前面多态的表现形式可以注意到, 有几个东西不太一样, Tagged Union, 泛型函数和方法/运算符重载, 它们都是编译时就确定的.
从多态的实现也可以看出来, 跳转表和虚函数表是在内存中的, 是可以运行时查表的.
从多态的定义可以看出来, 只要有类型和操作的映射关系就可以实现多态, 至于这个映射关系是怎么得到的, 是编译时可用还是运行时可用, 那不重要.
所以上面的泛型函数和方法重载, 它们对于类型和操作的关系是编译时就确定了的, 所以它们是编译时多态.
而对于通过反射获取类型, 跳转表, 虚函数表, 它们的类型信息是可以运行时获得的, 所以它们是运行时多态, 本质上讲运行时多态是一种动态绑定.
是否运行时多态都是通过虚函数表这样的机制实现的?
当然不是, 前面提到的 JS 的原型链, apply
/call
/bind
, 委托/函数指针, 反射等都可以是运行时多态.
是否只要对象具有虚函数表且方法在虚函数表中, 则调用该方法都是运行时多态?
答案是否定的, 这里的关键在于类型信息的确定是在编译时还是运行时, 类型只能在编译时确定的是编译时多态, 类型只能在运行时确定的是运行时多态, 但是当然还有一种可能是两者都有, 如果类型信息能够在编译时确定, 并且函数被放入了虚函数表中, 运行时也能够获得, 那也是多态, 这种情况, 当编译器有足够信息在调用处确定类型信息, 则可以把原本虚函数表的函数调用优化成直接调用, 这种情况叫作去虚函数化, 是一种编译器优化技术.
一个对象调用的方法即便它在虚函数表中, 也不一定会去查, 只要编译器觉得你没必要查, 那就变成去虚函数化.
所以也可以说, 如果人对于一个函数/方法调用处, 不能一眼看出来调用了哪个函数/方法, 则该调用处表现出多态, 而能够在编译期看出来的叫编译时多态, 能够在运行时看出来的叫运行时多态...
补充一点, 虽然 Tagged Union, 泛型函数和方法重载都是编译时多态, 但 Tagged Union和方法重载的状态是有穷的, 需要实现者手动枚举出来, 而泛型则可以是无穷的. 所以按照这样的分类, Tagged Union和方法重载是 Ad-Hoc 多态(特设型多态), 而泛型则是参数化多态. 关于更多细分这里不提了(其实我也不知道), 这里只按照编译时和运行时来分类, 因为本文关注的重点是虚函数, 接口, 抽象类的开销和优化.
狭义的多态
当我们说多态的时候我们在说什么? 什么东西表现出多态? 一个类吗? 一个对象(右值意义)吗? 一个变量(左值意义)吗? 一个方法吗? 表现多态是表现出什么多态, 是表现出运行时多态还是表现出编译时多态?
在 C++ 中, 我们说只有通过指针或引用调用方法才表现出运行时多态.
为什么? 因为 C++ 变量赋值是拷贝内存, 你把一个子类对象赋值给父类对象, 相当于只拷贝父类的字段, 相当于产生了一个不具有子类字段的新的对象, 调用方法则是相当于你把这个对象作为子类方法的隐式参数传递给子类方法, 真的不怕出事吗...
而指针和引用总是操作地址, 地址是可以被解释为父类对象或子类对象的, 解释成子类对象也是安全的, 也就是说, 你把一个父类对象传给子类方法是没有意义的, 但你把一个子类对象传给子类方法或父类方法则是可以的, 这个原则也是逆变协变的原则.
C# 则有点不同, 因为 C# 的变量名/标识符总是相当于指针, 所以 C# 变量的方法调用处都可能表现出多态.
而 Java 中方法都是虚的, 所以直接对着子类方法 @Override
就行不需要父类有 virtual
这样的关键字.
什么东西表现出多态?
多态总是针对一个函数调用来说, 即一个函数调用处表现出多态. 比如可以说一个泛型函数调用表现出多态, 一个变量调用方法表现出多态. 具体一点以 a.foo()
为例, 我们可以说这是一个虚函数调用, 或者这是一个非虚调用.
所以我们再说多态的时候, 具体到实际代码中, 总是在说, 如 a.foo()
这样一个方法调用是否表现出多态.
什么时候表现出多态, 以及表现出什么多态?
再具体一点, 对于代码 a.foo()
, 什么时候它表现出多态, 什么时候它不表现出多态? 表现了运行时多态还是编译时多态? 考虑如下代码.
csharp
class A {
public void foo() {
Console.WriteLine("A");
}
}
class B: A {
public new void foo() {
Console.WriteLine("B");
}
}
A a = new B();
a.foo(); // A
B b = a;
b.foo(); // B
这里 a.foo()
, 尽管 a
的运行时类型是 B
, 但由于编译时类型是 A
所以调用 A
的 foo
, 即便运行时类型不同, 也只会调用 A
这个父类的方法, 而不会调用子类的方法, 也就是说, a.foo()
没有表现出运行时多态 . 当然考虑到 a.foo()
其实等价于 foo(a)
我们也可以认为 foo()
表现出了编译时多态 , 因为方法调用本质上是将对象作为隐式参数, 而 foo()
根据对象编译时类型不同产生了不同的结果, 所以是编译时多态.
而如果将 A
的 foo
改写成虚函数.
csharp
class A {
public virtual void foo() {
Console.WriteLine("A");
}
}
class B: A {
public override void foo() {
Console.WriteLine("B");
}
}
A a = new A();
a.foo(); // A
A b = new B();
b.foo(); // B
这里 b.foo()
尽管 b
的(编译时)类型是 A
, 但 b.foo()
调用的实际上是子类 B
的 foo()
, 因此可以说 b.foo()
根据运行时类型的不同而产生了不同的结果, 所以 b.foo()
表现出运行时多态 . 那他有没有表现出编译时多态? 也是有的, 和前面一样, foo()
对于隐式参数编译时类型不同而产生了不同的结果, 所以是编译时多态.
从这里也可以看出来, 那其实等于说, 只要有继承和隐藏就算是有了编译时多态.
也就是说, 一个方法调用处, 可能同时表现出编译时多态和运行时多态(考虑方法重载的虚函数, 先做重载决议即编译时多态, 再运行时由虚函数动态派发)
至此, 面向对象的多态性体现我们可以更加具体地说
重载(overload), 编译时多态
重写/覆盖(override), 运行时多态
隐藏(hide), 按照宽泛的定义来说是编译时多态, 不过调用处是非多态调用, 不表现多态
而对于多态的定义, 我们也可以更加具体一些, 对于一个接口, 或者说函数/方法, 在它的调用处, 如果根据参数的编译时类型不同而产生不同的结果, 则该调用处表现出编译时多态, 如果根据参数的运行时类型不同而产生不同的结果, 则该调用处表现出运行时多态
为什么我们要关心这个?
因为它关系到编译器的优化, 下面我们的关注点主要放在运行时多态, 因为它带来了额外开销, 所以编译器需要尽量优化它.
C# 中的虚函数
这里我们主要关注运行时多态, 虚函数及其实现, 内存视角, 以及相关的优化. C# 中的虚函数规则基本和 C++ 一样. 默认情况下, 方法是非虚的, 不能 override
一个非虚方法.
其实关于虚函数更加建议看 C++ 中的虚函数及其实现, 可能更加清晰, C# 中还有诸多关键字以及接口抽象类等特性反而不好理解.
一个函数是虚函数意味着什么? 我们知道 C# 中有着 new
override
virtual
几个关键字, 它们之间的关系是什么?
第一个问题
前面的例子已经看到了, 一个函数声明为虚函数, 意味着这个函数调用的时候可能具有运行时多态.
virtual
override
new
的关系
考虑父类 A
和子类 B
, virtual
总是出现在父类方法上, 而 new
override
则总是出现在子类方法上. new
和 override
是互斥的, 所以根据排列组合有六种情况.
父类非 virtual
子类无关键字, 相当于子类 new
, 即子类隐藏父类函数, 会 warning
父类非 virtual
子类 new
, 子类隐藏父类函数
父类非 virtual
子类 override
, 不合法
父类 virtual
子类无关键字, 相当于子类 new
, 即子类隐藏父类函数, 会 warning
父类 virtual
子类 new
, 子类隐藏父类函数
父类 virtual
子类 override
, 子类覆盖(override)父类, 表现运行时多态
也就是说实际上只有两种情况, 一种是父类虚函数子类 override
, 一种是子类隐藏父类函数, 不管父类是否是虚函数, 剩下的情况都是非法.
虽然看上去 new
和 override
处于一个互斥的位置, 但某种意义上讲, 其实它们更像是毫无关系.
首先来说 new
, new
总是意味着子类隐藏父类函数, 本质上讲, 隐藏其实相当于你写了一个和父类中 foo
函数同名的函数 foo
, 编译器帮你重命名了, 假设为 foo1
, 然后把所有调用处都换成重命名的函数, 所以你写的这个函数其实和父类同名函数已经没有什么关系了, 在子类对象 sub
上调用, 编译器会帮你替换为 sub.foo1()
, 在父类对象 parent
上调用, 则还是 parent.foo()
, 这可以很容易看出来隐藏是不需要查找虚函数表的, 因为这只是属于子类特有的方法, 所以隐藏是编译期多态.
而 parent.foo()
则需要查找虚函数表, 所以只要类型声明了 virtual
, 通过该类型调用 virtual
方法都是要查找虚函数表的(除非 sealed
, 后面会提).
而 new
关键字只是为了显式表明该函数隐藏了父类同名函数, 其实 C# 中默认子类和父类同名函数就是隐藏. 所以 new
和 virtual
其实没什么关系, 子类不需要去管父类的函数是否是虚函数, 父类也不知道子类是否隐藏了对应函数.
对于 override
, 从 C++ 的历史来看会更加清晰, 一开始没有 override
关键字, 和 C# 一样, 子类和父类同名方法则默认是隐藏, 需要 override
则需要把子类方法也声明为 virtual
并且签名一样或协变返回类型 , 但是为了更加明确表示 override 以及更好地在编译时暴露问题, 加了 override
关键字.
也就是说, override
的方法总是和父类的 virtual
方法对应的, 并且 override
方法还是 virtual
的.
这也意味着, 即便是在子类对象上调用 override
的方法, 也可能要去查找虚函数表的(为什么是可能? 后面会提).
所以子类 override
总是对应了父类的 virtual
方法. 而子类的 new
则和父类同名方法, 不管是否 virtual
, 都没有关系, override
和 virtual
的方法都要查虚函数表, 而 new
的方法则不需要. 这也解释了为什么 override
的父类方法必须为 virtual
abstract
或 override
, 因为它们都是虚函数.
这也能够帮助理解为什么 virtual
不能和 static
abstract
private
override
等关键字一起使用, 因为运行时多态要求两个类之间蕴含着 is a
的关系, 类的实例符合里氏替换原则, 所以有着继承关系, static
不是实例的成员, 不存在向上转型再调用所以没有意义, private
则不会被继承, override
和 abstract
本身即是虚的也就不需要再多一个 virtual
了.
既然子类方法 override
签名为协变返回类型是可以的, 那为什么逆变参数类型不可以?
先考虑一个协变返回类型 override
的例子
csharp
interface Test {
object foo();
}
public class A: Test {
int age = 24;
public A foo() {
return this;
}
}
Test a = new A();
object obj = a.foo(); // 这是安全的
这是合法的关键在于编译器在编译时就能确定接受者 obj
的类型是 object
, 以及可以确定不管 a.foo()
的返回类型是 A
还是 object
, 它们的类型都是兼容的.
再考虑一个假如允许逆变参数类型的例子, 当然下面代码编译不通过.
csharp
interface A {
}
interface B {
}
public class Person: A, B {
}
abstract class Test {
abstract void foo(Person a);
}
public class Student: Test {
public override void foo(A a) {
a.bar();
}
}
public class Teacher: Test {
public override void foo(B a) {
}
}
void bar(Test a, B b) {
a.foo(b); // 不安全
}
编译器知道 a
的运行时类型吗? 不知道. 那么 a.foo
调用的是 Student.foo
还是 Teacher.foo
也不知道, 假如 a
的运行时类型是 Teacher
, 那么这里 a.foo(b)
传入 B
类型是合法的, 但假如 a
的运行时类型是 Student
呢? 传入 B
类型还合法吗? 当然不是.
这里编译器需要确定两个东西, 一个是值的运行时类型, 另一个是接受值的接受者的编译时类型, 当接受者的编译时类型可以兼容值的运行时类型便是合法.
你可能会说, 那协变返回类型例子中, 返回值的运行时类型也是不确定的啊. 的确, 但是返回值的类型是有穷的, 只要这所有的类型都兼容接受者编译时类型, 那也是合法的, 而这所有的类型是可以编译时确定的.
而逆变参数类型不合法的问题在于, 编译器无法在编译时确定 a
的运行时类型, 也就无法确定调用的函数, 而不巧, 接受者在函数参数位置, 于是接受者的编译时类型和值的运行时类型都无法确定.
接口
看完虚函数你可能会想子类实现接口, 子类实例向上转型接口后调用方法, 不也和虚函数被 override
一样?
没错, 子类实现的接口中的方法/函数默认就是虚函数 , 因为接口没有自己的实现, 所以通过接口调用方法只能是调用子类方法(所以子类也不需要 override
因为接口没有实现所以也只能 override
), 不同子类向上转型接口后调用方法自然也是调用不同子类的方法, 所以接口也是运行时多态, 自然接口也是有额外开销的.
接口方法对应了 C++ 中的纯虚函数, 接口则对于了接口类. 但是为了更加明确区分, 以及避免多继承的问题(虽然其实也不是什么问题), 所以引入了接口 interface
关键字, 但顺着 C++ 的历史来理解会显得更加清晰.
为什么要有接口? 接口方法和虚函数有什么区别?
你可能会问, 既然接口方法和虚函数都是虚的, 那它们有什么区别? 为什么有了虚函数还要有接口?
首先从接口的设计意图来说, 我们总是说面向接口, 什么是面向接口? 即接口设计者只定义接口签名, 而不负责也不关心具体实现.
假如接口设计和子类实现是两个不同的人, 那么接口设计者肯定是不知道子类具体如何实现接口的, 所以接口方法只能为纯虚.
接口表达的是约束一个类的实例(注意是实例不是类)应当具有怎样的形状(类似 TS 的 type
定义但略弱, 接口只对功能/方法做要求, 而不对字段做要求), 即接口和子类的关系是 can do. 接口是一个类的功能子集, 也因此使用上接口要能够被多继承, 因为每个接口设计者只关心它们想要的功能, 而接口实现者则需要考虑是否要实现多个接口. 而另一方面, 接口方法由于是纯虚, 那它只能是被 override
而没有提供隐藏的选项.
虚函数只能定义在类中, 而且必须有实现, 这就提供了子类隐藏的选择, 但由于类只能单继承, 这就让子类无法表达如果想要实现多个接口这样的意图.
所以从机制上讲, 虚函数和接口方法都是虚的, 都是运行时多态, 都需要运行时查找虚函数表, 主要区别就是如果你要用虚函数, 那就得用类, 用类就得继承, 继承就得继承可能不需要的字段, 变成强耦合, 还影响内存布局, 并且用类又不能多继承, 但是好处是子类有隐藏的选择.
而用接口就可以多继承, 并且不需要继承多余字段, 松耦合,不影响内存布局, 但是不提供隐藏机制. 至于接口不能实例化这种显而易见的区别就不提了.
既然接口用来描述一个类的形状, 那为什么接口不能声明字段?
好想法, 所以隔壁 TS 的接口可以声明字段...
那它不会有菱形继承的问题吗?
答案是会的但你有的是体操姿势为什么要这样做呢...考虑如下例子.
typescript
interface A {
name: string;
}
interface B {
name: number;
}
interface C extends A, B {} // error
但是如果两个类型的 name
类型一致则是可以的, 另一方面在 TS 我们有各种类型萃取姿势完全不用写这么蠢的多继承, 再不济我们还有 any
秒变 JS...以至于官方完全不想解决这个问题. 参考 github.com/microsoft/T... 这个 17 年的 issue 现在还开着.
现代的接口
站在 C++ 角度看, 接口就是接口类, 里面没有字段, 全部都是纯虚函数就完事了. 但前面说了, 接口只能约束类的实例应当具有哪些方法 , 它甚至既不能描述一个实例的形状(即实例字段和方法, TS type
可以)也不能描述一个类的形状(即包含实例字段和静态字段静态方法的整个类, C++ 模板可以), 以及总有人想偷懒希望接口能够实现方法方便复用.
所以为了加强点接口, 又允许接口有了默认实现, 以及可以定义静态字段静态方法静态构造函数和 static virtual
/static abstract
等等, 不过依然不允许定义实例字段, 从这点来讲它更像抽象类了.
CLR 对接口的优化
按照虚函数的理解来说, 接口等于接口类, 接口方法都是纯虚函数, 那实现接口的类中的方法也是虚函数, 毕竟 override
的函数也是虚函数嘛, 没毛病.
但是 CLR 对此的处理不是这样的, CLR 对于实现接口的派生类中的方法, 如果派生类中在实现方法时没有标记为 virtual
, 则 CLR 当它是 sealed override
即既是 sealed
也是 virtual
, 也就是说当这个实现接口的类的子类包含同名方法时, 默认是 hide 而不是 override
, 这也给接口去虚留下了空间. 当实现接口的类的实例以自身类调用时, 可以作为非虚调用.
而如果实现接口方法时标注了 virtual
, 则 CLR 不会添加 sealed
, 这对应我们一开始的理解.
抽象类
抽象类的抽象方法都是纯虚的. 从这点来说和接口是一样的. 那
抽象类和接口有什么区别?
首先抽象类和接口一样, 方法都是纯虚(不考虑接口默认实现), 都不能实例化, 子类也都必须实现虚函数/方法.
但抽象类可以定义字段, 抽象类只能单继承, 如果说接口对应了 C++ 的接口类, 那抽象类就对应了既包含纯虚函数又包含一些字段和其他方法实现的抽象类.
从设计意图来讲, 抽象类侧重于代码复用, 即它就只是表达一个残缺的类, 残缺部分交给子类补全, 只提过部分实现. 抽象类和虚函数的类和子类的关系是 is a, 而接口则是表达一种 can do 的约束, 不提供实现(不考虑默认实现).
抽象类和使用虚函数的类有什么区别?
如果你作为类的设计者, 知道类的所有东西该怎么实现, 只是希望子类自己根据情况选择是 override
还是 hide, 那就用虚函数.
如果你作为类的设计者, 有些方法你也不知道该怎么实现, 需要交给子类实现, 但你可以把能实现的地方写完, 那就用抽象类.
从意图上讲, 虽然都是虚, 但虚函数是一种主动选择, 而抽象方法则是被迫只能虚.
去虚函数化
好了, 终于到本文重点了...
虚函数, 接口, 抽象方法, 作为运行时多态, 前面说了, 缺点是有运行时开销, 多几步查找, 这个开销其实不大, 主要是由于虚函数是运行时动态查找, 所以编译器无法在编译时将函数内联, 而无法内联带来的问题是函数调用的指令会多一些, 这其实问题也不大, 更大的是一些信息由于是运行时的而无法进行编译时计算, 导致一些原本可以常量折叠的地方也没了. 而一些虚函数的实现可能还有多层虚函数表, 虚函数表中除了函数地址也可能还有其他类型信息, 多次跳转也带来 CPU 缓存命中率下降和打断 CPU 流水线导致分支预测成功率下降.
那怎么减少虚函数的开销? 答案是去虚函数化.
为什么可以去虚函数? 去谁的虚函数?
首先虚函数为什么会有开销? 因为一个变量的类型信息运行时才知道, 运行时才能确定调用哪个函数. 那优化自然就变成了能不能编译时确定变量的类型信息, 特定情况下来说当然是可以的.
一种常见的情况是, 考虑一个类 Sub
包含虚函数 foo()
, 通过该类(编译时类型)对象 sub
调用函数/方法 sub.foo()
, 问该调用是否要查找虚函数表?
正常来说当然是要的, 因为你不知道 sub
的运行时类型是 Sub
还是 Sub
的子类, 作为虚函数, sub.foo()
有可能调用子类的 foo()
. 那如果 Sub
是 sealed
/final
呢? 那 Sub
就没有子类了, 那 sub.foo()
就可以不用去查找虚函数表了.
所以去虚函数化是对于一个方法调用处如 sub.foo()
, 去掉查找虚函数表而直接调用函数, 提供内联可能性的一种优化.
C# 的每个对象中都有一个虚函数表, 但对于虚函数表里的函数来说, 其实也不一定会去查, 只要编译器觉得你没必要查
至于为什么每个对象都有虚函数表, 大概是因为 C# 中接口被大量使用吧.
是否声明为 virtual
/abstract
的函数或接口方法被调用时都必须查虚表?
前面说了, 即便函数在虚表中, 只要编译器觉得你没必要去查, 那也不一定要查虚表. 考虑下面例子(From Milo Yip).
cpp
struct Base {
virtual ~Base() {}
virtual void Foo() { printf("Base::Foo()\n"); }
}
struct Derived: Base {
virtual void Foo() { printf("Derived::Foo()\n"); }
}
Base* b = new Derived; // 非 static 令编译器不能在编译期知道 b 指向那个类型的对像
int main() {
b->Foo(); // 不可能内联
b->Base::Foo(); // 非多态调用,可以内联(但具体是否内联由编译器决定)
delete b;
}
这里说明了, 是否查虚表只跟调用处是否是多态调用有关, 跟声明处是否是虚函数无关. 一个常见错觉是认为一个函数只要声明了是虚函数, 那它被调用的时候就会有额外开销, 但是站在内存角度, 虚函数就只是函数, 它并没有什么 Debuff, 当你明确它是不作为多态调用时, 即便这个函数在某个类的虚表中, 那也不需要查表.
换句话说, 不是你叫虚函数我就一定要以多态形式调用你.
哪些情况下可以去虚函数?
在 sealed
类对象上调用虚方法
在对象上调用 sealed
的虚方法(即包括 sealed override
)
在明确知道对象类型的情况下调用虚方法(例如紧挨着构造函数调用)
以上这些都可以作为性能优化的手段. 更多情况可以参考以下
zhuanlan.zhihu.com/p/626606583
learn.microsoft.com/en-us/dotne...
特定场景下的 Duck Type
虽然接口是有点点开销, 不过特定场景下我们也可以不需要继承接口而只实现接口方法. 比如在使用 foreach
迭代一个 IEnumerable
对象的时候, 其实对象的类也可以不用继承 IEnumberable
, 只需要实现对应方法即可.
CRTP
既然虚函数接口有开销, 一个自然的想法是, 那是不是可以在 C# 中实现 CRTP 来避免虚函数开销呢?
csharp
Base<Derived1> b = new Derived1();
b.Foo();
public class Base<T> where T: Base<T> {
public void Foo() {
T self = (T)this;
self.Foo(); // 无限递归
}
}
public class Derived1: Base<Derived1> {
public new void Foo() {
Console.WriteLine("D1");
}
}
public class Derived2: Base<Derived2> {
public new void Foo() {
Console.WriteLine("D2");
}
}
然而并不能...self.Foo()
在类型转换后并没有调用子类 Foo()
方法, 而是依然调用了父类的方法. 从代码字面意思来看, 这不符合隐藏规则, 明明子类都已经隐藏了父类方法, 那 self.Foo()
就应该调用子类.
改成下面这样则是 OK 的.
csharp
Base<Derived1> b = new Derived1();
b.Foo();
public class Base<T> where T: notnull, Base<T> {
public virtual void Foo() {
T self = (T)this;
self.Foo();
}
}
public class Derived1: Base<Derived1> {
public override void Foo() {
Console.WriteLine("D1");
}
}
public class Derived2: Base<Derived2> {
public new void Foo() {
Console.WriteLine("D2");
}
}
但这又显得失去了意义, 明明希望靠着 CRTP 消除虚函数开销, 这不等于又把虚函数请了回来.
那么为什么造成了这样的差异?
其实我也不知道...不过猜测是这样(以下内容纯属口胡).
首先我们从类型转换开始说起, 什么是类型转换? 类型是对二进制数据的解释方式, 同一个数据, 不同类型有着不同解释. 而类型转换则是改变对数据的解释方式.
而这个改变解释方式的动作可以是在编译时, 也可以是在运行时.
C# 中强制类型转换是一个运行时操作, 是有运行时检查的, 即在运行时会检查转换是否安全, 而方法绑定是在编译时, 也就是说, 在实例化 this
时, Foo()
方法就绑定到 this
了, 这是编译器生成代码就确定了的, 即在编译时, this
的数据就被解释为基类并且作为 self.Foo()
调用处的隐式参数, 而 T self = (T)this
在运行时才对 this
重新解释, 编译器在编译时不知道 this
类型改变了, 所以 Foo()
依然是绑定到父类.
而 C++ 中的 static_cast
是一个编译时转换, 即改变对数据的解释是发生在编译时的, 在模板实例化的时候可以取得子类的实际类型而非仅仅是泛型参数(有点像是一个编译时 Duck Type 但编译完会被确定为具体子类), 在编译时已经知道类型改变了, 所以绑定也可以改为绑定到子类.
也就是 C# 强制类型转换和 C++ static_cast
的区别导致在 C# 中无法通过 CRTP 消除虚函数调用.
那我是不是可以用 as
转换, 也不行, as
也是一个运行时转换, 本质上 E as T
是 is
运算符的语法糖, 即 E is T ? (T)(E) : (T)null
, 显然这是一个运行时操作.
但修改后的 C# 中的 CRTP 虽然没法消除虚函数调用, 也有一定的应用场景就是了.
析构函数
C++ 中总是要求析构函数为虚函数, 否则当子类实例被作为父类删除时只会调用父类析构函数, 即只释放父类部分资源, 而子类所拥有的资源则不会被释放, 可能造成资源泄露.
但 C# 中则不需要也不能将析构函数声明为虚函数, CLR 自动从子类的析构函数依次调用到父类, 估计是因为 CLR 可以拿到运行时类型吧.
总结
C# 的虚函数, 接口方法, 抽象方法的都是虚的, 自然也都是有开销的, 虽然有去虚函数这样的优化, 但更多时候是无法优化的. 多态有很多种实现方式, 需要根据场景选择合适的方式, 考虑清楚是需要运行时还是编译时的多态, 而了解这些可以帮助更合理的选择合适的方式.
C++ 中靠着虚函数和多继承把接口, 抽象类的事情都给做了, 清晰的确还是 C# 这样细分出来比较清晰, 但初学概念太多也让人选择困难, 某种角度来说 C++ 这方面反而显得简单.
至于说多继承的问题, 其实也不是什么问题, 又或者继承本身就是问题, 反而多不是问题...
继承的问题
其实前面已经提过了, 继承的问题是强耦合. 何谓强耦合?
当你只想复用一部分方法代码的时候, 继承塞进来一堆你用不到的字段, 造成对象占用内存膨胀. 还可能造成菱形继承. 当你只想表达 can do 关系的时候, 继承要求你是 is a 的关系...
两个具有相同接口的类型, 必须要有相同的数据成员吗?
两个具有相同数据成员的类型, 必须要实现相同的接口吗?
两个具有一样数据成员和接口的类型, 必须要互相兼容(允许隐式转换)吗?
两个可以互相隐式转换的类型, 必须要具有相同的数据成员和接口吗?
可见,「继承」这个操作完全可以拆分为几个相互正交的操作,「继承」本身却不是一个正交的操作。
本质上是 is a 的关系粒度太粗, 不管你想不想要, 你继承了父类, 就相当于请了个爹, 爹要自动塞一堆你不需要的东西给你, 你没有选择的权利. 多继承就是你请了多个爹, 都想塞你一堆东西还有冲突, 那可不得爹味爆炸, 所以你得用各种花活协调各位爹.
那怎么办? 正交化.
怎么正交化? 当然是自己选择需要什么拿什么, 自由组合.
怎么选择? 经典如 JS 借用构造函数模式. 这也是组合优于继承的思想.
参考
zhuanlan.zhihu.com/p/563418849
zhuanlan.zhihu.com/p/626606583
learn.microsoft.com/en-us/dotne...
learn.microsoft.com/en-us/dotne...
learn.microsoft.com/zh-cn/dotne...
learn.microsoft.com/zh-cn/dotne...
learn.microsoft.com/en-us/dotne...
learn.microsoft.com/en-us/dotne...
learn.microsoft.com/zh-cn/dotne...
learn.microsoft.com/en-us/dotne...
learn.microsoft.com/zh-cn/dotne...
learn.microsoft.com/zh-cn/dotne...
learn.microsoft.com/en-us/dotne...
www.yycoding.xyz/post/2022/1...
zhuanlan.zhihu.com/p/441781774
stackoverflow.com/questions/3...
www.cnblogs.com/Mered1th/p/...
jacktang816.github.io/post/virtua...
learn.microsoft.com/zh-cn/dotne...
zhuanlan.zhihu.com/p/533706615
zhuanlan.zhihu.com/p/345799727