C++ Learning 2
第五章 循环和关系表达式
5.1 for循环
使用for循环访问字符串
1 |
|
运行结果:
1 | Enter a word: animal |
前缀格式和后缀格式:
i++是后缀递增,
++i是前缀递增。
1 | for(n = lim; n > 0; --n) // 前缀 |
从逻辑上说,在上述两种情形下,使用前缀格式和后缀格式没有任何区别。表达式的值未被使用,因此只存在副作用。在上述的例子中,使用这些运算符的表达式为完整表达式,因此将n加1和n减1的副作用将在程序进入下一步之前完成,前缀格式和后缀格式的最终效果相同。
然而,虽然选择使用前缀格式还是后缀格式对程序的行为没有影响,但执行速度可能有细微的差别。对于内置类型的当代的编译器而言,没有什么问题。对于类而言,前缀版本的效率比后缀版本的高。
总之,对于内置类型,采用哪种格式不会有差别;==但对于用户定义的类型,如果有用户定义的递增和递减运算符,则前缀格式的效率更高==。
递增/递减运算符和指针
可以将递增运算符用于指针和基本变量。将递增运算符用于指针时,将把指针的值增加其指向的数据类型占用的字节数,这种规则适用于对指针递增和递减:
1 | double arr[5] = {21.1,32.8,23.4,45.2,3.4}; |
可以结合使用这些运算符和*运算符来修改指针指向的值。前缀递增/递减和解除引用运算符的优先级相同,以从右到左的方式结合。后缀递增和后缀递减的优先级相同,但比前缀运算符的优先级高,这两个运算符以从左到右的方式结合。
1 | double x = *++pt; // increment pointer,take the value; arr[2],or 23.4 |
前缀运算符的从右到左的结合规则意味着*++pt的含义:先将++应用于pt,然后将*应用于被递增后的pt。
1 | ++*pt; // increment the pointed to value; i.e.,change 23.4 to 24.4 |
意味着先取pt指向的值,然后将这个值加1。在这种情况下,pt仍然指向arr[2]。
1 | (*pt)++; // increment pointed-to value |
圆括号指出,首先对指针解除引用,得到24.4。然后,运算符++将这个值递增到25.4,pt仍然指向arr[2]。
1 | x = *pt++; // dereference original location, then increment pointer |
后缀运算符++的优先级更高,这意味着将运算符用于pt,而不是*pt,因此对指针递增。
然后后缀运算符意味着将对原来的地址(&arr[2])而不是递增后的新地址解除引用,因此*pt++的值为arr[2],即25.4,但该语句执行完毕后,pt的值将为arr[3]的地址。
程序清单:
1 |
|
运行结果:
1 | Please enter five values: |
C风格字符串的比较
数组名是数组的地址,同样,用引号括起来的字符串常量也是其地址。
C风格字符串库中的strcmp()函数来比较,该函数接受两个字符串地址作为参数,这意味着参数可以是指针、字符串常量或字符数组名。如果两个字符串相同,该函数将返回零;如果第一个字符串按字母顺序排在第二个字符串之前,则strcmp()将返回一个负值;如果第一个字符串按字母顺序排在第二个字符串之后,则strcpm()将返回一个正数值。
在有些语言(如BASIC和标准Pascal)中,存储在不同长度的数组中的字符串彼此不相等。但在C风格字符串是通过结尾的空值字符定义的,而不是由其所在数组的长度定义的。这意味着两个字符串即使被存储在长度不同的数组中,也可能是相同的。
1 | char big[80] = "Daffy"; // 5 letters plus \0 |
虽然不能用关系运算符比较字符串,但却可以用它们来比较字符,因为字符实际上是整型。
因此下面的代码可以用来显示字母表中的字符,至少对于ASCII字符集和Unicode字符集来说是有效的:
1 | for(ch = 'a';ch <= 'z';ch++) |
程序清单:
例如:在for循环的测试条件中使用了strcmp()。该程序显示一个单词,修改其字母,然后再次显示这个单词,这样循环往复,查到strcmp()确定该单词与字符串”matc”相同为止。
1 |
|
运行结果:
1 | ?ate |
程序说明:
strcmp()判断出两个字符串不相同,测试就继续进行,最显而易见的测试是这样的:
1 | strcmp(word,"mate") = 0; |
如果字符串不相等,则该语句的值为1(true)。如果字符串相等,则语句的值为0(false)。
类型别名
C++为类型建立别名的方式有两种。一种是使用预处理器:
1 |
这样,预处理器将在编译程序时用char替换所有的BYTE,从而使BYTE成为char的别名。
第二种方法是使用C++(和C)的关键字typedef来创建别名。
例如:要将byte作为char的别名,可以这样做:
1 | typedef char byte; // make byte an alias for char |
通用格式:
1 | typedef typeName aliasName; |
换句话说,如果要将aliasName作为某种类型的别名,可以声明aliasName,如同将aliasName声明为这种类型的变量那样,然后在声明的前面加上关键字typedef。
例如:要让byte_pointer成为char指针的别名,可将byte_pointer声明为char指针,然后在前面加上typedef:
1 | typedef char * byte_pointer; // pointer to char type |
相比于#define,使用typedef是一种更佳的选择,有时候,也是唯一的选择。
注意:==typedef不会创建新类型,而只是为已有的类型建立一个新名称==。如果将word作为int的别名,则cout将把word类型的值视为int类型。
5.2 基于范围的for循环(C++11)
C++11 新增了一种循环:基于范围(range-based)的for循环。
这简化了一种常见的循环任务:对于数组(或容器类,如vector和array)的每个元素执行相同的操作。
1 | double prices[5] = {4.99,10.99,6.87,7.99,8.49}; |
其中,x最初表示数组prices的第一个元素。显示第一个元素后,不断执行循环,而x依次表示数组的其他元素。因此,上述代码显示全部5个元素,每个元素占据一行。总之,该循环显示数组中的每个值。
要修改数组的元素,需要使用不同的循环变量语法:
1 | for(double &x : prices) |
符号&表明x是一个引用变量。这种声明让接下来的代码能够修改数组的内容。
还可以结合使用基于范围的for循环和初始化列表:
1 | for(int x:{3,5,2,8,6}) |
5.3 循环和文本输入
循环完成的一项最常见、最重要的任务:逐字符地读取来自文件或键盘的文本。例如:可能想要编写一个能够计算输入中的字符数、行数和字数的程序。传统上,C++和C语言一样,也使用while循环来完成这类任务。
cin对象支持3种不同模式的单字符输入,其用户接口各不相同。
1、使用原始的cin进行输入:
1 |
|
运行结果:
1 | Enter characters;enter # to quit: |
注意:cIn在读取char值时,将忽略空格和换行符。因此输入中的空格没有被回显,也没有被包括在计数内。
更为复杂的是,发送给cin的输入被缓冲。这意味着只有在用户按下回车键后,他输入的内容才会被发送给程序。这就是在运行该程序时, 在#后面输入字符的原因。按下回车键后,整个字符序列将被发送给程序,但程序在遇到#字符后将结束对输入的处理。
2、使用cin.get(char)进行补救
通常,逐个字符读取输入的程序需要检查每个字符,包括空格、制表符和换行符。cin所属的istream类中包含一个能够满足这种要求的成员函数。具体地说,成员函数cin.get(ch)读取输入中的下一个字符(即使它是空格),并将其赋给变量ch。
程序清单:
1 |
|
运行结果:
1 | Enter characters;enter # to quit: |
现在,该程序回显了每个字符,并将全部字符计算在内,其中包括空格。输入仍被缓冲,因此输入的字符个数仍可能比最终到达程序的要多。
3、使用哪一个cin.get()
1 | char name[ArSize]; |
最后一行相当于两个连续的函数调用:
1 | cin.get(name,ArSize); |
cin.get()的一个版本接受两个参数:数组名(字符串(char * 类型)的地址)和ArSize(int类型的整数)。其中,数组名是第一个元素的地址,因此字符数组名的类型为char *。
另一种用法:只接受一个char参数:
1 | char ch; |
在C++中可以这样使用,因此该语言支持被称为函数重载的OOP特性。
函数重载:允许创建多个同名函数,条件是它们的参数列表不同。
例如:如果在C++中使用cin.get(name,ArSize),则编译器将找到char *和int作为参数的cin.get()版本。如果使用cin.get(ch),则编译器将使用接受一个char参数的版本。如果没有提供参数,则编译器将使用不接受任何参数的cin.get()版本。
函数重载允许对多个相关的函数使用相同的名称,这些函数以不同方式或针对不同类型执行相同的基本任务。
4、文件尾条件
如果输入来自于文件,则可以使用一种功能更强大的技术:检测文件尾(EOF)。
很多操作系统都允许通过键盘来模拟文件尾条件。在Unix中,可以在行首按下Ctrl+D来实现;在Windows命令提示符模式下,可以在任意位置按Ctrl+Z和Enter。用于PC的Microsoft Visual C++、Borland C++ 5.5 和 GNU C++ 都能够识别行首的Ctrl + Z,但用户必须随后按下回车键。
很多PC编程环境都将Ctrl + Z视为模拟的EOF。
程序清单:
1 |
|
5.4 嵌套循环和二维数组
假设要打印数组所有的内容,可以用一个for循环来改变行,用另一个被嵌套的for循环来改变列:
1 | for (int row = 0; row < 4; row++) |
在每个值之后打印一个制表符(使用C++转义字符表示时为\t),打印完每行后,打印一个换行符。
第六章 函数:C++的编程模块
6.1 函数的基本知识
自定义函数时,需要定义函数、提供函数原型和调用函数。
1 |
|
定义函数:
可以将函数分成两类:没有返回值的函数和有返回值的函数。没有返回值的函数被称为void函数。其通用格式:
1 | void functionName(parameterList) |
其中,parameterList指定了传递给函数的参数类型和数量。
void函数相当于Pascal中的过程、FORTRAN中的字程序和现代BASIC中的子程序。
通常,可以用void函数来执行某种操作。例如,将Cheers!打印指定次数(n)的函数:
1 | void cheers(int n) |
有返回值的函数将生成一个值,并将它返回给调用函数。这种函数的类型被声明为返回值的类型。其通用格式:
1 | typeName functionName(parameterList) |
对于有返回值的函数,必须使用返回语句,以便将值返回给调用函数。值本身可以是常量、变量,也可以是表达式,只是其结果的类型必须为typeName类型或可以被转换为typeName。
(例如,如果声明的返回值类型为double,而函数返回一个int表达式,则该int值将被强制转换为double类型)。
C++对于返回值的类型有一定的限制:不能是数组,但可以是其他任何类型:整数、浮点数、指针,甚至可以是结构和对象!(有趣的是,虽然C++函数不能直接返回数组,但可以将数组作为结构或对象组成部分来返回。)
函数原型和函数调用
我们已经熟悉了函数调用,但对函数原型可能不太熟悉,因为它经常隐藏在include文件中。
程序清单:
1 |
|
运行结果:
1 | Cheers! Cheers! Cheers! Cheers! Cheers! |
为什么需要原型:
原型描述了函数到编译器的接口,也就是说,它将函数返回值的类型以及参数的类型和数量告诉编译器。
1 | double volume = cube(side); |
首先,原型告诉编译器,cube()有一个double参数。如果程序没有提供这样的参数,原型将让编译器能够捕获这种错误。其次,cube()函数完成计算后,将把返回值放置在指定的位置——可能是CPU寄存器,也可能是内存中。然后调用函数将从这个位置取得返回值。由于原型指出了cube()的类型为double,因此编译器知道应检索多少个字节以及如何解释它们。
原型的语法:
函数原型是一条语句,因此必须以分号结束。获得原型最简单的方法是,复制函数定义的函数头,并添加分号。
1 | double cube(double x); // add ; to header to get prototype |
然而,函数原型不要求提供变量名,有类型列表就足够了。对于cheer()的原型,该程序只提供了参数类型:
1 | void cheers(int); // okay to drop variable names in prototype |
通常,在原型的参数列表中,可以包括变量名,也可以不包括。原型中的变量名相当于占位符,因此不必与函数定义中的变量名相同。
6.2 函数参数和按值传递
C++通常按值传递参数,这意味着将数值参数传递给函数,而后者将其赋值给一个新的变量。
1 | double volume = cube(side); |
cube()的函数头:
1 | double cube(double x) |
==用于接受传递值的变量被称为形参,传递给函数的值被称为实参==。出于简化的目的,C++标准使用参数(argument)来表示实参,使用参量(parameter)来表示形参,因此参数传递将参量赋给参数。
在函数中声明的变量(包括参数)是该函数私有的。在函数被调用时,计算机将为这些变量分配内存;在函数结束时,计算机将释放这些变量使用的内存。这样的变量被称为局部变量。
多个参数
函数可以有多个参数,在调用函数时,只需要使用逗号将这些参数分开即可。
1 | n_chars('R',25); |
在定义函数时,也在函数头中使用由逗号分隔的参数声明列表:
1 | void n_chars(char c,int n); // two arguments |
该函数头指出,函数n_char接受一个char参数和一个int参数。传递给函数的值被赋给参数c和n。如果函数的两个参数的类型相同,则必须分别指定每个参数的类型,而不能像声明常规变量那样,将声明组合在一起。
1 | void fifi(float a, float b) // declare each variable separately |
和其他函数一样,只需要添加分号就可以得到该函数的原型:
1 | void n_chars(char c,int n); // prototype,style 1 |
和一个参数的情况一样,原型中的变量名不必与定义中的变量名相同,而且可以省略:
1 | void n_chars(char,int); // prototype,style 2 |
然而,提供变量名将使原型更容易理解,尤其是两个参数的类型相同时。这样,变量名可以提醒参量和参数间的对应关系:
1 | double melon_density(double weight,double volume); |
程序清单:
1 |
|
运行结果:
1 | Enter a character: W |
另外一个接受两个参数的函数
程序清单:
1 |
|
运行结果:
1 | Enter the total number of choices on the game card and |
程序说明:
首先是形参(number 和 picks),这是在左括号前面的函数头声明的;其次是其他局部变量(result、n和p),它们是在将函数定义括起的括号内声明的。形参与其他局部变量的主要区别是,形参从调用probability()的函数那里获得自己的值,而其他变量是从函数中获得自己的值。
6.3 函数和数组
1 | int sum_arr(int arr[],int n) // arr = array name , n = size |
方括号指出arr是一个数组,而方括号为空则表明,可以将任何长度的数组传递给该函数,但实际情况并非如此:arr实际上并不是数组,而是一个指针!好消息是,在编写函数的其余部分时,可以将arr看作是数组。
程序清单:
演示如同使用数组名那样使用指针的情况。
1 |
|
运行结果:
1 | Total cookies eaten: 255 |
函数如何使用指针来处理数组
在大多数情况下,C++和C语言一样,也将数组名视为指针。==C++将数组名解释为其第一个元素的地址==。
1 | cookies == &cookies[0]; // array name is address of first element |
该规则有一些例外。首先,数组声明使用数组名来标记存储位置;其次,对数组名使用sizeof将得到整个 数组的长度(以字节为单位);第三,将地址运算符&用于数组名时,将返回整个数组的地址。例如:&cookies将返回一个32字节内存块的地址。
1 | int sum = sum_arr(cookies,ArSize); |
其中,cookies是数组名,而根据C++规则,cookies是其第一个元素的地址,因此函数传递的是地址。由于数组的元素的类型是int,因此cookies的类型必须是int指针,即int*。这表明,正确的函数头应该是:
1 | int sum = sum_arr(int * arr, int n) |
其中用int * arr替换了int arr[ ]。这证明这两个函数头都是正确的,因为在C++中,当(且仅当)用于函数头或函数原型中,int * arr和int arr[ ]的含义才是相同的。它们都意味着arr是一个int指针。然而,数组表示法(int arr[ ])提醒用户,arr不仅指向int,还指向int数组的第一个int。当指针指向数组的第一个元素时,使用数组表示法;而当指针指向一个独立的值时,使用指针表示法。注意:在其他上下文中,int * arr和int arr[ ]的含义并不相同。例如:不能在函数体中使用int tip[ ]来声明指针。
鉴于变量arr实际上就是一个指针,函数的其余部分是合理的。同数组名和指针一样,也可以用方括号表示法来访问数组元素。无论arr是指针还是数组名,表达式arr[3]都指的是数组的第4个元素。就目前而言,下面两个是恒等式,将不会有任何的坏处:
1 | arr[i] == *(arr + i) // values in two notations |
记住,将指针(包括数组名)加1,实际上是加上了一个与指针指向的类型的长度(以字节为单位)相等的值。对于遍历数组而言,使用指针加法和数组下标时等效的。
将数组作为参数意味着什么
实际上并没有将数组的内容传递给函数,而是将数组的位置(地址)、包含的元素种类(类型)以及元素数目(n变量)提交给函数。有了这些信息后,函数便可以使用原来的数组。传递常规变量时,函数将使用该变量的拷贝;但传递数组时,函数将使用原来的数组。
1 | // arr告知数组地址 |
数组名和指针对应是件好事。==将数组地址作为参数可以节省复制整个数组所需的时间和内存==。如果数组很大,则使用拷贝的系统开销将非常大;程序不仅需要更多的计算机内存,还需要花费时间来复制大块的数据。另一方面,使用原始的数据增加了破坏数据的风险。
程序清单:
1 |
|
运行结果:
1 | 0x61fdf0 = array address, 32 = sizeof cookies |
注意:地址值和数组的长度随系统而异。
为将数组类型和元素数量告诉数组处理函数,请通过两个不同的参数来传递:
1 | void fillArray(int arr[], int size); // prototype |
而不要试图使用方括号表示法来传递数组长度:
1 | void fillArray(int arr[size]); // No bad prototype |
更多数组函数示例:
1、填充数组
由于接受数组名参数的函数访问的是一个原始数组,而不是其副本,因此可以通过调用该函数将值赋给数组元素。
可以使用循环连续地将数值读入到数组中,但如何提早结束循环呢?一种方法是,使用一个特殊值来指定输入结束。由于所有的属性都不为负,因此可以使用负数来指出输入结束。另外,函数应对错误输入作反应,如停止输入等。
1 | int fillArray(double ar[], int limit) |
注意,代码中包含了对用户的提示。如果用户输入的是非负值,则这个值将被赋给数组,否则循环结束。如果用户输入的都是有效值,则循环将在读取最大数目的值后结束。循环完成的最后一项工作是将i加1,因此循环结束后,i将比最后一个数组索引大1,即等于填充的元素数目。然后返回这个值。
2、显示数组及用const保护数组
创建显示数组内容的函数很简单,只需将数组名和填充的元素数目传递给函数,然后该函数使用循环来显示每个元素。然而,还有个很重要的问题,确保显示函数不修改原始数组。除非函数的目的就是修改传递给它的数据,否则应避免发生这种情况。使用普通参数时,这种保护将自动实现,这是由于C++按值传递数据,而且函数使用数据的副本。然而,接受数组名的函数将使用原始数据 ,为防止函数无意中修改数组的内容,可以声明形参时使用关键字const。
1 | void show_array(const double arr[], int n); |
该声明表明,指针arr指向的是常量数据,这意味着不能使用arr修改该数据,也就是说,可以使用像arr[0这样的值,但不能修改。注意:这并不是意味着原始数组必须是常量,而只是意味着不能在show_array()函数中使用arr来修改这些数据。因此,show_array()将数组视为只读数据。
3、修改数组
例如:将每个元素与同一个重新评估因子相乘。需要给函数传递3个参数:因子、数组和元素数目。
1 | void revalue(double r,double arr[],int n) |
4、将上述代码组合起来
1 |
|
运行输出:
1 | Enter value #1:100000 |
使用数组区间的函数
一种给函数提供所需信息的方法:指定元素区间(range)。可以通过传递两个指针来完成:一个指针标识数组的开头,另一种指针标识数组的结尾。例如:C++标准模板库,将区间方法广义化了。STL方法使用“超尾”概念来指定区间。也就是说,对于数组而言,标识数组结尾的参数将指向最后一个元素后面的指针。
1 | double elboud[20]; |
则指针elboud和elboud + 20 定义了区间。数组名elboud指向第一个元素,elboud + 20指向数组结尾后面的一个位置。将区间传递给函数将告诉函数应处理哪些元素。
程序清单:
1 |
|
运行结果:
1 | Total cookies eaten: 255 |
指针cookies + ArSize 指向最后一个元素后面的一个位置(数组有ArSize个元素,因此cookies[ArSize - 1] 是最后一个元素,其地址为cookies + ArSize - 1)。因此,区间[cookies, cookies + ArSize]指向的是整个数组。
注意:根据指针减法规则,在sum_arr()中,表达式end - begin 是一个整数值,等于数组的元素数目。
指针和const
将const 用于指针有一些微妙的地方。可以用两种不同的方式将const关键字用于指针。第一种方法是让指针指向一个常量对象,这样可以防止使用该指针来修改所指向的值,第二种方法是将指针本身声明为常量,这样可以防止改变指针指向的位置。
1 | int age = 39; |
该声明指出,pt指向一个const int,因此不能使用pt来修改这个值。换句话来说,*pt的值为const,不能被修改:
1 | *pt += 1; // invalid |
pt的声明并不意味着它指向的值实际上就是一个常量,而只是意味着对pt而言,这个 值是常量。例如:pt指向age,而age不是const。可以直接通过age变量来修改age的值,但不能使用pt指针来修改它:
1 | *pt = 20; // invalid |
以前将常规变量的地址赋给常规指针,现在若将常规的地址赋给指向const指针。出现两种可能:将const变量的地址赋给指向const的指针、将const的地址赋给常规指针。第一种是可行的,但是第二种是不可行的。
1 | const float g_earth = 9.80; |
C++禁止将const的地址赋给非const指针。如果非要这样做,可以使用强制类型转换来突破这种限制。
注意:如果数据类型本身并不是指针,则可以将const数据或非const数据的地址赋给指向const的指针,但只能将非const数据的地址赋给非const指针。
尽可能使用const
将指针参数声明为指向常量数据的指针有两条理由:
- 这样可以避免由于无意间修改数据而导致的编程错误;
- 使用const使得函数能够处理const和非const实参,否则将只能接受非const数据。
如果条件允许,则应将指针形参声明为指向const的指针。
6.4 函数和二维数组
为编写将二维数组作为参数的函数。必须牢记,数组名被视为其地址,因此,相应的形参是一个指针,就像一堆数组一样。
1 | int data[3][4] = {{1,2,3,4},{9,8,7,6},{2,4,6,8}}; |
data的类型是指向由4个int组成的数组的指针,其原型为:
1 | int sum(int (*ar2)[4],int size); |
其中的括号是必不可少的,因为下面的声明将声明一个由4个指向int的指针组成的数组,而不是由一个指向由4个int组成的数组的指针,另外,函数参数不能是数组。
1 | int *ar2[4]; |
还有另外一种格式,这种格式与上述原型的含义完全相同,但可读性更强。
1 | int sum(int ar2[][4],int size); |
上述两个原型都指出,ar2是指针而不是数组。还需要注意的是,指针类型指出,它指向由4个int组成的数组。因此,指针类型指定了列数,这就是没有将列数作为独立的函数参数进行传递的原因。
由于参数ar2是指向数组的指针,那么如何在函数定义中使用它,最简单的方法就是将ar2看作是一个二维数组的名称。
1 | int sum(int ar2[][4],int size) |
同样,行数被传递给size参数,但无论是参数ar2的声明或是内部for循环中,列数都是固定的:4列。
可以使用数组表示法的原因:由于ar2指向数组的第一个元素,因此表达式ar2+r指向编号为r的元素。因此ar2[r]是编号为r的元素。由于该元素本身就是一个由4个int组成的数组,因此ar2[r]是由4个int组成的数组的名称。将下标用于数组名将得到一个数组元素,因此ar2[r][c]是由4个int组成的数组中的一个元素,是一个int值。必须对指针ar2执行两次解除引用,才能得到数据。最简单的方法是使用方括号两次:ar2[r][c]。
或者也可以使用运算符* 两次。
1 | ar2[r][c] == *(*(ar2 + r) + c) |
1 | ar2 // pointer to first row of an array of 4 int |
6.5 函数和C-风格字符串
C-风格字符串由一系列字符组成,以空值字符结尾。将字符串作为参数时意味着传递的是地址,但可以使用const来禁止对字符串参数进行修改。
将C-风格字符串作为参数的函数
假设要将字符串作为参数传递给函数,则表示字符串的方式有三种:
- char 数组;
- 用引号括起的字符串常量(也称字符串字面值);
- 被设置为字符串的地址的char指针
上述3种选择的类型都是char指针(准确地说是char *),因此可以将其作为字符串处理函数的参数:
1 | char ghost[15] = "galloping"; |
可以说是将字符串作为参数来传递,但实际传递的是字符第一个字符的地址。这意味着字符串函数原型应将其表示字符串的形参声明为char *类型。
C-风格字符串与常规char数组之间的一个重要区别是,字符串有内置的结束字符(包含字符,但不以空值字符结尾的char数组只是数组,而不是字符串)。这意味着不必将字符串长度作为
参数传递给函数,而函数可以使用循环依次检查字符串中的每个字符,直到遇到结尾的空值字符为止。
1 |
|
运行结果:
1 | 3 m characters in minimum |
c_in_str()函数不应修改原始字符串,因此它在声明形参str时使用了限定符const。这样,如果错误地址函数修改了字符串的内容,编译器将捕获这种错误。
处理字符串中字符的标准方式:
1 | while(*str) |
str最初指向字符串的第一个字符,因此*str表示的是第一个字符。例如:第一次调用该函数后,*str的值将为m——“minimum”的第一个字符。只要字符不为空值字符(\0),*str就为非零值,因此循环将继续。在每轮循环的结尾处,表达式str++将指针增加一个字节,使之指向字符串的下一个字符。最终,str将指向结尾的空值字符,使得*str等于0——空值字符的数字编码,从而结束循环。
返回C-风格字符串的函数
==函数无法返回一个字符串,但是可以返回字符串的地址==,这样做效率更高。例如:下面程序定义一个buildstr()的函数,该函数返回一个指针。该函数接受两个参数:一个字符和一个数字。函数使用new创建一个长度与数字参数相等的字符串,然后将每个元素都初始化为该字符,然后,返回指向新字符串的指针。
1 |
|
运行结果:
1 | Enter a character: V |
程序说明:==要创建包含n个字符的字符串,需要能够存储n+1个字符的空间,以便能够存储空值字符==。该函数请求分配n+1个字节的内存来存储该字符串,并将最后一个字符设置为空值字符,然后从后向前对数组进行填充。
下面的循环将循环n次,直到n减少为0,这将填充n个元素:
1 | while(n-- > 0) |
在最后一轮循环开始时,n的值为1。由于n是先使用这个值,然后将其递减,因此while循环测试条件将对1和0进行比较,发现测试为true,循环继续。测试后,函数将n减为0,因此pstr[0]是最后一个被设置为c的元素。之所以从后向前(而不是从前向后)填充字符串,是为了避免使用额外的变量。
从前向后填充代码:
1 | int i = 0; |
注意:变量pstr的作用域为buildstr函数内,因此该函数结束时,pstr(而不是字符串)使用的内存将被释放。但由于函数返回了pstr的值,因此程序仍然可以通过main()中的指针ps来访问新建的字符串。
6.6 函数和结构
现在将注意力从数组到结构。为结构编写函数比为数组编写函数简单得多。虽然结构变量和数组一样,都可以存储多个数据项,但在涉及到函数时,结构变量的行为更接近于基本的单值变量。也就是说,与数组不同,结构将其数据组合成单个实体或数据对象,该实体被视为一个整体。可以将一个结构赋给另外一个结构,同样,也可以按值传递结构,就像普通变量那样。在这种情况下,函数将使用原始结构的副本。另外,函数也可以返回结构。与数组名(数组第一个元素的地址)不同的是,**结构名只是结构的名称,要获得结构的地址,必须使用地址运算符&**。在C语言和C++中,都使用==符号&来表示地址运算符==;另外,C++还使用该运算符来表示引用变量。
1、传递和返回结构:
程序清单:
1 |
|
运行输出:
1 | Two-day total: 10 hours, 40 minutes |
其中,travel_time就像是一个标准的类型名,可被用来声明变量、函数的返回值和函数的参数类型。由于total和t1变量是travel_time结构,因此可以对它们使用句点成员运算符。由于sum()函数返回travel_time结构,因此可以将其用作show_time()函数的参数。
2、另一个处理结构的函数示例
介绍个处理空间,而不是时间的案例。具体地说,这个例子将定义两个结构,用于表示两种不同的描述位置的方法,然后开发一个函数,将一种格式转换为另一种格式,并显示结果。
1 |
|
程序说明:
该程序如何使用cin来控制while循环:
1 | while(cin >> rplace.x >> rplace.y) |
cin是istream类的一个对象。抽取运算符(>>)被设计成使得cin >> rplace.x也是一个istream对象。类运算符是使用函数实现的,使用cin >> rplace.x时,程序将调用一个函数,该函数返回一个istream值。整个while循环的测试表达式的最终结果为cin,而cin被用于测试表达式中时,将根据输入是否成功,被转换为bool值true或者false。在该程序中,cin期望用户输入两个数字,如果用户输入了q,cin>>将知道q不是数字,从而将q留在输入队列中,并返回一个将被转换为false的值,导致循环结束。
1 | for(int i = 0; i < limit; i++) |
要提早结束该循环,可以输入一个负值。将cin>>用作测试条件消除了这种限制,因为它接受任何有效的数字输入。在需要使用循环来输入数字时,可以采用这种方式。==如果程序在输入循环后还需要进行输入,则必须使用cin.clear()重置输入==,然后还可能需要通过读取不合法的输入来丢弃它们。
3、传递结构的地址
假设要传递结构的地址而不是整个结构以节省时间和空间,则需要重新编写前面的函数,使用指向结构的指针。重新编写show_polar()函数,需要修改三个地方:
- 调用函数时,将结构的地址(&pplace)而不是结构本身(pplace)传递给它;
- 将形参声明为指向polar的指针,即
polar*类型。由于函数不应该修改结构,因此使用const修饰符; - 由于形参是指针而不是结构,因此应使用间接运算符(->),而不是成员运算符(句点)。
1 | void show_polar (const polar * pda) |
6.7 函数和string对象
虽然C- 风格字符串和string对象的用途几乎相同,但与数组相比,string对象与结构的更相似。例如:可以将一个结构赋给另一个结构,也可以将一个对象赋给另一个对象。可以将结构作为完整的实体传递给函数,也可以将对象作为完整的实体进行传递。如果需要使用多个字符串,可以声明一个string对象数组,而不是二维char数组。
程序清单:
1 |
|
运行结果:
1 | Enter your 5 favorite astronomical sights: |
如果需要string数组,只需要使用通常的数组声明格式即可:
1 | string list[SIZE]; // an array holding 5 string object |
这样,数组list的每个元素都是一个string对象,可以如此使用:
1 | getline(cin,list[i]); |
==getline()函数可读取整行,包括前导和嵌入的空格,并将其存储在字符串对象中==。
同样,形参sa是一个指向string对象的指针,因此sa[i]是一个string对象,可以这样使用:
1 | cout << i + 1 << ": " << sa[i] << endl; |
string与char的区别:
string 是定义一个字符串,存储的是一段如“abcd”的数据,而且最后还有一个结束符’\0’;
char 是定义一个字符,存储一个字符,占一个字节。
char数组可以表示字符串,比如:char[10]就是一个字符串
6.8 函数与array对象
在C++中,类对象是基于结构的,因此结构编程方面的有些考虑因素也适用于类。例如:可按值将对象传递给函数,在这种情况下,函数处理的是原始对象的副本。另外,==也可以传递指向对象的指针,这让函数能够操作原始对象==。
请注意:模板array并非只能存储基本数据类型,它还可以存储类的对象。
1 |
|
运行结果:
1 | Enter Spring expenses: 212 |
程序说明:
由于const array 对象Sname是在所有函数之前声明的,因此可后面的任何函数定义中使用它。
1 | cin >> (*pa)[i]; |
pa是一个指向array<double,4>对象的指针,因此*pa为这种对象,而(*pa)[i]是该对象的一个元素。由于运算符优先级的影响,其中的括号必不可少。
6.9 递归
C++函数有一种有趣的特点——可以调用自己(然而,与C语言不同的是,C++ 不允许main()调用自己),这种功能被称为递归。
1、包含一个递归调用的递归
如果递归函数调用自己,则被调用的函数也将调用自己,这将无限循环下去,除非代码中包含终止调用链的内容。通常的方法将递归调用放在if语句中。
例如:void类型的递归函数recurs()的代码:
1 | void recurs(argumentlist) |
test最终将为false,调用链将断开。只要if语句为true,每个recurs()调用都将执行statements1,然后再调用recurs(),而不会执行statements2。当if语句为false时,当前调用将执行statements2。当前调用结束后,程序控制权将返回给调用它的recurs(),而该recurs()将执行其statements2部分,然后结束,并将控制权返回给前一个调用,依次类推。
因此,如果recurs()进行了5次递归调用,则第一个statements1部分将按函数调用的顺序执行5次,然后statements2部分将以与函数调用相反的顺序执行5次。进入5层递归后,程序将沿进入的路径返回。
1 |
|
运行结果:
1 | Counting down ... 4 |
注意:每个递归调用都创建自己的一套变量,因此当程序到达第5次调用时,将有5个独立的n变量,其中每个变量的值都不同。
为验证这一点,可以修改程序,使之显示n的地址和值:
1 | cout << "Counting down ... " << n << " (n at " << &n << ")" << endl; |
运行结果:
1 | Counting down ... 4 (n at 0x61fe00) |
2、包含多个递归调用的递归
在需要将一项工作不断分为两项较小的,类似的工作时,递归非常的有用。
例如:考虑使用这种方法来绘制标尺的情况,标出两端,找到中点并将其标出。然后将同样的操作用于标尺的左半部分和右半部份。如果进一步细分,可将同样的操作用于当前的每一部分。递归方法有时被称为分而治之策略。
程序清单:
1 |
|
运行结果:
1 | | | |
6.10 函数指针
与数据项相似,函数也有地址。==函数的地址是存储其机器语言代码的内存的开始地址==。通常,这些地址对用户而言,既不重要,也没有什么用处,但对程序而言,却很有用。例如:可以编写将另一个函数的地址作为参数的函数,这样第一个函数将能够找到第二个函数,并运行它。与直接调用另一个函数相比,这种方法很笨拙,但它允许在不同的时间传递不同函数的地址,这意味着可以在不同的时间使用不同的函数。
1、函数指针的基础知识
假设要设计一个名为estimate()的函数,估算编写指定行数的代码所需要的时间,并且希望不同的程序员都将使用该函数。对于所有的用户来说,estimate()中一部分代码都是相同的,但该函数允许每个程序员提供自己的算法来估算时间。为实现这种目标,采用的机制是,将程序员使用的算法函数的地址传递给estimate()。为此,需要完成:
- 获取函数的地址
- 声明一个函数指针
- 使用函数指针来调用函数
1)获取函数的地址
获取函数的地址很简单:只要使用函数名即可。也就是说,如果think()是一个函数,则think就是该函数的地址。要将函数作为参数进行传递,必须传递函数名。一定要区分传递的是函数的地址还是函数的返回值。
1 | process(think); // passes address of think() to process() |
process()调用使得process()函数能够在其内部调用think()函数。thought()调用首先调用think()函数,然后将think()的返回值传递给thought()函数。
2)声明函数指针
声明指向某种数据类型的指针时,必须指定指针指向的类型。同样,声明指向函数的指针时,也必须指定指针指向的函数类型。这意味着声明应指定函数的返回类型以及函数的特征标(参数列表)。也就是说,声明应像函数原型那样指出有关函数的信息。
例如:假设编写一个估算时间的函数,其原型:
1 | double pam(int); // prototype |
则正确的指针类型声明如下:
1 | double (*pf)(int); |
这与pam()声明类似,这是将pam替换为(*pf)。由于pam是函数,因此(*pf)也是函数。而如果(*pf)是函数,则pf就是函数指针。
提示:通常,要声明指向特定类型的函数指针,可以首先编写这种函数的原型,然后用(*pf)替换函数名。这样pf就是这类的函数的指针。
为提供正确的运算符优先级,必须在声明中使用括号将*pf括起。括号的优先级比*运算符高,因此*pf(int)==意味着pf()是一个返回指针的函数==,而(*pf)(int)意==味着pf是一个指向函数的指针==。
1 | //函数指针 |
正确地声明pf后,便可以将相应函数的地址赋给它:
1 | double pam(int); |
注意:pam()的特征标和返回类型必须与pf相同。如果不相同,编译器将拒绝这种赋值:
1 | double ned(double); |
假设要将编写的代码行数和估算算法(如pam()函数)的地址传递给它,则原型如下:
1 | void estimate(int lines,double (*pf)(int)); |
第二个参数是函数指针,它指向的函数接受一个int参数,并返回一个double值。
要让estimate()使用pam()函数,需要将pam()的地址传递给它:
1 | estimate(50,pam); // function call telling estimate() to use pam() |
显然,使用函数指针时,比较棘手的是编写原型,而传递地址则非常简单。
3) 使用指针来调用函数
现在进入最后一步,即使用指针来调用被指向的函数。线索来自指针声明。(*pf)扮演的角色与函数名相同,因此使用(*pf)时,只需将它看作函数名即可:
1 | double pam(int); |
实际上,C++也允许像使用函数名那样使用pf:
1 | double y = pf(5); // also call pam() using the pointer pf |
第一种格式虽然不太好看,但是给出了强有力的提示:代码正在使用函数指针。
2、函数指针示例
程序清单:
1 |
|
运行结果:
1 | How many lines of code do you need? 30 |
3、 深入探讨函数指针
函数指针的表示可能非常的恐怖。
下面是一些函数的原型,它们的特征标和返回值类型相同:
1 | const double * f1(const double ar[], int n); |
三者是等价的。但是函数定义必须提供标识符,因此需要使用const double ar[] 或者const double * ar。
程序清单:
1 |
|
运行输出:
1 | Using pointers to function: |
感谢 auto
C++11 的目标之一是让C++更容易使用,从而让程序员将主要精力放在设计上而不是细节上。
1 | auto pc = &pa; // C++11 automatic type deduction |
自动类型推动功能表明,编译器的角色发生了改变。在C++98中,编译器利用其知识帮助您发现错误,而在C++11中,编译器利用其知识帮助您进行正确的声明。
存在一个潜在的缺点,自动类型推断确保变量的类型与赋给它的初值的类型一致,但您提供的初值可能不对:
1 | auto pc = *pa; // oops! used *pa instead of &pa |
该声明导致pc的类型与*pa一致。后面使用它时假定其类型与&pa相同,这将导致编译错误。
4、 使用typedef进行简化
除了auto外,C++还提供了其他简化声明的工具。==关键字typedef能够创建类型的别名==:
1 | typedef double real; // makes real another name for double |
这里采用的方法是,将别名当做标识符进行声明,并在开头使用typedef。因此,可将p_fun声明为使用的函数指针类型的别名:
1 | typedef const double *(*p_fun)(const double *, int); |
然后使用这个别名来简化代码:
1 | p_fun pa[3] = {f1,f2,f3}; // pa an array of 3 function pointers |
使用typedef可以减少输入量,在编写代码时不容易犯错,并让程序更容易理解。
6.11 总结
函数是C++的编程模块。要使用函数,必须提供定义和原型,并调用该函数。函数定义是实现函数的功能的代码;函数原型描述了函数的接口:传递给函数的值的数目和种类以及函数的返回类型。函数调用使得程序将参数传递给函数,并执行函数的代码。
在默认情况下,C++函数按值传递参数。这意味着函数定义中的形参是新的变量,它们被初始化为函数调用所提供的值。因此,C++函数通过使用拷贝,保护了原始数据的完整性。
C++将数组名参数视为数组第一个元素的地址。从技术上讲,这仍然是按值传递的,因为指针是原始地址的拷贝,但函数将使用指针来访问原始数组的内容。当且仅当声明函数的形参时,下面两个声明才是等价的:
1 | typeName arr[]; |
这两个声明都表明,arr是指向typeName的指针,但在编写函数代码时,可以像使用数组名那样使用arr来访问元素:arr[i]。即使在传递指针时,也可以将形参声明为const指针,来保护原始数据的完整性。由于传递数据的地址时,并不会传输有关数组长度的信息,因此通常将数组长度作为独立的参数来传递。另外,也可以传递两个指针(其中一个指向数组开头,另一个指向数组末尾的下一个元素),以指定一个范围,就像STL使用的算法一样。
C++提供了3种表示C-风格字符串的方法:字符数组、字符串常量和字符串指针。它们的类型都是char*(char指针),因此被作为char*类型参数传递给函数。C++使用空值(\0)来结束字符串,因此字符串函数检测空值字符来确定字符串的结尾。
C++还提供了string类,用于表示字符串。函数可以接受string对象作为参数以及将string对象作为返回值。string类的方法size()可用于判断其存储的字符串的长度。
C++处理结构的方式与基本类型完全相同,这意味着可以按值传递结构,并将其用作函数返回类型。然而,如果结构非常大,则传递结构指针的效率将更高,同时函数能够使用原始数据。这些考虑因素也适用于对象。
C++函数可以是递归的,也就是说,函数代码中可以包括对函数本身的调用。
C++函数名与函数地址的作用相同。通过将函数指针作为参数,可以传递要调用的函数的名称。
第七章 函数探幽
7.1 C++内联函数
内联函数是C++为提高程序运行速度所做的一项改进。常规函数和内联函数之间的主要区别不在于编写方式,而在于C++编译器如何将它们组合到程序中。要了解内联函数与常规函数的区别,必须深入到程序内部。
编译过程的最终产品是可执行程序:由一组机器语言指令组成。运行程序时,操作系统将这些指令载入到计算机内存中,因此每条指令都有特定的内存地址。计算机随后将逐步执行这些指令。有时,将跳过一些指令,向前或向后跳到特定地址。常规函数调用也使程序跳到另一个地址(函数地址),并在函数结束时返回。
C++内联函数提供了另一种选择,内联函数的编译代码与其他程序代码”内联”起来了。也就是说,编译器将使用相应的函数代码替换函数调用。对于内联代码,程序无需跳到另一个位置处执行代码,再跳回来。因此,内联函数的运行速度比常规函数稍快,但代价是需要占用更多内存。如果程序在10个不同的地方调用同一个内联函数,则该程序将包含该函数代码的10个副本。
应该有选择地使用内联函数。如果执行函数代码的时间比处理函数调用机制的时间长,则节省的时间将只占整个过程的很小一部分。如果代码执行时间很短,则内联调用就可以节省非内联调用使用的大部分时间。另一方面,由于这个过程相当快,因此尽管节省了该过程的大部分时间,但节省的时间绝对值并不大,除非该函数经常被调用。
要使用这项特性,必须采取下述措施之一:
- 在函数声明前加上关键字inline
- 在函数定义前加上关键字inline
通常的做法是省略原型,将整个定义(即函数头和所有函数代码)放在本应提供原型的地方。
程序清单:
1 |
|
运行结果:
1 | a = 25,b = 144 |
输出表明,内联函数和常规函数一样,也是按值来传递参数的。如果参数为表达式,则函数将传递表达式的值。这使得C++的内联功能远远胜过C-语言的宏定义。
尽管程序没有提供独立的原型,但C++原型特性仍在起作用。这是因为在函数首次使用前出现的整个函数定义充当了原型。这意味着可以给square()传递int或者long值,将值传递给函数前,程序自动将这个值强制转换为double类型。
:ship:内联与宏
inline工具是C++新增的特性。C语言使用预处理器语句#define来提供宏——内联代码的原始实现。例如:计算平方的宏:
1 |
这并不是通过传递参数实现的,而是通过文本替换来实现的——X是”参数”的符号标记。
1 | a = SQUARE(5.0); is replaced by a = 5.0 * 5.0; |
上述示例只有第一个能正常工作。可以通过使用括号来进行改进:
1 |
但仍然存在这样的问题,即宏不能按值传递。即使使用新的定义,SQUARE(c++)仍将c递增两次,但是上面程序中的内联函数square()能计算c的结果,传递它,以计算其平方值,然后将c递增一次。
这里的目的不是演示如何编写C宏,而是要指出,如果使用C语言的宏执行了类似函数的功能,应考虑将其转换为C++内联函数。
7.2 引用变量
C++新增了一个复合类型:引用变量。==引用是已定义的变量的别名(另一个名称)==。
例如:如果将twain作为clement变量的引用,则可以交替使用twain和clement来表示该变量。
引用变量的主要用途是用作函数的形参,通过将引用变量用作参数,函数将使用原始数据,而不是其副本。这样除了指针外,引用也可以为函数处理大型结构提供了一种非常方便的途径。同时对于设计类来说,引用也是必不可少的。
1、创建引用变量
C和C++使用&符号来指示变量的地址。C++给&符号赋予了另一个含义,将其用来声明引用。
例如:将rodents 作为 rats变量的别名,可以:
1 | int rats; |
其中,&不是地址运算符,而是类型标识符的一部分。就像声明中的char*指的是指向char的指针一样,==int & 指 的是指向int的引用==。上述引用声明允许将rats和rodents互换,它们指向相同的值和内存单元。
1 |
|
运行结果:
1 | rats = 101,rodents = 101 |
请注意:下述语句中的&运算符不是地址运算符,而是将rodents的类型声明为 int &,即指向int变量的引用。
1 | int & rodents = rats; |
但下面语句中的&运算符是地址运算符,其中&rodents表示rodents引用的变量的地址。
1 | cout << ", rodents address = " << &rodents << endl; |
由此可知,rats和rodents的值和地址都相同。将rodents加1将影响这两个变量。更准确地说,rodents++操作是将一个有两个名称的变量加1.
指针和引用之间是有区别的。例如:可以创建指向rats的引用和指针。
1 | int rats = 101; |
这样,表达式rodents和 *prats 都可以同rats互换,而表达式&rodents和prats都可以同&rats互换。从这一点来说,引用看上去像是伪装表示的指针(其中,*解除引用运算符被隐式理解 )。实际上,引用还是不同于指针的,除了表示法不同外,还有其它的区别。
例如:必须在声明引用的同时将其初始化。而不能像指针那样,先声明,再赋值。
1 | int rat; |
注意:必须在声明引用变量时进行初始化。
引用更接近const指针,必须在创建时进行初始化,一旦与某个变量关联起来,就将一直效忠于它。也就是说:
1 | int & rodents = rats; |
实际上是下述代码的伪装表示:
1 | int * const pr = & rats; |
其中,引用rodents扮演的角色与表达式*pr相同。
程序清单:
1 |
|
运行结果:
1 | rats = 101, rodents = 101 |
最初,rodents引用的是rats,但随后程序试图将rodents作为bunnies的引用。
1 | rodents = bunnies; |
乍一看,这种意图暂时是成功的,因为rodents的值从101变成了50.但仔细研究将发现,rats也变成了50,同时rats和rodents的地址相同,而地址与bunnies的地址不同。由于rodents是rats的别名,因此上述赋值语句与下面的语句等效:
1 | rats = bunnies; |
也就是说,这意味着:将bunnies变量的值赋给rat变量。简而言之,可以通过初始化声明来设置引用,但不能通过赋值来设置。
2、将引用用作函数参数
引用经常被用作函数参数,使得函数中的变量名成为调用程序中的变量的别名,这种传递参数的方法称为按引用传递。按引用传递允许被调用的函数能够访问调用函数中的变量。C++新增的这项特性是对C语言的超越,C语言只能按值传递。按值传递导致被调用函数使用调用程序的值的拷贝。当然,C语言也允许避开按值传递的限制,采用按指针传递的方式。
解决常见的计算机问题:交换两个变量的值,对使用引用和使用指针做一下比较。
交换函数必须能够修改调用程序中的变量的值。这意味着按值传递变量将不管用,因为函数将交换原始变量的副本的内容,而不是变量本身的内容。但传递引用时,函数将可以使用原始数据。另一种方法是,传递指针来访问原始数据。
程序清单:
1 |
|
运行结果:
1 | wallet1 = $300 wallet2 = $350 << original values |
引用和指针方法都成功得交换了两个钱包(wallet)中的内容,而按值传递的方法没能完成这项任务。
程序说明:
1 | swapr(wallet1,wallet2); // pass variables |
按引用传递和按值传递看起来调用相同,只能通过原型或函数定义才能知道是如何传递的。然而,地址运算符(&)使得按地址传递(swapp())(类型声明int *p表明,p是一个int指针,因此与p对应的参数应为地址,如&wallet1)。
比较函数swapr()(按引用传递)和swapv()(按值传递)的代码,唯一的外在区别是声明函数参数的方式不同:
1 | void swapr(int & a, int & b); |
内在区别是:在swapr()中,变量a和b是wallet1和wallet2的别名,所以交换a和b的值相当于交换wallet1和wallet2的值;但在swapv()中,变量a和b是复制了wallet1和wallet2的值的新变量。因此交换a和b的值并不会影响wallet1和wallet2的值。
比较函数swapr()(传递引用)和swapp()(传递指针),第一个区别是声明函数参数的方式不同:
1 | void swapr(int & a, int & b); |
另一个区别是指针版本需要在函数使用p和q的整个过程中使用解除引用运算符*。
应在定义引用变量时对其进行初始化。函数调用使用实参初始化形参,因此函数的引用参数被初始化为函数调用传递的实参。也就是说,下面的函数调用将形参a和b分别初始化为wallet1和wallet2:
1 | swapr(wallet1,wallet2); |
3、 引用的属性和特别之处
使用引用参数时,需要了解其一些特点。
程序清单:
1 |
|
运行结果:
1 | 27 = cube of 3 |
refcube()函数修改了main()中的x值,而cube()没有,这提醒了我们为何通常按值传递。变量a位于cube()中,它被初始化为x的值,但修改a并不会影响到x。但由于refcube()使用了引用参数,因此修改ra实际上就是修改了x。如果意图是让函数使用传递给它的信息,而不对这些信息进行修改,同时又想使用引用,则应使用常量引用。
例如:应在函数原型和函数头中使用const:
1 | double refcube(const double &ra); |
如果这样做,当编译器发现代码修改了ra的值,将生成错误的消息。
顺便说一句,如果要编写类似于上述示例 的函数(即使用基本数值类型),应采用按值传递的方式,而不要采用按引用传递的方式。
临时变量、引用参数和const
如果实参与引用参数不匹配,C++将生成临时变量。当前,仅当参数为const引用时,C++才允许这样做。
何种情况下,C++将生成临时变量,以及为何对const引用的限制是合理的。
首先,什么时候将创建临时变量呢?如果引用参数是const,则编译器将在下面两种情况下生成临时变量:
- 实参的类型正确,但不是左值;
- 实参的类型不正确,但可以转换为正确的类型。
左值参数是可以被引用的数据对象。例如:变量、数组元素、结构成员、引用和解除引用的指针都是左值。非左值包括字面常量(用引号括起的字符串除外,它们由地址表示)和包含多项的表达式。在C语言中,左值最初指的是可出现在赋值语句左边的实体,但这是引入关键字const之前的情况,现在,常规变量和const变量都可视为左值,因为可通过地址访问它们。但常规变量属于可修改的左值,而const变量属于不可修改的左值。
注意:如果函数调用的参数不是左值或与相应的const引用参数的类型不匹配,则C++将创建类型正确的匿名变量,将函数调用的参数的值传递给该匿名变量,并让参数来引用该变量。
:ship:应尽可能使用const
将引用参数声明为常量数据的引用的理由有三个:
- 使用const可以避免无意中修改数据的编程错误;
- 使用const使函数能够处理const和非const实参,否则将只能接受非const数据;
- 使用const引用使函数能够正确生成并使用临时变量。
因此,应尽可能将引用参数声明为const。
C++11 新增了另一种引用:右值引用(rvalue reference)。这种引用可指向右值,是使用&&声明的。
1 | double && rref = std::sqrt(36.00); //not allowed for double & |
新增右值引用的主要目的是:让库设计人员能够提供有些操作的更有效实现。
4、将引用用于结构
引用非常适合用于结构和类(C++的用户定义类型)。确实,引入引用主要是为了用于这些类型的,而不是基本的内置类型。
使用结构引用参数的方式与使用基本变量引用相同,只需要在声明结构参数时使用引用运算符&即可。
1 | struct free_throws |
则可以这样编写函数原型,在函数中指向该结构的引用作为参数:
1 | void set_pc(free_throws & ft); // use-a reference to a structure |
如果不希望函数修改传入的结构,可用const:
1 | void display(const free_throws & ft); //don't allow changes to strcture |
程序清单:
1 |
|
运行结果:
1 | Name: Ifelsa Branch |
5、将引用用于类对象
将类对象传递给函数时,C++通常的做法是使用引用。例如:可以通过使用引用,让函数将类string、ostream、istream、ofstream和ifstream等类的对象作为参数。
6、对象、继承和引用
ofstream对象可以使用ostream类的方法,这使得文件输入/输出的格式与控制台输入/输出相同。使得==能够将特性从一个类传递给另一个类的语言特性被称为继承==。简单来说,ostream是基类(因为ofstream是建立在它的基础之上的),而ofstream是派生类(因为它是从ostream派生而来的)。派生类继承了基类的方法,这意味着ofstream对象可以使用基类的特性,如格式化方法precision()和setf()。
==继承的另一个特征是,基类引用可以指向派生类对象,而无需进行强制类型转换==。这种特征的一个实际结果是,可以定义一个接受基类引用作为参数的函数,调用该函数时,可以将基类对象作为参数,也可以将派生类对象作为参数。例如:参数类型ostream &的函数可以接受ostream对象(如cout)或声明的ofstream对象作为参数。
7、何时使用引用参数
使用引用参数的主要原因有两个:
- 程序员能够修改调用函数中的数据对象
- 通过传递引用而不是整个数据对象,可以提高程序的运行速度。
当数据对像较大时(如结构和类对象),第二个原因最重要。这些也是使用指针参数的原因。这是有道理的,因为引用参数实际上是基于指针的代码的另一个接口。
什么时候应使用引用、什么时候应使用指针、什么时候应按值传递呢?
对于使用传递的值而不作为修改的函数。
- 如果数据对象很小,如内置数据类型或小型结构,则按值传递。
- 如果数据对象是数组,则使用指针,因为这是唯一的选择,并将指针声明为指向const的指针。
- 如果数据对象是较大的结构,则使用const指针或const引用,以提高程序的效率。这样可以节省复制结构所需的时间和空间。
- 如果数据对象是类对象,则使用const引用。类设计的语义常常要求使用引用,这是C++新增这项特性的主要原因。因此,传递类对象参数的标准方式是按引用传递。
对于修改调用函数中数据的函数:
- 如果数据对象是内置数据类型,则使用指针。如果看到诸如fixit(&x)这样的代码(其中x是int),则很明显,该函数将修改x。
- 如果数据对象是数组,则只能使用指针。
- 如果数据对象是结构,则使用引用或指针。
- 如果数据对象是类对象,则使用引用。
7.3 默认参数
默认参数值的是当函数调用中省略了实参时自动使用的一个值。例如:如果将void wow(int n)设置成n有默认值为1,则函数调用wow()相当于wow(1)。这极大得提高了使用函数的灵活性。假设有一个名为left()的函数,它将字符串和n作为参数,并返回该字符串的前n个字符。更准确地说,该函数返回一个指针,该指针指向原始字符串中被选中的部分组成的字符串。例如:函数调用left(“theory”,3)将创建新字符串”the”,并返回一个指向该字符串的指针。现假设第二个参数的默认值被设置为1,则函数调用left(“theory”,3)仍像前面那样工作,3将覆盖默认值。但函数调用left(“theory”)不会出错,它认为第二个参数的值为1,并返回指向字符串”t”的指针。如果程序经常需要抽取一个字符组成的字符串,而偶尔需要抽取较长的字符串,则这种默认值将很有帮助。
默认值的设置必须通过函数原型。由于编译器通过查看原型来了解函数所使用的参数数目,因此函数原型也必须将可能的默认参数告知程序。方法是将值赋给原型中的参数。
1 | char * left (const char * str,int n = 1); |
==对于带参数列表的函数,必须从右向左添加默认值。==也就是说,要为某个参数设置默认值,则必须为它右边的所有参数提供默认值:
1 | int harpo(int n,int m = 4,int j = 5); // valid |
例如,harpo()原型允许调用该函数时提供1个、2个、3个参数。
1 | beeps = harpo(2); // same as harpo(2,4,5) |
==实参按从左到右的顺序依次被赋给相应的形参,而不能跳过任何参数。==因此,下面的调用是不允许的:
1 | beeps = harpo(3,,8); // invalid , doesn't set m to 4 |
默认参数只是提供了一种便捷的方式。在设计类时,可以通过使用默认参数,减少要定义的析构函数、方法以及方法重载的数量。
程序清单:
1 |
|
运行结果:
1 | Enter a string: |
该程序使用new创建一个新的字符串,以存储被选择的字符。
1 | i < n && str[i] |
i < n 测试让循环复制了n个字符后终止。测试的第二部分:表达式str[i],是要复制的字符的编码。遇到空值字符(其编码为0)后,循环将结束。这样,while循环将使字符串以空值字符结束,并将余下的空间设置为空值字符。
另一种设置新字符串长度的方法是,将n设置为传递的值和字符串长度中较小的一个。
1 | int len = strlen(str); |
这将确保new分配的空间不会多于存储字符串所需的空间。
例:当m的值等于n或到达字符串结尾时,下面的值循环都将终止:
1 | int m = 0; |
在str[m]不是空值字符时,表达式str[m] != ‘\0’的结果为true,否则为false。由于在&&表达式中,非零值被转换为true,而零被转换为false,因此也可以这样编写这个while测试:
1 | while (m <= n && str[m]) |
7.4 函数重载
函数重载指的是可以用多个同名的函数,因此对名称进行了重载。函数重载完成相同的工作,但使用不同的参数列表。
函数重载的关键是函数的参数列表,也称为函数特征标(function signature)。如果两个函数的参数数目和类型相同,同时参数的排列顺序也相同,则它们的特征标相同,而变量名是无关紧要的。C++允许定义名称相同的函数,条件是它们的特征标不同。如果参数数目和/或参数类型不同,则特征标也不同。
一些看起来彼此不同的特征标是不能共存的。例如:
1 | double cube(double x); |
可能认为在此处使用函数重载,因为它们的特征标看起来不同。然而,从编译器的角度来考虑:
1 | cout << cube(x); |
参数x与double x原型和double & x原型都匹配,因此编译器无法确定究竟应使用哪个原型。为避免这种混乱,编译器在检查函数特征标时,将把类型引用和类型本身视为同一种特征标。
匹配函数时,并不区分const和非const变量。
1 | void dribble (char * bits); //overloaded |
注意:是特征标,而不是函数类型使得可以对函数进行重载。
例如:下面两个声明是互斥的:
1 | long gronk (int n, float m); |
因此,C++不允许以这种方式重载gronk()。返回类型可以不同,但特征标也必须不同:
1 | long gronk (int n, float m); |
何时使用函数重载
虽然函数重载很吸引人,但也不要滥用。仅当函数基本上执行相同的任务,但使用不同的数据时,才应采用函数重载。另外,是否可以通过使用默认参数来实现同样的目的。
例如:可以用两个重载函数来代替面向字符串的left()函数:
1 | char * left(const char * str,unsigned n); // two arguments |
使用一个默认参数的函数要简单些,只需编写一个函数(而不是两个函数),程序也只需为一个函数(而不是两个)请求内存;需要修改函数时,只需修改一个。然而,如果需要使用不同类型的参数,则默认参数便不管用了,在这种情况下,应该使用函数重载。
7.5 函数模板
函数模板是通用的函数描述,也就是说,它们使用泛型来定义函数,其中的泛型可用具体的类型(如int 或 double)替换。通过将类型作为参数传递给模板,可使编译器生成该类型的函数。由于模板允许泛型(而不是具体类型)的方式编写程序,因此有时也被称为通用编程。由于类型是用参数表示的,因此模板特性有时也被称为参数化类型(parameterized types)。
例如:定义一个交换两个int值的函数。假设要交换两个double值,则一种方法是复制原来的代码,并用double替换所有的int。如果需要交换两个char 值,可以再次使用同样的技术。进行这种修改将浪费宝贵的时间,且容易出错。如果进行手工修改,则可能会漏掉一个int。如果进行全局查找和替换(如用double替换int)时,可能将:
1 | int x; |
C++的函数模板功能能自动完成这一过程,可以节省时间,而且更可靠。
函数模板允许以任意类型的方式来定义函数。例如:可以建立一个交换模板:
1 | template <typename AnyType> |
第一行指出,要建立一个模板,并将类型命名为AnyType。关键字template 和 typename是必需的,除非可以使用关键字class代替typename。另外,必须使用尖括号。类型名可以任意选择(这里是AnyType),只要遵守C++命名规则即可;许多程序员都使用简单的名称,如 T。
==模板并不创建任何函数,而只是告诉编译器如何定义函数==。需要交换int的函数时,编译器将按模板模式创建这样的函数,并用int代替AnyType。同样,需要交换double函数时,编译器将按模板模式创建这样的函数,并用double代替AnyType。
在标准C++98 添加关键字typename之前,C++使用关键字class来创建模板。也就是说,可以这样编写模板定义:
1 | template <class AnyType> |
typename关键字使得参数AnyType表示类型这一点更为明显;然而,有大量代码库是使用关键字class开发的。在这种上下文中,这两个关键字是等价的。
提示:如果需要多个将同一种算法用于不同类型的函数,请使用模板。如果不考虑向后兼容的问题,并愿意键入较长的单词,则声明类型参数时,应使用关键字typename而不使用calss。
程序清单:
1 |
|
运行结果:
1 | i, j = 10, 20. |
程序说明:
第一个Swap()函数接受两个int参数,因此编译器生成该函数的int版本。也就是说,用int替换所有的T,生成下面这样的定义:
1 | void Swap(int &a, int &b) |
程序员看不到这些代码,但编译器确实生成并在程序中使用了它们。第二个Swap()函数接受两个double参数,因此编译器将生成double版本。也就是说,用double替换T,生成下述代码:
1 | void Swap(double &a, double &b) |
注意:函数模板不能缩短可执行程序。对于上述程序清单,最终仍将由两个独立的函数定义,就像以手工方式定义了这些函数一样。最终的代码不包含任何模板,而只包含了为程序生成的实际函数。使用模板的好处是,它使生成多个函数定义更简单、更可靠。
1、重载的模板
需要对多个不同类型使用同一种算法的函数时,可使用模板。然而,并非所有的类型都使用相同的算法,为满足这种需求,可以像重载常规函数定义那样重载模板定义。和常规重载一样,被重载的模板的函数特征标必须不同。
例如:下面程序新增一个交换模板,用于交换两个数组中的元素。原来的模板的特征标为(T &,T &),而新模板的特征标为(T [ ], T [ ],int)。注意,在后一个模板中,最后一个参数的类型为具体类型(int),而不是泛型。并非所有的模板参数都必须是模板参数类型。
程序清单:
1 |
|
运行结果:
1 | i, j = 10, 20. |
2、模板的局限性
假设有如下模板函数:
1 | template <class T> // or template <typename T> |
通常,代码假定可执行哪些操作。例如:下面的代码假定定义了赋值,但如果T为数组,这种假设将不成立:
1 | a = b |
同样,下面的语句假设定义了<,但如果T为结构,该假设便不成立:
1 | if(a > b) |
另外,为数组名定义了运算符 > ,但由于数组名为地址,因此它比较的是数组的地址,而这种不是您希望的。
下面的语句假定为类型T定义了乘法运算符,但如果T为数组、指针或结构,这种假设便不成立:
1 | T c = a * b; |
总之,编写的模板函数很可能无法处理某些类型。
3、显式具体化
假设定义了如下结构:
1 | struct job |
另外,假设希望能够交换两个这种结构的内容。原来的模板使用下面的代码来完成交换:
1 | temp = a; |
由于C++允许将一个结构赋给另一个结构,因此即使T是一个job结构,上述代码也适用。然而,假设只想交换salary和floor成员,而不交换name成员,则需要使用不同的代码,但Swap()的参数将保持不变(两个job结构的引用),因此无法使用模板重载来提供其他的代码。
然而,可以提供一个具体化函数定义,称为显示具体化(explicit specialization)。其中包含所需的代码。当编译器找到与函数调用匹配的具体化定义时,将使用该定义,而不再寻找模板。
具体化机制随着C++的演变而不断变化。下面介绍C++标准定义的形式。
试验其他具体化方法后,C++98标准选择了下面的方法。
- 对于给定的函数名,可以有非模板函数、模板函数和显式具体化模板函数以及它们的重载版本。
- 显式具体化的原型和定义应以template<>开头,并通过名称来指出类型。
- 具体化优先于常规模板,而非模板函数优于具体化和常规模板。
下面是用于交换job结构的非模板函数、模板函数和具体化的原型:
1 | // non template function prototype |
如果有多个原型,则编译器在选择原型时,非模板版本优先于显式具体化和模板版本,而显式具体化优先于使用模板生成的版本。
例如:在下面的代码中,第一次调用Swap()时使用通用版本,而第二次调用使用基于job类型的显示具体化版本。
1 | template <class T> |
Swap<job>中的job是可选的,因为函数的参数类型表明,这是job的一个具体化。因此,该原型也可以这样编写:
1 | template <> void Swap(job &, job &); // simpler from |
4、模板函数的发展
在C++发展的早期,大多数人都没有想到模板函数和模板类会有这么强大而有用。C++98标准做出了相应的修改,并添加了标准模板库。从此以后,模板程序员在不断探索各种可能性,并消除模板的局限性。C++11标准根据这些程序员的反馈做出了相应的修改。
1)是什么类型
在C++98中,编写模板函数时,一个问题是并非总能知道应在声明中使用哪种类型。
1 | template<class T1, class T2> |
xpy应为什么类型呢?由于不知道ft()将如何使用,因此无法预先知道这一点。正确的类型可能是T1、T2或其他类型。例如,T1可能是double,而T2可能是int,在这种情况下,两个变量的和将为double类型。T1可能是short,而T2可能是int,在这种情况下,两个变量的和将为int类型。T1还可能是short,而T2可能是char,在这种情况下,加法运算符将导致自动整型提升,因此结果类型为int。另外,结构和类可能重载运算符+,这导致问题更加复杂。因此,在C++98中,没有办法声明xpy的类型。
2)关键字decltype(C++11)
C++11 新增的关键字decltype提供了解决方案。可这样使用该关键字:
1 | int x; |
给decltype提供的参数可以是表达式,因此在前面的模板函数ft()中,可使用下面的代码:
1 | decltype(x + x) xpy; // make xpy the same type as x + y |
或者,将这两条语句合而为一:
1 | decltype(x + x) xpy = x + y; |
因此,可以这样修复前面的模板函数ft():
1 | template<class T1, class T2> |
为确定类型,编译器必须遍历一个核对表。假设有如下声明:
1 | decltype(expression) var; |
则核对表的简化版如下:
第一步:如果expression是一个没有用括号括起的标识符,则var的类型与该标识符的类型相同,包括const等限定符:
1 | double x = 5.5; |
第二步:如果expression是一个函数调用,则var的类型与函数的返回值类型相同:
1 | long indeed(int); |
注意:并不会实际调用函数。编译器通过查看函数的原型来获悉返回类型,而无需实际调用函数。
第三步:如果expression是一个左值,则var为指向其类型的引用。这好像意味着前面的w应为引用类型。因为x是一个左值。但别忘了,这种情况已经在第一步处理过了,要进入第三步,expression不能是未用括号括起的标识符。那么,expression是什么时候将进入第三步呢?一种显而易见的情况是,expression是用括号括起的标识符:
1 | double xx = 4.4; |
顺便说一句,括号并不会改变表达式的值和左值性。例如:下面两条语句是等效的:
1 | xx = 98.6; |
第四步:如果前面的条件都不满足,则var的类型与expression的类型相同:
1 | int j = 3; |
请注意:虽然k和n都是引用,但表达式k+n不是引用;它是两个int的和,因此类型为int。
如果需要多次声明,可结合使用typedef和decltype。
1 | template <class T1, class T2> |
3) 另一种函数声明语法(C++11 后置返回类型)
有一个相关的问题是decltype本身无法解决的。请看下面这个不完整的模板函数:
1 | template <class T1, class T2> |
同样,无法预先知道将x和y相加得到的类型。好像可以将返回类型设置为decltype(x + y)。但不幸的是,此时还未声明参数x和y,它们不在作用域内(编译器看不到它们,也无法使用它们)。必须在声明参数后使用decltype。为此,C++新增了一种声明和定义函数的语法。
例如:使用内置类型来说明这种语法的工作原理:
1 | double h(int x, float y); |
使用新增的语法可编写成这样:
1 | auto h(int x, float y) -> double; |
这将返回类型移到了参数声明的后面。**->double被称为后置返回类型(trailing return type)**。其中auto是一个占位符,表示后置返回类型提供的类型。
也可用于函数定义:
1 | auto h(int x, float y) -> double |
通过结合使用这种语法和decltype,便可给gt()指定返回类型。
1 | template<class T1, class T2> |
现在,decltype再参数声明后面,因此x和y位于作用域内,可以使用它们。
7.6 总结
C++扩展了C语言的函数功能,通过将inline关键字用于函数定义,并在首次调用该函数前提供其函数定义,可以使得C++编译器将该函数视为内联函数。也就是说,编译器不是让程序跳到独立的代码段,以执行函数,而是用相应的代码替换函数调用,只有在函数很短时才能采用内联方式。
引用变量是一种伪装指针,它允许为变量创建别名(另一个名称)。引用变量主要被用作处理结构和类对象的函数的参数。通常,被声明为特定类型引用的标识符只能指向这种类型的数据;然而,如果一个类(如ofstream)是从另一个类(如ostream)派生出来的,则基类引用可以指向派生类对象。
C++原型可以定义参数的默认值。如果函数调用省略了相应的参数,则程序将使用默认值;如果函数调用提供了参数值,则程序将使用这个值(而不是默认值)。只能在参数列表中从右到左提供默认参数。因此,如果为某个参数提供了默认值,则必须为该参数右边所有的参数提供默认值。
函数的特征标是参数列表。程序员可以定义两个同名函数,只要其特征标不同。这被称为函数多态或函数重载。通常,通过重载函数来为不同的数据类型提供相同的服务。
函数模板自动完成重载函数的过程。只需使用泛型和具体算法来定义函数,编译器将为程序中使用的特定参数类型生成正确的函数定义。
