C++、构造函数与拷贝构造函数

1. 初始化构造函数

构造函数的函数体内属于数据成员的赋值,初始化发生在函数体语句之前,所以需要显式初始化的成员必须在初始化列表中完成初始化,包括:

  1. 包含 const 或引用成员,且没有提供默认初始化值,这两者只能初始化而不能赋值。
  2. 包含了没有默认构造函数的类数据成员,没有默认构造函数的不能隐式初始化。只有当一个类没有定义任何构造函数时,编译器才会自动生成默认构造函数。
  3. 基类没有默认构造函数,派生类必须使用初始化列表显式调用基类的构造函数。这个与第 2 条类似。

初始化列表中成员初始化的顺序与其在类中声明的顺序一致,与初始化列表中的顺序无关,不要用声明在后面的成员初始化声明在前面的成员。但在构造函数内部属于赋值操作,可以不按声明的顺序赋值。

最好使用默认实参来代替默认构造函数,如 A(int _a=0) { a = _a; },可以节省代码量。

派生类不能直接初始化基类的数据成员,即便对于派生类是可见的。但是派生类可以在构造函数内对基类可见成员进行赋值。

2. 拷贝构造函数与重载赋值操作符

编译器会提供默认的拷贝构造函数以及重载赋值操作符,但是这只是浅拷贝,即单纯复制其数据成员的值,如果数据成员包含指针,并指向了对象以外的内存,就容易造成访问冲突的情况。为了更加灵活地处理对象的拷贝,通常需要手动定义拷贝构造函数以及重载赋值操作符,一般形式如下:

 A(const A& a);
 A& operator=(const A& a);

注意,对于拷贝构造函数,输入参数必须采用引用的方式传递,这并不是代码风格的问题,而是 C++ 标准就是这样要求。这是因为如果采用按值传递的方式,会导致拷贝构造函数的递归调用,因为按值传递本身就需要调用拷贝构造函数。而对于赋值操作符,采用按值传递只是多调用了一次拷贝构造函数,所以这是允许的,只是从效率上讲通常会使用引用。

注意,尽管有时候输入参数并不强制为 const,但不使用 const 修饰会导致很多的麻烦。没有 const 修饰的参数只能接受左值的输入,而无法接受右值(即不具名的临时变量),当然在 C++11 之后我们可以通过右值引用来定义移动构造函数来处理这个问题。但更严重的问题是,拷贝构造函数的输入有时候本身就是 const 对象,如果参数没有 const 修饰,参数列表就无法匹配,也就无法调用拷贝构造函数。例如在 vector 容器中,我们可以通过调用 vector(size_t n, const T& x); 来创建一个长度为 n,初始值为 x 的向量,但是如果 T 的拷贝构造函数参数不为 const,我们就没办法对 x 进行拷贝,这会严重影响我们对 STL 容器的使用。

赋值运算符的重载不一定要有返回值,因为重载的时候函数会包含左侧操作数的 this 指针,从而可以直接修改其值。使用返回值主要是为了实现等号的连续赋值,即如 x=y=z; 但注意这时一般使用引用返回,返回的是 *this 的引用。因为 *this 对象的作用域在函数之外,返回其引用是有意义的。如果直接返回 *this 对象,那么对于 x=y; 除了调用一次 operator= 以外还有一次拷贝构造函数的调用,即编译器生成了一个临时变量来存储 operator= 返回的结果。对于 x=y=z; 实际上相当于 A tmp1(y=z); A tmp2(x=tmp1); 即多调用了两次拷贝构造函数,所以应该尽量使用引用返回。

3. 移动拷贝构造函数与移动赋值函数 (C++11)

对于拷贝构造函数以及赋值重载,我们使用 const 来防止对原始对象的修改,所以当 A 中包含了动态分配的内存时,应该新开辟一块内存副本来拷贝原始对象的内容,而不是直接复制其内存地址,这就是对象的深拷贝。如果直接复制动态内存的地址,当原始对象在某个阶段被析构时,该内存就会被 delete 释放掉,这时保留其指针已经没有意义了,这就是浅拷贝以及多个指针指向同一块内存的弊端。

然而,如果原始对象是右值,即一些临时变量,我们没必要再重新开辟一块内存副本,因为我们知道临时变量即便不去修改马上也会被销毁,这时我们直接把原始对象指向的动态内存地址保留下来即可,但是需要把原始对象的指针置为空,这样才不会在原始对象被析构时把内存释放掉,这就是移动构造函数的存在意义。假设 A 包含了一个指针如 int *ptr,其一般形式如下:

    A(A&& a) noexcept    // 移动构造以及移动赋值函数一般要加上 noexcept,
    {
    
                        // 用于告诉编译器函数不会发生异常,可以进行优化,没有的话STL可能调用的是普通拷贝构造函数
        ptr = a.ptr;
        a.ptr = nullptr;
    }

因为我们需要修改原始对象的指针,所以不能使用const修饰。这时编译器会根据原始对象是左值还是右值来选择拷贝构造函数还是移动构造函数。

 A getA() 
 {
    
    
    A x;
    return x;
 }
 A a = getA();

在以上的例子中,实际发生了一次构造和两次拷贝,即首先构造局部变量 x,x 在函数结束后就会被析构销毁,属于临时变量。因为按值返回发生了一次拷贝,生成了另外一个临时变量 getA(),最后才被拷贝到具名对象 a 中。如果 A 中包含了动态分配的内存,普通的拷贝构造函数由于深拷贝的原因需要进行两次动态内存的拷贝,而移动构造函数则不需要任何的动态内存拷贝,降低了程序运行的复杂度。实际上,以上的例子在编译器中通常会被优化为只需单次构造无需拷贝,关掉优化可使用 -fno-elide-constructors 选项。

猜你喜欢

转载自blog.csdn.net/qq_33552519/article/details/124046299