前言
一元运算符是只接受一个操作数的运算符:取负-a、取正+a、逻辑非!a、按位取反~a、自增++a/a++、自减、解引用*p、取地址&a、成员访问p->m。重载它们,就是为自定义类型定义这些符号的行为,让Integer a; -a;这样的写法有意义。
重载一元运算符的语法比二元简单,但有几个必须拿准的细节:成员函数不带参数,非成员函数带一个参数;前缀++返回引用,后缀++返回旧值且要多一个int形参;operator-不应修改自身,应该声明为const并返回新对象。把这些签名写对,代码才能既符合直觉又能参与泛型算法。
常见误解是「重载了+就自动有-」——不会,每个运算符都要单独定义。另一个误解是「后缀++的int参数要传值」——它只是个占位符,编译器用来区分前缀和后缀,调用时不需要也不能显式传它。还有人把所有一元运算符都实现成修改自身,导致-a把a变成相反数,违反最小惊讶原则。
本文按「有哪些一元运算符、成员还是非成员、前缀与后缀、完整示例」四部分讲解,最后给出重载时的取舍原则。代码以 C++17 为准,explicit operator bool等 C++11 特性会注明。
一、一元运算符清单
| 运算符 | 表达式 | 语义 | 常见返回类型 |
|---|
一元+ | +a | 取正,通常返回副本 | T |
一元- | -a | 取负 | T |
! | !a | 逻辑非 | bool |
~ | ~a | 按位取反 | T |
* | *p | 解引用 | T& |
& | &a | 取地址 | T*(极少重载) |
前缀++ | ++a | 先自增,返回自身 | T& |
后缀++ | a++ | 返回旧值,再自增 | T |
前缀-- | --a | 先自减 | T& |
后缀-- | a-- | 返回旧值 | T |
-> | p->m | 成员访问 | 指针或代理 |
| 转换函数 | static_cast<bool>(a) | 类型转换 | bool(常加explicit) |
operator&重载后,&obj不再返回对象地址,而是调用你写的函数。这会破坏依赖「取地址」语义的泛型代码,标准库用std::addressof绕过它。除非在写特殊代理类,否则不要重载operator&。
二、成员还是非成员
一元运算符可以写成成员函数,也可以写成非成员函数:
// 适用于 C++17
struct Num {
int v;
Num operator-() const; // 成员:无参数
};
Num operator-(const Num& x); // 非成员:一个参数成员版本的左操作数隐含为*this,所以参数列表为空;非成员版本显式接收一个参数。两者不能同时定义,否则调用时产生二义性。
选择建议:
- 需要访问私有成员、或运算符与类型强相关时,用成员;
- 需要允许左操作数隐式转换时,用非成员(成员版本的左操作数必须是该类类型);
- 一元运算符的左操作数就是对象本身,隐式转换的需求较少,所以多数写成成员。
赋值类运算符(=、+=)必须是成员;->、()、[]也必须是成员。一元++/--没有这个限制,但习惯上写成成员。
三、前缀与后缀
前缀和后缀的签名不同:
// 适用于 C++17
T& operator++(); // 前缀,返回引用
T operator++(int); // 后缀,int 是占位符,返回旧值后缀的int参数没有名字,也不会被使用。编译器看到a++时,会自动传一个 0 进去,用来和前缀区分;你写a.operator++(0)也能调用后缀,但正常代码不会这么写。
返回类型的选择有明确理由:
- 前缀返回
T&,因为++a的结果就是a本身,链式写法++(++a)应该修改同一个对象; - 后缀返回
T(按值),因为要保留自增前的值,不能返回引用——局部变量在函数返回后就销毁了,返回引用会悬垂。
后缀的典型实现是先拷贝旧值,再调用前缀,最后返回旧值:
// 适用于 C++17
T T::operator++(int) {
T old(*this);
++(*this); // 复用前缀逻辑
return old;
}拷贝构造在这里不可避免:要返回自增前的状态,就必须保留一份副本。对重对象,后缀自增的成本比前缀高,这也是泛型代码里常推荐++it而不是it++的原因之一。
四、完整示例
下面的Integer重载了一元+、-、!、~和前后缀自增自减;RawPtr演示operator*与operator->。
// 适用于 C++17
#include <iostream>
#include <string>
class Integer {
public:
explicit Integer(int value = 0) : value_(value) {}
Integer operator-() const { return Integer(-value_); }
Integer operator+() const { return *this; }
bool operator!() const { return value_ == 0; }
Integer operator~() const { return Integer(~value_); }
Integer& operator++() {
++value_;
return *this;
}
Integer operator++(int) {
Integer old(*this);
++(*this);
return old;
}
Integer& operator--() {
--value_;
return *this;
}
Integer operator--(int) {
Integer old(*this);
--(*this);
return old;
}
int value() const { return value_; }
private:
int value_;
};
template <typename T>
class RawPtr {
public:
explicit RawPtr(T* p = nullptr) : p_(p) {}
~RawPtr() { delete p_; }
RawPtr(const RawPtr&) = delete;
RawPtr& operator=(const RawPtr&) = delete;
T& operator*() const { return *p_; }
T* operator->() const { return p_; }
private:
T* p_;
};
int main() {
Integer a(5);
const Integer b = -a;
std::cout << a.value() << ' ' << b.value() << '\n'; // 5 -5
std::cout << std::boolalpha << !a << ' ' << !Integer(0) << '\n'; // false true
Integer c(1);
Integer d = ++c;
std::cout << c.value() << ' ' << d.value() << '\n'; // 2 2
Integer e = c++;
std::cout << c.value() << ' ' << e.value() << '\n'; // 3 2
Integer f = +a;
std::cout << f.value() << '\n'; // 5
RawPtr<std::string> p(new std::string("hi"));
std::cout << p->size() << ' ' << *p << '\n'; // 2 hi
return 0;
}按标准语义,输出依次是5 -5、false true、2 2、3 2、5、2 hi。
~的结果依赖有符号整数的表示。C++20 起标准规定符号整数采用补码,~5等于 -6;在 C++17 及以前,表示方式由实现定义,理论上可能是反码或原码,~5的值不一定相同。示例没有打印~的结果,就是为了避开这个差异。如果业务依赖按位取反的具体数值,应改用无符号类型。
RawPtr的operator*返回T&而不是T,这样*p = "new"才能修改指向的对象;返回按值就只能读。operator->返回裸指针,编译器会对返回值继续应用->,直到找到成员。若返回的是对象而不是指针,该对象还必须自己重载operator->。
五、重载的原则
- 保持语义直觉:
-a不应修改a,++a应该修改,!a应返回布尔。让运算符的行为偏离内置类型,会让读代码的人困惑。 - 不要重载
&&、||、,:它们的内置版本有短路或顺序求值保证,重载后变成普通函数调用,这些保证消失,容易埋坑。 - 谨慎重载
operator&:它会改变取地址的含义,破坏泛型代码。 operator bool加explicit(C++11):避免对象被隐式转换成整数,参与意外的算术或重载决议。- 成对提供:有
++就给--,有+就给-,保持接口完整;但不要为了「对称」实现没有意义的运算符。
常见坑点
1. 后缀++返回引用
❌T& operator++(int) { T old(*this); ++*this; return old; }—— 返回局部变量的引用,悬垂。
✅ 返回T(按值),让调用方拿到自增前的副本。
2. 后缀忘记int参数
❌ 同时定义T& operator++()和T operator++()—— 签名相同,编译报错。
✅ 后缀写成T operator++(int),参数只用于区分。
3. 一元-修改自身
❌T& operator-() { v = -v; return *this; }——-a把a改了,不符合语义。
✅T operator-() const { return T(-v); },返回新对象。
4.operator++返回void
❌void operator++()——++(++a)无法编译,泛型代码也可能要求返回引用。
✅ 前缀返回T&。
5. 非成员版本参数个数写错
❌T operator-(const T& a, const T& b);当成一元 —— 这是二元减法。
✅ 一元非成员只接收一个参数。
6. 后缀实现里返回*this
❌T operator++(int) { ++*this; return *this; }—— 返回的是自增后的值。
✅ 先保存旧值,再自增,返回旧值。
7. 重载operator&破坏取地址
❌ 为了「拦截取地址」而重载&,导致模板和标准库拿到错误地址。
✅ 不要重载;确需真实地址时用std::addressof。
8. 隐式operator bool
❌operator bool() const;不加explicit—— 对象能隐式转成整数,参与意外运算。
✅explicit operator bool() const;(C++11),在if条件里仍可正常使用。
总结
| 运算符 | 签名(成员) | 返回 | 是否修改自身 |
|---|
-a | T operator-() const | T | 否 |
+a | T operator+() const | T | 否 |
!a | bool operator!() const | bool | 否 |
~a | T operator~() const | T | 否 |
++a | T& operator++() | T& | 是 |
a++ | T operator++(int) | T(旧值) | 是 |
*p | T& operator*() const | T& | 否 |
p->m | T* operator->() const | 指针/代理 | 否 |
一元运算符重载的关键不在语法,而在「语义是否与内置类型一致」。取负返回新值、逻辑非返回布尔、前缀返回引用、后缀返回旧值——这四条对齐了,代码就能被直觉理解;签名写对只是让编译器接受。最后记住:运算符重载是给类型加「自然的语法」,不是炫技,语义不直观时,写一个具名函数往往更好。