C++中的资源管理和智能指针
Last updated on
前言
写RPG项目总是举步维艰,反思了一下觉得自己的基础实在太差,所以现在先开始学习一些在实际开发中使用较为频繁的“神兵利器”,如此方能行健致远。
关于C++这门语言,在此之前,我觉得我对它的使用方式和我们社长描述的很相似:只是把它当成了有STL和class的C语言。 而且class我还把它当成struct用 。故此反思,学习C++中一些我未曾接触过的新特性(其实也不是很新了)
今天要学习的是C++中的资源管理和智能指针。
关于栈内存和堆内存
栈内存
在研究智能指针之前,我们首先要搞清楚,C++,或者说,其他任何编程语言在运行时的任务和资源是如何调配的。
思考在执行一个函数时,计算机内部发生了什么?我们想象计算机中有一个栈,每当一个函数被调用时,系统会在栈空间上为该函数分配一块连续的内存区域,这被称为栈帧(Stack Frame)。它就像是一个临时的工作台,专门存放该函数的局部变量、参数、返回地址以及调用者的上下文信息。
计算机执行任务的逻辑是这样的:它盯着这个栈,每次只执行栈顶的任务,当任务执行完毕,就被弹出,下面的任务浮上来,而计算机继续执行下面的任务。
在这个栈帧内声明的变量(比如 int c),被称为局部变量或自动变量。它们的生命周期被严格限制在这个栈帧的生存期内。比如说下面这样:
int calc(int a, int b)
{
int c = a + b;
int d = a * b;
return c + d;
}
int main()
{
int a, b;
std::cin >> a >> b;
std::cout << calc(a, b);
return 0;
}
当calc函数被调用时,就在栈中创建了一个新的栈对象,这个栈对象里就包含了calc函数的执行逻辑,以及在它内部声明的变量c,d
我们知道变量在计算机中是需要依托内存地址而存在的,也就是说它需要占用内存。那么这个栈对象中的变量就相应地,占用了一个名叫栈空间的区域中的一块内存。
对于这个栈空间,它实际上是从高地址到低地址的一块连续内存。当我们在栈对象中声明变量时,计算机就会查找当前未被使用的最高的内存地址,然后尝试从它开始拉取一块内存地址以创建变量。当剩余的空间不足,比如说需要占用的地址超出了最低的地址限制,创建变量失败,就出现了我们经常见到的栈溢出(stack overflow)错误。
一般什么情况下会发生栈溢出呢?主要有以下两种:
- 过于深的递归:我们知道每次开始执行一个函数都会在栈内开辟一个栈对象,而这个函数的地址,参数以及在内部创建的变量都会占据栈空间。当递归层数过深,这些内存就会大量堆积从而撑爆栈空间。
- 函数内声明过大的对象:当我们在函数内创建一个非常大的对象或者说变量,比如一个大小的
long long数组,占据了大量栈内存,就很容易发生栈溢出。
你可能会想,那为什么一个执行次数非常多的循环不会使得栈内存溢出呢(一般情况下),这就牵涉到栈空间的特性:压入即创建,弹出即销毁。
比如说下面的这个循环:
while(true)
{
long long homo[114514];
}
显然我们知道它是一个很烂的循环,但是它并没有使得栈溢出,它只会让你的控制台保持漆黑。
当我们进入由{}包裹的块级作用域,我们声明了一个长度为,类型为long long的homo数组,并占用了相应的栈空间。重要的在下面:当我们离开{}时,就相当于在栈中的这个栈对象的任务执行完毕了,直接将其弹出,相应的,所创建的这个变量也被销毁,对应的内存被释放,地址空出。
堆内存
讲完了栈内存,堆内存又是什么呢?
从堆内存的特性来看,和栈内存比较,它是一块巨大的、混乱的内存池,它不像栈内存那样井然有序,严格按照后进先出的逻辑进行管理。当你new了一个变量,计算机会给它分配到堆内存,它会在里面开一块空间用来存你的变量。如果说栈内存指向的是局部变量,仅在对应的栈对象创建之后有效,并随着栈对象弹出而销毁,那么堆内存所存储的就是全局变量,每个人都能看到,一直有效,除非你手动销毁它。
与栈内存不同的是,在栈内存中,当你想要访问一个变量时,它直接将这个变量本身传给你,速度很快;而堆内存则是给你对应的指针,让你自己去找,速度较慢。
且堆内存中的变量有一个特性,就是它所指向的内存空间不会自动销毁。因为这个变量全局可见,计算机不知道你什么时候用完,所以不敢自动销毁它。只有当你说,我要delete的时候,它才会被销毁。
对比总结
- 为什么我们需要栈内存?方便你及时使用,调用迅速。当CPU访问了栈顶的一个变量,硬件就会将对应的整块栈都打包塞进缓存中。
- 为什么我们需要堆内存?因为栈内存太小,不够放太大的对象和变量,所以只能塞到堆内存中。
*指针——旧时代的遗老
当我们之前要使用指针时,我们一般直接使用*a这样的方式。然而我们知道,C++是一门较为严苛的语言,它需要你手动进行内存管理和调配。如果你是天才,熟练掌握内存管理方式,知道什么时候该new,什么时候该delete,那么C++将会成为最犀利的武器,它的运行速度飞快;反之,如果你是一个蠢材,指针乱飞,内存开得东一块西一块,还忘记回收,那么实际运行时会让你明白什么叫噩梦。
首先我们要知道,什么叫内存泄漏?根据我们上面所提到的,当我们在堆内存中开辟空间,对应的内存并不会手动释放。也就是说,如果你new了而没有delete,那块内存就会一直放在那里。
比如说在一个对象内:
class people
{
private:
std::string name;
int *health_points;
public:
people(std::string n, int hp) : name(n)
{
health_points = new int(hp); // 在堆上申请了内存
}
~people()
{
std::cout << "wsdsb";
}
};
void func()
{
// 函数开始,创建一个局部对象
people p("lpl", 1919810);
// 函数结束,p离开作用域,触发析构函数
}
int main()
{
while(true)
{
func();
}
return 0;
}
**你的析构函数没有释放health_points对应的内存!**这会导致内存无限堆积,每一次你没有delete的health_points,都是你泄漏的内存。
你可能会说,那我直接在析构函数中加上不就好了?然而在实际应用中,往往有很多情况会导致你根本没有delete成功。比如在函数块内,你将delete放在最后,然而在前面就可能因为抛出了异常等等其他原因,函数提前退出,你的内存就成了孤魂野鬼,永远找不到方向了。
而这就是传统的内存管理方式,即单纯的new和delete的缺陷,你不知道你new出来了这一块内存,最终会不会delete,更不知道你将要delete的这一块内存,是不是已经被delete过了。
RAII(Resource Aquisition Is Initialization,资源获取即初始化)
针对上面提出的问题,人们提出了RAII原则用于管理内存。
什么叫资源获取即初始化?意思就是资源的生命周期与对象相同,当对象被创建时,即运行构造函数,相应的资源也开辟了对应的内存;当对象被销毁时,即运行析构函数,对象所持有的资源的内存也被释放。
比如说下面的示例:
class ResourceManager
{
private:
int *data;
public:
ResourceManager(size_t size) // 构造函数获取资源
{
data = new int[size];
}
~ResourceManager() // 析构函数释放资源
{
delete[] data;
}
};
void func()
{
ResourceManager r(10);
// 函数结束时资源随对象一同销毁
}
智能指针
而在C++的新特性中,我们有更好更灵活的方式去践行RAII原则,也就是智能指针。
智能指针与普通指针的区别就是,普通指针不会自动给你调用析构函数,而智能指针会。它在离开函数作用域时,会自动销毁,从而减少了野指针的风险。
要使用智能指针,我们需要引用<memory>头文件
有三种主要的智能指针:
std::unique_ptr
特点:资源只能由一个人持有,禁止拷贝,禁止赋值。
语法:
auto p1 = std::make_unique<obj>(param); // 使用std::make_unique<class_name>()进行构造
auto p2 = p1; // 禁止赋值!会报错
auto p2 = std::unique_ptr<obj>(p1); // 同样禁止拷贝构造,也会报错
auto p2 = std::move(p1); // 如果要改变资源的持有者,只能这样做,使用std::move()
std::shared_ptr
特点:需要多个对象共享一份资源时使用。
语法:
auto texture = std::make_shared<Texture>("texture.png"); // 使用std::make_shared<class_name>()进行构造
auto monster1 = texture;
auto monster2 = texture; // 两个怪物共享同一份纹理资源
机制:其内部具有一个引用计数。每多一个指针指向它,计数+1;每有一个指向它的指针销毁,计数-1;计数为0时,内存才会被释放。
对性能的消耗略高于std::unique_ptr
std::weak_ptr
首先我们考虑下面的情况:
struct Pet;
struct Hero
{
std::shared_ptr<Pet> my_pet;
~Hero() { std::cout << "hero die"; }
};
struct Pet
{
std::shared_ptr<Hero> my_owner;
~Pet() { std::cout << "pet die"; }
};
此时我们看发生了什么:Hero指向Pet,Hero的引用计数变为1;Pet指向Hero,Hero的引用计数变为1
当程序结束想要销毁它们时,Hero要等到Pet销毁才肯析构,同样Pet要等到Hero销毁才肯析构。
就这样两个都不肯析构,内存直接爆炸了。这就是循环引用带来的后果。
而std::weak_ptr就解决了这个问题,它的设计初衷是:观测一个对象,但不增加它的引用计数。
此时把Pet的定义改一下:
struct Pet
{
std::weak_ptr<Hero> my_owner;
~Pet() { std::cout << "pet die"; }
}
此时Pet知道它的主人是谁,但不像std::shared_ptr那样强行霸占Hero的生命,从而让Hero可以正常析构,从而顺便把Pet也一同带走。
语法:
不能直接像使用其他指针一样使用std::weak_ptr,因为它不确定自己指向的对象是否仍然存在。所以需要这样做:
std::weak_ptr<Hero> wPtr = heroSharedPtr;
if (auto sPtr = wPtr.lock()) {
// lock() 如果成功,会返回一个临时的 shared_ptr
sPtr->attack();
} else {
std::cout << "Hero Died.";
}
小结
| 工具 | 核心场景 | 资源所有权 | 备注 |
|---|---|---|---|
| 栈变量 | 局部逻辑、小型数组 | 自动管理 | 性能最高 |
std::unique_ptr | 独占资源(如:Hero 实例) | 唯一 | 几乎零开销,禁止拷贝 |
std::shared_ptr | 共享资源(如:地图纹理) | 共享(计数) | 有原子操作开销,注意循环引用 |
std::weak_ptr | 观察者、防止循环引用 | 无所有权 | 必须 lock() 后使用 |