前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【Advanced C++】: 详解RAII,教你如何写出内存安全的代码

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

作者头像
TechFlow-承志
发布2020-03-05 15:06:47
2.9K1
发布2020-03-05 15:06:47
举报
文章被收录于专栏:TechFlowTechFlow

引言

这是专题【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的应用场景。

互斥锁

代码语言:javascript
复制
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来使我们的代码异常安全并且整洁。

代码语言:javascript
复制
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

代码语言:javascript
复制
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++中一个非常常见的应用场景就是调用一个函数来产生一个对象,然后消费这个对象,最后手动释放指针。如以下代码所示。

代码语言:javascript
复制
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来管理指针。

代码语言:javascript
复制
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 constructorcopy assignment。有人可能会疑惑,为什么 get_object函数创建的 unique_ptr为什么没有在函数返回前释放指针?这是因为 std::unique_ptr实现了 move constructor(一种可以将资源从另一个对象“偷”过来的构造函数)并在返回时将指针传给了main函数中 obj变量。如果不太理解发生了什么,可以看一下以下我自己尝试实现的 unique_ptr.

代码语言:javascript
复制
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; 
    }
};

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

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

我们来用我们自己定义的 unique_ptr运行一下看会发生什么:

代码语言:javascript
复制
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:

代码语言:javascript
复制
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_ptrmove constructor都没有被call过,但是我之前明确说了,main函数中的 obj是用 get_object函数中构造的 unique_ptr通过 move constructor构造的。可是为什么我们没有抓到 move constructor打印出来的东西呢?这是因为C++编译器做了一个叫做 copy elision的优化,来避免不必要的构造和析构,例如本例中,两个函数中的 unique_ptr对象其实是一个东西,因此他们之间的转换和赋值被优化掉了。如果我们通过 std::move来强制 move constructor发生,如下所示:

我们将看到这样的信息:

代码语言:javascript
复制
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的威力,其将资源绑定到轻量级对象(比如智能指针,内存占用很少,可以像普通指针一样随意传递)的方法使得我们再也不需要关心在获取资源之后对资源的释放。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2019-12-25,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Coder梁 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档