cxx-allocator类

七月 01, 2022 [c++, allocator] #c++ #allocator

cxx-allocator类

allocator

new 在灵活性上有一些局限 可以使用allocator类进行更加强大的内存操作 分配器也是标准库的一个话题,我们这一片文章不对分配器的角色进行讨论,仅仅结合标准库的源代码对齐进行简单的剖析以及解释它的一些用法。 双层级配置器,也就是第一级配置器和第二级配置器。 第一级配置器直接使用 malloc() 和 free() , 第二级配置器则视情况采用不同的策略:当配置区块超过128 bytes 时,视之为 “足够大”,便调用第一级配置器;当配置区块小于 128 bytes 时,视之为 “过小” ,为了降低额外负担,便采用复杂的 内存池 管理方式。

allocator name; .allocate(n) 分配原始的,未构造的保存n个T类型的空间返回一个指针 .consstruct(p,args) 构造对象,在p指向的内存中,args是构造类型所需要的参数 .destory(p) p为 T 的指针,对其指向的对象进行析构操作 .deallocate(p,n) 释放空间,从p指向开始后的n块内存空间,此指针必须是allocate分配的,在执行此操作之前还要对每一个元素都进行destory操作

首先是构造对象,分为两部 我们使用后allocate方法的得到的空间是未经过初始化的。 时候使用construct方法来对每一个对象进行初始化

如果不再使用对象将其销毁,也分为两步 首先使用destory方法将其析构 这个方法不是必须调用的,比如内置基本类型就可以不调用。下面我们会有演示的代码。

但是,如果要保证模版足够通用,就要调用这个方法了; 再使用dealloc方法回收空间,这是真正会回收的动作

#include <iostream>
#include <vector>
#include <algorithm>
#include <functional>
#include <array>
#include <memory>
class Foo {
public:
  Foo(int i): i_(i) { std::cout << "Foo\n"; }
  ~Foo() { std::cout << "~Foo\n"; }
  int i_;
};

void print_int_arr(Foo* data, const int length)
{
    using std::cout;
    using std::endl;
    for(int i=0; i<length; ++i)
    {
        cout << data[i].i_ << " ";
    }
    cout << endl;
}

void test01()
{
    using namespace std;
    std::allocator<Foo> my_allocator;
    auto data = my_allocator.allocate(10);
    cout << typeid(data).name() << endl;
    print_int_arr(data,10);
    // my_allocator.construct(data,10);
    // print_int_arr(data,10);
    for(int i=0; i<10; ++i)
    {
        // construct is deprecated in c++17; and removed in c++20
        my_allocator.construct(data + i, i);
    }
    print_int_arr(data,10);
    for(int i=0; i<10; ++i)
    {
        my_allocator.destroy(data + i);
    }
    print_int_arr(data, 10);
    my_allocator.deallocate(data, 10);
    print_int_arr(data,10);
}
 
void * operator new(std::size_t size)
{
    std::cout << "malloc: " << size << "size" << std::endl;
    return malloc(size);
}
 
void operator delete(void * p) noexcept
{
    std::cout << "delete" << std::endl;
    free(p);
}
int main()
{
    test01();
    return 0;
}

首先看下allocate的实现:

 _GLIBCXX_NODISCARD pointer  //_GLIBCXX_NODISCARD仅仅是一个宏名称 pointer是_Tp *的一个typedef
      allocate(size_type __n, const void* = static_cast<const void*>(0))
      {
	if (__n > this->max_size())
	  std::__throw_bad_alloc();
 
#if __cpp_aligned_new
	if (alignof(_Tp) > __STDCPP_DEFAULT_NEW_ALIGNMENT__)
	  {
	    std::align_val_t __al = std::align_val_t(alignof(_Tp));
	    return static_cast<_Tp*>(::operator new(__n * sizeof(_Tp), __al));
	  }
#endif
	return static_cast<_Tp*>(::operator new(__n * sizeof(_Tp)));
      }

首先,allocarte先进行一个是否超出限制的判断,如果超出限制,就抛出bad_alloc异常。 我们线不用看第二段的条件编译。直接看最后一行。其调用::new操作符,并将::new返回的结果强转成对应类型的指针。那这样就显而易见了,allocate也不过就是仅仅调用new而已。 再来看看construct,在观看源码之前,想都不用想,肯定是要用到转发技术的。

      template<typename _Up, typename... _Args>
	void
	construct(_Up* __p, _Args&&... __args)
	noexcept(noexcept(::new((void *)__p)
			    _Up(std::forward<_Args>(__args)...)))
	{ ::new((void *)__p) _Up(std::forward<_Args>(__args)...); }

其中,上面的部分是异常声明,我们的关注点不在那,如果读者有兴趣,也可以自行剖析一下。我们只看函数体中唯一的语句即可。 使用定位new并且将我们传入进的指针转成void*,然后使用转发级数构造一个对象。洗去铅华只剩金,也只不过是调用了定位new而已。

再来看看destroy方法,就更简单了

template<typename _Up>
	void
	destroy(_Up* __p)
	noexcept(noexcept(__p->~_Up()))
	{ __p->~_Up(); }

异常声明,然后单纯的显示调用对象的析构函数而已。 最后剩下的就是deallocate方法,我们也应该能够胸有成竹的剖析。

      void
      deallocate(pointer __p, size_type)
      {
#if __cpp_aligned_new
	if (alignof(_Tp) > __STDCPP_DEFAULT_NEW_ALIGNMENT__)
	  {
	    ::operator delete(__p, std::align_val_t(alignof(_Tp)));
	    return;
	  }
#endif
	::operator delete(__p);
      }

也仅仅就是调用的::delete而已;

现在,分配器的面目已经不是那么的神秘,甚至里面的实现也没有什么过于复杂的手法。要说复杂,应该是麻烦的条件编译和异常声明而已吧。

下面是一些好用的函数

uninitialized_copy(b,e,b2) 返回类似尾后迭代器(拷贝到此空间最后一个元素的下一个位置) uninitialized_copy_n(b,n,b2) 同上 uninitialized_fill(b,e,t) 同上 uninitialized_fill_n(b,n,t) 同上

整理C++中Allocator

Allocator(概念)是对访问、寻址、分配、释放、构造和析构策略的封装。是一个满足特定要求的类。标准库中需要分配释放存储空间的容器都需要一个Allocator,除了std::array。

必选成员

Allocator 需要满足的条件有很多,但是大部分都是可选的,只有几个必须存在的成员。

  1. value_type: 要分配空间的类型
  2. allocate(n): 分配方法
  3. deallocate(ptr, n): 释放方法
  4. 拷贝构造: 对于表达式Alloc a2 = a1; Alloc a2(a1) 要求执行完毕后a2 == a1,不可抛出异常
  5. 移动构造: 对于表达式Alloc a2 = std::move(a1); Alloc a2(std::move(a1)); 使用a1构造a2,a2应等于a1的先前值。(C++17 起要求a1的值在构造后不发生改变,并且a1 == a2。)
  6. 拷贝赋值和移动赋值 标准没有声明他们的存在,但是显然他们应该和上面的构造语义相同。
  7. 从另一个allocator类型构造 用于map等分配的实际类型不是你传入的allocator的value_type的情况。 对于表达式 AllocA a(b),其中b是由AllocA::template rebind获得的类型AllocB的实例,构造a,使得AllocB(a) == b, b == AllocA(a)。
  8. 从另一个allocator类型移动构造 对于表达式 AllocA a(b),其中b是由AllocA::template rebind获得的类型AllocB的实例,构造a,使得a == 之前的 AllocA(b) 标准推荐通过 std::allocator_traits 来调用 Allocator 的各种方法,及获取其他类型,这个 trait 类提供了 Allocator 中可选成员的默认实现。

一些可选成员的用途

  1. size_type 如果你不想使用默认的std::size_t,可以用这个来自定义size_type类型,当然对应的allocate和deallocate方法的size参数也应该变成这个类型
  2. template rebind::other rebind用来从已有的allocator类型获取一个新的用来分配另一个类型U的allocator类型 注意,rebind只对有模板参数的allocator可选 allocator_traits的默认实现是用U来替换当前类型的第一个模板参数
  3. allocate(n, ptr) 分配足够容纳n个对象的连续空间,ptr用作一个hint(比如在ptr地址附近寻找可用内存,用来保持局部性)
  4. max_size() 获取可分配的最大对象数目 allocator_traits会提供一个返回(size_t)-1的实现(或者(size_t)-1 / sizeof(T),since C++17)
  5. select_on_container_copy_construction() 在标准库容器拷贝构造时,由构造函数调用,向源allocator获取一个用来构造新容器的allocator的实例 allocator_traits会提供一个直接返回源容器的allocator本身的实现 在构造函数不能够满足allocator的逻辑需求时定义这个函数
  6. construct(ptr, args…) 在给定指针指向的内存上构造对象,需要注意的是,ptr指向的对象类型不一定是allocator的value_type,这个函数有必要做成模板的 在需要自定义对象构造行为时定义它,比如打个log,try_catch一下什么的
  7. destroy(ptr) 析构ptr指向的对象,需要注意的是,ptr指向的对象类型也不一定是value_type,这个函数有必要做成模板的
  8. is_always_equal (since C++17) allocator的相等比较的意义是,一个allocator分配的空间,是否可以用另外一个allocator来释放,is_always_equal旨在尽可能消除运行期的比较 std::allocator就是always_equal的,因为他们都是new和delete的封装,一个std::allocator new的当然可以用另一个std::allocator来delete allocator_traits的默认实现是,当你的allocator是空类,那么为true_type
  9. propagate_on_container_copy_assignment
  10. propagate_on_container_move_assignment
  11. propagate_on_container_swap 此三个类型标记了在容器进行拷贝赋值、移动赋值或交换的时候,allocator是否需要进行对应操作。

容器在进行拷贝赋值、移动赋值和交换时的逻辑,应该考虑到以上成员和allocator的相等性

在两个容器拷贝赋值时(container1 = container2)

propagate_on_container_copy_assignment | 两个allocator是否相等 | 拷贝赋值行为 true | true | 拷贝allocator,拷贝container2所有元素 true | false | 析构container1所有元素并释放空间,拷贝allocator,拷贝container2元素 false| true | 不拷贝allocator,拷贝container2所有元素 false| false | 不拷贝allocator,拷贝container2所有元素

在两个容器移动赋值时(container1 = std::move(container2))

propagate_on_container_move_assignment | 两个allocator是否相等 | 移动赋值行为 true | true | 析构container1所有元素并释放空间,移动allocator,接管container2的内部指针 true | false | 析构container1所有元素并释放空间,移动allocator,接管container2的内部指针 false| true | 析构container1所有元素并释放空间,不移动allocator,接管container2的内部指针 false| false | 析构container1所有元素,不释放空间,不移动allocator,分配足够装下container2所有元素的空间,将container2的元素尽数移动过来

在两个容器交换时,没有更多问题,仅需视propagate_on_container_swap值,交换allocator即可。但需要注意的是,如果allocator不可交换,并且不相等,那么容器交换是UB。 当然以上只是标准容器的实现,你的容器大可以不必如此麻烦。

学习std::pmr 分配器

std::pmr::vector