前言
+和=是 C++ 里最常用的两个运算符,也是最容易被「想当然」的两个。加法涉及整数提升、算术转换、有符号溢出和浮点精度;赋值涉及右结合、返回值、自赋值、类型转换,以及拷贝赋值与移动赋值的区别。很多看似简单的表达式,实际行为由标准里几条不起眼的规则决定。
常见的误解包括:认为short + short的结果是short(实际会提升为int);认为有符号整数溢出会「回绕成负数」(实际是未定义行为,undefined behavior, UB);认为a += b与a = a + b完全等价(前者只求值一次左操作数,且转换规则略有不同);认为赋值表达式没有值(它返回左操作数的引用,所以可以链式赋值)。
本文分「加法」「赋值」「复合赋值」「重载」四部分讲清这些规则,重点标注哪些是标准保证、哪些是 UB、哪些依赖具体实现。
一、加法运算符
1.1 操作数转换
对内置类型,+执行「通常算术转换」(usual arithmetic conversions):
- 先做整数提升:
bool、char、short等转换为int(若int能表示其全部值)。 - 若两个操作数类型不同,按等级和符号性转换成公共类型。常见规则是:有符号与无符号同等级时,转换成无符号;低等级转换成高等级。
因此short a = 1, b = 2; auto c = a + b;中c的类型是int,不是short。char + char也是int。这解释了为什么用auto接收两个short的和会得到int。
有符号与无符号混合时尤其危险:
// 适用于 C++17
#include <iostream>
int main() {
const int i = -1;
const unsigned u = 1;
if (i < u) {
std::cout << "less\n";
} else {
std::cout << "not less\n";
}
return 0;
}按标准语义,i会转换成unsigned,-1变成该类型能表示的最大值,因此条件为假,输出 "not less"。编译器通常会对这种比较给出警告,不要忽略。
1.2 溢出
无符号整数的加法按模 2^N 回绕,这是标准定义的行为,不是 UB。有符号整数的加法溢出则是 UB,编译器可以假设它不会发生,并据此做优化。因此判断溢出不能等溢出后再检查,必须在运算前检查:
// 适用于 C++17
#include <limits>
bool addWouldOverflow(int a, int b) {
if (b > 0 && a > std::numeric_limits<int>::max() - b) {
return true;
}
if (b < 0 && a < std::numeric_limits<int>::min() - b) {
return true;
}
return false;
}对浮点数,溢出得到无穷大(IEEE 754 语义),不是 UB,但业务上通常也要检查std::isfinite。浮点加法还不满足结合律,(a + b) + c与a + (b + c)可能得到不同结果,这是精度问题,不是错误。
1.3 字符串与指针
std::string重载了+,但要求至少一个操作数是std::string。"a" + "b"是两个const char*相加,是指针算术,结果无意义,甚至可能越界。正确写法是std::string("a") + "b"或std::string("a") + std::string("b")。
指针加法只允许「指针 + 整数」,且结果必须落在同一数组(含尾后位置)内;两个指针相加没有定义。指针相减只在同一数组内才有定义,结果是std::ptrdiff_t。
二、赋值运算符
2.1 右结合与返回值
赋值是右结合运算符,a = b = c解析为a = (b = c)。b = c的结果是b的引用,因此a得到c的值。内置类型的赋值返回左值引用,可以继续被赋值、取地址。
赋值还涉及「先求值哪边」的问题。C++17 起,简单赋值的右操作数先于左操作数求值,赋值副作用在两侧值计算之后。但像i = i++这样的表达式仍然不要写:自增的副作用与赋值的副作用之间的顺序并不足以让你安全依赖,实际项目中应直接避免。
2.2 拷贝赋值与移动赋值
自定义类型如果管理资源,需要定义拷贝赋值和移动赋值。拷贝赋值要处理自赋值、释放旧资源、复制新资源;移动赋值要偷取资源并把源置于可析构状态。更简单的做法是拷贝并交换:
// 适用于 C++17
#include <algorithm>
#include <cstddef>
#include <utility>
class Buffer {
public:
Buffer() = default;
Buffer(const Buffer& other)
: size_(other.size_),
data_(other.size_ == 0 ? nullptr : new char[other.size_]) {
if (data_ != nullptr) {
std::copy(other.data_, other.data_ + other.size_, data_);
}
}
Buffer(Buffer&& other) noexcept
: size_(other.size_), data_(other.data_) {
other.size_ = 0;
other.data_ = nullptr;
}
Buffer& operator=(Buffer other) noexcept {
swap(other);
return *this;
}
~Buffer() {
delete[] data_;
}
void swap(Buffer& other) noexcept {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
}
private:
char* data_ = nullptr;
std::size_t size_ = 0;
};按值接收参数,左值走拷贝、右值走移动;交换后临时对象析构,负责释放旧资源。自赋值时先复制一份再交换,也安全。
三、复合赋值
a += b大致等价于a = a + b,但有三点不同:
- 左操作数只求值一次。若左操作数是
*p++这类有副作用的表达式,两者行为不同。 - 运算结果会隐式转换回左操作数的类型,可能发生窄化。
- 对自定义类型,
+=是独立的重载,不一定由+和=组合而成。
// 适用于 C++17
int i = 10;
i += 2.7; // 先按 double 算出 12.7,再转换回 int,i 变为 12严格说,i += 2.7先按double做加法得到 12.7,再转换回int得到 12。若写成i = i + 2.7,同样先得到double再转换,结果一致;但若左操作数类型是自定义类,两条路径可能调用不同的重载。
复合赋值通常返回*this的引用,因此(a += b) += c合法。内置类型也是如此。
四、重载加法与赋值
重载+时,推荐按值接收左操作数并复用+=:
// 适用于 C++17
class Money {
public:
explicit Money(long long cents) : cents_(cents) {}
Money& operator+=(const Money& rhs) {
cents_ += rhs.cents_;
return *this;
}
friend Money operator+(Money lhs, const Money& rhs) {
lhs += rhs;
return lhs;
}
long long cents() const { return cents_; }
private:
long long cents_;
};operator=作为成员函数必须返回*this的引用,才能支持链式赋值;参数用const Money&或按值。不要把operator=声明为friend之外的自由函数——赋值运算符必须是成员函数(C++ 规定)。
重载+不改变优先级和结合性,也不改变内置类型的语义。不要重载&&、||去模拟短路,那会失去短路求值;也不要重载,,除非有非常明确的理由。
常见坑点
- ❌ 认为
short + short得到short。
// 适用于 C++17
short a = 30000, b = 30000;
short c = a + b; // 先按 int 相加,再窄化回 short,结果实现定义✅ 用int或更宽类型接收。
// 适用于 C++17
int c = a + b;- ❌ 依赖有符号溢出回绕。
// 适用于 C++17
int x = INT_MAX;
int y = x + 1; // UB,不能假设得到 INT_MIN✅ 运算前检查范围,或改用无符号类型并明确模运算语义。
// 适用于 C++17
if (x > INT_MAX - 1) { /* 处理溢出 */ }- ❌ 混合有符号与无符号比较。
// 适用于 C++17
int i = -1;
unsigned u = 1;
if (i < u) { /* 实际为假 */ }✅ 统一类型,或显式转换并确认值域。
// 适用于 C++17
if (i < static_cast<int>(u)) { /* ... */ }- ❌ 两个字符串字面量相加。
// 适用于 C++17
auto s = "hello" + "world"; // 两个 const char* 相加,不是拼接✅ 至少一个操作数是std::string。
// 适用于 C++17
std::string s = std::string("hello") + "world";- ❌
operator+返回引用。
// 适用于 C++17
Money& operator+(const Money& a, const Money& b); // 返回局部对象,悬垂✅ 按值返回。
// 适用于 C++17
Money operator+(Money lhs, const Money& rhs);- ❌
operator=不返回引用。
// 适用于 C++17
void operator=(const Money& rhs); // 无法链式赋值,也不符合惯例✅ 返回*this。
// 适用于 C++17
Money& operator=(const Money& rhs) { /* ... */ return *this; }- ❌ 自赋值时先释放再复制。
// 适用于 C++17
Buffer& operator=(const Buffer& rhs) {
delete[] data_;
data_ = new char[rhs.size_]; // rhs 就是自己时,数据已被释放
// ...
}✅ 先复制再释放,或用拷贝并交换。
// 适用于 C++17
Buffer& operator=(Buffer other) { swap(other); return *this; }- ❌ 认为
a += b与a = a + b对所有类型完全等价。
// 适用于 C++17
// 自定义类型可能只重载了 +=,没有 +;或两者语义不同✅ 以类型提供的运算符为准,不假设两者可以互相替换。
总结
| 主题 | 关键规则 |
|---|
| 整数提升 | char/short参与运算先转int |
| 混合符号 | 有符号可能转成无符号,比较结果可能反直觉 |
| 有符号溢出 | UB,必须运算前检查 |
| 无符号溢出 | 模 2^N 回绕,标准定义 |
| 赋值 | 右结合,返回左值引用 |
| 复合赋值 | 左操作数只求值一次,结果转回左类型 |
重载+ | 按值返回,复用+= |
重载= | 必须是成员,返回*this,处理自赋值 |
加法与赋值的语法都很短,但背后的类型转换和求值顺序规则很长。写表达式时尽量让操作数类型一致、让副作用单独成句,大多数坑就不会出现。