条款10:注意分配器的协定和约束

优质
小牛编辑
135浏览
2023-12-01

条款10:注意分配器的协定和约束

分配器是怪异的。它们最初是为抽象内存模型而开发的,允许库开发者忽略在某些16位操作系统上near和far指针的区别(即,DOS和它的有害产物),但努力失败了。分配器也被设计成促进全功能内存管理器的发展,但事实表明那种方法在STL的一些部分会导致效率损失。为了避免效率冲击,C++标准委员会向标准中添加了词语,把分配器弱化为对象,同时也表达了他们不会让操作损失能力的希望。

还有更多。正如operator new和operator new[],STL分配器负责分配(和回收)原始内存,但分配器的客户接口与operator new、operator new[]甚至malloc几乎没有相似之处。最后(而且可能非常惊人),大多数标准容器从未向它们相关的分配器索要内存。从没有。结果分配器是,嗯,分配器是怪异的。

当然,那不是它们的错,而且无论如何,这不意味着它们没用。但是,在我解释分配器好在哪里之前(那是条款11的主题),我需要解释它们哪里不好。有许多事情分配器好像能做,但不能,而且在你试图开始使用之前,知道领域的边界很重要。如果不,你将肯定会受伤。此外,关于分配器的事实如此独特,总结它的行为既有启发性又有趣。至少我希望是。

分配器的约束的列表从用于指针和引用的残留typedef开始。正如我提到的,分配器最初被设想为抽象内存模型,在那种情况下,分配器在它们定义的内存模型中提供指针和引用的typedef才有意义。在C++标准里,类型T的对象的默认分配器(巧妙地称为allocator<T>)提供typedef allocator<T>::pointer和allocator<T>::reference,而且也希望用户定义的分配器也提供这些typedef。

C++老手立即发现这有问题,因为在C++里没有办法捏造引用。这样做要求有能力重载operator.(“点操作符”),而那是不允许的。另外,建立行为像引用的对象是使用代理对象的例子,而代理对象会导致很多问题。(一个这样的问题产生了条款18。对代理对象的综合讨论,转向《More Effective C++》的条款30,你能知道什么时候它们工作什么时候不。)

就STL里的分配器而言,没有任何代理对象的技术缺点会导致指针和引用typedef失效,实际上标准明确地允许库实现假设每个分配器的pointer typedef是T*的同义词,每个分配器的reference typedef与T&相同。对,库实现可以忽视typedef并直接使用原始指针和引用!所以即使你可以设法写出成功地提供新指针和引用类型的分配器的方法,也好不到哪里去,因为你使用的STL实现将自由地忽视你的typedef。很优雅,不是吗?

当你钦佩标准化的怪癖时,我将再介绍一个。分配器是对象,那表明它们可能有成员功能,内嵌的类型和typedef(例如pointer和reference)等等,但标准允许STL实现认为所有相同类型的分配器对象都是等价的而且比较起来总是相等。很唐突,听起来并不可怕,而且对它当然有好的动机。考虑这段代码:

template<typename T>				// 一个用户定义的分配器
class SpecialAllocator {...};			// 模板
typedef SpecialAllocator<Widget> SAW;		// SAW = “SpecialAllocator
						// for Widgets”
list<Widget, SAW> L1;
list<Widget, SAW> L2;
...
L1.splice(L1.begin(), L2);			// 把L2的节点移到
						// L1前端

记住当list元素从一个list被接合到另一个时,没有拷贝什么。取而代之的是,调整了一些指针,曾经在一个list中的节点发现他们自己现在在另一个list中。这使接合操作既迅速又异常安全。在上面的例子里,接合前在L2里的节点接合后出现在L1中。

当L1被销毁时,当然,它必须销毁它的所有节点(以及回收它们的内存),而因为它现在包含最初是L2一部分的节点,L1的分配器必须回收最初由L2的分配器分配的节点。现在清楚为什么标准允许STL实现认为相同类型的分配器等价。所以由一个分配器对象(比如L2)分配的内存可以安全地被另一个分配器对象(比如L1)回收。如果没有这样的认为,接合操作将更难实现。显然它们不能像现在一样高效。(接合操作的存在也影响了STL的其他部分。另一个例子参见条款4。)

那当然好,但你想得越多,越会意识到STL实现可以认为相同类型的分配器等价是多严厉的约束。那意味着可移植的分配器对象——在不同的STL实现下都功能正确的分配器——不能有状态。让我们明确这一点:它意味着可移植的分配器不能有任何非静态数据成员,至少没有会影响它们行为的。一个都没有。没有。那表示,例如,你不能有从一个堆分配的SpecialAllocator<int>和从另一个堆分配的另一个SpecialAllocator<int>。这样的分配器不等价,而试图使用那两个分配器的现存STL实现可能导致错误的运行期数据结构。

注意这是一个运行期问题。有状态的分配器可以很好地编译。它们只是不按你期待的方式运行。确保一个给定类型的所有分配器都等价是你的责任。如果你违反这个限制,不要期待编译器发出警告。

为了对标准委员会公平,我应该指出,在“允许STL实现认为相同类型的分配器等价”的文字之后,紧接着有下列陈述:

鼓励实现提供...支持非相等实例的库。在那样的实现中,...当分配器实例非相等时,容器和算法的语义是由实现定义的。

这是个可爱的句子,但是作为一个考虑开发带状态自定义分配器的STL用户,它几乎没向你提供什么。你可以利用这句话除非(1)你知道你使用的STL实现支持不等价的分配器,(2)你愿意钻研它们的文档来确定你是否可以接受“非相等”分配器的实现定义行为,(3)你不关心把你的代码移植到那些可能从标准给予的自由中获得好处的STL实现。简而言之,这个段落——第20.1.5节第5段,为坚持要求知道的那些人——是标准为分配器的“I have a dream”演讲。在梦想成为现实之前,关心移植性的程序员应该把他们自己限制在没有状态的自定义分配器。

我早先提及了分配器在分配原始内存方面类似operator new,但它们的接口不同。如果你看看operator new和allocator<T>::allocate最普通形式的声明,就会很清楚:

void* operator new(size_t bytes);
pointer allocator<T>::allocate(size_type numObjects);
					// 记住事实上“pointer”总是
					// T*的typedef

两者都带有一个指定要分配多少内存的参数,但对于operator new,这个参数指定的是字节数,而对于allocator<T>::allocate,它指定的是内存里要能容纳多少个T对象。例如,在sizeof(int) == 4的平台上,如果你要足够容纳一个int的内存,你得把4传给operator new,但你得把1传给allocator<int>::allocate。(在operator new情况下这个参数的类型是size_t,而在allocate的情况下它是allocator<T>::size_type。在两种情况里,它都是无符号整数类型,通常allocator<T>::size_type是一个size_t的typedef。)关于这个差异没有什么“错误”,但是operator new和allocator<T>::allocate之间的不同协定使应用自定义operator new的经验到开发自定义分配器的过程变得复杂。

operator new和allocator<T>::allocate的返回类型也不同。operator new返回void*,那是C++传统的表示一个到未初始化内存的指针的方式。allocator<T>::allocate返回一个T*(通过pointer typedef),不仅不传统,而且是有预谋的欺诈。从allocator<T>::allocate返回的指针并不指向一个T对象,因为T还没有被构造!在STL里暗示的是希望allocator<T>::allocate的调用者将最后在它返回的内存里构造一个或多个T对象(也许通过allocator<T>::construct,通过uninitialized_fill或通过raw_storage_iterator的一些应用),虽然在这里没有发生vector::reserve或string::reserve(参见条款14)。在operator new和allocator<T>::allocate之间返回类型的不同使未初始化内存的概念模型发生了变化,而它再次使把关于实现operator new的知识应用到开发自定义分配器变得困难。

那也带来了我们对STL分配器最后的好奇——大多数标准容器从未调用它们例示的分配器。这是两个例子:

list<int> L;				// 和list<int, allocator<int> >一样;
					// allocator<int>从未用来
					// 分配内存!
set<Widget, SAW> s;			// 记住SAW是一个
					// SpecialAllocator<Widget>的typedef;
					// SAW从未分配内存!

这个怪癖对list和所有标准关联容器都是真的(set、multiset、map和multimap)。那是因为这些是基于节点的容器,即,这些容器所基于的数据结构是每当值被储存就动态分配一个新节点。对于list,节点是列表节点。对于标准关联容器,节点通常是树节点,因为标准关联容器通常用平衡二叉搜索树实现。

想一会儿可能怎么实现list<T>。list本身由节点组成,每个节点容纳一个T对象和到list中后一个和前一个节点的指针:

template<typename T,			// list的可能
typename Allocator = allocator<T> >	// 实现
class list{
private:
	Allocator alloc;		// 用于T类型对象的分配器

	struct ListNode{		// 链表里的节点
		T data:
		ListNode *prev;
		ListNode *next;
	};
	...
};

当添加一个新节点到list时,我们需要从分配器为它获取内存,我们要的不是T的内存,我们要的是包含了一个T的ListNode的内存。那使我们的Allocator对象没用了,因为它不为ListNode分配内存,它为T分配内存。现在你理解list为什么从未让它的Allocator做任何分配了:分配器不能提供list需要的。

list需要的是从它的分配器类型那里获得用于ListNode的对应分配器的方法。按照协定,分配器得提供完成那个工作的typedef,否则将会很难办。那个typedef叫做other,但它不那么简单,因为other是嵌入一个叫做rebind的结构体的typedef,rebind自己是一个嵌入分配器的模板——分配器本身也是模板!

请不要试图考虑最后那句话。取而代之的是,看看下段代码,然后直接阅读后面的解释。

template<typename T>			// 标准分配器像这样声明,
class allocator {			// 但也可以是用户写的
public:					// 分配器模板
	template<typename U>
	struct rebind{
		typedef allocator<U> other;
	}
	...
};

在list<T>的实现代码里,需要确定我们持有的T的分配器所对应的ListNode的分配器类型。我们持有的T的分配器类型是模板参数Allocator。在本例中,ListNodes的对应分配器类型是:

Allocator::rebind<ListNode>::other

和我保持一致。每个分配器模板A(例如,std::allocator,SpecialAllocator,等)都被认为有一个叫做rebind的内嵌结构体模板。rebind带有一个类型参数,U,并且只定义一个typedef,other。 other是A<U>的一个简单名字。结果,list<T>可以通过Allocator::rebind<ListNode>::other从它用于T对象的分配器(叫做Allocator)获取对应的ListNode对象分配器。

这或许对你有意义,或许不。(如果你注视它足够长时间了,它会,但你可能还必须注视一会儿。我知道我必须。)作为一个可能想要写自定义分配器的STL用户,你其实不需要知道它怎样工作。你需要知道的是如果你选择写分配器并让标准容器使用它们,你的分配器必须提供rebind模板,因为标准容器认为它在那里。(为了调试的目的,知道T对象的基于节点的容器为什么从未从T对象的分配器获取内存也是有帮助的。)

哈利路亚!我们最后完成了对分配器特质的检查。因此,如果你想要写自定义分配器,让我们总结你需要记得的事情。

  • 把你的分配器做成一个模板,带有模板参数T,代表你要分配内存的对象类型。
  • 提供pointer和reference的typedef,但是总是让pointer是T*,reference是T&。
  • 决不要给你的分配器每对象状态。通常,分配器不能有非静态的数据成员。
  • 记得应该传给分配器的allocate成员函数需要分配的对象个数而不是字节数。也应该记得这些函数返回T*指针(通过pointer typedef),即使还没有T对象被构造。
  • 一定要提供标准容器依赖的内嵌rebind模板。

写你自己的分配器时你必须做的大部分事情是重现大量样板代码,然后修补一些成员函数,特别是allocate和deallocate。我建议你从Josuttis的样例allocator网页[23]或Austern的文章《What Are Allocators Good For?》[24]的代码开始,而不是从头开始写样板。

一旦你消化了本条款中的信息,你将知道很多关于分配器不能做的事情,但是那或许不是你想要知道的。相反,你或许想知道分配器能做什么。那有权成为一个丰富的主题,一个我称为“条款11”的主题。