参数传递

引用形参

引用

引用是别名——引用就是对象的另一个名字,通过在变量名前添加&符号来定义。

1
2
3
4
int ival = 1024;
int &refVal = ival; // ok: refVal refers to iVal
int &refVal2; // error: a reference must be initialized
int &refVal3 = 10; // error: initializer must be an object

因为引用只是它绑定的对象的另一个名字,因此作用在引用上的所有操作事实上都是作用在该引用绑定的对象上:

1
2
3
int ival = 1024;
int &refVal = ival;
refVal += 2;

将refVal指向的对象ival加2。类似地,

1
int ii = refVal;

把和ival相关联的值赋给ii。

注意 :当引用初始化后,只要该引用存在,它就保持绑定到初始化时指向的对象。不可能将引用绑定到另一个对象。

const 引用

const 引用是指向const对象的引用。将普通的引用绑定到 const 对象是不合法的。

1
2
3
const int ival = 1024;
const int &refVal = ival; // ok: both reference and object are const
int &ref2 = ival; // error: non const reference to a const object

引用形参

从 C 语言背景转到 C++ 的程序员习惯通过传递指针来实现对实参的访问。在 C++ 中,使用引用形参则更安全和更自然。

引用形参

在 C 语言中,一个交换两个元素的函数可能用指针来实现:

1
2
3
4
5
6
void swap(int *p1, int *p2)
{
int tmp = *p2;
*p2 = *p1;
*p1 = tmp;
}

但在 C++ 中,更好的做法是使用引用形参:

1
2
3
4
5
6
void swap(int &v1, int &v2)
{
int tmp = v2;
v2 = v1;
v1 = tmp;
}

与所有引用一样,引用形参直接关联到其所绑定的对象,而并非这些对象的副本。定义引用时,必须用与该引用绑定的对象初始化该引用。引用形参完全以相同的方式工作。每次调用函数,引用形参被创建并与相应实参关联。此时,当调用 swap 时

1
swap(i, j);

形参 v1 只是对象 i 的另一个名字,而 v2 则是对象 j 的另一个名字。对 v1 的任何修改实际上也是对 i 的修改。同样地,v2 上的任何修改实际上也是对 j 的修改。重新编译使用 swap 的这个修订版本的 main 函数后,可以看到输出结果是正确的:

1
2
Before swap(): i: 10 j: 20
After swap(): i: 20 j: 10

利用 const 引用避免复制

在向函数传递大型对象时,需要使用引用形参,这是引用形参适用的另一种情况。虽然复制实参对于内置数据类型的对象或者规模较小的类类型对象来说没有什么问题,但是对于大部分的类类型或者大型数组,它的效率(通常)太低了;使用引用形参,函数可以直接访问实参对象,而无须复制它。

编写一个比较两个 string 对象长度的函数作为例子。这个函数需要访问每个 string 对象的 size,但不必修改这些对象。由于 string 对象可能相当长,所以我们希望避免复制操作。使用 const 引用就可避免复制:

1
2
3
4
5
// compare the length of two strings
bool isShorter(const string &s1, const string &s2)
{
return s1.size() < s2.size();
}

其每一个形参都是 const string 类型的引用。因为形参是引用,所以不复制实参。又因为形参是 const 引用,所以 isShorter 函数不能使用该引用来修改实参。

指向指针的引用

假设我们想编写一个与前面交换两个整数的 swap 类似的函数,实现两个指针的交换。已知需用 * 定义指针,用 & 定义引用。现在,问题在于如何将这两个操作符结合起来以获得指向指针的引用。这里给出一个例子:

1
2
3
4
5
6
7
// swap values of two pointers to int
void ptrswap(int *&v1, int *&v2)
{
int *tmp = v2;
v2 = v1;
v1 = tmp;
}

形参

1
int *&v1

的定义应 从右至左理解 :v1 是一个引用,与指向 int 型对象的指针相关联。也就是说,v1 只是传递进 ptrswap 函数的任意指针的别名。

相应的 main 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
int main()
{
int i = 10;
int j = 20;
int *pi = &i; // pi points to i
int *pj = &j; // pj points to j
cout << "Before ptrswap():\t*pi: "
<< *pi << "\t*pj: " << *pj << endl;
ptrswap(pi, pj); // now pi points to j; pj points to i
cout << "After ptrswap():\t*pi: "
<< *pi << "\t*pj: " << *pj << endl;
return 0;
}

编译并执行后,该程序产生如下结果:

1
2
Before ptrswap(): *pi: 10 *pj: 20
After ptrswap(): *pi: 20 *pj: 10

即指针的值被交换了。在调用 ptrswap 时,pi 指向 i,而 pj 则指向 j。在 ptrswap 函数中,指针被交换,使得调用 ptrswap 结束后,pi 指向了原来 pj 所指向的对象。换句话说,现在 pi 指向 j,而 pj 则指向了 i。

容器的形参

通常,函数不应该有 vector 或其他标准库容器类型的形参。调用含有普通的非引用 vector 形参的函数将会复制 vector 的每一个元素。

从避免复制 vector 的角度出发,应考虑将形参声明为引用类型。然而事实上,C++ 程序员 倾向于通过传递指向容器中需要处理的元素的迭代器来传递容器

1
2
3
4
5
6
7
8
9
10
// pass iterators to the first and one past the last element to print
void print(vector<int>::const_iterator beg,
vector<int>::const_iterator end)
{
while (beg != end) {
cout << *beg++;
if (beg != end) cout << " "; // no space after last element
}
cout << endl;
}

数组形参

因为数组会被自动转化为指针,所以处理数组的函数通常通过操纵指向数组指向数组中的元素的指针来处理数组。

数组形参的定义

如果要编写一个函数,输出 int 型数组的内容,可用下面三种方式指定数组形参:

1
2
3
4
// three equivalent definitions of printValues
void printValues(int*) { /* ... */ }
void printValues(int[]) { /* ... */ }
void printValues(int[10]) { /* ... */ }

虽然不能直接传递数组,但是函数的形参可以写成数组的形式。虽然形参表示方式不同,但可将使用数组语法定义的形参看作指向数组元素类型的指针。上面的三种定义是等价的,形参类型都是 int *

通常,将数组形参直接定义为指针要比使用数组语法定义更好。这样就明确地表示,函数操纵的是指向数组元素的指针,而不是数组本身。由于忽略了数组长度,形参定义中如果包含了数组长度则特别容易引起误解。

不需要修改数组形参的元素时,函数应该将形参定义为指向 const 对象的指针:

1
2
// f won't change the elements in the array
void f(const int*) { /* ... */ }

通过引用传递数组

和其他类型一样,数组形参可声明为数组的引用。如果形参是数组的引用,编译器不会将数组实参转化为指针,而是传递数组的引用本身。

和使用指针的区别是,在这种情况下,数组大小成为形参和实参类型的一部分。编译器检查数组的实参的大小与形参的大小是否匹配:

1
2
3
4
5
6
7
8
9
10
11
// ok: parameter is a reference to an array; size of array is fixed
void printValues(int (&arr)[10]) { /* ... */ }
int main()
{
int i = 0, j[2] = {0, 1};
int k[10] = {0,1,2,3,4,5,6,7,8,9};
printValues(&i); // error: argument is not an array of 10 ints
printValues(j); // error: argument is not an array of 10 ints
printValues(k); // ok: argument is an array of 10 ints
return 0;
}

这个版本的 printValues 函数只严格地接受含有 10 个 int 型数值的数组,这限制了哪些数组可以传递。然而,由于形参是引用,在函数体中依赖数组的大小是安全的:

1
2
3
4
5
6
7
// ok: parameter is a reference to an array; size of array is fixed
void printValues(int (&arr)[10])
{
for (size_t i = 0; i != 10; ++i) {
cout << arr[i] << endl;
}
}

``&arr` 两边的圆括号是必需的,因为下标操作符具有更高的优先级:

1
2
f(int &arr[10]) // error: arr is an array of references
f(int (&arr)[10]) // ok: arr is a reference to an array of 10 ints

数组的边界处理

非引用数组形参的类型检查只是确保实参是和数组元素具有同样类型的指针,而不会检查实参实际上是否指向指定大小的数组。

有三种常见的编程技巧确保函数的操作不超出数组实参的边界。

方法1:使用特殊标记

第一种方法是在数组本身放置一个标记来检测数组的结束。C 风格字符串就是采用这种方法的一个例子,它是一种字符数组,并且以空字符 null 作为结束的标记。处理 C 风格字符串的程序就是使用这个标记停止数组元素的处理。

方法2:标准库规范

第二种方法是传递指向数组第一个和最后一个元素的下一个位置的指针。这种编程风格由标准库所使用的技术启发而得。使用这种方法重写函数 printValues 并调用该函数,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void printValues(const int *beg, const int *end)
{
while (beg != end) {
cout << *beg++ << endl;
}
}
int main()
{
int j[2] = {0, 1};
// ok: j is converted to pointer to 0th element in j
// j + 2 refers one past the end of j
printValues(j, j + 2);
return 0;
}

printValues 中的循环很像用 vector 迭代器编写的程序。每次循环都使 beg 指针指向下一个元素,从而实现数组的遍历。当 beg 指针等于结束标记时,循环结束。结束标记就是传递给函数的第二个形参。

调用这个版本的函数需要传递两个指针:一个指向要输出的第一个元素,另一个则指向最后一个元素的下一个位置。只要正确计算指针,使它们标记一段有效的元素范围,程序就会安全。

方法3:显式传递表示数组大小的形参

第三种方法是将第二个形参定义为表示数组的大小,这种用法在 C 程序和标准化之前的 C++ 程序中十分普遍。

用这种方法再次重写函数 printValues,新版本及其调用如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// const int ia[] is equivalent to const int* ia
// size is passed explicitly and used to control access to elements
of ia
void printValues(const int ia[], size_t size)
{
for (size_t i = 0; i != size; ++i) {
cout << ia[i] << endl;
}
}
int main()
{
int j[] = { 0, 1 }; // int array of size 2
printValues(j, sizeof(j)/sizeof(*j));
return 0;
}

这个版本使用了形参 size 来确定要输出的元素的个数。调用 printValues 时,要额外传递一个形参。只要传递给函数的 size 值不超过数组的实际大小,程序就能安全运行。

返回值

在含有 return 语句的循环后没有提供 return 语句是很危险的,因为大部分的编译器不能检测出这个漏洞,运行时会出现什么问题是不确定的。

但此规则有一个例外情况:允许主函数 main 没有返回值就可结束。如果程序控制执行到主函数 main 的最后一个语句都还没有返回,那么编译器会隐式地插入返回 0 的语句。

返回非引用类型

函数的返回值用于初始化在调用函数处创建的临时对象。在求解表达式时,如果需要一个地方储存其运算结果,编译器会创建一个没有命名的对象,这就是临时对象。在英语中,C++ 程序员通常用 temporary 这个术语来代替 temporary object。

用函数返回值初始化临时对象与用实参初始化形参的方法是一样的。如果返回类型不是引用,在调用函数的地方会将函数返回值复制给临时对象。当函数返回非引用类型时,其返回值既可以是局部对象,也可以是求解表达式的结果。

例如,下面的程序提供了一个计数器、一个单词 word 和单词结束字符串 ending,当计数器的值大于 1 时,返回该单词的复数版本:

1
2
3
4
5
6
// return plural version of word if ctr isn't 1
string make_plural(size_t ctr, const string &word,
const string &ending)
{
return (ctr == 1) ? word : word + ending;
}

我们可以使用这样的函数来输出单词的单数或复数形式。

这个函数要么返回其形参 word 的副本,要么返回一个未命名的临时 string 对象,这个临时对象是由字符串 word 和 ending 的相加而产生的。这两种情况下,return 都在调用该函数的地方复制了返回的 string 对象。

返回引用类型

当函数返回引用类型时,没有复制返回值。相反,返回的是对象本身。例如,考虑下面的函数,此函数返回两个 string 类型形参中较短的那个字符串的引用:

1
2
3
4
5
// find longer of two strings
const string &shorterString(const string &s1, const string &s2)
{
return s1.size() < s2.size() ? s1 : s2;
}

形参和返回类型都是指向 const string 对象的引用,调用函数和返回结果时,都没有复制这些 string 对象。

不要返回局部对象的引用

千万不能返回局部变量的引用!

当函数执行完毕时,将释放分配给局部对象的存储空间。此时,对局部对象的引用就会指向不确定的内存。考虑下面的程序:

1
2
3
4
5
6
7
// Disaster: Function returns a reference to a local object
const string &manip(const string& s)
{
string ret = s;
// transform ret in some way
return ret; // Wrong: Returning reference to a local object!
}

这个函数会在运行时出错,因为它返回了局部对象的引用。当函数执行完毕,字符串 ret 占用的储存空间被释放,函数返回值指向了对于这个程序来说不再有效的内存空间。

引用返回左值

返回引用的函数返回一个左值。因此,这样的函数可用于任何要求使用左值的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
char &get_val(string &str, string::size_type ix)
{
return str[ix];
}
int main()
{
string s("a value");
cout << s << endl; // prints a value
get_val(s, 0) = 'A'; // changes s[0] to A
cout << s << endl; // prints A value
return 0;
}

给函数返回值赋值可能让人惊讶,由于函数返回的是一个引用,因此这是正确的,该引用是被返回元素的同义词。

如果不希望引用返回值被修改,返回值应该声明为 const:

1
const char &get_val(...

默认实参

设计带有默认实参的函数,其中部分工作就是排列形参,使最少使用默认实参的形参排在最前,最可能使用默认实参的形参排在最后。

默认实参是一种虽然并不普遍、但在多数情况下仍然适用的实参值。调用函数时,可以省略有默认值的实参。编译器会为我们省略的实参提供默认值。

默认实参是通过给形参表中的形参提供明确的初始值来指定的。程序员可为一个或多个形参定义默认值。但是,如果有一个形参具有默认实参,那么,它后面所有的形参都必须有默认实参。

例如,下面的函数创建并初始化了一个 string 对象,用于模拟窗口屏幕。此函数为窗口屏幕的高、宽和背景字符提供了默认实参:

1
2
3
string screenInit(string::size_type height = 24,
string::size_type width = 80,
char background = ' ' );

调用包含默认实参的函数时,可以为该形参提供实参,也可以不提供。如果提供了实参,则它将覆盖默认的实参值;否则,函数将使用默认实参值。下面的函数 screenInit 的调用都是正确的:

1
2
3
4
5
string screen;
screen = screenInit(); // equivalent to screenInit (24,80,' ')
screen = screenInit(66); // equivalent to screenInit (66,80,' ')
screen = screenInit(66, 256); // screenInit(66,256,' ')
screen = screenInit(66, 256, '#');

函数调用的实参按位置解析,默认实参只能用来替换函数调用缺少的尾部实参。例如,如果要给 background 提供实参,那么也必须给 height 和 width 提供实参:

1
2
screen = screenInit(, , '?'); // error, can omit only trailing arguments
screen = screenInit( '?'); // calls screenInit('?',80,' ')

默认实参的初始化式

默认实参可以是任何适当类型的表达式:

1
2
3
4
5
6
7
string::size_type screenHeight();
string::size_type screenWidth(string::size_type);
char screenDefault(char = ' ');
string screenInit(
string::size_type height = screenHeight(),
string::size_type width = screenWidth(screenHeight()),
char background = screenDefault());

如果默认实参是一个表达式,而且默认值用作实参,则在调用函数时求解该表达式。例如,每次不带第三个实参调用函数 screenInit 时,编译器都会调用函数 screenDefault 为 background 获得一个值。

指定默认实参的约束

通常,应在函数声明中指定默认实参,并将该声明放在合适的头文件中。

既可以在函数声明也可以在函数定义中指定默认实参。但是,在一个文件中,只能为一个形参指定默认实参一次。下面的例子是错误的:

1
2
3
4
5
6
// ff.h
int ff(int = 0);
// ff.cc
#include "ff.h"
int ff(int i = 0) { /* ... */ } // error

如果在函数定义的形参表中提供默认实参,那么只有在包含该函数定义的源文件中调用该函数时,默认实参才是有效的。

内联函数

先看一个返回两个 string 形参中较短的字符串的函数:

1
2
3
4
5
// find longer of two strings
const string &shorterString(const string &s1, const string &s2)
{
return s1.size() < s2.size() ? s1 : s2;
}

优点

将函数指定为 inline 函数,(通常)就是将它在程序中每个调用点上“内联地”展开。假设我们将 shorterString 定义为内联函数,则调用:

1
cout << shorterString(s1, s2) << endl;

在编译时将展开为:

1
cout << (s1.size() < s2.size() ? s1 : s2) << endl;

从而消除了把 shorterString 写成函数的额外执行开销。

1
2
3
4
5
6
// inline version: find longer of two strings
inline const string &
shorterString(const string &s1, const string &s2)
{
return s1.size() < s2.size() ? s1 : s2;
}

inline 说明对于编译器来说只是一个建议,编译器可以选择忽略这个。

一般来说,内联机制适用于优化小的、只有几行的而且经常被调用的函数。大多数的编译器都不支持递归函数的内联。一个 1200 行的函数也不太可能在调用点内联展开。

注意事项

内联函数应该在头文件中定义,这一点不同于其他函数。

inline 函数的定义对编译器而言 必须是可见的 ,以便编译器能够在调用点内联展开该函数的代码。此时,仅有函数原型是不够的。

inline 函数可能要在程序中定义不止一次,只要 inline 函数的定义在某个源文件中只出现一次,而且在所有源文件中,其定义必须是完全相同的。把 inline 函数的定义放在头文件中,可以确保在调用函数时所使用的定义是相同的,并且保证在调用点该函数的定义对编译器可见。

重载函数

通过省去为函数起名并记住函数名字的麻烦,函数重载简化了程序的实现,使程序更容易理解。函数名只是为了帮助编译器判断调用的是哪个函数而已。例如,一个数据库应用可能需要提供多个 lookup 函数,分别实现基于姓名、电话号码或账号之类的查询功能。函数重载使我们可以定义一系列的函数,它们的名字都是 lookup,不同之处在于用于查询的值不相同。如此可传递几种类型中的任一种值调用 lookup 函数:

1
2
3
4
5
6
Record lookup(const Account&); // find by Account
Record lookup(const Phone&); // find by Phone
Record lookup(const Name&); // find by Name
Record r1, r2;
r1 = lookup(acct); // call version that takes an Account
r2 = lookup(phone); // call version that takes a Phone

这里的三个函数共享同一个函数名,但却是三个不同的函数。编译器将根据所传递的实参类型来判断调用的是哪个函数。

任何程序都仅有一个 main 函数的实例。main 函数不能重载。

重载与作用域

1
2
3
4
5
6
7
8
9
10
/* Program for illustration purposes only:
* It is bad style for a function to define a local variable
* with the same name as a global name it wants to use
*/
string init(); // the name init has global scope
void fcn()
{
int init = 0; // init is local and hides global init
string s = init(); // error: global init is hidden
}

一般的作用域规则同样适用于重载函数名。如果局部地声明一个函数,则该函数将屏蔽而不是重载在外层作用域中声明的同名函数。由此推论,每一个版本的重载函数都应在同一个作用域中声明。

一般来说,局部地声明函数是一种不明智的选择。函数的声明应放在头文件中。

函数匹配与实参转换

函数重载确定,即函数匹配是将函数调用与重载函数集合中的一个函数相关联的过程。通过自动提取函数调用中实际使用的实参与重载集合中各个函数提供的形参做比较,编译器实现该调用与函数的匹配。匹配结果有三种可能:

  1. 编译器找到与实参最佳匹配的函数,并生成调用该函数的代码。
  2. 找不到形参与函数调用的实参匹配的函数,在这种情况下,编译器将给出编译错误信息。
  3. 存在多个与实参匹配的函数,但没有一个是明显的最佳选择。该调用具有二义性。

大多数情况下,编译器都可以直接明确地判断一个实际的调用是否合法,如果合法,则应该调用哪一个函数。重载集合中的函数通常有不同个数的参数或无关联的参数类型。当多个函数的形参具有可通过隐式转换关联起来的类型,则函数匹配将相当灵活。在这种情况下,需要程序员充分地掌握函数匹配的过程。