Skip to content

C++踩坑:当你想要写一个vector(二)

关于代码

用到的示例代码在Locietta/loia_vector中可以找到。

WIP

还在施工中,猴年马月能写好吧(大概)

这部分在我本地已经放了很久了,一直都是这种半成品的样子,主要是大改博客结构之后把改过的part1给上传了,结果dead link不能通过CI,所以干脆把part2也放上来先。

前情提要

当你想要写一个vector(一)中,我们已经完成了一个纯C版本的变长int数组。

如果你忘记了,那么这是纯 C 部分的完整代码
cpp
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct vector {
    size_t size, capacity;
    int *arr;
} vector;

/// Geometrically increase capacity
size_t calc_new_capacity(size_t old_capacity, size_t new_size) {
    if (old_capacity > SIZE_MAX - old_capacity / 2) return SIZE_MAX;
    const size_t new_capacity = old_capacity + old_capacity / 2;
    if (new_capacity < new_size) return new_size;
    return new_capacity;
}

/// Add an element at the end
void push_back(vector *self, int x) {
    if (self->size == self->capacity) {
        const size_t new_capacity = calc_new_capacity(self->capacity, self->size + 1);
        int *new_arr = malloc(sizeof(int) * new_capacity);
        memcpy(new_arr, self->arr, sizeof(int) * self->size);
        free(self->arr);
        self->arr = new_arr;
        self->capacity = new_capacity;
    }
    self->arr[self->size++] = x;
}

/// Remove and element from the end
void pop_back(vector *self) {
    assert(self->size); // don't `pop_back` on an empty array
    self->size--;
}

/// Reserve more capacity to git rid of reallocation when `push_back`
void reserve(vector *self, size_t new_capacity) {
    if (new_capacity <= self->capacity) return; // `reserve` never shrinks

    int *new_arr = malloc(sizeof(int) * new_capacity);
    memcpy(new_arr, self->arr, sizeof(int) * self->size);
    free(self->arr);
    self->arr = new_arr;
    self->capacity = new_capacity;
}

#define vector_init() ((vector){.size = 0, .capacity = 0, .arr = NULL})
#define vector_destroy(v) free((v).arr)

int main(int argc, const char *argv[]) {
    vector v;
    v = vector_init(); // ctor

    reserve(&v, 1000);

    for (int i = 0; i < 1000; ++i) push_back(&v, i);
    for (int i = 0; i < v.size; ++i) printf("%d ", v.arr[i]);
    for (int i = 0; i < 300; ++i) pop_back(&v);

    printf("\nsize: %zu, capacity: %zu\n", v.size, v.capacity);

    vector_destroy(v); // dtor
}

而现在,我们终于要踏入C++的世界了!

真正开始之前:什么,C代码不能过编?!

我相信大家已经在各种地方听到很多遍“C++完全兼容C”,“C++是C语言的超集”之类的话。虽然是老生常谈,但时至今日,C++虽然很多地方和C语言兼容,但绝非C语言的超集

总而言之,如果你兴冲冲地把之前的代码文件后缀名从.c给改成了.cpp,你就会发现C++的编译器对这一段代码并不满意:比如C++并不允许void *类型隐式转换到其他的指针类型,以及C++并不支持C99标准支持的结构体字面量(虽然gccclang支持这么写,但是MSVC的cl对此就不太高兴了)。

C++标准化在C99标准之前,所以C99以后的C语言特性有不少是C++不支持的。

如果只按照C语言的经验来写C++会遇到不少类似这样的问题,还是要多注意,尽量按照C++的方式来写C++代码。

琐碎的修改
cpp
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <cstring>

typedef struct vector {
struct vector {
    size_t size, capacity;
    int *arr;
} vector;
};
...
        int *new_arr = malloc(sizeof(int) * new_capacity);
        int *new_arr = (int *) malloc(sizeof(int) * new_capacity);
...
#define vector_init() ((vector){.size = 0, .capacity = 0, .arr = NULL})
#define vector_init() (vector{.size = 0, .capacity = 0, .arr = nullptr})
...
  • 使用cstdio而不是stdio.h没有什么明显的好处,只是为了和其他C++标准库的命名一致,属于编码风格喜好问题
  • 在C++中结构体定义会自动将结构的名字也当作类型,不用多写typedef
  • vector{.size=...}这样的按成员名初始化的语法在C++20才被支持
  • 关于nullptr:由于C++不允许void*隐式转换为其他指针,NULL只能被定义为0(而不是C中的((void *)0)),然后再在语法上开洞允许用字面量0给指针赋值。但这么搞参数为NULL的函数重载就又不对了,最后在C++11给空指针单独弄了个类型来解决这毛病。现在C++代码里尽量只写nullptr就好。

生命周期与RAII

手动初始化和销毁有什么不好?

回顾一下纯C版本的vector,我们用vector_init初始化,用vector_destroy来清理。手动进行的操作有一点不好:如果你忘记了,就会造成灾难性的后果。如果能让编译器自动为我们插入这些初始化和清理代码就好了。

欸,等等,你可能会说:不就是成对使用初始化和清理嘛,有什么难的?但事情可能没有你想象得那么简单,来看以下例子👇

cpp
int foo(void *data) {
    // assuming `data` is actually a `vector*`
    free(data); // leak!

    vector v = vector_init();
    // ... long tedious code

    if (some_flag) {
        // leak!
        return 1;
    } else {
        bar();
    }
    vector_destroy(v);
    return 2;
}

void bar() {
    // leak!
    longjmp(env, 1); // poor man's exception handling
    exit(1);
}

以上是掉坑的若干种姿势:

  • 堆上的vectorfree掉之前必须先调清理代码
  • 代码块有多个出口时,每个出口处都要调清理代码

    注意不一定是函数return,也包括循环break/continue,以及goto

  • 在内层的函数调用出现longjmp或者exit一类的可以直接中断当前函数的调用

    这种情况下没法写清理代码

总而言之,我们总是希望变量产生时立即被初始化,否则我们没法使用;而在变量被消灭时立即进行清理,否则会造成资源的泄露。而变量的产生与消灭我们就称为这个变量的生命周期

栈上变量的生命周期很明确,就是它们的作用域,由花括号来标识。变量一旦离开它们的作用域就被摧毁,生命周期随之结束。而堆上变量的生命周期从malloc开始,到被free结束。

如果能让编译器自动在变量产生时插入初始化代码,而在变量被消灭时插入清理代码,那么就能杜绝遗忘,并且能减少程序员的心智负担——不用时刻记着要清理啦。

出于这样的考虑,在C++里我们可以给结构体自定义初始化和清理代码。我们把它们叫做构造函数析构函数,或者简称ctordtor.

因此那两个宏在C++中就可以被如下的写法所代替:

cpp
struct vector {
    size_t size, capacity;
    int *arr;

    vector() : size(0), capacity(0), arr(nullptr) {}
    ~vector() { free(arr); } // safe to free a nullptr
};

int main() {
    vector v; // ctor inserted by compiler
    // ...
    // v.~vector(); // manually calling dtor
    // ...

    // dtor inserted by compiler
}

对于栈上变量,编译器会在变量产生的地方自动插入ctor,在变量离开作用域时自动插入dtor.

当然也可以手动调用dtor提前结束变量的生命周期,但这之后再使用这个变量就会引发未定义行为了。

而对于堆上变量,编译器没法直接帮我们插入ctor和dtor,因此C++加入了新的分配与释放机制:newdelete. new在分配完内存之后会调用对应类型的ctor,delete在释放内存之前会先调用对应的dtor. 这样只要我们使用newdelete,就可以正确地处理带有ctor/dtor类型的分配和释放了。

这样一来,借助构造与析构的机制,我们可以把资源的获取和释放同变量的产生与消灭绑定在一起。这也就是我们常说的RAII(Resource Acquisition Is Initializadtion),所谓资源获取就是初始化。如果要获取某个资源,那么只需创建一个管理该资源的对象;如果要释放资源,那么只需销毁这个对象,由对象为你释放资源。这里面资源可以是内存,也可以是系统句柄、数据库链接、文件、套接字等等。

关于RAII的一点个人看法

其实我个人感觉虽然RAII里面提到了初始化,但RAII里面析构的重要性实际上是大于构造的。

像Rust实际上就没有C++这样的构造函数,而是使用类似我们纯C方案里面类似的语法来初始化一个对象。

rust
struct Foo {
    a: u8,
    b: u32,
    c: bool,
}

let foo = Foo { a: 0, b: 1, c: false };

即使在C++中,也有不少人采用工厂模式而不是构造函数来初始化对象。

说到底想要自己手动添加清理代码实现析构的效果,不借助编译器基本难以做到也不便于维护。但是在变量定义的地方加个初始化就简单得多了,而且也比隐式地调用一大堆初始化代码更符合直观一些。

cpp
// maybe tons of work in its ctor, but hard to notice at first glance
Foo foo;

// easy to see there's something happening here
Bar bar = Bar{.x = 1, .y = 2};
Bar baz = make_my_complex_bar(123, handle);

异常!

Last updated: