跳到主要内容

std::string的底层演进史:深拷贝、COW、移动语义到短字符串优化

·2739 字·6 分钟
简易模拟标准库中string的实现,从深拷贝到COW理解拷贝、所有权、引用计数的意义,了解移动语义到短字符串优化带来的性能优化。

从char*到最简单的RAII String

C字符串风格带来的核心问题:不表达所有权 在C和C++中,定义出来一个字符串有多种形式:

1
2
3
4
5
6
7
8
9
// 1. 栈上的字符数组(包括存放在.data的全局字符数组)
char s[] = "bitofux";

// 2. 字符串字面量
const char* p1 = "bitofux";

// 3. 堆上的字符数组
char* p2 = new char[6];
std::strcpy(p2,"bitofux");
  1. 对于字符数组来说,它可以表达一个字符串,且数组对象s本身就拥有那一段存储字符的内存空间,在上段代码中,s的类型是char[8],它可表示长度、生命周期、所有权。它的长度是6,若它是局部变量,它的生命周期是当前函数内部作用域,若是全局变量的,它的生命周期是整个进程的生命周期,且它拥有这段内存空间的所有权。但是,数组名在大多数场景下都会退化成一个指向s[0]的指针。在以下表达式中,数组名s就会退化成一个指向s[0]的指针。

    1
    
    char* p3 = s;
    

    此时s是数组对象,但是p3是一个char*类型的指针,它本身不拥有这段内存,只是存储了s的首地址,你也无法通过delete[] p3去释放它,因为它不是new[]出来的,而是栈为数组对象s分配的,与p3无关系,p3只是表示了指向了字符串所在的内存空间的什么位置。

    image
    因此,字符数组是可表达一个字符串,而且数组对象本身确实拥有那一段字符存储,最主要的问题不是字符数组有没有所有权,而在于在C字符串风格体系下,字符数组常在大多数表达式中会退化成char*或者const char*,而一旦退化成char*或者const char*类型的指针,此类型本身就不再表达所有权、长度和生命周期

  2. 字符串字面量"bitofux"本质上是**静态存储期(static storage duration)**的字符数组。这个字符数组中的所有字符是从程序被加载到操作系统成为进程开始到进程结束都存在,也就是在整个程序运行期间都保持有效。在类unix/ELF文件格式下,它通常被编译器和链接器放入只读数据段,例如.rodata。因此p1这个const char*类型的变量并不拥有这块内存空间,不可通过delete[]释放,也不能修改。p3也一样不表达所有权。

  3. 堆上的字符数组,存储字符数组的这块内存空间是在堆上,为了防止内存泄漏,理论上包括实际上是需要在不使用这块堆内存的时候,需要delete[],但是p2的类型char*本身不说明谁负责delte[],在程序员的视角,如果你指定p是拥有者,但是你需要delete[] p,但最主要的问题是这个指定没有体现在char*类型里。你可以指定p拥有者,但是若是再定义char* p5 = p2;那你也可以通过delete[] p5释放这块内存。

  4. 至于char*或者const char*作为函数参数的问题,对于函数而言,我也只是拿到一个地址,我不知道这个地址是局部字符数组、还是堆字符数组、还是字符串字面量,更不知道它的长度,更不知道这块地址所在的内存是否被释放,何谈所有权。 所以,综合以上的情况来看,char*/const char*作为裸指针类型只表达"指向某个内存空间中某个字符的位置",不表达所有权、生命周期、长度、容量,更重要的是是否有效都不一定,因为可能存储已经被释放的字符的地址。

表达和拥有的一些区别

在阐述表达所有权和拥有所有权的时候,容易被搞混。

  1. 不表达所有权是类型语义层面的说法
  2. 不拥有所有权是某个具体指针变量在某个具体场景下的事实判断 说的更加严谨一些就是char\*/const char\* 这个类型本身不表达所有权。但是,通过这两个类型生命的变量可能拥有所有权,也可能不拥有。看下面的例子:
1
char* p = new char[8];

在程序员的角度,p可能是拥有者,因为这块堆内存是需要有人delete[]的,但也只是可能,若是你定义一个新的char*类型的变量p1,将p的值赋值给p1,那么p1也可能是拥有者,也可通过它来delete[],前提是不要出现double free。所以,char*类型本身并没有指定的说:

  1. 指定谁负责delete[],p?还是p1?
  2. 生命周期谁来管理?
  3. p是拥有者吗? 而对于具有静态存储期或者自动存储期的字符数组来说,使用char*定义一个指针变量指向其数组中的第一个字符,这个指针变量更不拥有所有权。再次总结一下就是,从char*/const char*类型本身看不出来我是拥有者还是借用的,因为它本身不表达所有权,且可以指向拥有型资源和非拥有型资源,是否拥有需要看一些约定,甚至是程序员的行为。

具有RAII的字符串类

既然char*/const char*类型表达不了所有权,那么就需要一个可表达所有权的字符串类型,且这个类型不仅表达所有权,它还需要表明我拥有这段存储字符串的内存空间,也就是说在实例化对象的时候,我就已经将这块存储字符串的内存空间分配好了,且在这个对象的生命周期结束的时候,我会自动去释放这块内存空间,也就是说我不仅仅表达所有权,更重要的是我也拥有。

  1. 字符串所在的这块内存空间由字符串类实例化的对象来管理
  2. 对象生命周期结束调用字符串类中的析构函数释放,无需指定谁来去释放
  3. 字符串类实例化的对象是拥有者,因为它决定了这块内存空间的分配初始化和释放

什么是RAII

上述的表达可能不够标准和准确,但阐述的思想是一样的,在C++中,这叫做RAII(Resource Acquisition Initialization),翻译过来就是资源获取即初始化,大白话的意思就是资源的生命周期绑定到对象的生命周期,构造函数获取资源,析构函数释放资源,对于字符串来说,资源就是堆上的char数组,当定义对象的时候,对象诞生,那么就调用构造函数获取一块在堆上的char数组,你可看作这块堆内存是由于对象诞生它才诞生的,当对象死亡的时候,调用析构函数进行delete[],你可看作是对象死亡,堆上的char数组就释放了,资源的生命周期与对象的生命周期息息相关,所以这个类型定义的对象绝对的拥有资源的所有权,对应的类型可表达所有权 因此,最简单的一个字符串类需要有构造函数、析构函数、和指向堆内存的字符指针的数据成员

1
2
3
4
5
6
7
class String{
    public:
        String(const char* s); // 构造时分配并复制字符数组
        ~String();             // 析构时释放字符数组
    private:
        char* ptr_;            // 指向自己拥有的字符数组
};

字符串类的第一版实现

在字符串类的实现中,暂时不支持字符串类对象的拷贝和赋值,编译器默认生成的拷贝构造函数和赋值运算符函数也要delete,否则会产生浅拷贝导致double free。

  1. 在构造函数中,申请新的字符数组时,需要多分配一个字节用于存储字符’\0’,作为字符数组的边界
  2. 析构函数中,必须使用delete[],它要与new char[n]进行配对使用