函数的调用和返回

Posted 夕晖

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了函数的调用和返回相关的知识,希望对你有一定的参考价值。

 

说明

 

    本文翻译自斯坦福开放课程【编程范式】的系列课件,有删改。

    本系列会持续更新,预计一周发布1到2篇。

    如有意见/建议或是存在版权问题,欢迎园友指正。

    转载无需通知本人,但请注明出处,谢谢!

 

函数调用分析

 

    对有递归特性的编程语言来说,区分函数定义和函数调用是十分有必要的。函数定义规定了函数的行为,函数每次调用都创建一个函数实例。虽然一个函数只有一个定义,随着时间的流逝,它可能产生很多不同的实例。对于一个递归函数来说,若干个实例可能会同时存在。

    每个函数实例都需要分配内存空间,一个函数从调用到返回,它的活动记录包括以下内容:

  • 本地存储   包括参数、局部变量、以及编译器在实现函数时使用的辅助空间。
  • 返回信息   当这次调用结束时,执行流程应该在哪里继续?怎样继续?

    活动记录指的是一个函数实例被分配的内存块活动记录只存在于函数执行时,一旦函数返回,这块内存就会被释放。

 

活动记录

 

    一个活动记录保存和一个实例(一次调用、一个函数)相关的状态。活动记录的主要任务是保存参数和局部变量。实际上,活动记录也可能会保存辅助信息。

    编译器用函数声明生成活动记录。正如C语言中,一个活动记录就是一个集合,集合的每一项包括名字和类型(比如int a就是一项)。这些项存储在同一块内存中。下面的表达式把两个整数视为参数:

 

void Simple(int a, int b)  // 两个参数
{
    int temp1, temp2;  // 局部变量
    ....  
}

         

          从上面的声明可以看出,编译器生成活动记录时需要四个整数:a, b ,temp1和temp2。假设一个整数占据4个字节,那么这个活动记录至少需要16个字节。编译器不会在函数声明阶段产生一个活动记录,函数调用时,编译器会给这个函数实例分配内存空间。

 

 

    栈是一种分配、回收函数实例的完美结构。函数调用时,它的活动记录会入栈并且初始化。活动记录必须在函数执行之前保持不变。函数退出时,该活动记录占据的内存空间会被释放。当前活动记录出栈,上一个函数的实例成为新的栈顶。因为函数调用随处可见,栈分配、回收的效率就十分重要了。

 

void A(void){
    B();
}

void B(void){
    C();
}

void C(void){
    printf("Hi!");
}

          

          函数调用的时候有一个限制:函数必须以调用顺序的逆序返回。如果A先调用B,然后B调用C,最终函数必须以C、B、A的顺序返回。对于函数B的实例来说,在A实例之后和C实例之前返回是不可能的。只有正在执行的函数实例有权调用变量,其他所有实例都被暂时挂起。在上面的例子中,当C实例打印出“Hi!”的时候,A和B就被挂起。B在等待C退出,同样A也在等待B。

    栈这种结构非常适合存储活动记录--它准备好当前实例,同时保持其他的实例挂起。当维护一个集合时,栈是一种相当常用的抽象数据结构,就像堆成一列的盘子。如果从顶部观察的话,只能看见最上面的盘子。栈定义了两种操作--入栈和出栈。入栈就是拿一个新盘子放在栈的顶部。新盘子阻挡你看到下面的盘子。出栈就是拿掉最顶部的盘子,下面那个盘子成为这个栈的新“栈顶”。

    在这个函数调用的例子中,这堆盘子实际上就是活动记录。最上面的盘子就是当前正在执行函数的活动记录。当函数被调用之后,它的活动记录会出栈,下面的实例成为当前执行的函数。当该函数也退出后,它下面的实例继续执行。

 

活动协议

 

    编译器生成代码时必须遵循一连串协议,为的是把控制流和信息从当前执行环境传递到被调用函数。不同的语言,甚至相同语言的不同编译器可能使用着略微不同的协议。

    下面是一个用来传递参数、控制函数执行的简单协议:

  1. 当前执行环境分配一块内存,入栈同时生成了一个活动记录
  2. 初始化活动记录中的参数部分(这里是把当前执行环境的值复制到被调函数活动记录中)。
  3. 当前执行环境保存状态和返回信息。在参数之上入栈额外的4个字节。
  4. 被调函数成为新的当前执行环境,被调函数把它的局部变量入栈,执行函数。
  5. 被调函数正常执行,期间从它的活动记录中调用变量和参数。
  6. 当被调函数退出后,活动记录的部分内容出栈。
  7. 初始函数再度成为当前执行环境,依据返回信息继续执行,同时移除栈中被调函数的参数,被调函数的活动记录随之完全出栈。

    注意到在这个协议中,局部变量并没有被初始化--栈指针仅仅给它们分配了空间。参数看起来像是初始化了的局部变量。

    由于栈是很多计算机活动的核心,大部分计算机操纵栈的指令都很特殊,为的是使上述协议高效实现。有些编译器使用寄存器代替栈来传递参数,现在我们忽略这种情况。为了让操作更快,有几个寄存器专门用于内务管理。SP寄存器用于保存栈顶地址。PC寄存器专用于保存当前执行指令的地址,RV寄存器专用于传递函数的返回值。其他的寄存器(R1,R2,等)是多功能的,用途广泛。

    协议也规定出活动记录的空间是如何分配的。我们选一种简单的规则来进行说明。我们约定,把参数按从右到左的顺序入栈,(从右向左入栈和C编译器有关,这点会在后面详述),紧随其后的是返回地址,然后把局部变量按从上向下的顺序入栈。我们假设函数的返回值不会放在栈里,而是放在那个特殊的寄存器RV中。注意,这点决定了返回值不能超过一个机器字长,通常,这种情况会使用一种复杂的(常常也是颇费开销的)手段来处理,这里不提。就像大部分现代体系结构一样,实例栈也是从上往下增长,从高地址到低地址。下面以本文前段声明的Simple函数为例,给出一种活动记录布局:

 

    协议中划定了参数和局部变量的分布,我们可以把活动记录视做一对相似且相邻的结构,一块放变量、另一块放参数,把返回地址夹在中间。SP刚好指向局部变量块的基地址,上面紧跟着返回地址,再上面是参数块。

 

struct SimpleActivationLocal{
    int temp2; // 偏移量是 0(从栈顶开始计算)
    int temp1; // 偏移量是 4
};

struct SimpleActivationParameters{
    int a; // 偏移量是12(从栈顶开始计算)
    int b; // 偏移量是16
}

          

          在编译后的代码中,函数访问变量和参数时,靠的是它们各自距离栈顶的偏移量。在Simple函数里,参数b距离栈顶的偏移量是16字节。以下几点应当注意:

  • 协议允许编译器在不知道函数确切结束位置的情况下生成代码。
  • 每个函数实例运行同一份代码。
  • 当前使用的标识符不会在代码中留下任何痕迹,特殊变量的类型信息不会放在代码中,但是编译器能使用那些数据。

 

调用和返回

 

    为了支持函数调用操作,我们现在往指令集里添加两条指令。这两条特殊的指令直接控制函数之间的执行流、保存和恢复执行状态。

    Call指令比Jmp指令小一些,它能立刻把控制流重定向到另一个地址,它也能在栈上开辟空间、保存返回地址--当控制流返回到调用函数时,要执行的下一条指令的地址。我们列出函数名,以此控制它们的跳转。这实际上就是编译器的工作方法。链接器负责把函数名转换成地址。地址是运行时需要的东西,因为在那时我们不知道怎样在内存中通过函数名来找到一个函数。

 

# 跳转到strlen & 在栈中保存下一条指令的地址
Call <strlen>

    

    Ret指令通常是函数体生成代码中的最后一句。当函数执行完毕时,释放占用的栈空间,把返回值保存在RV寄存器,接着函数使用Ret指令恢复到调用函数的上下文。当Ret指令被调用时,我们设想栈指针已经指向了返回地址,所以它能很容易的把这个返回地址放入PC寄存器。当返回地址出栈(此时栈指针指向最右边的参数),此时PC寄存器正好指向被调函数之前的位置,就好像这个函数从来没有被调用过一样。

 

# 把控制权还给保存在栈上的指令地址
Ret

 

 

形式参数和实际参数

 

    结构化语言中的函数和过程经常会接收“参数”--由调用者提供的、传递给被调函数的值。在函数体内存在的参数和在运行时传入的参数是不同的。在函数里声明的、在函数体里用到的是“形式参数”。当函数被调用时传入的参数叫“实际参数”。比如:

 

void Jane(void){
    int arf, meow;
    John(arf, meow);
}

int John(int left,  int right){
    return (left * right);
}

 

    在上面的例子中,leftrightJohn函数的形式参数。当John函数被Jane函数调用的时候,arfmeowJohn函数的实际参数。

 

值参数

 

    在C语言里,所有的参数都是通过值来传递的。为了值参数,活动记录有一个槽位专门放置形式参数的副本。函数被调用时,调用者仅仅把实际参数的值拷贝到槽位里面。通过这种方式,后面形式参数发生的变化不会反映到实际参数上。

    也许,当被调函数改变它的形式参数时,我们想让它的实际参数也发生变化。比如:

 

int main(void){
    int i, j;
    i = 5;
    j = 76;
    Betty(i, j);
    printf("%d %d\\n", i ,j);
}

void Betty(int m, int n){
    m = 20;
    n = 30;
}

         

          这个过程的输出是:5 76

    也许,我们想在执行Betty函数时改变ij。C语言中有一个众所周知的方法:传递指针然后修改它们指向空间所存放的值。比如,如果我们想允许Betty函数(间接地)修改它的形式参数,我们可以这样写:

 

int main(void){
    int i, j;
    i = 5;
    j = 76;
    Betty(&i, &j);
    printf("%d %d\\n", i, j);
}

void Betty(int *m, int *n){
    *m = 20;
    *n = 30;
}

   

    会输出:20 30

 

   (全文终)

以上是关于函数的调用和返回的主要内容,如果未能解决你的问题,请参考以下文章

从使用 wkwebview 返回值的 javascript 调用 swift 函数

Swift6-函数

Python 函数声明和调用

如何测量代码片段的调用次数和经过时间

调用模板化成员函数:帮助我理解另一个 *** 帖子中的代码片段

python使用上下文对代码片段进行计时,非装饰器