📚【C++ 从入门到入睡】内存模型与字符串底层

,

— c++11入门费曼学习副产物,如有谬误,望佬友指正,不胜感激 —


:books: 其他c++姊妹帖

    ❀ 命名空间
    ❀ const与类型转换
    ❀ 指针与数组
    ✿ 内存模型与字符串底层
    ❀ 引用与移动语义


c++中对对象的生命周期根据创建和销毁时机进行了如下划分:

  • 自动对象:在定义处自动创建,出作用域后自动销毁。

  • 动态分配对象:显式申请与释放内存来创建和销毁。

  • 静态对象:在运行结束时才销毁。

    static int a = 1;	// 全局静态,唯一
    void fun(){
        static int b = 1;	// 局部静态,唯一,但仅在函数的局部作用域可见
        ++a;
        ++b;
    }
    
    int main() {
        ++a;	// a=2,b=1
        // ++b; 在这里无法访问b,即使它是静态
        fun();	// a=3,b=2
        fun();	// a=4,b=3
        return 0;
    }
    
  • 临时对象:生命周期到达完整表达式末尾,或者绑定到const引用或者右值引用上延长至引用销毁,由用法决定,c++标准中划分为特殊的自动对象。

    int fun(){
        return 1;
    }
    
    int main() {
        int a = fun();  // 返回值1是临时对象,拷贝给a后销毁
        const int &b = fun();   // 返回值1直接绑定到b,b销毁时跟着销毁
        return 0;
    }
    

    当然加上编译器优化以及标准至C++17后,实际上的实现可能不再是上述的说法,而是:

    int a = fun();	// 不再生成临时对象,值1直接在a的位置上构造
    const int &b = fun(); // 值1从寄存器上转到栈区物化成临时对象后绑定到b
    

    (这部分等到后面讲到左右值再讲)

  • 线程本地对象:线程创建跟着创建,线程销毁跟着销毁。


◢ 程序内存模型

大部分实现使用了如下内存分区模型(内存从高到低):

  • 栈区:用于存放自动对象,一般从高地址往低存,但一些编译器优化也会从低到高。
  • 堆区:放动态分配对象,从低地址往高存。
  • 静态全局区:放非常量的静态对象。
  • 只读常量区:放只读的常量静态对象。
  • 代码段:如其名,放函数定义等代码逻辑,不放对象。

临时对象可能放栈区和堆区甚至寄存器(等后面讲到左右值再说)

线程本地对象跟随操作系统实现不确定,用到再讲。

:pushpin: 只读常量区放的是静态常量,普通常量可能不放在这里,例如局部对象的常量就可能直接放在栈区。

:pushpin: C++标准只规定了不同生命周期的对象分类,没有规定内存模型,上述仅是常见的对象分类到内存模型的分配方式之一。


◢ c风格字符串基础

两种形式:

  • 字符数组

    char str[] = "hello";
    char str[6]{'h','e','l','l','o','\0'};	// 等价上式
    

    字符串常量(本质是数组类型char [6])拷贝给str进行初始化,由于C++的特殊适配,其不会退化为指针后才拷贝。

  • 字符指针

    const char* str = "hello";
    

    字符串常量初始化给非同类型数组的字符指针,数组类型char [6]退化为char*类型后拷贝指针的地址到str。

    :pushpin: 复制指针的值,但还是指向字符串常量,常量不可改,因此C++11警告要使用const修饰的字符指针接收。而上面的字符数组直接拷贝了一份可写值到自己内存,因此无需const要求。


❖ 局部对象指针

函数在执行时可能会在函数体中定义变量,例如:

void fun(){
    int a = 1;  // fun执行结束后a就被销毁
}

这是因为a属于自动对象,由系统自动管理销毁,常见的实现方式为:

当函数fun执行时会在栈区压入属于该函数的栈帧,期间创建的变量都在栈帧中,函数执行结束后栈帧销毁,创建的局部变量也会跟着销毁。

那么如下代码中将局部对象指针传出函数就是危险的:

int* fun(){
    int a = 1;
    return &a;	// 返回局部变量指针是危险的,不应该这么做
}

因为调用fun函数获得的是a的指针,但a本身已经随着函数的结束而销毁了,此时该指针是悬空指针(指向无效对象或非预期对象),继续使用该指针是未定义行为。

:pushpin: 未定义行为:即标准中没有定义这么做会有什么作用,编译期会假定未定义行为进行激进优化,如果强行使用未定义行为,会出现非预期结果或报错。

但上面我们提到过:

只读常量区:放只读的常量静态对象。

静态对象:在运行结束后才销毁,但仅在其作用域可见。

也就是说,静态对象即使是在函数内第一次定义,也不会随着栈帧销毁,即

const char* fun(){
    const char* str = "hello";	// "hello"看似是自动实际是静态
    return str;	// 返回静态变量的指针是完全可行的
}

下述两种就是危险的:

char* fun2(){
    char str[6]{'h','e','l','l','o','\0'};	// 值在栈帧上
    return str;
}

char* fun3(){
    char str[] = "hello";	// "hello"是静态但数组拷贝一份在栈帧上
    return str;	// 退化后返回的是栈帧上可写副本的指针值,不是静态变量指针值
}

❖ 字符串转义

字符串常量以""包裹字符来定义,如果要在其中写"则使用转义符修饰\",即

const char* str = "\"";

如果要在其中写\本身则使用\\,即

const char* str = "\\";

如果要用\\那就是写成\\\\,此时爱用正则的小伙伴已经哭晕在撤硕,不仅难写还难读。

好在C++11引入了原始字符串的机制,支持使用R"($$$)"的形式来写字符串,其中$$$为不使用转义的原始字符串:

const char* str1 = "^.+\\\\abc\\\\\\d+$";
const char* str2 = R"(^.+\\abc\\\d+$)";

那么问题又来了,我的原始字符串中有)"不就提前截断了吗?

原始字符串机制提供了解决办法,用来包裹原始字符串的"(和)"不是固定的,可以在加入任意字符序列"@@@(和)@@@",@@@为自定义序列,只要前后一致就行。

那么我们只需要选取不会和原始字符串中冲突的序列加入包裹的标识符即可,例如:

const char* str = R"123()")123";	// 原始字符串为)",自定义序列为123

这样就可以愉快地写正则了。


❖ 字符串常量拼接

此外,编译器会自动把相邻的字符串常量连接起来,那么我们也可以这么写来加强可读和美观:

const char* str = 
    "1. 今天星期四\n"
    "2. vivo50\n"
    "3. 搞大我肚子\n";

❖ 字符串的符号运算

C++标准中并没有规定两个相同的字符串常量是否是同一个数组,即:

const char* str1 = "hello";
const char* str2 = "hello";

让编译器自由发挥,则上述str1和str2可能是同一个地址值,也可能不是。

但是有一点需要辨析:双目运算符==作用于str1和str2是在比较它们的地址值

if(str1 == str2){}	// 比较的是地址不是内容

因此这段代码在不同编译器上,可能是相同的,也可能是不同的,谨慎使用。


特别地,流插入<<对于字符指针类型和字符数组类型的读取并不是它们本身保存的地址值,而是从该地址值开始逐字节读取直至'\0'的出现。

C风格字符串后以'\0'标识结束,因此常量"hello"在C/C++中需要6个字节的存储空间,多出来的一个字节保存末尾的'\0'。

◢ c++风格字符串的底层实现

因为要手动管理空间和\0的存在,使用C风格字符串是很麻烦且易出错的。

C++提供了std::string类来集成常用操作,更安全便捷,完全可以视为与int,double等同层次的基础类型来使用。

但今天要讲的不是使用方法(因为多又杂,我也不全会,都是用到了查),而是并不在标准范围内的,由编译器自行选择的底层实现。


上面我提到过一句话:

C++标准中并没有规定两个相同的字符串常量是否是同一个数组

现在探究的就是std::string在编译器层面的历史实现方式,大致有三种:

  • 深拷贝:每次创建新字符串都深拷贝数据过来初始化到对象。
  • 写时复制:创建新字符串不复制,而是指向字符串所在内存,仅当需要修改时才会创建副本去修改。
  • SSO短字符串优化:当字符数比较小时(<=15,还有1位放\0),直接深拷贝存储在对象中。大于时则存储在堆空间上,用指针指向它。

❖ 写时存储的存储模型

需要维护一个堆上字符串空间和对应的引用计数,当计数为0时才会销毁这个堆空间。

那么这个计数放在哪里就成了问题:

  • 放在std::string中:计数需要共享以进行更改,不可能放在单独的对象中,该方案不可行。

  • 作为静态变量:每个字符串堆空间需要自己的独立计数,在不知道会有多少个字符串出现的情况下无法设置为静态。

  • 放在堆区:是可行的。且因为每个堆字符串都有自己的计数,为了节省分配空间的开销,可以一次性分配空间来保存两个数据。

    即在每个堆字符串开头加入一个int长度的空间保存其引用计数。

❖ SSO的存储模型

这个比较简单,存储数据部分要么是一个char[16]数组类型,要么是一个char*指针类型,满足这种可能是两个类型中的任意一个的结构就只有Union结构体了(当然这也是实现方法之一,不唯一):

union Buffer{
    char * _pointer;
    char _local[16];
};

再加上一些描述容量和大小的变量就差不多了。

❖ 实现的选择

因为写时复制要求的计数原子操作会带来性能瓶颈,且在短字符串中SSO的直接拷贝开销小于申请空间和更新计数开销,在现代实现中写时复制使用的比较少。

大多数实现采用SSO,且短模式(数组)转为长模式(指针)后不再转回短模式,以免抖动产生大量开销。

10 个赞

C艹 对于方块来说还是太难了

会赢的(

永远的c++)www

1 个赞

大佬好强!

1 个赞

C++,除了名字,其他都不懂 :bili_017:

不够通俗易懂 :bili_040:

再通俗文字量有点大(
先过一遍再说 :zany_face:

学不动了,根本学不进去

不慌,请务必先休息一天,会赢的

觉得佬太厉害了~~

1 个赞

看到第一句就睡了

1 个赞