现在的位置: 首页 > 综合 > 正文

The Standard Librarian: What Are Allocators Good For?

2013年05月05日 ⁄ 综合 ⁄ 共 13588字 ⁄ 字号 评论关闭

 The Standard Librarian: What Are Allocators Good For?

Matt Austern

http://www.cuj.com/experts/1812/austern.htm?topic=experts

--------------------------------------------------------------------------------

AllocatorC++语言标准库中最神秘的部分之一。它们很少被显式使用,标准也没有明确出它们应该在什么时候被使用。今天的allocator与最初的STL建议非常不同,在此过程中还存在着另外两个设计--这两个都依赖于语言的一些特性,而直到最近才在很少的几个编译器上可用。对allocator的功能,标准似乎在一些方面追加了承诺,而在另外一些方面撤销了承诺。

    这篇专栏文章将讨论你能用allocator来做什么以及如何定义一个自己的版本。我只会讨论C++标准所定义的allocator:引入准标准时代的设计,或试图绕过有缺陷的编译器,只会增加混乱。

什么时候不使用Allocator

    C++标准中的Allocator分成两块:一个通用需求集(描述于§ 20.1.5(表 32)),和叫std::allocator的class(描述于§20.4.1)。如果一个class满足表32的需求,我们就称它为一个allocator。std::allocator类满足那些需求,因此它是一个allocator。它是标准程序库中的唯一一个预先定义allocator类。

    每个 C++程序员都已经知道动态内存分配:写下new X来分配内存和创建一个X类型的新对象,写下delete p来销毁p所指的对象并归还其内存。你有理由认为allocator会使用new和delete--但它们没有。(C++标准将::operator new()描述为“allocation function”,但很奇怪,allocator并不是这样的。)

    有关allocator的最重要的事实是它们只是为了一个目的:封装STL容器在内存管理上的低层细节。你不应该在自己的代码中直接调用allocator的成员函数,除非正在写一个自己的STL容器。你不应该试图使用allocator来实现operator new[];这不是allocator该做的。 如果你不确定是否需要使用allocator,那就不要用。

    allocator是一个类,有着叫allocate()和deallocate()成员函数(相当于malloc和free)。它还有用于维护所分配的内存的辅助函数和指示如何使用这些内存的typedef(指针或引用类型的名字)。如果一个STL容器使用用户提供的allocator来分配它所需的所有内存(预定义的STL容器全都能这么做;他们都有一个模板参数,其默认值是std::allocator),你就能通过提供自己的allocator来控制它的内存管理。

    这种柔性是有限制的:仍然由容器自己决定它将要申请多少内存以及如何使用它们。在容器申请更多的内存时,你能控制它调用那个低层函数,但是你不能通过使用allocator来让一个vector行动起来像一个deque一样。虽然如此,有时候,这个受限的柔性也很有用。比如,假设你有一个特殊的fast_allocator,能快速分配和释放内存(也许通过放弃线程安全性,或使用一个小的局部堆),你能通过写下std::list<T, fast_allocator<T> >而不是简单的std::list<T>来让标准的list使用它。

    如果这看起来对你很陌生,那就对了。没有理由在常规代码中使用allocator的。

定义一个Allocator

    关于allocator的这一点你已经看到了:它们是模板。Allocator,和容器一样,有value_type,而且allocator的value_type必须要匹配于使用它的容器的value_type。这有时会比较丑陋:map的value_type相当复杂,所以显式调用allocator的map看起来象这样的,std::map<K,V, fast_allocator<std::pair<const K, V> > >。(像往常一样,typedef会对此有帮助。)

    以一个简单的例子开始。根据C++标准,std::allocator构建在::operator new()上。如果你正在使用一个自动化内存使用跟踪工具,让std::allocator更简单些会更方便。我们可以用malloc()代替::operator new(),而且我们也不考虑(在好的std::allocator实作中可以找到的)复杂的性能优化措施。我们将这个简单的allocator叫作malloc_allocator 。

    既然malloc_allocator的内存管理很简单,我们就能将重点集中在所有STL的allocator所共有的样板上。首先,一些类型:allocator是一个类模板,它的实例专为某个类型T分配内存。我们提供了一序列的typedef,以描述该如何使用此类型的对象:value_type指T本身,其它的则是有各种修饰字的指针和引用。

    template <class T> class malloc_allocator

    {

    public:

      typedef T                 value_type;

      typedef value_type*       pointer;

      typedef const value_type* const_pointer;

      typedef value_type&       reference;

      typedef const value_type& const_reference;

      typedef std::size_t       size_type;

      typedef std::ptrdiff_t    difference_type;

      ...

    };

    这些类型与STL容器中的很相似,这不是巧合:容器类常常直接从它的allocator提取这些类型。

    为什么有这么多的typedef?你可能认为pointer是多余的:它就是value_type *。绝大部份时候这是对的,但你可能有时候想定义非传统的allocator,它的pointer是一个pointer-like的class,或非标的厂商特定类型value_type __far *;allocator是为非标扩展提供的标准hook。不寻常的pointer类型也是存在address()成员函数的理由,它在malloc_allocator中只是operator &()的另外一种写法:

    template <class T> class malloc_allocator

    {

    public:

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

      const_pointer address(const_reference x) const {

        return &x;

      }

      ...

    };

    现在我们能开始真正的工作:allocate()和deallocate()。它们很简单,但并不十分象malloc()和free()。我们传给allocate()两个参数:我们正在为其分派空间的对象的数目(max_size返回可能成功的最大请求值),以及可选的一个地址值(可以被用作一个位置提示)。象malloc_allocator这样的简单的allocator没有利用那个提示,但为高性能而设计的allocator可能会利用它。返回值是一个指向内存块的指针,它足以容纳n个value_type类型的对象并有正确的对齐。

    我们也传给deallocate()两个参数:当然一个是指针,但同样还有一个元素计数值。容器必须自己掌握大小信息;传给allocate和deallocate的大小参数必须匹配。同样,这个额外的参数是为效率而存在的,而同样,malloc_allocator不使用它。

    template <class T> class malloc_allocator

    {

    public:

      pointer allocate(size_type n, const_pointer = 0) {

        void* p = std::malloc(n * sizeof(T));

        if (!p)

          throw std::bad_alloc();

        return static_cast<pointer>(p);

      }

      void deallocate(pointer p, size_type) {

        std::free(p);

      }

      size_type max_size() const {

        return static_cast<size_type>(-1) / sizeof(value_type);

      }

      ...

    };

    allocate()和deallocate()成员函数处理的是未初始化的内存,它们不构造和销毁对象。语句a.allocate(1)更象malloc(sizeof(int))而不是new int。在使用从allocate()获得的内存前,你必须在这块内存上创建对象;在通过deallocate()归还内存前,你需要销毁那些对象。

    C++语言提供一个机制以在特定的内存位置创建对象:placement new。如果你写下new(p) T(a, b),那么你正在调用T的构造函数产生一个新的对象,一如你写的new T(a, b)或 T t(a, b)。区别是当你写new(p) T(a, b),你指定了对象被创建的位置:p所指向的地址。(自然,p必须指向一块足够大的内存,而且必须是未被使用的内存;你不能在相同的地址构建两个不同的对象。)。你也可以调用对象的析构函数,而不释放内存,只要写p->~T()。

    这些特性很少被使用,因为通常内存的分配和初始化是一起进行的:使用未初始化的内存是不方便的和危险的。你需要如此低层的技巧的很少几处之一就是你在写一个容器类,于是allocator将内存的分配与初始化解耦。成员函数construct()调用placement new,而且成员函数destory()调用析构函数。

    template <class T> class malloc_allocator

    {

    public:

      void construct(pointer p, const value_type& x) {

        new(p) value_type(x);

      }

      void destroy(pointer p) { p->~value_type(); }

      ...

    };

    (为什么allocator有那些成员函数,什么时候容器可以直接使用placement new?一个理由是要隐藏笨拙的语法,而另一个是如果写一个更复杂的allocator时你可能想在构造和销毁对象时construct()和destroy()还有其它一些副作用。比如,allocator可能维护一个所有当前活动对象的日志。)

    这些成员函数没有一个是static的,因此,容器在使用allocator前做的第一件事就是创建一个allocator对象--也就是说我们应该定义一些构造函数。但是,我们不需要赋值运算:一旦容器创建了它的allocator,这个allocator就从没想过会被改变。表32中的对allocator的需求没有包括赋值。只是基于安全,为了确保没人偶然使用了赋值运算,我们将禁止掉这个可能自动生成的函数。

    template <class T> class malloc_allocator

    {

    public:

      malloc_allocator() {}

      malloc_allocator(const malloc_allocator&) {}

      ~malloc_allocator() {}

    private:

      void operator=(const malloc_allocator&);

      ...

    };

    这些构造函数实际上没有做任何事,因为这个allocator不需要初始化任何成员变量。基于同样的理由,任意两个malloc_allocator都是可互换的;如果a1和a2的类型都是malloc_allocator<int>,我们可以自由地通过a1来allocate()内存然后通过a2来deallocate()它。我们因此定义一个比较操作以表明所有的malloc_allocator对象是等价的:

    template <class T>

    inline bool operator==(const malloc_allocator<T>&,

                           const malloc_allocator<T>&) {

      return true;

    }

    template <class T>

    inline bool operator!=(const malloc_allocator<T>&,

                           const malloc_allocator<T>&) {

      return false;

    }

    你会期望一个allocator,它的不同对象是不可替换的吗?当然--但很难提供一个简单而有用的例子。一种明显的可能性是内存池。它对大型的C程序很常见,从几个不同的位置(“池”)分配内存,而不是什么东西都直接使用malloc()。这样做有几个好处,其一是it only takes a single function call to reclaim all of the memory associated with a particular phase of the program。 使用内存池的程序可能定义诸如mempool_Alloc和mempool_Free这样的工具函数,mempol_Alloc(n, p)从池p中分配n个字节。很容易写出一个mmepool_alocator以匹配这样的架构:每个mempool_allocator对象有一个成员变量以指明它绑定在哪个池上,而mempool_allocator::allocate()将调用mempool_Alloc()从相应的池中获取内存。[注1]

    最后,我们到了allocator的定义体中一个微妙的部份:在不同的类型之间映射。问题是,一个allocator类,比如malloc_allocator<int>,全部是围绕着单个value_type构建的:malloc_allocator<int>::pointer是int*,malloc_allocator<int>().allocate(1)返回足够容纳一个int对象的内存,等等。然而,通常,容器类使用malloc_allocator可能必须处理超过一个类型。比如,一个list类,不分配int对象;实际上,它分配list node对象。(我们将在下一段落研究细节。)于是,当你创建一个std::list<int, malloc_allocator<int> >时,list必须将malloc_allocator<int>转变成为处理list_node类型的malloc_allocator。

    这个机制称为重绑定,它有二个部份。首先,对于给定的一个value_type是X1的allocator类型A1,你必须能够写出一个allocator类型A2,它与A1完全相同,除了value_type是X2。其次,对于给定的A1类型的对象a1,你必须能够创建一个等价的A2类型对象a2。这两部分都使用了成员模板,这也就是allocator不能被老的编译器支持,或支持得很差的原因。

    template <class T> class malloc_allocator

    {

    public:

      template <class U>

      malloc_allocator(const malloc_allocator<U>&) {}

      template <class U>

      struct rebind { typedef malloc_allocator<U> other; };

      ...

    };

    这其实意味着一个allocator类不能仅仅是单个类;它必须是一族相关类,每个都有它自己的value_type。一个allocator类必须要有一个rebind成员,因为它使得从一个类变成同族的另外一个类成为可能。

    如果有一个allocator类型A1,对应于另外一个value_type的类型是typename A1::template rebind<X2>::othere[注2]。正如你能将一个类型转换为另外一个,模板的转换用构造函数让你转换值:你可以写malloc_allocator<int>(a),无论a的类型是malloc_allocator<int>,或malloc_allocator<double>,或malloc_allocator<string>。象往常一样,malloc_allocator的转换用构造函数用不着做任何事,因为malloc_allocator没有成员变量。

    附带一提,虽然绝大多数的allocator有一个模板参数(allocator的value_type),但这不是需求中的规定,只不过常常正巧如此。重绑定机制在多模板参数的allocator上也工作良好:

    template <class T, int flags> class my_allocator

    {

    public:

      template <class U>

      struct rebind { typedef my_allocator<U, flags> other; };

      ...

    };

    最后,最后一个细节:对于void我们该怎么做?有时一个容器必须涉及void的指针(再一次,我们将会在下一段落研究细节),而重绑定机制几乎给了我们所需要的东西,但并不完全。它不能工作,因为我们会写出类似malloc_allocator<void>::pointer的代码,而我们定义的malloc_allocator用void实例化时是非法的。它使用了sizeof(T),和涉及了T &;当T是void时,这两个都是非法的。解决的方法如同问题本身一样简单:为void特化malloc_allocator,扔掉其它一切,只留下我们需要的void的指针。

    template<> class malloc_allocator<void>

    {

      typedef void        value_type;

      typedef void*       pointer;

      typedef const void* const_pointer;

      template <class U>

      struct rebind { typedef malloc_allocator<U> other; };

    就是它了!malloc_allocator的完备源码见Listing 1。

使用Allocator

    使用allocator的最简单方法,当然是将它们作为参数传给容器类;用

    std::vector<char, malloc_allocator<char> > V;

取代简单的std::vector<char>,或用

      typedef std::list<int, mempool_allocator<int> > List;

      List L(mempool_allocator<int>(p));

取代简单的std::list<int>。

    但是你能做得更多。STL的卖点是它是可扩展性:正如你能写自己的allocator,你也能写你自己的容器类。如果你很小心, 并且你写的容器类使用它的allocator来处理所有的内存相关操作,那么别人将能够加入他们自己的用户自定义allocator。

    诸如std::vector和std::list这样的容器类是很复杂的,而且大部分复杂性与内存管理无关。让我们以一个简单的例子开始,这样我们可以只关注于allocator。考虑一个固定大小数组的类,Array,元素的个数是在构造函数中设定的,并且在此之后不会改变。(这有点像std::valarray的一个简化版本。)它有二个模板参数,元素类型和allocator类型。

    容器,和allocator一样,以巢式类型申明开始:value_type, reference,const_reference,size_type,difference_type,iterator,和const_iterator。通常,这些类型中的绝大部分都可以直接从它的allocator中获得--这也解释了为什么容器的value_type必须和allocator中的相匹配。

    当然,iterator类型通常不来自于allocator;通常iterator是一个类,完全取决于容器的内在表示。Array类比通常见到的容器简单,因为它实际上将所有元素存储在单块连续内存中;我们只要维护指向内存块开始和结束处的两个指针。iterator就是指针。

    在更进一步之前,我们必须决定:我们将怎样储存allocator?构造函数将接受一个allocator对象作为参数。我们必须在容器的整个生命期内保存它的一个拷贝,因为在析构函数中还需要它。

    依感觉,这儿没什么问题:我们只要申明一个Allocator类型的成员变量,然后使用它。方法是正确的,但不爽。毕竟,在99%的时间里,用户都不想考虑有关allocator的事;他们只会写Array<int>并使用默认值--而默认的allocator可能是一个没有任何非static成员变量的空类。问题是即使Allocator是一个空类,这样的成员变量也会有开销。(这是C++标准所要求的。) 我们的Array类将会有三个word的开销,而不是两个。也许一个word的额外开销不是大问题,但总是不爽,它迫使所有用户为一个几乎从不使用的功能承担了开销。

    都很多方法来解决这个问题,其中一些使用了traits类和偏特化。或许最简单的解决方法就是使用(私有)继承而不是成员变量。编译器被允许优化掉空基类,而且时下绝大多数的编译器都这么做了。

    我们最终能写下定义的骨架了:

    template <class T, class Allocator = std::allocator<T> >

    class Array : private Allocator

    {

    public:

      typedef T value_type;

     

      typedef typename Allocator::reference reference;

      typedef typename Allocator::const_reference

              const_reference;

      typedef typename Allocator::size_type size_type;

      typedef typename Allocator::difference_type

              difference_type;

      typedef typename Allocator::pointer       iterator;

      typedef typename Allocator::const_pointer const_iterator;

      typedef Allocator allocator_type;

      allocator_type get_allocator() const {

        return static_cast<const Allocator&>(*this);

      }

      iterator begin()             { return first; }

      iterator end()               { return last; }

      const_iterator begin() const { return first; }

      const_iterator end() const   { return last; }

      Array(size_type n = 0,

            const T& x = T(),

            const Allocator& a = Allocator());

      Array(const Array&);

      ~Array();

      Array& operator=(const Array&);

    private:

      typename Allocator::pointer first;

      typename Allocator::pointer last;

    };

    如果要满足对容器的需求的话(需求全集见于C++标准§23.1,Table 65),这还不是我们所要的全部样板,但是绝大部分都和allocator完全无关。对于我们的目的而言,最感兴趣的成员函数是构造函数(它分配内存和创建对象),和析构函数(它销毁内存并释放内存)。

    构造函数初始化allocator基类,获得足够容纳n个元素的一块内存(如果我们正在写类似于vector的东西,我们可能申请更大些的内存以供增长用),然后遍历内存以创建初始化值的拷贝。唯一的问题是异常安全:如果某一个元素的构造函数抛出了一个异常,我们必须撤销我们所做的一切。

    template <class T, class Allocator>

    Array<T, Allocator>::Array(size_type n,

                               const T& x,

                               const Allocator& a)

      : Allocator(a), first(0), last(0)

    {

      if (n != 0) {

        first = Allocator::allocate(n);

        size_type i;

        try {

          for (i = 0; i < n; ++i) {

            Allocator::construct(first + i, x);

          }

        }

        catch(...) {

          for(size_type j = 0; j < i; ++j) {

            Allocator::destroy(first + j);

          }

          Allocator::deallocate(first, n);

          throw;

        }

      }

    }

    (你可能会问为什么我们手写了这个循环;std::uninitialized_fill()不是已经做我们所需要的东西?几乎,但不完全相同。我们必须调用allocator的构造成员函数而不是简单的pacement new。也许未来的C++标准将会包含一个接受allocator为参数的uninitialized_fill()函数,从而使得不再需要这个显式循环。

    析构函数比较简单,因为我们不需要担心异常安全:T的析构函数被假设为从不抛出异常。

    template <class T, class Allocator>

    Array<T, Allocator>::~Array()

    {

      if (first != last) {

        for (iterator i = first; i < last; ++i)

          Allocator::destroy(i);

        Allocator::deallocate(first, last - first);

      }

    }

    我们这个简单的array类不需要使用重绑定或转换,但这只是因为Array<T, Allocator>绝不产生T类型以外的对象。当你定义更复杂的数据结构时,其它类型就出现了。比如,考虑一下value_type是T的链表类list。链表通常是由节点组成的,每个节点含有一个T类型的对象和一个指向下个节点的指针。于是,作为最初的尝试,我们可能定义链表的节点为:

    template <class T>

    struct List_node

    {

      T val;

      List_node* next;

    };

    把一个新的值加入list的过程看起来可能是这样的:

l         使用一个value_type是List_node<T>的allocator,为一个新节点分配内存。

l         使用一个value_type是T的allocator,在节点的val位置上构建新的元素。

l         将这个节点连接到适当的位置。

    这个过程需要处理两个不同的allocator,其中一个是通过对另一个的重绑定而获得的。它对几乎所有程序都工作得很好,即使是将allocator用于相当复杂的目的的程序。它所不能完成的是向allocator提供一些不寻常的指针类型。它明显依赖于能使用List_node<T> *类型的普通指针。

    如果你极端野心勃勃,想将其它可能的指针类型传给allocator,一切都突然变得复杂多了--从一个节点指向另一个节点的指针不再是List_node<T> *或void *了,但它必须是能够从allocator获取的某个类型。直接实现是不太可能的:用一个不完全的类型实例化allocator是非法的,因此,除非List_node已经被完整定义,否则无法谈论指向List_node的指针。我们需要一个精巧的申明顺序。

    template <class T, class Pointer>

    struct List_node

    {

      T val;

      Pointer next;

    };

    template <class T, class Alloc>

    class List : private Alloc

    {

    private:

      typedef typename Alloc::template rebind<void>::other 

              Void_alloc;

      typedef typename Void_alloc::pointer Voidptr;

      typedef typename List_node<T, Voidptr> Node;

      typedef typename Alloc::template rebind<Node>::other

              Node_alloc;

      typedef typename Node_alloc::pointer Nodeptr;

      typedef typename Alloc::template rebind<Voidptr>::other

              Voidptr_alloc;

      Nodeptr new_node(const T& x, Nodeptr next) {

        Alloc a = get_allocator();

        Nodeptr p = Node_alloc(a).allocate(1);

        try {

          a.construct(p->val, x);

        }

        catch(...) {

          Node_alloc(a).deallocate(p, 1);

          throw;

        }

        Voidptr_alloc(a).construct(p->next, Voidptr(next));

        return p;

      }

      ...

抱歉!评论已关闭.