控制流语句#

在前面的所有例子中,我们的程序都是顺序执行的,即,一条一条语句执行。有时候, 我们希望根据条件判断,来改变程序的走向。这就是控制流语句的作用。

一个例子#

/*ctrl-flow.c*/
#include <stdio.h>

int main(void)
{
    int var = 20;
    if (var > 10)
    {
        var = 30;
    }
    printf("%d", var);
    return 0;
}
30

这里我们判断了var是否大于10,如果大于10,就变为30。 在了解控制流语句之前,我们需要了解布尔类型、比较运算符和逻辑运算符。

布尔类型与相关运算符#

布尔类型和布尔转换#

在前面的的部分中我们讲过布尔类型_Bool,布尔类型是一种可以表示的类型。 其具有两个值:0(假)和1(真)。在前面提到过,布尔类型都是整数类型。

C语言中的数值类型都可以隐式地转换为布尔类型,其规则如下:

  • 如果该值与同类型的0相等,则为0(假)
  • 否则为1(真)

Hint

如果你包含了stdbool.h,则可以用bool表示布尔类型,用true表示真, 用false表示假。

比较运算符#

比较运算符返回0(假)和1(真)。当条件成立的时候返回1,条件不成立的时候返回0。

比较运算符一共有六个: - <(小于) - >(大于) - <=(小于等于) - >=(大于等于) - ==(等于) - !=(不等于)

Grammar

表达式 < 表达式

表达式 > 表达式

表达式 <= 表达式

表达式 >= 表达式

表达式 == 表达式

表达式 != 表达式

如果比较运算符的两边是不同的类型,比较运算符的两边也会经过 通常算术转换

虽然说比较运算符返回的类型是int。由于上述的布尔转换,它可以被转换为布尔值。

Attention

不要将==(等于)和=(赋值)混淆!

以下是一些例子,它们返回1(真):

1 < 2
2 >= 2
4 != 5
2 < 2.4
1 + 2 >= 3

以下是一些例子,它们返回0(假):

2 > 4
1 != 1
3.0 + 1.5 < 4

Attention

在比较浮点数相等/不相等时要格外小心。浮点数有误差, 会导致看似相等的浮点数其实不相等。误差来自以下两个地方:

  • 计算时的舍入
  • 类型转换
  • 字面量到实际值

计算舍入我们在之前的内容中已经讲过。这里来看一个类型转换和字面量到实际值的例子:

(float)0.1 == 0.1

这个表达式返回0

这个例子中(float)0.1是由double类型的字面量转到float。 左边实际代表的值离0.1有一定误差。由于float表示不了double那么高的精度, 类型转换后的这个误差比右边double类型的

通过通常算术转换将左边转回double时, 其值被如实地转换了(因为double精度更大),误差保持了。 这样左边和右边的值就是不相等的。

Hint

一个常见的比较浮点数相等的方法是采用“近似相等”, 即其差的绝对值在某个容忍度(如1e-5)之内。

Hint

C语言的布尔类型是一种后来才有的类型,所以在绝大多数的逻辑表达式中, 还都是以int类型作为返回类型的int类型的0和1(以及其他值)通过上述的布尔转换可以转换到布尔类型。

逻辑运算符#

逻辑运算符同样会返回int的0(假)和1(真)。逻辑运算符会将作用的表达式当作布尔值处理, 然后进行逻辑演算,给出真假结果。

逻辑运算符一共有三个:

  • !逻辑非
  • &&逻辑与
  • ||逻辑或

Grammar

! 表达式

表达式 && 表达式

表达式 || 表达式

位运算 最大的不同在于,位运算是对整数逐位的进行操作并返回整数, 而逻辑运算将左右两边都视为布尔值,然后返回0或1

逻辑非!的真值表如下:

输入 输出
0(假) 1(真)
1(真) 0(假)

逻辑与&&的真值表如下:

输入 输入 输出
0(假) 0(假) 0(假)
0(假) 1(真) 0(假)
1(真) 0(假) 0(假)
1(真) 1(真) 1(真)

逻辑或||的真值表如下:

输入 输入 输出
0(假) 0(假) 0(假)
0(假) 1(真) 1(真)
1(真) 0(假) 1(真)
1(真) 1(真) 1(真)

通过逻辑运算,可以完成各种逻辑表达式。如下的例子:

(1 + 3 > 4 && 2 == 2) || (!(2 < 3))

这个表达式的求值是:0(假)。

逻辑或和逻辑与都有短路求值的特性。由于与运算只要求出一个值是假就能确定返回是假, 如果左表达式已经是假,则右表达式不再求值,也没有副作用。看以下的例子:

#include <stdio.h>

int main(void)
{
    int a = 4;
    a || (a = 1);
    printf("%d", a);
    return 0;
}

此时输出的值为4。表达式a || (a = 1)中,左边a为真(不等于0), 因此右边的赋值不再求值,也就没有将a变为1。

同理还有逻辑或运算,如果左表达式已经是真,则右表达式不再求值。

我们在这里现在我们遇到的所有运算符的优先级:

优先级 运算符 结合性
+(正号)、-(负号)、~!
*/%
+(加号)、-(减号)
<<>>
<><=>=
==!=
&
^
|
&&
||
=

条件语句与三元运算符#

if语句#

if语句也叫条件判断语句,是表示“如果……,则……”意义的语句。其语法如下:

if语句

if (条件表达式) 语句

其中,条件表达式是一个表达式。 语句可以是复合语句

if语句的作用是:当条件表达式为时,执行所带有的语句/复合语句。 否则就跳转到所带有的语句/复合语句之后继续执行

从上面的语法看出,if表达式一共有两种形式

if (...) ...;
if (...)
{
    ...
}

前者只作用于一条语句,后者可以作用于一个块(复合语句)。 以下两个if语句是等价的:

if (var > 10)
{
    var = 30;
}
if (var > 10) var = 30;

当然,如果要作用于多个语句,只能选取后面的语法。如:

if (var > 10)
{
    var = 30;
    printf("%d", var);
}

Good-practice

对于控制流语句总是使用复合语句的语法是一个好习惯。 因为单条语句语法是无法添加语句到作用范围内的,并因此可能执行了不想要执行的语句

由于if语句本身也是个语句,所以你可以相互嵌套。如:

if (var > 10)
{
    var = 30;
    if (var != 15)
    {
        printf("%d", var);
    }
}

甚至是这样:

if (var > 10)
    if (var != 15)
    {
        printf("%d", var);
    }

Note

你可以使用逻辑运算符来避免某些多余的if跳转。如下面的例子:

if (var > 10)
{
    if (var != 15)
    {
        var = 30;
    }
}

实际上等价于:

if (var > 10 && var != 15)
{
    var = 30;
}

if-else语句#

if语句后面可跟随else子句形成if-else语句,表示“如果……,则……,否则……”,语法如下:

if-else语句

if (条件表达式) if子句 else else子句

其中,条件表达式是一个表达式。 if子句else子句是语句,可以是复合语句

if-else由两个子语句构成:if子句(跟在if的括号后)和else子句(跟在else后)。 其作用是:当条件表达式为时,执行if子句。 否则就跳转到else子句,然后跳出if-else语句继续执行。

下面的例子:

if (var > 10)
{
    var = 10;
}
else
{
    var = var + 2;
}

做了这么一件事:如果var大于10则将var改为10,否则给var加2。

Attention

else子句需要跟随在if子句的后面。不能悬空一个else而没有相应的if。 悬空一个else是语法错误。

else子句中的语句也可以是if语句。如下所示:

if (var > 10)
{
    var = 10;
}
else if (var < 5)
{
    var = var + 2;
}

该片段的意思是:如果var大于10则将var改为10,否则如果var小于5则加2。 像这样else子句是if语句,造成else后面接着if的,被非正式地做else-if语句

else子句也是if-else语句,就像这样:

if (var > 10)
{
    var = 10;
}
else if (var < 5)
{
    var = var + 2;
}
else if (var < 7)
{
    var = var + 3;
}
else
{
    var = 0;
}

上述的例子就是“如果……,否则如果……,否则如果……,否则……”的意思。

Attention

else始终与最近前接if相关联。也就是前方紧接着的那个if。

三元运算符#

三元运算符

条件表达式 ? 表达式真 : 表达式假

其中,条件表达式表达式真表达式假都是表达式。

三元运算符有些类似if-else语句,但是它是一个表达式。 如果条件表达式,则计算并返回:之前的表达式真, 否则计算并返回:之后的表达式假。 下面有一个例子:

int cond = 1;
int val = cond > 3 ? 1 : 0;

cond的值大于3则赋值val为1,否则为0.

三元表达式的优先级在逻辑运算符和赋值之间。?:之间视同有括号。

循环语句与赋值、自增自减运算符#

while语句#

while语句是最简单的循环语句(loop statement)。 之前我们遇到的语句最多执行一次,而循环语句使得某些语句可以反复地被执行。 下面来看一下while语句的语法。

while语句

while (条件表达式) 语句

其中,条件表达式是表达式。 语句可以是复合语句

我们把括号之内的表达式叫做循环条件,while带有的反复执行的语句叫循环体。 当程序执行到while语句时,首先判断循环条件是否为真,如果是,则执行循环体。 然后跳回到while处再次判断循环条件是否为真,如果是,则执行循环体。 如此循环往复直到某次判断为假,则跳出循环体继续执行。

flow of while

下面是一个使用while循环的例子:

#include <stdio.h>

int main(void)
{
    int i = 0;
    int sum = 0;
    while (i <= 100)
    {
        sum = sum + i;
        i = i + 1;
    }
    printf("%d", sum);
    return 0;
}

其输出的结果就是 \(\sum_{i = 0}^{100} i = 5050\).

Attention

如果循环条件始终为真,在函数体中又无法跳出循环。则程序就会永远在循环中执行,无法跳到外部。 我们称这样的循环为死循环(endless loop)。我们希望程序给出一个确定的结果, 因此我们大多数情况需要避免误写了死循环。 下面是可能造成死循环的情况:

  • 循环条件是一个常数值始终为真,内部无break。
  • 循环条件的变量无法在循环体执行时被改变
  • 循环的变量虽然被改变,但是改变的方向使得循环条件永真:如循环条件为a >= 0, 但是a在循环中永远增加,不能使得循环条件为假。
    • 这里特别要注意类型的影响,如unsigned是永远为非负的。
  • 循环条件为浮点数不相等,但是浮点数计算的误差累积导致永远不相等
    • 循环判断最好使用整数进行判断,或者使用浮点数大小比较

Hint

有些地方还是会用到“死循环”的。比如操作系统需要持续地于我们交互, 窗口程序需要等待我们的操作。它们不能立即执行完就退出,所以拥有一个持久的循环。 当然这种循环是有方法退出的,但是退出条件和方式比较复杂,所以更像一个死循环。

do-while语句#

while语句

do 语句 while (条件表达式);

其中,条件表达式是表达式。 语句可以是复合语句

do-while语句与while语句不同的是:先执行do后面的循环体, 然后到while的表达式去判断。如果为真则返回do执行循环体。 所以循环体至少执行一次

flow of do-while

例如下面的例子:

#include <stdio.h>

int main(void)
{
    int i = 0;
    int sum = 0;
    do
    {
        sum = sum + i;
        i = i + 1;
    } while (sum < 100);
    printf("%d", i);
    return 0;
}

复合赋值运算符#

复合赋值运算符将二元运算和赋值结合起来。先对左值和另一个值进行运算,再赋给原左值。 算术运算符和=结合就是对应的复合赋值运算符。如:+=。 每一个算术运算符对应一个复合赋值运算符。 复合赋值运算符和普通赋值运算符=优先级相同。复合赋值运算符返回计算结果,返回值是右值。

Grammar

左值表达式 += 表达式

左值表达式 -= 表达式

左值表达式 *= 表达式

左值表达式 /= 表达式

左值表达式 %= 表达式

左值表达式 ^= 表达式

左值表达式 &= 表达式

左值表达式 |= 表达式

左值表达式 <<= 表达式

左值表达式 >>= 表达式

其中,左值表达式必须可修改。

例如sum = sum + isum += i的效果相同,除了sum只求值一次。其他同理, 如:diff -= idiff = diff - i

接下来我们介绍来介绍复合赋值运算符和自增自减运算符。 它们是对诸如sum = sum + i;i = i + 1;这种左值参与计算后修改自身的简化。 这种计算在循环中时常被用到。

自增自减运算符#

Grammar

++ 左值表达式

-- 左值表达式

左值表达式 ++

左值表达式 --

其中,左值表达式必须可修改。

自增和自减运算符则是给左值增加/减少1的。++意思为自增(加1),--意思为自减(减1)。 返回的结果都是右值。

自增自减分为两种形式:前缀(如++a)和后缀(和a++)。两者有以下差别:

前缀 后缀
返回值 变化的值 变化的值
优先级 与正负号相同 最先,且高于正负号

以下的例子:

int i = 1;
int pre = ++i;
int post = i++;

prepost都被赋值为2.

求值顺序和序列点#

在C语言中,除了语言规定的情况之外,一个表达式的每个子表达式可以按照任意顺序计算。 这与运算符的结合性无关。

如:5 * 3 + 7 * 9 + 1 * 2这个表达式,虽然+从左向右结合, 但是5 * 37 * 91 * 2三个子表达式的值谁先被计算出结果是由编译器自己决定的。 优先性和结合性只会保证语义正确:如不会算出5 * (3 + 7)。又如:5 * 37 * 9计算加和, 再把加和和1 * 2相加。

任意性也体现在副作用上,虽然(++a) + 3计算值是与a + 1 + 3等同的, 但是++a有一个“自增”的副作用,这个副作用是否在加3时完成是由编译器决定的 (即究竟是a自增之后与3相加,还是a在还是原值时参与了a + 1 + 3计算,然后再加1)。

好在以上的例子都告诉我们,这样的任意性对最终结果几乎没有影响。 我们在开发时很难注意到它的存在。

为了正确性,C语言规定了一些情况求值顺序不能颠倒。为此C语言中由以下两个概念:

  • 先序于,如果A先序于B,则A在B之前完成
  • 序列点(sequence point),如果两个表达式E和F之间存在序列点, 则E的值计算副作用全部先序于F值计算和副作用。

以下是一些包含我们现在接触到内容的求值顺序:

  • 序列点
    • 一个完整表达式(即非其他表达式的子表达式,如后随分号;或者作为if、while的判断表达式) 的求值后有序列点。
    • 逻辑运算符&&||后有序列点。(用于短路求值)
    • 三元运算符中?后有序列点。
    • 在完整声明的结尾,有一个序列点。
  • 其他的先序
    • 运算符的运算数求值先序于运算符的返回值计算。(副作用顺序是未知的)
    • 赋值的左右运算数求值先序于赋值的副作用。
    • 后缀自增自减运算符的值计算先序于其副作用。

Undefined-behavior

对于同一个(标量)对象的两个副作用之间副作用和求值之间没有相对顺序, 则行为未定义。如:i = i++ + 1;

for语句#

Grammar

for (初始化子句; 条件表达式; 迭代表达式) 语句

其中,初始化子句条件表达式迭代表达式都可选。初始化子句可以为声明或表达式。 条件表达式迭代表达式都为表达式。

for语句相比其他两个循环语句,虽然在语法上较为复杂,但是具有易于理解的特点。 for语句首先处理初始化子句的内容,进行表达式求值或声明,然后循环地做以下事情:

  1. 判断条件表达式是否为真,若真则继续,否则跳出循环。
  2. 执行循环体
  3. 求值迭代表达式,并回到1.

flow of for

下面的一个例子:

int sum = 0;
for (int i = 0; i < 10; i++)
{
    sum += i;
}

首先申明了一个inti变量,在i小于10之前,每次给sum加上i,并给i加上1. 其等价于从i值为0开始,至9结束,依次地给sum加上i的值。 我们可以清楚地知道这个循环执行了10次。

需要规定循环次数的循环,使用for语句写比较简单,且不容易出错。 如果是规定循环次数通常我们会这样使用for语句:

for (int i = 0; i < N; i++)
{
    ...
}

其中i是循环变量,也就是我们想要跟踪循环次数所在初始化子句声明的变量,N是循环次数。

当然我们可以让i从1开始:

for (int i = 1; i <= N; i++)
{
    ...
}

可以递减:

for (int i = N; i > 0; i--)
{
    ...
}

可以以2作为增加量,这也叫步长

for (int i = 0; i < N; i += 2)
{
    ...
}

下面是一些for语句的说明:

初始化子句的声明的范围为整个for语句,包括条件表达式、迭代表达式和循环体。 如果想要让循环变量在循环外部可用,那就在外部声明,然后再初始化子句里面赋值。 甚至可以外部初始化,初始化子句空着。如:

int i = 0;
for (; i < N; i++)
{
    ...
}

如果条件表达式空着,那么代表这个条件永真。除非在循环体中能跳出循环之外,否则就是死循环。

跳转语句#

break语句#

除了在循环条件为假跳出循环之外,还有一种方法可以在循环体内部跳出循环,就是使用break语句。

break语句

break;

程序执行到break语句就会跳出循环。如果是多个循环语句相互嵌套,break会跳出它所在的那层循环, 而非跳出所有循环。

下面的例子:

#include <stdio.h>

int main(void)
{
    int i = 0;
    int sum = 0;
    while (1)
    {
        sum = sum + i;
        if (sum > 100)
        { 
            break;   
        }
        i = i + 1;
    }
    printf("%d", i);
    return 0;
}

可以看到,该循环语句的判断条件永远为真。但是在内部我们使用了break语句跳出这个循环。 你可以想想这个程序要求解的问题是什么。也试着把他改写为不用break语句的写法。

continue语句#

continue语句

continue;

continue语句可以在循环体中直接跳转到下一个循环开始,也就是条件表达式处进行判断。 对于for语句,跳转之前迭代表达式会被计算

flow of continue

#include <stdio.h>

int main(void)
{
    for (int i = 0; i < 10; i++)
    {
        if (i == 8)
        {
            continue;
        }
        printf("%d\n", i);
    }
    return 0;
}

这个程序不会打印8.

switch语句#

如果有这样的一个要求:给出一个数字,取对5的模数。然后分别对0~4每个不同的情况进行输出。 如果用else-if写,则会出现以下情况:

int input = 1001; /*any input*/
int res = input % 5;
if (res == 0)
{
    ...
}
else if (res == 1)
{
    ...
}
else if (res == 2)
{
    ...
}
...

写道2的时候恐怕已经不想写了。使用switch可以避免else-if的堆叠。

switch语句

switch (整数类型表达式) 语句

其中,语句可以包含有casedefault标号的语句。其格式如下:

case 常量表达式 : 语句

default: 语句

我们先来看一个switch语句的例子:

int input = 1001; /*any input*/
int res = input % 5;
switch (res)
{
case 0: printf("res is zero.");
    break;
case 1: printf("res is one.");
    break;
case 2: printf("res is two.");
    break;
case 3: printf("res is three.");
    break;
case 4: printf("res is four.");
    break;
}

上述程序在余数是0~4时分别输出对应的英语单词。相比else-if,它简洁许多,也更好懂。

switch语句的逻辑如下:

  1. 先计算表达式的值
  2. 跳转到有相应值case标号的语句,依次执行。
  3. 若无相应标号的语句,则跳转到default标号的语句执行。
  4. 若无相应标号的语句,也无default标号的语句,直接离开switch语句。
  5. 若在执行中遇到了这一层的break,则离开switch语句。

flow of switch

如果在case标号之间不添加break;,那么在执行到下一个case还会继续执行,直到遇到break;。 这叫做直落(fall through)。如下的例子:

int count = 3; /*any input*/
switch (count)
{
case 4: printf("4!\n");
case 3: printf("3!\n");
case 2: printf("2!\n");
case 1: printf("1!\n");
    break;
}

则会输出:

3!
2!
1!

Attention

确保你的直落是有意为之的。

case标号需要一个整数常量表达式整数字面量字符字面量都是整数常量表达式。 它们的通过算术运算符组成的表达式也是整数常量表达式。 换言之,整数常量表达式是在编译期就能确定的常量值。 我们之后会更加详细地介绍整数常量表达式。

Attention

常限定变量不是也不能构成整数常量表达式!下面的代码会有编译器报错:

const int lab = 1;
int test = 1;
switch (test)
{
case lab: break;
}

goto语句和标号#

标号

标识符 : 语句

goto语句

goto 标识符;

标号是给语句打上的标签,代表了程序的一个位置。 而goto语句可以在函数内部自由地跳转到某个带有标号的语句处。 既可以先声明标号再使用goto,向前方跳转;也可以先使用goto再声明标号,向后方跳转。

flow of goto

Good-practice

goto语句有较大的跳转自由性。通常我们不建议使用goto,因为:

  • 完全可以用if/while重写。
  • 使得控制流混乱,难于调试。
  • 跳过某些变量的初始化,使得某些变量出现野值

如:

int i = 1;
goto lab;
int j = 2;
lab: printf("%d", i + j);

但是有些地方使用goto比较方便,也比较清晰,如跳出多重循环。

int main(void)
{
    for (int i = 0; i < 4; i++) 
    {
        for (int j = 0; j < 4; j++) 
        {
            printf("%d\n", i + j);
            if (i + j >= 5) 
            {
                goto endloop;
            }
        }
    }
endloop:;
}

本章练习#

Exercise

计算 \(1001^{1001} \mod 2281701377\). 提示:

  1. \(a \times b \mod m = (a \mod m) \times (b \mod m) \mod m\)

  2. \(2281701377 < 2^{32}\)

1377575194

这是一个显而易见的实现,但不是最好的。

#include <stdio.h>

int main(void)
{
    const long long mod = 2281701377;
    long long ans = 1;
    for (int i = 0; i < 1001; i++)
    {
        ans *= 1001;
        ans %= 2281701377;
    }
    printf("%lld", ans);
    return 0;
}

它的循环次数是1001次。

我们使用一个叫“快速幂”的算法来提升我们的效率。

#include <stdio.h>

int main(void)
{
    const long long mod = 2281701377;
    long long base = 1001;
    long long ans = 1;
    for (long long index = 1001; index; index >>= 1)
    {
        if (index & 1)
        {
            ans *= base;
            ans %= mod;
        }
        base *= base;
        base %= mod;
    }
    printf("%lld", ans);
    return 0;
}

它的循环次数是9次。你可以自己尝试搜索并理解它的原理。

Exercise

计算斐波那契数列的前30项。输出每10个数为一行,行内数以空格分割, 可以以空格结尾每行,以换行结束。

1 1 2 3 5 8 13 21 34 55
89 144 233 377 610 987 1597 2584 4181 6765
10946 17711 28657 46368 75025 121393 196418 317811 514229 832040
1346269 2178309 3524578 5702887 9227465 14930352 24157817 39088169 63245986 102334155
165580141 267914296 433494437 701408733 1134903170 1836311903 2971215073 4807526976 7778742049 12586269025
#include <stdio.h>

int main(void)
{
    long long p = 1;
    long long q = 1;
    long long cur = 1;
    printf("1 1 ");
    for (int i = 3; i <= 50; i++)
    {
        p = q;
        q = cur;
        cur = p + q;
        printf("%lld ", cur);
        if (i % 10 == 0)
        {
            printf("\n");
        }
    }
    return 0;
}

Exercise

计算自然对数e的值,精确到小数点后6位。 提示: $$ e = \sum_{i=0}^\infty \frac{1}{n!} $$

想一想:你真的需要每次循环都重新计算一遍阶乘吗? 当一个项为多小的时候,对于小数点后6位已经没有作用了?

2.718282
#include <stdio.h>

int main(void)
{
    float e = 1;
    float inv_fact = 1;
    for (int i = 1; inv_fact > 1e-7; i++)
    {
        inv_fact *= 1.f / i;
        e += inv_fact;
    }
    printf("%f", e);
    return 0;
}