C++内存控制

内存布局

程序内存结构

  • static area:静态区,存储程序指令(按位存储)、 全局变量

    • 位于地址编址号较小、接近地址空间开始处
    • 该区域中分配的内存大小在程序整个执行期间不发生改变
    • 正文段:CPU执行机器指令

      • 每个程序只有一个副本
      • 只读,避免程序因为意外事故而修改自身指令
    • 初始化数据段:程序中所有赋初值全局变量

    • 非初始化数据段(bss段):程序中所有未初始化全局变量

      • 内核将此段初始化为0
  • heap area:堆区,程序运行期间动态分配

    • 程序中未分配的可用内存池
    • 处于栈区、静态区之间
    • 缺乏组织
    • 需要寻址、操作速度慢
    • 可以用于存储编译时未知大小、可变数据
  • stack area:栈区,存放函数栈帧

    • 最高地址区
    • 程序每调用函数、方法都会在此内存区域中创建新的栈帧, 函数返回所创建栈帧会被撤销,释放内存
    • 操作迅速,不需要寻址
    • 数据大小已知、固定
  • 堆、栈以相反方向增长,方便任一区域都可以依照需要增长, 直到所有可用内存耗尽

内存分配

  • static allocation:静态分配,声明全局变量、常量时, 编译器为其在静态区中分配在整个程序生命周期内持久的内存 空间
  • automatic allocation:自动分配,调用函数时,编译器为 局部变量在栈帧分配存储空间,函数返回时空间自动释放
  • dynamical allocation:程序允许时,动态获得内存空间
  • stack frame:栈帧

    • 栈帧随机为函数中局部变量分配内存、地址
    • 栈帧中还包含额外信息,其结构取决于机器架构
  • 变量:C++中声明变量时,编译器必须保证给声明变量分配足够 内存存储该类型变量值,分配内存大小取决于变量类型

Pointer

  • C++设计原则:应该尽可能多的访问到有底层硬件提供的机制, 所以C++语言使得内存位置的地址对程序员可见

指针:值是内存中一个地址的数据项

  • 指针允许以压缩方式引用大的数据结构
  • 指针使得程序在运行时能够预订新的内存
  • 指针可以用于记录数据项之间关系

LvalueRvalue

  • lvalue:左值,引用内存中能够存储数据的内存单元的表达式
  • rvalue:右值,非左值表达式
  • xvalue:返回右值引用的函数、表达式
  • gvalue:lvalue、xvalue总称

  • 具体参见cs_program/program_design/language_design

左值引用

左值引用:只能绑定左值,绑定有其他对象内存空间的变量

  • 建立引用时是将内存空间绑定

    • 使用的是对象在内存中位置
    • 则被引用对象需要是左值
    • 则不能将右值绑定到左值引用上
  • 常量左值引用保证不能通过引用改变对应内存空间值

    • 尝试绑定右值引用时,编译器会自动为右值分配空间
    • 则可以将右值绑定在常量(左值)引用上
1
2
3
4
5
6
7
8
9
int foo(42);
int& bar = foo;
// OK:`foo`是左值,使用其在内存中位置
int& baz = 42;
// Err:`42`是右值,不能将其绑定在左值引用上
const int& quz = 42;
// OK:`42`是右值,但编译器可以为其开辟内存空间
int& garply = ++foo;
// OK:前置自增运算符返回左值

右值引用

右值引用&&只能且必须绑定右值

  • 考虑右值只能是字面常量、或临时对象,则右值引用

    • 是临时的、即将销毁
    • 不会在其他地方使用
  • 则接受、使用右值引用的代码,可以自由的接管所引用对象 的资源,无需担心对其他代码逻辑造成数据破坏

    • move sematics:与右值引用交换成员
1
2
3
4
5
6
7
8
9
int foo(42);
int&& baz = foo;
// Err:`foo`是左值,不能绑定在右值引用上
int&& quz = 42;
// OK:`42`是右值,可以绑定在右值引用上
int&& quux = foo * 1;
// OK:`foo * 1`结果是右值,可以绑定在右值引用上
int&& waldo = foo--;
// OK:后置自减运算符返回右值
Move Sematics
  • 使用左值引用对类型X赋值操作流程如下

    1
    2
    3
    4
    5
    X& X::operator=(X const & rhs){
    // make a clone of what rhs.m_pResource refers to
    // destruct the resource this.m_pResource refers to
    // attach the clone to this.m_pResource
    }
    • m_pResourceX拥有某种资源
  • 考虑如下代码中最后一行赋值的执行

    1
    2
    3
    X foo();
    X x;
    x = foo();
    • 克隆foo()返回的临时对象中资源
    • 析构x中资源,替换为foo()返回临时对象中资源副本
    • 析构foo()返回临时对象
  • 以上赋值流程效率低、没有必要,考虑交换xfoo()返回 临时对象资源副本,即move语义

    1
    2
    3
    X& x::operator=(X&& rhs){
    // swap this->m_pResource and rhs.m_pResource
    }
    • 执行同样x=foo()

      • 此赋值操作只有由编译器自动析构foo()返回的临时 对象
      • x.m_pResource被转移给临时对象,在临时对象析构 时被析构
    • 交换成员其实和右值引用没有必然联系

      • 在其他方法中同样可以交换成员对象
      • 但是只有在参为右值引用时,交换成员对象确保 不会对其他代码逻辑造成破坏
    • 右值引用参数函数是对左值引用参数函数的重载

      • 编译器优先为右值引用调用以右值引用为形参的函数
      • 区分右值引用、左值引用可以尽量节省资源
Perfect Forwarding

完美转发:

引用值类型

  • 无论左值引用、右值引用

    • 引用作为变量被保留,则其为左值
    • 否则为右值
  • 左值引用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    int foo(42);
    int & bar = foo;
    // `bar`是对`foo`的左值引用
    int & baz = bar;
    // `baz`是对`bar`的左值引用
    // `bar`左值引用本身是左值
    int qux = ++foo;
    // 前置自增运算符返回左值引用
    // 此时左值引用作为右值
  • 右值引用

    1
    2
    3
    4
    5
    6
    7
    class Type;
    void foo(Type&& bar){
    Type baz(bar);
    // `bar`是左值
    }
    Type&& qux();
    quxx = qux();

引用叠加

  • C++11中引用叠加规则如下

    • Type& & -> Type&
    • Type& && -> Type&
    • Type&& & -> Type&
    • Type&& && -> Type&&
  • C++11之前不支持引用叠加,以下代码报错

    1
    2
    typedef int& intR;
    typedef intR& intRR;

指针使用

声明指针

1
int *p1, *p2;
  • 编译器需要知道指针base type,才能正确的解释指针地址 中的数据

  • *用于指明变量为指针变量

    • 语法上属于变量名:声明时需要给每个指针变量标记*
    • 但拥有基类型:是用于声明、定义的类型
  • base type:基类型,指针所指对象的类型

指针使用

  • &:取地址
  • *dereferencing,解析引用,取指针所指向对象的值
  • ->:解析+选择操作符,取指针指向对象的成员

特殊指针

  • this指向当前对象

    • 解决二义性:引用当前对象的实例变量,即使其被形参、 局部变量覆盖
    • 有建议:总是使用this引用当前实例变量使代码更具有 可读性
    • 类方法调用都将this作为隐含参数,指向当前实例,即 主调函数中类实例
  • NULLnull pointer,空指针,不指向任何实际内存地址

    • 在内部表示为0
    • <cstddef>中已定义
    • 使用*解释空指针不合法,但不总是能检测出来

引用调用

C++内部通过指针实现引用调用

  • 参数通过引用传递时,栈帧会在调用时存储一个指针指向该值 的内存单元
    • 引用参数被声明为引用类型,编译器会自动解析其指针值
  • 可以通过明确调用指针替代引用调用的效果

Pointer Arithmetic

指针运算:对指针进行加减的运算

1
2
3
4
5
p + k
// **定义**为:`&array[k]`
*p++
// 一元运算符右结合,等价于`*(p++)`
// 检索数组当前元素,并将索引指向下个元素
  • 只有+-运算有意义,且有约束

    • 可以+-整数
    • 不能指针相加
    • 可以指针相减,返回两个指针之间数组元素数量
  • 建议使用数组索引而不是指针运算提高可靠性

Array

数组:较低级的多个数据值的集合

  • 特性
    • 有序
    • 同质
  • 约束
    • 数组分配内存大小固定
    • 数组大小不允许被获得
    • 不支持插入、删除元素
    • 不检查越界:重大安全隐患
  • C++提供的内置数组类型,基于从C语言继承而来的语言模型
  • 考虑到Vector集合类更加灵活方便,没有什么理由继续使用 数组

数组使用

1
2
3
4
5
6
7
8
type name[size];
// 声明大小为`size`、类型为`type`的数组`name`
name[idx];
// 选择数组`name`中`idx`处元素
type static_arr [] = {};
// 数组静态初始化
const int ARR_LEN = sizeof static_arr / sizeof static_arr[0];
// 获取数组长度
  • 声明:多数情况下,应该使用符号常量而不是确定的整数值指定 数组大小,以便修改代码

  • selection:通过数组名+[idx]选择元素

  • 静态初始化:可以忽略数组容量,编译器自动从初始化的元素 数目推断

  • 获取数组分配容量:基于数组同质性

数组容量

  • allocated size:声明时指定的数组容量
  • effective size:实际使用到的元素数目
  • 声明比需求大的数组
    • 定义常量表示数组元素数目最大值,以此声明数组

指针&数组

数组名同时也用作一个指针值,表示数组中首个元素地址

  • 如果编译器遇到数组变量名没带下标,则将其解释为指向数组 开始内存的指针变量

  • C++将数组视为指针最重要的原因:数组形参和实参共享

    • 数组和指针作为形参声明函数完全相同
    • 数组作为实参传递时,其值(首个元素地址)类似指针被 复制,调用函数中对数组的改变持久
    • 应该使用能反映其用途的方式声明参数,打算用数组作为 参数就声明参数为数组
  • C++中指针、数组最关键区别:变量声明时内存分配

    • 数组:连续的、可以存储数组元素的内存
    • 指针:存储机器地址的一个字的内存,不能直接存储数据
  • 指针作为数组使用

    • 将已存在数组首地址赋给指针有严格限制
    • 真正优势是程序运行时动态分配内存创建数组

动态内存管理

分配内存

  • 动态分配的内存在分配其的栈帧被释放后仍然保持
  • 动态内存分配一定要手动及时释放

new

new:以某种类型,从堆中分配一块空间给所指定类型的变量

  • new操作符返回堆中预留的、存储某类型值的地址

    1
    2
    3
    4
    5
    6
    7
    8
    9
    int *ip = new int;
    // 从堆中分配单个类型空间给指定指针变量
    int *array = new double[4];
    // 从堆上给数组分配空间给指定指针变量
    // 动态数组
    Point *p_1 = new Point;
    // 从堆上给对象、结构体分配空间,调用默认构造函数
    Point *p_2 = new Point(2, 3);
    // 类型名后提供参数,则`new`会调用相应构造函数
  • 一旦在堆中分配了空间,可以通过解析指针来引用

xlloc

释放内存

delete

delete:取new操作符事先分配内存的指针,释放该指针指向 的内存空间

1
2
3
4
delete ip;
// 释放单个类型空间指针变量
delete[] array;
// 释放动态数组

释放内存策略

  • garbage collection:垃圾回收,自动查找不再使用的内存, 然后释放

拷贝

  • shallow copying:浅拷贝,C++默认拷贝

    • 如果值是指针,不会拷贝指针所指的值
    • 可以通过重载赋值操作符、构造拷贝构造函数改变默认的 浅拷贝行为
  • deep copying:深拷贝

    • 拷贝指针时,拷贝指针所指的值

关键字

Global Variable

const

const:常量,初始化之后不能改变

  • 优势

    • 描述性常量名使得程序更易于阅读
    • 大大简化程序日常中代码维护问题
  • const修饰经过依据优先级、其他关键字处理后的主体

    • 指针、值、参数:其他关键字处理得到目标语义主体处
    • 返回值:const置于函数签名头
    • 函数主体:函数体{}前、函数签名后
  • C++中常量声明中字符应全部为大写
  • 所以常量类成员必须在初始化列表中设置值

static

非成员静态变量

非成员静态变量:程序执行前既已在静态数据区分配内存

  • 静态全局变量

    • 未经初始化时会被自动初始化为0
    • internal属性,仅在声明文件内部可见,文件外不可见, 较一般全局变量不容易冲突
    • 普通全局变量:默认external,可以通过extern关键字 被其他文件访问,随机值初始化
  • 静态局部变量

    • 仅会在首次声明时被初始化,之后重复声明不会初始化
    • 具有static-storage duration/static extent,仅在 声明其的局部作用域中可见,但不会随函数栈退出而销毁, 适合函数重入需要保存局部状态场合

非成员静态函数

非成员静态函数:作用域仅限于声明文件

  • 仅在声明其的文件中可见,不能被其他文件使用,较一般函数 不容易发生冲突

    • 函数定义、声明默认extern,可通过extern关键字在 其他文件中使用

静态成员变量

静态成员变量:程序执行前既已在静态数据区分配内存

  • 静态成员变量属于类

    • 在内存中只有一份拷贝,所有类对象共享
    • 可以通过类名直接访问<cls>::<static_var>,若访问 权限允许
  • 只能在类中声明,在类外初始化

    • 初始化:<var_type> <cls>::<static_var> = <value>

静态成员函数

静态成员函数

  • 静态成员函数属于类

    • 没有this指针
    • 只能访问静态成员变量、静态成员函数
    • 可通过类名直接调用<cls>::<static_func>(),若访问 权限允许
  • 类内声明静态成员函数需要static关键字,在类外定义时 无需static关键字

extern

extern声明使用未定义(全局)变量、函数

  • extern修饰的变量可在当前文件、或其他文件中定义

    • 当前文件extern声明处后定义:扩展(全局)变量作用 范围
    • 在其他文件中定义:引用在其他文件中定义的(全局)变量
  • 函数声明中extern:仅表示函数可能在其他源文件中定义, 即extern总是可以省略

    • 因为函数声明默认无定义,而变量声明默认同时定义
  • extern声明要严格对应定义格式

    • extern char *a不能用于声明char a[6]
  • extern不能用于访问其他文件中静态全局变量
  • 编译器遇到extern函数、变量时在其他模块中寻找定义

external全局变量

  • 全局变量默认为external

    • 虽然作用域默认仅限于为当前文件,但是可以通过 external关键字在其他文件中声明访问
    • 可以通过static关键字使变量internal,在其他文件 中不可见
  • 程序中不能有多个同名非静态全局变量

    • 编译时:C++以文件为单位进行编译,可以有多个文件定义 同名非静态全局变量
    • 链接时:链接器将无法确定链接目标报错

头文件全局变量

  • 引入头文件相当于直接复制头文件内容,若在头文件中定义 全局变量

    • 头文件可以被多个源文件引入
    • 相当于在多个文件中定义同名全局变量报错
  • 头文件中不应定义非静态全局变量

    • 定义静态全局变量:在多源文件中定义不共享、互相独立、 不可见全局变量
    • 在头文件中声明extern全局变量:在某个源文件中仅定义 一次该全局变量,包含该头文件源文件共享该全局变量

extern "C"

extern "C":指定编译、链接规约,不影响语义

  • extern "C"是为C++编译器指定的

    • 编译C++文件时:对extern "C"声明的函数按照C编译规约 翻译名称、编译
    • 链接时:对extern "C"声明的函数按照C链接规约链接
  • "C":是指编译、链接规约,不是指C语言

    • 其他符合类C语言编译、链接规约的语言,如:Fortran、 Assembler,均可以使用extern "C"声明
  • C++支持重载,编译器会联合函数名、参数生成中间函数名, 在C++中使用C函数可能导致链接器无法到对应C函数
C++环境使用C函数

C++可以直接使用C函数接口,只是需要指明使用C函数

  • 使用extern "C"逐个声明C函数

    1
    2
    3
    4
    extern "C" void func(int a);
    extern "C"{
    void func(int a);
    }
    • 适合需要声明函数数量较少、分散在不同文件中
  • 在头文件中设置宏编译编译条件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    #ifdef __cplusplus
    #if __cplusplus
    extern "C"{
    #endif
    #endif

    /*...c code...*/

    #ifdef __cplusplus
    #if __cplusplus
    }
    #endif
    #endif
    • 仅在C++源文件中有extern "C"声明,保证正常编译、 链接
    • 适合C函数声明集中在同一头文件中
C环境使用C++函数

C环境无法直接使用C++函数,必须转换为C函数接口才能使用

  • 通过extern "C"将C++中函数声明为C函数

    1
    2
    3
    extern "C" void func(int a);
    void func(int a);
    void func(int a, int b);
    • 带有extern "C"、不带是两个中函数声明,可以共存
    • 虽然C++中不能对重载函数声明extern "C",但C不支持 函数重载,也没有必要对重载函数声明extern "C"
  • 通过extern "C"创建包装C函数

    • 可以类似C++翻译重载函数名,将重载函数包装为多个C函数

      1
      2
      3
      4
      5
      void f(int);
      void f(doubel);

      extern "C" void f_i(int i){ f(i); }
      extern "C" void f_d(double d){ f(d); }
    • 可以包装类成员方法

      1
      2
      3
      4
      5
      6
      7
      class C{
      virtual doubel f(int);
      }

      extern "C" double call_C_f(C* p, int i){
      return p->f(i);
      }

register

register:寄存器变量,将局部变量值放在运算器的寄存器中

  • 存放在寄存器中的变量参与运算时,无需从内存中获取,节省 时间、提高效率

auto

auto:要求编译器对变量类型进行自动推导

  • auto声明变量必须初始化,以使编译器能够推导变量类型

    • 从此意义上说,auto是类型声明占位符,编译器在编译时 将auto替换变量实际类型
  • auto优势、用途

    • 声明有初始化表达式的复杂类型变量时简化代码
    • 避免声明变量类型时错误:编译器自动选择最合适类型
      • 函数返回值类型不确定,使用auto代替
    • 一定程度上支持泛型编程
      • 修改函数返回值类型时,无需修改代码
      • auto可以结合模板使用,加强泛型能力
  • C++11前,auto指具有自动存储期的局部变量,而没有声明为 static变量总是具有自动存储期的变量,使用频率极低
    • 具有自动存储期变量在进入声明该变量的程序块时被建立
    • 存在于程序块存活时,退出程序块时被销毁

注意事项

  • auto可以联合volatile*&&&使用

  • auto需要被推导为类型

    • 声明变量必须初始化
    • 不能和其他类型联合使用
    • 函数参数、模板参数不能被声明为auto
  • auto只是占位符,不是独立类型,不能用于类型转换或其他 涉及具体类型操作,如:sizeoftypeid

  • 定义在一个auto序列中变量必须推导为同一类型

  • auto不会被自动推导为constant&volatile qualifiers, 除非被声明为引用类型

    1
    2
    3
    const int i = 99;
    auto j = i; // `j`为`int`类型,不是`const int`
    auto& k = i; // `i`为`const int&`类型
  • auto会退化为指向数组的指针,除非被声明为引用

    1
    2
    3
    4
    5
    6
    7
    int a[9];
    auto j = a;
    count << typeid(j).name() << endl;
    // 输出`int*`
    auto& k = a;
    count << typeid(k).name() << endl;
    // 输出`int[9]`

decltype

decltype:要求编译器在编译时进行类型推导

  • 以普通表达式作为参数,返回表达式类型

    • decltype不会对表达式进行求值
  • decltype用途、优势

    • 推导表达式类型
    • using/typedef联合使用,定义类型
    • 重用匿名类型
    • 结合auto,追踪函数返回值类型

推导规则

  • e为无括号的标记符表达式、类成员访问表达式

    • decltype(e)e所命名的实体类型
    • e为被重载函数、或实体不存在,将导致编译错误
  • e类型为T

    • e为将亡值,则decltype(e)T&&
    • e为左值,则decltype(e)T&
    • e为纯右值,则decltype(e)T

volatile

  • volatile: a situation that is likely to change suddenly and unexpectedly

volatile:变量可能受到程序外因素影响,不应该对其做出 任何假设

  • C/C++中对volatile对象访问,有编译器优化上的副作用, 降低性能

    • volatile对象访问必须与内存进行交互,不能直接 使用寄存器中已有值
    • volatile对象相关代码块不允许被优化消失
    • 多个volatile对象访问保序编译器不可交换 对volatile对象访问的执行次序
      • 不能保证CPU乱序执行不交换执行次序
      • 因此volatile无法解决多线程同步问题
    • X86、AMD64等常用架构CPU只允许store-load乱序, 不允许store-store乱序,此时volatile用于线程同步 是安全的,但是这依赖于硬件规范,且会降低执行效率
    • 推荐使用原子操作、互斥量等实现
  • 用途

    • 信号处理相关场合
    • 内存映射硬件相关场合
    • 非本地跳转相关场合

错误线程同步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
volatile bool flag = false;
thread1(){
flag = false;
volatile Type* value = new Type();
thread2(value);

while(true){
if(flag == true){
// 希望`thread2`更新完`value`后继续执行
// `flag`为`volatile`保证此语句块不会被优化消失
apply(value);
break;
}
}
thread.join();
if(nullptr != value){
delete value;
}
return;
}

thread2(volatile Type* value){
value->update();
flag = true;
// `flag`、`value`均为`volatile`保证两语句不会交换执行次序
return;
}
  • 以上代码即使对相关变量都设为volatile,也依赖CPU执行 规范才能保证安全

__unaligned