跳转到内容

使用 C 和 C++ 的编程语言概念/控制级别结构

来自维基教科书,开放的世界,开放的书籍

本章讨论隐式和显式控制结构,这些结构影响计算机执行构成程序的一组操作的顺序,在表达式级别、语句级别和子程序级别。

此顺序有时由语言规则隐式指定,有时由程序员使用编程语言中的一些构造显式强加。前者被称为隐式控制结构,而后者被称为显式控制结构。

对操作的控制

[编辑 | 编辑源代码]

表达式级别的控制与对操作的控制有关,即计算表达式值的顺序。

表达式

[编辑 | 编辑源代码]

表达式是计算值的公式,它表示为运算符操作数和括号的正式序列。表达式产生一个计算值,该值驻留在临时存储位置,直到被使用。因此,表达式具有从其子表达式派生的隐式类型。

表达式写为运算符、操作数和括号的序列。运算符是通过单个符号或两个符号的字符串表示的原始函数。操作数表示数据值,实际上是访问值的工具,它可以是常量、变量名、函数引用、数组引用,甚至可以是另一个表达式。

编程语言通过优先级结合性规则提供对表达式的隐式控制。程序员可以通过使用括号来强加显式控制。

表达式求值顺序。
 9 + 7 * 3 is evaluated as 9 + (7 * 3) because * has higher precedence than +.
 9 + 7 + 3 is evaluated as (9 + 7) + 3 because + associates to left.
 (9 + 7) * 3 is evaluated differently than the first expression due to the use of parentheses.
许多 PL 不指定运算符操作数求值的顺序。也就是说,在subexp1 op subexp2中,程序员不能假设subexp1subexp2之前被求值;这也适用于子程序参数求值顺序。在这两种情况下,编译器都可以自由选择求值顺序:当一个编译器按您的预期行为时,另一个编译器可能会选择打破它。避免这种情况的最佳方法是将产生副作用的表达式替换为没有副作用的表达式。例如,greater_than(i++, i); 必须替换为

k = i++; greater_than(k, i); 最后要注意的一点是:编程语言可能会提供对此一般规则的例外。例如,在 C 中,&&||?:, 的操作数以明确定义的顺序进行求值。

运算符可以根据它接受的操作数数量进行分类。

  • 一元运算符:它只接受一个操作数。例如,基于 C 的编程语言中的 ++-- 运算符。
  • 二元运算符:运算符接受两个操作数。典型的例子是基本算术运算符 +-*/
  • 三元运算符:只有一个运算符接受三个操作数,即条件表达式:?:
示例:返回两个值中较大的 C 函数。
int max(int i, int j) { return(i > j ? i : j); }

表达式的表示

[编辑 | 编辑源代码]

有许多方法可以表示表达式。它们是

  1. 函数式形式:函数式形式将所有操作表示为函数;操作数是这些函数的参数。它也称为应用式形式普通前缀形式。使用此表示法,9 + 7 * 3 将表示为 Add(9, Mult(7, 3))
  2. 剑桥波兰语:作为 LISP 中使用的函数式形式的类型,括号围绕运算符及其相关操作数。根据此表示法,9 + 7 * 3 表示为 (Add 9 (Mult 7 3))
  3. 中缀:这是表达式的传统表示方式,其中运算符出现在操作数的中间。
  4. 前缀:在前缀表示法中,运算符出现在操作数之前。它也被称为波兰表示法。使用此表示法,9 + 7 * 3 表示为 + * 7 3 9
  5. 后缀:在后缀表示法中,操作数出现在运算符之前。它也被称为逆波兰表示法9 + 7 * 3 表示为 7 3 * 9 +

请注意,前缀和后缀表示法是无括号的。这是因为我们不需要显式地强加求值顺序;运算符和操作数的顺序本身足以指示操作应用的顺序。

对语句的控制

[编辑 | 编辑源代码]

语句级控制关注构成程序的语句的排序——这是一种可以按顺序、通过选择备选方案或通过迭代来完成的组合。

简单序列

[编辑 | 编辑源代码]

简单序列,或顺序流,是语句级控制流的默认控制流。语句的执行遵循它们在程序源代码中的顺序。通常,仅靠简单序列不足以表达必要的计算。

当我们希望从两个或多个备选语句(或语句组)中进行选择时,就会使用选择性组合。选择性组合的典型例子是

  • if: if 语句根据指定表达式是否为真,提供对语句或语句块的条件执行。通常,它采用以下形式

if (condition) statement; else statement;

一些编程语言,如 Scheme 和 Perl,提供了一个 if 语句的否定版本:unless
  • case: 深度嵌套的 if-else 语句在语法上通常是正确的,但不能表达程序员的预期逻辑。例如,意外的 else-if 匹配更有可能被忽略。对语句的修改也很难正确执行。因此,作为在多个互斥选择之间进行选择的一种替代方法,大多数编程语言都提供了一个 case 语句。
需要注意的一点是:许多编程语言将选择器表达式的类型限制为整数值。也就是说,以下代码片段可能会被拒绝。

switch (name) { case "Deniz": ... break; case "Defne": ... break; case "Mete": ... break; ... default: ... } /* end of switch(name) */

示例:一个计算每个元音出现次数的 C++ 程序片段。

char ch; int aCnt = 0, eCnt = 0, iCnt = 0, oCnt = 0, uCnt = 0, consonantCnt = 0, othersCnt = 0;

// switch-case 版本 while (cin >> ch) switch (ch) { case 'a': case 'A': ++aCnt; break; case 'e': case 'E': ++eCnt; break; case 'i': case 'I': ++iCnt; break; case 'o': case 'O': ++oCnt; break; case 'u': case 'U': ++uCnt; break; default: if (isalpha(ch)) ++consonantCnt; else ++othersCnt; } /* end of switch(ch) */

// if-else 版本 while (cin >> ch) if (ch == a || ch == A) ++aCnt; else if (ch == e || ch == E) ++eCnt; else if (ch == i || ch == I) ++iCnt; else if (ch == o || ch == O) ++oCnt; else if (ch == u || ch == U) ++uCnt; else if (isalpha(ch)) ++consonantCnt; else ++othersCnt;

当程序的一部分需要执行零次或多次时,就会使用迭代组合。有四种基本的迭代结构。

索引循环

[编辑 | 编辑源代码]

也称为确定性循环,当我们知道循环执行的次数时,我们会使用这种结构。一个例子是Pascal中的for循环。

示例:计算从1到n的数字之和的Pascal代码片段。

sum := 0; for counter := 1 to n do sum := sum + counter;

请注意,C语言中的for语句是一个功能更强大、更通用的结构,应该被视为一个“测试前”循环。除了实现非确定性循环之外,确定性循环也可以很容易地被模拟。例如,上面的代码片段可以写成

for (sum = 0, counter = 1; counter <= n; counter++) sum += counter;

for循环中有一个变体,它可以迭代一个值列表。它从列表中取出每个值(而不是从一个数字范围内取值)并对它执行一个或多个命令。

示例:一个csh代码片段,它检查每个命令行参数是选项还是非选项。

foreach arg ($argv) # 它是否以 ‘-‘ 开头? if ($arg =~ -*) then echo “An option” else echo “Not an option” endif end

测试前循环

[edit | edit source]

作为两种非确定性循环之一,条件在循环开始时检查。这意味着循环体可能永远不会被执行。一个典型的例子是ALGOL语言中的while语句。

示例:一个Modula-3子程序,它计算两个数字的最大公约数。

PROCEDURE gcd(x, y: CARDINAL): CARDINAL = BEGIN WHILE y # 0 DO VAR temp: CARDINAL := y; BEGIN y := x MOD y; x := temp; END; END; RETURN x; END gcd;

示例:一个C代码片段,用于计算单行上的字符数量。

for (n = 0; getchar() != ‘\n; ++n) /* no-op */ ;

请注意,右括号后跟一个单独的分号。这意味着for循环包含一个空语句。

C语言中for循环的一般形式如下所示。 expression-1在循环开始之前被评估一次。它用于进行初始化。 expression-2在每次迭代之前被评估,只要它的结果为零(或者在更安全的编程语言如Java和C#中为假),执行就会从for循环后的下一行继续执行。 expression-3在每次迭代结束时被评估。

for (expression1; expression2; expression3) statement;

这些表达式中的任何一个都可以被省略,尽管分号必须保留。如果第一个表达式被省略,for循环将简单地不执行任何初始化。如果第三个表达式被省略,在迭代结束时将不会有任何副作用发生。至于第二个表达式的缺失,它将被视为真。也就是说,

for ( ; ; ) statement; while (any non-zero value) statement;

在Java和C#中,它等价于while (true) statement;

有关for循环和while循环等价性的更多信息,请参见Goto语句部分。

测试后循环

[edit | edit source]

在测试后循环的情况下,条件在循环结束时检查。因此,循环体至少被执行一次。典型的例子是Pascal语言中的repeat-until语句和C语言中的do-while语句。

示例:一个C代码片段,它不断从输入流中读取,直到读取到 'a' 为止。

do ch = getchar(); while (ch != a);

无条件循环

[edit | edit source]

除了这三种结构之外,一些编程语言还提供了一种无条件循环结构。这种结构通常与exit语句一起使用。Modula-3中的LOOP-END结构就是一个例子。

示例:一个Modula-3代码片段,它计算从1到n的数字之和。

sum := 0; LOOP IF counter > n THEN EXIT; END; /* end of IF */ sum := sum + counter; INC(counter, 1); END;

Goto语句

[edit | edit source]

已经证明,如果选择和迭代结构可用,那么任何程序都可以不使用goto语句编写。但这并不意味着它完全没有用。不同的迭代形式也可以用其他形式来表达。这是否意味着它们没有用?不!

用while循环表达C语言的for循环。

for (expression1; expression2; expression3) statement;

可以使用while循环重写为

expression1; while (expression2) { statement; expression3; }

众所周知,goto 是一种相当低级的控制结构,在可能的情况下最好避免使用它。但是,在某些情况下,它可能是最佳选择。例如,当我们需要提前退出循环时该怎么办?一些编程语言,如 C、C++、Java[1] 和 Modula-3,如无条件循环结构的示例所示,提供了breakcontinue 这样的控制结构;但有些编程语言没有[2]。在这种情况下,唯一的方法是 either 复杂化循环的测试表达式或者使用一个 goto,其目标是循环结束后的语句。因此,推论是:避免使用它,但不要说它完全没有用。

子程序控制

[edit | edit source]

子程序级别的控制与子程序调用以及调用模块和被调用模块之间的关系有关。以下是调用者和被调用者之间可能存在关系的列表。

简单调用

[edit | edit source]

在简单调用中,调用者对被调用者拥有完全控制权。也就是说,它们之间存在主从关系。

Control flow of simple call

当调用一个子程序时,执行控制权会转移到子程序的入口点,而下一个指令(即在子程序完成之后将要处理的指令)的地址会被保存。当执行过程中遇到子程序的出口点时,控制权会返回到调用者,以及与调用时保存的地址相对应的指令。

这种调用者和被调用者之间关系的内在约束是

  1. 子程序不能调用自身。
  2. 子程序通过调用程序中构成调用程序的一系列语句中的显式调用语句来调用。
  3. 在任何时间,只有一个子程序拥有执行控制权。因此,两个子程序不能被调用并发执行。
  4. 当然,调用程序对从属子程序拥有完全控制权,就像主程序对从程序一样。

递归

[edit | edit source]

当我们删除上述列表中的第一个约束时,就会得到递归。对于递归子程序的每一次调用,都会创建一个单独的激活记录(帧)。考虑到调用指令的成本以及运行时堆栈中使用的空间,可以公平地说,递归解决方案在时间和空间方面都可能不如其迭代对应解决方案高效。但是,为了公平起见,应该强调,成本是由调用指令造成的,而不是递归本身。

在函数式编程语言中,递归(一种伪装的循环结构)比迭代更受欢迎。为了避免由于函数调用造成的性能损失和运行时堆栈溢出,Scheme(一种函数式编程语言)规定编译器将尾递归调用转换为 goto 语句。出于同样的原因,许多编译器(不是语言!)在其技巧包中包含了尾递归消除。

示例:使用 gcc 进行尾递归消除。

#include <stdio.h> int add_expensive(int a, int b) { if (b > 0) return add_expensive(a + 1, b - 1); else if (b < 0) return add_expensive(a 1, b + 1); else return a; } /* end of int add_expensive(int, int) */ int main(void) { int a, b; printf("Enter two integers: "); scanf("%i %i", &a, &b); printf("%i and %i added gives %i\n", a, b, add_expensive(a, b)); return 0; } /* end of int main(void) */

如果您在没有优化的情况下编译后运行上述程序,对于足够大的数字,它将退出而不会写入任何结果。但是,在打开优化的情况下,保证会输出结果。

# 您可以使用 -O2、-O3 或 -Os 代替 -foptimize-sibling-calls gcc -foptimize-sibling-calls -o Tail_Recursion.exe Tail_Recursion.c↵ # 使用 DJGPP-gcc Tail_Recursion↵ Enter two integers: 123456789 987654321↵ 123456789 and 987654321 added gives 1111111110 gcc -o Tail_Recursion.exe Tail_Recursion.c↵ Tail_Recursion↵ Enter two integers: 123456789 987654321↵

一些编程语言要求显式声明子程序为递归。例如,PL/I 和 FORTRAN 90。90 之前的 FORTRAN 和 COBOL 版本甚至不允许递归子程序定义。为什么这样一个重要的解决问题的工具被排除在外,并不是因为这些编程语言的设计者无法预见递归的使用。这些编程语言问世时,不得不与汇编语言和机器代码竞争。为了使这个目标更容易实现,他们的设计者决定将所有数据实体设为静态的,这自动排除了递归的可能性。

隐式调用

[edit | edit source]

当我们取消子程序必须显式调用的要求时,我们就有了子程序在程序员控制范围之外被调用的可能性。这种情况以两种方式发生

  1. 异常处理:异常是指意外、不频繁且随机发生的事件。例如,除以零下标越界文件结尾。异常处理程序是由程序员编写的子程序,它与操作系统交互,只有在遇到指定的“异常情况”时才会被调用。
  1. 调度调用:调度调用是面向事件的仿真程序中使用的子程序控制类型的典型代表。对某些子程序的控制是由计时机制管理的,计时机制可能是内置在编程语言中的子程序,也可能是程序员编写的子程序。例如,假设子程序 A “调用”(即预调度)子程序 B,并指定一个激活时间。[3] 为子程序 B 创建一个激活记录,并将其插入到一个按照激活时间升序排列的队列中。计时例程会定期检查队列中的第一条记录;当其激活时间与当前模拟时间匹配时,该记录将从队列中删除,放置到运行时堆栈上,并调用该子程序。子程序也可以被调度为不是在明确的时间发生,而是在“尽快”发生。当子程序依赖于特定资源的可用性或另一个子程序的完成时,就会发生这种情况。

并行处理

[编辑 | 编辑源代码]

并行处理,也称为并发处理,是指同时执行两个或多个子程序以解决特定问题的过程。 并行性可以是真实的,即在多个处理器上实时同时执行,也可以是虚拟的,即在单个中央处理器上模拟的并行性。

Parallel flow of control
示例:Ada83 中的任务。
假设一家小型公司的会计部门负责执行以下功能(任务):订购用品、支付账单和准备发票。 只有一个会计的情况下,流程可以表示为

PROCEDURE Accounting IS BEGIN Order_Supplies; Pay_Bills; Prepare_Invoices; END Accounting;

但是,如果有三个会计,这些任务可以并行执行。 Ada 对这种情况的典型表示将是

PROCEDURE Accounting IS TASK Order; TASK BODY Order IS BEGIN Order_Supplies; END Order; TASK Pay; TASK BODY Pay IS BEGIN Pay_Bills; END Pay; BEGIN Prepare_Invoices; END Accounting;

在设计具有并行控制的程序时,程序员必须考虑一些重要问题,包括同步临界区死锁

如果我们取消调用者对被调用者拥有完全控制权的要求,我们就会得到相互控制。 相互控制子程序被称为协程。 协程可以互相调用。 但是,与普通调用不同,B 对 A 的调用实际上将控制权转移到子程序 A 中 A 最后调用 B 的位置。 不同的关键字(如 resume)可以用作调用的替代。

Control flow in coroutines

操作系统中不同进程相互关联的方式大体相同。 当一个进程放弃处理器时,另一个进程会在处理器上执行。 一旦该进程完成了其时间段,它将放弃处理器,可能返回到前一个进程。 此进程不会从第一行开始执行,而是从其之前停止的位置继续执行。

  1. Java 没有 goto 语句。 但它是一个保留字
  2. 与 Java 不同,C 和 C++ 没有带标签的 break。 在需要退出深度嵌套循环的情况下,我们可能最终需要使用带有 goto 的解决方案。
  3. 在模拟程序中,这通常是指模拟时间,而不是实际时间。
华夏公益教科书