程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> 關於C++ >> 泛型編程-轉移構造函數(Generic Programming: Move Constructor)

泛型編程-轉移構造函數(Generic Programming: Move Constructor)

編輯:關於C++

1 引言

我相信大家很了解,創建、復制和銷毀臨時對象是C++編譯器最愛的戶內運動。不幸的是,這些行為會降低C++程序的性能。確實,臨時對象通常被視為C++程序低效的第一因素[1]。

下面的代碼是正確的:

vector < string > ReadFile();
vector < string > vec = ReadFile();

或者

string s1, s2, s3;
//...
s1 = s2 + s3;

但是,如果關心效率,則需要限制類似代碼的使用。ReadFile()和operator+創建的臨時對象分別被復制然後再廢棄。這是一種浪費!

為了解決這個問題,需要一些不太優雅的約定。例如,可以按照引用傳遞函數參數:

void ReadFile(vector < string > & dest);
vector < string > dest;
ReadFile(dest);

這相當令人討厭。更糟的是,運算符沒有這個選擇,所以如果想高效的處理大對象,程序員必須限制創建臨時對象的運算符的使用:

string s1, s2, s3;
//...
s1 = s2;
s1 += s3;

這種難纏的手法通常減緩了設計大程序的大團隊的工作效率,這種強加的持續不斷的煩惱扼殺了編寫代碼的樂趣而且增加了代碼數量。難道從函數返回值,使用運算符傳遞臨時對象,這樣做是錯誤的嗎?

一個正式的基於語言的解決方案的提議已經遞交給了標准化委員會[2]。Usenet上早已引發了大討論,本文也因此在其中被反復討論過了。

本文展示了如何解決C++存在的不必要的復制問題的方法。沒有百分之百讓人滿意地解決方案,但是一個干淨的程度是可以達到的。讓我們一步一步的來創建一個強有力的框架,來幫助我們從程序中消除不需要的臨時對象的復制。這個解決方案不是百分之百透明的,但是它消除了所有的不需要的復制,而且封裝後足以提供一個可靠的替代品,直到多年以後,一個干淨的、基於語言的標准化的實現出現。

2 臨時對象和“轉移構造函數”(Move Constructor)

在和臨時對象斗爭了一段時間之後,我們意識到在大多數情況下,完全消除臨時對象是不切實際的。大多數時候,關鍵是消除臨時對象的復制而不是臨時對象本身。下面詳細的討論一下這個問題。

大多數具有昂貴的復制開銷的數據結構將它們的數據以指針或者句柄的形式儲存。典型的例子包括,字符串(String)類型儲存大小(size)和字符指針(char*),矩陣(Matrix)類型儲存一組整數維數和數據存儲區指針(double*),文件(File)類型儲存一個文件句柄(handle)。

如你所見,復制字符串、矩陣或者文件的開銷不是來自於復制實際的數據成員,而是來自於指針或者句柄指向的數據的復制。

因此,對於消除復制的目的來說,檢測臨時對象是一個好方法。殘酷點說就是,既然一個對象死定了,我們完全可以趁著它還新鮮,把它用作器官捐獻者。

順便說一下什麼是臨時對象?這裡給出一個非正式的定義:

當且僅當離開一段上下文(context)時在對象上執行的僅有的操作是析構函數時,一個對象被看成是臨時的。這裡上下文可能是一個表達式,也可能是一個語句范圍,例如函數體。

C++標准沒有定義臨時對象,但是它假定臨時對象是匿名的,例如函數的返回值。按照我們的更一般化的定義,在函數中定義的命名的棧分配的變量也是臨時的。稍後為了便於討論我們使用這個一般化的定義。

考慮這個String類的實現(僅作為示例):

class String
{
  char* data_;
  size_t length_;
public:
  ~String()
  {
    delete[] data_;
  }
  String(const String& rhs)
    : data_(new char[rhs.length_]), length_(rhs.length_)
  {
    std::copy(rhs.data_, rhs.data_ + length_, data_);
  }
  String& operator=(const String&);
  //...
};

這裡復制的成本主要由data_的復制組成,也就是分配新的內存並復制。如果可以探測到rhs實際上是臨時的就好了。考慮下面的C++偽代碼:

class String
{
  //...同前...
  String(temporary String& rhs)
    : data_(rhs.data_), length_(rhs.length_)
  {
    //復位源字符串使它可以被銷毀
    //因為臨時對象的析構函數仍然要執行
    rhs.data_ =0;
  }
  //...
}

這個我們虛構的重載構造函數String(temporary String&)在創建一個String臨時對象(按照前面的定義)時調用。然後,這個構造函數執行了一個rhs對象轉移的構造過程,只是簡單的復制指針而不是復制指針指向的內存塊。最後,“轉移構造函數”復位源指針rhs.data_(恢復為空指針)。使用這個方法,當臨時對象被銷毀時,delete[]會無害的應用在空指針上[譯注:C++保證刪除空指針是安全的]。

一個重要的細節是“轉移構造”後rhs.length_沒有被清0。按照教條主義的觀點,這是不正確的,因為data_==0而length_!=0,所以字符串被破壞了。但是,這裡有一個很站得住腳的理由,因為rhs的狀態沒有必要是完整的,只要它可以被安全而正確的銷毀就行了。這是因為會被應用在rhs上唯一一個操作就是析構函數,而不是其他的。所以只要rhs可以被安全的銷毀,而不用去看是否像一個合法的字符串。

“轉移構造函數”對於消除不需要的臨時對象復制是一個良好的解決方案。我們只有一個小問題,C++語言中沒有temporary關鍵字。

還應該注意到臨時對象的探測不會幫助所有的類。有時,所有的數據直接存儲在容器中。考慮:

class FixedMatrix
{
  double data_[256][256];
public:
  //...操作...
};

對這樣一個類,實際上復制成本在於逐字節的復制sizeof(FixedMatrix)個字節,而探測臨時對象並沒有幫助[譯注:因為數組不是指針,不能直接交換地址]。

3 過去的解決方案

不必要的復制是C++社區長期存在的問題。有兩個努力方向齊頭並進,其一是從編碼和庫編寫的角度,另一個是語言定義和編譯器編寫層面。

語言/編譯器觀點方面,有返回值優化(Return Value Optimization, RVO)。RVO被C++語言定義所允許[3][譯注:但是不是強制性的,而是實現定義的]。基本上,編譯器假定通過拷貝構造函數(Copy Constructor)復制返回值。

確切地說,基於這樣的假定,因此編譯器可以消除不必要的復制。例如,考慮:

vector< String > ReadFile()
{
  vector< String > result;
  //...填充result...
  return result;
}
vector< String > vec=ReadFile();

聰明的編譯器可以將vec的地址作為一個隱藏的參數傳遞給ReadFile而把result創建在那個地址上。所以上面的源代碼生成的代碼看起來像這樣:

void ReadFile(void* __dest)
{
  //使用placement new在dest地址創建vector
  vector< String >& result=
    *new(__dest) vector< String >;
  //...填充result...
}
//假設有合適的字節對齊
char __buf[sizeof(vector< String >)];
ReadFile(__buf);
vector< String >& vec=
  *reinterpret_cast < vector< String >* >(__buf);

RVO有不同的風格,但要旨是相同的:編譯器消除了一次拷貝構造函數的調用,通過簡單的在最終目的地上構造函數返回值。

不幸的是,RVO的實現不像看上那樣容易。考慮ReadFile稍稍修改後的版本:

vector< String > ReadFile()
{
  if (error) return vector< String >();
  if (anotherError)
  {
    vector< String > dumb;
    dumb.push_back("This file is in error.");
    return dumb;
  }
  vector< String > result;
  //...填充result...
  return result;
}

******************************************************

Wang Tianxing校注:

這個例子並不是很有說服力。裡面的三個對象的作用域互不相交,因此還是比較容易使用 RVO 的。難以運用RVO的是這種情況:

vector< String > ReadFile()
{
  vector< String > dumb;
  dumb.push_back( "This file is in error." );
  vector< String > result;
  // ... 填充 result ...
  return error ? dumb : result;
}

******************************************************

現在有不止一個局部變量需要被映射到最後的結果上,他們有好幾個。有些是命名的(dumb/result),而另一些是無名的臨時對象。無需多說,面對這樣的局面,大量優化器會投降並且服從保守的和缺乏效率的方法。

即使想寫不導致混淆RVO實現的“直線條”的代碼,也會因為聽到每個編譯器或者編譯器版本都有自己探測和應用RVO的規則而失望。一些RVO應用僅僅針對返回無名臨時對象的函數,這是最簡單的RVO形式。最復雜的RVO應用之一是函數返回值是一個命名的結果,叫做命名返回值優化(Named RVO或NRVO)。

本質上,寫程序時要指望可移植的RVO,就要依賴於你的代碼的精確寫法(在很難定義的“精確”意義下),依賴於月亮的圓缺,依賴於你的鞋的尺碼。

但是,別忙,還有很多種情況下RVO無法避免臨時對象的拷貝。編譯器時常不能應用RVO,即使它很想。考慮稍稍改變後的 ReadFile() 的調用:

vector vec;
vec=ReadFile();

這個改變看上去完全沒有惡意,但是卻導致了巨大的差異。現在不再調用拷貝構造函數而調用賦值運算符(assignment operator),這是令一個不同的脫缰野馬。除非編譯器優化技巧完全像是在使用魔法,現在真的可以和RVO吻別了:vector<T>::operator=(const vector<T>&)期望一個vector的常量引用,所以ReadFile會返回一個臨時對象,綁定到一個常量引用,復制到vec,然後被廢棄。不必要的臨時對象又來了!

在編碼方面,一個長期被推薦的技術是COW(按需復制,copy-on-write)[4],這是一個基於引用計數的技巧。

COW有幾個優點,其中之一是探測和消除了不必要的復制。例如,函數返回時,返回的對象的引用計數是1。然後復制的時候,引用計數增加到2。最後,銷毀臨時對象的時候,引用計數回到1,引用指向的目的地僅僅是數據的所有者。實際上沒有復制動作發生。

不幸的是,引用計數在多線程安全性方面有大量的缺陷,增加自己的開銷和大量隱藏的陷阱[4]。COW是如此之笨拙,因此,雖然它有很多優點,最近的STL實現都沒有為std::string使用引用計數,盡管實際上std::string的接口有目的設計為支持引用計數!

已經開發了幾個實現“不可復制”對象的辦法,auto_ptr是最精煉的一個。auto_ptr是容易正確使用的,但是不幸的是,剛好也容易不正確的使用。本文的討論的解決方法擴充了定義auto_ptr中使用的技術。

4 Mojo

Mojo(聯合對象轉移,Move of Joint Objects)是一項編碼技術,又是一個消除不必要的臨時對象復制的小框架。Mojo通過辨別臨時對象和合法的“非臨時”的對象而得以工作。

4.1 傳遞函數參數

Mojo引發了一個有趣的分析,即函數參數傳遞約定的調查。Mojo之前的一般建議是:

[規則1]如果函數試圖改變參數(也就是作為副作用),則把參數作為非常量對象的指針或者引用傳遞。例如:

void Transmogrify(Widget& toChange);
void Increment(int* pToBump);

[規則2]如果函數不修改它的參數而且參數是基本數據類型,則按照值傳遞參數。例如:

double Cube(double value);

[規則3]否則,參數是用戶自定義類型(或者模板的類型參數)而且一定不變,則作為常量引用傳遞參數。例如:

String& String::operator=(const String& rhs);
template< class T > vector< T >::push_back(const T&);

第三條規則試圖避免意外的大對象的復制。然而,有時第三條規則強制不必要的復制進行而不是阻止它的發生。考慮下面的Connect函數:

void Canonicalize(String& url);
void ResolveRedirections(String& url);
void Connect(const String& url)
{
  String finalUrl=url;
  Canonicalize(finalUrl);
  ResolveRedirections(finalUrl);
  //...使用finalUrl...
}

Connect函數獲得一個常量引用的參數,並快速的創建一個副本。然後進一步處理副本。

這個函數展示了一個影響效率的常量引用的參數使用。Connect的函數聲明暗示了:“我不需要一個副本,一個常量引用就足夠了”,而函數體實際上卻創建了一個副本。所以假如現在這樣寫:

String MakeUrl();
//...
Connect(MakeUrl());

可以預料MakeUrl()會返回一個臨時對象,他將被復制然後銷毀,也就是令人畏懼的不需要的復制模式。對一個優化復制的編譯器來說,不得不作非常困難的工作,其一是訪問Connect函數的定義(這對於分離編譯模塊來說很困難),其二是解析Connect函數的定義並進一步理解它,其三是改變Connect函數的行為以使臨時對象和finalUrl融合。

假如現在將Connect函數改寫如下:

void Connect(String url)  //注意按值傳遞
{
  Canonicalize(url);
  ResolveRedirections(url);
  //... 使用 url ...
}

從Connect的調用者的觀點來看,絕對沒有什麼區別:雖然改變了語法接口,但是語義接口仍然是相同的。對編譯器來說,語法的改變使所有事物都發生了改變。現在編譯器有更多的余地關心url臨時對象了。例如,在上面提到的例子中:

Connect(MakeUrl());

編譯器不一定要真的聰明到將MakeUrl返回的臨時對象和Connect函數需要的常量融合。如果那麼做,確實會更加困難。最終,MakeUrl的真正結果會被改變而且在Connect函數中使用。使用常量引用參數的版本會使編譯器窒息,阻止它實行任何優化,而使用傳值參數的版本和編譯器順暢的合作。

這個新版本的不利之處在於,現在調用Connect也許生成了更多的機器碼。考慮:

String someUrl=...;
Connect(someUrl);

在這種情況下,第一個版本簡單的傳遞someUrl的引用[譯注:從非常量到常量是標准轉型]。第二個版本會創建一個someUrl的副本,調用Connect,然後銷毀那個副本。隨著調用Connect的靜態數量的增長,代碼大小的開銷同時增長。另一方面,例如Connect(MakeUrl())這樣的調用會引入臨時對象,在第二個版本中又剛好生成更少的代碼。在多數情況下,大小差異好像不會導致問題產生[譯注:在某些小內存應用中則是一個問題,例如嵌入式應用環境]。

所以我們給出了一套不同的推薦規則:

[規則1]如果函數內部總是制作參數的副本,按值傳遞。

[規則2]如果函數從來不復制參數,按常量引用傳遞。

[規則3]如果函數有時復制參數,而且關心效率,則按照Mojo協議。

現在只留下開發Mojo協議了,不管它是什麼。

主要的想法是重載同樣的函數(例如Connect),目的是辨別臨時的和非臨時的值。後者也稱為左值(lvalue),因為歷史原因,左值因為可以出現在賦值運算符的左邊而得名。

現在開始重載Connect,第一個想法是定義Connect(const String&)來捕捉常量對象。然而這是錯誤的,因為這個聲明“吞吃”了所有的String對象,不管是左值(lvalue)或者臨時對象[譯注:前面提到過,非常量可以隱式轉型為常量,這是標准轉型動作]。所以第一個好主意是不要聲明接受常量引用的參數,因為它像一個黑洞一樣,吞噬所有的對象。

第二個嘗試是定義Connect(String&)試圖捕獲非常量的左值。這工作良好,特別是常量值和無名的臨時對象不能被這個重載版本接受,這是一個好的起點。現在我們只剩下在常量對象和非常量臨時對象之間作出區分了。

為了達到這個目的,我們采取了一種技術,定義兩個替身類型[譯注:原文是type sugar,嘿嘿,如果你願意,可以叫他類型砂糖,如果你喜歡吃糖的話。]ConstantString和TemporaryString,並且定義了從String對象到這些對象轉型運算符:

class String;
//常量String的替身類型
struct ConstantString
{
  const String* obj_;
};
//臨時String的替身類型
struct TemporaryString : public ConstantString {};
class String
{
public:
  //...構造函數,析構函數,運算符,等等......
  operator ConstantString() const
  {
    ConstantString result;
    result.obj_ = this;
    return result;
  }
  operator TemporaryString()
  {
    TemporaryString result;
    result.obj_ = this;
    return result;
  }
};

現在定義下面三個重載版本:

//綁定非常量臨時對象
void Connect(TemporaryString);
//綁定所有的常量對象(左值和臨時對象)
void Connect(ConstantString);
//綁定非常量左值
void Connect(String& str)
{
  //調用另一個重載版本
  Connect(ConstantString(str));
}

常量String對象被Connect(ConstantString)吸收。沒有其他綁定可以工作,另兩個僅僅被非常量String對象調用。

臨時對象不能調用Connect(String&)。然而它們可以調用Connect(TemporaryString)或者Connect(ConstantString),前者必然被選中而不發生歧義。原因是因為TemporaryString從ConstantString派生而來,一個應該注意的詭計。

考慮一下ConstantString和TemporaryString都是獨立的類型。那麼,當要求復制一個臨時對象時,編譯器將同等的對待operator TemporaryY()/Y(TemporarY)或者operator ConstantY() const/Y(ConstantY)。

為什麼是同等的?因為就選擇成員函數來說,非常量到常量轉型是“無摩擦的”。

因而,需要告訴編譯器更多的選擇第一個而不是第二個。那就是繼承在這裡的作用。現在編譯器說:“好吧,我猜我要經過ConstantString或者TemporaryString...,但是等等,派生類TemporaryString是更好的匹配!”

這裡的規則是從重載候選中選擇函數時,匹配的派生類被視作比匹配的基類更好。

[譯注]

我對上述代碼稍作修改,從std::string派生了String,並在此基礎上按照Mojo的方式修改,結果在gcc3.2編譯器下的確如作者指出的行為一般無二。這條重載的決議規則很少在C++書籍中提到,Wang Tianxing從煙波浩淼的標准文本中找出了這條規則:

13.3.3.2 Ranking implicit conversion sequences [over.rank]
4 [...]
-- If class B is derived directly or indirectly
  from class A and class C is derived directly
  or indirectly from B,
 [...]
-- binding of an expression of type C to a
   object of type B is better than binding
   an expression of type C to a object
   of object A,

上面這些標准中的條款,是從隱式轉型的轉換等級中節選出來的,大致的意思是說,如果C繼承B,而B繼承A,那麼類型為C的表達式綁定到B的對象比到A的對象更好,這是上面敘述的技術的標准依據。此外,類似的引用和指針的綁定也適用於此規則,這裡省略了這些條款。

最後一個有趣的花樣是,繼承不需要必須是public的。存取規則和重載規則是不沖突的。

讓我們看看Connect如何工作的例子:

String s1("http://moderncppdesign.com");
// 調用Connect(String&)
Connect(s1);
// 調用operator TemporaryString()
// 接下來調用Connect(TemporaryString)
Conncet(String("http://moderncppdesign.com"));
const String s4("http://moderncppdesign.com");
// 調用operator ConstantString() const
// 接下來調用Connect(ConstantString)
Connect(s4);
如你所見,我們達到了期望的主要目標:在臨時對象和所有其他對象之間制造了差別。這就是Mojo的要旨。

還有一些不太顯眼的問題,大多數我們要一一解決。

首先是減少代碼重復:Connect(String&)和Connect(ConstantString)基本上作相同的事情。上面的代碼通過第一個重載函數調用第二個重載函數解決了這個問題。

讓我們面對第二個問題,為每個需要mojo的類型寫兩個小類聽上去不是很吸引人,所以讓我們開始制作一些更具一般性的東西更便於使用。我們定義了一個mojo名字空間,並放入兩個泛型的Constant和Temporary類:

namespace mojo
{
  template < class T >
  class constant
  {
    const T* data_;
  public:
    explicit constant(const T& obj) : data_(&obj)
    {
    }
    const T& get() const
    {
      return *data_;
    }
  };

  template < class T >
  class temporary : private constant< T >
  {
  public:
    explicit temporary(T& obj) : contant< T >( obj)
    {
    }
    T& get() const
    {
      return const_cast< T& >(constant< T >::get());
    }
  };
}

讓我們再定義一個基類mojo::enabled,它包括了兩個運算符:

template < class T > struct enabled //在mojo名字空間中
{
  operator temporary< T >()
  {
    return temporary< T >(static_cast< T& >(*this));
  }
  operator constant< T >() const
  {
    return constant< T >(static_cast< const T& >(*this));
  }
protected:
  enabled() {} //只能被派生
  ~enabled() {} //只能被派生
};

使用這個“腳手架”,將一個類“mojo化”的任務可以想象會變得更簡單:

class String : public mojo::enabled< String >
{
   //...構造函數,析構函數,運算符,等等...
public:
   String(mojo::temporary< String > tmp)
   {
     String& rhs = tmp.get();
     //...執行rhs到*this的析構性復制...
   }
};

這就是傳遞函數參數的Mojo協議。

通常,一切工作良好,你得到了一個好的設計品。不錯,那些意外的情況都控制在一個很小的范圍內,這使他們更有價值。

用Mojo設計我們可以很容易檢測到一個類是否支持Mojo。只需要簡單的寫:

namespace mojo
{
  template < class T >
  struct traits
  {
    enum
    {
      enabled = Loki::SuperSubclassStrict< enabled< T >, T >::value
    };
  };
};

Loki提供了探測一個類型是否從另一個類派生的機制。[5]

現在可以發現一個任意的類型X是按照Mojo協議設計的,只要通過mojo::traits<X>::enabled即可確定。這個檢測機制對泛型編程是很重要的,很快我們就會看到它的作用。

4.2 函數返回值優化

現在我們可以正確的傳遞參數,讓我們看看如何將Mojo擴展到函數返回值優化。這次的目的又是具有可移植性的效率改善,即100%的消除不需要的復制而不依賴於特定的返回值優化(RVO)實現。

讓我們先看看通常的建議怎麼說。出於好意,一些作者也推薦返回值的使用規則[7]:

[規則4]當函數返回用戶定義的對象的值的時候,返回一個常量值。例如:

const String operator+(const String& lhs,const String& rhs);

規則4的潛台詞是使用戶定義的運算符更加接近於內建的運算符可以禁止錯誤的表達式的功能,就好像想是if (s1+s2==s3)的時候筆誤成了if (s1+s2=s3)。如果operator+返回一個常量值,這個特定的BUG將會在編譯期間被檢測到[譯注:返回內建數據類型的值隱含地總是常量的,而用戶定義類型則需要顯式的用常量限定符指出]。然而,其他的作者[6]推薦不要返回常量值。

冷靜的看,任何返回值都是短暫的,它是剛剛被創建就要很快消失的短命鬼。那麼,為什麼要強迫運算符的使用者獲得一個常量值呢?從這個觀點看,常量的臨時對象看上去就象是自相矛盾的,既是不變的,又是臨時的。從實踐的觀點看,常量對象強迫復制。

現在假定我們同意,如果效率是重要的,最好是避免返回值是常量,那麼我們如何使編譯器確信將函數的結果轉移到目的地,而不是復制他呢?

當復制一個類型為T的對象時,拷貝構造函數被調用。按照下面的設置,我們剛好可以提供這樣一個拷貝構造函數實現這個目標。

class String : public mojo :: enabled < string >
{
//...
public:
 String( String& );
 String( mojo :: temporary < String > );
 String( mojo :: constant < String > );
};

這是一個很好的設計,除了一個小細節--它不能工作。

因為拷貝構造函數和其他的函數不完全相同,特別是,對一個類型X來說,在需要X(const X&)的地方定義X(X&),下面的代碼將無法工作:

void FunctionTakingX(const X&);
FunctionTakingX(X()); // 錯誤!不能發現X(const X&)

[譯注]

Wang Tianxing在gcc3.2, bcc5.5.1, icl7.0環境下測試結果表明都不會發生錯誤,並進而查閱了標准,發現Andrei是正確的,如果一定說要有什麼錯誤的話,他沒有指出這是實現定義的。

8.5.3 References
5 [...]
— If the initializer expression is an rvalue, with T2 a class type,
and “cv1 T1” is reference-compatible with “cv2 T2,” the reference
is bound in one of the following ways (the choice is implementation-
defined):
— The reference is bound to the object represented by the rvalue
(see 3.10) or to a sub-object within that object.
— A temporary of type “cv1 T2” [sic] is created, and a
constructor is called to copy the entire rvalue object into the
temporary. The reference is bound to the temporary or to a
sub-object within the temporary.93)
The constructor that would be used to make the copy shall be
callable whether or not the copy is actually done.
93) Clearly, if the reference initialization being processed is one
for the first argument of a copy constructor call, an implementation
must eventually choose the first alternative (binding without
copying) to avoid infinite recursion.

我引用了這段標准文本,有興趣的讀者可以自行研究它的含義。

這嚴重的限制了X,所以我們被迫實現String(const String&)構造函數。現在如果你允許我引用本文的話,在前面我曾經說過:“所以第一個好主意是不要聲明一個函數接受常量引用,因為它像一個黑洞一樣吞噬所有的對象。”

魚與熊掌不可兼得,不是嗎?

很清楚,拷貝構造函數需要特別的處理。這裡的想法是創建一個新的類型fnresult,那就是為String對象提供一個“轉移器(mover)”。下面是需要執行的步驟:

前面返回類型為T的值的函數現在將返回fnresult<T>。為了使這個變化對對調用者透明,fnresult必須可以被隱式的轉型為T。

然後為fnresult建立轉移語義:無論何時一個fnresult<T>對象被復制,裡面包含的T被轉移。

類似運算符的常量性和臨時性,在mojo::enabled類中為fnresult提供一個轉型運算符。

一個mojo化的類(如前例中的String)定義了一個構造函數String( mojo :: fnresult < String > )完成轉移。

這個fnresult的定義看起來就像:

namespace mojo
{
 template < class T >
 class fnresult : public T
 {
 public:
  fnresult ( const fnresult& rhs )
   : T ( temporary < T > ( const_cast < fnresult& > ( rhs ) ) )
  {
  }
  explicit fnresult ( T& rhs ) : T ( temporary < T > ( rhs ) )
  {
  }
 };
}

因為fnresult<T>從T繼承而來,第一步值得注意,即fnresult<T>轉型為T,然後第二個值得注意的就是復制fnresult<T>對象的時候,隱含著它的T子對象(subobject)強制轉型為temporary<T>。

正如前面提到的,我們增加一個轉型允許返回一個fnresult,最後的版本看起來是這樣的:

template < class T > struct enabled
{
 operator temporary < T > ( )
 {
  return temporary < T > ( static_cast < T& > ( *this ) );
 }
 operator constant < T > ( ) const
 {
  return constant < T > ( static_cast < const T& > ( *this ) );
 }
 operator fnresult < T > ( )
 {
  return fnresult < T > ( static_cast < T& > ( *this ) );
 }
 protected:
  enabled ( ) { } // intended to be derived from
  ~enabled ( ) { } // intended to be derived from
};

最後是String的定義:

class String : public mojo :: enabled < String >
{
 //...
public:
 // COPY rhs
 String ( const String& rhs );
 // MOVE tmp.get() into *this
 String ( mojo :: temporary < String > tmp );
 // MOVE res into *this
 String ( mojo :: fnresult < String > res );
};

現在考慮下面的函數:

mojo :: fnresult < String > MakeString()
{
 String result;
//?..
 return result;
}
//...
String dest(MakeString());

在MakeString的return語句和dest的定義之間的路徑是:

result -> String :: operator fnresult < String > () -> fnresult < String > (const fnresult < String >& ) -> String :: String ( fnresult < String > )

使用RVO的編譯器可以消除調用鏈中fnresult<String>(const fnresult<String>&)的調用。然而,更重要的是沒有函數執行真正的復制,它們都被定義為結果的實際內容平滑的轉移到dest。也就是說沒有涉及內存分配和復制。

現在,正如所見,有兩個,最多三個轉移操作。當然,在一定條件和一定類型的情況下,一次復制比三次轉移可能更好。還有一個重要的區別,復制也許會失敗(拋出異常),而轉移永遠不會失敗。

5 擴展

好的,我們使Mojo工作了,而且對於單獨的類相當好。現在怎樣將Mojo擴展到組合對象,它們也許包含大量其他的對象,而且他們中的一些已經是mojo化的。

這個任務就是將轉移構造函數從類傳遞到成員。考慮下面的例子,內嵌類String在類Widget中:

class Widget : public mojo::enabled < Widget >
{
 String name_;
public:
 Widget(mojo::temporary< Widget > src) // source is a temporary
  : name_(mojo::as_temporary(src.get().name_))
 {
  Widget& rhs = src.get();
  //... use rhs to perform a destructive copy ...
 }
 Widget(mojo::constant< Widget > src) // source is a const
  : name_(src.get().name_) // 譯注:這裡原文name_(src.name_)顯然有誤
 {
  Widget& rhs = src;
  //... use rhs to perform a destructive copy ...
 }
};

在轉移構造函數中的name_的初始化使用了一個重要的Mojo輔助函數:

namespace mojo
{
 template < class T >
 struct traits
 {
  enum { enabled =
   Loki::SuperSubclassStrict< enabled< T >, T >::value };
  typedef typename
    Loki::Select< enabled,temporary< T >,T& >::Result temporary;
 };
 template < class T >
 inline typename traits< T >::temporary as_temporary(T& src)
 {
  typedef typename traits< T >::temporary temp;
  return temp(src);
 }
}

as_temporary做的所有事情就是根據一個左值創建一個臨時對象。使用這個方法,類成員的轉移構造函數被目標對象所調用。

如果String是mojo化的,Widget得到他的優點;如果不是,一個直接的復制被執行。換句話說,如果String是mojo::enabled<String>的一個派生類,那麼as_temporary返回一個mojo::temporary<String>。否則,as_temproary(String& src)是一個簡單的函數,帶一個String&的參數並返回同樣的String&。

6 應用:auto_ptr的親戚和mojo化的容器

考慮一個mojo_ptr類,它通過使拷貝構造函數私有而禁止它們:

class mojo_ptr : public mojo::enable< mojo_ptr >
{
 mojo_ptr(const mojo_ptr&); // const sources are NOT accepted
public:
 // source is a temporary
 mojo_ptr(mojo::temporary< mojo_ptr > src)
 {
  mojo_ptr& rhs = src.get();
  //... use rhs to perform a destructive copy ...
 }
 // source is a function's result
 mojo_ptr(mojo::fnresult< mojo_ptr > src)
 {
  mojo_ptr& rhs = src.get();
  //... use rhs to perform a destructive copy ...
 }
 //..
};

這個類有一個有趣的行為。你不能復制這個類的常量對象。你也不能復制這個類的左值。但是你可以復制這個類的臨時對象(使用轉移語義),而且你可以顯式的移動一個對象到另外的對象:

mojo_ptr ptr1;
mojo_ptr ptr2 = mojo::as_temporary(ptr1);

這本身並沒有什麼大不了的,如果 auto_ptr 裡讓 auto_ptr(auto_ptr&)私有,也可以做到這一點。有趣的地方不是mojo_ptr本身,而是如何使用as_temporary。你可以建立高效的容器,儲存“經典”的類型、一般的mojo化的類型以及和mojo_ptr類似的類型。所有這樣的一個容器當他需要轉移元素時,必須使用as_temporary。對於“經典”類型,as_temporary是一個什麼都不做的等效函數,對於mojo_ptr,as_temporary是一個提供平滑轉移機制的函數書。move()以及uninitialized_move()的函數模板(參見所附代碼,譯注:代碼請到原版鏈接處尋找)也唾手可得。

使用標准術語,mojo_ptr既不是可以復制的,也不是可以賦值的。然而,mojo_ptr可以看作是一種新類型,叫做“可轉移的”。這是一個重要的新的分類,也許可以用於鎖(lock)、文件(file)和其他的不可復制的句柄(handle)。

如果你曾經希望一個擁有元素的類似於 vector< auto_ptr<Widget> > 的容器,而且有安全、清楚的語義,現在你得到了,而且還有其他功能。另外,當包含一個拷貝昂貴的類型時,如vector< vector<string> >,mojo化的vector“更能適應元素個數增減的需要”。

7 結論

mojo是一種技術,也是一個緊湊的小框架,用於消除不必要的臨時對象的復制。mojo的工作方式是檢測臨時對象並且通過函數重載操縱他們而不是簡單的作為左值。這樣做的結果是,獲得臨時對象的函數執行一個破壞性的復制,只要確信其他代碼不再使用這個臨時對象即可。

如果客戶代碼按照一套簡單的規則傳遞函數參數和返回值,可以應用mojo。

mojo定義了一個單獨的機制來消除函數返回時的復制。

額外的機制和類型轉換使mojo對於客戶代碼不是100%的透明,然而對於基於庫的解決方案來說集成度是相當好的。說得好聽一點,mojo將作為一個健壯的替代品,直到一個更健壯的、基於語言特性的被標准化並實現。

8 致謝

原文的致謝略,譯文得到了Wang Tianxing的熱情幫助,除了幫助我審核了若干技術細節之外,還指出了不少打字錯誤,以及若干英語中的諺語。

9 參考文獻

[1] Dov Bulka and David Mayhew. Efficient C++: Performance Programming Techniques, (Addison-Wesley, 1999).

[2] Howard E. Hinnant, Peter Dimov, and Dave Abrahams. "A Proposal to Add Move Semantics Support to the C++ Language," ISO/IEC JTC1/SC22/WG21 — C++, document number N1377=02-0035, September 2002, <http://anubis.dkuug.dk/jtc1/sc22/wg21/docs/papers/2002/n1377.htm>.

[3] "Programming Languages — C++," International Standard ISO/IEC 14882, Section 12.2.

[4] Herb Sutter. More Exceptional C++ (Addison-Wesley, 2002).

[5] Andrei Alexandrescu. Modern C++ Design (Addison-Wesley, 2001).

[6] John Lakos. Large-Scale C++ Software Design (Addison-Wesley, 1996), Section 9.1.9.

[7] Herb Sutter. Exceptional C++ (Addison-Wesley, 2000).

作者簡介

Andrei Alexandrescu是一位華盛頓大學西雅圖分校的博士生,廣受贊譽的《Modern C++ Design》(中譯本現代C++設計正在譯制中)一書的作者。可以通過電子郵件[email protected]聯系。Andrei還是一個C++課程的有號召力的講師。

譯者的話

作為第一次編譯技術文章,而且選擇的是C++中自己相對比較陌生的主題,並且本文講述的內容是具有前瞻性的,而不是見諸於現有資料和文獻的重新整理。因此在翻譯過程中,有些細節譯者本人也沒有完全理解,因此難免出現不少差錯,歡迎大家來到newsfan的C++新聞組討論。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved