使用 C 和 C++ 的编程语言概念/控制级别结构
本章讨论隐式和显式控制结构,这些结构影响计算机执行构成程序的一组操作的顺序,在表达式级别、语句级别和子程序级别。
此顺序有时由语言规则隐式指定,有时由程序员使用编程语言中的一些构造显式强加。前者被称为隐式控制结构,而后者被称为显式控制结构。
表达式级别的控制与对操作的控制有关,即计算表达式值的顺序。
表达式是计算值的公式,它表示为运算符、操作数和括号的正式序列。表达式产生一个计算值,该值驻留在临时存储位置,直到被使用。因此,表达式具有从其子表达式派生的隐式类型。
表达式写为运算符、操作数和括号的序列。运算符是通过单个符号或两个符号的字符串表示的原始函数。操作数表示数据值,实际上是访问值的工具,它可以是常量、变量名、函数引用、数组引用,甚至可以是另一个表达式。
编程语言通过优先级和结合性规则提供对表达式的隐式控制。程序员可以通过使用括号来强加显式控制。
表达式求值顺序。 |
---|
|
许多 PL 不指定运算符操作数求值的顺序。也就是说,在subexp1 op subexp2 中,程序员不能假设subexp1 在subexp2 之前被求值;这也适用于子程序参数求值顺序。在这两种情况下,编译器都可以自由选择求值顺序:当一个编译器按您的预期行为时,另一个编译器可能会选择打破它。避免这种情况的最佳方法是将产生副作用的表达式替换为没有副作用的表达式。例如,greater_than(i++, i); 必须替换为
|
运算符可以根据它接受的操作数数量进行分类。
- 一元运算符:它只接受一个操作数。例如,基于 C 的编程语言中的
++
、--
运算符。 - 二元运算符:运算符接受两个操作数。典型的例子是基本算术运算符
+
、-
、*
和/
。 - 三元运算符:只有一个运算符接受三个操作数,即条件表达式:
?:
示例:返回两个值中较大的 C 函数。 |
---|
int max(int i, int j) { return(i > j ? i : j); }
|
有许多方法可以表示表达式。它们是
- 函数式形式:函数式形式将所有操作表示为函数;操作数是这些函数的参数。它也称为应用式形式或普通前缀形式。使用此表示法,
9 + 7 * 3
将表示为Add(9, Mult(7, 3))
。 - 剑桥波兰语:作为 LISP 中使用的函数式形式的类型,括号围绕运算符及其相关操作数。根据此表示法,
9 + 7 * 3
表示为(Add 9 (Mult 7 3))
。 - 中缀:这是表达式的传统表示方式,其中运算符出现在操作数的中间。
- 前缀:在前缀表示法中,运算符出现在操作数之前。它也被称为波兰表示法。使用此表示法,
9 + 7 * 3
表示为+ * 7 3 9
。 - 后缀:在后缀表示法中,操作数出现在运算符之前。它也被称为逆波兰表示法。
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++ 程序片段。 | |
---|---|
| |
|
|
当程序的一部分需要执行零次或多次时,就会使用迭代组合。有四种基本的迭代结构。
也称为确定性循环,当我们知道循环执行的次数时,我们会使用这种结构。一个例子是Pascal中的for
循环。
示例:计算从1到n的数字之和的Pascal代码片段。 |
---|
|
请注意,C语言中的for
语句是一个功能更强大、更通用的结构,应该被视为一个“测试前”循环。除了实现非确定性循环之外,确定性循环也可以很容易地被模拟。例如,上面的代码片段可以写成
for (sum = 0, counter = 1; counter <= n; counter++) sum += counter;
在for
循环中有一个变体,它可以迭代一个值列表。它从列表中取出每个值(而不是从一个数字范围内取值)并对它执行一个或多个命令。
示例:一个csh代码片段,它检查每个命令行参数是选项还是非选项。 |
---|
|
测试前循环
[edit | edit source]作为两种非确定性循环之一,条件在循环开始时检查。这意味着循环体可能永远不会被执行。一个典型的例子是ALGOL语言中的while语句。
示例:一个Modula-3子程序,它计算两个数字的最大公约数。 |
---|
|
示例:一个C代码片段,用于计算单行上的字符数量。 |
---|
|
请注意,右括号后跟一个单独的分号。这意味着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' 为止。 |
---|
|
无条件循环
[edit | edit source]除了这三种结构之外,一些编程语言还提供了一种无条件循环结构。这种结构通常与exit语句一起使用。Modula-3中的LOOP-END结构就是一个例子。
示例:一个Modula-3代码片段,它计算从1到n的数字之和。 |
---|
|
Goto语句
[edit | edit source]已经证明,如果选择和迭代结构可用,那么任何程序都可以不使用goto语句编写。但这并不意味着它完全没有用。不同的迭代形式也可以用其他形式来表达。这是否意味着它们没有用?不!
用while循环表达C语言的for循环。 |
---|
|
可以使用while 循环重写为 |
|
众所周知,goto 是一种相当低级的控制结构,在可能的情况下最好避免使用它。但是,在某些情况下,它可能是最佳选择。例如,当我们需要提前退出循环时该怎么办?一些编程语言,如 C、C++、Java[1] 和 Modula-3,如无条件循环结构的示例所示,提供了break
和 continue
这样的控制结构;但有些编程语言没有[2]。在这种情况下,唯一的方法是 either 复杂化循环的测试表达式或者使用一个 goto
,其目标是循环结束后的语句。因此,推论是:避免使用它,但不要说它完全没有用。
子程序控制
[edit | edit source]子程序级别的控制与子程序调用以及调用模块和被调用模块之间的关系有关。以下是调用者和被调用者之间可能存在关系的列表。
简单调用
[edit | edit source]在简单调用中,调用者对被调用者拥有完全控制权。也就是说,它们之间存在主从关系。
当调用一个子程序时,执行控制权会转移到子程序的入口点,而下一个指令(即在子程序完成之后将要处理的指令)的地址会被保存。当执行过程中遇到子程序的出口点时,控制权会返回到调用者,以及与调用时保存的地址相对应的指令。
这种调用者和被调用者之间关系的内在约束是
- 子程序不能调用自身。
- 子程序通过调用程序中构成调用程序的一系列语句中的显式调用语句来调用。
- 在任何时间,只有一个子程序拥有执行控制权。因此,两个子程序不能被调用并发执行。
- 当然,调用程序对从属子程序拥有完全控制权,就像主程序对从程序一样。
递归
[edit | edit source]当我们删除上述列表中的第一个约束时,就会得到递归。对于递归子程序的每一次调用,都会创建一个单独的激活记录(帧)。考虑到调用指令的成本以及运行时堆栈中使用的空间,可以公平地说,递归解决方案在时间和空间方面都可能不如其迭代对应解决方案高效。但是,为了公平起见,应该强调,成本是由调用指令造成的,而不是递归本身。
在函数式编程语言中,递归(一种伪装的循环结构)比迭代更受欢迎。为了避免由于函数调用造成的性能损失和运行时堆栈溢出,Scheme(一种函数式编程语言)规定编译器将尾递归调用转换为 goto 语句。出于同样的原因,许多编译器(不是语言!)在其技巧包中包含了尾递归消除。
示例:使用 gcc 进行尾递归消除。 |
---|
|
如果您在没有优化的情况下编译后运行上述程序,对于足够大的数字,它将退出而不会写入任何结果。但是,在打开优化的情况下,保证会输出结果。 |
|
一些编程语言要求显式声明子程序为递归。例如,PL/I 和 FORTRAN 90。90 之前的 FORTRAN 和 COBOL 版本甚至不允许递归子程序定义。为什么这样一个重要的解决问题的工具被排除在外,并不是因为这些编程语言的设计者无法预见递归的使用。这些编程语言问世时,不得不与汇编语言和机器代码竞争。为了使这个目标更容易实现,他们的设计者决定将所有数据实体设为静态的,这自动排除了递归的可能性。
隐式调用
[edit | edit source]当我们取消子程序必须显式调用的要求时,我们就有了子程序在程序员控制范围之外被调用的可能性。这种情况以两种方式发生
- 异常处理:异常是指意外、不频繁且随机发生的事件。例如,除以零、下标越界和文件结尾。异常处理程序是由程序员编写的子程序,它与操作系统交互,只有在遇到指定的“异常情况”时才会被调用。
- 调度调用:调度调用是面向事件的仿真程序中使用的子程序控制类型的典型代表。对某些子程序的控制是由计时机制管理的,计时机制可能是内置在编程语言中的子程序,也可能是程序员编写的子程序。例如,假设子程序 A “调用”(即预调度)子程序 B,并指定一个激活时间。[3] 为子程序 B 创建一个激活记录,并将其插入到一个按照激活时间升序排列的队列中。计时例程会定期检查队列中的第一条记录;当其激活时间与当前模拟时间匹配时,该记录将从队列中删除,放置到运行时堆栈上,并调用该子程序。子程序也可以被调度为不是在明确的时间发生,而是在“尽快”发生。当子程序依赖于特定资源的可用性或另一个子程序的完成时,就会发生这种情况。
并行处理,也称为并发处理,是指同时执行两个或多个子程序以解决特定问题的过程。 并行性可以是真实的,即在多个处理器上实时同时执行,也可以是虚拟的,即在单个中央处理器上模拟的并行性。
示例:Ada83 中的任务。 |
---|
假设一家小型公司的会计部门负责执行以下功能(任务):订购用品、支付账单和准备发票。 只有一个会计的情况下,流程可以表示为 |
|
但是,如果有三个会计,这些任务可以并行执行。 Ada 对这种情况的典型表示将是 |
|
在设计具有并行控制的程序时,程序员必须考虑一些重要问题,包括同步、临界区和死锁。
如果我们取消调用者对被调用者拥有完全控制权的要求,我们就会得到相互控制。 相互控制子程序被称为协程。 协程可以互相调用。 但是,与普通调用不同,B 对 A 的调用实际上将控制权转移到子程序 A 中 A 最后调用 B 的位置。 不同的关键字(如 resume
)可以用作调用的替代。
操作系统中不同进程相互关联的方式大体相同。 当一个进程放弃处理器时,另一个进程会在处理器上执行。 一旦该进程完成了其时间段,它将放弃处理器,可能返回到前一个进程。 此进程不会从第一行开始执行,而是从其之前停止的位置继续执行。