【Advanced C++】: 详解RAII,教你如何写出内存安全的代码

** 引言 **

这是专题【Advanced C++】的第一篇文章,在这个专题中笔者将分享一些自己在使用C++过程中遇到的一些困惑与钻研之后的收获,并且分享一些大厂面试会问到的点。名为advanced C++,是因为阅读这个专题会需要一些C++基础,希望这个专题能帮读者解开一些对C++的困惑之处,同时可以跟大家一起探讨精进C++的理解和使用技巧。

** RAII **

如果你有写过C++或者RUST,你也许听过Resource Acquisition Is Initialisation (RAII), 但是并不了解这名字的含义是什么,或者不知道这个机制有什么用处。在这篇文章中,笔者将详细阐述RAII的原理以及它在资源管理方面巨大的威力。

RAII是一种使用在面向对象语言中的资源(内存,互斥锁,或者文件描述符)管理机制,使用RAII的语言中,最出名的当属C++和RUST。对C++来说,许多公司已经开始禁用裸指针(强制使用基于RAII的智能指针)来避免内存泄漏。而RUST,正是因为强制RAII机制使得其拥有了绝对的内存安全。Resource Acquisition Is Initialisation, 顾名思义,意味着任何资源的获取都应该发生在类的构造函数中,但我个人认为这个名字不太完备,有另一半的意思没有解释到,那就是资源的释放应发生在析构函数中,这意味着所有资源的life cycle都与一个 object紧紧绑定在一起。我将用几段代码来具体阐述RAII的应用场景。

** 互斥锁 **

std::mutex mut;

int write_to_a_file_descriptor(std::string content)
{

    mut.lock();
    // critical area below (might throw exception)

    // Writing  content to a file descriptor...

    // Critical areas above
    mut.unlock();
}

以上代码展示了一个将字符串写进某个文件描述符的函数,并且这个函数会被很多线程并行调用 (这种情况在高并发线上服务的logger中非常常见),因此这个共用的文件描述符必须用一个互斥锁保护起来,否则不同线程的字符串会混在一起。这段代码看起来仿佛没有问题,但是如果当写IO时是抛出了异常,call stack会被直接释放,也就意味着 unlock方法不会执行,造成永久的死锁。这个问题可以像java一样用一个try-catch语句来避免但是也会让代码变得臃肿和难看。并且在复杂的逻辑中,往往很可能会忘了解锁,或者花很多精力来管理锁的获得和释放(如果在一个函数调用中有多处返回,每个return statement之前都需要 unlock)。这就是RAII发挥其威力的时候了,下面一段代码将展示如何用 lock_guard来使我们的代码异常安全并且整洁。

std::mutex mut;

int write_to_a_file_descriptor(std::string content) 
{

    std::lock_guard<std::mutex> lock(mut);
    // critical area below (might throw exception)

    // Writing  content to a file descriptor...

    // Critical areas above
}

lock_guard保证在函数返回之后释放互斥锁,因此使得开发人员不需要为抛出异常的情况担心且不需手动释放锁。但是 lock_guard是如何做到的呢?笔者将尝试自己手动实现一个 lock_guard

template <typename T>
class lock_guard
{
private:
    T _mutex;
public:
    explicit lock_guard(T &mutex) : _mutex(mutex) 
    {
        _mutex.lock();
    }
    ~lock_guard() 
    {
        _mutex.unlock();
    }
};

从以上实现中可看出, lock_guard在构造函数中锁住了引用传入的mutex (resource acquisition is initialisation),并且在析构函数中释放锁。其异常安全的保障就是析构函数一定会在对象归属的scope退出时自动被调用(在本例中在函数返回前执行)。如果你用过golang的话会知道golang的defer机制,这与C++的析构函数十分相似,但是golang的defer只能保证在函数返回前执行,而C++的析构函数可以保证在当前scope退出前执行(个人感觉golang的defer相比之下比较鸡肋)。

** 智能指针 **

接下来笔者将介绍RAII在C++中最强的应用:智能指针。

C++中一个非常常见的应用场景就是调用一个函数来产生一个对象,然后消费这个对象,最后手动释放指针。如以下代码所示。

class my_struct
{
public:
    my_struct() = default;
};

template <typename T>
T* get_object()
{
    return new T();
}

int main()
{
    auto obj = get_object<my_struct>();
    // consume the object
    // ...
    // consume finish
    delete obj;
}

然而,在大型应用程序中,指针的产生和消费错综复杂,写到后面程序员根本不记得自己有没有释放指针,或者某处地方读取一个已经释放的指针直接导致segmentation fault程序崩溃。而这就是C/C++各种内存泄漏的万恶之源。

而自从C++11推出智能指针后,其极大地减轻了C++开发者们内存管理的压力。通过在裸指针上包一层智能指针,再也不用通过手动 delete来释放内存了。下面的代码将展示如何用 std::unique_ptr来管理指针。

class my_struct
{
public:
    my_struct() = default;
};

template <typename T>
std::unique_ptr<T> get_object()
{
    return std::unique_ptr<T>(new T());
}

int main()
{
    auto obj = get_object<my_struct>();
    // consume the object
    // ...
    // consume finish
}

智能指针的方便之处在于它会在自己的析构函数中执行 delete操作而不需程序员手动释放。在上述代码中,当main函数退出时, std::unique_ptr在自己的析构函数中释放指针,而为了防止有别的 std::unique_ptr指向自己管理的对象而导致的提早释放与空指针访问, std::unique_ptr禁止了 copy constructor与 copy assignment。有人可能会疑惑,为什么 get_object函数创建的 unique_ptr为什么没有在函数返回前释放指针?这是因为 std::unique_ptr实现了 move constructor(一种可以将资源从另一个对象“偷”过来的构造函数)并在返回时将指针传给了main函数中 obj变量。如果不太理解发生了什么,可以看一下以下我自己尝试实现的 unique_ptr.

template <typename T>
class unique_ptr
{
private:
    T* _ptr;
public:
    // Construct from plain pointer
    explicit unique_ptr(T* ptr) : _ptr(ptr) {
        std::cout << "unique_ptr constructed" << std::endl;
    };

    // Move constructor
    unique_ptr(unique_ptr &&ptr) noexcept : _ptr(ptr._ptr) {
        ptr._ptr = nullptr;
        std::cout << "unique_ptr move constructed" << std::endl;
    }

    // Copy constructor is forbidden
    unique_ptr(unique_ptr &ptr) = delete;

    // Move assignment
    unique_ptr& operator=(unique_ptr &&ptr) noexcept {
        if (this == &ptr) {
            return *this;
        }
        _ptr = ptr._ptr;
        ptr._ptr = nullptr;
        return *this;
    }

    // Copy assignment is forbidden
    unique_ptr& operator=(unique_ptr &ptr) = delete;

    ~unique_ptr() {
        delete _ptr;
        std::cout << "unique_ptr destructed" << std::endl;
    }

    T* operator->() {
        return _ptr; 
    }
};

代码看上去比较复杂,不过我将一个方法一个方法地和大家分析。

第8行代码实现了最基本的构造函数:从一个裸指针开始构造。 第13行实现了 move constructor,这个方法会用一个已有的 unique_ptr来构造一个新的对象,它将旧 unique_ptr的指针替换为 nullptr来防止多个指针指向相同对象。 第19行禁止了 copy constructor的使用,因为不允许多个指针指向同一对象。 第22行实现了 move assignment,原理与 move constructor相同。 第32行禁止了 copy assignment,原理与 copy constructor相同。 第34行是析构函数,将最终释放指针。 第39行实现了 operatoroverload,使得我们可以像访问普通指针一样访问 unique_ptr。 我们来用我们自己定义的 unique_ptr运行一下看会发生什么:

class my_struct
{
public:
    std::string _name = "name";

    my_struct() = default;
    explicit my_struct(std::string name) : _name(std::move(name)) {
        std::cout << "my_struct constructed" << std::endl;
    }
    ~my_struct() {
        std::cout << "my_struct destructed" << std::endl;
    }
};

template<typename T>
unique_ptr<T>get_object()
{
    return unique_ptr<T>(new T("struct name"));
}

int main() {
    unique_ptr<my_struct> obj = get_object<my_struct>();
    std::cout << obj->_name << std::endl;
}

console output:

my_struct constructed
unique_ptr constructed
struct name
my_struct destructed
unique_ptr destructed

首先, my_struct被构造,然后 unique_ptr被构造,并且可以发现, my_struct的析构函数会在 unique_ptr的析构函数返回前执行,这意味着我们成功地将指针的life cycle绑定到了 unique_ptr上!不过,细心的同学可能发现了,全程 unique_ptr的 move constructor都没有被call过,但是我之前明确说了,main函数中的 obj是用 get_object函数中构造的 unique_ptr通过 move constructor构造的。可是为什么我们没有抓到 move constructor打印出来的东西呢?这是因为C++编译器做了一个叫做 copy elision的优化,来避免不必要的构造和析构,例如本例中,两个函数中的 unique_ptr对象其实是一个东西,因此他们之间的转换和赋值被优化掉了。如果我们通过 std::move来强制 move constructor发生,如下所示:

我们将看到这样的信息:

my_struct constructed
unique_ptr constructed
unique_ptr move constructed
unique_ptr destructed
struct name
my_struct destructed
unique_ptr destructed

此时我们可以清晰地看到,main函数中的 obj是通过 move constructor构造的,并且在其构造完成之后, get_object函数中构造的 unique_ptr对象被析构了,因为我们已经提早将其内部指针替换成了 nullptr, 其析构函数什么都不会释放。

智能指针中,除了 std::unique_ptr,还有其他类型,比如允许多个指针指向同一变量的 std::shared_ptr,其内存管理逻辑会复杂许多,如果有同学有兴趣,可以在评论中告诉我,下次专门写一篇文章讲如何实现 std::shared_ptr。

** 技术总结 **

通过这篇文章,相信大家都体会到了RAII的威力,其将资源绑定到轻量级对象(比如智能指针,内存占用很少,可以像普通指针一样随意传递)的方法使得我们再也不需要关心在获取资源之后对资源的释放。