Guru of the Week 條款23:物件的生存期(第二部分) (轉)

worldblog發表於2008-01-05
Guru of the Week 條款23:物件的生存期(第二部分) (轉)[@more@]

GotW #23 Lifetimes – Part II:namespace prefix = o ns = "urn:schemas--com::office" />

著者:Herb Sutter

翻譯:CAT*G

[宣告]:本文內容取自網站上的Guru of the Week欄目,其著作權歸原著者本人所有。譯者CAT*G在未經原著者本人同意的情況下翻譯本文。本翻譯內容僅供自學和參考用,請所有閱讀過本文的人不要擅自轉載、傳播本翻譯內容;本翻譯內容的人請在閱讀瀏覽後,立即刪除其。譯者CAT*G對違反上述兩條原則的人不負任何責任。特此宣告。

Revision 1.0

 

Guru of the Week 條款23:的生存期(第二部分)

 

難度:6 / 10

 

(接著條款22,本期條款考慮一個經常被推薦使用的C++慣用法——它經常也是危險且錯誤的。)

 

[Problem]

[問題]

 

評述下面的慣用法(用常見的程式碼形式表達如下): 

  T& T::operator=( const T& other ) {


  if( this != &other ) {


  this->~T();


  new (this) T(other);


  }


  return *this;


  }


1.程式碼試圖達到什麼樣的合法目的?修正上述程式碼中所有的編碼缺陷。

 

2.假如修正了所有的缺陷,這種慣用法是的嗎?對你的回答做出解釋。如果其是不安全的,員又該如何達到預想的目標呢?

 

(參見GotW條款22,以及October 1997 C++ Report

 

[Solution]

[解答]

 

評述下面的慣用法(用常見的程式碼形式表達如下):

  T& T::operator=( const T& other ) {


  if( this != &other ) {


  this->~T();


  new (this) T(other);


  }


  return *this;


}


 


[Summary][1]

[小結][注1]

 

這個慣用法經常被推薦使用,且在C++標準草案中作為一個例子出現。[注2]但其卻具有不良的形式,而且——若要這麼形容的話——恰恰是有害無益。請不要這樣做。

 

1.程式碼試圖達到什麼樣的合法目的?

 

這個慣用法以複製構造(copy construction)操作來實現複製賦值(copy assignment)操作。這即是說,該方法試圖保證「T的複製構造與複製賦值實現的是相同的操作」,以避免程式設計師被迫在兩個地方不必要的重複相同的程式碼。

 

這是一個高尚的目標。無論如何,它使更為簡單,因為你不必把同一段程式碼編寫兩次,而且當T被改變(例如,給T增加了新的成員變數)的時候,你也不會像以前那樣在了其中一個之後忘記更新另一個。

 

假如虛擬基類擁有資料成員,那麼這個慣用法還是蠻有用的,因為若不使用此方法的話,資料成員在最好的情況下會被賦值數次,而在最壞的情況下則會被施以不正確的賦值操作。這聽起來頗佳,但實際上卻並無多大用處,因為虛擬基類其實是不應該擁有資料成員的。[注3] 另外,既然有虛擬基類,那便意味著該類是為了用於繼承而設計的——這又意味著:(正如我們即將看到的那樣)我們不能使用這個慣用法,原因是它太具危險性。

 

修正上述程式碼中所有的編碼缺陷。

 

上面的程式碼中包含一個可以修正的缺陷,以及若干個無法修正的缺陷。

 

[Problem #1: It Can Slice Objects]

[問題#1:它會切割物件]

 

如果T是一個帶有虛擬析構(virtual destructor)的基類,那麼”this->~T();”這一句就了錯誤的操作。如果是對一個派生類的物件執行這個的話,這一句的執行將會銷燬派生類的物件並用一個T物件替代。而這種結果幾乎將肯定破壞性的影響後續任何試圖使用這個物件的程式碼。(更多關於“切割(slicing)” 問題的討論,參見GotW 條款22。)

 

特別要指出的是,這種狀況將會使編寫派生類的編碼者們陷入人間地獄般的生活(另外還有其它一些關於派生類的潛在陷阱,見下面的敘述)。回想一下,派生的賦值運算子通常是基於基類的賦值操作編寫的: 

  Derived&


  Derived::operator=( const Derived& other ) {


  Base::operator=( other );


  // ...現在對派生成員進行賦值...


  return *this;


  }


這樣我們得到: 

  class U : /* ... */ T { /* ... */ }; 


  U& U::operator=( const U& other ) {


  T::operator=( other );


  // ...現在對U 成員進行賦值... 嗚呼呀


  return *this;  //嗚呼呀


  }


正如程式碼所示,對T::operator=()的呼叫一聲不響的對其後所有的程式碼(包括U成員的賦值操作以及返回語句)產生了破壞性的影響。如果U的解構函式沒有把它的資料成員重置為無效數值的話(譯註:即可以編譯執行透過),這裡將表現為一個神秘的、極難的執行期錯誤。

 

為了改正這個問題,可以呼叫"this->T::~T();"作為替代,這可以保證「對於一個派生類物件,只有其中的T subobject 被替換(而不是整個派生類物件被切割從而被錯誤的轉為一個T物件)」。這樣做只是用一個更為微妙的危險替換掉了一個明顯的危險,而這個替換方案仍然會影響派生類的編寫者(見下面的敘述)。

 

2.假如修正了所有的缺陷,這種慣用法是安全的嗎?

 

不,不安全。要注意:如果不放棄整個慣用法的話,下列任何一個問題都無法得到解決:

 

[Problem #2: It's Not Exception-Safe]

[問題#2:它不是異常安全的]

 

‘new’語句將會喚起T的複製建構函式。如果這個建構函式可以丟擲異常的話(其實許多甚至是絕大部分的類都會透過丟擲異常來報告建構函式的錯誤),那麼這個函式就不是異常安全的,因為其在建構函式丟擲異常時會導致「銷燬了原有物件而沒有用新的物件替換上去」的情形。

 

與切割(slicing)問題一樣,這個缺陷將會對後續的任何試圖使用這個物件的程式碼產生破壞性影響。更糟糕的是,這還可能導致「程式試圖將同一個物件銷燬兩次」的情況發生,因為外部的程式碼無法知曉這個物件的解構函式是否已經被執行過了。(參見GotW條款22中更多關於重複析構的討論。)

 

[Problem #3: It’s Inefficient for Assignment]

[問題#3:它使賦值操作變得低效]

 

這個慣用法是低效的,因為賦值過程中的構造操作幾乎總是涉及到比重置數值更多的工作。析構和重構在一起進行則更是增加了工作量。

 

[Problem #4: It Changes Normal Object Lifetimes]

[問題#4:它改變了正常的物件生存期]

 

這個慣用法破壞性的影響了那些依賴於正常的物件生存期之程式碼。特別是它破壞或干預了所有使用常見的“初始化就是資源獲取(initialization is re acquisition)”慣用法的類。

 

例如,若T在建構函式里獲取了一個互斥鎖(mutext lock)或者開啟了事務(database transaction),又在解構函式里釋放這個鎖或者事務處理,那會發生什麼呢?這個鎖或者事務處理將會以不正確的方式被釋放並在賦值操作中被重新獲得——這一般來說會破壞性的影響客戶程式碼(client code)和這個類本身。除了T和T的基類以外,如果T的派生類也依賴於T正常的生存期語義,它也會同樣破壞性的影響這些派生類。

 

有人會說,“我當然決不會對一個在建構函式和解構函式中獲取和釋放互斥量的類使用這個慣用法了!”回答很簡單:“真的嗎?你怎麼知道你使用的那些(直接或間接)的基類不這樣做呢?”坦白的說,你經常是無法知曉這個情況的,你也絕不應該依賴那些工作起來似乎正常但卻與物件生存期玩花招兒的基類。

 

這個慣用法的根本問題在於它攪濁了構造操作和析構操作的含義。構造操作和析構操作分別準確的對應於物件生存期的開始和結束,物件通常分別在這兩個時刻獲取和釋放資源。構造操作和析構操作不是用來改變物件值的操作(實際上它們壓根兒也不會改變物件的值,它們只是銷燬原來的物件並替換上一個看起來一樣、恰好擁有新數值的東西,其實這個新的東西與原來的物件根本就不是一回事兒)。

 

[Problem #5: It Can Still Break Derived Classes]

[問題#5:它可以對派生類產生破壞性影響]

 

用"this->T::~T();"作為替代語句解決了問題#1之後,這個慣用法僅僅替換掉派生類物件中的T subobject。許多派生類都可以如此正常工作,把它們的基類subobject換出換入,但有些派生類卻可能不行。

 

特別要指出的是,有些派生類可對其基類的狀態予以控制,如果在不知道此資訊的情況下對這些派生類的基類subobject進行盲目修改(以不可見的方式銷燬和重構一個物件當然也算作是一種修改),那麼這些派生類就可能導致產生失敗。一旦賦值操作做了任何超出「一個“正常寫入”型賦值運算子所應該做的操作」之額外操作,這個危險就會體現出來。

 

[Problem #6: It Relies on Unreliable Pointer Comparisons]

[問題#6:它依賴於不可靠的指標比較操作]

 

該慣用法完全依賴於"this != &other"測試。(如果你對此有疑問的話,請考慮自賦值的情形。)

 

其問題在於:這個測試並不保證你希望它保證的事情:C++標準保證「對指向同一個物件的多個指標的比較之結果必須是“相等(equal)” 」,但卻並不保證「對指向不同物件的多個指標的比較之結果必須是“不相等(unequal)”」。如果這種情況發生,那麼賦值操作就無法如願完成。(關於"this != &other"測試的內容,參見GotW條款11。)

 

如果有人認為這太鑽牛角尖了,請參看GotW條款11中的簡要論述:所有“必須”檢查自賦值(self-assignment)的複製賦值操作都不是異常安全的。[注4][注意:請看Exceptional C++及其勘誤表以得到更新的資訊。]

 

另外還有一些能夠影響客戶程式碼和/或派生類的潛在危險(諸如虛擬賦值運算子的情形——這即使是在最好的情況下也還是多少有些詭異的),但到目前為止已經有足夠多的內容用來演示該慣用法存在的嚴重問題了。

 

[So What Should We Do Instead?]

[那現在我們應該怎麼做呢]

 

如果其是不安全的,程式設計師又該如何達到預想的目標呢?

 

用同一個成員函式完成兩種複製操作(複製構造和複製賦值)的注意是很好的:這意味著我們只需在一個地方編寫和維護操作程式碼。本條款問題中的慣用法只不過是選擇了錯誤的函式來做這件事。如此而已。

 

其實,慣用法應該是反過來實現的:複製構造操作應該以複製賦值操作來實現,不是反過來實現。例如: 

  T::T( const T& other ) {


  /* T:: */ operator=( other );


  } 


  T& T::operator=( const T& other ) {


  // 真正的工作在這裡進行


  // (大概可以在異常安全的狀態下完成,但現在


  // 其可以丟擲異常,卻不會像原來那樣產生什麼不良影響


  return *this;


  }


這段程式碼擁有原慣用法的所有益處,卻不存在任何原慣用法中存在的問題。[注5] 為了程式碼的美觀,你或許還要編寫一個常見的私有輔助函式,利用其做真正的工作;但這也是一樣的,無甚區別: 

  T::T( const T& other ) {


  do_copy( other );


  } 


  T& T::operator=( const T& other ) {


  do_copy( other );


  return *this;


  } 


  T& T::do_copy( const T& other ) {


  // 真正的工作在這裡進行


  // (大概可以在異常安全的狀態下完成,但現在


  // 其可以丟擲異常,卻不會像原來那樣產生什麼不良影響


}


 


[Conclusion]

[結論]

 

原始的慣用法中充滿了缺陷,且經常是錯誤的,它使派生類的編寫者過上人間地獄般的生活。我時常禁不住想把這個原始的慣用法貼在辦公室的廚房裡,並註明:“有暴龍出沒。”

 

摘自GotW編碼標準:

 

-如果需要的話,請編寫一個私有函式來使複製操作和複製賦值共享程式碼;千萬不要利用「使用顯式的解構函式並且後跟一個placement new」的方法來達到「以複製構造操作實現複製賦值操作」這樣的目的,即使這個所謂的技巧會每隔三個月就在新聞組中出現幾次。(也就是說,決不要編寫如下的程式碼:) 

  T& T::operator=( const T& other )


  {


  if( this != &other)


  {


   this->~T();  // 有害!


  new (this) T( other );  // 有害!


  }


  return *this;


  }


 

[Notes]

 

[注1]:這裡我忽略一些變態的情形(例如,過載T::operator&(),使其做出返回this以外的事情)。GotW條款11提到一些有關情況。

 

[注2]:在C++標準草案中的那個例子意在演示物件生存期的規則,而不是要推薦一個好的現實用法(它不現實!)。下面給出草案3.8/7中的那個例子(處於空間的考慮做了微小的修改)以饗感興趣的讀者: 

  [例子:


  struct C {


  int i;


  void f();


  const C& operator=( const C& );


  };


  const C& C::operator=( const C& other)


  {


  if ( this != &other )


  {


  this->~C();  // '*this'的生存期結束


  new (this) C(other);


  // 新的C型別的物件被建立


  f();  // 此處定義良好


  }


  return *this;


  }


  C c1;


 C c2;


  c1 = c2; //此處定義良好


  c1.f();  //此處定義良好; c1指的是


  //  新的C型別的物件


  --例子 完]


並不推薦實際使用該程式碼的進一步的證據在於:C::operator=()返回了一個const C&而不單純是C&,這不必要的避免了這些物件在標準程式庫之容器(container)中的可移植用法。

 

摘自GotW編碼標準:

 

- 將複製賦值操作宣告為"T& T::operator=(const T&)"

- 不要返回const T&,儘管這樣做避免了諸如"(a=b)=c"的用法;這樣做意味著:你無法出於移植性的考慮而將T物件放入標準程式庫之容器——因為其需要賦值操作返回一個單純的T&(Cline95: 212; Murray93: 32-33)

 

[注3]:參見tt Meyers的《Effective C++》

 

[注4]:儘管你不能依賴於"this != &other"測試,但如果你為了透過處理排除已知的自賦值情形而這樣做,則並沒有錯。如果它起作用的話,你便可以省掉一個賦值操作。當然,如果它不起作用的話,你的賦值運算子應該仍然以「對於自賦值而言是安全的」之方式來編寫。關於使用這個測試作為最佳化手段,有人贊同也有人反對——但這超出了本期GotW的討論範圍。

 

[注5]:的確,它仍然需要一個預設的建構函式,並可能仍然不是最高效的;但要知道,你唯有利用初始化列表(initializer lists)才能得到最優的高效性(利用初始化列表即在構造過程中同時初始化成員變數,一氣呵成,而不是分為先構造,再賦值兩步來完成)。當然,這樣做又要犧牲程式碼的公用性(commonality),而對此的權衡也超出了本期GotW的討論範圍。

(完)


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

相關文章