一、背景
最近在研究SIL语言,一种包含高级语义信息的SSA格式的中间语言,中间涉及到关于方法的派发及虚函数一些内容,正所谓工欲善其事必先利其器,所以产生了了解下方法调用相关的知识。
1.1 我们知道的
编译器在处理编程语言的方法调用时,有两种计算机制,即:静态调度(
static dispatch)和动态调度(dynamic dispatch),绑定方式也分为两种,即
早期绑定(early binding)和后期绑定(late binding)。
1.2 我们想了解的
那么一个方法到底是被如何调用的?不同的调用机制到底有什么区别?它们分别适用于什么场景呢?
接下来我们就来了解下今天的主题,编译和运行时的方法调用。
二、方法调度(dispatch)
什么是方法调度(dispatch)?在程序运行过程中,编译器通过一个机制来选择正确的方法,然后传递参数并唤起它,这个机制就是方法的调度。
每种编程语言都需要通过
dispatch的机制来唤起正确的方法。
方法从写好到调用,需要经过编译和运行两个阶段,也就是我们所熟知的编译期和运行期,而disptach就是在这两个时期进行的,而通过这两个时期,dispatch也被分为两种:静态调度(static dispatch)和动态调度(dynamic dispatch)。
2.1 静态调度(static dispatch)
static dispatch是在编译期就完全确定调用方法,在运行期进行调用。
static dispatch主要用于在多态的情况下,在编译期就实现对于确定的类型,在函数调用表中推断和追溯的正确的方法。
通俗点来说,一些能在编译器就确定的方法实现的函数,比如由static/private/final修饰的方法,就是使用static dispatch。
而像函数重载,用于特定调用的函数的确定也是在编译期解决的,所以使用的应该也是static dispatch。
static dispatch可以确保某个方法只有一种实现,速度明显快于dynamic dispatch。
2.1.1 如何使用static dispatch
所有的编程语言都是支持static dispatch,不同的语言默认的调度方式不同,有的默认为static dispatch,有的默认是dynamic dispatch。
大部分语言都有自己的语法去标识使用static dispatch,比如前文提到的final/private/static等,标明这些关键字的代码一般代表基类的方法不会被子类修改。
2.1.2 static dispatch是如何实现调度的
在编译器确定某个方法使用static dispatch后,会在生成的可执行文件中,直接制定包含了方法实现内存地址的指针,在运行时,直接通过指针调用特定的方法。
static dispatch还有一种优化实现方法,叫做内联(inline)。
2.1.3 内联(inline)
inline是将被调用方法的指针替换为方法实现体,即内联展开,与宏展开(macro expansion)很像,它可以人为声明,也可以通过编译器优化来实现。
内联展开和宏展开的区别在于,内联发生在编译期,并且不会改变源文件,但是宏展开是在编译前就完成的,会改变源码本身,之后再对此进行编译。
2.1.3.1 inline对性能的影响
inline对性能的影响比较复杂,因为内联展开会消耗内存,通常来说,有些inline可以通过很小的内存消耗来提升运行速度,但是同样的,无节制的内联也可能降低运行速度,因为内联的代码需要大量的CPU缓存,并且也会消耗内存空间。
内联方法的运行比传统方法调用要快一些,因为节省了指针到方法实现体的调用消耗,但是会带来一些内存损失。如果一个方法被内联10次,就会出现10份方法的副本,所以内联适合用于会被频繁调用的比较小的方法。
C++中,如果使用class去定义,会默认使用inline,否则需要表明inline关键字。
但需要注意的是,如果一个方法特别大,即使被inline修饰,编译器也可能会选择不适应内联实现。所以inline只是告诉编译器你期望的实现方式,但最终是否使用是由编译器决定的。
2.1.3.2 inline的作用
消减方法被调用的时间,适合频繁调用的方法,为进一步的编译优化提供基础,更具体的编译优化则不在本文的探讨范围内了。
2.2 动态调度(dynamic dispatch)
在计算机科学中,
dynamic dispatch是用于在运行期选择调用方法的实现的过程,通常用于面向对象编程(OOP)语言和系统中,并被认为是其主要特征.
——《维基百科》
OOP通过名称来查找对象和方法,但是多态时,因为可能会出现多个方法名相同,但内部实现不同的方法,如果把OOP理解为向对象发送消息的话,多态情况下,就是编译期程序向不知道哪个对象发送了消息,然后在运行期再将消息派发给正确的对象,之后对象再决定执行的操作。
与static dispatch在编译期确定最终执行不同,dynamic dispatch的目的是为了支持在编译期无法确定最终实现操作,即运行期才能通过参数确定对象类型。
例如:
class A {
}
class B : A {
}
var obj:A = B();
编译期会认为obj是A类型,而运行期确定为B类型。
2.2.1 dynamic dispatch和late binding
动态派发(dynamic dispatch)与后期绑定(late binding)并不完全相同,多态操作下一个方法有多个实现,通过名称绑定与相同的名称关联,这个可以在编译期绑定,也可以在运行期绑定,然后使用dynamic dispatch,在运行期选择一个特定的操作实现。
虽然
dynamic dispatch并不意味着late binding,但是late binding意味着dynamic dispatch,因为late binding操作的实现直到运行时才知道。
2.2.2 single dispatch
single dispatch是通过对象类型去选择调用方法的模式,这是面向对象语言普遍支持的一种方式,如C++/Java/Objective-C/Swift/JavaScript/Python等。
例如:
class A {
func divide(param:String) {};
}
var a:A = A();
a.divide("text");
这里我们向a对象发送了一个包含参数param的方法名为divide的消息,选择方法实现时,只会通过消息对象a来选择,忽略param的类型。
对于single dispatch来说,在调用方法时,参数会被特殊处理用于去选择方法实现,在很多语言中,这种特殊参数就是在语法上进行指明,即生成的方法名中会带上特殊参数的名称。
2.2.3 multiple dispatch
multiple dispatch与single dispatch的不同在于,multiple dispatch会根据方法名结合方法的参数,一起来判断需要执行的方法。这方面的编程语言接触的不多,所以不在此举例。
2.2.4 dynamic dispatch的实现机制
一种语言可能有多种dynamic dispatch的实现机制,这里我们只选择一种虚函数表(v_table: virtual function table)来进行说明。
2.2.4.1 虚函数表
是用于支持动态分派的一种实现机制
当一个类定义了虚函数,即virtual function之后,大部分编译器会对类增加一个隐藏的属性,属性指向一个虚函数表,表内包含收纳了调用方法的指针数组,这些方法指针用于在运行期来调用正确的方法实现。
虚函数表是C类语言中最普遍的实现方式,例如C++/Swift/'Objective-C'等。
Java所有的实例方法都默认使用虚函数表实现,因为所有方法都可以被子类重载使得类变得复杂,当类不可以被继承时,理论上是不需要虚函数表的,所以当使用final或private等静态修饰符去修饰时,编译器就可以放心的使用static dispatch。
Python是不支持static dispatch的,实际上Python所有的方法和属性的实现都使用了late binding。
2.2.4.2 虚函数表的实现
对象的虚函数表包含对象绑定的方法地址,方法的调用需要从虚函数表内获取方法地址,同一个类的所有对象,生成的虚函数表都是一样的,属于同一系列的派生类,他们对象的虚函数表都有相同的布局,同一个方法在表内的位移都是相同的,所以,在知道了方法的位移之后,就可以通过虚函数表直接获取正确的方法。
编译器会为每个类创建单独的虚函数表,当对象创建后,会生成一个隐藏对象,是一个指向虚函数表的指针,编译器也会生成包含了虚函数表指针的代码。
2.2.4.3 虚函数表示例(C++)
class B1 {
public:
virtual ~B1() {}
void f0() {}
virtual void f1() {}
int int_in_b1;
};
class B2 {
public:
virtual ~B2() {}
virtual void f2() {}
int int_in_b2;
};
class D : public B1, public B2 {
public:
void d() {}
void f2() {} // override B2::f2()
int int_in_d;
};
B2 *b2 = new B2();
D *d = new D();
GCC的g++编译后,b2内存分布如下:
b2:
+0: pointer to virtual method table of B2
+4: value of int_in_b2
virtual method table of B2:
+0: B2::f2()
可以看到,B2类虚函数表的指针,占4个字节
下面是d的内存分布:
d:
+0: pointer to virtual method table of D (for B1)
+4: value of int_in_b1
+8: pointer to virtual method table of D (for B2)
+12: value of int_in_b2
+16: value of int_in_d
Total size: 20 Bytes.
virtual method table of D (for B1):
+0: B1::f1() // B1::f1() is not overridden
virtual method table of D (for B2):
+0: D::f2() // B2::f2() is overridden by D::f2()
可以看到,没有通过virtual关键字声明的方法f0()和d(),都没有出现在虚函数表中,可以对于缺省构造函数会有一些特殊操作,这里不做分析。
通过D重载的B2的f2()方法,是B2的虚函数表中,将过去的B2::f2()的指针替换为D::f2()的指针。
方法调用的实现
单继承中,如果调用d->f1()方法,可以分解为如下伪代码:
(*((*d)[0]))(d)
*d是D的虚函数表,[0]代表虚函数表内的一个方法,参数d为对象的this指针。
多继承中,调用B1::f1()和D::f2()就会复杂得多:
(*(*(d[+0]/*pointer to virtual method table of D (for B1)*/)[0]))(d) /* Call d->f1() */
(*(*(d[+8]/*pointer to virtual method table of D (for B2)*/)[0]))(d+8) /* Call d->f2() */
方法d->f1()的调用会把B1当做参数传入,方法d->f2()的调用会把B2当做参数传入,第二个调用就会用到指针修正,因为B2:f2()的地址并不在D的虚函数表中。
比较来看,d->f0()的调用就简单很多,因为B1:f0()不需要dynamic dispatch:
(*B1::f0)(d)
2.2.4.4 虚函数表的性能
相比于static dispatch的直接跳转到方法指针,虚函数表调用至少需要一次额外的索引重定向,有时还需要进行指针修正,所以虚函数表调用是慢于非虚函数表调用的。
为了避免额外的性能消耗,编译器会通过计算,如果调用可以在编译期确定,那么就不会创建虚函数表。
三、名称绑定(name binding)
名称绑定是实体与标识符的关联,绑定到对象的标识符被称为引用该对象,根据绑定时间分为早期绑定(early binding)和后期绑定(late binding)。
3.1 早期绑定(early binding)
early binding是在程序运行之前执行的名称绑定,一个直接的例子是直接C函数调用,标识符引用的函数在运行时不能更改。
对于OOP语言的early binding来说,在编译阶段就处理了所有的变量和表达式,通常存储在编译程序的虚函数表中,通过位移的方式去获取,非常高效。
3.2 后期绑定(late binding)
OOP对于我来说,意味着几个方面:消息,本地存储,保护机制,状态流的隐藏,和极致的late binding of all things
——美国计算机科学专家Alan Kay
late binding是一种用于处理在运行时通过对象调用方法或者通过函数名去调用包含参数的方法的一种编程机制。
对于late binding而言,编译器不会解读足够的信息去确认方法是否存在,也不会将其绑定到虚函数表内,而是通过运行时通过方法名去查找的。
3.3 OC的运行时
苹果官方对于OC dynamic binding文档中指出,dynamic binding就是在运行期来决定方法调用的实现。dymanic binding也叫做late binding。
在OC中所有的方法都是在运行期动态判断的。真正执行的方法是通过方法名和接收对象一起来确定的。