第8章 函数探幽

说明

看《C++ Primer Plus》时整理的学习笔记,部分内容完全摘抄自《C++ Primer Plus》(第6版)中文版,Stephen Prata 著,张海龙 袁国忠译,人民邮电出版社。只做学习记录用途。

本章介绍 C++ 函数在 C 语言基础上新增的特性。

8.1 C++ 内联函数

内联函数是 C++ 为提高程序运行速度所做的一项改进,常规函数和内联函数的主要区别在于 C++ 编译器如何将它们组合到程序中。

8.1.1 内联函数原理及用法

调用常规函数时,程序将存储调用指令的内存地址,并将函数参数复制到指定的内存块中,然后跳到被调用函数的起始内存地址,执行被调用函数的代码,若有返回值,则会将返回值复制到另一指定的内存块中,最后跳回调用指令的内存地址,来回跳跃并记录跳跃位置意味着使用常规函数需要一定的开销。

使用内联函数后,编译器将自动用被调用函数的代码替换函数调用指令,此时程序无需跳到另一个地址处执行代码,再跳回来,因此,内联函数的运行速度比常规函数稍快,但代价是需要占用更多内存。(若有 10 个地方调用了同一个内联函数,则编译完成后程序将有 10 个该函数代码的副本)

使用内联函数,需采取以下两个措施之一:

  • 在函数声明前加上关键字 inline
  • 在函数定义前加上关键字 inline

通常的做法是省略函数原型,将加上关键字 inline 的函数定义放在本应提供函数原型的地方。

#include <iostream>

//定义内联函数
inline double square(double x) { return x * x;}

//使用内联函数
int main()
{
    std::cout << square(5.6);
    
    return 0;
}

8.1.2 内联函数与 C 宏

C 语言使用预处理器语句 #define 来提供宏,它在部分情况下可以达到和 C++ 内联函数 inline 类似的效果,但使用 C 宏来提供内联函数是一种不推荐的做法,因为它可能无法按期望运行。例如下面是一个计算平方的 C 宏,相比于 C++ 内联函数,它是通过文本替换的方式来实现内联,无法按值传递参数,很多情况下的运行结果不符合预期。

//用于计算平方的C宏
#define SQUARE(X) X*X

//使用场景一:符合预期
a = SQUARE(5.0);   //实际结果为a=5.0*5.0=25

//使用场景二:不符合预期
b = SQUARE(1+2);   //实际结果为b=1+2*1+2=5

//使用场景三:不符合预期,c实际自增了两次
c = 1;
d = SQUARE(c++);   //实际结果为d=c++*c++=1,同时c=3

8.1.3 何时使用内联函数

使用内联函数时,关键字 inline 只是程序员给编译器的一个编译建议,编译器并不一定会满足这种要求,比如函数过大或者出现了递归内联函数(内联函数不能递归),除此之外,编译器实际是否采用内联建议还与其自身的优化机制有关,比如在 Microsoft Visual Studio 下,若项目属性中未开启内联函数扩展,则不会启用内联函数的功能,此时内联函数和常规函数的调用过程没有区别。

当函数代码执行时间很短且函数频繁被调用时,可使用内联函数,此时因调用机制节省下来的时间将十分可观。

8.2 引用变量

相比于 C 语言,C++ 新增了一种复合类型:引用变量,引用是已定义的变量的别名(另一个名称)。后来 C++11 又新增了另一种引用:右值引用,这种引用可指向右值,以前的 C++ 引用现被称为左值引用

8.2.1 创建左值引用变量

引用变量可按如下方式创建,声明语句中的 & 并不是地址运算符,而是类型标识符的一部分。初始化完毕后,名称 xrx 表示同一内存单元,因而修改 rx 的值也即是修改 x 值,在之后的任一时刻,它们表示的值都相同。

//创建引用变量
int x = 101;
int & rx = x;

//查看地址(32位系统)
cout << &x;  //结果为0x012FFF08
cout << &rx; //结果为0x012FFF08

//修改引用变量会影响原始变量
rx = 555;
cout << rx;  //结果为555
cout << x;   //结果为555

左值引用的表现与指针常量很相似,它们都必须在创建的同时进行初始化,左值引用变量一旦与某个变量关联起来,就将一直效忠于它,不可再将其用作其它变量的引用,例如左值引用类型 int & 的表现与指针类型 int * const 很相似(关于 const 关键字与一级指针,可参考本人另一篇博客 C++ 一级指针与 const 关键字)。将 const 关键字用于左值引用变量时,会在以下两种情况下生成临时变量,此时左值引用变量与原始变量并不表示同一内存单元:

  • 赋值数据的类型正确,但不是左值(左值例如变量、数组元素、结构成员、引用、解除引用的指针,非左值例如字面常量、多项表达式、函数返回值);
  • 赋值数据的类型不正确,但可以转换为正确的类型。

关于 const 关键字与左值引用,可参考本人另一篇博客 C++ 左值引用与 const 关键字

8.2.2 创建右值引用变量

C++11 新增了另一种引用:右值引用(rvalue reference),这种引用可指向右值,是使用 && 声明的,也必须在创建的同时进行初始化

//创建右值引用变量
int && rrx = 55;

//查看地址(32位系统)
cout << &rrx;  //结果为0x0062FF00

//修改右值引用变量
rrx = 89;
cout << rrx;   //结果为89

右值引用一般被用来实现移动语义完美转发,这将在后续章节介绍。右值引用在部分情况下会生成临时变量(一个典型例子是使用字面常量右值如 55 来初始化右值引用变量),然后将右值引用变量作为该临时变量的别名,这与 const 左值引用十分相似,但与之不同的是:const 左值引用变量初始化完成后其值就无法被修改(权限为只读),右值引用变量初始化完成后其值仍可修改(权限为可读可写)。在另一些情况下,右值引用可以延长已有临时变量的生命周期,减少复制操作的次数(一个典型例子是使用函数返回的类对象来初始化右值引用变量,在关闭编译器优化的情况下可以将本需 2 次的复制操作减少为 1 次)。关于 const 关键字与右值引用,可参考本人另一篇博客 C++ 右值引用与 const 关键字

8.2.3 将左值引用用作函数参数

通过将左值引用用作函数参数,函数将使用原始数据,而不是其副本,这为函数处理大型结构提供了一种非常方便的途径。调用函数时,按引用传递与按值传递的使用方式相同,只能通过函数原型或函数定义才能知道被调用函数的传参方式(按引用还是按值)。

//三种参数传递方式的函数原型
void funr(int &a,int &b); //按引用传递
void funv(int a, int b);  //按值传递
void funp(int *a,int *b); //传递指针

//三种参数传递方式的调用方法
int a = 8;
int b = 56;
funr(a, b);    //调用按引用传递的函数
funv(a, b);    //调用按值传递的函数
funp(&a, &b);  //调用传递地址的函数

//三种参数传递方式的函数定义
void funr(int &a,int &b)
{
    //按引用传递:修改操作会影响原数据
    a = b + 1;
}
void funv(int a, int b)
{
    //按值传递:修改操作不会影响原数据
    a = b + 1;
}
void funp(int *a,int *b)
{
    //传递指针:修改操作会影响原数据
    *a = *b + 1;
}

如果意图是让函数使用传递给它的信息,而不对这些信息进行修改,同时又想使用左值引用以提升传递效率,则应加上 const 关键字,使用常量左值引用,此时如果实参不是左值或与引用类型不匹配,则 C++ 将创建类型正确的匿名临时变量,并让形参来引用该变量(类似于按值传递)。使用 const 左值引用参数主要有以下三个优点:

  • 可以避免无意中修改数据的错误;
  • 使函数能够处理 const 实参和非 const 实参,否则只能处理非 const 实参;
  • 使函数能够正确生成并使用临时变量,能处理的实参形式更加多样。

8.2.4 将左值引用用作函数返回值

传统的按值返回方式涉及到 2 次复制操作,后面章节将会详细介绍,这里举个例子,如下程序中,函数 sqrt() 计算出值为 4.0 后,先将其复制到一个临时位置(指定的寄存器或内存单元),然后从这个临时位置再复制给变量 m(理论上的描述,实际上,编译器可能对此做优化,合并某些步骤,可以查看关于 RVO 与 NRVO 的相关资料)。

//传统的按值返回方式
double m = sqrt(16.0);

当函数返回值为左值引用时,可以避免费时的复制操作,运行效率更高,特别是在返回类型为大型结构时,这种优点更加明显。返回左值引用时,有以下几点需要注意:

  • 不要返回函数终止时不再存在的内存单元引用(例如函数局部变量的引用);
  • 能返回 const 引用时尽量返回 const 引用

第一条的原因和”不要返回临时变量的指针”一样,函数终止后,这些内存单元将被回收,其中的值是不确定的,访问被回收的内存,其结果也是不确定的,可能造成程序崩溃,也可能返回一个不合理的结果。可以返回一个作为参数传进来的引用,作为参数的引用将指向调用函数使用的数据,因此返回的引用也将指向这些数据;另一种方法是用 new 来分配新的存储空间,然后返回所分配空间里变量的引用,但这种方法需配合使用 delete 释放内存,忘记时会导致内存泄漏。

//定义结构体
struct person
{
    int age;
    std::string name;
};

//方式一:返回作为参数传进来的引用
person & setInfo(person & rp)
{
    rp.age = 18;
    rp.name = "王麻子";
    return rp;
}

//方式二:返回new出来的
person & setInfo(person & p1)
{
    person * pp = new person{18,"王麻子"};
    return *pp;
}

第二条的原因是:返回非 const 左值引用时可以对其进行赋值,增加了犯错误的机会,这种赋值方式有点含糊不清,无法一眼看出其影响的是哪个变量,应尽量避免,如下例子所示。

//返回非const引用
int & fun(int & a)
{
    a = 20;
    return a;
}

//定义变量x
int x = 15;

//以下方式都可以改变x的值
fun(x);        //x被改为20
fun(x) = 25;   //x被改为25
x = 30;        //x被改为30

上述例中将函数 fun() 的返回类型设定为 const 后,就使 fun(x) = 25; 变为非法的表达了,因此更容易捕捉到错误;但有时候又需要省略 const,例如运算符 << 的重载。

//返回const引用
const int & fun(int & a)
{
    a = 20;
    return a;
}

//非const引用返回的例子(将在后续章节介绍)
ostream & operator<<(ostream & os, const Time & t);

8.2.5 将左值引用用于类对象

将左值引用用于类对象时,有两点需注意:

  • 若类有多种形式的构造函数,则 const 左值引用可接受的初始化方式会更广泛
  • 基类引用可以指向派生类对象,而无需进行强制类型转换

第一点举个例子如下,等号右边 "Hello" 的类型为 const char *,与 string 类型不匹配,但 string 类定义了一种 char *string 的转换功能,此时程序将创建一个类型正确的临时变量,然后让 str 成为该临时变量的引用,可翻看后续章节中介绍的类的构造函数。

//用C风格字符串初始化string类的常量引用
const string & str = "Hello";

第二点的常用用法为:定义一个接受基类引用作为参数的函数,则调用该函数时,可以将基类对象作为参数,也可以将派生类对象作为参数。例如,参数类型为 ostream & 的函数可以接受 ostream 对象,也可以接受 ofstream 对象。

//函数原型:接受基类引用作为参数的函数
void myDisplay(ostream & os);

//调用函数:传入基类对象,输入到控制台
myDisplay(cout);

//调用函数:传入派生类对象,输出到文件
ofstream fout;
fout.open(fileName);
if(fout.is_open())
{
    myDisplay(fout);
}

//函数定义:接受基类引用作为参数的函数
void myDisplay(ostream & os)
{
    //保存之前的格式信息
    ios_base::fmtflags initial;
    initial = os.setf(ios_base::fixed);
    
    ...
    
    //恢复之前的格式
    os.setf(initial);
}

8.2.6 何时使用左值引用参数

引用非常适用于结构(C++ 的用户定义类型)。使用左值引用参数的主要原因有两个:一是能够修改原始数据、二是可以避免耗时拷贝以提升程序运行效率。以下是一些函数传参时的指导原则:

  • 如果数据对象是内置数据类型或小型结构,不修改原始数据时通常选择按值传递,修改原始数据时常使用指针,因为相比于使用引用,调用函数时 fixIt(&x) 会比 fixIt(x) 更好传达出“值会被修改”的意思,但也有例外,比如 cin 使用了按引用传递。
  • 如果数据对象是数组,则只能使用指针且在不修改原始数据时使用指向 const 的指针,这是唯一的选择。
  • 如果数据对象是较大的结构,则使用指针或引用且在不修改原始数据时配合添加 const 关键字,这样可以大大提升效率。
  • 如果数据对象是类,则使用引用且在不修改原始数据时使用 const 引用,这是 C++ 新增引用类型的主要原因,即传递类对象参数的标准方式是按引用传递。

8.3 默认参数

默认参数也是 C++ 相对于 C 语言新增的一项内容,它指的是当函数调用中省略了实参时自动使用的一个值。

8.3.1 默认参数用法

默认参数在使用时,最常用的做法如下:

  • 通过函数原型设置默认参数,对于有多个参数的函数,必须从右向左添加默认值,也就是说,要为某个参数设置默认值,则必须为它右边所有参数提供默认值;
  • 函数定义与没有默认参数时完全相同
  • 函数调用时,不允许跳过中间的任何参数,实参会按从左到右的顺序依次赋给形参,但可以跳过右边已有默认值的部分。

另一种做法是,省略函数原型,将函数定义放在本应提供函数原型的地方,并在函数定义的参数列表中从右到左设置参数默认值。不论何时,都不允许在函数定义和函数原型中同时提供默认值。一个默认参数的例子如下,

//函数原型
int sum(int k, int x = 1, int y = 2, int z = 3);

//函数调用
int s = 0;
s = sum(1);       //结果为1+1+2+3=7
s = sum(1,2);     //结果为1+2+2+3=8
s = sum(1,2,3);   //结果为1+2+3+3=9
s = sum(1,2,3,4); //结果为1+2+3+4=10

//函数定义
int sum(int k, int x, int y, int z)
{
    return k+x+y+z;
}

8.3.2 何时使用默认参数

默认参数极大地提高了使用函数的灵活性,通过使用默认参数,可以减少要定义的函数数量。当函数传入的参数值在大多数情况下都固定时,就可以使用默认参数来达到简化函数调用的目的。

8.4 函数重载

函数重载完成相同的工作,但使用不同的参数列表。

8.4.1 函数重载用法及名称修饰

函数重载的关键是函数的参数列表(函数特征标),如果两个函数的参数数目类型相同,同时参数的排列顺序也相同,则它们的特征标相同,而变量名是无关紧要的。C++ 函数重载的条件是它们的特征标不同,与返回类型无关。当两函数特征标相同但返回类型不同时会引起冲突,不能完成函数重载。一个重载例子如下:

//一组不冲突的print()函数原型
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

//多义性匹配导致重载失败的调用例子
print(3210U, 6);       //同时与#2,#3,#4匹配

当没有完全匹配的函数原型时,C++ 会尝试使用标准类型转换进行再次匹配。上述失败例子中,由于 unsigned int 可以转换为 double,也可以转换为 long,还可以转换为 int,因此有 3 种转换方式,在这种情况下,C++ 将拒绝这种函数调用,并报错。

C++ 为了跟踪每一个重载函数,编译器会自动对函数进行名称修饰(name decoration)或名称矫正(name mangling),它根据函数原型中指定的形参类型对每个函数名进行加密。对原始名称进行的表面看来无意义的修饰将对参数数目和类型进行编码,添加的一组符号随函数特征标而异,修饰时使用的约定随编译器而异。关于这方面的内容,可参考 MSDN-名称修饰

//C++函数原型
long MyFunctionFoo(int, float);

//编译器进行名称修饰的结果
?MyFunctionFoo@@YAXH

8.4.2 值参数和引用参数引起的二义性

为避免混乱,编译器在检查函数特征标时,将把类型引用和类型本身视为同一个特征标。以下面的程序为例,这两个函数在部分情况下,可以通过编译,但在另一些情况下无法通过编译,实际应用时应避免这种重载方式。

//一组函数原型
double cube(double x);  //原型#1
double cube(double &x); //原型#2

//以下调用方式可以正常运行不会报错
double y = cube(5);     //使用版本#1
double z = cube(y + 5); //使用版本#1
double w = cube(abs(z));//使用版本#1

//以下调用方式会出现二义性匹配报错
double x = 5.0;
double y = cube(x);     //同时与#1,#2匹配

8.4.3 const 参数和非 const 参数的函数重载

const 参数相比于非 const 参数,可接受的实参类型更加广泛。以下面的程序为例,主要原因是:将非 const 值赋给 const 变量是合法的,但反之是非法的

//一组函数原型
void constCharAndCharFun(char * bits);       //原型#1
void constCharAndCharFun(const char * bits); //原型#2
void charFun(char * bits);                   //原型#3
void constCharFun(const char * bits);        //原型#4

//定义实参
const char cpchar[20] = "How are you";
char pchar[20] = "I am fine";

//重载函数的合法调用
constCharAndCharFun(pchar);  //使用版本#1
constCharAndCharFun(cpchar); //使用版本#2

//charFun的合法调用
charFun(pchar);    //使用版本#3, 仅能传入非const实参

//constCharFun的合法调用
constCharFun(pchar);  //使用版本#4, 既能传入非const实参
constCharFun(cpchar); //使用版本#4, 又能传入const实参

这里有一点需要注意,当且仅当形参类型是指针或引用时,const 参数版本的函数和非 const 参数版本的函数才可同时存在,否则编译器将把 const 形参和非 const 形参视为同一个特征标,出现重复定义函数的问题。

8.4.4 使用引用参数时的函数重载

不同的引用类型可接受的实参类型不同。以下面的程序为例,三种形参可接受的实参类型如下:

  • 左值引用类型 double &可修改的左值参数(如 double 变量)匹配;
  • const 左值引用类型 const double &可修改的左值参数const 左值参数右值参数(如两个 double 值的和)匹配;
  • 右值引用类型 double &&右值参数匹配。

如果重载使用这三种参数的函数,编译器将自动调用最匹配的版本。

//一组函数原型
void stove(double & rx);       //原型#1
void stove(const double & rx); //原型#2
void stove(double && rx);      //原型#3

//定义实参
const double y = 32.0;
double x = 55.5;

//重载函数的合法调用
stove(x);     //使用版本#1
stove(y);     //使用版本#2
stove(x + y); //使用版本#3

如果没有定义右值参数版本 #3stove(x + y) 将使用版本 #2

8.4.5 何时使用函数重载

仅当函数基本上执行相同的任务,但使用不同形式的数据时,才应采用函数重载。有时候使用带默认参数的函数更简单些,这只需编写一个函数,程序也只需为一个函数请求内存,内存占用相比会少一些。但需使用不同类型的参数时,默认参数便不管用了,此时应使用函数重载。

8.5 函数模板

函数模板是通用的函数描述,它们使用泛型来定义函数,通过将类型作为参数传递给模板,可使编译器生成该类型的函数,因此函数模板也被称为通用编程。由于类型是用参数表示的,因此模板特性也被称为参数化类型(parameterized types)。

8.5.1 函数模板基础用法及其局限性

以交换函数为例,来说明函数模板的基础用法。以下是一个交换函数模板的常规用法,原型及定义开头 template <typename T> 中的关键字 template 和尖括号 <> 是固定的语法,是函数模板必需要有的;类型名称 T 可以是遵守 C++ 命名规则的任意名称,通常使用大写字母如TU 表示;在 C++98 之前,C++ 使用关键字 class 来表示 typename,但在后来的编译器中,template <typename T>template <class T> 是等价的,若不考虑向 C++98 前兼容的问题,则应尽量在模板中使用 typename 而不是 class 做为类型。

#include <iostream>

//交换函数模板原型
template <typename T>
void Swap(T &a, T &b);

//在main()函数中调用
int main()
{
    using namespace std;
    
    int i = 10;
    int j = 20;
    cout << "i, j = " << i << ", " << j << ".\n";
    Swap(i,j);
    cout << "Now i, j = " << i << ", " << j << ".\n";
    
    double x = 24.5;
    double y = 81.7;
    cout << "x, y = " << x << ", " << y << ".\n";
    Swap(x,y);
    cout << "Now x, y = " << x << ", " << y << ".\n";
    
    return 0;
}

//交换函数模板定义
template <typename T>
void Swap(T &a, T &b)
{
    T temp;
    temp = a;
    a = b;
    b = temp;
}

编译并运行该程序,输出结果如下,可知实现了交换功能。

i, j = 10, 20.
Now i, j = 20, 10.
x, y = 24.5, 81.7.
Now x, y = 81.7, 24.5.

使用函数模板时的程序布局和使用常规函数时相同,都是在首次使用前提供函数原型,然后在后面提供函数定义,也可以省略函数原型直接在本应提供原型的地方提供函数定义,对于函数模板而言,后者是更常规的用法,一般将函数模板定义放在头文件中,并在需要使用模板的文件中包含该头文件。使用函数模板时形参类型表示的等价性和使用常规函数时相同,比如函数头或函数原型中的 T * aT a[] 等价。

函数模板的好处是,它使生成多个函数定义更简单、更可靠,函数模板不能缩短可执行程序,编译器最终生成的代码不包含任何模板,只包含为程序生成的实际函数,在上述例子中,编译器会根据函数模板定义及调用模板时的代码生成 int 版本和 double 版本的交换函数,可借助 C++ Insights 工具查看函数模板的展开情况。

并非所有的模板参数都必须是模板参数类型,部分模板参数也可以是具体类型(如 int),此外,可以像重载常规函数那样重载模板函数,被重载的模板的函数特征标必须不同。例如,可以对前述交换模板 Swap() 进行重载,使其能够交换两数组的元素,重载模板定义如下:

//重载交换函数模板,使其能够交换两数组元素
template <typename T>
void Swap(T *a, T *b, int n)
{
    T temp;
    for (int i = 0; i < n; i++)
    {
        temp = a[i];
        a[i] = b[i];
        b[i] = temp;
    }
}

函数模板也具有局限性,编写的模板函数很可能无法处理某些类型,比如,若在模板函数中执行了乘法操作 a*b,其中 ab 均为模板参数类型,当它们实例化为 intdouble 等算术类型时,这种乘法操作是合法的,但若实例化为指针、结构体或自定义类,这种乘法操作就不一定合法了。针对这种局限性,有以下几种解决方案:

  • 从结构体或类自身来看,可以重载运算符,以便能够将某些运算符用于结构体或自定义类;
  • 从模板函数自身来看,可以显式具体化,以便使模板函数遇到这些特殊类型时,自动重载显式具体化后的函数版本。
  • 在 C++11中可以使用静态断言在编译期检查模板参数类型的合法性,当实例化遇到不合法类型时就会立即报错,提前发现程序崩溃的潜在风险。

8.5.2 显式具体化

前述交换函数模板也可用于结构,结果将交换两个结构的全部内容。若想仅交换结构的部分内容,则可以对模板类型为该结构的交换函数进行定制,也称函数模板的显式具体化。对函数模板进行显式具体化后,若调用时有多个函数原型可以匹配,编译器在选择原型时的优先级为:非模板函数 > 显式具体化的模板函数 > 常规模板函数。需要注意的是,必须先声明常规的函数模板,再声明对其进行显式具体化的函数模板,以下程序为一个显式具体化的交换模板示例,例子中省略了部分代码,仅保留了核心部分。

//定义结构体
struct job
{
    char name[40];
    double salary;
    int floor;
};

//函数原型:显式具体化交换模板,使其交换结构的部分内容
template <> void Swap<job>(job &, job &);

//函数调用:省略了调用函数中的其他代码
Swap(jobSue, jobDan);

//函数定义:显式具体化交换模板,使其交换结构的部分内容
template <> void Swap<job>(job &j1, job &j2)
{
    double temp;
    temp = j1.salary;
    j1.salary = j2.salary;
    j2.salary = temp;
}

显式具体化的原型和定义必须以 template <> 打头,并显式指明参数类型,但函数名后的 <T> 是可选的。以上述显式具体化为例,在函数原型或函数头中,以下两种写法是等价的,都表示显式具体化。

//显式具体化写法一
template <> void Swap<job>(job &, job &)

//显式具体化写法二
template <> void Swap(job &, job &)

8.5.3 隐式实例化和显式实例化

在代码中包含函数模板本身并不会生成函数定义,它只是一个用于生成函数定义的方案。编译器使用模板为特定类型生成函数定义时,得到的是模板实例。8.5.1 小节的 main() 函数中,由于调用方式 Swap(i,j) 提供了 int 参数,所以导致编译器生成了 Swap() 函数模板的 int 类型实例,这种通过查看调用函数时的实参类型进行实例化的方式被称为隐式实例化(implicit instantiation)。最初 C++ 只支持通过隐式实例化生成函数定义,但现在编译器还允许显式实例化(explicit instantiation),以下为交换模板显式实例化的一个示例。

//显式实例化方式一
template void Swap<int>(int &, int &);

与函数声明不同,它不需再次人为地提供函数定义,因为支持这一特性的编译器看到这条语句后,会自动生成相应类型的函数定义,因此,不要在同一个文件(或转换单元)中使用同一种类型的显式实例化和显式具体化。除了以上方式可以完成显式实例化外,还可通过在调用模板函数时指定类型来进行显式实例化,如下:

//显式实例化方式二
char a = '1';
char b = '2';
Swap<char>(a, b);

模板的显式实例化必须保留函数名后的 <T>,上例中为 <char>,若方式二例子中调用模板时函数时省略了 <char>,程序也能正常运行,但此时实例化的方式不是显式实例化,而是隐式实例化。由于前述交换模板的参数为非 const 引用类型,类型不匹配时不会进行类型转换,因此当两个实参的类型不相同时,程序可能无法正常运行。

显式具体化、隐式实例化和显式实例化三者统称为具体化(specialization),以下是同时使用三者的例子,例子中省略了部分代码,仅保留了核心部分。

//函数模板声明
template <typename T>
void Swap(T &a, T &b);

//显式具体化
template <> void Swap<job>(job &, job &);

//显式实例化
template void Swap<char>(char &, char &);

//函数调用
int main()
{
    //使用隐式实例化
    short a1 = 1;
    short b1 = 2;
    Swap(a1, b1);
    
    //使用显式实例化
    char a2 = '1';
    char b2 = '2';
    Swap(a2, b2);
    
    //使用显式实例化
    double a3 = 1.0;
    double b3 = 2.0;
    Swap<double>(a3, b3);
    
    //使用显式具体化
    job jobSue = {"Suan Yaffee", 73000.60, 7};
    job jobDan = { "Sidney Taffee", 52000.12, 9 };
    Swap(jobSue, jobDan);
    
    return 0;
}

//函数模板定义
...

//显式具体化定义
...

8.5.4 重载解析策略

C++ 使用重载解析策略来决定为函数调用使用哪一个函数定义。重载解析过程大致分为如下三步:

  • 第 1 步:创建候选函数列表,只要求函数名一样即可,对函数特征标以及是否为模板函数无要求;
  • 第 2 步:在上一步的基础上创建可行函数列表,包含特征标完全匹配的常规函数或模板函数、以及实参隐式转换后完全匹配的常规函数或模板函数,这些都是参数数目正确的函数;
  • 第 3 步:在上一步的基础上确定最佳匹配函数,若有则使用它,若没有则该函数调用失败。

下面以一个例子来说明这个重载过程:

//全部函数原型
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
void may(char, double);               //原型#8
void mbk(float);                      //原型#9
char mkk(int, char);                  //原型#10
int mck(char);                        //原型#11
double myk(float);                    //原型#12
void mpk(char);                       //原型#13

//函数调用
may('B');

//函数定义
...

重载第 1 步:创建候选函数列表。即函数名称为 may 的常规函数和模板函数,候选函数列表如下:

//重载第1步:创建候选函数列表
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
void may(char, double);               //原型#8

重载第 2 步:创建可行函数列表。由于整数类型 char 不能被隐式地转换为指针类型 char *,因此函数 #4 和函数 #7 都被排除,而函数 #8 因为参数数目不匹配也会被排除。进行完全匹配时,C++ 允许下表这些无关紧要的转换,表中 Type 表示任意类型,例如 char &const char & 的转换也包含在内,表中 Type (argument-list) 意味着用作实参的函数名和用作形参的函数指针只要返回类型和参数列表相同,就是匹配的。

实参类型 形参类型
Type Type &
Type & Type
Type [] Type *
Type (argument-list) Type (*) (argument-list)
Type const Type
Type volatile Type
Type * const Type *
Type * volatile Type *

根据此表可知,剩下的函数中包含特征标完全匹配的常规函数 #3#5、特征标完全匹配的模板函数 #6(此时 T 可以被实例化为 char)、实参隐式转换后完全匹配的常规函数 #1#2。可行函数列表如下:

//重载第2步:创建可行函数列表
void may(int);                        //原型#1
float may(float, float = 3);          //原型#2
void may(char);                       //原型#3
char may(const char &);               //原型#5
template<class T> void may(const T &);//原型#6

重载第 3 步:确定最佳匹配函数。通常,从最佳到最差的顺序如下所述:

  1. 特征标完全匹配
  2. 类型需经隐式提升转换,例如 charshort 自动转换为 intfloat 自动转换为 double
  3. 类型需经隐式标准转换,例如 int 转换为 charlong 转换为 double
  4. 类型需经隐式自定义转换,例如类中用户定义的类型转换。

依此规则,函数 #3 和函数 #5、函数 #6 都是特征标完全匹配的最佳匹配函数,函数 #1 需经隐式提升转换,函数 #2 需经隐式标准转换,由此各函数最佳匹配程度为:(#3, #5, #6) > #1 > #2。当特征标完全匹配时,又有如下规则:

  • 指向非 const 数据的指针和引用优先与形参为非 const 指针和引用的函数匹配;
  • 优先与非模板函数匹配;
  • 同为模板函数时,优先与较具体的模板函数匹配。

依此规则,非模板函数 #3#5 最佳匹配程度要高于模板函数 #6 ,即各函数最佳匹配程度为:(#3, #5) > #6 > #1 > #2。最终出现了两个最佳匹配函数 #3#5 ,因此该函数调用失败,编译器将报错

//重载第 3 步:确定最佳匹配函数
void may(char);                       //原型#3
char may(const char &);               //原型#5

下面展开来说上述几条完全匹配时的规则。

第 1 条:指向非 const 数据的指针和引用优先与形参为非 const 指针和引用的函数匹配,这一点需明确,const 和非 const 之间的区别只适用于指针和引用。下面 4 个函数都与函数调用是完全匹配的:

//函数原型
void recycle(int);        //原型#1
void recycle(const int);  //原型#2
void recycle(int &);      //原型#3
void recycle(const int &);//原型#4

//函数调用
int x = 5;
recycle(x);

//函数定义
...
  • 如果这 4 个函数同时存在,则无法完成重载,编译器会报多义性匹配的错误;
  • 如果只存在函数 #1#2,则无法完成重载,编译器会报重复定义的错误;
  • 如果只存在函数 #1#3,则无法完成重载,编译器会报多义性匹配的错误;
  • 如果只存在函数 #1#4,则无法完成重载,编译器会报多义性匹配的错误;
  • 如果只存在函数 #2#3,则无法完成重载,编译器会报多义性匹配的错误;
  • 如果只存在函数 #2#4,则无法完成重载,编译器会报多义性匹配的错误;
  • 如果只存在函数 #3#4,则函数调用时编译器将会选择 #3

第 2 条:优先与非模板函数匹配,这一点比较简单,当完全匹配的函数中,一个是非模板函数,另一个是模板函数时,非模板函数将优于模板函数,显式具体化、显式实例化、隐式实例化都属于模板函数。

第 3 条:同为模板函数时,优先与较具体的模板函数匹配,找出最具体的模板的规则被称为函数模板的部分排序规则(partial ordering rules)。这意味着显式具体化优先于常规模板函数,都为常规模板函数时,编译器优先选择实例化时类型转换更少的那一个。以下面的程序为例,调用方式 recycle(&ink) 既与模板 #1 匹配,此时 Type 将被解释为 blot *,也与模板 #2 匹配,此时 Type 将被解释为 blot,因此将这两个隐式实例 recycle<blot *>(blot *)recycle<blot>(blot *) 发送到可行函数池中。在选择最佳匹配函数时,#2 被认为是更具体的,因为它已经显式地指出,函数参数是指向 Type 的指针,相比于 #1,它对类型的要求更加地具体,在生成过程中所需要的转换更少,因此调用方式 recycle(&ink) 实际会匹配版本 #2

//两个常规模板函数
template <class Type> void recycle(Type t);   //原型#1
template <class Type> void recycle(Type * t); //原型#2

//调用程序包含如下代码
struct blot {int a; char b[10];};
blot ink = {25, "spots"};
...
recycle(&ink);  //使用版本#2

//函数定义
...

部分排序规则的另一个示例程序如下,它与上一个例子有异曲同工之妙。由于模板 #2 做了特定的假设:数组内容是指针,对类型的要求更加地具体,因此在调用时第一个参数若传入指针数组 pt,则将实际匹配函数 #2

//两个常规模板函数
template <typename T> 
void ShowArray(T arr[], int n);   //原型#1
template <typename T> 
void ShowArray(T * arr[], int n); //原型#2

//调用程序包含如下代码
int things[6] = {13, 31, 103, 301, 310, 130};
int * pt[3] = {&things[0], &things[2], &things[4]};
ShowArray(things, 6);  //使用版本#1
ShowArray(pt, 3);      //使用版本#2

//函数定义
...

将有多个参数的函数调用与有多个参数的原型进行匹配时,编译器必须考虑所有参数的匹配情况。如果找到比其他可行函数都合适的函数,则选择该函数。一个函数要比其他函数都合适,其所有参数的匹配程度都必须不比其他函数差,同时至少有一个参数的匹配程度比其他函数都高。

在有些情况下,可通过编写合适的函数调用,来引导编译器做出程序员期望的选择。如下所示,其中模板函数返回两个值中较小的一个,非模板函数返回两个值中绝对值较小的那个。第一次调用时根据重载解析策略选择了非模板函数 #2;第二次调用时根据重载解析策略选择了模板函数 #1double 版本,属于模板函数的隐式实例化;第三次调用的 <> 指出,编译器应该选择模板函数,此时编译器会查看调用函数时的实参类型来进行实例化,也属于模板函数的隐式实例化;第四次调用的 <int> 显式指出,编译器应该使用模板函数的 int 实例化版本,此时属于模板函数的显式实例化。

#include <iostream>

//函数#1
template<class T>
T lesser(T a, T b)
{
    return a < b ? a : b;
}

//函数#2
int lesser(int a, int b)
{
    a = a < 0 ? -a : a;
    b = b < 0 ? -b : b;
    return a < b ? a : b;
}

//函数调用
int main()
{
    using namespace std;
    
    int m = 20;
    int n = -30;
    double x = 15.5;
    double y = 25.9;
    
    //使用#2,结果为20
    cout << lesser(m, n) << endl;
    
    //使用#1,double隐式实例化,结果为15.5
    cout << lesser(x, y) << endl;
    
    //使用#1,int隐式实例化,结果为-30
    cout << lesser<>(m, n) << endl;
    
    //使用#1,int显式实例化,结果为15
    cout << lesser<int>(x, y) << endl;
    
    return 0;
}

8.5.5 C++11 关键字 decltype 及后置返回类型

在模板编程中,有时候无法预先知道声明变量时应该使用何种类型。除了 auto 关键字外,C++11 还提供了 decltype 关键字,其基本使用方式如下,给 decltype 提供的关键字可以是变量,也可以是表达式,由于 shortint 在进行运算时,会整型提升至 int,因此 decltype(x + y) 类型实际为 int

//定义已知类型的变量
int x = 5;
short y = 3;

//使用decltype声明未知类型的变量,方式一
decltype(x + y) z;
z = x + y;

//使用decltype声明未知类型的变量,方式二
decltype(x + y) z = x + y;

decltype 通用的使用方式如下:

decltype(expression) var;

为确定 expression 的类型,编译器必须遍历一个核对表,简化的核对表如下:

  • 情况一:若 expression 是一个没有用括号括起来的标识符,则 var 的类型与该标识符的类型相同,包括 const 等限定符;
  • 情况二:若 expression 是一个函数调用,则 var 的类型与函数的返回类型相同;
  • 情况三:若 expression用括号括起来的标识符,且 expression 是一个左值,则 var 的类型为指向其类型的引用;
  • 情况四:若前面的条件都不满足,则 var 的类型与 expression 的类型相同。

auto 关键字不同,decltype 确定类型时并不会实际计算表达式的值,也不会实际调用函数(编译器通过查看函数的原型来获悉返回类型),以下是不同情况的使用示例。

//情况一
double x = 5.5;
double y = 7.9;
double &rx = x;
const double * pd = &x;
decltype(x) w;      //类型为double
decltype(rx) u = y; //类型为double &
decltype(pd) v;     //类型为const double *

//情况二
long fun(int);
decltype(fun(3)) m; //类型为long

//情况三
double xx = 4.4;
decltype((xx)) rxx = xx; //类型为double &

//情况四
int j = 3;
int &k = j;
int &n = j;
decltype(j+6) xi;   //类型为int
decltype(100L) xj;  //类型为long
decltype(k+n) xk;   //类型为int

情况三中需注意,括号并不会改变表达式的值和左值性,例如 x = 98.6(x) = 98.6 是等效的,() 对变量 x 没有任何影响。当需要多次使用该未知类型时,可结合使用 typedefdecltype,如下所示:

template<class T1, class T2>
void ft(T1 x, T2 y)
{
    ...
    typedef decltype(x + y) xytype;
    xytype xpy = x + y;
    xytype arr[10];        //数组
    xytype & rxy = arr[2]; //引用
    ...
}

C++11 新增了一种函数声明语法,使用后置返回类型声明函数返回值,如下所示,这将返回类型移到了参数声明后面。->double 被称为后置返回类型(trailing return type),其中 auto 是一个占位符,表示后置返回类型提供的类型,这是 C++11 给 auto 新增的一种角色。

//常规声明方式
double h(int x, float y);

//使用后置返回类型的声明方式
auto h(int x, float y) -> double;

//使用后置返回类型的函数定义
auto h(int x, float y) -> double
{
    ...
}

可将这种语法结合 decltype 关键字用于模板函数中,如下所示,此时 decltype 位于参数声明的后面,因此在形参 x 和形参 y 的作用域内,编译器可使用它们。若将 decltype(x + y) 放在参数声明前面,代替 auto,此时由于还未声明参数 xy,它们不在作用域内,编译器看不到它们,也无法使用它们,因此会报错。

template<class T1, class T2>
auto fun(T1 x, T2 y) -> decltype(x + y)
{
    return x + y;
}

8.5.6 何时使用函数模板

需要对多个不同类型的数据使用相同的算法时,可使用函数模板。

posted @ 2022-10-21 08:00  木三百川  阅读(158)  评论(0编辑  收藏  举报