【C++】内存管理 + 初识模板
文章目录
- 📖前言
- 1. C/C++内存管理
- 1.1 C语言的内存管理回顾:
- 1.2 C++的内存管理:
- 1.3 C++开空间失败了的情况:
- 1.4 operator new 和 operator delete:
- 1.5 定位new:(了解)
- 2. 模板
- 2.1 模板的引入:
- 2.2 函数模板的使用:
- 2.3 类模板的使用:
- 2.4 模板和函数的联系和区别:
- 2.5 函数模板/类模板的声明和定义分离:
- 2.6 模板的隐式类型转换:
- 2.7 多个模板参数:
📖前言
本章将介绍C++的内存管理方式和泛型编程思想中的模板…
1. C/C++内存管理
1.1 C语言的内存管理回顾:
在我们之前学C语言的过程中,已经接触过了动态内存管理,我们当时用的是使用C语言的方式。
- malloc
void * malloc (size_t size)
- 这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针。
- 如果开辟成功,则返回一个指向开辟好空间的指针。
- 如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查。
- 返回值的类型是 void* ,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自
己来决定。 - 如果参数 size 为0,malloc的行为是标准是未定义的,取决于编译器。
同时配合着free函数一起使用,申请 — 释放空间
void free (void * ptr);
- 如果参数 ptr 指向的空间不是动态开辟的,那free函数的行为是未定义的。
- 如果参数 ptr 是NULL指针,则函数什么事都不做。
- calloc
void * calloc (size_t num, size_t size);
- 函数的功能是为 num 个大小为 size 的元素开辟一块空间,并且把空间的每个字节初始化为0。
- 与函数 malloc 的区别只在于 calloc 会在返回地址之前把申请的空间的每个字节初始化为全0。
- realloc
void * realloc (void ptr, size_t size);*
- ptr 是要调整的内存地址
- size 调整之后新大小
- 返回值为调整之后的内存起始位置。
- 这个函数调整原内存空间大小的基础上,还会将原来内存中的数据移动到新的空间
情况1:原有空间之后有足够大的空间,就地扩容。
情况2:原有空间之后没有足够大的空间,异地扩容,需要改变ptr指针。
1.2 C++的内存管理:
C语言内存管理方式在C++中可以继续使用,但有些地方就无能为力,而且使用起来比较麻烦,因
此C++又提出了自己的内存管理方式:通过new和delete操作符进行动态内存管理。
1.new 和 malloc的区别:
- 对于内置类型而言,用malloc和new,除了用法不同,没有本质区别
- 它们区别在于自定义类型
- malloc只开空间,new开空间 + 调用构造函数初始化
图解:
2.正常使用的代码如下:
struct ListNode
{
ListNode* _next;
int _val;
ListNode(int val = 0)
:_next(nullptr)
,_val(val)
{}
};
ListNode* BuyListNode(int x)
{
struct ListNode* node = (struct ListNode*)malloc(sizeof(struct ListNode));
assert(node);
node->_next = NULL;
node->_val = x;
return node;
}
int main()
{
int* p1 = new int;//一个对象
int* p2 = new int[10];//多个对象
int* p3 = new int(3);//new一个int对象,初始化成10
//int* p4 = new int[10](10); - 不能这样,是错的
int* p4 = new int[10]{ 10 };//new10个int对象,初始化成{}中的值
//C++11支持的语法 - 初始化的是第一个
delete p1;
delete[] p2;
//释放的时候要匹配
//不匹配不一定会内存泄漏,但是有可能会崩溃
//建议一定要匹配
delete p3;
delete[] p4;
//BuyListNode是开空间加初始化
struct ListNode* n1 = BuyListNode(1);
//new是
ListNode* n2 = new ListNode(2);//会去调用该类的构造函数
return 0;
}
释放的时候要匹配,不然有可能出问题:
3.new 和 delete 的特点:
C++内存管理和C语言中内存管理的区别:不在于内置类型,而是在于自定义类型。
- malloc/free 和 new/delete 的区别在于
- malloc/free是函数,new/delete是关键字
- 在申请自定义类型的空间时,new会调用构造函数,delete会调用析构函数,而malloc与free不会。
(1)基本使用1:
class Stack
{
public:
Stack(int capacity = 10)
{
cout << "Stack(int capacity = 10)" << endl;
_a = new int[capacity];
_capacity = capacity;
_top = 0;
}
~Stack()
{
cout << "~Stack()" << endl;
delete[]_a;
_capacity = 0;
_top = 0;
}
void Push(int x)
{}
private:
int* _a;
int* _top;
int _capacity;
};
int main()
{
//Stack st;
Stack* ps1 = (Stack*)malloc(sizeof(Stack));
assert(ps1);
Stack* ps2 = new Stack;//开空间+调用构造函数初始化
free(ps1);
delete ps2;//调用析构函数清理资源+释放空间
return 0;
}
- ps1和ps2是两个指针,指向一段动态开辟的空间
- new会开空间进行初始化,调用构造函数初始化
- ps1都不好初始化,因为类中的成员变量是私有的
(2)基本使用2:
用两个栈实现队列:
class Stack
{
public:
Stack(int capacity = 10)
{
cout << "Stack(int capacity = 10)" << endl;
_a = new int[capacity];
_capacity = capacity;
_top = 0;
}
~Stack()
{
cout << "~Stack()" << endl;
delete[]_a;
_capacity = 0;
_top = 0;
}
void Push(int x)
{}
private:
int* _a;
int* _top;
int _capacity;
};
class MyQueue
{
private:
Stack _pushT;
Stack _popT;
};
int main()
{
MyQueue* obj = new MyQueue;
delete obj;
return 0;
}
- new创造一个MyQueue的对象,并且调用其构造函数,因为MyQueue这个类只有自定义类型,直接调用它的默认构造函数,默认构造
- delete一个MyQueue对象的时候,调用其析构函数,因为该没有显示写析构函数,所以只能调用默认的析构函数
总结:
- 之前C语言释放这种类型的时候要先将MyQueue对象中的两个栈先释放掉,再去释放队列这个对象否则会发生内存泄漏。
- 而C++直接delete就可以了,是因为它去调用了MyQueue对象的析构函数,析构函数自己去一层一层的释放了空间
1.3 C++开空间失败了的情况:
- 在我们之前学的C语言中,我们知道,当malloc开辟空间失败了之后,会返回一个空指针,所以用malloc之后,我们要对返回的指针进行判空。
- 但是C++中的new是不需要判空的,在其开辟失败的时候会抛异常。
在堆上开一个G,大概率会开失败。
void func()
{
//malloc失败,返回空指针
Stack* ps1 = (Stack*)malloc(sizeof(Stack));
assert(ps1);
//new失败,抛异常
Stack* ps2 = new Stack(4); // 开空间+调用构造函数初始化
void* p0 = malloc(1024 * 1024 * 1024);
cout << p0 << endl;
void* p1 = malloc(1024 * 1024 * 1024);
cout << p1 << endl;
void* p2 = new char[1024 * 1024 * 1024];
cout << p2 << endl;
}
int main()
{
//捕获异常
try
{
func();
}
catch (const exception & e)
{
cout << e.what() << endl;
}
return 0;
}
1.4 operator new 和 operator delete:
operator new 和 operator delete,是C++标准库中的库函数,不是符号重载,C++中设计反常的地方。
new和delete是用户进行动态内存申请和释放的操作符,operator new 和operator delete是系统提供的全局函数。
new在底层调用operator new全局函数来申请空间,delete在底层通过,operator delete全局函数来释放空间。
- operator new 是封装了malloc,malloc失败了就抛异常。
- operator delete 也进行了封装抛异常检查等,最终调用了_free_dbg,而C语言的free其实一个宏函数,它也是调用了_free_dbg,所以也能理解成operator delete 封装了free。
使用:
int main()
{
//跟malloc功能一样,但是失败以后抛异常
Stack* ps2 = (Stack*)operator new(sizeof(Stack));
operator delete(ps2);
Stack* ps1 = (Stack*)malloc(sizeof(Stack));
assert(ps1);
free(ps1);
Stack* ps3 = new Stack;
//call operator new
//call Stack构造函数
//面向对象编程不再用返回值的方式来处理,它们更喜欢抛异常
return 0;
}
跟malloc功能一样,但是失败以后抛异常,不用检查失败。
- operator new 和 operator delete没有直接价值的,它们是由间接价值的,是nwe的底层原理。
- new的底层原理是调用operator new 和构造函数。
我们来看一下汇编:
由汇编可见上述结论。
总结见下图:
使用delete的时候一定要要匹配去使用,不然有可能会崩溃
int main()
{
int* p = new int[10];//对于内置类型不涉及调用构造函数
//要调用operator malloc和malloc机制一样的
delete p;//调用operator delete 也就是调用free
Stack* stArray = new Stack[10];//要调用十次构造函数和十次析构函数
delete stArray;//要调用十次析构函数,这里只调用一次,崩了和底层实现的逻辑有关
//不匹配可能没问题,可能会有问题
//所以一定要匹配
return 0;
}
我们来看一下汇编:
- new T[N]的原理:
- 调用operator new[]函数,在operator new[]中实际调用operator new函数完成N个对
象空间的申请。 - 在申请的空间上执行N次构造函数。
- delete[]的原理:
- 在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理。
- 调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释
放空间。
既然这里是对应的那么不匹配会出现什么问题呢?
有些不匹配不会报错,而有些不匹配就要报错,所以建议是匹配的,如上代码,就是不匹配的情况,就会报错。
- 对于内置类型:
- int* p = new int[10];对于内置类型不涉及调用构造函数
- 要调用operator malloc和malloc机制一样的
- 对于自定义类型:
- Stack* stArray = new Stack[10];要调用十次构造函数和十次析构函数
- delete stArray;要调用十次析构函数,这里只调用一次,崩了和底层实现的逻辑有关,便宜指针不对就会报错,了解即可
不匹配可能没问题,可能会有问题,所以一定要匹配。
1.5 定位new:(了解)
定位new表达式是在已分配的原始内存空间中调用构造函数初始化一个对象。
int main()
{
Stack* obj = (Stack*)operator new(sizeof(Stack));
//针对一个空间,显示调用构造函数初始化
//obj->Stack(); -- 构造函数又调不动,不让显示调用,是自动调用的
//bbj->_top = 0; -- 私有成员不能访问
//这是用到一个定位new
new(obj)Stack(4);
//等价于Stack* obj = new Stack(4);
//用new直接就调用堆了
return 0;
}
- new(obj)Stack(4);
- 等价于Stack* obj = new Stack(4);
使用格式:
- new (place_address) type或者new (place_address) type(initializer-list)
- place_address必须是一个指针,initializer-list是类型的初始化列表
使用场景:
定位new表达式在实际中一般是配合内存池使用。因为内存池分配出的内存没有初始化,所以如
果是自定义类型的对象,需要使用new的定义表达式进行显示调构造函数进行初始化。
内存泄漏指的是:指针丢了,不是内存丢了。
普通的内存泄露不怕,进程只要正常结束的,申请的内存会还给操作系统。
2. 模板
2.1 模板的引入:
我们如何实现一个通用的交换函数?
- 经过我们之前的学习,我们知道可以使用函数重载:
使用函数重载虽然可以实现,但是有一下几个不好的地方:
- 重载的函数仅仅是类型不同,代码复用率比较低,只要有新类型出现时,就需要用户自己增加对应的函数。
- 代码的可维护性比较低,一个出错可能所有的重载均出错,那能否告诉编译器一个模子,让编译器根据不同的类型利用该模子来生成代码呢?
模板的处理是在编译阶段处理的。
- C++提出的编程思想叫泛型编程,不再是针对某种类型,能适应广泛类型,跟具体类型无关的代码。
- 而泛型编程所用的东西叫做 — 模板
- 模板分为:函数模板 和 类模板
2.2 函数模板的使用:
交换函数模板的实现:
//函数模板可以自动推导
//template<class T>
template<typename T>
void Swap(T& left, T& right)
{
T tmp = left;
left = right;
right = tmp;
}
int main()
{
int a = 0, b = 1;
double c = 2.2, d = 3.3;
//调用的不是同一个函数
swap(a, b);
swap(c, d);
return 0;
}
函数模板是一个蓝图,它本身并不是函数,是编译器用使用方式产生特定具体类型函数的模具。所以其实模板就是将本来应该我们做的重复的事情交给了编译器。
看一下反汇编:
- 编译器是根据参数的类型通过模板推导出所需要的函数。
- 上图可见,两个函数所调用的函数地址不一样,所以不是调用同一个函数
- 模板函数,具体需要什么编译器实例化出来什么样子
- 函数模板是没有地址的
函数模板可以自动推导:
std C++ 标准库中就有交换函数的模板,以后用到交换直接用库里的模板即可。
2.3 类模板的使用:
在之前的数据结构中我们学习过:栈
用C语言实现栈:
//C语言实现栈
typedef int STDatetype;
class Stack
{
public:
Stack(STDatetype capacity = 10)
{
_a = new STDatetype[capacity];
_capacity = capacity;
_top = 0;
}
~Stack()
{
delete[]_a;
_capacity = 0;
_top = 0;
}
void Push(int x)
{}
private:
STDatetype* _a;
STDatetype* _top;
int _capacity;
};
int main()
{
Stack st1;//一个栈存储int
Stack st2;//一个栈存储double
//C语言需要手动修改typedef的内容
return 0;
}
C语言的缺陷:
C语言的方式实现栈的话,要改变栈里面存储数据的类型,只能通过typedef来改变,但是只能是st1和st2两个栈存储的类型都是同一种类型的,若是一个栈存Int类型的数据,一个栈存double类型的数据是不可以的,这就是C语言的缺陷。
C++用类模板解决了这个问题:
//类模板 - 模板参数一般习惯用大写,不一定只能用T
template<class T>
class Stack
{
public:
Stack(int capacity = 10)
{
_a = new T[capacity];
_capacity = capacity;
_top = 0;
}
~Stack()
{
delete[]_a;
_capacity = 0;
_top = 0;
}
void Push(T x)
{}
private:
T* _a;
int* _top;
int _capacity;
};
int main()
{
Stack<int> st1;//一个栈存储int
Stack<double> st2;//一个栈存储double
//C语言需要手动修改typedef的内容
return 0;
}
2.4 模板和函数的联系和区别:
一定说函数模板是推演的,类模板就是一定是指定的吗?
答案是也不一定。
见如下代码:
//模板参数 -- 很多用法和函数参数是很像的
//模板参数 -- 传递的是类型
//函数参数 -- 传递的时对象值
template<class T = char>
T* func(int a)//T的类型推不出来了
{
return new T[n];
}
int main()
{
//函数模板的显式实例化
int* p1 = func<int>(10);
double* p2 = func<double>(10);
return 0;
}
这个时候就要显式实例化了,因为这个T的类型是不能被推导出来的。
用模板替换的过程叫做实例化
1、函数模板的类型一般是编译器根据实参传递给形参,推演出来的,如果不能自动推演,那么我们就需要显示实例化,指定模板参数。
2、类模板的类型显示实例化,明确指定的
模板参数可以是typename也可以是class,切记不能是struct。
模板参数是可以有缺省参数,template< class T = char>
2.5 函数模板/类模板的声明和定义分离:
函数模板声明和定义的分离:
//声明的时候给模板参数
template<typename T>
void Swap(T& left, T& right);
//定义时候也给模板参数
template<typename T>
void Swap(T& left, T& right)
{
T tmp = left;
left = right;
right = tmp;
}
如果每个函数模板的声明定义都分离,那么每个函数定义前都要加上声明模板参数。
类模板声明和定义的分离:
//声明的时候给模板参数
template<typename T>
class Vector
{
public:
Vector<T>(size_t capacity = 10);
private:
T* _pDate;
size_t _size;
size_t _capacity;
};
//定义的时候也给模板参数
template<typename T>
Vector<T>::Vector<T>(size_t capacity)
:_pDate(new T[capacity])
,_size(0)
,_capacity(capacity)
{}
类外定义要指定类域。
注意:模板是不支持声明和定义放在两个文件当中的,会出现链接错误。
原因是:分离的话,模板实例化不出对应的函数,但是编译时可以通过的,因为声明中有模板的声明,最后符号表重定位的时候,找不到对应的函数模板调用的地址。
补充:
- 模板不支持声明和定义分别放到xxx.h和xxx.cpp中
- 一般是要放到一个文件中。有些地方就会命名成
- xxx.hpp,寓意就是头文件和定义实现内容合并一起.
- 但是并不是必须是.hpp, .h也是可以的
- 解决方案1:在template.cpp中针对于要使用的模板类型显示实例化
- 解决方案2:在不要分离到两个文件中。直接写在xxx.hpp或xxx.h中
这样就能将函数实例化出来,在编译的时候就能call这个函数的地址了,就不需要链接的时候去找了。
2.6 模板的隐式类型转换:
直接见代码:
template<class T>
T Add(const T& left, const T& right)
{
return left + right;
}
int main()
{
int a1 = 10, a2 = 20;
double d1 = 10.0, d2 = 20.0;
Add(a1, a2);
Add(d1, d2);
//Add(a1, d2); - 错误的写法,编译器推不出来函数(自身是不能矛盾的)
//两种解决方法:
//隐式类型转换
Add<int>(a1, d2);
Add<double>(a1, d2);
//强转
Add(a1, (int)d2);
Add((double)a1, d2);
return 0;
}
2.7 多个模板参数:
直接见代码:
代码1:
//多个模板参数
template<class K, class V>
void Func(const K& key, const V& value)
{
cout << key << ":" << value << endl;
}
int main()
{
Func(1, 1);
Func(1, 1);
Func<int, char>(1, 'A');//只能连着指定,除非有却省模板参数
return 0;
}
代码2:
template<class K = char, class V = char>
void Func()
{
cout << sizeof(K) << endl;
cout << sizeof(V) << endl;
}
int main()
{
//类比函数的参数去学习,一个是类型,一个是变量/对象
Func<int, int>();
Func<int>();
Func();
return 0;
}
- 缺省是从右往左的,可以全缺省,也可以半缺省
- 因为传参是从左往右传的
- 和函数一样去理解就行了