目录
一、C++内存管理方式
1.new和delete操作内置类型
(1)new的初始化
(2)多个数据空间开辟及初始化
2.new和delete操作自定义类型
(1)new和delete的特性
(2)应用场景
3.new与delete、malloc()与free()要匹配使用
4.new与malloc()申请空间失败时的处理
(1)malloc()申请失败时的处理
(2)new申请失败时的处理
5.new与delete底层实现原理
(1)new的原理
(2)delete的原理
(3)new T(N)的原理
(4)delete[]的原理
6.定位new表达式
(1)使用格式
(2)使用场景
7.malloc/free和new/delete的区别
共同点:
不同点:
二、模板初阶
1.函数模板
(1)模板单参数使用形式
(2)函数模板的实例化
(3)模板多参数使用形式
2.类模板
(1)类模板形式
(2)类模板的使用
3.模板声明和定义不可分离
(1)模板声明和定义分离的情况处理
(2)模板声明和定义不分离的情况处理
在C语言中,如果我们想在堆上开辟一个新的空间,那我们就需要使用到malloc()、calloc()和realloc()函数来针对性的开辟空间。但是这几个函数在使用起来都比较麻烦,因此C++针对这一特性,提出了新的内存管理方式:通过new和delete操作符进行动态内存管理
对于new和delete,我们要记住,new和delete是一个运算符或者操作符,也可以将它看成是一个关键字。
new和delete的使用方法很简单,如下所示:
int main()
{
int* p1 = new int;
delete p1;
return 0;
}
仅仅只需要在对应的变量后使用new + 类型即可。而delete的使用方式则是直接delete + 对应变量。通过这种方式,编译器就会自动向系统申请一块对应类型大小的空间和释放空间
但是要注意,这里申请的空间和malloc()一样,是不会进行处理的。可以看成,new的方式申请的空间与malloc()方式申请的空间并无区别:
可以看到,无论是new和malloc()申请的空间,其内部的数据都未进行初始化
1.单个数据空间开辟及初始化
为了解决上述问题,new也提供了初始化选择。只需在申请的空间类型后面加上初始化值即可:
在这里,我们就将对应的空间初始化为了1。可以看见,C++中的new成功将对应的空间数据初始化为了1。在这里,new的初始化相比malloc()就非常的方便。当然,有人说C中也可以使用calloc()进行初始化,但是一方面是比较麻烦,另一方面是new在多个数据初始化上也更有优势
new不仅支持单个空间的开辟,也支持多个空间的开辟。要开辟的多个空间的话只需在对应的数据类型后面加上“[]”,在方括号里面写上开辟个数即可。当然,为了对多个空间进行销毁,在delete后面也需要带上“[]”:
前文我们也说了,callo()在多个数据的初始化上也略逊与new。在new中如果遇到需要对数据进行不同初始化赋值,new就很好用,只需在数据类型后面以“{}”的形式书写即可:
可以看到,通过这种方式,我们便可以手动控制对应的数据空间的初始化值
而如果我们想将该数据空间内的所有数据初始化,那么在花括号里面就什么都不写,编译器会自动帮我们初始化为0:
只要在申请的多个数据空间后面写了{},那么对于那些手动写了初始化值的空间内会初始化为对应的值,没有初始化的数据则会自动初始化为0
在内置类型上,可以说new和malloc()等函数相比,仅仅只是写法上简洁了。但是大家有没有想过,如果new相对于malloc()等函数仅仅只有写法简洁,那么C++委员会根本没有必要写加这个关键字。其实new存在的根本目的是为了适配C++中的类等自定义类型
在C++中new主要作用是对类等自定义类型进行空间开辟。我们都知道,在C++中创建对象时我们需要调用构造函数,而在销毁对象时则需要调用析构函数。而new一个对象时,则会调用对应类的构造函数,delete时则会调用对应类的析构函数
class A
{
public:
A(int a = 0)
:_a(a)
{
cout << "调用构造函数" << endl;
}
~A()
{
cout << "调用析构函数" << endl;
}
private:
int _a;
};
为了方便测试,假设我们现在有以上一个类A
在这里我们什么都没有做,仅仅是创建了A类类型的空间和对其进行销毁。但是这里却打印出了“调用构造函数”和“调用析构函数”。这也就直接证明了new一个对象时,会自动调用对应类的构造函数和析构函数
而malloc()则没有这一特性:
在上图中我们分别用new和malloc()申请了一个A类类型空间,但却只打印了一次。根据上文可以知道这是new的结果。这也就说明了malloc()并不会对类中的构造函数和析构函数自动调用
new的这一特性是非常有用的,例如我们在以前用C语言写一个链表,要单独写一个BuyListNode()函数来负责开辟空间,并且在开辟的空间里面我们还需要进行检查等一系列操作,用起来非常麻烦。而现在用new的话,就会变得非常方便:
struct ListNode
{
ListNode* _next;
int _data;
ListNode(int data)
:_next(nullptr)
,_data(data)
{}
};
int main()
{
ListNode* p1 = new ListNode(0);
ListNode* p2 = new ListNode(1);
ListNode* p3 = new ListNode(2);
ListNode* p4 = new ListNode(3);
p1->_next = p2;
p2->_next = p3;
p3->_next = p4;
return 0;
}
要注意,在C++中的struct并不是C中的结构体,而是类的一种,与class的区别就是类成员默认为public。因此创建时也是需要调用构造函数的。在这里我们仅仅是创建了一个构造函数。但是在创建节点时我们却只需要new一个对象即可。在节点连接时可以直接调用_next来与下一个节点相连接:
这比起写BuyListNode()去申请空间就非常的方便
在使用这些操作符、函数时,一定要匹配使用。如果不匹配,就可能出出现各种各样的错误,严重时甚至可能出现内存泄漏。如下面的代码:
以上三种方式都是不匹配使用,在这些情况下是否会出问题去取决于其底层实现,而底层实现又是由编译器实现的。因此在某些编译器下这样写不会有问题,但在另一些编译器中可能就会有问题。因此,在使用时都要匹配使用
malloc()函数我们已经很熟悉了,在malloc()函数中,如果申请的内存空间过大或过多导致没有足够的内存时就会申请失败。而当malloc()申请失败时,就会返回一个空指针。基于这一特性,对于malloc()我们通常以如下方式进行检查:
int* p1 = (int*)malloc(sizeof(int));
if (p1 == nullptr)
{
perror("malloc fail");
exit(-1);
}
new不同于malloc()申请失败返回空指针,而是抛出异常。我们现阶段还不了解异常,在这里可以将其简单的看做一个处理机制。既然new申请失败不会返回空指针,那么就意味malloc()的检查方式对new是无效的
为此,对于new抛出的异常,我们就需要进行捕获异常,以如下方式捕获:
try
{
while (1)
{
char* p1 = new char;
}
}
catch (exception& e)
{
cout << e.what() << endl;
}
虽然我们还不了解它的具体使用和机制,但我们现在可以按照上面的格式先用着,等到后面再具体了解异常机制
1.调用operator new函数申请空间
注意这里虽然用了operator,但是并不是运算符重载,而是函数名。可以将其看做一个特殊的函数名,不要与运算符重载弄混淆,下面的operator同理。
2.在申请的空间上执行构造函数,完成对象的构造
1.在空间上执行析构函数,完成对象中资源的清理
2.调用operator delete函数释放空间
1.调用operator new[]函数,在operator new[]中实际调用operator new函数完成N个对象空间的申请
2.在申请的空间上执行N次构造函数
1.在释放的对象空间上执行N此析构函数,完成N个对象中资源的清理
2.调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放空间
该内容仅仅了解即可,不必深入
定位new表达式是在已分配的原始内存空间中调用构造函数初始化一个对象
简单来讲,就是对一块我们已经申请好的空间,显示调用构造函数初始化
new(place_adress) type或者new(place_address) type(initializer-list)。注意,这里的place——address必须是一个指针;initializer-list是类型的初始化列表
1.new(place_adress) type
可以看到,这里使用的malloc()开辟的空间是没有初始化的。但是这里我们用new(p1)A的方式,使这块空间显式调用了A类的构造函数进行初始化
2.new(place_address) type(initializer-list)
这种方式也是与第一种不同,第一种是不传参显式调用构造函数,第二种方式new(p1)A(1)则是自己传初始化值显式调用构造函数
定位new表达式在实际中一般是配合内存池使用,因为内存池分配出的内存没有初始化,所以如果是自定义类型的对象,需要使用new的定义表达式进行显示调用构造函数进行初始化
内存池我们现在还没有接触过。简单来讲,是因为在实际中如果每次需要空间都向系统申请,会拉低运行效率。为了解决这一问题,便提出了内存池的概念。即一次性申请大量内存空间,每次需要申请空间时便从内存池中拿即可,无需再向系统申请。因为此时我们的知识储备并不能完全理解内存池,所以这里就仅仅只是提一下
都是从堆上申请空间,并且需要用户手动释放
1.malloc和free是函数;new和delete是操作符
2.malloc申请的空间不会初始化;new可以选择是否初始化
3.malloc申请空间时,需要手动计算空间大小并传递;new只需在其后面跟上空间的类型即可,如果是多个对象,[]中指定对象个数即可
4.malloc的返回值为void*,在使用时必须强转;new不需要,因为new后跟的是空间的类型
5.malloc申请空间失败时,返回的是NULL,因此使用时必须判空;new不需要,但是new需要捕获异常
6.申请自定义类型对象时,malloc/free只会开辟空间,不会调用构造函数和析构函数;而new在申请空间后会调用构造函数完成对象的初始化,delete在释放空间前会调用析构函数完成空间中资源的清理
在我们的日常学习中,如果我们要做一个ppt,一般都会去网上找一个ppt模板,按照这个模板的壳将内容套进去。C++中模板的作用也是一样的
以swap()函数为例。在以前我们提到过,对于写多个逻辑相同,参数不同的swap()函数,我们可以用函数重载的机制来让函数名相同,这样在使用时就像使用一个函数一样。虽然函数名方便了,但是假如我们要交换int,double,float、char等各种各样的类型时,就要写对应份数的函数代码。这样就会显得代码很冗杂。为了解决这一问题,C++就提出了模板机制
模板是泛型编程的基础。泛型编程则是编写与类型无关的通用的代码,是代码复用的一种手段
函数模板的使用需要使用template
1.传入参数类型相同
template
T Swap(T& left, T& right)
{
T tmp = left;
left = right;
right = tmp;
}
可以看到,这里的T替代了原来的变量类型的位置。通过这种方式,这个swap()函数就可以适应不同参数的传入:
在这里我们分别传入了int,double,char三种类型,但只写了void Swap(T& left, T& right)一个函数模板。在写好这个模板后,编译器回根据我们传入的数据类型,自动生成对应的函数。要注意,这里看似只有一个函数模板,但是在编译器中生成了三个不同的函数。原因就在于调用函数要建立栈帧,函数所操作的数据类型都不同,建立的栈帧大小也不同。这里只是编译器帮我们生成了对应类型的函数,无需我们自己写
可以看成如上图所示,这个过程叫做“函数模板的实例化”
2.传入参数类型不同
当我们传入的参数类型不同时,这里就会直接报错。有人可能会疑惑,正常来讲,传入参数类型不同时会发生类型提升,那这里怎么没有进行类型提升呢?原因是类型提升是在传参或赋值时进行的。
函数模板要根据传入的类型判断T的类型,但是传入了两个不同的变量类型,这就导致函数模板无法判断要生成哪种类型的函数,进而函数模板就会在推演实例化时报错
这种情况我们可以用强制类型转换解决。
template
T Add(const T& left, const T& right)
{
cout << left + right << endl;
return left + right;
}
假设我们现在有以上函数模板,那么我可以以强制类型转换的方式在函数实例化之前先将对应的变量类型转换。
可以看到,这样我们的函数模板就可以正常运行了。但是这样是有局限性的。这里我们使用的是传引用,因为强制类型转换并不是改变原变量的类型,而是生成一份原变量的拷贝,改变这份拷贝的数据类型,而临时拷贝具有常性,导致函数参数部分必须要有const修饰,否则就会报错。当然如果我们不用传引用的方式就无需加const。但是像swap()这种需要修改原值的函数模板依然无法使用强制类型转换
要解决上述问题,一个方式就是利用函数模板的实例化
用不同的类型的参数使用函数模板时,称为函数模板的实例化,模板参数实例化分为:隐式实例化和显式实例化
1.隐式实例化
让编译器根据实参推演模板参数的实际类型
这种方式就是上文中的实例化方式,让编译器来推演参数类型,我们看不到实例化的过程
2.显式实例化
显示实例化就是我们在编译器生成函数之前告诉编译器要生成的函数类型,如下图:
template
T Add(const T& left, const T& right)
{
cout << left + right << endl;
return left + right;
}
这种方式就是告诉编译器所有传入的参数是什么类型,类型不同的进行强转后再传入
但这种方式是运用了隐式类型强制转换,依然无法解决类型不同的需要改变原值的函数,如swap()
为了应对多种情况,模板中的参数是可以存在多个的:
template
由此,swap()函数便可以通过传多参数的形式解决
用多模板参数的形式,我们就无需手动强制转换或显示实例化了
模板更重要的意义是运用在类中
以以下的栈为例子:
typedef int StackDateType;
class Stack
{
public:
Stack(int capacity = 4)
{
_data = (StackDateType*)malloc(sizeof(StackDateType) * capacity);
if (_data == nullptr)
{
perror("malloc fail");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
void Push(StackDateType x)
{
//……空间不足申请空间
_data[_top++] = x;
}
private:
StackDateType* _data;
int _top;
int _capacity;
};
在以前,我们写一个栈都是会用到typedef来重定义变量类型名称。这样当我们需要改变传入的数据类型时,直接修改typedef的内容即可。但是,我们在实际中也可能遇到这样的情况:在同一个文件中,我们需要用一个传int数据的栈,也需要一个传double数据的栈。在这种情况下,typedef就无法满足需要。同时,由于C++并不支持类的重载,类名还需要取不同的名称。更为重要的是,我们想要满足该需求,就不得不将这个栈拷贝一份并修改成不同的类型。这样的话我们的工作量就会很大,而且也会导致文件中有大量的重复代码,造成代码冗杂。
为了解决上述问题,类模板便诞生了
template
class Stack
{
public:
Stack(int capacity = 4)
{
_data = (T*)malloc(sizeof(T) * capacity);
if (_data == nullptr)
{
perror("malloc fail");
exit(-1);
}
_top = 0;
_capacity = capacity;
}
void Push(const T& x)
{
//……空间不足申请空间
_data[_top++] = x;
}
private:
T* _data;
int _top;
int _capacity;
};
在这里,我们直接使用template
类模板的使用与函数模板不同。函数模板可以用隐式类型实例化的方式让编译器来推参数类型,但是类模板必须要显式类型实例化:
通过类模板的方式,我们只需要写一遍对应类的代码,就可以创建传不同类型的参数的对象。当然,类模板也是支持多模板参数的,这里就不再演示
无论是函数模板还是类模板,声明和定义都是不可分离的。假设现在我们有以上一个定义在.h头文件中的类模板:
template
class Stack
{
public:
Stack(int capacity = 4);
void Push(const T& x);
~Stack();
private:
T* _data;
int _top;
int _capacity;
};
此时我们想将它的成员函数声明和定义分离,那么.cpp中的每个成员函数就必须要加上template
template
void Stack::Push(const T& x)
{
//……空间不足开辟空间
_data[_top++] = x;
}
template
Stack::~Stack()
{
delete _data;
_data = nullptr;
_top = _capacity = 0;
}
要记住,无论这个成员函数有没有用到模板类型,都要在前面加上模板声明和在类域中加上模板类型
可以看到,在上图中报错了LNK201,LNK112,LNK就表示是链接错误。而链接出现错误基本都是因为声明无法找到定义。
而此处出现链接错误的原因就在于类模板的实例化。在编译链接中,在.h头文件中的类模板会先进行实例化,但这里实例化出来的仅仅只是声明,.cpp文件中的成员函数因为不知道要生成声明类型便没有实例化。这就会导致声明去对应的地址找不到对应的函数,出现链接错误。
要解决这一问题很简单,便是在对应的.cpp文件中提前显式实例化,只需在对应的.cpp文件中加上下述代码即可,这里的Stack是类名,可以随意修改:
template class Stack;
缺陷:
但是这种方式是有缺陷的,因为这种方式就意味着在每个有类模板成员函数的.cpp文件中都要加上这行代码。并且这行代码仅仅只能实例化出一种类型,要适配多种类型就要重复写这行代码并实例化为不同类型。那有人可能会说那直接把所有可能的内置类型都实例化就可以了。但是传过来的不仅可能是内置类型,也可能是自定义类型,自定义类型的名字由使用者决定,我们并不知道。这就意味着这种方式不仅繁琐,而且无法适配自定义类型
声明和定义不分离的话就要将定义写在.h头文件中,如上图所示。这样写的话在编译时便会直接在头文件中对函数和声明实例化。
在头文件中的成员函数最好写在类外面,这样在类中找函数和看函数时方便点。当然,对于那些代码较少且经常使用的可以直接定义在类里面,因为类中的成员函数默认为inline函数。
成员函数写在类外的话就必须要给每个成员函数加上模板声明和模板类型
通过这种方式定义的头文件的文件名后缀一般会改为.hpp。.hpp和.h在编译器中没有任何区别,只是为了表明该头文件中有或只有类模板或类模板函数