1. 从算术运算开始:C++ 那套"看着简单"的加减乘除
说实话,第二天的内容往往比第一天更容易让人栽跟头。第一天大家还在兴奋地折腾变量声明、cout 输出,觉得"C++ 也就这样嘛"。到了基本运算这一章,表面上全是小学一年级就会的加减乘除,可真到自己写代码的时候,各种莫名其妙的输出结果、莫名其妙的编译警告,立刻让人怀疑人生。
我把这天的学习笔记重新整理了一份,从算术运算入手,逐个把运算符的脾气摸清楚。别嫌啰嗦,这些看起来人畜无害的符号,恰恰是后面写算法、写工程代码时最容易埋雷的地方。
算术运算符一共就六个:加(+)、减(-)、乘(*)、除(/)、取模(%)、自增自减(++ / --)。其中前四个在所有编程语言里都差不多,真正需要花心思的是除法和取模,还有自增自减的前置后缀区别。
先看一个最经典的坑——整数除法。C++ 里,两个整数相除,结果直接丢掉小数部分,而不是四舍五入。比如 7 / 2 的结果是 3,不是 3.5。这可能和很多人的直觉不符,但语言就是这么规定的:整数除以整数,结果永远是一个整数。如果你想要 3.5,就必须让至少一边变成浮点数,比如 7.0 / 2,或者 7 / 2.0。
注意:C++ 的整数除法并非严格的"向下取整",而是"向零取整"。这一点在涉及负数时特别容易出问题,我后面专门讲。
取模运算符 % 是取余数,也就是整数除法除完之后剩下的那点"零头"。7 % 3 的结果是 1,因为 7 除以 3 得 2 余 1。这个操作在判断奇偶、循环数组下标、控制输出换行等场景里极其常用。比如判断一个数是不是偶数,直接写num % 2 == 0就行,这比任何其他办法都干净利落。
但取模有个非常容易忽略的限制:在 C++ 标准里,% 的两个操作数必须是整数类型。你不能写7.5 % 2——浮点数做取模是不合法的,编译器直接报错。很多人第一次在这个地方被卡住,还以为是写法问题,其实是类型不对。
1.1 整数除法遇上负数:向零取整的"反直觉"
我之所以单独把负数除法拎出来说,是因为它藏得特别深。很多人做了一辈子开发,可能都没认真想过 -7 / 2 到底等于多少。我当年也以为答案是 -3.5,既然是整数除法,取整后应该是 -3 或者 -4,具体是哪个其实取决于语言。
C++ 的规则是:商向零取整。-7 / 2 的结果是 -3,因为 -3.5 向零取整就是 -3。同理,7 / -2 还是 -3。那么余数呢?C++ 规定:余数的符号与被除数保持一致(或者说,结果满足(a / b) * b + a % b == a这个恒等式)。于是 -7 % 2 的结果是 -1,7 % -2 的结果是 1。
这里我建议你拿笔算一下验证,因为负数余数的规则在不同语言里并不完全一样,C 系语言基本都照这个规矩来。实际项目里如果遇到负数取模的场景,最好提前想清楚是"向零取余"还是"往负无穷取余",否则边界条件很容易算错。
1.2 自增自减:前置和后缀不是一回事
自增自减是 C++ 里最有特点的一对运算符。++i是先加再用,i++是先用再加。单独写一行时两者没区别,一旦塞进表达式里,差别立刻显现。
int a = 5; int b = a++; // b 得到 5,之后 a 变成 6 int c = ++a; // a 先变成 7,然后 c 得到 7很多人背过这个规则,但写代码时还是会搞混。我的建议是:如果不是必须,不要把自增自减写在复杂表达式里。像arr[i++] = x;这种写法虽然简洁,但读代码的人很容易在脑子里多转三圈。它们的存在是为了在循环里写i++方便,不是为了让你玩一元运算符大杂烩。
另外一个隐藏细节是:自增自减本质上是一个有副作用的操作,它在修改变量本身的值。如果你对同一个变量在一个表达式里既自增又取值,比如int x = i++ + ++i;,这是未定义行为(Undefined Behavior)。不同的编译器、不同的优化级别,算出来的结果都可能不一样。这种代码在任何正经项目里都不该出现。
2. 赋值运算与复合赋值:那些"顺手"写的坑
从基本运算的角度看,赋值运算符似乎没什么好讲的,一个等号而已。但往深了看,赋值这一块牵扯到 C++ 非常多核心机制:左值右值、类型转换、连续赋值、复合赋值运算符的效率等。不少"看着对"的代码,其实在赋值环节就歪了。
首先是复合赋值运算符,就是把运算和赋值合在一起:+=、-=、*=、/=、%=,以及后面会讲的位运算复合版本&=、|=、^=、<<=、>>=。它们的含义很直观:a += 3等价于a = a + 3。
不过如果只是把它理解为简写,那就低估了它的价值。复合赋值在 C++ 里还有一个隐性的优势:它只对左值做一次求值。拿数组下标来说,arr[get_index()] += 5里的get_index()只调用一次;而如果写成arr[get_index()] = arr[get_index()] + 5,函数会被调用两次。如果这个函数有副作用或者代价高昂,那差距就大了。所以我在项目里一直要求同事能用复合赋值就不要展开写,不是因为少打几个字,而是因为语义更精确。
2.1 连续赋值与括号的阅读习惯
C++ 支持a = b = c = 10;这种连续赋值,含义是从右往左依次赋值。语法上合法,也符合结合性规则。但实际工程里,我强烈不建议写成这样。
原因不在于正确性,而在于可读性。三个人同时看一行a = b = c = 10;,每个人脑子里解析的顺序可能都不同。你当然可以说"从右往左",但代码审查时这种行总是会引起没必要的讨论。更稳妥的写法是分成三行,或者明确加括号a = (b = (c = 10));——不过说实话,加了括号的连续赋值依然不让人省心。
如果遇到不同类型变量连续赋值,问题更大。int a; double b; a = b = 3.7;,b 先变成 3.7,然后 a 赋值时被截断成 3。如果不留意,很容易把"每句都赋对了值"误写成"全都赋同一个值",类型转换的损耗和精度丢失就这样无声无息地发生了。
2.2 赋值的类型隐式转换:编译通过不等于没毛病
赋值时如果右边类型和左边类型不一致,C++ 会进行隐式转换。这个"隐式"二字最坑人,因为编译器往往连警告都不给。
最典型的是 double 到 int 的窄化转换:int n = 3.99;,n 的结果是 3,小数部分直接丢弃。如果你以为 n 会四舍五入成 4,那就错了。C++ 在这条规则上和很多人的预期不一致,它不进行四舍五入,就是直接截断。
还有 char 到 int、int 到 unsigned 这类转换,更容易引发隐蔽问题。比如 `unsigned int u = 10;