C++11精要:部分語言特性

語言: CN / TW / HK

新的C++標準給我們帶來的不僅是對併發的支持,還有許多新程序庫和C++新特性。對於線程庫和本書其他章節涉及的某些C++新特性,本附錄給出了簡要概覽。

雖然這些特性都與併發功能沒有直接關係(thread_local除外,見A.8節),但對多線程代碼而言,它們既重要又有用。我們限定了附錄的篇幅,只介紹必不可少的特性(如右值引用),它們可以簡化代碼,使之更易於理解。假使讀者尚未熟識本附錄的內容,就徑直閲讀採用了這些特性的代碼,那麼代碼理解起來可能會比較吃力。一旦熟識本附錄的內容後,所涉及的代碼普遍會變得容易理解。隨着C++11的推廣,採用這些特性的代碼也會越來越常見。

閒言少敍,我們從右值引用開始介紹。C++線程庫中包含不少組件,如線程和鎖等,其歸屬權只能為單一對象獨佔,為了便於在對象間轉移歸屬權,線程庫充分利用了右值引用的功能。

A.1右值引用

如果讀者曾經接觸過C++編程,對引用就不會陌生。C++的引用准許我們為已存在的對象創建別名。若我們訪問和修改新創建的引用,全都會直接作用到它指涉的對象本體上,例如:

int var=42;
int& ref=var;    ⇽---  ①創建名為ref的引用,指向的目標是變量var
ref=99;
assert(var==99);    ⇽---  ②向引用賦予新值,則本體變量的值亦隨之更新

在C++11標準發佈以前,只存在一種引用——左值引用(lvaluereference)。術語左值來自C語言,指可在賦值表達式等號左邊出現的元素,包括具名對象、在棧數據段和堆數據段​ ​[1]​ ​​上分配的對象​ ​[2]​ ​​、其他對象的數據成員,或一切具有確定存儲範圍的數據項。術語右值同樣來自C語言,指只能在賦值表達式等號右邊出現的元素,如字面值​ ​[3]​ ​和臨時變量。左值引用只可以綁定左值,而無法與右值綁定。譬如,因為42是右值,所以我們不能編寫語句:

int& i=42;     ⇽---  ①無法編譯

但這其實不盡然,我們一般都能將右值綁定到const左值引用上:

int const& i=42;

C++在初期階段尚不具備右值引用的特性,而在現實中,代碼卻要向接受引用的函數傳入臨時變量,因而早期的C++標準破例特許了這種綁定方式。

這能讓參數發生隱式轉換,我們也得以寫出如下代碼:

void print(std::string const& s);
print("hello");    ⇽---  ①創建std::string類型的臨時變量

C++11標準採納了右值引用這一新特性,它只與右值綁定,而不綁定左值。另外,其聲明不再僅僅帶有一個“&”,而改為兩個“&”。

int&& i=42;
int j=42;
int&& k=j;    ⇽---  ①編譯失敗

我們可以針對同名函數編寫出兩個重載版本,分別接收左、右值引用參數,由重載機制自行決斷應該調用哪個,從而判定參數採用左值還是右值。這種處理​ ​[4]​ ​是實現移動語義的基礎。

A.1.1移動語義

右值往往是臨時變量,故可以自由改變。假設我們預先知曉函數參數是右值,就能讓其充當臨時數據,或“竊用”它的內容而依然保持程序正確運行,那麼我們只需移動右值參數而不必複製本體。按這種方式,如果數據結構體積巨大,而且需要動態分配內存,則能省去更多的內存操作,創造出許多優化的機會。考慮一個函數,它通過參數接收std::vector<int>容器並進行改動。為了不影響原始數據​ ​[5]​ ​,我們需在函數中複製出副本以另行操作。

根據傳統做法,函數應該按const左值引用的方式接收參數,並在內部複製出副本;

void process_copy(std::vector<int> const& vec_)
{
     std::vector<int> vec(vec_);
     vec.push_back(42);
}

這個函數接收左值和右值​ ​[6]​ ​皆可,但都會強制進行復制。

若我們預知原始數據能隨意改動,即可重載該函數,編寫一個接收右值引用參數的版本,以此避免複製​ ​[7]​ ​。

void process_copy(std::vector<int> && vec)
{
     vec.push_back(42);
}

現在,我們再來考慮利用自定義類型的構造函數,竊用右值參數的內容直接充當新實例。考慮代碼清單A.1中的類,它的默認構造函數申請一大塊內存,而析構函數則釋放之。

代碼清單A.1具備移動構造函數的類

class X
{
private:
    int* data;
public:
    X():
        data(new int[1000000])
    {}
    ~X()
    {
        delete [] data;
    }
    X(const X& other):    ⇽---  ①
        data(new int[1000000])
    {
        std::copy(other.data,other.data+1000000,data);
    }
    X(X&& other):    ⇽---  ②
        data(other.data)
    {
        other.data=nullptr;
    }
};

拷貝構造函數①的定義與我們的傳統經驗相符:新分配一塊內存,並從源實例複製數據填充到其中。然而,本例還展示了新的構造函數,它按右值引用的方式接收源實例②,即移動構造函數。它複製data指針,將源實例的data指針改為空指針,從而節約了一大塊內存,還省去了複製數據本體的時間。

就類X而言,實現移動構造函數僅僅是一項優化措施。但是,某些類卻很有必要實現移動構造函數,強令它們實現拷貝構造函數反而不合理。以std::unique_ptr<>指針為例,其非空實例必然指向某對象,根據設計意圖,它也肯定是指向該對象的唯一指針,故只許移動而不許複製,則拷貝構造函數沒有存在的意義。依此取捨,指針類std::unique_ptr<>遂具備移動構造函數,可以在實例之間轉移歸屬權,還能充當函數返回值。

假設某個具名對象不再有任何用處,我們想將其移出,因而需要先把它轉換成右值,這一操作可通過static_cast<X&&>轉換或調用std::move()來完成。

X x1;
X x2=std::move(x1);
X x3=static_cast<X&&>(x2);

上述方法的優點是,儘管右值引用的形參與傳入的右值實參綁定,但參數進入函數內部後即被當作左值處理。所以,當我們處理函數的參數的時候,可將其值移入函數的局部變量或類的成員變量,從而避免複製整份數據。

void do_stuff(X&& x_)
{
    X a(x_);    ⇽---  ①複製構造
    X b(std::move(x_));    ⇽---  ②移動構造
}
do_stuff(X());    ⇽---  ③正確,X()生成一個匿名臨時對象,作為右值與右值引用綁定
X x;
do_stuff(x);    ⇽---  ④錯誤,具名對象x是左值,不能與右值引用綁定

移動語義在線程庫中大量使用,既可以取代不合理的複製語義,又可以實現資源轉移。另外,按代碼邏輯流程,某些對象註定要銷燬,但我們卻想延展其所含的數據。若複製操作的開銷大,就可以改用轉移來進行優化。2.2 節曾舉例,藉助std::move()向新構建的線程轉移std::unique_ptr<>實例;2.3節則再次向讀者舉例,在std::thread的實例之間轉移線程歸屬權。

std::thread、std::unique_lock<>、std::future<>、std::promise<>和std::packaged_task<>等類無法複製,但它們都含有移動構造函數,可以在其實例之間轉移關聯的資源,也能按轉移的方式充當函數返回值。std::string和std::vector<>仍然可以複製,並且這兩個類也具備移動構造函數和移動賦值操作符,能憑藉移動右值避免大量複製數據。

在C++標準庫中,若某源對象顯式移動到另一對象,那麼源對象只會被銷燬,或被重新賦值(複製賦值或移動賦值皆可,傾向於後者),除此之外不會發生其他任何操作。按照良好的編程實踐,類需確保其不變量(見3.1節)的成立範圍覆蓋其“移出狀態”(moved-from state)。如果std::thread的實例作為移動操作的數據源,一旦發生了移動,它就等效於按默認方式構造的線程實例​ ​[8]​ ​​。再借std::string舉例,假設它的實例作為數據源參與移動操作,在完成操作後,這一實例仍需保持某種合法、有效的狀態,即便C++標準並未明確規定該狀態的具體細節​ ​[9]​ ​​、​ ​[10]​ ​(例如其長度值,以及所含的字符內容)。

A.1.2右值引用和函數模板

最後,但凡涉及函數模板,我們還要注意另一細節:假定函數的參數是右值引用,目標是模板參數,那麼根據模板參數的自動類型推導機制,若我們給出左值作為函數參數,模板參數則會被推導為左值引用;若函數參數是右值,模板參數則會被推導為無修飾型別(plain unadorned type)的普通引用​ ​[11]​ ​。這聽起來有點兒拗口,下面舉例詳細解説,考慮函數:

template<typename T>
void foo(T&& t)
{}

若按下列形式調用函數,類型T則會被推導成參數值所屬的型別:

foo(42);     ⇽---  ①調用foo<int>(42)
foo(3.14159);     ⇽---  ②調用foo<double>(3.14159)
foo(std::string());    ⇽---  ③調用foo<std::string>(std::string())

然而,若我們在調用foo()時以左值形式傳參,編譯器就會把類型T推導成左值引用:

int i=42;
foo(i);    ⇽---  ①調用foo<int&>(i)

根據函數聲明,其參數型別是T&&,在本例的情形中會被解釋成“引用的引用”,所以發生引用摺疊(reference collapsing),編譯器將它視為原有型別的普通引用​ ​[12]​ ​。這裏,foo<int&>()的函數簽名是“void foo<int&>(int& t);”。

利用該特性,同一個函數模板既能接收左值參數,又能接收右值參數。std::thread的構造函數正是如此(見2.1節和2.2節)。若我們以左值形式提供可調用對象作為參數,它即被複制到相應線程的內部存儲空間;若我們以右值形式提供參數,則它會按移動方式傳遞。

A.2刪除函數

有時候,我們沒理由准許某個類進行復制,類std::mutex就是最好的例證。若真能複製互斥,則副本的意義何在?類std::unique_lock<>即為另一例證,假設它的某個實例正在持鎖,那麼該實例必然獨佔那個鎖。如果精準地複製這一實例,其副本便會持有相同的鎖,顯然毫無道理。因此,上述情形不宜複製,而應採用A.1.2節所述特性,在實例之間轉移歸屬權。

要禁止某個類的複製行為,以前的標準處理手法是將拷貝構造函數和複製賦值操作符聲明為私有,且不給出實現。假如有任何外部代碼意圖複製該類的實例,就會導致編譯錯誤(因為調用私有函數);若其成員函數或友元試圖複製它的實例,則會產生鏈接錯誤(因為沒有提供實現):

class no_copies
{
public:
    no_copies(){}
private:
    no_copies(no_copies const&);    ⇽---  
    no_copies& operator=(no_copies const&);    ⇽---  ①不存在實現
};
no_copies a;
no_copies b(a);    ⇽---  ②編譯錯誤

標準委員會在擬定C++11檔案時,已察覺到這成了常用手法,也清楚它是一種取巧的手段。為此,委員會引入了更通用的機制,同樣適合其他情形:聲明函數的語句只要追加“=delete”修飾,函數即被聲明為“刪除”。因此,類no_copies可以改寫成:

class no_copies
{
public:
    no_copies(){}
    no_copies(no_copies const&) = delete;
    no_copies& operator=(no_copies const&) = delete;
};

新寫法更清楚地表達了設計意圖,其説明效力比原有代碼更強。另外,假設我們試圖在成員函數內複製類的實例,只要遵從新寫法,就能讓編譯器給出更具説明意義的錯誤提示,還會令本來在鏈接時發生的錯誤提前至編譯期。

若我們在實現某個類的時候,既刪除拷貝構造函數和複製賦值操作符,又顯式寫出移動構造函數和移動賦值操作符,它便成了“只移型別”(move-only type),該特性與std::thread和std::unique_lock<>的相似。代碼清單A.2展示了這種只移型別。

代碼清單A.2簡單的只移型別

class move_only
{
    std::unique_ptr<my_class> data;
public:
    move_only(const move_only&) = delete;
    move_only(move_only&& other):
        data(std::move(other.data))
    {}
    move_only& operator=(const move_only&) = delete;
    move_only& operator=(move_only&& other)
    {
        data=std::move(other.data);
        return *this;
    }
};
move_only m1;
move_only m2(m1);    ⇽---  ①錯誤,拷貝構造函數聲明為“刪除”
move_only m3(std::move(m1));    ⇽---  ②正確,匹配移動構造函數

只移對象可以作為參數傳入函數,也能充當函數返回值。然而,若要從某個左值移出數據,我們就必須使用std::move()或static_cast<T&&>顯式表達該意圖。

説明符“=delete”可修飾任何函數,而不侷限於拷貝構造函數和賦值操作符,其可清楚註明目標函數無效。它還具備別的作用:如果某函數已聲明為刪除,卻按普通方式參與重載解釋(overload resolution)並且被選定,就會導致編譯錯誤。利用這一特性,我們即能移除特定的重載版本。例如,假設某函數接收short型參數,那它也允許傳入int值,進而將int值強制向下轉換成short值。若要嚴格杜絕這種情況,我們可以編寫一個傳入int類型參數的重載,並將它聲明為刪除:

void foo(short);
void foo(int) = delete;

照此處理,如果以int值作為參數調用foo(),就會產生編譯錯誤。因此,調用者只能先把給出的值全部顯式轉換成short型。

foo(42);    ⇽---  ①錯誤,接收int型參數的重載版本聲明成刪除
foo((short)42);    ⇽---  ②正確

A.3默認函數

一旦將某函數標註為刪除函數,我們就進行了顯式聲明:它不存在實現。但默認函數則完全相反:它們讓我們得以明確指示編譯器,按“默認”的實現方式生成目標函數。如果一個函數可以由編譯器自動產生,那它才有資格被設為默認:默認構造函數​ ​[13]​ ​、析構函數、拷貝構造函數、移動構造函數、複製賦值操作符和移動賦值操作符等。

這麼做所為何故?原因不外乎以下幾點。

  • 藉以改變函數的訪問限制。按默認方式,編譯器只會產生公有(public)函數。若想讓它們變為受保護的(protected)函數或私有(private)函數,我們就必須手動實現。把它們聲明為默認函數,即可指定編譯器生成它們,還能改變其訪問級別。
  • 充當説明註解。假設編譯器產生的函數可滿足所需,那麼把它顯式聲明為“默認”將頗有得益:無論是我們自己還是別人,今後一看便知,該函數的自動生成正確貫徹了代碼的設計意圖。
  • 若編譯器沒有生成某目標函數,則可借“默認”説明符強制其生成。一般來説,僅當用户自定義構造函數不存在時,編譯器才會生成默認構造函數,針對這種情形,添加“=default”修飾即可保證其生成出來。例如,儘管我們定義了自己的拷貝構造函數,但通過“聲明為默認”的方式,依然會令編譯器另外生成默認構造函數。
  • 令析構函數成為虛擬函數,並託付給編譯器生成。
  • 強制拷貝構造函數遵從特定形式的聲明,譬如,使之不接受const引用作為參數,而改為接受源對象的非const引用。
  • 編譯器產生的函數具備某些特殊性質,一旦我們給出了自己的實現,這些性質將不復存在,但利用“默認”新特性即能保留它們並加以利用,細節留待稍後解説。

在函數聲明後方添加“=delete”,它就成了刪除函數。類似地,在目標函數聲明後方添加“=default”,它則變為默認函數,例如:

class Y
{
private:
    Y() = default;    ⇽---  ①改變訪問級別
public:
    Y(Y&) = default;    ⇽---  ②接受非const引用
    T& operator=(const Y&) = default;    ⇽---  ③聲明成“默認”作為註解
protected:
    virtual ~Y() = default;     ⇽---  ④改變訪問級別並加入“虛函數”性質
};

前文提過,在同一個類中,若將某些成員函數交由編譯器實現,它們便會具備一定的特殊性質,但是讓我們自定義實現,這些性質就會喪失。兩種實現方式的最大差異是,編譯器有可能生成平實函數​ ​[14]​ ​。據此我們得出一些結論,其中幾項如下。

  • 如果某對象的拷貝構造函數、拷貝賦值操作符和析構函數都是平實函數,那它就可以通過memcpy()或memmove()複製。
  • constexpr函數(見附錄A.4節)所用的字面值型別(literal type)必須具備平實構造函數、平實拷貝構造函數和平實析構函數。
  • 若要允許一個類能夠被聯合體(union)所包含,而後者已具備自定義的構造函數和析構函數,則這個類必須滿足:其默認構造函數、拷貝構造函數、複製操作符和析構函數均為平實函數。
  • 假定某個類充當了類模板std::atomic<>的模板參數(見5.2.6節),那它應當帶有平實拷貝賦值操作符,才可能提供該類型值的原子操作。

只在函數聲明處加上“=default”,還不足以構成平實函數。僅當類作為一個整體滿足全部其他要求,相關成員函數方可構成平實函數​ ​[15]​ ​。不過,一旦函數由用户自己動手顯式編寫而成,就肯定不是平實函數。

在同一個類中,某些特定的成員函數既能讓編譯器生成,又准許用户自行編寫,我們繼續分析兩種實現方式的第二項差異:如果用户沒有為某個類提供構造函數,那麼它便得以充當聚合體​ ​[16]​ ​,其初始化過程可依照聚合體初值(aggregate initializer)表達式完成。

struct aggregate
{
    aggregate() = default;
    aggregate(aggregate const&) = default;
    int a;
    double b;
};
aggregate x={42,3.141};

在本例中,x.a初始化為42,而x.b則初始化為3.141。

編譯器生成的函數和用户提供的對應函數之間還有第三項差異:它十分隱祕,只存在於默認構造函數上,並且僅當所屬的類滿足一定條件時,差異才會顯現。考慮下面的類:

struct X
{
    int a;
};

若我們創建類X的實例時沒有提供初值表達式,內含的int元素(成員a)就會發生默認初始化。假設對象具有靜態生存期​ ​[17]​ ​,它便會初始化為零值;否則該對象無從確定初值,除非另外賦予新值,但如果在此之前讀取其值,就有可能引發未定義行為。

X x1;     ⇽---  ①x1.a的值尚未確定

有別於上例,如果類X的實例在初始化時顯式調用了默認構造函數,成員a即初始化為0​ ​[18]​ ​。

X x2=X();    ⇽---  ①x2.a==0必然成立

這個特殊性質還能擴展至基類及內部成員。假定某個類的默認構造函數由編譯器產生,而它的每個數據成員與全部基類也同樣如此,並且後面兩者所含的成員都屬於內建型別​ ​[19]​ ​。那麼,這個最外層的類是否顯式調用該默認構造函數,將決定其成員是否初始化為尚不確定的值,抑或發生零值初始化。

儘管上述規則既費解又容易出錯,但它確有妙用。一旦我們手動實現默認構造函數,它就會喪失這個性質:要是指定了初值或顯式地按默認方式構造,數據成員便肯定會進行初始化,否則初始化始終不會發生。

X::X():a(){}    ⇽---  ①a==0 必然成立
X::X():a(42){}    ⇽---  ②a==42 必然成立
X::X(){}    ⇽---  ③

假設類X的默認構造函數採納本例③處的方式,略過成員a的初始化操作​ ​[20]​ ​,那麼對於類X的非靜態實例,成員a不會被初始化。而如果類X的實例具有靜態生存期,成員a即初始化成零值。它們完全相互獨立,不存在重載,現實代碼中只允許其中一條語句存在(任意一條語句),這裏並列只是為了方便排版和印刷。

一般情況下,若我們自行編寫出任何別的構造函數,編譯器就不會再生成默認構造函數。如果我們依然要保留它,就得自己手動編寫,但其初始化行為會失去上述特性。然而,將目標構造函數顯式聲明成“默認”,我們便可強制編譯器生成默認構造函數,並且維持該性質。

X::X() = default;     ⇽---  ①默認初始化規則對成員a起作用

原子類型正是利用了這個性質(見5.2節)將自身的默認構造函數顯式聲明為“默認”。除去下列幾種情況,原子類型的初值只能是未定義:它們具有靜態生存期(因此靜態初始化成零值);顯式調用默認構造函數,以進行零值初始化;我們明確設定了初值。請注意,各種原子類型均具備一個構造函數,它們單獨接受一個參數作為初值,而且它們都聲明成constexpr函數,以准許靜態初始化發生(見附錄A.4節)。

A.4常量表達式函數

整數字面值即為常量表達式(constant expression),如42。而簡單的算術表達式也是常量表達式,如23*2−24。整型常量自身可依照常量表達式進行初始化,我們還能利用前者組成新的常量表達式:

const int i=23;
const int two_i=i*2;
const int four=4;
const int forty_two=two_i-four;

常量表達式可用於創建常量,進而構建其他常量表達式。此外,一些功能只能靠常量表達式實現。

  • 設定數組界限:
int bounds=99;
int array[bounds];    ⇽---  ①錯誤,界限bounds不是常量表達式
const int bounds2=99;
int array2[bounds2];    ⇽---  ②正確,界限bounds2是常量表達式
  • 設定非類型模板參數(nontype template parameter)的值:
template<unsigned size>
struct test
{};
test<bounds> ia;    ⇽---  ①錯誤,界限bounds不是常量表達式
test<bounds2> ia2;    ⇽---  ②正確,界限bounds2是常量表達式
  • 在定義某個類時,充當靜態常量整型數據成員的初始化表達式​ ​[21]​ ​:
class X
{
    static const int the_answer=forty_two;
};
  • 對於能夠進行靜態初始化的內建型別和聚合體,我們可以將常量表達式作為其初始化表達式:
struct my_aggregate
{
    int a;
    int b;
};
static my_aggregate ma1={forty_two,123};    ⇽---  ①靜態初始化
int dummy=257;
static my_aggregate ma2={dummy,dummy};    ⇽---  ②動態初始化
  • 只要採用本例示範的靜態初始化方式,即可避免初始化的先後次序問題,從而防止條件競爭(見3.3.1節)。

這些都不是新功能,我們遵從C++98標準也可以全部實現。不過 C++11 引入了constexpr關鍵字,擴充了常量表達式的構成形式。C++14 和C++17 進一步擴展了 constexpr關鍵字的功能,但其完整介紹並非本附錄力所能及。

constexpr關鍵字的主要功能是充當函數限定符。假設某函數的參數和返回值都滿足一定要求,且函數體足夠簡單,那它就可以聲明為constexpr函數,進而在常量表達式中使用,例如:

constexpr int square(int x)
{
    return x*x;
}
int array[square(5)];

在本例中,square()聲明成了constexpr函數,而常量表達式可以設定數組界限,使之容納25項數據。雖然constexpr函數能在常量表達式中使用,但是全部使用方式不會因此自動形成常量表達式。

int dummy=4;
int array[square(dummy)];    ⇽---  ①錯誤,dummy不是常量表達式

在本例中,變量dummy不是常量表達式①,故square(dummy)屬於普通函數調用,無法充當常量表達式,因此不能用來設定數組界限。

A.4.1constexpr關鍵字和用户定義型別

目前,所有範例都只涉及內建型別,如int。然而,在新的C++標準中,無論是哪種型別,只要滿足要求並可以充當字面值類型​ ​[22]​ ​,就允許它成為常量表達式。若某個類要被劃分為字面值型別,則下列條件必須全部成立。

  • 它必須具有平實拷貝構造函數。
  • 它必須具有平實析構函數。
  • 它的非靜態數據成員和基類都屬於平實型別​ ​[23]​ ​。
  • 它必須具備平實默認構造函數或常量表達式構造函數(若具備後者,則不得進行拷貝/移動構造)。

我們馬上會介紹常量表達式構造函數。現在,我們先着重分析平實默認構造函數,以代碼清單A.3中的類CX為例。

代碼清單A.3含有平實默認構造函數的類

class CX
{
private:
    int a;
    int b;
public:
    CX() = default;     ⇽---  ①
    CX(int a_, int b_):    ⇽---  ②
        a(a_),b(b_)
    {}
    int get_a() const
    {
        return a;
    }
    int get_b() const
    {
        return b;
    }
    int foo() const
    {
        return a+b;
    }
};

請注意,我們實現了用户定義的構造函數②,因而,為了保留默認構造函數①,就要將它顯式聲明為“默認”(見A.3節)。所以,該型別符合全部條件,為字面值型別,我們能夠在常量表達式中使用該型別。譬如,我們可以給出一個constexpr函數,負責創建該類型的新實例:

constexpr CX create_cx()
{
    return CX();
}

我們還能創建另一個constexpr函數,專門用於複製參數:

constexpr CX clone(CX val)
{
    return val;
}

然而在C++11環境中,constexpr函數的用途僅限於此,即constexpr函數只能調用其他constexpr函數。C++14則放寬了限制,只要不在constexpr函數內部改動非局部變量,我們就幾乎可以進行任意操作。有一種做法可以改進代碼清單A.3的代碼,即便在C++11中該做法同樣有效,即為CX類的成員函數和構造函數加上constexpr限定符:

class CX
{
private:
    int a;
    int b;
public:
    CX() = default;
    constexpr CX(int a_, int b_):
        a(a_),b(b_)
    {}
    constexpr int get_a() const    
    {
        return a;
    }
    constexpr int get_b()          
    {
        return b;
    }
    constexpr int foo()
    {
        return a+b;
    }
};

根據C++11標準,get_a()上的const現在成了多餘的修飾①,因其限定作用已經為constexpr關鍵字所藴含。同理,儘管get_b()略去了const修飾,可是它依然是const函數②。在C++14中,constexpr函數的功能有所擴充,它不再隱式藴含const特性,故get_b()也不再是const函數,這讓我們得以定義出更復雜的constexpr函數,如下所示:

constexpr CX make_cx(int a)
{
    return CX(a,1);
}
constexpr CX half_double(CX old)
{
    return CX(old.get_a()/2,old.get_b()*2);
}
constexpr int foo_squared(CX val)
{
    return square(val.foo());
}
int array[foo_squared(half_double(make_cx(10)))];    ⇽---  ①49個元素

雖然本例稍顯奇怪,但它意在説明,如果只有通過複雜的方法,才可求得某些數組界限或整型常量,那麼憑藉constexpr函數完成任務將省去大量運算。一旦涉及用户自定義型別,常量表達式和constexpr函數帶來的主要好處是:若依照常量表達式初始化字面值型別的對象,就會發生靜態初始化,從而避免初始化的條件競爭和次序問題:

CX si=half_double(CX(42,19));     ⇽---   ①靜態初始化

構造函數同樣遵守這條規則。假定構造函數聲明成了constexpr函數,且它的參數都是常量表達式,那麼所屬的類就會進行常量初始化​ ​[24]​ ​​,該初始化行為會在程序的靜態初始化​ ​[25]​ ​階段發生。隨着併發特性的引入,C++11為此規定了以上行為模式,這是標準的最重要一項修訂:讓用户自定義的構造函數擔負起靜態初始化工作,而在運行任何其他代碼之前,靜態初始化肯定已經完成,我們遂能避免任何牽涉初始化的條件競爭。

std::mutex類和std::atomic<>類(見3.2.1節和5.2.6節)的作用是同步某些變量的訪問,從而避免條件競爭,它們的功能可能要靠全局實例來實現,並且不少類的使用方式也與之相似,故上述行為特性對這些類的意義尤為重要。若std::mutex類的構造函數受條件競爭所累,其全局實例就無法發揮功效,因此我們將它的默認構造函數聲明成constexpr函數,以確保其初始化總是在靜態初始化階段內完成。

A.4.2constexpr對象

目前,我們已學習了關鍵字constexpr對函數的作用,它還能作用在對象上,主要目的是分析和診斷:constexpr限定符會查驗對象的初始化行為,核實其所依照的初值是常量表達式、constexpr構造函數,或由常量表達式構成的聚合體初始化表達式。它還將對象聲明為const常量。

constexpr int i=45;     ⇽---  ①正確
constexpr std::string s("hello");    ⇽---  ②錯誤,std::string不是字面值型別
int foo();
constexpr int j=foo();    ⇽---  ③錯誤,foo()並未聲明為constexpr函數

A.4.3constexpr函數要符合的條件

若要把一個函數聲明為constexpr函數,那麼它必須滿足一些條件,否則就會產生編譯錯誤。C++11標準對constexpr函數的要求如下。

  • 所有參數都必須是字面值型別。
  • 返回值必須是字面值型別。
  • 整個函數體只有一條return語句。
  • return語句返回的表達式必須是常量表達式。
  • 若return返回的表達式需要轉換為某目標型別的值,涉及的構造函數或轉換操作符必須是constexpr函數。

這些要求不難理解。constexpr函數必須能夠嵌入常量表達式中,而嵌入的結果依然是常量表達式。另外,我們不得改動任何值。constexpr函數是純函數(見4.4.1節),沒有副作用。

C++14標準大幅度放寬了要求,雖然總體思想保持不變,即constexpr函數仍是純函數,不產生副作用,但其函數體能夠包含的內容顯著增加。

  • 准許存在多條return語句。
  • 函數中創建的對象可被修改。
  • 可以使用循環、條件分支和switch語句。

類所具有的constexpr成員函數則需符合更多要求。

  • constexpr成員函數不能是虛函數。
  • constexpr成員函數所屬的類必須是字面值型別。

constexpr構造函數需遵守不同的規則。

  • 在C++11環境下,構造函數的函數體必須為空。而根據C++14和後來的標準,它必須滿足其他要求才可以成為constexpr函數。
  • 必須初始化每一個基類。
  • 必須初始化全體非靜態數據成員。
  • 在成員初始化列表中,每個表達式都必須是常量表達式。
  • 若數據成員和基類分別調用自身的構造函數進行初始化,則它們所選取​ ​[26]​ ​執行的必須是constexpr構造函數。
  • 假設在構造數據成員和基類時,所依照的初始化表達式為進行類型轉換而調用了相關的構造函數或轉換操作符,那麼執行的必須是constexpr函數。

這些規則與普通constexpr函數的規則一致,區別是構造函數沒有返回值,不存在return語句。然而,構造函數還附帶成員初始化列表,通過該列表初始化其中的全部基類和數據成員。平實拷貝構造函數是隱式的constexpr函數。

A.4.4constexpr與模板

如果函數模板或類模板的成員函數加上constexpr修飾,而在模板的某個特定的具現化中,其參數和返回值卻不屬於字面值型別,則constexpr關鍵字會被忽略。該特性讓我們可以寫出一種函數模板,若選取了恰當的模板參數型別,它就具現化為constexpr函數,否則就具現化為普通的inline函數,例如:

template<typename T>
constexpr T sum(T a,T b)
{
    return a+b;
}
constexpr int i=sum(3,42);    ⇽---  ①正確,sum<int>具有constexpr特性
std::string s=sum(std::string("hello"),
    std::string(" world"));    ⇽---  ②正確,但sum<std::string>不具備constexpr特性

具現化的函數模板必須滿足前文的全部要求,才可以成為constexpr函數。即便是函數模板,一旦它含有多條語句,我們就不能用關鍵字constexpr修飾其聲明;這仍將導致編譯錯誤​ ​[27]​ ​。

A.5lambda函數

lambda函數是C++11標準中一個激動人心的特性,因為它有可能大幅度簡化代碼,並消除因編寫可調用對象而產生的公式化代碼。lambda函數由C++11的新語法引入。據此,若某表達式需要一個函數,則可以等到所需之處才進行定義。std::condition_variable類具有幾個等待函數,它們要求調用者提供斷言(見4.1.1節範例),因此上述機制在這類場景中派上了大用場,因為lambda函數能訪問外部調用語境中的本地變量,從而便捷地表達出自身語義,而無須另行設計帶有函數調用操作符的類,再借成員變量捕獲必需的狀態。

最簡單的lambda表達式定義出一個自含的函數(self-contained function),該函數不接收參數,只依賴全局變量和全局函數,甚至沒有返回值。該lambda表達式包含一系列語句,由一對花括號標識,並以方括號作為前綴(空的lambda引導符):

[]{    ⇽---  
    do_stuff();    ⇽---  ①lambda表達式從[]開始
    do_more_stuff();    ⇽---  
}();    ⇽---  ②lambda表達式結束,並被調用

在本例中,lambda表達式後附有一對圓括號,由它直接調用了這個lambda函數,但這種做法不太常見。如果真要直接調用某lambda函數,我們往往會捨棄其函數形式,而在調用之處原樣寫出它所含的語句。在傳統泛型編程中,某些函數模板通過過參數接收可調用對象,而lambda函數則更常用於代替這種對象,它很可能需要接收參數或返回一個值,或二者皆有。若要讓lambda函數接收參數,我們可以仿照普通函數,在lambda引導符後附上參數列表。以下列代碼為例,它將vector容器中的全部元素都寫到std::cout,並插入換行符間隔:

std::vector<int> data=make_data();
std::for_each(data.begin(),data.end(),[](int i){std::cout<<i<<"\n";});

返回值的處理幾乎同樣簡單。如果lambda函數的函數體僅有一條返回語句,那麼lambda函數的返回值型別就是表達式的型別。以代碼清單A.4為例,我們運用簡易的lambda函數,在std::condition_variable條件變量上等待一個標誌被設立(見4.1.1節)。

代碼清單A.4一個簡易的lambda函數,其返回值型別根據推導確定

std::condition_variable cond;
bool data_ready;
std::mutex m;
void wait_for_data()
{
    std::unique_lock<std::mutex> lk(m);
    cond.wait(lk,[]{return data_ready;});    ⇽---  ①
}

①處有一個lambda函數傳入cond.wait(),其返回值型別根據變量data_ready的型別推導得出,即布爾值。一旦該條件變量從等待中被喚醒,它就繼續保持互斥m的鎖定狀態,並且調用lambda函數,只有充當返回值的變量data_ready為true時,wait()調用才會結束並返回。

假若lambda函數的函數體無法僅用一條return語句寫成,那該怎麼辦呢?這時就需要明確設定返回值型別。假若lambda函數的函數體只有一條return語句,我們就可自行選擇是否顯式設定返回值型別。假若函數體比較複雜,那該怎麼辦呢?就要明確設定返回值型別。設定返回值型別的方法是在lambda函數的參數列表後附上箭頭(→)和目標型別。如果lambda函數不接收任何參數,而返回值型別卻需顯式設定,我們依然必須使之包含空參數列表,代碼清單A.4條件變量所涉及的斷言可寫成:

cond.wait(lk,[]()->bool{return data_ready;});

只要指明瞭返回值型別,我們便可擴展lambda函數的功能,以記錄信息或進行更復雜的處理:

cond.wait(lk,[]()->bool{
    if(data_ready)
    {
        std::cout<<"Data ready"<<std::endl;
        return true;
    }
    else
    {
        std::cout<<"Data not ready, resuming wait"<<std::endl;
        return false;
    }
});

本例示範了一個簡單的lambda函數,儘管它具備強大的功能,可以在很大程度上簡化代碼,但lambda函數的真正厲害之處在於捕獲本地變量。

涉及本地變量的lambda函數

如果lambda函數的引導符為空的方括號,那麼它就無法指涉自身所在的作用域中的本地變量,但能使用全局變量和通過參數傳入的任何變量。若想訪問本地變量,則需先捕獲之。要捕獲本地作用域內的全體變量,最簡單的方式是改用lambda引導符“[=]”。改用該引導符的lambda函數從創建開始,即可訪問本地變量的副本。

我們來考察下面的簡單函數,以分析實際效果:

std::function<int(int)> make_offseter(int offset)
{
   return [=](int j){return offset+j;};
}

每當make_offseter()被調用,它都會產生一個新的lambda函數對象,包裝成std::function<>形式的函數而返回。該函數在自身生成之際預設好一個偏移量,在執行時再接收一個參數,進而計算並返回兩者之和。例如:

int main()
{
    std::function<int(int)> offset_42=make_offseter(42);
    std::function<int(int)> offset_123=make_offseter(123);
    std::cout<<offset_42(12)<<","<<offset_123(12)<<std::endl;
    std::cout<<offset_42(12)<<","<<offset_123(12)<<std::endl;
}

以上代碼會輸出“54,135”兩次,因為make_offseter()的第一次調用返回一個函數,它每次執行都會把傳入的參數與42相加。make_offseter()的第二次調用則返回另一個函數,它在運行時總是將外界提供的參數與123相加。

依上述方式捕獲本地變量最為安全:每個變量都複製出副本,因為我們能令負責生成的函數返回lambda函數,並在該函數外部調用它。這種做法並非唯一的選擇,還可以採取別的手段:按引用的形式捕獲全部本地變量。照此處理,一旦lambda函數脱離生成函數或所屬代碼塊的作用域,引用的變量即被銷燬,若仍然調用lambda函數,就會導致未定義行為。其實在任何情況下,引用已銷燬的變量都屬於未定義行為,lambda函數也不例外。

下面的代碼展示了一個lambda函數,它以“[&]”作為引導符,按引用的形式捕獲每個本地變量:

int main()
{
    int offset=42;    ⇽---  ①
    std::function<int(int)> offset_a=[&](int j){return offset+j;};    ⇽---  ②
    offset=123;     ⇽---  ③
    std::function<int(int)> offset_b=[&](int j){return offset+j;};     ⇽---  ④
    std::cout<<offset_a(12)<<","<<offset_b(12)<<std::endl;     ⇽---  ⑤
    offset=99;     ⇽---  ⑥
    std::cout<<offset_a(12)<<","<<offset_b(12)<<std::endl;    ⇽---  ⑦
}

在前面的範例中,make_offseter()函數生成的lambda函數採用“[=]”作為引導符,它捕獲的是偏移量offset的副本。然而,本例中的offset_a()函數使用的lambda引導符是“[&]”,通過引用捕獲偏移量offset②。偏移量offset的初值為42①,但這無足輕重。offset_a(12)的調用結果總是依賴於偏移量offset的當前值。偏移量offset的值隨後變成了123③。接着,代碼生成了第二個lambda函數offset_b()④,它也按引用方式捕獲本地變量,故其運行結果亦依賴於偏移量offset的值。

在輸出第一行內容的時候⑤,偏移量offset仍是123,故輸出是“135,135”。不過,在輸出第二行內容之前⑦,偏移量offset已變成了99⑥,所以這次的輸出是“111,111”。offset_a()函數和offset_b()函數的功效相同,都是計算偏移量offset的當前值(99)與調用時提供的參數(12)的和。

上面兩種做法對所有變量一視同仁,但lambda函數的功能不限於此,因為靈活、通達畢竟是C++與生俱來的特質:我們可以分而治之,自行選擇按複製和引用兩種方式捕獲不同的變量。另外,通過調整lambda引導符,我們還能顯式選定僅僅捕獲某些變量。若想按複製方式捕獲全部本地變量,卻針對其中一兩個變量採取引用方式捕獲,則應該使用形如“[=]”的lambda引導符,而在等號後面逐一列出引用的變量,併為它們添加前綴“&”。下面的lambda函數將變量i複製到其內,但通過引用捕獲變量j和k,因此該範例會輸出1239:

int main()
{
    int i=1234,j=5678,k=9;
    std::function<int()> f=[=,&j,&k]{return i+j+k;};
    i=1;
    j=2;
    k=3;
    std::cout<<f()<<std::endl;
}

還有另一種做法:我們可將按引用捕獲設定成默認行為,但以複製方式捕獲某些特定變量。這種處理方法使用形如“[&]”的lambda引導符,並在“&”後面逐一列出需要複製的變量。下面的lambda函數以引用形式捕獲變量i,而將變量j和k複製到其內,故該範例會輸出5688:

int main()
{
    int i=1234,j=5678,k=9;
    std::function<int()> f=[&,j,k]{return i+j+k;};
    i=1;
    j=2;
    k=3;
    std::cout<<f()<<std::endl;
}

若我們僅僅想要某幾個具體變量,並按引用方式捕獲,而非複製,就應該略去上述最開始的等號或“&”,且逐一列出目標變量,再為它們加上“&”前綴。下列代碼中,變量i和k通過引用方式捕獲,而變量j則通過複製方式捕獲,故輸出結果將是5682。

int main()
{
    int i=1234,j=5678,k=9;
    std::function<int()> f=[&i,j,&k]{return i+j+k;};
    i=1;
    j=2;
    k=3;
    std::cout<<f()<<std::endl;
}

最後這種做法肯定只會捕獲目標變量,因為如果在lambda函數體內指涉某個本地變量,它卻沒在捕獲列表中,將產生編譯錯誤。假定採取了最後的做法,而lambda函數卻位於一個類的某成員函數內部,那麼我們在lambda函數中訪問類成員時要務必注意。類的數據成員無法直接捕獲;若想從lambda函數內部訪問類的數據成員,則須在捕獲列表中加上this指針以捕獲之。下例中的lambda函數添加了this指針,才得以訪問類成員some_data:

struct X
{
    int some_data;
    void foo(std::vector<int>& vec)
    {
        std::for_each(vec.begin(),vec.end(),
            [this](int& i){i+=some_data;});
    }
};

在併發編程的語境下,lambda表達式的最大用處是在std::condition_variable::wait()的調用中充當斷言(見4.1.1節)、結合std::packaged_task<>包裝小任務(見4.2.1節)、在線程池中包裝小任務(見9.1節)等。它還能作為參數傳入std::thread類的構造函數,以充當線程函數(見2.1.1節),或在使用並行算法時(如8.5.1節示範的parallel_for_each())充當任務函數。

從C++14開始,lambda函數也能有泛型形式,其中的參數型別被聲明成auto,而非具體型別。這麼一來,lambda函數的調用操作符就是隱式模板,參數型別根據運行時外部提供的參數推導得出,例如:

auto f=[](auto x){ std::cout<<"x="<<x<<std::endl;};
f(42); // 屬於整型變量,輸出“x=42”
f("hello"); //  x的型別屬於const char*,輸出“x=hello”

C++14還加入了廣義捕獲(generalized capture)的概念,我們因此能夠捕獲表達式的運算結果,而不再限於直接複製或引用本地變量。該特性最常用於以移動方式捕獲只移型別,從而避免以引用方式捕獲,例如:

std::future<int> spawn_async_task(){
    std::promise<int> p;
    auto f=p.get_future();
    std::thread t([p=std::move(p)](){ p.set_value(find_the_answer());});
    t.detach();
    return f;
}

這裏的p=std::move(p)就是廣義捕獲行為,它將promise實例移入lambda函數,因此線程可以安全地分離,我們不必擔心本地變量被銷燬而形成懸空引用。Lambda函數完成構建後,原來的實例p即進入“移出狀態”(見A.1節),因此,我們事先從它取得了關聯的future實例。

A.6變參模板

變參模板即參數數目可變的模板。變參函數接收的參數數目可變,如printf(),我們對此耳熟能詳。而現在C++11引入了變參模板,它接收的模板參數數目可變。C++線程庫中變參模板無處不在。例如,std::thread類的構造函數能夠啟動線程(見2.1.1節),它就是變參函數模板,而std::packaged_task<>則是變參類模板(見4.2.1節)。從使用者的角度來説,只要瞭解變參模板可接收無限量的參數​ ​[28]​ ​,就已經足夠。但若我們想編寫這種模板,或關心它到底如何運作,還需知曉細節。

我們聲明變參函數時,需令函數參數列表包含一個省略號(...)。變參模板與之相同,在其聲明中,模板參數列表也需帶有省略號:

template<typename ...ParameterPack>
class my_template
{};

對於某個模板,即便其泛化版本的參數固定不變,我們也能用變參模板進行偏特化。譬如,std::packaged_task<>的泛化版本只是一個簡單模板,具有唯一一個模板參數:

template<typename FunctionType>      //此處的FunctionType沒有實際作用
class packaged_task;        //泛化的packaged_task聲明,並無實際作用

但任何代碼都沒給出該泛化版本的定義,它的存在是為偏特化模板​ ​[29]​ ​充當“佔位符”。

template<typename ReturnType,typename ...Args>
class packaged_task<ReturnType(Args...)>;

以上偏特化包含該模板類的真正定義。第4章曾經介紹,我們憑代碼std::packaged_task<int(std::string,double)>聲明一項任務,當發生調用時,它接收一個std::string對象和一個double類型浮點值作為參數,並通過std::future<int>的實例給出執行結果。

這份聲明還展示出變參模板的另外兩個特性。第一個特性相對簡單:普通模板參數(ReturnType)和可變參數(Args)能在同一聲明內共存。所示的第二個特性是,在packaged_task的特化版本中,其模板參數列表使用了組合標記“Args...”,當模板具現化時,Args所含的各種型別均據此列出。這是個偏特化版本,因而它會進行模式匹配:在模板實例化的上下文中,出現的型別被全體捕獲並打包成Args。該可變參數Args叫作參數包(parameter pack),應用“Args...”還原參數列表則稱為包展開(pack expansion)​ ​[30]​ ​。

變參模板中的變參列表可能為空,也可能含有多個參數,這與變參函數相同。例如,模板std::packaged_task<my_class()>中的ReturnType參數是my_class,而Args是空參數包,不過在模板std::packaged_task<void(int,double,my_class&,std::string*)>中,ReturnType屬於void型別,Args則是參數列表,由int、double、my_class&、std::string*共同構成。

展開參數包

變參模板之所以強大,是因為展開式能夠物盡其用,不侷限於原本的模板參數列表中的型別展開。首先,在任何需要模板型別列表的場合,我們均可以直接運用展開式,例如,在另一個模板的參數列表中展開:

template<typename ...Params>
struct dummy
{
    std::tuple<Params...> data;//tuple元組由C++11引入,與std::pair相似,但可含有多個元素
};

本例中,成員變量data是std::tuple<>的具現化,其內含型別全部根據上下文設定,因此dummy<int,double,char>擁有一個數據成員data,它的型別是std::tuple<int,double, char>。展開式能夠與普通型別結合:

template<typename ...Params>
struct dummy2
{
    std::tuple<std::string,Params...> data;
};

這次,tuple元組新增了一個成員(位列第一),型別是std::string。展開式大有妙用:我們可以創建某種展開模式,在隨後展開參數包時,針對參數包內各元素逐一複製該模式。具體做法是,在該模式末尾加上省略號標記,表明依據參數包展開。上面的兩個範例中,dummy類模板的參數包直接展開,其中的成員tuple元組據此實例化,所含的元素只能是參數包內的各種型別。然而,我們可以依照某種模式創建元組,使得其中的成員型別都是普通指針,甚至都是std::unique_ptr<>指針,其目標型別對應參數包中的元素。

template<typename ...Params>// ①[31]

展開模式可隨意設定成複雜的型別表達式,前提是參數包在型別表達式中出現,並且該表達式以省略號結尾,表明可依據參數包展開。

一旦參數包展開成多項具體型別,便會逐一代入型別表達式,分別生成多個對應項,最後組成結果列表。

若參數包含有3個型別int、int和char,那麼模板std::tuple<std::pair<std::unique_ptr <Params>,double>...>就會展開成std::tuple<std::pair<std::unique_ptr<int>,double>、std:: pair<std::unique_ptr<int>,double>、std::pair<std::unique_ptr<char>,double>>。假設模板的參數列表用到了展開式,那麼該模板就無須再用明文寫出可變參數;否則,參數包應準確匹配模板參數,兩者所含參數的數目必須相等。

template<typename ...Types>
struct dummy4
{
    std::pair<Types...> data;
};
dummy4<int,char> a;    ⇽---  ①正確,data的型別為std::pair<int,char>
dummy4<int> b;    ⇽---  ②錯誤,缺少第二項型別
dummy4<int,int,int> c;    ⇽---  ③錯誤,型別數目過量

展開式的另一種用途是聲明函數參數:

template<typename ...Args>
void foo(Args ...args);

這段代碼新建名為args的參數包,它是函數參數列表而非模板型別列表,與前文的範例一樣帶有省略號,表明參數包能夠展開。我們也可以用某種展開模式來聲明函數參數,與前文按模式展開參數包的做法相似。例如,std::thread類的構造函數正是採取了這種方法,按右值引用的形式接收全部函數參數(見A.1節):

template<typename CallableType,typename ...Args>
thread::thread(CallableType&&func,Args&& ...args);

一個函數的參數包能夠傳遞給另一個函數調用,只需在後者的參數列表中設定好展開式。與型別參數包展開相似,參數列表中的各表達式能夠套用模式展開,進而生成結果列表。下例是一種針對右值引用的常用方法,借std::forward<>靈活保有函數參數的右值屬性。

template<typename ...ArgTypes>
void bar(ArgTypes&& ...args)
{
    foo(std::forward<ArgTypes>(args)...);
}

請注意本例的展開式,它同時涉及型別包ArgTypes和函數參數包args,而省略號緊隨整個表達式後面。若按以下方式調用bar():

int i;
bar(i,3.141,std::string("hello "));

則會展開成以下形式:

template<>
void bar<int&,double,std::string>(
    int& args_1,
    double&& args_2,
    std::string&& args_3)
{
    foo(std::forward<int&>(args_1),
        std::forward<double>(args_2),
        std::forward<std::string>(args_3));
}

因此,第一項參數會按左值引用的形式傳入foo(),餘下參數則作為右值引用傳遞,準確實現了設計意圖。最後一點,我們通過sizeof...運算符確定參數包大小,寫法十分簡單:sizeof...(p)即為參數包p所含元素的數目。無論是模板型別參數包,還是函數參數包,結果都一樣。這很可能是僅有的情形——涉及參數包卻未附加省略號。其實省略號已經包含在sizeof...運算符中。下面的函數返回它實際接收的參數數目:

template<typename ...Args>
unsigned count_args(Args ...args)
{
    return sizeof...(Args);
}

sizeof...運算符求得的值是常量表達式,這與普通的sizeof運算符一樣,故其結果可用於設定數組長度,以及其他合適的場景中。

A.7自動推導變量的型別

C++是一門靜態型別語言,每個變量的型別在編譯期就已確定。而我們身為程序員,有職責設定每個變量的型別。有時候,這會使型別的名字相當宂長,例如:

std::map<std::string,std::unique_ptr<some_data>> m;
std::map<std::string,std::unique_ptr<some_data>>::iterator
    iter=m.find("my key");

傳統的解決方法是用typedef縮短型別標識符,並藉此解決類型不一致的問題。這種方法到今天依然行之有效,但C++11提供了新方法:若變量在聲明時即進行初始化,所依照的初值與自身型別相同,我們就能以關鍵字auto設定其類型。一旦出現了關鍵字auto,編譯器便會自動推導,判定該變量所屬型別與初始化表達式是否相同。上面的迭代器示例可以寫成:

auto iter=m.find("my key");

這只是關鍵字auto的最普通的一種用法,我們不應止步於此,我們還能讓它修飾常量、指針、引用的聲明。下面的代碼用auto聲明瞭幾個變量,並註釋出對應型別:

auto i=42;        // int
auto& j=i;        // int&
auto const k=i;   // int const
auto* const p=&i; // int * const

在C++環境下,只有另一個地方也發生型別推導:函數模板的參數。變量的型別推導沿襲了其中的規則:

some-type-expression-involving-auto var=some-expression;   //①

上面是一條聲明語句,定義了變量var並賦予了初值。其中,等號左邊是個涉及關鍵字auto的型別表達式​ ​[32]​ ​。再對比下面的函數模板,它也用相同的型別表達式聲明參數,只不過將auto改換成了模板的型別參數的名字。那麼,上例的變量var與下例的參數var同屬一種型別。

template<typename T>          //這一條語句與下一條語句是同一條語句的拆分寫法
void f(type-expression var);    //這條語句聲明瞭一個函數模板
f(some-expression);             //這條語句是一個函數調用

這使數組型別退化為指針,而且引用被略去,除非型別表達式將變量顯式聲明為引用,例如:

int some_array[45];
auto p=some_array;   // int*
int& r=*p;
auto x=r;            // int
auto& y=r;           // int&

變量的聲明因此簡化。如果完整的型別標識符過分宂長,甚至無從得知目標型別(如模板內部的函數調用的結果型別),效果就特別明顯。

A.8線程局部變量

在程序中,若將變量聲明為線程局部變量,則每個線程上都會存在其獨立實例。在聲明變量時,只要加入關鍵字thread_local標記,它即成為線程局部變量。有3種數據能聲明為線程局部變量:以名字空間為作用域的變量、類的靜態數據成員和普通的局部變量。換言之,它們具有線程存儲生存期(thread storage duration):

thread_local int x;    ⇽---  線程局部變量,它以名字空間為作用域

class X
{
    static thread_local std::string s;    ⇽---  類的靜態數據成員,也是線程局部變量,該語句用於聲明
};
static thread_local std::string X::s;    ⇽---  按語法要求定義X::s,該語句用於定義,類的靜態數據成員應在外部另行定義
void foo()
{
    thread_local std::vector<int> v;    ⇽---  普通的局部變量,也是線程局部變量
}

對於同一個翻譯單元​ ​[33]​ ​內的線程局部變量,假如它是類的靜態數據成員,或以名字空間為作用域,那麼在其初次使用之前應完成構造,但C++標準沒有明確規定構造行為的具體提前量。線程局部變量的構造時機因不同編譯器而異,某部分實現選擇的是線程啟動之際,某部分實現卻就每個線程分別處理,選擇的是該變量初次使用的前一刻,其他實現則設定別的時間點,或根據使用場景靈活調整。實際上,在給定的翻譯單元中,若所有線程局部變量從未被使用,就無法保證會把它們構造出來。這使得含有線程局部變量的模塊得以動態加載,當給定線程初次指涉模塊中的線程局部變量時,才進行動態加載,進而構造變量。

對於函數內部聲明的線程局部變量,在某個給定的線程上,當控制流程第一次經過其聲明語句時,該變量就會初始化。假設某函數在給定的線程上從來都沒有被調用,函數中卻聲明瞭線程局部變量,那麼在該線程上它們均不會發生構造。這一行為規則與靜態局部變量相同,但它對每個線程都單獨起作用。

線程局部變量的其他性質與靜態變量一致,它們先進行零值初始化,再進行其他變量初始化(如動態初始化​ ​[34]​ ​)。如果線程局部變量的構造函數拋出異常,程序就會調用std::terminate()而完全終止。

給定一個線程,在其線程函數返回之際,該線程上構造的線程局部變量全都會發生析構,它們調用析構函數的次序與調用構造函數的次序相反。由於這些變量的初始化次序並不明確,因此必須保證它們的析構函數間沒有相互依賴。若線程局部變量的析構函數因拋出異常而退出,程序則會調用std::terminate(),與構造函數的情形一樣。

如果線程通過調用std::exit()退出,或從main()自然退出(這等價於先取得main()的返回值,再以該值調用std::exit()),那麼線程局部變量也會被銷燬。當應用程序退出時,如果有其他線程還在運行,則那些線程上的線程局部變量不會發生析構。

線程局部變量的地址因不同線程而異,但我們依然可以令一個普通指針指向該變量。假定該指針的值源於某線程所執行的取址操作,那麼它指涉的目標對象就位於該線程上,其他線程也能通過這一指針訪問那個對象。若線程在對象銷燬後還試圖訪問它,將導致未定義行為(向來如此)。所以,若我們向另一個線程傳遞指針,其目標是線程局部變量,那就需要確保在目標變量所屬的線程結束後,該指針不會再被提取。

A.9類模板的參數推導

C++17拓展了模板參數的自動推導型別的思想:如果我們通過模板聲明一個對象,那麼在大多情況下,根據該對象的初始化表達式,能推導出模板參數的型別。

具體來説,若僅憑某個類模板的名字聲明對象,卻未設定模板參數列表,編譯器就會根據對象的初始化表達式,指定調用類模板的某個構造函數,還藉以推導模板參數,而函數模板也將發生普通的型別推導,這兩個推導機制遵守相同的規則。

例如,類模板std::lock_guard<>單獨接收一個模板參數,其型別是某種互斥類,該類模板的構造函數也接收唯一一個參數,它是個引用,所引用的目標對象的型別與模板參數對應。如果我們以類模板std::lock_guard<>聲明一個對象,並提供一個互斥用於初始化,那麼模板的型別參數就能根據互斥的型別推導出。

std::mutex m;
std::lock_guard guard(m); // 將推導出 std::lock_guard<std::mutex>

相同的推導機制也適用於std::scoped_lock<>,只不過它具有多個模板參數,可以根據多個互斥參數推導出。

std::mutex m1;
std::shared_mutex m2;
std::scoped_lock guard(m1,m2);  //將推導出std::scoped_lock<std::mutex,std:: shared_mutex>

某些模板的構造函數尚未完美契合推導機制,有可能推導出錯誤的型別。這些模板的作者可以明確編寫推導指南,以保證推導出正確的型別。但這些議題超出了本書的範疇。

A.10小結

C++11標準為語言增加了不少新特性,本附錄僅觸及皮毛,因為我們只着眼於有效推動線程庫演進的功能。C++11增加的新特性包括靜態斷言(static assertion/static_assert)、強類型枚舉(strongly typed enumeration/enum class)、委託構造(delegating constructor)函數、Unicode編碼支持、模板別名(template alias)和新式的統一初始化列表(uniform initialization sequence),以及許多相對細小的改變。本書的主旨並非詳細講解全部新特性,因為那很可能需要單獨編寫一本專著。C++14和C++17增加的新特性也不少,但這些新特性也超出了本書的範疇。截至本書編寫的時候,有兩份資料完整涵蓋了標準所做的改動,分別是網站cppreference整理的文檔和Bjarne Stroustrup編撰的C++11 FAQ,它們幾乎可以説是C++新特性的最佳概覽。還有不少C++參考書籍廣受歡迎,相信它們也會與時俱進,不斷修訂和更新。

本附錄涵蓋了部分C++新特性,希望它的深度足以充分展現這些新特性,演示出它們在線程庫中如何大展拳腳,兩者是如何密切關聯,也希望通過以上簡介,讀者能夠理解並運用新特性的多線程代碼,進而舉一反三,藉助這些特性編寫多線程程序。本附錄按一定深度講解了C++新特性,應該足夠讓讀者簡單地學以致用。雖説如此,畢竟這只是一份簡介,而非針對新特性的完整參考材料或自學教材。如果讀者有意大量使用C++新特性,我們建議購買專門的參考材料或自學教材,從而獲取事半功倍的學習效果。

​[1] ​ ​譯者注:這裏的棧和堆都指可執行程序的內存空間的某些特定部分。抽象數據結構中也有同名的概念,但它們的含義與這裏所提的不同。STL庫還提供了棧容器,它也有別於於此處的棧。

​[2] ​ ​譯者注:這裏的對象特指語言層面的數據實例(由C++標準文件給出定義),不同於“面向對象編程”中的抽象概念的對象,詳見5.1.1節。

​[3] ​ ​譯者注:字面值即literal,是代碼中明文寫出的具體數值,如“double a=1.6;”中的1.6,或下例中的42。

​[4] ​ ​譯者注:實際上,單憑右值傳參即可獨立實現移動語義。函數重載是為了兼容舊代碼,某些類尚不支持移動行為,依舊按傳統的左值形式傳遞參數。

​[5] ​ ​譯者注:本例要求維持數據不變,但後文作為對照的相關範例卻假定準許修改數據,顯然有失嚴謹。原書着眼於移動語義的實現方法和性能優勢,而忽略了其具體前提假設和實際功能需求。儘管如此,這只是在需求層面出現的前後不一,並不妨礙移動語義本身的實現和使用。

​[6] ​ ​譯者注:此處的右值特指前文的綁定常量的引用,而非C++11新特性的右值引用。

​[7] ​ ​譯者注:這裏為了講解移動語義,刻意採用右值引用傳參,但實際上,按傳統的非const左值引用傳參也能避免複製(直接引用原始數據,並不採用移動語義)。

​[8] ​ ​譯者注:按默認方式構造的std::thread對象不含實際數據,也不管控或關聯任何線程,請參考2.3節。

​[9] ​ ​譯者注:指移動操作在源對象上實際產生的效果。對於std::string類,C++標準僅要求移動操作在常數複雜度的時間內完成,卻沒有規定源數據上的實際效用如何。另外請注意,移動語義可能通過不同方式實現,不一定真正竊取數據,也不一定搬空源對象,請參考 Effective Modern C++ 中的條款29。

​[10] ​ ​譯者注:2020年3月,原書作者在自己的網站上發表了一篇技術博客,詳盡解釋了類的不變量,還闡述了它與移動語義的關聯,更深入分析了不變量在併發環境中種種情況下的破與立,是本書的重要補充,感興趣的讀者可自行查閲。

​[11] ​ ​譯者注:這一特性又稱“萬能引用”(universal reference),深入分析見 Effective Modern C++ 中的條款24。

​[12] ​ ​譯者注:左值的多重引用會引發摺疊,請參閲 Effective Modern C++ 中的條款28。

​[13] ​ ​譯者注:“默認構造函數”特指不接收任何參數的構造函數(或參數全都具備默認值)。該術語在C++11之前已長久存在,原意強調“根據規則自然成為默認”。本節的“=default”意指“按設計意圖人為指定成默認”,請注意區分。

​[14] ​ ​譯者注:平實函數即trivial function,其現實意義是默認構造函數和析構函數不執行任何操作;複製、賦值和移動操作僅僅涉及最簡單、直接的按位進行內存複製/內存轉移,而沒有任何其他行為;若對象所含的默認函數全是平實函數,就可依照Plain Old Data(POD)方式進行處理。

​[15] ​ ​譯者注:若要成為平實函數,函數自身及所屬的類都應符合一定條件,具體條件因各個函數而異,詳見C++官方標準文件ISO/IEC 14882:2011,12.1節第5款、12.4節第5款、12.8節第12款和第25款。

​[16] ​ ​譯者注:聚合體即aggregate,是C++11引入的概念,它通常可以是數組、聯合體、結構體或類(不得含有虛函數或自定義的構造函數,亦不得繼承自父類的構造函數,還要服從其他限制),其涵蓋範圍隨C++標準的演化而正在擴大。

​[17] ​ ​譯者注:靜態生存期(static storage duration)指某些對象隨整個程序開始而獲得存儲空間,到程序結束空間才被回收,這些對象包括靜態局部變量、靜態數據成員、全局變量等。

​[18] ​ ​譯者注:在本例中,等號右側先由默認構造函數生成一個臨時變量,左側再根據該變量創建變量x2並初始化(根據前面類型X的定義,變量x2按複製方式構造而成)。無論編譯選項是否採用任何優化設置,編譯器都會照此處理,不會發生賦值行為。詳見《C++程序設計語言第四版》16.2.6節。

​[19] ​ ​譯者注:更精確地説,這個性質按遞歸方式擴展,即基類的基類、成員的基類、基類的成員、基類的基類的成員、成員的成員的成員等均必須滿足要求。換言之,繼承關係與包含關係中的元素要全部符合條件:它們或屬於內建型別,或由編譯器生成默認構造函數。

​[20] ​ ​譯者注:本例的代碼是3個自定義默認構造函數,3個花括號是它們的函數體(內空,未進行任何操作),而非初始化列表。

​[21] ​ ​譯者注:本例主旨在於示範常量表達式,但它還牽涉另一特殊之處:靜態數據成員the_answer由表達式forty_two初始化,所在的語句既是聲明又是定義。作為靜態數據成員,其只許枚舉值和整型常量在類定義內部直接定義,而任意其他類型僅能聲明,且必須在類定義外部給出定義(參考下一個範例)。詳見C++官方標準文件ISO/IEC 14882:2011,9.4.2節。

​[22] ​ ​譯者注:字面值類型是C++11引入的新概念,是某些型別的集合,請注意與字面值區分,其是在代碼中明確寫出的值。

​[23] ​ ​譯者注:平實型別即trivial type,指默認構造函數、拷貝/移動構造函數、拷貝/移動賦值操作符、析構函數全都屬於平實函數的類型,參考A.3節。

​[24] ​ ​譯者注:常量初始化即constant initialization。在實踐中,常量往往在編譯期就完成計算,在運行期直接套用算好的值。

​[25] ​ ​譯者注:靜態初始化是指全局變量、靜態變量等在程序整體運行前完成初始化。

​[26] ​ ​譯者注:指存在多個構造函數重載的情形。

​[27] ​ ​譯者注:此處特指C++11的情形。在C++14中,constexpr()函數模板可以合法含有多條語句,前提是符合前文所列要求。

​[28] ​ ​譯者注:雖然C++標準的正文部分確實如此規定,但出於現實考慮(計算機資源畢竟有限),C++標準的附錄建議模板參數數目的最低上限為1024。各編譯器可能自行按其他限制給出實現,譬如微軟Visual C++ 2015的模板參數最多為2046個。詳見官方標準文件ISO/IEC 14882-2011附錄B。

​[29] ​ ​譯者注:按C++語法,任何模板都必須具備泛化形式的聲明,不能只以偏特化形式進行聲明。儘管泛化的packaged_task沒有實際作用,但作為聲明它不得省略,用途是告訴編譯器程序中存在名為packaged_task的模板。然而該泛化版本所含信息不足,無從確定模板的具體性質,而它的偏特化版本卻可以勝任,故詳細定義由後者負責。

​[30] ​ ​譯者注:後文中,包展開多指形如“Args...”的組合標記,譯為“展開式”以便理解。本節還有幾處出現“展開式結合模式”(pattern with the pack expansion),簡稱為“展開模式”。請讀者注意區別。

​[31] ​ ​譯者注:請注意對比①與②③處省略號的不同位置。①處省略號是變參模板聲明的語法成分,表示型別參數的數目可變,②③兩處的省略號標示出展開模式。②處的模式是型別表達式Params*,而③處的模式則是型別表達式std::unique_ptr<Params>。

​[32] ​ ​譯者注:some-type-expression-involving-auto意為“涉及關鍵字auto的某種型別表達式”,指auto、auto&、auto const或auto* const等,見前一個關於變量i、j、k、p的範例,而some-expression意為“某種表達式”。

​[33] ​ ​譯者注:翻譯單元即translation unit,是有關C++代碼編譯的術語,指當前代碼所在的源文件,以及經過預處理後,全部有效包含的頭文件和其他源文件。詳見C++官方標準文件ISO/IEC 14882:2011,2.1節。

​[34] ​ ​譯者注:動態初始化即dynamic initialization,指除非靜態初始化(指零值初始化和常量初始化)以外的一切初始化行為。

本文摘自:《C++併發編程實戰(第2版)》

這是一本介紹C++併發和多線程編程的深度指南。本書從C++標準程序庫的各種工具講起,介紹線程管控、在線程間共享數據、併發操作的同步、C++內存模型和原子操作等內容。同時,本書還介紹基於鎖的併發數據結構、無鎖數據結構、併發代碼,以及高級線程管理、並行算法函數、多線程應用的測試和除錯。本書還通過附錄及線上資源提供豐富的補充資料,以幫助讀者更完整、細緻地掌握C++併發編程的知識脈絡。

本書適合需要深入瞭解C++多線程開發的讀者,以及使用C++進行各類軟件開發的開發人員、測試人員,還可以作為C++線程庫的參考工具書。