资讯动态

C++学习之算术运算符使用详解

发布时间:2026/10/20 13:42:57 来源:尧图企业网站定制
前言算术运算符是学习 C 时最早接触、也最容易被轻视的一组符号 - * / %加上自增自减和复合赋值看起来和数学课本没有区别。但 C 的算术运算有三处与数学直觉明显不同整数除法会截断、取模的符号跟随被除数、有符号溢出是未定义行为。再加上整型提升与隐式转换一行看似简单的a / b可能得出与预期完全相反的结果。另一个高频误解是「i比i快」。在 C 的语义层面对内置类型两者在现代编译器优化后通常没有差别对类类型i需要返回自增前的副本可能多一次构造。把这条当成普适的性能结论是不准确的真正要关心的是「是否用到自增前的值」这一语义差别。本文按「基本运算 → 自增与复合赋值 → 类型转换 → 优先级与求值顺序 → 浮点与溢出」展开所有涉及未定义行为的地方都会明确标注。文中的结论以 C17 为基准涉及旧标准差异的地方会单独说明。一、五个基本算术运算符1.1 加法、减法、乘法、-、*对整型和浮点型的语义与数学一致但整型运算受宽度限制。两个int相加结果类型是int若真实结果超出int范围就是未定义行为undefined behavior, UB。标准不会要求「回绕成负数」也不会保证得到某个特定值。这一点与无符号类型相反无符号整数的运算按模 2^N 回绕是良定义的。// 适用于 C17#include limitsint overflow_example() {int a std::numeric_limitsint::max();// return a 1; // UB有符号溢出return 0;}unsigned wrap_example() {unsigned u 0;return u - 1; // 良定义结果为 UINT_MAX}要做「加法前检查溢出」可以在运算前比较if (a INT_MAX - b)则说明a b会溢出要求b 0。标准库包括 C20 的numeric没有内置的 checked add仍需手写或使用编译器内建函数如 GCC 的__builtin_add_overflow属于扩展。一元和-用于取正负。对无符号类型一元-的结果是模运算意义下的补数例如-1u等于UINT_MAX。这个结果常被用来表示「全 1 位掩码」但可读性差不如直接写~0u或明确的常量。1.2 除法整数除法向零截断truncation toward zero这是 C11 起明确规定的C11 之前舍入方向由实现定义。7 / 2得 3-7 / 2得 -3不是 -4。整数除法的除数为 0 是 UB程序可能崩溃也可能被优化器假设「不会发生」而删掉后续检查。因此除零检查必须放在除法之前且要防止编译器把检查优化掉通常靠运行时分支即可。INT_MIN / -1同样是 UB数学结果是INT_MAX 1超出int范围。这个边界在写「取绝对值」或「归一化」代码时很容易忽略。浮点除法的规则不同若实现支持 IEC 60559常见平台都支持除以 0.0 会得到无穷大或 NaN而不是 UB但标准并不强制所有实现都如此跨平台代码不应依赖这一行为。1.3 取模%只能用于整型以及 C11 起可用于重载了operator%的类类型。结果符号跟随被除数// 适用于 C17int a 7 % 3; // 1int b -7 % 3; // -1int c 7 % -3; // 1int d -7 % -3; // -1这组结果由标准保证(a / b) * b a % b a且a % b的符号与a相同当结果非零时。很多人误以为-7 % 3等于 2那是数学上的「最小非负剩余」不是 C 的规则。若需要非负余数写成((a % b) b) % b并注意b为负时的行为。%也不能用于浮点。需要浮点余数时用std::fmodcmath它的符号同样跟随被除数。二、自增、自减与复合赋值i先自增再返回新值i返回旧值再自增。对内置类型二者的区别只在表达式结果副作用发生的时点都在表达式求值内。真正的风险来自「在同一表达式中多次修改同一对象」// 适用于 C17int i 0;// int x i i; // UB两个未排序的修改// i i 1; // UB对 i 的修改与读取未排序C17 规定了赋值运算符右侧先于左侧求值也规定了移位运算符的左操作数先于右操作数但没有让i i变成合法。只要同一个标量对象在两个未排序的副作用中被修改就是 UB。正确写法是拆成多条语句。复合赋值、-、*、/、%以及位运算的复合形式语义是a a op b但左操作数只求值一次。这对有副作用的左值很重要v[f()] 1中f()只调用一次而v[f()] v[f()] 1会调用两次。复合赋值还会做隐式转换int x 1; x 2.5;等价于x x 2.5再转回int结果是 3截断。这类隐式收窄不会报错容易积累误差。自增自减对指针也适用p让指针前进一个元素按sizeof(*p)字节不是前进一个字节。对void*做算术是 GCC 扩展按 1 字节处理标准 C 不允许。三、类型转换与整型提升两个不同类型的操作数参与运算时会先做「通常算术转换」usual arithmetic conversions。大致流程是对小于int的整型char、short、bool等做整型提升integral promotion通常提升为int若一方为long double另一方转为long double否则double、float依次类推双方都是整型时按等级和符号性确定公共类型若等级相同而有符号性不同通常转为无符号类型。第 3 步是无数 bug 的来源// 适用于 C17#include iostreamint main() {unsigned int u 1;int i -1;if (i u) {std::cout less\n; // 不会执行} else {std::cout not less\n; // 实际走这里}return 0;}i被转换为unsigned int-1变成UINT_MAX所以i u为假。编译器通常会对这类比较给出-Wsign-compare警告应当开启并认真处理。整型提升还有一个容易忽略的效果unsigned char a 200, b 100;时a b的结果类型是int400不是unsigned char。若把结果赋回unsigned char会发生模 256 的转换得到 144。这个「先提升后截断」的过程是很多字节处理 bug 的根源。浮点与整型混合时整型转为浮点。int到float在数值较大时会丢失精度float只有约 7 位十进制有效数字int到double在常见平台上可以精确表示全部 32 位整数。long long到double在超过 2^53 后同样会丢精度。四、优先级、结合性与求值顺序优先级决定「先算哪个运算符」结合性决定「同级运算符从左还是从右算」求值顺序决定「操作数何时求值」。三者是不同维度不能混为一谈。优先级高到低运算符结合性1::从左到右2()[].-后缀--后缀从左到右3!~一元-前缀--前缀*sizeof从右到左4.*-*从左到右5*/%从左到右6-从左到右7从左到右8C20从左到右9从左到右10!从左到右11从左到右12^从左到右13\14从左到右15\\16?:从右到左17及复合赋值从右到左18,从左到右记忆负担很重工程上的做法是用括号表达意图而不是背表。a b * c没问题但a b c就容易误读优先级高于实际是a (b c)。求值顺序方面C17 之前大部分运算符的操作数求值顺序是未指定的C17 起明确了若干规则赋值右侧先于左侧移位左操作数先于右操作数下标表达式中的下标先于被下标对象等等。但f() g()中两个函数谁先调用仍然不定。若两个函数有副作用且互相依赖程序行为就不可移植。C17 起这类情况是「不确定顺序」indeterminately sequenced不会交错执行但先后不定。五、浮点运算与溢出检查浮点运算遵循 IEEE 754 的常见实现但标准并不强制。几个实践要点浮点相等比较不可靠0.1 0.2 0.3通常为假应比较差值是否小于容差float的有效数字约 6 到 7 位十进制累加大量小数会积累误差必要时用double或 Kahan 求和浮点到整型的转换在超出目标范围时是 UBC 标准的规定不要依赖「饱和」行为NaN参与任何比较都返回 false包括NaN NaN判断要用std::isnan。整数溢出检查的常用手法是「运算前判断」// 适用于 C17#include limitsbool safe_add(int a, int b, int out) {if (b 0 a std::numeric_limitsint::max() - b) return false;if (b 0 a std::numeric_limitsint::min() - b) return false;out a b;return true;}bool safe_mul(int a, int b, int out) {if (a 0 || b 0) { out 0; return true; }if (a -1 b std::numeric_limitsint::min()) return false;if (b -1 a std::numeric_limitsint::min()) return false;long long product static_castlong long(a) * b;if (product std::numeric_limitsint::max()) return false;if (product std::numeric_limitsint::min()) return false;out static_castint(product);return true;}safe_mul先把一个操作数转成long long再相乘避免int乘法本身溢出随后用long long的范围判断是否超出int。safe_add用减法判断避免了「先加再检查」这种已经触发 UB 的写法。注意std::numeric_limitsint::min() - b在b为负时是加法不会溢出因为min - b的结果在int范围内。六、完整示例整数统计下面把除法、取模、溢出检查和类型转换放在一个小程序里。// 适用于 C17#include cstddef#include iostream#include limits#include vectorstruct Stats {long long sum 0;int min_value std::numeric_limitsint::max();int max_value std::numeric_limitsint::min();};Stats compute(const std::vectorint data) {Stats s;for (int v : data) {s.sum v; // long long 累加避免 int 溢出if (v s.min_value) s.min_value v;if (v s.max_value) s.max_value v;}return s;}int main() {std::vectorint data {3, -7, 12, -4, 9};Stats s compute(data);const long long count static_castlong long(data.size());std::cout sum s.sum \n;if (count 0) {std::cout mean static_castdouble(s.sum) / static_castdouble(count) \n;std::cout sum % count s.sum % count \n;}std::cout min s.min_value max s.max_value \n;return 0;}几个细节。s.sum v中sum是long longv是int复合赋值会先把v提升为long long不会溢出。s.sum % count中两个操作数都是long long结果的符号跟随s.sum若sum为负余数也为负。求平均值时先转double再除避免整数除法截断。data.size()的类型是std::size_t无符号先转long long再参与有符号运算避免无符号与有符号混合。常见坑点❌ 认为整数除法会四舍五入。✅ 向零截断7 / 2 3-7 / 2 -3需要四舍五入时显式处理。❌ 认为-7 % 3 2。✅ C 中结果为 -1符号跟随被除数需要非负余数时手动调整。❌ 用a b之后再检查是否溢出。✅ 溢出发生时已是 UB必须在运算前判断或用更宽的类型计算。❌ 把int与unsigned直接比较。✅ 有符号数会被转换为无符号负数变成极大值显式转换或统一类型。❌ 写i i或i i 1。✅ 同一表达式内对同一标量的多次修改是 UB拆成多条语句。❌ 用比较两个浮点数。✅ 比较差值绝对值是否小于容差或使用std::abs(a - b) epsilon。❌ 对INT_MIN取绝对值或除以 -1。✅ 这两种情况都会溢出属于 UB先判断边界。❌ 依赖i与i的性能差异。✅ 对内置类型语义差异只在结果值对类类型按需选择不要传播未经测量的性能结论。总结主题规则风险整数除法向零截断负数结果与数学直觉不同取模符号跟随被除数误用为「最小非负剩余」有符号溢出UB任何结果都可能发生无符号回绕模 2^N良定义与有符号混合时语义突变自增自减同一表达式多次修改同一对象是 UB未排序副作用隐式转换通常算术转换 整型提升有符号/无符号比较陷阱浮点比较不能用精度误差导致误判算术运算符的全部难点可以归结为一句话类型决定语义顺序决定结果。写每一行算式时先确认操作数的类型尤其是有符号性再确认是否有副作用交织在同一表达式中最后对边界值最小值、最大值、零做一次心算。需要精确宽度就用定宽类型需要防溢出就用更宽的类型或前置检查拿不准优先级就加括号——这些习惯比记住优先级表更有价值。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑