1

现代C++ [7]: 自定义分配器

 11 months ago
source link: https://no5-aaron-wu.github.io/2023/03/17/cpp-modern-7-CustomAllocator/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

旭穹の陋室

现代C++ [7]: 自定义分配器

发表于2023-03-17|更新于2023-05-05|语言
阅读量:3

分配器(Allocator)是对堆内存管理的方法进行封装的类,在整个C++标准库,尤其是STL容器中被广泛使用。绝大数情况下,我们都是使用默认的分配器std::allocator,但其实我们可以自定义分配器,并将其应用到容器中。自定义分配器的理由可能有以下几种:

  • 有些嵌入式平台没有提供默认的mallocfree等底层堆内存管理函数,你需要继承std::allocator,并封装自定义版本的mallocfree等底层堆内存管理函数;
  • std::allocator的性能表现不能满足具体场景的需求;

定义类型别名,可以根据需要自行定义:

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;
using const_void_pointer = const void *;

内置绑定机制

分配器类包含一个名为rebind的结构体成员,其构成如下:

template <typename T>
class MyAllocator {
public:
...
template <typename U>
struct rebind {
using other = MyAllocator<U>;
};
...
}

其作用是为与类型T有关但又不同的类型U提供相同的(同族)分配器。需要提供rebind的理由大概有如下三点:

  • 给到容器的分配器是一个模板的实例,如std::list<int, std::allocator<int>> l;,容器本身不知道(也不应该知道)分配器的基础模板(base templates)是什么,具体是怎么实现的也不需要关心;
  • 而容器中很多时候不只是会对类型T分配内存,还需要对与T相关的类型U分配内存,如链表std::list<int> 还需要对结点类型Node<int>分配内存;
  • 而容器希望对类型U分配内存的操作可以和类型T使用相同的分配器;

这时候rebind的作用就体现出来了,对于一个std::allocator<T>和一个相关类型U,有std::allocator<U>std::allocator<T>::rebind<U>::other 相当。我们常为U的分配器定义这样的类型别名:

using U_alloc_type = typename std::allocator<T>::template rebind<U>::other;

这里的语法有几点需要解释一下:

  • rebind是类模板std::allocator的从属名称,为了表征rebind也是一个模板,需要在其前加上template关键字;
  • other是模板rebind的从属名称,所以是类模板std::allocator嵌套从属名称,为了表明嵌套从属名称是一个类型,需要使用typename关键字。详见这里

必要的接口实现

内存分配/释放接口

pointer allocate(size_type n);
pointer allocate(size_type n, const_void_pointer hint = 0);
void deallocate(pointer p, size_type n);

这里allocate函数有两种重载方式(如用不到hint,可任选其一进行实现),其中hint参数可以传入一个已经分配内存的堆指针,在某些情况下可以用于优化已分配内存的释放,比如可以将已经pop的元素的内存回收到一个缓存的池子中,新分配的内存尺寸如果合适,就使用缓存池中的内存块,而不需要重新分配。

可选的接口实现

分配器的构造/析构

可以选择实现分配器的构造函数和析构函数,以用于必要的初始化操作。若未实现,则会适用默认的构造/析构函数:

template <typename T>
class MyAllocator {
public:
...
MyAllocator() {} // ctor
MyAllocator(const MyAllocator&) {} // copy ctor
~MyAllocator() {} // dtor
...
}

容器元素的构造/析构

template <typename T>
class MyAllocator {
public:
...
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) {
p->~U();
}
...
}

construct用于在分配器分配的内存(指针p所指)上(使用placement-new)构造类型T的实际对象(构造所需的参数通过完美转发传递),而destroy则用于析构T对象。

这两个接口在C++20中已经被移除。

pointer address(reference x) const {
return &x;
}
const_pointer address(const_reference x) const {
return &x;
}

address用于将对象引用转换为指针,在C++20中被移除。

size_type max_size() const {
return std::numeric_limits<size_type>::max();
}

max_size用于返回最大支持的元素个数,在C++20中被移除。

自定义示例

这里将实现一个简陋的预分配内存的线性分配器:

#include <vector>

#define ALLOC_ONCE_MAX_SIZE 100

template <typename T>
class AllocOnceAllocator {
public:
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;
using const_void_pointer = const void *;

template <typename U>
struct rebind {
using other = AllocOnceAllocator<U>;
};

// neccessary
AllocOnceAllocator() {
init();
}

template <typename U>
AllocOnceAllocator(const AllocOnceAllocator<U> &) {
deinit();
init();
}

~AllocOnceAllocator() {
deinit();
}

pointer allocate(size_type n) {
if (n <= 0)
return nullptr;
if (n + size_ > max_size()) {
printf("AllocOnceAllocator allocate failed, n = %lld, size_ = %lld", n, size_);
return nullptr;
}

printf("allocate %lld elements\n", n);

pointer p = pool_ + size_;
size_ += n;

return p;
}

void deallocate(pointer p, size_type n) {
printf("deallocate %lld elements\n", n);
}

// optional
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) {
p->~U();
}

pointer address(reference x) const {
return &x;
}
const_pointer address(const_reference x) const {
return &x;
}

size_type max_size() const {
return ALLOC_ONCE_MAX_SIZE;
}

private:
void init() {
if (max_size() <= 0) {
printf("AllocOnceAllocator init failed: max_size must > 0!");
return;
}

pool_ = (pointer)malloc(max_size() * sizeof(T));
if (!pool_) {
printf("AllocOnceAllocator init failed: malloc size %d failed!", max_size() * sizeof(T));
return;
}

size_ = 0;
}

void deinit() {
if (pool_) {
free((void *)pool_);
pool_ = nullptr;
}
}

private:
pointer pool_ = nullptr;
size_type size_ = 0;
};

class A {
public:
A() : val_(0) {
}

A(int val) : val_(val) {
printf("A:A(%d)\n", val);
}

void Print() {
printf("A val : % d\n", val_);
}

private:
int val_;
};

int main() {
const int vecSize = 75;

std::vector<int, AllocOnceAllocator<int>> intVec;
std::vector<A, AllocOnceAllocator<A>> classVec;
intVec.reserve(vecSize);
classVec.reserve(vecSize);
for (int i = 0; i < vecSize; ++i) {
intVec.push_back(i);
classVec.push_back(i);
}

for (int i = 0; i < vecSize; ++i) {
printf("int val: %d\n", intVec[i]);
classVec[i].Print();
}

return 0;
}

这个简陋的线性分配器只有在搭配reserve使用时(仅在reserve时有一次allocate),才能勉强使用,否则会随着容器的扩容(先allocate新的内存块,拷贝后deallocate旧的内存块),旧的内存块并没有回收,也没有其他策略进行重用,从而产生内存的浪费。

但是线性分配器的初衷一般是为了在程序执行过程中,不会因扩容而产生新的内存申请释放操作,从而提升性能。所以线性分配器多用于一些相对比较固定的场景,且内存空间比较富裕,可以用空间(内存)换时间(申请释放动作)的情况:比如循环调用的函数中的局部vector变量,当你想省去重复的内存申请释放操作,又不想将其放到更大的作用域(循环外,如全局变量或类的成员变量)中(这样会降低封装性,从而降低可读性和可维护性),则可以考虑给这些vector一个线性分配器。但需要在上述基础上稍作修改,比如将pool_改成一个单例类,在程序的初始化阶段完成创建,所有局部vector共用(从同一个池子中allocate);再比如增加RAII的Guard类,在合适的时间点(比如循环结束或一轮计算结束)重置pool_size_

[1] 第10篇:C++ 堆内存管理器-allocator

[2] STL源码剖析-实现自定义的allocator

[3] 11.6 Using Custom Allocators


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK