學懂現代C++——《Effective Modern C++》之轉向現代C++

發表於2023-09-28

前言

現代C++中像auto、智慧指標、移動語義等都是一些重大的最佳化特性,但也有一些像constexpr、nullptr等等這樣一個小的特性。這章的內容就是這些小特性的集合。

條款7:在建立物件時注意區分()和{}

在現代C++中有3種方式來以指定的值初始化物件,分別時小括號、等號和大括號:

int x(0);  //初始化值在小括號中
int y = 0; //初始化值在等號後
int z{0};  //初始化值在大括號中

其中,大括號形式的初始化時C++11引入的統一初始化方式。大括號初始化可以應用的語境最為寬泛,可以阻止隱式窄化的型別轉換,還對最令人苦惱的解析語法免疫。

先說阻止隱式窄化的型別轉換,比如下面程式碼可以透過編譯:

double x,y,z;

int sum1(x+y+z);  //可以透過編譯,表示式的值被截斷為int
int sum2 = x+y+z; //同上

而以下程式碼不可以透過編譯,因為大括號初始化禁止內建型別直接進行隱式窄化型別的轉換。

int sum3{x+y+z};  //編譯不透過

再說最令人苦惱的解析語法免疫。C++規定:任何能夠解析為宣告的都要解析為宣告,而這會帶來副作用。所謂最令人庫娜的解析語法就是——程式設計師本來想要以預設方式構造一個物件,結果卻不小心宣告瞭一個函式。舉個例子,我想呼叫一個沒有形參的Widget建構函式,如果寫成Widget w();,那結果就變成了宣告瞭一個函式(名為w,返回一個Widget型別物件)而非物件。而用大括號初始化Widget w{};就不存在這個問題了。

但是,不能盲目的都使用大括號初始化。在建構函式被呼叫時,只要形參中沒有任何一個具備std::initializer_list型別,那麼大括號和小括號沒有區別 ;如果又一個或多個建構函式宣告瞭任何一個具備std::initializer_list型別的形參,那麼採用了大括號初始化語法的呼叫語句會強烈地優先選用帶有std::initializer_list型別形參的過載版本。也就是說,因為std::initializer_list的存在,大括號初始化和小括號初始化會產生大相徑庭的結果。

這點最突出的例子是:使用兩個實參來建立一個std::vector<數值型別>物件。std::vector有一個兩個引數的建構函式,允許指定容器的初始大小(第一個引數),以及所有元素的初始值(第二個引數);但它還有一個std::initializer_list型別形參的建構函式。如果要建立一個元素為數值型別的std::vector(比如std::vector<int>),並且傳遞兩個實參給建構函式,那麼使用大括號和小括號初始化的差別就比較大了:

std::vector<int> v1(10, 20); //建立一個含有10個元素的vector,所有元素的初始值都是20

std::vector<int> v1{10, 20}; //建立一個含有2個元素的vector,元素的值分別時1,20

所以,如果是作為一個類的作者,最好把建構函式設計成客戶無論使用小括號還是大括號都不會影響呼叫得過載版本才好。

條款8:優先選用nullptr,而非0或NULL

因為0和NULL都不是指標型別,而nullptr才是真正的指標型別。比如在過載指標型別和整型的函式時,如果使用0或者NULL呼叫這樣的過載函式,則永遠不會呼叫到指標型別的過載版本,只有使用nullptr才能呼叫到。當然為了相容我們仍然需要遵循C++98的指導原則:避免在整型和指標型別之間過載。

條款9:優先選用別名宣告,而非typedef

C++11提供了別名宣告來替換typedef,兩者作用在大部分情況下是一樣的。比如下面的typedef:

typedef std::unique_ptr<<std::unordered_map<std::string, std::string>>> UPtrMapSS;

typedef void (*FP)(int, const std::string&);

可以用下面的別名宣告來替換:

using UPtrMapSS = std::unique_ptr<<std::unordered_map<std::string, std::string>>>;

using FP = void (*)(int, const std::string&);

但還有有一種場景是隻能使用別名宣告的,那就是在定義模板的時候,typedef不支援模板化,但別名宣告支援。在C++98中需要用巢狀在模板化的struct裡的typedef才能達到相同效果。比如下面這段:

template<typename T>
struct MyAllocList {
    typedef std::list<T, MyAlloc<T>> type; //MyAllocList<T>::type是std::list<T, MyAlloc<T>>的同義詞
};

MyAllocList<Widget>::type lw; //客戶程式碼

在C++11中用別名宣告就很簡單了:

template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>; //MyAllocList<T>是std::list<T, MyAlloc<T>>的同義詞

MyAllocList<Widget> lw; //客戶程式碼

這裡還可以看到,別名模板可以讓人免寫“::type”字尾。並且在模板內,對於內嵌typedef的引用經常要求加上typename的字首,而別名模板沒有這個要求。

條款10:優先選用限定作用域的列舉型別,而非不限作用域的列舉型別

推薦優先選用C++11提供的限定作用域的列舉型別有3個理由。第一,它可以降低名字空間的汙染,因為限定作用域的列舉型別僅在列舉型別內可見。比如下面C++98的程式碼會報錯:

enum Color { black, white, red}; // black、white、red和Color所在作用域相同

auto white = false; // 編譯報錯!white在前面已經宣告

而類似程式碼選用限定作用域的列舉型別則不會有問題:

enum class Color { black, white, red}; // black、white、red所在作用域限定在Color內

auto white = false; // 沒有問題

第二,它的列舉量是更強型別的,只能透過強制型別轉換以轉換為其他型別。這樣可以避免奇怪的使用列舉值與數值型別比較的程式碼,真要使用時也必須進行一次強制轉換來提醒這裡有一個別扭的比較。

第三,限定作用域的列舉型別總是可以進行前置宣告,而不限作用域的列舉型別卻只有在指定了預設底層型別的前提下才可以進行前置宣告。

還有一點需要記住,這兩種列舉型別都支援指定底層型別。限定作用域的列舉型別預設底層型別是int。而不限作用域的列舉型別則沒有預設底層型別,編譯器會為列舉型別選擇足夠表示列舉值的最小型別,這也是為什麼它不能直接進行前置宣告,在沒定義前編譯器無法確認底層型別的。

條款11:優先選用刪除函式,而非private未定義函式

C++11提供了使用“=delete”的方法將想阻止客戶呼叫得函式標識為刪除函式的方法,用以替代C++98中傳統的將這些函式宣告為private的方法。

刪除函式的一個重要優點在於,任何函式都能成為刪除函式,包括非成員函式和模板的具現。比如,我想定義一個判斷是否是幸運數字的函式,因為隱式轉換的存在會有一些奇怪的呼叫,而將他們定義為刪除函式後就可以阻止這些奇怪的呼叫:

bool isLucky(int number);
bool isLucky(char) = delete;
bool isLucky(bool) = delete;
bool isLucky(double) = delete;

// 下面奇怪的呼叫無法透過編譯
if (isLucky('a')) ...
if (isLucky(true)) ...
if (isLucky(3.5)) ...

事實上,C++98中把函式宣告為private並且不去定義,這樣的實踐想要的就是C++11中的刪除函式實際達到的效果。前者作為後者的一種模擬動作,當然不如本尊來的好用。比如,前者無法應用於類外部的函式,也不總是能夠應用於類內部的函式(類內部的函式模板)。就是它能用,也可能直到連結階段才發揮作用。所以,請始終使用刪除函式。

條款12:為意在改寫的函式新增override宣告

由於對於宣告派生類中的改寫,保證正確性很重要,而出錯又很容易,C++11提供了一種方法來顯示地標識派生類中的函式時為了改寫基類版本:為其加上override。這樣如果派生類中的改寫出錯,編譯器在編譯階段就會報錯。

它還有一個好處就是可以在你打算更改基類中虛擬函式的簽名時,衡量以下波及的影響面。

條款13:優先選用const_iterator,而非iterator

const_iterator是STL中相當於指涉到const的指標的等價物。它們指涉到不可被修改的值。

C++11中獲取和使用const_iterator相比於C++98變得很容易了。容器的成員函式cbegin和cend都返回const_iterator型別,甚至對於非const容器也是如此,並且STL成員函式若要取用指示位置的迭代器(例如,作插入或刪除只用),它們也要求使用const_iterator型別。下面是一段C++11中使用const_iterator的示例程式碼:

std::vector<int> values;

auto it = std::find(values.cbegin(), values.cend(), 1983);
values.insert(it, 1988);

條款14:只要函式不會丟擲異常,就為其加上noexcept宣告

當你明知道一個函式不會丟擲異常卻未給它加上noexcept宣告的話,這就是介面規格缺陷。對於不會丟擲異常的函式應用noexcept宣告還有一個動機,那就是它可以讓編譯器生成更好的目的碼。相對於不帶noexcept宣告的函式,它有更多機會的得到最佳化。

noexcept性質對於移動操作,swap、記憶體釋放函式和解構函式最有價值。預設地,記憶體釋放函式和所有的解構函式都隱式地具備noexcept性質。

大多數函式都是異常中立的。此類函式自身並不丟擲異常,但它們呼叫得函式則可能會丟擲異常。當這種情況真的發生時,異常中立函式會允許該異常經由它傳至呼叫棧的更深一層。異常中立函式用於不具備noexcept性質,因為它們可能會丟擲這種“路過”的異常。

條款15:只要有可能使用constexpr,就使用它

constexpr物件都具備const屬性,並由編譯期已知的值完成初始化。所有的constexpr物件都是const物件,而並非所有的const物件都是constexpr物件。

constexpr函式在呼叫時若傳入的實參值時編譯器已知的,則會產生編譯器結果。如果傳入的值有一個或多個在編譯期未知,則它的運作方式和普通函式無異,亦即它也是在執行期執行結果的計算。

在C++11中,constexpr函式不得包含多餘一個可執行語句,即一條return語句。但在C++14中,這種限制被大大地放寬了,可以有多條語句。

條款16:保證const成員函式的執行緒安全性

保證const成員函式的執行緒安全性,除非可以確信它們不會用在併發語境中。

運用std::atomic型別的變數會比運用互斥量有更好的效能,因為其開銷往往較小。

對於單個要求同步的變數或記憶體區域,使用std::atomic就足夠了。但是如果有兩個或更多個變數或記憶體區域需要作為以整個單位進行操作時,就要動用互斥量了。

條款17:理解特種成員函式的生成機制

特種成員函式是指那些C++會自行生成的成員函式:預設建構函式、解構函式、複製操作和移動操作。其中移動操作時C++11新增的,包括兩個成員——移動建構函式和移動賦值運算子。示例如下:

class Widget {
public:
...
Widget(Widget&& rhs);               // 移動建構函式
Widget& operator=(Widget&& rhs);    // 移動賦值運算子
}

C++11中,特種成員函式的生成機制如下:

  • 預設建構函式:與C++98的機制相同。僅當類中不包含使用者宣告的建構函式時才生成。
  • 解構函式:與C++98的機制基本相同,唯一的區別在於解構函式預設為noexcept。與C++98的機制相同的是,僅當基類的解構函式為虛的,派生類的解構函式才是虛的。
  • 複製建構函式和複製賦值運算子:執行期行為與C++98相同——按成員進行非靜態資料成員的複製構造和複製賦值。複製建構函式僅當類中不包含使用者宣告的複製建構函式時才生成,如果該類宣告瞭移動操作則複製建構函式將被刪除。複製賦值運算子僅當類中不包含使用者宣告的複製賦值運算子時才生成,如果該類宣告瞭移動操作則複製賦值運算子將被刪除。在已經存在顯示宣告的解構函式的條件下,生成複製操作已經成為了被廢棄的行為。
  • 移動建構函式和移動賦值運算子:都按成員進行非靜態資料成員的移動操作。僅當類中不包含使用者宣告的複製操作、移動操作和解構函式時才生成。

綜上,如果想宣告一個基類,且提供預設的移動操作和複製操作,則需要如下定義:

class Base {
public:
    virtual ~Base() = default;
    Base(Base&&) = default; //提供移動操作
    Base& operator=(Base &&) = default;
    
    Base(const Base&) = default; //提供複製操作
    Base& operator=(const Base &) = default;
}

這裡解釋一下:通常情況下,虛解構函式的預設實現就是正確的,而“=default”則是表達這一點的很好方式。不過,一旦使用者宣告瞭解構函式,移動操作的生成就被抑制了,而如果可移動性是能夠支援的,加上“=default”就能夠再次給予編譯器以生成移動操作的機會。宣告移動操作又會廢除複製操作,所以如果還要可複製性,就再加一輪“=default”。

還有一點需要注意的是,成員函式模板在任何情況下都不會已知特種成員函式的生成。

相關文章