文章

【信奥业余科普】C++ 的奇妙之旅 | 15:让机器不知疲倦的秘密——循环语句背后的底层逻辑

在上一篇文章中,我们了解了 if-else 判断语句。依靠底层“程序计数器(PC)”的强制跳转功能,程序能够在遇到分岔路口时做出各种方向选择。然而,如果我们要让程序计算从 1 加到 1000 的和,或者让程序连续处理百万个用户的数据,光靠一次性的判断语句显然是不够的。

这就是我们今天要探讨的话题:计算机是如何完成成千上万次重复劳动的?我们将跳出单纯的语法规则,去看看 循环结构(Loops) 产生的设计初衷,以及它在硬件底层的真实运作机制。

写在前面的话:这是一系列专为对信奥(信息学奥赛)感兴趣的中小学生及家长朋友们准备的科普文章。笔者受自身学识所限,文中若存在不严谨之处,还望各位读者指正。

本系列文章往期回顾:

第二部分 【C++的奇妙之旅】


一、 被逼出来的设计:为什么我们需要循环?

想象一下,如果你需要让计算机在屏幕上打印 10 次“你好”。在没有循环结构的时候,你唯一的办法就是把 std::cout << "你好"; 这行代码老老实实地复制粘贴 10 遍。

如果只是 10 次,这似乎还能接受。但如果是 1 万次呢?

这里涉及到一个计算机早期的核心痛点:内存空间极其昂贵。 在经典的冯·诺依曼架构下,程序代码本身也是作为数据存储在内存里的。每一行程序指令都会占据宝贵的内存空间。如果为了重复执行某个动作,就硬生生把代码复制 1 万次,那么整个计算器的内存就会被这些完全相同的指令瞬间塞满。

计算机科学家们意识到,与其无意义地复制指令,不如“重复利用”已经在内存里的指令。于是,循环结构应运而生。它的设计初衷非常务实:用最少的内存空间,让 CPU 反复倒回去执行同一段代码。

二、 循环的底层真相:向回跳转的指令

在第 14 篇文章中我们讲到,if-else 的底层是依靠 “跳转(Jump)指令” 让程序计数器(PC)略过一段代码,往内存地址的下方(即前方的备用选项)跳跃。

而“循环”在硬件底层也并没有用到什么神奇的新技术,它本质上同样是跳转,只不过是往上方(即后方)跳转

当我们写下一个循环时,CPU 的实际工作流程是这样的:

  1. 一如往常地按顺序执行循环体内部的代码。
  2. 当执行到循环体的最末尾时,遇到一个专门的向后跳转指令。
  3. 它会把程序计数器(PC)的指针强行往回拨,重新指回这个循环体第一行代码所在的内存地址。
  4. CPU 并不会觉得无聊,它会顺着地址,把刚才已经执行过的代码再一丝不苟地执行一遍。

但是,如果仅仅是无条件地往回跳转,程序就会陷入 “死循环(Infinite Loop)” :CPU 会永远在这几句代码里打转,拼命计算。这会导致 CPU 处理器的占用率飙升到极限,程序彻底失去响应,永远无法结束。

为了打破这个死循环的厄运,底层硬件必须结合上节课讲的条件检查(布尔值真假)。只有当某个提前定好的界限发生改变(比如数到了第 10 次),条件判断反馈出 false 时,程序才会忽略那个向后跳转的指令,顺理成章地脱离出来,继续往下执行别的任务。

三、 C++ 中的循环工具:从 while 到 for

在 C++ 中,为了让我们方便地指挥 CPU 进行这种“有条件限制的向后跳转”,语言的缔造者们主要提供了两种结构结构的语法工具。

1. 最纯粹的循环:while

while 这个英文单词的意思是“当……的时候”。顾名思义,它的核心逻辑是:当括号里的条件判定为真(true)时,就不断重复执行下方花括号里的代码。

如果我们要用 while 让程序安全地重复做一件事,我们写代码时需要人工维护三个关键步骤,缺一不可:

1
2
3
4
5
6
7
8
int count = 1; // 步骤一:设立一个初始的状态(比如从 1 起步记数)

while (count <= 10) { // 步骤二:每次重复执行前,检查条件(数字是不是还没超过 10?)

    std::cout << "你好!这是第 " << count << " 次打招呼。" << std::endl;

    count = count + 1; // 步骤三:更新内部状态(把记数加 1)。这一步事关生死!如果不加这句,count 永远是 1,计算机将掉进死循环。
}

2. 深思熟虑的防呆设计:for 循环

在早期的各种工业编程实践中,程序员们经常因为粗心大意引发事故:比如在写 while 循环时,要么忘了把步骤一(初始化)放在外面,要么经常把最要命的步骤三(状态更新)给漏写了,导致服务器频频陷入死循环卡死。

为了应对人类容易遗漏的天性,C++(包括它的前辈 C 语言)顺势推出了 for 循环。

在计算机科学中,for 循环其实并不是凭空捏造的底层新技术,它完全就是 while 循环的一个 “语法糖(Syntactic sugar)”。所谓的语法糖,是指底层的运行机制完全没变,只是在语法规则的表面提供了一种更紧凑、更不容易出错的外壳写法。

for 循环强制要求程序员把刚才提到的 “初始状态”、“条件检查”、“更新状态” 这三大核心步骤,在一个括号里齐齐整整地排好,用分号隔开。这样一来,漏写的概率就大大降低了:

1
2
3
4
5
6
// 格式规范:for (步骤一初始状态; 步骤二条件检查; 步骤三更新状态)
for (int count = 1; count <= 10; count = count + 1) {

    std::cout << "你好!这是第 " << count << " 次打招呼。" << std::endl;
    // 括号内部只管专注业务逻辑,不再需要手动操心 count = count + 1 这回事了。for 语句在每次大括号执行到底部时,会自动帮你执行步骤三。
}

这段 for 代码和上面的 while 代码,在最终被 C++ 翻译成机器指令交给 CPU 时,是完全一致的:都是一次普通的条件检查加上一个向后的回跳指令。具体用哪一种,仅仅取决于开发者觉得哪种对于当前场景更干净直观。

四、图灵完备

我们可以简单总结一下,循环结构的本质,就是在最大限度节省内存空间的原则下,利用“带条件的向后地址跳转”,指使计算机高速、不知疲倦地重复处理任务。

从第 11 篇的变量,第 12 篇的运算,第 14 篇的分支判断,再到今天的条件循环结构。这四样核心组件拼凑在一起,我们就已经搭建起了让计算机处理所有复杂逻辑的地基。

在计算机科学理论中,这四个组件不仅是日常编程的工具,更是衡量“可计算性”的终极标准。早在 1936 年,英国数学家阿兰·图灵(Alan Turing)就提出了著名的抽象计算模型——“图灵机”。它的设定极其纯粹:利用一条无限长的、用于记录状态数据的纸带(相当于代码里的变量)、一个可以读取并修改数据的磁头(相当于运算与赋值)、以及一套根据当前状态改变方向并持续运转的规则表(相当于分支判断与条件循环),就能在数学上计算出所有可解的问题。到了 1966 年,科学家们进一步通过严密的推演(Böhm-Jacopini 结构化定理)抛出一个轰动的结论:哪怕是最错综复杂、纠缠不清的程序分支,也完全可以仅凭顺序执行、分支判断、条件循环这三种最基础的结构组装而成。

因为这四样基本组件恰好能完美模拟理论中“图灵机”的存储、操作与流转机制,所以只要一种编程语言支持它们,我们就严谨地称这门语言是 “图灵完备(Turing Complete)” 的。这意味着,在补齐了循环结构之后,理论上你已经具备了让机器模拟世间一切可计算逻辑的能力!

结语

然而,我们虽然学会了用循环让算法连续流转 1 万次,但倘若我们需要在这个循环里依次核对 1 万个不同学生的成绩,给每一个学生都手工取一个变量名(比如 s1s2 一直到 s10000)显然是精神崩溃的。 为了配合循环结构处理成批的海量数据,我们迫切需要引入一种能在内存里把相同类型数据紧挨着打包存放的“大挂车”——也就是下一篇文章将会登场的:数组(Array)

所有代码已上传至Github:https://github.com/lihongzheshuai/yummy-code

GESP 学习专题站:GESP WIKI

"luogu-"系列题目可在洛谷题库进行在线评测。

"bcqm-"系列题目可在编程启蒙题库进行在线评测。

欢迎加入Java、C++、Python技术交流QQ群(982860385),大佬免费带队,有问必答

欢迎加入C++ GESP/CSP认证学习QQ频道,考试资源总结汇总

欢迎加入C++ GESP/CSP学习交流QQ群(688906745),考试认证学员交流,互帮互助

GESP/CSP 认证学习微信公众号
GESP/CSP 认证学习微信公众号
本文由作者按照 CC BY-NC-SA 4.0 进行授权