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++執行緒庫的參考工具書。