前面的知识中,C++ 和 C 语言的差别其实并不大,C++ 只是新增了 bool 类型变量、const 关键字、变量初始化方法、修改了 auto 关键字的作用。
PS:C 语言后来也引入了 const 关键字,_Bool 类型。
在函数这里,C++ 提供了很多新的函数特性,包括内联函数、按引用传递变量、默认参数值、函数重载以及模板函数。函数是进入加加 (++) 领域的重要一步。
C++ 内联函数
内联函数是 C++ 提高程序运行速度所做的一项改进。常规函数和内联函数之间的区别不在于编写方式,而在于 C++ 编译器如何将它们组合到程序中。
定义内联函数需要在函数声明和函数定义前面加上关键字 inline,但由于内联函数通常都是比较简短的,因此,通常的做法是省略函数原型,而是将整个函数定义放到本应提供函数原型的地方。
C++ 新增的内联函数和 C 语言中宏函数的定位很相似,但内联函数和宏函数是有差别的。
在使用上比宏函数安全的多,内联函数采用函数的值传递的方式来传递参数,而宏函数则是文本替换的方式传递参数,这导致定义宏函数时需要非常小心,否则使用时可能出现偏差。
程序员定义一个内联函数时,只是请求将函数作为内联函数,编译器并不一定满足这种要求(可以参考 C 语言中的 register 关键字)。编译器可能会认为该函数过大或注意到函数调用了自己(内联函数不能递归),因此可能会出现即使程序员声明时使用了 inline 关键字,但是编译器并不将其作为内联函数的情况。而有些编译器可能并没有启动或实现这种特性。
C 语言后来也增加了 inline 关键字,新增内联函数的特性。
引用变量
什么是引用?
C++ 新增了一种符合类型 —— 引用变量,引用是已定义的变量的别名。
如何声明引用?
Type & name = var;
在声明引用变量时,必须进行初始化,并且不能为 null,一旦关联某个变量,就不能再关联其他变量。引用更像是指针常量。
引用有什么用?
引用常用作函数参数,这种传递参数的方式叫做引用传递。
在函数中使用引用参数的主要原因有两个:1. 程序员能够修改调用函数中的数据对象;2. 通过传递引用而不是整个数据对象,可以提高程序的运行速度。
什么时候用引用?
对于使用传递的值,而不需要修改的函数:
- 如果数据对象很小,如内置数据类型或者小型结构,使用按值传递。
- 如果数据对象是数组,则只能使用指针,因为这是唯一的选择,并且需要声明为常量指针。
- 如果数据对象是较大的结构,则使用常量指针或者常引用,以提高程序效率。这样可以节省复制结构所需的时间和空间。
- 如果数据对象是类对象,则推荐使用常引用。类设计的语义常常要求使用引用,这是 C++ 新增这项特性的主要原因。因此,传递类对象参数的标准方式是按引用传递。
对于需要修改调用函数中数据的函数:
- 如果数据对象是内置数据类型,使用指针,这样在函数调用时可以很清楚的区分该函数是不是会修改这个数据对象。
- 如果数据对象是数组,则只能使用指针。
- 如果数据对象是结构,则使用指针或者引用都可。
- 如果数据对象是类对象,则推荐使用引用。
默认参数
默认参数是 C++ 的另一项新内容。默认函数是指当函数调用中省略了实参时自动使用的一个值。例如,如果将 void wow(int n) 设置成 n 的默认值为 1,则函数调用 wow() 相当于 wow(1),这极大地提高了程序使用函数的灵活性。而如果我们需要传递的参数不是 1,而是 4,这可以使用 wow(4) 来调用,4 会覆盖默认值 1。
如何设置默认值?必须通过函数原型来设置默认值!因为编译器是通过函数原型来了解函数所使用的参数数目的,因此函数原型必须将可能的默认参数告知编译器。方法是将默认值赋值给函数原型中的参数,函数定义不需要改变。
char * left(const char * str, int n = 1);
调用上面的函数时,如果不传递参数 n,则它的值默认为 1;否则,传递的值将覆盖 1。
需要注意的点:对于带参数列表的函数,必须从右向左添加默认值。这也意味着,如果要为某个参数设置默认值,则必须为它右边的所有参数提供默认值。
int harpo(int n, int m = 4, int j = 5); // 有效
int chico(int n, int m = 4, int j); // 无效
harpo 原型允许调用该函数时提供 1、2 或者 3 个参数:
int tmp;
tmp = harpo(1); // 等价于 harpo(1, 4, 5);
tmp = harpo(2, 6); // 等价于 harpo(2, 6, 5);
tmp = harpo(3, 7, 8); // 没有使用默认参数
默认参数并非编程方面的重大突破,而只是提供了一种便捷的方式。在设计类的时候会发现,通过使用默认参数,可以减少需要定义的析构函数、方法以及方法重载的数量。
函数重载
函数多态是 C++ 在 C 语言的基础上新增的功能。在 C 语言中,函数名不能重名,而 C++ 中允许声明多个同名的函数。
- 默认参数使得 C++ 能够使用不同数目的参数调用同一个函数。
- 函数重载使得 C++ 能够使用多个同名的函数。
- 函数重写子类重新定义父类中有相同名称和参数的虚函数 —— 在学习 class 的时候讲解。
函数重载和函数重写是不同的概念,只是名称有些类似而已。
术语“函数多态”指的是函数有多种形式;术语“函数重载”指的是可以有多个同名函数,因此对名称进行了重载。这两个术语指的是一回事,但我们通常使用函数重载。可以通过函数重载来设计一系列函数 —— 它们完成相同的工作,有着相同的函数名,使用不同的参数列表。
函数重载的关键是函数的参数列表 —— 也称为函数特征标。如果两个函数的参数数目和类型相同,同时参数的排列顺序也相同,则它们的特征标相同,而变量名和返回值是无关紧要的。
C++ 允许定义名称相同的函数,条件是它们的特征标不同。
void print(int a, int b); // #1
void print(int c, int d); // #2
int print(int a, int b); // #3
void print(int a, double b); // #4
void print(double a, int b); // #5
void print(int a); // #6
2 和 #3 不满足函数重载条件,它们和 #1 的特征标相同。
#4 和 #5 是函数重载,它们的参数类型和数目相同,但是参数顺序不同,是不同的特征标。
注意:有些看起来彼此不同的特征标是不能共存的,请从编译器角度考虑。例如,编译器将把类型引用和类型本身视为同一个特征标。
虽然函数重载很吸引人,但也不要滥用。仅当函数基本上执行相同的任务,但是使用不同形式的数据时,才应该采用函数重载。
简单了解:C++ 编译器编译程序时,编译器会执行一些神奇的操作 —— 名称修饰或名称矫正,它根据函数原型中指定的形参类型对每个函数名进行加密。
long f(int a, double b); // 函数原型
?f_int_double();// 编译器处理
long f(double a, int b); // 函数原型
?f_double_int();// 编译器处理
这只是举了个例子,实际中的编译器可能是用一系列无意义的符号来对参数类型和数目进行编码的,而且修饰时使用的符号因编译器而定。
函数模板
现在的 C++ 编译器实现了 C++ 新增的一项特性 —— 函数模版。
函数模版是通用的函数描述,也就是说,它们使用泛型来定义函数。其中泛型可以用具体的类型来替换。通过将类型作为参数传递给模版,可以使得编译器生成该类型的函数。
创建模版
template <typename T>
void swap(T& a, T& b) {
T tmp;
tmp = a;
a = b;
b = tmp;
}
第一行指出要创建一个模版,并将类型命名为 T。关键字 template 和 typename 是必须的,除非用 class 代替 template。另外,必须用尖括号。上面代码中使用的类型名 T 可以随意起名,只要遵守 C++ 命名规则即可。
模版并不会创建任何函数,而只是告诉编译器如何定义函数。当需要交换 int 类型的函数时,编译器将根据模版创建这样的函数,并用 int 代替 T。
在 C++98 标准添加 typename 关键字之前,C++ 一直是使用 class 来创建模版。也就是说,也可以这样编写模版定义:
template <class T>
void swap(T& a, T& b) {
T tmp;
tmp = a;
a = b;
b = tmp;
}
模版重载演示
#include <iostream>
template <typename T>
void swap(T &, T &);
template <typename T>
void swap(T * a, T * b, int c);
template <class T>
void show_arr(T * arr, int n = 10);
int main(void) {
int a = 10, b = 20;
// use swap(T &, T &) generates swap(int &, int &)
std::cout << "a = " << a << ", b = " << b << std::endl;
swap(a, b);
std::cout << "a = " << a << ", b = " << b << std::endl;
double c = 2.9, d = -9.7;
std::cout << "c = " << c << ", d = " << d << std::endl;
swap(c, d);
std::cout << "c = " << c << ", d = " << d << std::endl;
int arr_a[10] = {1, 3, 5, 7, 9, 11, 13, 15, 17, 19};
int arr_b[10] = {2, 4, 6, 8, 10, 12, 14, 16, 18, 20};
std::cout << "arr_a:";
show_arr(arr_a, 10);
std::cout << "arr_b:";
show_arr(arr_b);
swap(arr_a, arr_b, 10);
std::cout << "arr_a:";
show_arr(arr_a);
std::cout << "arr_b:";
show_arr(arr_b);
return 0;
}
template <typename T>
void swap(T & a, T & b) {
T temp = a;
a = b, b = temp;
}
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;
}
template <class T>
void show_arr(T * arr, int n) {
for (int i = 0; i < n; i++)
std::cout << arr[i] << " ";
std::cout << std::endl;
}
上面的程序演示了默认参数、函数重载、函数模版的混合使用。
注意:模版不能缩短可执行程序,而是像手工方式定义了独立的函数一样。最终的代码中不包含任何模版,而只包含了为程序生成的实际函数。使用模版的好处是,它使得生成多个函数定义更简单、可靠。
更常见的情形是,将模版放在头文件中,并在需要使用模版的文件中包含该头文件。
模版的局限性
假设有以下模版函数:
template <class T>
void f(T a, T b)
{ ... }
通常,代码假定执行某些操作。例如,a = b 就假定使用的数据类型已经定义了赋值。但如果 T 是数组类型,这种假定就不成立。a < b 同样假定使用的数据类型已经定义了 <,但如果 T 为结构,该假设不成立。另外,数组名虽然定义了 <,但是数组名比较的是地址,这可能不是我们想要的。
总之,编写的模版函数很可能无法处理某些类型。另一方面,有时候通用化是有意义的,但 C++ 语法不允许这样做。例如,将两个包含位置坐标的结构相加是有意义的,虽然没有为结构定义运算符 +。
一种解决方案是,C++ 允许您重载运算符 +,以便能够将其用于特定的结构或类。这样使用运算符 + 的模版便可以处理重载了运算符 + 的结构。
另一种解决方案是,为特定类型提供具体化的模版定义。
模版的具体化
假设定义了如下的结构:
struct job {
char name[40];
double salary;
int floor;
};
另外,假设希望能够交换两个这种结构变量的内容,原来的模版使用下面的代码来进行交换:
temp = a;
a = b;
b = temp;
由于 C++ 允许将一个结构赋值给另一个结构,因此即使 job 是一个结构体,上述代码也适用。但是,如果只想交换 salary 和 floor 成员,而不交换 name 成员,则需要使用不同的代码,但是 swap() 的参数将保持不变,因此无法使用函数模版重载来提供其他的代码。
然而,可以提供一个具体化的函数定义 —— 称为显式具体化,其中包含所需的代码。当编译器找到与函数调用匹配的具体化定义时,将使用该定义,而不再寻找模版。
具体化机制随着 C++ 的演变而不断变化。C++ 98 选择了下面的方法:
- 对于给定的函数名,可以有非模版函数、模版函数和显式具体化模板函数以及它们的重载版本。
- 显式具体化的原型和定义应该以 template<> 打头,并通过名称来指出类型。
- 具体化优先于常规模板,而非模板函数优先于具体化和常规模板。
```cpp
// job 的非模板函数
void swap(job &, job &);
// job 的常规模板函数
template
void swap(T &, T &); // job 的具体化模板 template <> void swap (job &, job &); // 是可选的,与下面的函数原型等价 template <> void swap(job &, job &);
```cpp
...
template <typename T>
void swap(T &, T &);
template <> void swap<job>(job &, job &);
int main(void) {
double u, v;
...
swap(u, v);// 使用通用模板
job a, b;
swap(a, b);// 使用job具体化模板
return 0;
}
template <typename T>
void swap(T &, T &) {
...
}
template <> void swap<job>(job &, job &) {
...
}
实例化和具体化
template <typename T>
void swap (T & a, T & b);
template <> void swap<int>(int & a, int & b); // 显式具体化
template void swap<double>(double &a, double &b); // 显式实例化
int main (void) {
...
}
template <> void swap<int>(int &a, int &b) {
...
}
显式实例化是使用 swap 的模板函数生成一个使用 double 类型的实例,也就是说,该声明的意思是“使用 swap() 模板生成 double 类型的函数定义”。
显式具体化的意思是“不要使用 swap() 模板生成函数定义,而应该使用专门为 int 类型显式定义的函数定义”。
警告:试图在同一个文件中使用同一种类型的显式实例和显式具体化将出错。
模板函数的发展
在C++发展的早期,大多数人都没有想到模板函数和模板类会有这么强大而有用,它们甚至没有就这个主题发挥想象力。但聪明而专注的程序员挑战模板技术的极限,阐述了各种可能性。根据熟悉模板的程序员提供的反馈,C++98标准做了相应的修改,并添加了标准模板库。从此以后,模板程序员在不断探索各种可能性,并消除模板的局限性。C++11标准根据这些程序员的反馈做了相应的修改。下面介绍一些相关的问题及其解决方案。
1.是什么类型
在C++98中,编写模板函数时,一个问题是并非总能知道应在声明中使用哪种类型。请看下面这个不完整的示例:
template<class T1, class T2>
void ft(T1 x, T2 y) {
...
?type? xpy = x + y;
...
}
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 提供了解决方案。可以用 decltype() 代替数据类型来声明变量。例如,可以这样使用该关键字:
int x;
decltype(x) y; // make y the same type as x
给 decltype 提供的参数可以是表达式,因此在前面的模板函数 ft() 中,可使用下面的代码:
decltype(x + y) xpy; // make xpy the same type as x + y
xpy = x + y;
另一种方法是,将这两条语句合而为一:
decltype(x + y) xpy = x + y;
因此,可以这样修复前面的模板函数 ft():
template<class T1, class T2>
void ft(T1 x, T2 y) {
...
decltype(x + y) xpy = x + y;
...
}
decltype 比这些示例演示的要复杂些。为确定类型,编译器必须遍历一个核对表。假设有如下声明:
decltype(expression) var;
则核对表的简化版如下:
第一步:如果expression是一个没有用括号括起的标识符,则var的类型与该标识符的类型相同,包括const等限定符:
double x = 5.5;
double y = 7.9;
double &rx = x;
const double * pd;
decltype(x) w; // w is type double
decltype(rx) u = y; // u is type double &
decltype(pd) v; // v is type const double *
第二步:如果expression是一个函数调用,则var的类型与函数的返回类型相同:
long indeed(int);
decltype (indeed(3)) m; // m is type int
注意:并不会实际调用函数,编译器通过查看函数的原型来获悉返回类型,而无需实际调用函数。
第三步:如果 expression 是一个左值,则 var 为指向其类型的引用。这好像意味着前面的 w 应为弓I用类型,因为 x 是一个左值。但别忘了,这种情况己经在第一步处理过了。要进入第三步,expression 不能是未用括号括起的标识符。那么,expression 是什么时将进入第三步呢? 一种显而易见的情况是,expression 是用括号括起的标识符:
double xx = 4.4;
decltype ((xx)) r2 = xx; // r2 is double &
decltype(xx) w = xx; // w is double (Stage 1 match)
顺便说一句, 括号并不会改变表达式的值和左值性。
第四步:如果前面的条件都不满足, 则 var 的类型与 expression 的类型相同。
int j = 3;
int &k = j;
int &n = j;
decltype(j+6) i1; // i1 type int
decltype(100L) i2; // i2 type long
decltype(k+n) i3; // i3 type int;
请注意,虽然 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 arr [10];
xytype & rxy = arr[2] ; // rxy a reference
...
}
3.另一种函数声明语法(C++11后置返回类型)
有一个相关的问题是 decltype 本身无法解决的。请看下面这个不完整的模板函数:
template<class T1, class T2>
?type? gt(T1 x, T2 y)
{
...
return x + y;
}
同样,无法预先知道将 x 和 y 相加得到的类型。好像可以将返回类型设置为 decltype (x + y),但不幸的是,此时还未声明参数 x 和 y,它们不在作用域内(编译器看不到它们,也无法使用它们)。必须在声明参数后使用 decltype。为此,C++ 新增了一种声明和定义函数的语法。下面使用内置类型来说明这种语法的工作原理。对于下面的原型:
double h(int x, float y);
使用新增的语法可编写成这样:
auto h(int x, float y) -> double;
这将返回类型移到了参数声明后面。->double 被称为后置返回类型。其中 auto 是一个占位符,表示后置返回类型提供的类型,这是 C++11 给 auto 新増的一种角色。这种语法也可用于函数定义:
auto h(int x, float y) -> double
{/* function body */};
通过结合使用这种语法和 decltype,便可给 gt() 指定返回类型,如下所示:
template<class T1, class T2>
auto gt(T1 x, T2 y) -> decltype(x + y) {
...
return x + y;
}
现在,decltype 在参数声明后面,因此 x 和 y 位于作用域内,可以使用它们。