“C makes it easy to shoot yourself in the foot; C++ makes it harder, but when you do it blows your whole leg off”
-- Bjarne Stroustrup's FAQ
使用C++的過程中要小心防備各種陷阱,這些陷阱不只是因為C++語言自身的復雜性,也是因為C++要解決的問題領域的復雜性。這導致C++是一門既要精通語法特征,還要能熟練使用各種最佳實踐的語言。這些最佳實踐能保證你在優雅的使用C++,并幫助你規避各種意料之外的陷阱。
自從C++11推出以后,C++的版本升級就進入了快車道。如今C++14,C++17標準都已發布,C++20也基本敲定,C++已然是一門嶄新的語言(modern C++
)。新的C++標準不僅提供了更多強大的能力,也解決了很多歷史遺留問題和不足。面對語言提供的更多選擇,C++程序員們需要與時俱進,學習新的語法特性,還要同步刷新自己的C++最佳編碼實踐。這些新的實踐可以幫你寫出更簡潔易讀的代碼,提高代碼的安全性,甚至可以低成本的收獲更高的運行時效率。
以下是一些常用的modern C++最佳實踐,看看你的C++技能是否還在與時俱進中。
- 盡可能的使用
auto
代替顯式類型聲明
曾經我們的最佳實踐是不要使用匈牙利命名,避免將變量的類型信息在變量名中重復。
如今我們更進一步:在變量聲明的時候最好連類型也不要寫出,而是盡量依賴編譯器的自動類型推導。
這不僅能讓你不必寫出typename std::iterator_trait<T>::value_type it
這樣的晦澀代碼,還能避免你寫出很多錯誤或者低效的代碼。
auto
依賴于初始值進行類型推斷,所以強制你定義變量時必須進行初始化,這將會避免很多使用未初始化變量所帶來的悲劇。
auto x = 0 // Must be initialized as defined
下面的auto
則避免了手寫類型不匹配時在循環過程中產生的大量臨時對象的開銷。
std::unordered_map<std::string, int> m;
for (const auto&p : m) {
...
}
在C++14中,lambda的形參類型可以使用auto
,這樣可以把同一個lambda表達式復用在更多的地方(如不同元素類型容器的算法中)。
auto filter = [](const auto& item) {
...
}
在C++14中,普通函數或者模板函數的返回值可以使用auto
進行自動推導,這極大的簡化了模板函數的寫法。
template <typename A, typename B>
auto do_something(const A& a, const B& b)
{
return a.do_something(b);
}
記住,盡可能的使用auto
,可以讓代碼更簡單、安全和高效。
- 盡量使用統一初始化
我們有個一直都很有用的最佳實踐是“變量定義時即初始化”,現在補充一下初始化的方式:“盡量使用統一初始化
”。
曾經C++在不同場合下可以分別使用()
和=
進行變量初始化。
int x = 0;
int y(0);
class Foo {
public:
Foo(int value) : v(value){
}
private:
int v = 0; // 這里不能寫作 int v(0)
};
Foo f1(5);
Foo f2(f1);
C++11引入了統一初始化:采用{}
為變量進行初始化。所以上述各種寫法可以統一為:
int x{0};
class Foo {
public:
Foo(int value) : v{value}{
}
private:
int v{0};
};
Foo f{5};
Foo f2{f1};
另外統一初始化還可以為容器初始化:
std::vector<int> v{1, 2, 3};
遺憾的是在C++11版本中,當統一初始化應用于auto
時,auto x{3}
會被推導為std::initializer_list
類型,所以在以C++11標準為主流的社區中,大家還都習慣于優先使用傳統的()
或者=
進行初始化,然后選擇性的使用{}
。
如今C++17修復了這一問題。
auto x1{ 1, 2, 3 }; // error: not a single element
auto x2 = { 1, 2, 3 }; // decltype(x2) is std::initializer_list<int>
auto x3{ 3 }; // decltype(x3) is int
auto x4{ 3.0 }; // decltype(x4) is double
所以如果你的編譯器已經支持C++17,現在則可以大膽的使用統一初始化了,這可以減少我們被各種不同初始化方式所帶來的腦細胞損耗。
- 盡可能使用
constexpr
曾經我們說“盡可能多使用const關鍵字”,今天我們同樣說“盡可能多使用constexpr關鍵字”。
雖然constexpr
和const
看起來很像,但其實它們并無直接關系。
const
承諾的是對狀態的不修改,而constexpr
承諾的是對狀態的計算可以發生在編譯期。
在編譯期進行計算,有諸多好處。除了可以把編譯期計算結果應用于數組定義、模板參數中,還可以把計算結果放置在只讀內存中,這對于嵌入式系統開發來說是非常重要的語言特性。
用constexpr
定義的函數,不僅可以發生在編譯期,也能發生在運行期,這取決于調用它的語境。
constexpr int pow(int base, int exp) noexcept {
auto result = 1;
for (int i = 0; i < exp; ++i) {
result *= base;
}
return result;
}
上面的pow
函數既可以用在編譯時int arr[pow(2, 3)]
,也可以用于運行時std::cout << pow(2, 3)
。
由于C++14放寬了對constexpr
的限制,所以pow
的寫法和普通函數是一樣的(C++11中需要靠遞歸實現)。
constexpr
不僅可以消除編譯期和運行期的代碼重復,它也是提高系統運行時性能的一種手段。雖然這種運行時效率是用編譯時效率換來的,但是大多程序都是一次編譯多次運行。因此,和const
關鍵字一樣,如果有可能使用constexpr
,就使用它。
- 掌握
三法則
和五法則
,但是盡可能應用零法則
熟悉C++98的程序員都知道經典的C++三法則,即“若某個類需要用戶自定義的析構函數、拷貝構造函數或賦值運算符,則它幾乎肯定三者全部都需要自定義”。
class StringWrapper final {
public:
StringWrapper(const char* s) {
if (s == nullptr) return;
std::size_t n = std::strlen(s) + 1;
cstring = new char[n];
std::memcpy(cstring, s, n);
}
~StringWrapper() {
if (cstring != nullptr) delete[] cstring;
}
private:
char* cstring{nullptr};
};
以上是一個實現非常拙劣的字符串封裝類,它違反了三法則,自定義了析構函數,但是沒有對應的自定義拷貝構造函數和賦值運算符。
這將導致下面代碼中s2使用編譯器默認生成的拷貝構造函數,對s1進行淺拷貝。于是s2和s1共享了同一個指針地址,當s1或者s2中有一個被析構,另一個對象將會持有一個失效指針,這往往是系統災難的開始。
StringWrapper s1{"hello"};
StringWrapper s2{s1};
所以謹記三法則的程序員會為StringWrapper
同時定義拷貝構造函數和賦值運算符。
class StringWrapper final {
public:
StringWrapper(const char* s) {
init(s);
}
StringWrapper(const StringWrapper& other)
{
init(other.cstring);
}
StringWrapper& operator=(const StringWrapper& other)
{
if(this != &other) {
delete[] cstring;
cstring = nullptr;
init(other.cstring);
}
return *this;
}
~StringWrapper() {
if (cstring != nullptr) delete[] cstring;
}
private:
void init(const char* s)
{
if (s == nullptr) return;
std::size_t n = std::strlen(s) + 1;
cstring = new char[n];
std::memcpy(cstring, s, n);
}
char* cstring{nullptr};
};
而C++11引入了移動語義!用戶定義的析構函數、拷貝構造函數或賦值運算符會阻止移動構造函數和移動賦值運算符的隱式定義,所以任何想要移動語義的類必須定義全部五個特殊成員函數。
class StringWrapper final {
public:
StringWrapper(const char* s) {
init(s);
}
StringWrapper(const StringWrapper& other)
{
init(other.cstring);
}
StringWrapper(StringWrapper&& other) noexcept
: cstring(std::exchange(other.cstring, nullptr)){
}
StringWrapper& operator=(const StringWrapper& other)
{
if(this != &other) {
delete[] cstring;
cstring = nullptr;
init(other.cstring);
}
return *this;
}
StringWrapper& operator=(StringWrapper&& other) noexcept
{
std::swap(cstring, other.cstring);
other.cstring = nullptr;
return *this;
}
~StringWrapper() {
if (cstring != nullptr) delete[] cstring;
}
private:
void init(const char* s)
{
if (s == nullptr) return;
std::size_t n = std::strlen(s) + 1;
cstring = new char[n];
std::memcpy(cstring, s, n);
}
char* cstring{nullptr};
};
事實上想要全部正確的實現析構函數、拷貝構造函數、賦值運算符、移動構造函數和移動賦值運算符是需要花費一番心思的,上述例子中就隱藏著好幾處缺陷。這就是為何C++核心規范中提出了C.20: If you can avoid defining default operations, do
,也就是我們說的零法則。具體實施的辦法是:在實現你的類的時候,最好不要自定義析構函數、拷貝構造函數、賦值構造函數、移動構造函數和移動賦值函數,取而代之用C++智能指針和標準庫中的類來管理資源。
所以針對上述例子,直接使用std::string類,或者可以采用標準庫的容器輔助定義:
class StringWrapper final {
public:
StringWrapper(const char* s) {
if (s == nullptr) return;
std::size_t n = std::strlen(s) + 1;
data.resize(n)
for (int i = 0; i < n; i++) {
data[i] = s[i];
}
}
private:
std::vector<char> data;
};
因此,你應當熟悉modern C++的五法則,但是實踐的時候盡量遵循零法則。
- 在某些必須拷貝的情況下,考慮用傳值代替傳遞引用
曾經C++的最佳實踐告訴我們,“為了避免函數傳參時的對象拷貝開銷,盡量選擇傳遞引用,最好是傳遞const引用”。
C++11引入移動語義后,這一規則在某些時候需要做些調整:如果拷貝不能避免,那么為了能夠統一代碼,或者保證異常安全,優先考慮用傳值代替傳遞引用。
class ResourceWrapper final {
public:
ResourceWrapper(const std::size_t size)
: resource {new char[size]}, size {size} {
}
~ResourceWrapper() {
if (resource != nullptr) {
delete [] resource;
}
}
ResourceWrapper(const ResourceWrapper& other)
: ResourceWrapper{other.size} {
std::copy(other.resource, other.resource + other.size, resource);
}
ResourceWrapper(ResourceWrapper&& other) noexcept {
swap(other);
}
ResourceWrapper& operator=(ResourceWrapper other) {
swap(other);
return *this;
}
private:
void swap(ResourceWrapper& other) noexcept {
std::swap(resource, other.resource);
std::swap(size, other.size);
}
char* resource{nullptr};
std::size_t size;
};
上面的代碼中,operator=
函數采用按值傳遞參數,不僅統一了普通賦值和移動賦值函數的實現,而且還保證了異常安全性(先用臨時對象統一申請內存,申請成功后才會進行swap)。
誠然,是否應用這一規則和場景有關。但是當你實現的函數既要處理左值也要處理右值,而處理左值時不可避免的要拷貝,這時請考慮設計成傳值是否是個更好的選擇。
- 使用
nullptr
而非0
或NULL
曾經的最佳實踐告訴我們,“不要直接使用0作為指針的空值”,所以每個C++項目都會封裝自己的NULL
實現。
當初C++11帶來了標準的空指針nullptr
,就是為了結束各種千奇百怪的NULL
實現。
NULL
的最大問題在于每種實現的類型都不一樣,有的是int
,有的是double
,還有的是enum
,這不僅導致了各種參數傳遞時出現的轉型問題,還會影響模板的類型推演。
而nullptr
的類型是確定的std::nullptr_t
,并且實現了向每種兼容類型的隱式轉換。它的安全性和兼容性要比過去的實現都好。
現在你需要做的是將原來的NULL
定義做下修改,例如將#define NULL int(0)
改為#define NULL nullptr
,然后讓編譯器幫你發現代碼中的潛在錯誤并進行修改。
- 為覆寫的虛函數加上
override
關鍵字
曾經,當子類中覆寫的虛函數的方法名不小心拼寫錯誤的時候,如果父類中又提供了默認實現,將會導致嚴重的邏輯錯誤。而這般嚴重的錯誤往往只能等到程序運行后才能被發現。
最終C++11為此提供了override
關鍵字。所以你應該毫不猶豫地在每個被你覆寫的虛函數后面加上override
,然后讓編譯器幫你檢查虛函數覆寫的錯誤,將問題在程序發布前徹底解決掉。
- 保持持續學習和實踐
前面介紹了一些日常比較容易用到的modern C++最佳實踐,當然還有很多,包括一些高級語法的或者和標準庫有關的實踐,鑒于篇幅這里就不再介紹了。
C++的設計哲學是能更好的解決現實中存在的問題,新的語法的引入都是由現實問題驅動出來的。那些曾經不能解決的、或者解決得不夠優雅的問題,今天在新的C++標準中很可能都有了更好的解決方案。你應該保持持續學習,時常看看曾經棘手的問題,是否已經有了更優解。
在寫這篇文章的時候我看到C++17的通過構造函數進行類模板參數類型推導(CTAD)
,內心就不禁一陣喜悅,這個特性可以讓我曾經構造的一個Promise斷言庫的DSL寫的更加的精煉。
// Example of CTAD
template <typename T = float>
struct Container {
T val;
Container() : v() {}
Container(T v) : v(v) {}
// ...
};
Container c1{ 1 }; // Container<int>
Container c2; // Container<float>
除了不斷關注C++的特性演進外,還需要經常檢查自己的編譯器,在可能的情況下更新編譯器版本。盡量使用新的語法,寫出更簡潔、安全和高效的代碼。(C++各種編譯器版本和語法特性的對照表)
作為一名合格的程序員,我們以這樣一個最佳實踐結束:保持持續學習和實踐。