vector使用技巧

幾乎每個人都會使用std::vector,這是個好現象。不過遺憾的是,許多人都誤解了它的語義,結果無意間以奇怪和危險的方式使用它。本條款中闡述的哪些問題會出現在你目前的程序中呢?

 

JG問題

1. 下面的代碼中,註釋A跟註釋B所示的兩行代碼有何區別?

void f(vector<int>& v) {

 v[0];       // A

 v.at(0);    // B

}

Guru問題

2. 考慮如下的代碼:

vector<int> v;

 

v.reserve(2);

assert(v.capacity() == 2);

v[0] = 1;

v[1] = 2;

for(vector<int>::iterator i = v.begin(); i < v.end(); i++) {

 cout << *i << endl;

}

 

cout << v[0];

v.reserve(100);

assert(v.capacity() == 100);

cout << v[0];

 

v[2] = 3;

v[3] = 4;

// ……

v[99] = 100;

for(vector<int>::iterator i = v.begin(); i < v.end(); i++) {

 cout << *i << endl;

}

請從代碼的風格和正確性方面對這段代碼做出評價。

解決方案

訪問vector的元素

1. 下面的代碼中,註釋A跟註釋B所示的兩行代碼有何區別?

// 示例1-1: [] vs. at

//

void f(vector<int>& v) {

 v[0];          // A

 v.at(0);        // B

}

示例1-1如果v非空A行跟B行就沒有任何區別如果v爲空B行一定會拋出一個std::out_of_range異常至於A行的行爲標準未加任何說明。

有兩種途徑可以訪問vector內的元素。其一使用vector<T>::at。該成員函數會進行下標越界檢查以確保當前vector中的確包含了需要的元素。試圖在一個目前只包含10個元素的vector中訪問第100個元素是毫無意義的,這樣做會導致拋出一個std::out_of_range異常。

其二我們也可以使用vector<T>::operator[]C++98標準說vector<T>::operator可以、但不一定要進行下標越界檢查。實際上,標準對operator[]是否需要進行下標越界檢查隻字未提,不過標準同樣也沒有說它是否應該帶有異常規格聲明。因此,標準庫實現方可以自由選擇是否爲operator[]加上下標越界檢查功能。如果使用operator[]訪問一個不在vector中的元素,你可就得自己承擔後果了,標準對這種情況下會發生什麼事情沒有做任何擔保(儘管你使用的標準庫實現的文檔可能做了某些保證)——你的程序可能會立即崩潰,對operator[]的調用也許會引發一個異常,甚至也可能看似無恙,不過會偶爾或神祕地出問題。

既然下標越界檢查幫助我們避免了許多常見問題,那爲什麼標準不要求operator[]實施下標越界檢查呢?簡短的答案是效率。總是強制下標越界檢查會增加所有程序的性能開銷(雖然不大),即使有些程序根本不會越界訪問。有一句名言反映了C++的這一精神:一般說來,不應該爲不使用的東西付出代價(或開銷)。所以,標準並不強制operator[]進行越界檢查。況且我們還有另一個理由要求operator[]具有高效性:設計vector是用來替代內置數組的,因此其效率應該與內置數組一樣,內置數組在下標索引時是不進行越界檢查的。如果你需要下標越界檢查,可以使用at

調整vector的大小

現在讓我們來看示例1-2該示例對vector<int>進行了簡單操作

2. 考慮如下的代碼:

// 示例1-2: vector的一些函數

//

vector<int> v;

v.reserve(2);

assert(v.capacity() == 2);

這裏的斷言存在兩個問題一個是實質性的另一個則是風格上的。

首先實質性問題是,這裏的斷言可能會失敗。爲什麼?因爲上一行代碼中對reserve的調用將保證vector容量至少爲2,然而它也可能大於2。事實上這種可能性是很大的,因爲vector的大小必須呈指數速度上升,因而vector的典型實現可能會選擇總是按指數邊界來增大其內部緩衝區,即使是通過reserve來申請特定大小的時候。因此,上面代碼中的斷言條件表達式應該使用>=,而不是==,如下所示:

assert(v.capacity() >= 2);

其次,風格上的問題是,該斷言即使是改正後的版本是多餘的。爲什麼?因爲標準已經保證了這裏所斷言的內容。所以再將它明確地寫出來只會帶來不必要的混亂。這樣做毫無意義,除非你懷疑正在使用的標準庫實現有問題,在這種情況下,你可就遇到大麻煩了。

v[0] = 1;

v[1] = 2;

上面這些代碼中的問題都是比較明顯的,但可能是比較難於發現的明顯錯誤,因爲它們很可能會在你所使用的標準庫實現上“勉強”能夠“正常運行”。

大小sizeresize相對應跟容量capacityreserve相對應之間有着很大的區別

l     size告訴你容器中目前實際有多少個元素,而對應地,resize則會在容器的尾部添加或刪除一些元素,來調整容器當中實際的內容,使容器達到指定大小。這兩個函數對listvectordeque都適用,但對其他容器並不適用。

l     capacity則告訴你最少添加多少個元素纔會導致容器重分配內存,而reserve在必要的時候總是會使容器的內部緩衝區擴充至一個更大的容量,以確保至少能滿足你所指出的空間大小。這兩個函數僅對vector適用。

本例中我們使用的是v.reserve(2)因此我們知道v.capacity()>=2這沒有問題但值得注意的是我們實際上並沒有向v當中添加任何元素因而v仍然是空的v.reserve(2)只是確保v當中有空間能夠放得下兩個或更多的元素而已。

準則記住size/resize以及capacity/reserve之間的區別。

我們只可以使用operator[]()at()去改動那些確實存在於容器中的元素這就意味着它們是跟容器的大小息息相關的。首先你可能想知道爲什麼operator[]不能更智能一點,比如當指定地點的元素不存在的時候“聰明地”往那裏塞一個元素,但問題是假設我們允許operator[]()以這種方式工作,就可以創建一個有“漏洞”的vector了!例如,考慮如下的代碼:

vector<int> v;

v.reserve(100);

v[99] = 42; // 錯誤!但出於討論的目的,讓我們假設這是允許的……

 

//……這裏v[0]v[98]的值是什麼呢?

正是因爲標準並不強制要求operator[]()進行區間檢查,所以在大多數實現上,v[0]都會簡單地返回內部緩衝區中用於存放但尚未存放第一個元素的那塊空間的引用。因此v[0]=1;這行語句很可能被認爲是正確的,因爲如果接下來輸出v[0](cout<<v[0])的話,或許會發現結果確實是1,跟(錯誤的)預期相符合。

再一次提醒,標準並無任何保證說在你使用的標準庫實現上一定會出現上述情形,本例只是展示了一種典型的可能情況。標準並沒有要求特定的實現在這類情況下(諸如一個空的vector v寫成v[0])該採取什麼措施,因爲它假定程序員對這類情況有足夠的認識。畢竟,如果程序員想要庫來幫助進行下標越界檢查的話,他們可以使用v.at(0),不是嗎?

當然如果將v.reserve(2)改成v.resize(2)的話v[0]=1;v[1]=2;這兩行賦值語句就能夠順利工作了。只不過上文中的代碼並沒有使用resize(),因此代碼並不能保證正常工作。作爲一個替代方案,我們可以將這兩行語句替換成v.push_back(1)v.push_back(2),它們的作用是向容器的尾部追加元素,而使用它們總是安全的。

for(vector<int>::iterator i = v.begin(); i < v.end(); i++) {

 cout << *i << endl;

}

首先,上面這段代碼什麼都不會打印,因爲vector現在根本就是空的!這可能會讓代碼的作者感到意外,因爲他們還沒意識到其實前面的代碼根本就沒有往vector中添加任何東西。實際上,跟vector中的那些已經預留但尚未正式使用的空間“玩遊戲”是很危險的。

話雖如此,這個循環本身並沒有任何明顯的問題,只不過如果在代碼審查階段看到這段代碼的話,我會指出其中存在的一些風格上的問題。大多數意見都是初級的,如下所示:

1儘量做到const正確性。以上的循環當中迭代器並沒有用來修改vector中的元素因此應當改用const_iterator

2儘量使用!=而不是<來比較兩個迭代器。確實,由於vector<int>::iterator恰巧是一個隨機訪問迭代器(當然,並不一定是int*),因此在這種特定情況下將它跟v.end()比較是沒有任何問題的。但問題是<只對隨機訪問迭代器有效,而!=對於任何迭代器都是有效的,因此我們應該將使用!=比較迭代器作爲日常慣例,除非某些情況下確實需要<(注意,使用!=還有一個好處就是便於將來(如有需要)更改容器類型)。例如,std::list的迭代器並不支持<,因爲它們只不過是雙向迭代器。

3儘量使用前綴形式的--++。讓自己習慣於寫++i而不是i++,除非真的需要用到i原來的值。例如,如果既要訪問i所指的元素,又要將i向後遞增一位的話,後綴形式v[i++]就比較適用了。

4避免無謂的重複求值。本例中v.end()所返回的值在整個循環的過程中是不會改變的,因此應當避免在每次判斷循環條件時都調用一次v.end(),或許我們應當在循環之前預先將v.end()求出來。

注意,如果你的標準庫實現中的vector<int>::iterator就是int*,而且能夠將end()進行內聯及合理優化的話,原先的代碼也許並無任何額外開銷,因爲編譯器或許能夠看出end()返回的值一直是不變的,從而安全地將求值提到循環外部。這是一種相當常見的情況。然而,如果你的標準庫實現的vector<int>::iterator並非int*(例如,在大多數調試版實現當中,其類型都是類類型的),或者end()之類的函數並沒有內聯,或者編譯器並不能進行相應的優化,那麼只有手動將這部分代碼提出才能獲得一定程度的性能提升。

5儘量使用/n而不是endl。使用endl會迫使輸出流刷新其內部緩衝區。如果該流的確有內部緩衝區,而且又確實不需要每次都刷新它的話,可以在整個循環結束之後寫一行刷新語句,這樣程序會執行得快很多。

最後一個意見稍微高級一些:

6儘量使用標準庫中的copy()for_each(),而不是自己手寫循環,因爲利用標準庫的設施,你的代碼可以變得更爲乾淨簡潔。這裏,風格跟美學判斷起作用了。在簡單的情況下,copy()for_each()可以而且確實比手寫循環的可讀性要強。不過,也只有像本例這樣的簡單情形纔會如此,如果情況稍微複雜一些的話,除非你有一個很好的表達式模板庫,否則使用for_each()來寫循環反而會降低代碼的可讀性,因爲原先位於循環體中的代碼必須被提到一個仿函數當中才能使用for_each()。有時候這種提取是件好事,但有時它只會導致混淆晦澀。

之所以說大家的口味可能各不相同,就是這個原因。另外,在本例中我傾向於將原先的手寫循環替換成如下的形式:

copy(v.begin(), v.end(), ostream_iterator<int>(cout, "/n"));

此外,如果你如此使用copy(),那麼原先關於!=++end()以及endl的問題就不用操心了,因爲copy()已經幫你做了這些事情(當然,我還是假定你並不希望在每輸出一個int的時候都去刷新輸出流,否則你只有手寫循環了)。複用如果運用得當的話不但能夠改善代碼的可讀性,而且還可以避開一些陷阱,從而讓代碼更佳。

你可以更進一步,編寫一個基於容器的複製算法,也就是說,施加在整個容器(而不僅僅是迭代器區間)之上的算法。這種做法同樣也可以自動糾正const_iterator問題。例如:

template<class Container, class OutputIterator>

OutputIterator copy(const Container& c, OutputIterator result) {

 return std::copy(c.begin(), c.end(), result);

}

這裏我們只需簡單地包裝std::copy()讓它對整個容器進行操作此外由於我們是以const&來接受容器參數的因而迭代器自然就是const_iterator了。

準則const正確性。特別是不對容器內的元素做任何改動的時候,記得使用const_iterator
儘量使用!=而不是<來比較兩個迭代器。
養成默認情況下使用前綴形式的- -++的習慣,除非你的確需要用到原來的值。
實施複用:儘量複用已有的算法,特別是標準庫算法(例如for_each()),而不是手寫循環。

接下來我們遇到下面這行代碼:

cout << v[0];

當程序執行這一行的時候,可能會打印出1。這是因爲前面的程序以錯誤的方式改寫了v[0]所引用的那塊內存,只不過,這行代碼也許並不會導致程序立即崩潰,真遺憾!

v.reserve(100);

assert(v.capacity() == 100);

同樣,這裏的斷言表達式當中應該使用>=,而且和前面一樣,這也是多餘的。

cout << v[0];

很奇怪!這次的輸出結果可能爲0,我們剛剛賦值的1神祕失蹤了!

爲什麼?我們假設reserve(100)確實引發了一次內部緩衝區的重分配(即如果第一次reserve(2)並沒有使內部緩衝區擴大到100或更多的話),這時v就只會將它確實擁有的那些元素複製到“新家”當中,而問題是實際上v認爲它內部空空如也(因此不進行任何元素拷貝)!另一方面,新分配的內部緩衝區最初值爲0,因此就出現了上述情況。

v[2] = 3;

v[3] = 4;

// ……

v[99] = 100;

毫無疑問,看到如上的代碼你可能已經嘆着氣搖頭了。這真是糟糕、糟糕、太糟糕了但由於標準並不強制operator[]()進行越界檢查,所以在大多數實現上這種代碼或許會靜悄悄地“正確”運行着,而不會立即導致異常或內存陷阱。

如果這樣改寫:

v.at(2) = 3;

v.at(3) = 4;

// ……

v.at(99) = 100;

那麼問題就會變得明朗了,因爲第一個調用語句就會拋出一個out_of_range異常。

for(vector<int>::iterator i = v.begin(); i < v.end(); i++) {

 cout << *i << endl;

}

再一次提醒以上代碼什麼也不會打印出來應當考慮將它改寫成

copy(v.begin(), v.end(), ostream_iterator<int>(cout, "/n"));

再次注意,這種複用自動地解決了!=、前綴++end()以及endl問題,因此程序永遠不會在這些方面犯錯誤。良好的複用通常也會讓代碼自動變得更快和更安全。

小結

瞭解size()capacity()之間的區別瞭解operator[]()at()之間的區別如果需要越界檢查請使用at()而不是operator[]()。這麼做可以幫助我們節省大量的調試時間。

發佈了9 篇原創文章 · 獲贊 2 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章