第8章 函数探幽
书中程序清单和练习将在https://github.com/linlll/CppPrimePlus发布
C++内联函数
内联函数是C++为提高程序运行速度所做的一项改进。
执行到函数调用指令时,程序将在函数调用后立即存储该指令的内存地址,并将函数参数复制到堆栈(为此保留的内存块),跳到标记函数起点的内存单元,执行函数代码(也许也需将返回值放入到寄存器中),然后跳回到地址被保存的指令处(这与阅读文章时停下来看脚注,并在阅读完脚注后返回到以前阅读的地方类似)。来回跳跃并记录跳跃位置意味着以前使用函数时,需要一定的开销。
这便是函数的底层实现机制,但这往往会有比较大的开销,使用内联函数可以抵消这种开销,但是却耗费了内存,对于一些不需要执行很多代码的函数而言,就可以使用这种内联函数机制。
内联函数需要采取下述措施:
- 在函数声明前加上关键字
inline
- 在函数定义前加上关键字
inline
通常可以省略函数原理,将整个定义放在函数原型的位置。例如
#include <iostream>
using namespace std;
inline double square(double x) {
return x * x }
int main(){
/*code*/}
需要注意的是,程序员请求将函数作为内联函数时,编译器并不一定会满足这种要求。它可能认为该函数多大或注意到函数调用了自己(内联函数不能递归),因此不将其作为内联函数;而有些编译器没有启用或实现这种特性。
内联函数的前身其实是C语言的宏,宏和内联函数不一样,内联函数是使用按值传递的,但是宏是通过文本替代来实现的,如以下示例
#define SQUARE(X) X*X
a = SQUARE(5.0); // is replaced by a = 5.0 * 5.0
b = SQUARE(4.5 + 7.5); // is replaced by b = 4.5 + 7.5 * 4.5 + 7.5
c = SQUARE(C++); // is replaced by c = C++ * C++
显然,要实现平方的功能,上述代码只有第一个可以正常运行。对于第二种的改进如下
#define SQUARE(X) ((X) * (X))
引用变量
前面提到的函数指针的概念,如果不适用自动类型判断,会出现一些很麻烦的情况,这使得我们在编写代码的时候会花费很多的时间在理解逻辑上面。C++新增了一种复合类型——引用变量,引用是已定义的变量的别名(另一个名称)。通过引用,我们就可以使用原始数据了,而不用想形参一样按值传递,这样耗费了时间和内存,对于一些结构体和对象而言,是十分不友好的,因为结构和对象一般会花费较大的内存,这也隐隐透露着引用好像就是为结构和对象量身打造的一样。让我们来看看引用是如何实现的吧,并且指出引用和指针的区别。
那么引用时如何实现的呢,使用&
符号,这里的&
并不是取地址符,而是类型标识符的一部分。如下代码,这里需要注意的是,在声明引用变量的时候,必须要有初始化,就好像这个引用一定要有一个人来为你取名一样,不然就相当于没有上户口的孤儿一样,C++不允许这样的存在。例如注释处的代码不能够实现,这一点不能像指针一样进行赋值操作。
int rats;
int & rodents = rats;
// int & rodents; rodents = rats
引用更接近const
指针,因为引用必须在声明的时候初始化,也就是一旦引用变量出生,就一定要有名字。指针的写法如下。
int * const pr = &rats;
引用最常用也是最典范的用法就是引用传递,利用引用的特点,函数就可以使用主函数中的原始数据了。例如
void swap(int & a, int & b)
{
int temp;
temp = a;
a = b;
b = temp;
}
但是引用传递和按值传递还是有一些区别的,我们说过,引用变量是一个变量的别名,我们知道,变量和表达式是不一样的,一个是可修改的左值,一个是右值,按值传递是可以传递表达式的,只要表达式的类型是正确的或者可以强制转换。例如
double cube(double a)
{
a *= a * a;
return a;
}
double refcube(double & ra)
{
ra *= ra * ra;
return ra;
}
int x = 10;
cube(x + 10);
cube(x);
cube(10);
refcube(x + 10);
refcube(x);
refcube(10);
你会发现,使用引用传递的函数是不合法的,对于现在的C++是错误的,大多数编译器会指出这一点,较老的编译器会发出警告。
早期的C++确实可以采取这样温和的反应,这取决于临时变量的功劳,当实参与引用参数不匹配的时候,C++将会生成临时变量。对于现在,只有const
引用时,C++才允许这样做。 我们还是规矩点,不这样做了吧。
void display(const free_throws & ft)
{
using namespace std;
...
}
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 & accumulate(free_throws & target, const free_throws & source)
{
...
return target;
}
accumulate(dup, five) = four;
观察上述代码(完整代码请看原文),其中最值得讲解的就是accumulate(dup, five) = four;
,accumulate
这个函数有两个参数,两个参数都是引用变量,其中一个是const
类型的,并且函数传回了引用的类型。这个等式其实等同于下述代码
accumulate(dup, five);
dup = four;
我们还是再来讨论这个等式吧。你会注意到等式的左值是一个函数,一般的按值返回的函数是不可以将函数放于左值的,因为赋值语句要求等式的左边是可修改的左值,那么为什么accumulate
这个函数却可以呢,因为它返回的是引用,引用即使一个变量的别名,相当于一个变量,也就是说是可以修改的,所以可以使用上述的等式进行赋值。
accumulate
这个函数写的没有一点问题,但是我们写的时候,可以随便使用吗?假如我们写出了下面的代码
double & clone(const & a)
{
double b = a;
return b;
}
这样的代码是否可行呢,答案是不可行,因为b
变量仅仅是clone
函数中的局部变量,在函数返回的时候属于临时变量,随时会被编译器销毁,我们不能返回一个即将被销毁的临时变量。如何解决呢,使用指针便可以,例如
const double & clone(const & a)
{
double *b = &a;
return *b;
}
double & clone(const & a)
{
double *b = &a;
return *b;
}
由于引用其实是主函数中的变量,而指针又是指向了主函数中的变量,但会这个变量,其实就是返回主函数中的变量。上面的代码给出了两个函数,这两个函数都是可行的,但是我们本着尽量使用const
的原则,返回的值是不可修改的,但这也要看具体语境的。就比如accumulate
函数,因为它的返回值允许被修改。
将类对象传递给函数,C++通常的做法就是使用引用。下面是一些string
类的例子
string version1(const string & s1, const string & s2)
{
string temp;
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)
{
string temp;
temp = s2 + s1 + s2;
return temp;
}
result = version1(input, "***");
result = version2(input, "###");
result = version3(input, "@@@");
这里值得一提的是,我们知道,C-风格字符串的类型为const char *
类型,上述三个version
函数的第二个参数我们都是以字符串字面值输入的,也就是const char *
类型的,但是函数原型是string
类的输入类型,为什么可以这样呢?这是C++允许的,string
·类定义了一种char *
到string
的转换功能,所以其实是可以的。
我们知道ostream
和ofstream
是一种对象,对象也是可以使用引用,实际上最好使用引用传递。ofstream
是继承与ostream
的,具体继承的内容今后会讲述。用法请看原文程序清单。
那么什么时候应该使用引用参数呢?下面列出了很多
首先使用引用参数的主要原因有两个
- 程序员能够修改调用函数中的数据对象
- 通过传递引用而不是整个数据对象,可以提高程序的运行速度
当数据很大时,比如结构体和对象等,使用引用参数可以提高程序的运行速度。
对于使用传递的值而不作修改的函数
- 如果数据对象很小,如内置数据类型或小型结构,则按值传递
- 如果数据对象是数组,则使用指针,因为这是唯一的选择,并将指针声明为指向
const
的指针 - 如果数据对象是较大的结构,则使用
const
指针或const
引用,以提高程序的效率。这样可以节省复制结构所需的时间和空间 - 如果数据对象是类对象,则使用
const
引用。类设计的语义常常要求使用引用,这是C++新增这项特性的主要原因。因此,传递类对象的标准方式是按引用传递
对于修改调用函数中数据的函数
- 如果数据对象是内置数据类型,则使用指针。如果看到诸如
fixit(&x)
这样的代码(其中x
是int
),则很明显,该函数将修改x
。 - 如果数据对象是数组,则只能使用指针
- 如果数据对象是结构,则使用引用或指针
- 如果数据对象是类对象,则使用引用。
默认参数
默认参数是C++的一个新内容,我们可以通过函数原型实现默认参数,例如
char * left(const char * str, int n = 1);
left("LinJia", 10);
left("LinJia");
但是对于带参数列表的函数,必须从右向左添加默认值,也就是说,要为某个参数设置默认值,则必须为它右边的所有参数提供默认值。请看
int fun(int a, int b = 1, int c = 2); //VALID
int fun(int a, int b = 1, int c); //INVALID
int fun(int a = 1, int b = 2, int c = 3); //VALID
而调用的时候允许提供1个、2个、或3个参数:
beeps = fun(4);
beeps = fun(4, 5);
beeps = fun(4, 5, 6);
这些参数必需按从左向右的顺序依次被赋值给相应的形参,而不能跳过任何参数,例如beeps = fun(3, , 8)
。
函数重载
函数重载又为函数多态,重载可以让你能够使用多个同名的函数,这有什么好处呢,就好比一个单词有好几种意思,每种意思都有独特的用法一样,函数重载就能够这样处理,能够接受不同类型的参数,并对不同的参数实施不同的运算,并且返回值也能够重载。例如
void print(const char * str, int width);
void print(double d, int width);
void print(long l, int width);
void print(int i, int width);
void print(const char * str);
还有一种编译器会判断不出是什么类型,因为double
和double &
传入一个变量的时候都可以使用,所以为了避免这种混乱,编译器在检查函数特征标时,将把类型引用和类型本身是为同一种特征标。
double cube(double x);
double cube(double & x);
还有一点,对于非const
值是可以赋值给const
值的,但是反之是违法的。例如
void dribble(char *bits);
void dribble(const char *cbits);
void dabble(char *bits);
void drivel(const char *bits);
const char p1[20] = "How's the weather?";
char p2[20] = "How's business?";
dribble(p1);
dribble(p2);
dabbale(p1); // not match
dabbale(p2);
drivel(p1);
drivel(p2);
可以看到有一句是不能匹配的,这也就是为什么对于非const
值是可以赋值给const
值的,但是反之是违法的。
对于特征标才是让函数能够重载的标志,例如
long fun(int n);
double fun(int n);
对于函数重载来说,返回类型不一样是允许的,但是上述代码为什么就是不行呢,可以看到,参数列表中只有一个int
类型的变量,但是两个函数都一样,这样就不行,因为特征标是括号里面的参数,不能够完全一样,下面的代码就是OK的
long fun(int n);
double fun(double n);
请看原文的重载引用参数
void fun(double && r);
&&
符号表示的是右值参数输入。
函数模板
函数模板也是C++新增的一个特性。使得我们可以使用泛型定义来定义函数。所以有时候也被称为通用编程。下面建立了一个函数模板
template <typename AnyType>
void swap(AnyType & a, AnyType & b)
{
AnyType temp;
temp = a;
a = b;
b = temp;
}
这样就定义个函数模板。
函数模板并不能够缩短可执行程序,也就是说,如果我们在主函数中以不同类型的变量调用了swap
函数,最终编译器会编译成两个独立的函数定义,函数模板的出现只是将我们在编程的过程通用化,而对编译器而言还是一样的。
函数模板也能够重载,例如
template <typename T>
void swap(T & a, T & b){
}
void swap(T *a, T *b){
}
通用编程也带来了一定的局限性,比如说赋值操作,当你定义了一个函数模板,如果程序员不提示输入,有些用户不清楚需要传入的数据类型,若传入的是一个数组,我们知道数组是不能够使用赋值操作的,所以这将导致错误。另一个例子是判断式,比如a > b
,如果两个变量是结构体,这样是能够进行比较的。
1. 隐式实例化
当我们写好了一个函数模板,假如之前所写的交换函数swap()
,当在主函数中调用了swap(i, j)
,而i
,j
这两个变量都是int
类型的,那么编译器就会根据函数模板生成一个int
的swap()
的函数定义,这样就成之为隐式实例化。
2. 显式实例化
template void swap<int>(int, int);
这样的就是显示实例化,能过够指定编译器使用指向的函数定义。这样的实例化是使用函数模板生成一个使用int
类型的实例。就像隐式实例化一样。
3. 显式具体化
template <> void swap<int>(int, int);
template <> void swap(int, int);
这样的声明的意思是:“不要使用swap()
模板来生成函数定义。而应使用专门为int
类型显式地定义的函数定义”,也就是说显式具体化必须要有自己的函数定义
编译器如何选择函数版本
请看原文
函数模板中遇到的问题
是什么类型
template<typename T1, typename T2>
void fun(T1 x, T2, y)
{
...
?type? xpy = x + y;
...
}
这样一来,xpy
的类型我们不从而知,这样会出现很多麻烦。
关键字decltype
(C++)
template<typename T1, typename T2>
void fun(T1 x, T2, y)
{
...
decltype(xpy) = x + y;
...
}
这样就能够判断类型了。
另一种函数声明语法(C++11 后置返回类型)
就像之前的问题一样,利用return
语法来返回数值,对于返回的类型我们也不从而知,后置返回类型是另一种函数声明语法,如下
template<typename T1, typename T2>
auto fun(T1 x, T2, y)->decltype(x + y)
{
return x + y;
}