从零开始 / 一次只解释眼前的一步

控制流:
选择与重复。

怎样数出0到n减1的偶数,n为0时怎么办?沿着条件和跳转,一步步说明接下来执行哪一条语句。

先备:基础01–026 小节 · 预计共 10 小时阅读、推演与编码合计

10小时是未经真人试学校准的设计估算。可分多次学习,遇到不清楚的地方保留预测、实际结果和疑问,之后再回修教材。手机可读图与做预测,编译需要电脑终端。

这一章怎样学

先读一小段,写下预测,再运行程序。每次只改一个条件,最后关掉示例,从空文件独立写一次。遇到错误,把第一条报错和自己的修复记下来;不用赶着把页面滚到底。

每次先指出下一条实际执行的语句,再记录该语句执行后的状态。每章学习记录单独保留;本章完成不自动通过M1或G0。

前两章的程序从上往下走,每条语句执行一次。现在的问题是:同样一份程序,怎样在输入不同的时候选择不同的动作,又怎样让一段工作重复恰好需要的次数?这一章始终追问一句话:接下来执行哪一条,凭什么?

先备是新02:类型与表达式及它之前的第一份程序。输入仍写在每份源码的初始化处,每个文件独立编译。复用01的编译流程、02的算术与余数;本章不需要键盘解析、容器或自己定义函数。

六节预估10小时,含预测、运行、改一个条件和独立编码。这个数未用真人试学校准,可以拆成多次学习。前半学习条件、分支和while,后半学习for、跳转与其他控制形式;不要把看过全部答案当成做完。

03.1 布尔与逻辑条件 · 预估90分钟

“还有位置吗”怎样成为一个可以计算的问题

你要决定是否继续放入读数,不能只保存“已经放了5个”,还需要判断它与容量的关系。这一节先建立判断的结果,再组合两个判断。我们还会检查:表达式里写着一次除法,运行时是否一定会做它?

**布尔类型(bool)**保存两种值:true表示真,false表示假。bool ready{true};创建并初始化一个布尔对象,和前两章创建整数的读法相同。truefalse是关键字,不加引号。在本书当前没有开启布尔文字格式的默认输出流中,输出true显示1,输出false显示0;对象的类型仍是bool,不能仅凭看到1就认成int。

对于本章的两个小整数,a < b检查a是否小于b,a <= b检查是否小于或等于;>>=检查大于、大于或等于。==检查相等,!=检查不相等。这些**比较(comparison)**产生bool结果,本身不改写a或b。count == 5是提问,count = 5是把5写入已有对象,两个符号不能互换。

当一个普通整数被转换为bool时,0变成false,其他值变成true。例如bool any = count;读取count再保存转换结果;不会改变count。后面条件位置也允许这样的转换,但本章通常明确写count != 0。这说明了为什么把==误写为=不一定被编译器当作语法错误:对本节int变量执行赋值后,若继续读取这个赋值表达式的数值,得到刚写入的值。例如count = 0会写入0,若随后将这个结果转成bool,就得到false;独立写count = 0;时并没有要求判断条件。判断是否正确,要看它究竟读了什么、改了什么,不能只看是否编译成功。

组合条件时,要知道右边有没有执行

!ready是逻辑非(logical NOT):ready为true时得到false,反之得到true。它读取ready,不把ready永久改成另一个值。left && right是逻辑与(AND):先算left;若它为false,整个条件已经是false,right不求值;只有left为true才需要算right。left || right是逻辑或(OR):先算left;若它为true,整个条件已为true,right不求值;只有left为false才算right。这个按需要跳过右侧的规则叫短路(short-circuit evaluation)

本章讲的是bool和普通标量上的内建逻辑运算符。它们明确保证左侧先于可能发生的右侧求值;不要把这条保证扩展为“所有运算都从左到右执行”。比较比逻辑与、逻辑或结合得紧,逻辑与又比逻辑或结合得紧。初学时可以写(count > 0) && (count < 10),先按括号理解比较的分组,再计算左边;仅当短路规则要求时才计算右边。0 < count < 10并不表示数学上的连续区间判断,本章使用两个比较和&&表达范围。

这里的&&写在.cpp文件里,组合C++条件;第一章终端里编译命令 && 运行命令由shell解释,检查的是前一条命令退出状态。两者分别属于两种语言,不能把C++的bool输出1与shell退出码0的成功约定混成一张表。

从比较到保护一次除法

下面所有送入输出流的完整条件都加了圆括号,先得到bool,再输出。divisor初始为0,但不能仅看到源码含count / divisor就断定运行会零除;要先沿着短路路径走一遍。

#include <iostream>
int main() {
    int count{5};
    int limit{10};
    bool ready{true};
    bool empty{false};
    std::cout << (count < limit) << "\n";
    std::cout << (count <= 5) << "\n";
    std::cout << (count > limit) << "\n";
    std::cout << (count >= 5) << "\n";
    std::cout << (count == limit) << "\n";
    std::cout << (count != limit) << "\n";
    std::cout << (!empty) << "\n";
    std::cout << (ready && count < limit) << "\n";
    int divisor{0};
    std::cout << (divisor != 0 && count / divisor > 1) << "\n";
    std::cout << (divisor == 0 || count / divisor > 1) << "\n";
    return 0;
}

前六行比较5和10、5和5,依次得到1、1、0、1、0、1empty为false,所以逻辑非输出1;ready为true,且5小于10,逻辑与输出1。倒数第二行先算divisor != 0,结果false,右边除法被跳过,输出0。最后一行先算divisor == 0,结果true,右边也被跳过,输出1。因此十行输出是1、1、0、1、0、1、1、1、0、1,没有执行零除。

停下来,看一次变化

保护放在左边:这次除法究竟会不会执行

count5divisor0整个条件左 && 右1 / 4 · 内建逻辑与的求值路径count5divisor0整个条件左 && 右1 / 4
1 · 从同一份输入开始

count是5,divisor是0。需要先确定右侧除法是否会被执行。

1 / 4
查看所有步骤的文字与数值
  1. 1 · 从同一份输入开始

    count:5;divisor:0;整个条件:左 && 右

    count是5,divisor是0。需要先确定右侧除法是否会被执行。

  2. 2 · 先算左侧

    左侧比较:0 != 0;左侧结果:false;右侧除法:尚未执行

    divisor != 0比较0和0,结果false。比较没有修改divisor。

  3. 3 · 左侧为假,跳过右侧

    左侧:false;右侧:不求值;输出bool:0

    内建&&在左侧false时已有确定结果;count / divisor不会求值,没有运行零除。

  4. 4 · 只把除数改成2

    左侧 2 != 0:true;右侧 5 / 2 > 1:true;输出bool:1

    先前的false变为true,需要算右侧:整数5 / 2为2,2 > 1为true,整体true输出1。

只改divisor为2。先写下最后两行的路径:不等于零为true,才执行整数5 / 2得到2,再比较2大于1;另一行等于零为false,因此也要走右侧。两行都会输出1。重新编译确认,不能只修改源码就运行上次的程序。

小练习:能否把保护条件换到右边

输入count=5、divisor=0。把倒数第二个条件换成count / divisor > 1 && divisor != 0,还能预测为false吗?再手算!true || falsetrue && !false。最后解释count = 0count == 0分别会不会改写count。

提示与答案:先标出必定求值的一侧

交换后先求除法,零除已经发生,右边的保护来不及补救;这是02讲过的整数未定义行为,不运行它来碰输出。两个合法逻辑表达式分别为false和true。赋值把count改为0;相等比较只读取count并生成bool结果。编译器可能对可疑赋值发出警告,是否警告不是这两种语义的定义。

03.2 if/else与块 · 预估90分钟

一份输入,哪些动作应该发生

读数的个数可能不合法,可能为零,也可能是正数。输出三句话并不能实现“只报告一种情况”;我们需要让程序根据条件选择路径。选完以后,还要知道是回到公共的下一行,还是直接结束这次运行。

if语句写成if (条件) { 语句 }:先计算括号中的条件,真时进入花括号内,假时跳过。花括号包住的一组语句叫块(block)else { 语句 }紧接一个if分支,表示条件为假时走这里;同一次选择不会把if和对应else两边都执行。else if (另一个条件)让未被前一条件接纳的输入继续接受下一次判断。

块可以放在块里面,if里面也可以再放if,这叫嵌套(nesting)。缩进帮助人阅读,但决定边界的是花括号。没有花括号时,if只控制紧接的一条语句,else归属于尚未匹配else的最近if。为了让每条路径清晰,本章始终给分支加花括号,不用缩进替代语法边界。条件后的){之间也不要随手放;;单独的分号可以构成一条空语句,从而让后面的块失去原先想要的条件控制。

第一章已教return 0;return 1;从main返回运行结果。现在把return放到分支内:一旦执行到return,main剩余语句不再执行。没有走到那一条return的路径,则按分支结构继续。用非零退出表示这份小程序拒绝输入,是我们选定的程序合同;退出码的观察方式仍沿用01的echo $?

非法、零、正偶数、正奇数

下面先拒绝负数,再处理零;正数分支里嵌套一次余数判断。余数的读法由02提供,此处只把“余数等于零”的bool结果用作条件。

#include <iostream>
int main() {
    int n{5};
    if (n < 0) {
        std::cout << "invalid\n";
        return 1;
    } else if (n == 0) {
        std::cout << "zero\n";
    } else {
        if (n % 2 == 0) {
            std::cout << "even\n";
        } else {
            std::cout << "odd\n";
        }
    }
    std::cout << "done\n";
    return 0;
}

输入n=5。第一个条件假,else-if里的等于零也假,进入最后的else;5除以2的余数为1,内层条件假,输出odd。内层选择结束、外层选择结束后,到公共语句输出done,最后返回0。将n改为0,只输出zerodone;改为4,输出evendone;改为-1,第一分支输出invalid并返回1,不输出done。用这四个输入分别覆盖四条路径。

一个名字可以从哪里读到

局部变量名在声明以后、所在块以内可用,也可以在更深的内层块使用。出了声明它的那个块,就不能再用这个局部名字查找它。这叫名字的**作用域(scope)**规则。它回答“这行代码能不能用这个名字”,和以后要深入学习的资源寿命不是同一份合同。此处只使用普通局部标量。

#include <iostream>
int main() {
    int count{2};
    if (count > 0) {
        int saved{count};
        count = 3;
        std::cout << saved << "\n";
    }
    std::cout << count << "\n";
    return 0;
}

count在main块内声明,因此if内部能读取和改写它。saved在if块内用count当时的2初始化,随后count被改成3,saved仍是自己的2。块内输出saved得到2,块外输出count得到3。两份对象的值独立,复用01的初始化规则;这里新增的是名字的可见范围。

下面是故意编译失败的文件。先指出超出哪个名字的作用域,再编译;不要为这份失败构建预测终端输出。

#include <iostream>
int main() {
    int count{2};
    if (count > 0) {
        int saved{count};
        std::cout << saved << "\n";
    }
    std::cout << saved << "\n";
    return 0;
}

块内的saved及其输出合法;块外第二次输出的saved找不到此局部声明。即使count为正、你确信分支会执行,也不能让块外的名字突然可见。若确实需要块外继续访问,应在外层声明有明确初值的对象,再在内层更新,或者把使用移回合法范围。不要把一个新声明误当作恢复原来那个对象。

小练习:写出共同的下一行

为branches.cpp的n=0与n=-1分别画出会执行的输出行和return行;说明为什么只有一个会到done。再从scope.cpp出发,把count的初值改为0:saved会不会建立?程序最后输出什么?

提示与答案:画实际经过的路径

n=0经过zero、done、return 0;n=-1经过invalid、return 1并结束。scope.cpp的count=0使if条件假,块整体跳过,saved不建立;块外读取原count并输出0。名字的作用域和某一次分支是否进入是两层问题,不能用一个代替另一个。

03.3 while与更新 · 预估120分钟

同一行代码怎样处理几个不同的数

假设要累计0、1、2、3。如果手写四条加法,输入改了就要改程序结构。循环让同一条加法多次工作,但正确与否取决于每次进入前保存的状态,以及什么时候停止。

while循环写成while (条件) { 循环体 }。执行顺序是:先检查条件;假则直接到循环之后;真则执行循环体;循环体走完,再回去检查条件。while不是“先执行一次再判断”,第一次就可能不进入。

先看简短的更新写法。对本章不发生溢出的普通int变量,total += i读取total和i的值,相加后保存回total;等价的数值变化是total = total + ii -= 1减去1再保存。一般复杂左侧表达式的求值次数不能靠复制文本推断;此处左侧始终只是一个变量名。

把循环分成四栏:初值 → 条件 → 循环体 → 更新。初值通常在循环前设一次;更新可能写在循环体最后,它不是while自动提供的步骤。若每次处理后不改变i,而条件又一直为真,就不能指望“运行一会儿自己结束”。本节n限定为0到100,i与total都在小整数范围内,不靠溢出让循环停下。

先看每轮进入值,再看累计值

#include <iostream>
int main() {
    int n{4};
    int i{0};
    int total{0};
    while (i < n) {
        total += i;
        std::cout << i << " " << total << "\n";
        i += 1;
    }
    std::cout << "total=" << total << "\n";
    return 0;
}

输入n=4,i从0开始,total从0开始。第一轮条件0小于4为真,加0以后total仍为0,输出0 0,再把i变成1。第二轮加1,输出1 1;第三轮加2,输出2 3;第四轮加3,输出3 6。第四次更新后i为4,再次检查4小于4为假,离开循环,最后输出total=6。更新i发生在输出后,不能把下一轮的i配给上一轮的total。

判断时的i 条件i小于4 本轮相加后的total 本轮结束的i
0 0 1
1 1 2
2 3 3
3 6 4
4 不执行相加,仍为6 不再更新

只改n为0。第一次条件已经假,四条过程输出一条都没有,只有最后的total=0。这不是特殊补丁,而是同一套规则处理“没有要累计的数”。

自增自减:对象更新与表达式结果要分开

++xx++都会把x增加1。区别是表达式交给使用它的位置什么值:前缀++x先增加,再提供增加后的值;后缀x++先保留旧值作为这次表达式的结果,再完成x的增加。相应地,--x先减1并提供新值,x--提供旧值而把x减1。这里所有值都可表示,不讨论越界更新。

当它们单独写成一句++x;x++;时,表达式结果没有被保存,下一条语句看到的x都已增加1。若用来初始化另一个整数对象,前后缀区别就可见。每次更新和观察在不同的完整语句中;不要把多次修改同一对象挤在一条复杂表达式里猜求值次序。

#include <iostream>
int main() {
    int x{4};
    int old = x++;
    std::cout << old << " " << x << "\n";
    int current = ++x;
    std::cout << current << " " << x << "\n";
    int before = x--;
    std::cout << before << " " << x << "\n";
    int after = --x;
    std::cout << after << " " << x << "\n";
    x += 2;
    x -= 1;
    ++x;
    x--;
    std::cout << x << "\n";
    return 0;
}

从x=4开始,old接收后缀的旧值4,随后x是5,第一行输出4 5。current接收前缀增加后的6,第二行6 6。before接收后缀减之前的6,x变5,第三行6 5。after接收前缀减之后的4,第四行4 4。接着四条独立更新让x依次变为6、5、6、5,最后输出5。

小练习:让循环结束,不能只要求它“重复”

在while-sum.cpp里删掉i += 1;,不运行,写出前两次判断与相加的状态:为什么永远到不了i=4?再把该更新改为++i;,预测合法n=1的全部输出。最后从x=4分别预测int a = ++x;与另一次独立实验中的int a = x++;

提示与答案:检查哪一条语句改变条件所读的状态

缺更新时两次进入都看到i=0,加0,total仍为0;后续重复同样状态。++i;仍每轮增加1,n=1输出0 0total=0。独立的前缀实验a和x都是5;后缀实验a为4、x为5。不要在第一次实验改变x后,又假装第二次仍从4开始。

03.4 普通for · 预估120分钟

把一轮需要的控制信息放在一起

上一节把i的初值放在循环前、更新放在循环体里。读循环时需要来回找它们。普通for把这三处控制信息放在同一行;我们用它重写同一个累计任务,检查行为是否真的保持。

三段式for循环写成for (初始化; 条件; 更新) { 循环体 }。先执行一次初始化,然后检查条件;真则执行循环体,正常走完后执行更新,再检查条件;假则到循环之后。圆括号里的两个分号分隔三段,不表示每段每轮都执行。这里int i{0}声明的名字i可以在条件、更新和循环体内使用,不能在整个for结束后继续使用这个名字。

for (int i{0}; i < n; ++i)令i依次为0、1,直到小于n的最后一个整数。数学记号**[0,n)**表示包含左端0、不包含右端n,叫半开区间。若n=4,处理0、1、2、3;若n=0,没有整数需要处理。记号不是C++数组语法,本节并没有数组。n在这里是处理项数或停止边界,循环条件必须由这个含义推出。

完整例一:累计的是0到n减1

#include <iostream>
int main() {
    int n{4};
    int total{0};
    for (int i{0}; i < n; ++i) {
        total += i;
        std::cout << i << " " << total << "\n";
    }
    std::cout << "total=" << total << "\n";
    return 0;
}

先初始化n=4与total=0。进入for时只创建一次i=0;每轮先total += i,再显示本轮i和累计结果,最后在for的更新位置执行++i。四轮依次显示0 01 12 33 6;第五次检查时i已经为4,条件失败,不执行循环体也不再更新,随后输出total=6。它与上一节while版本的输出相同,但本节i的可见范围只覆盖for。

停下来,看一次变化

for的一次初始化、重复判断与更新

n4i / total0 / 0下一步判断 i < n1 / 6 · 循环状态的变化n4i / total0 / 0下一步判断 i < n1 / 6
1 · 初始化,只做一次

total=0;for建立i=0。下一步是条件判断,不是直接加一次。

1 / 6
查看所有步骤的文字与数值
  1. 1 · 初始化,只做一次

    n:4;i / total:0 / 0;下一步:判断 i < n

    total=0;for建立i=0。下一步是条件判断,不是直接加一次。

  2. 2 · 第一轮,加0

    进入 i / total:0 / 0;输出 i / total:0 / 0;更新后的 i:1

    0 < 4为真。total加0仍为0,输出0 0;正常走完循环体后更新i到1。

  3. 3 · 第二轮,加1

    进入 i / total:1 / 0;输出 i / total:1 / 1;更新后的 i:2

    1 < 4为真。total从0变1,输出1 1,然后更新i到2。

  4. 4 · 第三轮,加2

    进入 i / total:2 / 1;输出 i / total:2 / 3;更新后的 i:3

    2 < 4为真。total从1变3,输出2 3,然后更新i到3。

  5. 5 · 第四轮,加3

    进入 i / total:3 / 3;输出 i / total:3 / 6;更新后的 i:4

    3 < 4为真。total从3变6,输出3 6,然后更新i到4。

  6. 6 · 再判断,才知道结束

    判断 4 < 4:false;循环体 / 更新:都不执行;循环后输出:total=6

    4 < 4为假,跳到for后面输出total=6。本次判断不执行相加,也不再执行更新。

累计量放在哪里,会改变问题。 total在循环外初始化一次,因此后一次相加能读到前一次留下的累计值。若把int total{0};放在循环体内,每次进入块都会建立自己的total并从0开始,无法跨轮累积;出了循环还不能用那个块内名字。这把03.2的作用域规则放进了重复执行的场景。

渐进阶梯一:先预测

遮住上面的运行过程,自己写n=4的每轮i、进入前total、相加后total,以及最后一次失败的条件。再只改n为0。不要只交最后的6;过程表用来定位少算、多算或错误更新发生在哪一轮。

提示与答案:最后一次判断也是过程的一部分

四轮进入前total分别为0、0、1、3,相加后为0、1、3、6;更新后i依次为1、2、3、4。最后判断4小于4失败。n=0第一次判断0小于0失败,直接输出total=0。它不需要先进行一次加法。

渐进阶梯二:补上条件与更新

另建一个练习文件,按上例输入n、在循环外初始化total,写完整for。只参考“处理0到n减1”这句话补出条件与更新,再分别用n=0、1、4验证。不要把三个答案硬写成三个分支。

提示与答案:一个范围,三份输入

初始化i为0,条件是i < n,更新是++i或独立意义相同的i += 1。累计结果应分别为0、0、6;n=1只处理0,所以它与空组的最后总和相同,过程次数却不同。可保留上例的每轮输出观察差别,最后再只输出结果。

03.5 break/continue与嵌套 · 预估90分钟

跳过一个值,与结束全部工作有什么区别

现在有两种要求:遇到某一项只跳过它,遇到另一项停止后面的全部项。把两个要求都写成“跳出去”会出错,因为它们各自的下一条语句不同。

continue结束本轮循环体剩余部分,转到这类循环的继续位置:普通for先执行更新段,再检查条件;while直接重新检查条件;do/while转到末尾条件。break结束包住它的最近一层循环或switch,转到那一层结构之后。它不是return,不结束main;外面还有循环时,外层仍可继续。

标出跳转后第一条执行的语句

#include <iostream>
int main() {
    for (int i{0}; i < 6; ++i) {
        if (i == 1) {
            continue;
        }
        if (i == 4) {
            break;
        }
        std::cout << i << "\n";
    }
    std::cout << "done\n";
    return 0;
}

i=0时两个if都为假,输出0,再更新为1。i=1时continue跳过后面的if和输出,执行for更新,i变2;i=2、3分别输出2、3。i=4时break结束整个for,不执行本轮输出、也不执行for的更新,随后输出done。于是完整输出为0、2、3、done。若把continue误当break,会漏掉2和3;若以为continue跳回for初始化,则会把i错误地重置为0。

while没有单独更新段。假设i=0,在while (i < 3)内先判断if (i == 0) { continue; },把++i;放在后面,实际会永远跳过更新;重新判断时i仍为0。这个错误只做路径分析,不运行无限循环。修复时必须保证每条需要继续处理的路径都能推进状态,例如先完成当前项所需的读取,再把更新放到可能执行continue之前;不能在不了解计算含义时随便搬动加一。

两层循环,只退出哪一层

#include <iostream>
int main() {
    for (int row{0}; row < 2; ++row) {
        for (int column{0}; column < 3; ++column) {
            if (column == 1) {
                break;
            }
            std::cout << row << ":" << column << "\n";
        }
    }
    return 0;
}

外层row从0走到1。每进入一轮外层,内层都会重新创建column=0。column=0时输出行列;column=1时break结束内层,于是本次row不会到column=2。外层正常更新后,下一行仍从column=0开始。完整输出0:01:0。这里冒号是引号里的输出文字,不是新的C++控制形式。

小练习:只改一处,推演更多路径

把jumps.cpp的停止条件从i == 4改为i == 2,写出全部输出。再只在nested.cpp里把break改成continue,保留其他代码,预测各对行列。说明最后一轮continue后,内层先去哪里。

提示与答案:给跳转画一个精确终点

jumps只输出0和done:1被跳过,2结束循环。嵌套版本改continue后输出0:0、0:2、1:0、1:2;column=1跳过输出但仍执行内层for的++column,到2继续判断。最后处理column=2后更新到3,条件假才返回外层,continue没有直接退出外层。

03.6 其他控制形式 · 预估90分钟

先做一次、按整数编号选择、产生一个值

while与if足以表达很多任务,但读别人的代码时还会遇到另外三种形式。这里分别弄清它们的执行路径,不因为名称短就省略过程。每种只使用前面已经教过的小整数和输出。

do/while:第一次检查发生在哪里

do { 循环体 } while (条件);先进入循环体,正常走到末尾或遇到continue后才检查条件;真则再做一轮,假则结束。结尾这个分号是此语法需要的部分。若循环体提前执行break或return,则按相应跳转离开,不会为了“至少一次检查”而再算末尾条件。

#include <iostream>
int main() {
    int limit{0};
    int before{0};
    while (before < limit) {
        before += 1;
    }
    int after{0};
    do {
        after += 1;
    } while (after < limit);
    std::cout << before << " " << after << "\n";
    return 0;
}

limit=0,while开始时before=0,第一次判断假,所以before保持0。do的after也从0开始,却先执行加一变成1,随后1小于0为假,结束。因此输出0 1。只改limit为3,while与do都最后得到3,输出3 3;相同的最终结果不能证明两者第一次检查的位置相同。

switch:从一个标签进入,为什么还会走到下一个

本章switch (code)使用一个int值进行选择。case 1:是值为1的入口标签,case 2:是值为2的入口,default:是没有case匹配时的入口。case在本例中写固定整数,不能写成case code < 3:来表达任意运行时范围。相同switch中的case值不能重复。若没有匹配case也没有default,switch内部就不执行。

进入匹配的标签后,语句按正常顺序往下走,下一个case标签本身不阻止执行。要结束switch,执行break,或按照已学规则return结束main。省略break而继续走到后面,叫贯穿(fallthrough)。下面故意保留一次贯穿,用于观察路径;不要加尚未讲授的属性或把所有case误读成各自独立的if。 本章严格编译检查记录使用实际的Apple Clang;真正的GNU GCC在部分警告设置下会对缺break给出警告,若同时把警告当错误,可能拒绝这份故意贯穿的示例。警告不意味着贯穿违反C++语言规则;跨编译器结果未在此冒充已执行。

#include <iostream>
int main() {
    int code{2};
    switch (code) {
        case 1:
            std::cout << "A\n";
            break;
        case 2:
            std::cout << "B\n";
        case 3:
            std::cout << "C\n";
            break;
        default:
            std::cout << "other\n";
            break;
    }
    return 0;
}

code=2,直接进入case 2,输出B;这里没有break,继续来到case 3的输出,输出C,再执行break离开switch。因此输出两行B、C,不输出A。只改code=1,输出A后立刻break;改为3,只输出C;改为9,从default进入,输出other。若业务规定编号2只能报告B,就应在输出B之后补break,而不是改编号来躲开问题。

此处case后只有输出与break,没有声明局部变量。后续若在分支内声明对象,应先明确块边界和初始化路径,不能把本例当作任意跨标签跳转都合法的保证。switch里的break只结束这个switch;若switch放在循环中,它不会自动结束外层循环。

条件运算符:选择的是一个表达式结果

条件 ? 真时表达式 : 假时表达式先判断条件,然后只求值被选中的一侧,用这一侧的结果参与后面的计算。问号与冒号共同组成条件运算符(conditional operator),不是if语句的两段语句块。这里两侧都为int数值表达式,所以结果也按本节的int用途处理;混合类型及引用结果不在这份窄合同里。

#include <iostream>
int main() {
    int n{-3};
    int nonnegative = (n < 0) ? 0 : n;
    std::cout << nonnegative << "\n";
    int total{5};
    int divisor{0};
    int quotient = (divisor == 0) ? 0 : (total / divisor);
    std::cout << quotient << "\n";
    return 0;
}

n=-3,条件n小于0为真,选择0初始化nonnegative,第一行输出0。divisor=0,等于零为真,选择0,另一侧的除法不求值,第二行仍输出0。只改divisor为2,第二个条件假,执行整数5除2,输出2。把除法搬到条件运算符之前先算,并不能保留这份保护;路径顺序仍然决定是否执行危险运算。

渐进阶梯三:找出最小边界反例

以下程序能编译运行,但不符合“累计0到n减1”的合同。先用n=0观察,再选择最小的正整数输入使结果与正确版本不同,指出只需要修哪个运算符。

#include <iostream>
int main() {
    int n{1};
    int total{0};
    for (int i{0}; i <= n; ++i) {
        total += i;
    }
    std::cout << total << "\n";
    return 0;
}
提示与答案:零项总和可能掩盖多处理了一项

n=0时错误版本处理了一次i=0,仍得到0,最后总和碰巧与正确版本一样。最小正输入n=1时,它处理0和1,错误总和为1;正确半开区间只含0,总和为0。把i <= n改成i < n即可修复这份范围错误。n=0的最后输出相同不能证明循环次数相同,过程与结果都值得观察。

渐进阶梯四:独立统计偶数

从空文件完成:输入n写在源码中,合同要求0到100,包含两端;统计整数区间[0,n)里偶数的个数。范围外先输出invalid并用非零退出,不进入计数循环。范围内只输出个数,用零退出。先写下你认为n=0、1、5、100分别是多少,再编码。这里0也是偶数,因为它除以2的余数为0;判断依据是02已学的余数及本章比较。

先说明三件事:计数器在哪里初始化;i能取到哪些值;哪一条语句使计数器增加。再用-1和101检查拒绝路径。不要将每个指定输入硬写成独立答案分支。

提示:先拒绝,再遍历,最后只在条件成立时计数

范围外可写n < 0 || n > 100,其中任一条件成立就提前结束。合法路径把count初始化为0,以普通for遍历i从0到小于n;当i % 2 == 0时执行一次++count。没有被条件接纳的i不改变count。

完整例二与参考答案:自己完成以后逐行对照
#include <iostream>
int main() {
    int n{5};
    if (n < 0 || n > 100) {
        std::cout << "invalid\n";
        return 1;
    }
    int count{0};
    for (int i{0}; i < n; ++i) {
        if (i % 2 == 0) {
            ++count;
        }
    }
    std::cout << count << "\n";
    return 0;
}

n=5,范围检查为假,建立count=0。i=0的余数为0,count变1;i=1不变;i=2变2;i=3不变;i=4变3;更新到5后循环结束,输出3并返回0。n=0不进入循环,输出0;n=1只处理0,输出1;n=100处理0到99,其中0、2、4一直到98共50个偶数,输出50。n=-1与101都输出invalid并返回1,count的声明和循环均不会执行。

离开本章前,交一份可解释的程序

保存你自己的偶数计数程序,保留六种输入的预测、实际输出和退出码;再用两句话说出为什么<不能随意换成<=、continue在for里为什么仍会更新。若某一步只能说“运行后就是这样”,回到对应小节重新走路径。本章不要求自定义函数、引用或容器;下一章将在这些已经能预测的动作外面建立普通函数接口。

关掉参考,再做一次

把理解说出来

先完成正文的独立迁移,再回答下面六题。它们只检验本章内容;打开答案、编译成功或填写用时,都不会自动通过本章,更不代表通过 G0。

解释与预测

默认输出流下bool的true/false显示什么?divisor为0时,divisor != 0 && total / divisor > 1是否执行除法?

对照推理与英文回答

显示1和0。内建&&先检查左边,false时不求值右边,因此此处不执行除法;把除法放到左侧就失去保护。

With the default stream formatting, true prints as 1 and false as 0. Built-in logical AND evaluates the left operand first and skips the right operand when the left is false. The guard must come before the division.

找错与追问

count == 0与count = 0有什么差别?if分支内return 1之后,main末尾的done还能执行吗?

对照推理与英文回答

比较不改写count,赋值会把count写成0;不能靠编译成功证明用了正确运算符。实际走到return 1时,main后续语句不再执行。没有走到该分支则沿正常路径继续。

Equality compares without changing count. Assignment writes a new value. Once a return statement in main executes, the remaining statements in main are skipped. A branch that was not taken does not return.

状态预测

两次独立实验都从x=4开始:int a = x++;和int a = ++x;分别保存什么?只写x++;时下一条语句看到什么?

对照推理与英文回答

后缀实验a=4、x=5;前缀实验a=5、x=5。独立自增语句执行完后,下一条语句读到x=5。将表达式的结果与被修改对象的状态分别记账。

Post-increment gives the old value to a, while pre-increment gives the incremented value. In both experiments x becomes five. After a standalone increment statement, the next statement observes the updated value.

边界与解释

for从i=0开始、条件i<n时,n=0与n=1各执行几轮?把条件改成i<=n,为什么只检查最终总和0可能漏错?

对照推理与英文回答

正确版本分别0轮和1轮;n=1的唯一项是0。错误版本n=0也处理一次0,最终总和仍为0;n=1则错误加上1。要检查范围和过程,不能只看恰好相同的结果。

The half-open loop executes zero times for n equal to zero and once for n equal to one. An inclusive bound can process an extra zero without changing the sum. A small positive input exposes the extra right endpoint.

追踪跳转

continue在普通for里下一步到哪里?嵌套循环的break会退出所有循环吗?while的更新放在continue后面有什么风险?

对照推理与英文回答

for先执行更新段再检查条件;break只结束最近的循环或switch。while的continue直接去条件,如果必要更新被跳过,状态可能永远不推进。

In a regular for loop, continue proceeds to the update expression, then the condition. Break exits only the nearest enclosing loop or switch. A while loop can get stuck if continue skips the update that would make progress.

代码路径与英文复述

do/while初始条件为假还会执行循环体吗?switch匹配一个case后为何能输出下一case的内容?条件?:会把两边都算出来再选吗?

对照推理与英文回答

进入do时先做循环体,再到条件(除非break或return先离开)。case是入口标签,之后顺序执行,缺break可能贯穿。内建?:先求条件,只求值选中的一侧;本章例子两侧均为int。

A do-while starts with the body before testing its condition, unless control leaves earlier. A case label does not stop execution; a missing break can allow fallthrough. The conditional operator evaluates only the selected operand after its condition.

和正文是同一份源码

示例文件

先自己输入和预测,卡住时再下载对照。文件名相同不代表内容相同;把它们放在单独的练习目录中,避免覆盖自己的作品。

完整编译、输出和失败路径验证随本地构建记录保留;这里的程序没有调用加速卡。

可选的学习反馈

记下你真正花的时间

每完成一个学习时段,再填实际分钟。环境准备、阅读推演、独立编码和卡点排查分别记录,避免同一段时间重复计算。离开吃饭或做其他事情的时间不算进去。

记录只保存在你的浏览器,可导出给我复盘。留空表示尚未记录,不等于零耗时;页面停留时间不会自动计为学习。不要把开发者检查时间填进来。

尚无真实试学用时。

    按开始学习本章前的情况选择;已经会 C++ 时选择“会 C++”。起点随每条时段保存,之后改选不会重标旧记录。旧记录缺少起点时单独保留;已有基础者的用时不用于校准零基础预算。

      换设备:导入记录,或取回损坏的旧记录

      导入会合并时段,相同编号不重复累加;发生冲突会保留现有记录。

      阅读记录与课程验收分别保存。

      本章资料与查证

      本章独立解释所需读法;资料用于核对与补充。工具版本、操作系统和实际执行状态见自己的运行记录。