48、迭代控制结构与函数调用全解析

迭代控制结构与函数调用全解析

1. 迭代控制结构

在编程中,迭代控制结构是实现循环操作的重要工具,不同的循环结构适用于不同的场景。

1.1 forever..endfor 循环

常见的循环结构如 while 循环在循环开始处检查终止条件,repeat..until 循环在循环结束处检查终止条件。而 forever..endfor 循环则用于在循环体中间检查终止条件。

大多数现代编程语言都提供了 while 循环和 repeat..until 循环,但只有少数命令式编程语言(如 C/C++、Java、Pascal、BASIC 和 Ada)提供了显式的 forever..endfor 循环。实际上,forever..endfor 循环是这三种形式中最通用的,通过它可以轻松合成 while 循环或 repeat..until 循环。

在支持 while 循环或 repeat..until/do..while 循环的语言中,创建一个简单的 forever..endfor 循环很容易。例如在 Pascal 中,可以使用如下代码:

const
    forever := true;
while( forever ) do begin
    << code to execute in an infinite loop >>
end;

不过标准 Pascal 没有提供显式跳出循环的机制(除了通用的 goto 语句),但许多现代 Pascal 版本(如 Delphi)提供了 break 语句来立即退出当前循环。

C/C++ 语言虽没有显式创建无限循环的语句,但语法奇特的 for(;;) 语句从第一个 C 编译器诞生起就用于此目的。示例代码如下:

for(;;)
{
    << code to execute in an infinite loop >>
}

C/C++ 程序员可以使用 break 语句(结合 if 语句)在循环中间设置终止条件,示例如下:

for(;;)
{
    << Code to execute (at least once) prior to the termination test >>
    if( termination_expression ) break;
    << Code to execute after the loop-termination test >>
}

HLA 语言提供了显式的 forever..endfor 语句(以及 break 和 breakif 语句),可以在循环中间终止循环,示例如下:

forever
    << Code to execute (at least once) prior to the termination test >>
    breakif( termination_expression );
    << Code to execute after the loop-termination test >>
endfor;

将 forever..endfor 循环转换为纯汇编语言很简单,只需要一个 jmp 指令将控制从循环底部转移到顶部。break 语句的实现也很简单,就是一个跳转到循环后第一条语句的跳转(或条件跳转)。以下是 HLA 中 forever..endfor 循环(带 breakif)及其对应的“纯”汇编代码示例:

// High-level forever statement in HLA:
forever
    stdout.put
    ( 
     "Enter an unsigned integer less than five:" 
    );
    stdin.get( u );
    breakif( u < 5);
    stdout.put
    ( 
      "Error: the value must be between zero and five" nl 
    );
endfor;

// Low-level coding of the forever loop in HLA:
foreverLabel:
    stdout.put
    ( 
      "Enter an unsigned integer less than five:" 
    );
    stdin.get( u );
    cmp( u, 5 );
    jbe endForeverLabel;
    stdout.put
    ( 
      "Error: the value must be between zero and five" nl 
    );
    jmp foreverLabel;
endForeverLabel:

还可以通过代码旋转创建一个更高效的版本:

// Low-level coding of the forever loop in HLA using code rotation:
jmp foreverEnter;
foreverLabel:
    stdout.put
    ( 
      "Error: the value must be between zero and five" nl 
    );
foreverEnter:
    stdout.put
    ( 
      "Enter an unsigned integer less than five:" 
    );
    stdin.get( u );
    cmp( u, 5 );
    ja foreverLabel;

如果使用的语言不支持 forever..endfor 循环,一个不错的编译器会将 while(true) 语句转换为单个跳转指令。但不建议使用 goto 语句创建 forever..endfor 循环。如果编译器不能将 while(true) 转换为单个跳转指令,说明其优化能力较差,手动优化代码也往往是徒劳的。

1.2 强制布尔求值

在 forever 循环中,由于使用 if 语句退出循环,所以强制完全布尔求值和强制短路布尔求值的技术与 if 语句相同。

1.3 确定循环(for 循环)

forever..endfor 循环是无限循环(假设不通过 break 语句跳出),while 和 repeat..until 循环是不定循环,因为程序通常在首次遇到这些循环时无法确定循环将执行的迭代次数。而确定循环则不同,程序在执行循环体的第一条语句之前就能确切知道循环将重复的迭代次数。

Pascal 的 for 循环是传统高级语言中确定循环的典型例子,其语法如下:

for <<variable>> := <<expr1>> to <<expr2>> do
    <<statement>>

当 expr1 小于或等于 expr2 时,循环遍历 expr1 到 expr2 的范围;或者:

for <<variable>> := <<expr1>> downto <<expr2>> do
    <<statement>>

当 expr1 大于或等于 expr2 时,循环遍历 expr1 到 expr2 的范围。示例代码如下:

for i := 1 to 10 do
    writeln( "hello world" );

这个循环显然会精确执行十次,是确定循环。不过,编译器不必在编译时就能确定循环迭代次数,确定循环也允许使用在运行时确定迭代次数的表达式。例如:

write( "Enter an integer:" );
readln( cnt );
for i := 1 to cnt do
    writeln( "Hello World" );

Pascal 编译器无法确定这个循环的迭代次数,因为迭代次数取决于用户输入,每次执行该循环时迭代次数可能不同。但程序在遇到这个循环时能确切知道迭代次数(在这个例子中,cnt 变量的值决定了迭代次数)。

需要注意的是,Pascal(以及大多数支持确定循环的语言)明确禁止在循环体执行期间更改循环控制变量的值。高质量的 Pascal 编译器会检测并报告此类错误。而且确定循环只计算一次起始和结束值,即使循环体修改了作为第二个表达式的变量,循环也不会在每次迭代时重新计算该表达式。

确定循环具有一些特殊属性,使得优秀的编译器能够生成更好的机器代码。因为编译器在执行循环体的第一条语句之前就能确定迭代次数,所以通常可以省去复杂的循环终止测试,只需将一个寄存器递减到零来控制迭代次数。编译器还可以使用归纳法优化对循环控制变量的访问。

C/C++/Java 中的 for 循环并非真正的确定循环,而是不定 while 循环的特殊情况。大多数优秀的 C/C++ 编译器会尝试确定 for 循环是否为确定循环,并在能提前确定时生成不错的代码。为帮助编译器为 C/C++ 的 for 循环生成好的代码,应确保以下几点:
- C/C++ 的 for 循环应使用与 Pascal 等语言中确定循环相同的语义,即初始化单个循环控制变量,在该值小于或大于某个结束值时测试循环终止条件,并将循环控制变量递增或递减 1。
- 循环内不修改循环控制变量的值。
- 循环体执行期间循环终止测试保持不变,即循环体不能改变终止条件(否则循环将变为不定循环)。例如,如果循环终止条件是 i < j,循环体不应修改 i 或 j 的值。
- 循环体不通过引用将循环控制变量或出现在循环终止条件中的任何变量传递给会修改实际参数的函数。

2. 函数和过程调用

自 20 世纪 70 年代结构化编程革命开始以来,子程序(过程和函数)一直是软件工程师组织、模块化和结构化程序的主要工具。虽然 CPU 制造商努力使函数和过程调用尽可能高效,但这些调用及其相关返回操作仍有成本,许多程序员在创建函数时并未考虑到这些成本。不恰当使用过程和函数会导致程序大小和执行时间大幅增加。

2.1 简单函数和过程调用

函数是计算并返回某个值(函数结果)的代码段,过程(在 C/C++ 术语中为 void 函数)则只是完成某些操作。函数调用通常出现在算术或逻辑表达式中,过程调用看起来像编程语言中的语句。在大多数情况下,编译器对过程和函数调用的实现方式相同,但在函数返回值方面存在一些效率问题。

大多数 CPU 通过类似于 80x86 的 call 指令调用过程,使用 ret 指令返回给调用者。call 指令执行三个离散操作:
- 确定从过程返回时要执行的指令地址(通常是 call 指令紧随的下一条指令)。
- 将该地址(通常称为返回地址或链接地址)保存到已知位置。
- 通过跳转机制将控制转移到过程的第一条指令。

执行从过程的第一条指令开始,直到 CPU 遇到 ret 指令。ret 指令获取返回地址并将控制转移到该地址的机器指令。以下是一个 C 函数及其对应的 80x86 和 PowerPC 代码示例:

#include <stdio.h>
void func( void )
{
    return;
}
int main( void )
{
    func();
    return( 0 );
}

PowerPC 代码如下:

.text
; void func( void )
    .align 2
    .globl _func
_func:
    ; Set up activation record for function.
    ; Note R1 is used as the stack pointer by
    ; the PowerPC ABI (Application Binary
    ; Interface, defined by IBM).
    stmw r30,-8(r1)
    stwu r1,-48(r1)
    mr r30,r1
    ; Clean up activation record prior to the return
    lwz r1,0(r1)
    lmw r30,-8(r1)
    ; Return to caller (branch to address
    ; in the link register):
    blr
    .align 2
    .globl _main
_main:
    ; Save return address from
    ; main program (so we can
    ; return to the OS):
    mflr r0
    stmw r30,-8(r1) ;Preserve r30/31
    stw r0,8(r1)    ;Save rtn adrs
    stwu r1,-80(r1) ;Update stack for func()
    mr r30,r1       ;Set up frame pointer
    ; Call func:
    bl _func
    ; Return zero as the main
    ; function result:
    li r0,0
    mr r3,r0
    lwz r1,0(r1)
    lwz r0,8(r1)
    mtlr r0
    lmw r30,-8(r1)
    blr

80x86 代码如下:

.file   "t.c"
    .text
    .p2align 2,,3
; Conversion of void func( void )
    .globl func
    .type   func,@function
func:
    ; Construct activation record:
    pushl   %ebp
    movl    %esp, %ebp
    ; Clean up activation record:
    leave
    ; Return to main program:
    ret
    .globl main
    .type   main,@function
main:
    ; Build activation record for
    ; the main program.
    pushl   %ebp
    movl    %esp, %ebp
    subl    $8, %esp
    andl    $-16, %esp
    ; call func():
    call    func
    ; Return zero as the main
    ; function's result:
    xorl    %eax, %eax
    leave
    ret

从这些代码可以看出,80x86 和 PowerPC 都花费大量精力构建和管理激活记录。重要的是要注意 PowerPC 代码中的 bl _func 和 blr 指令以及 80x86 代码中的 call func 和 ret 指令,它们分别用于调用函数和从函数返回。

2.2 存储返回地址

CPU 存储返回地址的位置有多种选择。在没有递归和某些其他程序控制结构的情况下,CPU 可以将返回地址存储在任何足够大且在过程返回给调用者时仍能保存该地址的任意位置。例如,可以选择将返回地址存储在机器寄存器中,此时返回操作将是间接跳转到该寄存器中包含的地址。但使用寄存器存在问题,因为 CPU 的寄存器数量通常有限,每个保存返回地址的寄存器都无法用于其他目的。因此,在将返回地址保存在寄存器中的 CPU 上,应用程序通常会将返回地址移动到内存中,以便重用该寄存器。

以 PowerPC 的 bl 指令为例,它将控制转移到操作数指定的目标地址,并将 bl 指令之后的指令地址复制到 LINK 寄存器。在过程内部,如果没有代码修改 LINK 寄存器的值,过程可以通过执行 blr 指令返回给调用者。在前面的简单示例中,func() 函数没有执行修改 LINK 寄存器值的代码,因此就是这样返回给调用者的。但如果函数使用 LINK 寄存器用于其他目的,过程就有责任保存返回地址,以便在函数调用结束时通过 blr 指令返回之前恢复该值。

更常见的做法是将返回地址保存在内存中。虽然大多数现代处理器访问内存比访问 CPU 寄存器慢,但将返回地址保存在内存中允许程序进行大量嵌套过程调用。大多数 CPU 实际上使用栈来保存返回地址。例如,80x86 的 call 指令将返回地址压入内存中的栈数据结构,ret 指令将该返回地址从栈中弹出。使用栈保存返回地址有以下优点:
- 栈的后进先出(LIFO)组织方式完全支持嵌套过程调用和返回以及递归过程调用和返回。
- 栈在内存使用上高效,因为它可以为不同的过程返回地址重用相同的内存位置(而不是为每个过程的返回地址需要单独的内存位置)。
- 尽管栈访问比寄存器访问慢,但 CPU 通常可以比访问其他位置的单独返回地址更快地访问栈上的内存位置,因为 CPU 频繁访问栈,栈内容往往会保留在缓存中。
- 如前面章节所述,栈也是存储激活记录(如参数、局部变量和其他过程状态信息)的好地方。

使用栈也有一些代价。最重要的是,维护栈通常需要专门使用一个 CPU 寄存器来跟踪内存中的栈。这可以是 CPU 专门为此目的指定的寄存器(如 80x86 上的 ESP 寄存器),也可以是没有提供显式硬件栈支持的 CPU 上的通用寄存器(例如,运行在 PowerPC 处理器系列上的应用程序通常使用 R1 用于此目的)。

在提供硬件栈实现和 call/ret 指令对的 CPU 上,进行过程调用很简单。如前面 80x86 GCC 示例输出所示,程序只需执行 call 指令将控制转移到过程的开始,然后执行 ret 指令从过程返回。

PowerPC 使用“分支然后链接”指令的方法可能看起来比 call/ret 机制效率低。虽然“分支然后链接”方法确实需要更多代码,但并不一定比 call/ret 方法慢。call 指令是复杂指令(用一条指令完成多个独立任务),通常需要多个 CPU 时钟周期来执行,ret 指令的执行情况类似。额外的开销是否比维护软件栈更昂贵取决于 CPU 和编译器。然而,“分支然后链接”指令和通过链接地址的间接跳转,在不维护软件栈开销的情况下,通常比相应的 call/ret 指令对更快。如果一个过程不调用任何其他过程,并且可以将参数和局部变量保存在机器寄存器中,那么就可以完全跳过软件栈维护指令。例如,前面示例中对 func() 的调用在 PowerPC 上可能比在 80x86 上更高效,因为 func() 不需要将 LINK 寄存器的值保存到内存中,而是在整个函数执行过程中保持该值在 LINK 寄存器中。

由于许多过程很短且参数和局部变量较少,优秀的 RISC 编译器通常可以完全省去软件栈维护。因此,对于许多常见过程,这种 RISC 方法比 CISC(call/ret)方法更快。但不要认为 RISC 方法总是更好,前面的简单示例是非常特殊的情况。在完整的应用程序中,通过 bl 指令调用的函数可能距离 bl 指令很远,编译器可能无法将目标地址编码为指令的一部分。这是因为 RISC 处理器(如 PowerPC)必须将整个指令编码在单个 32 位值中(必须包括操作码和到函数的位移)。如果 func 距离太远,超出了剩余位移位(在 PowerPC 的 bl 指令中为 24 位)所能编码的范围,编译器就必须发出一系列指令来计算目标例程的地址,并通过该间接地址间接转移控制。大多数情况下,这不应是问题,毕竟很少有程序大到函数会超出这个范围(在 PowerPC 中为 64MB)。但有一种常见情况,GCC(以及其他编译器)必须生成此类代码,即当编译器不知道函数的目标地址时,因为它是一个外部符号,需要在编译完成后由链接器合并。由于编译器不知道例程在内存中的位置(而且大多数链接器只处理 32 位地址,而不是 24 位位移字段),编译器必须假设函数的地址超出范围,并发出长版本的函数调用。例如,对前面示例进行如下轻微修改:

#include <stdio.h>
extern void func( void );
int main( void )
{
    func();
    return( 0 );
}

此时 GCC 生成的 PowerPC 代码如下:

.text
    .align 2
    .globl _main
_main:
    ; Set up main's activation record:
    mflr r0
    stw r0,8(r1)
    stwu r1,-80(r1)
    ; Call a "stub" routine that will
    ; do the real call to func():
    bl L_func$stub
    ; Return zero as Main's function
    ; result:
    lwz r0,88(r1)
    li r3,0
    addi r1,r1,80
    mtlr r0
    blr
; The following is a stub that calls the
; real "func" function, wherever it is in
; memory.
    .data
    .picsymbol_stub
L_func$stub:
    .indirect_symbol _func
    ; Begin by saving the LINK register
    ; value in R0 so we can restore it
    ; later.
    mflr r0
    ; The following code sequence gets
    ; the address of the L_func$lazy_ptr
    ; pointer object into R12:
    bcl 20,31,L0$_func      ; R11<-adrs(L0$func)
L0$_func:
    mflr r11
    addis r11,r11,ha16(L_func$lazy_ptr-L0$_func)
    ; Restore the LINK register (used by the
    ; preceeding code) from R0:
    mtlr r0
    ; Compute the address of func and move it
    ; into the PowerPC COUNT register:
    lwz r12,lo16(L_func$lazy_ptr-L0$_func)(r11)
    mtctr r12
    ; Set up R11 with an environment pointer:
    addi r11,r11,lo16(L_func$lazy_ptr-L0$_func)
    ; Branch to address held in the COUNT
    ; register (that is, to func):
    bctr
; The linker will initialize the following
; dword (.long) value with the address of
; the actual "func" function:
    .data

综上所述,不同的循环结构和函数调用方式各有特点和适用场景,程序员需要根据具体需求合理选择和使用,以提高程序的性能和效率。

迭代控制结构与函数调用全解析

3. 宏与内联函数

宏和内联函数是提高程序执行效率的两种重要方式,它们都可以减少函数调用的开销。

3.1 宏

宏是一种预处理指令,在编译之前由预处理器进行文本替换。宏的定义通常使用 #define 指令。例如:

#define MAX(a, b) ((a) > (b) ? (a) : (b))

在代码中使用 MAX(x, y) 时,预处理器会将其替换为 ((x) > (y) ? (x) : (y)) 。宏的优点是没有函数调用的开销,执行速度快。但宏也有一些缺点:
- 缺乏类型检查 :宏只是简单的文本替换,不会进行类型检查,可能会导致一些难以发现的错误。
- 副作用 :如果宏的参数是表达式,可能会产生副作用。例如:

#define SQUARE(x) (x * x)
int a = 2;
int result = SQUARE(a++); 

这里 a 会被递增两次,结果可能不是预期的。

3.2 内联函数

内联函数是一种特殊的函数,编译器会尝试将内联函数的代码直接嵌入到调用处,而不是进行函数调用。在 C++ 中,可以使用 inline 关键字定义内联函数。例如:

inline int max(int a, int b) {
    return (a > b) ? a : b;
}

内联函数的优点是既有函数的类型检查和作用域规则,又能减少函数调用的开销。但内联函数也有一些限制:
- 编译器决策 inline 只是给编译器的一个建议,编译器不一定会真正将函数内联。例如,函数体过大的函数通常不会被内联。
- 代码膨胀 :如果内联函数被大量调用,会导致代码膨胀,增加程序的内存占用。

以下是宏和内联函数的使用对比表格:
| 特性 | 宏 | 内联函数 |
| ---- | ---- | ---- |
| 类型检查 | 无 | 有 |
| 副作用 | 可能有 | 无 |
| 调用开销 | 无 | 无(编译器内联时) |
| 代码膨胀 | 可能导致 | 可能导致 |
| 编译器控制 | 无 | 有(编译器决定是否内联) |

4. 参数传递与调用约定

在函数调用中,参数传递和调用约定是非常重要的概念,它们决定了参数如何传递到函数中以及函数如何返回结果。

4.1 参数传递方式

常见的参数传递方式有值传递、引用传递和指针传递。

  • 值传递 :函数接收的是参数的副本,函数内部对参数的修改不会影响到原始参数。例如:
void swap(int a, int b) {
    int temp = a;
    a = b;
    b = temp;
}
int main() {
    int x = 1, y = 2;
    swap(x, y);
    // x 和 y 的值不会改变
    return 0;
}
  • 引用传递 :函数接收的是参数的引用,函数内部对参数的修改会影响到原始参数。在 C++ 中可以使用引用类型。例如:
void swap(int& a, int& b) {
    int temp = a;
    a = b;
    b = temp;
}
int main() {
    int x = 1, y = 2;
    swap(x, y);
    // x 和 y 的值会交换
    return 0;
}
  • 指针传递 :函数接收的是参数的指针,通过指针可以修改原始参数的值。例如:
void swap(int* a, int* b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}
int main() {
    int x = 1, y = 2;
    swap(&x, &y);
    // x 和 y 的值会交换
    return 0;
}

以下是三种参数传递方式的对比表格:
| 传递方式 | 优点 | 缺点 |
| ---- | ---- | ---- |
| 值传递 | 安全,不会修改原始参数 | 有复制开销 |
| 引用传递 | 无复制开销,可修改原始参数 | 只能用于 C++ |
| 指针传递 | 可修改原始参数,可用于 C 和 C++ | 语法较复杂,可能有空指针问题 |

4.2 调用约定

调用约定规定了参数传递的顺序、栈的清理方式等。常见的调用约定有 __cdecl __stdcall __fastcall

  • __cdecl :参数从右到左入栈,由调用者负责清理栈。这是 C 和 C++ 默认的调用约定。
  • __stdcall :参数从右到左入栈,由被调用者负责清理栈。常用于 Windows API 函数。
  • __fastcall :部分参数通过寄存器传递,其余参数从右到左入栈,由被调用者负责清理栈。

调用约定的选择会影响函数的调用效率和兼容性。例如,不同调用约定的函数不能互相调用。

5. 激活记录与局部变量

激活记录是在函数调用时在栈上创建的数据结构,用于保存函数的上下文信息,包括参数、局部变量、返回地址等。

5.1 激活记录的结构

激活记录通常包含以下几个部分:
- 返回地址 :函数调用结束后要返回的地址。
- 参数 :传递给函数的参数。
- 局部变量 :函数内部定义的变量。
- 保存的寄存器值 :如果函数需要使用某些寄存器,可能会先保存这些寄存器的值,以便在函数返回时恢复。

以下是一个简单的 mermaid 流程图,展示了函数调用时激活记录的创建和销毁过程:

graph TD;
    A[调用函数] --> B[创建激活记录];
    B --> C[保存返回地址];
    B --> D[保存参数];
    B --> E[分配局部变量空间];
    C --> F[执行函数体];
    F --> G[销毁激活记录];
    G --> H[恢复寄存器值];
    G --> I[弹出参数];
    G --> J[返回调用处];
5.2 局部变量

局部变量是在函数内部定义的变量,它们的生命周期仅限于函数的执行期间。局部变量存储在激活记录中,当函数返回时,激活记录被销毁,局部变量也随之消失。例如:

void func() {
    int local_var = 10; 
    // local_var 存储在激活记录中
} 

局部变量的作用域仅限于定义它的函数内部,不同函数的局部变量可以同名,不会相互影响。

6. 函数返回结果

函数返回结果是函数执行完成后返回给调用者的值。函数返回结果的方式和效率会影响程序的性能。

6.1 返回值的传递

函数返回值可以通过寄存器或内存传递。

  • 寄存器传递 :对于较小的返回值(如整数、指针等),通常通过寄存器传递。例如,在 x86 架构中, eax 寄存器常用于返回整数类型的值。
  • 内存传递 :对于较大的返回值(如结构体、对象等),通常通过内存传递。调用者会在栈上分配一块内存,函数将返回值复制到这块内存中。
6.2 效率考虑

函数返回结果的效率与返回值的大小和传递方式有关。如果返回值较小,使用寄存器传递可以提高效率;如果返回值较大,频繁的内存复制会带来较大的开销。为了提高效率,可以考虑使用引用或指针作为返回值,避免不必要的复制。例如:

class LargeObject {
    // 包含大量数据的类
};

LargeObject& getLargeObject() {
    static LargeObject obj;
    return obj;
}

这里返回的是引用,避免了对象的复制。

综上所述,在编程中,合理使用迭代控制结构、函数调用、宏和内联函数等技术,正确处理参数传递、激活记录和函数返回结果,可以提高程序的性能和可维护性。程序员需要根据具体的应用场景和需求,选择最合适的方法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值