第 5 章 函数探幽
从这一章开始,将会进入到 C++ 中比较核心的部分。
文章目录
5.1 内联函数
内联函数是 C++ 为提高程序运行速度所做的一项改进。常规函数和内联函数之间的主要区别不在于编写方式,而在于 C++ 编译器如何将它们组合到程序中。
对于常规函数,执行到函数调用指令时,程序将跳到标记函数起点的内存单元,执行函数代码,然后再跳回到原来的指令处(这与阅读文章时停下来看脚注,并在阅读完脚注后返回到原来阅读的地方类似)。来回跳跃并记录跳跃位置意味着需要一定的开销。
C++ 内联函数提供了另一种选择。内联函数的编译代码与其他程序代码内联起来了。也就是说,编译器将使用相应的函数代码替换函数调用。对于内联代码,程序无需跳到另一个位置处执行代码,再跳回来。因此,内联函数的运行速度比常规函数稍快,但代价是需要占用更多内存。
要使用这项特性,必须采取以下措施:在函数声明或者函数定义前加上关键字 inline
。通常的做法是省略原型,将整个定义(即函数头和所有函数代码)放在本应提供原型的地方。
程序员请求将函数作为内联函数时,编译器不一定会满足这种要求。它可能认为该函数过大或者调用了自己(内联函数不能递归),因此不能作为内联函数,下面是一个使用内联函数的例子:
#include <iostream>
inline double square(double x) { return x * x; }
int main()
{
using namespace std;
double a, b,c=13.0;
a = square(5.0);
b = square(4.5 + 7.5);
cout << "a= " << a << endl;
cout << "b= " << b << endl;
cout << "c= " << c << " , c^2= " << square(c++) << endl;
cout << "Now, c= " << c;
}
//输出
a= 25
b= 144
c= 13 , c^2= 169
Now, c= 14
输出表明,内联函数和常规函数一样,也是按值来传递参数的。如果参数为表达式,如 4.5+7.5
,则函数将传递表达式的值。这使得 C++ 的内联功能远远胜过 C 的宏定义。
内联与宏
inline
工具是 C++ 新增的特性。C 语言使用预处理器语句#define
来提供宏——内联代码的原始实现。例如,下面是一个计算平方的宏:#define SQUARE(X) X*X
这并不是通过传递参数来实现的,而是通过文本替换来实现的——
X
是参数的符号标记。如:a = SQUARE(5.0); // 等价于 a = 5.0*5.0; b = SQUARE(4.5+7.5); // 等价于 b = 4.5+7.5*4.5+7.5; d = SQUARE(c++); // 等价于 d = c++ * c++;
上述代码中只有第一个可以正常工作。可以通过使用括号来改进宏:
#define SQUARE(X) ((X)*(X))
通过改进后,示例中的前两个代码可以正常工作,但
SQUARE(c++)
仍会使c
递增两次,不能正常工作。 因此,如果使用了 C 的宏执行类似函数的功能,应考虑将它们转换为 C++ 内联函数。
5.2 引用变量
C++ 新增了一种复合类型——引用变量。引用是已定义变量的别名,主要用途是作为函数的形参。通过将引用变量作为函数的参数,函数将使用原始数据,而不是其副本。这样,除了指针之外,引用也为函数处理大型结构提供了一种非常方便的途径。
5.2.1 创建引用变量
C++ 给 &
符号赋予了另一个含义,将其用来声明引用。例如,要将 rodents
作为 rats
的别名,可以这样做:
int rats;
int & rodents = rats;
其中,&
不是地址运算符,而是类型标识符的一部分。就像声明 char*
指的是指向 char
的指针一样,int&
指的是指向 int
的引用。上述引用声明允许将 rats
和 rodents
互换——它们指向相同的值和内存单元。例如:
#include <iostream>
int main()
{
using namespace std;
int rats = 101;
int &rodents = rats;
cout << "rats = " << rats << endl;
cout << "rodents = " << rodents << endl;
rodents++;
cout << "After rodents++,\n";
cout << "rats = " << rats << endl;
cout << "rodents = " << rodents << endl;
cout << "rats address = " << &rats << endl;
cout << "rodents address = " << &rodents << endl;
}
//输出如下:
rats = 101
rodents = 101
After rodents++,
rats = 102
rodents = 102
rats address = 0xd64adffa44
rodents address = 0xd64adffa44
对于 C 语言用户而言,首次接触到引用时可能会感到困惑,因为引用看起来很类似指针。实际上,引用还是不同于指针的。除了表示法不同外,还有其他的差别。例如,必须在声明引用时将其初始化,而不能像指针那样先申明再赋值:
int rats = 101;
int & rodents;
rodents = rat; //错误,必须在声明引用时将其初始化
引用更接近 const
指针,必须在创建时进行初始化,一旦与某个变量关联起来,就将一直效忠于它。也就是说:
int & rodents = rats;
//实际上是下面代码的伪装表示:
int * const pr = &rats;
//其中,rodents扮演的角色与表达式 *pr 相同
引用变量是谁的别名只看引用变量的初始化声明,一旦声明后就无法再更改。
5.2.2 将引用用作函数参数
引用经常被用作函数参数,使得函数中的变量名成为调用程序中变量的别名,这种传递方式称为按引用传递。
下面程序展示了按照传值、传址、传引用交换变量的函数:
#include <iostream>
using namespace std;
void swap1(int &a, int &b)
{
int temp = a;
a = b;
b = temp;
}
void swap2(int *p,int *q)
{
int temp = *p;
*p = *q;
*q = temp;
}
void swap3(int a, int b)
{
int temp = a;
a = b;
b = temp;
}
只有前两个函数才能成功交换变量的值。按引用传递(swap1
)和按值传递(swap3
)的函数看起来相同,只能通过原型或函数定义才能知道 swap1
是按引用传递的。
5.2.3 引用的属性和特别之处
-
如果想要让函数使用传递给它的信息而不对其进行修改,同时又想使用引用,则应使用常量引用(使用
const
修饰引用变量):double refcube(const double &ra);
-
按值传递的函数可以使用多种类型的实参,而传递引用的限制更加严格(只允许传递左值),如:
//cube() 为按值传递的函数 double z = cube(x + 2.0); z = cube(8.0); int k = 10; z = cube(k); //refcube() 为传递引用的函数 double z = refcube(x + 3.0); //错误,x+3.0不是可修改的左值 //这好比令 x + 3.0 = 5.0,是错误的
-
临时变量、引用参数和
const
当参数为
const
引用时,如果实参与引用参数不匹配,C++ 将在以下两种情况下创建临时变量:- 实参的类型正确,但不是左值;
- 实参的类型不正确,但可以转换为正确的类型。
左值是什么呢?左值参数是可被引用的数据对象,包括常规变量和 const
变量。非左值包括字面常量(用引号括起的字符串除外,它们由其地址表示)和包含多项的表达式。
假设定义了函数 refcube
如下:
double refcube(const double &ra)
{
return ra*ra*ra;
}
考虑以下代码:
double side = 3.0;
double * pd = &side;
double & rd = side;
long edge = 5L;
double lens[4] = {2.0, 5.0, 10.0, 12.0};
double c1 = refcube(side); //ra为side
double c2 = refcube(lens[2]); //ra为lens[2]
double c3 = refcube(rd); //ra为side
double c4 = refcube(*pd); //ra为side
double c5 = refcube(edge); //ra为临时变量
double c6 = refcube(7.0); //ra为临时变量
double c7 = refcube(side + 10.0); //ra为临时变量
上述代码中,后三种情况下,编译器都将生成一个临时匿名变量,并让 ra
指向它,这些临时变量只在函数调用期间存在。
总而言之,如果声明将引用指定为 const
,C++ 将在必要时生成临时变量。实际上,对于形参为 const
引用的 C++ 函数,如果实参不匹配,则其行为类似于按值传递。
应尽可能使用
const
将引用参数声明为
const
引用的理由有三个:
- 可以避免无意中修改数据的编程错误
- 使用
const
使函数能够处理const
和 非const
实参,否则将只能接受非const
数据- 使用
const
引用使函数能够正确生成并使用临时变量
5.2.4 将引用用于结构
引用非常适合用于结构和类(引入引用主要就是为了用于这些类型),考虑下面的的程序:
#include <iostream>
#include <string>
struct free_throws
{
std::string name;
int made;
int attempts;
float percent;
};
void display(const free_throws &ft)
{
using std::cout;
cout << "Name: " << ft.name << '\n';
cout << " Made: " << ft.made << '\t';
cout << "Attempts: " << ft.attempts << '\t';
cout << "Percent: " << ft.percent << '\n';
}
void set_pc(free_throws &ft)
{
if (ft.attempts != 0)
ft.percent = 100.0f * float(ft.made) / float(ft.attempts);
else
ft.percent = 0;
}
free_throws &accumulate1(free_throws &target, const free_throws &source)
{
target.attempts += source.attempts;
target.made += source.made;
set_pc(target);
return target;
}
int main()
{
free_throws one = {"Ifelsa Branch", 13, 14};
free_throws two = {"Andor Knott", 10, 16};
free_throws three = {"Minnie Max", 7, 9};
free_throws four = {"Whily Looper", 5, 9};
free_throws five = {"Long Long", 6, 14};
free_throws team = {"Throwgoods", 0, 0};
free_throws dup;
set_pc(one);
display(one);
accumulate1(team, one);
display(team);
display(accumulate1(team, two));
accumulate1(accumulate1(team, three), four);
display(team);
dup = accumulate1(team, five);
std::cout << "Displaying team:\n";
display(team);
std::cout << "Displaying dup after assignment:\n";
display(dup);
set_pc(four);
accumulate1(dup, five) = four;
std::cout << "Displaying dup after ill-advised assignment:\n";
display(dup);
return 0;
}
//输出如下:
Name: Ifelsa Branch
Made: 13 Attempts: 14 Percent: 92.8571
Name: Throwgoods
Made: 13 Attempts: 14 Percent: 92.8571
Name: Throwgoods
Made: 23 Attempts: 30 Percent: 76.6667
Name: Throwgoods
Made: 35 Attempts: 48 Percent: 72.9167
Displaying team:
Name: Throwgoods
Made: 41 Attempts: 62 Percent: 66.129
Displaying dup after assignment:
Name: Throwgoods
Made: 41 Attempts: 62 Percent: 66.129
Displaying dup after ill-advised assignment:
Name: Whily Looper
Made: 5 Attempts: 9 Percent: 55.5556
- 程序说明
上述代码中,accumulate1
函数的返回类型为 free_throws &
,这意味着返回的是最初传给 accumulate1
函数的 team
对象。
接下来,程序中使用了一条赋值语句:dup = accumulate1(team, five)
,这条语句返回的是指向 team
的引用,将把 team
对象的值赋给 dup
。注意,虽然返回的是引用,但 dup
并不是 team
的引用,也就是说改变 dup
并不会改变 team
,这里不能弄混了。
最后,程序以独特的方式使用了 accumulate1
:accumuate1(dup, five) = four
。这是可行的,因为函数的返回值是一个引用,相当于:
accumulate1(dup, five);
dup = four;
第二条语句消除了第一条语句做的工作,因此在原始赋值语句使用 accumulate1()
的方式并不好。
-
为何要返回引用
考虑语句
dup = accumulate1(team, five)
,如果accumulate1()
返回结构,而不是指向结构的引用,则该结构会被复制到一个临时的位置,再将这个拷贝复制给dup
。但在返回值为引用时,将直接把team
复制到dup
,效率更高。 -
返回引用时需要注意的问题
返回引用时最重要的一点是,应避免返回函数终止时不再存在的内存单元引用。例如:
const free_throws & clone2(free_throws & ft) { free_throws = newguy; newguy = ft; return newguy; }
该函数返回一个指向临时变量的引用,但函数运行完毕后他将不复存在。为避免这种问题,最简单的方法是返回一个作为参数传递给函数的引用。另一种方法是用
new
来分配新的存储空间,如:const free_throws & clone(free_throws & ft) { free_throws *pt = new free_throws; *pt = ft; return *pt; }
上述代码将返回
pt
指向的结构的引用。这样,便可以这样使用函数:free_throws & jolly = clone(three);
。这使得jolly
成为新结构的引用。这种方法存在一个问题:当不再需要new()
分配的内存时,应使用delete
来释放它们。调用clone()
隐藏了对new
的调用,这使得以后很容易忘记使用delete
来释放内存。以后将使用智能指针来解决这种情况。 -
为何将
const
用于引用返回类型上述程序包含如下语句:
accumulate1(dup, five) = four;
这条语句会让人觉得很奇怪,但又是合法的。假设我们需要使用引用返回值,但又不允许像给
accumulate1()
赋值这样的操作,只需将返回类型声明为const
引用,使该语句不再合法。
5.2.5 将引用用于类对象
将类对象传递给函数时,C++ 通常的做法是使用引用。下面是一个例子:
#include <iostream>
#include <string>
using namespace std;
string version1(const string &s1, const string &s2)
{
string temp = s2 + s1 + s2;
return temp;
}
const string &version2(string &s1, const string &s2)
{
s1 = s2 + s1 + s2;
return s1;
}
const string &version3(string &s1, const string &s2) //bad
{
string temp = s2 + s1 + s2;
return temp;
}
int main()
{
string input, copy, result;
cout << "Enter a string: ";
getline(cin, input);
copy = input;
cout << "Your string as entered: " << input << endl;
result = version1(input, "***");
cout << "Your string enhanced: " << result << endl;
cout << "Your original string: " << input << endl;
result = version2(input, "###");
cout << "Your string enhanced: " << result << endl;
cout << "Your original string: " << input << endl;
cout << "Resetting original string.\n";
input = copy;
result = version3(input, "@@@");
cout << "Your string enhanced: " << result << endl;
cout << "Your original string: " << input << endl;
return 0;
}
//输出如下:
Enter a string: It's not my fault.
Your string as entered: It's not my fault.
Your string enhanced: ***It's not my fault.***
Your original string: It's not my fault.
Your string enhanced: ###It's not my fault.###
Your original string: ###It's not my fault.###
Resetting original string.
程序崩溃
上述程序中有三个字符串拼接函数。其中,version1
最简单:它接受两个 string
参数,这两个参数都是 const
引用。如果使用 string
对象,最终结果将不变:
string version4(string s1, string s2);
在这种情况下,s1、s2
为 string
对象。使用引用的效率更高,因为函数不需要创建新的 string
对象,并将原来对象中的数据复制到新对象中。
函数 version1
中的 temp
是一个新的 string
对象,只在函数内部有效,当函数执行完毕后,它将不复存在。因此,返回指向 temp
的引用是不可行的。
将 C 风格字符串用作
string
对象引用参数 对于函数
version1
,该函数的两个形参s1、s2
的类型都是const string &
,但程序调用时实参(input
和"***"
)的类型分别是string
和const char *
。为什么程序可以接受将char
指针赋值给sting
引用呢? 有两方面原因。首先,
string
类定义了一种char*
到string
的转换功能,这使得可以使用 C 风格字符串来初始化string
对象。其次,之前已经讨论过类型为const
引用的形参的一个属性:假设实参的类型与引用参数类型不匹配,但可被转换为引用类型,程序将创建一个正确类型的临时变量,使用转换后的实参值来初始化它,然后传递一个指向该临时变量的引用。 这种属性的结果是,如果形参类型为
const string &
,在调用函数时,使用的实参可以是string
对象或 C 风格字符串。
函数 version2
不创建临时 string
对象,而是直接修改原来的 string
对象。
函数 version3
将引起程序崩溃,因为程序试图引用已经释放的内存。
5.2.6 对象、继承和引用
之前提到过,ofstream
对象可以使用 ostream
类的方法。使得能够将特性从一个类传递给另一个类的语言特性被称为继承。简单地说,ostream
类是基类,而 ofstream
类是派生类。派生类继承了基类的方法,这意味着 ofstream
对象可以使用基类的特性,如方法 precision()
和 setf()
。
继承的另一个特征是:基类引用可以指向派生类对象,而无需进行强制类型转换。这种特征的一个结果是,可以定义一个接受基类引用作为参数的函数,调用该函数时,可以将基类对象作为参数,也可以将派生类对象作为参数。例如,参数类型为 ostream &
的函数可以接受 ostream
对象(如 cout
)或声明的 ofstream
对象作为参数。如:
#include <iostream>
#include <fstream>
#include <cstdlib>
using namespace std;
void file_it(ostream & os, double fo, const double fe[], int n)
{
ios_base::fmtflags initial;
initial = os.setf(ios_base::fixed);
os.precision(0);
os << "Focal length of objective: " << fo << " mm\n";
os.setf(ios::showpoint);
os.precision(1);
os.width(12);
os << "f.l. eyepiece";
os.width(15);
os << "magnification" << endl;
os.setf(initial);
}
对于该程序,最重要的一点就是参数 os
既可以指向 ostream
对象,也可以指向 ofstream
对象。
下面介绍一些 ostream
类中的格式化方法:
-
setf()
方法
setf()
能够设置各种格式化状态。例如,方法调用setf(ios_base::fixed)
将对象置于使用定点表示法的模式;setf(ios_base::showpoint)
将对象置于显示小数点的模式,即使小数部分是 0 0 0。方法setf()
返回调用它之前的所有格式化设置。ios_base::fmtflags
是存储这种信息所需的数据类型名称。因此,setf(initial)
会将所有的格式化设置恢复到原来的值。 -
precision()
方法
precision()
指定显示多少位小数(假设对象处于定点模式下)。这些设置将一直保持不变,直到再次调用相应方法重新设置它们。
-
width()
方法
width()
设置下一次输出操作使用的字段宽度,这种设置只在显示下一个值时有效,然后将恢复到默认设置。默认的字段宽度为 0 0 0,这意味着刚好能容纳下要显示的内容。
5.2.7 何时使用引用参数
使用引用参数的主要原因有两个:
- 程序员能够修改作为函数参数的数据对象
- 通过传递引用而不是整个数据对象,可以提高程序的运行速度
当数据对象较大时,第二个原因最重要。
5.3 默认参数
下面介绍 C++ 的另一项新内容——默认参数。默认参数指的是当调用函数时省略了实参后自动使用的值。设置默认值必须通过函数原型,如 char * left(const char * str, int n = 1)
。
对于带参数列表的函数,必须从右向左添加默认值。也就是说,要为某个参数设置默认值,则必须为它右边的所有参数提供默认值:
int harpo(int n, int m = 4, int j = 5);
int chico(int n, int m = 6, int j); //错误
int groucho(int k = 1, int m = 2, int n = 3);
函数 harpo()
原型允许调用该函数时提供
1
、
2
1、2
1、2 或
3
3
3 个参数:
beeps = harpo(2); //等价于 harpo(2,4,5)
beeps = harpo(1,8); //等价于 harpo(1,8,5)
beeps = harpo(8,7,6);
实参按从左到右的顺序依次被赋给相应的形参,而不能跳过任何参数。因此,下面的调用是不允许的
beeps = harpo(3, ,8);
默认参数并非编程方面的重大突破。而只是提供了一种便捷的方式。
5.4 函数重载
函数多态是 C++ 在 C 语言的基础上新增的功能。默认参数允许用户使用不同数目的参数调用同一个函数,而函数多态(函数重载)允许用户使用多个同名的函数。
函数重载的关键是函数的参数列表——也称为函数特征标。如果两个函数的参数数目和类型相同,同时参数的排列顺序也相同,则它们的特征标相同,而变量名是无关紧要的。C++ 允许定义名称相同的函数,条件是它们的特征标不同。例如:
void print(const char * str, int width); //#1
void print(double d, int width); //#2
void print(long l, int width); //#3
void print(int i, int width); //#4
void print(const char *str); //#5
使用 print()
函数时,编译器将根据所采取的用法使用相应的原型:
print("Pancakes", 15); //调用#1
print("Syrup"); //调用#5
print(1999.0, 10); //调用#2
print(1999, 12); //调用#4
print(1999L, 15); //调用#3
使用被重载的函数时,需要使用正确的数据类型,例如:
unsigned int year = 3210;
print(year, 6);
print()
调用将不与任何原型匹配。没有匹配的原型并不会自动停止使用其中的某个函数,因为 C++ 将尝试使用标准类型转换强制进行匹配。如果 #2
原型是 print()
唯一的原型,则函数调用 print(year, 6)
将把 year
转换为 double
类型。但在上面的代码中,有
3
3
3 个将数字作为第一个参数的原型,因此有
3
3
3 种转换 year
的方式。在这种情况下,C++ 将拒绝这种函数调用,并将其视为错误。
此外,为避免混乱,编译器在检查函数特征标时,将把类型引用和类型本身视为同一个特征标,下面这两个函数是不能共存的:
double cube(double x);
double cube(double &x);
匹配函数时,要区分 const
和非 const
变量:
void f1(char * bits);
void f1(const char * cbits);
void f2(char * bits);
void f3(const char * bits);
//下面为函数调用
const char p1[20] = "How's the weather?";
char p2[20] = "How's business?";
f1(p1); //f1(char * bits);
f1(p2); //f1(const char * cbits);
f2(p1); //无法调用
f2(p2); //f2(char * bits);
f3(p1); //f3(const char * bits);
f3(p2); //f3(const char * bits);
函数 f2
和 f3
之所以在调用上有差别,主要是因为将非 const
值赋给 const
变量是合法的,反之则是非法的。
特征标相同、函数返回类型不同的函数不能进行重载,如以下两个函数不能共存:
long gronk(int n, float m);
double gronk(int n, float m);
重载引用参数
考虑以下几个函数:
void stove(double & r1); void stove(const double & r2); void stove(double && r3);
上面三个函数在,与
r1
和r3
匹配的参数都与r2
匹配。这就带来了一个问题:如果重载使用这三种参数的函数,结果将如何?答案是将调用最匹配的版本。例如,double x = 55.5; const double y = 32.0; stove(x); //调用stove(double & r1); stove(y); //调用stove(const double & r2); stove(x+y); //stove(double && r3);
如果没有定义函数
stove(double &&)
,则stove(x+y)
将调用函数stove(const double &)
。
名称修饰
C++ 如何跟踪每一个重载函数呢?它给这些函数指定了秘密身份。使用 C++ 开发工具中的编辑器编写和编译程序时,C++ 编译器将执行一些神奇的操作——名称修饰(或名称矫正),它根据函数原型中指定的形参类型对每个函数名进行加密。通过这种操作,C++ 编译器便能区分不同的同名函数。
补充:笔者发现下面这种情况也是不能进行函数重载的:
#include <iostream>
using namespace std;
struct stu
{
int a, b;
};
void show(stu a)
{
cout << "hehe";
}
void show(const stu a)
{
cout << "hehe";
}
这段代码的问题在于定义了两个具有相同参数类型的 show
函数。一个参数类型是 stu
,另一个参数类型是 const stu
,这实际上是相同的,因为参数是按值传递的。按值传递时,参数的常量性(const
)对函数重载不起作用。编译器会认为这是两次定义了同一个函数,从而导致函数重载冲突。
5.5 函数模板
函数模板是通用的函数描述,也就是说它们使用泛型来定义函数,其中的泛型可用具体的类型(如 int
或 double
)替换。通过将类型作为参数传递给模板,可使编译器生成该类型的函数。
函数模板允许以任意类型的方式来定义函数。例如,可以这样建立一个交换模板:
template <typename AnyType>
void swap(AnyType &a, AnyType &b)
{
AnyType temp = a;
a = b;
b = temp;
}
上述代码中,关键字 template
和 typename
是必需的,除非可以使用关键字 class
代替 typename
。另外,必须使用尖括号 <>
。类型名可以任意选择(这里为 AnyType
),只要遵守 C++ 命名规则即可。
模板并不创建任何函数,而只是告诉编译器如何定义函数。例如,需要交换 int
的函数时,编译器将按模板模式创建这样的函数,并用 int
代替 AnyType
。
要让编译器知道程序需要一个特定形式的交换函数,只需在程序中使用 swap()
函数即可。下面程序是一个示例:
#include <iostream>
template <typename T>
void Swap(T &a, T &b);
int main()
{
using namespace std;
int i = 10, j = 20;
cout << "i = " << i << ", j = " << j << endl;
cout << "Using Swap:\n";
Swap(i, j);
cout << "i = " << i << ", j = " << j << endl;
double x = 24.5, y = 81.7;
cout << "x = " << x << ", y = " << y << endl;
cout << "Using Swap:\n";
Swap(x, y);
cout << "x = " << x << ", y = " << y << endl;
return 0;
}
template <typename T>
void Swap(T &a, T &b)
{
auto temp = a;
a = b;
b = temp;
}
函数模板并不能缩短可执行程序,但它能使生成多个函数定义更简单、更可靠。
5.5.1 函数模板重载
需要多个对不同类型使用同一种算法的函数时,可以使用模板。然而,并非所有的类型都使用相同的算法,在这种情况下,可以像重载常规函数那样重载模板定义。例如:
template <typename T>
void Swap(T &a, T &b);
template <typename T>
void Swap(T *a, T *b, int n);
注意,在后一个模板中,最后一个参数的类型为具体类型 int
,而不是泛型。并非所有的模板参数都必须是模板参数类型。
5.5.2 显式具体化
假设定义了如下结构:
struct job
{
char name[40];
double salary;
int floor;
}
假设希望能够交换这样的两个结构,原来的模板使用下面的代码来完成交换:
temp = a;
a = b;
b = temp;
由于 C++ 允许将一个结构赋给另一个结构,因此上述代码可以完成交换结构的任务。然而,假设只想交换 salary
和 floor
成员而不交换 name
成员,将无法使用该模板进行交换。
这种情况下,可以提供一个具体化函数定义——称为显式具体化。当编译器找到与函数调用匹配的具体化定义时,将使用该定义,而不再寻找模板。
具体化机制如下:
- 对于给定的函数名,可以有非模板函数、模板函数和显式具体化模板函数以及它们的重载版本。
- 显式具体化的原型和定义应以
template<>
打头,并通过名称来指出类型。 - 具体化优先于常规模板,而非模板函数优先于具体化和常规模板。
下面是用于交换 job
结构的非模板函数、模板函数和具体化原型:
//非模板函数
void Swap(job &, job &);
//模板函数
template <typename T>
void Swap(T &, T &);
//具体化原型
template <> void Swap<job> (job &, job &);
正如前面指出的,如果有多个原型,则编译器在选择原型时,非模板版本优先于显式具体化和模板版本,而显式具体化优先于使用模板生成的版本。例如:
template <typename T>
void Swap(T &, T &);
template <> void Swap<job>(job &, job &);
int main()
{
double u,v;
Swap(u,v); //使用模板版本
job a,b;
Swap(a,b); //使用显式具体化版本
}
Swap<job>
中的 <job>
是可选的,也可以像下面这样编写:
template <> void Swap(job &, job &);
5.5.3 实例化和具体化
为进一步了解模板,必须理解术语实例化和具体化。在代码中包含函数模板本身并不会生成函数定义,模板只是一个用于生成函数定义的方案。编译器使用模板为特定类型生成函数定义时,得到的是模板实例。例如,对于 int
类型变量 a、b
,Swap(a,b)
将生成一个使用 int
类型实例的 Swap
函数。这种实例化方式被称为隐式实例化。
还有一种实例化方式,为显式实例化。这意味着可以直接命令编译器创建特定的实例。其语法是,声明所需的种类——用 <>
符号指定类型,并在声明前加上关键字 template
:
template void Swap<int>(int &, int &);
还可以通过在程序中使用函数来创建显式实例化,例如:
int m = 6, n = 5;
Swap<int<(m,n);
隐式实例化、显式实例化和显式具体化统称为具体化。引入显示实例化后,必须使用新的语法——在声明中使用前缀 template
和 template <>
,以区分显式具体化和显式实例化。
5.5.4 编译器选择使用哪个函数版本
对于函数重载、函数模板和函数模板重载,C++ 具有一个定义良好的策略来决定为函数调用使用哪一个函数定义,尤其是有多个参数时。这个过程称为重载解析,其大致流程如下:
- 创建候选函数列表。
- 使用候选列表创建可行函数列表(参数数目正确)。
- 确定是否有最佳的可行函数。如果有,则调用它,否则该函数调用出错。
例如,有函数调用:may('B')
。
首先,编译器将寻找候选者,即名称为 may()
的函数和函数模板,然后寻找那些可以用一个参数调用的函数。例如,下面的函数符合要求:
void may(int); //#1
float may(float, float = 3); //#2
void may(char); //#3
char * may(const char *); //#4
char may(const char &); //#5
template<class T> void may(const T &); //#6
template<class T> void may(T *); //#7
注意,寻找时只考虑下标,而不考虑返回类型。其中, 4 4 4 和 7 7 7 不可行,因为整数类型不能被隐式地转换为指针类型。
接下来,编译器必须确定哪个可行函数是最佳的。通常,从最佳到最差的顺序如下所述:
- 完全匹配,但常规函数优先于模板
- 提升转换(例如,
char
和short
自动转换为int
,float
自动转换为double
) - 标准转换(例如,
int
转换为char
,long
转换为double
) - 用户定义的转换,如类声明中定义的转换
例如,函数
1
1
1 优于函数
2
2
2,因为 char
到 int
的转换是提升转换,而 char
到 float
的转换是标准转换。函数
3
、
5
、
6
3、5、6
3、5、6 优于函数
1
、
2
1、2
1、2,因为它们都是完全匹配的。函数
3
、
5
3、5
3、5 优于函数
6
6
6,因为函数
6
6
6 是模板。
上面的分析有一个问题:什么是完全匹配?如果有两个函数都完全匹配,会怎么样呢?通常,有两个函数完全匹配是一种错误,但这个规则有一些例外。下面将探讨完全匹配:
-
完全匹配和最佳匹配
进行完全匹配时,C++ 允许某些无关要紧的转换,如下表所示:
从实参 到形参 Type Type & Type & Type Type [ ] Type * Type (argument-list) (传递函数作为实参) Type (*) (argument-list) (函数指针) Type const Type Type volatile Type Type * const Type * Type * volatile Type * 例如,假设有以下代码:
struct blot {int a; char b[10];} blot ink {25, "spots"}; ... recycle(ink)
在这种情况下,下面的原型都是完全匹配的:
void recycle(blot); //#1 void recycle(const blot); //#2 void recycle(blot &); //#3 void recycle(const blot &); //#4
如果有多个匹配的原型,则编译器将无法完成重载解析过程;如果没有最佳的可行函数,则编译器将生成一条错误信息,该消息可能会使用诸如 a m b i g u o u s ambiguous ambiguous 这样的词语。
然而,有时候即使两个函数都完全匹配,仍可以完成重载解析:
-
指向非
const
数据的指针和引用优先与非const
指针和引用匹配。例如,在
recycle()
示例中,如果只定义了函数 3 、 4 3、4 3、4,则编译器将优先选择 3 3 3,因为变量int
没有被声明为const
。然而,const
和非const
之间的区别只适用于指针和引用指向的数据。也就是说,如果只定义了函数 1 、 2 1、2 1、2,则将出现二义性错误。 -
一个是模板函数,另一个是非模板函数。
这种情况下,非模板函数将优先于模板函数(包括显式具体化)。
-
如果两个完全匹配的函数都是模板函数,则较具体的模板函数优先。
术语最具体并不一定意味着显示具体化,而是指编译器推断使用哪种类型时执行的转换最少。例如,下面有两个模板:
template<class T> void recycle (T t); template<class T> void recycle (T *t);
假设调用函数代码如下:
struct blot {int a; char b[10];}; blot ink = {25, "spots"}; ... recycle(&ink);
该函数调用将认为第二个函数是更具体的,因为在生成过程中,它需要进行的转换更少。
简而言之,重载解析将寻找最匹配的函数。如果只存在一个这样的函数,则选择它;如果存在多个这样的函数,但其中只有一个是非模板函数,则选择该函数;如果存在多个适合的函数,且它们都是模板函数,但其中有一个函数比其它函数更具体,则选择该函数。如果有多个同样合适的非模板函数或模板函数,但没有一个比其它函数更具体,则函数调用将是不确定的,因此是错误的。
-
-
创建自定义选择
在有些情况下,可通过编写合适的函数调用,来引导编译器做出用户希望的选择。例如下面的程序:
#include <iostream> using namespace std; template <class T> //#1 T lesser(T a, T b) { return a < b ? a : b; } int lesser(int a, int b) //#2 { a = a < 0 ? -a : a; b = b < 0 ? -b : b; return a < b ? a : b; } int main() { int m = 20, n = -30; double x = 15.5, y = 25.9; cout << lesser(m, n) << endl; //调用#2 cout << lesser(x, y) << endl; //调用#1 cout << lesser<>(m, n) << endl; //调用#1,lesser<int> cout << lesser<int>(x, y) << endl; //调用#1,lesser<int> return 0; }
语句
lesser<>(m,n)
中的<>
指出,编译器应该选择模板函数,而不是非模板函数。语句lesser<int>(x, y)
要求进行显式实例化,x
和y
将被强制转换为int
类型,再返回一个int
值。 -
多个参数的函数
将有多个参数的函数调用与有多个参数的原型进行匹配时,情况将非常复杂。编译器必须考虑所有参数的匹配情况。如果找到比其他可行函数都合适的函数,则选择该函数。一个函数要比其他函数都合适,其所有参数的匹配程度都必须不比其他函数差,同时至少有一个参数的匹配程度比其他函数都高。
5.5.5 一些特殊情况
在编写模板函数时,一个问题是并非总能知道应在声明中使用哪种类型。下面是一个示例:
template<class T1, class T2>
void ft(T1 x, T2 y)
{
...
?type? xpy = x + y;
...
}
xpy
应该是什么类型?无法预先知道。例如,T1、T2
分别为 int、double
类型,则结果为 double
类型;T1、T2
分别为 int、short
类型,则结果为 int
类型。为了解决这种情况,C++11 新增了关键字 decltype
。
假设有如下声明:
decltype(expression) var;
则 var
的类型应该按照下面的规则确定:
-
如果
expression
是一个没有用括号括起来的标识符,则var
的类型与该标识符的类型相同,包括const
等限定符。例如:double x = 5.5; double y = 7.9; double &rx = x; const double * pd; decltype(x) w; // 等价于 int w; decltype(rx) u = y; // 等价于 double & u = y; decltype(pd) v; // 等价于 const double * v;
-
如果
expression
为函数调用,则var
与函数的返回类型相同:long indeed(int); decltype(indeed(3)) m; //等价于 long m;
注意:并不会实际调用函数。编译器通过查看函数的原型来获悉返回类型,而无需实际调用函数。
-
如果
expression
是一个左值,则var
为指向其类型的引用。这好像意味着前面的
w
应为引用类型,因为x
是一个左值。但别忘了,这种情况已经在第一步处理过了。要进入第三步,一种常见的情况是expression
是用括号括起来的标识符:double xx = 4.4; decltype((xx)) r2 = xx; //等价于 double & r2 = xx; decltype(xx) r3 = xx //等价于 double r3 = xx;
-
如果前面的情况都不满足,则
var
类型与expression
相同:int j = 3; int &k = j; int &n = j; decltype(j+6) a; //等价于 int a; decltype(100L) b; //等价于 long b; decltype(k+n) c; //等价于 int c;
注意,虽然
k
和n
都是引用,但表达式k+n
不是引用,它是两个int
的和,因此为int
类型。如果需要多次声明,可结合使用
typedef
和decltype
:template<class T1, class T2> void ft(T1 x, T2 y) { ... typedef decltype(x+y) xytype; xytype xpy = x+y; xytype & rxy = x; ... }
还有一个问题是 decltype
无法解决的,如下:
template<class T1, class T2>
?type? ft(T1 x, T2 y)
{
...
return x+y;
}
同样,无法预先知道 x+y
的类型。好像可以将返回类型设置为 decltype(x+y)
,但不幸的是此时还未声明参数 x
和 y
,它们不在作用域内。为此,C++ 定义了一种声明和定义函数的新语法:后置返回类型。例如:
double h(int x, float y);
//可以写成
auto h(int x, float y) -> double;
这将返回类型移到了参数声明的后面,->double
称为后置返回类型。其中 auto
是一个占位符,表示后置返回类型提供的类型。
通过这种语法,便可以解决上面的问题:
template<class T1, class T2>
auto ft(T1 x, T2 y) -> decltype(x+y)
{
...
return x+y;
}
现在,decltype
在参数声明后面,因此 x、y
位于作用域内,可以使用它们。