前言
上一讲我们已经说了关于C++的默认成员函数中的两个——构造和析构函数。所谓默认成员函数也就是:用户没有显示定义实现时,编译器会自动生成的成员函数。
6个默认成员函数如下:
- 构造函数(主要完成初始化操作)
- 析构函数(主要完成清理操作)
- 拷贝构造(使用同类对象初始化对象)
- 赋值重载(把一个对象赋值给另一个对象)
- 取地址重载
- const取地址操作符重载
下面我们来介绍剩下四个默认成员函数。
拷贝构造
1.概念
只有单个形参,该形参是对本类对象类型对象的引用
(一般用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
class Date
{
public:
Date(int year, int month, int day)//用初始化列表做构造函数
:_year(year)
,_month(month)
,_day(day)
{}
Date(const Date& d)//拷贝构造函数,不写编译器会默认生成
{
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year = 2024;
int _month = 7;
int _day = 15;
};
int main()
{
Date d1(2024, 7, 15);
Date d2(d1);
Date d3 = d1;
//拷贝构造的形式
return 0;
}
Date d2(d1);
Date d3 = d1;
是拷贝构造的两种形式,都是用同类型对象拷贝初始化
。
为了防止有人在写拷贝构造函数时会将其写成:
Date(Date& d)
{
d._year = _year;
d._month = _month;
d._day = _day;
}
所以我们在传入参数时增加const,使其不被修改,也就变成了:Date(const Date& d)
2.特征
拷贝构造函数也是特殊的成员函数,也包括一些独有的特性。
- 拷贝构造函数是构造函数的一种重载形式,所以拷贝构造函数是构造函数的一种特殊的形式,也拥有构造函数的特性。
- 拷贝构造函数的参数只有一个且必须是类类型对象的引用,
使用传值方式编译器会报错,因为它会引发无穷递归
。
下面我们来验证一下为何一定要用带引用的参数。
首先我们要明白一个点,函数调用时需要传参,内置类型直接传参,但是对于自定义类型需要调用拷贝构造才能完成。
所以当你调用拷贝构造没有用引用传参时,编译器会一直调用拷贝构造函数,形成无限递归,当然现在编译器也会通过报错来告诉你不能直接传类类型的参数,如下:
- 若未显示定义,编译器会生成默认的拷贝构造函数,
默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫浅拷贝,也叫值拷贝
。
注意:在编译器生成的默认拷贝构造函数中,
内置类型
是按照字节方式拷贝的,而自定义类型
是调用其拷贝构造函数完成的。
- 编译器生成的默认拷贝构造已经可以完成字节序的值拷贝了,那还需要显示定义吗,我们来看看Stack栈这个类:
typedef int DataType;
class Stack
{
public:
Stack(int capacity = 10)
{
_arr = (DataType*)malloc(capacity * sizeof(DataType));
if (_arr == nullptr)
{
perror("malloc申请空间失败");
return;
}
_size = 0;
_capacity = capacity;
}
void Push(const DataType& data)
{
_arr[_size++] = data;
}
~Stack()
{
if (_arr)
{
free(_arr);
_arr = nullptr;
_size = 0;
_capacity = 0;
}
}
private:
DataType* _arr;
int _size;
int _capacity;
};
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
s1.Push(3);
s1.Push(4);
Stack s2(s1);
return 0;
}
在以上代码中,我们简单写了一个栈,我们没有显示写拷贝构造函数,编译器会默认生成,但是我们发现编译器并不能通过该程序,会报一个错误:
出现这个错误的原因也比较多,但是根据我们的代码,问题应该出现在创建对象时的内存分配问题上,也就是s2这个对象和s1这个对象两个内存是同一个位置,在后面析构时会出错,下面我们来具体介绍了解一下。
我们通过调试发现,s1和s2的地址是一样的,它们指向的是同一块空间。
s1对象调用构造函数创建,且放入了4个元素1 2 3 4。
s2对象使用s1进行拷贝构造,而Stack类没有显示定义拷贝构造函数,则编译器自动生成默认的拷贝构造,是值拷贝,也就是将s1中的内容原封不动拷贝到s2中,因此s1和s2指向同一块空间。
当程序退出时,会调用析构函数对内存进行清理,即s2和s1要消耗,先构造的后析构销毁,因此s2先销毁,此时它已经将指向的内存空间释放了,但s1并不知道,s1会继续销毁,此时会将同一块空间再次释放,一块空间多次释放,就会导致程序崩溃。
因此我们对于栈这类有资源申请的类,需要自己显示定义拷贝构造函数,如下:
typedef int DataType;
class Stack
{
public:
Stack(int capacity = 10)
{
_arr = (DataType*)malloc(capacity * sizeof(DataType));
if (_arr == nullptr)
{
perror("malloc申请空间失败");
return;
}
_size = 0;
_capacity = capacity;
}
Stack(const Stack& st)//显示定义拷贝构造函数
{
_arr = (DataType*)malloc(st._capacity * sizeof(DataType));
if (_arr == nullptr)
{
perror("malloc申请空间失败");
return;
}
memcpy(_arr, st._arr, st._size * sizeof(DataType));
_size = st._size;
_capacity = st._capacity;
}
void Push(const DataType& data)
{
_arr[_size++] = data;
}
bool Empty()
{
return _size == 0;
}
DataType Top()
{
return _arr[_size - 1];
}
void Pop()
{
--_size;
}
~Stack()
{
if (_arr)
{
free(_arr);
_arr = nullptr;
_size = 0;
_capacity = 0;
}
}
private:
DataType* _arr;
int _size;
int _capacity;
};
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
s1.Push(3);
s1.Push(4);
Stack s2(s1);
s2.Push(5);
s2.Push(6);
cout << "拷贝后:" << endl;
cout << "s2:";
while (!s2.Empty())
{
cout << s2.Top() << " ";
s2.Pop();
}
cout << endl;
cout << "s1:";
while (!s1.Empty())
{
cout << s1.Top() << " ";
s1.Pop();
}
cout << endl;
return 0;
}
结果如下:
我们发现,对栈s2进行插入元素,也没有改变s1,因为此时两者的地址不同,也就互不影响,这也叫深拷贝。
注意:类中如果没有涉及资源申请时,拷贝构造函数写不写都可以,但如果涉及资源申请,就必须要写拷贝构造函数,否则就是浅拷贝,编译器会报错。
补充一小点:
为了提高程序效率,一般进行对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用用引用。
3.总结
对于拷贝构造函数的总结如下:
- 如果没有资源管理,一般情况下不需要写拷贝构造,使用默认生成的拷贝构造就可以,如:Date类。
- 如果都是自定义类型成员,内置类型没有指向资源,也用默认生成的拷贝构造就可以,例如:MyQueue。
- 一般情况下,不需要显示写析构函数,就不需要写拷贝构造。
- 如果内部有指针或者有指向资源的类型,需要显示写析构函数,通常要显示写构造完成深拷贝,如Stack,Queue,List等。
赋值重载
运算符重载
我们都知道类型分为两种,一种是内置类型,一种是自定义类型,编译器对于内置类型的各种运算是了如指掌的,但是它不知道自定义类型要如何加减乘除,此时我们就引入了运算符重载,它是一个具有特殊函数名的函数。
->函数名为:关键字operator后面接需要重载的运算符符号,如operator+。
->函数形式:返回值类型 operator操作符(参数列表)
使用运算符重载时有几个需要注意的点:
- 不能通过连接其他符号来创建新的操作符,如operator@,这是错误的。
- 重载运算符必须有一个类类型的参数,如int operator+(int i, int j);这也是错误的。
- 操作符是几目则参数个数就有几个,如+ 是双目操作符,参数个数就有两个,++是单目操作符,参数个数就是一个。
- 有五个运算符是不能进行重载的:.*(调用函数成员指针)、::(域作用限定符)、sizeof、?:(三目操作符)、.(对象.成员)这五个。
下面我们来尝试写写operator==这个函数:
class Date
{
public:
//private:
int _year = 2024;
int _month = 7;
int _day = 18;
};
bool operator==(const Date& d1, const Date& d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
void Test()
{
Date d3(2024, 3, 10);
Date d4(2024, 7, 18);
cout << (d3 == d4) << endl;
cout << operator==(d3, d4) << endl;
}
对于运算符重载函数,既可以直接写出来判断如:(d3 == d4) ,也可以直接显示调用:operator==(d1,d2)。如果直接写出来,编译器也会转换成operator==(d1,d2),从底层汇编来讲二者是一样如,如下面的反汇编码所示:
我们发现,我们将operator==重载成了全局函数,但是这样又有问题出现了,我们发现只有将私有成员函数将其变为公有,才能够使用,那这就破坏了代码的一个封装性。那么该如何解决呢,有以下几个办法可以解决:
- 提供这些成员的get和set函数
例如:
class Date
{
public:
int GetYear() const
{
return _year;
}
int GetMonth() const
{
return _month;
}
int GetDay() const
{
return _day;
}
private:
int _year = 2024;
int _month = 7;
int _day = 18;
};
bool operator==(const Date& d1, const Date& d2)
{
return d1.GetYear() == d2.GetYear()
&& d1.GetMonth() == d2.GetMonth()
&& d1.GetDay() == d2.GetDay();
}
void Test()
{
Date d3(2024, 3, 10);
Date d4(2024, 7, 18);
cout << (d3 == d4) << endl;
}
此时同样可以调用,至于函数后面加的const我们下面会再讲解。
- 写成友元函数(后面会讲到)
- 重载成成员函数
重载成成员函数时需要注意的是不能直接将其放入到成员函数中,是因为成员函数的参数有隐含的this指针,直接放入会导致参数过多而报错,因此我们要减少参数再放入,如下所示:
class Date
{
public:
bool operator==(const Date& d1)
{
return _year == d1._year
&& _month == d1._month
&& _day == d1._day;
}
private:
int _year = 2024;
int _month = 7;
int _day = 18;
};
void Test()
{
Date d3(2024, 3, 10);
Date d4(2024, 7, 18);
cout << (d3 == d4) << endl;
}
但是要注意的是,当我们将其重载成了成员函数,就不能显示调用了,因为参数过多,会报错。
赋值运算符重载
上面说的是运算符重载,现在我们来说运算符重载中的一个特例:赋值运算符重载,它是六个默认成员函数中的第四个,它是把一个已经存在的对象赋值给另一个已经存在的对象,我们来详细剖析这个函数。
- 赋值运算符的重载格式。
- 参数类型:const T&,传递引用可以提高传参效率
- 返回值类型:T& ,返回引用可以提高返回效率,有返回值的目的是为了支持连续赋值。
- 检测是否自己给自己赋值
- 返回*this,要符合连续赋值的含义。
当我们写一个Date类,显示定义赋值运算符重载:
class Date
{
public:
Date(int year, int month, int day)//用初始化列表做构造函数
:_year(year)
, _month(month)
, _day(day)
{
cout << "Date(int year, int month, int day)" << endl;
}
Date(const Date& d)//拷贝构造函数,不写编译器会默认生成
{
_year = d._year;
_month = d._month;
_day = d._day;
cout << "Date(const Date& d)" << endl;
}
Date& operator=(const Date& d)//显示定义赋值运算符重载
{
if (this != &d)//判断this指向的地址和d的地址是否相同,相同则表明是自己给自己赋值
//加了判断条件就能防止自己给自己赋值,避免更多的损耗。
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
private:
int _year = 2024;
int _month = 7;
int _day = 15;
};
int main()
{
Date d1(2024, 7, 15);
Date d2(2024, 7, 18);
Date d3(2024, 7, 20);
d1 = d2 = d3;//一个已经存在的对象赋值给另一个已经存在的对象
//此时我们将d3的日期值先赋值给了d2,然后又赋值给了d1.
return 0;
}
如果是下面这段代码:
Date d4 = d1;
则这个是拷贝构造,因为这是一个已经存在的对象,拷贝给另一个要创建初始化的对象。
要注意区分二者。
当我们定义的赋值重载函数没有返回值类型时则不能支持连续赋值,如下所示:
void operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
}
探讨传引用返回和传值返回的区别
我们再来考虑第二点,传引用返回和不传引用返回的区别是什么,什么时候可以用传引用返回,什么时候不可以。
当我们使用引用返回时:
可以看到,编译器只对三个对象进行了构造,而没有调用拷贝构造。
但是当使用传值返回时:
编译器不仅对三个变量进行了构造,还在赋值时进行了两次拷贝构造,由此我们可以看出,使用引用返回可以减少拷贝构造次数,减少消耗。
那我们什么时候能用引用返回呢,我们来举个例子就知道了:
Date func()
{
Date d(2024, 7, 20);
return d;
}
int main()
{
const Date& ref = func();
ref.Print();
return 0;
}
我们定义了一个func函数,传值返回,并将结果打印出来,如下:
由结果可以看出,它先通过构造函数构造了对象d,接收值打印后进行了析构,析构的是对象d。
但实际上,编译器在进行传值返回时,会将d进行一次拷贝构造出来一个临时对象,这个临时对象具有常性,ref引用的就是这个临时对象,所以需要加const。
但是由于编译器的优化,将构造和拷贝构造直接优化成了只剩构造,所以我们没能看出来有拷贝构造的存在,如果我们使用的编译器是vs2019,则可以看得出来。
但是如果我们使用引用返回:
我们发现,得出的结果并不是我们想要的结果,是因为返回对象是一个局部的临时对象,ref和d的地址是一样的,出了func的作用域,d就析构销毁了,所以当我们打印ref时得到的值会是随机值。
而在原来的代码中,我们返回的是this,用引用返回,是因为this在main作用域中就创建出来了,出了main才会被销毁,而出operator=这个函数是不会被销毁的,因此可以用传引用返回。
总结来说:
返回对象是一个局部对象或临时对象时,出了函数作用域会被析构销毁的话,则不能用引用返回。引用返回可以减少拷贝,但是引用返回存在风险,要看出了函数作用且对象还在,则可以使用引用返回。
- 赋值运算符不能重载成全局函数。
当我们想把赋值运算符重载成全局函数时,全局函数不存在this指针,则需要用两个参数传参,如下所示:
class Date
{
public:
Date(int year, int month, int day)//用初始化列表做构造函数
:_year(year)
, _month(month)
, _day(day)
{}
//private:
int _year = 2024;
int _month = 7;
int _day = 15;
};
Date& operator=(Date& left, Date& d)
{
if (&left != &d)
{
left._year = d._year;
left._month = d._month;
left._day = d._day;
}
return left;
}
当我们将其重载成全局函数时,需要考虑私有成员变量的时候,这里为了方便演示,将其变成了公有。此时编译器会报错:
error C2801: “operator =”必须是非静态成员。
原因也很简单:赋值运算符如果不显示实现,编译器会生成一个默认的,此时用户在类外自己实现的这个全局的赋值运算符重载就会和编译器在类中生成的默认赋值运算符重载冲突,所以赋值运算符重载只能是类的成员函数。
- 用户没有显示实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字拷贝。
值得注意的是:内置类型成员变量是可以直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。这点与拷贝构造是类似的。
const成员
将const修饰的成员函数称为const成员函数,const修饰类成员函数,实际修饰的是该成员函数隐含的this指针,表明在该函数成员中不能对类的任何成员进行修改。
编译器对const成员函数的处理如下:
对于const,就会存在一个权限的问题,要如何使用才不会报错:
最后还有几个可以思考的问题:
- const对象可以调用非const成员函数吗?
->不可以,这属于权限放大 - 非const对象可以调用const成员函数吗?
->可以,这属于权限缩小 - const成员函数内可以调用其他非const成员函数吗?
->不可以,这属于权限放大 - 非const成员函数内可以调用其他const成员函数吗?
->可以,这属于权限缩小
取地址及const取地址操作符重载
这是最后两个默认成员函数,一般不用重新定义,编译器会默认生成,我们在实践中用的也比较少。
使用如下所示:
class Date
{
public:
Date(int year, int month, int day)//用初始化列表做构造函数
:_year(year)
, _month(month)
, _day(day)
{}
Date* operator&()
{
return this;
}
const Date* operator&() const
{
return this;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024, 7, 15);
const Date d2(2024, 7, 20);
cout << &d1 << endl;
cout << &d2 << endl;
return 0;
}
结果则会生成这两个对象的地址。
这两个运算符一般不需要重载,使用编译器生成的默认取地址重载即可,只有特殊情况才需要重载,如想让别人获取到指定的内容。
今天的内容到此结束啦,感谢大家观看,如果大家喜欢,希望大家一键三连支持一下,如有表述不正确,也欢迎大家批评指正。