本章将阐述变量间的计算、转换、溢出错误等内容
3.0 聪明 の 阿拉丁 #
想象阿拉丁与灯神发生了这样的对话:
灯神:你可以许 1 个愿望。
阿拉丁:好的,我想再要三个愿望。
灯神:错误!不能添加剩余愿望数。
阿拉丁:呃……🤓☝️ 我想再许-2 个愿望。
灯神:你还可以许 4294967295 个愿望。
发生了什么?这里面涉及到的内容包括了变量的初始化、赋值、比较和溢出等等。我们接下来一个个的拆解。学完这章,你就能理解灯神出什么问题了。
3.1 算术运算符 #
就像数学中的加减乘除一样,C/C++提供了基本的算术运算符来操作变量。
| 运算符 | 名称 | 示例 | 等价于 | 说明 |
|---|---|---|---|---|
+ |
加法 | c = a + b; |
||
- |
减法 | c = a - b; |
||
* |
乘法 | c = a * b; |
||
/ |
除法 | c = a / b; |
注意整数除法的特性 | |
% |
取模 | c = a % b; |
求余数 | |
= |
赋值 | a = b; |
将右边的值赋给左边 | |
+= |
加后赋值 | a += b; |
a = a + b; |
|
-= |
减后赋值 | a -= b; |
a = a - b; |
|
*= |
乘后赋值 | a *= b; |
a = a * b; |
|
/= |
除后赋值 | a /= b; |
a = a / b; |
|
%= |
取模后赋值 | a %= b; |
a = a % b; |
|
++ |
自增 | a++; ++a; |
a = a + 1; |
前置和后置有细微差别 |
-- |
自减 | a--; --a; |
a = a - 1; |
前置和后置有细微差别 |
碎碎念之一:整数除法
如果/运算符两边的操作数都是整数,那么执行的是 整数除法,结果会 舍去小数部分,只保留整数部分。int a = 5; int b = 2; int c = a / b; // c 的值是 2,而不是 2.5 double d = a / b; // d 的值也是 2.0!因为 a/b 的整数结果2再被转换成double double e = (double)a / b; // e 的值才是 2.5。这叫强制类型转换,后面会讲灯神第一次说的“不能添加剩余愿望数”,可能就是某种检查阻止了
+=操作。
碎碎念之二:自增/自减的前置与后置
a++和++a都会让a的值增加 1,但它们的 返回值 不同。
prefix = ++a;-> 先 自增,后 返回自增后的值。
postfix = a++;-> 先 返回当前值,后 自增。int a = 5; int b = ++a; // a 先变成6,然后b被赋值为6 int c = a++; // c 被赋值为6(a的当前值),然后a变成7除了应付考试,真心不建议一边赋值一边
++或--!
3.2 关系与逻辑运算符 #
这些运算符用于比较和逻辑判断,它们的返回值是 bool 类型(C++)或 int 类型(C,0 表示假,非 0 表示真)。它们是程序做出决策的基础。
| 运算符 | 名称 | 示例 | 说明 |
|---|---|---|---|
== |
等于 | a == b |
|
!= |
不等于 | a != b |
|
> |
大于 | a > b |
|
< |
小于 | a < b |
|
>= |
大于等于 | a >= b |
|
<= |
小于等于 | a <= b |
|
! |
逻辑非(NOT) | !a |
将 a 的结果取反。具体来说,如果 a 为假则结果为真,反之亦然C++也可以使用 not |
&& |
逻辑与(AND) | a && b |
只有 a 和 b 都为真,结果才为真C++也可以使用 and |
| |
逻辑或(OR) | a | b |
a 或 b 任意一个为真,结果就为真C++也可以使用 or |
注意:短路求值
逻辑运算符&&和||有“短路”特性。这意味着:
对于
a && b,如果a为假,编译器就不会再去计算b的值,因为结果肯定是假。对于
a || b,如果a为真,编译器就不会再去计算b的值,因为结果肯定是真。
这在b是一个函数调用或有副作用(如i++)的表达式时尤为重要。
所以同上文,真的、真的别自找苦吃!
3.3 类型转换 #
当不同类型的变量一起运算时,编译器需要将它们转换为相同的类型。转换分为 隐式转换 和 显式转换。
3.3.1 隐式转换(自动转换) #
编译器自动进行的转换,遵循一些规则(如“向上转换”,也就是小的、低精度的向大的、高精度的提升):
int i = 42;
double d = i; // 隐式转换:int -> double,安全
float f = 3.14f;
d = f; // 隐式转换:float -> double,安全
int j = d; // 隐式转换:double -> int,警告!可能丢失精度(小数部分被截断)
unsigned int u = -1; // 隐式转换:int -> unsigned int,巨大溢出风险!后面会讲。
3.3.2 显式转换(强制转换) #
程序员主动要求进行的转换,告诉编译器:“我知道我在做什么”。
C 风格强制转换(更简单):
double pi = 3.14159;
int approx_pi = (int)pi; // approx_pi 的值是 3
C++风格强制转换(更安全):
double pi = 3.14159;
int approx_pi = static_cast<int>(pi); // approx_pi 的值是 3
// 还有其他几种cast,如const_cast, dynamic_cast, reinterpret_cast,用于特定场景
注意: 强制转换绕过了编译器的类型检查,使用时要非常小心,确保转换是合理且安全的。
3.4 溢出:上溢(Overflow)与下溢(Underflow) #
3.4.1 上溢 #
变量的值超出了其数据类型所能表示的范围,就会发生 溢出,没有特殊说明这一般特指上溢。
-
整数溢出:
unsigned int wishes = 4294967295; // unsigned int 最大值 wishes++; // 发生溢出,wishes 变成 0 wishes--; // 从0减1,发生下溢,wishes 变回 4294967295对于有符号整数,溢出是 未定义行为,结果完全不可预测。虽然在某些编译器和平台上可能观察到像是截断(数值上等于这个值除以变量上限值的余数,这是最简单方便的实现)的行为,但 绝对不要依赖这种行为。
-
浮点数溢出:会得到一个表示无穷大的特殊值(如
inf)。当计算结果无限接近零但类型精度无法表示时,就会发生 Underflow(下溢)。
灯神谜题完全揭秘:
愿望数
wishes很可能是一个unsigned int类型变量,初始值为 1。阿拉丁说“再要三个愿望”,可能试图执行
wishes += 3;,但被某种逻辑检查阻止(if (不能添加))。阿拉丁改口“许-2 个愿望”,系统执行
wishes = -2;。发生 隐式类型转换,
-2被转换成了unsigned int类型。-2的二进制补码表示被直接当作无符号整数解释,正好是4294967295(即\(2^{32} - 1\))。灯神读取
wishes的值,报出了这个巨大的数字。上面提到的“补码”没有理解没关系。如果有兴趣,可以在课后习题找出答案。
3.4.2 下溢 #
当浮点计算结果无限接近零但类型精度无法表示时,就会发生 Underflow(下溢)。
这是因为浮点数的表示方法可以理解为于二进制的科学计数法。也就是\(a \times 2^{b}\),而\(a\)和\(b\)是共同占有一个浮点变量的空间的。当指数\(b\)很大的时候,必然会挤占\(a\)的空间,造成\(a\)有效数字减少。
拿十进制举个例子:
假设一个十进制的 float 是这样的“坑位”:\(0. 0 \times 10^{00}\) (0 表示坑位,共有 4 个坑位)
\(0.7 \times 10^{21}\) -÷10-> \(0.7 \times 10^{20}\) -> \(0.7 \times 10^{19}\) -> … ->
\(0.7 \times 10^{10}\) -÷10-> \(0.70 \times 10^{9}\) -> \(0.70 \times 10^{8}\) -> … ->
(这里从 10 次方到九次方,指数少了一位,所以让位给有效数字部分。这样小数点就“浮动”了一位,这也是“浮点数”的由来)
\(0.70 \times 10^{1}\) -> \(0.70 \times 10^{0}\) -> \(0.07 \times 10^{0}\) ->(开始非正规化了:少了一位有效数字,会丢失精度!不过这里没体现出来。)
\(0.00 \times 10^{0}\),这就发生了下溢!
当计算结果比能表示的最小正数还要接近零时发生,结果可能被舍入为 0.0,或者成为一个 非正规化数(denormal number),但这会损失精度。
所以,当我们不断对一个浮点数进行除以一个大数的操作时,就像在这个十进制例子一样,它的指数部分会不断减小,有效数字的精度也会逐渐丧失,经历“非正规化”后,最终无法在有限的‘坑位’中表示,从而 下溢 为 0.0。下面的代码示例也演示了这一过程:
#include <iostream>
#include <iomanip>
int main() {
// 连续除以一个大数16次
float value = 1.0f;
for (int i = 0; i <= 15; ++i) {
value /= 1000.0f; // 每次除以10
std::cout << std::setprecision(20) << "Step " << i << ": " << value << std::endl;
}
// 最终会 underflow 到 0.0
return 0;
}
输出:
Step 0: 0.0010000000474974513054
Step 1: 9.9999999747524270788e-07
Step 2: 9.9999997171806853657e-10
Step 3: 9.9999999600419720025e-13
Step 4: 1.0000000036274937255e-15
Step 5: 1.0000000458137049657e-18
Step 6: 1.0000000692397184072e-21
Step 7: 1.0000001181490945309e-24
Step 8: 1.0000001235416983752e-27
Step 9: 1.0000000972106249168e-30
Step 10: 1.0000001155777241484e-33
Step 11: 1.0000001256222315406e-36
Step 12: 1.0000002153053332574e-39
Step 13: 1.0005271035279193886e-42
Step 14: 1.4012984643248170709e-45
Step 15: 0 --->下溢了。其实前面几轮有效的精度已经大大降低了
3.5 运算符优先级 #
当一个表达式中存在多个运算符时,优先级决定了谁先算,大部分优先级不是“反人类”的。记不住或者不确定没关系,多用括号 () 来明确顺序是明智的。
int result = a + b * c; // 先算乘法,再算加法
int clear_result = a + (b * c); // 用括号让意图更清晰
int different_result = (a + b) * c; // 不同的顺序,不同的结果
给张没啥用,笔者也从来没用过的、改自网上的表格:
再次强调!记不住优先级没关系,多用括号 () 来明确顺序是最高效、最安全的方法!
| 优先级 | 运算符 | 名称或含义 | 使用形式 | 结合方向 | 说明 |
| 1 | [] | 数组下标 | 数组名 [常量表达式] | 左到右 | |
| () | 圆括号 | (表达式)/函数名(形参表) | 左到右 | ||
| . | 成员选择(对象) | 对象.成员名 | 左到右 | ||
| -> | 成员选择(指针) | 对象指针-> 成员名 | 左到右 | ||
| 2 | - | 负号运算符 | -表达式 | 右到左 | 一元运算符 |
| ~ | 按位取反运算符 | ~表达式 | 右到左 | 一元运算符 | |
| ++ | 自增运算符 | ++变量名/变量名++ | 右到左 | 一元运算符 | |
| – | 自减运算符 | –变量名/变量名– | 右到左 | 一元运算符 | |
| * | 取值运算符 | * 指针变量 | 右到左 | 一元运算符 | |
| & | 取地址运算符 | &变量名 | 右到左 | 一元运算符 | |
| ! | 逻辑非运算符 | ! 表达式 | 右到左 | 一元运算符 | |
| (类型) | 强制类型转换 | (数据类型)表达式 | 右到左 | ||
| sizeof | 长度运算符 | sizeof(表达式) | 右到左 | ||
| 3 | / | 除 | 表达式/表达式 | 左到右 | 二元运算符 |
| * | 乘 | 表达式* 表达式 | 左到右 | 二元运算符 | |
| % | 余数(取模) | 整型表达式%整型表达式 | 左到右 | 二元运算符 | |
| 4 | + | 加 | 表达式+表达式 | 左到右 | 二元运算符 |
| - | 减 | 表达式-表达式 | 左到右 | 二元运算符 | |
| 5 | « | 左移 | 变量 « 表达式 | 左到右 | 二元运算符 |
| » | 右移 | 变量 » 表达式 | 左到右 | 二元运算符 | |
| 6 | > | 大于 | 表达式 > 表达式 | 左到右 | 二元运算符 |
| >= | 大于等于 | 表达式 >= 表达式 | 左到右 | 二元运算符 | |
| < | 小于 | 表达式 < 表达式 | 左到右 | 二元运算符 | |
| <= | 小于等于 | 表达式 <= 表达式 | 左到右 | 二元运算符 | |
| 7 | == | 等于 | 表达式== 表达式 | 左到右 | 二元运算符 |
| != | 不等于 | 表达式!= 表达式 | 二元运算符 | ||
| 8 | & | 按位与 | 表达式&表达式 | 左到右 | 二元运算符 |
| 9 | ^ | 按位异或 | 表达式^表达式 | 左到右 | 二元运算符 |
| 10 | | | 按位或 | 表达式|表达式 | 左到右 | 二元运算符 |
| 11 | && | 逻辑与 | 表达式&&表达式 | 左到右 | 二元运算符 |
| 12 | | | 逻辑或 | 表达式|表达式 | 左到右 | 二元运算符 |
| 13 | ?: | 条件运算符 | 表达式 1?表达式 2: 表达式 3 | 右到左 | 三元运算符 |
| 14 | = | 赋值运算符 | 变量 = 表达式 | 右到左 | |
| /= | 除后赋值 | 变量/= 表达式 | 右到左 | ||
| *= | 乘后赋值 | 变量* = 表达式 | 右到左 | ||
| %= | 取模后赋值 | 变量%= 表达式 | 右到左 | ||
| += | 加后赋值 | 变量+= 表达式 | 右到左 | ||
| -= | 减后赋值 | 变量-= 表达式 | 右到左 | ||
| «= | 左移后赋值 | 变量 «= 表达式 | 右到左 | ||
| »= | 右移后赋值 | 变量 »= 表达式 | 右到左 | ||
| &= | 按位与后赋值 | 变量&= 表达式 | 右到左 | ||
| ^= | 按位异或后赋值 | 变量^= 表达式 | 右到左 | ||
| |= | 按位或后赋值 | 变量|= 表达式 | 右到左 | ||
| 15 | , | 逗号运算符 | 表达式, 表达式,… | 左到右 |
说明:1. 同一优先级的运算符,运算次序由结合方向所决定;2. x 元的意思是,这个运算符操作 x 个变量。比如 + 就是二元的
简单记就是:! > 算术运算符 > 关系运算符
3.6 变量作用域 #
变量的作用域决定了变量在程序中的可见性(看不看得到)和生命周期(什么时候销毁)。
作用域分为局部变量,全局变量,块作用域,类作用域…… (⊙_☉) 好多好杂!但是记住一点就好:一层花括号就是一层作用域,里面可以访问外面,但是外面一般不能访问里面,平行的花括号之间一般不互通。此外,里层的同名变量会掩盖外层,使用 :: 前缀访问全局变量
#include <iostream>
using namespace std;
// 全局变量 - 在整个程序中可见
int global_var = 100;
void function_scope() {
// 函数作用域 - 在整个函数内可见
int function_var = 200;
cout << "=== in function_scope() ===" << endl;
cout << "global_var accessible: " << global_var << endl;
cout << "function_var accessible: " << function_var << endl;
{
// 块作用域1 - 只在当前花括号内可见
int block_var_1 = 300;
cout << "\n--- in block scope1 ---" << endl;
cout << "global_var accessible: " << global_var << endl;
cout << "function_var accessible: " << function_var << endl;
cout << "block_var_1 accessible: " << block_var_1 << endl;
}
{
// 块作用域2 - 平行的块作用域
int block_var_2 = 500;
cout << "\n--- in block scope1 ---" << endl;
cout << "global_var accessible: " << global_var << endl;
cout << "function_var accessible: " << function_var << endl;
cout << "block_var_2 accessible: " << block_var_2 << endl;
// 不能访问其他平行块的变量
// cout << block_var_1 << endl; // 错误!
}
// 这里block_var_2已经销毁,无法访问
// cout << block_var_2 << endl; // 错误!
cout << "\n=== back in scope of function_scope() ===" << endl;
cout << "block_var_1 and ~2 destroyed, inaccessible! " << endl;
}
void another_function() {
cout << "\n=== in another_function() ===" << endl;
cout << "global_var accessible: " << global_var << endl;
cout << "function_var of function_scope() inaccessible! ";
}
int main() {
cout << "=== main() ===" << endl;
cout << "global_var accessible: " << global_var << endl;
function_scope();
another_function();
// 同名变量测试 - 局部变量会隐藏全局变量
int global_var = 999; // 局部变量,隐藏了全局的global_var
cout << "\n=== variables with the same name ===" << endl;
cout << "local 'global_var': " << global_var << endl;
// 使用::访问全局
cout << "actual global_var: " << ::global_var << endl;
// 循环中的作用域
cout << "\n=== 循环作用域测试 ===" << endl;
for (int i = 0; i < 3; i++) {
int loop_var = i * 10;
cout << "in-loop, i: " << i << ", loop_var: " << loop_var << endl;
if (i == 1) {
int if_var = 777;
cout << " in if - if_var: " << if_var << endl;
}
// cout << if_var << endl; // 错误!if_var只在if块内可见
}
// cout << i << endl; // 错误!i只在for循环内可见
return 0;
}
本章介绍了对变量进行各种运算的运算符,讲解了类型转换的规则与风险,并揭示了数值溢出的原理。理解这些概念是写出正确、健壮程序的基础。现在,你明白灯神的 bug 出在哪了吗?那就检验下你的学习成果吧:
-
写出以下表达式的值,并说明为什么:
int a = 5, b = 2; double c = 2.0; a / b * c; (double)a / b * c; -
模仿 3.4.2 的例子,以十进制的 float 写出 3.4.1 中上溢的流程, 从\( 07 \times 10^{21} \)开始。用
inf表示\(+\infty\)(下面是你画图的地方): -
写出
x与y最终的取值:int x = 0; bool y = ((x++ == 3) and (x++ == 4)); -
虽然不想恶心各位读者,但是这似乎是 C 语言课程的传统异能。写出以下表达式的值:
int x = 1; x = x++ + ++x;再次提醒,在 C/C++中,同一个表达式中对同一个变量的多个修改是未定义行为(undefined behavior)。只不过大部分编译器觉得那样实现很方便而已。绝不应该 依赖未定义行为的运算结果!
-
写出程序的输出:
#include <iostream> using namespace std; int x = 10; void test() { int x = 20; { int x = 30; cout << x << " "; cout << ::x << " "; } cout << x << " "; } int main() { test(); cout << x << endl; return 0; } -
小 挑战: 原码、反码和补码:
-
原码:就是肉眼看到的数字,比如(右下角的数字表示进制)\((110)*{2}\)、\((-1919810)*{10}\) 等等。正数的二进制编码就是原码本身
-
反码:这个用二进制好理解些,就是每一位取反。
比如\((33550336)*{10}=(1111111111111000000000000)*{2}\),反码就是\((0000000000000111111111111)*{2}=(8191)*{10}\)。
具体到特定变量,如一个char变量(1 字节,也就是 8 位),坑位是bbbbbbbb,取反码就是 8 位都取反。 -
补码:等于
反码+1,至于为什么叫做补码,用十进制好理解些:假设一个十进制整数,坑位是
dddddddddd,那么 1 的补码就是\(10000000000 - 1 = 9999999999\)。
理解了吗,补码就是“互补”的那个数字,“互补”的两个数相加刚好超过最大上限\(9999999999\),达到了\(10000000000\)。
有符号整数如何表示符号?以 8 位
char为例,坑位是bbbbbbbb,找不到负号对不对?那么如果把最高位(第一位)当成符号位 0 正 1 负,剩下的仍然是数字部分,能表示的范围是多少?这样有什么问题?使用补码呢? -