前言

以STL的运用角度而言,空间配置器是最不需要介绍的东西,它总是隐藏在一切组件(更具体地说是指容器,container)的背后,默默工作,默默付出。但若以 STL 的实现角度而言,第一个需要介绍的就是空间配置器,因为整个STL 的操作对象(所有的数值)都存放在容器之内,而容器一定需要配置空间以置放资料。不先掌握空间配置器的原理,难免在阅读其它STL组件的实现时处处遇到挡路石。
———— 《STL源码剖析》侯捷

本篇博客将会记录在实现 Allocator 过程中需要了解的知识点。

Allocator 相关知识点

newdelete

为了精密分工,STL 将空间分配与构造对象两阶段操作分开来进行,内存配置操作由 alloc::allocate() 负责,内存释放操作由 alloc::deallocate() 负责,对象构造操作由 ::construct() 负责,对象析构操作由 ::destory() 负责。在实现这些功能之前需要了解一般情况下的 C++ 内存配置与释放操作。

  1. New 干了什么事情

    先分配 memory,再调用ctor(构造函数)

    假设有一个 Complex 类型,代表复数,含有两个double 变量分别代表实部和虚部

    1
    2
    3
    4
    5
    6
    7
    class Complex {
    public:
    Complex(...) { ... }
    private:
    double m_real;
    double m_imag;
    }
    1
    Complex* pc = new Complex(1, 2);

    new 的操作可分解为以下三步:

    1
    2
    3
    4
    5
    6
    7
    Complex *pc;
    // 1. 调用 operator new 分配内存,其内部调用 malloc(n)
    void* mem = operator new( sizeof(Complex) );
    // 2. 将其类型转换为Complex指针
    pc = static_cast<Complex*>(mem);
    // 3. 调用 Complex 的构造函数
    pc->Complex::Complex(1, 2);
  2. Delete

    先调用dtor(析构函数),再释放memory

    假设有String类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class String {
    public:
    ~String() {
    // array new 一定要搭配 array delete,这样才能保证 array 中的每个元素都
    // 唤起了 dtor,若仅使用 delete,则只会唤起一次 dtor
    delete[] m_data; // 释放掉动态分配的内存
    }
    ...
    private:
    char* m_data;
    }
    1
    2
    3
    String* ps = new String("Hello");
    ...
    delete ps;

    delete的操作可分为以下两步:

    1
    2
    3
    4
    // 1. 调用析构函数: 释放掉类内部动态分配的的内存
    String::~String(ps);
    // 2. 释放内存:释放掉类本身占的空间(m_data指针),其内部调用 free
    operator delete(ps);

右值、移动构造与 std::move

C++11新标准(以下简称为新标准) 的一个最主要的特性是可以移动而非拷贝对象的能力。很多情况下都会发生对象拷贝,但在其中某些情况下,对象拷贝后就立即被销毁了。在这些情况下,移动而非拷贝对象会大幅度提升性能。

在旧C++标准中,没有直接的方法移动对象。因此,即使不必拷贝对象的情况下,我们也不得不拷贝。如果对象较大,或者是对象本身要求分配内存空间(如 string),进行不必要的拷贝代价非常高。类似的,在旧版本的标准库中,容器中所保存的类必须是可拷贝的。但在新标准中,我们可以用容器保存不可拷贝的类型,只要它们能被移动即可。

为了支持移动操作,新标准引入了一种新的引用类型 —— 右值引用(rvalue reference)。所谓右值引用就是必须绑定到右值的引用。我们通过 && 而不是 & 来获得右值引用。如我们将要看到的,右值引用有一个重要的性质——只能绑定到一个将要销毁的对象。因此,我们可以自由地将一个右值引用的资源“移动”到另一个对象中。

类似任何引用,一个右值引用也不过是某个对象的另一个名字而已。如我们所知,对于常规引用(为了与右值引用区分开来,我们可以称之为左值引用(lvalue reference)),我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。右值引用有着完全相反的绑定特性:我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上:

1
2
3
4
5
6
int i = 42;
int &r = i; //正确: r引用i
int &&rr = i; //错误: 不能将一个右值引用绑定到一个左值上
int &r2 = i * 42; //错误: i*42是一个右值
const int &r3 = i * 42; //正确: 我们可以将一个const的引用绑定到一个右值上
int &&rr2 = i * 42; //正确: 将rr2绑定到乘法结果上

由于右值引用只能绑定到临时对象,我们得知

  • 所引用的对象将要被销毁
  • 该对象没有其他用户

这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。

虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型。我们还可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用

1
int &&rr3 = std::move (rr1);  // ok

move 调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。我们必须认识到,调用move就意味着承诺:除了对rr1赋值或销毁它外,我们将不再使用它。在调用move之后,我们不能对移后源对象的值做任何假设。我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。

为了实现移动构造函数,即利用一个右值引用来构造一个对象,需要利用到一个能够获得绑定到左值上的右值引用的函数,即 std::move()。

移动构造函数通过接受一个右值引用(R-value reference)作为参数来定义。它用于将一个临时对象或将要销毁的对象的资源有效地“移动”到新创建的对象中,而不是进行资源的复制或拷贝。除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。

​ 作为一个例子,我们为 strVec 类定义移动构造函数,实现从一个 strVec 到另一个 strVec 的元素移动而非拷贝:

1
2
3
4
5
6
7
strVec::strvec (StrVec &&s) noexcept //移动操作不应抛出任何异常
//成员初始化器接管 s 中的资源
: elements(s.elements), first_free(s.first_free), cap (s.cap)
{
//令s 进入这样的状态——对其运行析构函数是安全的
s.elements = s.first_free = s.cap = nullptr;
}

由于移动操作“窃取”资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库。noexcept 是 C++ 中的关键字,用于指定函数是否可能引发异常。当一个函数被声明为 noexcept,表示该函数不会抛出任何异常。noexcept 在函数声明或定义中的使用对编译器是一种承诺,即函数不会引发异常。它有助于编译器进行优化,以提高程序的性能和效率。当调用一个被声明为 noexcept 的函数时,编译器可以进行一些优化假设,例如避免生成额外的异常处理代码。

不抛出异常的移动构造函数和移动赋值运算符必须标记为 noexcept

完美转发与 std::forward

C++ 中的完美转发(Perfect Forwarding)是一种技术,用于在函数模板中将参数以原始的值类别转发给其他函数,保持参数的值类别不变。它是泛型编程中非常有用的概念。

完美转发的目标是在转发参数时避免不必要的拷贝或移动操作。当一个函数接收到一个参数,然后将其传递给另一个函数时,完美转发可以确保参数以原始的左值引用或右值引用形式传递,而不会进行额外的拷贝或移动操作。

完美转发的关键在于使用 通用引用(Universal Reference)引用折叠(Reference Collapsing) 的特性。通用引用是指以 T&& 形式声明的参数,其中 T 是一个类型模板参数。通过使用通用引用,参数可以根据传入的实参的值类别(左值还是右值)推导出适当的引用类型。

其次,解释一下折叠的含义。所谓的折叠,就是多个的意思。上面介绍引用分为左值引用和右值引用两种,那么将这两种类型进行排列组合,就有四种情况:

1
2
3
4
- 左值-左值 T& &
- 左值-右值 T& &&
- 右值-左值 T&& &
- 右值-右值 T&& &&

所有的引用折叠最终都代表一个引用,要么是左值引用,要么是右值引用。

规则是:如果任一引用为左值引用,则结果为左值引用。否则(即两个都是右值引用),结果为右值引用。

在std中,实现完美转发的函数为 std::forwardstd::forward 必须配合通用引用即 T&&使用,例如:

  1. 接收 T 为 int,为右值,返回 T&& 亦为右值
  2. 接收 T 为 int &,为左值,返回 T& &&,按照引用折叠规则,等价于 T& ,为左值
  3. 接收 T 为 int &&,为右值,返回 T&& && = T&& 为右值

这样就利用通用引用与引用折叠的特性完成了完美转发。

可变参数模板

1
2
3
4
5
template <class Ty, class... Args>
void construct(Ty* ptr, Args&&... args) {
// 将 ptr 强转为 void*,然后在 ptr 上调用 Ty 的构造函数
::new ((void*)ptr) Ty(tinystl::forward<Args>(args)...);
}

在这段代码中,... 是 C++ 中的可变参数模板(Variadic Template)语法的一部分,用于表示模板参数包(parameter pack)。

  1. class... Args
    • 这里使用 ... 表示模板参数 Args 是一个模板参数包,它可以接受零个或多个参数类型。
    • Args 是一个用于接受模板参数的占位符,表示可能的多个类型参数。
  2. Args&& ...args
    • 这里使用 ... 表示模板参数 Args 是一个模板参数包,它可以接受零个或多个参数类型。
    • Args&& 是通用引用类型的模板参数包,即用于接受传入的可变参数 args 的类型。
    • 通过 Args&& ...args,函数模板 construct 可以接受任意数量的参数,并以右值引用或左值引用形式进行转发。
  3. tinystl::forward<Args>(args)...
    • 在这里,tinystl::forward<Args> 是一个完美转发函数模板,用于将参数以原始的值类别转发。
    • tinystl::forward<Args>(args) 是将参数 args 以原始的值类别转发给模板函数 tinystl::forward
    • ... 在这里是展开语法,用于展开模板参数包 args,将其作为一系列参数进行传递给 tinystl::forward
    • 通过 tinystl::forward<Args>(args)...,函数模板 construct 将接受的参数进行完美转发,保持参数的原始值类别,并将其传递给 Ty 类型的构造函数。

volatile

https://zhuanlan.zhihu.com/p/62060524

volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改。比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。

一般说来,volatile用在如下的几个地方:

  1. 中断服务程序中修改的供其它程序检测的变量

  2. 多任务环境下各任务间共享的标志

  3. 存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能由不同意义;

多线程下的 volatile:

有些变量是用volatile关键字声明的。当两个线程都要用到某一个变量且该变量的值会被改变时,应该用volatile声明,该关键字的作用是防止优化编译器把变量从内存装入CPU寄存器中。如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。volatile的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值,