1. 写在前面
c++在线编译工具,可快速进行实验: https://www.bejson.com/runcode/cpp920/
这段时间打算重新把c++捡起来, 实习给我的一个体会就是算法工程师是去解决实际问题的,所以呢,不能被算法或者工程局限住,应时刻提高解决问题的能力,在这个过程中,我发现cpp很重要, 正好这段时间也在接触些c++开发相关的任务,所有想借这个机会把c++重新学习一遍。 在推荐领域, 目前我接触到的算法模型方面主要是基于Python, 而线上的服务全是c++(算法侧, 业务那边基本上用go),我们所谓的模型,也一般是训练好部署上线然后提供接口而已。所以现在也终于知道,为啥只单纯熟悉Python不太行了, cpp,才是yyds。
和python一样, 这个系列是重温,依然不会整理太基础性的东西,更像是查缺补漏, 不过,c++对我来说, 已经5年没有用过了, 这个缺很大, 也差不多相当重学了, 所以接下来的时间, 重温一遍啦 😉
资料参考主要是C语言中文网和光城哥写的C++教程,然后再加自己的理解和编程实验作为辅助,加深印象。 关于更多的细节,还是建议看这两个教程。
今天这篇文章学习C++的模板与泛型程序设计, 泛型程序设计是一种算法在实现时不指定具体操作的数据类型的程序设计方法,所谓泛型,指的是算法只要实现一遍,就能适用于多种数据类型, 这种设计方法的优势在于减少重复代码编写。 其应用最成功的就是C++的标准模板库(STL)。 泛型程序设计在C++中非常重要, 而具体来讲,这种程序设计就是大量编写模板,使用模板的程度设计思路。 所以呢, 这篇内容主要整理与模板相关的知识。
主要内容:
- C++函数模板与类模板初识
- C++模板编程的前世今生
- C++函数模板的重载与实参推断
- C++模板的显式具体化
- C++模板中的非类型参数
- C++模板的实例化
- C++类模板与继承和友元
- C++类模板的静态成员
Ok, let’s go!
2. C++函数模板与类模板初识
C++中,模板分为函数模板和类模板两种。
2.1 函数模板
函数模板从一个需求开始, 比如现在让我们实现一个程序,能够完成不同类型变量值的交换,比如两个整数,两个浮点,两个char变量交换值。 如果按照常规的思路(函数重载),我们可能要分别写3个交换函数,这3个函数名字相同,但参数列表不同, like this:
//交换 int 变量的值
void Swap(int *a, int *b){
int temp = *a;
*a = *b;
*b = temp;
}
//交换 float 变量的值
void Swap(float *a, float *b){
float temp = *a;
*a = *b;
*b = temp;
}
//交换 char 变量的值
void Swap(char *a, char *b){
char temp = *a;
*a = *b;
*b = temp;
}
int main()
{
int a = 100, b = 50;
Swap(a, b);
cout << a << " " << b << endl;
float c = 12.5, d = 2.5;
Swap(c, d);
cout << c << " " << d << endl;
return 0;
}
这时候就能实现需求,但我们发现, 这三个本身,本质上逻辑是一模一样的,只不过数据的类型不同, 那要是再有新需求,比如再实现bool类型,字符串类型的变量交换, 我们还得再重新加两个Swap()
函数。 太麻烦,也太浪费代码。
那么有没有一种方式,能把这3个甚至更多同功能,同函数体但不同类型的代码压缩成一个函数呢? 可以的,这就是函数模板的魅力。
我们知道, 数据的值可以通过参数传递, 在函数定义时,数据的值是未知的,只有等函数调用接收实参才能确定其值,这是值的参数化。
C++中,数据的类型也可以通过参数传递,函数定义时不指明具体的数据类型,当发生函数调用时,编译器可以根据传入的实参自动推断数据类型,这就是类型的参数化
值和类型是数据两个主要特征,它们在C++中都可以被参数化。
所谓函数模板,实际上是建立一个通用函数,它所用到的数据类型(包括返回值类型,形参类型,局部变量类型)可以不具体指定,而是用一个虚拟类型代替(实际上是用一个标识符占位), 等发生函数调用时,再根据传入的实参来逆推出真正的类型。 这个通用函数就称为函数模板。
函数模板中,数据的值和类型都被参数化,发生函数调用时编译器会根据传入的实参推演形参的值和类型。 换个角度,函数模板除了支持值的参数化,也支持类型参数化。
一旦定义函数模板,就可以将类型参数用于函数定义和声明,再直白,原来使用int, float, char
等内置类型的地方,都可以用类型参数代替。 下面是上面三个函数的压缩版:
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){
T temp = a;
a = b;
b = temp;
}
template是定义函数模板的关键字,它后面紧跟尖括号<>
,尖括号包围的是类型参数(也可以说是虚拟的类型,或者说是类型占位符)。typename是另外一个关键字,用来声明具体的类型参数,这里的类型参数就是T。从整体上看,template<typename T>
被称为模板头。 模板头中包含的类型参数可用在函数定义的各个位置,包括返回值,形参列表和函数体。
定义模板函数的语法如下:
template <typename 类型参数1 , typename 类型参数2 , ...> 返回值类型 函数名(形参列表){
//在函数体中可以使用类型参数
}
// 这里的typename关键字也可以用class关键字代替,没有任何区别, 但建议用前者
类型参数可以有多个,它们之间以逗号,
分隔。类型参数列表以< >
包围,形式参数列表以( )
包围。
2.2 类模板
C++也支持类模板, 函数模板中定义的类型参数可以用在函数声明和函数定义中,类模板定义的类型参数可以用在类声明和类实现中。 类模板的目的同样是将数据的类型参数化。
声明类模板的语法:
template<typename 类型参数1 , typename 类型参数2 , …> class 类名{
//TODO:
};
template开头, 后跟类型参数。 类型参数不能为空,多个类型参数逗号隔开。 一旦声明类模板,就可以将类型参数用于类的成员函数和成员变量,即原来使用int, float, char等内置类型的地方,都可以用类型参数代替。
依然是从需求出发,看个例子, 假设现在要定义一个类表示坐标, 坐标的数据类型可以是整数,小数和字符串, 这时候,如何用类模板定义呢?
templete<typename T1, typename T2>
class Point{
public:
Point(T1 x, T2 y): m_x(x), m_y(y){}
T1 getX() const;
void setX(T1 x);
T2 getY() const;
void setY(T2 y);
private:
T1 m_x;
T2 m_y;
};
x和y坐标的数据类型不确定,借助类模板可以将数据类型参数化,这样就不必定义多个类了。
注意: 模板头和类头是一个整体,可以换行,但中间不能有分号
上面是类模板的声明,还需要在类外定义成员函数, 此时仍然需要带上模板头:
template<typename 类型参数1 , typename 类型参数2 , …>
返回值类型 类名<类型参数1 , 类型参数2, ...>::函数名(形参列表){
//TODO:
}
下面对Point类的成员函数定义:
template<typename T1, typename T2>
T1 Point<T1, T2>::getX() const{
return m_x;
}
template<typename T1, typename T2>
void Point<T1, T2>::setX(T1 x){
m_x = x;
}
template<typename T1, typename T2>
T2 Point<T1, T2>::getY() const{
return m_y;
}
template<typename T1, typename T2>
T2 Point<T1, T2>::setY(T2 y) const{
m_y = y;
}
两点要注意:
- 类名Point后面要带上类型参数
- 类外定义成员函数时, template后面的类型参数要和类声明时一致
那么定义完了类模板,怎么创建对象呢?
Point<int, int>p1(10, 20);
Point<int, float>p2(10, 15.5);
Point<float, char*>p3(12.4, "东经180度");
与函数模板不同的是, 类模板在实例化时必须显式的指明数据类型,编译器不能根据给定的数据推演出数据类型。
除了对象变量,也可以用对象指针实例化,但注意,赋值号两边都要指明具体的数据类型,且要保持一致。
Point<float, float> *p1 = new Point<float, float>(10.6, 109.3);
Point<char*, char*> *p = new Point<char*, char*>("东经180度", "北纬210度");
3. C++模板编程的前世今生
编程语言根据"在定义变量是是否需要显式指明数据类型", 分为强类型和弱类型语言。
- 强类型语言: 定义变量的时候,要显式指明数据类型,并且一旦为变量指明了某种数据类型,该变量以后不能赋予其他类型数据,除非强制类型转换或隐式转换,典型的C/C++, Java
- 弱类型语言: 定义变量时不需要显式指明数据类型,编译器根据赋给变量的数据自动推导类型,并可以赋给变量不同类型数据,典型的python,php等
类型对于编程语言很重要,不同类型支持不同操作,例如clas student
类型变量可以调用display()
方法,而int
变量就不行。不管是强类型还是弱类型,编译器内部都有一个类型系统维护变量的各种信息。
- 强类型语言, 变量类型"几乎"是不变的,编译器编译期间就能检测某个变量的操作是否正确,这样最终生成的程序中,不用再维护一套类型信息了,从而减少内存使用,加快程序运行。 之所以是几乎,是因为多态的情况下,编译器在编译阶段会在对象内存模型加入虚函数表,type_info对象等辅助信息,维护一个完整继承链,等程序运行后,才能确定调用哪个函数
- 弱类型语言,变量类型随时可变,编译器在编译期间不好确定变量类型,所以传统编译对弱类型语言意义不大,它一般是一边执行,一边编译,这样可以根据上下午推导很多有用信息,编译更高效。 这种语言也称为解释型语言。
强类型语言比较严谨,编译时就能发现很多错误,适合开发大型,系统级项目,弱类型语言比较灵活,编码效率高,部署容易,web开发中大显身手。 看看灵活不灵活:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def getX(self):
return self.x
def getY(self):
return self.y
if __name__ == "__main__":
p = Point(10, 20)
print(p.getX(), " ", p.getY())
p1 = Point(10.1, 20.1)
print(p1.getX(), " ", p1.getY())
p2 = Point("hello", "world")
print(p2.getX(), " ", p2.getY())
弱类型语言非常灵活,天生对类似不敏感,可以处理多种类型数据,但强类型就比较"死板", 所以后来C++开始支持模板, 主要是为了弥补"不够灵活"的特点。
模板支持的类型是宽泛的,没有限制的,可以使用任意类型替换,这种编程方式叫泛型编程。即可以将参数T看做一个泛型,将int,float,string等看做具体类型, C++,java等也都支持泛型。 上面代码如果换成C++, 就是下面这样了:
template<typename T1, typename T2>
class Point{
public:
Point(T1 x, T2 y): m_x(x), m_y(y){}
T1 getX() const;
T2 getY() const;
private:
T1 m_x;
T2 m_y;
};
template<typename T1, typename T2>
T1 Point<T1, T2>::getX() const{ return m_x;}
template<typename T1, typename T2>
T2 Point<T1, T2>::getY() const{ return m_y;}
int main()
{
Point<int, int>p(10, 20);
cout << p.getX() << " " << p.getY() << endl;
Point<float, float>p1(10.1, 20.1);
cout << p1.getX() << " " << p1. getY() << endl;
Point<string, string>p2("hello", "world");
cout << p2.getX() << " " << p2.getY() << endl;
return 0;
}
C++ 模板也是被迫推出的,最直接的动力来源于对数据结构的封装,C++ 开发者们希望为线性表、链表、图、树等常见的数据结构都定义一个类,并把它们加入到标准库中,这样以后程序员就不用重复造轮子了,直接拿来使用即可。但是这个时候遇到了一个无法解决的问题,就是数据结构中每份数据的类型无法提前预测, 而 C++ 又是强类型的,数据的种类受到了严格的限制,这种矛盾是无法调和的。
于是乎,模板就诞生了,C++模板非常重要, 标准库STL几乎都是模板来开发的。
STL(Standard Template Library,标准模板库)就是 C++ 对数据结构进行封装后的称呼。
4. C++函数模板的重载与实参推断
4.1 函数模板重载
当需要对不同类型使用同一种算法(同一个函数体)时,为避免定义多个功能重复的函数,可以使用模板。 而并非所有类型都使用同一种算法,有些特定类型需要特殊处理,为满足这种需求, C++允许对函数模板进行重载。
看个简单的例子,重载函数模板,使得用同一个函数名,完成基本类型和数组类型的变量交换
template<class T> void Swap(T &a, T &b); //模板①:交换基本类型的值
template<typename T> void Swap(T a[], T b[], int len); //模板②:交换两个数组
int main(){
//交换基本类型的值
int m = 10, n = 99;
Swap(m, n); //匹配模板①
cout<<m<<", "<<n<<endl;
//交换两个数组
int a[5] = { 1, 2, 3, 4, 5 };
int b[5] = { 10, 20, 30, 40, 50 };
int len = sizeof(a) / sizeof(int); //数组长度
Swap(a, b, len); //匹配模板②
printArray(a, len);
printArray(b, len);
return 0;
}
template<class T> void Swap(T &a, T &b){
T temp = a;
a = b;
b = temp;
}
template<typename T> void Swap(T a[], T b[], int len){
T temp;
for(int i=0; i<len; i++){
temp = a[i];
a[i] = b[i];
b[i] = temp;
}
}
4.2 实参推断
在使用类模板创建对象的时候, 往往需要显式指明实参(具体类型):
template<typename T1, typename T2> class Point;
// 创建对象
Point<int, int> p1(10, 20); //在栈上创建对象
Point<char*, char*> *p = new Point<char*, char*>("东京180度", "北纬210度"); //在堆上创建对象
由于已经显式的指明了T1和T2的具体类型,编译器就不用自己推断了,直接拿来用即可。
而对于函数模板,调用函数时可以不显式指明实参:
//函数声明
template<typename T> void Swap(T &a, T &b);
//函数调用
int n1 = 100, n2 = 200;
Swap(n1, n2);
float f1 = 12.5, f2 = 56.93;
Swap(f1, f2);
此时,编译器会根据n1和n2, f1和f2的类型自动推断T的类型。 这种通过函数实参来确定模板实参(类型参数的具体类型)的过程就是模板实参推断。
模板实参推断过程中,编译器使用函数调用中的实参类型寻找类型参数的具体类型。
4.2.1 实参推断过程中的类型转换
对于普通函数(非模板函数),发生函数调用时会对实参的类型进行适当的转换,以适应形参的类型。这些转换包括:
- 算数转换:
int -> float, char -> int, double -> int
等。 - 派生类向基类的转换: 向上转型
- const转换: 非const -> const
- 数组或函数指针转换: 如果函数形参不是引用类型,数组名就会转换成数组指针,函数名也会转成函数指针
- 用户自定的类型转换
例如,下面两个函数原型:
void func1(int n, float f);
void func2(int *arr, const char *str);
// 具体调用
int nums[5];
char *url = "http://c.biancheng.net";
func1(12.5, 45); // 12.5 double->int 45 int ->float
func2(nums, url); // nums int[] -> int * url char * -> const char*
而对于函数模板,类型转换则受到了更多的限制,仅能进行const转换和数组或函数指针转换, 其他的都不能应用于函数模板。
template<typename T> void func1(T a, T b);
template<typename T> void func2(T *buffer);
template<typename T> void func3(const T &stu);
template<typename T> void func4(T a);
template<typename T> void func5(T &a);
// 调用
int name[20];
Student stu1("张华", 20, 96.5); //创建一个Student类型的对象
func1(12.5, 30); // error 编译器不知道应该将T实例化double还是int
func2(name); // name类型从int[20]转成int *, 所以T的真实类型为int
func3(stu1); // 非const转为const, T真实类型为Student
func4(name); // name类型从int[20]转为int *, T 真实类型是int *
func5(name); // name类型依然是int[20],不会转为int *, 所以T真实类型为int[20]
当函数形参是引用类型时, 数组不会转为指针, 所以下面的这个是错误的:
template<typename T> void func(T &a, T &b);
int str1[20];
int str2[10];
func(str1, str2); // 函数调用过程中不会把函数名转成指针, 所以编译器不知道将T实例化为int[20]还是int[10],调用失败
4.2.2 为函数模板显式指明实参(具体类型)
函数模板的实参推断指函数调用过程中根据实参的类型寻找类型参数的具体过程, 大部分情况下是有效的,但当类型参数个数较多的时候,会有个别的类型无法推断出来,这时候必须显式指明实参。
template<typename T1, typename T2> void func(T1 a){
T2 b;
}
func(10); //函数调用
func()有两个类型参数, 但编译器只能从函数调用中推断出T1的类型来,推断不出T2的类型来,所以调用时失败的,这时候必须显式指明T1,T2的具体类型。
func<int, int>(10); // 函数模板显式指明实参和类模板显式指明实参形式类似
显式指明的模板实参会按照从左到右的顺序与对应的模板参数匹配:第一个实参与第一个模板参数匹配,第二个实参与第二个模板参数匹配,以此类推。只有尾部(最右)的类型参数的实参可以省略,而且前提是它们可以从传递给函数的实参中推断出来。
对于上面的func(), 必须同时指明T1和T2的类型,因为虽然T2位于参数列表的最右边,但没法自动推断, 如果改成下面这个样子, 还可以省略T2的类型:
template<typename T1, typename T2> void func(T2 a){
T1 b;
}
//函数调用
func<int>(10); //省略 T2 的类型 这个是因为,int->T1,而10可以自动推断类型给到T2
func<int, int>(20); //指明 T1、T2 的类型
显示的指明实参,还可以应用正常的类型转换
上面我们提到,函数模板仅能进行「const 转换」和「数组或函数指针转换」两种形式的类型转换,但是当我们显式地指明类型参数的实参(具体类型)时,就可以使用正常的类型转换(非模板函数可以使用的类型转换)了。
比如下面这个模板:
template<typename T> void func(T a, T b);
// 调用
func(10, 23.5) // 编译器不知道把T实例化为int还是float,所以error
func<float>(20, 23.5) // 这个就OK了,显示指明了T的类型为float
5. C++模板的显式具体化
C++没有办法限制类型参数的范围,可以使用任意一种类型实例化模板。 但模板中的语句(函数体或者类体)不一定适应所有类型,可能个别类型没有意义或导致语法错误。 比如
template<typename T> const T& Max(const T& a, const T& b){
return a > b ? a : b;
}
这里的a>b
, 可以用来比较int, float, char
等基本类型,但像结构体变量,对象及数组等,就没法用了, 毕竟没有重载。 所以呢, 编写的函数模板很可能无法处理某些类型。
模板是一种泛型技术,能接受的类型是宽泛,没有限制的,并且对这些类型使用算法都是一样的,但有时候我们需要让模板针对某种具体类型使用不同算法(函数体或类体不同), 这种技术就称为模板的显式具体化
5.1 函数模板的显式具体化
假如目前有这样一个需求, 比较不同数据类型下两份数据的大小,返回大的那一份数据, 但是呢,这里面的数据包括(int, float, char, class), 对于前面3个基本类型,直接比较它们本身的值, 而对于class类, 需要比较某个属性的值,从而返回最大的属性值。
这种怎么办呢? 就需要借助模板的显式具体化技术, 看下面的例子:
typedef struct{
string name;
int age;
float score;
}STU;
// 函数模板
template<typename T> const T& Max(const T& a, const T& b);
//函数模板的显式具体化(针对STU类型的显式具体化)
template<> const STU& Max<STU>(const STU& a, const STU& b);
// 重载
ostream & operator <<(ostream &out, const STU& stu);
int main()
{
int a = 10, b = 20;
float c = 10.1, d = 20.1;
char e = 'a', f = 'b';
cout << Max(a, b) << endl; // 20
cout << Max(c, d) << endl; // 20.1
cout << Max(e, f) << endl; // 'b'
STU s1 = {"zhongqiang", 16, 95.5};
STU s2 = {"zhangsan", 17, 90.0};
cout << Max(s1, s2) << endl; // zhongqiang 16 95.5
return 0;
}
template<typename T> const T& Max(const T&a, const T& b){
return a > b? a : b;
}
template<> const STU& Max<STU>(const STU&a, const STU& b){
return a.score > b.score? a : b;
}
ostream & operator <<(ostream &out, const STU& stu){
out << stu.name << ", " << stu.age << ", " << stu.score;
return out;
}
这里由于class与基本类型的比较方案不一样,所以,借助模板的显式具体化技术对STU类型单独处理。Max<STU>
中的STU
表明将类型参数T
具体化为STU
类型,原来使用T
的位置都用STU
替换,包括返回值类型,形参类型,局部变量类型。 Max只有一个类型参数T,并且已经被具体化为STU,这样整个模板就不再有类型参数了,类型参数列表为空,模板头应该写作template<>
函数的调用规则:
在C++中,对于给定的函数名,可以有非模板函数,模板函数,显式具体化模板函数以及它们的重载函数, 调用时,非模板函数优先于显式具体化和常规模板函数, 而显式具体化函数又优先于常规模板
5.2 类模板的显式具体化
上面曾经定义了一个类模板Point, 接收不同类型的参数坐标,然后输出, 这里假设有个需求, 如果是普通的int, float类型的这种坐标, 那么输出的时候, 用","
分隔输出, 如果是两个字符串类型, 输出的时候用"|"
分隔输出。 这种情况下,就可以用显示具体化技术对字符串类型做特殊处理。
// 类模板
template<typename T1, typename T2>
class Point{
public:
Point(T1 x, T2 y): m_x(x), m_y(y){}
void display() const;
private:
T1 m_x;
T2 m_y;
};
// 这里必须带模板头
template<typename T1, typename T2>
void Point<T1, T2>::display() const{
cout << "x=" << m_x << ", y=" << m_y << endl;
}
// 类模板显式具体化(针对字符串类型)
template<>
class Point<string, string>{ // 表明将类型参数T1, T2具体化为string类型,上面类模板不能加参数类型了
public:
Point(string x, string y): m_x(x), m_y(y){}
void display() const;
private:
string m_x;
string m_y;
};
// 这里不能带模板头
void Point<string, string>::display() const{
cout << "x=" << m_x << "| y=" << m_y << endl;
}
int main()
{
(new Point<int, int>(10, 20)) -> display(); // x=10, y=20
(new Point<int, string>(10, "东经180度")) -> display(); // x=10, y=东经180度
(new Point<string, string>("东经180度", "北纬26度")) -> display(); // x=东经180度| y=北纬26度
return 0;
}
5.3 部分显式具体化
上面的类模板显式初始化中,为所有类型参数都提供了实参,所以最后模板头为空,即template<>
,C++还允许只为一部分类型参数提供实参,这称为部分显式具体化。
部分显式具体化只能用于类模板,不能用于函数模板
比如上面代码改下:
template<typename T2>
class Point<string, T2>{
public:
Point(string, T2 y): m_x(x), m_y(y){}
void display() const;
private:
string m_x;
T2 m_y;
};
template<typename T2> // 这时候要带上模板头
void Point<string, T2>::display() const{
cout << "x=" << m_x << "| y=" << m_y << endl;
}
(new Point<string, int>("东京180度", 10)) -> display(); // x=东京180度 | y=10
(new Point<string, char*>("东京180度", "北纬210度")) -> display(); // x=东京180度 | y=北纬210度
模板头template<typename T2>
中声明没有被具体化的类型参数, 类名Point<char*, T2>
列出了所有类型参数,包括未被具体化和已经被具体化的。之所以要全部列出, 是为了让编译器确认"到底第几个类型参数被具体化了"。
6. C++模板中的非类型参数
模板是一种泛型技术,目的是数据类型参数化,增强C++语言的灵活性, C++模板中除了可以包含类型参数,也可以包含非类型参数,如:
template<typename T, int N> class Demo{};
template<typename T, int N> void func(T (&arr)[N]);
T是一个类型参数, typename
关键字指定, 传的是数据的类型。 N是一个非类型参数,用来传递数据的值,而不是类型,它和普通函数的形参一样,都需要指明具体类型。
类型参数和非类型参数都可以用在函数体或类体中,当调用一个函数模板或者通过一个类模板创建对象的时候,非参数类型会被用户提供的,或编译器推断的值给取代。
6.1 函数模板中使用非类型参数
在上面模板重载的时候整理过一个事情,就是如果通过模板函数交换两个数组的值,是这样写的:
template<typename T> void Swap(T a[], T b[], int len){
// 交换数组逻辑
}
//交换两个数组
int a[5] = { 1, 2, 3, 4, 5 };
int b[5] = { 10, 20, 30, 40, 50 };
int len = sizeof(a) / sizeof(int); //数组长度
Swap(a, b, len);
形参len用来指明要交换数组的长度,调用Swap()
函数之前必须先通过sizeof求的数组长度,然后传过去。 那么这里要引申出问题了, 为啥不能在Swap()
函数内部求出数组长度,而是要通过形参把数组长度传递进去呢? 这是因为数组在作为函数参数时会自动转换为数组指针, 而sizeof
只能通过数组名求数组长度,不能通过数组指针求数组长度。 关于这个问题, 在这里可以再往下剖析一层。
-
数组和指针绝不是等价的,数组是另外一种类型
之前总是认为, 数组名表示数组首地址,多数情况下数组名当指针用,但这俩哥们不等价,非常典型的一个例子就是求数组长度,此时只能用数组名,不能用指针。int a[6] = {0, 1, 2, 3, 4, 5}; int *p = a; int len_a = sizeof(a) / sizeof(int); // 6 int len_p = sizeof(p) / sizeof(int); // 2
数组是一系列数据的集合,没有开始和结束标志,p仅仅是一个指向int类型的指针,编译器不知道它指向的是一个整数还是一堆整数,对p使用sizeof求得的是指针变量本身的长度。即编译器没有把p和数组关联起来, p仅仅是一个指针变量,不管指向哪里,sizeof求得的永远是它本身占用的字节数。
int *
在32环境下长度是4, 64环境长度是8,所以sizeof(p)
上面会出现这个结果。站在编译器的角度,变量名,数组名都是一种符号,最终都要和数据绑定。 变量名用来指代一份数据,数组名用来指代一组数据,他们都是有类型的,以此推断指代数据长度。 对的, 数组也有类型,sizeof就是根据符号的类型计算长度。
所以上面出现这样结果的原因,是因为a和p这两个符号类型不同,指代数据也不同,不是一码事,
sizeof
根据符号类型求长度,所以类型不同,求得长度也不一样。
更高层理解:编程语言目的是为了将计算机指令抽象成人类能理解的自然语言,让我们更容易管理和操作各种计算机资源,这些资源最终表现为编程语言中的各种符号和语法规则。
整数,小数,数组,指针等不同类型数据都是对内存的抽象,他们的名字指代不同的内存块, 编译器在编译过程中,会创建一张专门的表格用来保存名字以及名字对应的数据类型,地址,作用域信息,使用sizeof就可以从这张表格中查询到符号的长度,这是一个操作符,不是函数。
与普通变量名相比,数组名有一般性和特殊性:- 一般性: 数组名用来指代特定的内存块,有类型和长度
- 特殊性: 数组名有时候会转换为一个指针,而不是它所指代的数据本身的值
-
数组会在特定情况下转换为指针
数组集合包含了多份数据,直接使用一个集合没有明确含义,将数组名转换为指向数组的指针后,可以很容易访问其中任何一份数据。C语言标准规定, 当数组名作为数组定义的标识符(定义或声明数组时)、sizeof或&的操作数时,它才表示整个数组本身,其他表达式中,数组名会转换为指向第0个元素的指针(地址)。
C语言还规定,数组下标与指针偏移量相同,即对数组下标的引用总可以写成"一个指向数组起始地址的指针加上偏移量"。 假设现在有一个数组a和指针变量p,它们定义形式int a={1, 2, 3, 4, 5}, *p, i=2;
可以用好几种方式访问
a[i]
:p=a; p[i];
p=a; *(p+i);
p = a+i; *p;
对数组的引用a[i]
在编译时总是被编译器改写成*(a+i)
形式。取下标操作符[]
建立在指针的基础上,作用是使一个指针和一个整数相加,产生出一个新的指针,然后从这个新指针(新地址)上取得数据。 假设指针的类型是T *
, 所产生的结果类型就是T
。
C语言还规定,作为"类型的数组"的形参应该调整为"类型的指针"。在函数形参定义这个特殊情况下,编译器必须把数组改写成指向数组第0个元素的指针形式。 编译器只向函数传递数组地址,而不是整个数组的拷贝。 这种隐式转换意味着下面三种形式函数定义完全等价:void func(int *parr){} void func(int arr[]){} void func(int arr[5]){}
函数内部, arr会被转成一个指针变量,编译器为arr分配4个字节的内存,用
sizeof(arr)
求得指针变量的长度,而不是数组的长度。如果想在函数内部获得数组长度,必须额外增加一个参数,在调用函数之前求得数组长度。参数传递是一次赋值过程,赋值也是一个表达式,函数调用时不管传递的是数组名还是数组指针,效果都是一样的,相当于给一个指针变量赋值
把作为形参的数组和指针等同起来是处于效率方面的考虑。 数组是若干类型相同数据集合,数据数目没有限制,可能只有几个,可能成千上万,如果要传递数组,无论时间还是内存空间开销都可能非常大。 而绝大部分情况,其实不需要整个数组拷贝,只想告诉函数在那一时刻对哪个特定数组感兴趣
所以呢? 把上面整理的总结下,就是数组和指针可交换性的总结规则:
a[i]
这样的形式对数组进行访问总是会被编译器改写成像*(a+i)
这样的指针形式- 指针始终是指针,绝不可以改写成数组。可以用下标形式访问数组(
[]
作用是使一个指针和一个整数相加,产生出一个新的指针),一般都是指针作为函数参数时,实际传递给函数的是一个数组 - 在数组作为函数形参时, 一个数组和可以看做是一个指针。 作为函数形参的数组始终会被编译器修改成指向数组第一个元素的指针
- 当希望向函数传递数组时,可以把函数参数定义为数组形式,也可以定义为指针,函数内部都要作为指针变量对待
OK, 上面的插曲完事之后, 拉回来进入正轨!!!
上面的模板函数会多出形参len, 也就是在调用交换函数之前必须要先求出数组的长度, 那有没有办法不那么麻烦呢? 那就是借助模板中的非类型参数。 下面看一个完整例子:
template<typename T, unsigned N> void Swap(T (&a)[N], T (&b)[N]);
template<typename T, unsigned N> void printArray(T (&arr)[N]);
int main()
{
int a[5] = {1, 2, 3, 4, 5};
int b[5] = {10, 20, 30, 40, 50};
Swap(a, b);
printArray(a);
printArray(b);
return 0;
}
template<typename T, unsigned N> void Swap(T(&a)[N], T(&b)[N]){
T temp;
for (int i=0; i<N; i++){
temp = a[i];
a[i] = b[i];
b[i] = temp;
}
}
template<typename T, unsigned N> void printArray(T (&arr)[N]){
for(int i=0; i<N; i++){
if(i == N-1){
cout<<arr[i]<<endl;
}else{
cout<<arr[i]<<", ";
}
}
}
T (&a)[N]
表明a是一个引用,引用的数据类型是T[N]
,调用模板函数的时候, 编译器会使用数组类型int
代替类型参数T
,使用数组长度5
代替非类型参数N
。
通过非类型参数设计,只需要传递数组名字就好。
6.2 类模板中使用非类型参数
C/C++ 规定,数组一旦定义后,它的长度就不能改变了;
即数组容量不能动态地增大或者减小。这样的数组称为静态数组。静态数组有时候会给编码代码不便,可以通过自定义的 Array 类来实现动态数组。所谓动态数组,是指数组容量能够在使用的过程中随时增大或减小。
下面是动态数组实现代码看,很大的学习价值哈哈
template<typename T, int N>
class Array{
public:
Array();
~Array();
public:
T & operator[](int i); //重载下标运算符[]
int length() const { return m_length; } //获取数组长度
bool capacity(int n); //改变数组容量
private:
int m_length; //数组的当前长度
int m_capacity; //当前内存的容量(能容乃的元素的个数)
T *m_p; //指向数组内存的指针
};
template<typename T, int N>
Array<T, N>::Array(){
m_p = new T[N];
m_capacity = m_length = N;
}
template<typename T, int N>
Array<T, N>::~Array(){
delete[] m_p;
}
template<typename T, int N>
T & Array<T, N>::operator[](int i){
if(i<0 || i>=m_length){
cout<<"Exception: Array index out of bounds!"<<endl;
}
return m_p[i];
}
template<typename T, int N>
bool Array<T, N>::capacity(int n){
if(n > 0){ //增大数组
int len = m_length + n; //增大后的数组长度
if(len <= m_capacity){ //现有内存足以容纳增大后的数组
m_length = len;
return true;
}else{ //现有内存不能容纳增大后的数组
T *pTemp = new T[m_length + 2 * n * sizeof(T)]; //增加的内存足以容纳 2*n 个元素
if(pTemp == NULL){ //内存分配失败
cout<<"Exception: Failed to allocate memory!"<<endl;
return false;
}else{ //内存分配成功
memcpy( pTemp, m_p, m_length*sizeof(T) );
delete[] m_p;
m_p = pTemp;
m_capacity = m_length = len;
}
}
}else{ //收缩数组
int len = m_length - abs(n); //收缩后的数组长度
if(len < 0){
cout<<"Exception: Array length is too small!"<<endl;
return false;
}else{
m_length = len;
return true;
}
}
}
// 这个地方要用引用
template<typename T, int N>
void show(Array<T, N> &arr, int len){
for(int i=0; i<len; i++){
cout<<arr[i]<<" ";
}
}
int main(){
Array<int, 5> arr;
//为数组元素赋值
for(int i=0, len=arr.length(); i<len; i++){
arr[i] = 2*i;
}
//第一次打印数组
show(arr, arr.length()); // 0 2 4 6 8
cout<<endl;
//扩大容量并为增加的元素赋值
arr.capacity(8);
for(int i=5, len=arr.length(); i<len; i++){
arr[i] = 2*i;
}
//第二次打印数组
show(arr, arr.length()); // 0 2 4 6 8 10 12 14 16 18 20 22 24
cout<<endl;
//收缩容量
arr.capacity(-4);
//第三次打印数组
show(arr, arr.length()); // 0 2 4 6 8 10 12 14 16
cout<<endl;
return 0;
}
Array是一个类模板,有一个类型参数T
和一个非类型参数N
,T
指明数组元素类型, N
指明数组长度。
6.3 非类型参数的限制
非类型参数不能随意指定,只能是一个整数,或者是一个指向对象或函数的指针(或者引用)。
- 当非类型参数是一个整数时,传递给它的实参,或者由编译器推导出的实参必须是一个常量表达式,不能是变量, 比如
n
或者m
这种。 如果上面int len; cin>>len; Array<int, len>arr;
这个是不允许的。 - 当非类型参数是一个指针(引用)时,绑定到该指针的实参必须具有静态的生存期,或者说,实参必须存储在虚拟地址空间中的静态数据区。 局部变量位于栈区,动态创建的对象位于堆区,都不能用作实参。
7. C++模板的实例化
7.1 隐式实例化
模板不是真正的函数或者类, 是编译器用来生成函数或类的一张"图纸", 所以模板也不会占用内存。 由模板生成函数或类的过程叫模板实例化, 针对某个类型生成特定版本函数或类叫模板的一个实例。
模板的实例化是按需进行的, 用到哪个类型,就生成针对哪个类型的函数或者类,不会提前生成过多的代码。 即编译器会根据传递给类型参数的实参生成一个特定版本的函数或类,并且相同类型只生成一次。 实例化类型很简单,把传递到的实参代替所有类型参数即可。
比如, 如果我们声明一个交换的模板函数:
template<typename T> void Swap(T &a, T&b){
T temp;
temp = a;
a = b;
b = temp;
}
这时候,如果要实例化:
int n1=100, n2=200;
Swap(n1, n2);
此时编译器会根据传入的实参类型,先生成一个对应版本的函数:
void Swap(int &a, int &b){
int temp = a;
a = b;
b = temp;
}
然后调用这个函数,完成交换过程。 如果再次传入int类型实参,调用函数, 编译器就只需要调用这个函数即可,不会再重新生成一份。 同样的,假设,这时候,又传入了两个float实参,那么编译器就会再重新生成一个float版本的Swap
函数,完成float类型数据交换, like this:
float n3=12.5, n4=56.9;
Swap(n3, n4);
// 此时编译器会生成一个float版的代码
void float(float &a, float &b){
float temp;
temp = a;
a = b;
b = temp;
}
关于类模板的实例化, 通过类模板创建对象的时候,并不会实例化所有的成员函数,只有等真正调用它们时才会被实例化。 如果一个成员函数永远不会调用,那么就永远不会实例化。 即类的实例化是延迟的,局部的,编译器不着急生成所有代码。
通过类模板创建对象的时候, 一般只需要实例化成员变量和构造函数。
- 成员变量被实例化就能够知道对象的大小(字节数)
- 构造函数被实例化后就能知道如何初始化
对象的创建过程就是分配一块大小已知的内存,并对内存进行初始化。
这里同样看看背后执行过程, 假设我们声明了一个类模板:
template<typename T1, typename T2>
class Point{
public:
Point(T1 x, T2 y): m_x(x), m_y(y){}
T1 getX() const {return m_x;}
void setX(T1 x){m_x = x;}
T2 getY() const {return m_y;}
void setY(T2 y){m_y = y;}
void display() const;
private:
T1 m_x;
T2 m_y;
}
template<typename T1, typename T2>
void Point<T1, T2>::display() const{
cout << "x=" << m_x << ", y=" << m_y << endl;
}
这时候,如果我们要实例化一个对象p1:
Point<int, int>p1(10, 20);
p1.setX(40);
p1.setY(50);
cout << p1.getX() << p1.getY() << endl;
p1.display();
编译器,会先编译一个Point<int, int>
的类,由于对象p1调用了所有的函数,所以, 这个类会被完整的实例化出来。
此时,如果再实例化一个对象p2:
Point<string, string>p2("东经180", "北纬250")
p2.display();
编译器,会先生成一个Point<string, string>
的类,由于对象p2只调用了构造函数和display()
函数,那么其他的set和get函数就不会被实例化。
上面这个实例化过程,是调用函数或创建对象的时候,编译器自动完成的,不需要我们干预, 所以叫隐式实例化。 当然,我们也可以通过代码告诉编译器需要针对哪个类型进行实例化,这就是显式实例化。
但是在看显式实例化之前呢? 还得先通过一个场景引出来,那就是C++模板用于多文件编程。
7.2 将C++模板应用于多文件编程
将函数应用于多文件编程,通常是函数定义放在源文件(.cpp
), 函数声明放在头文件(.h
)中,使用函数时,引入(#include
)对应的头文件即可。 这里先走一个多文件编程小例子, 这个就没法在上面网站里面演示了,此处要请出我的vscode, 关于C++的配置,可以参考我另一篇博文
这里在helloworld.h
里面声明了一个display()
函数, 在helloworld.cpp
里面进行了定义,输出hello world, 然后再主函数里面进行调用的过程,比较简单。 但这里要知道一些原理。
编译时针对单个源文件的,只要有函数声明,编译器就能知道函数调用是否正确, 而将函数调用和函数定义对应起来的过程, 可以延迟到链接时期。 有了链接器的存在, 函数声明和函数定义的分离才得以实现。
将类应用于多文件编程也同理,可以将类的声明和类的实现分别放在头文件和源文件中, 类的声明已经包含了所有成员变量的定义和所有成员函数的声明,这样就知道如何创建对象了, 也知道如何调用成员函数了。 只是还不能将函数调用和函数实现对应起来,因为函数实现过程在源文件中, 将头文件和源文件连接的过程,就是链接器帮助我们实现了。
所以,不管是函数还是类, 声明和定义的分离其实是一回事,都是将函数定义放在其他文件中,最终要解决的问题也只有一个,就是把函数调用和函数定义对应起来(找到函数定义的地址,并填充到函数调用处), 从而保证这项工作的就是链接器。
但是,对于模板要注意, 模板的声明和定义都要放到头文件中, 不能将模板的声明放到头文件, 模板的定义放到源文件。 下面看个反面例子:
7.2.1 函数模板的声明和定义分散到不同文件
看下整体的代码结构:
在func.h中的代码:
template<typename T> void Swap(T &a, T &b);
void bubble_sort(int arr[], int n);
在func.cpp中的代码:
//交换两个数的值
template<typename T> void Swap(T &a, T &b){
T temp = a;
a = b;
b = temp;
}
//冒泡排序算法
void bubble_sort(int arr[], int n){
for(int i=0; i<n-1; i++){
bool isSorted = true;
for(int j=0; j<n-1-i; j++){
if(arr[j] > arr[j+1]){
isSorted = false;
Swap(arr[j], arr[j+1]); //调用Swap()函数
}
}
if(isSorted) break;
}
}
该工程包含了两个源文件和一个头文件, func.cpp中定义了两个函数,func.h中对函数进行了声明, main.cpp中对函数进行了调用,这也是典型函数声明和实现分离的编程模式。
但运行上面程序,会报一个链接错误undefined reference to void Swap<double>(double&, double&)
,也就是找不到double版本函数的定义。 为啥, int版本的能够找到, 而double版本的就找不到定义了呢?
首先, 编译器编译
main.cpp
的时候,发现使用到了double版本的Swap()
函数,于是尝试生成一个double版本的实例,但由于只有声明没有定义,所以生成失败。不过这个时候,编译器不会报错,而是对该函数调用做一个记录,希望等到链接程序时在其他目标文件(.obj或者.o文件)找到该函数的定义。 但遗憾的是,func.cpp
中没有调用double版本的Swap()
函数,编译器不会生成double版本的实例,所以链接器最终也找不到double版本的函数定义,只能抛出一个链接错误。
而int版本的之所以能找到定义,是因为在func.cpp
中有个bubble_sort
函数,这里面调用了int版本的Swap()
函数, 这时候,编译生成的func.obj
中会有一个int版本的Swap()
函数定义。
不能将模板的声明和定义分散到多个文件中的根本原因: 模板的实例化是由编译器完成的,而不是链接器完成的,这可能会导致在链接期间找不到对应的实例。 所以如果修改的话,就把模板的定义合并到func.h里面。
7.3 显式实例化
编译器在实例化的过程中需要知道模板的所有细节,对于函数模板,需要知道函数的具体定义; 对于类模板,需要同时知道类声明和定义。 如果要显式实例化, 就必须将显式实例化的代码放在包含了模板定义的源文件中,这样,就实现了模板的声明和定义的分割(分散在不同文件)。
那么,如何用呢?
就拿上面那个例子来说,只需要在func.cpp
文件中加一个显示实例化的定义即可。
这个的意思,就是告诉编译器将模板实例化一份和该函数原型对应的版本,这样就可以找到double版本的Swap()
函数了。
关于类模板的显式实例化, 同样的, 在对应的.cpp里面加代码:
#include <iostream>
#include "point.h"
using namespace std;
template<class T1, class T2>
void Point<T1, T2>::display() const{
cout<<"x="<<m_x<<", y="<<m_y<<endl;
}
//显式实例化定义
template class Point<string, string>;
template class Point<int, int>;
有两点要注意,第一个就是这里加class,表示针对类模板的实例化, 第二个就是显式实例化类模板时,会一次性实例化该类所有成员,包括成员变量和成员函数。
C++ 支持显式实例化的目的是为「模块化编程」提供一种解决方案,这种方案虽然有效,但是也有明显的缺陷:程序员必须要在模板的定义文件(实现文件)中对所有使用到的类型进行实例化。这就意味着,每次更改了模板使用文件(调用函数模板的文件,或者通过类模板创建对象的文件),也要相应地更改模板定义文件,以增加对新类型的实例化,或者删除无用类型的实例化。
所以, 如果我们开发的模板只有自己使用, 可以勉强用显式实例化,如果希望他人使用(库或者组件), 只能将模板的声明和定义都放到头文件。
8. C++类模板与继承和友元
8.1 模板继承
类模板与类模板之间, 类模板和类之间可以互相继承。
-
类模板继承类模板
template<typename T1, typename T2> class A{ T1 v1; T2 v2; }; template<typename T1, typename T2> class B: public A<T2, T1>{ T1 v3; T2 v4; }; template<typename T> class C: public B<T, T>{ T v5; }; int main() { B<int, double> obj1; C<int> obj2; return 0; }
这个可以捋一下, 比如B对象这一行, 会把
<int, doule>
传到B模板声明的地方,所以T1是int
, T2是double
。编译器会把B相应位置和A相应位置的符号替换。 -
类模板继承模板类
template<class T1, class T2> class A{ T1 v1; T2 v2; }; template <class T> class B: public A <int, double>{T v;}; int main() { B <char> obj1; return 0; } // 这个会自动生成两个模板类: A<int,double>和B<char>
A<int,double>
是一个具体类名字,是一个模板类。 -
类模板继承普通类
class A{ int v1; }; template<class T> class B: public A{ T v; }; int main (){ B <char> obj1; return 0; }
-
普通类继承类模板
template <class T> class A{ T v1; int n; }; class B: public A <int> { double v; }; int main() { B obj1; return 0; }
8.2 模板友元
这里也是四种情况。
-
函数,类,类的成员函数可以作为类模板的友元
void Func1(){} class A{}; class B{ public: void Func(){} }; template<typename T> class Tmp1{ friend void Func1(); friend class A; friend void B::Func(); }; int main(){ Tmp1<int> i; Tmp1<double>f; }
类模板实例化时,除了类型参数被替换,其他所有内容都保留,所以任何从Temp1实例化得到的类都包含上面三条友元声明,会把
Func1
, 类A和B::Func()
当作友元。 -
函数模板作为类模板的友元
template<typename T1, typename T2> class Pair{ private: T1 key; T2 value; public: Pair(T1 k, T2 v): key(k), value(v){}; template <typename T3, typename T4> friend ostream & operator <<(ostream & o, const Pair<T3, T4>& p); }; template<typename T1, typename T2> ostream & operator << (ostream & o, const Pair<T1, T2>& p){ o << "(" << p.key << "," << p.value << ")"; return o; } int main() { Pair<string, int> student("Tom", 29); Pair<int, double> obj(12, 3.14); cout << student << " " << obj; return 0; }
编译该程序, 编译器自动生成了两个operator << 函数,原型分别是:
ostream & operator << (ostream & o, const Pair<string, int> & p); // Pair<string, int>类的友元 ostream & operator << (ostream & o, const Pair<int, double> & p); // Pair<int, double>类的友元
-
函数模板作为类的友元
class A{ public: A(int n): v(n){} template <typename T> friend void Print(const T &p); private: int v; }; template <typename T> void Print(const T &p){ cout << p.v; } int main() { A a(4); Print(a); // 4 return 0; }
编译器到
Print(a)
的时候,实例化出一个Print函数void Print(const A & p);
这个函数本来不能访问p的私有成员,但编译器发现,如果将类A的友元声明中的T换成A,就能起到该Print函数声明为友元的作用,故编译器认为该Print函数时类A的友元。 -
类模板作为类模板的友元
template<typename T> class A{ public: void Func(const T &p){cout << p.v;} }; template<typename T> class B{ public: B(T n): v(n){} template<typename T2> friend class A; // 类模板A声明为友元 private: T v; }; int main() { B<int> b(5); A< B<int> >a; // B<int>替换A模板中的T a.Func(b); // 5 return 0; }
A< B<int> >
类成为B<int>
类的友元。
9. C++类模板的静态成员
类模板中可以定义静态成员, 从该类模板实例化得到的所有类都包含同样的静态成员。但同一个类模板实例化的不同类的静态成员变量不能共享。 这个也很好理解,毕竟是两个不同的类嘛, 举个例子:
class A
{
private:
static int count;
public:
A() { count ++; }
~A() { count -- ; }
static void PrintCount() { cout << count << endl; }
};
template<> int A<int>::count = 0;
template<> int A<double>::count = 0;
int main()
{
A<int> ia;
A<double> da;
A<int> ic;
A<int> id;
ia.PrintCount(); // 3
da.PrintCount(); // 1
ic.PrintCount(); // 3
id.PrintCount(); // 3
return 0;
}
A<int>
和A<double>
是两个不同的类,虽然都有静态成员count,但显然,彼此的对象并不会共享一份count, 而A<int>
系列对象的count会共享一份。