每次调用函数时,都会重新创建该函数所有的形参,此时所传递的实参将会初始化对应的形参。
<Note>:
形参的初始化与变量的初始化一样:如果形参具有非引用类型,则复制实参的值,如果形参为引用类型,则它只是实参的别名。
7.2.1. 非引用形参
普通的非引用类型的参数通过复制对应的实参实现初始化。
当用实参副本初始化形参时,函数并没有访问调用所传递的实参本身,因此不会修改实参的值。下面再次观察 gcd 这个函数的定义:
// return the greatest common divisor
int gcd(int v1, int v2)
{
while (v2) {
int temp = v2;
v2 = v1 % v2;
v1 = temp;
}
return v1;
}
while 循环体虽然修改了 v1 与 v2 的值,但这些变化仅限于局部参数,而对调用 gcd 函数使用的实参没有任何影响。
于是,如果有函数调用
gcd(i, j)
则 i 与 j 的值不受 gcd 内执行的赋值操作的影响。
<Note>:非引用形参表示对应实参的局部副本。对这类形参的修改仅仅改变了局部副本的值。
一旦函数执行结束,这些局部变量的值也就没有了。
指针形参
函数的形参可以是指针,此时将复制实参指针。与其他非引用类型的形参一样,该类形参的任何改变也仅作用于局部副本。如果函数将新指针赋给形参,主调函数使用的实参指针的值没有改变。
回顾讨论,事实上被复制的指针只影响对指针的赋值。
如果函数形参是非 const 类型的指针,则函数可通过指针实现赋值,修改指针所指向对象的值:
void reset(int *ip) { *ip = 0; // changes the value of the object to which ip points ip = 0; // changes only the local value of ip; the argument is unchanged }
调用 reset 后,实参依然保持原来的值,但它所指向的对象的值将变为 0:
int i = 42; int *p = &i; cout << "i: " << *p << '\n'; // prints i: 42 reset(p); // changes *p but not p cout << "i: " << *p << endl; // ok: prints i: 0
如果保护指针指向的值,则形参需定义为指向 const 对象的指针:
void use_ptr(const int *p) { // use_ptr may read but not write to *p }
指针形参是指向 const 类型还是非 const 类型,将影响函数调用所使用的实参。
我们既可以用 int* 也可以用 const int* 类型的实参调用 use_ptr 函数;
但仅能将 int* 类型的实参传递给 reset 函数。这个差别来源于指针的初始化规则。
可以将指向 const 对象的指针初始化为指向非 const 对象,但不可以让指向非 const 对象的指针向 const 对象。
const 形参
在调用函数时,如果该函数使用非引用的非 const 形参,则既可给该函数传递 const 实参也可传递非 const 的实参。
例如,可以传递两个 int 型 const 对象调用 gcd:
const int i = 3, j = 6; int k = rgcd(3, 6); // ok: k initialized to 3
这种行为源于 const 对象的标准初始化规则。因为初始化复制了初始化式的值,所以可用 const 对象初始化非 const 对象,反之亦然。
如果将形参定义为非引用的 const 类型:
void fcn(const int i) { /* fcn can read but not write to i */ }
则在函数中,不可以改变实参的局部副本。由于实参仍然是以副本的形式传递,因此传递给 fcn 的既可以是 const 对象也可以是非 const 对象。
令人吃惊的是,尽管函数的形参是 const,但是编译器却将 fcn 的定义视为其形码被声明为普通的 int 型:
void fcn(const int i) { /* fcn can read but not write to i */ } void fcn(int i) { /* ... */ } // error: redefines fcn(int)
这种用法是为了支持对 C 语言的兼容,因为在 C 语言中,具有 const 形参或非 const 形参的函数并无区别。
复制实参的局限性
复制实参并不是在所有的情况下都适合,不适宜复制实参的情况包括:
-
当需要在函数中修改实参的值时。
-
当需要以大型对象作为实参传递时。对实际的应用而言,复制对象所付出的时间和存储空间代价往往过在。
-
当没有办法实现对象的复制时。
对于上述几种情况,有效的解决办法是将形参定义为引用或指针类型。
7.2.2. 引用形参
考虑下面不适宜复制实参的例子,该函数希望交换两个实参的值:
// incorrect version of swap: The arguments are not changed! void swap(int v1, int v2) { int tmp = v2; v2 = v1; // assigns new value to local copy of the argument v1 = tmp; } // local objects v1 and v2 no longer exist
这个例子期望改变实参本身的值。但对于上述的函数定义,swap 无法影响实参本身。
执行 swap 时,只交换了其实参的局部副本,而传递 swap 的实参并没有修改:
int main() { int i = 10; int j = 20; cout << "Before swap():\ti: " << i << "\tj: " << j << endl; swap(i, j); cout << "After swap():\ti: " << i << "\tj: " << j << endl; return 0; }
编译并执行程序,产生如下输出结果:
Before swap(): i: 10 j: 20 After swap(): i: 10 j: 20
为了使 swap 函数以期望的方式工作,交换实参的值,需要将形参定义为引用类型:
// ok: swap acts on references to its arguments void swap(int &v1, int &v2) { int tmp = v2; v2 = v1; v1 = tmp; }
与所有引用一样,引用形参直接关联到其所绑定的圣贤,而并非这些对象的副本。
定义引用时,必须用与该引用绑定的对象初始化该引用。引用形参完全以相同的方式工作。
每次调用函数,引用形参被创建并与相应实参关联。此时,当调用 swap
swap(i, j);
形参 v1 只是对象 i 的另一个名字,而 v2 则是对象 j 的另一个名字。
对 v1 的任何修改实际上也是对 i 的修改。同样地,v2 上的任何修改实际上也是对 j 的修改。
重新编译使用 swap 的这个修订版本的 main 函数后,可以看到输出结果是正确的:
Before swap(): i: 10 j: 20 After swap(): i: 20 j: 10<Tips>:
从 C 语言背景转到 C++ 的程序员习惯通过传递指针来实现对实参的访问。在 C++ 中,使用引用形参则更安全和更自然。
使用引用形参返回额外的信息
通过对 swap 这个例子的讨论,了解了如何利用引用形参让函数修改实参的值。引用形参的另一种用法是向主调函数返回额外的结果。
函数只能返回单个值,但有些时候,函数有不止一个的内容需要返回。
例如,定义一个 find_val 函数。在一个整型 vector 对象的元素中搜索某个特定值。如果找到满足要求的元素,则返回指向该元素的迭代器;
否则返回一个迭代器,指向该 vector 对象的 end 操作返回的元素。此外,如果该值出现了不止一次,我们还希望函数可以返回其出现的次数。
在这种情况下,返回的迭代器应该指向具有要寻找的值的第一个元素。
如何定义既返回一个迭代器又返回出现次数的函数?我们可以定义一种包含一个迭代器和一个计数器的新类型。
而更简便的解决方案是给 find_val 传递一个额外的引用实参,用于返回出现次数的统计结果:
// returns an iterator that refers to the first occurrence of value // the reference parameter occurs contains a second return value vector<int>::const_iterator find_val( vector<int>::const_iterator beg, // first element vector<int>::const_iterator end, // one past last element int value, // the value we want vector<int>::size_type &occurs) // number of times it occurs { // res_iter will hold first occurrence, if any vector<int>::const_iterator res_iter = end; occurs = 0; // set occurrence count parameter for ( ; beg != end; ++beg) if (*beg == value) { // remember first occurrence of value if (res_iter == end) res_iter = beg; ++occurs; // increment occurrence count } return res_iter; // count returned implicitly in occurs }
调用 find_val 时,需传递四个实参:一对标志 vector 对象中要搜索的元素范围的迭代器,所查找的值,以及用于存储出现次数的 size_type 类型对象。
假设 ivec 是 vector<int>, it 类型的对象,it 是一个适当类型的迭代器,而 ctr 则是 size_type 类型的变量,则可如此调用该函数:
it = find_val(ivec.begin(), ivec.end(), 42, ctr);调用后, ctr 的值将是 42 出现的次数,如果 42 在 ivec 中出现了,则 it 将指向其第一次出现的位置;
否则,it 的值为 ivec.end(),而 ctr 则为 0。
利用 const 引用避免复制
在向函数传递大型对象时,需要使用引用形参,这是引用形参适用的另一种情况。
虽然复制实参对于内置数据类型的对象或者规模较小的类类型对象来说没有什么问题,但是对于大部分的类类型或者大型数组,它的效率(通常)太低了;
此外,我们将在第十三章学习到,某些类类型是无法复制的。
使用引用形参,函数可以直接访问实参对象,而无须复制它。
编写一个比较两个 string 对象长度的函数作为例子。这个函数需要访问每个 string 对象的 size,但不必修改这些对象。
由于 string 对象可能相当长,所以我们希望避免复制操作。使用 const 引用就可避免复制:
// compare the length of two strings
bool isShorter(const string &s1, const string &s2)
{
return s1.size() < s2.size();
}
其每一个形参都是
const string 类型的引用。
因为形参是引用,所以不复制实参。
又因为形参是 const 引用,所以 isShorter 函数不能使用该引用来修改实参。
如果使用引用形参的唯一目的是避免复制实参,则应将形参定义为 const 引用。
更灵活的指向 const 的引用
如果函数具有普通的非 const 引用形参,则显然不能通过 const 对象进行调用。毕竟,此时函数可以修改传递进来的对象,这样就违背了实参的 const 特性。
但比较容易忽略的是,调用这样的函数时,传递一个右值或具有需要转换的类型的对象同样是不允许的:
// function takes a non-const reference parameter int incr(int &val) { return ++val; } int main() { short v1 = 0; const int v2 = 42; int v3 = incr(v1); // error: v1 is not an int v3 = incr(v2); // error: v2 is const v3 = incr(0); // error: literals are not lvalues v3 = incr(v1 + v2); // error: addition doesn't yield an lvalue int v4 = incr(v3); // ok: v3 is a non const object type int }
问题的关键是非 const 引用形参只能与完全同类型的非 const 对象关联。
应该将不修改相应实参的形参定义为 const 引用。如果将这样的形参定义为非 const 引用,则毫无必要地限制了该函数的使用。
例如,可编写下面的程序在一个 string 对象中查找一个指定的字符:
// returns index of first occurrence of c in s or s.size() if c isn't in s // Note: s doesn't change, so it should be a reference to const string::size_type find_char(string &s, char c) { string::size_type i = 0; while (i != s.size() && s[i] != c) ++i; // not found, look at next character return i; }
这个函数将其 string 类型的实参当作普通(非 const)的引用,尽管函数并没有修改这个形参的值。
这样的定义带来的问题是不能通过字符串字面值来调用这个函数:
if (find_char("Hello World", 'o')) // ...虽然字符串字面值可以转换为 string 对象,但上述调用仍然会导致编译失败。
继续将这个问题延伸下去会发现,即使程序本身没有 const 对象,而且只使用 string 对象(而并非字符串字面值或产生 string 对象的表达式)调用 find_char 函数,
编译阶段的问题依然会出现。例如,可能有另一个函数 is_sentence 调用 find_char 来判断一个 string 对象是否是句子:
bool is_sentence (const string &s) { // if there's a period and it's the last character in s // then s is a sentence return (find_char(s, '.') == s.size() - 1); }如上代码,函数 is_sentence 中 find_char 的调用是一个编译错误。
传递进 is_sentence 的形参是指向 const string 对象的引用,不能将这种类型的参数传递给 find_char,因为后者期待得到一个指向非 const string 对象的引用。
<Note>:
应该将不需要修改的引用形参定义为 const 引用。普通的非 const 引用形参在使用时不太灵活。
这样的形参既不能用 const 对象初始化,也不能用字面值或产生右值的表达式实参初始化。
传递指向指针的引用
假设我们想编写一个与前面交换两个整数的 swap 类似的函数,实现两个指针的交换。
已知需用 * 定义指针,用 & 定义引用。现在,问题在于如何将这两个操作符结合起来以获得指向指针的引用。
这里给出一个例子:
// swap values of two pointers to int void ptrswap(int *&v1, int *&v2) { int *tmp = v2; v2 = v1; v1 = tmp; }
形参
int *&v1
的定义应从右至左理解:v1 是一个引用,与指向 int 型对象的指针相关联。
也就是说,v1 只是传递进 ptrswap 函数的任意指针的别名。
重写第 7.2.2 节的 main 函数,调用 ptrswap 交换分别指向值 10 和 20 的指针:
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; }
编译并执行后,该程序产生如下结果:
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。
7.2.3. vector 和其他容器类型的形参
通常,函数不应该有 vector 或其他标准库容器类型的形参。
调用含有普通的非引用 vector 形参的函数将会复制 vector 的每一个元素。
从避免复制 vector 的角度出发,应考虑将形参声明为引用类型。
然而,看过第十一章后我们会知道,事实上,C++ 程序员倾向于通过传递指向容器中需要处理的元素的迭代器来传递容器:
// 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; }
这个函数将输出从 beg 指向的元素开始到 end 指向的元素(不含)为止的范围内所有的元素。
除了最后一个元素外,每个元素后面都输出一个空格。
7.2.4. 数组形参
数组有两个特殊的性质,影响我们定义和使用作用在数组上的函数:一是不能复制数组(第 4.1.1 节);
二是使用数组名字时,数组名会自动转化为指向其第一个元素的指针(第 4.2.4 节)。
因为数组不能复制,所以无法编写使用数组类型形参的函数。
因为数组会被自动转化为指针,所以处理数组的函数通常通过操纵指向数组指向数组中的元素的指针来处理数组。
数组形参的定义
如果要编写一个函数,输出 int 型数组的内容,可用下面三种方式指定数组形参:
// three equivalent definitions of printValues
void printValues(int*) { /* ... */ }
void printValues(int[]) { /* ... */ }
void printValues(int[10]) { /* ... */ }
虽然不能直接传递数组,但是函数的形参可以写成数组的形式。
虽然形参表示方式不同,但可将使用数组语法定义的形参看作指向数组元素类型的指针。
上面的三种定义是等价的,形参类型都是 int*。
通常,将数组形参直接定义为指针要比使用数组语法定义更好。
这样就明确地表示,函数操纵的是指向数组元素的指针,而不是数组本身。
由于忽略了数组长度,形参定义中如果包含了数组长度则特别容易引起误解。
形参的长度会引起误解
编译器忽略为任何数组形参指定的长度。根据数组长度(权且这样说),可将函数 printValues 编写为:
// parameter treated as const int*, size of array is ignored void printValues(const int ia[10]) { // this code assumes array has 10 elements; // disaster if argument has fewer than 10 elements! for (size_t i = 0; i != 10; ++i) { cout << ia[i] << endl; } }
尽管上述代码假定所传递的数组至少含有 10 个元素,但 C++ 语言没有任何机制强制实现这个假设。下面的调用都是合法的:
int main() { int i = 0, j[2] = {0, 1}; printValues(&i); // ok: &i is int*; probable run-time error printValues(j); // ok: j is converted to pointer to 0th // element; argument has type int*; // probable run-time error return 0; }
虽然编译没有问题,但是这两个调用都是错误的,可能导致运行失败。
在这两个调用中,由于函数 printValues 假设传递进来的数组至少含有 10 个元素,因此造成数组内在的越界访问。
程序的执行可能产生错误的输出,也可能崩溃,这取决于越界访问的内存中恰好存储的数值是什么。
当编译器检查数组形参关联的实参时,它只会检查实参是不是指针、指针的类型和数组元素的类型时是否匹配,而不会检查数组的长度。
数组实参
和其他类型一样,数组形参可定义为引用或非引用类型。
大部分情况下,数组以普通的非引用类型传递,此时数组会悄悄地转换为指针。
一般来说,非引用类型的形参会初始化为其相应实参的副本。
而在传递数组时,实参是指向数组第一个元素的指针,形参复制的是这个指针的值,而不是数组元素本身。
函数操纵的是指针的副本,因此不会修改实参指针的值。
然而,函数可通过该指针改变它所指向的数组元素的值。
通过指针形参做的任何改变都在修改数组元素本身。
不需要修改数组形参的元素时,函数应该将形参定义为指向 const 对象的指针:
// f won't change the elements in the array void f(const int*) { /* ... */ }
通过引用传递数组
和其他类型一样,数组形参可声明为数组的引用。
如果形参是数组的引用,编译器不会将数组实参转化为指针,而是传递数组的引用本身。
在这种情况下,数组大小成为形参和实参类型的一部分。编译器检查数组的实参的大小与形参的大小是否匹配:
// 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 型数值的数组,这限制了哪些数组可以传递。
然而,由于形参是引用,在函数体中依赖数组的大小是安全的:
// 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;
}
}
<Note>:
&arr 两边的圆括号是必需的,因为下标操作符具有更高的优先级: |
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
多维数组的传递
回顾前面我们说过在 C++ 中没有多维数组。所谓多维数组实际是指数组的数组。
和其他数组一样,多维数组以指向 0 号元素的指针方式传递。多维数组的元素本身就是数组。
除了第一维以外的所有维的长度都是元素类型的一部分,必须明确指定:
// first parameter is an array whose elements are arrays of 10 ints void printValues(int (matrix*)[10], int rowSize);
上面的语句将 matrix 声明为指向含有 10 个 int 型元素的数组的指针。
<Note>: 再次强调,*matrix 两边的圆括号是必需的: |
int *matrix[10]; // array of 10 pointers int (*matrix)[10]; // pointer to an array of 10 ints
我们也可以用数组语法定义多维数组。与一维数组一样,编译器忽略第一维的长度,所以最好不要把它包括在形参表内:
// first parameter is an array whose elements are arrays of 10 ints void printValues(int matrix[][10], int rowSize);
这条语句把 matrix 声明为二维数组的形式。实际上,形参是一个指针,指向数组的数组中的元素。
数组中的每个元素本身就是含有 10 个 int 型对象的数组。
7.2.5. 传递给函数的数组的处理
就如刚才所见的,非引用数组形参的类型检查只是确保实参是和数组元素具有同样类型的指针,而不会检查实参实际上是否指向指定大小的数组。
<Beware>;任何处理数组的程序都要确保程序停留在数组的边界内。
有三种常见的编程技巧确保函数的操作不超出数组实参的边界。
第一种方法是在数组本身放置一个标记来检测数组的结束。
C 风格字符串就是采用这种方法的一个例子,它是一种字符数组,并且以空字符 null 作为结束的标记。
处理 C 风格字符串的程序就是使用这个标记停止数组元素的处理。
使用标准库规范
第二种方法是传递指向数组第一个和最后一个元素的下一个位置的指针。这种编程风格由标准库所使用的技术启发而得,在第二部分将会进一步介绍这种编程风格。
使用这种方法重写函数 printValues 并调用该函数,如下所示:
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 指针等于结束标记时,循环结束。结束标记就是传递给函数的第二个形参。
调用这个版本的函数需要传递两个指针:一个指向要输出的第一个元素,另一个则指向最后一个元素的下一个位置。
只要正确计算指针,使它们标记一段有效的元素范围,程序就会安全。
显式传递表示数组大小的形参
第三种方法是将第二个形参定义为表示数组的大小,这种用法在 C 程序和标准化之前的 C++ 程序中十分普遍。
用这种方法再次重写函数 printValues,新版本及其调用如下所示:
// 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 值不超过数组的实际大小,程序就能安全运行。
7.2.6. main: 处理命令行选项
主函数 main 是演示 C 程序如何将数组传递给函数的好例子。直到现在,我们所定义的主函数都只有空的形参表:
int main() { ... }
但是,我们通常需要给 main 传递实参。传统上,主函数的实参是可选的,用来确定程序要执行的操作。
比如,假设我们的主函数 main 位于名为 prog 的可执行文件中,可如下将实参选项传递给程序:
prog -d -o ofile data0
这种用法的处理方法实际上是在主函数 main 中定义了两个形参:
int main(int argc, char *argv[]) { ... }
第二个形参 argv 是一个 C 风格字符串数组。第一个形参 argc 则用于传递该数组中字符串的个数。
由于第二个参数是一个数组,主函数 main 也可以这样定义:
int main(int argc, char **argv) { ... }表示 argv 是指向 char* 的指针。
当将实参传递给主函数 main 时,argv 中的第一个字符串(如果有的话)通常是程序的名字。
接下来的元素将额外的可选字符串传递给主函数 main。以前面的命令行为例,argc 应设为 5,argv 会保存下面几个 C 风格字符串:
argv[0] = "prog"; argv[1] = "-d"; argv[2] = "-o"; argv[3] = "ofile"; argv[4] = "data0";
7.2.7. 含有可变形参的函数
<Beware>:C++ 中的省略符形参是为了编译使用了 varargs 的 C 语言程序。
关于如何使用 varargs,请查阅所用 C 语言编译器的文档。对于 C++ 程序,只能将简单数据类型传递给含有省略符形参的函数。
实际上,当需要传递给省略符形参时,大多数类类型对象都不能正确地复制。
在无法列举出传递给函数的所有实参的类型和数目时,可以使用省略符形参。省略符暂停了类型检查机制。
它们的出现告知编译器,当调用函数时,可以有 0 或多个实参,而实参的类型未知。省略符形参有下列两种形式:
void foo(parm_list, ...); void foo(...);第一种形式为特定数目的形参提供了声明。在这种情况下,当函数被调用时,对于与显示声明的形参相对应的实参进行类型检查,
而对于与省略符对应的实参则暂停类型检查。在第一种形式中,形参声明后面的逗号是可选的。
大部分带有省略符形参的函数都利用显式声明的参数中的一些信息,来获取函数调用中提供的其他可选实参的类型和数目。
因此带有省略符的第一种形式的函数声明是最常用的。