啃书《C++ Primer Plus》 面向对象部分 深拷贝与浅拷贝问题 拷贝构造函数 赋值函数

干货长文预警


这一篇,我们来解决对象的赋值与拷贝问题。赋值与拷贝,是程序设计过程中不可或缺的部分,对于基础类型,这两个概念相信大家早已熟知,这里不多赘述。
但是对于自定义类型的对象,拷贝与赋值就有这不同的含义:

拷贝发生在一个对象诞生的时候,基于另一个同类对象进行构造
而赋值则是将一个已存在的对象的内容传递给另一个已存在的对象。

虽然编译器提供了默认的拷贝与赋值函数,又由于对象的数据成员在被赋值或是拷贝时会有着诸如指针指向新内存这些默认函数不能实现的不同行为需求,因此赋值与拷贝操作有时需要重新定义其行为,以满足程序的具体要求。这也就引出了本篇的第二个关键问题:深拷贝与浅拷贝

本文思维导图:
在这里插入图片描述


拷贝构造函数

根据名称,不难知道拷贝构造函数是一种特殊的构造函数,它的任务就是用来拷贝对象。

默认拷贝构造函数

拷贝构造函数享有编译器赋予默认添加的vip特权。当程序中没有拷贝构造函数时,编译器会自动提供一个缺省的拷贝构造函数。这个拷贝构造函数的访问级别是公有的。

在进行拷贝时,默认的拷贝构造函数的行为是固定的,它有两种行为:

  • 普通成员(内置类型,指针等)按位拷贝。
  • 对象成员 执行其拷贝构造函数

对于普通成员,程序将按位进行复制进行拷贝,执行“浅复制”
对于对象成员,程序将调用它的拷贝构造函数进行复制,这个成员的复制的具体行为由其拷贝构造函数决定

对于对象成员拷贝的解读,来看个粟子:

class A{
public:
    A(){}
    A(const A& a){cout << "A类拷贝构造函数" << endl << endl;}
};
class B
{
public:
    B(){}
    A a;	//对象成员,在默认拷贝构造函数中将被调用其拷贝构造函数
};
int main()
{
    B b;
    B c = b; //产生拷贝情况
}

运行结果:
在这里插入图片描述
表示在拷贝B类型对象过程中调用到了A类型拷贝构造函数

另外,我们还漏掉了一类情况:引用类型!!
将上述代码稍作修改,用来观察引用类型成员在默认拷贝构造函数中的行为:

class A{
public:
    A(){}
    A(const A& a){cout << "A类拷贝构造函数" << endl << endl;}
};
class B
{
public:
    B():aa(*new A()){}	//引用类型必须进行初始化!!!
    A a;	//对象成员,在默认拷贝构造函数中将被调用其拷贝构造函数
    A& aa;
};
int main()
{
    B b;
    B c = b; //产生拷贝情况
    cout << "对象成员 " << &b.a << " " << &c.a << endl;
    cout << "引用成员 " << &b.aa << " " << &c.aa << endl;
}

在主函数中打印两个对象的成员地址,用以区分其是否为同一对象。同时,该代码保留了上一份代码对A类拷贝构造函数的提示功能。其运行结果如下:
在这里插入图片描述
不难发现的是,对于引用型的成员,初始化拷贝时直接引用了被拷贝对象的引用,并没有创建新的引用。
考虑到引用在底层是使用指针实现的,相当于一个 A * const类型指针,所以引用成员的拷贝行为就也可以看作是按位复制的结果,仍然是一种“浅拷贝”。

扫描二维码关注公众号,回复: 11296476 查看本文章

自定义拷贝构造函数

当默认的拷贝构造函数不能满足程序设计需求时,就需要程序员自己来实现拷贝构造函数了。
实现自定义的拷贝构造函数的第一步,就理解拷贝构造函数的书写形式。

拷贝构造函数的形式

重点放在前面
拷贝构造函数声明形式如下:

类名(cosnt 类名& );

实现形式如下:

类名 (const 类名& 引用对象名):初始化列表{构造函数方法体}

作为一种特殊的构造函数,拷贝构造函数具有与构造函数相同的特点。但是不同于一般构造函数的是,拷贝构造函数的参数列表是确定的,必须是同类对象的引用常量!!!

什么,你有大胆的想法?

  • 可不可以是同类型普通引用——不行!
  • 可不可以是同类型对象——不行!
  • 可不可以在后面带有其他带有缺省值参数——可以,它将兼容拷贝构造函数
  • 可不可以是其他类型或者同类型指针——可以,但这就不是拷贝构造函数了呀~~~~

不难发现,为了避免程序调用拷贝构造函数产生歧义,C艹直接废除了一般情况,保留了兼容性最强的写法。同时,你也可以选择兼容性更强的写法比如在后面跟上带有缺省值的参数,但一般不建议这么搞。

如何禁止对象拷贝

一些情况下,我们需要禁止对象的拷贝,比如单例模式下,仅允许整个程序中存在不超过一个的对象,并且这个对象由类保管。倘若不禁止拷贝,在类外拷贝一个新的对象就会违背设定。

那么应当如何禁止对象的拷贝呢?我们不能呵斥这个类让它不要进行拷贝,也不可能威胁使用类的程序员不要写拷贝。那么最好的办法就是将拷贝构造函数 “藏起来” 。将它的访问级别限制为私有

像这样:

class A
{
public:
	A(){}	//无参构造函数会因为拷贝构造函数的存在而不会自动给出,因此这里要写出来
private:
	A(const A& a){}
};

这时再在代码中尝试拷贝这个对象将会遭遇失败:

int main()
{
	A a;
	A b = a;	//在这里尝试使用拷贝将会失败,遭遇编译错误
}

点击编译:
在这里插入图片描述

nice!!!

当然,禁止对象拷贝需要慎之又慎,如果不是确切的需要,尽量不要把拷贝构造函数写在private中。
当其他类含有禁止拷贝类对象作为成员时,其默认的拷贝构造将会出错

class A
{
public:
	A(){}	//无参构造函数会因为拷贝构造函数的存在而不会自动给出,因此这里要写出来
private:
	A(const A& a){}
};
class B
{
public:
    B(){}	
    A a;	//对象成员,在默认拷贝构造函数中将被调用其拷贝构造函数
};
int main()
{
    B a;
    B b = a; //由于成员中的a对象不能被拷贝,这个拷贝将会出错
}

点击编译:
在这里插入图片描述

哦吼不太妙!!

使用到拷贝构造函数的时机

与其说使用到拷贝构造函数的时机,不如说是拷贝对象的时机。

在一个对象诞生时以其它同类对象作为模板进行初始化,这个过程中就发生了拷贝。在出现拷贝时,程序通过调用拷贝构造函数完成拷贝工作。

具体的,分为以下三种情况:

  • 实例化一个对象
  • 按值传递给函数
  • 函数按值返回一个对象

我们来看个粟子:

#include<iostream>
using namespace std;
class A	//A类,实现拷贝构造函数
{
public:
    A(int k = 0){}
    A(const A& a){cout << "A类拷贝构造函数" << endl << endl;}
};
/*函数f,按值接受A类对象,按值返回A类对象*/
A f(A a)	//按值传递,产生拷贝
{
    cout << "函数f" << endl << endl;
    return a;	//按值返回,产生拷贝
}

int main()
{
    A a;
    cout << "将对象a拷贝到对象b" << endl << endl;
    A b = a;	//初始化时进行拷贝
//  A b = A(a); //显式的调用拷贝构造函数  
    cout << "调用函数f" << endl << endl;
    f(a);
}

结果如下:
在这里插入图片描述

最后,看完了拷贝构造函数,千万不要把它和另一类特殊的构造函数——转换构造函数搞混了!


赋值函数

赋值是两个已经存在的对象之间的数据传递问题,它不同于拷贝行为发生在一个对象诞生的时候,

赋值函数是一类特殊的运算符重载函数,具体来说,它是重载了=运算符,且参数应该是同类的引用或是引用常量,返回值是同类型的引用。

默认赋值函数

与拷贝构造函数相似, 当程序员没有显式的给出默认赋值函数的定义时,编译器会提供一个缺省的赋值函数,相当于为这个类默认重载了=

与拷贝构造函数相似的不仅是他自动生成的机制,还有他赋值成员的机制。类比于拷贝函数,赋值函数对成员的赋值有:

  • 普通成员(内置类型,指针等)按位赋值。
  • 对象成员 执行其赋值函数

对于普通成员,程序将按位进行复制进行拷贝,执行“浅复制”
对于对象成员,程序将调用它的赋值函数进行赋值,这个成员的复制的具体行为由其赋值函数决定

可真是无可奈何花落去,似曾相识燕归来

来看个粟子;

class B
{
public:
	/*同时为B类的拷贝构造函数和赋值函数进行标记,以便追踪谁被调用*/
    B& operator=(const B& b){cout << "B类赋值函数" << endl;}
    B(const B& b){cout << "B类拷贝构造函数" << endl;}
    B(){}	//存在其他构造函数需要给出无参构造函数
};
class A	//仅有一个成员B类对象b,拷贝构造函数和赋值函数都是默认形式
{
public:
    B b;
};
int main()
{
    A a;
    A b;
    a = b;	//对象a、b都是已经存在的对象,使用等号连接调用赋值函数
}

编译执行:
在这里插入图片描述
可以看出A类对象在调用赋值函数时,B类成员在也调用了其赋值函数而非拷贝构造函数。

但是还不能高兴的太早,对于引用,这里的情况就发生了很大的改变,
我们再改造一下验证引用成员的粟子,将拷贝改为赋值:

class A{
public:
    A& operator=(const A& a){}
};
class B
{
public:
    B():aa(*new A()){}	//引用类型必须进行初始化!!!
    A a;	//对象成员,在默认拷贝构造函数中将被调用其拷贝构造函数
    A& aa;
};
int main()
{
    B b;
    B c;
    c = b; //赋值
    cout << "对象成员 " << &b.a << " " << &c.a << endl;
    cout << "引用成员 " << &b.aa << " " << &c.aa << endl;
}

编译:在这里插入图片描述

boom!!!

和我们设想到的不一样,引用成员并没有赋值过去而是直接报了错。问题就出现在了。引用底层是一个指针常量,指向对象是不能够通过赋值来改变的。看来赋值还是逊了拷贝一筹。(其实引用只是常量成员的特殊情况,对于其他类型的常量成员,仍然会存在这个问题。)

解决这个问题,只有自定义赋值函数这一条道路。

自定义赋值函数

为了解决上面的问题以及应对更多的默认赋值函数解决不了的问题,我们需要自定义赋值函数。

自定义赋值函数的形式

首先,还是重点放在前面,自定义复制函数的形式为:

类名 & operator=([const] 类名 & 对象名){...return *this;}

因为是成员函数,这个函数含有隐藏的this指针。

这里的重点问题在于其返回类型以及参数的const限定。

返回类型

将返回类型设置成为类引用是为了满足赋值的一般规律,其中典型的的就是结合律。

例如,对于内置类型变量,以下赋值语句将会等价:

a = b = c
(a = b) = c

如果将赋值函数的返回类型变为对象,那么第二个式子将不等价于第一个式子

参数

对于参数主要矛盾点在于是否需要加const限定符。对于一般情况,const限定符是需要的,因为赋值时通常不改变模板。但是如果需要在赋值时对模板进行修改,那么就不要加const修饰了。

解决带有引用成员的赋值问题

引用成员本质上是一种指针,在默认赋值函数中,是通过赋值改变这个指针的指向,这和其性质相悖,是不被允许的。
在自定义的赋值函数中,对于引用成员的处理,就只能修改其指向对象的内容了,这点是允许的。

因此,上面的代码应该添加自定义赋值函数,修改为:

class A{
public:
    A& operator=(const A& a){}
};
class B
{
public:
    B():aa(*new A()){}	//引用类型必须进行初始化!!!
    A a;	//对象成员,在默认拷贝构造函数中将被调用其拷贝构造函数
    A& aa;
    B& operator=(const B& b){}//自定义赋值函数可以解决引用和常量的默认赋值报错问题
};
int main()
{
    B b;
    B c;
    c = b; //赋值
}

尽管自定义的赋值函数没有对引用成员做任何操作,但是它避免了对其非法赋值。如果需要对引用的对象做任何修改,在函数内部修改即可。

防止自赋值行为

有一种雷区自爆行为,就是给自己赋值!!!
对于内置类型,这样的行为并不会产生任何异常:

int a = 10; 
a = a;

但是如果是对象给自己赋值,就会出现各种各样的问题。
倘若在赋值的过程中,需要将一个成员指针指向的内存释放并根据模板创造一个新的对象。这时如果模板是对象本身的话,先释放在拿去会导致无效内存访问,这是个相当严重的问题。

杜绝此类问题的方案,就是防止自赋值行为,具体的,可以通过地址来判断两个对象是否相同。

粟子:

class B
{
public:
	B& operator=(const B& b)
	{
		if(this == &b)
		{
			return *this;	//如果是同一个对象,那么什么都不要干,直接返回
		}
		...
	}
}

总之,赋值函数应该含有以下四种行为:

  • 检查自我复制情况
  • 释放成员指针以前指向的内存
  • 复制数据而非数据的地址
  • 返回一个指向调用对象的引用

深拷贝与浅拷贝

啊哈,前面说了那么多,终于说到本节的重点部分了。
深拷贝与浅拷贝是程序员永远绕不开的重要知识点,也是各种面试书上经久不衰的热点话题 (好吧,博主并没有参加过面试。。。。)

浅拷贝

其实就是上文说到的按位拷贝。不论是默认拷贝构造函数还是默认赋值函数,其对内置类型的拷贝机制都是按位拷贝,也就是浅拷贝。

深拷贝

非按位复制的拷贝,概念是相对于浅拷贝而言的。需要程序员自行设计拷贝过程中执行的行为。通常,对于成员中含有通过指针关联的对象
,需要实现深拷贝,以在拷贝过程中更新或者重造实际关联的对象,而非仅改变指针使其指向模板的对象。

(有关关联相关概念,戳这
对于深拷贝提到的指针关联对象的问题,我们来看个图示;
对于类a和类b:

class B{};
class A
{
public:
	A():p(new B()){}
	B *p;
};

默认拷贝

在这里插入图片描述

主函数

int main()
{
	A a;
	A b = a;
}

默认赋值

在这里插入图片描述
主函数:

int main()
{
	A a,b;
	a = b;
}

这两种情况都会导致在进行拷贝或者赋值后,会有多个对象同时指向同一个对象。这样的操作往往会导致多次删除,无效的地址访问等情况。赋值的情况更加糟糕,它直接产生了一个内存垃圾。

我们赋值的本意,应该是在类中将指针指向一个同模板指向对象一模样的对象,并将原先指向的对象释放。这就需要我们自定义拷贝构造函数与赋值函数来实现这个操作。

更改上述代码:

class B{};
class A
{
public:
	A():p(new B()){}
	A(const A& a):p(new B(*a.p)){cout << "拷贝构造函数" << endl;}	//拷贝构造函数
	A& operator=(const A& a)										//赋值函数
	{
	    cout << "赋值函数" << endl;
        if(p != nullptr)											//释放原有对象
        {
            delete p;
        }
        p = new B(*a.p);											//根据模板创建新的对象
        return *this;
	}
	B *p;
};
int main()
{
    A a;
    A b = a;														//拷贝
    cout << "a.p:" << a.p << "  b.p:" << b.p <<endl;	
    a = b;															//赋值
    cout << "a.p:" << a.p << "  b.p:" << b.p <<endl;
}

结果:

在这里插入图片描述

很nice!!


啃书系列持续更新ing,关注博主一起学C++鸭~

系列文章:

语言基础部分:

面向对象部分:

猜你喜欢

转载自blog.csdn.net/wayne_lee_lwc/article/details/105783952
今日推荐