跳过正文

丙加·第3章·And let's begin the CALCULATION(变量运算)

·1518 字·8 分钟·
目录

本章将阐述变量间的计算、转换、溢出错误等内容

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 只有 ab 都为真,结果才为真
C++也可以使用 and
| 逻辑或(OR) a | b ab 任意一个为真,结果就为真
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(下溢)。

灯神谜题完全揭秘

  1. 愿望数 wishes 很可能是一个 unsigned int 类型变量,初始值为 1。

  2. 阿拉丁说“再要三个愿望”,可能试图执行 wishes += 3;,但被某种逻辑检查阻止(if (不能添加))。

  3. 阿拉丁改口“许-2 个愿望”,系统执行 wishes = -2;

  4. 发生 隐式类型转换-2 被转换成了 unsigned int 类型。-2 的二进制补码表示被直接当作无符号整数解释,正好是 4294967295(即\(2^{32} - 1\))。

  5. 灯神读取 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 出在哪了吗?那就检验下你的学习成果吧:

  1. 写出以下表达式的值,并说明为什么:

    int a = 5, b = 2;
    double c = 2.0;
    a / b * c;
    (double)a / b * c;
    
  2. 模仿 3.4.2 的例子,以十进制的 float 写出 3.4.1 中上溢的流程, 从\( 07 \times 10^{21} \)开始。用 inf 表示\(+\infty\)(下面是你画图的地方):

  3. 写出 xy 最终的取值:

    int x = 0; 
    bool y = ((x++ == 3) and (x++ == 4));
    
  4. 虽然不想恶心各位读者,但是这似乎是 C 语言课程的传统异能。写出以下表达式的值:

    int x = 1;
    x = x++ + ++x;
    

    再次提醒,在 C/C++中,同一个表达式中对同一个变量的多个修改是未定义行为(undefined behavior)。只不过大部分编译器觉得那样实现很方便而已。绝不应该 依赖未定义行为的运算结果!

  5. 写出程序的输出:

    #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;
    }
    
  6. 挑战: 原码、反码和补码:

    • 原码:就是肉眼看到的数字,比如(右下角的数字表示进制)\((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 负,剩下的仍然是数字部分,能表示的范围是多少?这样有什么问题?使用补码呢?

命令提示符@CommandPrompt-Wang
作者
命令提示符@CommandPrompt-Wang