C++ 运算符重载完全指南:让你的类像内置类型一样自然
想象一下,如果你自己写的Complex类能直接用+相加,Vector能直接用[]访问元素,String能直接用==比较——运算符重载让这些成为可能。
它是 C++ 最强大的特性之一,也是最容易被滥用的特性。用好了,代码简洁优雅;用歪了,代码晦涩难懂。今天我们就系统梳理运算符重载的所有知识点。
1. 什么是运算符重载?为什么要用它?
运算符重载就是给已有的运算符赋予新的含义,让它们能操作自定义类型。
// 没有运算符重载的世界Complexc1(1,2),c2(3,4);Complex c3=c1.add(c2);// 丑陋// 有了运算符重载Complex c3=c1+c2;// 自然、直观核心原则:
- 不能创建新的运算符,只能重载已有的
- 至少有一个操作数是用户自定义类型(不能重载
int + int) - 不能改变运算符的优先级和结合性
- 不能改变操作数的个数(
+永远是二元,!永远是一元) - 某些运算符不能重载:
::、.、.*、?:、sizeof() 记法:带点的运算符不能重载,加上sizeof
2. 重载的方式:成员函数 vs 友元函数
运算符重载有两种实现方式:
2.1 作为成员函数
classComplex{doublereal,imag;public:Complex(doubler=0,doublei=0):real(r),imag(i){}// 成员函数版本:左侧操作数是 *thisComplexoperator+(constComplex&other)const{returnComplex(real+other.real,imag+other.imag);}};Complexc1(1,2),c2(3,4);Complex c3=c1+c2;// 等价于 c1.operator+(c2)特点:
- 左侧操作数必须是该类对象(
*this) - 可以访问私有成员(天然的)
- 某些运算符只能用成员函数重载:
=、[]、()、->
2.2 作为友元函数
classComplex{doublereal,imag;public:Complex(doubler=0,doublei=0):real(r),imag(i){}// 友元版本:两个操作数都是显式参数friendComplexoperator+(constComplex&a,constComplex&b);friendComplexoperator+(doublea,constComplex&b);friendComplexoperator+(constComplex&a,doubleb);};Complexoperator+(constComplex&a,constComplex&b){returnComplex(a.real+b.real,a.imag+b.imag);}Complexoperator+(doublea,constComplex&b){returnComplex(a+b.real,b.imag);}// 使用Complexc1(1,2);Complex c2=2.5+c1;// 友元版本让 double 在左边成为可能什么时候用友元?
- 需要对称性(如
double + Complex) - 左侧操作数不是本类对象(如
cout << obj)
2.3 选择指南
| 运算符 | 推荐实现 |
|---|---|
=[]()-> | 必须成员 |
+=-=*=等复合赋值 | 成员(修改 *this) |
+-*/算术 | 友元(对称性更好) |
==!=<>比较 | 友元(对称性更好) |
<<>>流 | 友元(左侧是 iostream) |
!-~一元 | 成员或友元均可 |
++--自增减 | 成员(修改 *this) |
3. 常用运算符重载详解
步骤
- 先确定这个函数的返回值是什么类型(加法运算返回值应该是一个临时的Complex对象,所以此处返回类型为Complex)
- 再写上函数名(operator + 运算符,此处就是operator+)
- 再补充参数列表(考虑这个运算符有几个操作数,此处加法运算应该有两个操作数,分别是两个Complex对象,因为加法操作不改变操作数的值,可以用const引用作为形参)
- 最后完成函数体的内容(此处直接调用Complex构造函数创建一个新的对象作为返回值)。
3.1 算术运算符(+-*/%)
推荐用友元实现,通常搭配复合赋值:
classVector2D{doublex,y;public:Vector2D(doublex=0,doubley=0):x(x),y(y){}// 复合赋值:成员函数,返回 *thisVector2D&operator+=(constVector2D&other){x+=other.x;y+=other.y;return*this;}// 算术运算:友元函数,基于 += 实现friendVector2Doperator+(constVector2D&a,constVector2D&b){Vector2D result=a;// 拷贝result+=b;// 用 += 实现returnresult;// 返回新对象}};设计模式:先用成员函数实现+=,再用友元的+调用+=。减少重复代码,保证一致性。
3.2 比较运算符(==!=<><=>=)
classString{char*data;size_t length;public:// 相等比较friendbooloperator==(constString&a,constString&b){returnstrcmp(a.data,b.data)==0;}// 用 == 实现 !=,减少重复friendbooloperator!=(constString&a,constString&b){return!(a==b);}// 小于比较(用于排序、set、map)friendbooloperator<(constString&a,constString&b){returnstrcmp(a.data,b.data)<0;}};技巧:!=用==实现,>用<实现,>=用<实现。这是标准库的惯用法。
// 标准做法booloperator!=(constT&a,constT&b){return!(a==b);}booloperator>(constT&a,constT&b){returnb<a;}booloperator<=(constT&a,constT&b){return!(b<a);}booloperator>=(constT&a,constT&b){return!(a<b);}3.3 赋值运算符(=)
classBuffer{char*data;size_t size;public:// 拷贝赋值:必须成员函数Buffer&operator=(constBuffer&other){if(this!=&other){// 防止自赋值delete[]data;// 释放旧资源size=other.size;data=newchar[size];// 分配新资源std::copy(other.data,other.data+size,data);}return*this;// 返回 *this}// 移动赋值Buffer&operator=(Buffer&&other)noexcept{if(this!=&other){delete[]data;data=other.data;size=other.size;other.data=nullptr;other.size=0;}return*this;}};要点:
- 必须是成员函数
- 检查自赋值安全
- 返回
*this引用(支持链式赋值a = b = c)
3.4 下标运算符([])
classArray{int*data;size_t size;public:// 非 const 版本:可读写int&operator[](size_t index){if(index>=size)throwstd::out_of_range("Index out of range");returndata[index];}// const 版本:只读constint&operator[](size_t index)const{if(index>=size)throwstd::out_of_range("Index out of range");returndata[index];}};Array arr;arr[0]=10;// 调用非 const 版本constArray&carr=arr;intx=carr[0];// 调用 const 版本为什么要两个版本?const 对象只能调用 const 成员函数,如果只有非 const 版本,const Array无法使用[]。
3.5 函数调用运算符(())——仿函数
// 函数对象(仿函数)classAdder{intbase;public:Adder(intb):base(b){}intoperator()(intx)const{returnbase+x;}};Adderadd5(5);std::cout<<add5(10)<<std::endl;// 15,像函数一样使用std::cout<<add5(20)<<std::endl;// 25// 标准库中的典型用法std::vector<int>vec={1,2,3,4,5};std::sort(vec.begin(),vec.end(),std::greater<int>());// greater 是仿函数3.6 自增自减运算符(++--)
这是区分前置和后置的经典问题:
classCounter{intvalue;public:Counter(intv=0):value(v){}// 前置 ++(++c):返回修改后的引用Counter&operator++(){++value;return*this;}// 后置 ++(c++):返回修改前的拷贝,参数 int 只是区分符Counteroperator++(int){Counter old=*this;// 保存旧值++value;// 修改自身returnold;// 返回旧值}};Counterc(5);Counter a=++c;// c=6, a=6(前置:先加后用)Counter b=c++;// c=7, b=6(后置:先用后加)区分方式:
- 前置:
operator++()—— 无参,返回引用 - 后置:
operator++(int)—— 有一个int哑元参数,返回值
性能提示:后置版本多了拷贝操作,能用前置就用前置。这个原则对迭代器尤其重要。
3.7 流运算符(<<>>)
classPoint{intx,y;public:Point(intx=0,inty=0):x(x),y(y){}// 输出运算符friendstd::ostream&operator<<(std::ostream&os,constPoint&p){returnos<<"("<<p.x<<", "<<p.y<<")";}// 输入运算符friendstd::istream&operator>>(std::istream&is,Point&p){charch;// 期望输入格式:(x, y)returnis>>ch>>p.x>>ch>>p.y>>ch;}};Pointp(3,4);std::cout<<"Point: "<<p<<std::endl;// Point: (3, 4)std::cin>>p;// 输入 (10, 20)必须是友元:左侧操作数是ostream&/istream&,不能作为成员函数。
返回值:必须返回ostream&/istream&,支持链式调用:cout << a << b << c。
3.8 类型转换运算符
classFraction{intnumerator,denominator;public:Fraction(intn=0,intd=1):numerator(n),denominator(d){}// 转换为 doubleoperatordouble()const{returnstatic_cast<double>(numerator)/denominator;}// 转换为 bool(判断是否有效)explicitoperatorbool()const{// C++11 explicit 防止意外转换returndenominator!=0;}};Fractionf(3,4);doubled=f;// d = 0.75(隐式转换)// bool 转换需要显式(因为有 explicit)if(f){// 条件判断中 explicit 也会被隐式调用std::cout<<"Valid fraction\n";}// bool b = f; // 错误!explicit 阻止了隐式转换重要:explicit operator bool()是 C++11 引入的安全机制。它防止了Fraction + 3这样的意外隐式转换,但在条件判断中仍然生效。这就是为什么if (cin)能工作——istream有explicit operator bool()。
4. 不能重载的运算符
| 运算符 | 原因 |
|---|---|
:: | 作用域解析,不涉及对象操作 |
. | 成员访问,语义固定 |
.* | 成员指针访问,语义固定 |
?: | 三目条件,不是真正意义上的运算符 |
sizeof | 编译时运算符,与类型有关 |
typeid | RTTI,语义固定 |
static_cast等 | 强制转换,语义固定 |
5. 运算符重载的设计原则
5.1 保持运算符的直觉语义
// 好:+ 表示"加",自然Complexoperator+(constComplex&a,constComplex&b);// 坏:把 + 重载成"连接"会让所有人大跌眼镜// Complex operator+(const Complex& a, const Complex& b) { return connect(a, b); }如果重载的语义不直观,就写一个普通的有名函数。
5.2 保持一致的行为
classNumber{public:Number&operator+=(constNumber&other);friendNumberoperator+(constNumber&a,constNumber&b){Number result=a;result+=b;returnresult;}};// 保证 a + b == c 意味着 a += b 后 a == c(至少数值上)5.3 成对重载
// 如果重载了 ==,通常也需要 !=booloperator==(constT&a,constT&b);booloperator!=(constT&a,constT&b){return!(a==b);}// 如果重载了 <,通常也需要 > <= >=booloperator<(constT&a,constT&b);booloperator>(constT&a,constT&b){returnb<a;}// 如果重载了 +,通常也需要 +=T&operator+=(T&a,constT&b);Toperator+(constT&a,constT&b){T r=a;r+=b;returnr;}5.4 参数和返回值的 const 正确性
// 不修改操作数的二元运算符 → 参数用 const &Toperator+(constT&a,constT&b);// 修改左操作数的 → 成员函数,返回非 const 引用T&operator+=(constT&b);// 比较运算符 → 参数用 const &,返回 boolbooloperator==(constT&a,constT&b);6. 面试常考清单
6.1 哪些运算符必须重载为成员函数?
答案要点:=(赋值)、[](下标)、()(函数调用)、->(成员访问)必须是成员函数。因为它们与this指针紧密绑定。
6.2 哪些运算符不能重载?
答案要点:::、.、.*、?:、sizeof、typeid、static_cast等四个强制转换运算符。
6.3 前置 ++ 和后置 ++ 的重载有什么区别?
答案要点:前置返回引用T& operator++(),后置返回旧值T operator++(int)(int 是哑元参数)。前置效率更高(后置有拷贝开销),迭代器中优先用前置。
6.4 为什么流运算符(<<、>>)必须用友元重载?
答案要点:流运算符的左侧操作数是ostream&/istream&,不能作为自定义类的成员函数(因为ostream的类定义不能修改)。必须定义为非成员函数,然后通过友元访问私有成员。
6.5 什么时候用成员函数重载,什么时候用友元?
答案要点:
- 成员:修改左操作数(
+=++=[])、必须成员的(=[]()->) - 友元:需要对称性(二元算术、比较)、左侧不是本类(
<<>>)
6.6 类型转换运算符为什么要加 explicit?
答案要点:C++11 的explicit operator bool()防止意外的隐式类型转换(如Fraction + 3被误编译)。explicit的类型转换在条件判断中仍然隐式生效,保持了if (obj)的便利。
6.7 赋值运算符为什么要检查自赋值?
答案要点:在管理资源的类中,如果没有自赋值检查,delete[]释放旧资源时可能同时销毁了新资源的数据源(因为新旧是同一个对象),导致数据丢失或崩溃。
MyClass&operator=(constMyClass&other){if(this!=&other){// 必须检查delete[]data;data=newint[other.size];// ...}return*this;}6.8 运算符重载能改变优先级或结合性吗?
答案要点:不能。运算符的优先级和结合性在编译时确定,不可改变。a + b * c永远是a + (b * c)。
7. 运算符重载一览表
| 运算符 | 推荐实现 | 返回类型 | 备注 |
|---|---|---|---|
+ - * / % | 友元 | 新对象(值) | 基于+=实现 |
+= -= *= /= %= | 成员 | T& | 返回*this |
- ! ~(一元) | 成员/友元 | T或T& | 一元运算符 |
++ --(前置) | 成员 | T& | 返回修改后的引用 |
++ --(后置) | 成员 | T | 返回旧值 |
== != < > <= >= | 友元 | bool | 成对重载 |
= | 成员 | T& | 必须成员,检查自赋值 |
[] | 成员 | T&/const T& | const 和非 const 两个版本 |
() | 成员 | 任意 | 仿函数 |
-> | 成员 | 指针或可调用对象 | 智能指针用 |
<< >> | 友元 | ostream&/istream& | 支持链式 |
operator T() | 成员 | 目标类型 | C++11 加explicit |
8. 最佳实践总结
- 保持直觉:重载后的运算符含义应该和内置类型一致
- 成对重载:
==和!=,<和>,+和+=应该一起出现 - 用复合赋值实现算术:
+基于+=实现,减少重复 - 前置优于后置:
++i比i++少一次拷贝 - 对称运算用友元:让
int + MyClass也能工作 - 类型转换加 explicit:防止意外隐式转换
- 赋值检查自赋值:管理资源的类必须检查
- 不是所有运算符都该重载:没有清晰语义的,就用普通函数
运算符重载是 C++ 给我们的语法糖,正确使用能让代码简洁优雅,滥用则会制造混乱。让运算符做它"本该做"的事,用普通函数做其他事。