这是C++类重新复习学习笔记的第 四 篇,同专题的其他文章可以移步:https://www.jianshu.com/nb/39156122
函数基础
函数的定义
typeName functionName(parameterList)
{
statement;
return value;
}
函数有多个参数,使用逗号间隔。
函数在执行完第一个 return
语句后结束,如果返回值是void
,可以直接使用 return
; 或者不写返回语句。
函数原型
函数原型描述了函数到编译器的接口,需要写在main函数前边,通常放在头文件引用后。函数原型需要说明函数的返回类型、函数名、函数的参数类型,不需要写变量名,以分号结尾。
函数与一些数据类型
数组
函数是使用指针来处理数组的。已知C++中将数组名解释为该数组中第一个元素的地址,即 arrayName = &arrayName[0]
,在函数中,当将数组作为函数的参数传递时,实际上传递的是数组的第一个元素的地址,即在调用函数时,传入的参数是数组名。
以数组为参数的函数声明:
int arrayFunction(int arr [], int size);
调用该函数时,只将数组的名字作为参数传入(即传入的是指向数组首元素的指针)即可:
int myArray[5]={1,2,3,4,5};
int result = arrayFunction(myArray,5);
所以实际上传入的类型应该是一个int
的指针,也就是说函数其实是这样的:
int arrayFunction(int * arr, int size);
可以看到,我们使用 int arr []
替换了 int *arr
。既然这两个函数头都是正确的,那么就证明了一个问题:在C++中,当(且仅当)用于函数头或函数原型中,int* arr
和 int arr[]
的含义才是相同的,它们都表示arr
是一个int
指针。然而,数组表示法(int ar[]
)提醒用户,arr
不仅指向int
,还指向int
数组的第一个int
值。
但是!在其他的上下文中,int*ar
和 int arr[]
的含义并不相同。
数组名与指针对应是一件好事。将数组地址作为参数可以节省复制整个数组所需的时间和内存。如果数组很大,则使用拷贝的系统开销将非常大,程序不仅需要更多的计算机内存,还需要花费时间来复制大块的数据。但是另一方面,使用原始数据增加了破坏数据的风险,所以要尽可能的使用const限定词来保护数组。
使用const来保护数组
由于数组名与指针对应,所以数组是按照引用传递的,不是按值传递,因此保护传入的数组不被函数修改破坏非常重要,因此如果函数内部没有修改传入的数组的理由(例如便利打印数组),则最好用const
来限定参数,从而起到保护数组的作用。
通常分为如下两种声明方法:
void functionModify(int arr[], int size);
void functionNoChange(const int arr[], int size);
使用数组区间的函数
传统的传递数组到函数中的方式为:传递一个数组名称作为指向第一个元素的指针,再传递一个整数告知函数数组的大小。
还有一种方式是通过传递两个指针来告知函数要处理的元素区间(range),一个指针标识数组的开头,另一个指针标识数组的结尾。例如下边这样:
int sumArray(const int * begin, const int * end)
{
const int * pt;
int sum=0;
for(pt=begin;pt!=end;pt++)
sum+=*pt;
return sum
}
int arrayName[arraySize]={0,1,2,3,4};
int sum = sumArray(arrayName,arrayName+arraySize);
指针、数组与函数的参数传递
我们知道,函数对于传递数组作为参数,根据函数是否有权限改变数组的值,通常有两种声明方式:
void functionModify(int arr[], int size); // 1号函数
void functionNoChange(const int arr[], int size); // 2号函数
此时,如果我们有两个数组:
cosnt int array1 = {1,2,3}; // 1号数组
int array2 = {4,5,6}; // 2号数组
很明显,一个是常量数组,一个是变量数组。两种数组对于两个函数的关系是这样的:
- 禁止将常量数组的地址赋给非常量指针,即:1号数组不能传递给2号函数,只能传递给1号函数
- 可以将非常量数组赋给常量或非常量指针,即:2号数组可以被传递给1和2号函数
因此,在设计函数时,要尽可能地使用const,因为将指针参数声明为指向常量数据的指针可以:
- 避免由于无意间修改数据而导致的编程错误
- 使用const使得函数能够处理const和非const实参,否则将只能接受非const数据
所以,如果条件允许,则应将指针形参声明为指向const
的指针
二维数组
编写以二维数组作为参数的方法如下:首先,数组名称被视为其地址,而且数组名可以认为是指向第一层元素的指针(第一层的元素是数组(第二层))。
例如下边的这个数组:
int data[3][4] = { {1,2,3,4}, {5,6,7,8}, {9,10,11,12}};
可以这样声明一个函数:
int sum (int (*arr)[4], int size);
或者这样声明:
int sum (int arr[][4], int size);
然后这样使用它:
int result = sum(data,3);
可以看到,传入的参数是数组名,即表示大数组data中第一个元素的引用,而这个元素又是一个4维的数组,所以其实传入的第一个参数是一个指向由4个int组成的数组的指针。而且,第一种声明方式中,必须将 *arr
用括号括起来,否则 int *arr[4]
将表示的是一个由4个指向int的指针组成的数组,然而函数的参数不可能是数组。以上两种声明方式都表明 arr 是指针而不是数组,只是在声明时需要表明它是一个由4个int组成的数组罢了,这也就意味着已经将数组的列数传入函数,所以只需要单独传入一个行数作为参数即可。
在函数中,可以直接将 arr 看成数组名,然后像这样使用它:
result+=arr[i][j];
显然,这里存在如下的一个关系:
arr[i][j] == *(*(arr+i)+j)
实际上,是这样推演过来的:
arr // 指向第一行的由4个int组成的数组的指针
arr+i // 指向低i行的元素的指针(这个元素是一个4元int数组)
*(arr+i) // 第i行的元素,也就是第i行数组,即第i行的指向这个数组第一个元素的指针,等同于 arr[i]
*(arr+i)+j // 指向第i行第j个元素的指针,等同于 arr[i]+j
*(*arr+i)+j) // 第i行第j列的元素值,等同于 arr[i][j]
C风格字符串
C风格字符串只一系列字符,以空值结尾的字符串。
作为参数
将C风格的字符串作为参数传递给函数,有三种表示字符串的方式:
- char数组
- 用引号括起来的字符串常量(字符串字面值)
- 被设置为字符串的地址的char指针
但其实这三种方式都是传递了一个char指针(char*
),由于C风格的字符串与一般的char数组的最主要区别是,C风格字符串自带了结束字符(\0),所以不需再传入一个数组的长度作为参数。
变量的声明与传入函数:
char myChar[6] = "hello";
char * ch = "hello";
charFunction(myChar); // myChar 是hello的第一个元素(h)的地址
charFunction(ch); // ch是指向char(h)的一个指针
charFunction("hello"); // 直接传入字符串的地址
函数原型:
charFunction(char * str);
charFunction(char str[]);
函数中对参数的使用:
charFunction(const char * str)
{
while(*str)
{
cout<<*str;
str++
}
}
作为返回值
函数无法直接返回一个字符串,但是可以返回字符串的地址,比如可以这样声明一个返回值为字符串的函数:
char * buildChar(char c, int n)
{
char * pstr = new char[n+1];
pstr[n]='\0';
while(n-->0)
pstr[n]=c;
return pstr;
}
结构体
传递结构的值
当结构体较小时,按值传递结构比较合理,且安置传递可以将结构体看成和int、double等一样的普通单值。
struct myStruct
{
int myInt;
double myDouble;
}
myStruct sum(myStruct str1, myStruct str2)
{
myStruct result;
result.myInt = str1.myInt + str2.myInt;
result.myDouble = str1.myDouble + str2.myDouble;
return result;
}
传递结构的地址
当结构体较大时,传递结构体的地址更为合理,可以节省时间与空间,此时需要将参数由结构体变成指向它的指针,并且访问结构体内的元素时需要使用 ->
代替点:
void printStruct(const myStruct * str)
{
cout<<"myInt = "<<str->myInt;
cout<<"myDouble = "<<str->myDouble;
}
string对象
由于string是对象,对象是可以赋值的,所以和结构体类似,可以直接将string的实体按值传递给函数,也可以直接从函数返回,如果需要多个string,可以使用string的数组而不需要一个char的二维数组。
void pringStrings(const string str[], int n)
{
for(int i = 0; i<n; ++i)
cout<< i+1 << " : "<< str[i]<<endl;
}
array对象
array对象也是对象,所以依然有两种传递给函数的方式:按值传递和按引用传递(传递地址):
void printArray(array<int,3> arr)
{
for(int i = 0; i<3; i++)
{
cout<<arr[i]<<endl;
}
}
void printArray(array<int,3> * arr)
{
for(int i = 0; i<3;i++)
{
cout<<(*arr)[i]<<endl;
}
}
递归
递归就是函数自己调用自己。在递归的过程中,需要设置终止条件,否则将无限循环下去,例如下边的test为false时将会断开递归。
void recurs(argumentlist)
{
statements1;
if(test)
recurs(arguments)
statements2;
}
递归对于函数的调用是基于栈这种数据结构的,即后进先出,例如三次递归上边的函数,会首先有三次statements1,然后发现终止条件满足后再倒叙执行三次statements2,形成一个栈的后进先出的结果。
函数递归一般会在时间上对于程序有所缩短,但是占用内存的开销却一般较大,因为所以未完成递归的函数都需要存储在内存中,直到整个递归结束才会释放。
函数指针
函数也有地址,指向函数的存储地址的数据类型为函数指针,在存储机器语言代码的内存块中,函数指针指向这个函数的内存块的开始地址。利用函数指针,我们可以使用一个函数找到另一个函数,亦或者让函数成为另一个函数的变量。那为什么不直接使用函数调用呢?因为函数调用的代码是写好的,只能调用那一个固定的函数,但是函数指针如果作为一个变量来使用的话,可以传入不同的值,即可以选择使用不同的函数。
获取函数指针
函数名(不带参数)即为指向该函数的指针,若将函数作为参数传递,必须传递函数名(函数指针):
invoke(myFunction); // 传入的是函数指针,即函数
invoke(myFunction()); // 传入的是函数的返回值
声明函数指针
声明函数指针时必须指定指针指向的函数类型,即说明函数的返回类型与参数列表,和函数原型的声明方式非常类似。
int myFunction(double); // 函数原型
int (*myFunctionPoint)(double); // myFunction是函数指针
这里将 (*myFunctionPoint)
替换了 myFunction
,从而 myFunction
是函数名, myFunctionPoint
是函数指针
注意区分如下的两种情况:
int (*myFunctionPoint)(double); // myFunction是函数指针
int * myFunction(double); // 返回一个指向int的指针的函数
在正确的声明函数指针后,要通过赋值使得函数指针指向函数,由于上文提到函数名(不带参数)即是函数的地址,从而赋值的方式就很简单(赋值必须保证返回类型和参数列表均相同):
myFunctionPoint = myFunction; // 左侧为函数指针,右侧为函数的地址
使用指针调用函数
已知,(*myFunctionPoint)
和myFunction
扮演的角色相同,所以可以使用任何一种形式来调用这个函数:
int result = myFunction(1.2); // 使用函数名调用函数
int result = (*myFunctionPoint)(5.6); // 使用函数指针调用函数
int result = myFunctionPoint(3.7); // 实际上这也是一种可行的方式
函数指针数组
函数指针的神奇之处在于它可以保存成数组类型。假设我们有三个返回类型和参数列表均相同但是作用却不同的函数:
double f1(int);
double f2(int);
double f3(int);
我们可以使用一个指向这种函数类型的函数指针的数组来存储这三个函数的指针:
double (*fp[3]) (int) = {f1,f2,f3};
现在说明一下数组索引3的位置:fp是一个包含三个元素的数组,而要声明这样的数组,首先需要使用fp[3]
,该声明的其他部分指出了数组包含的元素是什么样的。运算符[]
的优先级高于*
,因此fp[3]
表明fp
是一个包含三个指针的数组。上述声明的其他部分指出了每个指针指向的是什么:参数为int,且返回类型为double的函数。因此,fp
是一个包含三个指针的数组,其中每个指针都指向这样的函数,即将int作为参数,并返回一个double。
注意,此处无法使用auto赋值,因为auto只能用于单值初始化,无法用于初始化列表。
接下来使用该数组调用函数:
double = fp[0](1);
double = (*fp[1])(2);
内联函数
内联函数与其他函数的区分之处在于编译器对函数的处理方式,常规函数在编译时,函数调用就是函数调用,在调用到该函数时,运行程序会跳转到存储该函数的代码块去执行这个函数,执行结束后再跳转回来继续执行;而内联函数再编译时直接将整个函数编译到调用的地方,即用函数的内容替换函数调用。
这个性质就决定了,内联函数最好是简单的函数,如果函数过于复杂,不适合被声明为内联函数,因为如果复杂的函数作为了内联函数,那么编译时每一个函数调用都会复制一次整个函数放在调用的位置,是非常耗费空间的。但是对于简单的却调用次数很多的函数,适合声明为内联函数,这样其实相当于将一些多次多处重复的代码单独拎出来写了,但是编译时编译器帮你放了回去。
内联函数的声明方法:在函数生命和定义前都加上关键词 inline,而且通常由于内联函数很简单,会直接写在原型处:
inline double square(double x) { return x*x; }
引用变量
引用变量被定义为一个变量的别名,即引用变量和其指向的变量代表同一个值,指向同一个存储单元,并且引用变量自从赋值起就已知跟着这个变量,不会再发生改变,也就是一个变量两个名字,所以更改其中的任何一个这个变量都会发生改变。
&
符号可以指示变量的地址,同时它的另一个含义就是用来声明引用:
int genius;
int & me = genius;
这里的&
不是地址运算符,而是类型标识符的一部分,就像int *
是int
类型的指针一样,int &
是指向int
的引用。上述声明将me和genius等价,它们指向相同的值和内存单元,就是说me就是genius,而genius就是me。
引用和指针
引用和指针貌似很相像,例如:
int genius = 100;
int & me = genius;
int * myself = &genius;
这样,神奇的事情来了:
me == *myself == genius
&me == myself == &genius
引用与指针也有区别,例如必须再声明时将引用的值初始化,不能像指针一样,先声明在赋值;而且引用变量一旦赋值将不会再更改,这一点更接近于const指针:
int & me = genius;
int * const myself = &genius;
从而,me == *myself
将引用用作函数参数
按值传递与按引用传递
- 按值传递:函数复制一份传入的值,然后对复制的数据进行操作,与被传入的数据无关
- 按引用传递:函数直接使用传入的数据,不对其进行拷贝,修改的内容会直接体现在原数据上,这一点可以轻松地使用引用变量完成,抑或通过使用指针传递变量的地址来完成
例如,完成一个交换两个变量的值的函数:
按值传递
void swapByValue(int a, int b)
{
int temp = a;
a = b;
b = temp;
}
按引用传递:
void swapByReference(int & a, int & b)
{
int temp = a;
a = b;
b = temp;
}
按地址(指针)传递:
void swapByPoint(int * a, int * b)
{
int temp = *a;
*a = *b;
*b = temp;
}
三种函数的调用方式:
swapByValue(intA, intB);
swapByReference(intA, intB);
swapByPoint(&intA, &intB);
可以看出,按指针传递的声明方法和调用方法与其他两种均不同,需要在函数定义时告知传入的变量时 int*
类型,在调用时传入变量的地址 &intA
才可以;按值传递和按引用传递的调用方式相同,直接将变量名传入,只有在定义函数处才可以直到两者的不同,一个是按值传递,一个指出了是按地址传递 & int
。
按引用传递的一个重要方面是被传入的值会被直接使用或者修改,例如:
int squareByValue(int a)
{
a *= a;
return a;
}
int squareByReference(int & a)
{
a *= a;
return a;
}
调用时会出现:
int a =2;
cout << squareByValue(a) << " = square of " << a; // 输出:4 = square of 2
cout << squareByReference(a) << " = square of " << a; // 输出:4 = square of 4
可见,按照引用传递时,被传入的变量的更改会影响到原始的变量。
将引用用于结构
声明一个结构
struct myStruct
{
int myInt;
double myDouble;
}
将引用用于结构
将结构的引用作为参数传递非常简单,同时需要注意的是最好使用引用作为返回值。否则的话,函数需要先将待返回的结构体赋予一个临时变量,然后将这个临时变量返回,这是非常耗费时间和空间的。所以应该像这样使用它们:
myStruct & sum(myStruct & target, const myStruct & source)
{
target.myInt += source.myInt;
target.myDouble += source.myDouble;
return target;
}
调用该函数:
myStruct target = {1,2.5};
myStruct source = {2,3.8};
myStruct result;
result = sum(target, source);
对返回值使用const
如果不对返回值使用const,意味着返回值是可以被赋值的,即下边的表达式成立:
sum(target, source) = anotherStruct;
未来避免这种情况发生,可以使用const限定返回值:
const myStruct & sum(myStruct & target, const myStruct & source);
从而如下的语句都不能成立:
sum(target, source) = anotherStruct;
sum(sum(target, source), source);
因为无法将一个有const限定的变量传递给一个没有const限定的变量,但是可以将返回值赋给其他结构:
result = sum(target, source);
何时使用引用参数
使用引用参数的主要原因有两个:
- 程序员能够修改调用函数中的数据对象
- 通过传递引用而不是整个数据对象,可以提高程序的运行速度
当数据对象较大时(如结构和类对象),第二个原因最重要。这些也是使用指针参数的原因。这是有道理的,因为引用参数实际上是基于指针的代码的另一个接口。那么,什么时候应使用引用、什么时候应使用指针呢?什么时候应按值传递呢?下面是一些参考原则:
- 对于使用传递的值而不作修改的函数
- 如果数据对象很小,如内置数据类型或小型结构,则按值传递
- 如果数据对象是数组,则使用指针,因为这是唯一的选择,并将指针声明为指向const的指针
- 如果数据对象是较大的结构,则使用const指针或const引用,以提高程序的效率。这样可以节省复制结构所需的时间和空间。
- 如果数据对象是类对象,则使用const引用。传递类对象参数的标准方式是按引用传递
- 对于修改调用函数中数据的函数
- 如果数据对象是内置数据类型,则使用指针
- 如果数据对象是数组,则只能使用指针
- 如果数据对象是结构,则使用引用或指针
- 如果数据对象是类对象,则使用引用
默认参数
默认参数显然就是在调用函数时,如果没有主动传入参数,可以直接使用默认的参数。
默认参数的写法只需在函数原型中的参数后跟上默认值即可:
int myFunction(const int, double = 1.0);
默认参数的原则如下:
- 在参数列表中,带有默认参数的参数必须放到所有参数的右边,即从某个参数开始,后边的如果是带有默认参数的就都是带有默认参数的:
int myFunction(int m, double n = 1.0, char c = 'c'); // 这是可行的
int myFunction(int m, doubel n = 1.0, char c); // 这是不行的
- 调用函数时,可以不传入有默认值的参数,但是所有参数赋值必须从左到右,不能跳过某个参数值,比如上边的第一个函数:
int result = myFunction(1); // 传一个参数,1,1.0,'c'
int result = myFunction(1, 2.0); // 传两个参数,1,2.0,'c'
int result = myFunction(1, 2.0, 'h'); // 传三个参数,1,2.0,'h'
int result = myFunction(1, 'h'); // 不允许
int result = myFunction(1, , 'h'); // 不允许
函数重载
函数重载提供的功能是,定义同名、同返回值,但是参数列表不同的函数,这样在调用时,编译器根据传入参数的类型自行选择使用哪一种函数。
加入我们定义了如下的三个函数:
void myPrint(int i); // #1
void myPrint(double d); // #2
void myPrint(string s); // #3
在调用时会根据参数选择使用哪一种:
myPrint(1); // #1
myPrint(2.5); // #2
myPrint("hello world!"); // #3
重载的一些原则
引用变量是无法作为两种重载函数的,比如有两个函数原型如下:
double square(double x);
double square(double & x);
这样,在调用函数时出入参数:
double result = square(x);
根本无法确定是使用哪种函数模型,所以在函数重载中,编译器将类型本身和类型的引用看成是一种参数。
在匹配函数时,并不区分const和非const的变量,如以下的函数:
void function(char c);
void function(const char c);
广义上来讲这也是一种重载,但是由于const函数可以处理const变量和非const变量,而非const的函数只能处理非const变量,所以其实并不是严格意义上的重载函数
函数模板
函数模板的作用是允许我们使用泛型定义函数,然后根据具体的数据类型替换泛型。通过将类型作为参数传递给模板,可以是编译器生成该类的函数。
比如要交换两个变量的值,这时候我们写好了一个交换两个int值的函数,又需要一个交换两个double值的函数,我们需要将第一个函数重复一遍,然后再将其中的int替换成double,是非常复杂的。尤其是当一些算法可以应用于很多数据类型时,我们无法对每一种数据类型都写一套算法函数,这就时函数模板解决的问题。
模板函数的定义与使用
我们可以先定义一个模板,然后用具体的数据类型替换,像下边这样:
template<typename T>;
void swap(T &a, T &b)
{
T temp;
temp = a;
a = b;
b = temp;
}
要建立一个模板,必须先使用template
语法来定义一个模板变量T,这里也可以用旧的声明方式:
template<class T>;
很多代码库都使用class
开发的,这样的上下文中,两个关键词完全相同,如果不考虑向后兼容并且不介意输入长单词的话,使用typename
而不使用class
调用模板函数时,和其他函数类似,编译器会根据我们传入的函数类型自行生成一个对应的函数,这个函数我们是看不到的,但是编译器会直接在背后生成并为我们使用好,例如调用下边的语句时:
int i = 1;
int j = 2;
swap(i, j);
这是,编译器会自动生成一个函数:使用int代替所有的T,从而完成相关的逻辑,这个函数我们看不到,但是没问题
void swap(int &a, int &b)
{
int temp;
temp = a;
a = b;
b = temp;
}
模板函数的重载
模板函数也是可以重载的,虽然泛型使我们合并解决了很多问题,但是有些数据类型是无法合并的。比如交换变量值的函数,我们可以将上述逻辑同时用于int、double、char、string等,但是却无法用于数组,因为数组需要将每个元素交换,于是可以使用函数模板的重载:
template<typename T>;
void swap(T &a, T &b)
{
T temp;
temp = a;
a = b;
b = temp;
}
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;
}
}
这样就可以用同名函数swap交换任意数据类型和数组类型了。
函数模板的局限性
定义模板函数要考虑好这个函数应用的数据类型的范围,有些操作对于数据类型是很有局限性的,比如 a>b 这种操作,只能用于 int、double 等,string和char显然无法使用
显式具体化
假如定义了一个结构:
struct myStruct
{
int myInt;
double myDouble;
}
这时我们同样可以通过上面的swap函数交换两个struct的值,因为结构体是可以直接被赋给另一个结构体的。但是如果我们只想交换两个结构体的myInt变量,而不想交换myDouble变量呢,就无法使用上述模板函数了,但是由于这种情况下我们传入的参数还是和上述模板函数相同的(两个待交换的T),所以重载无法达到这个愿望。这就用到了显式具体化(explicit specialization)。
显示具体化是我们可以提供一个具体化的函数定义,其中包含这个特殊的处理情况下的代码,当编译器找到了与函数调用匹配的具体化定义时,就不再使用模板,而是用该具体化的定义:
- 对于给定的函数名,可以有非模板函数、模板函数和显式具体化模板函数以及它们的重载函数四个版本
- 显式具体化的原型和定义以
<template>
打头,并通过名称来指出类型 - 非模板函数优先于具体化和常规模板,具体化模板优先于常规模板
例如对于刚才的结构体,下面是用于交换逻辑的非模板函数、模板函数和具体化函数的原型:
// 非模板函数 #1
void swap(myStruct &, myStruct &);
// 模板函数 #2
template<typename T>
void swap(T &, T &);
// 模板具体化函数 #3
template <> void swap<myStruct>(myStruct &, myStruct &);
在上述三个函数原型同时存在时,#1优先于#3优先于#2。在具体化函数模板中,可以省略函数名后的<myStruct>
,因为参数类型已经表明了这是一个myStruct的具体化函数:
template <> void swap(myStruct &, myStruct &);
实例化与具体化
当我们定义了一个模板函数swap后,通过调用时传入了两个int值可以使得编译器在后台自动为我们实例化了一个int类型的函数,这个函数是编译中产生的,所以我们看不到,但是它确实是产生了,这个过程成为隐式实例化。
同时我们也可以进行显式实例化(explicit instantiation),即可以直接命令编译器创建特定的实例,比如一个处理int的swap函数,只需要这样声明它:
template void swap<int>(int, int);
这是要和显示具体化(explicit specialization)区分开的,具体化是在template
后还需要加一个<>
:
template < > void swap<int>(int, int);
template < > void swap(int, int);
在template
后有无<>
是区分显示具体化和显示实例化的重要标志。
通常的,隐式实例化、显式实例化和显示具体化都被称为具体化(specialization)
转载请注明出处,本文永久更新链接:https://blogs.littlegenius.xin/2019/08/12/【C-温故知新】四函数/