从零开始的 STL 实现记录:Allocator-1
前言
以STL的运用角度而言,空间配置器是最不需要介绍的东西,它总是隐藏在一切组件(更具体地说是指容器,container)的背后,默默工作,默默付出。但若以 STL 的实现角度而言,第一个需要介绍的就是空间配置器,因为整个STL 的操作对象(所有的数值)都存放在容器之内,而容器一定需要配置空间以置放资料。不先掌握空间配置器的原理,难免在阅读其它STL组件的实现时处处遇到挡路石。
———— 《STL源码剖析》侯捷
本篇博客将会记录在实现 Allocator
过程中需要了解的知识点。
Allocator 相关知识点
new
与 delete
为了精密分工,STL
将空间分配与构造对象两阶段操作分开来进行,内存配置操作由 alloc::allocate()
负责,内存释放操作由 alloc::deallocate()
负责,对象构造操作由 ::construct()
负责,对象析构操作由 ::destory()
负责。在实现这些功能之前需要了解一般情况下的 C++ 内存配置与释放操作。
-
New 干了什么事情
先分配 memory,再调用ctor(构造函数)
假设有一个 Complex 类型,代表复数,含有两个double 变量分别代表实部和虚部
1
2
3
4
5
6
7class Complex {
public:
Complex(...) { ... }
private:
double m_real;
double m_imag;
}1
Complex* pc = new Complex(1, 2);
new 的操作可分解为以下三步:
1
2
3
4
5
6
7Complex *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); -
Delete
先调用dtor(析构函数),再释放memory
假设有String类
1
2
3
4
5
6
7
8
9
10
11class String {
public:
~String() {
// array new 一定要搭配 array delete,这样才能保证 array 中的每个元素都
// 唤起了 dtor,若仅使用 delete,则只会唤起一次 dtor
delete[] m_data; // 释放掉动态分配的内存
}
...
private:
char* m_data;
}1
2
3String* 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 | int i = 42; |
由于右值引用只能绑定到临时对象,我们得知
- 所引用的对象将要被销毁
- 该对象没有其他用户
这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。
虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型。我们还可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用。
1 | int &&rr3 = std::move (rr1); // ok |
move 调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。我们必须认识到,调用move就意味着承诺:除了对rr1赋值或销毁它外,我们将不再使用它。在调用move之后,我们不能对移后源对象的值做任何假设。我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。
为了实现移动构造函数,即利用一个右值引用来构造一个对象,需要利用到一个能够获得绑定到左值上的右值引用的函数,即 std::move()。
移动构造函数通过接受一个右值引用(R-value reference)作为参数来定义。它用于将一个临时对象或将要销毁的对象的资源有效地“移动”到新创建的对象中,而不是进行资源的复制或拷贝。除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。
作为一个例子,我们为 strVec 类定义移动构造函数,实现从一个 strVec 到另一个 strVec 的元素移动而非拷贝:
1 | strVec::strvec (StrVec &&s) noexcept //移动操作不应抛出任何异常 |
由于移动操作“窃取”资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库。noexcept
是 C++ 中的关键字,用于指定函数是否可能引发异常。当一个函数被声明为 noexcept
,表示该函数不会抛出任何异常。noexcept
在函数声明或定义中的使用对编译器是一种承诺,即函数不会引发异常。它有助于编译器进行优化,以提高程序的性能和效率。当调用一个被声明为 noexcept
的函数时,编译器可以进行一些优化假设,例如避免生成额外的异常处理代码。
不抛出异常的移动构造函数和移动赋值运算符必须标记为
noexcept
。
完美转发与 std::forward
C++ 中的完美转发(Perfect Forwarding)是一种技术,用于在函数模板中将参数以原始的值类别转发给其他函数,保持参数的值类别不变。它是泛型编程中非常有用的概念。
完美转发的目标是在转发参数时避免不必要的拷贝或移动操作。当一个函数接收到一个参数,然后将其传递给另一个函数时,完美转发可以确保参数以原始的左值引用或右值引用形式传递,而不会进行额外的拷贝或移动操作。
完美转发的关键在于使用 通用引用(Universal Reference) 和 引用折叠(Reference Collapsing) 的特性。通用引用是指以 T&&
形式声明的参数,其中 T
是一个类型模板参数。通过使用通用引用,参数可以根据传入的实参的值类别(左值还是右值)推导出适当的引用类型。
其次,解释一下折叠的含义。所谓的折叠,就是多个的意思。上面介绍引用分为左值引用和右值引用两种,那么将这两种类型进行排列组合,就有四种情况:
1 | - 左值-左值 T& & |
所有的引用折叠最终都代表一个引用,要么是左值引用,要么是右值引用。
规则是:如果任一引用为左值引用,则结果为左值引用。否则(即两个都是右值引用),结果为右值引用。
在std中,实现完美转发的函数为 std::forward
,std::forward
必须配合通用引用即 T&&
使用,例如:
- 接收
T
为 int,为右值,返回T&&
亦为右值 - 接收
T
为 int &,为左值,返回T& &&
,按照引用折叠规则,等价于T&
,为左值 - 接收
T
为 int &&,为右值,返回T&& && = T&&
为右值
这样就利用通用引用与引用折叠的特性完成了完美转发。
可变参数模板
1 | template <class Ty, class... Args> |
在这段代码中,...
是 C++ 中的可变参数模板(Variadic Template)语法的一部分,用于表示模板参数包(parameter pack)。
class... Args
:- 这里使用
...
表示模板参数Args
是一个模板参数包,它可以接受零个或多个参数类型。 Args
是一个用于接受模板参数的占位符,表示可能的多个类型参数。
- 这里使用
Args&& ...args
:- 这里使用
...
表示模板参数Args
是一个模板参数包,它可以接受零个或多个参数类型。 Args&&
是通用引用类型的模板参数包,即用于接受传入的可变参数args
的类型。- 通过
Args&& ...args
,函数模板construct
可以接受任意数量的参数,并以右值引用或左值引用形式进行转发。
- 这里使用
tinystl::forward<Args>(args)...
:- 在这里,
tinystl::forward<Args>
是一个完美转发函数模板,用于将参数以原始的值类别转发。 tinystl::forward<Args>(args)
是将参数args
以原始的值类别转发给模板函数tinystl::forward
。...
在这里是展开语法,用于展开模板参数包args
,将其作为一系列参数进行传递给tinystl::forward
。- 通过
tinystl::forward<Args>(args)...
,函数模板construct
将接受的参数进行完美转发,保持参数的原始值类别,并将其传递给Ty
类型的构造函数。
- 在这里,
volatile
volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改。比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。
一般说来,volatile用在如下的几个地方:
-
中断服务程序中修改的供其它程序检测的变量
-
多任务环境下各任务间共享的标志
-
存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能由不同意义;
多线程下的 volatile:
有些变量是用volatile关键字声明的。当两个线程都要用到某一个变量且该变量的值会被改变时,应该用volatile声明,该关键字的作用是防止优化编译器把变量从内存装入CPU寄存器中。如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。volatile的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值,