C++ allocator 自定义指南

闲话

昨天培神在群里抱怨说自定义allocator遇到了奇怪的问题,然后选择了pmr,我表示很理解。allocator这个东西,出生时就伴随着设计错误和无用的抽象,C++03-14糊了这么久,甚至还加了新feature来兼容旧翔和糊新翔,结果C++17最终还是另立门派搞了个pmr。

简单说,虽然allocator的concept说了很多东西,也有一些周边的concept比如allocator aware container和语言设施如allocator_traits的支持,allocator的自定义依然收到了极大的限制。

我对此的结论是,虽然C++11开始标准自称支持stateful allocator,但从各种各样的历史和标准库实现隐含的限定中推导得到,对allocator的自定义只能是stateless的。从实现上讲,allocator里最多只能放一个指针。

容器实现强制要求allocator支持拷贝和转换

allocator作为容器的构造参数,是被拷贝进容器的,而对于一个有状态的allocator,它的拷贝不一定是合理的。其次,容器可能使用rebind获得另一个allocator类型,然后使用传入的allocator来转换构造,这种转换构造实际上也是一种拷贝,而之前说过了,拷贝不一定合理。再次,各大标准库都会在debug版本的容器中保存一些元信息,而这些元信息占用的内存,也是使用allocator分配的,所以往往在debug模式下,容器实现需要两个allocator,一个是allocator<value_type>,另一个是allocator<metadata>,容器会在需要分配元信息的位置用allocator<value_type>当场转换构造出allocator<metadata>并使用(比如MSVC),所以你可能会在容器中看到如下代码

//片段1
void some_container::check_some_metadata() {
#ifdef _DEBUG
    allocator<metadata> _metaal(this->_allocator); //当场构造
    _metaal.allocate(...); //使用
    _metaal.deallocate(...); //使用
#endif
}

对于有状态allocator,拷贝不一定合理,以stack_allocator为例,他可能有两种实现,一种是内部装一个定长数组的

struct stack_allocator {
    byte _stack[MAX_SIZE];
    byte* _stack_ptr;
};

另一种是内部装有指向外部固定空间的指针的

struct stack_allocator {
    byte* _stack_bottom;
    byte* _stack_top;
};

第一种实现完全无法拷贝,因为它只能被用户定义在确定的位置上,由用户保证他的生命周期大于等于所分配的内存的生命周期,如果允许了它的拷贝,在片段1的代码中就会出现问题。因为函数返回后空间就不复存在。

第二种实现和第一种一样不可行,首先,浅拷贝是不合理的,如果你使用副本allocator分配了空间,副本的stack_top指针移动到了新的位置,而本体的指针却没有变化,那下一次使用本体来分配空间,也会出现问题。

为此,C++标准特别规定,allocator拷贝(或转换)之后,两个allocator分配的空间必须能互相释放,进一步确认了allocator无法有状态的事实。

即使你放弃了调试便利,使用了宏等条件编译选项禁用了容器内的debug信息,保证了容器只使用一个allocator,依然不能解决问题。这个问题来源于我们提到过的,allocator被拷贝进容器,以及rebind的存在。你可能想通过不提供allocator参数,让容器通过默认构造的方式来构造allocator来避免拷贝,然而这样依然不可能,以MSVC的容器为例,在没有提供allocator时,容器的最外层构造函数会默认构造容器,然后将他拷贝给内部实现,就像如下的伪代码那样

template<class T, class Al>
class vector_base { //内部实现
    vector(const Al& a)
        : _allocator(a) //拷贝
    {}
    //...
};

template<class T, class Al = /*...*/>
class vector : vector_base<T, Al> //内部实现
{
    vector() :
        vector_base(Al())
    {}
    //...
}

对于map这种value_type和allocator实际分配的东西不一样的情况,实现也会从构造函数接受(或默认构造)一个allocator<value_type>然后将他传给rebind得到的allocator<node>来进行转换构造,也不行。

看起来在这种情况下,有状态allocator只能通过引用计数来共享状态才能实现了,这就是我上面所说的结论,allocator里最多放一个指针了。

讽刺的是,容器的的拷贝构造和拷贝赋值却没有对allocator的拷贝性质提出要求,实现会通过allocator_traits判断allocator是否可以拷贝(propagate_on_container_copy_construction),对于不能拷贝的allocator,移动构造(赋值)不会直接进行内部指针的交换,而是像拷贝构造(赋值)那样,在目标容器预留够空间,然后将元素一个一个move过去。可是这设计有p用,你根本写不出来不能拷贝的allocator。

主从allocator无法解决问题

对于这种不能拷贝的情况,我曾经构想过一种hack,是通过特殊实现allocator的rebind,让allocator::rebind<U>返回一个ref_allocator,通过它来引用主allocator,进而达到不通过引用计数来让rebind后的allocator和主allocator共享状态的目的(如下面代码),这样子看似可以解决片段1中的问题。然而依然有其他的问题没有解决,那就是对于map, set这样的容器,容器内部只会保存rebind后的allocator_ref,而不会存储主allocator,这样你就只能自己手动控制在外面当啷着的allocator的生命周期要随容器一起,这么做并不好。而且,这样的实现还禁用了容器的默认构造,因为默认构造的allocator<value_type>用来构造allocator_ref后就结束了生命周期,此时allocator_ref内部储存的是主allocator的悬挂引用。

//主从allocator设计示意

template<class U, class T>
struct allocator_ref {
    allocator<T>* _ref;
    
    template<class U2>
    struct rebind {
        using other = allocator_ref<U2, T>;
    };
};

template<class T>
struct allocator {
    template<class U>
    struct rebind {
        using other = allocator_ref<U, T>;
    };
};

fancy pointer也不是那么fancy

allocator::pointer可以是一个自定义的fancy pointer,并且容器的实现也假定了allocator可能使用fancy pointer,比如MSVC的string里面那个union就写了空的构造函数来支持其中的pointer成员是对象的情况。然而fancy pointer依然不能是有状态的,标准要求fancy pointer必须能和它指向的对象的裸指针无痛转换,所以shared_ptr不是fancy_pointer,unique_ptr勉强算。你想通过fancy pointer来封装某些复杂抽象的希望又破灭了。

鸡肋的allocator::construct

construct函数接受变长参数,在给定指针上构造对象,这个函数你以为它有很大的扩展空间?实际上他几乎不能在对象构造上做太多文章,我本想通过它来实现在fancy pointer上自定义构造,可他从C++11开始接受的就是裸指针了,就算你自定义的construct强行不写裸指针作为参数,allocator_traits在转发costruct调用的时候传给你的也是裸指针,没有办法用它来实现fancy pointer上的自定义构造。

其次,allocator<T>的construct不能只用来构造T,这让想打细算地尝试根据T的类型来压缩分配内存的空间的想法变得不可能。为什么不能只用来构造T呢,还是rebind,以MSVC的map为例,rebind得到的allocator<node>分配的是node类型,然后实现会用allocator<node>的construct在node类型的value_type成员上construct那个pair,也就是说会有如allocator<node>.construct<value_type>(value_type*)这样的调用,这还玩毛。

你还能对construct抱什么自定义的希望呢?顶多就用cout打个log了吧,储存几个调试对象都做不到,因为我们前面说过了,allocator不能有状态。

标准库怎么做的?pmr

pmr干了什么?你自己在别处开个memory_resource,然后pmr::allocator里面装个指针去引用它。好了,无状态,支持拷贝和转换,支持自定义分配,实现简单,容器不会因为allocator类型不一样无法拷贝,兼容旧标准代码,兼容scoped_allocator_adapter,简直完美。

就是有个虚函数很不爽。

原文链接: https://www.cnblogs.com/pointer-smq/p/9197817.html

欢迎关注

微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍

    C++ allocator 自定义指南

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/276251

非原创文章文中已经注明原地址,如有侵权,联系删除

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2023年2月15日 上午1:35
下一篇 2023年2月15日 上午1:36

相关推荐