从STL的std::queue说起
STL的std::queue类是个容器适配器,即由其它容器包装而成的特殊数据结构。
提到queue,就少不了提及它的两个最重要的操作:往队列尾部填加数据的push和从队列头部弹出数据的pop。本文不打算讨论push,只想考查一下pop。std::queue的pop函数相当简单:
void pop();
它的唯一作用就是将当前的队首元素从队列中删除。
同时,std::queue又提供两个重载的front函数,用以获得当前的队首元素:
value_type& front();
const value_type& front() const;
注意,这两个front函数返回的都是队中元素的引用(而非临时变量)。
pop和front这两个成员函数,一个删除队首顶素,一个获得队首元素,在绝大多数情况下,必须联合使用才能完成我们需要的动作。因为我们在使用队列时,最常用的操作就是把队首元素从队列中“取”出来并进行处理。
Java的标准库中,也有个类似的Queue模板类:java.util.Queue<E>。这个类有一个类似pop的方法poll:(还有一个与之类似的remove,区别仅在于队空时是否抛出异常。)
E poll();
与STL的std::queue不同的是,这个函数在删除队首元素的同时,顺便返回了被删除的元素。虽然Java的Queue类中也有一个跟C++的std::queue的front相似的方法:
E peek();
但在相当多的情况下,仅靠poll就可以完成大多数任务。
Microsoft .NET平台上System.Colletions.Queue的情形与Java类似。
显然,Java的这种方式可以使队列的使用更加方便。那么,C++中什么还要分为front跟pop,并让大家将两者结合起来使用呢?
为什么要front/pop
要回答这个问题,我们可以试着实现一下queue类的pop,让它在弹出元素的同时返回被弹出的元素值。为方便讨论并使问题清晰,我们不采用标准库中的实现,而是假定queue类有一个数组成员容纳数据,以及指示队首、队尾位置的下标:
// 注意此queue并非标准的std::queue。
template <class T>
class queue {
public:
……
typedef T value_type;
value_type pop();
private:
value_type array_[MAX_SIZE];
int head_, tail_;
};
下面我们试着实现pop成员函数:我们只实现非const版本的,并假定它将返回被删除的队首元素。而且,为方便问题的讨论,我们故意省去了一些维护下标有效性的代码。
template<class T>
value_type queue<T>::pop() {
if(head_ == tail_) {
throw std::runtime_error("Queue empty");
}
value_type result = array_[head_];
++head_;
return result;
}
假如我们有一个类A,并定义了队列queue<A> q;
那么当我们执行:
A a = q.pop();
时,会发生什么呢?
注意,上面的赋值表达式是基于pop返回的对象来构造对象a,这是拷贝构造。如A类的拷贝构造函数抛出了异常,那问题就出现了:一方面队列q的内部状态已经发生改变(下标head_后移),另一方面,返回的对象却丢失了。这不符合关于异常安全的“强保证”(Strong Guarantee,参见《Exceptional C++》)要求。异常安全强保证要求:“当操作因异常而终止,程序的状态应保持不变。”而上面的pop实现显然是不能满足。
这就是标准库的std::queue为什么没有让pop直接返回队首对象的原因。相反,std::queue通过front和pop这两个成员函数来操作队首元素。其中:
front只是返回队首对象,并不从队列中删除对象。它也因此可以返回引用类型,从而在某种情况下甚至能省去返回值的拷贝。但即使需要拷贝也不要紧,因为它没有对队列进行任何增删操作,异常发生时自然是安全的;
而pop则什么也不返回,只是删除队首元素,因此我们很容易把它实现好,使之满足异常安全强保证(pop抛出异常时队列的状态跟调用pop之前一样)。
于是,std::queue在其元素类型的拷贝构造函数可能抛出异常的情况下,仍然能达到强异常安全级别。对于一个通用的设施来说,这无疑是一种优势。
看起来不错。那为什么Java标准库的Queue模板类没有按这一思路来设计呢?那是因为Java中的变量只有内建类型和引用类型这两种,而这两种类型的拷贝过程(甚至包括装箱拆箱过程)都不会抛出异常。
实际情形
在实际编程中,我们有时需要针对自己的情况编写专用的队列设施,我们构造这类队列的时候,可能是基于std::queue或std::deque来实现,也可能不基于它们,而是从零开始手工构造。那我们在编写自己的队列时是不是也一定要遵循标准库中的front/pop做法,从而提供高强度的异常安全保证呢?答案是否定的。
这也是“专用”和“通用”之间的区别。
当我们针对某种特定情形,甚至特定类型来构造队列设施的时候,我们的元素类型要么是非常简单的类型,其拷贝构造函数根本不会抛出异常,要么是比较复杂的类型,但这种情况下,为了效率,我们通常不会将整个对象塞到队列中,然后在不同的模块和类之间传来传去,而是只在队列中保存对象的地址。
一种典型的情形是:模块A在堆中创建对象,并将其指针塞到队列中,然后模块B从队列中取出指向对象的指针,对所指对象做相应处理,然后通过指针释放这一对象。指针的拷贝不可能抛出异常,因此这些“上层”的、“专用”的队列类也就没有必要考虑那么多。
多线程中的情形
最后,有必要说一下多线程中的情况。因为我们常常会需要构造一种队列,用来在多个线程间共享数据,所谓的“生产者-消费者”模式就是这么一种情况。构造这种队列时,我们常常想把同步和互斥机制实现在队列内部,这样,不同的线程使用队列时就会方便许多。
而标准库的std::queue所采用的front/pop机制恰恰不利于这种共享队列的实现。
原因是:如果采用类似的front/pop机制,则队列的front和pop都需要加锁(即使采用读-写锁,那front至少也要加“读锁”)。而由于读取跟删除不在同一个函数中,因此只能在各自的函数中分别加锁。那么,当多个消费者线程共同处理队列中的数据时,会发生什么情况呢?一个线程用front获得了队首元素,正想把它pop掉,这时另一个线程上台了,于是它也获得了同一个元素,并开始处理……
这当然不行。正确的做法自然是让读取和删除合在一块,在一次加锁动作的保护下全部完成,也就是说,我们需要有一个成员函数,既能返回队首元素,同时又能将它从队首删除:也就是Java标准库中的Queue那样的poll操作。——当然,基于前面的分析,我们在编写这种队列时,要注意队列元素的类型:它的拷贝构造函数不可以抛出异常。我个人在多线程间共享数据时,常用指针,从而避开拷贝异常的问题。