前言
本篇是c++总结的第二篇,关于c++的对象模型,在构造、拷贝虚函数上重点分析,也包含了c++11class的新用法和特性,如有不当,还请指教!
c++三大特性
- 访问权限
在c++中通过public、protected、private三个关键字来控制成员变量和成员函数的访问权限,它们分别表示为公有的、受保护的、私有的,称为成员访问限定符
在类的内部,无论成员被声明为public、protected还是private,并没有访问权限的限制,都可以互相访问;在类的外部,只能通过对象访问成员,且只能访问public权限的,不可访问private、protected
无论继承是public、private还是protected,base class的private成员都不能被derived class成员访问,base class中的public和protected成员能被派生类访问
对于public继承,derived class对象只能访问base class中的public成员;对于protected 和 private继承,derived class不能访问base class的所有成员,而derived class的成员可以通过derived class对象来访问base class的derived成员
private继承意为"根据某物实现出",且private继承只继承实现,忽略接口,这纯粹只是继承了实现细节,也就是说它在软件设计上没有意义,意义在于软件实现.若派生类需要访问基类private的成员,或需要重新定义继承而来的虚函数,则采用private继承
class Base
{
public:
int num3;
protected:
int num1;
};
class derived : public Base
{
void func(Base&);
void func(derived&);
int num2;
};
void derived::func(Base& b) { b.num1 = 0; } //不合法
void derived::func(derived& d) //合法
{
d.num1 = 0;
d.num3 = num1;
}
- 继承
定义:让某类获得另一个类的属性和方法
功能:使用现有类的所有功能,并可以在无需重新编写原来的类的情况下对功能进行扩展
三种继承方式:
- 实现继承(非虚函数):使用基类的属性和方法而无需额外编写
- 接口继承(纯虚函数):仅使用基类的属性和方法的名称,但子类须重新编写对应的方法
- 可视继承(虚函数):子窗体(类)使用基窗体(类)的外观和实现代码的能力
- 封装
定义:数据和代码捆绑在一起,避免外界干扰和不确定性访问
功能:将客观事物封装成抽象的类,且类可以把自己的数据和方法只让可信的类或对象操作,对不可信的进行隐藏。比如public修饰公共的数据和方法,private修饰那些进行隐藏的数据和方法
c++对于结构体和函数(不包含virtual和non-inline)的封装并没有增加布局成本,在布局以及存取时间上主要的额外负担是由virtual引起
- 多态
定义:一个public 基类的指针或引用,寻址出一个派生类对象
功能:允许将子类类型的指针赋值给父类类型的指针
实现多态的两种方式:override和overload(c++常见关键字总结 - 爱莉希雅 - 博客园 (cnblogs.com))
c++用以下三种方法支持多态:
- 经由隐式转化。将derived class指针类型转化为public基类类型
- 经由虚函数机制
- 经由dynamic_cast和typeid运算符
虚函数:当基类希望派生类定义适合自己版本的函数,就将对应的函数声明为虚函数
虚函数依靠虚函数表工作,表中保存虚函数地址,当用基类指针指向派生类时,虚表指针指向对应派生类的虚函数表,如此保证派生类中的虚函数被准确调用
虚函数是动态绑定的
使用虚函数的指针和引用是去寻找目标类的对应函数,而不是执行类的函数,且发生在运行期,所对应的函数和属性依赖于对象的动态类型
多态中,调用函数是通过指针或引用的,这个被调用的函数必须是虚函数且进行了重写
同一个class的所有对象都使用同一个虚表
派生类的override虚函数的返回类型和形参必须和父类完全一致,除非父类中返回值为一个指针或引用,子类的返回值可以返回这个指针或引用的派生
那么虚函数机制是如何分辨指针类型的不同
的呢?比如以下例子,指向A类的指针如何与指向int的指针或指向模板Array的指针有所不同呢?
A* px;
int* px;
Array<String>*pta;
首先,一个指针不管它指向哪个类型,它的大小是固定的。在内存需求来讲,它们三个都需要有足够的内存来放置一个机器地址(通常为word)
这三者之间的不同并不在表示法、地址,而是在其寻址出的对象类型不同
,也就是说,指针类型会告诉编译器如何解释某个特定地址的内存内容及其大小
这引出一个问题void类型的指针如何解释呢?答案是我们不知道将涵盖多大的地址空间.这也说明了另一件事——一个void指针只能持有一个地址,而不能通过它来操作所指的object.转换是一种编译器指令,大部分情况并不改变一个指针所含的真正地址,只影响被指出的内存大小和内容
虚函数实例:
class A
{
public:
A();
virtual ~A();
virtual void Afunc();
protected:
int numA;
std::string strA;
};
class B : public A
{
public:
B();
~B();
void Afunc() override;
virtual void Bfunc();
int numB;
};
B b;
//两个指针都指向B对象的起始地址,差别是pa涵盖的地址只包含子对象A,而pb涵盖整个B
A* pa = &b;
B* pb = &b;
pa->numB; //不合法
//显式的downcast即可
(static_cast<B*>(pa) )->numB;
//以下方式更安全,但成本较高,是一个运行期运算符
if (B* pb2 = dynamic_cast<B*>(pa))
{
pb2->numB;
}
//以下pa的类型将在编译期决定以下两点
//1.固定的可用接口。pa只能调用A的public
//2.该接口的访问级。此处函数为public
pa->Afunc();
//以下行为有两个问题
//初始化将一个对象的内容完整拷贝到另一个对象去,为什么vptr没有指向b的vtbl?因为编译器必须确定如果某个对象含有一个及以上的vptrs,这些vptrs的内容不会被基类对象初始化或改变
//为什么a调用的Afunc函数的版本是A的?多态虽然"支持一个以上的类型",但不能在"直接存取对象"这方面做支持,因为面向对象并不支持对 对象的直接处理,且前面说过指针或引用支持多态是因为它们只是改变所指向的内存的"大小和内容解释方式",并不改变对象内存大小
//这里以派生类对象对基类对象进行初始化或赋值,派生类对象会被切割塞进基类内存中,而派生类的类型并不其中,这会导致多态不再起作用
A a = b; //造成切割
a.Afunc();
纯虚函数:将类定义为抽象类,不可实例化对象。纯虚函数一般来说没有定义体,但也可以有
形式:virtual 数据类型 函数名(形参) = 0;
抽象类:抽象类描述了类的行为和功能,而不需要完成类的实现
类中至少有一个函数被声明为纯虚函数,则此类就是抽象类
抽象类不可实例化,只能作为接口使用
虚基类最有效的运用方式是一个抽象基类,且没有任何数据成员
c++的三种对象模型
class Point
{
public:
Point(float xval);
virtual ~Point();
float x() const;
static int PointCount();
protected:
virtual ostream& print( ostream & os ) const;
float _x;
static int _point_count;
}
对于以上封装,c++通过三种对象模型来表示:
-
A Simple Object Model
一个对象包含一系列槽slots,每一个slots指向一个成员,各个成员按其声明顺序,各被指定一个slot。每一个数据成员和函数成员都有自己的一个slot
尽量减低C++ complier的设计复杂度,但会损失空间和执行器的效率
这个模型后来被应用到"指向成员的指针"观念中
-
A Table-driven Object Model
将所有成员的信息抽离出来放在一个 数据成员表 和一个 成员函数表 中,而class则含有指向这两个表的指针
这个模型后来成为虚函数的一个方案
-
The C++ Object Model
Nonstatic 数据成员存放在于每一个class对象内,static数据成员、static成员函数、 nonstatic成员函数则被存放在class对象外,虚函数用两个步骤来支持此model:
- 每个class生成指向虚函数的指针,其中一个指针对应一个虚函数,这些指针放在表格中,这个表格被称为虚函数表virtual table(vtbl)
- 每个class对象中安插一个指针,这个指针指向相关的虚函数表,这个指针被称为虚指针vptr。vptr的设定和重置都由class里的构造函数、析构函数、拷贝赋值运算符自动完成。每个class关联的type_info object(支持runtime type identification,RTTI)亦由虚函数表指出,通常放于表格中的第一个slot
class和struct
在c中,数据与处理数据的操作(函数)是分开声明的,struct是被看作一个结构体,其中包含多个变量或数组,这些成员的数据类型可以不同;而在c++中,数据和处理数据的操作应该被视为一个整体,使用abstract data type(ADT)或class hierarchy的数据封装,struct被看作一个类,其中包含数据和处理数据的方法,这是希望自定义类型更加健全
为什么struct看起来似乎和class并无太大区别?那是因为c++为了维护与c之间的兼容性,所以c++需要做到向下兼容。若非如此,c++完全可以摒弃struct,直接用关键字class支持类的观念
那么什么时候应该用struct取代class呢?答案是你认为struct好则用struct,否则是class.struct和class并无太大区别,struct也可以支持public、protected、private,virtual以及单一继承、多重继承、虚拟继承等等
//以下定义,你说他是struct或class都可以
{
public:
operator int();
virtual void foo();
//...
protected:
static int i;
//...
}
class和struct的微小区别:
- 默认的访问和继承权限。class默认private,struct默认public,这很有道理,因为c中struct很明显是公有的
- 模板并不打算和c兼容,因此模板中只能使用class作为类类型,使用struct代替class是不合法的
c风格的struct在c++的一个合理的用法:传递复杂的class对象的全部或部分到c函数时,struct声明可以将数据封装起来,并保证拥有与c兼容的空间布局,不过只用在组合
情况下
class和struct的布局:c++对于结构体和函数(不包含virtual和non-inline)的封装并没有增加布局成本,主要的额外负担是由virtual引起的(之前已用图说明过)
现有如下片段:
typedef struct
{
float x, y, z;
}Point;
Point global;
Point foobar()
{
Point local;
Point* heap = new Point;
*heap = local;
delete heap;
return local;
}
对于Point这样的声明,在c++会被贴上Plain OI' Data标签。编译器并不会为其声明default constrcutor、destructor、copy constructor、copy assignment operator
对于 Point global; 这样的定义,在c++中members并没有被定义或调用,行为和c如出一辙。编译器并不会调用constructor和destructor。除非在c中,global被视作临时性定义
临时性定义:因为没有显示初始化操作,一个临时性定义可以在程序多次发生,但编译器最终会将这些实例链接折叠起来,只留下一个实例,放在data segment中"保留给未初始化的global object使用的"空间 但在c++中并不支持临时性定义,对于此例,会阻止后续的定义
对于 Point* heap = new Point;编译器并不会调用default constructor,只是 Point* heap = __new( sizeof(Point) )。delete亦是如此
对于*heap = local;编译器并不会调用copy assignment operator做拷贝,但只是像c那样做简单的bitwise
return操作也是,只是简单的bitwise,并没有调用copy constructor
构造函数
定义:类通过一个或多个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数(constructor)
构造函数名字和类名相同,但没有返回类型,除此以外和普通函数没啥区别.构造函数支持重载,并支持普通函数的重载规则,但构造函数不可声明为const
,因为构造函数任务是进行初始化,声明为const无法修改
无论何时只要类的对象被创建,就会执行构造函数
默认构造函数:不用实参进行调用的构造函数。这包含两种情况:
- 没有明显的形参
- 提供默认实参
合成的默认构造函数:当我们没有定义构造函数时,编译器在需要构造函数时,会合成默认构造函数,这种合成的默认构造函数执行满足编译器所需要的行动(不负责初始化数据成员),但为了程序继续执行下去,编译器有时候还是会初始化所需数据成员,若数据成员含有类内的初始值,则用此来初始化成员,否则默认初始化该成员
"= default"要求编译器合成默认构造函数.若=default和声明一起出现在类的内部,则合成的默认构造函数是inline的;出现在类外部,则不是inline的
对于一个类,如果开发人员没有声明任何一个构造函数,则会隐式声明一个默认构造函数,这种被隐式声明出的构造函数若不满足后面所讲的四种情况且又没有声明任何构造函数的class,是没有用的(trivial)构造函数。那么什么是有用的(nontrivial)呢?一个有用的默认构造函数是编译器需要的那种,必要的话会由编译器合成出来
在讨论有用的默认构造函数有哪些情况前,我们需要知道:在c++各个不同的编译模块(文件)中,编译器将合成的default constructor、copy constructor、destructor、assignment copy operator都用inline
方式完成来避免合成多个default constructor;但若函数太复杂,用inline不合适,而是会合成出一个显式的non-inline static实例
四种有用的nontrivial默认构造函数
以下四种情况默认的构造函数会被视为有用的:
带有默认构造函数的成员类对象
如果一个class没有任何构造函数,但其内含的成员对象拥有默认构造函数,编译器需要为该class合成默认构造函数。不过这个合成操作只有在构造函数真正需要被调用时才发生;不过若若类中包含一个其他类类型的成员且这个成员的类型没有构造函数,编译器不会合成默认构造函数,将无法初始化该成员,这种情况需要开发者自己定义构造函数
例子:
//假设B包含A
class A
{
public:
A();
A(int);
//...
};
class B
{
public:
A a;
char* str;
//...
};
void func()
{
B b; //A::a必须在此初始化。合成的默认构造函数内涵必要代码,能够调用A的默认构造函数来处理成员对象B::a
if (str) //之前说过编译器不负责初始化数据成员,但程序想要执行下去,编译器在此处必须初始化str
{
//...
}
}
上面例子种合成的默认构造函数可能是这个样子:
inline B::B()
{
a.A::A();
}
在这个例子中如果开发者已经为class B显示定义一个默认构造函数,但这个构造函数并没有调用所需要的 对象的构造函数,这时编译器会在当前的默认构造函数中进行扩张,在显式的开发者定义的代码前安插一些代码,以满足编译器需求。如果有多个class成员对象,则按照这些成员对象在class中的声明顺序来调用对应的构造函数。内部可能长这样:
B::B() { str = 0;} //开发者定义的
//扩张后的默认构造函数
B::B()
{
//编译器定义的代码
a.A::A();
//显式的代码
str = 0;
}
那如果显式的定义了构造函数而没有默认构造函数,还需要合成默认的构造函数嘛?并不会,会根据编译器要求扩张现有的构造函数,并不会再合成默认的构造函数
带有默认构造函数的基类
这种情况下有用的默认构造函数道理和上面的类似,如果一个没有构造函数的类派生自一个有默认构造函数的基类,那么此时派生类的默认构造函数会被视为有用的并合成出来,这个构造函数将上一层基类的默认构造函数(根据他们的声明顺序)
带有虚函数的类
以下两种情况需合成默认构造函数:
- class声明或继承一个虚函数
- class派生自一个继承串链,其中有一个及以上的虚基类(virtual base classes)
例子:
//B和C派生自A
class A
{
public:
virtual void func() = 0;
//...
};
void callfunc(const A& a) { a.func(); }
void foo()
{
B b;
C c;
callfunc(b);
callfunc(c);
}
这里为了支持虚函数机制需要合成或对现有的构造函数进行扩张,两个扩张行动在编译期发生:
- 一个虚函数表vtbl被编译器产出,其中放置对应类的虚函数地址
- 每个类对象中,编译器产出一个虚表指针vptr,内含相关的class的虚函数表的地址
a.func()的虚拟调用会进行重写,以支持虚函数机制
其中:
- 1表示虚函数表数组的索引(在前面的第三种对象模型可以看到布局)
- 后面的参数其实就是this指针
//a.func()的转变
( *a.vptr[1] )(&a)
对于构造函数的合成规则也是遵循之前的,没有则合成,有则在每个构造函数中安插
带有虚基类的class
对于每个class所定义的每一个构造函数,编译器会安插"允许每个虚基类的执行期存取操作"的代码;若没有声明构造函数,则编译器为其合成一个默认构造函数
不同的编译器对虚基类的实现有极大的差异,但每种实现的思想都是必须使虚基类在其每个派生类中的位置能够在运行期确定
先来看个例子:
//菱形继承
class X
{
public:
int nX;
};
class A : virtual public X
{
public:
int nA;
};
class B : virtual public X
{
public:
double nB;
};
class C : public A, public B
{
public:
int nK;
};
//无法在编译期确定pa->X::nX的地址
void func( A* pa) { pa->nX = 1024; }
func(new A);
func(new C);
对于以上例子,编译器无法固定经由pa存取的X::nx的实际偏移地址,因为虚拟派生类对象中的虚基类偏移位置是会随着派生而变化的(详细原因请移至虚拟继承),编译器必须改变执行存取操作的代码,使X::nx可以延迟至运行期决定正确结果
在此我们提供cfront的做法:在派生类的每个虚基类中安插一个指针,所有经由指针或引用来存取一个虚基类的操作都可通过相关指针来完成
可能的转变操作:
其中,__vbcX表示编译器所产生的指针,指向所对应的虚基类
void func( A* pa) { pa->__vbcX->nX = 1024; }
成员初值列
我们知道由编译器合成的构造函数不负责初始化数据成员,因此我们应该确保每一个构造函数都将对象的每个成员进行初始化,而这一保证通过成员初值列
(member initialization list)来完成
别混淆赋值和初始化.对于构造函数来说,类成员的初值可以通过member initialization list或在构造函数体内进行赋值
//赋值
class A
{
public:
A(int _i, int _j)
{
i = _i;
j = _j;
}
private:
int i, j;
};
//成员初值列
A( int _i, int _j) : i(_i), j(_j) {}
对于将class中成员设定常量值,使用explicit initialization list更有效率。因为当函数的活动记录(activation record)被放进堆栈,initialization list的常量即可放入local1内存中
注:活动记录过程的调用是过程的一次活动,当过程语句(及其调用)结束后,活动生命周期结束。变量的生命周期为其从被定义后有效存在的时间
对于以下四种情况,为确保程序顺利编译,必须使用member initialization list:
- 初始化一个
引用
成员时 - 初始化一个
const
成员时 - 调用
基类
的构造函数,且这个构造函数有一组参数时 - 调用
成员class
的构造函数,且此构造函数有一组参数时
对于赋值,在这四种情况下,编译的效率并不高;相反成员初值列更有效率
来看一个例子:
class Word
{
String _name;
int _cnt;
public:
Word()
{
_name = 0;
_cnt = 0;
}
};
//编译器会在构造函数中产生一个临时性的String对象,然后再初始化,仅仅是提供给另一个对象进行拷贝赋值,最后会被摧毁
Word::Word()
{
_name.String::String();
String temp = String(0); //临时对象
_name.String::operator=(temp);
temp.String::~String(); //摧毁临时对象
_cnt = 0;
}
//成员初值列
Word::Word : _name(0), _cnt(0){}
//进行扩张
Word::Word()
{
_name.String::String();
_cnt = 0;
}
member initialization list不是一组函数调用,编译器按照成员在class中的声明顺序
一一操作member initialization list,会在任何显式的用户代码前以适当顺序安插初始化操作。若不注意声明顺序,产生的bug很难观察出来,因此尽量把一个成员的初始化和另一个放在一起
来看一个例子:
class A
{
int i;
int j;
public:
//i比j先声明,因此是先初始化i再初始化j
A( int val ) : j(val), i(j) { }
}
//改善
A::A(int val) : j(val)
{
i = j;
}
c++规定,对象的成员变量的初始化动作发生在进入构造函数本体前,也就是这些成员的构造函数被自动调用时。也就是说上面改善的方案,i == j并不会有问题(因为理论上是i先初始化)
你可能会问,在member initialization list中实参传回函数的返回值可以吗?当然可以,但是最好是使用"存在构造函数体内的一个成员",而非"member initialization list内的成员",因为你并不知道这个函数是否需要class的数据成员,所以将这种初始化放在构造体内就完全没有问题
来看一个例子:
X::X(int val) : i( func(val) ), j(val) { } //万一func需要数据成员q,而q的声明顺序在i之后
//扩张
X::X()
{
i = this->func(val);
j = val;
}
如果一个派生类成员函数被调用,不要将其返回值做为基类构造函数的一个参数
来看一个例子:
class AA : public A
{
int _AAval;
public:
int AAval() { return _AAval; } //派生类的成员函数
AA( int val ) : _AAval(val), A( AAval() ) { }
};
//扩张
AA()
{
A::A(this, this->AAval() ); //先构造基类,但需要数据成员,可是这个数据成员在后面进行初始化
_AAval = val;
}
explicit initialization list也有不足:
- class成员需要为public
- 只能指定常量,因为其常量在编译器即可求值
- 编译器并没有自动施行它,初始化很可能失败
编译器对构造函数的扩充
定义一个对象,编译器会对于构造函数进行如下扩充操作:
- 记录在member initialization list的数据成员初始化会被放进构造函数本体,以成员声明顺序为顺序。若有一个成员没有出现在member initialization list,但其有默认构造函数,那么该default constructor必须被调用
- 在那之前,若class对象有virtual table pointers,其需指定初值
- 在那之前,所有上层的base class constructors必须被调用,以base class的声明顺序
- 若class被列于member initialization list,如果有任何显示指定的参数,都应传过去;若没有列于list,而class有默认构造函数,则调用之
- 若基类是多重继承下的第二或后继基类,那么this指针需调整
- 在那之前,所有的虚基类的构造函数必须被调用,从左到右,从深到浅
- 若class被列于member initialization list,如果有任何显示指定的参数,都应传过去。若没有列于list,而class有default constructor,则调用此
- 此外,class中的每个虚基类 subobject的offset必须在执行期可被存取
- 若 class对象是最底层的class,其构造函数可能被调用;某些用以支持这一行为的机制必须被放进来
拒绝编译器合成的默认函数
对于一个空类,编译器会为我们自动合成默认的构造函数、拷贝构造函数、拷贝赋值运算符、析构函数(非虚函数)
有时候我们想禁止一个class对象的拷贝操作,就需要进制拷贝构造函数和拷贝复制运算符。问题是,不显式声明他们,编译器可能会为我们自动合成一个默认的;但是想要避免编译自动生成,又得自己定义一个,属于是陷入恶性循环了。那么如何解决这个问题呢?
有两种解决方案:
-
将拷贝构造函数、拷贝赋值运算符声明为private属性的函数,如此便不能调用这两个函数
class A { public: A() = default; private: A( const A& ); A& operator=( const A& ); }
其实这样问题并没有完全解决,因为A的其他成员函数和友元函数依旧可以调用private
-
定义一个基类专门阻止拷贝
class unA { protected: unA() {} ~unA() {} private: unA( const unA& ) {} unA& operator=( const unA& ) {} }; class A : private unA { public: A() = default; }
现在,就算是A的成员函数或友元函数,也无法调用,编译器的尝试合成的动作将被基类阻止
别在构造过程调用虚函数
class A
{
public:
A();
virtual fFunc() const = 0;
//...
};
A::A()
{
fFunc();
}
class derivedA : public A
{
public:
virtual void fFunc() const;
}
//先构造基类
A a;
这个时候会先进行基类的构造,fFunc()的调用将是基类那个版本的,这就意味着我们多调用了一个函数,永远不可能下降到当前这个子类的层级(因为父类构造先于子类,此时子类的成员并未初始化,若调用子类版本的虚函数,程序将报错)
最根本的原因是子类对象在调用基类构造函数期间,对象类型是基类而不是子类,不仅虚函数会使用基类版本,此时dynamic_cast和typeid也会将对象视为基类类型
令operator=返回⼀个绑定到*this的引⽤
也就是如下形式:
class A {
public:
...
A& operator=(const A& other) { ... return *this; }
}
//目的是为了连锁赋值
int x, y, z;
x = y = z = 100;
//原理如下
x = (y = (z = 100))
也并非需要一定让operator=返回⼀个绑定到*this的引⽤,不遵守这个规则代码一样可以通过。但是这样效率更高,可以调用更少的构造和析构函数
拷贝构造
拷贝构造函数(copy constructor):在创建对象时,使用同一类型的class对象来初始化新创建的对象
拷贝构造函数的形式,采用ClassName&
(类的名称)类型作为参数:
class A
{
public:
A( const A& );
}
以下三种情况会以一个类对象的内容作为另一个类对象的初值:
-
显式地以一个类对象的内容作为另一个类对象的初值
class A { ... }; A a; A aa = a;
-
对象被当作参数传给某函数
void do( A a ); void do2() { A aa; func( aa ); }
-
函数传回类对象
A do2() { A a; return a; }
设计一个class,并以一个class object指定给另一个class object,我们有三种选择:
- 什么都不做,实施默认行为
- 提供一个explicit 拷贝赋值运算符
- 显示拒绝把class对象指定给另一个class对象。也就是将拷贝赋值运算符声明为private,且不提供定义
只有在默认的memberwise copy行为不安全或不正确时,才需要设计一个拷贝赋值运算符。且如果class有bitwise copy,隐式的赋值运算符不会合成
class对于default copy assignment operator,在以下情况,不会表现bitwise copy:
- 当class 内含成员对象,而其class有一个拷贝赋值运算符
- 当class的基类有一个拷贝赋值运算符
- 当class声明了任何虚函数。一定别拷贝右边class对象的vptr地址,它很有可能是派生类对象
- 当class继承自虚基类
即使赋值由bitwise copy完成,并没有调用copy assignment operator,但还是需要提供一个copy constructor(编译器合成的也算),以此打开NRV优化
尽可能不要允许一个虚基类的拷贝操作。不要在任何虚基类中声明数据
对于单一继承和多重继承,若class使用bitwise copy,一般不会合成拷贝构造,就不会增加效率成本
对于虚拟继承,bitwise copy不再支持,而是合成拷贝赋值运算符r和inline 拷贝构造,导致成本大大增加。且继承体系复杂度增加,对象拷贝和构造的成本也会增加
memberwise initialization
若一个类未定义显式的拷贝构造函数,并一个类对象以另一个类对象作为初值时,其内部以default memberwise initialization(成员初始化)方式完成,也就是将每个内部的或派生的数据成员的值,从某个对象拷贝一份到另一个对象身上,不过并不会拷贝成员类对象
,而是以递归的方式施行memberwise initialization
memberwise initialization有两种方式:
- 展现bitwise copy semantics,进行位逐次拷贝
- 未展现bitwise copy semantics,编译器合成默认的拷贝构造函数,调用内部的类的拷贝构造函数
当一个类未定义显式的拷贝构造函数,若类展现出"bitwise copy semantics",编译器将不会合成默认的拷贝构造函数;若未展现,则合成。也就是说,和构造函数类似,是否合成默认的构造函数也是看编译器的需求
位逐次拷贝(bitwise copy semantics ):对 源类中的成员变量 中的每一位 都逐次 复制到 目标类中,编译器只是直接将数据成员的地址拷贝过来,并不拷贝其值
class A
{
public:
A( const char* );
~A() { delete []str; }
//...
int cnt;
char* str;
};
//进行位逐次拷贝,不合成默认的构造函数
A a2("c++");
A a1 = a2;
当然这种方式会导致一个问题,比如当前例子中的char*指针,会造成a1的指针和a2的指针指向同一内存地址,调用两次析构函数,必将报错。因此对于这类情况,只有靠设计者实现一个显式的拷贝构造函数
那什么情况时没有展现bitwise copy semantics呢?class含有另一个class,后面这个class定义了显式的拷贝构造函数
//将char*改为String
class A
{
public:
A( const std::String&);
//...
int cnt;
const std::String str;
};
//string声明了显式的拷贝构造函数
class String
{
public:
String( const String& );
//...
}
//这个时候简单的位逐次拷贝无法满足编译器需求,因为需要调用String的拷贝构造函数,因此编译器需要合成默认的拷贝构造函数
A a2("c++");
A a1 = a2;
//合成的默认拷贝构造函数
inline A::A( const A& a )
{
str.String::String( a.str );
cnt = a.cnt;
}
不展现bitwise copy semantics的四种情况
其实不展现"bitwise copy semantics"有四种情况:
-
class
内含
一个成员对象,而后者的class声明了一个拷贝构造时(无论是显式的,还是编译器合成的) -
class
继承
自一个基类而这个基类存在一个拷贝构造时(无论是显式的,还是编译器合成的)第一点和第二点在上面已经解释过了
-
class声明一个及以上的
虚函数
若class含有虚函数,在构造函数中会安插一个vtbl和一个vptr,调用拷贝构造函数时需要正确的处理其初值,因此,当编译器在class中导入一个vptr时,某些情况下,bitwise copy semantics将不在生效
来看一个例子:
class ZooAnimal { public: ZooAnimal(); virtual ~ZooAnimal(); virtual void animate(); virtual void draw(); //... }; class Bear : public ZooAnimal { public: Bear(); void animate() override; void draw() override; virtual void dance(); //... }; Bear yogi; Bear winnie = yogi;
在这里,
两个类型相同,bitwise copy semantics依旧生效(动态转换的指针除外)
,将yogi的vptr的赋值给winnie是安全的但是要注意,当一个基类对象以派生类对象的内容初始化时,vptr的复制操作需保证安全
来看一个例子:
void draw( const ZooAnimal& zoey ) { zoey.draw(); } void fun() { ZooAnimal franny = yogi; //造成切割 draw(yogi); draw(franny); }
事实上franny只有ZooAnimal那一部分,Bear那部分已经被切割掉,这里调用的是ZooAnimal的版本。那么问题来了,vptr不是会进行赋值操作嘛,为什么vptr还是指向ZooAnimal的vbtl?答案是,对于这种情况,
合成出来的ZooAnimal的默认拷贝构造函数不再进行简单的拷贝赋值操作,而是显式设定vptr指向ZooAnimal class的vbtl,这也就是为什么包含虚函数的情况下bitwise copy semantics会失效
-
class派生自一个继承串链,其中有一个及以上的
虚基类
在一个类对象以其派生类对象作为初值的情况下,bitwise copy semantics依然会失效,因为虚基类的位置并不确定,简单的进行bitwise copy semantics可能会破坏这个位置,所以编译器必须合成默认的拷贝构造函数做出判断;不过若是相同的类型之间进行赋值,bitwise copy semantics依旧生效(动态转换的指针除外)
来看一个例子:
class Raccoon : public virtual ZooAnimal
{
public:
Raccoon();
Raccoon(int val);
//...
};
class RedPanda : public Raccoon
{
public:
RedPanda();
RedPanda(int val);
//...
};
//两个相同类对象,bitwise copy semantics依旧生效
Raccoon rocky;
Raccoon little_critter = rocky;
//一个类对象以其派生类对象作为初值,bitwise copy semantics不生效
//这个时候编译器必须合成一个拷贝构造函数,已初始化虚基类指针
RedPanda little_red;
Raccon little_critter = little_red;
这种情况下,编译器必须合成默认的拷贝构造函数,额外任务是安插必要代码来设定虚基类的指针的初值
对于以下这样的情况,因为不知道指针真正指定的对象类型,因此编译器并不知道bitwise copy semantics是否生效
Raccoon* ptr;
Raccoon little_critter = *ptr;
程序转化
先来看一段代码:
A func()
{
A aa;
//...
return aa;
}
对于以上代码,你可能会认为每次func被调用,就传回aa的值;且如果class A定义了一个拷贝构造,那么当func被调用时,保证该拷贝构造也会被调用。这可能成立也可能不成立,具体如何视编译器的优化来定
分析这个问题之前,我们先来看几个基础转化:
-
显式的初始化
来看一个例子:
A a0; void func1() { //定义a1、a2、a3 A a1(a0); A a2 = a0; A a3 = A(a0); }
这里程序会进行转化,而这转化可以分为两个阶段:
- 重写每一个定义,去除初始化操作
- 安插class的拷贝构造调用
可能的转化如下:
void func1() { //重写定义 A a1; A a2; A a3; //安插拷贝构造调用 a1.A::A( a0 ); a2.A::A( a0 ); a3.A::A( a0 ); }
-
参数的初始化
将一个class对象作为参数传给一个函数或作为函数的返回值,相当于以下的初始化操作:
A aa; void func( A a0 ); A a0 = aa;
这会要求局部对象a0以memberwise的方式将aa作为初值.编译器针对这个要求有不同的做法,其中一种做法是导入所谓的临时对象,并调用拷贝构造函数将其初始化,再将此临时对象交给函数
以上例子可能的转化:
A __temp0; __temp0.A::A( aa ); //需要将函数参数改为&,否则这会多进行一次bitwise foo( A& a0 ); //随后便对临时对象进行析构
-
返回值的初始化
A func() { A aa; //... return aa; }
对于以上片段中,你可能会问函数的返回值如何从局部对象aa中拷贝而来?其中一种做法是进行双阶段转化:
- 首先加上一个额外参数,类型是class对象的引用,放置进行构造后的对象
- 在return前安插一个拷贝构造函数的调用操作,将想要传回的对象的内容当作新增参数的初值
可能进行以下转化:
void func( A& __result ) { A aa; aa.A::A(); __result.A::A(aa); return; } //编译器必须转换每个func的调用 A a0 = func(); //可能的转化 A a0; func( a0 ); func().mem(); //对于以上调用可能的转化 A __temp0; ( func( _temp0 ), __temp0 ).mem(); //对于函数指针 A (*pa) (); pa = func; //可能的转化 void (*pa)(A&); pa = func;
以上三种转化其实效率并不算好,我可以在两方面进行优化:
-
开发人员层面
//不再是以下这样 A func( const T& y, const T&z ) { A aa; //...用y和z处理aa return aa; } //而是定义一个用于计算的构造函数,这样减少了拷贝次数 A func( A& __result, const T& y, const T&z ) { __result.A::A(y,z); return; }
-
编译器层面
以__result参数取代named return value(NRV)。主要注意的是NRV优化需要copy constructor编译器才可实施
//用__result取代aa void func( A& __result ) { __result.A::A(); return; }
不过NRV优化,也有缺点:
-
优化由编译器完成,但是我并不知道这项优化是否真的完成
-
一旦函数变得复杂,优化会难以实施
因为对于函数体内若嵌套了块block,块里又包含return语句,此时NRV优化大概率会关闭
-
某些程序员不喜欢自己的代码被优化,因为很可能打乱本来正确的顺序
来看一个例子:
void callFunc() { //调用拷贝构造 A aa = func(); //调用析构 }
这种情况下,虽然程序优化了速度很快,但却是错误的,因为在前面的例子可以看到NRV优化会剔除构造函数
-
现在让我们回到最初的两个问题:
-
每次func被调用,就传回aa的值
很明显,编译器对函数进行了转化,通过引用__result传回aa
-
如果class A定义了一个拷贝构造,那么当func被调用时,保证该拷贝构造也会被调用
不一定,视编译器优化而定,NRV优化施行了的话,便不会调用拷贝构造
A func()
{
A aa;
//...
return aa;
}
是否需要拷贝构造?
是否需要拷贝构造视情况而定,若bitwise copy即可满足,则不定义显式的拷贝构造函数,因为编译器想得比你周到,已经为你施行了最好的方案;但若需要大量的memberwise操作,也就是说bitwise copy不再满足开发者要求,这种情况需要提供一个显式的构造函数且编译器提供NRV优化,当然难度非常高,因此推荐禁止拷贝构造函数
class Point3d
{
public:
//都符合NRV优化
Point3d operator+(const Point3d&);
Point3d operator-(const Point3d&);
Point3d operator*(const Point3d);
//...
private:
float _x, _y, _z;
};
//合理
Point3d::Point3d( const Point3d &rhs )
{
_x = rhs._x;
_y = rhs._y;
_z = rhs._z;
}
//但是可以更有效率
Point3d::Point3d( const Point3d &rhs )
{
memcpy( this, &rhs, sizeof(Point3d) ); //memset也可以
}
不过这里如果存在编译器生成的内部成员如虚指针,memcpy或memset将使得编译器生成的成员的值被改写
class A
{
public:
A() { memset( this, 0, sizeof(A) ); }
//这里有虚函数或虚基类
};
//扩张
A()
{
__vptr__A = __vbtl__A;
memset( this, 0, sizeof(A) );
}
值得一提的是,当⼀个class内含有reference/const成员时,编译器不会提供拷⻉赋值运算符的补充,只能由开发人员⾃⼰编写.因为C++不允许改变reference成员的指向,也不允许更改const成员
赋值对象时勿忘其每个成员
copy函数包含:拷贝构造函数,拷贝赋值运算符
当定义了copy函数后,编译器将不再生成默认版本,若编译器有额外需求,编译器会在copy函数中安插必要的代码,不过这并不意味着编译器会为你的遗漏服务
当为class添加⼀个成员变量,必须同时修改copy函数
为派生类定义copy函数时,要注意对基类成员的复制。基类成员通常是private,派生类⽆法直接访问,应该让派生类的复制函数调⽤相应的基类复制函数
不能为了简化代码,就在拷贝构造函数中调⽤拷贝赋值运算符,也不能在拷贝赋值运算符中调⽤拷贝构造函数。因为拷贝构造函数⽤来初始化新对象,⽽赋值运算符只能⽤于已初始化的对象
要想消除copy函数的重复代码,可以建⽴⼀个新的成员函数给copy函数调⽤,这个函数通常是private且命名为init
移动构造
右值引用
左值:指表达式结束后依然存在的持久对象,可以取地址,具名变量或对象
右值:表达式结束后就不再存在的临时对象,不可以取地址,没有名字
变量和文字常量都有存储区,并且有相关的类型。区别在于变量是可寻址的(addressable).对于每一个变量都有两个值与其相联:
- 它的
数据值
,存储在某个内存地址。有时这个值也被称为对象的右值(rvalue,读做are-value).也可认为右值是被读取的值(read value). 文字常量和变量都可 被用作右值 - 它的
地址值
。有时被称为变量的左值(lvalue,读作ell-value)。也可认为左值是位置值location value.文字常量不能被用作左值
也就是说,有些变量可作左值和右值;而文字常量只能作右值
例子:
//a++.a为右值
int temp = a;
a = a + 1;
return temp;
//++a.a为左值
a = a + 1;
return a;
纯右值:临时变量值、不跟对象关联的字面量值.右值是纯右值
将亡值:将要被移动的对象、T&&函数返回值、std::move返回值和转换为T&&的类型的转换函数的返回值。可以理解为“盗取”其他变量内存空间。在确保其他变量不再被使用、或即将被销毁时,通过“盗取”的方式可以避免内存空间的释放和分配,能够延长变量值的生命期
左值引用:对一个左值进行引用的类型,必须进行初始化.左值引用是有名变量
的别名
常量左值引用是一个“万能”的引用类型,可以接受左值、右值、常量左值和常量右值
右值引用
:右值引用必须立即进行初始化操作,且只能使用右值进行初始化.右值引用是不具名变量
的别名
右值引用可以对右值进行修改.c++支持定义常量右值引用
定义
的右值引用并无实际用处。右值引用主要用于移动语义
和完美转发
,其中前者需要有修改右值的权限
通过右值引用,这个将亡的右值又“重获新生”,它的生命周期与右值引用类型变量的生命周期一样,只要这个右值引用类型的变量还活着,那么这个右值临时量就会一直活着。可利用这一点会一些性能优化,避免临时对象的拷贝构造和析构
右值引用通常不能绑定到任何的左值,要想绑定一个左值到右值引用,通常需要std::move()将左值强制转换为右值
T&&是什么,一定是右值吗?
来看个例子:
template<typename T>
void f(T&& t){}
f(10); //t是右值
int x = 10;
f(x); //t是左值
T&&表示的值类型不确定,可能是左值又可能是右值
右值引用独立于左值和右值。意思是右值引用类型的变量可能是左值也可能是右值
来看个例子:
int&& var1 = 1; //var1类型为右值引用,但var1本身是左值,因为具名变量都是左值
例子:
//常量左值
int num = 10;
const int &b = num;
const int &c = 10;
//右值引用
int num = 10;
int && a = num; //不合法。右值引用不能初始化为左值
int && a = 10;
//对右值引用对右值进行修改
int && a = 10;
a = 100;
cout << a << endl;
//常量右值引用
const int&& a = 10;//合法
移动构造函数
-
为什么需要移动构造函数?
在之前的拷贝构造函数中我们学到了,对于指针这种进行浅拷贝,而后很容易进行两次析构导致程序崩溃,而深拷贝或编译器自行合成默认的拷贝构造函数,又会生成临时对象,这会导致效率大大降低,虽然有NRV优化,但NRV优化也有限制。因此,c++针对这一状况引进了移动构造函数
-
什么是移动构造函数?
c++引入右值引用,借助它可以实现移动语义
什么是移动语义?将资源(如动态分配的内存)从一个对象转移到另一个对象,也就是说允许从临时对象(无法在程序中的其他位置引用)转移资源
举个例子:
struct A { A(); A(const A& a); ~A(); }; A GetA() { return A(); } int main() { A a = GetA(); return 0; } //生成一个临时对象,最后还会多调用一次析构函数,一次拷贝 //可能的转化如下 void GetA( A& __result ) { A aa; aa.A::A(); __result.A::A(aa); aa.~A::A(); return; }
可以看到这样效率不高,但是通过移动语义,就可以提高效率
改动如下:
//少调用一次拷贝构造和析构 A&& a = GetA();
通过右值引用,比之前少了一次拷贝构造和一次析构,原因在于右值引用绑定了右值,让临时右值的生命周期延长了。我们可以利用这个特点做一些性能优化,即避免临时对象的拷贝构造和析构
事实上,在c++98/03中,通过
常量左值引用
也经常用来做性能优化改动如下:
const A& a = GetA();
我们来看一个移动构造函数的例子:
class A { public: A() :m_ptr(new int(0)){} A(const A& a):m_ptr(new int(*a.m_ptr)) //深拷贝的拷贝构造函数 { cout << "copy construct" << endl; } A(A&& a) :m_ptr(a.m_ptr) //移动构造 { a.m_ptr = nullptr; cout << "move construct" << endl; } ~A(){ delete m_ptr;} private: int* m_ptr; }; int main(){ A a = Get(); } 输出: construct move construct move construct
这个构造函数并没有做深拷贝,仅仅是将指针的所有者转移到了另外一个对象,同时,将参数对象a的指针置为空,这里仅仅是做了浅拷贝,因此,这个构造函数避免了临时变量的深拷贝问题。这个构造函数其实就是移动构造函数,参数是一个右值引用类型.为什么会匹配到这个构造函数?因为这个构造函数只能接受右值参数,而函数返回值是右值
值得一提的是,提供移动构造函数的同时提供一个拷贝构造函数,以防止移动不成功的时候还能拷贝构造,更有严谨性
移动拷贝构造函数的定义
:定义一个空的构造函数方法,该方法采用一个对class类型的右值引用作为参数MemoryBlock(MemoryBlock&& other) : _data(nullptr) , _length(0) { } //在移动构造函数中,将源对象中的class数据成员添加到要构造的对象 _data = other._data; _length = other._length; //将源对象的数据成员分配给默认值。 这可以防止析构函数多次释放资源(如内存) other._data = nullptr; other._length = 0;
移动赋值运算符的定义
:定义一个空的赋值运算符,该运算符采用一个对class类型的右值引用作为参数并返回一个对class类型的引用MemoryBlock& operator=(MemoryBlock&& other) { } //在移动赋值运算符中,如果尝试将对象赋给自身,则添加不执行运算的条件语句 if (this != &other) { }
你可能会疑惑在拷贝构造函数中的转化,是构造一个临时对象,再进行拷贝,这个临时对象是个左值;而在这里移动构造参数接受的是右值,如何实现呢?可能是使用了std::move,将这个左值转为右值
std::move():将左值转换为右值,从而方便应用移动语义.move是将
对象资源的所有权从一个对象转移到另一个对象
,只是转移,没有内存的拷贝.也就是说,使用move几乎没有任何代价如果一个对象内部有较大的对内存或者动态数组时,很有必要写move语义的拷贝构造函数和赋值函数;如果是基本类型比如int和char[10]定长数组等,使用move仍然会发生拷贝(因为没有对应的移动构造函数)
std::move()的定义
:template <class Type> constexpr typename remove_reference<Type>::type&& move(Type&& Arg) noexcept;
委托构造
为什么引入委托构造?
如下,现有三个class含有执行类似操作的多个构造函数:
class class_c {
public:
int max;
int min;
int middle;
class_c() {}
class_c(int my_max) {
max = my_max > 0 ? my_max : 10;
}
class_c(int my_max, int my_min) {
max = my_max > 0 ? my_max : 10;
min = my_min > 0 && my_min < max ? my_min : 1;
}
class_c(int my_max, int my_min, int my_middle) {
max = my_max > 0 ? my_max : 10;
min = my_min > 0 && my_min < max ? my_min : 1;
middle = my_middle < max && my_middle > min ? my_middle : 5;
}
};
我们可以通过添加一个包含所有验证的函数来减少重复的代码,但是如果一个构造函数可以将部分工作委托给其他构造函数,则这样的代码更易于了解和维护。对于这种情况,c++11引入了委托构造函数,目的是简化构造函数的书写,提高代码的可维护性,避免代码冗余膨胀
什么是委托构造函数?
一个委托构造函数使用它所属的类的其他构造函数执行自己的初始化过程,或者说它把自己的一些或全部职责委托给了其他构造函数
委托构造函数的语法:constructor (. . .) : constructor (. . .)
例子:
class class_c {
public:
int max;
int min;
int middle;
class_c(int my_max) {
max = my_max > 0 ? my_max : 10;
}
class_c(int my_max, int my_min) : class_c(my_max) {
min = my_min > 0 && my_min < max ? my_min : 1;
}
class_c(int my_max, int my_min, int my_middle) : class_c (my_max, my_min){
middle = my_middle < max && my_middle > min ? my_middle : 5;
}
};
int main() {
class_c c1{ 1, 3, 2 };
}
现在,构造函数 class_c(int, int, int)
首先调用构造函数 class_c(int, int)
,该构造函数再来调用 class_c(int)
和其他构造函数一样,一个委托构造函数也有一个成员初始化列表和一个函数体,成员初始化列表只能包含一个其它构造函数,不能再包含其它成员变量的初始化,且参数列表必须与构造函数匹配
调用的第一个构造函数将初始化对象,以便此时初始化其所有成员。 不能在委托给另一个构造函数的构造函数中执行成员初始化
例子:
class class_a {
public:
class_a() {}
// member initialization here, no delegate
class_a(string str) : m_string{ str } {}
//can't do member initialization here
// error C3511: a call to a delegating constructor shall be the only member-initializer
class_a(string str, double dbl) : class_a(str) , m_double{ dbl } {}
// only member assignment
class_a(string str, double dbl) : class_a(str) { m_double = dbl; }
double m_double{ 1.0 };
string m_string;
};
如果构造函数还将初始化给定的数据成员,则将重写成员初始值
例子:
class class_a {
public:
class_a() {}
class_a(string str) : m_string{ str } {}
class_a(string str, double dbl) : class_a(str) { m_double = dbl; }
double m_double{ 1.0 };
string m_string{ m_double < 10.0 ? "alpha" : "beta" };
};
int main() {
class_a a{ "hello", 2.0 }; //expect a.m_double == 2.0, a.m_string == "hello"
int y = 4;
}
构造函数委托语法不会阻止构造函数的递归. - Constructor1 将调用 Constructor2(其调用 Constructor1),在出现堆栈溢出之前不会出错,应当避免这个递归
例子:
class class_f{
public:
int max;
int min;
// don't do this
class_f() : class_f(6, 3){ }
class_f(int my_max, int my_min) : class_f() { }
};
如果在委托构造函数中使用try,可以捕获目标构造函数中抛出的异常
析构函数
析构函数(destructor)是成员函数的一种,它的名字与类名相同,但前面要加~,没有参数和返回值
析构函数在对象消亡时即自动被调用,可以定义析构函数在对象消亡前做善后工作.也就是说,在对象超出范围或通过调用 delete 显式销毁对象时,会自动调用析构函数
一个类有且仅有一个析构函数
若class没定义destructor,只有在class内含member object含有destructor时,编译器才会合成destructor;对许多类来说,这就足够了。 只有当类存储了需要释放的系统资源的句柄
,或拥有其指向的内存的指针
时,你才需要定义自定义析构函数
声明析构函数的规则:
- 不接受自变量
- 没有返回值(或 void)
- 不能声明为 const、volatile 或 static。 但是,可以为声明为 const、volatile 或 static的对象的析构调用它们
- 可以声明为 virtual。 通过使用虚拟析构函数,无需知道对象的类型即可销毁对象(使用虚函数机制调用该对象的正确析构函数)。析构函数也可以声明为抽象类的纯虚函数
析构函数在对象消亡时即自动被调用,可以定义析构函数在对象消亡前做善后工作.也就是说,在对象超出范围或通过调用 delete 显式销毁对象时,会自动调用析构函数
当符合以下条件时,将调用析构函数:
- 具有块范围的本地(自动)对象超出范围
- 使用 delete显式解除分配了使用 new运算符分配的对象
- 临时对象的生存期结束
- 程序结束,并且存在全局或静态对象
- 使用析构函数的完全限定名显式调用了析构函数
若base class不含desturctor,那么derived class也不需要desturctor
destructor被扩展的方式。与constructor相似,但顺序相反:
- destructor函数本体先被执行
- 若class含有member class object,而后者含有destructors,他们会以其声明顺序的相反顺序被调用
- 若object内含vptr,现需被重新指定,指向适当的base class的virtual table
- 若有任何直接的nonvirtual base classes含有destructor,它们会以其相反的声明顺序被调用
- 若由任何virtual base classes含有destructor,如之前的PVertex例子,会以其原来的构造顺序的相反顺序调用
很少需要显式调用析构函数。 但是,对置于绝对地址的对象进行清理会很有用。 这些对象通常使用采用位置参数的用户定义的 new运算符进行分配。delete运算符不能释放该内存,因为它不是从自由存储区分配的
一般而言,constructor和destructor的安插都如预期那样:
{
Point point;
//point.Point::Point() 安插于此
...
//point.Point::~Point() 安插于此
}
但有些情况desctructor需要放在每一个离开点(此时object还存活)前,例如swith,goto:
{
Point point;
//point.Point::Point() 安插于此
swith ( int(point.x() ) )
{
case -1 :
...
//point.Point::~Point() 安插于此
return;
case 0 :
...
//point.Point::~Point() 安插于此
return;
case 1 :
...
//point.Point::~Point() 安插于此
return;
default :
...
//point.Point::~Point() 安插于此
return;
}
//point.Point::~Point() 安插于此
}
数据成员的内存布局与继承
影响class内存大小的因素
我们由一个例子引入内存布局的话题:
//64位系统
class A{ }; //sizeof(A)为1
class B : virtual public A{ }; //sizeof(B)为8
class C : virtual public A{ }; //sizeof(C)为8
class D : public B, public C{ }; //sizeof(D)为16
四个类的内存布局:
接下来我们一一分析为什么会产生这样的结果
class A明明是一个空类,为什么它的内存大小为1呢?表面上它是个空类,但其实它并不是,它有一个隐藏的1byte大小的char
,这使得这样的class的不同对象在内存拥有独一无二的地址
class B和C虚拟派生自A,为什么内存大小为8?这个大小与机器和编译器都有关系,收到三个因素的影响:
-
语言本身额外的负担
。若派生类 派生自 虚基类,则派生类中含有一个虚表指针vbptr,此指针指向virtual base class subobject(某对象中的虚基类)或一个相关表格vbtable,而vbtable存放virtual base class subobject地址或编译位置(offset) -
编译器对特殊情况的优化处理
。虚基类 A subobject的1 bytes的char一般放于派生类的固定部分的末端,某些编译器会对空的虚基类提供特殊支持但空的虚基类情况不同,其不定义任何数据,提供一个virtual interface。在某些编译器(比如vs c++)处理下,一个空的虚基类被视为派生类对象最开始的那一部分,并没有使用任何的额外空间,因为含有成员,所以也没有必要安插char,这就节省了1btyes
-
内存对齐alignment的限制
。大部分机器上,聚合的结构体大小会受到内存对齐的限制,使它们能够更有效地在内存中存取。在32位机器上,内存对齐为4bytes;64位机器上,内存对齐为8bytes
classD的内存大小为何为16?在一些并未对空的虚基类进行特别处理的编译器上,大小将为24;进行特别处理的是16,我们讨论的是未进行特别处理的
classD的内存大小受以下四点影响:
- 虚基类A,大小为1byte
- 基类Y和Z的大小,减去配置虚基类的大小,为8byte,总共16
- class D自己的大小0byte
- class D的内存对齐的大小。在这里为7
因此综上得出class D的内存大小为1 + 16 + 7 = 24;而进行特殊处理的,只含有基类Y和Z的大小为16
值得一提的是,基类subobjects的排列顺序或不同存取层级的数据成员的排列顺序并未有标准的规定,一切都由厂商来自行定义
data成员
c++对象模型以空间优化和存取速度优化的角度来表现nonstatic数据成员,且保持和c风格struct data的兼容性
。它将data存在每个class对象中
,继承而来的亦是如此,但并不强制定义它们的排列顺序
;而static数据成员则放置在全局数据段global data segment中
,不影响class对象的大小
每个class对象需要分配足够的空间来容纳所有的nonstatic data members,有些情况下会超乎你的预料,原因是:
- 编译器自动加上的额外数据成员,主要是virtual特性
- 内存对齐的需要
data成员的绑定
以前的c++对data成员的绑定与现在稍有不同
我们来看一个例子:
extern int x;
class A
{
public:
A( int, int, int );
int X() const { return x; }
void X( float _x ) const { x = _x; }
//...
private:
int x,y,z;
}
请问X()应该返回哪一个x?绝大部分的人,会回答是内部的那个,这个答案放在如今是正确的,但在以前并不对,在以前这两个X函数都会绑定外部的那个
因此针对这一现象,衍生出了两种防御性程序设计风格:
-
将所有数据成员放在class声明开始处,以确保正确的保定
class A { private: int x,y,z; public: A( int, int, int ); int X() const { return x; } void X( float _x ) const { x = _x; } //... };
-
将所有inline函数,无论其大小都放于class声明外
class A { private: int x,y,z; public: A( int, int, int ); int X() const { return x; } void X( float _x ) const { x = _x; } //... }; inline int A::X() const { return x; }
事实上,这种语言规则已经遗弃了,它的大致意思是"一个inline函数实体的返回值和函数体
内,在整个class声明未被完全看见时,不会被评估求值(evaluated)"。也就是说对inline函数的分析会推迟并放在class外;而在现在,inline是要被立刻评估求值,但两种效果其实都是一样的
class A
{
private:
int x,y,z;
public:
A( int, int, int );
int X() const { return x; }
void X( float _x ) const { x = _x; }
//...
}; //对函数本体的分析将在class声明块结束后才开始,也就是右大括号
//分析在此处进行
以前这一规则并不适用于成员函数的参数列表argument list,成员函数第一次遇见函数列表类型都会进行决议,编译器往后如果看到了同一名字的不同类型的数据成员,会将前面的数据成员标示为非法;现在如今,c++语言依旧支持这一特性,但不再标示为非法(vs c++ 2022)
来看一个例子:
typedef float length;
class A
{
public:
//在这里func的length都会被决议为全局(global)类型也就是float而非int
//_val被决议为A::_val
void func( length val ) { _val = val; }
length func() { return _val; }
private:
//将之前绑定的全局类型替换当前这个
typedef int length;
length_val;
}
对于这种情况,仍需防御性程序风格:将所有数据成员放在class声明开始处,以确保正确的绑定
data成员的布局
我们先来看一个程序片段:
class A
{
public:
//...
private:
float x;
static int a;
float y;
static int c;
float z;
}
//在class中可能的内存排列顺序如下
//float x
//float y
//float z
nonstatic数据成员在class对象中内存排列顺序应和声明的顺序相同
,即使static数据成员声明在nonstatic数据成员之间,也会不受static data members影响
在同一个访问区段(access section.也就是private、public、protected范围内)成员的排列只需符合较晚出现的成员在class对象中有较高的地址
这一条件即可,也就是说各个成员不一定得连续排列,可能会有什么东西介于声明的成员之间,这些东西是内存对齐,编译器合成的用来支持对象模型的数据成员
如vptr,在现在vptr放在class对象的最前端,以前是显式声明的成员这一范围的末尾
编译器可以将多个访问区段内的数据成员自由排列,不必在意它们在class中声明的顺序。但大部分编译器都是将一个以上的区段合并在一起,按照声明顺序,成为一个连续的区块
也就是说,在上面那个例子可能有如下排列:
//当然排列方式不固定,依据编译器
class A
{
private:
float x;
static int a;
private:
float y;
private:
static int c;
float z;
}
访问区段的多少并不会影响内存大小,也就是说访问区段并不会带来额外负担。声明一个private和声明八个private得到的对象大小都是相同的
data成员的存取
现有以下程序片段:
A a;
a.x = 0.0;
A* pa = &A;
pa->x = 0.0;
这里有两个问题:
- x的存取成本?
- 通过pa存取x和通过对象存取x有何差异?
接下来我们将围绕这两个问题展开分析
-
static数据成员
声明在class中的static数据成员,无论定义几个当前class类型的对象,
static数据成员只有一个实例,存放在数据段
(data segment)对一个static数据成员取地址,得到的是一个指向其数据类型的指针,而非一个指向其class成员的指针,因为static成员并不在class对象中
你可能会问如果两个类型不同的class都声明了一个static数据成员,这会导致名称冲突啊。其实编译器面对这种情况,会将每一个static数据成员进行名称修饰。以获得独一无二的名称
每一个成员的存取许可(priavte、public、protected)和与class的关联,并不会带来空间或时间上的额外负担,对于static数据成员来说亦是如此
每次对static数据成员进行存取,编译器会对其存取操作进行转换
可能的转换如下:
//a.x = 1; A::x = 1; //pa->x = 1; A::x = 1;
也可以看出,用指针和对象对static数据成员进行存储其实没什么两样。事实上确实如此,这是c++中唯一一个通过指针和通过一个对象来存取成员结论完全相同的情况,即使它的继承体系十分复杂,也无关紧要,因为在内存中只有唯一一份实例
你可能会问,static数据成员放于数据段(data segment),那为什么还要通过对象来存取?实际上,通过这种方式来存取只是因为方便,static成员并不在class对象中,因此存取它可以不用通过class对象
那么通过函数来调用呢?
来看一个例子:
func().x = 5; //可能的转化 (void) func(); A.x = 5;
可以看到即使func有返回值,对x的存取其实并没有用到这个返回值
-
nonstatic数据成员
nonstatic数据成员直接存放在每个class对象中,只能由显式或隐式的class对象来存取它们
显式的自然就是加上this指针了
隐式的是编译器进行的扩张.只要在一个成员函数中直接处理一个nonstatic数据成员,隐式的class对象就会发生
void A::translat( const A& obj ) { x += obj.x; y += obj.y; z += obj.z; } //进行如下转换 void A::translat( A* this, const A& obj ) { this->x += obj.x; this->y += obj.y; this->z += obj.z; }
但其实对一个数据成员进行存取,并没有看上去的如此简单,编译器需要把class对象的起始地址加上数据成员的偏移位置(offset)。这个offset在编译期即可得知,即使成员派生自单一或多重继承串链,也就是说
在这种情况下,存取一个nonstatic数据成员的效率其实和存取一个c风格的struct成员或一个不经过派生的class的成员是一样的
但在虚拟继承的情况下,效率会有差异。比如:
A* pa; pa->x = 0;
对于strcut、class、单一继承、多重继承情况下存取一个数据成员,效率都是相同的,因为在编译期即可准备好它们的offset,而虚拟继承因为指针或引用动态转换的关系将会将offset的确定工作移至执行期(具体的在后面虚拟继承将会讲解)
来看一个例子:
a.y = 0.0; //&a.y的地址 &a + (&A::y - 1)
为什么这里有个-1呢?这是因为指向数据成员的指针,它的offset总是会加上1,如此编译系统可以区分"
一个指向数据成员的指针,用来指向class的第一个成员的指针
" 和 "一个指向数据成员的指针,没有指向任何成员
"这两种情况(若不明白这两个含义,请移至指向data成员的指针)
现在我们回到最初的两个问题
-
x的存取成本?
对于static数据成员并没有什么额外成本;对于nonstatic数据成员和c风格的struct成员,以及不经过派生的class的成员是一样的,需要经过指针或'.'(select operator).不过这种调用只是符号上的便利,编译器会对这种调用进行转化"A::"
-
通过pa存取x和通过对象存取x有何差异?
当这个对象是个派生class,继承结构中有虚基类,且存取的成员从虚基类继承而来,用指针存取将会有重大差异。因为编译期无法确定指针具体的对象,只能在运行期确定,自然这个成员的offset也无法确定;而通过对象存取,其类型一定可以确定,也就可以在编译期确定offset了
指向data成员的指针
如何区别class种一个"没有指向任何数据成员的指针"和"一个指向第一个数据成员的指针"?答案是将指向数据成员的指针的值+1
你可能会疑惑为什么要这么做,来看一个例子:
class A
{
public:
float x;
const static int y = 1;
};
A a;
std::cout << &A::x <<std::endl; //结果为1,在class中的offset
std::cout << a.x <<std::endl; //x在内存中的地址
std::cout << &A::y <<std::endl; //对static数据成员取地址是它在内存中的地址
//若不加1,以下情况将无法区分
float A::* p1 = 0;
float A::* p2 = &A::x;
if( p1 == p2 ){}
因此,在使用指向数据成员的指针来指出一个成员前,需要减一
。也就是说,对一个class中的nonstatic数据成员取地址,得到的是它在class中的offset;而对一个绑定在class对象的数据成员取地址,得到的将是该成员在内存中的真正地址.对class中的static成员取地址是它在内存中的地址
在多重继承中,若将第二个或后继基类的指针,和一个"与派生类对象绑定"的成员结合起来,结果可能会不如你意,需要做一些操作
struct A1
{
int val1;
};
struct A2
{
int val2;
};
struct D : A1, A2{ ... };
void func1( int D::* dmp,D* pd )
{
pd->*dmp;
}
void func1( D* pd )
{
int A2::*amp = &A2::val2;
func1(amp, pd); //此时pd指向的将是A1::val1,而非A2::val2
}
//因此编译器需要进行转换
func1( amp + sizeof(A1), pd );
//但需要防范amp == 0
func1( amp ? amp + sizeof( A1 ) : 0, pd );
效率
- 对于对象成员,在编译期未优化时聚合、封装、继承方式在存取方面都有效率上的差异;优化后都是相同的,且封装并不会带来执行器的效率成本。其中聚合和封装、单一继承效率高,因为单一继承中members被连续存储在derived class中,且offset于编译期就计算出了;但虚拟继承的效率很低
- 对于指向data member的指针,在编译期未优化时,通过指针间接存取效率相对于直接存取会更低,但优化后都是一样的;单一继承并不会降低效率,但虚拟继承中,因每一层都导入一个额外层次的间接性,因此效率较差
继承
要说c++最具威力的便是继承,一个继承可以很简单,但也非常非常复杂,这节我们将进一步了解单一继承、多重继承、虚拟继承的威力
在c++继承模型中,一个派生类对象内含的部分是自己的成员加上它的基类成员的总和。但它们的排列顺序是由编译器自行安排,不过大多数都是基类先出现,但虚基类除外(一般而言,任何一条规则遇见虚基类将不再如你预期)
单一继承与多态
- 只考虑单一继承非多态
我们先来看一个例子:
class Point2d
{
public:
//...
private:
float _x,_y;
};
class Point3d
{
public:
//...
private:
float _z;
}
对于这两个class意为2d,3d坐标点,我们想要这些两种坐标点通过一个class即可使用,这个时候就是继承发挥作用的时候了,我们让Point3d继承自Point2d,这样Point3d即可共享Point2d的方法和数据成员,并将之局部化,这种继承被称为具体继承。一般而言,具体继承并不会增加空间或存取时间上的额外负担(虚基类,虚函数除外)
继承后如下:
class Point2d
{
public:
Point2d( float x = 0.0, float y = 0.0 ) : _x(x), _y(y) { }
float x() { return _x; }
float y() { return _y; }
void operation+=( const Point2d& rhs )
{
_x += rhs.x();
_y += rhs.y();
}
protected:
float _x,_y;
};
class Point3d
{
public:
Point3d( float x = 0.0, float y = 0.0, float z = 0.0 ) : Point( x, y ), _z(z) { }
float z() { return _z; }
void z( float newZ ) { _z = newZ; }
void operator+=( const Point3d& rhs )
{
Point2d::operator+=( rhs );
_z += rhs.z();
}
protected:
float _z;
}
内存布局如下:
这里有一个易错点,大多数人的设计可能会为了如上这种继承而导致膨胀内存空间
我们来看一个例子:
//32位系统
class Concrete
{
public:
//...
private:
int val;
char c1;
char c2;
char c3;
};
//对以上以继承体系实现
class Concrete1
{
public:
//...
private:
int val;
char bit1;
};
class Concrete2 : public Concrete1
{
public:
//...
private:
char bit2;
};
class Concrete3 : public Concrete2
{
public:
//...
private:
char bit3;
}
内存布局如下:
Concrete1内含val和bit1,总共5bytes,还需3bytes用作内存对齐,目前看着很正常;问题出现在Concrete2,大多数人会认为Concrete2内存只占了5bytes,于是加上一个bit2的1bytes和内存对齐2bytes,为8bytes,大错特错!这里应该是算对齐后的8bytes + bit2的1bytes + 3bytes的内存对齐,因此为12bytes
也许你会认为这太愚蠢了!这不白白多浪费了吗?但你知道这么设计的缘由吗?
我们继续上面那个例子:
//现有一组指针,这三个指针可以指向前面三种任意class对象的对应部分
Concrete2* pc2;
Concrete1* pc1_1, pc1_2;
//这会导致Concrete1派生类sub object被覆盖,这个时候你应该能意识到为什么要这么设计了
pc1_1 = pc2;
*pc1_2 = *pc1_1;
若不这么设计,结果如下,这个bug都不好找!
- 单一继承与多态
接着以上的例子,若要处理一个坐标点,并不在乎其具体是point2d或是point3d实例,实现方法是提供虚函数接口,这样子类可以重写这个方法表现不同的特性
改变如下:
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) { }
virtual void operator+=(const Point2d& rhs)
{
_x += rhs.x();
_y += rhs.y();
}
protected:
float _x, _y;
};
class Point3d : public Point2d
{
public:
Point3d(float x = 0.0, float y = 0.0, float z = 0.0) : Point2d(x, y), _z(z) {}
float z() override { return _z; }
void z(float newZ)override { _z = newZ; }
void operation += (const Point2d & rhs) override
{
Point2d::operator+=(rhs);
_z += rhs.z();
}
protected:
float _z;
}
void func( Point2d& p1, Point2d& p2 ) //p1和p2可以为Point2d或Point3d
{
//...
p1 += p2;
}
想要支持多态这种弹性,势必带来空间和时间上的额外负担,这些额外负担如下:
- 虚函数表。存放虚函数地址和slots(支持运行期类型识别runtime type identification)
- 每个class 对象导入一个vptr
- 加强构造函数,在其中设定vptr的初值,使其指向当前class对应的virtual table
- 加强析构函数,在其中抹去vptr
在c++最开始时,vptr一般放于class object尾端,如此可以保留基类class C struct的对象布局,放在c中亦可使用;但当后面需要支持虚拟继承及抽象基类,微软的vc++的编译器将vptr放于class对象的开头处,这对于通过指向class成员的指针调用虚函数是有益的
来看一个例子:
struct no_virts
{
int d1, d2;
};
class has_virts : public no_virts
{
public:
virtual void foo();
//...
private:
int d3;
};
no_virts* p = new has_virts;
内存布局如下:
多重继承与多态
单一继承不含virtual的情况下,基类类型和派生类类型之间的转换(无论继承串链多深),不需要编译器去修改地址,发生的很自然,执行效率也很高。比如上面例子中,基类Point2d和派生类Point3d对象的地址开始的出都是相同的,不同之处是派生类更大
对于以下操作,只需将派生类对象地址赋给基类的指针或引用,不需要进行计算:
Point3d p3d;
Point2d* p = &p3d;
但是当基类没有虚函数而派生类有,这一特性将不再生效,编译器需要介入调整地址。若既是多重继承又是虚拟继承,更是如此
多重继承的问题发生在派生类对象和其第二或后继的基类对象间的转换
来看一个例子:
class Point2d
{
public:
//含有virtual函数
protected:
float _x, _y;
};
class Point3d : public Point2d
{
public:
//...
protected:
float _z;
};
class Vertex
{
public:
//含有virtual函数
protected:
Vertex* next;
};
class Vertex3d : public point3d, public Vertex
{
public:
//...
protected:
float mumble;
};
Vertex3d v3d;
Vertex* pv;
Point2d* p2d;
Point3d* p3d;
pv = &v3d;
//内部转换 pv = (Vertex*)( ( (char*)&v3d ) + sizeof(Point3d) );
//无需转换
p2d = &v3d;
p3d = &v3d
Vertex3d* pv3d;
Vertex* pv;
//若想进行指针的指定操作,还需加个判断
pv = pv3d ? (Vertex*)((char*)pv3d) + sizeof( Point3d ); //pv3d可能为野指针
若要存取第二个或后继的基类的数据成员,不需要付出额外成本,因为成员位置的offset在编译期就确定了
c++并未要求派生类Vertex3d中,基类Point3d和Vertex按照特定的顺序排列,具体顺序还是按照编译器排列,不过大多数是根据声明顺序来排列
虚拟继承
多重继承有个问题,比如如下的iostream,istream和ostream都派生自ios,则会导致各自拥有一个ios,这不是浪费了内存吗?
针对这一问题,虚拟继承可以完美的支持。但背后的实现难度是颇高,需要将他们各自的ios折叠为一个,还能支持基类和派生类的指针之间的多态指定操作
//对应如下左图
class ios {...};
class istream : public ios {...};
class ostream : public ios {...};
class iostream : public istream, public ostream {...};
//对应如下右图
class ios {...};
class istream : virtual public ios {...};
class ostream : virtual public ios {...};
class iostream : public istream, public ostream {...};
实现方法如下.class若内涵一个或多个虚基类subobjects,将被分割为两部分:前一个不变区域和后一个共享区域,先放置好不变区域,再放置共享区域
- 不变区域:含有固定的offset,不受影响,可以直接存取。其实就是除开虚基类那部分
- 共享区域:也就是虚基类subobjects,这一区域会受每次派生操作影响而变化,只可以被间接存取。什么叫派生操作呢?因为不变区域在前,共享区域在后,每次派生不变区域本拥有的subobjects是不会改变的,会在不变区域的最后放入当前派生类的数据成员,然后在这之后才是共享区域
不同编译器对于共享区域的实现是不同的。我们来看看实现策略:
class Point2d
{
public:
//...
protected:
float _x, _y;
}
class Point3d : virtual public Point2d
{
public:
//...
protected:
float _z;
}
class Vertex : virtual public Point2d
{
public:
//...
protected:
Vertex* next;
}
class Vertex3d : public Vertex, public Point3d
{
public:
//...
protected:
float mumble;
}
继承体系:
这存在一个问题:如何存取共享部分呢?cfront编译器是在每个派生对象中安插一些指针,每个指针指向一个虚基类,通过这些指针来存取虚基类成员
比如:
void Point3d::operator+=( const Point3d& rhs )
{
_x += rhs._x;
_y += rhs._y;
_z += rhs._z;
}
//cfront的转换
//vbc为虚基类
__vbcPoint2d->_x += rhs.__vbcPoint2d->_x;
__vbcPoint2d->_y += rhs.__vbcPoint2d->_y;
_z += rhs._z;
//派生类与基类的转换
Point2d* p2d = pv3d;
//进行如下转换
Point2d* p2d = pv3d ? pv3d->__vbcPoint2d : 0;
这种实现模型有两个缺点:
- 每个class背负一些指向对应的虚基类地址的额外指针,随着虚基类个数的增长,这个指针也会变多。理想情况下是一个指针
- 随着虚拟继承串链的变长,导致间接存取层次的增加.比如三层虚拟派生,则需要经由三个虚基类指针进行间接存取。比如,假设Vertex也是虚拟继承,Vertex3d内有一个指针,这个指向Vertex,再指向Point2d.理想情况是固定存取时间
对于第二点的改进方法是拷贝所有虚基类指针,再放在派生类中,不过这会导致空间换时间
改善后的内存布局:
对于第一点来说,有两种实现方案:
-
在虚函数表中存放虚基类的offset,虚函数表经由正负来索引虚函数和虚基类。若为正值,则是索引虚函数;为负,则是索引虚基类
-
vc++引入虚基类表,这些虚基类表存放的是指向对应虚基类的指针,再在class的开头处放置一个vbptr,这个指针指向虚基类表
成员函数
成员函数的调用方式
c++支持三种类型的member functions:static、nonstatic、virtual,且每一种调用方式不尽相同
-
nonstatic成员函数
调用成员函数时,编译器会把成员函数转换为非成员的形式。步骤如下:
-
向函数原型中安插一个额外参数this指针
-
对nonstatic数据成员的存取操作将改为由this指针来存取
-
对成员函数的名称进行符号改编(name mangling)
为了防止重载和名称冲突,数据成员和成员函数进行name mangling.一般而言,member的名称前会加上class名称,形成独一无二的命名;而member function则还需加上参数链表
举个例子:
class Bar{ public: int ival; } //ival可能的转化 ival_3Bar; class Point { public: void x( float newX ); float x(); } //可能的转化 void x__5PointFf( float newX ); float x__5PointFv();
-
-
虚函数
若func()为虚函数,对它的调用可能的转化如下:
ptr->func(); //转化 (*ptr->vptr[1])(ptr);
转化规则:
- vptr为编译器生成的指针,指向虚函数表。也会进行namemangling
- [1]为虚函数表的slots,1为索引值
- prt表示this指针
-
static成员函数
static成员函数的特性:
- 没有this指针,成为一个callback函数
- 不可直接存取class中的nonstatic members
- 不可被声明为const、volatile、virtual
- 不需要经由class object调用
static 成员函数同样也有name mangling
对static成员函数取地址,得到的是内存中的地址;由于没有this指针,static member function地址类型是一个nonmember函数指针
虚函数
单一继承
为支持虚函数机制,必须能对于多态对象有一种"执行期类型判断法"(RTTI),有了RTTI就可以在执行期查询一个多态的pointer或引用
//以下调用需要ptr指针执行期的信息
ptr->z();
RTTI鉴定一个class是否展现多态特性是通过查看它是否有虚函数,这就是RTTI进行判断需要的额外信息
那么究竟什么具体的额外信息我们需要存储?有以下两个:
- ptr所指对象的真实类型.如此才能选择正确的函数实例
- 函数的位置.如此才能调用它
具体的实现只需在每个class对象中增加两个成员:
- 一个字符串或数字,表示class类型
- 一个指针,指向一表格(数组),表格中含有虚函数执行期地址.这一地址在编译期即可得知,且固定不变
随后只需两步即可找到其地址:
- 每个class 对象安插一个由编译器生成的指针,该指针指向表格
- 每个虚函数被指派一个表格索引值
以上工作都由编译器完成,执行期要做的只是在特定的虚函数表的slot中调用虚函数
一个class只有一个虚函数表,同一个类的所有对象都使用同一个虚表
,每个表内含对应的class对象中的active 虚函数实例地址。active 虚函数又包括:
- 这一class定义的函数实例
- 继承自基类的函数实例
- 一个pure virtual called函数
来看一个例子:
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;
};
对应的内存布局。pure_virtual_called是纯虚函数:
让我们再来看看一个class派生自基类,对于class来说虚函数又如何表现。有三种可能性:
- 派生类可以继承基类所定义的虚函数,也可以说该函数实例的地址会被拷贝到派生类的虚函数表的相对应slot
- 派生类可以对基类的虚函数进行重写,定义自己的版本
- 派生类可以写入一个新的虚函数,虚函数表会增大一个slot,来容纳这个新的虚函数
来看个例子:
class Point2d : public Point
{
public:
Point2d( float x = 0.0, float y = 0.0 ) : Point(x), _y(y) {}
~Point2d();
Point2d& mult( float );
float y() const { return _y; }
//...
protected:
float _y;
}
内存布局:
现在回归到最开始的那个例子:
ptr->z()
在编译期以下信息来设定这个虚函数的调用:
- 虽然不知道ptr类型,但知道经由ptr可以存取对象的虚函数表
- 虽然不知道具体调用的哪个版本的虚函数,但知道虚函数在表中固定的slot
可能的转化:
//假设虚函数的固定slot为4
(*ptr->vptr[4])(ptr)
多重继承
多重继承的虚函数实现将比单一继承复杂不少,主要表现在第二个及后继的基类,以及必须在执行期调整指针
先来看一个程序片段:
class Base1
{
public:
Base1();
virtual~Base1();
virtual void speakClearly();
virtual Base1* clone() const;
protected:
float data_Base1;
};
class Base2
{
public:
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;
};
在这里,我们将heap配置得到的Drieved对象的地址指定给Base2指针,它的new和delete都需要编译器进行offset计算。但是deleted的offset计算无法在编译期设定,因为指针指向的对象只有在执行期才能确定:
Base2* pbase2 = new Derived;
//new可能的转化
Derived* temp = new Derived;
Base* pbase2 = temp ? temp + sizeof(Base1) : 0;
//delete可能进行转化,也可能不用。因为delete一个class对象,需要先析构再调用delete,而这里析构函数是虚函数,需要将指针指向Derived开头处
delete pbase2;
也就是说,在这里我们需要必须确定offset,以及调整this指针,这需要由编译器在某个地方插入,但问题是在哪里?如何实现?
对此,c++引入thunk技术来支持多态的多重继承。简单来说,所谓thunk是一小段汇编(assembly)代码,它有两个用处:
-
以适当的offset值调整this指针
-
跳到虚函数去
例如:
//由Base2指针调用Derived析构函数,可能的thunk pbase2_dtor_thunk: this += sizeof( base1 ); Derived::~Derived( this );
thunk技术下,虚函数表的slot继续内含一个简单的指针.若需要调整this指针,则指向一个相关thunk;若不需要,则直接指向虚函数
我们知道在单一继承下,一个class只含有一个虚函数表,而在多重继承下,一个派生类内含n-1个额外的虚函数表,n表示上一层基类的个数
这些表格类型分为两种:
- 主要表格,基类中最左端的。Base1
- 次要表格,基类中第二个以以后的。Base2
用以支持"一个class拥有多个虚函数表"的方法:将每个表以外部对象的形式产出,并给予独一无二的名称
//可能的名称 vtbl__Derived; //主要表格 vtbl__Base2__Derived; //次要表格
我们将当前派生类对象的地址指定给最左端的基类或当前派生类的指针时,被处理的是主要表格;而将当前派生类对象的地址指定给第二个及以后的基类的指针时,被处理的是次要表格
举个例子:
Base1* pt1 = new Derived; //主要表格 Derived* ptD = new Derived; //主要表格 Base2* pt2 = new Derived; //次要表格
内存布局:
这样执行会非常慢,可行的优化方案是将多个虚函数表连锁为一个。指向次要表格的指针,由主要表格的地址加上一个offset获得
现在,回到最开始的问题,第二个或后继的基类会影响对虚函数的支持。有三种情况:
-
通过一个指向第二个基类的指针,调用派生类虚函数
例如:
Base2* ptr = new Derived; //如上图得知,派生类的虚函数在主要表格中,因此ptr必须进行offset计算,指向derived开头处 delete ptr;
-
通过一个指向基类的指针,调用第二个基类中继承而来的虚函数
例如:
Derived* pder = new Derived; //mumble属于次要表格,需要offset pder->mumble();
-
因为虚函数的返回值,需要改变指针指向
例如:
Base2* pb1 = new Derived; //调整pb1指向次要表格,但clone()属于主要表格的,需要进行offset Base2* pb2 = pb1->clone();
不要在virtual base class中声明nonstatic data members
同一个函数在不同的模型下执行,在编译器优化的情况下,nonmember, static member nonstatic member他们的效率完全相同
inline函数
inline关键词只是一个请求,若此请求被编译期接受,编译期则认为其可以用一个表达式将函数展开
inline函数的复杂度通过计算assignments、function calls、virtual function calls等操作的次数以及每个表达式种类的权值综合决定
若函数因其复杂度或建构问题,被判断不可称为Inline,那么此函数将被转换为static函数,并在"被编译模块"内产生对应的函数定义
inline function展开期间,做了以下两件事:
-
每个形参都被对应的实参取代。但这其中可能会导致实际参数的多次求值,面对这种情况,需要引入临时对象。比如,若实际参数是常量表达式,在替换前先引入临时对象,常量表达式求值后赋值给临时对象,后继inline替换只需使用临时对象
例子:
inline int min( int i, int j ) { return i < j ? i : j; } inline int bar() { int minval; minval = min( foo(), bar() + 1 ); return minval; } //minval = min( foo(), bar() + 1 )展开 int t1, t2; minval = ( t1 = foo() ), ( t2 = bar() + 1 ), t1 < t2 ? t1 : t2;
-
若内含局部变量,则需将局部变量放在函数调用的一个封闭区段,且拥有一个独一无二的名称。因为,如果Inline以单一表达式的方式扩展多次,每次扩展都需要自己的局部变量,特别是还含有副作用参数,可能会导致大量临时性对象产生;但如果是分离成多个式子扩展多次,只需一组局部变量即可重复使用
例子:
inline int min( int i, int j ) { int minval = i < j ? i : j; //局部变量 return minval; } { ... //minval = min(val1, val2); int __min_lv_minval; minval = (__min_lv_minval = val1 < val2 ? val1 : val2), __min_lv_minval; }
尽量不要Inline中套inline,可能会使简单的Inline因其连锁复杂度而没办法展开
指向成员函数的指针
取一个nonstatic成员函数的地址,若该函数不是虚函数,得到的结果是内存中的地址,但这个值不完全,还需要对象的地址
通过一个指向成员函数的指针来调用它,会进行转化
例子:
double ( A::* pt )(); //声明
double (A::*pt)() = &A::x; //初始化指针
//两种调用
(a.*pt)();
(ptr->*pt)();
//转化
(pt)(&a);
(pt)(ptr);
可以看出指向member function的指针和指向member selection operator的指针,其作用是作为this指针的空间保留者
。这也说明了为什么static member function的指针类型是函数指针,毕竟其没有this指针
使用member function指针,若不用于virtual function、多重virtual继承、virtual base class,其成本不必用nomember function指针高
虚拟成员函数的地址在编译期是未知的,我们所能知道的仅是虚函数在其相关之虚函数表的slots.因此对一个class的虚函数取地址,结果是其slots
绝不重新定义继承而来的非虚函数
先来看个例子:
class B
{
public:
void mf();
};
class D : public B
{
void mf(); //覆盖基类的mf()
};
D d;
B* pB = &d;
pB->mf(); //调用B版本的
D* pD = &d;
pD->mf(); //调用D版本的
发生如上现象的原因是,public继承说明,每个派生类的非虚函数一定会继承基类的接口和实现,非虚函数采用的是静态绑定.那么,由于pB为指向B类型的指针,pb调用的非虚函数的版本永远是B类型的
绝不重新定义继承而来的缺省参数值
原因和上一条类似,因为缺省参数值是静态绑定,而虚函数才是动态绑定
静态类型是程序中被声明时采用的类型;动态类型是目前指针或引用所指对象的类型
抽象基类
现有如下片段:
class Abstract_base
{
public:
virtual ~Abstract_base() = 0;
virtual void interface() const = 0;
virtual const char* mumble() const { return _mumble; }
protected:
char* _mumble;
}
以上抽象基类声明有几个问题:
- 即使class被声明为抽象基类,其依然需要explicit constructor来初始化protected data member _mumble,否则derived class无法决定_mumble初值
- 抽象基类的virtual destructor不要声明为pure。因为每个derived class destructor会被编译器扩张,以静态方式调用每个virtual base class和上一层base class的destructor
- mumble()不应声明为virtual function,因为其定义的内容和类型无关,derived class并不会改写此函数
合理的声明如下:
class Abstract_base
{
public:
virtual ~Abstract_base();
virtual void interface() = 0;
const char* mumble() const { return _mumble; }
protected:
Abstract_base( char* pc = 0 );
char* _mumble;
}
一般而言,class的data member应被初始化,且只在constructor中或在class的其他member function中指定初值。其他操作都会破坏封装性质,让class的维护和修改变得愈加困难
我们可以定义和调用一个pure virtual function,不过它只能被静态的调用,但不能经由虚拟机制
c++保证继承体系中每个class object的destructors都能被调用,编译期不可压抑这一操作,且编译器并没有足够知识合成pure virtual destructor函数定义
虚拟基类中,不要把所有的member functions都声明为virtual function,再靠编译器的优化把非必要的虚拟调用去除
虚拟基类中,virtual function尽量不要声明为const
区分接口继承和实现继承
public继承由函数接口继承和函数实现继承构成,是"is-a"的关系
纯虚函数有两个性质:
-
派生类必须重新定义基类的版本
-
在基类中通常不提供定义,但并非不可以。它可以提供一个默认缺省,防止派生类忘记定义自己的版本
例子:
class Airport { ... }; // represents airports class Airplane { public: virtual void fly(const Airport& destination); ... }; void Airplane::fly(const Airport& destination) { default code for flying an airplane to the given destination } class ModelA: public Airplane { ... }; //若ModelA忘记定义自己版本的虚函数,将调用基类版本 Airport PDX(...); Airplane *pa = new ModelC; ... pa->fly(PDX); //基类版本
为了避免上例的失误,我们可以做如下改动:
//将fly声明为抽象基类,并提供一个默认行为的普通函数,这样派生类必须自己声明一个 class Airplane { public: virtual void fly(const Airport& destination) = 0; ... protected: void defaultfly( const Airport& destination ); //默认行为 }; class ModelA : public Airplane { public: virtual void fly(const Airport& destination) { defaultfly(destination); } }
声明⼀个纯虚函数
的目的是让派生类只继承函数接口,让他们根据自身定义适合自己的版本
声明非纯虚函数
的目的是让派生类继承基类版本的接口和缺省实现。且必须支持一个虚函数,若不想重写,可以调用基类提供的缺省版本
声明非虚函数的目的是让派生类继承函数接口和一份强制性实现,任何派生类都不能修改这类函数
友元
class可以允许其他class或函数访问它的非公有成员,方法是让这些class或函数称为它的友元(friend)
在class定义中,使用 friend
关键字和非成员函数或其他class的名称,以允许其访问类的私有
和受保护
成员
声明
一般来说,最好的class定义开始或结束前的位置集中声明友元
friend
声明中声明的函数被视为使用 extern
关键字声明
全局函数
可以在其原型之前声明为 friend函数,但是成员函数在它们的完整类声明出现前不能声明为friend函数
void func();
class B;
class A
{
public:
friend void B::funcB(); //不合法,我们只是声明B,还没定义funcB成员函数
};
//改成这样就没问题
class B
{
public:
void funcB();
};
在 C++11 中,一个类有两种形式的友元声明
friend class F;
friend F;
如果最内层的命名空间中找不到任何具有该名称的现有类,则第一种形式引入新的类 F;第二种形式不引入新的类,当将 typedef
声明为 friend
时,必须使用该形式
在引用类型尚未声明时使用 friend class F
:
namespace NS
{
class M
{
friend class F; // 引用F没有定义它
};
}
如果使用尚未声明的class类型为 friend
,则会报错:
namespace NS
{
class M
{
friend F; // error C2433: 'NS::F': 'friend' not permitted on data declarations
};
}
class F {};
namespace NS
{
class M
{
friend F; // 合法
};
}
用于 friend F
将 typedef 声明为友元:
class Foo {};
typedef Foo F;
class G
{
friend F; // 合法
friend class F // Error C2371 -- redefinition
};
若要声明两个互为友元的类,则必须将整个第二个类指定为第一个类的友元
友元函数
friend函数是一个不为class成员的函数,但它可以访问class的private和protected的成员。 友元函数不被视为class成员,它们是获得了特殊访问权限的普通外部函数
友元不在class的范围内,除非它们是另一个class的成员,否则不会使用成员select运算符(. 和 ->)调用它们
friend 函数由授予访问权限的class声明。 可将 friend声明放置在class声明中的任何位置。 它不受访问控制关键字的影响
例子:
//普通函数
class Point
{
friend void ChangePrivate( Point & );
public:
Point( void ) : m_i(0) {}
void PrintPrivate( void ){cout << m_i << endl; }
private:
int m_i;
};
void ChangePrivate ( Point &i ) { i.m_i++; }
//类的成员函数
class A {
public:
int Func1( B& b );
private:
int Func2( B& b );
};
class B {
private:
int _b;
// A::Func1 is a friend function to class B
// so A::Func1 has access to all members of B
friend int A::Func1( B& );
};
class
friend class是声明为friend的所有成员函数都是另一个class的friend函数 的class,也就是说声明为friend class的成员函数具有对另一个class的私有成员和受保护成员访问权限
友元关系不能继承
可以在class声明中定义友元函数。,这些函数是inline函数。 类似于成员内联函数,其行为就像它们在所有class成员显示后但在类范围结束前(在类声明的结尾)被定义时的行为一样。 类声明中定义的友元函数在封闭类的范围内
例子:
class YourClass {
friend class YourOtherClass;
public:
YourClass() : topSecret(0){}
void printMember() { cout << topSecret << endl; }
private:
int topSecret;
};
class YourOtherClass {
public:
void change( YourClass& yc, int x ){yc.topSecret = x;}
};
类型转换
转换可以是显式(通过调用从一个类型转换为另一个类型时,例如强制转换或直接初始化的情况),也可以是隐式(当语言或程序调用其他类型而非程序员给定的类型时)
以下将发生隐式转换:
- 函数的形参和实参类型不同
- 函数的返回值与函数本来定义的返回值的类型不同
- 初始值表达式与其初始化的对象的类型不同
- 用于控制条件语句、循环构造或切换的表达式不具有对其进行控制时所需的结果类型
- 提供给运算符的操作数与匹配的操作数参数的类型不同。 对于内置运算符,这两个操作数的类型必须相同,并且要转换为可表示它们的常规类型
构造函数的转换
默认情况下,当创建用户定义的转换时,编译器可使用它来执行隐式转换。 有时这是你需要进行的操作,但另一些时候用于指导编译器进行隐式转换的简单规则会使其接受你不希望接受的代码
例子:
#include <iostream>
class Money
{
public:
Money() : amount{ 0.0 } {};
Money(double _amount) : amount{ _amount } {};
double amount;
};
void display_balance(const Money balance)
{
std::cout << "The balance is: " << balance.amount << std::endl;
}
int main(int argc, char* argv[])
{
Money payable{ 79.99 };
display_balance(payable); //不发生转换
display_balance(49.95); //发生转换。因为Money有参数为double类型得构造函数,他会先构造一个Money对象,再调用display_balance
display_balance(9.99f); //先执行一次标准转换,再执行用户定义的转换
return 0;
}
class类型能定义由编译器自动执行的转换,不过编译器每次只能执行一种class类型的隐式转换
:
class A
{
public:
A() : str("0") {};
A(std::string temp) : str(temp) {};
void func(A a) { ; }
private:
std::string str;
};
A a;
a.func("000"); //不合法
声明转换构造函数的规则:
- 转换的目标类型是要构造的用户定义的类型
- 转换构造函数通常仅采用一个参数,它为源类型。 但是,当每个附加参数都具有默认值时,转换构造函数可指定这些附加参数。 源类型保留了第一个参数的类型
- 与所有构造函数一样,转换构造函数不指定返回类型
- 转换构造函数可以为显式
禁用构造函数的转换:explicit
关键字会告知编译器指定的转换不能用于执行隐式转换。 explicit可以创建简便的转换,它们只能用于执行显式强制转换或直接初始化
例子:
#include <iostream>
class Money
{
public:
Money() : amount{ 0.0 } {};
explicit Money(double _amount) : amount{ _amount } {};
double amount;
};
void display_balance(const Money balance)
{
std::cout << "The balance is: " << balance.amount << std::endl;
}
int main(int argc, char* argv[])
{
Money payable{ 79.99 };
display_balance(payable); // 合法
display_balance(49.95); // 不合法。已经禁用了隐式转换。Error: no suitable conversion exists to convert from double to Money.
display_balance((Money)9.99f); // 合法。显式转换
return 0;
}
普通的成员函数一个道理
reference
知识星球 | 深度连接铁杆粉丝,运营高品质社群,知识变现的工具 (zsxq.com)
C++析构函数详解 (biancheng.net)
C# 文档 - 入门、教程、参考。 | Microsoft Learn
(25条消息) 左值和右值_coolwriter的博客-CSDN博客_左值
从4行代码看右值引用 - qicosmos(江南) - 博客园 (cnblogs.com)
C++11委托构造函数 - 腾讯云开发者社区-腾讯云 (tencent.com)
深度探索c++对象模型
c++ primer 5th
c++语言的设计和演化