C++ Primer中文版,第五版——第六章 函数

6.1函数基础

一个典型的函数定义包括以下几个部分:返回类型(return type)、函数名字、由0个或多个形参组成的参数列表以及函数体。其中,形参以逗号隔开,形参的列表位于一对圆括号之内。函数执行的操作在语句块中说明,该语句块称为函数体。

我们通过调用运算符(call operator)来执行函数。调用运算符的形式是一对圆括号,它作用于一个表达式,该表达式是函数或者是指向函数的指针;圆括号之内是一个用逗号隔开的实参列表,我们用实参初始化函数的形参。调用表达式的类型就是函数的返回类型。

编写函数

举个例子,我们准备编写一个求数的阶乘的程序。n的阶乘是从1到n所有数字的乘积,例如5的阶乘是120。即:1 * 2 * 3 * 4 * 5 = 120。

程序如下所示:

// val的阶乘是val * (val - 1) * (val - 2) ... * ((val - (val - 1))*1)

int fact(int val)

{

int ret = 1; // 定义一个局部变量,用于保存计算结果

while(val > 1)

ret *= val--; // 把ret和val的乘积赋值给ret,然后val减1

return ret; // 返回结果,同时将控制权从被调函数转移回主调函数,被调函数的返回值副本用于初始化调用表达式的结果。

}

函数的名字是fact,它作用于一个整型参数,返回一个整型值。在while循环内部,在每次迭代时,用后置递减运算符将val的值减1.return语句负责结束fact函数,并返回结果ret的值。

函数调用

要调用fact函数,必须提供一个int型值,调用得到的结果也是一个整数:

int main()

{

int j = fact(5); // j = 120,即fact(5)的结果。

cout << "5! is : " << j << endl;

return 0;

}

函数的调用完成两项工作:一、用实参初始化函数对应的形参;二、将控制权转移给被调函数。此时,主调函数的执行被暂时中断,被调函数开始执行。

执行函数的第一步是(隐式地)定义并初始化它的形参。因此,当调用fact函数时,首先创建一个名为val的int型变量,然后将它初始化为调用时所用的实参5。

当遇到一条return语句时函数结束执行过程。和函数的调用一样,return语句也完成两项工作:一是返回return语句中的值(如果有的话);二是将控制权从被调函数转移回主调函数。函数的返回值用于初始化调用表达式的结果,之后继续执行主调函数中调用所在表达式的剩余部分。因此,对fact函数的调用等价如下形式:

int val = 5; // (隐式地)定义并初始化形参val,即用字面值5初始化val

int ret = 1; // fact函数体内的代码

while(val > 1)

{

ret *= val--;

}

int j = ret; // 用被调函数的返回值ret的副本,初始化主调函数的调用表达式的结果j

形参和实参

实参是形参的初始值。第一个实参初始化第一个形参,第二个实参初始化第二个形参,以此类推。尽管实参和形参存在对应关系,但是并没有规定实参的求值顺序。编译器能够以任意可行的顺序对实参求值。

实参的类型必须与对应的形参类型匹配。函数有几个形参,就必须提供相同数量的实参。因为函数的调用规定实参数量应该与形参数量一致,所以形参一定会被初始化。

在上面的例子中,fact函数只有一个int型的形参,所以每次调用它的时候,都必须提供一个能转换成int型的实参:

fact("hello"); // 错误:参数类型不正确

fact(); // 错误:实参数量不足

fact(42,10,0); // 错误:实参数量过多

fact(3.14); // 正确:该实参类型能够转换成int类型

因为不能将const char*转换成int,所以第一个调用失败。第二个和第三个调用也会失败,不过错误原因与第一个不同,它们是因为传入的实参数量不对。要想调用fact函数智能使用一个实参,只要实参数量不是一个,调用都将失败。最后一个调用是合法的,因为double可以转换成int。执行调用时,参数隐式地转换成int类型(截去小数部分),调用等价于:fact(3);

函数的形参列表

函数的形参列表可以为空,但是不能省略。要想定义一个不带形参的函数,最常用的办法是书写一个空的形参列表。不过为了与C语言兼容,也可以使用关键字void表示函数没有形参。

void f1 () {/*...*/} // 隐式地定义空形参列表

void f2 (void) {/*...*/} // 显示地定义空形参列表,与C语言兼容

形参列表中的形参通常用逗号隔开,其中每个形参都是含有一个声明符的声明。即使两个形参的类型一样,也必须把两个类型都写出来:

int f3 (int v1,int v2) {/*...*/}

任意两个形参都不能同名,而且函数最外层作用域中的局部变量也不能使用与函数形参一样的名字。形参名是可选的,但是由于我们无法使用未命名的形参,所以形参都应该有个名字。偶尔,函数确实有个别形参不会被用到,则此类形参通常不命名,以表示在函数体内不会用到此类形参。不管怎样,是否设置未命名的形参并不影响调用时所提供的实参数量。即使某个形参不会被函数使用,也必须为它提供一个实参。

函数返回类型

大多数类型都能用作函数的返回类型。一种特殊的类型是void,它表示函数不返回任何值。函数的返回类型不能是数组类型或函数类型,但可以使指向数组或函数的指针。

6.1节练习:

6.3 编写你自己的fact函数,上机检查是否正确。

#include "stdafx.h"
#include <iostream>
//using namespace std;

int fact(int val)
{   

    int ret = 1; 
    while (val > 1)
    {
        ret *= val--;
    }
    return ret;
}

int main()
{
    int j = fact(5);
    std::cout << "5! is : " << j << std::endl ;
    return 0;
}

6.4 编写一个与用户交互的函数,要求用户输入一个数字,计算生成该数字的阶乘。在main函数中调用该函数。

 1 #include "stdafx.h"
 2 #include <iostream>
 3 using namespace std;
 4 
 5 void factorial_with_interacts()
 6 {
 7     int num;
 8     std::cout << "Please input a positive number: ";
 9     while (std::cin >> num && num < 0)
10         std::cout << "Please input a positive number again: ";
11     std::cout << num;
12 
13     unsigned long long result = 1;
14     while (num > 1) result *= num--;
15 
16     std::cout << "! is ";
17     if (result)
18         std::cout << result << std::endl;
19     else
20         std::cout << "too big" << std::endl;
21 }
22 
23 int main()
24 {
25     factorial_with_interacts();
26 }

6.5 编写一个函数输出其实参的绝对值。

 1 #include "stdafx.h"
 2 #include <iostream>
 3 using namespace std;
 4 
 5 void Absolute()
 6 {
 7     int val;
 8     cout << "Input a number : " << endl;
 9     while(cin >> val)
10     {
11         if (val >= 0)
12         {
13             cout << "The absolute value is: " << val << endl;
14         }
15         else
16             cout << "The absolute value is: " << -val << endl;
17     }
18 }
19 
20 int main()
21 {
22     Absolute();
23     return 0;
24 }

6.1.1局部对象

在C++中,名字有作用域,对象有生命周期。名字的作用域是程序文本的一部分,名字在其中可见。对象的生命周期是程序执行过程中该对象存在的一段时间。

函数体是一个语句块。块构成一个新的作用域,我们可以在其中定义变量。形参和函数体内部定义的变量统称为局部变量。它们对函数而言是“局部”的,仅在函数的作用域内可见,同时局部变量还会隐藏在外层作用域中同名的其他所有声明中。

在所有函数体之外定义的对象存在于程序的整个执行过程中。此类对象在程序启动时被创建,直到程序结束才会销毁。局部变量的生命周期依赖于定义的方式。

自动对象

对于普通局部变量对应的对象来说,当函数的控制路径经过变量定义语句时创建该对象,当到达定义所在的块末尾时销毁它。我们把只存在于块执行期间的对象称为自动对象。当块的执行结束后,块中创建的自动对象的值就变成未定义的了。

形参是一种自动对象。函数开始时为形参申请存储空间,因为形参定义在函数体作用域之内,所以一旦函数终止,形参也就被销毁。

我i们用传递给函数的实参初始化形参对应的自动对象。对于局部变量对应的自动对象来说,则分为两种情况:如果变量定义本身含有初始值,就用这个初始值进行初始化;否则,如果变量定义本身不含初始值,执行默认初始化。这意味着内置类型的未初始化局部变量将产生未定义的值。

注:

默认初始化:如果定义变量时没有指定初始值,则变量被默认初始化,此时变量被赋予了“默认值”。默认值到底是什么由变量的类型决定,同时定义变量的位置也会对此有影响。如果是内置类型的变量未被显示的初始化,它的值由定义的位置决定。定义于任何函数体之外的变量被初始化为0.然而,一种例外的情况是,定义在函数体内部的内置类型变量将不被初始化。一个未被初始化的内置类型的变量的值是未定义的,如果试图拷贝或以其他形式访问此类值将引发错误。类的对象如果没有显示地初始化,则其值由类确定。

局部静态对象

某些时候,有必要令局部变量的生命周期贯穿函数调用以及之后的时间,可以将局部变量定义成static类型,从而获得这样的对象。局部静态对象在程序的执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。

// 下面的函数统计它自己被调用了多少次

size_t count_calls ()

{

static size_t ctr = 0; // 调用结束后,这个值仍然有效

return ++ctr;

}

int main ()

for (size_t i = 0; i != 10; ++i)

cout << count_calls () << endl; // 输出从1到10(包括10)

return 0;

在控制流第一次经过ctr的定义时,ctr被创建并初始化为0.每次调用将ctr加1并返回新值。每次执行count_ctr函数时,变量ctr都已经存在并且等于上一次推出时ctr的值。因此,第二次调用时ctr的值是1,第三次调用时ctr的值是2,以此类推。

如果局部静态变量没有显示的初始化,它将执行值初始化,内置类型的局部静态变量初始化为0。

6.1.1 节练习:

6.7 编写一个函数,当它第一次被调用时返回0,以后每次被调用返回值加1。

#include "stdafx.h"
#include <iostream>

using namespace std;

size_t count_calls()
{
    static size_t ctr = 0;
    return ctr++;    //  ++ctr 和 ctr++的区别  
}

int main()
{
    for (size_t i = 0; i != 10; ++i)
        cout << count_calls() << endl; // 输出从0到9的数字(包括9)
    return 0;
}

6.1.2 函数声明(函数原型)

和其他名字一样,函数的名字也必须在使用之前声明。类似于变量,函数只能定义一次,但可以声明多次。唯一的例外是,如果一个函数永远也不会被我们用到,那么它可以只有声明没有定义。函数的声明和函数的定义非常类似,唯一的区别是函数声明无须函数体,用一个分号替代即可。

因为函数的声明不包含函数体,所以也就无须形参的名字。实际上,在函数的声明中经常省略形参的名字。尽管如此,写上形参的名字还是有用处的,它可以帮助使用者更好地理解函数的功能:

// 我们选择beg和end作为形参的名字以表示这两个迭代器划定了输出值的范围

void print(vector<int>::const_iterator beg,vector<int>::const_iterator end);

函数的三要素(返回类型、函数名、形参类型)描述了函数的接口,说明了调用该函数所需要的全部信息。函数声明也称作函数原型(function prototype)。

在头文件中进行函数声明

在之前我们建议,变量在头文件中声明,在源文件中定义。与之类似,函数也应该在头文件中声明而在源文件中定义。

看起来把函数的声明直接放在使用该函数的源文件中是合法的,也比较容易被人接受;但是这么做可能会很繁琐而且容易出错。相反,如果把函数声明放在头文件中,就能确保同一函数的所有声明保持一致。而且一旦我们想要改变函数的接口,只需要改变一条声明即可。

定义函数的源文件应该把含有函数声明的头文件包含进来,编译器负责验证函数的定义和声明是否匹配。

Note:含有函数声明的头文件应该被包含到定义函数的源文件中。

6.1.3 分离式编译

6.2 参数传递

每次调用函数时都会重新创建它的形参,并用传入的实参对形参初始化。形参的初始化的机理与变量一样。和其他变量一样,形参的类型决定了形参和实参交互的方式。如果形参是引用类型,它将绑定到对应的实参上;否则,将实参的值拷贝后赋给形参。

当形参是引用类型时,我们说它对应的实参被引用传递或者函数被传引用调用。和其他引用一样,引用形参也是它绑定的对象的别名;也就是说,引用形参是它对应的实参的别名。

当实参的值被拷贝给形参时,形参和实参是两个相互独立的对象。我们说这样的实参被值传递或者函数被传值调用

6.2.1 传值参数

当初始化一个非引用类型的变量时,初始值被拷贝给变量。此时,对变量的改动不会影响初始值:

int n = 0; //int类型的初始变量

int i = n; //i是n的值的副本

i = 42; //i的值改变;n的值不变

传值参数的机理完全一样,函数对形参做的所有操作都不会影响实参。例如,在fact()函数内对变量val执行递减操作:

ret *= val--; //将val的值减1

尽管fact函数改变了val的值,但是这个改动不会影响传入fact的实参。调用fact(i)不会改变i的值。

指针形参

指针的行为和其他非引用类型一样。当执行指针拷贝操作时,拷贝的是指针的值。拷贝之后,两个指针是不同的指针。因为指针使我们可以间接地访问它所指的对象,所以通过指针可以修改它所指对象的值:

int n = 0,i = 42;

int *p = &n,*q = &i; // p指向n,q指向i

*p = 42; // n的值改变,p不变

p = q; // p现在指向了i;但是i和n的值都不变

指针形参的行为与之类似:

// 该函数接受一个指针,然后将指针所指的值置为0

void reset(int *ip)

{

*ip = 0; // 改变指针ip所指对象的值

ip = 0; // 只改变了ip的局部拷贝,实参未被改变

调用reset函数后,实参所指的对象被置为0,但是实参本身并没有改变:

int i = 42;

reset(&i); //改变 i 的值而非 i 的地址

cout << " i = " << i << endl; //输出 i = 0

Note:熟悉C的程序员常常使用指针类型的形参访问函数外部的对象。在C++语言中,建议使用引用类型的形参代替指针。

6.2.2 传引用参数

前面说过,对于引用的操作实际上是作用在引用所引的对象上:

int n = 0, i = 42;

int &r = n; // r绑定了n(即r是n的另一个名字)

r = 42; // 现在n的值为42

r = i; // 现在n的值和i相同

i = r; // i的值和n相同

引用形参的行为与之类似。通过使用引用形参,允许函数改变一个或多个实参的值。

举个例子,我们可以改写上一小节的reset程序,使其接受的参数是引用类型而非指针:

// 该函数接收一个int对象的引用,然后将对象的值置为0

void reset( int &i ) // i是传给reset函数的对象的另一个名字

{

i = 0; // 改变了i所引用的对象的值

}

和其他引用一样引用形参绑定初始化它的对象。当调用这一版本的reset函数时,i绑定我们传给函数的int对象,此时改变i也就是改变了i所引用对象的值。此例中,改变的对象是传入reset函数的实参。

调用这一版本的reset函数时,我们直接传入对象而无须传递对象的地址:

int j = 42;

reset(j); // j采用传引用方式,它的值被改变

cout << " j = " << j << endl; // 输出j = 0

在上述调用过程中,形参i仅仅是j的又一个名字,在reset内部对i的使用即是对j的使用。

使用引用避免拷贝

拷贝大的类类型对象或者容器对象比较低效,甚至有的类类型(包括IO类型在内)根本就不支持拷贝操作。当某种类型不支持拷贝操作时,函数只能通过引用形参访问该类型的对象。

举个例子,我们准备编写一个函数比较两个string对象的长度。因为string对象可能会非常长,所以应该尽量避免直接拷贝它们,这时使用引用形参是比较明智的选择。又因为比较长度无须改变string对象的内容,所以把形参定义成对常量的引用:

// 比较两个string对象的长度

bool isShorter( const string &s1, const string &s2 )

{

return s1.size( ) < s2.size( );

}

当函数无须修改引用形参的值时最好使用常量引用。

注:如果函数无须改变引用形参的值,最好将其声明为常量引用。

使用引用形参返回额外信息

一个函数只能返回一个值,然而有时函数需要同时返回多个值,引用形参为我们一次返回多个结果提供了有效的途径。举个例子,我们定义一个名为find_char的函数。它返回在string对象中某个指定字符第一次出现的位置。同时,我们也希望函数能返回该字符出现的总次数。

该如何定义函数使得它能够既返回位置也返回出现次数呢?一个方法是定义一个新的数据类型,让它包含位置和数量两个成员。还有另外一种更简单的方法,我们可以给函数传入一个额外的引用实参,令其保存字符出现的次数:

// 返回s中c第一次出现的位置索引
// 引用形参occurs负责统计c出现的总次数
string :: size_type find_char(const string &s,char c, string :: size_type &occurs)
{
   auto ret = s.size( );   // 第一次出现的位置(如果有的话)
   occurs = 0;             // 设置表示出现次数的形参的值
   for (decltype(ret) i = 0; i != s.size(); ++i)
   {
      if (s[i] == c)
      {
        if(ret == size())
            ret = i;     // 记录c第一次出现的位置
        ++occurs;        // 将出现的次数加1
      }    
   } 
   return ret;        // 出现次数通过occurs隐式返回
}

当我们调用find_char函数时,必须传入三个实参;作为查找范围的一个string对象、要查找的字符以及一个用于保存字符出现次数的size_type对象。假设s是一个string对象,ctr是一个size_type对象,则我们通过如下形式调用find_char函数:

auto index = find_char(s,'o', ctr);

调用完成后,如果string对象中确实存在o,那么ctr的值就是o出现的次数,index指向o第一次出现的位置;否则如果string对象中没有o,index等于s.size()而ctr等于0。

6.2.3 const形参和实参

当形参是const时,必须注意前面关于顶层const的讨论。如前所述,顶层const作用于对象本身:

const int ci = 42; // 不能改变ci,const是顶层的

int i = ci; // 正确:当拷贝ci时,忽略了它的顶层const

int *const p = &i; // const是顶层的,不能给常量指针p赋值

*p = 0; // 正确:通过p改变对象的内容是允许的,现在i变成了0

和其他初始化过程一样,当用实参初始化形参时会忽略掉顶层const。换句话说,形参的顶层const被忽略掉了。当形参有顶层const时,传给它常量对象或者非常量对象都是可以的:

void fcn(const int i) { /***fcn能够读取i,但是不能向i写值***/ }

调用fcn函数时,既可以传入const int也可以传入int。忽略掉形参的顶层const可能会产生意想不到的后果:

void fcn(const int i) { /***fcn能够读取i,但是不能向i写值***/ }

void fcn(int i) { /***......***/ } // 错误:重复定义了fcn(int)

在C++语言中,允许我们定义若干具有相同名字的函数,不过前提是不同函数的形参列表应该有明显区别。因为顶层const被忽略掉了,所以在上面的代码中传入两个fcn函数的参数可以完全一样。因此第二个fcn是错误的,尽管形式上有差别,但实际上它的形参和第一个的形参没什么不同。

指针或引用形参与const

形参的初始化方式和变量的初始化方式是一样的,所以回顾通用的初始化规则有助于理解本节知识。我们可以使用非常量初始化一个底层const对象,但是反过来不行;同时一个普通的引用必须同类型的对象初始化。

int i =42;

const int *cp = &i; // 正确:cp是指向常量的指针,但是不能通过cp改变i的值

const int &r = i; // 正确:但是不能改变i

const int &r = 42; // 正确:允许用字面值初始化一个常量引用

int *p = cp; // 错误:指针p的类型和cp的类型不匹配

int &r3 = r; // 错误:r3的类型和r的类型不匹配

int &r4 = 42; // 错误:不能用字面值初始化一个非常量引用

将同样的初始化规则应用到参数传递上可得如下形式:

int i = 0;

const int ci = i;

string :: size_type ctr = 0;

reset(&i); // 调用形参类型是int*的reset函数

reset(&ci); // 错误不能用指向const int对象的指针初始化int*

reset(i); // 调用形参类型是int&的reset函数

reset(ci); // 错误:不能把普通引用绑定到const对象ci上

reset(42); // 错误:不能把普通引用绑定到字面值上

reset(ctr); // 错误:类型不匹配,ctr是无符号类型

find_char("Hello Word!", 'o', ctr); // 正确:find_char的第一个形参是对常量的引用

要想调用引用版本的reset函数,只能使用int类型对象,而不能使用字面值、求值结果为int的表达式、需要转换的对象或者const int类型的对象。类似的,要想调用指针版本的reset只能只用int*。另一方面,我们能传递一个字符串字面值作为find_char的第一个实参,这是因为该函数的引用形参是常量引用,而C++允许我们用字面值初始化常量引用。

尽量使用常量引用

把函数不会改变的形参定义成(普通的)引用是一种比较常见的错误,这么做带给函数的调用者一种误导,即函数可以修改它的实参值。此外,使用引用而非常量引用也会极大地限制函数所能接收的实参类型。就像刚刚看到的,我们不能把const对象、字面值或者需要类型转换的对象传递给普通的引用形参。

这种错误绝不像看起来那么简单,它可能造成出人意料的后果。以find_char函数为例,那个函数(正确地)将它的string类型的形参定义成常量引用。假如我们把它定义成普通的string&:

// 不良设计:第一个形参的类型应该是const string&

string :: size_type find_char(string &s, char, string :: size_type &occurs);

则只能将find_char函数作用于string对象。类似下面这样的调用:

find_char("Hello Word", 'o' , ctr);

将在编译时发生错误。

还有一个更难察觉的问题,假如其他函数(正确地)将它们的形参定义成常量引用,那么第二个版本的find_char无法在此类函数中正常使用。举个例子,我们希望在一个判断string对象是否是句子的函数中使用find_char:

bool is_sentence(const string &s)

{

// 如果在s的末尾有且只有一个句号,则s是一个句子

string :: size_type ctr = 0;

return find_char(s, '.', ctr) == s.size( ) - 1 && ctr ==1;

}

如果find_char的第一个形参类型是string&,那么上述这条调用find_char的句子将在编译时发生错误。原因在于s是常量引用,但find_char被(不正确地)定义成只能接受普通引用。

解决问题的一种思路是修改is_sentence的形参类型,但是这么做只不过转移了错误而已,结果是is_sentence函数的调用者只能接受非常量string对象了。

正确的修改思路是改正find_char函数的形参。如果实在不能修改find_char,就在is_sentence内部定义一个string类型的变量,令其为s的副本,然后把这个string对象传递给find_char。

6.2.4 数组形参

数组的两个特殊性质对我们定义和使用作用在数组上的函数有影响,这两个性质分别是:不允许拷贝数组以及使用数组时(通常)会将其转换成指针。因为不能拷贝数组,所以我们无法以值传递的方式使用数组参数。因为数组会转换成指针,所以当我们为函数传递一个数组时,实际上传递的是指向数组首元素的指针。

尽管不能以值传递的方式传递数组,但是我们可以把形参写成类似数组的形式:

// 尽管形式不同,但是这三个print函数是等价的,每个函数都有一个const int*类型的形参

void print(const int*);

void print(const int[ ]); // 可以看出来,函数的意图是作用于一个数组

void print(const int[10]); // 这里的维度表示我们期望数组含有多少个元素,实际上不一定

尽管表现形式不同,但是上面的三个函数是等价的:每个函数的唯一形参都是const int*类型的。当编译器处理对print函数的调用时,只检查传入的实参是否是const int*类型:

int i =0,j[2] = {0, 1};

print(&i); // 正确:&i的类型是int*

print(j); // 正确:j转换成int*并指向j[0]

如果我们传给print函数的是一个数组,则实参自动地转换成指向数组首元素的指针,数组的大小对函数的调用没有影响。

注:和其他使用数组的代码一样,以数组作为形参的函数也必须确保是哦那个数组时不会越界。