你好,C++(25)函数调用和它背后的故事5.1.2 函数调用机制

5.1.2  函数调用机制

在前面的学习中,我们多次提到了“调用函数”的概念。所谓调用函数,就是将程序的执行控制权从调用者(某个函数)交给被调用的函数,同时通过参数向被调用的函数传递数据,然后程序进入被调用函数内部,执行函数定义中的代码获得结果数据,函数体代码执行完毕后再将控制权交回给调用者同时将结果数据通过返回值传递给调用者,作为整个函数调用表达式的值。简而言之,调用函数也就是执行函数中的代码,完成函数的功能。

在学习如何编写一个函数之前,首先要了解函数的调用机制,学会如何调用一个已经存在的函数。世界上已经有很多现成的功能各异的函数,我们可以直接调用这些函数来完成一些常见的开发任务。例如,直接使用标准函数库中的strcpy()函数就可以完成复制字符串的功能,这样可以复用他人的开发成果,避免了自己去开发相同功能函数的重复劳动,提高了开发效率。同时,这些函数已经经过实践的检验,其实现质量是值得信赖的,使用这些函数可以提高我们的代码质量。在实际的开发中,可供直接调用的现成函数主要有编译器提供的标准库函数、操作系统提供的API函数及第三方提供的函数库(如OpenGL)等,如图5-3所示。

                       

图5-3  天上掉下个函数库

有了他人提供的函数,就可以直接调用这些函数来完成自己的功能。可以通过下面的形式来调用一个函数:

返回值变量 = 函数名(参数);

这就是一个简单的函数调用语句,其中,用返回值变量来保存函数执行完毕后的返回值,如果函数的返回值类型为void,或者是我们不需要保存函数的返回值,则这个变量及其后面的“=”可以省略。整个函数调用语句的核心是用“函数名()”的形式所表达的对一个函数的调用。如果我们想调用某个函数,那就在其函数名之后加上一对小括号“()”表示对它的调用。如果这个函数的声明中有形式参数,那就按照函数声明中的参数类型和顺序,将实际的参数分别依次放到函数名之后的括号中,并用逗号间隔,形成函数调用的实际参数。通过这样的形式,就可以实现对一个函数的调用。例如:

// 用1和2作为实际参数,实现对Add()函数的调用
// 返回值保存到nRes中
int nRes = Add(1, 2);

这行代码就实现了对Add()函数的调用,它所表达的意义是,以1和2这两个数据作为实际参数调用Add()函数,然后程序进入Add()函数执行具体的计算,执行完毕后将执行结果保存到nRes变量中。这样,我们只需要简单地调用Add()函数,就得到了1和2两个数加法运算的结果3。至于具体的运算过程,可以交给Add()函数去处理,无需我们操心。

特别注意:对函数的调用应该在函数的声明或定义之后

这里值得特别指出的是,对函数的调用,应该在函数的声明或者定义之后,否则会出现“找不到标识符”的编译错误。这是因为函数的声明或定义确定了函数的调用方式(函数名和参数),编译器必须先知道这些信息,然后才知道如何去调用这个函数。这也就意味着,我们在调用一个函数之前,必须先声明或者定义这个函数。而如果这个函数是某个函数库所提供的,则在调用之前需要用“#include”预编译指令引入这个函数所在的头文件,因为其中有这个函数的声明。例如,如果我们想要使用标准函数库中的strcpy()函数,我们就需要先引入它的声明所在的<cstring>头文件。而这也正是要在某些源文件开始部分用“#include”预编译指令引入头文件的根本原因。

既然是函数调用,就存在一个谁调用谁的问题。我们把调用其它函数的函数称为主调函数,而把被调用的函数称为被调函数。调用的实质就是把主调函数的一部分工作交给被调函数去完成。那么,一个函数调用到底是如何进行的呢?在程序执行过程中,如果遇到了对其他函数的调用,那么首先会暂停主调函数的执行,保存执行现场,传递参数给被调函数;然后将执行控制权交给被调函数,并开始执行被调函数代码;当被调函数执行完毕或者是遇到return关键字时,被调函数返回将结果数据作为函数调用表达式的值,这时会恢复先前保存的执行现场,执行控制权交还给主调函数并继续主调函数的执行。下面来看一个实际的例子:

// 定义一个加法函数
// 将函数定义在被调用的位置之前,函数的声明和定义同时完成
int Add(int a, int b)
{
    int nRes = a + b;
     return nRes;
}

// 在主函数中调用加法函数
// 这时的main()主函数就是Add()函数的主调函数,Add()函数就是被调函数
int main()
{
    // 准备函数调用的实际参数
    int a = 1;
    int b = 2;
    // 以a和b作为实际参数调用Add()加法函数
    int nRes = Add(a, b);

    cout<<a<<" + "<<b<<" = "<<nRes<<endl;

    return 0;
}

这段代码展示的就是main()函数对实现加法运算的Add()函数的调用。程序在执行的时候,从­main()函数开始,首先定义a、b两个变量并对其赋值,然后以a和b为实际参数调用Add()函数来计算这两个数的和。虽然从表面上我们并不能看到函数调用的实现细节,但是在背后它却做了很多事情:

;Add()函数
int Add(int a,int b)
{
;保存现场…
009813DB  push        edi 
009813DC  lea         edi,[ebp-0CCh] 
009813E2  mov         ecx,33h 
009813E7  mov         eax,0CCCCCCCCh 
009813EC  rep stos    dword ptr es:[edi] 
;执行函数体代码,计算两个数的和  
int nRes = a + b;
009813EE  mov         eax,dword ptr [a] 
009813F1  add         eax,dword ptr [b] 
;将结果保存到nRes
int nRes = a + b;
009813F4  mov         dword ptr [nRes],eax 
;将结果数据nRes移动(MOV)到eax寄存器,函数返回  
return nRes;
009813F7  mov         eax,dword ptr [nRes] 
}

;…

;主函数  
;准备实际参数
int a = 1;
0098142E  mov         dword ptr [a],1 
int b = 2;
00981435  mov         dword ptr [b],2 
;开始函数调用
int nRes = Add(a,b);
;将实际参数压入(PUSH)调用栈,向函数内传递数据
0098143C  mov         eax,dword ptr [b] 
0098143F  push        eax 
00981440  mov         ecx,dword ptr [a] 
00981443  push        ecx 
;调用(CALL)Add函数,程序跳转到Add函数所在的地址开始执行
00981444  call        Add (0981087h) 
00981449  add         esp,8 
;将eax寄存器中存放的结果数据移动(MOVC)到nRes,获得函数执行后的结果数据
0098144C  mov         dword ptr [nRes],eax 
;…  

在执行函数调用表达式“Add(a,b)”的时候,首先会将两个实际参数a和b压入(PUSH)调用栈,向函数内传递数据。接着就是用CALL指令调用一个函数,也就是跳转到被调用函数所在的代码地址,开始进入被调函数Add()内部执行。进入Add()函数后,首先是保存现场环境,然后才是执行具体的函数体代码。利用“DWORD ptr [a]”的形式可以从调用栈中取出(MOV)之前压入的实际参数,这样就实现了从主调函数传递数据给被调函数的过程。这时Add()函数开始执行具体的运算过程得到结果数据并保存到nRes。最后用return关键字将计算结果nRes返回,也就是将结果数据移动到eax寄存器,作为整个函数调用表达式“Add(a,b)”的值。被调函数返回后,会首先恢复之前保存的执行现场,执行控制权重新交还给主函数。主函数继续向后执行,将函数调用表达式“Add(a,b)”的值3赋值给nRes变量,最后将计算结果输出。整个调用过程如图5-4所示。

 

 

图5-4  函数调用的执行流程

在图5-4中,箭头的方向代表整个程序的执行流程,虚线框包围的部分是系统为了实现函数调用而额外做的幕后工作。

在C++中,除了可以调用自己定义的函数来实现某个功能之外,更多时候,我们是直接调用一些函数库中已经定义好的函数,高效高质量地完成一些常见的编程任务,例如文件读写、字符串处理等。下面的例子就展示了如何调用Windows操作系统提供的GetLocalTime()函数(需要安装Windows SDK)来获取系统时间,从而方便快捷地实现一个闹钟程序:

// Alarm.cpp 闹钟程序
#include <iostream>
// 为了调用GetLocalTime()和Sleep()函数,
// 首先引入其声明所在的<Windows.h>头文件
#include <Windows.h>

using namespace std;

// 自己定义的闹铃函数
void Alarm()
{
    // 输出十个’/a’字符,计算机响铃十次
    for(int i = 0; i < 10; ++i)
    {
        cout<<'\a';
    }
}

int main()
{
    // 构造闹钟循环,不断获取当前时间并判断是否到了设定时间
    while(true)
    {
        SYSTEMTIME stLocal;
        // 直接调用GetLocalTime()函数获取系统时间
       GetLocalTime(&stLocal);

        // 判断是否到了设定时间7点
        if(7 == stLocal.wHour)
        {
             // 调用自己定义的函数,实现闹铃
             Alarm();
             // 已经闹铃,结束闹钟循环
             break;
        }

        // 如果尚未到达设定时间,调用Sleep()函数,
         // 程序执行暂停1秒钟开始下一次循环
        Sleep(1000);
    }

    return 0;
}

在main()主函数中,我们既调用了自己定义的Alarm()函数来完成闹铃的功能,又调用了Windows操作系统提供的GetLocalTime()函数来获取系统时间,同时还调用了Sleep()函数来暂停程序的执行。通过几个函数的综合运用,我们很快地就完成了一个闹钟程序。另外一方面,这些函数是经过实践检验的,我们无需担心这些函数出现问题。由此可见,合理地利用各种函数库所提供的现有函数,可以极大地提高我们的开发效率和质量。

我们知道函数的参数可以有默认值。在调用参数拥有默认值的函数时,既可以使用实际的参数像普通函数一样对其进行调用,又可以省略具有默认值的参数而直接使用参数的默认值对其进行调用,这样就使得函数的调用更加灵活。例如:

// 省略拥有默认值的参数,直接使用参数的默认值60,相当于调用IsPassed( 60 )
bool bPassed = IsPassed();
// 当做一个普通函数,给定具体的参数值进行调用
bool bPassed = IsPassed( 82 ); // 使用具体的参数值代替参数的默认值

同一个函数,其主调与被调的身份是相对而言的。很多情况下,一个被调函数同时也是主调函数,在被某个函数调用的同时,它也会调用其他的函数来完成更加具体的功能。例如,“泡面”函数会调用“烧水”函数,而“烧水”函数又会调用“洗锅”函数,等等。正是这样逐层向下地将一个比较大的任务层层分解成小任务,最终细小到一个函数就可以单独解决为止。这种“逐层向下分解”的思想,反映到C++语言中,就是函数的嵌套调用,第一个函数可以调用第二个函数,而第二个函数又可以调用第三个函数。以此类推,直到功能实现不再继续向下调用其它函数为止。

为了更好地理解函数的嵌套调用,下面来看一个计算平方和的程序。

// 计算平方函数
int Power( int n )
{
    return n*n;
}

// 计算平方和函数
int PowerSum( int a, int b )
{
    return Power(a) + Power(b);
}

// 计算平方和的主函
int main()
{
    // 调用求平方和函数
    int nRes = PowerSum(2,3);
//

    return 0;
}

我们知道,在数学中求平方和的方法是先求两个数的平方,然后再进行相加运算以求得两个数的平方和。这是一种“自底向上”的计算方式,而在程序设计中,却是反过来的“自顶向下”的计算方式。我们首先用PowerSum()函数来计算两个数的平方和,也就是将两个数的平方加和起来。这样问题就分解成了计算两个数的平方以及将他们加和起来。两个数的加和很好计算,只需要“+”操作符就可以实现,而计算一个数的平方比较复杂,我们继续用Power()函数来计算。这样,我们就可以用“Power(a) + Power(b)”来表示a、b两个数的平方和。接下来就是实现Power()函数计算一个数的平方,这就很好计算了,按照数学定义只要将这个数与自己相乘(n*n)就可以了。这样就实现了计算平方的功能,无需再继续向下分解。在具体执行的时候,main()主函数调用PowerSum()函数,而PowerSum()函数又嵌套调用Power()函数,这样就将一个比较复杂的问题通过不断细化和分解,最后转化为比较简单的问题并逐个得到解决,而这个过程,就是“自顶向下,逐步求精”的设计思想的体现,如图5-5所示。

知道更多:自顶向下,逐步求精

在开发实践中,一个程序要完成的任务往往是很复杂的,当无法在一个函数中完成这个复杂任务时,可以考虑采取“分而治之”的原则,将较大的任务分成多个较小的任务,如果分解后的小任务仍然十分复杂,则可以继续向下分解,直到任务足够简单可以在一个函数内完成为止。这种“自顶向下”逐层将任务分解的方式,反映到程序代码中就是函数的嵌套调用。就像盖一座大楼,首先要将大楼分成很多层,然后每层又分成很多套,而每一套又分成多个房间。这种将大问题逐渐分解的程序设计方法,被称为“自顶向下,逐步求精”的设计方法。也就是说,在写一个程序时,先应该考虑整体的结构,然后再不断细化,最终完成整个任务。

“自顶向下,逐步求精”的设计思想是结构化程序设计的精髓,这种方法符合人类解决复杂问题的普遍规律,可以显著提高软件开发的效率。同时,用先全局后局部、先整体后细节、先抽象后具体的“逐步求精”的过程开发出来的程序有清晰的层次结构,更容易阅读和理解,也更易于实现和维护。

 

 

图5-5  平方和程序的函数嵌套调用

posted @ 2014-12-17 13:32  你好,C++  阅读(1905)  评论(0编辑  收藏  举报