“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++語言自身的復(fù)雜性议泵,也是因為C++要解決的問題領(lǐng)域的復(fù)雜性痴施。這導(dǎo)致C++是一門既要精通語法特征搏色,還要能熟練使用各種最佳實踐的語言。這些最佳實踐能保證你在優(yōu)雅的使用C++频轿,并幫助你規(guī)避各種意料之外的陷阱垂涯。
自從C++11推出以后烁焙,C++的版本升級就進入了快車道。如今C++14耕赘,C++17標(biāo)準(zhǔn)都已發(fā)布骄蝇,C++20也基本敲定,C++已然是一門嶄新的語言(modern C++
)操骡。新的C++標(biāo)準(zhǔn)不僅提供了更多強大的能力九火,也解決了很多歷史遺留問題和不足。面對語言提供的更多選擇当娱,C++程序員們需要與時俱進吃既,學(xué)習(xí)新的語法特性,還要同步刷新自己的C++最佳編碼實踐跨细。這些新的實踐可以幫你寫出更簡潔易讀的代碼,提高代碼的安全性河质,甚至可以低成本的收獲更高的運行時效率冀惭。
以下是一些常用的modern C++最佳實踐,看看你的C++技能是否還在與時俱進中掀鹅。
- 盡可能的使用
auto
代替顯式類型聲明
曾經(jīng)我們的最佳實踐是不要使用匈牙利命名散休,避免將變量的類型信息在變量名中重復(fù)。
如今我們更進一步:在變量聲明的時候最好連類型也不要寫出乐尊,而是盡量依賴編譯器的自動類型推導(dǎo)戚丸。
這不僅能讓你不必寫出typename std::iterator_trait<T>::value_type it
這樣的晦澀代碼,還能避免你寫出很多錯誤或者低效的代碼扔嵌。
auto
依賴于初始值進行類型推斷限府,所以強制你定義變量時必須進行初始化,這將會避免很多使用未初始化變量所帶來的悲劇痢缎。
auto x = 0 // Must be initialized as defined
下面的auto
則避免了手寫類型不匹配時在循環(huán)過程中產(chǎn)生的大量臨時對象的開銷胁勺。
std::unordered_map<std::string, int> m;
for (const auto&p : m) {
...
}
在C++14中,lambda的形參類型可以使用auto
独旷,這樣可以把同一個lambda表達式復(fù)用在更多的地方(如不同元素類型容器的算法中)署穗。
auto filter = [](const auto& item) {
...
}
在C++14中,普通函數(shù)或者模板函數(shù)的返回值可以使用auto
進行自動推導(dǎo)嵌洼,這極大的簡化了模板函數(shù)的寫法案疲。
template <typename A, typename B>
auto do_something(const A& a, const B& b)
{
return a.do_something(b);
}
記住,盡可能的使用auto
麻养,可以讓代碼更簡單褐啡、安全和高效。
- 盡量使用統(tǒng)一初始化
我們有個一直都很有用的最佳實踐是“變量定義時即初始化”回溺,現(xiàn)在補充一下初始化的方式:“盡量使用統(tǒng)一初始化
”春贸。
曾經(jīng)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引入了統(tǒng)一初始化:采用{}
為變量進行初始化。所以上述各種寫法可以統(tǒng)一為:
int x{0};
class Foo {
public:
Foo(int value) : v{value}{
}
private:
int v{0};
};
Foo f{5};
Foo f2{f1};
另外統(tǒng)一初始化還可以為容器初始化:
std::vector<int> v{1, 2, 3};
遺憾的是在C++11版本中萍恕,當(dāng)統(tǒng)一初始化應(yīng)用于auto
時逸嘀,auto x{3}
會被推導(dǎo)為std::initializer_list
類型,所以在以C++11標(biāo)準(zhǔn)為主流的社區(qū)中允粤,大家還都習(xí)慣于優(yōu)先使用傳統(tǒng)的()
或者=
進行初始化崭倘,然后選擇性的使用{}
。
如今C++17修復(fù)了這一問題类垫。
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
所以如果你的編譯器已經(jīng)支持C++17司光,現(xiàn)在則可以大膽的使用統(tǒng)一初始化了,這可以減少我們被各種不同初始化方式所帶來的腦細胞損耗悉患。
- 盡可能使用
constexpr
曾經(jīng)我們說“盡可能多使用const關(guān)鍵字”残家,今天我們同樣說“盡可能多使用constexpr關(guān)鍵字”。
雖然constexpr
和const
看起來很像售躁,但其實它們并無直接關(guān)系坞淮。
const
承諾的是對狀態(tài)的不修改,而constexpr
承諾的是對狀態(tài)的計算可以發(fā)生在編譯期陪捷。
在編譯期進行計算回窘,有諸多好處。除了可以把編譯期計算結(jié)果應(yīng)用于數(shù)組定義市袖、模板參數(shù)中啡直,還可以把計算結(jié)果放置在只讀內(nèi)存中,這對于嵌入式系統(tǒng)開發(fā)來說是非常重要的語言特性苍碟。
用constexpr
定義的函數(shù)酒觅,不僅可以發(fā)生在編譯期,也能發(fā)生在運行期驰怎,這取決于調(diào)用它的語境阐滩。
constexpr int pow(int base, int exp) noexcept {
auto result = 1;
for (int i = 0; i < exp; ++i) {
result *= base;
}
return result;
}
上面的pow
函數(shù)既可以用在編譯時int arr[pow(2, 3)]
,也可以用于運行時std::cout << pow(2, 3)
县忌。
由于C++14放寬了對constexpr
的限制掂榔,所以pow
的寫法和普通函數(shù)是一樣的(C++11中需要靠遞歸實現(xiàn))。
constexpr
不僅可以消除編譯期和運行期的代碼重復(fù)症杏,它也是提高系統(tǒng)運行時性能的一種手段装获。雖然這種運行時效率是用編譯時效率換來的,但是大多程序都是一次編譯多次運行厉颤。因此穴豫,和const
關(guān)鍵字一樣,如果有可能使用constexpr
,就使用它精肃。
- 掌握
三法則
和五法則
秤涩,但是盡可能應(yīng)用零法則
熟悉C++98的程序員都知道經(jīng)典的C++三法則,即“若某個類需要用戶自定義的析構(gòu)函數(shù)司抱、拷貝構(gòu)造函數(shù)或賦值運算符筐眷,則它幾乎肯定三者全部都需要自定義”。
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};
};
以上是一個實現(xiàn)非常拙劣的字符串封裝類习柠,它違反了三法則匀谣,自定義了析構(gòu)函數(shù),但是沒有對應(yīng)的自定義拷貝構(gòu)造函數(shù)和賦值運算符资溃。
這將導(dǎo)致下面代碼中s2使用編譯器默認生成的拷貝構(gòu)造函數(shù)武翎,對s1進行淺拷貝。于是s2和s1共享了同一個指針地址溶锭,當(dāng)s1或者s2中有一個被析構(gòu)宝恶,另一個對象將會持有一個失效指針,這往往是系統(tǒng)災(zāi)難的開始趴捅。
StringWrapper s1{"hello"};
StringWrapper s2{s1};
所以謹記三法則的程序員會為StringWrapper
同時定義拷貝構(gòu)造函數(shù)和賦值運算符卑惜。
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引入了移動語義!用戶定義的析構(gòu)函數(shù)驻售、拷貝構(gòu)造函數(shù)或賦值運算符會阻止移動構(gòu)造函數(shù)和移動賦值運算符的隱式定義,所以任何想要移動語義的類必須定義全部五個特殊成員函數(shù)更米。
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};
};
事實上想要全部正確的實現(xiàn)析構(gòu)函數(shù)欺栗、拷貝構(gòu)造函數(shù)、賦值運算符征峦、移動構(gòu)造函數(shù)和移動賦值運算符是需要花費一番心思的迟几,上述例子中就隱藏著好幾處缺陷。這就是為何C++核心規(guī)范中提出了C.20: If you can avoid defining default operations, do
栏笆,也就是我們說的零法則类腮。具體實施的辦法是:在實現(xiàn)你的類的時候,最好不要自定義析構(gòu)函數(shù)蛉加、拷貝構(gòu)造函數(shù)蚜枢、賦值構(gòu)造函數(shù)、移動構(gòu)造函數(shù)和移動賦值函數(shù)针饥,取而代之用C++智能指針和標(biāo)準(zhǔn)庫中的類來管理資源厂抽。
所以針對上述例子,直接使用std::string類丁眼,或者可以采用標(biāo)準(zhǔn)庫的容器輔助定義:
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;
};
因此筷凤,你應(yīng)當(dāng)熟悉modern C++的五法則,但是實踐的時候盡量遵循零法則苞七。
- 在某些必須拷貝的情況下藐守,考慮用傳值代替?zhèn)鬟f引用
曾經(jīng)C++的最佳實踐告訴我們挪丢,“為了避免函數(shù)傳參時的對象拷貝開銷,盡量選擇傳遞引用卢厂,最好是傳遞const引用”乾蓬。
C++11引入移動語義后,這一規(guī)則在某些時候需要做些調(diào)整:如果拷貝不能避免足淆,那么為了能夠統(tǒng)一代碼巢块,或者保證異常安全,優(yōu)先考慮用傳值代替?zhèn)鬟f引用巧号。
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=
函數(shù)采用按值傳遞參數(shù),不僅統(tǒng)一了普通賦值和移動賦值函數(shù)的實現(xiàn)丹鸿,而且還保證了異常安全性(先用臨時對象統(tǒng)一申請內(nèi)存越走,申請成功后才會進行swap)。
誠然靠欢,是否應(yīng)用這一規(guī)則和場景有關(guān)廊敌。但是當(dāng)你實現(xiàn)的函數(shù)既要處理左值也要處理右值,而處理左值時不可避免的要拷貝门怪,這時請考慮設(shè)計成傳值是否是個更好的選擇骡澈。
- 使用
nullptr
而非0
或NULL
曾經(jīng)的最佳實踐告訴我們,“不要直接使用0作為指針的空值”掷空,所以每個C++項目都會封裝自己的NULL
實現(xiàn)肋殴。
當(dāng)初C++11帶來了標(biāo)準(zhǔn)的空指針nullptr
,就是為了結(jié)束各種千奇百怪的NULL
實現(xiàn)坦弟。
NULL
的最大問題在于每種實現(xiàn)的類型都不一樣护锤,有的是int
,有的是double
酿傍,還有的是enum
烙懦,這不僅導(dǎo)致了各種參數(shù)傳遞時出現(xiàn)的轉(zhuǎn)型問題,還會影響模板的類型推演赤炒。
而nullptr
的類型是確定的std::nullptr_t
氯析,并且實現(xiàn)了向每種兼容類型的隱式轉(zhuǎn)換。它的安全性和兼容性要比過去的實現(xiàn)都好可霎。
現(xiàn)在你需要做的是將原來的NULL
定義做下修改魄鸦,例如將#define NULL int(0)
改為#define NULL nullptr
,然后讓編譯器幫你發(fā)現(xiàn)代碼中的潛在錯誤并進行修改癣朗。
- 為覆寫的虛函數(shù)加上
override
關(guān)鍵字
曾經(jīng)拾因,當(dāng)子類中覆寫的虛函數(shù)的方法名不小心拼寫錯誤的時候,如果父類中又提供了默認實現(xiàn),將會導(dǎo)致嚴(yán)重的邏輯錯誤绢记。而這般嚴(yán)重的錯誤往往只能等到程序運行后才能被發(fā)現(xiàn)扁达。
最終C++11為此提供了override
關(guān)鍵字。所以你應(yīng)該毫不猶豫地在每個被你覆寫的虛函數(shù)后面加上override
蠢熄,然后讓編譯器幫你檢查虛函數(shù)覆寫的錯誤跪解,將問題在程序發(fā)布前徹底解決掉。
- 保持持續(xù)學(xué)習(xí)和實踐
前面介紹了一些日常比較容易用到的modern C++最佳實踐签孔,當(dāng)然還有很多叉讥,包括一些高級語法的或者和標(biāo)準(zhǔn)庫有關(guān)的實踐,鑒于篇幅這里就不再介紹了饥追。
C++的設(shè)計哲學(xué)是能更好的解決現(xiàn)實中存在的問題图仓,新的語法的引入都是由現(xiàn)實問題驅(qū)動出來的。那些曾經(jīng)不能解決的但绕、或者解決得不夠優(yōu)雅的問題救崔,今天在新的C++標(biāo)準(zhǔn)中很可能都有了更好的解決方案。你應(yīng)該保持持續(xù)學(xué)習(xí)捏顺,時沉酰看看曾經(jīng)棘手的問題,是否已經(jīng)有了更優(yōu)解幅骄。
在寫這篇文章的時候我看到C++17的通過構(gòu)造函數(shù)進行類模板參數(shù)類型推導(dǎo)(CTAD)
劫窒,內(nèi)心就不禁一陣喜悅,這個特性可以讓我曾經(jīng)構(gòu)造的一個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>
除了不斷關(guān)注C++的特性演進外烛亦,還需要經(jīng)常檢查自己的編譯器,在可能的情況下更新編譯器版本懂拾。盡量使用新的語法,寫出更簡潔铐达、安全和高效的代碼岖赋。(C++各種編譯器版本和語法特性的對照表)
作為一名合格的程序員,我們以這樣一個最佳實踐結(jié)束:保持持續(xù)學(xué)習(xí)和實踐瓮孙。