C/C++面试知识点

本文深入探讨C++中的内存管理技术,包括std::alloc的实现细节,内存配置与释放策略,以及语言基础特性如const、volatile、mutable等的使用场景。同时,解析了类的继承方式、构造与析构函数的工作流程,以及智能指针shared_ptr和weak_ptr的内部机制。
摘要由CSDN通过智能技术生成

语言基础

空间的配置与释放 std::alloc(SGI实现)

设计时需要考虑的问题:

  • 从堆区申请内存空间
  • 考虑多线程
  • 考虑内存不足时的应对措施
  • 考虑大量小区块可能造成的内存碎片问题

SGI使用了双层配置器,以分别处理大内存请求和小内存请求,以避免内存碎片问题。
代码通过检查是否定义了 __USE_MALLOC 来决定是否使用二级配置器。

  • 当没有定义 __USE_MALLOC 时,使用一级配置器。一级配置器使用 malloc、realloc、free 函数执行实际的内存申请和释放,
    并实现了 new-handler 用于允许用户设置内存不足时的处理函数。
  • 否则,使用二级配置器处理用户请求。二级配置器主要用于处理对小内存块的请求,如果发现用户请求的内存块大于128bytes,
    就会将工作转交到一级配置器。

下面主要介绍二级配置器如何处理对小内存块(<= 128bytes)的请求。这涉及两个部分:free_list和内存池。
free_list保存了不同的固定大小的内存块,内存块大小分别是 8bytes、16bytes、...、128bytes,总计16种。
数据结构如下所示:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

int Align = 8;

int MaxBytes = 128;

int NFreeLists = MaxBytes/Align; // 16

 

// 注意这种数据结构,内存管理者使用next指针,用于形成一个链表。

// 对于用户,管理者按照用户请求分配空间,用户就可以使用 client_data 作为空间起始地址,申请大小即为内存大小。

union obj

{

    union obj *next;

    char client_data[1];

};

 

// 用一个数组维护每种内存块链表的起始地址,free_list 元素的下标与内存块大小一一对应。

// 内存块同样用 union obj 来表示,当其在链表中时,表示没有被用户使用,其 next 成员就指向下一个空闲内存块。

// 值得一提的是这里我们没有用额外的空间记录空闲内存,而是直接利用空闲内存来记录,这增大了空闲的利用率,但是这是以固定内存块大小为代价的。

// 当分配给用户时,我们可以直接将链表首部指向的整块内存分配给用户。

obj * volatile free_list[NFreeLists];

当响应用户的小内存块请求时,首先将其转换为能满足需求的最小的固定内存块大小,再以此为用户分配内存。过程为:

  1. 如果 free_list 中对应固定块的链表不为空,直接将其分配给用户,并更新 free_list。
  2. 否则重新填充 free_list,填充成功就返回对应内存给用户,否则返回 NULL。

下面详细介绍填充 free_list 的过程,主要是获取内存,默认是填充20个内存块:

  1. 如果内存池空间足够满足20个内存块,直接取得空间。
  2. 否则,如果内存池空间可以满足 k(0 < k < 20) 个内存块,返回可用的 k 个内存块。
  3. 将内存池空间分配给其他的较小的内存块链表。
  4. 从堆中申请空间,补充内存池。一般申请的空间比需要的空间多。
  5. 申请成功,则递归调用自身,继续分配。
  6. 申请失败,从 free_list 中更大内存块链表中寻找可用内存。如果找到,将其分配到内存池,再递归调用自身。
    如果没有找到,就调用一级空间配置器,由其调用 new-handler 处理空间不足的情况/抛出异常。

初始化

默认初始化发生在下面的情形中:

1

2

3

T obj;

 

new T;

  • 定义一个变量但是没有给出初始值.
  • 用 new 创建一个对象, 但是没有给出初始值.(提供了括号就认为提供了初值)
  • 当父类或非静态数据成员没有出现在构造函数初始化列表中时.

此时其效果为:

  • 如果变量是类类型, 就调用默认构造函数进行初始化.
  • 如果变量是数组类型, 就对每个成员进行默认初始化.
  • 否则, 不做任何事. 此时变量的值是不确定的.

定义数组时, 如果不给出初始值列表, 数组的值是不定的, 如果给出的初始值列表长度小于数组长度,
甚至是0, 也会使得没有给出初值的部分被值初始化.

const

  • 用于声明常量, 表明变量是不可以修改的.
  • const 引用可以延长临时变量的生命期, 例如将函数返回值赋予一个常量引用时, 该返回值的生命期会持续到引用的作用域结束.

constexpr

  • 用于指明函数/表达式/变量的值在编译时就能确定下来.
  • 用于修饰类的数据成员时, 隐式声明数据成员为 const.

volatile

计算机有时会将变量缓存到寄存器中, 而不从内存获取变量的值, 有时候这可能会带来错误.
volatile 将会阻止这样的优化, 使得每次访问(读, 写, 成员调用)值时都会访问内存.

可以定义 const volatile 变量, const 表明程序不能修改变量, 但是外部设备可能会修改变量.
例如只读寄存器, 程序不可以写, 但是 CPU 可能会更新其值.

mutable

  • 用于修饰类的非静态数据成员, 成员不能是引用, 不能是 const.
  • 表明即使类对象是常量, 或通过常量引用访问该数据成员, 或在 const 函数中访问该成员时, 也可以写该成员.

引用

  • 类的非静态引用成员必须在类内给定初始值或在构造函数初始化列表中初始化, 当两者都存在时, 忽略前者.

decltype

decltype 得到变量或表达式的类型, 但是不会计算表达式. 注意:

1

2

3

4

5

6

int a, *b = &a, &c = a ;

decltype(a)   d; // int

decltype((a)) e; // int&, 这是一个表达式, 变量被这样使用时, 结果永远是引用.

decltype(*b)  f; // int&, 因为这是一个表达式, 解引用得到的是左值.

decltype(c)   g; // int&. 通常情况下, 引用都是其所指对象的同义词, 只有在decltype中例外.

decltype(a+0) h; // 类型是 int.

static

可以用于声明类静态成员, 表明类成员与对象无关. 静态数据成员一般需要在类外初始化, 除了下面几种情况:

  • inline static: 可以在类内初始化
  • const static: 可以在类内初始化
  • constexpr static LiteralType: 必须在类内初始化

可以用于函数中, 声明静态局部变量. 变量在第一次调用该语句时被初始化, 以后经过该语句时不再初始化.
并且从 C++11 开始, 这是线程安全的. 如果没有给定初始值, 会进行零初始化或调用默认构造函数.

用于声明静态全局变量, 表示该变量只在本文件内可见, 即使其他文件声明了该变量, 链接时也无法找到他.

用于声明静态函数, 表示该函数只在本文件内可见.

this

  • 在类的非静态函数中, 隐含了 this 指针, 隐式声明为 ClassName * const this, 因此不能对 this 赋值.
  • 当对象是常量, 或通过常引用调用函数, 或函数被声明为 const 时, this 指针被隐式声明为 const ClassName * const this,
    因此不能修改对象成员, 对于前两者, 不能调用对象的非 const 函数.
  • this 是一个右值, 因此不能取 this 的地址.

inline

  • inline 只是建议内联, 并不能保证, 是否内联由编译器决定.
  • inline 是在编译期实现的, 因此对于通过引用或指针调用的虚函数, 不会发生内联,
    这是因为在运行期才会知道实际调用的是哪个函数.

sizeof

sizeof(type or expr), sizeof expression: 前者用于类型和表达式, 后者用于表达式, 但是不会执行.

pragma pack

详细解释参考gcc onlinedocs 6.62.11.
其作用是改变默认的最大对齐方式, 针对的是随后的 struct, union, class 的成员的对齐. 要求指定的对齐大小必须是2的幂.

1

2

3

4

5

6

7

8

9

10

11

12

// 设置新的对齐大小.

#pragma pack(n)

 

// 使用编译时通过参数给出的对齐大小.

#pragma pack()

 

// 将当前的对齐大小入栈, 也可以将指定大小的对齐方式入栈(第二种形式)

#pragma pack(push)

#pragma pack(push, n)

 

// 将栈顶数字出栈, 并将对齐大小设置为该值.

#pragma pack(pop)

extern

首先介绍一下链接, 是指将不同的编译单元组合为一个可执行文件的过程. 其中涉及的一个问题是一个编译单元引用了其他编译单元定义的函数,
在链接过程中就需要将这个引用修改为正确的函数地址, 因此就需要保证链接器能找到引用的函数.
这就需要两个编译单元使用同样的 calling convention, name mangling algorithm 等.
对于 name mangling, C++由于支持重载, name mangling 还包含了函数的参数.
而C语言不支持重载, name mangling 只使用了函数名.

因此C++和C语言混合使用时,需要使用extern "C"声明,保证能互相调用。具体包括下面的几种情况:

  • 用于C++代码中,修饰C函数声明,函数定义在C编译单元中。这使得C++编译单元可以和C编译单元链接,即在C++中可以调用C编译单元中定义的代码。例如:

    1

    2

    3

    4

    5

    6

    7

    8

    9

    // C++ source code

    extern "C"

    {

        int open(const char *pathname, int flags); // C function declaration

    }

    int main()

    {

        int fd = open("test.txt", 0); // calls a C function from a C++ program

    }

  • 用于C++代码中,修饰函数定义。这使得在其他C编译单元中可以调用该函数,在函数定义中可以使用C++语法、标准库等。例如:

    1

    2

    3

    4

    5

    // C++ source code

    extern "C" void handler(int// can be called from C source code

    {

        std::cout<<"Callback invoked\n";

    }

但是注意,当块中出现类成员声明和类函数声明时,即使声明了 extern "C",仍然会被作为 extern "C++"。

switch 和 goto

不允许出现可能跳过变量定义, 而后面的语句仍然使用该变量的情况. 例如:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

switch (n)

{

case 1:

    int i = 10;

    ++i;

    break;

case 2:

    i = 11; // n 值为 2 时导致跳过 i 的定义语句.

    ++i;

    break;

default:

    cout<<n<<endl;

    break;

}

 

{

    goto END;

    int j = 10;

 

    END:

    j += 10; // 提过了 j 的定义

}

强制类型转换

  • static_cast: 非多态类型的转换, 可以将子类(引用/指针)转换为父类(引用/指针), 不能向下转换.

  • const_cast: 用于改变对象的 const/volatile. 如果对象本身是一个常量, 移除 const 属性后对其修改是 UB.

  • reinterpret_cast: 为运算对象的位模式提供较低层次上的重新解释, 常用于改变指针的类型.
    能保证对象被转换为某种中间类型, 再转换回原类型时值不会改变.
    尽量不要使用 reinterpret_cast, 无关类型的转换是不安全的.

    1

    2

    3

    int i = 11;

    char *cp = reinterpret_cast<char *>(&i);

    reinterpret_cast<unsigned int&>(i) = 22; // now i is 22

  • dynamic_cast: 用于执行多态类型的转换, 只能用于指针或引用, 会进行运行时检查, 转换指针失败时返回 nullptr,
    转换引用失败时抛出 std::bad_cast 异常. 这是他区别于 static_cast 的地方.

  • C风格的强制类型转换: 当替换为 static_cast/const_cast 也合法时, 就是等价的, 不合法时,
    等价于 reinterpret_cast.

static_cast 的能执行的转换并不多, 例如不能将 char* 转换为 string*, 这需要 reinterpret_cast.
static_cast 一般要求类型之间有一定联系, 例如都是数值类型, 指针类型存在父子关系等.
reinterpret_cast 在执行指针转换时没有这个限制, 可以执行任意指针类型间的转换.

malloc 实现

malloc用于在堆上分配空间, 一般 malloc 实际申请的内存比用户请求的内存要大.
对于已经从操作系统取得的内存, malloc 将其组织为一个空闲链表(元素不一定是空闲的).
链表元素为一块内存, 内存开始为上一块和下一块内存的地址, 另外是一个标志位, 记录此块内存是否已经被程序使用, 其余就是程序可以使用的空间.
当进程调用 malloc 时, 就在这个链表中搜索可用的内存空间, 第一次遇到可用的并且空间大于申请空间的时,
就将其分配给进程, 返回对应的地址. 如果分配后还剩余部分空间, 就将其拆分为另一个内存块, 加入到链表中, 并更新原来内存块的记录.
当搜索整个链表都没有发现可用块时, 就调用 brk/sbrk 以从操作系统获取内存, 并将其加入到空闲链表中. 或者在空闲链表中合并相邻空闲块.
实际 malloc 的实现可能更加复杂, 例如使用内存池, 位图等.

三种继承方式

继承方式的作用不是限制父类成员在子类中的访问权限(这是由父类中的成员权限声明决定的), 而是规定了外部对象通过子类访问父类成员时的权限.
在子类中, 可以访问父类的 public 成员和 protected 成员. 父类成员继承到子类后的权限为:

  • public 继承: 保持不变.
  • protected 继承: 父类的 public 成员在子类中变为 protected, 其余不变.
  • private 继承: 全部变为 private, 通过子类对象, 不能访问父类的任一成员.

数据成员

  • 在类中给定数据成员的初始值时, 只能使用 = 或 花括号, 不能使用小括号, 因为当括号为空时无法区分是变量初始化还是函数声明.

  • 类中的非静态的引用成员, 常量成员, 无默认构造函数的类类型对象: 必须在类中给出初值, 或在构造函数初始化列表中初始化.

  • 类中的静态数据成员不能在类内给定初值, 必须在类外定义赋值, 例外是静态常量或constexpr可以在类内赋值.

函数成员

  • 类成员函数可以基于 const 重载, 例如:

    1

    2

    3

    4

    5

    6

    class A

    {

    public:

        void f() const {}

        void f() {}

    }

  • 重载与作用域

    名字查找发生在类型检查之前.

    在局部作用域定义的函数/变量会隐藏定义在外部的同名函数, 注意函数的多个重载类型都会被隐藏.

  • 一般情况下, 函数和函数指针可以混用, 但是当函数返回一个函数指针时, 不能替换成返回一个函数.
    decltype(func) 得到的是函数类型, 不是函数指针类型.

虚函数

  • 只能用于非静态成员函数, virtual 关键字只能出现在类内的函数声明/定义中, 不能出现在类外的成员函数定义中.

  • 父类函数为 virtual 时, 子类函数如果具有相同的

    • 函数名
    • 参数列表(不包括返回值类型, 但是要求返回值类型要么相同, 要么是 covariant 的)
    • const 类型
    • 引用类型

    那么在子类中函数也是虚函数(即使没有声明为 virtual).

    covariant 一般用于: 父类和子类的虚函数返回值类型同时是引用/指针, 分别为 Tb, Td(或 Tb&, Td&),
    并且 Tb 是 Td 的基类, 并且后者的 cv 标记符不能多于前者的. 总之, 后者可以隐式转换为前者.

  • 尽量将基类析构函数声明为虚函数, 保证对象能被正确析构.

  • 父类和子类的虚函数不必具有相同的访问级别.

  • 可以用 final 关键字来禁止子类重写 virtual 函数, final 放在函数声明头部的最后.

  • 可以用 override 关键字表明重写父类 virtual 函数, 如果声明不满足重写条件, 则会报错.

  • 构造函数, 复制构造函数, 静态函数不能是虚函数.

  • 在构造函数或析构函数中调用虚函数时, 只会调用在构造函数所属类层次可见的函数,
    即使构造函数所属类是其他类的父类. 因为此时这些子类的构造函数还没有被调用, 也就不存在.
    当存在多个继承分支时, 如果通过其他分支获取基类(即两个分支都继承了该基类)对象, 并访问虚函数, 行为是 UB.

  • 当虚函数的参数存在默认参数值时, 其值是在编译期决定的, 即由对象的静态类型决定.
    当基类虚函数定义了默认参数值时, 如果通过基类引用/指针访问子类虚函数, 使用的是默认参数值是基类定义的.
    另外, 如果基类虚函数定义了默认参数值, 而子类重写该函数时没有定义默认参数值, 那么通过子类直接调用虚函数就是违法行为.

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    14

    15

    16

    17

    struct base

    {

        virtual void f(int a = 1);

        virtual void g(int a = 2);

    };

    struct derived: base

    {

        void f(int a = 11) override;

        void g(int a) override;

    };

     

    derived d;

    base &b = d;

    b.f(); // a = 1;

    b.g(); // a = 2;

    d.f(); // a = 11;

    d.g(); // illegal, need parameter.

纯虚函数

  • 仍然可以定义, 但是不能在类内定义. 可以在子类对象中通过类名加限定符的方式调用之.

  • 析构函数可以声明为纯虚函数, 但是必须在类外提供其定义.

  • 当父类虚函数不是纯虚函数时, 子类仍然可以将其声明为纯虚函数.

  • 不能在抽象类的构造函数和析构函数中调用纯虚函数, 不论该函数是否有定义.

  • 即使是抽象类, 当其作为基类时, 子类的构造函数也会调用其构造函数.

虚继承

  • 为了解决菱形继承导致的二义性问题, 并节省存储空间.

虚函数表

参考<<深度探索C++对象模型>>的笔记.

其他

  • 自动类型转换

    • 一个表达式中同时含有无符号整型和有符号整型时, 有符号整型会被提升到无符号整型,
      此时如果有符号数为负数, 就会带来错误, 因为它提升后的值为原来的值(负数)加上无符号类型的最大值再加一.

      1

      2

      3

      4

      5

      int i1 = -42;

      unsigned int i2 = 10;

       

      // equal

      cout<<(i1 + i2)<<' '<<(std::numeric_limits<unsigned int>::max() - 42 + 1 + 10)<<endl;

    • 计算表达式中, char, unsigned char, signed char, short, unsigned short 等类型的变量,
      只要其值能出现在 int 中, 都会被转换为 int, 即使表达式中没有 int. 这被称为 integer promotion.
      其原因是转换为 int 后往往会带来更高的效率, 体积更小的可执行文件.

      1

      2

      3

      short sval;

      char cval;

      sval + cval; // sval 和 cval 都会被先转换为 int.

    • 数组转换成指针: 大多数情况下都会自动转换, 除了:

      • 数组被作为 decltype 的参数,
      • 数组作为取地址符(&), sizeof 和 typeid 等运算符的运算对象. 对数组取地址得到的值等于数组首地址,
        但是类型是 type (*) [n], 即加一之后指向数组后的地址.
      • 用数组初始化一个数组的引用.

      1

      2

      3

      4

      5

      int arr[10];

      decltype(arr) arr2; // int [10]

      sizeof arr; // 40

      auto p = &arr; // == &arr[0]

      ++p; // point to &arr[10]

  • 异常安全

  • 编译相关: 例如不同编译单元的编译顺序, non-local static 对象的初始化顺序等.

C++11 相关

  • shared_ptr, weak_ptr

    • use_count 方法是线程安全的, 并且资源管理是线程安全的, 也就是说多线程环境下能保证资源被安全释放.
      但是资源访问不是安全的, 因此多线程访问资源时仍然需要同步.

    • 两个智能指针内部最主要的两个数据成员如下(这里和下面提到的数据成员不一定是类内直接定义的, 有的是在父类内),
      分别是一个指向对应数据的指针和一个计数器, 计数器并不直接保存之所以不是指针是为了保证在复制时能更新计数器.

      1

      2

      3

      element_type* _M_ptr;               // pointer to data

      __shared_count<_Lp> _M_refcount;    // counter in shared_ptr

      __weak_count<_Lp>  _M_refcount;     // counter in weak_ptr

      两种计数器都将对方声明为友元, 这样就能直接用一方来初始化另一方. 二者内部数据成员相同, 如下:

      1

      2

      3

      4

      5

      6

      7

      8

      9

      10

      11

      _Sp_counted_base<_Lp>*  _M_pi;

       

      template<_Lock_policy _Lp = __default_lock_policy>

      class _Sp_counted_base: public _Mutex_base<_Lp>

      {

          /*

           * 相关函数等

           */

          _Atomic_word  _M_use_count;  // #shared, 记录shared_ptr指针个数

          _Atomic_word  _M_weak_count; // #weak + (#shared != 0), 记录weak_ptr指针个数

      };

      这个指针指向真正的计数器. 其内部有两个数据成员, 分别记录 shared_ptr 和 weak_ptr 指针的个数.
      对这两个值的改变是原子的, 也就保证了 use_count 方法的原子性.

  • enable_shared_from_this

    • 当需要在类内部通过 this 指针构造 shared_ptr 时, 需要让类继承 enable_shared_from_this 类,
      然后通过 shared_from_this 来构造 shared_ptr.

    • 注意, 当使用继承了 enable_shared_from_this 的类时, 最好不要直接构造对象(静态/动态),
      而应该通过智能指针来访问和管理类对象. 也就是说, 当我们调用成员函数, 并在函数中通过 this 构造 shared_ptr 时,
      对象已经被其他 shared_ptr 管理着.

    • 其实现方法是: enable_shared_from_this 类中定义了 weak_ptr, 当 shared_ptr 管理一个对象时,
      会先检查他是否继承自 enable_shared_from_this. 如果是的话, 就会初始化这个 weak_ptr(指针及引用计数).
      其后调用 shared_from_this 时就通过此 weak_ptr 来构造 shared_ptr.

    • shared_ptr循环引用

      当两个对象中都包含对方的智能指针时, 产生循环引用, 因此无法释放资源.
      下面的代码中, p1 先销毁, 此时发现其管理对象的引用是2, 因此不会调用析构函数来销毁对象, p2 销毁时同理.
      最终两个对象都没有被释放, 造成内存泄漏.

      1

      2

      3

      4

      5

      6

      7

      8

      9

      10

      11

      struct A

      {

          shared_ptr<A> p;

      };

      shared_ptr<A> p1(new A), p2(new A);

      p1->p = p2;

      p2->p = p1;

       

      // 还可以自指, 同样会造成资源无法释放

      shared_pt<A> p3(new A);

      p3->p = p3;

  • allocator类

    new 将内存分配和对象初始化组合起来, 简化了操作, 但是有时候对象的初始化是没有必要的, 这就带来了额外的开销.
    allocator的作用是将内存分配和对象初始化分离, 将内存释放和对象析构分离, 并提供一个通用的内存管理和对象管理的接口.
    主要是利用 operator new/malloc 来申请内存, place new 来构造对象.

  • 右值和左值引用成员函数

    可以通过引用限定符来限制成员函数只能用于左值或右值对象, 方法是在成员函数的声明和定义中加上 & 或 && 符号,
    位置与 const 成员函数中 const 的位置相同. 当引用限定符应用于 const 成员函数时, 必须放在 const 之后.
    注意: 如果一个成员函数有引用限定符, 则有相同参数列表的所有版本都必须有引用限定符, 此属性可参与重载.
    不带引用限定符的函数可用于左值和右值.

  • auto和decltype是基于模板推导实现的.

  • std::movestd::forward的实现

参考博客.

首先介绍引用折叠原则(reference collapsing rule), 使用typedef可一窥其容:

1

2

3

4

5

6

7

typedef int&  lref;

typedef int&& rref;

int n;

lref&  r1 = n; // type of r1 is int&

lref&& r2 = n; // type of r2 is int&

rref&  r3 = n; // type of r3 is int&

rref&& r4 = 1; // type of r4 is int&&

即有下面的引用折叠规则:

  1. T& & 被折叠为 T&
  2. T&& & 被折叠为 T&
  3. T& && 被折叠为 T&
  4. T&& && 被折叠为 T&&

而当在模板函数中使用右值引用类型的参数时,有特殊的规则,举例说明:

1

2

3

4

5

6

template<typename T>

int f(T&&);

 

int i = 1;

f(i); // 实参是左值,函数 f 被实例化为 int f(int&),T 类型是 T&。

f(0); // 实参是右值,函数 f 被实例化为 inf f(int&&),T 类型是 int。

即在使用右值作为参数的模板函数中,推断规则是:

  1. 实参是左值时,T 类型被推断为 T&。
  2. 实参是右值时,T 类型被推断为 T。而不是 T&&。

然后就可以分析 std::move 和 std::forward 的实现代码了,如下所示。对于 std::move,无需解释。对于 std::forward,假设实参是int类型。
当传递的是一个左值时,调用第一种实现,T 被指定为 int,强制类型转换后成为 int& 类型,与返回类型折叠后成为 int&。
当传递的是一个右值时,调用第二种实现,T 被指定为 int&&,强制类型转换后成为 int&& 类型,与返回类型折叠后厨成为 int&&。
注意这里的两个函数都存在两次引用折叠,折叠遵循上面的引用折叠原则

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

template<typename T>

constexpr typename std::remove_reference<T>::type&& move(T&& __t) noexcept

{

    return static_cast<typename std::remove_reference<T>::type&&>(__t);

}

 

template<typename T>

constexpr T&& forward(typename std::remove_reference<T>::type& __t) noexcept

{

    return static_cast<T&&>(__t);

}

 

template<typename T>

constexpr T&& forward(typename std::remove_reference<T>::type&& __t) noexcept

{

    static_assert(!std::is_lvalue_reference<T>::value, "template argument substituting T is an lvalue reference type");

    return static_cast<T&&>(__t);

}

线程相关

thread 类用于创建一个线程, 对象一建立线程就开始运行. 传递给构造函数的是要运行的函数和传递给函数的参数,
一个要注意的问题是如果函数形参是引用, 需要用 std::ref(var) 来传递引用.

mutex 类表示互斥锁, shared_mutex 表示共享锁.

std::lock, std::try_lock 函数用于两个或两个以上的锁. 前者可以避免死锁. 当发生异常时, 二者会保证释放已lock的锁.

lock_guard 模板类是 RAII 的代表, 使用时用一个锁变量(如mutex)来构造它, 析构时析构函数为自动释放锁,
避免了忘记释放锁导致的问题. 如下所示:

1

2

3

4

5

6

7

std::mutex m;

void f()

{

    std::lock_guard<std::mutex> lg(m); // call m.lock() in constructor.

    // do some job;

    // ...

// call m.unlock() in lock_guard's destructor

unique_lock 实现了对锁的 RAII, 并且允许将锁的拥有权转移到其他 unique_lock 变量. 并且允许更加精细的对锁的操作, 如下:

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

28

std::mutex m1, m2;

void f0()

{

    // 在构造函数中用 lock 获取锁, 等价于 lock_guard.

    std::unique_lock<std::mutex> ul1(m1);

    std::unique_lock<std::mutex> ul2(m2);

// 析构函数释放锁.

void f1()

{

    // defer_lock 表示不获取锁, 由后面的其他语句获取锁.

    std::unique_lock<std::mutex> ul1(m1, std::defer_lock);

    std::unique_lock<std::mutex> ul2(m2, std::defer_lock);

    std::lock(m1, m2);

// 析构函数释放锁.

void f2()

{

    // try_to_lock 表示在构造函数中用 try_to_lock 获取锁.

    std::unique_lock<std::mutex> ul1(m1, std::try_to_lock);

    std::unique_lock<std::mutex> ul2(m2, std::try_to_lock);

    std::lock(m1, m2);

// 析构函数释放锁.

void f1()

{

    std::lock(m1, m2);

    // adopt_lock 表示假定线程已经获得了锁, 不再在构造函数中获取锁.

    std::unique_lock<std::mutex> ul1(m1, std::adopt_lock);

    std::unique_lock<std::mutex> ul2(m2, std::adopt_lock);

// 析构函数释放锁.

call_once 函数结合 once_flag 实现保证可调用对象只会被调用一次. 如下:

1

2

3

4

5

std::once_flag flag;

 

void f(int i1, int i2);

 

std::call_once(flag, f, i1, i2);

condition_variable 表示条件变量类. 使用方式如下:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

std::mutex m;

std::condition_variable cv;

 

void reader()

{

    m.lock();

    cv.wait(lk, callable); // 表示当调用 callable() 为 true 时才会返回.

 

    // process ...

 

    m.unlock();

}

 

void writer()

{

    m.lock();

 

    // process ...

 

    m.unlock();

    cv.notify_one();

}

promise 类用于线程之间的通信, 创建者将该类型的变量传递给线程函数, 当线程设置该变量的值时, 会通知创建者, 从而获取值.
其中还需要结合 future 类. 如下所示, 还可以将promise保存的对象类型设为 void 来实现线程和创建者间的同步.

1

2

3

4

5

6

7

8

9

10

11

12

void worker(std::promise<std::string> work_promise)

{

    // process ...

    work_promise.set_value(str);

}

 

std::promise<std::string> work_promise;

std::thread t(worker, work_promise);

std::future<string> work_future = work_promise.get_future();

 

work_future.wait(); // blocked until worker call set_value.

work_future.get();  // return value set by worker.

原子操作. 用 atomic 类包装数据, 支持整型, 指针, bool类型.

内存序(memory order)

存在的问题:

  1. 编译器为了提高程序效率, 可能会将用户的指令重新排序. CPU也存在乱序执行的情况. 而在多线程背景下,
    这样的乱序可能引发错误. 因此, 如果是一个没有锁保护的变量访问, 可能由于指令乱序导致错误.
  2. 在多线程中, 多个线程共享同一个变量时, 一个线程对变量的写可能无法立刻在另一个线程可见, 这也会导致两个线程出现不一致的情况.
    这个问题比较令人迷惑, 表面上看, 多个CPU可以拥有自己的缓存, 可以分别缓存同一变量, 分别对缓存进行操作时就会出现不一致.
    但是实际上CPU会有缓存一致性机制来避免此问题.

为此, C++提供内存序类来解决此问题, 他可以用来指明一个原子操作周围的普通指令(非原子)如何被排序,
例如不能将某个指令后的命令重排到该指令之前. 对于原子操作, 我们可以分为几类(不严格的分类):

  • load operation: 原子地读取数据
  • store operation: 原子地写入数据
  • read-modify-write(RMW) operation: 原子地读取并写入数据, 读取和写入中间可能还包括比较原始数据的操作.

有下面几种内存序, 可以在进行原子操作时指定对应的内存序:

  • memory_order_relaxed: 用于 load/store operation. 不做限制, 允许任何的指令乱序.
  • memory_order_comsume: 用于 load operation. 在本线程中, 该指令后的依赖于对应变量的操作不允许重排到该指令前.
    这一般只会影响到编译器的优化.
  • memory_order_acquire: 用于 load operation. 在本线程中, 该指令后的读写操作(包括对其他变量的?)不会被重排到该指令前.
    其他线程对该变量的写会立即同步到本线程.
  • memory_order_release: 用于 store operation. 在本线程中, 该指令前的读写操作(包括对其他变量的?)不会被重排到该指令后.
    本线程对该变量的写会立即同步到那些对该变量使用了 acquire 和 consume 的线程中.
  • memory_order_acq_rel: 用于 read-modify-write operation. 在本线程中,
    该指令后的读写操作(包括对其他变量的?)不会被重排到该指令前, 该指令前的读写操作(包括对其他变量的?)不会被重排到该指令后.
  • memory_order_seq_cst: 可用于所有操作, 保证满足 acquire 和 release, 并且保证对于所有线程,
    观察到的对数据的修改都是一致的.

从系统角度来说, 上面的限制可以分为优化屏障内存屏障. 在 linux 部分对此详述.

问题

  • 复制构造函数参数为什么不能是值, 必须是引用? 可以是非const, 那么为什么一般定义成const?

    当参数定义为值类型时, 将参数传递到复制构造函数中需要一次复制, 又会调用复制构造函数,
    这样会导致无限地递归调用. 因此C++规定复制构造函数参数必须是引用.

    定义成const引用有下面几个原因:

    1. 复制一个对象时, 按照语义不应该修改原对象. 即使做一些计数类的操作, 也应该将这些成员声明为mutable.
    2. 当原对象是const类型时, 如果不将参数定义为const, 就无法复制该对象.
    3. 如果不将参数定义为const, 就无法复制一个临时对象. 临时对象是一个右值, 非常量引用无法绑定到右值.
  • 用C++实现单例模式

    Lazy Singleton(懒汉模式): 单例实例在第一次使用时才进行初始化, 称为延迟初始化.

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    21

    // legal since C++11

    // Init of static local variable is UB in multithread-enviroment before C++11.

    class Singleton

    {

    private:

        Singleton()

        {

            // ...

        }

    public:

        Singleton(const Singleton&) = delete;

        Singleton& operator = (const Singleton&) = delete;

        static Singleton& instance()

        {

            static Singleton instance;

            return instance;

        }

    };

     

    // 使用

    auto &s = Singletion::instance();

    Eager Singleton(饿汉模式): 在使用实例前就初始化.

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    21

    class Singleton

    {

    private:

        Singleton()

        {

            // ...

        }

        static Singleton ins;

    public:

        Singleton(const &Singleton) = delete;

        Singleton& operator = (const Singleton&) = delete;

        static Singleton& instance()

        {

            return ins;

        }

    };

     

    Singleton Singleton::ins;

     

    // 使用

    auto &s = Singleton::instance();

  • 构造函数工作的过程

    首先初始化父类(直接父类和虚基类), 再初始化非静态数据成员, 最后执行构造函数的函数体.

    1. 如果当前构造函数是属于实际对象类型的, 就调用虚基类的构造函数.
      当有多个虚基类时, 按照类声明中虚基类的出现顺序从左到右调用构造函数.

    2. 初始化直接基类, 如果有多个, 按照当前类的继承声明, 从左到右的顺序初始化.

    3. 初始化虚表指针(vptr).

    4. 初始化非静态的数据成员, 按照成员在类中的定义顺序初始化.

    5. 执行构造函数的函数体.

      下面几点需要注意:

    6. 当虚基类/直接基类/非静态数据成员的部分或全部没有出现在初始化列表中时, 编译器仍然会按照上面的顺序初始化对象,
      并对那些没有出现在初始化列表中的数据成员采用默认初始化(如果类对象没有默认构造函数, 就会报错).
      (是的, 虚基类和直接基类也可以出现在构造函数初始化列表中)

    7. 对于有虚基类的情况, 即使没有出现菱形继承的情况, 也会先初始化虚基类.

    8. 当有多个虚基类时, 虚基类有时候并不是当前类的直接基类, 这时候顺序判断是: 看当前类的继承列表,
      从左到右检查该类是否有虚基类, 如果有就执行其构造函数(该虚基类也会执行其基类的构造函数, 并且虚基类的基类以后不会再次被构造).
      简而言而, 顺序是从左到右, 深度优先. 如果把当前类看做多叉树的根, 直接父类就是子节点(注意是父类作为子节点),
      子节点按照继承声明顺序从左到右排列. 初始化虚基类的顺序就是中序遍历的过程. 唯一的区别是,
      这里中序遍历的时候, 如果遇到了虚基类, 就不再继续向下遍历, 而是直接调用其构造函数, 由其构造函数负责其他子节点的初始化.

    9. 对虚基类的初始化要求"当前构造函数是属于实际对象类型的", 这是因为构造时会调用其他构造函数,
      例如父类的, 成员类对象的. 在调用父类的构造函数过程中, 我们不需要再次调用虚基类的构造函数.
      对于成员对象, 由于和当前类没有继承关系, 也就不存在这个问题, 其构造过程由其自己的构造函数决定.

    10. 注意vptr在初始化完基类之后就被设置. 因此在构造函数中调用虚函数时, 实际调用的是当前对象类型对应的虚函数,
      而不一定是我们想要的实际类型对应的虚函数. 因为C++根据vptr决定虚函数的调用. 析构函数中同样如此.

    11. 由于vptr在构造数据成员之前被初始化, 因此初始化列表中可以使用虚函数.

  • 析构函数工作的过程

    和构造函数的执行顺序正好相反, 不过没有处理vptr的步骤.

    1. 执行析构函数函数体
    2. 对非静态数据成员调用析构函数
    3. 对直接父类调用析构函数
    4. 对虚基类调用析构函数
  • 为什么不能根据返回值类型进行函数重载?

    因为在调用函数时无法显式地提供函数返回值类型, 这就导致编译器不能进行重载决议. 即使有时候会将函数返回值赋予某些变量,
    但是有时候程序员也不会保存函数的返回值, 仅仅是直接调用函数.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值