0x00 绪论

写这篇文章的起因是某位 C++ 入门群友在群里提起了某个关于 C++ 内内存生命周期的问题。因此笔者想就此机会讨论笔者在写 C++ 的时候的一些内存管理实践。本文将会叙述笔者管理 C++ 中资源的一些思路,以及理解这些思路必要的前置思维方式。

Disclaimer: 本文许多实践具有个人风格,且笔者亦为 C++ 初学者。因此相关内容可能不准确或有误,欢迎在评论中指出。

0x01 前置思维方式

本节将讨论笔者建议初学者注意的一些思维方式。

分开「语义」与「实现」

此处“语义”指的是抽象的语言概念,而"实现"指的是这一语言概念在计算机中的具体表示。为了更好的理解语言概念,建议先单理解抽象的概念,再结合其具体实现。同时,这也意味着一些“语义”可能在具体的语言实现中根本不存在,但却在编写代码的时候存在于我们的头脑之中。

例如,在提到一个“C++ 类”的时候,就应该将其理解为一个抽象的、以某种形式存在于计算机中的对象,而非“一段内存”。再比如,提到一个资源的“所有权”,虽然 C++ 中根本不存在 Rust 一样的所有权的概念,但我们在编写代码的时候可以利用“所有权”的思维。

从正在编写的代码对应的抽象层级思考问题

笔者觉得,计算机科学最重要的概念之一就是抽象化。从某个角度编写代码时候,我们应该从这个角度出发,去思考正在操作的对象,而避免去观察实现细节,除非在进行性能调优等。这一思维广泛运用于下面的代码,例如对“所有者”与“相对生命周期”的理解。

0x02 资源与其勾柄

本文中的“资源”指的是广义的一切资源,从最基础的一段内存,到 IO 句柄,到 Mutex。

笔者用“勾柄”来称呼我们在程序中调用这些资源的时候使用的描述符。其可以是指针、句柄、文件描述符、引用等等。本文中将其统称为勾柄是为了防止与 C++/Windows/POSIX 中别的概念混淆。同时勾柄也是对“句柄(Handle)”的另一个翻译。注意,虽然勾柄一定也需要某种资源(例如栈上变量)来存在于计算机中,但为了避免复杂化问题,我们将所有勾柄视为抽象对象。且为了方面,在绝大多数情况下勾柄的生命周期应当被系统自动管理。即其能在不再使用时被自动销毁。

下面给出一个资源和其勾柄的例子。

unsigned char* buf = new unsigned char[1024];

此处我们在堆上分配了一段内存,并且使用指针 buf 这一勾柄来描述此资源。图解就是这样:

0x03 资源的所有权与生命周期

本节将讨论C++没有显性体现,但非常有用的两个概念:资源的生命周期与所有权。

资源的生命周期

任何资源均具有其生命周期,而这一生命周期通常包含其创建、使用与销毁三个阶段。

创建

在「创建」这一生命周期,资源在通过系统调用等方法分配并可用后,我们会获得该资源的一个勾柄。例如,对于一块内存,下列代码描述了其创建过程,且获得了一个 buf 的勾柄:

unsigned char* buf = new unsigned char[1024];

使用

在「使用」生命周期,我们通过获得的勾柄来利用创建的资源。

std::memset(buf, 0x00, 1024);
stream.read(buf, 1024);
...

销毁

在「销毁」生命周期,资源被释放。同时,所有指向该资源的勾柄均被无效化。

delete[] buf;
// buf 此处不再有效

生命周期的管理

通常有两种方式管理资源的生命周期:手动管理或通过系统自动管理。手动管理即是我们完全手动销毁资源:

unsigned char* buf = new unsigned char[1024];
............
delete[] buf;

虽然现在对自动资源管理的实践越来越普遍,但是手动管理内存的方法仍然广泛存在,特别是对于初学者而言。下方资源所有权的介绍就提供了一种手段以保证手动资源管理的正确性。

而自动资源管理则是藉由系统提供的一些语言特性,例如 C++ 的本地变量离开作用域自动被销毁的特性来实现。具体的实现有 RAII(Resource Aquisition Is Initialization,资源获取即初始化)和以 RAII 为基础进一步封装的智能指针(shared_ptrunique_ptr 等)。我们稍后会提及这部分内容,下面给出了这种实践的一个例子。

std::unique_ptr<unsigned char[]> buf(new unsigned char[150]);

大部分资源管理实践的目标就是解决销毁生命周期常被开发者忽略,以及销毁后已无效化的勾柄被错误地再次使用的问题。考虑如下三段代码:

void foo1() {
    unsigned char* buf = new unsigned char[1024];
}

unsigned char* foo2() {
    unsigned char a[150];
    return a;
}
unsigned char* foo3() {
    unsigned char* buf = foo2();
    std::cout << buf[15] << std::endl;
}

unsigned char* foo4() {
    unsigned char* buf;
    std::cout << buf[15] << std::endl;
}

对于 foo1 函数,其创建了一段内存资源并通过 buf 勾柄持有它。然而,在 foo1 函数返回时,该资源并未被销毁,而已经没有任何勾柄可以访问到这一资源。因此,我们将一个资源的所有勾柄均被销毁,而该资源本身未被销毁称为资源的泄漏。在涉及的资源为内存时,这一现象通常称内存泄漏(memory leak)。对于该现象的另一个定义是一个资源的生命周期长过了其所有勾柄

对于 foo2 函数,其创建了一段内存资源并通过 a 勾柄持有它。然而关键在于此处这段内存资源的生命周期被系统自动管理,而在离开 foo2 时这段内存资源已经完成了销毁,不再有效。此时 foo2 函数返回的指针变成了一个无效的勾柄,自然会导致 foo3 出现未定义的行为。因此,我们将一个指向的资源已经被销毁的勾柄称为悬垂勾柄。在涉及的资源为内存时,这一现象通常称悬垂指针(dangling pointer)。该现象也可描述为勾柄的生命周期长过了其指向的对象

对于 foo4 函数,我们并没有分配任何资源。然而我们创建了一个勾柄,并尝试访问这个勾柄对应的(实际不存在的)资源,这样亦导致了未定义的行为。在涉及的资源为内存时,这一现象被称为野指针(wild pointer)。

综上,我们需要保证资源与其第一个勾柄同时被创建,与其最后一个勾柄同时(或几乎同时)被销毁。在小型的程序中,这通常比较好办。例如:

unsigned char* buf = new unsigned char[150]; // 同时创建勾柄与资源本身
// use buf.....
delete[] buf;  // 销毁
buf = nullptr; // 销毁勾柄

而在较大型的程序中,就可能需要利用下面提到的所有权,或尽量使用自动管理生命周期的对象(智能指针等)来避免这三种情况。

资源的所有权

智能指针为C++引入了所有权这一概念。虽然C++编译器没有强制规定使用所有权机制,接纳并正确使用这一机制可以避免绝大部分上述的指针与资源生命周期不匹配的情况。

所有权机制引入了所有者的概念,每一个资源被一个所有者持有,并随着所有者创建,使用和销毁。资源可以从一个所有者转移到另一个所有者,或者被其他所有者临时借用。

所有者

一个资源可以被包括但不限于对象、函数、Lambda 表达式、模块、服务(称为该资源的所有者)持有,且该资源的创建与销毁由这一所有者负责,此时我们称该所有者持有该资源的所有权。

注意,此处提到的“所有者”指的是以正在编写的代码为视角的所有者。例如,假设我们正在编写调用某个外部 SDK 的代码,而我们获得了对这个 SDK 内某个对象的引用 p。在没有额外信息的情况下,此时我们就称这一外部 SDK持有该对象,而不去纠结是否是这一 SDK 的堆(如果有垃圾回收的话),还是别的什么模块持有该对象。

这也就是说,对于一个特定的资源,其所有者并非是特定的某个事物,而是随着观测者不同而不同。

创建

在一个资源被创建的时候,创建该资源的一方自动成为该资源的所有者。例如:

void foo() {
    auto str = new char[150];
}

此时我们 foo 函数通过 str 指针这一勾柄持有其对应的内存资源。

转移

通常我们可以转移一个资源的所有权到另一个地方。其必须满足:

  1. 新的所有者取得该资源所有权,且负责其销毁;
  2. 旧的所有者不再持有该资源所有权

而第 2 点常被忽略而导致出现悬垂指针等情况。所有权的转移广泛存在于:

  1. C++ 对象的移动构造函数与移动赋值运算符
  2. API 调用:在使用某些 C 标准或外部库函数的时候创建一个对象,则创建完后通常会将所有权转移到我们的程序

对于第一种情形,考虑如下类:

class A {
private:
    unsigned char* buf;
public:
    A(): buf(new unsigned char[150]) {}
    A(A&& another) noexcept {
        buf = another.buf;
        another.buf = nullptr; // 重要
    }
    A& operator=(A&& another) noexcept {
        delete[] buf;
        buf = another.buf;
        another.buf = nullptr; // 重要
    }
    ~A() noexcept {
        delete[] buf;
    }
}

A 类中我们持有一个指向一段内存的勾柄 buf,其在 A 对象创建时候被创建,在 A 对象销毁的时候被销毁。而在 A 对象被从一个地方移动到另一个地方时,我们先将新对象的 buf 勾柄指向旧对象的 buf 所指向的资源,接着销毁旧对象所持有的勾柄。这就避免了违反前述的第 2 点。

对于第二种情形,考虑如下代码:

char* str2 = std::strdup("iLNb!!");
// Use str2......
std::free(str2);

在调用 strdup 函数后生成的字符串的所有权被转移到我们的程序,因此我们需要负责销毁这一资源。

出借

资源的出借/借用指的是让其他方可以“使用”这个对象,但并不转移对象的所有权。此种情况实际更为广泛。其通常表现为勾柄的传递(通过指针、引用等)。仍然考虑上述的 A 类:

class A {
private:
    unsigned char* buf;
public:
    A(): buf(new unsigned char[150]) {}

    void set_buf(const unsigned char* new_buf, size_t size) {
        std::memcpy(buf, new_buf, std::min(size, 150));
    }

    unsigned char* get_buf() { return buf; }

    A(A&& another) noexcept {
        buf = another.buf;
        another.buf = nullptr;
    }
    A& operator=(A&& another) noexcept {
        delete[] buf;
        buf = another.buf;
        another.buf = nullptr;
    }
    ~A() noexcept {
        delete[] buf;
    }
}

我们新增了两个函数 set_bufget_buf。这两个函数均涉及到了对 buf 指向的内存资源的借用。对于 set_buf 函数,我们借用了调用方的一段内存,并使用 new_buf 勾柄来使用它。显然,我们并没有在 set_buf 函数中销毁这个资源。

而对于 get_buf 函数,我们将 buf 指向的内存资源出借给了调用方。调用方不被允许销毁这段内存,否则则会导致 A 对象损坏。

然而,若考虑 buf 指向的资源的生命周期,我们会意识到其与 A 对象的生命周期相绑定,即 A 对象销毁时,buf 指向的资源一并被销毁了。同时,所有 buf 借用出的勾柄均被无效化。因此,我们必须保证出借的资源的勾柄在资源本身被销毁之前被销毁。为了保证这一点,我们可以借助下述相对生命周期的概念。

进一步理解所有者

在笔者的理解中,所有者应该呈现一个树状的结构,特别是在需要从多个角度观察所有者的时候。例如,若 A 对象持有一个 B 对象,B 对象持有一个 TCP 连接 conn。则我们可以称 A 也持有这个连接。这有利于从一个不了解 B 对象的角度(例如 BA 的一个内部状态)来了解 conn 的持有状态。

同时,笔者需要引入一个概念:「相对生命周期」。这是为了处理出借资源的时候资源的生命周期。在处理借用的资源的时候,许多时候资源的实际生命周期与我们观测到的生命周期并不匹配。特别是在所有者会重用底层资源的时候。也就是说,一个我们从其他地方借用的资源,可能并未被“实际”地销毁,但相对于我们来说,其生命周期已经终结。例如下面的代码:

class A {
private:
    int* buf;
public:
    A(): buf(new int[10]) {}
    ~A() { delete[] buf; }

    void do_something() {
        if (某种情况) {
            delete[] buf;
            buf = new int[某个值];
        }
        // 改写buf
    }
    /**
      (这里是文档)
      返回一个对buf的借用指针,其有效性保持到下一次do_something调用前。
    */
    int* get_current_buf() { return buf; }
}

void foo() {
    A a;
    int* ptr1 = a.get_current_buf();
    a.do_something();
    std::cout<<ptr1[5]<<std::endl; // !!!
}

get_current_buf 的文档中,明确提到了返回的指针属于借用勾柄,且有效性(我们可以视为相对生命周期)保持到下一次 do_something 调用前。因此在 foo 函数中,调用了 do_something 之后,ptr1 的相对生命周期即终结。虽然可能对应的 a 对象中的 buf 对象仍然存活,但是:

  1. 无法保证其仍然存活
  2. 即使存活,其内容可能并不符合预期(例如不能预期其是 do_something 调用之前的内容)

因此我们称其相对生命周期结束。

0x04 对 RAII 与智能指针的理解

介绍了生命周期与所有权的概念之后,我们现在可以更好地描述 RAII(Resource Acqusion is Initialization)的本质:将资源与一个对象的生命周期相绑定

(!-- 未完待续 --)


不想被自己的惰性打败。