CUJ:標準庫:容納不完全型別的容器 (轉)

worldblog發表於2007-12-14
CUJ:標準庫:容納不完全型別的容器 (轉)[@more@]

The Standard Librarian: Containers of Incomplete Types:namespace prefix = o ns = "urn:schemas--com::office" />

Matt Austern

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

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

在1997年,C++標準完成前夕,標準化委員會收到了一個詢問:用不完全的型別建立標準容器是可能的嗎?委員會花了好一會兒才理解這個問題。這樣的事情意謂著什麼,究竟為什麼會想這樣做?委員會最終解決了問題並給予了回答。(僅僅為了讓你不必跳到最後面,回答是“No”。)但問題本身比回答更令人感興趣:它指出了一個有用的,未經廣泛討論的技巧。標準執行庫並不直接支援這個技巧,但兩者可以共存。

不完全的型別

我們都用過不完全的型別,以很熟悉的向前申明的形式[注1]。如果你申明瞭一個類T,你甚至能在它被定義之前以受一定限制的方式使用它。(更確切地說,是在它的定義體結束之前:一個類在它的定義裡面還是不完全的)。你能使用T*型別的指標和T&型別的引用;你能寫簽名中包含這樣的指標或引用的;你甚至能申明一個T型別的extern的。你不能做的是:不能使用指標的運算,不能定義T型別的變數,不能寫new T,不能從T進行繼承,也不能使用sizeof()。

  這不是隨便列出來的。所有這些都是從這個事實推導的,如C++標準在一個腳註中指出的:“未完全定義的物件型別的大小和佈局是未知的。”如果一個型別是不完全的,你不能做任何需要知道它的大小或佈局的事情。你如何在不知道T型別的物件應該多大時建立這樣一個物件?宣告:

class T;

告訴T是一個clss或struct。(其反面,是說T是內建型別或其它class的別名。)這足以讓編譯器知道該如何處理T的指標或引用,但不足以建立一個T的物件:編譯器應該為這樣一個物件分配多少?它應該如何佈局其欄位?同樣地,指標標的運算是沒有意義的,因為p += 1這樣的要求編譯器知道p所指向的物件的大小。

  為什麼你想擁有指向一個只被前置申明瞭的型別的指標?經典的例子是(Kernighan和Ritchie[注2]稱為“自引用結構self-referential structures”):一個類包含一個指向它自己的指標,象連結串列或樹的節點。舉例來說:

struct int_list_node {

 int value;

 int_list_node* next;

};

  在類的定義體裡面,int_list_node自身是一個不完全型別:它一直是不完整的,直到編譯器看到了全部的定義體。如果next是int_list_node型別的,就了(一個類怎麼能包含它自己的一個例項?),但是指向int_list_node的指標或引用就沒問題。

  自然,自引用結構不是不完全型別的唯一用處。如果你的設計中含有指向對方例項的多個類時,或含有互為友元的緊耦合類時,這是必須的:

class int_list;

struct int_list_node;

  class int_list {

 friend class int_list_node;

 ...

};

struct int_list_node {

 friend class int_list;

 ...

};

  這個例子說明了不完全型別的一個重要的方面:在一個某處是不完全的型別可以在稍後被完成。在此處,int_list在它的前向申明出現後是不完全型別,但在它的完整定義體之後是一個完全型別。

  最後,前向申明可以被用作資料隱藏的一個技巧,以將實現與介面分離[注3]。你可以在標頭檔案中提供一個“不透明型別”my_class的前向申明,然後申明一個函式介面以提供所需要的操作。(你可以選擇在別處暴露完整的類定義體,或者也許完全不暴露)。自然地,標頭檔案中的函式的模樣有些限制。你可以寫:

my_class& clone(const my_class&);

但不可以寫:

int illegal_function(my_class);

或:

my_class illegal_function();

  你不能以值傳遞的方式傳入或返回不完全型別,就象不能定義一個不完全型別的變數一樣。當然,限制同樣加在成員函式上,就象加在獨立函式上的一樣。正如你不能寫上面的illegal_function(),你也不能寫:

struct illegal_class {

 my_class f();

};

不完全型別和模板

理解不完全型別對模板的影響,最好從這個法則出發:當你看到一個類别範本X的時候,設想它就是一個普通型別,並且每次看到T時,就用某些特定型別替代它。如果你用一個不完全型別來代替T並且得到了一個合法的類的話,那麼你就可以確定X可以用不完全型別例項化。於是,舉例來說,你能將我們上面看見的list_node類寫成一個模板:

template

struct list_node {

 T value;

 list_node* next;

};

 

 

不完全型別是list_node而不是T本身。你能將不完全型別定義為模板的實參?當然能!C++標準[注4]甚至明確地這麼說了。你不能用一個不完全型別例項化list_node(那是非法的;我們已經有了一個T型別的成員變數),但是那是因為list_node的特殊,而不是因為對模板有任何特別限制。這樣做沒有任何問題:

template

struct ptr_list_node {

 T* ptr_value;

 ptr_list_node* next;

};

class my_class;

ptr_list_node p;

  用類my_class例項化ptr_list_node是合法的,即使我們擁有的只是my_class的一個前向申明;擁有ptr_list_node型別的變數也是合法的。對這一點,在list_node或ptr_list_node這樣的模板與int_list_node這樣的普通類之間並沒有實質性的不同。

  然而,聯合使用前向申明和模板確實引入了在非模板類中沒有的新問題。

  比如,考慮這個類:

template

struct X {

 T f() {

  T tmp;

  return tmp;

 }

};

  這看起來非常象上面的illegal_class的例子。我們有一個成員函式f(),它返回一個T型別的值,並且我們有一個T型別的區域性變數。明顯,這是在T為不完全型別時不能做的,所以你可能認為如果只有my_class的前向申明的話,寫X是非法的。然而,實際上,這沒有錯。為什麼?

  這是一個技術問題,但很簡單:一個函式模板不會進行錯誤檢查(除了瑣細的語法錯誤),除非它被例項化,並且成員函式除非被使用否則不被例項化。X().f()是非法的(你以對不完全型別非法的方式使用了my_class),但只寫X是沒問題的;它不觸發任何會造成問題的東西的例項化。

  當然,這不是個非常令人感興趣的例子:X只具有一個無法使用的成員函式。然而,它確實提醒我們應該注意事物被被例項化的精巧位置。當我們混用模板和不完全型別時,有兩個關鍵點:不完全型別被完成的點 (也就是我們看到類的定義體而不是隻是前向申明的點),和例項化點。在兩者之間,有趣的事情可能發生。比如,你可能例項化X,然後定義my_class,只有在此之後才會例項化X::f()。

template

struct X {

 T f() {

  T tmp;

  return tmp;

 }

};

class my_class;

X x;

class my_class {

 ...

};

  你為什麼會期望這樣的定義鏈?這有一個重要的理由:它能讓你在my_class自己的定義體內部使用X。你能夠擁有一個X型別的成員變數,並且你甚至能從X進行繼承。這可能看起來是迴圈的,並且非常象my_class正在從它自身進行繼承,但它並不比int_list_node這樣擁有指向自己的指標的類更迴圈。鏈中的每一步都是合法的:X是按可以用不完全型別例項化的方式來寫的,而且我們當然能自由地在稍後定義完全型別。

  我們接近了現實中的事情。實踐中,你當然可能不必為前向申明煩惱:你可以立即定義my_class並在其中使用 X。(在類的定義體裡面,編譯器總是行動得好像它已經看到了正被定義的類的一個前向申明。) Barton和Nackman [注5]展示瞭如何對結構基類和策略基類使用這個技巧:

class ComplexFloat :

 public FieldCategory

{

 ...

};

  基類封裝了所有數學域模型共用的東西。基類和派生類是相互依賴的:FieldCategory需要從ComplexFloat獲得operator*=()這樣的函式,然後,它將pow()和repeat()之類的函式提供給ComplexFloat。

標準容器

我們已經偏離原來的問題了。我們已經談論了不完全型別與模板,但沒有提到標準容器。標準並沒有以“curiously recurring template pattern”的形式定義它們,而這個技巧正是以這種形式出現的[注6]。於是,不完全型別的容器是從哪來的呢?

  我們已經看到幾種形式的透過前置申明得到的近乎迴圈的東西,但還有一種我們還沒看到。int_list_node這樣的類含義一個指標指向另外一個int_list_node,但這不是非常具有柔性。首先,我們可能想擁有一個節點指向另外N個節點而不是隻一個。(許多應用包含樹狀結構,其一個節點可能有任意個子節點--比如,考慮一下XML。)其次,指標語義可能不很方便[注7](WQ注,標準STL容器總是值語義的,以避免麻煩的所有權與生命期問題)。明顯,我們不能定義一個類X,它包含一個X的物件陣列--就算我們可以,陣列也不能可變大小。但我們可能這樣代替嗎?

  struct tree_node {

 int value;

 std::vector children;

};

  從外部表現看,很像這個類的每個物件包含N個相同的其它例項。這是故意的:vector這樣的STL容器接近於內建的陣列。一個節點的第i個子成員就是n.children[i],並且因為子成員是tree_node物件而不僅是指標。我們能只用一行就複製整個子樹:

tree_node n2 = n1;

  不用擔心記憶體約定或顯式的深複製。它看起來是迴圈的,但迴圈的表象並不必然違法;正如我們已經看到的,不是看起來迴圈的東西都真的是迴圈的。所有必需的是:定義一個vector而T是一個不完全型別是可能的。

  當標準化委員會最初認識到這是一個未決問題時,tree_node的例子是我試的第一個測試。我不知道該期望什麼;我當然知道實現這個特別的std::vector的版本的人(我)從沒想過這樣的可能性。令我吃驚的是,它能工作!立即,我們開始考慮更可能發生的程式--比如,Greg Colvin,用於實現有限元狀態機,其每個state物件包含一個std::map

struct state {

 int value;

 std::map next;

};

  唉,狀態機是第一個表明這個問題不象我們所期望得那樣簡單的徵兆。這個狀態機編譯失敗,並且,在片刻的思考之後,我們認識到不該費心嘗試的--應該顯然任何類似的東西都不能工作的。然後,隨著更多的測試,我們發現,即使是tree_node這樣的例子都不能工作在所有STL實作上。最終,怎麼看都是太暗淡太難以接受;標準化委員會認為沒有任何其它選擇,除了說STL容器不能與不完全型別合作。額外地,我們也申請了對標準執行庫的其餘部分也禁止這麼做。在T或Char還沒被定義時,擁有std::complex或std::basic_istream有意義嗎?幾乎肯定沒有意義。

  C++標準[注8]說不允許用一個不完全型別例項化標準執行庫中的模板:“後果未知……如果在例項化模板元件時不完全型別被作為實參(WQ注,附原文:the effects are undefined ... if an incomplete type is used as a template argument when instantiating a template component)”。某些實作允許在某些情況下這麼做,但那只是意外。(記住,“未定義行為”含蓋了所有可能--包括它可能如你所期望的那樣工作。)

  回顧一下,在那個技術已經被更好地理解之後,那一個決定仍然看起來是基本正確的。是的,在某些情況下是可能實作一些標準容器以能用不完全型別例項化的--但同樣很清楚,在其它情況下這樣做很難或不可能。完全是運氣,我們所嘗試的第一個測試,使用std::vector,碰巧是容易的情況之一。

  很容易明白,定義std::map而K或V是不完全型別,是相當無希望的。畢竟,std::map的value_type(在容器中的物件的型別)是std::pair。而pair有一個T1型別的成員變數和一個T2型別的成員變數。你不能擁有不完全型別的成員變數,而例項化map必然需要例項化pair

  其它標準容器怎麼說,比如list或set?在這兒,我們進入了實現細節;很難明確證明例項化std::list或std::set是不可能的。但很容易明白它為什麼不工作於這些容器的現有實作,並且允許它工作的實作為什麼絕不會直截了當。這些容器通常以節點的形式實現的;比如,set的節點看起來可能有點象這樣:

template

struct rb_tree_node {

 V value;

 rb_tree_node *parent, *left, *right;

 bool color;

};

  當然,問題是成員變數value:它意味著我們不能用不完全型別例項化rb_tree_node,於是也就意味著(如果set是按這種方式實現的,)我們不能用不完全型別例項化set。可能以其它方式實現set而繞過這個限制嗎?可能的。但,據我所知,還沒人嘗試過--恐怕以後也不會有人嘗試,因為繞開限制的可行方法會造成set變大或變慢或同時兩者。

  對於vector,還有其它方法。C++標準沒有規定vector應該如何實現,但對於這裡的情況,可行的實現是允許T為不完全型別的實現。對std::vector的直截了當寫法類似於這樣:

template

class vector {

 ...

private:

 Allocator a;

 T* buffer;

 typename Allocator::size_type buffer_size;

 typename Allocator::size_type buffer_capacity;

};

這其中沒有任何東西要求T是完全型別;一個前向申明就足夠了。並且沒有明顯的變更(從Allocator進行繼承,使用三個指標而不是一個指標加兩個整數,等等)會影響這一點。的確,當為本文再次測試tree_node時,它在我試過的前三個編譯器上透過了[注9]

總結

我們處在何處?迴圈的tree_node這樣的設計對某些用途是非常好的,但如我們已經看到的,我們不能擁有它:它被C++標準明確禁止。但是這並不必然意謂著標準程式庫對這個設計是沒有用處的。重要的主意是表面上迴圈的設計(類X包含一個作為成員變數的容器,而此容器的value_type是X):它是在一個類自包含時的次好方案。C++標準說你不被允許使用任何標準容器類,但標準容器不是唯一的選擇。C++標準定義了容器的介面,而不只是一組不相干的類,而任何滿足那個介面的容器類都與list和set這樣的預定義類同樣好地適合執行庫的架構。

  在C++的未來修訂版中,放鬆在用不完全型別例項化標準執行庫模板上的限制可能會有意義。很清楚,常規的禁令應該繼續存在--用不完全型別例項化模板是個麻煩的事情,而標準執行庫中太多的類對此沒有意義。但也許應該以個案方式被放鬆,而vector看起來正是這樣的特例的一個很好的候選者:它是一個標準容器類,而有很好理由用一個不完全型別例項化它,並且標準執行庫的實現者想讓它能工作。時至今日,事實上,實現者不得不故意禁止它!

[1] Actually, there are two other kinds of incomplete types: arrays whose size is unknown, and void, which behaves like an incomplete type that can't ever be completed. See ?.9, paragraph 6, of the C++ Standard. However, the most important kind of incomplete type is a class that has been declared but not yet defined; it's the only kind that I'll discuss.

[2] B. W. Kernighan and D. M. Ritchie. The C Programming Language, First Edition (Prentice-Hall, 1978). I meant it when I said that this was "the classic example"!

[3] This is a well-known technique for managing dependencies in large programs. See, for example, J. Lakos's Large Scale C++ Design (Addison-Wesley, 1996). One classic example of this technique is the familiar C stdio library.

[4] ?4.3.1, paragraph 2.

[5] J. J. Barton and L. R. Nackman. Scientific and Engineering C++ (Addison-Wesley, 1994.)

[6] J. O. Coplien. "A Curiously Recurring Template Pattern," February 1995, .

[7] See my column "The Standard Librarian: Containers of Pointers," C/C++ Users Journal Experts Forum, .

[8] ?7.4.3.6, paragraph 2; this is the part of the Standard that discusses general requirements that the standard library places on user components.

[9] The three compilers I tried were g++ 2.95, Microsoft Visual C++ 7.0, and Borland C++ 5.5.1. Why did these results differ from the ones I got four years ago? I suspect it's because of changes in the compilers, not in the library implementations; some older compilers failed to obey the rule that an unused member function of a class template shouldn't be instantiated.

 


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

相關文章