第07章 C 控制语句:分支与跳转

这一章在干嘛?

上一章学会了循环,程序已经能”重复做一件事”;这一章教程序”看情况办事”:用 if/else 和 switch 让程序根据条件选择不同路径,用逻辑运算符(logical operator)组合更复杂的判断,再用 continue、break、goto 三种跳转语句灵活控制流程。学完本章,程序就真正”有脑子”了。

7.1 if 语句

先看一个例子:读入一批每日最低气温(摄氏度),统计总天数及低于冰点(0℃)的天数占比。

// colddays.c -- 计算低于冰点的天数百分比(程序清单 7.1,节选主体)
#include <stdio.h>
int main(void)
{
    const int FREEZING = 0;
    float temperature;
    int cold_days = 0;
    int all_days = 0;
 
    while (scanf("%f", &temperature) == 1)
    {
        all_days++;
        if (temperature < FREEZING)   // 核心新语句:低于冰点才计数
            cold_days++;
    }
    if (all_days != 0)
        printf("%d days total: %.1f%% were below freezing.\n",
               all_days, 100.0 * (float) cold_days / all_days);
    if (all_days == 0)
        printf("No data entered!\n");
    return 0;
}
10 days total: 30.0% were below freezing.

逐段讲解:

  • while (scanf("%f", &temperature) == 1):用 scanf() 的返回值做循环条件——成功读到 1 个数就继续,遇到 q 这类非数字输入就退出(第 6 章的经典套路)。
  • if (temperature < FREEZING) cold_days++;:温度小于 0 就给 cold_days 加 1,否则跳过。
  • 百分比计算用 (float) 强制转换避免整数除法截断成 0(100.0 * cold_days 其实也会先触发自动类型转换,显式转换更多是表明意图、防止日后改错)。

if 语句(if statement)也叫分支语句(branching statement)或选择语句(selection statement),它是程序的”岔路口”:if (expression) statement——expression 非零(真)就执行 statement,否则跳过。statement 可以是单条语句,也可以是用花括号包住的复合语句(compound statement);整个 if 结构在语法上只算一条语句。

7.2 给 if 加个 else

if else 让你在两条语句中二选一。用它可以把 7.1 里测两次的代码改优雅:

if (all_days != 0)
    printf("%d days total: %.1f%% were below freezing.\n", ...);
else                       // 走到这里必然 all_days == 0,不用再测
    printf("No data entered!\n");

C 不强制缩进,但缩进是标准风格,一眼能看出哪些语句依赖测试条件。

else 之前只能有一条语句

要在 if 和 else 之间放多条语句,必须用花括号组成一个块。下面这样写会编译报错——编译器把 printf() 当成 if 的语句,x++ 脱离了 if,于是 else 成了”没爹的孩子”:

if (x > 0)
    printf("Incrementing x:\n");
    x++;          // 已经脱离 if 了!
else              // 编译错误
    printf("x <= 0 \n");

图:if 与 if else 的流程对比

7.3 再举个例子:getchar() 与 putchar()

C 有一对专门做字符 I/O 的函数:ch = getchar(); 从输入读下一个字符(等价于 scanf("%c", &ch);),putchar(ch); 打印字符 ch(等价于 printf("%c", ch);)。它们不需要格式说明符,比 scanf()/printf() 更快更紧凑。

程序清单 7.2 用它们做”字符加密”:把每个非空格字符替换成 ASCII 序列中的下一个字符(读法:先读一个字符 → 循环处理 → 循环末再读下一个):

ch = getchar();        /* 读一个字符 */
while (ch != '\n')     /* 不是行尾就继续 */
{
    if (ch == SPACE)   /* 空格保持不变 */
        putchar(ch);
    else
        putchar(ch + 1);   /* 其他字符加 1 */
    ch = getchar();    /* 读下一个字符 */
}
CALL ME HAL.
DBMM NF IBM/

C 灵活的语法允许把”读取+测试”合并进一个表达式 while ((ch = getchar()) != '\n'),省掉循环前的单独读取。

括号一个都不能少

如果误写成 while (ch = getchar() != '\n'),由于 != 优先级高于 =,会先算 getchar() != '\n' 得到 0 或 1,再把 0/1 赋给 ch——ch 拿到的根本不是字符!记住:(ch = getchar()) 必须用括号包住。这个惯用法(idiom)在 C 里极常见。

另外 putchar(ch + 1) 说明字符本质是整数:ch 被提升为 int 参与计算,putchar() 接收 int 参数,只用最后一个字节决定显示哪个字符。

7.4 ctype.h 字符函数家族

清单 7.2 把句点也变成了斜杠(斜杠的 ASCII 码比句点大 1),但若只想转换字母呢?逐个列举”不是空格、不是逗号……”太啰嗦。ANSI C 提供了标准字符分析函数,原型在 ctype.h:参数属于某类字符就返回非零(真),否则返回 0(假)。

// cypher2.c -- 改变输入,保留非字母字符(程序清单 7.3)
#include <stdio.h>
#include <ctype.h>    // isalpha() 在这里
int main(void)
{
    char ch;
 
    while ((ch = getchar()) != '\n')
    {
        if (isalpha(ch))    // 是字母就转换
            putchar(ch + 1);
        else                // 否则原样打印
            putchar(ch);
    }
    putchar(ch);            // 打印换行符
    return 0;
}
Look! It's a programmer!
Mppl! Ju't b qsphsbnnfs!

大小写字母都被加密,空格和标点原样——一个 isalpha() 搞定手写一堆条件的事。

表 7.1 ctype.h 字符测试函数(参数是某类字符时返回真):

函数参数是什么时为真
isalnum()字母数字(字母或数字)
isalpha()字母
isblank()标准空白字符(空格、水平制表符、换行)等
iscntrl()控制字符,如 Ctrl+B
isdigit()数字
isgraph()除空格外的任何可打印字符
islower()小写字符
isprint()可打印字符
ispunct()标点符号(除空格和字母数字外的可打印字符)
isspace()空白字符(空格、换行、换页、回车、制表符等)
isupper()大写字符
isxdigit()十六进制数字字符

表 7.2 ctype.h 字符映射函数

函数行为
tolower()参数是大写字母就返回其小写形式,否则原样返回
toupper()参数是小写字母就返回其大写形式,否则原样返回

映射函数不修改原参数

tolower(ch); 对 ch 毫无影响!它只是返回转换后的值,必须接住:ch = tolower(ch);。初学者最容易在这里栽跟头——“函数明明调了,怎么没变化?“

7.5 多重选择 else if

生活中常常不止两个选项,用 else if 可把 if else 扩展成多路选择。示例:电费分三档计价(前 360 度、接下来 320 度、超过 680 度):

/* electric.c -- 计算电费(程序清单 7.4) */
#include <stdio.h>
#define RATE1 0.12589    /* 前 360 度的电价 */
#define RATE2 0.17901    /* 接下来 320 度的电价 */
#define RATE3 0.20971    /* 超过 680 度的电价 */
#define BREAK1 360.0     /* 第一个计价分界点 */
#define BREAK2 680.0     /* 第二个计价分界点 */
#define BASE1 (RATE1 * BREAK1)   /* 360 度的费用 */
#define BASE2 (BASE1 + (RATE2 * (BREAK2 - BREAK1)))  /* 680 度的费用 */
int main(void)
{
    double kwh;    /* 用电量 */
    double bill;   /* 账单 */
 
    printf("Please enter the kwh used.\n");
    scanf("%lf", &kwh);    /* double 类型用 %lf */
    if (kwh <= BREAK1)
        bill = RATE1 * kwh;
    else if (kwh <= BREAK2)    /* 用电量在 360 到 680 之间 */
        bill = BASE1 + (RATE2 * (kwh - BREAK1));
    else                       /* 用电量超过 680 */
        bill = BASE2 + (RATE3 * (kwh - BREAK2));
    printf("The charge for %.1f kwh is $%1.2f.\n", kwh, bill);
    return 0;
}
Please enter the kwh used.
580
The charge for 580.0 kwh is $84.70.

其实 else if 只是嵌套 if 的另一种写法——else 后面跟的正是另一条 if else,整个结构算一条语句,所以嵌套时不需要花括号。但写成一行更好读,一眼看出是三路选择;缩进嵌套的形式留给真正要测两个独立量的场合(如”夏季超 680 度再加收 10%”)。要点:费率、分界点都用符号常量集中定义,调价只改一处,BASE1、BASE2 联动更新(预处理器只做替换不做计算,BASE1 先替换成 0.12589 * 360.0,再由编译器算出 45.3204);只有 kwh ≥ 360 才可能走到第一个 else,所以 else if (kwh <= BREAK2) 实际等价于”360 < kwh ≤ 680”——每层判断都在为下一层”兜底”。

图:程序清单 7.4 electric.c 的程序流程

7.6 else 与 if 的配对及多层嵌套

if 和 else 一多,哪个 else 配哪个 if?规则:else 与它最近的、还没配过对的 if 配对,除非花括号另有安排。

图:if 与 else 的配对规则

所以下面代码里 else 配的是第二个 if:number 为 5 什么都不打印,为 10 打印 You’re close!,为 15 打印 Sorry…。注意编译器无视缩进——缩进看起来 else 像配第一个 if,纯属误导。真想让 else 配第一个 if,就把内层 if 用花括号包住。

多层嵌套的应用:找约数与判断素数

嵌套 if 的另一用法是”选定分支后再进一步选择”。经典问题:给定整数,打印所有约数;没有约数就报告它是素数(prime)。两个优化点:

  1. 只需测试到 num 的平方根——约数成对出现(144:2 和 72、3 和 48……12 和 12),越过平方根就开始重复。用 div * div <= num 代替开平方,整数乘法快得多。
  2. 用旗标变量(flag)记录”是否进过 if”:循环结束后旗标还是初值,说明从没找到过约数,num 是素数。
// divisors.c -- 用嵌套 if 显示一个数的约数(程序清单 7.5,节选核心)
#include <stdbool.h>
...
unsigned long num, div;    // 待检查的数、候选约数
bool isPrime;              // 素数旗标
while (scanf("%lu", &num) == 1)
{
    for (div = 2, isPrime = true; (div * div) <= num; div++)
    {
        if (num % div == 0)
        {
            if ((div * div) != num)          // 非完全平方数:打印一对约数
                printf("%lu is divisible by %lu and %lu.\n",
                       num, div, num / div);
            else                             // 完全平方数:只打印一次
                printf("%lu is divisible by %lu.\n", num, div);
            isPrime = false;                 // 有约数,不是素数
        }
    }
    if (isPrime)
        printf("%lu is prime.\n", num);
    ...
}

运行示例:输入 36 依次报告可被 2 和 18、3 和 12、4 和 9 整除,最后单独报告 6;输入 149 则报告 is prime。

旗标 + 逗号运算符的组合拳

旗标思路很普适:循环外设初值,循环内满足条件就翻转,循环后检查旗标即可知道”事件是否发生过”;for 初始化部分的 div = 2, isPrime = true 用逗号运算符保证每测一个新数字都重置旗标。C99 的 bool 类型(含 stdbool.h 后可用 true/false)比 int 更贴合旗标语义。(小提示:这个程序会把 1 判成素数,严格说 1 不是——学了逻辑运算符你就能排除它。)

7.7 逻辑运算符

C 有三个逻辑运算符(logical operator):

运算符含义规则
&&与(and)两个操作数都为真才为真
||或(or)至少一个为真就为真
!非(not)真变假、假变真

举例:5 > 2 && 4 > 7 为假;5 > 2 || 4 > 7 为真;!(4 > 7) 为真(等价于 4 <= 7)。C99 在 iso646.h 里为它们提供了 and、or、not 三个替代拼写,照顾打不出这些符号的键盘。优先级! 极高(与 ++ 同级);&& 高于 ||;两者都低于关系运算符、高于赋值——a > b && b > c || b > d 等价于 ((a > b) && (b > c)) || (b > d),但主动加括号更清晰。

求值顺序:C 保证逻辑表达式从左到右求值,&& 和 || 是序列点(sequence point),且一旦能确定整体真假就立即停止(短路求值,short-circuit evaluation)。两个实用技巧:

// 技巧一:防除零——number 为 0 时第二个条件根本不会执行
if (number != 0 && 12 / number == 2)
    printf("The number is 5 or 6.\n");
 
// 技巧二:先给 c 赋值再用 c 判断——顺序有保证
while ((c = getchar()) != ' ' && c != '\n')
    ...

程序清单 7.6 统计一句话中除单引号、双引号外的字符数(句点表示句子结束),核心就一行逻辑判断:

while ((ch = getchar()) != PERIOD)     /* PERIOD 即 '.' */
    if (ch != '"' && ch != '\'')       /* 双引号和单引号都不算 */
        charcount++;
// 末尾输出:There are 50 non-quote characters.

用 && 测试范围

测 90~100 之间的分数要写成两个完整的关系表达式。

千万别照抄数学写法 90 <= range <= 100

这是语义错误而非语法错误,编译器最多给个警告。由于 <= 左结合,表达式被解释成 (90 <= range) <= 100——左边先算出 0 或 1,而 0 和 1 都小于 100,整个条件永远为真!同理 ch != 'q' && != 'Q' 也是错的——&& 两侧必须是两个完整的测试表达式,正确写法 ch != 'q' && ch != 'Q'

判断小写字母时 ch >= 'a' && ch <= 'z' 依赖字符编码连续(ASCII 成立,EBCDIC 不成立),更可移植的做法是 islower(ch)。

字数统计程序

综合运用以上知识可写一个统计字符数、行数、单词数的程序(程序清单 7.7 wordcnt.c)。单词定义为”不含空白字符的字符序列”,核心逻辑是维护一个旗标 inword:

while ((c = getchar()) != STOP)     /* STOP 在第 7 章用 '|' */
{
    n_chars++;                      /* 统计字符 */
    if (c == '\n')
        n_lines++;                  /* 统计行 */
    if (!isspace(c) && !inword)     /* 非空白且不在单词中 */
    {
        inword = true;              /* 新单词开始 */
        n_words++;                  /* 单词数加 1 */
    }
    if (isspace(c) && inword)
        inword = false;             /* 单词结束 */
    prev = c;                       /* 保存字符,用于识别不完整的行 */
}

if (!isspace(c) && !inword) 比逐个测 c != ' ' && c != '\n' && c != '\t' 简洁得多;布尔变量的惯用写法是直接 if (inword),不必写 if (inword == true)。用 | 结束输入不够优雅,第 8 章会用 EOF 把它改造成能统计文件的通用程序。

7.8 条件运算符 ?:

条件运算符(conditional operator)是 C 唯一的三元运算符(ternary operator),是 if else 的速写:

expression1 ? expression2 : expression3
// expression1 为真整个表达式取 expression2 的值,否则取 expression3 的值

典型用途:x = (y < 0) ? -y : y;(求 |y|)、max = (a > b) ? a : b;(求较大值)。程序清单 7.8 用它解决”油漆桶要买整数桶”:int 除法 215/200 截断得 1,有余数就补 1 桶——cans += ((sq_feet % COVERAGE == 0)) ? 0 : 1;,实现向上取整;printf() 里 cans == 1 ? "can" : "cans" 还演示了条件表达式的操作数可以是字符串。

7.9 循环助手:continue 与 break

continue 语句

continue 在三种循环中都能用:跳过本轮剩余语句,直接开始下一轮;在嵌套结构中只作用于最内层。清单 7.9(skippart.c)用它过滤无效分数,核心结构:

while (scanf("%f", &score) == 1)
{
    if (score < MIN || score > MAX)
    {
        printf("%0.1f is an invalid value. Try again: ", score);
        continue;          // 越界值:跳过本轮后续处理,直接读下一个
    }
    /* ...处理有效分数:更新 min/max、累加 total、计数 n... */
}
/* 结束后:n > 0 则输出平均分与最低/最高分,否则提示没有有效分数 */

输入 188 这类越界值时,continue 让程序跳过后面的处理语句直接读下一个输入。不用 continue 也行(把剩下的语句放进 else 块),但 continue 能减少一层缩进,主逻辑较深时更易读。它还有个占位妙用——空语句的孤立分号难发现,while (getchar() != '\n') continue; 一眼就懂(读走并丢弃一行剩余内容)。

continue 之后程序从哪里继续?while/do while 回到测试表达式;for 循环先执行更新表达式(count++)再测试——两种循环行为有细微差别,计数时前者不含被跳过的字符、后者含。

break 语句

break 让程序跳出包含它的整个循环(或 switch),继续执行循环后面的语句;在嵌套循环中只跳出最内层。

图:break 与 continue 的对比

清单 7.10 计算矩形面积,长度或宽度任一输入非数字就退出——循环里有两个 scanf(),break 比 while (scanf("%f %f", ...) == 2) 更方便回显每个输入值:

while (scanf("%f", &length) == 1)
{
    ...
    printf("Enter its width:\n");
    if (scanf("%f", &width) != 1)
        break;     // 宽度输入非数字,退出循环
    ...
}

注意 break 跳出循环时跳过了 for 的更新部分,这是它与 continue 的又一区别。另外别滥用跳转:while ((ch = getchar()) != '\n' && ch != '\t') putchar(ch); 把两个退出条件写在同一处,比循环里藏一个 if + break 更清晰——原则是让逻辑更简单才用,否则就合并条件。

7.10 多重选择 switch 与 break

多路选择用 if else if…else 会很啰嗦,switch 更方便。清单 7.11(animals.c)读入一个字母,打印以它开头的动物名,核心结构如下(每个 case 末尾都有 break):

while ((ch = getchar()) != '#')
{
    if ('\n' == ch)
        continue;               // 忽略回车
    if (islower(ch))            // 只处理小写字母
        switch (ch)
        {
            case 'a': printf("argali, a wild sheep of Asia\n");
                      break;
            case 'b': printf("babirusa, a wild pig of Malay\n");
                      break;
            /* ...case 'c' 到 'f' 同理... */
            default:  printf("That's a stumper!\n");
        }                        // switch 结束
    else
        printf("I recognize only lowercase letters.\n");
    while (getchar() != '\n')
        continue;               // 跳过输入行剩余部分
    printf("Please type another letter or a #.\n");
}                                // while 结束
dab
desman, aquatic, molelike critter
Q
I recognize only lowercase letters.
#
Bye!

switch 的工作原理

  • 括号里的整型表达式求值,程序在 case 标号中找匹配值,找到就跳到那一行;没有匹配就走 default(如果有),否则继续执行 switch 后面的语句。
  • 表达式必须是整型值(包括 char);case 标号必须是整型(含 char)常量或整型常量表达式——不能用变量做标号,也不能像 Pascal 那样用区间做 case。

switch 忘写 break 会"贯穿"到底

匹配到一个 case 后,如果没有 break,程序会顺序执行后面所有 case 的语句直到 switch 结束。假如删掉所有 break 后输入字母 d,desman、echidna、fisher、That’s a stumper! 会全部冒出来!所以每个分支末尾的 break 不是可选项,而是必需品(除非刻意利用”贯穿”)。注意:break 在循环和 switch 里都能用,continue 只能用于循环(switch 位于循环内时可用 continue 跳过本轮循环)。

图:switch 在有 break 和无 break 时的程序流程

只读每行的第一个字符与多重 case 标号

animals.c 还演示了交互程序的输入技巧:输入 dab 只处理第一个字符 d——结尾的 while (getchar() != '\n') continue; 把回车前的字符全部读走丢弃,下一轮 getchar() 读到的正是下一行的第一个字符;开头的 if ('\n' == ch) continue; 处理直接按回车的情况。(常量写在左边 '\n' == ch 是个防手滑技巧——漏写成 = 编译器会报错。)

同一语句还可以有多个 case 标号。清单 7.12(vowels.c)统计元音字母,大小写各配一对标号:

case 'a':
case 'A': a_ct++;
          break;
/* ...e、i、o、u 同理... */
default:  break;

ch 是 i 时跳到 case ‘i’:,那里没有语句也没有 break,流程顺势落到 i_ct++;;ch 是 I 时直接落到同一条语句——两个标号共享一段代码。也可以先用 toupper(ch) 统一转大写再 switch,省掉成对标号。

switch 与 if else 怎么选? 依据浮点表达式选择时只能用 if else;变量落在某个范围(如 3 到 999)时 if else 方便——switch 得写几百个 case;选项是有限个整型/字符常量时用 switch,代码更短、运行通常也更快。

7.11 goto 语句

goto 有两部分:goto 和标号名,标号语句写在函数内任意位置,标号后跟冒号:

goto part2;
...
part2: printf("Refined analysis:\n");

K&R 说 goto “极其容易被滥用”,建议”能不用就不用”。老式 BASIC/FORTRAN 的 goto 套路在 C 中都有更好的替代:if 后多条语句→复合语句 { };二选一→if else;无限循环→while;跳到循环末尾→continue;跳出循环→break(break/continue 本质是特化的 goto,好处是名字自解释、没有标号放错位置的风险)。

唯一被许多 C 程序员容忍的用法:从多层嵌套循环中一口气跳出(单个 break 只能跳出最内层)——在最内层检测到问题就 goto help;,标号语句 help: 善后处理; 写在所有循环之外。

本章速记

if/else/switch 加逻辑运算符构成 C 全部的”看情况办事”能力:任何非零值都是真,0 是假;测范围必须用 && 连两个完整表达式;switch 必配 break,continue 只管循环;ctype.h 系列是字符分类利器;短路求值是安全网——n != 0 && 100/n > 5 永不除零。

本章自测