关于对象
一个对象 的大小跟类内非静态成员总和大小,因为对齐而填补的空间以及为了支持virtual 而产生的额外空间。
不同类型的指针在表示方法和内容并没有什么不同,留给编译器来解释某个特定地址中的内存内容及其大小。
黑色部分是基类ZooAnimal对象的内容,指向基类的指针根据vptr的内容选择虚函数调用的实体。
Bear b;
ZooAnimal za =b ;
za.rotate();
上面额情况中,如果不使用指针,而是直接用子类对象b初始化基类对象za,就会引起内存的切割slice。虚函数rotate还是会调用基类的实体。
构造语义学
一个类必须有默认构造函数,如果用户不定义的话,编译器会合成一个。
类中的每一个对象成员都需要调用构造函数,如果在 用户定义的类构造函数中没有调用某个类成员的构造函数,那么编译器会自动扩张类构造函数,使其包含这个类成员的构造函数。对于继承类,会使其自动包含父类的构造函数。如果父类有虚函数,则构造函数会合成虚函数表和vptr。对于菱形继承(虚继承)来说,每一个子类都会在构造时生成一个指向虚基类的指针。
-
拷贝构造
在以下情况调用拷贝构造:以一个object作为另一个object的初值,例如X x; X xx=x;还有当对象作为函数参数和返回值时。
当用户没用定义拷贝构造函数,并且类不能被逐字节拷贝(bitwise copy)时,编译器会自动合成一个逐成员(memberwise copy)拷贝函数。如果某个类成员不能被bitwise copy 则对其递归调用memberwise copy。
以下四种情况不能被bitwise copy:
对于后两种情况,只发生在用派生类给基类赋值的情况,基类与基类,派生类和派生类之间赋值,还是可以使用bitwise copy。后两种情况不能使用bitwise的原因是需要编译器合成用于初始化vtpr,虚函数表以及指向虚基类的指针(偏移)的代码。bitwise copy不会产生函数调用。第二种情况有点类似于第一节中的内存切割。被虚继承的类在子类对象中的实例被称为virtual base class subobject。
以下的情况不能确定是使用bitwise还是memberwise:Raccon *ptr; Raccon little_critter=*ptr;
-
程序转化
当给函数传递对象时,先使用拷贝构造函数之外创建一个临时对象,再把函数改为引用传递。或者直接在函数的栈上创建临时对象。 函数返回对象时,需要在参数列表中增加一个引用参数,指向函数外的一个变量,用来通过拷贝函数保存待返回的对象:
同理,返回对象值得函数指针也需要转化:
- 使用者层面优化
return 后面直接调用构造函数,可以避免使用xx,从而少调用一次X的拷贝构造函数:
2.编译器优化 Named return value NRV
编译器直接把xx用result代替。即把用拷贝函数赋值的两个变量视为一个变量。
- 初始化列表
以下四种情况必须使用初始化列表
初始化列表中的项目会安排在所有的用户显式初始化代码之前。且初始化列表中项目的执行顺序是按照声明顺序。
data 语义学
对于菱形继承(虚继承)来说,一个Virtual base class subobject 只会在最终派生类(比如A)中存在一份实例,不管它在class继承中出现了多少次。
定义在类声明里的函数默认为incline函数,为了防止incline里的变量被绑定到类外部的同名变量,incline函数会等到类声明被解析完后再进行解析。
假设有一个多重继承
各个类的对象的数据布局为
如果把一个vertex3d的指针pv3d赋给Vertex指针pv,那么实际上发生以下的转换
pv = (Vertex*)((char*)pv3d)+ sizeof( Point3d ); 因为Vertex subject不是在Vertex3d对象的开头位置。
如果Vertex和Point3d都虚继承自Point2d,那么每个类对象的布局为
实现虚继承的方法有两种,一种是为每一个虚基保存一个指针(如上图所示),第二种是在虚函数表中保存一条到virtual base class offset的索引(下图所示)。
用一个非多态派生类来读取或修改虚基类的成员,可以被优化成和读取普通成员一样的操作。一般而言,虚基类最好不要有任何数据成员。
- 指向Data Members的指针
指向Data Members的指针表示数据成员在对象中的偏移量。
int Derived:: * 是指向Data Members的指针类型,int表示该data member是整数类型。
当把一个指向基类成员的指针赋给派生类的成员指针时,偏移量会自动发生转换。
还可以使用指向nostatic类成员函数和虚成员函数的指针。
double (Point::*coord)()= &Point::x;
其中double 是该成员函数的返回类型
调用的时候可以这样做
(ptr->*coord)()
指向novirtual成员函数的指针和普通函数指针的值都是函数地址,但区别是前者必须结合this指针才能使用。
对于指向虚成员函数的指针,它实际的值是vtable中的一个索引。
如果是多重继承或者虚继承,指针应该是一个结构体,其中不仅保存索引或函数地址,还需要保存虚基类或者多重继承中第二类的vptr的偏移量。
incline函数的原理是把形参用实参替代。 如果实参是变量,那么直接替换即可。如果实参是常数,那么可以在编译期间就计算出值。如果实参是函数返回值,那么需要临时变量保存函数返回值,以防止重复求值。在incline函数内部定义的局部变量,在展开时为了防止和外部变量重名,所以会加一些前后缀。
包含纯虚函数的类是抽象类,不能用new来创建对象,只有实现了这个函数的子类才能new 出对象。
纯虚函数也可以定义和调用,但是只能通过静态调用,即Abstract_base::interface()的形式。virtual destructor必须要定义。
inline 最好不要修饰虚函数,因为inline是在编译阶段优化,而虚函数是在运行阶段决定调用哪个函数。
- 虚继承和虚函数
class A {
public:
int _a;
virtual void fun1() {}
};
class B : public virtual A {
public:
int _b;
virtual void fun1() {}
//virtual void fun2() {}
};
int main()
{
B b;
b._a = 2;
b._b = 1;
cout << sizeof(A) << endl;
cout << sizeof(B) << endl;
getchar();
return 0;
}
上面的情况a为8字节,因为b中没有新增加虚函数,只需要覆盖a的虚表就行了,所以b只相对于a增加一个int和一个指向A类subobject的偏移量,总共16字节。
如果b增加一个虚函数fun2,则b类自己就需要创建一个新的虚函数表指针,总共需要20字节。
构造,复制,析构语义学
一般来说构造函数会递归的调用继承链上父类的构造函数,但是对于虚继承,虚基类的构造函数只会被当前完整的对象调用。
实现的方法是在调用构造函数时传入一个参数表示是否是子类在调用基类的构造函数。
虚函数表指针的初始化时间在基类构造函数之后和初始化列表之前,从而可以在构造函数内使用自身的虚函数。
如果函数内部的对象需要以值传递的方式赋给一个外部对象,如果对象定义了拷贝构造函数,那么编译器会把外部对象放在函数内,并调用copy constructor. 如果编译器支持NRV, 那么copy constructor会触发NRV,后者会把调用copy constructor的两个对象看成是一个,从而不再需要调用copy constructor.
如果对象可以执行bitwise copy, 那么可以不用显示地定义copy assignment operator, 但是需要定义copy constructor,目的是打开NRV(name return value)优化。
C++标准明确规定,不能获取构造函数和析构函数的地址,因此也无法形成指向他们的成员函数指针。但是可以获取copy assignment operator的指针,这样造成一个问题,就是无法利用传参的方式来避免重复调用虚基类的copy assignment operator。 最好的做法是不要在虚基类中声明任何数据。
destructor 执行的工作按顺序如下:1. 执行本身的函数;2.执行成员的destructor;3.如果对象中有vptr则重新设置它;4.执行基类的destructor;5.如果当前对象是most-detrived,执行虚基类的destructor.
执行语义学
在c语言中,静态变量都在编译期间进行初始化。而在c++中,由于对象的引进,静态对象在程序被加载时立即初始化。比如linux系统ELF文件中的.init 和.finit两个section就负责静态初始化和释放操作。
#include<iostream>
#include<string>
using namespace std;
class A
{
public:
A(int a=1) :ma(a){}
public:
int ma;
};
class B :virtual public A
{
public:
B(int b=2) :mb(b), A(b){}
public:
int mb;
};
class C :virtual public A
{
public:
C(int c=3) :mc(c), A(c){}
public:
int mc;
};
class D : public C,public B
{
public:
D(int d=4) : md(d){}
public:
int md;
};
class E : public D
{
public:
E(int e=5) : me(e){}
public:
int me;
};
D* d_ptr=new E();
B* b_ptr=d_ptr;
A* a_ptr=b_ptr;
int main()
{
cout<<b_ptr->ma;
return 0;
}
以上代码展示了静态初始化中的多态, A类的subobject在B类对象中的位置只有在执行期间才能确认。 所以A* a_ptr=b_ptr;
被编译成A* a_ptr = b_ptr->vbcA;
这就要求编译器支持基本数据类型的静态初始化。
对于函数的局部静态变量,只有第一次运行到这个函数才会进行初始化,其寿命从被构造出来直到程序结束为止。
编译单元是指可以产生目标文件的那些源码。定义于不同编译单元内的全局静态变量的初始化顺序是没有明确定义的,如果某个全局变量在初始化函数内引用了别的编译单元的全局静态变量,这就会造成问题。解决方法是只使用局部静态变量,然后函数返回局部静态变量的引用。
但是,在多线程环境下,局部静态变量可能会初始化多次。
placement new operator 是预先定定义好的一个new操作的重载
它的作用是在给定的内存区域上放置新产生的对象。
它有一个很隐晦的问题
struct Base {
int j=0;
virtual void f(){
cout<<"------Base"<<endl;
}
};
struct Derived: Base{
Derived(){
j=1;
}
void f() final{
cout<<"---derived"<<endl;
}
};
int main()
{
Base b;
b.f();
b.~Base();
new (&b) Derived;
b.f();
cout<<b.j<<endl;
}
这里placement new 操作用Derived覆盖了Base类,因为Derived只是进行了重载,所以两个类的大小一样,按照多态的思想, b.f()应该会调用重载函数,但实际上会调用基类的f()。
只有基类中的虚函数才能进行重载,之后子类中的重载函数都不需要声明virtual。 但这样会造成代码阅读上的一定困难,所以c++11引入了override关键字,如果在函数后声明了override,那么它必须是重载自基类中的虚函数。final关键字用来禁止子类的重载。
临时对象
#include<iostream>
using namespace std;
class LUO {
private:
int x;
int y;
public:
LUO() {
cout << "默认构造函数" << endl;
}
LUO(int a, int b) :
x(a), y(b) {
cout << "普通带参数构造函数" << endl;
}
LUO(const LUO& a) {
cout << "拷贝构造函数" << endl;
}
~LUO() {
cout << "析构函数" << endl;
}
inline void operator =(const LUO& a) {
this->x = a.x;
this->y = a.y;
cout << "拷贝复制函数" << endl;
}
inline void print() {
cout << x << endl;
cout << y << endl;
}
};
int main() {
LUO a; //像这种情况编译器就没有进行优化
a=LUO(2, 2);
LUO e = LUO(3,3); /*有点明白了,这样好像是,降临时对象作为参数,然后LUO e调用了带参构造函数,编译器给优化了,
编译器直接就没有执行LUO(3,3)的构造过程*/
e.print();
return 0;
}
上面代码的运行结果为
默认构造函数
普通带参数构造函数
拷贝复制函数
析构函数
普通带参数构造函数
3
3
析构函数
析构函数
a使用的是默认构造函数,然后用LUO(2,2)产生的临时变量进行拷贝复制操作。 而e进行了NRV优化,把e本身看成了临时对象,所以直接调用普通带参构造函数。
临时对象一般产生在表达式,按值传递的函数参数中。一般表达式结束,临时变量也会自动析构,但是如果表达式中的临时变量用来拷贝构造object,那么临时变量应该保留到构造函数结束之后。另外一个特例是该临时变量被一个const reference 绑定,例如:
右值的作用是方便编译器匹配传入参数是临时变量的情况,然后程序员可以对这种情况进行手动优化,比如
MyString(const MyString& mystr) {
int len = strlen(mystr._ptr);
_ptr = new char[len + 1];
strcpy(_ptr, mystr._ptr);
}
MyString(MyString&& mystr) :_ptr(mystr._ptr){
mystr._ptr = NULL;
}
第一个MyString是普通的拷贝构造,第二个是移动构造,其中直接把临时变量的字符串占为己用,然后把它的指针设为NULL, 防止临时变量析构时把字符串给释放掉。
站在对象模型尖端
模板类定义中的成员,即便是static成员,也只能等到具现化之后才能使用。
template <class Type>
class Point{
public:
enum Status { unallocate, normalized }; // enum相当于 static const
Point (type x = 0.0, Type y = 0.0, Type z = 0.0);
~Point();
void* operator new ( size_t );
void operator delete( void*, size_t);
// ...
private:
static Point< Type > *freeList;
static int chunkSize;
Type _x, _y, _z;
}
如果分别定义Point<float>
和Point<int>
会产生两个Point类实体,并且每一份中都有独立的static 成员。但是实际上chunkSize和Status和模板参数Type并不相关,所以可以把它们放在非模板基类中。
template 中名称决议方式 (name resolution)
template 定义
extern double foo( double );
template < class type >
class ScopeRules{
public:
void invariant() {
_member = foo( _val );
}
type type_dependent() {
return foo( _member );
}
private:
int _val;
type _member;
};
template 具现化
extern int foo( int );
//....
ScopeRules< int > sr0;
sr0.invariant(); // 决议:调用scope of the template definition的name
sr0.type_dependent(); /
在模板类ScopeRules的定义的scope中只能看到 foo(double), 但此时该类并没有具现化。在具现化的scope中可以看到两个foo函数的signature, 所以现在的问题是如何决议invariant和type_dependent中foo函数的调用。 c++的规则是如果foo调用时不涉及模板参数,就使用template定义scope中的foo(double),否则根据参数选择foo(int)或foo(double)
-
私有继承
在讨论权限的时候必须区分类成员和类对象,以私有继承为例,基类中的所有成员在子类中都降级为私有成员,但是子类新增的成员(函数)还是可以访问基类中公有和保护成员。 基类的私有成员对任何子类成员来说,都是不可访问的。class Base { private: int i; public: void test(){ i++; } }; class Child :private Base //如果是protected也一样。 { };
如果给定一个Child类对象c,有没有可能通过它访问到Base类的公有成员函数test呢,一个简单的想法是指针的转换
int main() { Child c; Base *b=&c; b->test(); }
但是上面这样做会报错,正确的做法是使用reinterpret_cast
int main() { Child c; Base *b=reinterpret_cast<Base*>(&c); b->test(); }
-
try/catch
当一个throw操作发生时,首先检查该操作是否在try区段中,如果不在或者抛出的对象不能匹配catch,就从堆栈中弹出当前函数,并析构所有active local object, 然后在上层函数中重复上述步骤。 所以,资源释放等操作最好放在本地变量的析构函数中。比如new的到的指针用一个智能指针对象保存。
如果使用catch(exception e), 当传入一个exception的派生类时,它的Non-exception部分会被sliced off。 所以最好使用引用。
-
conversion operator
做了一个实验,如果A类中定义了以B类为参数的拷贝复制函数,同时B类中定义了转到A类型的conversion operator 会发生什么样的事情。
test.h#include "iostream" class B; class A{ public: int i=1; void operator=(const B&b); }; class B{ public: int k=2; operator A() { A a; std::cout<<"using conversion"<<std::endl; a.i=k; return a; } };
test.cpp
void A::operator=(const B &b) { i=b.k; std::cout<<"using operator="<<std::endl; } void test(A a){ return; } int main(void){ A a; B b; a=b; test(b); }
因为A类和B类相互引用,所以在A类的定义前用了一个B类的前置声明,又因为拷贝复制函数使用了B类对象,所以把它的定义放到cpp文件中。 最后得到的结论是a=b会优先使用拷贝复制函数。
upcast是指将派生类的指针赋给基类,而downcast是用基类指针还原成派生类指针。 downcast风险比较大,所以一般用dynamic_cast实现,它会自动判断指针所指向的对象是否是目标类型的或其子类,否则返回一个空指针。 dynamic_cast利用了虚表中的type_info,其中保存类的继承信息。所以实际上也可以通过(typeid(*f).name()结果和typeid(S).name()来判断指针f的类型是否是S。
STL 源码剖析
allocator 内部使用allocator::pointer替代指针, 它的基本功能是申请内存返回pointer, 在指定pointer上构造对象。 析构pointer上的对象,或者释放pointer对应的空间。