Effective STL
条款2:谨防容器无关代码的假象
(Item2: Beware the illusion of container-independent code.)
STL 是在泛化(generalization)的基础上构造出来的。容器由数组(arrays)泛化(generalized)而来,并且根据其容纳的对象类型被参数化(parameterized)。算法由函数(functions)泛化(generalized)而来,并且根据其使用的迭代器(iterators)类型被参数化(parameterized)。迭代器(iterators)由指针(Pointers)泛化(generalized)而来,并且根据其指向的对象(objects)的类型被参数化(parameterized)。
这仅仅是个开端。各自独立的容器类型泛化成序列式(sequence)和关联式(associative)容器,相似的容器拥有相似的功能。标准的连续内存(contiguous-memory)容器(见条款1)提供随机访问迭代器(random access iterators),而标准的基于节点(node-based)的容器(见条款1)提供双向迭代器(bidirectional iterators)。序列式容器(Sequence containers)支持push_front和/或push_back,而关联式容器(associative containers)不支持。关联式容器(associative containers)提供执行时间为对数时间的成员函数:lower_bound,upper_bound和equal_range,但是序列式容器(Sequence containers)没有提供。(译注:标准算法库提供lower_bound,upper_bound和equal_range三个模板函数,但要求作为参数传入的迭代器区间必须有序,换句话说就是首先要对容器内元素排序。)
随着所有这些泛化过程的继续,很自然的你想要更多的泛化。唔,这种态度值得称道,当你在写自己的容器,迭代器和算法时,的确应该尽可能的使用泛化。除此之外,许多程序员尝试着以一种不同的方式使用泛化。他们不是在他们的软件中增加特定的容器类型,而是试着去泛化容器的概念(notion),这样他们就能,比方说使用vector的同时,还保留着以后用deque或list来替换vector的选择权——所有这些无须改变任何相关代码。也就是说,他们力求写出容器无关代码(container-independent code)。虽然这种泛化源于好的目的,但几乎总是被误导。
即使最热情的容器无关代码(container-independent code)的鼓吹者也会很快意识到尝试去写在序列式和关联式容器下都能工作的软件几乎没有什么意义。许多成员函数只存在于某种容器类型中,例如只有序列式容器支持push_front或push_back,而关联式容器只支持count和lower_bound等等。即使像insert和erase这样的基本操作也会随着(容器)类型的不同而有各自的特点(signatures)和语义(semantics)。例如,当你在序列式容器中插入一个对象时,该对象就呆在所插入的位置不动,但是当你在关联式容器中插入一个对象时,容器会移动对象,所处位置取决于容器排序顺序。再例如,erase的一个重载版本(该版本以一个iterator作为参数),作为序列式容器的成员函数被调用时会返回一个新的iterator,但是作为关联式容器的成员函数被调用时返回为void。(条款9用例子说明了这是如何影响你写的代码的。)
假设你渴望写出能够与最普通的序列式容器(vector,deque和list)一起使用的代码。很明显,你只能编写适合它们能力的代码(即它们支持的操作的交集),这也就意味着你不能使用reserve或capacity(见条款14),因为deque和list不提供这两个操作。同样,list的使用意味着你要放弃operator[],并且你也只能使用双向迭代器(bidirectional iterators)所支持的操作。这样的话,也就意味着你必须远离需要随机访问迭代器(random access iterators)支持的算法,包括sort,stable_sort,partial_sort以及nth_element(见条款31)。
另一方面,你要支持vector的想法排除了push_front和pop_front的使用,vector和deque都对splice和sort的成员函数形式置之不理(vector和deque没有splice和sort这两个成员函数)。结合上面所讲的约束条件(constraints),后一个限制意味着你不能在你的“泛化的序列式容器”上调用成员函数sort。
很容易就可以得出结论:如果你违反了任何一种限制,那么至少有一种你希望可以使用的容器会导致编译失败,只要你的代码中使用了这种容器。要编译的代码也会更加难以捉摸。
(前面所说问题的)罪魁祸首是不同的序列式容器都有各自的迭代器,指针和引用的失效规则。要写出与vector,deque和list一起正确工作的代码,你必须假设,如果在某一容器内有一种操作会使迭代器,指针和引用失效,那么该操作也应该会使其它所有容器内的迭代器,指针和引用失效。这样,你就必须假设调用insert会使任何东西失效,这是因为deque::insert使所有的迭代器失效,另外在丧失调用capacity的能力的同时,你必须假设调用vector::insert会使所有的指针和引用失效。(条款1解释了deque是唯一的一种有时会使迭代器失效而不会使指针和引用失效的容器。)相似的原因你也必须假设调用erase会使任何东西失效。
要更多的例子吗?你不能传递容器内的数据给C接口,因为只有vector支持这种操作(见条款16)。你不能实例化以bool作为存贮对象类型的容器,这是因为(正如条款18解释的)vector<bool>的行为并不总是象vector,实际上它永远不会存储bools。你也不能假设list的常数时间的插入(insertions)和擦除(erasures),因为vector和deque需要花费线性时间去执行这些操作。
当所有的约束条件都被考虑后,你就拥有了一个“泛化的序列式容器”,要使用这个容器,你不能调用reserve,capacity,operator[],push_front,pop_front,splice或者需要随机访问迭代器(random access iterators)支持的算法;每次调用insert和erase要花费线性时间并且使所有的迭代器,指针和引用失效; 不兼容于C,也不能存储bools。这真的就是你希望在你的应用程序中使用的那种容器吗?我怀疑不是。
如果你想控制一下你的野心,决定不再支持list,你还是要放弃reserve,capacity,push_front和pop_front的使用;你还是要假设每次调用insert和erase要花费线性时间并且使所有的一切失效;你还是会丢失与C布局的兼容性;你还是不能存储bools。
如果你放弃了序列式容器,改变主意想要写出能够与所有关联式容器一起工作的代码,情况也不会更好。同时为set和map编写代码几乎是不可能的,因为set存储单一对象(single objects)而map存储双对象(pairs of objects)。即使是同时为set和multiset(或map和multimap)编写代码也是困难的。sets/maps中insert成员函数有一个重载版本(该版本只需要一个参数值value)和其multi兄弟(意即multisets/multimaps)相比拥有不同的返回类型(译注:sets/maps中的原型为:pair <iterator, bool> insert(const value_type& _Val); 而 multisets/multimaps中的原型为:iterator insert(const value_type& _Val);)。同时你也要虔诚地避免作出这样的假设:有多少个相同value值的拷贝存储在容器中(set和map不允许有重复值)。map和multimap共同使用时,你必须避免使用operator[],因为operator[]只存在于map中。
面对事实吧:这不值得做(写容器无关代码)。不同的容器是不同的,随侧重点的不同,它们各有优缺点。它们被设计出来不是用于互换的,不要想去忽略这一点。如果你尝试的话,那么你就是在挑衅命运,而命运不喜欢被挑衅。
同样,当你意识到所选的容器不是最理想的时候,天快要破晓了,而你需要使用一种不同的容器类型。现在你知道当改变容器类型时,你不但要解决编译器报告的问题,还要根据新容器的性能特征和迭代器,指针和引用的失效规则检查所有使用容器的代码,从而搞明白什么需要改变。如果要从vector转换到别的容器,你就要确保你不再依赖vector的C兼容内存布局,要转换到vector,你要确保你不会再使用它来保存bools。
既然不时的改变容器类型是不可避免的,你就可以使用一般的方法来使这种改变来得容易:通过封装,封装,还是封装。最容易的一种方法就是广泛的使用容器和迭代器类型的typedefs。因此,不要这样写:
class Widget {…};
vector<Widget> vw;
Widget bestWidget;
… // give bestWidget a value
vector<Widget>::iterator i = // find a Widget with the same
find( vw.begin(), vw.end(), bestWidget ); // value as bestWidget
这样写:
class Widget {…};
typedef vector<Widget> WidgetContainer;
typedef WidgetContainer::iterator WCIterator;
WidgetContainer cw;
Widget bestWidget;
…
WCIterator i = find(cw.begin(), cw.end(), bestWidget);
这就使改变容器类型时非常容易,如果要改变的仅仅是添加一个自定义的分配器(allocator)时尤其方便。(这样的改变不会影响iterator/pointer/reference的失效规则。)
class Widget {…};
template<typename T> // see Item 10 for why this needs to be
SpecialAllocator {…}; // a template
typedef vector<Widget, SpecialAllocator<Widget> > WidgetContainer;
typedef WidgetContainer::iterator WCIterator; // still works
WidgetContainer cw;
Widget bestWidget;
…
WCIterator i = find(cw.begin(), cw.end(), bestWidget); // still works
如果你不想要typedefs的封装功能,你还是很可能会欣赏它们帮你所节约的工作。例如,你有这种类型的对象:
map<string,
vector<Widget>::iterator,
CIStringCompare> // CIStringCompare is “case-insensitive
// string compare;” Item 19 describes it
并且你想使用map的const_iterators,你真的想拼写
map<string, vector<Widget>::iterator, CIStringCompare>::const_iterator
多过一次吗?一旦你使用了STL一段时间,你会意识到typedefs是你的好朋友。
typedef只是某种类型的同义词,因此它所提供的封装纯粹是字面意义上的。typedef不能阻止客户代码做(或依赖)任何它们已经不能再做(或依赖)的事。如果你想限制暴露给客户所选容器的细节,你需要更好的武器。你需要的是类。
如果要替换容器的类型,你应当限制可能需要修改的代码,把容器隐藏在类的内部,尽量减少外部能够访问的特定容器信息(通过类接口)。例如,如果你要建立一个客户list,不要直接使用list。相反,去创建一个CustomerList类,并且把一个list隐藏在私有域中:
class CustomerList {
private:
typedef list<Customer> CustomerContainer;
typedef CustomerContainer::iterator CCIterator;
CustomerContainer customers;
public:
… // limit the amount of list-specific
// information visible through this // interface
};
首先,这看起来似乎有点傻。毕竟客户list是一个list,对吗?恩,也许吧。不久你可能发现你不需要你曾期望的那样频繁的在list的中部插入(insert)和擦除(erase)客户,但你却需要快速地访问前面20%的客户——为算法nth_element定制的任务(见条款31)。但是nth_element需要随机访问迭代器(random access iterators)。它不能与list一起使用。在这种情况下,你的客户“list”可以用vector或deque更好的实现。
当你考虑这种改变时,你还是要检查每个CustomerList的成员函数和友元,搞清楚它们是如何被影响的(在性能和iterator/pointer/reference失效等方面),但是如果你在封装CustomerList的实现细节时做得很好,对CustomerList的客户代码的影响应该是很小的。你不能写出容器无关(container-independent)代码,但是它们(封装的类)也许可以做到。