C++基础——函数
文章目录
前言
后面函数模板之后的内容可能写的比较简略,也可能有些复杂,可以选择部分浏览,因为这部分在平时编程一般是用不到的,当开发比较系统的项目的时候这部分的优势才可能显现出来。
函数原型和函数定义
函数原型不需要提供变量名,即便提供了,定义中的变量名也可以与之不同,但是类型必须相同。
函数原型主要是对类型的规定,变量名只是占位符,没有实际意义。
函数原型不是必须的,但是当一个项目需要众多函数时就非常有用了。一般函数原型都放在头文件中。
函数和数组
当我们将一个数组传递给函数时,可以不需要指定数组长度,因为此时传递的是数组的地址,这也意味着,在函数中对形参。数组中内容的修改会直接改变实参。
也正是这个原因,在函数中无法通过sizeof
来获取正确的数组长度,因为函数中的形参实际上是指针。
指针在函数传递中的作用非常大,理解起来也比较困难。
指针本身也是一个变量,它指向一个地址,当我们在函数中对地址中的内容进行更改时会影响实参指向的地址的内容,但是直接对指针进行更改不会影响实参指针。
指针和const
指针本身有双重身份(姑且这么说),因此const
可以有两种用法:一种规定指针为const
,另一种规定指针指向的内容为const
。
指针指向一个常量对象
int age = 39;
const int *pt = &age;
非常巧妙的事情是,当我们使用指针指向一个常量对象时,这个对象本身并不是常量,可能我说的并不是很清楚。可以这样理解,我们可以通过age
来改变值,但是不能通过指针pt
来改变值,这个特性能够保护一个变量不能被指针改变,这在函数中是非常常用的。
换言之,当我们使用这种定义时,函数既能够处理const
实参,也能处理非const
实参,否则只能处理非const
实参。
这种方式只能有一层间接关系,如果是指向指针的指针,则不能使用const
。
定义一个常量指针
int age = 39;
int * const pt = &age;
这种方式pt
可以修改所指向的值,但本身不能够改变,即不能修改pt
指向其他值。
二者可以连用:
const int * const pt = &age;
二维数组
当我们使用二维数组时,不能省略二维数组的第二维,比如:
int data[3][4];
函数应声明为:
int sum(int arr[][4], int n);
即需要规定有多少列,因为我们的二维数组的一维实际上是指向数组的指针数组。
函数和结构体
结构体实际上也是个变量,和基本变量的参数传递一样。
当结构体较大时传递引用或地址更好,这种方式可能会导致在函数中对结构体内容进行修改,如果不想影响实参可以添加const
限定。
函数指针
函数指针通常用于将某个函数传递给另一个函数,以实现函数的不同调用。
函数和数据一样,在内存中也有地址,我们首先要获取函数地址才能进一步通过函数指针进行调用。
在C++中,函数名实际上就是一个地址,和数组名类似。
声明函数指针
我们的指针必须指明函数返回类型和函数的参数列表。
double pam(int);
对于上面的函数,我们的指针应声明为:
double (*pf)(int);
pf = pam;
如果不将*pf
括起来,就会变成声明函数:double * pf(int);
。
之后我们可以使用(*pf)(a)
来调用函数pam
,也可以通过pf(a)
来调用,前者能更明显看出正在使用函数指针。
函数指针数组
如果想要声明函数指针数组,应这样声明:
const double * (*pa[3])(const double *, int) = {f1, f2, f3};
*
的优先级低于[]
,因此*pa[3]
表示这是一个包含三个指针的数组,而括号外的均为函数指针的定义。
而对于这种(*pa)[3]
则表示指向数组的指针。
C++内联函数
内联函数的作用是为了节省函数调用所需要的时间。
普通函数调用时会跳转到函数入口地址继续执行代码,执行完成后跳回原位置,而内联函数则是将函数实现的代码直接替换到函数调用的位置。
当函数内容比较精简或调用次数较少时,内联函数的作用就凸显出来了。
通常做法是内联函数可以省略函数声明,直接在头文件中写出函数定义。对于内联函数,一定要在函数声明之前添加inline
关键字,且内联函数不能递归。
inline double square(double x){return x * x;}
C语言中可以使用宏来完成类似于内联函数的功能,但是实际上使用宏可能出现一些意想不到的错误,建议是尽量使用内联函数而不是宏。
引用变量
引用变量是C++的特性之一。
引用变量像是一个const
指针,只能在声明时赋值,且之后不能改变,始终作为赋值变量的别名。
但是其实引用变量更多用在函数参数中,如果我们想要在函数中改变实参,以前的做法是传入指针作为参数,但是指针用起来非常不方便,现在使用引用变量就可以达到这个效果且相当方便,就好像是使用原来的变量(实参)一样简单。
使用引用变量作为参数时,如果传入的参数类型不匹配,或者传入右值(不可通过地址访问,比如表达式),则会生成临时变量,让参数引用该临时变量。此时函数调用的效果就和按值传递的效果一样,不会影响实参(也不能影响)。
在C++中还引入了右值引用,通过&&
来标识:
int && a;
关于右值引用后面会解释,这里了解即可。
引用变量用于结构体
引用最初设计出来就是为了用于更加复杂的类型:结构体和类。
通过引用可以节省函数调用的时间,如果不想修改实参,可以使用const
限定。
返回引用
普通的函数返回会将返回值复制到临时位置,然后将临时位置的值复制到被调用位置。
而使用引用则简化了这一步骤,节省了返回时间。
但是要注意返回引用时应当返回函数结束后不会被销毁的数据,比如不能返回在函数作用域中创建的数据,因为这些数据在函数终止后就会被销毁。
一般返回引用都是返回传递给函数的参数的引用,或者返回通过new
创建的变量的引用。
将const用于引用返回类型
int & accumulate(int &target, int &source){
...
return target;
}
accumulate(dup, five) = four;
上面这个表达式先对five
和dup
进行某些操作,然后返回target
的引用,由于引用标识的是一个内存块,是一个可修改的左值,因此可以进行上面的这个操作。
当我们想要使用引用返回值,但是不想出现上面的这种操作,就可以使用const
限定,将其返回值变为不可修改的左值。
const int & accumulate(int &target, int &source){
...
return target;
}
通过使用const
,可以减少模糊性,编写更加易读的代码。
默认参数
默认参数也称为缺省参数,主要用于设定默认值,可以减少参数的传入。
比如:
int func(int a, int b = 3, int c = 4);
我们可以这样调用该函数:
int res;
res = func(1);
res = func(1, 2);
res = func(1, 2, 3);
当我们只提供一个参数时,第二个和第三个参数都会自动赋值为默认值。
需要注意的是缺省参数必须从右向左添加默认值,中间不允许跳过,即所有的默认值必须都在参数列表的右边。
下面的这个声明是不合法的:
int func(int a, int b = 3, int c);
当我们传入参数时,必须从左到右依次传入,中间不允许跳过任何参数。比如上面的第二个例子,2
将赋值给参数b
而不是参数c
。
注意在函数声明指定默认参数之后,在函数定义(实现)时参数列表就不要再写默认参数了,否则会报错。
函数重载
函数多态是C++新增的功能。
函数重载使得我们能够使用多个同名函数。
函数重载的区分就是看参数列表是否不同,也称为函数特征标。如果两个函数的参数列表(类型)不同,则为不同的函数,如果参数列表相同,即便返回值不同,也会视为相同函数,编译器会报错。
注意编译器再检查函数特征标时,把类型引用和类型本身视为同一个特征标。
假如我们有下面几个函数重载:
void func(double &a);
void func(const double &b);
void func(double &&c);
当我们的参数为左值引用时,调用第一个函数;当我们的参数为const
左值引用时,调用第二个函数;当我们的函数为右值引用时,调用第三个函数。
但是可以看到第二个函数包括了第一个和第三个函数的情况,当我们调用时,编译器会选择最匹配的函数进行调用。
函数模板
看这样一种情况:
void Swap(int &a, int &b){
int c = a;
a = b;
b = c;
}
我们可以通过这个函数交换两个变量值,但是如果我们想要交换两个double
变量的值,则需要重载函数Swap
:
void Swap(double &a, double &b){
double c = a;
a = b;
b = c;
}
如果我们还希望交换long
变量,则还需要重载。
可见这种方法非常笨拙,C++针对这种情况提出了新的概念:函数模板。
通过函数模板,我们可以通过泛型来定义函数,根据传入参数的类型不同转换为具体的类型。
template <typename AnyType>
void func(AnyType &a, AnyType &b);
在更早的C++中,使用的关键字不是typename
而是class
,二者的效果是等价的,为了向后兼容,最好使用typename
。
这里的AnyType
就是泛型的名称,可以任意取,通常就是T
。
要注意,函数模板并不创建任何函数,只是告诉编译器如何定义函数,函数模板之后必须紧跟需要使用模板的函数,函数声明和函数定义之前都需要有相同的函数模板,但泛型名称可以不同,比如:
template <typename T>
void Swap(T &a, T &b);
int main(){
int a, b;
a = 1;
b = 2;
Swap(a, b);
cout << a << b;
return 0;
}
template <typename P>
void Swap(P &a, P &b){
P c = a;
a = b;
b = c;
}
这样是可以的。
但是不能对同一个函数使用两个模板:
template <typename T>
template <typename P>
void Swap(T &a, P &b);
这样是错误的。
当我们想要灵活带入两个模板或多个模板时,可以采用这种方式:
template <typename T1, typename T2, typename T3, ...>
void Swap(T1 *a, T2 &b, T3 c, ...);
带有函数模板的函数重载基本上和普通函数重载没有区别,只是要注意函数模板的定义必须在函数之前::
template <typename T>
void Swap(T &a, T &b);
template <typename T>
void Swap(T &a, T &b, int c);
模板函数的作用还是有局限性的,比如上面的Swap
就不能对数组进行这种操作,因为数组不能进行=
运算。
显式具体化
显式具体化可以理解为当我们使用函数模板时,对于某些特殊类型比如结构体,我们想要进行一些更为特殊的操作,我们就可以显式具体指定这个类型并对其进行相应的操作。
如果程序中既有非模板函数,也有具体化模板函数,也有常规模板函数,则调用顺序为非模板函数→具体化模板函数→常规模板函数。
例如:
void Swap(job &a, job &b);
template <typename T>
void Swap(T &a, T &b);
template <> void Swap<job>(job &a, job &b);
这里显式具体化也可以写成:
template <> void Swap(job &a, job &b);
实例和具体化
在代码中的函数模板并不是真正的函数定义,只是一个用于生成函数定义的方案。
当编译器为特定类型生成函数定义时,得到的是函数实例,比如前面的调用中,传入int a
和int b
,此时会生成一个Swap
的实例。
模板并不是函数定义,使用int
的模板实例是函数定义。这种实例化方式称为隐式实例化。
后来C++又推出了显式实例化(要与显式具体化区分),比如
template void Swap<int>(int &a, int &b);
的含义是使用Swap
模板生成一个使用int
类型的实例,这个实例已经不是生成方案而是一个正式的定义了,可以理解为:使用Swap()
模板生成int
类型的函数定义。
也就是说,当我们进行显式实例化的时候,实际上就相当于定义了一个常规函数,只不过这个常规函数的定义方式比较特殊。
而显式具体化的含义为:不要使用Swap()
模板来生成函数定义,而应该使用指明为int
类型的模板函数进行定义。
显式具体化没有实例化,比如上面的例子,只是告诉了编译器,当我们遇到job
类型时优先选用template <> void Swap<job>(job &a, job &b);
模板来进行隐式实例化。
注意比较显式实例化和显式具体化之间的细微差别。
如果要在同一个文件中使用同一种类型的显式实例化和显式具体化将会报错。
隐式实例化,显式实例化和显式具体化统称为具体化,相同之处在于他们都是表示使用具体类型的函数定义。
另外注意一种特殊实例化方式:
template <typename T>
T add(T a, T b){
return a + b;
}
...
int m = 6;
double x = 10.2;
cout << add<double>(a, b) << endl;
此时我们想传入两个不同类型的变量,而模板规定的类型是一样的,此时通过显式实例化将使用double
进行实例化。
但是对于我们前面的Swap
,这种方法将会失效,因为无法将double &
指向int
。
编译器选择策略
编译器需要从众多重载函数中选择最佳的函数进行匹配,这就涉及到选择策略问题,这里我们简单讨论一下比较平常的情况。
通常,从最佳到最差的顺序如下:
- 完全匹配,常规函数优先于模板。
- 提升转换,在不同长度的相同类型之间进行转换,由长度短的类型转换为长度长的类型,比如
char
和short
(传入的类型)转换为int
(特征标),float
转换为double
。 - 标准转换,在不同类型之间或由长度长的类型转换为长度短的类型,比如
int
转换为char
,long
转换为double
。 - 用户定义的转换,比如类声明中定义的转换(这一块可以先不管)。
而完全匹配也有很多种,比如:
void recycle(int); // #1
void recycle(const int); // #2
void recycle(int &); // #3
void recycle(const int &); // #4
此时如果调用:
int a;
recycle(a);
需要从众多重载函数中选择最佳匹配。
通常会选择类型最匹配的,比如指向非const
数据的指针和引用优先与非const
指针和引用参数匹配,但是const
和非const
的区别只适用于指针和引用,如果我们只有#1
和#2
,则会出现二义性错误。
另一种情况,就是模板和非模板的完全匹配,根据优先原则,遵循非模板函数→显式具体化(或显式实例化)模板函数→常规模板函数,注意显式实例化和显式具体化不能同时出现。
如果都是模板函数,则更具体的模板函数优先。但是这里的更具体并不意味着一定要具体化,而是指需要进行更少的类型转换,比如:
template <class T> void recycle(T t); // #1
template <class T> void recycle(T *t); // #2
struct Exam{
int a;
char b;
};
这样进行调用:
Exam exam = {25, 'A'};
recycle(&exam);
此时应该选择#1
转换为Exam *
,还是选择#2
转换为Exam
呢。很显然后者进行的转换更少,所以选择#2
。
自定义选择
一些情况下,我们可以指定选择哪些函数执行。
比如,对于前面的Swap
例子:
Swap<>(a, b);
将会优先选择模板函数而不是非模板函数。
模板函数的发展
我们前面提到可以有多个模板泛型,但是此时会存在一个问题:
template<calss T1, class T2>
void func(T1 x, T2 y){
...
?type? xpy = x + y;
...
}
我们无法确定xpy
应该采用什么类型。
为此,C++11新增关键字decltype
用于类型的确定。
比如:
int x;
decltype(x) y;
上面代码的意思为将y
定义为与x
相同的类型。
decltype
的参数可以是表达式:
decltype(x + y) xpy;
xpy = x + y;
或者:
decltype(x + y) xpy = x + y;
可以简单将decltype
看成一个类型,其他地方都和普通变量定义一样。
甚至decltype
内部还可以是一个函数调用,此时确定的类型为与函数返回值类型相同。
这种情况下decltype
并不会真正调用函数,而是查看函数原型来获得返回值类型。
C++11后置返回类型
template<calss T1, class T2>
?type? func(T1 x, T2 y){
...
return x + y;
}
此时不能通过使用decltype(x + y) func(T1 x, T2 y)
来解决,因为此时还没有声明x
和y
,编译器无法看到也无法使用它们,必须在声明参数之后使用decltype
,比如:
double h(int x, float y);
可以使用后置返回类型写成:
auto h(int x, float y) -> double
这样就可以解决上面的问题:
template<calss T1, class T2>
auto func(T1 x, T2 y) -> decltype(x + y)
{
...
return x + y;
}