本節書摘來自異步社群出版社《c++程式設計剖析:問題、方案和設計準則》一書中的第1章,第1.1節,作者:【美】herb sutter(赫布 薩特),更多章節内容可以通路雲栖社群“異步社群”公衆号檢視。
c++程式設計剖析:問題、方案和設計準則
c++最強大的特性之一就是對泛型程式設計的支援。c++标準庫的高度靈活性就是明證,尤其是标準庫中的容器、疊代器以及算法部分(最初也稱為stl)。
與我的另一本書more exceptional c++ [sutter02]一樣,本書的開頭幾條也是介紹stl中一些我們平常熟悉的部件,如vector和string,另外也介紹了一些不那麼常見的設施。例如,在使用最基本的容器vector時如何避免常見的陷阱?如何在c++中進行常見的c風格字元串操縱?我們能夠從stl中學到哪些庫設計經驗(不管是好的、壞的,還是極其糟糕的)?
在考察了stl中的模闆設施之後,接着讨論關于c++中的模闆和泛型程式設計的一些更一般性的問題。例如,如何讓我們的模闆代碼避免不必要地(且相當不經意地)損失泛型性。為什麼說特化函數模闆實際上是個糟糕的主意,而我們又應當怎麼替換它?在模闆的世界中,我們如何才能正确且可移植地完成像授予友元關系這樣看似簡單的操作?此外還有圍繞着export這個有趣的關鍵字發生的種種故事。
随着我們逐漸深入與c++标準庫及泛型程式設計相關的主題,就會看到關于上述(以及其他)問題的讨論。
難度系數:4
幾乎每個人都會使用std::vector,這是個好現象。不過遺憾的是,許多人都誤解了它的語義,結果無意間以奇怪和危險的方式使用它。本條款中闡述的哪些問題會出現在你目前的程式中呢?
初級問題
下面的代碼中,注釋a跟注釋b所示的兩行代碼有何差別?
vector v;
v.reserve(2);
assert(v.capacity() == 2);
v[0] = 1;
v[1] = 2;
for(vector::iterator i = v.begin(); i < v.end(); i++) {
cout << *i << endl;
}
cout << v[0];
v.reserve(100);
assert(v.capacity() == 100);
v[2] = 3;
v[3] = 4;
// ……
v[99] = 100;
// 示例1-1: [] vs. at
//
void f(vector< int >& v) {
v[0]; // a
v.at(0); // b
}<code>`</code>
在示例1-1中,如果v非空,a行跟b行就沒有任何差別;如果v為空,b行一定會抛出一個std::out_of_range異常,至于a行的行為,标準未加任何說明。
有兩種途徑可以通路vector内的元素。其一,使用vector::at。該成員函數會進行下标越界檢查,確定目前vector中的确包含了需要的元素。試圖在一個目前隻包含10個元素的vector中通路第100個元素是毫無意義的,這樣做會導緻抛出一個std::out_of_range異常。
其二,我們也可以使用vector::operator[],c++98标準說vector::operator可以、但不一定要進行下标越界檢查。實際上,标準對operator[]是否需要進行下标越界檢查隻字未提,不過标準同樣也沒有說它是否應該帶有異正常格聲明。是以,标準庫實作方可以自由選擇是否為operator[]加上下标越界檢查功能。如果使用operator[]通路一個不在vector中的元素,你可就得自己承擔後果了,标準對這種情況下會發生什麼事情沒有做任何擔保(盡管你使用的标準庫實作的文檔可能做了某些保證)——你的程式可能會立即崩潰,對operator[]的調用也許會引發一個異常,甚至也可能看似無恙,不過會偶爾或神秘地出問題。
既然下标越界檢查幫助我們避免了許多常見問題,那為什麼标準不要求operator[]實施下标越界檢查呢?簡短的答案是效率。總是強制下标越界檢查會增加所有程式的性能開銷(雖然不大),即使有些程式根本不會越界通路。有一句名言反映了c++的這一精神:一般說來,不應該為不使用的東西付出代價(或開銷)。是以,标準并不強制operator[]進行越界檢查。況且我們還有另一個理由要求operator[]具有高效性:設計vector是用來替代内置數組的,是以其效率應該與内置數組一樣,内置數組在下标索引時是不進行越界檢查的。如果你需要下标越界檢查,可以使用at。
調整vector的大小
現在看示例1-2,該示例對vector進行了簡單操作。
考慮如下的代碼:
v[1] = 2;<code>`</code>
上面這些代碼中的問題都是比較明顯的,但可能是比較難于發現的明顯錯誤,因為它們很可能會在你所使用的标準庫實作上“勉強”能夠“正常運作”。
大小(size,跟resize相對應)跟容量(capacity,與reserve相對應)之間有着很大的差別。
size告訴你容器中目前實際有多少個元素,而對應地,resize則會在容器的尾部添加或删除一些元素,來調整容器當中實際的内容,使容器達到指定大小。這兩個函數對list、vector和deque都适用,但對其他容器并不适用。
capacity則告訴你最少添加多少個元素才會導緻容器重配置設定記憶體,而reserve在必要的時候總是會使容器的内部緩沖區擴充至一個更大的容量,以確定至少能滿足你所指出的空間大小。這兩個函數僅對vector适用。
本例中我們使用的是v.reserve(2),是以我們知道v.capacity()>=2,這沒有問題,但值得注意的是,我們實際上并沒有向v當中添加任何元素,因而v仍然是空的!v.reserve(2)隻是確定v當中有空間能夠放得下兩個或更多的元素而已。
準則
記住size/resize以及capacity/reserve之間的差別。
我們隻可以使用operator(或at())去改動那些确實存在于容器中的元素,這就意味着它們是跟容器的大小息息相關的。首先你可能想知道為什麼operator[]不能更智能一點,比如當指定地點的元素不存在的時候“聰明地”往那裡塞一個元素,但問題是假設我們允許operator以這種方式工作,就可以建立一個有“漏洞”的vector了!例如,考慮如下的代碼:
//……這裡v[0]至v[98]的值是什麼呢
正是因為标準并不強制要求operator進行區間檢查,是以在大多數實作上,v[0]都會簡單地傳回内部緩沖區中用于存放但尚未存放第一個元素的那塊空間的引用。是以v[0]=1;這行語句很可能被認為是正确的,因為如果接下來輸出v0的話,或許會發現結果确實是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),它們的作用是向容器的尾部追加元素,而使用它們總是安全的。
template
outputiterator copy(const container& c, outputiterator result) {
return std::copy(c.begin(), c.end(), result);
}<code>`</code>`
這裡,我們隻需簡單地包裝std::copy(),讓它對整個容器進行操作,此外由于我們是以const&來接受容器參數的,因而疊代器自然就是const_iterator了。
確定const正确性。特别是不對容器内的元素做任何改動的時候,記得使用const_iterator。
盡量使用!=而不是<來比較兩個疊代器。
養成預設情況下使用字首形式的--和++的習慣,除非你的确需要用到原來的值。
實施複用:盡量複用已有的算法,特别是标準庫算法(例如for_each()),而不是手寫循環。
接下來我們遇到下面這行代碼:
當程式執行這一行的時候,可能會列印出1。這是因為前面的程式以錯誤的方式改寫了v[0]所引用的那塊記憶體,隻不過,這行代碼也許并不會導緻程式立即崩潰,真遺憾!
`
assert(v.capacity() == 100);`
同樣,這裡的斷言表達式當中應該使用>=,而且和前面一樣,這也是多餘的。
<code>cout << v[0];</code>
很奇怪!這次的輸出結果可能為0,我們剛剛指派的1神秘失蹤了!
為什麼?我們假設reserve(100)确實引發了一次内部緩沖區的重配置設定(即如果第一次reserve(2)并沒有使内部緩沖區擴大到100或更多的話),這時v就隻會将它确實擁有的那些元素複制到“新家”當中,而問題是實際上v認為它内部空空如也(是以不複制任何元素)!另一方面,新配置設定的内部緩沖區最初值可能為0(嚴格講不确定),是以就出現了上述情況。
v.at(2) = 3;
v.at(3) = 4;
v.at(99) = 100;<code>`</code>
那麼問題就會變得明朗了,因為第一個調用語句就會抛出一個out_of_range異常。