c++ allocator

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

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

1 什么时候不使用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++标准将:perator 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的。

2 定义一个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构建在:perator 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;
}
tmplate <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 X>
  malloc_allocator(const malloc_allocator<X>&) {}
  template <class X>
  struct rebind { typedef malloc_allocator<X> other; };
  ...
};

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

如果有一个allocator类型A1,对应于另外一个value_type的类型是typename A1::template rebind<X2>:there[注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。

3 使用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;
};

 

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的过程看起来可能是这样的:
    使用一个value_type是List_node<T>的allocator,为一个新节点分配内存。
    使用一个value_type是T的allocator,在节点的val位置上构建新的元素。
    将这个节点连接到适当的位置。

这个过程需要处理两个不同的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;
  }
  ...
};

最后,以防你认为这么费劲才获得这么小的好处太不值了,提醒一下:仅仅因为你能写一个使用allocator的容器,并不意谓你必须,或你应该。有时你可能写一个依赖于特殊的内存分配策略的容器类,比如复杂到基于磁盘的B树容器或简单到我的书中所描述的block类。即使你确实想写一个使用 allocator的容器类,你也不是必须支持可能的指针类型。你可以写一个容器,而要求所有用户自定义的allocator使用普通指针,并在文档中明确说明这个限制。不是每件事都要完全通用。展望未来

如果你想写一个如同malloc_allocator这样简单的allocator,应该没有困难。(前提是你正在使用一个比较现代的编译器)。然而,如果你的心比较大,--基于内存池的或支持非标指针类型的allocator--情况就比较不令人满意了。

如果你想使用可能的类指针(pointer-like)类型,它必须支持哪些操作?它必须有一个null值吗?如果是的话,那个值应该如何书写?你能使用类型转换吗?你如何在类指针对象与普通指针间进行转换?你必须要考虑操作指针时抛的异常吗?我在这最后一节提出了一些假设;C++标准没有指明这些假设的对错。这些细节都留给了具体的标准库的实现,即使某个实现完全忽略可选指针类型也是合法的。C++标准还遗留了一些没有答案的问题,比如当一个 allocator的不同实例不可互换时,将发生什么。

幸运的是,情形并不象标准(§ 20.1.5,第4-5段)中的词汇所描述得那么可怕。标准留下没有答案的问题,是因为在制订标准时,C++标准化委员会对答案达不成共识;对 allocator的必要的经验还不存在。每个参与制订的人都认为这是一个临时的补丁,其中的含糊肯定将在将来的修订中被去除。

等一下,如果你关心可移植性,最好还是远离可选的指针类型,而,如果你乐意接受一些限制,就能安全地使用诸如mempool_allocator这样不同对象实体间差别很大的allocator。所有主流标准库的实现现在都在某个方面不支持这样的allocator,而且不同实现间的差别不大。

正如容器接受一个allocator作为模板参数,容器的构造函数也接受一个allocator作为参数。容器保留这个参数的一份拷贝,并使用这个拷贝来处理所有的内存管理;容器的allocator一旦在构造函数中初始化完成,就终生不变。

唯一的问题是当你运行一个需要两个容器在内存管理上进行协同的操作时,会发生什么。在标准库中确实存在两个这样的操作:swap()(所有容器)和std::list::splice()。原理上,可以以这样几个不同的方式处理他们:
禁止在两个allocator不等价的容器间进行swap()和splice()。
在swap()和splice()中放入一个allocator等价性的测试。如果不等价,降格调用copy()和赋值运算。
只提供swap():如同对容器中的数据一样,交换它们的allocator。(很难找出如何将它推广到splice上.它同样也带来一个问题:如何swap没有定义赋值运算符的东西?)

如果你能避免对可能具有不同allocator的容器使用swap()和splice(),一切都很安全。在实践中,我没有发现这会造成严重束缚:你需要严格练习安全使用诸如内存池这类特性,而你或许并不想不加选择地混用使用不同allocator的容器。

部分因为不熟悉,部分因为C++标准的要求并不令人满意,目前对allocator的绝大多数的使用都很简单。由于C++社群对allocator变得越来越熟悉,并且标准已被阐明,我们能期待更复杂的使用将会浮现。 

Listing 1: A sample allocator based on malloc

 

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;

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

  malloc_allocator() {}
  malloc_allocator(const malloc_allocator&) {}

  template <class U>
  malloc_allocator(const malloc_allocator<U>&) {}

  ~malloc_allocator() {}

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

  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(T);
  }

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

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

private:

  void operator=(const malloc_allocator&);
};

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; };
};

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;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值