Effective STL Item 43:優先使用STL泛型演算法以取代手寫迴圈 (轉)

worldblog發表於2007-12-13
Effective STL Item 43:優先使用STL泛型演算法以取代手寫迴圈 (轉)[@more@]

STL泛型演算法vs.手寫的迴圈:namespace prefix = o ns = "urn:schemas--com::office" />

tt Meyers

準備進行?別那麼急。Scott正試圖讓你相信庫比你自己寫的更好。

-------------------------------------------------------------------------------

 [這篇文章源自一本即將出版的書。S. Meyers,Effective STL:50 Specific Ways to Improve Your Use of the Standard Template Library,改自Item 26-28(WQ注,CUJ上原文如此,應為Item 43)。 2001 Addison-Wesley。 發行:pession of Pearson Education, Inc]

 

每個泛型演算法接受至少一對選擇子,以指示將被操作的元素區間。比如,min_element()尋找出此區間中的最小的值,而accumulate()則對區間內的元素作某種形式的整體求和運算,partition()將區間內的元素分割為滿足和不滿足某判決條件的兩個部分。當泛型演算法被時,它們必須檢查指示給它的區間中的每個元素,並且是按你所期望的方式進行的:從區間的起始點循還到結束點。有一些泛型演算法,比如find()和find_if(),可能在遍歷完成前就返回了,但即使是這些泛型演算法,內部都有著一個迴圈。畢竟,即使是find()和find_if()也必須在檢視過了每個元素後,才能斷定它們所尋找的元素不在此區間內。

所以,泛型演算法內部是一個迴圈。此外,STL泛型演算法涉及面廣泛,這意味著很多你本來要用迴圈來實現的任務,現在可以改用泛型演算法實現了。比如,有一個Widget類,它支援redraw()。

class Widget {

public:

  ...

  void redraw() const;

  ...

};

並且,你想redraw一個list中的所有Widget,你可能會使用這樣一個迴圈:

list lw;

...

for (list::iterator i =

  lw.begin();

  i != lw.end(); ++i) {

  i->redraw();

}

但是你也可以用for_each()泛型演算法:

for_each(lw.begin(), lw.end(),

  mem_fun_ref(&Widget::redraw));

對許多C++員而言,使用迴圈比泛型演算法的想法自然多了,並且讀解迴圈比弄明白mem_fun_ref和取Widget::redraw的地址要舒服多了。但是,這篇文章將說明呼叫泛型演算法更可取。事實上,這篇文章將證明呼叫泛型演算法通常比手寫的迴圈更優越。為什麼?

有三個理由:

:泛型演算法通常比迴圈高效。

正確性: 寫迴圈時比呼叫泛型演算法更容易產生錯誤。

可維護性: 與相應的顯式迴圈相比,泛型演算法通常使程式碼更乾淨、更直觀。

文章的以後部分將予以例證。

從效率方面看,泛型演算法在3個方面打敗了顯式迴圈,兩個主要因素,一個次要因素。次要因素是消除了多餘的計算。回頭看一下我們剛才寫的迴圈:

for (list::iterator i =

  lw.begin();

  i != lw.end();

  ++i) {

  i->redraw();

}

我已經加亮了迴圈終止測試語句,以強調每次迴圈,i都要與lw.end()作檢查。也就是說,每次的迴圈,都要呼叫函式list::end()。但我們不需要呼叫end()一次以上的,因為我們不準備修改這個list,對end()呼叫一次就夠了。而我們轉過來看一下泛型演算法,就可以看到只對end()函式作了正確的求值次數:

// this call evaluates lw.end() exactly

// once

for_each(lw.begin(), lw.end(),

  mem_fun_ref(&Widget::redraw));

憑心而論,STL的實現者知道begin()和end()(以及類似的函式,比如size())用得很頻繁,所以儘可能地實現得最高效。幾乎肯定會inline它們,並編碼得絕大部分都能避免重複計算(透過將計算結果外提(這種最佳化手段))。然而,表明,這不是總能成功的,而且當不成功時,對重複計算的避免足以讓泛型演算法比手寫的迴圈具有優勢。

但這只是影響效能的次要因素。第一個主要影響因素是:庫的實現者可以利用他們知道容器的具體實現的優勢,用庫的使用者無法採用的方式來最佳化程式碼。比如,在deque中的元素通常在(內部的)一個或多個固定大小的陣列上。基於指標的遍歷比基於選擇子的遍歷更快,但只有庫的實現者可以使用基於指標的遍歷,因為只有他們知道內部陣列的大小以及如何從一個陣列移向下一個。有一些STL容器和泛型演算法的實現版本特別考慮了它們的deque的內部資料結構,而且已經知道,這樣的實現比“通常”的實現快20%。

第二個主要因素是,除了最微不足道的演算法,所有的STL泛型演算法使用的數學演算法都比一般的C++程式設計師能拿得出來的演算法更復雜,--有時會複雜得多得多。不可能超越sort()及其同族泛型演算法的(比如,stable_sort(),nth_element()等);適用於已序區間的搜尋演算法(比如,binary_search(),lower_bound() 等)相當完美;就算是很平凡的任務,比如從vector、deque或陣列中銷燬元素,使用erase-remove慣用法都比絕大多數程式設計師寫的迴圈更高效。

如果效率的因素說服不了你,也許你更願意接受基於正確性的考慮。寫迴圈時,比較麻煩的事在於確保所使用的選擇子(a)有效,並且(b)指向你所期望的地方。舉例來說,假設有一個陣列,你想獲得其中的每一個元素,在上面加41,然後將結果從前端插入一個deque。用迴圈,你可能這樣寫:

// C : this function takes a pointer

// to an array of at most arraySize

// doubles and writes data to it. It

// returns the number of doubles written.

size_t fillArray(double *pArray, size_t arraySize);

// create local array of max possible size

double data[maxNumDoubles];

// create deque, put data into it

deque d;

...

// get array data from API

size_t numDoubles =

  fillArray(data, maxNumDoubles);

// for each i in data, insert data[i]+41

// at the front of d; this code has a !

for (size_t i = 0; i < numDoubles; ++i) {

  d.insert(d.begin(), data[i] + 41);

}

這可以執行,只要你能滿意於插入的元素是反序的。因為每次的插入點是d.begin(),最後一個被插入的元素將位於deque的前端!

如果這不是你想要的(還是承認吧,它肯定不是你想要的),你可能想這樣修改:

// remember d’s begin iterator

deque::iterator insertLocation = d.begin();

// insert data[i]+41 at insertLocation, then

// increment insertLocation; this code is also buggy!

for (size_t i = 0; i < numDoubles; ++i) {

  d.insert(insertLocation++, data[i] + 41);

}

看起來象雙贏,它不只是累加了指示插入位置的選擇子,還避免了每次對begin()的呼叫(這消除了影響效率的次要因素)。唉,這種方法陷入了另外一個的問題中:它導致了“未定義”的結果。每次呼叫deque::insert(),都將導致所有指向deque內部的選擇子無效,包括上面的insertLocation。在第一次呼叫insert()後,insertLocation就變得無效了,後面的迴圈可以產生任何行為(are allowed to head straight to looneyland)。

注意到這個問題後,你可能會這樣做:

deque::iterator insertLocation =

  d.begin();

// update insertLocation each time

// insert is called to keep the iterator valid,

// then increment it

for (size_t i = 0; i < numDoubles; ++i) {

  insertLocation =

  d.insert(insertLocation, data[i] + 41);

  ++insertLocation;

}

這樣的程式碼確實完成了你相要的功能,但回想一下費了多大勁才達到這一步!和呼叫泛型演算法tranorm()對比一下:

// copy all elements from data to the

// front of d, adding 41 to each

transform(data, data + numDoubles,

  inserter(d, d.begin()),

  bind2nd(plus(), 41));

這個“bind2nd(plus(), 41)”可能會花上一些時間才能看明白(尤其是如果不常用STL的bind族的話),但是與選擇子相關的唯有煩擾就是指出源區間的起始點和結束點(而這從不會成為問題),並確保在目的區間的起始點上使用inserter。實際經驗表明,為源區間和目的區間指出正確的初始選擇子通常都很容易,至少比確保迴圈體沒有於無意中將需要持續使用的選擇子變得無效要容易得多。

因為在使用選擇子前,必須時刻關注它們是否被不正確地操縱或變得無效,難以正確實現迴圈的情況太多了,這個例子只是比較有代表性。假設使用無效的選擇子會導致“未定義”的行為,又假設“未定義”的行為在開發和測試期間 has a nasty habit of failing to show itself ,為什麼要冒不必要的危險?將選擇子扔給泛型演算法,讓它們去考慮操縱選擇子時的各種詭異行為吧。

我已經解釋了泛型演算法為什麼可以比手寫的迴圈更高效,也描述了為什麼迴圈將艱難地穿行於與選擇子相關的荊棘叢中,而泛型演算法正避免了這一點。運氣好的話,你現在已是一個泛型演算法的信徒了。然而運氣是不足信的,在我休息前,我想更確保些。因此,讓我們繼續行進到程式碼清晰性的議題。最後,最好是那些最清晰的軟體、最好懂的軟體、能最被樂意於增強、維護和適用於新的環境的軟體。雖然習慣於迴圈,但泛型演算法在這個長期的競爭中具有優勢。

關鍵在於具名詞彙的力量。在STL中約有70個泛型演算法的名字,總共超過100個不同的函式模板(每個過載都算一個)。每個泛型演算法都完成一些精心定義的任務,而且有理由認為專業的C++程式設計師知道(或應該去看一下)每個泛型演算法都完成了什麼。因此,當程式設計師呼叫transform()時,他們認為對區間內的每個元素都施加了某個函式,而結果將被寫到另外一個地方。當程式設計師呼叫replace_if()時,他(她)知道區間內滿足判定條件的物件都將被修改。當呼叫partition()時,他(她)明白所有滿足判定條件的物件將被聚集在一起。STL泛型演算法的名字傳達了大量的語義資訊,這使得它們比隨意的迴圈清晰多了。

明擺著,泛型演算法的名字暗示了其功能。“for”、“while”和“do”卻做不到這一點。事實上,這一點對標準C語言或C++語言執行庫的所有部件都成立。毫無疑問地,你能自己實現strlen(), memset()或bsearch(),但你不會這麼做。為什麼不會?因為(1)已經有人幫你實現了它們,因此沒必要你自己再做一遍;(2) 名字是標準的,因此,每個人都知道它們做什麼用的;和(3)你猜測程式庫的實現者知道一些你不知道的關於效率方面的技巧,因此你不願意錯過熟練的程式庫實現者可能能提供的最佳化。正如你不會去寫strlen()等函式的自己的版本,同樣沒道理用迴圈來實現出已存在的STL泛型演算法的等價版本。

我很希望故事就此結束,因為我認為這個收尾很有說服力的。唉,好事多磨(this is a tale that refuses to go gentle into that good night)。 泛型演算法的名字比光溜溜的迴圈有意義多了,這是事實,但使用迴圈更能讓人明白加諸於選擇子上的操作。舉例來說,假設想要找出vector中第一個比x大又比y小的元素。這是使用迴圈的實現:

vector v;

int x, y;

...

// iterate from v.begin() until an

// appropriate value is found or

// v.end() is reached

vector::iterator i = v.begin();

for( ; i != v.end(); ++i) {

  if (*i > x && *i < y) break;

}

// i now points to the value

// or is the same as v.end()

將同樣的邏輯傳給find_if()是可能的,但是需要使用一個非標的functor,比如SGI的compose2[注1]:

// find the first value val where the

// "and" of val > x and val < y is true

vector iterator i =

  find_if(v.begin(), v.end(),

  compose2(logical_and(),

  bind2nd(greater(), x),

  bind2nd(less(), y)));

即使沒使用非標的元件,許多程式設計師也會反對說它遠不及迴圈清晰,我也不得不同意這個觀點。

find_if()的呼叫可以不顯得那麼複雜,只要將測試的邏輯封裝入一個獨立的functor(也就是申明瞭operator()成員函式的類):

template

class BetweenValues:

  public std::unary_function {

public:

  // have the ctor save the

  // values to be between

  BetweenValues(const T& lowValue,

  const T& highValue) 

  : lowVal(lowValue), highVal(highValue)

  {}

  // return whether val is

  // between the saved values

  bool operator()(const T& val) const

  {

  return val > lowVal && val < highVal;

  }

private:

  T lowVal;

  T highVal;

};

...

vector iterator i =

  find_if(v.begin(), v.end(),

  BetweenValues(x, y));

但這種方法有它自己的缺陷。首先,建立BetweenValues模板比寫迴圈體要多出很多工作。就光數一下行數。 迴圈體:1行;BetweenValues模板:24行。太不成比例了。其次,find_if()正在找尋是什麼的細節被從呼叫上完全割裂出去了,要想真的明白對find_if() 的這個呼叫,還必須檢視BetweenValues的定義,但BetweenValues 一定被定義在呼叫find_if()的函式之外。如果試圖將BetweenValues申明在這個函式內部,就像這樣,

// beginning of function

{

  ...

  template

  class BetweenValues:

  public std::unary_function { ... };

  vector::iterator i =

  find_if(v.begin(), v.end(),

  BetweenValues(x, y));

  ...

}

// end of function

你會發現編譯不透過,因為模板不能申明在函式內部。如果試圖用類代替模板而避開這個問題,

// beginning of function

{

  ...

  class BetweenValues:

  public std::unary_function { ... };

  vector iterator i =

  find_if(v.begin(), v.end(),

  BetweenValues(x, y));

  ...

}

// end of function

你會發現仍然運氣不佳,因為定義在函式內部的類是個區域性類,而區域性類不能繫結在模板的型別引數上(比如find_if()所需要的functor 型別)。很失望吧,functor類和functor類不能被定義在函式內部,不管它實現起來有多方便。

在泛型函式與手寫迴圈的長久較量中,關於程式碼清晰度的底線是:這完全取決於你想在迴圈裡做的是什麼。如果你要做的是泛型演算法已經提供了的,或者非常接近於它提供的,呼叫泛型演算法更清晰。如果迴圈裡要做的事非常簡單,但呼叫泛型演算法時卻要使用bind族和adapter或者獨立的functor類,你恐怕還是寫迴圈比較好。最後,如果你在迴圈裡做的事相當長或相當複雜,天平再次傾向於泛型演算法。長的、複雜的通常總應該封裝入獨立的函式。只要將迴圈體一封裝入獨立函式,你幾乎總能找到方法將這個函式傳給一個泛型演算法(通常是 for_each()),以使得最終程式碼直截了當。

如果你同意呼叫泛型演算法通常優於手寫迴圈這個主題,並且,如果你也同意作用於某個區間的成員函式優於迴圈呼叫作用於單元素的成員函式[注2],一個有趣的結論出現了:使用STL容器的C++精緻程式中的迴圈比不使用STL的等價程式少多了。這是好事。只要能用高層次的術語(如insert()、find()和for_each())取代了低層次的詞彙(如for、while和do),我們就提升了軟體的抽象層次,並因此使得它更容易實現、文件化、增強,和維護。

 

注和參考

[1] To learn more about compose2, consult the SGI STL site (

[2] Range member functions are container member functions such as insert, erase, and assign that take two iterators specifying a range to e.g., insert, erase, or assign. A single call to a range member is generally much more efficient than a hand-written l that does the same thing. For details, consult Item 5 of Effective STL.


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/10752043/viewspace-992976/,如需轉載,請註明出處,否則將追究法律責任。

相關文章