GeekBand C++面向物件高階程式設計(下)2
1.vptr 和 vtbl
C++的多型(動態繫結)在底層是如何實現的呢?
最主要的兩個的東西就是vptr(虛指標)和vtbl(虛擬函式表),先來看一個測試程式:
先定義一個父類和一個子類,在父類中使process函式為虛擬函式
class
Fruit
{
public
:
void
()
{
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
::
;
printf
(
“&Fruit::print %p
\n
”
,
fp
);
void
(
Apple
::*
ap
)()
=
&
Apple
::
;
printf
(
“&Apple::print %p
\n
”
,
ap
);
// 檢視save函式的地址
ap
=
&
Apple
::
save
;
printf
(
“&Apple::save %p
\n
”
,
ap
);
return
0
;
}
上圖是輸出結果,從結果看,虛擬函式process在父類和子類中被繫結到了不同的地方(這就是多型),非虛擬函式的print函式地址是一樣的。
1.1多型會在什麼時候出現呢?
(1)當父類指標,指向子類物件的時候
(2)。當父類引用,作為子類物件的別名的時候
#include
using
namespace
std
;
class
Basic
{
public
:
virtual
void
()
const
{
cout
<<
“class Basic”
<<
endl
;
}
};
class
A
:
public
Basic
{
public
:
void
()
const
{
cout
<<
“class A”
<<
endl
;
}
};
int
main
()
{
// 當父類引用,作為子類物件的別名的時候
A
a
;
Basic
&
b1
=
a
;
b1
。
();
// 當父類指標,指向子類物件的時候
Basic
*
b2
=
new
A
;
b2
->
();
return
0
;
}
1.2 右值引用
現在我想在做下面這件事:
int
main
()
{
// 用A型別指標接收一個堆空間
A
*
a
=
new
A
;
// 使用Basic類的指標引用繫結指標a
Basic
*&
b
=
a
;
// 透過b呼叫a中的print函式
b
->
();
return
0
;
}
但是編譯出現error
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
()
const
{
cout
<<
“class Basic”
<<
endl
;
}
};
然後寫一個main函式如下:
int
main
()
{
// 建立a
A
a
;
// 對a轉型
static_cast
<
Basic
>
(
a
);
return
0
;
}
然後輸出結果如下:
這說明轉型的時候呼叫了一次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:
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
()
const
{
cout
<<
“class Basic”
<<
endl
;
}
};
class
A
:
public
Basic
{
public
:
void
()
const
{
cout
<<
“class A”
<<
endl
;
}
};
int
main
()
{
A
*
a
=
new
A
;
// 使用右值引用
Basic
*&&
b
=
a
;
b
->
();
return
0
;
}
b繫結的是a型別轉換之後的臨時變數(其中有賦值過程,因為引用本身也是一個指標)
1.3 多型與指標帶來的彈性
我們想使用一個list,用來儲存不同型別的物件,但是list只能有一個型別,那麼這就給設計帶來了麻煩,如果沒有多型,我們就需要一個型別的物件建立一個匹配的list,這樣增加了程式設計師的負擔,也增加了程式碼的沉餘量,浪費了儲存空間
使用多型和指標就可以輕鬆的解決這個問題,我們可以讓想加入list的物件都有一個一樣的父類,再將list建立為父類的指標型別,如下:
#include
#include
using
namespace
std
;
class
Basic
{
public
:
virtual
void
()
const
{
cout
<<
“class Basic”
<<
endl
;
}
};
class
A
:
public
Basic
{
public
:
void
()
const
{
cout
<<
“class A”
<<
endl
;
}
};
class
B
:
public
Basic
{
public
:
void
()
const
{
cout
<<
“class B”
<<
endl
;
}
};
class
C
:
public
Basic
{
public
:
void
()
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
)
->
();
}
return
0
;
}
2.this pointer
先看一段程式碼:
#include
using
namespace
std
;
class
Basic
{
public
:
void
init
()
{
cout
<<
“init: ”
;
();
}
virtual
void
()
{
cout
<<
“class Basic”
<<
endl
;
}
};
class
Child
:
public
Basic
{
public
:
void
()
{
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函式
void print()
{
cout << “class Child” << endl;
}
在呼叫init函式時,其實相當於
Basic
::
init
(
&
c
);
每一個成員函式都有一個隱藏的引數用來傳遞呼叫物件自己(this指標),然後在呼叫print函式時相當於:
this
->
();
// 由於是動態繫結所以變成
(
*
(
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
()
const
{
cout
<<
“const Basic”
<<
endl
;
}
void
()
{
cout
<<
“Basic”
<<
endl
;
}
};
int
main
()
{
Basic
b
;
b
。
();
const
Basic
bc
;
bc
。
();
return
0
;
}
上述程式碼編譯透過,沒有error
print函式要夠成過載,不是應該只看函式名和引數型別嗎?我們來看一下彙編這麼說:
void print() const 在彙編中的名字:
_ZNK5Basic5printEv
void print() 在彙編中的名字:
_ZN5Basic5printEv
他們的名字是不一樣的,這是因為const也屬於函式簽名的一部分,什麼是函式簽名?
void
()
const
()
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
()
{
cout
<<
p
<<
endl
;
}
private
:
char
*
p
;
size_t
size
;
};
int
main
()
{
COW
c1
=
“hello”
;
c1
。
();
COW
c2
(
c1
);
c2
。
();
COW
c3
=
c1
;
c3
。
();
c3
[
0
]
=
‘H’
;
c2
[
3
]
=
‘L’
;
c1
。
();
c2
。
();
c3
。
();
cout
<<
“=============”
<<
endl
;
const
COW
c4
=
“copy on write”
;
const
COW
c5
=
c4
;
// c5[0] = ‘C’; error const物件不能修改
COW
c6
=
c5
;
c6
。
();
c6
[
0
]
=
‘C’
;
c6
。
();
return
0
;
}
// 這裡需要使用支援C++11標準的編譯器g++ xxx。cpp -std=c++1z
// 這個程式碼是有缺陷的,只是演示cow使用的玩具
如上面的例子,一個物件被多個其他物件共享,當他是一個const物件時,其const函式可以不考慮寫時複製(因為const物件不可修改),當他是一個non-const物件時,其non-const函式必需考慮寫時複製
。
注:const函式可以只能呼叫const函式,non-const函式可以呼叫const函式
不然出現error如下:
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
;
}
輸出結果:
即使operator delete(。。。)未能一一對應operator new(。。。),也不會有任何編譯waring或者error,因為你的意思是,放棄處理ctor丟擲的異常。