1.vptr 和 vtbl

C++的多型(動態繫結)在底層是如何實現的呢?

最主要的兩個的東西就是vptr(虛指標)和vtbl(虛擬函式表),先來看一個測試程式:

先定義一個父類和一個子類,在父類中使process函式為虛擬函式

class

Fruit

{

public

void

print

()

{

cout

<<

“Fruit print”

<<

endl

}

virtual

void

process

()

{

cout

<<

“Fruit process”

<<

endl

}

int

no

double

weight

char

key

};

class

Apple

public

Fruit

{

public

void

save

()

{

cout

<<

“Apple save”

<<

endl

}

virtual

void

process

()

{

cout

<<

“Apple process”

<<

endl

}

int

size

int

type

char

i

};

再使用 typedef 化簡指標定義,因為我們想從虛指標得到虛擬函式表中函式的地址,虛指標是一個函式指標,將它改名成 p,儲存一級函式指標的地址需要二級函式指標,所以在將 p* 改名成 VTABLE

typedef

void

*

p

)();

typedef

p

*

VTABLE

最後在main函式檢視函式地址繫結的情況

int

main

()

{

Fruit

f

Apple

a

// 檢視process函式的地址

VTABLE

vtf

=

*

((

VTABLE

*

&

f

);

printf

“&Fruit::process %p

\n

vtf

0

]);

vtf

0

]();

VTABLE

vta

=

*

((

VTABLE

*

&

a

);

printf

“&Apple::process %p

\n

vta

0

]);

vta

0

]();

cout

<<

“======================”

<<

endl

// 檢視print函式的地址

void

Fruit

::*

fp

)()

=

&

Fruit

::

print

printf

“&Fruit::print %p

\n

fp

);

void

Apple

::*

ap

)()

=

&

Apple

::

print

printf

“&Apple::print %p

\n

ap

);

// 檢視save函式的地址

ap

=

&

Apple

::

save

printf

“&Apple::save %p

\n

ap

);

return

0

}

GeekBand C++面向物件高階程式設計(下)2

GeekBand C++面向物件高階程式設計(下)2

上圖是輸出結果,從結果看,虛擬函式process在父類和子類中被繫結到了不同的地方(這就是多型),非虛擬函式的print函式地址是一樣的。

1.1多型會在什麼時候出現呢?

(1)當父類指標,指向子類物件的時候

(2)。當父類引用,作為子類物件的別名的時候

#include

using

namespace

std

class

Basic

{

public

virtual

void

print

()

const

{

cout

<<

“class Basic”

<<

endl

}

};

class

A

public

Basic

{

public

void

print

()

const

{

cout

<<

“class A”

<<

endl

}

};

int

main

()

{

// 當父類引用,作為子類物件的別名的時候

A

a

Basic

&

b1

=

a

b1

print

();

// 當父類指標,指向子類物件的時候

Basic

*

b2

=

new

A

b2

->

print

();

return

0

}

1.2 右值引用

現在我想在做下面這件事:

int

main

()

{

// 用A型別指標接收一個堆空間

A

*

a

=

new

A

// 使用Basic類的指標引用繫結指標a

Basic

*&

b

=

a

// 透過b呼叫a中的print函式

b

->

print

();

return

0

}

但是編譯出現error

GeekBand C++面向物件高階程式設計(下)2

GeekBand C++面向物件高階程式設計(下)2

error的意思是:Basic*&型別非const引用無效的初始化,來自於Basic*型別的右值。

這個錯誤很有意思,明明a是一個A型別的指標呀,為啥error提示的是Basic*型別的右值,這個右值和非const引用又有什麼關係呢?

先來解決,為啥提示的是Basic*型別的右值:

我們在class Basic中加入一個建構函式和一個複製構造

class

Basic

{

public

Basic

()

{

}

Basic

const

Basic

&

other

{

cout

<<

“Basic”

<<

endl

}

virtual

void

print

()

const

{

cout

<<

“class Basic”

<<

endl

}

};

然後寫一個main函式如下:

int

main

()

{

// 建立a

A

a

// 對a轉型

static_cast

<

Basic

>

a

);

return

0

}

然後輸出結果如下:

GeekBand C++面向物件高階程式設計(下)2

GeekBand C++面向物件高階程式設計(下)2

這說明轉型的時候呼叫了一次Basic的複製構造,建立了一個臨時物件。

那麼同樣的當A*型別的指標要被賦值給Basic*&型別的指標引用時,一樣會發生臨時物件的建立(因為有型別轉換),A*型別被轉型成了一個Basic*型別的臨時物件,所以會出現提示的是Basic*型別的右值。

明確了右值是一個Basic*型別的臨時物件後,現在能解決非const引用的問題了:

#include

using

namespace

std

int

fun

()

{

return

10

}

int

main

()

{

int

a

=

fun

();

int

&

i

=

fun

();

// error

return

0

}

編譯上述程式碼出現error:

GeekBand C++面向物件高階程式設計(下)2

GeekBand C++面向物件高階程式設計(下)2

fun()函式返回的是一個臨時物件,a是一個左值(一般是變數)可以直接接收,然而10不是一個左值他是一個右值(賦值運算子“=”的右側,通常是一個常數、表示式、函式呼叫),10無法被非const引用繫結。

在C++中,左值可被繫結到非const引用,左值或者右值則可被繫結到const引用。但是卻沒有什麼可以繫結到非const的右值(即右值無法被非const的引用繫結),這是為了防止人們修改臨時變數的值,這些臨時變數在被賦予新的值之前,都會被銷燬。

如果臨時變數可以被非const引用繫結,那就意味著,程式設計師可以使用非const引用對臨時變數進行各種操作,這將導致無法預知的錯誤。

那要如何解決這個問題呢?

我們把程式碼改成下面這樣:

int

main

()

{

int

a

=

fun

();

// 使用右值引用

int

&&

i

=

fun

();

return

0

}

&&表示右值引用,右值引用可以繫結到右值(但不能繫結到左值),這是在C++11標準中新的東西

下面給出例子正確的程式碼:

#include

using

namespace

std

class

Basic

{

public

virtual

void

print

()

const

{

cout

<<

“class Basic”

<<

endl

}

};

class

A

public

Basic

{

public

void

print

()

const

{

cout

<<

“class A”

<<

endl

}

};

int

main

()

{

A

*

a

=

new

A

// 使用右值引用

Basic

*&&

b

=

a

b

->

print

();

return

0

}

b繫結的是a型別轉換之後的臨時變數(其中有賦值過程,因為引用本身也是一個指標)

1.3 多型與指標帶來的彈性

我們想使用一個list,用來儲存不同型別的物件,但是list只能有一個型別,那麼這就給設計帶來了麻煩,如果沒有多型,我們就需要一個型別的物件建立一個匹配的list,這樣增加了程式設計師的負擔,也增加了程式碼的沉餘量,浪費了儲存空間

使用多型和指標就可以輕鬆的解決這個問題,我們可以讓想加入list的物件都有一個一樣的父類,再將list建立為父類的指標型別,如下:

#include

#include

using

namespace

std

class

Basic

{

public

virtual

void

print

()

const

{

cout

<<

“class Basic”

<<

endl

}

};

class

A

public

Basic

{

public

void

print

()

const

{

cout

<<

“class A”

<<

endl

}

};

class

B

public

Basic

{

public

void

print

()

const

{

cout

<<

“class B”

<<

endl

}

};

class

C

public

Basic

{

public

void

print

()

const

{

cout

<<

“class C”

<<

endl

}

};

int

main

()

{

list

<

Basic

*>

myList

myList

push_back

new

A

);

myList

push_back

new

B

);

myList

push_back

new

C

);

myList

push_back

new

B

);

myList

push_back

new

A

);

list

<

Basic

*>::

iterator

iter

=

myList

begin

();

for

(;

iter

!=

myList

end

();

++

iter

{

*

iter

->

print

();

}

return

0

}

2.this pointer

先看一段程式碼:

#include

using

namespace

std

class

Basic

{

public

void

init

()

{

cout

<<

“init: ”

print

();

}

virtual

void

print

()

{

cout

<<

“class Basic”

<<

endl

}

};

class

Child

public

Basic

{

public

void

print

()

{

cout

<<

“class Child”

<<

endl

}

};

int

main

()

{

Child

c

c

init

();

return

0

}

上面的程式碼輸出的結果是 init: class Chlid

這是為啥呢?

在main函式中先例項化一個Child型別 c

Child

c

然後執行c類中的 init() 函式

c

init

();

進入init函式內部

void

init

()

{

cout

<<

“init: ”

print

();

}

由於print函式在父類中是一個虛擬函式,在子類中被覆蓋,所以呼叫的是子類中的print函式

void print()

{

cout << “class Child” << endl;

}

在呼叫init函式時,其實相當於

Basic

::

init

&

c

);

每一個成員函式都有一個隱藏的引數用來傳遞呼叫物件自己(this指標),然後在呼叫print函式時相當於:

this

->

print

();

// 由於是動態繫結所以變成

*

this

->

vptr

)[

n

])(

this

);

// this就是物件c自己

這樣就造成了輸出結果是init:class Child

上面這個函式呼叫方法是一個設計模式叫Template Method(模板方法),它可以在父類中完成一系列通用的工作,然後在子類中透過對父類的虛擬函式進行覆蓋,達到特化的效果。

2.1 封裝pthread API

在面向物件程式設計,我們呼叫系統API時希望減少重複的程式碼,不再像C語言那樣死板的呼叫API函式,在unix環境程式設計中,如果我們想封裝一個thread類,用來建立執行緒那麼需要如下操作:

class

Thread

{

public

void

start

void

{

pthread_t

tid

pthread_create

&

tid

NULL

run

this

);

}

static

void

*

run

void

*

arg

{

static_cast

<

Thread

*>

arg

->

run

();

return

NULL

}

virtual

void

run

void

=

0

};

由於pthread_create函式的原型如下:

int

pthread_create

pthread_t

*

thread

const

pthread_attr_t

*

attr

void

*

*

start_routine

void

*

),

void

*

arg

);

其中有一個函式指標,這個函式指標所指向的函式只有一個引數(void*型別的引數),如果只傳入普通成員函式:

void

*

run

void

*

arg

{

。。。

}

那麼成員函式會帶有一個隱藏的this引數,那麼和函式指標所指向的函式型別不符(error),因此需要使用:

static

void

*

run

void

*

arg

{

。。。

}

使用static函式,是因為static函式沒有隱藏的this引數,型別相符

但是Thread是父類,我們希望在繼承後,在static函式內呼叫類內的虛擬函式run,呈現多型的特性,需要呈現多型,就需要this指標,那麼在pthread_create函式的最後一個引數,將物件的this指標傳入執行緒,線上程內透過:

static_cast

<

Thread

*>

arg

->

run

();

將引數arg向上轉型,然後呼叫在子類中被覆蓋的run函式,這樣就呈現了多型性。子類只需要專注與具體線上程中幹什麼就可以了,建立的過程透過父類(模板一樣)一次性搞定。

3.談談const

我們知道當一個函式不會改變類內變數的值的時候,要在後面加上const,這樣可以提高相容性,因為const物件只能呼叫const函式,non-const物件可以呼叫const函式和non-const函式

那麼當const函式和non-const函式同時存在時會怎麼樣呢?他們是否可以同時存在?non-const物件呼叫的是那一個函式呢?

3.1 他們是否可以同時存在?

先看看編譯器怎麼說:

#include

using

namespace

std

class

Basic

{

public

void

print

()

const

{

cout

<<

“const Basic”

<<

endl

}

void

print

()

{

cout

<<

“Basic”

<<

endl

}

};

int

main

()

{

Basic

b

b

print

();

const

Basic

bc

bc

print

();

return

0

}

上述程式碼編譯透過,沒有error

print函式要夠成過載,不是應該只看函式名和引數型別嗎?我們來看一下彙編這麼說:

void print() const 在彙編中的名字:

_ZNK5Basic5printEv

void print() 在彙編中的名字:

_ZN5Basic5printEv

他們的名字是不一樣的,這是因為const也屬於函式簽名的一部分,什麼是函式簽名?

void

print

()

const

print

()

const就是函式簽名

,去掉返回值型別的部分

3.2 non-const物件呼叫的是那一個函式呢?

當const函式和non-const函式同時存在時,non-const物件會呼叫non-const函式

注:const函式可以只能呼叫const函式,non-const函式可以呼叫const函式

3.3 copy on write

copy on write(寫時複製)是一種提高效率的行為,這是一種拖延策略,正如C++中可以隨處宣告的特點一樣,在真正需要一個儲存空間時,才去定義變數(分配記憶體),這樣可以讓程式的效能提高,在STL中許多的類都採用了這種手段。

寫時複製的例子:

#include

#include

#include

using

namespace

std

class

COW

{

public

COW

char

*

p

size

1

{

char

*

pos

=

p

while

‘\0’

!=

*

p

{

++

size

++

p

}

this

->

p

=

new

char

size

];

strcpy

this

->

p

pos

);

// 計數位

this

->

p

size

+

1

=

1

}

COW

const

COW

&

other

size

other

size

{

p

=

other

p

++

p

size

+

1

];

}

// const函式不需要考慮寫時複製

char

&

operator

[]

int

&&

i

const

{

return

p

i

];

}

//non-const函式需要考慮寫時複製

char

&

operator

[](

int

&&

i

{

char

*

cpy

=

new

char

size

];

strcpy

cpy

p

);

p

=

cpy

p

size

+

1

=

1

return

p

i

];

}

~

COW

()

{

// 透過計數位判斷共享的物件是否被釋放

if

p

size

+

1

==

1

{

cout

<<

“~COW”

<<

endl

delete

[]

p

}

else

if

0

!=

p

size

+

1

])

{

cout

<<

“~COW”

<<

endl

p

size

+

1

=

0

delete

[]

p

}

}

void

*

operator

new

[]

size_t

size

{

//過載new[]多分配一個位元組作計數位

return

malloc

sizeof

char

*

size

+

1

));

}

void

print

()

{

cout

<<

p

<<

endl

}

private

char

*

p

size_t

size

};

int

main

()

{

COW

c1

=

“hello”

c1

print

();

COW

c2

c1

);

c2

print

();

COW

c3

=

c1

c3

print

();

c3

0

=

‘H’

c2

3

=

‘L’

c1

print

();

c2

print

();

c3

print

();

cout

<<

“=============”

<<

endl

const

COW

c4

=

“copy on write”

const

COW

c5

=

c4

// c5[0] = ‘C’; error const物件不能修改

COW

c6

=

c5

c6

print

();

c6

0

=

‘C’

c6

print

();

return

0

}

// 這裡需要使用支援C++11標準的編譯器g++ xxx。cpp -std=c++1z

// 這個程式碼是有缺陷的,只是演示cow使用的玩具

如上面的例子,一個物件被多個其他物件共享,當他是一個const物件時,其const函式可以不考慮寫時複製(因為const物件不可修改),當他是一個non-const物件時,其non-const函式必需考慮寫時複製

注:const函式可以只能呼叫const函式,non-const函式可以呼叫const函式

不然出現error如下:

GeekBand C++面向物件高階程式設計(下)2

GeekBand C++面向物件高階程式設計(下)2

4. 過載new/new[]和delete/delete[]

4.1全域性過載的介面

void

*

operator

new

size_t

size

{

//。。。

}

void

operator

delete

void

*

ptr

{

//。。。

}

void

*

operator

new

[](

size_t

size

{

//。。。

}

void

operator

delete

[](

void

*

ptr

{

//。。。

}

這些介面是固定的,過載了全域性的new和delete後編譯器將不在呼叫預設的new/delete函式,需要謹慎,這個影響是相當大的,在函式內部要做什麼是設計者的事情了。

4.2類內過載的介面

class

Foo

{

public

。。。

void

*

operator

new

size_t

size

{

//。。。

}

void

operator

delete

void

*

ptr

{

//。。。

}

void

*

operator

new

[](

size_t

size

{

//。。。

}

void

operator

delete

[](

void

*

ptr

{

//。。。

}

。。。

};

在類內過載後,當遇到建立類物件的時候就會呼叫過載後的分配釋放函式,如下:

Foo

*

p

=

new

Foo

。。。

delete

p

我們通常將new/delete過載後,在分配的時候悄悄地多分配一些記憶體(作為記憶體池)

*注:如果有類內過載,就呼叫類內過載,如果沒有就呼叫全域性過載,如下方式是指明呼叫全域性過載:

Foo

*

p

=

::

new

Foo

。。。

::

delete

p

4.3過載new(),delete()

我們可以過載class member operator new(),每一個版本都需要宣告有獨特的引數列表,其中第一個引數必需是size_t,其餘的引數可以自行指定,出現在new(。。。)小括號內的就是placement argument(配置引數),如下列:

Foo

*

pf

=

new

100

‘c’

Foo

我們也可以過載class member operator delete(),但是他們不會被delete呼叫,只有new所呼叫的建構函式丟擲異常時,才會呼叫這些過載版的operator delete(),他們只可能這樣被呼叫,主要是用來回收未能完全建立的變數所佔用的記憶體(構造可能失敗)

舉例:

#include

#include

using

namespace

std

class

Bad

public

exception

{

public

Bad

const

string

&

msg

msg

msg

{

}

~

Bad

void

throw

(){

}

const

char

*

what

void

const

throw

()

{

return

msg

c_str

();

}

private

string

msg

};

class

Foo

{

public

Foo

()

{

cout

<<

“Foo()”

<<

endl

}

Foo

int

{

cout

<<

“Foo(int)”

<<

endl

// 在ctor中故意丟擲異常

throw

Bad

“Foo ctor error”

);

}

// 一般的operator new()

void

*

operator

new

size_t

size

{

return

malloc

size

);

}

// 標準庫提供的placement new()

void

*

operator

new

size_t

size

void

*

start

{

return

start

}

// 自定義的placement new

void

*

operator

new

size_t

size

long

extra

{

return

malloc

size

+

extra

);

}

// 一般的operator delete

void

operator

delete

void

*

{

cout

<<

“operator delete(void*)”

<<

endl

}

// 對應第二個operator new

void

operator

delete

void

*

void

*

{

cout

<<

“operator delete(void*, void*)”

<<

endl

}

// 對應第三個operator new

void

operator

delete

void

*

long

{

cout

<<

“operator delete(void*, long)”

<<

endl

}

private

int

i

};

int

main

()

{

try

{

// 呼叫operator new(size_t, long)

Foo

*

f

=

new

1

Foo

1

);

}

catch

Bad

&

bad

{

cout

<<

bad

what

()

<<

endl

}

return

0

}

輸出結果:

GeekBand C++面向物件高階程式設計(下)2

GeekBand C++面向物件高階程式設計(下)2

即使operator delete(。。。)未能一一對應operator new(。。。),也不會有任何編譯waring或者error,因為你的意思是,放棄處理ctor丟擲的異常。