对象初始化与清除

C++继承自C,并在易用性和安全性上不断扩展。C中将内置数据类型组合成struct结构体来扩展数据类型,而C++中,struct扩展成类的概念,类是由类设计者定制的抽象数据类型。它将C语言库中分散的构件(包括内置数据类型和函数),全都封装进类这个结构中,并对各个构件设置访问控制的分界。封装和访问控制在提高数据的安全性和库的易用性方面取得重大进展。

C struct与C++ class比较

在struct中,成员数据的属性默认是public的,而在类中,默认是private的。struct的数据需要手动初始化,并且要手动清除,struct没有操作数据的函数,也正因为如此,C 函数处理的是共同的外部数据。而对于类,它用构造函数初始化,用析构函数清除。当创建一个对象时,会调用构造函数。而传递给构造函数的第一个参数就是this指针。this指针也就是新创建的这个对象的地址,但this指针指向的是一块未初始化的内存块。当对象超出作用域时,编译器会自动调用析构函数。

用构造函数初始化

构造函数

构造函数是初始化非静态成员的特殊函数(与类同名,无返回值),并且这个函数可以被重载。

编译器会生成默认的构造函数,拷贝构造函数和拷贝赋值运算符。只有拷贝赋值运算符有返回值,而且一般是返回一个指向其左侧运算对象的引用。
C++规定,对象的成员变量的初始化动作在进入构造函数本体之前,使用成员初始化列表替换函数体中复制的手法通常能带来更高的效率。有些情况下,是一定得使用成员初始化列表的。比如成员是const或references,它们一定需要初值,不能被赋值。为了一致性,建议都使用成员初始化列表。
成员的构造顺序是按照非static成员在类中声明的顺序初始化,与初始化列表出现的顺序无关,因为假设根据初始化列表出现的顺序初始化成员, 则如果重载的构造函数中出现不同的初始化列表顺序,会造成二义性。如果是在继承层次中的派生类,则base classes早于derived classes被初始化。
C++对“定义于不同编译单元内的非局部静态对象”的初始化顺序无明确的定义,原因是编译器无法确定它们的初始化次序。但是可以通过Singleton模式的reference-returning函数来确定顺序,具体实现请参考设计模式的书籍。

静态对象,指存储在静态区的对象,根据其作用域分为局部静态对象和非局部静态对象。函数体内的静态对象称为局部静态对像,而global对象,定义与namespace作用域内的对象、在classes内的对象以及在文件作用域内被声明的静态对象称为非局部静态对象。静态对象的生命周期从被构造出来一直程序结束。
编译单元,指被编译器编译生成目标文件的源码,包括cpp实现文件以及其包含的头文件。

在涉及到包含虚函数的类时,编译器插入隐藏代码到默认构造函数中,这些隐藏代码的作用是初始化VPTR(虚表指针),检查this指针的值(以免opertator new返回零)。如果是派生类,还要调用基类构造函数。这些代码的代价可比是一个小内联函数的调用,影响程序执行的效率,不过构造函数的规模会抵消函数调用代价的减少。

拷贝构造函数

在下列情况下,会调用拷贝构造函数

  1. 用=定义变量时
  2. 将一个对象作为实参传递给一个非引用类型的形参
  3. 从一个返回类型为非引用类型的函数返回一个对象,这个对象会被用来初始化调用方的结果。

如果设计者认为没有任何理由使用拷贝构造函数和赋值构造函数,可以通过将这两个函数的原型声明为private并且忽略他们的函数体来禁止使用这两种操作。但这个做法并非绝对安全,因为成员函数和友元函数还是可以调用private函数。《Effective C++》中提供一种通过private继承Uncopyable class基类的实现方案来完全阻止拷贝构造和复制构造函数的调用。但这项技术可能导致多重继承。

    class Uncopyable{//总被视为基类
    protected:
        Uncopyable(){}//允许派生类对象构造和析构
        ~Uncopyable(){}
    private:
        Uncopyable(const Uncopyable&);//阻止拷贝
        Uncopyable& operator= (const Uncopyable& );
    class A:private Uncopyable{
        ...
    };

参数和返回值
在按值传递的函数调用中,会调用拷贝构造函数进行拷贝初始化,这也解释了为什么拷贝构造函数自己的参数必须是引用?如果不是引用,每次传参都创建副本,而创建副本又要调用本身,这个过程无法终止,拷贝构造函数会无限调用下去。

类的内存分布

加上封装后的布局成本

C struct转换成C++ class后,数据成员直接内含在每一个对象中,而成员函数虽然在class中声明,但是却不出现在对象之中,每个non-inline函数只会生成一个函数实例,而每个inline函数则会在每个调用模块上产生一个函数实例 。因此C++的布局和存取时间上的额外负担主要是有virtual引起的。这里的virtual包括虚函数和虚基类。
一个对象的占据的内存空间组成:

  1. 非静态成员变量的总和。
  2. 加上地址对齐(alignment)所需要的padding(padding空间可能存在于成员之间,也可能存在于结构体边界)
  3. 加上为支持virtual机制额外增加的空间代价(指针)。在固定的机器类型中,一个指针所需的内存大小是固定的,32位-4字节,64位-8字节。
    class A{
    public:
        A(){};
        virtual ~A(){};
        float x()const{};
        static int Acount(){};
    protected:
        virtual ostream& print(ostream& os) const{};
        float _x;
        static int _A_count;
    };
Object A 内存分布
float_x 一个float类型占据4字节
__vptr__A 一个指针占据8字节(对64-bit机器而言)
总共 16字节
    class B:public A{
    public:
        B(){};
         ~B(){};
        virtual void check_in(){};
    protected:
        enum ID{IDcard};
        int loc;
        static int _B_count;
    };
Object B 内存分布 声明位置
float _x 一个float类型占据4字节 A对象
int loc 一个int类型占据4字节 B对象
__vptr__B 一个指针占据8字节(对64-bit机器而言) B对象
总共 16字节

C++对象模型中,非静态数据成员被放在每个对象中,静态成员数据放在对象之外(放在静态区)。成员函数(不论是不是静态)也被放在对象之外。每个对象存放一个指向虚函数表的指针,称为虚表指针(VPTR),一个类中的虚函数表存放该类层次的虚函数的入口地址。因此,无论有多少个虚函数,只要一个虚表指针就可以实现函数调用,它们只占用一个虚表指针的空间,在牺牲一点空间和执行效率的基础上实现多态。在继承层次体系中,派生类对象的构造函数会将VPTR指向本层次的虚函数表,所有基类对象的VPTR是不包括在派生类对象之中的。
注:如果一个类中没有任何数据,则当创建对象时,编译器默认会分配一个字节给该对象,这么做是为了区分不同对象的地址。

参数初始化

把一个类对象当做参数传递给一个函数时,相当于以下形式的初始化操作:

X xx = arg;//xx表示形参,arg表示实参

在编译器实现上有两种策略:

第一种, 是先创建临时对象,用拷贝构造函数初始化该对象,之后将临时对象传递给函数,函数直接使用该临时变量,也意味着函数实际上的参数形式是func(const X & ___temp)

void fun(X x0);
X xx;
fun(xx);

转换为

X __temp;
__temp.X::X(xx);
fun( __temp);

第二种,把实参直接建构在其应该的位置上,此位置视函数活动范围不同而不同,记录在程序堆栈中。

返回值初始化

X func()
{
    X xx;//局部对象
    return xx;//返回局部对象的拷贝
}

编译器在局部对象的拷贝如何实现呢?

  1. 首先额外添加一个referrence参数,这个引用指向调用方传递来的临时变量__result。
  2. 在return之前,调用拷贝构造函数,将函数返回值拷贝到__result中。

编译器转换过程如下:

void func(X& __result)
{
    X xx;
    __result.X::X(xx);
    return;
}

用析构函数清除

析构函数

按初始化顺序的逆序销毁非static成员的特殊函数,析构函数隐式销毁一个内置指针类型的成员不会delete它所指向的对象。
在下列情况,会自动调用析构函数

  1. 变量在离开作用域时被销毁。
  2. 当一个对象被销毁时,其成员被销毁。
  3. 容器被销毁时,其元素被销毁。
  4. delete动态对象的指针时,动态对象被销毁。
  5. 对于临时对象,当创建它的完整表达式结束时被销毁。

当一个指向对象的引用或指针离开作用域时,析构函数不会执行。

为多态基类声明virtual析构函数

这个部分放到后面多态章节讲,这里只提及这个重要特性。

三/五法则

法则一:三个控制类的拷贝的函数:拷贝构造函数、拷贝赋值运算符和析构函数,需要同时定义。
如果一个类需要自定义一个析构函数,几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。因为编译器默认的拷贝构造函数和赋值构造函数只是简单的拷贝指针成员(浅层拷贝),这意味着多个对象中的指针指向同一块内存,从而导致对象销毁时,同一块内存被delete多次

法则二:需要定义拷贝构造函数的类也需要拷贝赋值运算符,反之亦然。


动态创建对象

创建对象时,需要两个步骤。
1)为对象分配内存
2)在内存的地址上调用构造函数,初始化那个内存。
分配内存可以在编译器确定(放在静态区),或者运行期(包括在栈上的自动变量或在堆上的对象)。在堆上分配内存的方式称为动态内存分配。

new操作符
1)在堆上为对象分配内存
2)使用参数调用构造函数或调用默认构造函数,this指针指向new返回的地址。

this指针

实际上this是成员函数的一个隐藏参数。this指针只有在成员函数中才有意义,this是被选中的对象的首地址,作为函数的首参来传递的(因此压栈的时候是最后入栈的)。调用每个成员函数时,this都作为参数压栈,所以成员函数知道它工作在哪个对象上。成员函数调用之前压栈的次数等于参数个数加1。

this 指针并不占用对象的内存空间。他与对象之间并不存在包含关系,只是当对象调用函数时,对象被this指向而已。类在实例化时,只分配类中的变量空间,并没有为函数分配空间。类的对象共享一份成员函数。

this 指针会因编译器不同,而放置的位置不同。一般的函数参数是直接压入栈中,而this指针却被放到了ecx寄存器中。在类的非成员函数中如果要用到类的成员变量,就可以通过访问ecx寄存器来得到指向对象的this指针,然后再通过this指针加上成员变量的偏移量来找到相应的成员变量。
静态成员函数:独立于任何对象之外,不包含this指针;静态成员函数不能声明为const,也不能在内部使用this指针。

delete操作符
1)调用析构函数
2)释放指定对象地址的内存

使用delete void*可能会出错
如果在程序中发现内存丢失的情况,那么搜索所的delete语句,并检查被删除指针的类型。如果是void*类型,需要在delete之前把它转换成适当的类型。

Object* a = new Object(40,'a');
delete a;//正确的调用析构函数, 并且释放内存
void* b = new Object(20,'b');
delete b;//不会调用析构函数,只释放Object对象的内存。存在内存泄漏的隐患

耗尽内存
1)返回0
2)调用new-handler函数
3)试着再分配或抛出bad_alloc的异常信息。
当new操作符找不到足够大的连续内存分配给对象时,一个称为new-handler的特殊函数被调用。这个函数的参数是一个函数指针,调用new-handler时,会检查参数,如果函数指针非0,则被调用。new-handler函数的行为与new捆绑在一起,除非new操作符被重载。

重载new和delete
重载new和delete通常是为了特定类分配内存的效率考虑。重载new和delete可以改变的只有内存的分配和释放阶段。
重载全局new和delete时,不能用iostream,因为当创建一个iostream对象时,他们调用new去分配内存,会导致进入死锁状态。

对一个类重载new和delete
内存分配方案针对某个类设计的,所以比默认的通用的new和delete的通用内存分配方案效率高一些。同时iostreams是可用使用的。如果要调用全局版本的new和delete,使用::new和::delete。

为数组重载new和delete
使用数组版本的new[]时,需要的长度比设置的多了4字节,这4个字节是系统用来存放数组中对象的数量的。联想到C++string对象,它的长度一般为8个字节(32位机器),char*占据四个字节,指示数据长度的整型占据4个字节。使用delete[],编译器会产生寻找数组中对象的数量的代码,然后多次调用析构函数。

构造函数的调用
如果new的内存分配没有成功,那么构造函数也不会被调用。并且现在主流的编译器会产生一个bad_alloc的异常信息。

以上,如有错误, 请批评指正。

猜你喜欢

转载自blog.csdn.net/chifredhong/article/details/66477377