【C++】—— 类与对象(三)
- 4、拷贝构造函数
- 4.1、初识拷贝构造
- 4.1.1、为什么要传引用
- 4.1.2、引用尽量加上 const
- 4.2、深入拷贝构造
- 4.2.1、为什么要自己实现拷贝构造
- 4.2.2、传值返回先调用拷贝构造的原因
- 4.2.3、躺赢的 MyQueue
- 4.2.4、传值返回与引用返回
- 4.3、总结
- 5、取地址运算符重载
- 5.1、const 成员函数
- 5.2、取地址运算符重载
4、拷贝构造函数
4.1、初识拷贝构造
我们要先知道,拷贝构造是一个特殊的构造函数。
拷贝构造的作用是:用一个自身类的对象来 初始化 当前的对象
拷贝构造基本特点:
- 拷贝构造函数是构造函数的一个重载
- 拷贝构造函数的
第一个参数
必须是自身类类型的引用,使用传值方式编译器直接报错
,因为语法逻辑上会引发无穷递归调用。其他任何额外的参数都要有缺省值(默认值)- C++ 规定
自定义类型对象
进行拷贝行为
必须调用拷贝构造,所以这里自定义类型传值传参
和传值返回
都会调用拷贝构造完成
我们先用
D
a
t
e
Date
Date 类型来感受一下拷贝构造:
运行结果:
拷贝构造的 调用方式 有两种:
- 一种是像上述代码一样类似于构造函数的调用方法:
Date d2(d1);
- 另一种是类似赋值的调用:
Date d2 = d1;
4.1.1、为什么要传引用
- C++ 规定:对
自定义类型
,传值传参要先调用拷贝构造
class date
{
public:
date(int year, int month, int day)
:_year(year)
, _month(month)
, _day(day)
{}
date(const date& d)
{
*this = d;
}
private:
int _year;
int _month;
int _day;
};
void func(const date d)
{
cout << "heallo world" << endl;
}
int main()
{
date d1(2024, 1, 1);
func(d1);
return 0;
}
我们来调试来验证 一下:
对自定义类型,传值传参都会调用拷贝构造函数。
那这样的话,拷贝构造用传值传参会发生什么呢?
答: 无穷递归
我们通过图来理解一下:
而如果是传引用的话,
d
d
d 是
d
1
d1
d1 的别名,就不会形成新的拷贝构造。
所以,对于
自定义类型
,传参都不建议使用传值传参。用传值传参还需要先调用拷贝构造
,尤其是当实参特别大
时,太费劲了
为什么传值传参要先调用拷贝构造呢?别急,我们学习完下一个知识点就来回答
4.1.2、引用尽量加上 const
使用
引用传参
,当函数体不需要改变
外面的实参
时,尽量都使用 c o n s t const const 引用!
- 因为加上 c o n s t const const 可以保护形参不被改变
假设,我要写一个判断逻辑,结果 “==” 不小心写成了 “=”。如果没加 c o n s t const const,那形参 d d d 就真被改了,我去给别人拷贝,结果我自己被改了,这合适吗?
Date(Date& d)
{
if (d._year = _year)
{
//···
}
}
- 而且,不使用
c
o
n
s
t
const
const 引用,当传的实参是
只读性质
时,会造成权限放大,编译不过去。使用了 c o n s t const const ,无论
实参是不是只读
,都能编过去
4.2、深入拷贝构造
拷贝构造的进阶特性:
- 若未显式定义拷贝构造,编译器会自动生成拷贝构造函数。自动生成的拷贝构造对
内置类型
成员变量会完成值拷贝/浅拷贝(一个字节一个字节的拷贝),对自定义类型
成员变量会调用它的拷贝构造
- 传值返回会产生一个
临时对象
,产生临时对象会调用拷贝构造
,传引用返回,返回的是返回对象的别名
(引用),没有产生拷贝
。
- 与前面的
构造函数
和析构函数
不同,如果我们没有显式实现,编译器默认生成的拷贝构造会对内置类型进行处理,会对内置类型进行值拷贝/浅拷贝
。所谓值拷贝(也叫浅拷贝)就是一个字节一个字节进行拷贝,相当于 m e m c p y memcpy memcpy函数的功能。
4.2.1、为什么要自己实现拷贝构造
那默认生成的拷贝构造不是挺好的吗,它都给你完成拷贝了,那我们还需要自己写吗?
我们来看下面这种情况:
现在,我们实现一个栈类,栈类的成员变量都是内置类型,看看编译器生成的拷贝构造能不能完成任务
typedef int STDataType;
class Stack
{
public :
Stack(int n = 4)
{
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a)
{
perror("malloc申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
STDataType* _a;
size_t _capacity;
size_t _top;
};
int main()
{
Stack st1(10);
//两种都行
//Stack st2(st1);
Stack st2 = st1
return 0;
}
我们调试来看一下:
好像没有问题,
s
t
2
st2
st2 是完成了初始化的。
但我们继续往下运行,发现程序崩了
为什么呢?
因为编译器 仅仅完成了值拷贝/浅拷贝
对于_
t
o
p
top
top 和 _
c
a
p
a
c
i
t
y
capacity
capacity 来说他们并没有指向什么资源,只进行值拷贝/浅拷贝没有问题
但对于 _
a
a
a 来说,虽然他是内置类型,但是 它指向一块开辟的空间。
s
t
1
st1
st1 的 _
a
a
a 中存放的是指向的块空间的地址
,将
s
t
1
st1
st1 中 _
a
a
a 的值拷贝给
s
t
2
st2
st2 的 _a,此时
s
t
2
st2
st2 的 _
a
a
a 也存这那块空间的地址
。也就是说
s
t
1
st1
st1 和
s
t
2
st2
st2 的 _
a
a
a 指向同一块空间
我们本来想的是他们指向不同的空间,空间中存放的是不同的数据(虽然现在没放数据)。虽然现在和我们想的有点不一样,但指向同一块空间也不至于让程序崩溃啊
答案出现在析构函数那。
m
a
i
n
main
main 函数结束,要销毁两个对象,销毁对象前先调用自身析构函数
。
后定义的先析构;
s
t
2
st2
st2 调用自身析构
,将 _
a
a
a 指向的空间释放,后
s
t
1
st1
st1 也调用析构
,也要对 _
a
a
a 指向的空间进行释放,但此时空间已经被释放掉了,也就是说 同一块空间被释放了两次,自然程序崩溃了。
其实,不仅仅是析构两次,它的问题是很多的。比如:在函数内插入一个 1,再在函数外插入一个 2,2 会将 1 给覆盖
现在,我们自己给它加上拷贝构造函数
Stack(const Stack& st)
{
// 需要对_a指向资源创建同样⼤的资源再拷⻉值
_a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
if (nullptr == _a)
{
perror("malloc申请空间失败!!!");
return;
}
memcpy(_a, st._a, sizeof(STDataType) * st._top);
_top = st._top;
_capacity = st._capacity;
}
4.2.2、传值返回先调用拷贝构造的原因
现在,我们可以回答为什么传值传参要先调用拷贝构造了
想一想,传值传参传的是实参的拷贝,但这拷贝仅仅只是浅拷贝。像是拷贝上面的
s
t
a
c
k
stack
stack 类,要是在函数里面将 _
a
a
a 空间释放了,而你以为是传值传参,里面不影响外面,在外面再次将 _
a
a
a 指向的空间释放,程序就崩溃
了。而 C语言只有浅拷贝
,是很坑
的
所以 C++ 规定,传值传参要先调用默认构造函数,在默认构造中实现深拷贝( _
a
a
a 指向的空间也拷贝一份),这样就没这些问题啦
当然,对于自定义类型,函数传参是不建议用传值传参的。毕竟就算是正确拷贝,当拷贝的内容太大,也会占用很大空间,而且效率不高
- 自定义类型传参,尽可能用引用,如果不改变,尽可能加 c o n s t const const
4.2.3、躺赢的 MyQueue
当然,也不是所有有指向资源的类都需要自己写拷贝构造,比如 M y Q u e u e MyQueue MyQueue 类(用两个栈模拟实现队列)
// 两个Stack实现队列
class MyQueue
{
public :
private:
Stack pushst;
Stack popst;
};
int main()
{
MyQueue mq1;
MyQueue mq2 = mq1;
return 0;
}
m
q
2
mq2
mq2 是正常完成初始化的。
因为对自定义类型成员,编译器会调用它自身的拷贝构造
虽然MyQueue是躺赢,但这一切都是有
S
t
a
c
k
Stack
Stack 替他负重前行
4.2.4、传值返回与引用返回
- 传值返回会
产生一个临时对象调用拷贝构造
,传引用返回,返回的是返回对象的别名
(引用),没有产生拷贝
。
什么意思呢?我们来看看
Stack func()
{
Stack st;
return st;
}
int main()
{
Stack ret = func();
return 0;
}
像上述代码,调用
f
u
n
c
func
func 函数,使用传值返回。返回时,先调用拷贝构造
将
s
t
st
st 拷贝到一个临时对象
,后再调用拷贝构造
将临时对象中的值拷贝到 ret
中。(实际编译器会进行优化,不会真的执行两个拷贝,但从语法层面来讲是会执行两次拷贝的)
为了减少拷贝,我们会使用传引用返回,返回
s
t
st
st 的别名
Stack& func()
{
Stack st;
return st;
}
但是这是不对的,st 出函数作用域就销毁了
,此时返回的是野引用,类似于野指针的东西
使用引用返回,一定要确保返回的对象,当函数结束后还在,才能用引用返回
例如下面两种情况:
//情况一
Stack& func()
{
static Stack st;
return st;
}
//情况二
Stack& func(Stack& st)
{
st.push(1);
st.push(1);
st.push(1);
return st;
}
int main()
{
Stack st1;
Stack st2 = func(st1);
return 0;
}
4.3、总结
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外的参数
都有默认值,则此构造函数也叫做拷贝构造函数,也就是说拷贝构造是一个特殊的构造函数
。
拷贝构造的特点:
- 拷贝构造函数是构造函数的一个重载
- 拷贝构造函数的第一个参数必须是自身类类型对象的引用,使用
传值方式
编译器直接报错
,因为语法逻辑上会引发 无穷递归 。若有其他参数必须给缺省值- C++ 规定自
定义类型对象
进行拷贝行为
必须调用拷贝构造,所以这里自定义类型传值传参
和传值返回
都会调用拷贝构造完成- 若
未显式定义
拷贝构造,编译器会自动生成拷贝构造函数。自动生成的拷贝构造对内置类型
成员变量会完成值拷贝/浅拷贝(一个字节一个字节的拷贝),对自定义类型
成员变量会调用他的拷贝构造- 像
Date
这样的类成员变量全是内置类型且没有指向什么资源
,编译器自动生成的拷贝构造就可以完成需要的拷贝,所以不需要我们显式实现拷贝构造。像Stack
这样的类,虽然
也都是内置类型
,但是_a 指向了资源
,编译器自动生成的拷贝构造完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。像MyQueue
这样的类型内部主要是自定义类型
S t a c k Stack Stack 成员,编译器自动生成的拷贝构造会调用 S t a c k Stack Stack 的拷贝构造,也不需要我们显式实现 M y Q u e u e MyQueue MyQueue 的拷贝构造。- 这里有一个小技巧,如果一个类显式实现了析构函数并释放了资源,那么他就需要写拷贝构造,否则不需要
传值返回
会产生一个临时对象调用拷贝构造,传值引用返回
,返回的对象的别名(引用),没有产生拷贝。但是如果返回对象是一个当前函数局部域的局部对象
,函数结束就销毁
了,那么使用引用返回是有问题的,这时的引用相当于一个野引用,类似一个野指针一样。传引用返回可以减少拷贝,但是一定要确保返回对象,在当前函数结束后还在,才能用引用返回
5、取地址运算符重载
5.1、const 成员函数
当类对象被 c o n s t const const 修饰会发生什么呢?
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
const Date d(2024, 1, 1);
d.Print();
return 0;
}
d.Print();
这句代码,我们知道到调用成员函数要传递地址给
t
h
i
s
this
this 指针的,这句代码实际上是d.Print(&d);
但是
d
d
d 的类型是const Date
,因此 &
d
d
d 传递的类型是const Date*
而
t
h
i
s
this
this 指针 的类型是Date* const this
(这里
c
o
n
s
t
const
const 修饰的是指针本身,不是对象,可以直接忽略)
很显然,const Date*
传给Date*
发生了权限放大,编译是无法通过的
这时要把
t
h
i
s
this
this指针 的类型变为const Date*
怎么做呢?要知道 C++ 规定,我们是不能在形参的位置显式写this指针
的,这样我们就不能直接通过形参来修改
t
h
i
s
this
this 指针
为此,C++就给了一个偏方:在函数参数列表后面加
c
o
n
s
t
const
const
如:
void Print() const
{
cout << _year << "-" << _month << "-" << _day << endl;
}
那如果对象是非 const
还能不能调用
P
r
i
n
t
Print
Print 呢?
int main()
{
Date d1(2024, 7, 5);
d1.Print();
const Date d2(2024, 8, 5);
d2.Print();
return 0;
}
可以的,权限虽然不能放大,但是能缩小
所以,对于不用修改成员变量的成员函数,建议都加const,原因与引用加const类似
5.2、取地址运算符重载
取地址运算符重载分为普通取地址运算符重载和 c o n s t const const 取地址运算符重载
Date* operator&()
{
return this;
}
const Date* operator&() const
{
return this;
}
注:两个都要写,因为普通对象返回Date*
,
c
o
n
s
t
const
const 对象要返回const Date*
取地址运算符重载也是一个默认成员函数,编译器会默认生成,往往不需要我们自己显式实现
。
除非你不想让别人取到该对象地址
,那你就可以这样写:
Date* operator&()
{
return nullptr;
}
const Date* operator&() const
{
return nullptr;
}