front/pop從理論到實踐

STLstd::queue說起

STLstd::queue類是個容器適配器,即由其它容器包裝而成的特殊數據結構。

提到queue,就少不了提及它的兩個最重要的操作:往隊列尾部填加數據的push和從隊列頭部彈出數據的pop。本文不打算討論push,只想考查一下popstd::queuepop函數相當簡單:

void pop();

它的唯一作用就是將當前的隊首元素從隊列中刪除。

同時,std::queue又提供兩個重載的front函數,用以獲得當前的隊首元素:

value_type& front(); 

const value_type& front() const;

注意,這兩個front函數返回的都是隊中元素的引用(而非臨時變量)。

popfront這兩個成員函數,一個刪除隊首頂素,一個獲得隊首元素,在絕大多數情況下,必須聯合使用才能完成我們需要的動作。因爲我們在使用隊列時,最常用的操作就是把隊首元素從隊列中“取”出來並進行處理。

 

Java的標準庫中,也有個類似的Queue模板類:java.util.Queue<E>。這個類有一個類似pop的方法poll:(還有一個與之類似的remove,區別僅在於隊空時是否拋出異常。)

E poll();

STLstd::queue不同的是,這個函數在刪除隊首元素的同時,順便返回了被刪除的元素。雖然JavaQueue類中也有一個跟C++std::queuefront相似的方法:

E peek();

但在相當多的情況下,僅靠poll就可以完成大多數任務。

Microsoft .NET平臺上System.Colletions.Queue的情形與Java類似。

顯然,Java的這種方式可以使隊列的使用更加方便。那麼,C++中什麼還要分爲frontpop,並讓大家將兩者結合起來使用呢?

爲什麼要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通過frontpop這兩個成員函數來操作隊首元素。其中:

front只是返回隊首對象,並不從隊列中刪除對象。它也因此可以返回引用類型,從而在某種情況下甚至能省去返回值的拷貝。但即使需要拷貝也不要緊,因爲它沒有對隊列進行任何增刪操作,異常發生時自然是安全的;

pop則什麼也不返回,只是刪除隊首元素,因此我們很容易把它實現好,使之滿足異常安全強保證(pop拋出異常時隊列的狀態跟調用pop之前一樣)。

於是,std::queue在其元素類型的拷貝構造函數可能拋出異常的情況下,仍然能達到強異常安全級別。對於一個通用的設施來說,這無疑是一種優勢。

看起來不錯。那爲什麼Java標準庫的Queue模板類沒有按這一思路來設計呢?那是因爲Java中的變量只有內建類型和引用類型這兩種,而這兩種類型的拷貝過程(甚至包括裝箱拆箱過程)都不會拋出異常。

實際情形

在實際編程中,我們有時需要針對自己的情況編寫專用的隊列設施,我們構造這類隊列的時候,可能是基於std::queuestd::deque來實現,也可能不基於它們,而是從零開始手工構造。那我們在編寫自己的隊列時是不是也一定要遵循標準庫中的front/pop做法,從而提供高強度的異常安全保證呢?答案是否定的。

這也是“專用”和“通用”之間的區別。

當我們針對某種特定情形,甚至特定類型來構造隊列設施的時候,我們的元素類型要麼是非常簡單的類型,其拷貝構造函數根本不會拋出異常,要麼是比較複雜的類型,但這種情況下,爲了效率,我們通常不會將整個對象塞到隊列中,然後在不同的模塊和類之間傳來傳去,而是隻在隊列中保存對象的地址。

一種典型的情形是:模塊A在堆中創建對象,並將其指針塞到隊列中,然後模塊B從隊列中取出指向對象的指針,對所指對象做相應處理,然後通過指針釋放這一對象。指針的拷貝不可能拋出異常,因此這些“上層”的、“專用”的隊列類也就沒有必要考慮那麼多。

多線程中的情形

最後,有必要說一下多線程中的情況。因爲我們常常會需要構造一種隊列,用來在多個線程間共享數據,所謂的“生產者-消費者”模式就是這麼一種情況。構造這種隊列時,我們常常想把同步和互斥機制實現在隊列內部,這樣,不同的線程使用隊列時就會方便許多。

而標準庫的std::queue所採用的front/pop機制恰恰不利於這種共享隊列的實現。

原因是:如果採用類似的front/pop機制,則隊列的frontpop都需要加鎖(即使採用讀-寫鎖,那front至少也要加“讀鎖”)。而由於讀取跟刪除不在同一個函數中,因此只能在各自的函數中分別加鎖。那麼,當多個消費者線程共同處理隊列中的數據時,會發生什麼情況呢?一個線程用front獲得了隊首元素,正想把它pop掉,這時另一個線程上臺了,於是它也獲得了同一個元素,並開始處理……

這當然不行。正確的做法自然是讓讀取和刪除合在一塊,在一次加鎖動作的保護下全部完成,也就是說,我們需要有一個成員函數,既能返回隊首元素,同時又能將它從隊首刪除:也就是Java標準庫中的Queue那樣的poll操作。——當然,基於前面的分析,我們在編寫這種隊列時,要注意隊列元素的類型:它的拷貝構造函數不可以拋出異常。我個人在多線程間共享數據時,常用指針,從而避開拷貝異常的問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章