摘要:本文首先介绍 C++ 的内存模型和变量周期作为知识背景,接着对C++中的引用和指针(原始指针和智能指针)进行介绍。
什么是对象生命周期?简单来说,对象生命周期指的是:对象从创建直到被释放的时间跨度。很自然地,我们会意识到不是所有变量的创建方式和释放时间都是一样的,据此,我们把对象的生命周期氛围四种类型:静态存储周期、线程存储周期、自动存储周期和动态存储周期。
静态存储周期类型的对象在程序执行开始的时候就会分配内存,直至整个程序结束了才会释放。主要包括:全局变量、静态的类数据成员和函数中的局部变量,如下例子所示:
// 1. 全局变量
int global_var = 10;
// 2. 类中的静态数据成员
class MyClass{
static int static_var_class;
}
// 3. 函数中的静态局部变量
int myFunc(){
static int static_var_func;
}
线程存储周期类型的对象只会在指定的线程内进行内存的分配与释放。在多线程编程中,为了避免数据紊乱,可以使用该方法,当然以下提及的自动存储周期严格来说也能算是线程周期,只不过这个线程是代码默认的主线程,因此不需要额外标注。如下例子展示如何使用线程存储周期的变量:
thread_local int thread_var;
自动存储周期类型的对象只会存在于其被声明和定义的作用域内,一旦退出作用域则自动释放,如函数的参数或者其内部定义的局部变量。这是最常见或者说默认的定义类型,不需要额外的关键字,以下例子展示的是函数内部定义的局部变量:
void myFunction() {
int local_var; // Automatic storage duration
}
动态存储周期类型的对象在程序执行的时候通过关键字 new 或 malloc 实时分配内存,直至整个作用域退出也不会自动释放内存,必须通过使用关键字 delete 或 free 函数进行手动释放,否则会造成内存泄漏的问题。如下例子展示如何分配和释放一个原始指针变量:
int* ptr = new int; // 定义一个原始指针
delete ptr; // 释放原始指针
内存泄漏:指的是程序从堆分配内存但是不把内存释放到操作系统中,导致内存耗尽或者程序奔溃。以下一个例子展示内存泄漏:
void memory_leak(){
int* ptr = new int[100]; // 创建一个原始指针并指向一个整型数组
// 其他功能代码 ...
// 确实手动释放内存操作: delete[] ptr;
} // 导致内存泄漏:函数作用域结束了,ptr 指针已经没用了,但是没有释放内存
除了通过使用关键字 delete 或 free 函数进行手动释放外,还可以通过智能指针、RAII(Resource Acquisition Is Initialization)和C++标准库的容器(vector)来进行自动释放。
为什么需要了解C++的内存模型?内存模型定义了如何在C++去存储和使用数据(与变量的生命周期对应),了解C++的有利于优化对内存资源的使用和整个程序的表现。
C++的数据模型主要包括四个部分:栈、堆、数据段和代码段。
自动生命周期的变量,如函数参数或局部变量都是使用栈的形式进行存储的。栈内存通过编译器进行管理,可以实现自动分配和释放。根据栈 先进后出 的特定,很容易知道最后定义的变量其实是最先释放的。
堆内存则被用于动态生命周期变量,如通过 new 关键字手动定义的对象。根据堆 logn 的查找复杂度特定,很容易知道由堆内存管理的对象存储空间更大,但查找速度更快。
数据段包括两个部分:初始化数据段和未初始化数据段。两者的区别在于是否在变量声明的同时做定义,数据段主要包括:全局变量、静态变量和常变量。以下例子进行简单解释:
// 初始化数据段
int global_var = 10; // 全局变量
static int static_var = 20; // 静态变量
const int const_var = 30; // 常变量
// 未初始化数据段
int global_var2; // 只是声明了变量,但是没有对取值进行初始化定义
代码段,也成为文本段,用于存储程序的可执行代码(机器语言),通常存在仅读的内存,防止被意外修改;
引用,常跟别名联系在一起,变量的引用和变量本身共享同一块内存,修改变量的引用时,变量本身的取值也会发生改变,通俗来说,两者只是一个对象的两个名字而已,故称为别名。在另一个角度,引用操作可以看作一个常指针,一旦这个常指针存储了地址,这个地址是无法修改的。
int raw = 10; // 原始变量
int& ref = raw; // 创建一个引用变量 ref 指向(引用)变量 raw
raw = 20;
std::cout << "ref is: " << ref << endl; // 修改原数据的取值,引用变量的取值也会发生变换,反之亦然;
void swap(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
int main() {
int x = 5, y = 10;
cout << "Before Swap: x = " << x << " y = " << y << endl; // Outputs 5 10
swap(x, y);
cout << "After Swap: x = " << x << " y = " << y << endl; // Outputs 10 5
}
指针,本质上也是一个变量,只是这个变量存储的是另外一个变量/函数的地址/首地址。
原始指针指的是直接存储其他低层次数据的地址的指针,如 int/float/char 、数组等。可以通过关键字 new/new[] 来创建指针,通过关键字 delete/delete[] 来释放内存,如下面例子所示:
int* ptr_int = new int; // 创建整型指针
float* ptr_array = new float[100]; // 创建浮点型数组
for(i=0; i<100;i++){
ptr_array[i] = i; // 数组指针的使用;
}
delete[] ptr_array; // 释放数组指针
delete ptr_int; // 释放整型指针
智能指针相对原始指针,一个显著的区别在于智能指针不需要手动释放。智能指针主要包括:unique指针、shared指针和weak指针。
unique指针可通过 std::unique_ptr 标准库创建,本质上是一个用于管理单个对象或者数组的模板类。
unique,顾名思义,唯一的。它表示每个对象/数组只能被一个unique指针所指,这个对象/数组可以可以通过所有权的转移,实现被另一个unique指针所指,但是无法同时被两个unique指针所指。
为什么需要unique指针?
unique指针具有避免悬垂指针、减少内存泄漏和避免手动释放内存的好处!
对于原始指针,手动释放内存可以避免内存泄漏,这个在1.1*有提及到。那么指针悬空指针是什么?悬空指针的定义是:指针最初指向的内存已经被释放了的一种指针,其所指向的地址存储的值是无法预测的随机值,以下举例说明出现指针悬空的情形。而在unique指针根本就不存在 delete unique_p1语句,也不存在两个unique指针指向同一个变量,故而指针悬空的问题。
#include
void func(int* p){
// 局部作用域
int var = 5;
p = &var;
std::cout << *p << std::endl; // 输出:5
}
int main(){
// 情形1:指针所指的内存被释放,则指针悬空,返回值无法估计
int* p1 = new int;
*p1 = 5;
std::cout << *p1 << std::endl; // 输出:5
delete p1;
std::cout << *p1 << std::endl; // 输出:-1152576448
// 情形2:两个指针指向同一块内存,其中指针所指的内存被释放,则另外一个指针悬空,返回值无法估计
int* p2 = new int;
int* p3 = new int;
*p2 = 5;
p3 = p2;
std::cout << *p3 << std::endl; // 输出:5
delete p2;
std::cout << *p3 << std::endl; // 输出:-2100685696
// 情形3:指针所指向的局部变量退出作用域,则局部变量被自动释放,指针悬空
int* p4 = new int;
func(p4);
std::cout << *p4 << std::endl; // 输出:-1163005939
}
接下来就是 unique指针 如何使用?包括如何创建 unique 指针,如何转移变量的所有权,以及如何自定义删除智能指针;
#include
#include // 1. 引入 memory 头文件
int main(){
// 2. 初始化变量:创建unique指针指向整型变量的两种方式
std::unique_ptr<int> p1(new int(666)); // 所指内存存储取值为666的整型变量
std::unique_ptr<int> p2 = std::make_unique<int>(999); // 更常用
std::cout << *p1 << ", " << *p2 << std::endl; //输出: 666, 999
// 3. 创建数组:创建unique指针指向整型数组的两种方式
std::unique_ptr<int[]> p3(new int[10]); // 长度为10的整型数组,数组取值未初始化
std::unique_ptr<int[]> p4 = std::make_unique<int[]>(10); // 更常用
for(int i=0;i<10;i++){
p3[i] = i;
p4[i] = i;
std::cout << p3[i] << ", " << p4[i] << std::endl;
}
// 4. 变量所有权的转移
std::unique_ptr<int> p5 = std::move(p1); // p5 拥有变量,而指针 p1 自动销毁
if(p1){
std::cout << "p1 owns the object" << std::endl;
}
else if (p5){
std::cout << "p5 owns the object" << std::endl; // 输出:p5 owns the object
}
// 5. 自定义析构函数:智能指针默认会自动销毁,但是也可以自定义销毁方法;
struct MyDeleter
{
void operator()(int* ptr){
std::cout << "Custom Deleter: Deleting pointer" << std::endl;
delete ptr;
}
};
// std::unique_ptr p6 = std::make_unique(999, MyDeleter()); 使用此方法自定义析构函数会报错
std::unique_ptr<int, MyDeleter> p7(new int(999), MyDeleter());
return 0; //主函数结束后自动调用 MyDeleter() 删除指针,输出:Custom Deleter: Deleting pointer
}
unique 指针提到:每个对象/数组只能被一个unique指针所指,这个对象/数组可以可以通过所有权的转移,实现被另一个unique指针所指,但是无法同时被两个unique指针所指。
那么很自然的一个想法就是:存不存在一种智能指针,可以实现多个指针指向同一个变量?
答案是可以的,这种指针就是 shared 指针。
那么这种多个指针指向同一个变量智能指针会导致什么问题吗?
很自然的一个问题就是:被多个指针所指的变量什么时候才会销毁?举个例子就是10个shared指针指向同一个变量,那么其中的一个或两个指针销毁后,被指的变量还在不在?当然,为了避免悬空指针,我们通过希望的是所有指针销毁后,变量才被销毁。为了实现这个直观的想法,不得不引入一个引用计数的概念,因此,每当增加一个shared指针指向变量,引用计数 +1,当引用计数等于0的时候,证明已经没有指针指向该变量了,该变量就可以自动销毁了。
那么我们下面用两个智能shared指针指向类对象来说明引用计数的使用方法:
#include
#include // 1. 引入 memory 头文件
// 2. 定义一个类
class MyClass{
public:
// 类里面只有构造函数和析构函数,通俗来说就是在对象的创建和销毁时就会调用该函数
// 我们在构造函数和析构函数print一些内容就可以知道被shared指针所指的对象何时创建/销毁
MyClass(){ std::cout << "Object is Constructed !" << std::endl;};
~MyClass(){ std::cout << "Object is Destructed !" << std::endl;};
};
int main(){
std::shared_ptr<MyClass> p1(new MyClass());
{
// 以下进入局部作用域
std::shared_ptr<MyClass> p2 = p1; // 类对象同时被 p1 和 p2 所指,引用计数为 2
std::cout << "Inside the inner scope." << std::endl;
// 退出局部作用域
}
// 退出局部作用域,引用计数减少为 1
std::cout << "Outside the inner scope." << std::endl;
}
该段程序依次打印的内容是:
Object is Constructed !
Inside the inner scope.
Outside the inner scope.
Object is Destructed !
Destructed 语句在 Outside the inner scope语句之后,证明了只有引用计数为0,才会销毁变量;
弱指针,也叫做弱智能指针,顾名思义也就是处于智能指针和原始指针的指针类型。它能够处理 share 指针 中存在的循环引用的问题。
很自然什么是循环引用呢?以下举个例子说明:
#include
#include // 1. 引入 memory 头文件
// 2. 定义一个类
class MyClassB;
class MyClassA{
public:
MyClassA(){ std::cout << "Object A is Constructed !" << std::endl;};
~MyClassA(){ std::cout << "Object A is Destructed !" << std::endl;};
std::shared_ptr<MyClassB> pB;
};
class MyClassB{
public:
MyClassB(){ std::cout << "Object B is Constructed !" << std::endl;};
~MyClassB(){ std::cout << "Object B is Destructed !" << std::endl;};
std::shared_ptr<MyClassA> pA;
};
int main(){
// while(True) // 3. 如果添加 while 循环会造成内存验证泄漏,可能导致死机,欢迎试一试
{
// 以下进入局部作用域
// 4. 创建两个 shared 指针,
std::shared_ptr<MyClassA> p1(new MyClassA()); // 对象 A 的引用计数为 1
std::shared_ptr<MyClassB> p2(new MyClassB()); // 对象 B 的引用计数为 1
// 两个指针的成员函数互相指向对方, -> 表示取成员变量
p1->pB = p2; // 对象 B 的引用计数为 2
p2->pA = p1; // 对象 A 的引用计数为 2
std::cout << "Inside the inner scope." << std::endl;
// 退出局部作用域,智能指针无法自动释放
}
std::cout << "Outside the inner scope." << std::endl;
}
该段程序依次打印的内容是:
Object A is Constructed !
Object B is Constructed !
Inside the inner scope.
Outside the inner scope.
Destructed 语句在退出局部作用域之后(Inside the inner scope之后,Outside the inner scope.之前)并没有打印,证明了循环引用导致退出局部作用域后,引用计数仍为2,不会销毁变量,导致内存泄漏;如此一来,A和B都互相指着对方吼,“放开我的引用!“,“你先发我的我就放你的!”,于是悲剧发生了。
那么接下来的问题就是:什么是 weak 指针,它是如何避免内存泄漏的?
weak 指针和 shared 指针的显著区别在于 weak 指针不会增加被指对象的引用计数。这能保证当一个 shared 指针和一个weak指针同时指向一个对象时,只要 shared 指针退出作用域后,这个对象就会被自动销毁。
那么 weak 指针的使用方法和 shared 指针有什么区别?如何去使用 weak 指针?
在使用 weak 指针时,必须要使用 lock() 函数基于weak指针创建一个新的 shared 指针,然后在新的作用域里面安全地使用这个新的 shared 指针,以下我们将基于上述循环引用的例子,将类A的shared指针转化为weak指针,讲述如何避免内存泄漏的。
#include
#include // 1. 引入 memory 头文件
// 2. 定义一个类
class MyClassB;
class MyClassA{
public:
MyClassA(){ std::cout << "Object A is Constructed !" << std::endl;};
~MyClassA(){ std::cout << "Object A is Destructed !" << std::endl;};
std::weak_ptr<MyClassB> pB; // 使用弱指针
};
class MyClassB{
public:
MyClassB(){ std::cout << "Object B is Constructed !" << std::endl;};
~MyClassB(){ std::cout << "Object B is Destructed !" << std::endl;};
std::shared_ptr<MyClassA> pA;
void DoSomething(){
std::cout << "Doing something..." << std::endl;
}
};
int main(){
// while(True) // 3. 如果添加 while 循环会造成内存验证泄漏,可能导致死机,欢迎试一试
{
// 以下进入局部作用域
// 4. 创建两个 shared 指针,
std::shared_ptr<MyClassA> p1(new MyClassA()); // 对象 A 的引用计数为 1
std::shared_ptr<MyClassB> p2(new MyClassB()); // 对象 B 的引用计数为 1
// 两个指针的成员函数互相指向对方, -> 表示取成员变量
p1->pB = p2; // weak指针不增加引用计数,对象 B 的引用计数为 1
p2->pA = p1; // 对象 A 的引用计数为 2
if (auto shareFromWeak = p1->pB.lock()){
// 通过lock函数创建新的 shared 指针,
shareFromWeak->DoSomething(); // 在新的局部作用域里面安全间接调用
std::cout << "Shared uses count: " << shareFromWeak.use_count() << std::endl; //此时对象 B 的引用计数为 2
}
// 退出 if 函数的局部作用域,shareFromWeak指针自动释放,对象 B 的引用计数变为 1
std::cout << "Inside the inner scope." << std::endl;
// 退出局部作用域,智能指针自动释放
}
std::cout << "Outside the inner scope." << std::endl;
}
该段程序依次打印的内容是:
Object A is Constructed !
Object B is Constructed !
Doing something...
Shared uses count: 2
Inside the inner scope.
Object B is Destructed !
Object A is Destructed !
Outside the inner scope.
可以看到,退出局部作用域后,对象A和对象B都得到了释放,也就是说避免了循环引用导致的内存泄漏。
在本博客中,为了讲述 智能指针这一个概念,我们首先铺垫了一些基础知识,例如变量的声明周期和C++的内存模型,这对于理解内存的释放和局部作用域等概念非常有用。接着我们快速地介绍了引用和原始指针,针对原始指针的内存管理(释放和泄漏问题),我们进一步解释了智能指针,这包括 unique, shared 和 weak指针。其中,我们着重地介绍了shared因为循环引用导致的内存泄漏问题,以及如何使用weak指针避免这个循环引用,使得智能指针能够正确释放!