C++中的资源管理和智能指针

C++中的资源管理和智能指针

Last updated on


3032 字 约 16 分钟

前言

写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)错误。

一般什么情况下会发生栈溢出呢?主要有以下两种:

  • 过于深的递归:我们知道每次开始执行一个函数都会在栈内开辟一个栈对象,而这个函数的地址,参数以及在内部创建的变量都会占据栈空间。当递归层数过深,这些内存就会大量堆积从而撑爆栈空间。
  • 函数内声明过大的对象:当我们在函数内创建一个非常大的对象或者说变量,比如一个1e71e7大小的long long数组,占据了大量栈内存,就很容易发生栈溢出。

你可能会想,那为什么一个执行次数非常多的循环不会使得栈内存溢出呢(一般情况下),这就牵涉到栈空间的特性:压入即创建,弹出即销毁。

比如说下面的这个循环:

while(true)
{
    long long homo[114514];
}

显然我们知道它是一个很烂的循环,但是它并没有使得栈溢出,它只会让你的控制台保持漆黑。

当我们进入由{}包裹的块级作用域,我们声明了一个长度为114514114514,类型为long longhomo数组,并占用了相应的栈空间。重要的在下面:当我们离开{}时,就相当于在栈中的这个栈对象的任务执行完毕了,直接将其弹出,相应的,所创建的这个变量也被销毁,对应的内存被释放,地址空出。

堆内存

讲完了栈内存,堆内存又是什么呢?

从堆内存的特性来看,和栈内存比较,它是一块巨大的、混乱的内存池,它不像栈内存那样井然有序,严格按照后进先出的逻辑进行管理。当你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对应的内存!**这会导致内存无限堆积,每一次你没有deletehealth_points,都是你泄漏的内存。

你可能会说,那我直接在析构函数中加上不就好了?然而在实际应用中,往往有很多情况会导致你根本没有delete成功。比如在函数块内,你将delete放在最后,然而在前面就可能因为抛出了异常等等其他原因,函数提前退出,你的内存就成了孤魂野鬼,永远找不到方向了。

而这就是传统的内存管理方式,即单纯的newdelete的缺陷,你不知道你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指向PetHero的引用计数变为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() 后使用
🐙