2014-07-29 20:16
深入剖析C/C++函数的参数传递机制
C语言的函数入口参数,可以使用值传递和指针传递方式,C++又多了引用(reference)传递方式。引用传递方式在使用上类似于值传递,而其传递的性质又象是指针传递,这是C++初学者经常感到困惑的。为深入介绍这三种参数传递方式,我们先把话题扯远些: 1、 C/C++函数调用机制及值传递: 在结构化程序设计方法中,先辈们告诉我们,采用“自顶向下,逐步细化”的方法将一个现实的复杂问题分成多个简单的问题来解决。而细化到了最底层,就是“实现单一功能”的模块,在C/C++中,这个最小的单元模块就是函数。然而,这些单个的模块(或者说函数)组合起来要能完成一项复杂的功能,这就注定各个函数之间必然要有这样或那样的联系(即耦合)。而参数耦合是各个函数之间最为常见的耦合方式,也就是说,各个函数之间通常通过参数传递的方式来实现通讯。 当我们设计或者调用一个函数时,首先要注意的是函数的接口,也就是函数的参数和返回值。调用一个函数就是将符合函数接口要求的参数传递给函数体,函数执行后返回一个值给调用者。(当然,C/C++允许void类型的参数和返回值。当返回值为void时,函数类似Basic的Sub子过程或者Pascal的Procedure过程。) 函数的参数传递,就是将在函数体外部已赋值(或者至少已经定义并初始化)的变量通过函数接口传递到函数体内部。根据变量种类的不同,有不同的参数传递方式: 若传递的参数是一个类对象(包括象Int和float这样的C/C++内部数据类型),这种传递方式为值传递。C/C++这种以函数为主体的语言中,几乎所有的功能都是通过函数调用来实现的。<不是吗?你说C/C++运算符操作?还有变量声明?你先等等,接下来我们就看看C++中这些操作是怎么实现的。>以下的C/C++代码是如此的简单,可能你从未想过还有什么要分析的,但它确实是函数值传递方式的典型例子。 float x = 0.254; float y = 3.1415; float z = x + y; 以上代码编译执行时,第一步float x,即声明一个实数变量。即将标志符x认为是一个实数变量,并调用float类的初始化函数。当然你可能感觉不到它的存在,因为现在的CPU都直接支持浮点运算,它只是一条汇编指令而已。 初始化完成后,调用赋值函数: x.operator = (0.254); 不要奇怪以上函数的写法,它实际上与 x = 0.254; 效果完全相同,会产生同样的汇编代码。 该函数首先根据变量x的数据类型分配合适的内存空间,并将该内存地址与标志符x关联。然后将立即数0.254写入分配的内存。(这里借用汇编语言的术语,立即数可以理解为程序已指定的具体数值。)然而,赋值函数的设计者并不能获知立即数0.254的数值,调用该函数时就必须通过参数传递的方法将数值通知给函数体。赋值函数接口大致是这样: float float::operator = (register float a); 变量a是在CPU寄存器中使用的临时变量。调用赋值函数时,将0.254送到寄存器变量a中,再将a值送到变量x所在的内存位置中。以上函数的返回值用于类似这样的链式表达式的实现: x = y = z; 说了许多,好象十分复杂,其实赋值操作仅仅只是两条汇编代码: mov AX, 0.254 mov [x], AX 事实上,它之所以简单,仅仅是因为float是CPU能直接处理的数据类型。若以上代码中不是float类型数据赋值,而是更复杂的(比如说自定义)类型数据,同样的赋值操作尽管是相同的步骤,但实际情况要复杂得多。因为寄存器容量限制,可能变量a无法作为寄存器变量存放,这样即使是简单的赋值操作也要为函数的临时变量分配内存并初始化,在函数的返回时,临时变量又要析构(或者说从内存中释放),这也就是参数值传递方式的弱点之一:效率低。以后我们还可以看到,值传递方式还有其力所不能及的时候。 上面的代码段中加法调用这样的函数,其参数传递方式同样是值传递: float::operator + (float a, float b); 下面看一个稍微复杂的类,Complex复数类。ObjectARX程序设计中使用的大部份对象类型都将比这个类复杂。 class Complex { public: Complex operator = (Complex others); //赋值函数,事实上不声明系统也会默认 Complex operator + (Complex c1, Complex c2); //加法 void Complex (float Re, float Im); //带参数的构造函数 //当然,真正的复数类接口远比这复杂,为了说明问题,仅写出这三个接口函数。 private: float Re; //复数的实部 float Im; //复数的虚部 } //类接口函数的实现应该并不复杂,在此略过。 类的接口函数的参数仍然用值传递方式。当执行下列代码中的加法和赋值操作时,程序将要多次执行Complex类的构造函数和析构函数。 Complex A(2.5, 3); Complex B(0.4, 2.5); Complex C = A + B; 最后一句代码,首先声明一个Complex类对象C,然后根据运算符优先级,执行加法运算,将对象A,B传递给加法函数,这时C++调用Complex类的默认构造函数声明两个临时变量,再调用默认的“拷贝构造函数”采用位拷贝的方法将对象A,B复制到临时变量,加法操作返回时,再将临时变量析构,返回值再用值传递方式传递给赋值函数。 从以上执行过程可以看出,值传递方式效率低的关键在于临时变量的建立和析构。于是考虑,因为在调用函数时该变量已经在内存中存在,将这个已经存在的变量直接传递给函数体而不去声明和拷贝临时变量。这样,临时变量的构造、拷贝、析构等工作都被省略,从而大大提高了函数效率。这便是使用C/C++指针和引用传递机制的主要原因。另外,使用这样的函数参数传递机制,在函数体内部可以很轻易地修改变量的内容。(而使用值传递方式,函数体内部只能修改临时变量,没有办法修改这些外部变量本身的值。)这样一方面增加了程序设计的灵活性,同时也给程序带来了安全隐患。当然,我们可以使用const声明防止变量的内容在函数体内部被修改,但这需要编程者有良好的编程风格和编程习惯。在介绍函数参数的指针和引用传递方式之前,先说一说指针和引用这两个概念。 2、指针和引用 在解释指针和引用之前,先看看普通变量是怎样在内存中存放的。声明变量后,编译程序要维护一张包括各种标识符的表。在这张表内,每一个标识符,比如说变量名都应该有它的类型和在内存中的位置。 在这要进一步说明几个问题,这些问题可能涉及多个计算机专业领域,我也不想在这作深入介绍,看不明白没有关系,不会影响您继续阅读这篇文章。 首先,C/C++的内存分配有静态分配和动态分配两种机制。静态分配内存是由编译程序为标识符分配固定的内存地址,而动态分配机制是应用程序在进入内存后再根据程序使用内存的实际情况决定变量存放地址。这个话题非常复杂,不过进行ObjectARX程序设计好象不必太在意内存分配机制,让编译程序和Windows去管这件事吧。而且内存分配机制对于我们理解指针和引用不会造成影响。 其次,标识符可以标识变量,也可以标识函数入口。从而它的类型可以是CPU能直接处理的内部数据类型<例如int类型>,也可以是用户自定义类型,还可以是函数类型。 另外,由于标识符的类型不同,它占用内存的大小也各有差异。“在内存中的位置”实际上指的是它占用的内存块的首地址。对于80286以上的计算机<这句话是不是多余?>,内存地址由基址(或段地址)加上偏移地址组成。基址是应用程序被调入内存时由操作系统分配,当然,编译程序把应用程序编译成多个段,从而要求操作系统对于不同的段分配不同的基址。而编译程序(哪怕是使用静态地址分配)只能决定标识符存放的偏移地址,也就是说,“在内存中的位置”只是标识符占用内存的第一个字节的偏移地址。说了这么多,有一点需要记住,无论是程序设计者还是编译程序都无法确知变量的内存中的实际位置。 最后,这个标识符表要比上面说的复杂,我只选择了与目前讨论的问题有关的内容。 好了,准备工作做了许多,让我们正式进入C/C++指针和引用的神秘世界。 指针变量其实质类似一个int整型变量。我们在源程序中这样声明一个指针变量: float *px; 此时,标识符px指示的内存位置上存放的就是一个int类型整数,或者说,通过变量px可以访问到一个int类型整数,并且这个整数与指针指向的数据类型<在此例中为float浮点数>无关。在ARX程序中,甚至可以用这样的方式打印一个指针变量: acutPrintf(“指针变量px的值为%d”, px); 当然,这个整数值到底意味着什么,可以只有计算机(或者说操作系统)自己知道,因为这个值表示的是指针指向的数据在内存中的位置。也就是说,不应该将指针变量与普通int整型混淆,例如,对指针进行四则运算将使用结果变得计算机和程序员都无法理解,尽管编译器允许你这样做。<实际上,计算数组下标就要使用指针的加法。> 与普通变量不同,若在程序中声明指针变量的同时不进行初始化,系统会自动将指针变量初始化为NULL。<NULL的值与0相同,但好的编程风格是使用NULL而非0,以与普通int类型区别。>而声明普通变量,系统仅为其分配内存,而不做自动初始化,从而未初始化的变量值是不可预测的。当然,直接使用未初始化的指针决不是一个好程序(此时编译器会发出警告信息),其危害或隐患以后在说明内存管理技术时再讨论。<若ObjectARX程序设计连载能坚持写下去,我想会要涉及到内存管理的。> 在声明时初始化指针变量可以这样: float *px = 0.254; 这是初始化同时赋值,也可以使用new运算符进行初始化: float *px = new float; 这种初始化方式经常用于不方便或不能直接赋值的复杂数据类型。 上述语句执行时,首先分配一块可存放数据的内存区域<大小与数据类型有关>,若要同时赋值,就调用赋值函数将数值写入刚分配的内存中。然后为标识符px分配一个int整型要占用的(通常为4字节)内存空间,最后将分配的用于存放数据的内存首地址写入内存。 注意:使用new运算符初始化指针,指针变量使用结束后应该用delete运算符释放其占用的内存。也就是说,new运算符和delete运算符最好能成对使用。 指针的初始化可以在程序的任何位置进行,<当然,最好在使用它之前初始化。>比如: float x = 0.254; float *px; //其它语句,请注意不要在这里使用px指针 px = &x; //在这进行px指针的初始化工作 上面最后一行代码是将变量x的地址赋值给指针px。以上初始化指针的方法效率相差无几,读者可自行分析。&运算符稍后讨论。 下面看一个ObjectARX程序中最为普通的使用指针的代码段:(注意,以下不是完整的代码,不能进行编译或执行。) void Sample(void) { //声明指针变量同时调用AcDbLine类的构造函数进行初始化。 //注意此时使用了new运算符为AcDbLine类对象分配内存。 AcDbLine *pLine = new AcDbLine(AcGePoint3d(10, 20, 0) , AcGePoint3d(50, 75, 0)); //下面看看如何访问指针变量及复杂类的接口函数。 pLine->setColorIndex(1); //将线对象的颜色设置为红色 //layer()函数返回一个指向char类型的指针,先声明一个指针变量用于接收返回值 char *pLayerName; pLayerName = pLine->layer(); acutPrintf(“n红色的线对象在%s图层上。”, pLayerName); } 这段代码不作深入分析,请读者注意pLine指针的声明和初始化过程以及pLayerName指针的赋值过程。 注意到我们在上面初始化px指针时使用了&运算符,即取地址运算符,这也是使用引用的一般方法之一。引用是C++概念,C++初学者容易将引用和指针混淆在一起。 以下代码中,m就是n的一个引用: int n; int &m = n; 编译程序编译以上代码时,在标识符表中添加一个int引用类型的标识符m,它使用与标识符n相同的内存位置。这样对m的任何操作实际上就是对n的操作,反之亦然。注意,m既不是n的拷贝,(这样的话,内存中应该的两块不同的区域,存放着完全相同的内容。),也不是指向n的指针,其实m就是n本身,只不过使用了另外一个名称而已。 尽管指针和引用都是利用内存地址来使用变量,它们之间还是有本质的区别: 首先,指针变量(包括函数调用时的临时指针变量)在编译和运行时要分配相当于一个int变量的内存空间以存放指针变量的值,尽管这个值表示的是指针指向的变量的地址。而引用与普通变量一样,标识符所指示的内存位置就是变量存放位置。这样不仅不需要在内存中分配一个int变量的内存空间(尽管它可能微不足道),而且在使用中可以少一次内存访问。<仅就内存使用效率而言,指针和引用所带来的区别确实不大,完全可以不去在意它。> 其次,由于标识符表在填写后就不能再被修改,因此引用在创建就必须初始化,并且初始化后,不能改变引用的关系。另外,引用初始化时,系统不提供默认设置,引用必须与合法的内存位置相关联。而这些特征对于指针而言都是不存在的。指针可以在程序任何时刻初始化,初始化的指针在程序中也可以根据需要随时改变所指向的对象,(这只需要改写指针变量的值就可以了。)当然,未初始化的指针变量系统会初始化为NULL,而NULL引用是非法的。 下面看一段类似文字游戏的程序: int I = 6; int J = 8; int &K = I; //K是I的引用 K = J; //K和I的值都变成了8 注意,由于引用关系不能被修改,语句K = J;并不能将K修改为对J的引用,只是修改了K的值。实际上,声明并初始化引用后,可以把引用当作普通变量来使用,只不过在操作时会影响另外一个变量。 以上代码仅仅只是解释引用的定义,并不能体现引用的价值。引用的主要功能在于函数的参数(或者返回值)的传递。 3、 函数参数的指针和引用传递机制 先看一下简单的例子。 void Func1(int x) //这个函数的参数使用值传递方式 { x = x + 10; } //当参数类型更复杂时,指针和引用传递方式在效率等方面的优势更为明显 //不过那样例子就不够“简单”了 void Func2(int *x) //这个函数的参数使用指针传递方式 { *x = *x + 10; }
void Func3(int &x) //这个函数的参数使用引用传递方式 { x = x + 10; } 以下代码调用这些函数: int n = 0; Func1(n); acutPrintf(“n = %d”, n); // n = 0 Func2(&n); acutPrintf(“n = %d”, n); //n = 10 Func3(n); acutPrintf(“n = %d”, n); //n = 20 以上代码段中,当程序调用Func1()函数时,首先在栈(Stack)内分配一块内存用于复制变量n。若变量n的类型复杂,甚至重载了该类的默认拷贝构造函数: CMyClass(const CMyClass &obj); 这个过程可能会比较复杂。<类的默认拷贝构造函数使用“位拷贝”而非“值拷贝”,若类中包括指针成员,不重载该函数几乎注定程序会出错。关于这个问题以后再深入探讨。> 程序进入函数Func1()体内后,操作的是栈中的临时变量,当函数结束(或者说返回)时,栈内变量被释放。而对于函数Func1()来说的外部变量n并未起任何变化,因此随后的acutPrintf函数将输出n = 0。 程序调用函数Func2()时,在栈内分配内存用于存放临时的指针变量x。然后用&运算取得变量n的地址,并拷贝给临时指针变量x作为x的值。此时,指针x就成了指向变量n的指针。在函数体内,*x运算得到的是指针x指向的内容,即变量n。对*x操作实际上就是对n操作。因此,在函数Func2()中变量n的值起了变化。在分析Func2()函数时应该注意到,临时指针变量x要指向的内存地址,也就是说变量x的“值”仍然是采用了值传递方式从函数外部(或者说函数调用者)获得,那么“值”也就应该具有值传递方式的特点,它要在栈中复制临时变量,它在函数体内被修改不会影响到函数外部。比如说,在上面的代码段中,函数Func2()内可以让指针x指向另外的变量,但函数结束或返回后,在函数外部是无法得到这样的指向另外变量的指针。 程序调用函数Func3()时,临时变量x是一个变量n的引用,此时变量x就是变量n本身,对x操作的同时,外部变量n也起了变化。实际上,引用能做的事,指针也能做到。 以上的代码段确实简单,以至还不能充分显示指针和引用在传递函数参数时的许多其他功能。下面我们设计这样一个函数,函数需要两个参数,在函数内将两个参数的值互换。由于值传递方式尽管能通过返回值赋值的方法修改一个参数值,但不能同时修改两个参数值,因此这个函数不能使用值传递方式。使用指针传递方式,函数可以写成这样: bool swap(int *x, int *y) { int temp; temp = *x; *x = *y; *y = temp; return true; } 以下代码调用该函数: int *a = 10; int *b = 15; if (swap(a, b)) { acutPrintf(“整数a,b已交换数据,a = %d, b = %d”, a, b); } 在以上代码中,swap()函数设计成与常见的ARX函数一致的风格,用一个bool类型返回函数执行状态。<在ARX中,这个返回值通常使用Acad::ErrorStatus类。>在调用函数时,由于变量a和b已经声明为指针,使用标识符a和b访问的是int类型变量的内存地址。 使用引用传递参数,可以这样设计swap()函数: bool swap(int &x, int &y) { int temp; temp = x; x = y; y = temp; return true; } 使用代码swap(int a, int b)调用以上函数时,进入函数体内,x、y分别是变量a、b的引用,对x、y操作就是操作变量a、b。函数返回后,变量a、b的值互相交换了。 注意:以上代码只是交换两个变量(或者指针指向的变量)的值。即将变量a、b(或指针a、b指向的变量)的修改为b、a(或指针b、a指向的变量)的值,而不是将指针a指向原来指针b指向的变量。也就是说,swap()函数调用前后,指针a和b的值(地址)并没有发生任何变化。(当然,引用关系在任何时候都不能修改。)要修改指针的地址值,应该使用指向指针的指针或者使用对指针的引用。这样设计和调用函数: bool swap(int **x, int **y); //使用指向指针的指针传递参数 int *a = 10; int *b = 15; swap(&a, &b); 或者: bool swap(int *&x, int *&y); //使用对指针的引用传递参数 int *a = 10; int *b = 15; swap(a,b); 在以上的两个swap()函数以交换两个指针的值,使指针a指向原来指针b指向的变量,指针b指向原来指针a指向的变量。 另外,由于引用关系不可修改,指向引用的指针和引用一个引用没有实际意义。若编译器允许它们存在,实际上也会退化为普通指针(或对指针的引用)和引用。这一点请读者自行分析。 最后,我们看一个ARX程序中使用指针和引用传递参数的函数例子: AcDbDatabase *pDb = new AcDbDatabase(); AcDbBlockTable *pBlkTbl; pDb->getBlockTable(pBlkTbl, AcDb::kForRead); 从ARX帮助中可以查看到,getBlockTable()函数的原型是: Acad::ErrorStatus getBlockTable( AcDbBlockTable*& pTable, AcDb::OpenMode mode); 其中可以看到,函数的第一个参数是对一个AcDbBlockTable类型指针的引用,从而可以在函数体内部对指针pBlkTbl进行修改,使之指向pDb指针指向的图形数据库的块表。
深入剖析C/C++函数的参数传递机制,布布扣,bubuko.com