方法呼叫的編譯和執行:static dispatch和dynamic dispatch
背景
靜態分派(static dispatch
)和動態分派
(dynamic dispatch
)是用來處理程式語言語言方法呼叫的兩種計算機制.
一個方法是如何被呼叫的,這兩種機制在編譯期和執行時分別做了什麼,他們各自的優缺點是什麼,分別適用於什麼樣的場景,讓我們帶著問題看下去吧.
dispatch介紹
我們都知道一個方法會在執行時被呼叫,一個方法被喚起,是因為編譯器有一個計算機制,用來選擇正確的方法,然後通過傳遞引數來喚起它.
這個機制通常被成為分派(dispatch
).分派
就是處理方法呼叫的過程.
分派在處理方法呼叫的時候,可能會存在多個合理的可被呼叫的方法列表,此時就需要去選擇最正確的方法.選擇正確方法的整個過程,就是人們熟知是分派
(dispatch
).
每種程式語言都需要分派機制來選擇正確的喚起方法.
方法從書寫完成到呼叫完成,概括上會經歷編譯期和執行期兩個階段,而前面說的確定哪個方法被執行,也是在這兩個時期進行的.
選擇正確方法的階段,可以分為編譯期和執行期,而分派機制通過這兩個不同的時期分為兩種:靜態分派
(static dispatch
)和動態分派
(dynamic dispatch
).
static dispatch
static dispatch
是在編譯期
就完全確定呼叫方法的分派方式.它是一種方法分派形式.用於描述一個語言或者環境是如何選擇被呼叫的方法的實現的.
例如結合了ofollow,noindex">函式過載
的C++
的模板
和其他語言的泛型
,都是這樣實現的.
用於在多型情況下,在編譯期 就實現對於確定的型別 ,在函式呼叫表中推斷和追溯正確的方法,包括列舉泛型的特定版本,在提供的全部函式定義中選擇的特定實現.
與static dispatch
相反的dymanic dispatch
,是基於執行期
的給定資訊來確定呼叫方法的,可能通過虛擬函式表
實現,也可能借助其他的執行期的資訊.dynamic dispatch
的細節我們會在下面進行詳細說明.
static dispatch
可以確保某個方法只有一種實現.static dispatch
明顯的快於dynamic dispatch
,因為dynamic dispatch
本身就意味著較高的效能開銷.
何時使用static dispatch
static dispatch
在編譯期確定需要呼叫的方法,在執行期進行呼叫.所有的程式語言都是支援static dispatch
的,不同語言預設的分派方式不同,有的預設為static dispatch
,有的預設是dynamic dispatch
.
有的語言可以通過宣告關鍵字,來標明使用static dispatch
,比如final
或private
或static
等.這樣用於避免基類的方法屬性等不被子類修改.
static dispatch如何實現
在編譯器確定使用static dispatch後,會在生成的可執行檔案內,直接指定包含了方法實現記憶體地址的指標.在執行時,直接通過指標呼叫特定的方法.這是static dispatch
的標準做法.
static dispatch
還可以進行進一步優化,優化的一種實現方式叫做內聯
(inline
:inline expansion
).inline
是指編譯期從指定被呼叫的方法指標,改為將方法的實現平鋪在呼叫方的可執行檔案內.下面就講一下inline
具體是如何實現的,它有什麼優缺點.
inline
inline
也叫內聯展開,它可以人為宣告,也可以通過編譯器優化來實現.inline
是將被呼叫方法的指標替換為方法實現體.inline
的具體實現其實就是內聯展開,它和巨集展開
(macro expansion
很像.
內聯展開和巨集展開的區別在於,內聯發生在編譯期,並且不會改變原始檔.但是巨集展開是在編譯前就完成的,會改變原始碼本身,之後再對此進行編譯.
內聯是一種非常重要的優化方式,但是內聯對於效能的影響比較複雜.從經驗法則 來講,有些內聯可以通過很小的記憶體消耗來提升執行速度.但是無節制的內聯,也可能會降低速度,因為內聯的程式碼需要大量的CPU%E7%BC%93%E5%AD%98" target="_blank" rel="nofollow,noindex">CPU 快取 ,並且也會消耗記憶體空間.
內聯方法的執行比傳統的方法呼叫要快一些,因為節省了指標到方法實現體的呼叫的消耗,但是會帶來一些記憶體損失.如果一個方法被內聯10次,那麼會出現10份方法的副本.所以內聯適用於會被頻繁呼叫的比較小的方法.在C++
中,如果方法通過class
去定義,預設使用內聯(不需要inline
關鍵字).其他情況想要使用內聯,需要標明inline
的關鍵字.
但是如果一個方法特別大,被inline
關鍵字修飾的話,編譯器也可能會選擇不適應內聯實現.
所以inline
關鍵字是一個desire
宣告而非require
.只能告訴編譯器傾向使用內聯方式,但是最終實現是編譯器決定的.
內聯的作用
內聯可以用於消減方法被呼叫的時間.非常適用於會被頻繁呼叫的方法.如果方法本身很小的話,可以降低記憶體上的消耗.內聯還為進一步的編譯優化提供了基礎.program optimization
編譯器一般會將陳述式 進行內聯.
在[函數語言程式設計語言]
內聯在效能方面會有提升,而且還可以基於內聯,做進一步的編譯優化,感興趣的可以參考內聯 文章的Effect on performance ,Compiler support 和Implementation 部分,這裡就不展開說了.
dynamic dispatch
在電腦科學中,dynamic dispatch
是 用於在執行期選擇呼叫方法的實現的流程.
dynamic dispatch
被廣泛應用,並且被認為是面向物件語音
(Object-Oriented programming
:OOP
)的基本特性.
OOP
是通過名稱來查詢物件和方法的.但是多型就是一種特殊情況了,因為可能會出現多個同名方法,但是內部實現各不相同.如果把OOP
理解為向物件傳送訊息的話.在多型模式下,就是程式向不知道型別的物件傳送了訊息,然後在執行期再將訊息分派給正確的物件.之後物件再確定執行什麼操作.
與static dispatch
在編譯期確定最終執行不同,dynamic dispatch
的目的是為了支援在編譯期無法確定最終最合適的實現的操作.這種情況一般是因為在執行期才能通過一個或多個引數確定物件的型別.例如 B繼承自A, 宣告var obj : A = B()
,編譯期會認為是A型別,但是真正的型別B,只能在執行期確定.
dynamic dispatch
和late binding
(也叫做dynamic binding
)不同,程式使用Name binding
通過名字去關聯操作.在多型操作中,會有多個不同的操作關聯同一個方法名.這個繫結關係可以在編譯期或者執行期確定.在dynamic dispatch
中,是在執行期去選擇方法的實現的.
雖然late binding
的繫結實現也是在執行期才能確定,但是dynamic dispatch
並不意味著late binding
,late binding
也不等同於dynamic dispatch
.之後會詳細講解late binding
,這裡先挖個坑.
single and multiple dispatch
通過物件型別去選擇呼叫方法的模式,叫做single dispatch
,這是面向物件語音普遍支援的一種方式,比如C++
,Java
,Objective-C
,Swift
,JavaScript
,Python
等.
在下面示例的方法呼叫中,引數divisor
是可選型別.
dividend.divide(divisor)# dividend / divisor
我們是向物件dividend傳送一個包含引數divisor的名為divide訊息.在選擇方法實現時,只會通過dividend,也就是訊息物件的型別來進行選擇.忽略引數divisor的型別.這種方式叫做single dispatch
.
multiple dispatch
Multiple dispatch
dynamic dispatch的實現機制
一種語言可能有多種dynamic dispatch
的實現機制.語言的特性不同,動態分派的實現也各有差異.下面我們只針對一種實現虛擬函式表
(vtable
:virtual function table
)來進行詳細說明.
這裡提一句,因為動態分派經常會引起高效能消耗,所以很多語言對某些特定的方法,提供了靜態分派的方式.
虛擬函式表
虛擬函式表 是用於支援動態分派的一種實現機制.
當一個類定義了虛擬函式virtual function 之後,大部分編譯器會對類增加一個隱藏的屬性,屬性指向一個包含了虛擬函式表,表內包含被收納了呼叫方法的指標陣列.這些方法指標用於在執行期來呼叫正確的方法實現.
用來實現動態分派的方式有很多,虛擬函式是在C類語言,例如C++
中最普遍的實現方式.
Java所有的例項方法都預設使用虛擬函式表實現.因為所有方法都可以被子類過載使得類變得特別複雜.當類不可被繼承時,理論上是不需要虛擬函式表的.所以當使用final
或private
等靜態修飾符去修飾時,編譯器就可以放心的去使用static dispatch
.
Python
是不支援static dispatch
的.實際上Python
所有的方法和屬性的實現都使用了late binding
.
虛擬函式表實現
物件的虛擬函式表包含物件繫結的方法地址.方法的呼叫需要從虛擬函式表內獲取方法地址.同一個類的所有物件,生成的虛擬函式表都是一樣的.屬於同一系列的派生類,他們物件的虛擬函式表都有相同的佈局.同一個方法在表內的位移都是相同的.所以,在知道了方法的位移之後,就可以通過虛擬函式表直接獲取正確的方法.
編譯器會為每個類建立單獨的虛擬函式表.當物件建立後,會生成一個隱藏物件,物件是一個指向虛擬函式表的指標.編譯器也會生成包含了虛擬函式表指標的程式碼.
在不同的語言中,虛擬函式表可能在物件的最後或者第一個屬性內,這不影響實際的功能實現.
虛擬函式表示例
以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; };
下面是它們的派生類D的宣告
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++3.4.6編譯之後,b2生成了如下的32位記憶體結果
b2: +0: pointer to virtual method table of B2 +4: value of int_in_b2 virtual method table of B2: +0: B2::f2()
分析一波記憶體分佈.b2的起始位置是B2類虛擬函式表的指標.佔4個位元組.之後是一個int型別.
下面看看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()
d是通過多繼承,B1和B2的派生類.可以看到記憶體中,前面幾個部分和父類的記憶體結構完全一樣.後面添加了自定義的屬性.
需要注意的是,沒有通過virtual
關鍵字宣告的方法f0()
和d()
,都沒有出現在虛擬函式表內.可能對於預設建構函式
會有一些特殊操作,這裡不展開說.
通過D過載的B2的f2()
方法,是在B2的虛擬函式表內,將過去的B2::f2()
的指標替換為D::f2()
的指標.
多繼承和thunks
可以看到g++編譯器實現通過使用B1
和B2
兩個虛擬函式表來實現多繼承
.每個表用來說明每個基類.這裡就需要說到指標修正
,也叫做thunks
D*d= new D(); B1 *b1 = d; B2 *b2 = d;
程式碼執行後,d
和b1
會指向同一個記憶體地址,但是b2
會指向d+8
.所以b2
所指向的d
的記憶體範圍會"看起來"像是B2
的例項.也就是說,和B2
擁有相同的記憶體佈局.
方法呼叫,在多繼承中的實現
在單繼承中,呼叫一個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(0
的呼叫會把B1當做引數傳入.方法d->f2(0
的呼叫會把B2當做引數傳入.第二個呼叫,就會用到指標修正來指向正確的指標.因為B2::f2
的地址並不在D的虛擬函式表中.
比較來看,d->f0()
的呼叫就簡單很多
(*B1::f0)(d)
虛擬函式表的效能分析
相比非虛擬函式呼叫的直接跳轉到編譯指標,虛擬函式表呼叫至少需要一次額外的索引重定向,有時還需要進行指標修正.所以虛擬函式表的呼叫一定是慢於非虛擬函式呼叫的.
此外,在不能使用即時編譯
的環境中,虛擬函式呼叫一般是不能夠inline
的.雖然有一些不常見的內聯方式,這裡就不展開了.
為了避免額外的效能消耗,編譯器會通過計算,如果呼叫可以在編譯期確定,那麼就不會建立虛擬函式表.
所以,對於上面示例中f1
的呼叫就可以不需要查表操作.因為編譯器可以分辨d
只會持有D
型別的指標,而D
沒有過載f1
.或者編譯器(或優化器)也可以發現B1
的所有子類都沒有過載f1
.所以B1::f1
或B2::f2
的呼叫因為可以確定它的最終實現,就可以不用查表.(雖然仍需要對'thsi'指標進行修正).
late binding
美國電腦科學家Alan Kay 曾經說過,OOP對我來說,意味著幾個方面:訊息,本地儲存,保護機制,狀態流的隱藏,和極致的late binding of all things.
後期微軟又大程度的對他們面向OOP
的COM
庫進行了升級,COM
也同樣提升了early binding
和late binding
,要知道,很多語言在語法層面是同時支援這兩種特性的.
什麼是late binding
呢?
late binding
(也叫dynamic binding
或dynamic linkage
)是一種用於處理在執行時通過物件呼叫方法或者通過函式名去呼叫包含引數的方法的一種程式設計機制.
對於OOP
語音的early binding
或static binding
來說,在編譯階段就處理了所有的變數和表示式.通常這些資料儲存在編譯程式的虛擬函式表內,通過位移的方式獲取,非常高效.對於late binding
而言,編譯器不會解讀足夠的資訊去確認方法是否存在也不會將其繫結到虛擬函式表內.late binding
是在執行時通過方法名去查詢的.
在元件物件模型
程式設計中,使用late binding
的最大優勢在於,不要求編譯器在編譯期間去引用包含物件的庫.這使得編譯過程可以更有效的去避免類的虛擬函式表突然更改帶來的衝突.
late binding
的實現
大部分的動態型別
)語言都可以在執行時去修改物件的方法列表,因此就需要late binding
.
說說OC執行時
蘋果官方對於OC dynamic binding
文件中指出,dynamic binding
就是在執行期來決定方法呼叫的實現.dymanic binding
也叫做late binding
.在OC中所有的方法都是在執行期動態判斷的.真正執行的方法是通過方法名和接收物件一起來確定的.