TinySTL实现项目(二):空间分配器allocator与全局construct、destroy

本篇文章是TinySTL实现的系列文章的第二篇,介绍了allocator模板类的作用和实现,以及其使用到的全局constructdestroy函数的实现。相关代码依然可以在我的github

1 allocator 的作用

关于为什么要有allocator类知乎上有专门的讨论,这里简单总结一下allocator的作用。

在C++中常用的内存配置和释放操作一般会使用newdelete,如下

1
2
3
class Foo { ... };
Foo* ptr = new Foo; // 配置内存,然后构造对象
delete ptr; // 调用对象的析构函数将对象析构,然后释放内存

如同注释所说,new其实包含了两步操作:

  • 调用::operator new配置内存
  • 调用类构造函数Foo:Foo()来构造对象

delete也包含了两步操作:

  • 调用类析构函数Foo:~Foo()将对象析构
  • 调用::operator delete来释放对象所占的内存

这里简单解释一下什么是::perator new::operator delete

上文的new也被称为new operator,如上所述包含了分配内存、构造对象两步操作,不能被重载;

::operator new接受一个类型为size_t的参数作为要求的空间大小,只分配所要求的空间,而不调用相关对象的构造函数。可以被重载。

::operator delete类似,一般接受一个指针类型的参数,只释放指针所占的内存,可以被重载。

在STL里面allocator的作用就是将以上newdelete的两步操作区分开来,用allocator::allocate()来分配内存,用::construct()来构造对象,用allocator::deallocate()释放内存,用::destroy()来析构对象。主要用在为各种容器如vectorlist分配空间完成构造。

这样的好处就是,有时已经有现成的内存空间可以用了,这时我想构造对象的时候就直接用construct(),而无需花费时间去寻找新的一块内存,然后再分配内存,最后才能构造对象。

C++中内存管理十分重要,要建立好概念,分配内存对应释放内存,构造对应析构。例如malloc()对应free()construct对应destroynew对应delete

2 简单的allocator类实现

在STL标准中,要求了allocator具有以下接口

allocator.h
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
29
30
31
32
33
34
35
36
template <typename T>
class allocator {
public:
// STL 要求的类型别名定义
using value_type = T;
using pointer = T*;
using const_pointer = const T*;
using reference = T&;
using const_reference = const T&;
using size_type = size_t;
using difference_type = ptrdiff_t;
public:
// STL 要求的类接口,使用静态函数实现可以使频繁调用下减小开销
// 负责分配内存
static T* allocate();
static T* allocate(size_type n);
// 负责释放内存
static void deallocate(T* ptr);
static void deallocate(T*, size_type n);
// 负责构造对象
static void construct(T* ptr);
static void construct(T* ptr, const T& value);
static void construct(T* ptr, T&& value);
// 负责析构对象
static void destroy(T* ptr);
static void destroy(T* first, T* last);
// 获取某对象的地址
static T* address(T& val);
// 获取可配置T类型对象的最大数目
static size_t max_size();
// 使T类型的alloctor可以为U类型的对象分配内存
template<typename U>
struct rebind {
using other = allocator<U>;
};
};

从上述代码可以看出,allocator类主要分为类型别名定义的接口和“干活”接口。

其中类型别名定义部分在STL模板类的实现里非常常见,包括后续的各种迭代器和容器如vectorlist等内部都有各种别名定义,且接口基本统一。其作用类似本系列上篇所说traits技巧,可以通过这些统一的内嵌别名定义,从模板类或其对象里直接取得所需类型,方便函数的实现和使用。后续在迭代器和容器设计的部分将会看到。

而“干活”的接口主要就是负责分配内存、释放内存、构造/析构对象,显然如第一节所说,我们可以使用::operator new::operator delete、对象的构造/析构函数来实现这些功能。

注意这些干活的接口都是以类中的静态成员函数实现的,为什么要用静态函数?依然是为了效率

普通的成员函数一般都隐含一个this指针,静态成员函数由于不与任何对象相联系,故而不具有this指针。

正是由于没有this指针的额外开销,因此静态成员函数与类的普通成员函数相比速度会有少许提升。同时调用静态成员函数时既可以通过类的对象、指向类对象的指针来调用,也可以直接使用类名来调用,这样就节省了创建对象的开销。

也正是因为以上原因,静态成员之间可以相互访问,但不能访问非静态成员函数和非静态数据成员

部分实现如下

allocator.h
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
29
30
31
32
33
34
35
36
// 使用::operator new实现,分配内存并返回空指针
// 使用static_cast将返回的void指针转换为目标类型的指针
template <typename T>
T* allocator<T>::allocate(size_type n) {
return static_cast<T*>(::operator new(n * sizeof(T)));
}
// 使用::operator delete释放指针指向内存
template <typename T>
void allocator<T>::deallocate(T* ptr) {
if (ptr != nullptr) ::operator delete(ptr);
}
// 负责构造对象,对全局函数construct()的调用
template <typename T>
void allocator<T>::construct(T* ptr) {
mystl::construct(ptr);
}

template <typename T>
void allocator<T>::construct(T* ptr, const T& value) {
mystl::construct(ptr, value);
}

template <typename T>
void allocator<T>::construct(T* ptr, T&& value) {
mystl::construct(ptr, mystl::move(value));
}
// 负责析构对象,对全局函数destroy()的调用
template <typename T>
void allocator<T>::destroy(T* ptr) {
mystl::destroy(ptr);
}

template <typename T>
void allocator<T>::destroy(T* first, T* last) {
mystl::destroy(first, last);
}

allocate()的实现上,用了static_cast静态转换,这是因为::operator new返回的是void*类型,需要做一个强制类型转换来确保类型安全。

  • 编译器隐式执行的任何类型转换都可以由static_cast来完成,比如intfloatdoublecharenumint之间的转换等

  • 使用static_cast可以找回存放在void*指针中的值

  • static_cast可以把任何类型的表达式转换成void类型

  • static_cast把任何类型的表达式转换成void类型

  • const_cast相比,static_cast不能改变变量的const属性,也包括volitale__unaligned属性

以上实现都在 TinySTL 项目的头文件 allocator.h中。

那么allocator<T>::construct()allocator<T>::destroy()分别调用的全局construct()destroy()函数又是什么呢?见下一小节介绍。

3 全局construct()destroy()

顾名思义,全局construct()的作用就是构造对象。在上一节中,我们知道分配内存的操作已经由allocator<T>::allocate()完成了,而这个construct(),就是将一个初值设定到已经分配好的的空间上去。因此其需要两个参数,一个是指针来表示内存的位置,另一个是我们希望设定的初值。其中一个实现版本如下:

construct.h
1
2
3
// 使用了两个模板参数,并使用类型转换
template<typename T1, typename T2>
inline void construct(T1* ptr, const T2& value) { new(ptr) T1(value); } // placement new

这里用两个模板参数的好处是可以使得函数在使用上更具“通用性”,同时使用了强制类型转换,使得不会产生隐式转换的警告。

这里的new不同于第一节中提到的new operatoroperator new,而是所谓的placement new。其实placement new只是operator new的一个重载版本。它并不分配内存,返回值是指向已分配好内存的指针。其作用就是在已分配的内存中创建一个对象,这是new做不到的。

placement new的作用是构造对象,故而对应了析构。其对象在使用结束后要记得析构。

上文已经强调了构造对应了析构,显然destroy()的作用就是将对象析构了,那么其实现就可以非常简单,只需调用对象的析构函数即可。

construct.h
1
2
3
// 单个参数的全局 destroy 函数,直接调用对象的析构函数
template<typename T>
inline void destroy(T* ptr) { ptr ->~T(); }

另外destroy()还有两个参数的重载版本,其接受两个迭代器,表示将一个范围内的对象析构。

而在本系列的上篇文章中已经介绍了POD类型,并提到了trivial destructor的概念。在对大范围的对象析构的时候,如果我们事先得知该对象的类型具有trivial destructor,即所谓的“平凡”或者说无意义的析构函数,那么我们就没有必要浪费开销来挨个调用这些无关痛痒的析构函数。

为什么说无关痛痒?因为并不涉及外部内存,对象本身的内存可以继续复用,所以不调用也不会影响之后的内存释放而造成内存泄露。只有在对象类型具有non-trivial destructor的时候,才有必要一次次调用其析构函数。

总之,如果得知对象类型具有trivial destructor,那么我们的destroy()函数可以什么也不做,无需付出复杂度为O(n)的多余开销,大大地提升了程序效率。所以我们需要针对这点对destroy()进行优化。代码如下

construct.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// destroy()内部调用的函数,根据对象类型是否有trivial destructor进行重载
// aux 为 auxiliary 缩写,表示其是辅助的函数
template<typename ForwardIterator>
inline void destroy_aux(ForwardIterator first, ForwardIterator last, _true_type) { }

template<typename ForwardIterator>
inline void destroy_aux(ForwardIterator first, ForwardIterator last, _false_type) {
for(; first != last; ++first)
destroy(first);
}

// 两个参数的全局 destroy 函数,根据其是否具有 trivial 析构函数进行重载
template<typename ForwardIterator>
inline void destroy(ForwardIterator first, ForwardIterator last) {
// value_type()函数可以取得迭代器指向对象的类型,注意此时还未实现
using is_trivial_dtor =typename type_traits<value_type(first)>::has_trivial_destructor;
destroy_aux(first, last, is_trivial_dtor());
}

destroy()函数在接受到两个迭代器以后,先用value_type()函数取得迭代器所指向对象的类型,然后将类型放入type_traits模板中,调用其内嵌的类型别名定义,创建一个类型为_true_type或者_false_true的对象作为第三个参数,来调用辅助函数destroy_aux,以此判断迭代器指向的对象是否拥有trivial destructor

可以看到以上函数内部并不复杂,实际上是只需要一两条语句就可以完成的“小操作”,将这些小操作写成函数的好处有很多,比如方便阅读、修改、重用、统一行为。但调用函数会比使用等价的表达式慢很多:大多数时候,调用前要先保存寄存器,并在返回时恢复,复制实参,程序还必须转向一个新位置执行。

为了避免调用函数带来的开销,我们可以使用inline关键字将函数变为内联函数,内联函数会在程序的调用点上“内联地”展开;并且函数被内联后,编译器可以通过上下文相关的优化的技术对结果代码执行更深入的优化。最终达到提升效率的目的。

下面对inline关键字做一个简单记录。

  • 关键字inline必须与函数定义体放在一起才能内联。

  • 内联函数最好放入头文件,否则需要在每个调用的源文件中重复地定义。

  • 内联函数省去了函数调用的开销,包括参数压栈、跳转、退栈和返回等操作。

  • 内联函数以代码膨胀(拷贝)为代价,在过大函数上滥用会导致总代码量增大,消耗更多内存,Google C++规范中建议函数少于10行时才定义为内联函数。

  • 若函数内部代码的执行时间远比函数调用开销大,则inline的效率收益很低,如出现循环或其他复杂的控制结构,此时不宜使用。

  • 类的构造/析构函数可能包含隐藏行为,如调用基类或成员对象的构造/析构函数,不应轻易内联。

  • 实际的实现中,inline是对编译器的请求,优秀的编译器可以根据函数定义取消不值得的内联,也有可能自动内联一些简单函数。

  • 根据上条,inline不应出现在函数声明中(不是不能)。

4 测试

我们这里已经写好了allocator类,那么如何使用它呢?

这里可以做一个简单的小测试,来测试我们写的allocator能否用于现有的std::vector,验证其有效性。

如果目前并未实现iterator_traits模板类和value_type,测试之前还需要对construct.h文件中的destroy()做一点小修改。

construct.h
1
2
3
4
5
6
7
template<typename ForwardIterator>
inline void destroy(ForwardIterator first, ForwardIterator last) {
// 由于暂未实现 mystl::iterator_traits,故而使用 std 中已有接口
using value_type = typename std::iterator_traits<ForwardIterator>::value_type;
using is_trivial_dtor = typename type_traits<value_type>::has_trivial_destructor;
destroy_aux(first, last, is_trivial_dtor());
}

然后新建一个test.cpp输入以下代码:

test.cpp
1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <vector>
#include "allocator.h"
#include "type_traits.h"

int main(){
std::vector<int,mystl::allocator<int>> vec(5,5);
for(auto &each: vec)
std::cout << each << " ";
std::cout << std::endl;
return 0;
}

g++ (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0环境下执行后,可以看到输出为是5个5,这说明我们写的allocator是可以兼容当前编译环境中实现的的std::vector。但在vs2017中编译未通过,应该是与其内部的内存管理尚有冲突,不过这也没关系,可以在后续使用我们自己实现的vector进行测试。

5 结束语

本方案实现的allocator类是比较简单的,从实现上也可以看出其仅仅是对operator newoperator deleteplacement new的封装。并不是具有SGI STL特色,使用更底层的mallocfree以及内存池机制的两级空间分配器,后续将会对此进行实现,并使用同一种容器来比较两者效率。本节代码已上传,allocator.hconstruct.h

-------- 本文结束 感谢阅读 --------
给我加块红烧肉吧
  • 本文标题: TinySTL实现项目(二):空间分配器allocator与全局construct、destroy
  • 本文作者: Chou Bin
  • 创建时间: 2020年01月02日 - 21时01分
  • 修改时间: 2020年01月03日 - 16时01分
  • 本文链接: http://yoursite.com/2020/01/02/STL2-AlloctorAndCtorDtor/
  • 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!