本篇文章是TinySTL实现的系列文章的第二篇,介绍了allocator
模板类的作用和实现,以及其使用到的全局construct
、destroy
函数的实现。相关代码依然可以在我的github。
1 allocator 的作用
关于为什么要有allocator
类知乎上有专门的讨论,这里简单总结一下allocator
的作用。
在C++中常用的内存配置和释放操作一般会使用new
和delete
,如下
1 | class Foo { ... }; |
如同注释所说,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
的作用就是将以上new
和delete
的两步操作区分开来,用allocator::allocate()
来分配内存,用::construct()
来构造对象,用allocator::deallocate()
释放内存,用::destroy()
来析构对象。主要用在为各种容器如vector
、list
分配空间完成构造。
这样的好处就是,有时已经有现成的内存空间可以用了,这时我想构造对象的时候就直接用construct()
,而无需花费时间去寻找新的一块内存,然后再分配内存,最后才能构造对象。
C++中内存管理十分重要,要建立好概念,分配内存对应释放内存,构造对应析构。例如malloc()
对应free()
,construct
对应destroy
,new
对应delete
。
2 简单的allocator类实现
在STL标准中,要求了allocator
具有以下接口
1 | template <typename T> |
从上述代码可以看出,allocator
类主要分为类型别名定义的接口和“干活”接口。
其中类型别名定义部分在STL模板类的实现里非常常见,包括后续的各种迭代器和容器如vector
、list
等内部都有各种别名定义,且接口基本统一。其作用类似本系列上篇所说traits
技巧,可以通过这些统一的内嵌别名定义,从模板类或其对象里直接取得所需类型,方便函数的实现和使用。后续在迭代器和容器设计的部分将会看到。
而“干活”的接口主要就是负责分配内存、释放内存、构造/析构对象,显然如第一节所说,我们可以使用::operator new
、::operator delete
、对象的构造/析构函数来实现这些功能。
注意这些干活的接口都是以类中的静态成员函数实现的,为什么要用静态函数?依然是为了效率。
普通的成员函数一般都隐含一个this
指针,静态成员函数由于不与任何对象相联系,故而不具有this
指针。
正是由于没有this
指针的额外开销,因此静态成员函数与类的普通成员函数相比速度会有少许提升。同时调用静态成员函数时既可以通过类的对象、指向类对象的指针来调用,也可以直接使用类名来调用,这样就节省了创建对象的开销。
也正是因为以上原因,静态成员之间可以相互访问,但不能访问非静态成员函数和非静态数据成员
部分实现如下
1 | // 使用::operator new实现,分配内存并返回空指针 |
在allocate()
的实现上,用了static_cast
静态转换,这是因为::operator new
返回的是void*
类型,需要做一个强制类型转换来确保类型安全。
编译器隐式执行的任何类型转换都可以由
static_cast
来完成,比如int
与float
、double
与char
、enum
与int
之间的转换等使用
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()
,就是将一个初值设定到已经分配好的的空间上去。因此其需要两个参数,一个是指针来表示内存的位置,另一个是我们希望设定的初值。其中一个实现版本如下:
1 | // 使用了两个模板参数,并使用类型转换 |
这里用两个模板参数的好处是可以使得函数在使用上更具“通用性”,同时使用了强制类型转换,使得不会产生隐式转换的警告。
这里的new
不同于第一节中提到的new operator
和operator new
,而是所谓的placement new
。其实placement new
只是operator new
的一个重载版本。它并不分配内存,返回值是指向已分配好内存的指针。其作用就是在已分配的内存中创建一个对象,这是new
做不到的。
placement new
的作用是构造对象,故而对应了析构。其对象在使用结束后要记得析构。
上文已经强调了构造对应了析构,显然destroy()
的作用就是将对象析构了,那么其实现就可以非常简单,只需调用对象的析构函数即可。
1 | // 单个参数的全局 destroy 函数,直接调用对象的析构函数 |
另外destroy()
还有两个参数的重载版本,其接受两个迭代器,表示将一个范围内的对象析构。
而在本系列的上篇文章中已经介绍了POD类型,并提到了trivial destructor
的概念。在对大范围的对象析构的时候,如果我们事先得知该对象的类型具有trivial destructor
,即所谓的“平凡”或者说无意义的析构函数,那么我们就没有必要浪费开销来挨个调用这些无关痛痒的析构函数。
为什么说无关痛痒?因为并不涉及外部内存,对象本身的内存可以继续复用,所以不调用也不会影响之后的内存释放而造成内存泄露。只有在对象类型具有non-trivial destructor
的时候,才有必要一次次调用其析构函数。
总之,如果得知对象类型具有trivial destructor
,那么我们的destroy()
函数可以什么也不做,无需付出复杂度为O(n)的多余开销,大大地提升了程序效率。所以我们需要针对这点对destroy()
进行优化。代码如下
1 | // destroy()内部调用的函数,根据对象类型是否有trivial destructor进行重载 |
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()
做一点小修改。
1 | template<typename ForwardIterator> |
然后新建一个test.cpp
输入以下代码:
1 |
|
在g++ (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0
环境下执行后,可以看到输出为是5个5,这说明我们写的allocator
是可以兼容当前编译环境中实现的的std::vector
。但在vs2017中编译未通过,应该是与其内部的内存管理尚有冲突,不过这也没关系,可以在后续使用我们自己实现的vector
进行测试。
5 结束语
本方案实现的allocator
类是比较简单的,从实现上也可以看出其仅仅是对operator new
、operator delete
、placement new
的封装。并不是具有SGI STL特色,使用更底层的malloc
、free
以及内存池机制的两级空间分配器,后续将会对此进行实现,并使用同一种容器来比较两者效率。本节代码已上传,allocator.h,construct.h。