跳到主要内容 C++ 类与对象内在机制:运算符重载、赋值重载与取址重载 | 极客日志
C++
C++ 类与对象内在机制:运算符重载、赋值重载与取址重载 C++ 中类与对象的运算符重载、赋值重载及取地址重载机制。首先阐述了运算符重载的规则,包括成员函数与全局函数的区别、参数个数及不能重载的内置类型操作符。接着详细讲解了赋值重载的实现,区分了浅拷贝与深拷贝场景,并提供了 Stack 类的深拷贝代码示例以解决内存泄漏问题。最后说明了如何区分拷贝构造与赋值重载,以及取地址重载的特殊应用场景。
DataScient 发布于 2026/3/30 更新于 2026/4/13 0 浏览
一、前置知识—运算符重载
在 C++ 中,运算符可以像函数一样进行重载。运算符的本质类似于函数,比如加操作相当于一个函数,左右操作数就是它的参数。一元操作符只有一个操作数,二元运算符有两个操作数。
在 C++ 中,运算符可以像函数一样进行重载,让同一个运算符根据操作数的不同调用不同的运算符函数,实现多态的效果。但是语法内置的那些运算符我们不能重载,比如运算符为" + ",操作数为两个整型,这样的例子就不能重载。
但是如果运算符同样是" + ",操作数却是两个类类型相加,或者是一个类类型和一个整型相加,那么就可以重载。因为类类型是程序员自己定义的,属于自定义类型,语法本身就不会规定自定义类型的相加规则。
可能一些还没有学过运算符重载的同学就会问,这个重载有什么意义呢?答案很简单,我们平常写的日期类不就可以和整型相加吗,也就是日期加上一个整数,比如 2025 年 1 月 1 日 + 5 = 2025 年 1 月 6 日。
下面列出运算符重载的一些规则:
当运算符被用于类类型的对象时,C++ 语言允许我们通过运算符重载的形式指定新的含义。C++ 规定类类型对象使用运算符时,必须转换成调用对应运算符重载,若没有对应的运算符重载,则会编译报错。
运算符重载是具有特殊名字的函数,它的名字是由 operator 和后面要定义的运算符共同构成。例如日期类和整型加号的重载为 Date operator+(Date& d1, int day)。
重载运算符函数的参数个数和该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个参数。
如果一个重载运算符函数是成员函数,则它的第一运算对象默认传给隐式的 this 指针,因此运算符重载作为成员函数时,参数比运算对象少一个。
运算符重载以后,其优先级和结合性与对应的内置类型运算符保持一致,同时不能通过连接语法中没有的符号来创建新的操作符。
有 5 个操作符不能重载,如:.*(点星)、::(域访问限定符)、sizeof 运算符、?:(唯一的三目操作符)、.(成员访问运算符)。
重载操作符至少有一个类类型参数,不能通过运算符重载改变内置类型对象的含义。
一个类需要重载哪些运算符,是看哪些运算符重载后有意义。
重载++运算符时,有前置++和后置++,后置++重载时,增加一个 int 形参,而前置++则没有参数。
重载<<和>>时,需要重载为全局函数。
首先强调的点是运算符重载怎么写。由于在第 2 点做了详细举例,所以可以光去看看第 2 点即可。其次要强调的就是,不能重载内置类型的运算符,至少要有一方是类类型,否则会报错。
#include <iostream>
using namespace std;
+( x, y){ x + y;}
{
;
}
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown 转 HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
HTML 转 Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
JSON美化和格式化 将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online
int
operator
int
int
return
int main ()
return
0
按照上面说的,这里重载了内置类型的运算符,最终应该会报错。
可以看到确实不能重载内置类型的运算符,那么正确的运算符重载该怎么写呢?还需要知道另一个规则,如果重载的运算符中有两个操作数,左边的那个参数就是左操作数,右边的那个参数就是右操作数。如果重载的运算符是成员函数,默认第一个参数就是 this 指针。
接下来我们知道了上面的知识,就可以真正来实现一个日期类的运算符重载了,这里我们选择日期类的相等运算符重载(不是赋值),我们来分析分析它的返回值和参数。
首先返回值肯定是 bool 类型的,也就是最后返回是否相等,参数就是两个日期类对象,由于第一个参数是当前对象的 this 指针,不用传参,所以只需要传另一个日期类对象过来即可。
#include <iostream>
using namespace std;
class Date {
public :
Date (int year =2025 ,int month =1 ,int day =1 ){
this ->_year = year;
this ->_month = month;
this ->_day = day;
}
bool operator ==(Date& d){
return this ->_year == d._year && this ->_month == d._month && this ->_day == d._day;
}
private :
int _year;
int _month;
int _day;
};
上面就是我们日期类的相等运算符的重载函数。接下来又有一个难点,就是我们写好了对应的运算符重载函数,该怎么使用呢?首先是第一个方法,这运算符重载不是个成员函数吗?我们就按照成员函数的调用方法对它进行调用即可。
int main () {
Date d1;
Date d2 (2025 ,1 ,9 ) ;
bool ret = d1. operator ==(d2);
if (ret) cout <<"两个日期相等" << endl;
else cout <<"两个日期不相等" << endl;
return 0 ;
}
这里我们完全使用了成员函数的调用方法来调用这个运算符重载函数。
可以看到这个方法确实可行,但是还是存在很大的问题,就是这个方法太复杂了,太麻烦了。我们本身就是重载的运算符,希望像运算符一样使用它,那么是否可以这样用呢?
int main () {
Date d1;
Date d2 (2025 ,1 ,9 ) ;
bool ret = d1 == d2;
if (ret) cout <<"两个日期相等" << endl;
else cout <<"两个日期不相等" << endl;
return 0 ;
}
在上面的代码中,我们将之前的函数调用方式,直接换成了使用相等运算符的方式。可以代码居然成功运行了,效果和我们使用成员函数调用方式一样。没错,只要是我们重载的运算符,我们都可以按照原本运算符的方式直接使用,因为在汇编层这里的 d1 == d2 会被编译器自动转换成成员函数的调用。
可以看到 d1 == d2 这条语句在汇编层变成了对应运算符重载函数的调用,我们方便了,是因为编译器悄悄帮我们做了很多事。
那么上面就是运算符重载的一些基础知识。既然我们运算符重载的基础知识讲解完了,我们接下来还是继续回到我们类和对象的重点上,继续讲解赋值重载这个默认成员函数。
二、赋值重载
赋值重载函数也是一个类的默认成员函数,因为当我们不写赋值重载时,编译器会默认生成一个赋值重载给我们使用。在这个部分我们除了要搞清楚赋值重载的作用和写法,我们还要和拷贝构造以及相等运算符重载作区分。
默认生成的赋值重载函数能干什么?
这里我们直接说结论,对于内置类型来说,编译器默认生成的赋值重载函数会帮我们完成浅拷贝方式的赋值,将目标对象的值拷贝到另一个对象中去,可以认为内部是一个字节一个字节进行拷贝的。对于自定义类型来说,会调用它自己的赋值重载,跟拷贝构造差不多。
深浅拷贝我们上一篇文章讲过,这里就不再赘述,我们主要是要知道什么情况下这个默认生成的赋值重载够用,什么时候需要自己写,还是两个技巧:
技巧一就是:看有没有写析构函数或者有没有写拷贝构造,如果写了就要写我们的赋值重载完成深拷贝,否则就不需要。
技巧二就是:看有没有内置类型的成员变量指向堆上面的空间,如果有,那么我们就需要自己写赋值重载以完成深拷贝,否则就不需要。
接下来我们就分别用 Date 类和 Stack 类来验证一下我们默认生成的赋值重载是否如我们上面所说,首先是 Date 类。
class Date {
public :
Date (int year =2025 ,int month =1 ,int day =1 ){
this ->_year = year;
this ->_month = month;
this ->_day = day;
}
private :
int _year;
int _month;
int _day;
};
int main () {
Date d1 (2025 ,1 ,5 ) ;
Date d2;
d2 = d1;
d1. Print ();
d2. Print ();
return 0 ;
}
上面我们就使用了默认的赋值重载将 d1 赋值给了 d2,应该默认会实现浅拷贝,应该没有问题。
可以看到代码没有问题,原本的 d2 使用了默认构造,是 25 年 1 月 1 日,我们将 d1 赋值给 d2 后,d1 就变成了 25 年 1 月 5 日。接下来我们来测试一下 Stack 类使用默认生成的赋值重载会发生什么。
class stack {
public :
stack (int n =10 ){
this ->_arr =(int *)malloc (n *sizeof (int ));
if (this ->_arr ==nullptr ){
perror ("malloc" );
return ;
}
this ->_top =0 ;
this ->_capacity = n;
}
~stack (){
if (this ->_arr) free (this ->_arr);
this ->_top =this ->_capacity =0 ;
}
stack (const stack& st){
this ->_arr =(int *)malloc (st._capacity *sizeof (int ));
if (this ->_arr ==nullptr ){
perror ("malloc" );
return ;
}
for (int i = 0 ; i < st._top; i++){
this ->_arr[i]= st._arr[i];
}
this ->_top = st._top;
this ->_capacity = st._capacity;
}
void push (int x) {
this ->_arr[this ->_top++]= x;
}
private :
int * _arr;
int _top;
int _capacity;
};
int main () {
stack st1;
st1. push (2 );
st1. push (3 );
st1. push (4 );
stack st2;
st2 = st1;
return 0 ;
}
按照我们的分析,上面的 Stack 类的赋值重载需要深拷贝,而编译器默认生成的赋值重载是浅拷贝,会导致同一空间被析构两次,最终导致程序运行出错。
可以看到确实如我们所料,程序崩溃了,间接证明了我们确实需要自己写一个深拷贝的赋值重载。
怎么写赋值重载函数
当我们要手动写赋值重载函数的时候一般都是深拷贝的场景。由于赋值重载也属于运算符重载函数,所以写起来的方式和我们上面讲的运算符重载函数差不多,内容也不难,就是完成深拷贝,新开一段空间,然后再进行数据的拷贝。
但是我们在写赋值重载之前,最好还是再来学习一下赋值重载的两三个独有的特点:
赋值运算符重载是一个运算符重载,规定必须重载为成员函数,同时赋值运算重载的参数建议写成 const 当前类类型引用,既可以避免权限的放大,又可以减少拷贝。
需要注意的是,stack 类有返回值,千万不要忘记了这一点,并且建议写成当前类类型的引用,引用返回可以不生成临时变量,从而提高效率,有返回值目的是为了支持连续赋值场景。
要注意判断是否是给自己赋值,如果是给自己赋值,导致的问题有两个,首先第一个问题就是给自己的_arr 重新开辟空间,此时_arr 中存放的都是随机值,第二个问题就是,之前_arr 指向的空间还没有被释放_arr 就指向另一块空间了,导致内存泄漏,所以需要特殊判断一下是不是给自己赋值。
上面就是我们在写赋值重载时需要额外注意的点,接下来我们就按照上面的特点再次完善一下我们的 stack 类,写出赋值重载。
class stack {
public :
stack (int n =10 ){
this ->_arr =(int *)malloc (n *sizeof (int ));
if (this ->_arr ==nullptr ){
perror ("malloc" );
return ;
}
this ->_top =0 ;
this ->_capacity = n;
}
~stack (){
if (this ->_arr) free (this ->_arr);
this ->_top =this ->_capacity =0 ;
}
stack (const stack& st){
this ->_arr =(int *)malloc (st._capacity *sizeof (int ));
if (this ->_arr ==nullptr ){
perror ("malloc" );
return ;
}
for (int i = 0 ; i < st._top; i++){
this ->_arr[i]= st._arr[i];
}
this ->_top = st._top;
this ->_capacity = st._capacity;
}
stack& operator =(const stack& st){
if (this !=&st){
this ->_arr =(int *)malloc (st._capacity *sizeof (int ));
for (int i = 0 ; i < st._top; i++){
this ->_arr[i]= st._arr[i];
}
this ->_top = st._top;
this ->_capacity = st._capacity;
}
return *this ;
}
void push (int x) {
this ->_arr[this ->_top++]= x;
}
private :
int * _arr;
int _top;
int _capacity;
};
int main () {
stack st1;
st1. push (2 );
st1. push (3 );
st1. push (4 );
stack st2;
st2 = st1;
return 0 ;
}
在上面我们就自己写了一个赋值重载,内部的逻辑和拷贝构造差不多,外部的语法结构和运算符重载那里讲的一样。那么我们接下来调试一下这段代码,看看我们写的赋值重载是否能解决深拷贝的问题。
可以看到我们自己写的赋值重载起到作用了,成功完成了赋值的任务。
怎么区分拷贝构造和赋值重载
拷贝构造和赋值重载有时候长得很像,我们要学会区分它们。首先我们要知道什么是拷贝构造,我们在拷贝构造部分讲过,拷贝构造是一种特殊的构造函数,而构造函数的作用就是在对象被创建时对它进行初始化,所以拷贝构造也是如此,它是在创建对象时被调用。
那么对象的赋值又是什么呢?这里的赋值是针对一个已经创建好的对象而言的,我们将源对象赋值给一个已经创建出来的目标对象,这里就会调用赋值重载。
所以综上,当我们在创建对象时,使用另一个对象对它进行初始化就是拷贝构造,当我们对已经创建好的一个对象进行赋值时,调用的就是赋值重载。
三、取地址重载
两个取地址重载函数是所有默认成员函数中最简单的,取地址运算符重载分为普通取地址运算符重载和 const 取地址运算符重载,它们分别可以给普通对象和 const 对象取地址,关键是编译器默认生成的两个取地址重载函数已经够我们平常使用了,我们基本上不需要自己写。
它们运用在一些非常非常特殊的场景,比如我们不想让别人取到当前类对象的地址,就可以自己实现取地址重载函数,返回空指针或者胡乱返回一个地址。
stack* operator &(){
return nullptr ;
}
stack* operator &()const {
return nullptr ;
}
int main () {
stack st1;
stack* ps =&st1;
return 0 ;
}
接下来我们来测试一下这段代码是否会按照我们预期进行,也就是取不到当前对象的地址,只能取到空指针。
可以看到确实如我们所料,现在其他人就不能轻易取到我们对象的地址了。这差不多就是取地址重载自己写的一种应用,但是又极为特殊,如果我们没有这种特殊需求,那么直接用编译器默认生成的取地址重载即可。