C++中对象的大小
需要多少内存才能表现一个class object?一般而言要有:
其nonstatic data members的总和大小;
加上任何由于字节对齐需要而填充上去的空间(可能存在与members之间,也可能存在于集合体边界);
加上为了支持virtual而由内部产生的任何额外负担。
C++中实际对象的大小,不同编译器的实现是不一样的,以下仅讨论.net2008,其他编译的可能出现的结果以下也做了分析和猜测。在反推不同编译器实现的C++对象的大小时,对齐是一个很重要也容易被遗忘的问题。
class A{};
看上去一个空的类A事实上并不是空的,它有一个隐含的1byte,那是被编译器安插进去的一个char,这使得这个class的两个objects得以在内存中配置独一无二的地址:
A a,b;
If ( &a == &b ) cerr<<”yipes!”<<endl;
class B:public virtual A{};
B类是对A类的虚继承,B中一般会有指向A的实例的指针,在IA-32下为4bytes。这里不同编译器的实现差别很大,有的编译器在B的对象中也会保留A类的那个隐含的char,于是就有1+4=5个bytes,再考虑对齐,有些编译器产生的结果为8bytes,但是在.net2008中优化过了,不会有A中的char,也就不需要对齐,只有4bytes大小
class C:public virtual A{};
同上
class D:public B,public C{};
D为8,如果编译器不优化A中的char就会有1(A)+8(B)+8(C)-4(B对A虚继承)-4(C对A虚继承)+3(对齐)=12bytes
class E
{
int i;
};
很明显4bytes
class F
{
double d;
};
很明显8bytes
class G
{
double num;
char in;
};
8bytes对齐,所以是8(double)+4(int)+4(对齐)=16
class H
{
int num;
double in;
};
同上
class I
{
int num;
double in;
public:
virtual ~I(){};
};
8(double)+4(int)+4(对齐)+4(vptr)+4(对齐)=24
class J
{
double num;
int in;
public:
virtual ~J(){};
};
同上8(double)+4(int)+4(对齐)+4(vptr)+4(对齐)=24
class K
{
int i;
int k;
public:
virtual ~K(){};
};
4(int)+4(int)+4(vptr)=12
class L
{
int i;
int j;
L(){};
public:
float ll(int i)
{
return 0.0;
}
static int hhh(int i)
{
return 0.0;
}
virtual ~L(){};
virtual ji(){};
};
虚函数表的指针vptr,只有类中出现虚函数才会出现,它指向虚函数表,所有虚函数的地址存放在此表中。4(int)+4(int)+4(vptr)=12从中看出,不管有多少虚函数,大小不变,因为类中只保存虚函数表。不管成员函数有多少,类大小也不变,因为他们不保存在对象中,无论是否是静态。
#include <iostream>
using std::cout;
using std::endl;
class A{};
class B:public virtual A{};
class C:public virtual A{};
class D:public B,public C{};
class E
{
int i;
};
class F
{
double d;
};
class G
{
double num;
char in;
};
class H
{
int num;
double in;
};
class I
{
int num;
double in;
public:
virtual ~I(){};
};
class J
{
double num;
int in;
public:
virtual ~J(){};
};
class K
{
int i;
int k;
public:
virtual ~K(){};
};
class L
{
int i;
int j;
L(){};
public:
float ll(int i)
{
return 0.0;
}
static double hhh(int i)
{
return 0.0;
}
virtual ~L(){};
virtual void ji(){};
};
int main()
{
cout <<"A "<<sizeof(A)<<endl;
cout <<"B "<<sizeof(B)<<endl;
cout <<"C "<<sizeof(C)<<endl;
cout <<"D "<<sizeof(D)<<endl;
cout <<"E "<<sizeof(E)<<endl;
cout <<"F "<<sizeof(F)<<endl;
cout <<"G "<<sizeof(G)<<endl;
cout <<"H "<<sizeof(H)<<endl;
cout <<"I "<<sizeof(I)<<endl;
cout <<"J "<<sizeof(J)<<endl;
cout <<"K "<<sizeof(K)<<endl;
cout <<"L "<<sizeof(L)<<endl;
}
/*******************************************************************/
输出结果为:
A 1
B 4
C 4
D 8
E 4
F 8
G 16
H 16
I 24
J 24
K 12
L 12
/*******************************************************************/
参考资料:
《深度探索 C++对象模型》P83~88
http://blog.csdn.net/zzm7000/archive/2006/03/30/644467.aspx
成员初始化表
#include <string>
class Account {
public:
Account();
Account( const char*, double=0.0 );
Account( const string&, double=0.0 );
Account( const Account& );
// ...
private:
// ...
};
//注意:构造函数的初始化列表只在构造函数的定义中指定,而不在声明中指定
inline Account::Account( const char* name, double opening_bal )
: _name( name ), _balance( opening_bal )
{
_acct_nmbr = get_unique_acct_nmbr();
}
成员初始化列表跟在构造函数的原型后,以冒号开头。成员名是被指定的,后面是括在括号中的初始值,类似于函数调用的语法。如果成员是类对象,则初始值变成被传递给适当的构造函数的实参,该构造函数然后被应用在成员类对象上。在我们的例子中,name被传递给应用在_name上的string构造函数,_balance 用参数opening_bal 初始化。类似地,下面是另一个双参数Account构造函数:
inline Account::Account( const string& name, double opening_bal )
: _name( name ), _balance( opening_bal )
{
_acct_nmbr = get_unique_acct_nmbr();
}
在这种情况下,string的拷贝构造函数被调用,把成员类对象_name 初始化成string 参数name。
C++新手关注的一个常见问题是,使用初始化列表和在构造函数内使用数据成员的赋值之间有什么区别。例如,以下代码:
inline Account::Account( const char *name, double opening_bal )
: _name( name ), _balance( opening_bal )
{
_acct_nmbr = get_unique_acct_nmbr();
}
和
inline Account::Account( const char *name, double opening_bal )
{
_name = name;
_balance = opening_bal;
_acct_nmbr = get_unique_acct_nmbr();
}
它们的区别是什么?
两种实现的最终结果是一样的。在两个构造函数调用的结束处,三个成员都含有相同的值,区别是成员初始化表只提供该类数据成员的初始化。在构造函数体内对数据成员设置值是一个赋值操作。区别的重要性取决于数据成员的类型。
在构造函数初始化列表中没有显示提及的每个成员,使用与初始化变量相同的规则来进行初始化。运行该类型的默认构造函数,来初始化类类型的数据成员。内置或复合类型的成员的初始值依赖于对象的作用域:在局部作用域中这些成员不被初始化,而在全局作用域中,它们被初始化为0(《c++ primer》4th p388)。
在概念上,我们可以认为构造函数的执行过程被分成两个阶段:隐式或显式初始化阶段,以及一般的计算阶段:
初始化阶段可以是显式的或隐式的,取决于是否存在成员初始化表。隐式初始化阶段按照声明的顺序依次调用所有基类的缺省构造函数,然后是所有成员类对象的缺省构造函数。
计算阶段由构造函数体内的所有语句构成。在计算阶段中,数据成员的设置被认为是赋值,而不是初始化。没有清楚地认识到这个区别是程序错误和低效的常见源泉。
例如,当我们写如下代码:
inline Account::Account()
{
_name = "";
_balance = 0.0;
_acct_nmbr = 0;
}
则初始化阶段是隐式的,在构造函数体被执行之前,先调用与_name相关联的缺省string构造函数,这意味着把空串赋给_name的赋值操作是没有必要的。
对于类对象,在初始化和赋值之间的区别是巨大的。成员类对象应该总是在成员初始化表中被初始化,而不是在构造函数体内被赋值。缺省Account构造函数的更正确的实现如下:
inline Account::Account() : _name( string() )
{
_balance = 0.0;
_acct_nmbr = 0;
}
它之所以更正确,是因为我们已经去掉了在构造函数体内不必要的对_name的赋值。但是,对于缺省构造函数的显式调用也是不必要的,下面是更紧凑但却等价的实现:
inline Account::Account()
{
_balance = 0.0;
_acct_nmbr = 0;
}
剩下的问题是,对于两个被声明为内置类型的数据成员,其初始化情况如何?例如,用成员初始化表和在构造函数体内初始化_balance 是否等价?回答是不。对于非类数据成员的初始化或赋值,除了两个例外,两者在结果和性能上都是等价的。更受欢迎的实现是用成员切始化表:
// 更受欢迎的初始化风格
inline Account::Account() : _balanae( 0.0 ), _acct_nmbr( 0 )
{ }
两个例外是指任何类型的const 和引用数据成员。const 和引用数据成员也必须是在成员初始化表中被初始化,否则,就会产生编译时刻错误。例如,下列构造函数的实现将导致编译时刻错误:
class ConstRef {
public:
ConstRef( int ii );
private:
int i;
const int ci;
int &ri;
};
ConstRef::ConstRef( int ii )
{ // 赋值
i = ii; // ok
ci = ii; // 错误: 不能给一个 const 赋值
ri = i; // 错误 ri 没有被初始化
}
当构造函数体开始执行时,所有const 和引用的初始化必须都已经发生。因此,只有将它们在成员初始化表中指定这才有可能。正确的实现如下:
// ok: 初始化引用和 const
ConstRef::ConstRef( int ii ):ci( ii ), ri( i )
{ i = ii; }
每个成员在成员初始化表中只能出现一次,初始化的顺序不是由名字在初始化表中的顺序决定,而是由成员在类中被声明的顺序决定的。例如,给出下面的Account 数据成员的声明顺序:
class Account {
public:
// ...
private:
unsigned int _acct_nmbr;
double _balance;
string _name;
};
下面的缺省构造函数:
inline Account::
Account() : _name( string() ), _balance( 0.0 ), _acct_nmbr( 0 )
{}
的初始化顺序为_acct_nmbr、_balance,然后是_name。但是在初始化表中出现(或者在被隐式初始化的成员类对象中)的成员,总是在构造函数体内成员的赋值之前被初始化。例如,在下面的构造函数中:
inline Account::Account( const char *name, double bal )
: _name( name ), _balance( bal )
{
_acct_nmbr = get_unique_acct_nmbr();
}
初始化的顺序是_balance、_name,然后是_acct_nmbr。
由于这种“实际的初始化顺序”与“初始化表内的顺序”之间的明显不一致,有可能导致以下难于发现的错误,当用一个类成员初始化另一个时:
class X {
int i;
int j;
public:
// 喔! 你看到问题了吗?
X( int val ): j( val ), i( j )
{}
// ...
};
尽管看起来j 好像是用val 初始化的,而且发生在它被用来初始化i之前,但实际上是i先被初始化的,因此它是用一个还没有被初始化的j 初始化的。我们的建议是,把“用一个成员对另一个成员进行初始化(如果你真的认为有必要)”的代码放到构造函数体内。
补充:
为了让你的程序能够顺利编译,在下面4种情况下,必须使用member initialization list:
当初始化一个reference member时;
当初始化一个const member时;
当类成员中含有一个const对象时,或者是一个引用时,他们也必须要通过成员初始化列表进行初始化,因为这两种对象要在声明后马上初始化,而在构造函数中,做的是对它们的赋值,这样是不被允许的。
当调用一个base class的constructor,而它拥有一组参数时;
当调用一个member class的constructor,而它拥有一组参数时;
我们知道类的对象的初始化其实就是调用它的构造函数完成,如果没有写构造函数,编译器会为你默认生成一个。如果你自定义了带参数的构造函数,那么编译器将不生成默认构造函数。这样这个类的对象的初始化必须有参数。如果这样的类的对象来做另外某个类的成员,那么为了初始化这个成员,你必须为这个类的对象的构造函数传递一个参数。同样,如果你在包含它的这个类的构造函数里用“=”,其实是为这个对象“赋值”而非“初始化”它。所以一个类里的所有构造函数都是有参数的,那么这样的类如果做为别的类的成员变量,你必须显式的初始化它,你也只能通过成员初始化列表来完成初始化。
参考材料:
《c++ primer》3th、4th
《深度探索C++对象模型》
缺省构造函数
首先看看下面一段程序代码:
class Foo {
public:
int val;
Foo *pnext;
};
void foo_bar()
{
Foo bar;
if( bar.val || bar.pnext )//程序假设bar的两个成员都已被清零
//... do something
//...
}
在上面这个例子中,正确的程序语意是要求Foo有一个缺省构造函数,可以将两个members初始化为0。但是实际情况却不是这样的。程序如果有需要,那是设计程序的人的责任;只有当编译器需要的时候,才会合成出一个缺省构造函数,并且合成出来的构造函数只执行编译器所需的行动。也就是说,即使有需要为class Foo合成一个缺省构造函数,那个constructor也不会将两个数据成员 val和pnext初始化为0。
C++ Standard[ISO-C++95]的Section12.1上这么说:
对于Class X,如果没有任何用户声明的构造函数,那么会有一个缺省构造函数被暗中声明出来……一个被暗中声明出来的缺省构造函数将是一个trivial(浅薄无能,没啥用的) constructor……
C++ Standard然后开始一一叙述在什么样的情况下这个暗中声明出来的缺省构造函数会被视为trivial。一个nontrivial缺省构造函数就是编译器需要的那种,必要的话会由编译器合成出来。下面将分别讨论nontrivial 缺省构造函数的四种情况。
带有Default Constructor的Member class object
如果一个类没有任何构造函数,但是它内含一个member object,而后者有一个缺省构造函数,那么这个类的implicit default constructor就是“nontrivial”,编译器需要为此合成一个default constructor。不过这个合成操作只有在constructor真正需要被调用时才会发生。举个例子,在下面的程序片段中,编译器为class Bar合成一个default constructor:
class Foo { public: Foo(); Foo(int)... };
class Bar { public: Foo foo; char *str; };//内含Foo的对象
void foo_bar()
{
Bar bar;
if( str ){ … }
//...
}
被合成的Bar default constructor内含必要的代码,能够调用class Foo的缺省构造函数来处理member object Bar::foo,但它并不产生任何代码来初始化Bar::str。将Bar::foo初始化是编译器的责任,将Bar::str初始化则是程序员的责任。被合成出来的default constructor看起来可能像这样:
inline Bar::Bar()
{
//C++伪码
foo.Foo::Foo();
}
假设程序员经由下面的default constructor提供了str的初始化操作:
Bar::Bar() { str = 0; }
现在程序的需求获得满足了,但是编译器还需要初始化member object foo。由于缺省构造函数已经被明确定义出来,编译器没办法合成第二个,于是编译器采取如下行动:“如果class A内含一个或一个以上的member class object,那么class A的每一个constructor必须调用每一个member classes的缺省构造函数”。编译器会扩张已存在的constructors,在其中安插一些代码,使得user code在被执行之前,先调用必要的缺省构造函数。延续前一个例子,扩张后得constructors可能像这样:
Bar::Bar() { //C++伪码
foo.Foo::Foo();
str = 0;
}
如果有多个class member objects都要求constructor初始化操作,将如何呢?C++语言要求以“members objects在class中的声明次序”来调用各个constructors。这一点由编译器完成,它为每一个constructor安插程序代码,以“member声明次序”调用每一个member所关联的default constructors。这些代码安插explicit user code之前。
带有Default Constructor的Base class
类似的道理,如果一个没有任何构造函数的类派生自一个带有缺省构造函数的基类,那么这个派生类的缺省构造函数被视为nontrivial,并因此需要被合成出来。它将调用上一层base classes的default constructor(根据它们的声明次序)。对一个后继派生的class而言,这个合成的constructor和一个“被明确提供的default construnctor”没什么差异。
如果设计者提供多个构造函数,但是其中却没有缺省构造函数呢?编译器会扩张现有的每一个构造函数,将“用以调用所有必要之default constructors”的程序代码加进去。它不会合成一个新的缺省构造函数,这是因为其它由用户提供的缺省构造函数存在的原因。如果同时亦存在着带有缺省构造函数的member class objects,那些缺省构造函数也会被调用—在所有基类构造函数都被调用之后。
带有一个虚函数的class
另外有两种情况,需要合成缺省构造函数:
class 声明(或继承)一个虚函数
class派生自一个继承串链,其中有一个或更多的虚基类
不管那一种情况,由于缺乏由user声明的构造函数,编译器会详细记录合成一个default constructor的必要信息。以下面这个程序片段为例:
class Widget{
public:
virtual void flip() = 0;
//...
}
void flip( const Widget & widget ) { widget.flip(); }
//假设Bell和Whistle都派生自Widget
void foo()
{
Bell b;
Whistle w;
flip(b);
flip(w);
}
下面两个扩张操作会在编译期间发生:
一个虚函数表会被编译器产生出来,内放类的虚函数地址
在每一个对象中,一个额外的vptr(虚函数表指针)会被编译器合成出来,内含相关的虚函数表的地址
此外,widget.flip()会被重新改写,以使用widget的vptr和vtbl中的flip()条目:
( *widget.vptr[1] )( &widget )
// 1表示flip()在虚表中的固定索引
// &widget代表要交给“被调用的某个flip()函数实体”的this指针
为了让这个机制发挥功效,编译器必须为每一个Widget或其派生类的对象的vptr设定初值,放置适当的虚表地址。对于class所定义的每一个构造函数,编译器会安插一些代码来做这些事情。对于那些未声明任何构造函数的类,编译器会为它们合成一个缺省构造函数,以便正确地初始化每一个类对象的vptr。
带有一个虚基类的class
虚基类的实现法则在不同的编译器之间有很大的差别。然而,每一种实现方法的共同点在于必须使虚基类在其每一个派生类对象中的位置,能够于执行期准备妥当。例如下面这段程序代码中:
class X { public: int i; };
class A : public virtual X { public : int j; };
class B : public virtual X { public : int double d; };
class C : public A, public B { public : int k; };
//无法在编译时期决定出pa->X::i的位置
void foo( const A* pa ){ pa->i =1024; }
main()
{
foo( new A );
foo( new C );
//...
}
编译器无法固定住foo()之中“经由pa而存取的X::i”的实际偏移位置,因为pa的真正类型可以改变。编译器必须改变“执行存取操作”的那些代码,使X::i可以延迟到执行期才决定下来。原先cfront的做法是靠“在派生类对象的每一个虚基类中安插一个指针”来完成。所有“经由引用或指针来存取一个虚基类”的操作都可以通过相关指针完成。在这个例子中,foo()可以被改写如下,以符合这样的实现策略:
void foo( const A* pa ){ pa->__vbcX->i = 1024; }
其中__vbcX表示编译器所产生的指针,指向虚基类X。__vbcX(或编译器所产生的某个什么东西)是在类对象建构期间被完成的。对于类所定义的每一个构造函数,编译器会安插那些“允许每一个虚基类的执行期存取操作”的代码。如果类没有声明任何构造函数,编译器必须为它合成一个缺省构造函数。
总结
在以上4种情况下,会导致编译器必须为未声明构造函数的类合成一个缺省构造函数。C++ Standard把那些合成物称为implicit nontrivial default constructors。被合成出来的constructor只能满足编译器(而非程序)的需要。至于没有存在以上4种情况而又没有声明任何构造函数的类,我们说它们拥有的是implicit tirvial default constructors,它们实际上并不会被合成出来。
在合成的default constructor中,只有base class subobjects和member class objects会被初始化。所有其它的nonstatic data member,如整数、整数指针、整数数组等等都不会被初始化。这些初始化对程序而言或许有必要,但对编译器则并非必要。如果程序需要一个“把某指针设为0”的缺省构造函数,那么提供它的人应该是程序员。
C++新手一般有两个常见的误解:
1、任何类如果没有定义缺省构造函数,就回被合成出一个来;
2、编译器合成出来的缺省构造函数会明确设定“class内每一个data member的默认值”。
如上面所阐述的,以上没有一个是正确的。
参考资料:
《深度探索C++对象模型》P39~47
拷贝构造函数
有三种情况,会以一个object的内容作为另一个class object的初值。最明显的一种情况当然就是对一个object做明确得初始化操作,像这样:
class X{…};
X x;
//明确地以一个object的内容作为另一个class object的初值
X xx = x;
另两种情况是当object被当做参数交给某个函数时,以及当函数传回一个class object时。
假设class设计者明确定义了一个拷贝构造函数,像下面这样:
X::X( const X& x );
Y::Y( const Y& y, int = 0 );
那么在大部分情况下,当一个class object以另一个同类实体作为初值时,上述的拷贝构造函数就会被调用。这可能会导致一些临时性对象的产生或程序代码的蜕变。
Default Memberwise Initialization
如果类没有提供一个explicit copy constructor又当如何?当class object以相同类的另外一个object作为初值时,其内部是以所谓的default memberwise initialization手法完成的,也就是把每一个内建的或派生的data member的值,从某一个对象拷贝一份到另外一个对象身上。不过它并不会拷贝其中的member class object,而是以递归的方式施行memberwise initialization。例如,考虑下面这个类声明:
class String{
public:
//...
private:
char *str;
int len;
};
一个String object的default memberwise initialization发生在这种情况之下:
String noun(“book”);
String verb = noun;
其完成方式就好像个别设定每一个members一样:
//语意相等
verb.str = noun.str;
verb.len = noun.len;
如果一个String object被声明为另一个class的member,像下面这样:
class Word{
public:
//...
private:
int _occurs;
String _word;
};
那么一个Word object的default memberwise initialization会拷贝其内建的member _occurs,然后再于String member object _word身上递归实施memberwise initialization。
就像缺省构造函数一样,C++ Standard上说,如果类没有声明一个拷贝构造函数,就会有隐含的声明(implicitly declared)或隐含的定义(implicitly defined)出现。和以前一样,C++ Standard把拷贝构造函数区分为trivial和nontrivial两种。只有nontrivial的实体才会被合成于程序之中。决定一个拷贝构造函数是否为trivial的标准在于class是否展现出所谓的“bitwise copy semantics”。
Bitwise Copy Semantics(位逐次拷贝)
在下面的程序片段中:
#include "word.h"
Word noun("book");
void foo()
{
Word verb = noun;
//...
}
很明显verb是根据noun来初始化。但是在尚未看过Word类的声明之前,我们不可能预测这个初始化操作的程序行为。如果Word的设计者定义了一个拷贝构造函数,verb的初始化操作就会调用它。但是如果该类没有定义explicit copy constructor,那么是否会有一个编译器合成的实体被调用呢?这就得视该class是否展现“bitwise copy semantics”而定。举个例子,已知下面得class Word声明:
class Word{
public:
Word( const char * );
~Word(){ delete[] str; }
//...
private:
int cnt;
char *str;
};
这种情况下,并不需要合成一个缺省拷贝构造函数,因为上述声明展现了“default copy semantics”,而verb的初始化操作也就不需要以一个函数调用收场(当然,该类的定义存在着严重的缺陷)。然而,如果class object是这样声明:
class Word{
public:
Word( const String& );
~Word(){ }
//...
private:
int cnt;
String str;
};
其中String声明了一个explicit copy constructor:
class String{
public:
String( const char * );
String( const String & );
//...
};
在这个情况下,编译器必须合成一个拷贝构造函数以便调用member class string object的拷贝构造函数:
//C++伪码
Inline Word::Word( const Word& wd )
{
str.String::String(wd.str);
cnt = wd.cnt;
}
有一点需要特别注意:在这个被合成出来的拷贝构造函数中,如整数、指针、数组等等的nonclass members也都会被复制,正如我们所期待的一样。
不要Bitwise Copy Semantics!
什么时候一个类不展现“bitwise copy semantics”呢?有以下4种情况:
当类内含一个member object,而后者的class声明有一个拷贝构造函数时(不论是被类设计者明确地声明,或是被编译器合成的)
当类继承自一个基类而后者存在有一个拷贝构造函数时(再次强调,不论是被明确声明或是被合成而得)
当类声明了一个或多个虚函数时
当类派生自一个继承串链,其中有一个或多个虚基类时
前两种情况中,编译器必须将member或base class的“copy constructors调用操作”安插到被合成的拷贝构造函数中。后面两种情况较为复杂一些,接下来将详细地讨论。
重新设定虚表的指针
假设类声明了一个或多个虚函数,编译期间会进行程序扩张操作:
增加一个虚函数表,内含每一个有作用的虚函数的地址
将一个指向虚函数表的指针,安插在每一个类对象中
显然,如果编译器对于每一个新产生的类对象的vptr不能成功而正确地设好其初值,将导致错误的结果。因此,当编译器导入一个vptr到class中时,该class就不再展现bitwise semantics了。现在,编译器需要合成出一个copy constructor,以便将vptr适当地初始化,下面是个例子:
首先,定义两个类,ZooAnimal和Bear:
class ZooAnimal{
public:
ZooAnimal();
virtual ~ZooAnimal();
virtual void animate();
virtual void draw();
//...
private:
//ZooAnimal的animate()和draw()所需要的数据
}
class Bear : public ZooAnimal{
public:
Bear();
void animate(); //虽然没有明写virtual,它实际上也是virtual
void draw(); //虽然没有明写virtual,它实际上也是virtual
virtual void dance();
//....
private:
//ZooAnimal的animate()、draw()和dance()所需要的数据
}
ZooAnimal class object以另一个ZooAnimal class object作为初值,或Bear class object以另一个Bear class object作为初值,都可以直接靠“bitwise copy semantics”完成(除了可能会有的pointer member之外,为了简化,这里不考虑这种情况)。举例:
Bear yogi;
Bear winnie = yogi;
yogi会被default Bear consturctor初始化。而在构造函数中,yogi的vptr被设定指向Bear class的virtual table。因此,把yogi的vptr值拷贝给winnie的vptr是安全的。
当一个基类对象以其派生类的对象做初始化操作时,其vptr复制操作也必须保证安全,例如:
ZooAnimal franny = yogi;//这会发生切割(sliced)行为
franny的vptr不可以被设定为指向Bear class的virtual table。合成出来的ZooAnimal copy constructor会明确设定object的vptr指向ZooAnimal class的virtual table,而不是从右手边的class object中将其vptr现值拷贝过来。
处理Virtual Base Class Subobject
每一个编译器对虚拟继承的支持承诺,都表示必须让派生类对象中的virtual base class subobject位置在执行期就准备妥当。维护“位置的完整性”是编译器的责任。“bitwise copy semantics”可能会破坏这个位置,所以编译器必须在它自己合成出来的拷贝构造函数中做出仲裁。举个例子,在下面的声明中,ZooAnimal成为Raccoon的一个虚拟基类,同时RedPanda public继承自Raccoon:
class Raccoon : public virtual ZooAnimal{
public:
Raccoon(){}
Raccoon( int val ){}
//…
private:
//…
}
class RedPanda : public Raccoon{
public:
RedPanda(){}
RedPanda( int val ){}
//…
private:
//…
}
如果以一个Raccoon object作为另一个Raccoon object的初值,那么“bitwise copy”就绰绰有余了:
Raccoon rocky;
Raccoon little_critter = rocky;
然而如果企图以一个RedPanda object作为little_critter的初值,编译器必须判断“后续当程序员企图存取其ZooAnimal subobject时是否能够正确地执行”:
RedPanda rocky;
Raccoon little_critter = rocky;
在这种情况下,为了完成正确的little_critter初值设定,编译器必须合成一个拷贝构造函数,安插一些代码以设定virtual base class pointer/offset的初值,对每一个members执行必要的memberwise初始化操作,以及执行其它的内存相关操作。
参考资料:
《深度探索C++对象模型》P48~60
数据成员的布局
已知下面一组数据成员:
class Point3d{
public:
//…
private:
float x;
static List<Point3d*> *freeList;
float y;
static const int chunkSize = 250;
float z;
}
非静态数据成员在class object中的排列顺序将和其被声明的顺序一样,任何中间介入的静态数据成员如freeList和chunkSize都不会被放进对象布局中。在上述例子中,每一个Point3d对象由三个float组成,次序是x、y、z。静态数据成员存放在程序的data segment中,和个别的class object无关。
C++标准要求,在同一个access section(也就是private、public、protected等区段)中,members的排列只须符合“较晚出现的members在class object中有较高的地址”这一条即可。也就是说,各个members并不一定得连续排列。什么东西可能会介于被声明的members之间呢?比如说members的边界调整时需要填充的一些字节等等。
同时,编译器还可能会合成一些内部使用的data members,以支持整个对象模型。vptr就是这样的东西,当前所有的编译器都把它安插在每一个“内含virtual function的class”的object内。vptr会被放在什么位置呢?传统上它被放在所有明确声明的members的最后,不过如今也有一些编译器把vptr放在class object的最前端。C++ standard允许编译器把这些内部产生出来的members自由放在任何位置上。
C++标准也允许编译器将多个access sections之中的data members自由排列,不必在乎它们出现在class声明中的次序。也就是说,下面这样的声明中:
class Point3d{
public:
//…
private:
float x;
static List<Point3d*> *freeList;
private:
float y;
static const int chunkSize = 250;
private:
float z;
}
其class object的大小和组成和我们先前声明的那个相同,但是members的排列次序则视编译器而定。编译器可以随意把y或z或其它什么东西放在第一个,不过大部分的编译器都没有这样做。当前各家编译器都是把一个以上的access sections连锁在一起,依照声明次序,成为一个连续的区块。access sections的多少,不会招来额外的负担。例如,在一个section中声明8个members,或是在8个sections中总共声明8个members,得到的object大小是一样的。
静态数据成员的存取
Static data members,按照其字面意思,被编译器提出到class之外,并被视为一个global变量(但只在class生命范围之内可见)。每一个member的存取许可(private或protected或public),以及与class的关联,并不会导致任何空间上或执行时间上的额外负担。
每一个static data member只有一个实体,存放在程序的data segment之中。每次程序取用static data member,就会被内部转化为对该唯一的extern实体的直接操作:
//origin.chunkSize = 250;
Point3d::chunkSize = 250;
//pt->chunkSize = 250;
Point3d::chunkSize = 250;
从指令执行的观点来看,这是C++语言中“通过一个指针和通过一个对象来存取member,结论完全相同”的唯一一种情况。这是因为“经由member selection operators对一个static data member进行存取操作“只是语法上都一种便宜行事而已。member其实不在class object之中,因此存取static members并不需要通过class object。
如果chunkSize是从一个复杂继承关系中继承而来都member,又当如何呢?或许它是一个“virtual base class的virtual base class“(或其它同等复杂的继承结构)的member也说不定。即使这样的情况,也是无关紧要的,程序之中对于static members还是只有唯一的一个实体,而其存取路径依然是那么直接。
若取一个静态数据成员的地址,会得到一个指向其数据类型的指针,而不是一个指向其class member的指针,因为static member并不内含在一个class object之中。例如:
&Point3d::chunkSize;
会获得类型如下都内存地址:
const int*
如果有两个classes,每一个都声明了一个static member freeList,那么当它们都被放在程序的data segment时,就会导致名称冲突。编译器的解决办法是暗中对每一个static data member编码(这种手法被称为:name-mangling),以获得一个独一无二的程序识别代码。
非静态数据成员的存取
非静态数据成员直接存放在每一个class object之中。除非经由明确的(explicit)或暗喻的(implicit)class object,没有办法直接存取它们。只要程序员在一个member function中直接处理一个nonstatic data member,所谓“implicit class object”就会发生。例如下面这段代码:
Point3d Point3d::translate( const Point3d &pt ){
x += pt.x;
y += pt.y;
z += pt.z;
}
表面上所看到的对于x、y、z的直接存取,事实上是经由一个“implicit class object“(由this指针表达)来完成,事实上这个函数的参数为:
//member function的内部转化
Point3d Point3d::translate( Point3d * const this, const Point3d &pt ){
this->x += pt.x;
this->y += pt.y;
this->z += pt.z;
}
欲对一个非静态数据成员进行存取操作,编译器需要把class object的起始地址加上data member的偏移量。比如:
origin._y = 0.0;
地址&origin._y将等于:
&origin + (&Point3d::_y - 1);
要注意这里都有减1的操作。指向数据成员的指针,其offset值总是被加上1,这样可以使编译系统区分出“没有指向任何数据成员的指针”和“指向第一个数据成员的指针”这两种情况。
每一个非静态数据成员的偏移量(offset)在编译时期即可获知,甚至如果member属于一个base class subobject也是一样,因此,存取一个非静态数据成员,其效率和存取一个C struct member或一个nonderived class的member也是一样的。
现在我们看看虚拟继承。虚拟继承将为“经由base class subobject“存取class members导入一层新的间接性,譬如:
Point3d *pt3d;
Pt3d->_x = 0.0;
其执行效率在_x是一个struct member、一个class member、单一继承、多重继承的情况下都完全相同。但如果_x是一个virtual base class的member,存取速度会慢一些。
“继承“与数据成员
只要继承不要多态(Inheritance without Polymorphism)
假设有如下三个类及其继承关系:
class Concreate1{
public:
//...
private:
int val;
char bit1;
};
class Concreate2 : public Concrete1{
public:
//...
private:
char bit2;
}
class Concreate3 : public Concrete2{
public:
//...
private:
char bit3;
}
Concreate1、Concreate2、Concreate3的对象布局情况:
C++语言保证”出现在派生类中的base class subobject有其完整原样性“。Concrete1内含两个members:val和bit1,加起来5bytes。而一个Concreate1 object实际上用掉8bytes,包括填充用的3bytes,以使object能够符合一部机器的word边界。一般而言,边界调整(alignment)是由处理器来决定的。
然而,Concreate2的bit2实际上却是被放在填补空间所用的3bytes之后,于是其大小变成12bytes,而不是8bytes,其中6bytes浪费在填补空间上。相同的道理使得Concreate3 object的大小是16bytes,其中9bytes用于填补空间。
加上多态(adding Polymorphism)
假设我们要处理一个坐标点,而不打算在乎它是一个Point2d或Point3d实例,那么我们需要在继承关系中提供一个virtual function接口:
class Point2d{
public:
Point2d(float x=0.0, float y=0.0) : _x(x),_y(y){};
virtual float z(){return 0.0;}
virtual void z(float){}
operate+=(const Point2d &rhs){
_x += rhs.x();
_y += rhs.y();
}
protected:
float _x;
float _y;
}
class Point3d : public Point2d{
public:
Point3d(float x=0.0, float y=0.0, float z=0.0) : Point2d(x,y),_z(z){};
virtual float z(){ return _z; }
virtual void z(float newZ){ _z = newZ; }
operate+=(const Point2d &rhs){
Point2d::operator+=(rhs);
_z += rhs.z();
}
protected:
float _z;
}
只有当我们以多态的方式来处理2d或3d坐标点时,在设计之中导入一个virtual接口才显得合理。也就是说,写下这样的代码:
void foo( Point2d &p1, Point2d &p2 ){
//…
P1 += p2;
//…
}
其中p1和p2可能是2d也可能是3d坐标点,这并不是以前任何设计所能支持的。这样的弹性,当然正是面向对象程序设计的中心。同时,支持这样的弹性,也给我们的Point2d class带来空间和存取时间的额外负担:
导入一个和Point2d有关的virtual table,用来存放它所声明的每一个virtual functions的地址。
在每一个class object中导入一个vptr,提供执行期的链接,使每一个object能够找到相应的virtual table。
加强constructor,使它能够为vptr设定初值,让它指向class所对应的virtual table。这可能意味着在derived class和每一个base class的constructor中,重新设定vptr的值。其情况视编译器的优化的积极性而定。
加强destructor,使它能够抹消“指向class之相关virtual table“的vptr。要知道,vptr很可能已经在derived class destructor中被设定为derived class的virtual table地址。记住,desturctor的调用次序是反向的:从derived class到base class。
下图显示了Point2d和Point3d加上了virtual function之后的继承布局。此图把vptr放在base class的尾端:
多重继承(Multiple Inheritance)
多重继承不像单一继承,不容易模塑出其模型。多重继承的复杂度在于derived class和其上一个base class乃至于上上一个base class…之间的“非自然“关系。例如,考虑下面这个多重继承所获得的class Vertex3d:
class Point2d{
public:
//...
protected:
float _x;
float _y;
}
class Point3d : public Point2d{
public:
//...
ptotected:
float _z;
}
class Vertex{
public:
//...
protected:
Vertex *next;
}
class Vertex3d : public Point3d, public Vertex{
public:
//...
protected:
float mumble;
}
至此,Point2d、Point3d、Vertex、Vertex3d的继承关系如下图所示:
多重继承的主要问题发生于derived class objects和其第二或后继的base class objects之间的转换。对于一个多重派生对象,将其地址指定给“最左端base class的指针”,情况将和单一继承时相同,因为二者都指向相同的起始地址。至于第二个或后继的base class的地址指定操作,则需要对地址进行调整:加上(或减去,如果downcast的话)介于中间的base class subobject大小,例如:
Vertex3d v3d;
Vertex *pv;
Point2d *p2d;
Point3d *p3d;
那么下面这个指定操作:
pv = &v3d;
需要这样的内部转化:
//虚拟C++码
pv = (Vertex *)( ((char *)&v3d) + sizeof( Point3d ) )
而下面都指定操作:
p2d = &v3d;
p3d = &v3d;
都只需要简单地拷贝其地址就可以了。下面是该多重继承的数据布局示意图:
虚拟继承(Virtual Inheritance)
下图是Point2d、Point3d、Vertex、Vertex3d的继承体系:
class Point2d{
public:
//...
protected:
float _x;
float _y;
};
class Vertex : public virtual Point2d{
public:
//...
protected:
Vertex *next;
};
class Point3d : public virtual Point2d{
public:
//...
protected:
float _z;
};
class Vertex3d: public Vertex, public Point3d{
public:
//...
protected:
float mumble;
};
在存取派生类的共有的虚拟基类的时候,cfront编译器会在每一个派生类对象中安插一些指针,每个指针指向一个virtual base class。要存取继承得来的virtual base class members,可以使用相关指针间接完成。
上面这种实现方式的一个缺点是:每一个对象必须针对每一个virtual base class背负一个额外的指针。然而理想情况下我们希望class object有固定的负担,不因为其virtual base classes的数目而有所变化。该如何解决这个问题呢?virtual table offset strategy采用了另外一种实现策略:在virtual function table中放置virtual base class的offset(而不是地址),将virtual base class offset和virtual function entries混在一起。virtual function table可经由正值或负值来索引:如果是正值,很显然就索引到virtual functions;如果是负值,则索引到virtual base class offsets。
一般而言,虚基类最有效的一种运用形式就是:一个抽象的虚基类,其中没有任何数据成员。
参考资料:
《深度探索C++对象模型》
指向数据成员的指针
指向数据成员的指针,是一个有点神秘又颇有用处的语言特性,特别是如果你需要详细调查class members的底层布局的话。这样的调查可以用于决定vptr是放在class的起始处或者尾端。另外一个用途是可以用来决定class中的access sections的次序。
考虑下面的Point3d声明。其中有一个virtual function,一个static data member,以及三个坐标:
class Point3d{
public:
virtual ~Point3d();
//…
protected:
static Point3d origin;
float x,y,z;
}
每一个Point3d的对象含有三个坐标值,依次为x、y、z,以及一个vptr。至于静态数据成员origin,将被放在class object之外。唯一可能因编译器不同而不同的是vptr的位置。C++标准允许vptr被放在对象中的任何位置:在起始处,在尾端,或者是在各个members之间。然而实际上,所有编译器不是把vptr放在对象的头部,就是放在对象的尾部。
那么,取某个坐标成员的地址,代表什么意思呢?例如,以下操作所得到的值代表什么:
&Point3d::z;
上述操作将得到z坐标在class object中的偏移量(offset)。最低限度其值将是x和y的大小总和,因为C++语言要求同一个access level中的members的排列次序应该和其声明次序相同。在一台32位机器上,每一个float是4个字节,所以我们应该期望刚才获得的值要不是8,就是12(在32位机器上,一个vptr是4个字节)。
然而,这样的期望还少了1个字节。对于C和C++程序员来说,这多少算是个有点年代的错误了。如果vptr放在对象的末尾,则三个坐标值在对象布局中的偏移量分别为0、4、8;如果vptr放在对象的开头,则三个坐标值在对象布局中的偏移量分别为4、8、12。然而你若去取data members的地址,传回的值总是多1,也就是1、5、9或5、9、12等等。
#include <iostream>
class Point3d{
public:
virtual ~Point3d(){};
//…
public://如果换成private或者protected,则报错
static Point3d origin;
float x;
float y;
float z;
};
int main()
{
printf("&Point3d::x = %p\n", &Point3d::x);
printf("&Point3d::y = %p\n", &Point3d::y);
printf("&Point3d::z = %p\n", &Point3d::z);
std::cout<<"&Point3d::x = "<<&Point3d::x<<std::endl;
std::cout<<"&Point3d::y = "<<&Point3d::y<<std::endl;
std::cout<<"&Point3d::z = "<<&Point3d::z<<std::endl;
return 0;
}
输出结果为:
&Point3d::x = 00000004
&Point3d::y = 00000008
&Point3d::z = 0000000C
&Point3d::x = 1
&Point3d::y = 1
&Point3d::z = 1
Press any key to continue
在vc6.0下,并没有增加1,原因可能是visual c++做了特殊的处理。
在vc6.0下,通过printf或者cout的形式,都可以正常运行,只不过,得到的结果不一致。使用std::cout时,都输出的是1,应该作何解释呢?
以上程序,如果数据成员为private或者protected的,则无法编译通过,而书上的例子,却是protected,作者的测试程序可能是怎样的呢?
(以上程序在vc6.0,virsual studio2008,DEV-C++下测试过,与《深入探索C++对象模型》P131对应的说明有些出入)
为啥传回的值会多1个字节呢?这一个字节,主要用来区分“没有指向任何数据成员的指针”和“指向第一个数据成员的指针”这两种情况。考虑下面这样的例子:
float Point3d::*p1 =0;
float Point3d::*p2 = &Point3d::x;
//Point3d::* 的意思是“指向Point3d data member”的指针类型
if( p1 == p2 ){
cout <<” p1 & p2 contain the same value.”;
cout <<”they must address the same member!”<<endl;
}
为了区分p1和p2,每一个真正的member offset值都被加上1。因此,不论编译器或者使用者都必须记住,在真正使用该值以指出一个member之前,请先减去1。
在充分认识“指向数据成员的指针”之后,要解释:
&Point3d::z;和 &origin.z
之间的差异,就非常明确了:取一个非静态数据成员的地址,将会得到它在class中的offset,取一个绑定于真正class object身上的数据成员的地址,将会得到该数据成员在内存中真正的地址。&origin.z的返回值类型应该是:float * 而不是:float Point3d::* 。
#include <iostream>
class Point3d{
public:
virtual ~Point3d(){};
//…
public:
float x;
float y;
float z;
};
int main()
{
Point3d origin;
printf("&origin.z = %p\n", &origin.z);
return 0;
}
输出结果为:
&origin.z = 0013FF7C
参考资料:
《深度探索C++对象模型》
成员函数
假设有一个Point3d的指针和对象:
Point3d obj;
Point3d *ptr = &obj;
当进行如下操作:
obj.mormalize();
ptr->normalize();
时,会发生什么事情呢?其中的Point3d::normalize()定义如下:
Point3d Point3d::normalize() const{
register float mag = magnitude();
Point3d normal;
normal._x = _x/mag;
normal._y = _y/mag;
normal._z = _z/mag;
return normal;
}
而其中的Point3d::magnitude()又定义如下:
float Point3d::magnitude() const{
return sqrt( _x*_x + _y*_y + _z*_z );
}
答案是:需要视实际情况而定,C++支持三种类型的成员函数:static、nonstatic、virtual,每一种被调用的方式都不相同。
非静态成员函数(Nonstatic Member Functions)
C++的设计准则就是:非静态成员函数至少必须和一般的非成员函数有相同的效率。也就是说,如果我们要在以下两个函数之间做选择:
float magnitude3d( const Point3d *_this ){…}
float Point3d::magnitude3d() const{…}
那么,选择成员函数不应该带来什么额外负担。这是因为编译器内部已经将“member函数实体”转换为对等的“nonmember函数实体”。
举个例子,下面是magnitude()的一个nonmember定义:
float magnitude3d( const Point3d *_this ){
return sqrt( _this->_x*_this->_x +
_this->_y*_this->_y +
_this->_z*_this->_z );
}
乍看之下似乎非成员函数比较没有效率,它间接地经由参数取用坐标成员,而成员函数却是直接取用坐标成员。然而实际上成员函数被内化为非成员的形式,下面就是转化步骤:
1、改写函数的signature以安插一个额外的参数到成员函数中,用以提供一个存取管道,使class object得以调用该函数。该额外参数被称为this指针:
Point3d Point3d::magnitude( Point3d *const this )
如果member function是const,则变成:
Point3d Point3d::magnitude( const Point3d *const this )
2、将每一个“对非静态数据成员的存取操作”改为经由this指针来存取:
{
return sqrt( this->_x*this->_x +
this->_y*this->_y +
this->_z*this->_z );
}
3、将成员函数重新写成一个外部函数。对函数名称进行“mangling”处理,使它在程序中独一无二:
extern magnitude__7Point3dFv( register Point3d *const this );
现在这个函数已经转换好了,而其每一个调用操作也都必须转换。于是:
”obj.magnitude();”变成了:”magnitude__7Point3dFv(&obj);”
”ptr->magnitude();”变成了:”magnitude__7Point3dFv(ptr);”
前面提及的normalize()函数会被转化为下面的形式,其中假设已经声明有一个Point3d copy constructor,而named returned value(NRV)的优化也已施行:
void magnitude__7Point3dFv( register const Point3d *const this, Point3d &__result )
{
Register float mag = this->magnitude();
__result.Point3d::Point3d();
__result.x = this->_x/mag;
__result.y = this->_y/mag;
__result.z = this->_z/mag;
}
静态成员函数(Static Member Functions)
静态成员函数由于缺乏this指针,因此差不多等同于非成员函数。如果Point3d::normalize()是一个静态成员函数,以下两个调用操作:
obj.normalize();
ptr->normalize();
将被转化为一般的nonmember函数调用,像这样:
//obj.normalize();
normalize__7Point3dSfv();
//ptr->normalize();
normalize__7Point3dSfv();
静态成员函数的主要特性就是它没有this指针,其次要的特性统统根源于这个主要特性:
它不能够直接存取其class中的nonstatic members
它不能够被声明为const、volatile或virtual
它不需要经由class object才被调用--虽然大部分时候它是这样被调用的
一个静态成员函数,会被提到class声明之外,并给予一个经过“mangling”的适当名称。例如:
unsigned int Point3d::object_count()
{
Return _object_count;
}
会被cfront转化为:
//在cfront之下的内部转化结果
unsigned int object_count_5Point3dSFv()
{
Return _object_count_5Point3d;
}
其中SFv表示它是个static member function,拥有一个空白(void)的参数链表。
如果取一个静态数据成员的地址,得到的将是其在内存中的位置,也就是其地址。由于静态成员函数没有this指针,所以其地址的类型并不是一个“指向类成员函数的指针”,而是一个“非成员函数指针”。也就是说:
&Point3d::object_count();
会得到一个数值,类型是:
unsigned int(*)();
而不是:
unsigned int( Point3d::* )();
虚拟成员函数(Virtual Member Functions)
虚函数的一般实现模型是:每一个类有一个虚表,内含该类之中各虚函数的地址,然后每一个对象有一个vptr,指向虚表的所在。在这一小节,将根据单一继承、多重继承和虚拟继承等各种情况,从细节上探讨该实现方式。
在单一继承的情况下,一个class只会有一个virtual table,每一个table内含其对应的class object中所有active virtual function函数实体的地址。这些active virtual function包括:
这个类所定义的函数实体,它会改写一个可能存在的base class virtual function函数实体。
继承自base class的函数实体,这是在derived class决定不该写virtual function时才会出现的情况
一个pure_virtual_called()函数实体,它既可以扮演pure virtual function的空间保卫角色,也可以当做执行期异常处理函数。
每一个虚函数都被指派一个固定的索引值,这个索引在整个继承体系中保持与特定的virtual function的关联。例如,在我们的Point class体系中:
class Point{
public:
virtual ~Point();
virtual Point& mult(float) = 0;
//...
float x() const { return _x; }
virtual float y() const { return 0; }
virtual float z() const { return 0; }
//...
protected:
Point( float x = 0.0 );
float _x;
}
virtual destructor被赋值slot1,而mult()被赋值slot2。此例并没有mult()的函数定义,所以pure_virtual_called()的函数地址会被放在slot2中。如果该函数意外地被调用,通常的操作是结束掉这个程序。y()被赋值slot3而z()被赋值slot4。X()的slot是多少?答案是没有,因为它并不是虚函数。在上图中,可以清楚地看到相关的内存布局及其virtual table。
当一个类派生于Point时,会发生什么事情?例如,类Point2d:
class Point2d:public Point{
public:
Point2d( float x=0.0, float y=0.0 ):Point(x),_y(y){}
~Point2d();
Point2d& mult(float);
//...
float x() const { return _x; }
float y() const { return 0; }
//...
protected:
float _x;
}
一共有三种可能性:
它可以继承base class所声明的virtual functions的函数实体。正确地说,是该函数实体的地址会被拷贝到派生类的virtual table相对应的slot之中。
它可以使用自己的函数实体。这表示它自己的函数实体地址必须放在对应的slot之中。
它可以加入一个新的virtual function。这时候virtual table的尺寸会增大一个slot,而新的函数实体地址会被放进该slot之中。
Point2d的virtual table在slot1中指出destructor,在slot2中指出mult()(取代pure virtual function)。它自己的y()函数实体地址放进slot3,继承自Point的z()函数实体地址则放在slot4。
类似情况,Point3d派生自Point2d,如下:
class Point3d : public Point2d{
public:
Point3d( float x=0.0, float y=0.0, float z=0.0 ):Point2d(x,y),_z(z){}
~Point3d();
Point3d& mult(float);
//...
float z() const { return _z; }
//...
protected:
float _z;
}
其virtual table中的slot1放置Point3d的析构函数,slot2放置Point3d::mult()函数地址。Slot3放置继承自Point2d的y()函数地址,slot4放置自己的z()函数地址。
现在,如果有如下的语句:
ptr->z();
那么,如何有足够的知识在编译时期设定virtual function的调用呢?
一般而言,我们并不知道ptr所指对象的真正类型。然而,我们知道,经由ptr可以存取到该对象的virtual table。
虽然不知道哪一个z()函数实体会被调用,但我们知道每一个z()函数地址都被放在slot4。
这些信息使得编译器可以将该调用转化为:
( *ptr->vptr[4] )( ptr );
在这个转化中,vptr表示编译器所安插的指针,指向virtual table;4表示z()被赋值的slot编号。唯一一个在执行期才能知道的东西是:slot4所指的到底是哪一个z()函数实体?
在一个单一继承体系中,vritual function机制的行为十分良好,不但有效率而且很容易塑造出模型来。但是在多重继承和虚拟继承之中,对virtual functions的支持就没有那么美好了。
多重继承下的Virtual Functions
在多重继承中支持virtual functions,其复杂度围绕在第二个以及后继的基类身上,以及“必须在执行期调整this指针”这一点上。以下面的class体系为例:
class Base1{
public:
Base1();
virtual ~Base1();
virtual void speakclearly();
virtual Base1 *clone() const;
protected:
float data_Base1;
};
class Base2{
public:
Base2();
virtual ~Base2();
virtual void mumble();
virtual Base2 *clone() const;
protected:
float data_Base2;
};
class Derived : public Base1,public Base2{
public:
Derived();
virtual ~Derived();
virtual Derived *clone() const;
protected:
float data_Derived;
};
该多重继承体系的虚表布局情况如下所示:
首先,把一个从堆中配置而得的Derived对象的地址,指定给一个Base2指针:
Base2 *pbase2 = new Derived;
新的Derived对象的地址必须调整,以指向其Base2 subobject。编译时期会产生以下的代码:
Derived *temp = new Derived;
Base2 *pbase2 = temp ? temp + sizeof(Base1) : 0;
当程序员要删除pbase2所指的对象时:
delete pbase2;
指针必须再次被调整,以便再一次指向Derived对象的起始处。
一般规则是,经由指向“第二或后继之base class”的指针(或引用)来调用derived class virtual function,那么该调用操作所需“必要的this指针调整”操作,必须在执行期完成。也就是说,offset的大小,以及把offset加到this指针上头的那一小段程序代码,必须由编译器在某个地方插入。
调整this指针的另外一个负担是,由于两种不同的可能:(1)经由derived class(或第一个base class)调用,(2)经由第二个(或其后继)base class调用,同一函数在虚表中可能需要多笔对应的slots。例如:
Base1 *pbase1 = new Derived;
Base2 *pbase2 = new Derived;
//…
delete pbase1;
delete pbase2;
虽然两个delete导致相同的Derived destructor,但它们需要两个不同的virtual table slots:
pbase1不需要调整this指针(因为Base1已经指向Derived对象都起始处)。其virtual table slot需放置真正的destructor地址。
pbase2需要调整this指针,其virtual table slot需要相关的thunk地址。
Thunk解释:所谓thunk是一小段assembly代码,用来(1)以适当的offset值调整this指针,(2)跳到virtual function去。例如,经由一个Base2指针调用Derived destructor,其相关的thunk可能看起来是下面这个样子:
//虚拟C++代码
pbase2_dtor_thunk:
this += sizeof( base1 );
Derived::~Derived( this );
Thunk技术允许virtual table slot继续内含一个简单的指针,因此多重继承下不需要任何空间上的额外负担。slots中的地址可以直接指向virtual function,也可以指向一个相关的thunk(如果需要调整this指针的话)。
在多重继承下,一个derived class内含n-1个额外的virtual tables,n表示其上一层base classes的数目。对于本例而言,会有两个virtual table被编译器产生出来:
一个主要实体,与Base1(最左端base class)共享;
一个次要实体,与Base2(第二个base class)有关。
针对每一个virtual table,Derived对象中有对应的vptr。vptrs将在constructor(s)中被设立初值(经由编译器所产生出来的码)。
虚继承下的Virtual Functions
《深入探索C++对象模型》P168~169
参考资料:
《深度探索C++对象模型》
指向成员函数的指针
取一个非静态成员函数的地址,如果该函数是nonvirtual,则得到的结果是它在内存中真正的地址。然而这个值也不是完全的,它也需要被绑定于某个class object的地址上,才能够通过它调用该函数。所有的非静态成员函数都需要对象的地址(以参数this指出)。
一个指向成员函数的指针,其声明语法如下所示:
double //return type
(Point::* //class the function is member
pmf) // name of pointer to member
(); // argument list
然后我们可以这样定义并初始化该指针:
double (Point::*coord)() = &Point::x;
也可以这样指定其值:
coord = &Point::y;
想调用它,可以这样做:
(origin.*coord)();或 (ptr->*coord)();
指向member function的指针的声明语法,以及指向“member selection运算符”的指针,其作用是作为this指针的空间保留者。这也就是为什么static member functions(没有this指针)的类型为“函数指针”,而不是“指向member function之指针”的原因。使用一个成员函数的指针,如果并不用于虚函数、多重继承、虚基类等情况的话,并不会比使用一个“nonmember function 指针”的成本更高。
支持“指向virtual member functions”的指针
注意下面的程序片段:
float (Point::*pmf)() = &Point::z;
Point *ptr = new Point3d;
pmf,一个指向成员函数的指针,被设置为Point::z()(一个虚函数)的地址。如果我们直接经由ptr调用z():
ptr->z();
则被调用的是Point3d::z()。但如果我们从pmf间接调用z()呢?
(ptr->*pmf)();
仍然是Point::z()被调用吗?也就是说,虚拟机制仍然能够在使用“指向成员函数的指针”的情况下正常运行吗?如何实现的呢?
面对一个虚函数,其地址在编译时期是未知的,所能知道的仅仅是虚函数在其相关的虚表中的索引值。也就是说,对一个虚成员函数取其地址,所能获得的只是一个索引值。假设我们有以下的Point声明:
Class Point{
public:
virtual ~Point();
float x();
float y();
virtual float z();
//…
}
然后取destructor的地址:
&Point::~Point;
得到的结果是1。取x()或y()的地址:
&Point::x();
&Point::y();
得到的是函数在内存中的地址,因为它们不是virtual。取z()的地址:
&Point::z();
得到的结果是2,通过pmf来调用z(),会被内部转化为一个编译时期的式子,一般形式如下:
( *ptr->vptr[(int)pmf] )( ptr );
对一个指向成员函数的指针评估求值,会因为该值有两种意义而复杂化,其调用操作也将有别于常规调用操作。pmf的内部定义,也就是:
float (Point::*pmf)();
必须允许该函数能够寻址出nonvirtual x()和virtual z()两个成员函数,这两个成员函数有着相同的原型,只不过其中一个代表内存地址,另外一个代表在对应虚表中的索引值。因此,编译器必须定义pmf使它能够(1)含有两种数值,(2)更重要的是其数值可以被区别代表内存地址还是虚表中的索引值。
在cfront2.0的非正式版本中,这两个值被内含在一个普通的指针内。它使用如下技巧:
(((int)pmf) & ~127)
? (*pmf)(ptr) //non-virtual invocation
: ( *ptr->vptr[(int)pmf] )( ptr ); //virtual invocation
这种实现技巧必须假设继承体系中最多只能够有128个虚函数。这并不是我们所希望的,但却证明是可行的。
在多重继承之下,指向Member Functions的指针
《深入理解C++对象模型》P178~180
参考资料:
《深度探索C++对象模型》
指向函数的指针
假定我们被要求提供一个如下形式的排序函数:
sort( start, end, compare );
start 和end 是指向字符串数组中元素的指针。函数sort()对于start 和end 之间的数组元素进行排序。compare 定义了比较数组中两个字符串的比较操作。
该怎样实现compare 呢?我们或许想按字典顺序排序数组内的字符串,或许想按长度排序它们,以便将最短的字符串放在前面,而长的放在后面。解决这种需求的一种策略是将第三个参数compare 设为函数指针,并由它指定要使用的比较函数。
为简化sort()的用法而又不限制它的灵活性,我们可能希望指定一个缺省的比较函数,以用于大多数的情况。让我们假设最常见的以字典序排列字符串的情况,缺省实参将指定一个比较操作,它用到了字符串的compare()函数。我们将考虑怎样用函数指针来实现我们的sort()函数。
指向函数的指针的类型
怎样声明指向函数的指针呢?用函数指针作为实参的参数会是什么样呢?下面是函数lexicoCompare()的定义,它按字典序比较两个字符串:
#include <string>
int lexicoCompare( const string &s1, const string &s2 ) {
return s1.compare(s2);
}
如果字符串s1 和s2 中的所有字符都相等,则lexicoCompare()返回0;否则,如果第一个参数表示的字符串小于第二个参数表示的字符串,则返回一个负数;如果大于,则返回一个正数。
函数名不是其类型的一部分,函数的类型只由它的返回值和参数表决定。指向lexicoCompare()的指针必须指向与lexicoCompare()相同类型的函数(带有相同的返回类型和相同的参数表)。让我们试一下:
int *pf( const string &, const string & ); // 喔! 差一点
这几乎是正确的。问题是编译器把该语句解释成名为pf 的函数的声明,它有两个参数,并且返回一个int*型的指针。参数表是正确的,但是返回值不是我们所希望的。解引用操作符* 应与返回类型关联,所以在这种情况下,是与类型名int 关联,而不是pf。要想让解引用操作符与pf 关联,括号是必需的:
int (*pf)( const string &, const string & ); // ok: 正确
这个语句声明了pf 是一个指向函数的指针,该函数有两个参数和int 型的返回值。即指向函数的指针,它与lexicoCompare()的类型相同。下列函数与lexicoCompare()类型相同,都可以用pf 来指向:
int sizeCompare( const string &, const string & );
但是,calc()和gcd()与前面两个函数的类型不同,不能用Pf 来指:
int calc( int , int );
int gcd( int , int );
可以如下定义pfi,它能够指向这两个函数:
int (*pfi)( int, int );
初始化和赋值
我们知道,不带下标操作符的数组名会被解释成指向首元素的指针。当一个函数名没有被调用操作符修饰时,会被解释成指向该类型函数的指针。例如,表达式
lexicoCompare;
被解释成类型:
int (*)( const string &, const string & );
的指针。
将取地址操作符作用在函数名上也能产生指向该函数类型的指针。因此,lexicoCompare和&lexioCompare 类型相同。指向函数的指针可如下被初始化:
int (*pfi)( const string &, const string & ) = lexicoCompare;
int (*pfi2)( const string &, const string & ) = &lexicoCompare;
指向函数的指针可以如下被赋值:
pfi = lexicoCompare;
pfi2 = pfi;
只有当赋值操作符左边指针的参数表和返回类型与右边函数或指针的参数表和返回类型完全匹配时,初始化和赋值才是正确的。如果不匹配,则将产生编译错误消息。在指向函数类型的指针之间不存在隐式类型转换。例如:
int calc( int, int );
int (*pfi2s)( const string &, const string & ) = 0;
int (*pfi2i)( int, int ) = 0;
int main() {
pfi2i = calc; // ok
pfi2s = calc; // 错误: 类型不匹配
pfi2s = pfi2i; // 错误: 类型不匹配
return 0;
}
函数指针可以用0 来初始化或赋值,以表示该指针不指向任何函数。
调用
指向函数的指针可以被用来调用它所指向的函数。调用函数时,不需要解引用操作符。无论是用函数名直接调用函数,还是用指针间接调用函数,两者的写法是一样的。例如:
#include <iostream>
int min( int*, int );
int (*pf)( int*, int ) = min;
const int iaSize = 5;
int ia[ iaSize ] = { 7, 4, 9, 2, 5 };
int main() {
cout << "Direct call: min: "
<< min( ia, iaSize ) << endl;
cout << "Indirect call: min: "
<< pf( ia, iaSize ) << endl;
return 0;
}
int min( int* ia, int sz ) {
int minVal = ia[ 0 ];
for ( int ix = 1; ix < sz; ++ix )
if ( minVal > ia[ ix ] )
minVal = ia[ ix ];
return minVal;
}
调用
pf( ia, iaSize );
也可以用显式的指针符号写出:
(*pf)( ia, iaSize );
这两种形式产生相同的结果,但是第二种形式让读者更清楚该调用是通过函数指针执行的。
当然,如果函数指针的值为0,则两个调用都将导致运行时刻错误。只有已经被初始化或赋值的指针(引用到一个函数)才可以被安全地用来调用一个函数。
函数指针的数组
我们可以声明一个函数指针的数组。例如:
int (*testCases[10])();
将testCases 声明为一个拥有10 个元素的数组。每个元素都是一个指向函数的函数指针,该函数没有参数,返回类型为int。
像数组testCases 这样的声明非常难读,因为很难分析出函数类型与声明的哪部分相关。在这种情况下,使用typedef 名字可以使声明更为易读。例如:
// typedefs 使声明更易读
typedef int (*PFV)(); // 定义函数类型指针的typedef
PFV testCases[10];
testCases 的这个声明与前面的等价。
由testCases 的一个元素引用的函数调用如下:
const int size = 10;
PFV testCases[size];
int testResults[size];
void runtests() {
for ( int i = 0; i < size; ++i )
//调用一个数组元素
testResults[ i ] = testCases[ i ]();
}
函数指针的数组可以用一个初始化列表来初始化,该表中每个初始值都代表了一个与数组元素类型相同的函数。例如:
int lexicoCompare( const string &, const string & );
int sizeCompare( const string &, const string & );
typedef int ( *PFI2S )( const string &, const string & );
PFI2S compareFuncs[2] = {
lexicoCompare,
sizeCompare
};
我们也可以声明指向compareFuncs 的指针。这种指针的类型是“指向函数指针数组的指针”。声明如下:
PFI2S (*pfCompare)[2] = &compareFuncs;
声明可以分解为:
(*pfCompare)
解引用操作符* 把pfCompare 声明为指针,后面的[2]表示pfCompare 是指向两个元素数组的指针:
(*pfCompare)[2]
typedef PFI2S 表示数组元素的类型,它是指向函数的指针,该函数返回int,有两个const string&型的参数。数组元素的类型与表达式&lexicoCompare 的类型相同,也与compareFuncs的第一个元素的类型相同。此外,它还可以通过下列语句之一获得:
compareFuncs[ 0 ];
(*pfCompare)[ 0 ];
要通过pfCompare 调用lexicoCompare,程序员可用下列语句之一:
// 两个等价的调用
pfCompare[ 0 ]( string1, string2 ); // 编写
((*pfCompare)[ 0 ])( string1, string2 ); // 显式
参数和返回类型
现在我们回头看一下本节开始提出的问题,在那里给出的任务要求我们写一个排序函数,怎样用函数指针写这个函数呢?因为函数参数可以是函数指针,所以我们把表示所用比较操作的函数指针作为参数传递给排序函数:
int sort( string*, string*,
int (*)( const string &, const string & ) );
我们再次用typedef 名字使sort()的声明更易读:
// typedef 使 sort() 的声明更易读
typedef int ( *PFI2S )( const string &, const string & );
int sort( string*, string*, PFI2S );
因为在多数情况下使用的函数是lexicoCompare(),所以我们让它成为缺省的函数指针参数:
// 提供缺省参数作为第三个参数
int lexicoCompare( const string &, const string & );
int sort( string*, string*, PFI2S = lexicoCompare );
sort()函数的定义可能像这样:
void sort( string *s1, string *s2, PFI2S compare = lexicoCompare )
{
// 递归的停止条件
if ( s1 < s2 ) {
string elem = *s1;
string *low = s1;
string *high = s2 + 1;
for (;;) {
while ( compare( *++low, elem ) < 0 && low < s2) ;
while ( compare( elem, *--high ) < 0 && high > s1) ;
if ( low < high )
low->swap(*high);
else break;
} // end, for(;;)
s1->swap(*high);
sort( s1, high - 1, compare );
sort( high + 1, s2, compare );
} // end, if ( s1 < s2 )
}
sort()是C.A.R.Hoare 的快速排序算法的一个实现。让我们详细查看该函数的定义。该函数对s1 和s2 之间的数组元素进行排序。sort()是一个递归函数,它将自己逐步地应用在较小的子数组上。停止条件是当s1 指向与s2 相同的元素时或指向s2 所指元素之后的元素(第5 行)。
elem(第6 行)被称作分割元素。所有按字典序小于elem 的元素部会被移到elem 的左边,而所有大于的都被移到右边。现在,数组被分成若干个子数组,sort()被递归地应用在它们之上。
for(;;)循环的目的是完成分割。在循环的每次迭代中,low 首先被向前移动到第一个大于等于elem 的数组元素的索引上。类似地,high 一直被递减,直到移动到小于等于elem 的数组最右元素的索引上。如果low 不再小于high,则表示元素已经分隔完毕,循环结束。否则,这两个元素被交换,下一次迭代开始。虽然数组已经被分隔,但elem 仍然是数组的第一个元素。在sort()被应用到两个子数组之前,第19 行的swap()把elem 放到它在数组中最终正确的位置上。
数组元素的比较通过调用compare 指向的函数来完成。swap()字符串操作被调用,以便交换数组元素所指的字符串。
下面main()的实现用到了我们的排序函数:
#include <iostream>
#include <string>
// 这些通常应该在头文件中
int lexicoCompare( const string &, const string & );
int sizeCompare( const string &, const string & );
typedef int (*PFI)( const string &, const string & );
void sort( string *, string *, PFI=lexicoCompare );
string as[10] = { "a", "light", "drizzle", "was", "falling","when", "they", "left", "the", "museum" };
int main() {
// 调用 sort(), 使用缺省实参作比较操作
sort( as, as + sizeof(as)/sizeof(as[0]) - 1 );
// 显示排序之后的数组的结果
for ( int i = 0; i < sizeof(as)/sizeof(as[0]); ++i )
cout << as[ i ].c_str() << "\n\t";
}
编译并执行程序,生成下列输出:
"a"
"drizzle"
"falling"
"left"
"light"
"museum"
"the"
"they"
"was"
"when"
函数参数的类型不能是函数类型,函数类型的参数将被自动转换成该函数类型的指针。例如:
// typedef 表示一个函数类型
typedef int functype( const string &, const string & );
void sort( string *, string *, functype );
编译器把sort()当作已经声明为:
void sort( string *, string *, int (*)( const string &, const string & ) );
上面这两个sort()的声明是等价的。
注意,除了用作参数类型之外,函数指针也可以被用作函数返回值的类型。例如:
int (*ff( int ))( int*, int );
该声明将ff()声明为一个函数,它有一个int 型的参数,返回一个指向函数的指针,类型为:
int (*) ( int*, int );
同样,使用typedef 名字可以使声明更容易读懂。例如,下面的typedef PF 使得我们能更容易地分解出ff()的返回类型是函数指针:
// typedef 使声明更易读
typedef int (*PF)( int*, int );
PF ff( int );
函数不能声明返回一个函数类型。如果是,则产生编译错误。例如,函数ff()不能如下声明:
// typedef 表示一个函数类型
typedef int func( int*, int );
func ff( int ); // 错误: ff()的返同类型为函数类型