C++ 以其强大的标准库及其对内存管理的控制而闻名。为您提供细粒度控制的工具之一是std::allocator
。在很多的时候,开发人员依赖默认分配器,但在某些情况下,自定义分配器可以显著提高性能或满足特定需求,或者通过实现自定义分配器来加深对这块的理解。
今天,来做一个手动挡,手撸一个STL中常见的内存分配器Allocator,文章内容将从一个简单的实力开始,然后逐步进行完善,最后与vector集成。
前序
可能有人会问,标准里面不是已经实现了分配器么,为什么还要自己手撸一个?
俗话说,存在即合理,既然标准开了这个口子,允许使用自定义的内存分配器,那就有其一定的道理,一般情况下,使用自定义分配器的原因无非是以下几个原因:
•性能优化:通过根据特定模式定制内存分配,可以减少碎片并改善缓存局部性。•内存池:分配器可以管理内存池,以便快速分配和释放•调试和分析:自定义分配器可以帮助跟踪内存使用情况并检测泄漏或损坏•专用硬件:分配器可以设计为与非标准内存交互,例如GPU内存或共享内存区域。
emm,还有一个原因,就是可以锻炼自己的编码能力,加深对标准的理解。好了,有些事想做的话,一个理由就可以,不想做的话,有千万个理由,手撸系列类似~
好了,开始吧!
实现自定义分配器
在本文中,我将着手一个分配器的实现,因此,内容将涉及到以下几个方面:
•定义类:通过定义其成员类型和方法来实现分配器•处理内存分配和释放:根据需要定制allocate
和``deallocate`方法来管理内存•与数据结构集成:在本文中,将其用在我们常见的std::vector中•正确性测试:确保我们实现的分配器在使用结果上与预期一致
在开始实现我们的自定义分配器之前,需要先了解下标准中用到了哪些方法,比如allocate、deallocate,construct以及destroy
,了解这些内容对我们来说非常重要,因为在使用中,我们往往提供一个类,剩下的则交给container来使用了,而container在使用中则用到allocate等操作:
•allocate:•目的:用以分配内存•调用方:标准库中的容器,当插入元素或者需要为元素重新分配内存时候•声明:T* allocate(std::size_t n)•deallocate:•目的:用以释放又allocate函数分配的内存•调用方:标准库中的容器,在销毁容器或者释放元素的时候•声明:void deallocate(T* p, std::size_t n)•construct:•目的:用以为分配的内存调用容器中存储元素的构造函数•调用方:在为元素创建内存的时候•声明:template<typename T, typename... Args> void construct( T* t, Args&&... args)•destroy•目的:用以释放对象•调用方:释放对方的时候用于释放其所占用的资源•声明:template
定义分配类
在实现一个面向对象功能的时候,我们要做的第一步就是实现一个基础类,把框架先搭起来,然后根据具体的功能要求,再进行添加。
#include <memory>
#include <iostream>
#include <vector>
template<typename T>
classSimpleAllocator{
public:
using value_type = T;
SimpleAllocator()=default;
template<typename U>
constexpr SimpleAllocator(const SimpleAllocator<U>&) noexcept {}
T* allocate(std::size_t n) {
if(n > std::allocator_traits<SimpleAllocator>::max_size(*this)){
throw std::bad_alloc();
}
returnstatic_cast<T*>(::operatornew(n *sizeof(T)));
}
void deallocate(T* p, std::size_t) noexcept {
::operator delete(p);
}
};
在上面这个基础类中:
•value_type是数据类型,即我们这个分配器所要分配内存的类型,类似于std::vectorstd::string中的std::string
•构造函数和拷贝构造函数,其中,拷贝构造函数是一个模板成员函数,用于在不同的数据数据类型之间进行分配•allocate和deallocate,分配调用了operator new 和 operator delete操作符,用于管理内存
特殊成员函数
接下来,我们要新增特殊的成员函数,比如construct和destroy,用以构造和释放对象:
template<typename T>
classSimpleAllocator{
public:
using value_type = T;
SimpleAllocator()=default;
template<typename U>
constexpr SimpleAllocator(const SimpleAllocator<U>&) noexcept {}
T* allocate(std::size_t n) {
if(n > std::allocator_traits<SimpleAllocator>::max_size(*this)){
throw std::bad_alloc();
}
returnstatic_cast<T*>(::operatornew(n *sizeof(T)));
}
void deallocate(T* p, std::size_t) noexcept {
::operator delete(p);
}
template<typename U, typename... Args>
void construct(U* p, Args&&... args) {
new(p)U(std::forward<Args>(args)...);
}
template<typename U>
void destroy(U* p) noexcept {
p->~U();
}
};
其中,construct用以在分配内存后,通过operator new
调用元素的构造函数,而destroy则用以调用元素的析构函数。
比较函数
因为允许容器进行比较是否相等,因此分配器也需要支持比较函数:
template<typename T>
classSimpleAllocator{
public:
using value_type = T;
SimpleAllocator()=default;
template<typename U>
constexpr SimpleAllocator(const SimpleAllocator<U>&) noexcept {}
T* allocate(std::size_t n) {
if(n > std::allocator_traits<SimpleAllocator>::max_size(*this)){
throw std::bad_alloc();
}
returnstatic_cast<T*>(::operatornew(n *sizeof(T)));
}
void deallocate(T* p, std::size_t) noexcept {
::operator delete(p);
}
template<typename U, typename... Args>
void construct(U* p, Args&&... args) {
new(p)U(std::forward<Args>(args)...);
}
template<typename U>
void destroy(U* p) noexcept {
p->~U();
}
friendbooloperator==(constSimpleAllocator&,constSimpleAllocator&){returntrue;}
friendbooloperator!=(constSimpleAllocator&,constSimpleAllocator&){returnfalse;}
};
在上述实现中,新增了两个函数分别是operator=
和operator!=
,其被声明为friend,用以比较两个分配器是否相同。
集成
前面把分配器的基本功能实现了,现在,我们就借助一个例子,将前面实现的分配器和现有的容器集成起来,看看运行是否正常:
int main() {
SimpleAllocator<int> alloc;
std::vector<int,SimpleAllocator<int>>vec(alloc);
vec.push_back(1);
vec.push_back(2);
vec.push_back(3);
for(int i : vec){
std::cout << i <<" ";
}
std::cout << std::endl;
return0;
}
emm,不出所料的话,输出为:
Vector contents: 1 2 3
结语
本文中,我们首先从宏观角度讲解了下标准库中的内存分配器,为了加深对这块的了解 ,我们从上到下,从简到全,实现了一个简单的内存分配器,并结合STL中的std::vector进行了使用。需要注意的是,本文只是一个基本简单的不能再简单的分配器,如果想在业务上进行使用,可能需要做更多的开发和扩展,虽然自定义分配器在某些场景上性能会较标准库高,但它们也伴随着一定的风险和注意事项:
•复杂性:实现自定义分配器会增加代码库的复杂性,使其更难维护和调试•兼容性:确保自定义分配器遵守标准库的要求,以避免与不同容器的兼容性问题•内存泄漏:分配器实现不当可能会导致内存泄漏或损坏,因此彻底的测试和验证至关重要•性能:虽然自定义分配器可以提高性能,但如果没有针对特定用例正确设计,它们也会降低性能,因此,分析和基准测试对于验证其优势至关重要
1、巧活用折叠表达式