CUJ:標準庫:容納指標的容器 (轉)

amyz發表於2007-10-31
CUJ:標準庫:容納指標的容器 (轉)[@more@]

The Standard Librarian: I/O and Function s:
Containers of Pointers:namespace prefix = o ns = "urn:schemas--com::office" />

Matthew Austern

http://www.cuj.com/experts/1910/austern.htm?topic=experts

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

  和標準C++執行庫中的絕大部分東西一樣,標準容器類是用型別來引數化的:你能建立一個std::vector來容納int型別的,建立一個std::vector<:string>來容納string物件,建立一個std::vector來容納自定義型別的物件。建立std::vector、std::vector<:string>或std::vector也是完全合理的。容納指標的容器很重要也很常見。

  不幸地是,雖然是常見技術,容納指標的容器對新手來說也是造成混亂的最常見的根源之一。幾乎沒有哪個星期在C++新聞組中不出現這樣的貼子的:為什麼這樣的程式碼導致洩漏:

{

 std::vector v;

 for (int i = 0; i < N; ++i)

  v.insert(new my_type(i));

 ...

} // v is destroyed here

  這個記憶體洩漏是的嗎?std::vector的析構不是會銷燬v的元素的嗎?

  如果你仔細想過std::vector大體上是如何工作的,並且你瞭解其中對指標並沒有特別的規則的話--也就是說,對vector來說,my_type *只不過是另外一個T--就不難明白為什麼vector有這樣的行為以及為什麼這段程式碼有記憶體洩漏了。然而,vector的行為可能會令對舊的容器庫更熟悉的人感到驚訝的。

  這篇文章解釋了容納指標的容器的行為是怎麼樣的,什麼時候容納指標的容器會有用,和在需要比標準容器在記憶體管理上的預設行更多的任務時,該做些什麼。

容器和所有權

標準容器使用值語義。舉例來說,當你向一個vector附加一個變數x時:

v.push_back(x)

你實際正在做的是附加x的一個複製。這個語句了x的值(的一個複製),而不是x的地址。在你將x加入一個vector後,你能對x做如何想做的事--比如賦給它一個新值或讓它離開生存域而銷燬--而不影響vector中的複製。一個容器中的元素不能是另外一個容器的元素(兩個容器的元素必須是不同的物件,即使這些元素碰巧相等),並且將一個元素從容器中移除將會銷燬這個元素(雖然具有相同的值的另外一個物件可能存在於別處)。最後,容器“擁有”它的元素:當一個容器銷燬時,其中的所以元素都隨它一起銷燬了。

  這些特性與平常的內建陣列很相似,並且可能是太明顯了而不值一提。我列出它們以清楚顯示容器和陣列有多麼相似。新手使用標準容器時發生的最常見的概念是認為容器“在幕後”做了比實際上更多的事。

  值語義不總是你所需要的:有時你需要在容器中儲存物件的地址而不是複製物件的值。你能以和陣列相同的方式,用容器實現引用語義:藉由顯式要求。你能將任何型別的物件放入容器[注1],而指標自己就是非常好的物件。指標佔用記憶體;能被賦值;自己有地址;有能被複製的值。如果你需要儲存物件的地址,就使用容納指標的容器。不再是寫:

std::vector v;

my_type x;

...

v.push_back(x);

你能寫:

std::vector v;

my_type x;

...

v.push_back(&x);

  感覺上,沒有任何變化。你仍然正在建立一個std::vector;只不過現在T碰巧是一個指標型別,my_type *。vector仍然“擁有”它的元素,但你必須明白這些元素是什麼:它們是指標,而不是指標所指向的東西。

  擁有指標和擁有指標所指的東西之間的區別就象是vector與陣列或區域性變數。假如你寫:

{

 my_type* p = new my_type;

}

  當離開程式碼域時,指標p將會消失,但它所指向的物件,*p,不會消失。如果你想銷燬這物件並釋放其記憶體,你需要自己來完成,顯式地寫delete p或用其它等價的方法。 同樣,在std::vector中沒有任何特殊程式碼以遍歷整個vector並對每個元素delete。元素在vector消失時消失。如果你想在那些元素銷燬前發生另外一些事,你必須自己做。

  你可能奇怪為什麼std::vector和其它標準容器沒有設計得對指標做些特別的動作。首先,當然,有一個簡單的一致性因素:理解有一致語義的庫比理解有許多特例的庫容易。如果存在特例,很難劃出分界線。你將iterator或使用者自定義的handle型別等同於指標嗎?如果在通用規則上對vector有一個例外,應該對vector再有一個例外的例外嗎?容器如何知道什麼時候用delete p,什麼時候用delete [] p?

  第二,並且更重要的是:如果std::vector確實自動地擁有所指向的物件,std::vector的用處就大為減少了。畢竟,如果你期望一個vector擁有一系列my_type的物件的話,你已經有vector了。vector是供你需要另外一些不同的東西時用的,在值語義和強所有權不合適時。當你擁有的物件被多個容器引用時,或物件能在同一容器出現多次時,或指標開始時並不指向有效物件時,你可以用容納指標的容器。(它們可能是NULL指標,指向原生記憶體的指標,或指向子物件的指標。)

  想像一個特別的例子:

l  你正在維護一個任務連結串列,某些任務當前是活動的,某些被掛起。你用一個std::list存放所有任務,用一個std::vector存放活動任務組成的任務子集。

l  你的有一個字串表:std::vector,每個元素p指向一個NULL結束的字元陣列。依賴於你如何設計你的字串表,你可能使用字串文字,或指向一個巨大的字元陣列內部--無論哪種方法,你都不能用一個迴圈遍歷vector,並對每個元素呼叫delete p。

l  你正在做I/O multiplexing,並且將一個std::vector<:istream>傳給一個函式。input stream是在別處開啟的,將於別處關閉,並且,也許其中之一就是&std::cin。

如果容納指標的容器多手多腳地delete了所指向的物件,上面的用法沒一個能成為可能。

擁有所指向的物件

如果你建立了一個容納指標的容器,原因通常應該是所指向的物件由別處建立和銷燬的。有沒有情況是有理由獲得一個容器,它擁有指標本身,還擁有所指向的物件?有的。我知道的唯一一個好的理由,但也是很重要的一個理由:多型。

  C++中的多型是和指標/引用語義繫結在一起的。假如,舉例來說,那個task不只是一個類,而且它是一個繼承體系的基類。如果p是一個task *,那麼p可能指向一個task物件或任何一個從task派生的類的物件。當你透過p呼叫task的一個虛擬函式,將會在執行期根據p所指向的實際型別呼叫相應的函式。

  不幸地是,將task作為多型的基類意味著你不能使用vector。容器中的物件是儲存的值;vector中的元素必須是一個task物件,而不能是派生類物件。(事實上,如果你遵從關於繼承體系的基類必須是抽象基類的忠告的話,那麼編譯器將不允許你建立task物件和vector物件。)

  物件導向的設計通常意味著在物件被建立到物件被銷燬之間,你透過指標或引用來訪問物件。如果你想擁有一組物件,除了容納指標的容器外,你幾乎沒有選擇[注2]。管理這樣的容器的最好的方法是什麼?

  如果你正使用容納指標的容器來擁有一組物件,關鍵是確保所有的物件都被銷燬。最明顯的解決方法,可能也是最常見的,是在銷燬容器前,遍歷它,併為每個元素呼叫delete語句。如果手寫這個迴圈太麻煩,很容易作一個包裝:

template

class my_vector : private std::vector

{

  typedef std::vector Base;

  public:

  using Base::iterator;

  using Base::begin;

  using Base::end;

  ...

  public:

  ~my_vector() {

  for (iterator i = begin(); i != end(); ++i)

  delete *i;

 }

};

  這個技巧能工作,但是它比看起來有更多的限制和要求。

  問題是,只改解構函式是不夠的。如果你有一個列出所有的正要被銷燬的物件的容器,那麼你最好確保只要指標離開了容器那個物件就要被銷燬,並且一個指標絕不在容器中出現兩次。當你用erase()或clear()移除指標時,必須要小心,但是你也需要小心容器的賦值和透過iterator的賦值:象v1 = v2,和v[n] = p這樣的操作是危險的。標準泛型演算法,有很多會執行透過iterator的賦值的,這是另外一個危險。你顯然不能使用std::copy()和std::replace()這樣的泛型演算法;稍微不太明顯地,你也不能使用std::remove()、std::remove_if(),和std::unique()[注3]。

  象my_vector這樣的包裝類能夠解決其中一些問題,但不是全部。很難看出如何阻止使用者以危險的方式使用賦值,除非你禁止所有的賦值--而那時,你所得到的就不怎麼象容器了。

  問題是每個元素都必須被單獨追蹤,所以,也許解決方法是包裝指標而不是包裝整個容器。

  標準執行庫定義了一個對指標包裝的類std::auto_ptr。一個auot_ptr物件儲存著一個T *型別的指標p,其建構函式delete由p所指的物件。看起來這正是我們所要找的:一個包裝類,其解構函式delete一個指標。自然會想到用vector >取代vector

  這是很自然的主意,但它是錯誤的。原因呢,再一次,是因為值語義。容器類假設它們能複製自己的元素。舉例來說,如果你有一個vector,那麼T型別的物件必須表現得和一個平常的數值一樣。如果t1是一個T型別的值,你最好能夠寫:

T t2(t1)

並且得到一個t1的複製t2。

  形式上,按C++標準中的說法,T要是Assignable的和CopyConstructible的。指標滿足這些要求--你能得到指標的一個複製--但auto_ptr不滿足。auto_ptr的賣點是它維護強所用權,所以不允許複製。有一個形式上是複製建構函式的東西,但auto_ptr的“複製建構函式”實際上並不進行複製。如果t1是一個 std::auto_ptr,並且你寫:

std::auto_ptr t2(t1)

然後t2將不是t1的一個複製。不是進行複製,而是發生了所有權轉移--t2得到了t1曾經有著的值,而t1被改成一個NULL指標。auto_ptr 的物件是脆弱的:你只不過看了它一下就能改變它的值。

  在某些實作上,當你試圖建立一個vector >的時候,會得到編譯期錯誤。這還是算你幸運;如果不幸運的話,事情看起來很好,直到執行期得到不可預知的行為。總之,標準容器類不能與複製建構函式不執行複製的型別合作。這也不是auto_ptr的設計目的,並且,標準[注4]甚至指出“用auto_ptr例項化標準執行庫中的容器會得到未定義的行為。”當你需要異常機制時,你應該使用auto_ptr以在退出程式碼空間時delete指標;auto_ptr是因模擬了自動變數而得名的。你不應該試圖在容器類中使用auto_ptr來管理指標;它不可行。

  取代auto_ptr,你應該使用一個不同的“智慧指標”,引用計數的指標類。帶引用計數的指標跟蹤多少個指標指向相同的物件。當你構造了一個引用計數指標的複製時,計數加1;當你銷燬一個引用計數指標時,計數減1。當計數變成0時,指標所指的物件被自動銷燬。

  寫一個引用計數的指標類不是特別困難,但也不是幾乎什麼都不用做;達到執行緒安全需要特別的技巧。幸運地是,使用引用計數並不意味著你需要寫一個自己的引用計數指標類;幾種這樣的類已經存在並可免費使用。比如,你能使用Boost的shared_ptr類[注5]。我期望 shared_ptr或其它類似的東西將成為C++標準的下個修訂版本的組成部分。

  當然,引用計數只是一種特別的垃圾回收。像所有形式的垃圾回收,它自動銷燬你不再需要的物件。引用計數指標的主要優勢是它們易於加入現有:share_ptr這樣的機制只是一個小的單獨的類,你能只在一個較大系統中某個部分中使用它。另一方面,引用計數是垃圾回收的一種最低效的形式(每個指標的賦值和複製都需要一些相關的複雜處理),最沒有柔性的形式(在兩個資料結構擁有互指指標時,你必須小心)。其它形式的垃圾回收在C++程式中工作得同樣好。特別地,Boehm conservative garbage collector[注6]是免費的、可移植的,並被很好測試過。

  如果你使用一個保守的垃圾回收器,你只需要將它連結入程式就可以了。你不需要使用任何特別的指標包裝類;只需要分配記憶體而不用關心delete它。特別地,如果你建立一個vector,你知道所指向的物件只要vector還存在就不會被delete掉(垃圾回收器絕不會破壞還有指標指向它們的物件),並且你也知道它們將在vector被銷燬後的某個時候delete掉(除非,程式的其它部份仍然引用它們)。

  垃圾回收的優勢--無論是引用計數還是保守的垃圾回收器,或其它方法--是它讓你將物件的生存期處理得完全不確定:你不須要掌握在某個特定時間程式的哪個部份引用了這個物件。另一方面,垃圾回收的缺點也正是這一點!有時你確實知道物件的生存期,或至少知道在程式的某個特定狀態結束後物件不應該繼續存在。舉例來說,你可能建立了一個複雜的分析樹;也許它填滿了多型物件,也許它是太複雜而無法單獨掌握每個節點,但是你能確定在分析完後就將不再需要它們中的任何一個。

 

 

從手工管理的vetor到vector >到保守的垃圾回收器,我們逐步放棄了vector擁有一組物件的觀點;垃圾回收的前提是物件的“所有權”是無關緊要的。在某種意義上,它透過扔掉“所有權”問題而解決了這個難題。

  如果你的程式確實有明確定義的狀態,那麼你可能有理由期望在某個狀態結束時銷燬一組物件。代替垃圾回收,另外一個可選方法是透過一個arena(WQ注:字典上為“競技場、舞臺”之意,譯不好,不譯)來分配物件:維護一個物件列表,以便能一次就銷燬所有物件。

  你可能想知道這個技術和前面提到的技術(遍歷容器併為每個元素呼叫destory())有多大差異。有實質性差異嗎?如果沒有,讓我花了這麼多時間的那些危險和限制,又怎麼說?

  差異很小,但很重要:arena儲存了一個物件集,目的是為了在以後delete它們,並且沒有其它目的。它可以以標準容器的形式實現,但是它不暴露容器介面。你在vector上遇到問題是因為你能移除元素,複製覆蓋元素,和運用泛型演算法。Arena是一個強所有權。Arena容器為每個它所管理的物件容納了一個並且只一個指標,並且它擁有所有這些物件;它不允許你移除、複製、覆蓋或用選擇子遍歷它容納的指標。要使用arena中的物件,你需要在別處儲存指向它們的指標--在容器中,在樹中,或在任何合適的資料結構中。使用權和所有權完全分離。

  arena是個通用的主意。arena可能簡單地是個容器,只要記住別使用不安全的成員函式,或者它可以是個包裝類以試圖執行得更安全。很多這樣的包裝類已經被寫出來了[注7]。Listing 1是一個arena類的簡化例子,使用了一個實現上的技巧以使得不必為每個指標型別使用一個不同的arena類。舉例來說,你可能寫:

arena a;

...

std::vector v;

v.push_back(a.create(3));

...

a.destroy_all();

  我們幾乎回到了起點:使用一個容納指標的vector,所指向的物件由別處擁有和管理。

總結

指標在C++程式中很常見,標準容器同樣如此;不用驚奇,它們的組合物,容納指標容器同樣很常見。

  新手最大的困難是容納指標的容器時的所有權問題:應該什麼時候delete所指向的物件?處理容納指標的容器的絕大部分技術都可以歸結到一個原則:如果你有一個容納指標的容器,所指向的物件應該由其它地方所擁有。

l  如果正在處理非多型物件集(型別為my_type),你應該將物件的值存入容器,比如list或deque。如果需要,你也以使用第二個容器以儲存指向那些物件的指標。

l  不要試圖將auto_ptr放入標準容器。

l  如果有一組多型物件,你需要用容納指標的容器來管理它們。(但是,那些指標可以被包裝在某種handle類或智慧指標類中。)當物件生存期不能預知時,或不重要時,最容易的方法是使用垃圾回收。垃圾回收的兩個最簡單的選擇是引用計數指標類和保守的垃圾回收器。對你來說哪個是最佳選擇取決於工具的可用性。

l  如果你有一組多型物件,並需要控制它們的生存期,最簡單的方法是使用arena。一個簡單的arena類例子展示於Listing 1。

Listing 1: A simple arena class

#include

 

class arena {

private:

  struct holder {

  virtual ~holder() { }

  };

 

  template

  struct obj_holder : public holder {

  T obj;

 

  template

  obj_holder(Arg1 arg1)

  : obj(arg1) { }

  };

 

  std::vector owned;

 

private:

  arena(const arena&);

  void operator=(const arena&);

 

public:

  arena() { }

  ~arena() {

  destroy_all();

  }

 

  template

  T* create(Arg1 arg1) {

  obj_holder* p = new obj_holder(arg1);

  owned.push_back(p);

  return &p->obj;

  }

 

  void destroy_all() {

  std::vector::size_type i = 0;

  while (i < owned.size()) {

  delete owned[i];

  ++i;

  }

  owned.clear();

  }

};

 

[1] Well, almost any: there are some restrictions — which will be discussed later — on the objects you put in a container. Most reasonable types confoto those restrictions; pointers certainly do.

[2] There is one sense in which you have a choice: you can simulate value semantics by hiding polymorphic pointers ins a non-polymorphic wrapper class. See, for example, James Coplien, Advanced C++ Programming Styles and Idioms (Addison-Wesley, 1991), for a discussion of this "envelope and letter" idiom. See also chapter 14 of Andrew Koenig and Barbara Moo's Accelerated C++ (Addison-Wesley, 2000), for a family of generic handle classes. However, while the envelope-and-letter idiom is useful, it is also fairly heavyweight and it will affect many ects of your design. Unless you have other reasons for using an envelope-and-letter design, it would be silly to turn to it just for the sake of container classes.

[3] See Harald Nowak, "A remove_if for vector," C/C++ Users Journal, July 2001, for an explanation of the problems with remove and remove_if, and for a technique that avoids them. However, this technique does not generalize to unique.

[4] ?0.4.5, paragraph 3.

[5] <>.

[6] Hans-J. Boehm, "A garbage collector for C and C++," <>.

[7] See, for example, Andrew Koenig, "Allocating C++ objects in clusters," Journal of Object-Oriented Programming, 6(3), 1993.

 

 


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

相關文章