這個翻譯的內容真不錯,分享給大家。 翻譯原文鏈接在這里:https://zhuanlan.zhihu.com/p/427778091 推薦大家直接看原文。
干貨開始:
這本書的副標題是:45ish Simple Rules with Specific Action items for better C++ ,這本書是由公司大佬推薦的, 個人認為有必要掌握一下這45條最佳實踐, 可以很大程度上提升代碼的可讀性和健壯性, 而且這本書也不長, 翻譯起來也會比較簡單,比很多人推薦的Effective C++ 要容易讀的多, 抽了一個十一的尾巴就翻譯完了,easy~ 翻譯的過程中去掉了歐美人寫書時喜歡帶上的口水話,從而讓文章更加精煉。++我翻譯的時候會帶上一些自己的理解,這部分我用下劃線標出來了, 注意區分++。
1. 導讀
我以一個訓練者的目標希望各位能夠:
學習如何自己做實驗;
不要僅僅信我說的話,要親手實驗;
學習到這門語言是如何工作的;
在下輪寫代碼的時候避免犯錯;
2. 有關"最佳實踐"
最佳實踐實際上就是說:1. 減少常見的錯誤;2. 能夠更快定位錯誤 3. 提升運行性能
為什么要最佳實踐? 因為你的項目并不是一個特殊的項目
如果你或者你同事在用C++編程,他們往往是在乎性能的,否則他們會用一些其它的語言。我經常去一些公司他們都告訴我他們的項目是特殊的,因為他們想要快速完成功能。
警告:他們都在出于相同的原因做著相同的決定。很少有例外, 例外者其實都是那些已經遵循這本書的組織。
3.使用工具:自動測試
你需要一個單獨的指令去跑所有的測試,如果你沒有自動測試, 沒有人會跑這些測試的:
Catch2
DocTest
GoogleTest
Boost.Test
Ctest 是一個Cmake下用來測試的runner, 它可以很好地利用 Cmake的 add_test 特性。你需要很熟悉測試工具、搞清楚他們是怎么做的、并且從他們中選擇一個。
沒有自動化的測試, 這本書的其它部分就是沒有意義的,在重構代碼的時候,如果你不能驗證你沒有破壞現有的代碼, 你就不能使用這些最佳實踐。
Oleg Rabaev 說過:
==如果一個部分是很難測試的, 那它肯定沒被設計好。如果一個組成部分是容易測試的, 那就意味它是有被很好的設計的。反過來說,一個好的設計,應該是一個容易被測試的設計。==
4.使用工具:持續構建
沒有自動化的測試, 那就很難去保證代碼的質量。在我生涯中的一些C++ 項目中,我的C++項目支持各種操作系統、各架構的組合。當你開始在不同平臺、架構上組合不同的編譯器的時候, 那就很有可能出現在一個平臺能夠使用在另外一個平臺上不能使用的情況。為了解決這個問題, 使用帶有持續測試的持續構建工具吧。
測試你將支持的所有平臺的組合。
將debug和最終發布分離
測試所有的配置項。
測試所有你需要支持的編譯器
5.使用工具:編譯器警告
有許多警告你也許都沒有在使用, 大多數警告實際上都是有好處的。-Wall 并不是GCC和Clang提供的所有warning, -Wextra
仍然只是warning中的冰山一角。
強烈考慮使用 -Wpedantic(GCC/Clang)
以及 /permissive(msvc)
, 這些編譯選項能夠進制語言拓展項,并且讓你更加接近于C++ 標準, 你今天開啟越多的warning, 你以后就越容易遷移到其它平臺。
6.使用工具:靜態分析
靜態分析工具可以在不編譯和運行你的代碼的情況下來分析你的代碼, 你的編譯器實際上就是這么一個工具并且是你的第一道代碼質量防線。許多這種工具都是免費的并且是開源的, CPPCheck 和clang-tidy是兩種流行并且免費的工具, 大多數IDE和編輯器都能夠支持。
7.使用工具:sanitizers
sanitizers是集成在gcc clang msvc下的一種運行時分析工具。如果你比較熟悉Valgrind, sanitizers提供了相似的功能但快了好幾個數量級。
Address sanitizer, UB Sanitizer, Thread sanitizer 可以找到很多像魔法一樣的問題, 在寫這本書的時候,msvc正在支持越來越多的sanitizer, gcc和clang 擁有更多對sanitizer的支持。
John Reghr 推薦在開發的時候永遠開啟ASan( Address Sanitizer) 和UBSan(Undefined Behaviour Sanitizer) 。
當類似于內存溢出的錯誤發生時, sanitizer 將會給你一個報告告訴你什么情況下會導致程序失敗, 通常還會給出修復問題的建議。
你可以用如下的命令開啟ASan 和UBSan:
gcc -fsanitize=address,undefined <filetocompile>
8.慢下來
在C++中,一個問題往往有很多解決方案, 對于哪種方案是最優的有若干個觀點存在。從被人的項目里面復制粘貼代碼是非常容易的, 使用你覺得最滿意的的方案去解決問題也是非常容易的(但這兩種都是要避免的), 要注意了:
如果解決方案似乎很復雜很大,停下。這時候是一個很好的時間去走走然后好好思考這個方案。當你結束了散步, 把你的設計方案和同事討論一下,或者用橡皮鴨思考方法講出來(++所謂橡皮鴨方法就是你對著一個橡皮鴨一五一十地說出你的思考過程和邏輯,從中可以察覺到自己的思維漏洞等++) 如果你還沒有發現一個直接的解決方法?去twitter上面問問吧。最關鍵的其實是不要盲目地去用你自己覺得滿意的方案去解決問題, 要多停一段時間多思考。隨著年齡的增長,我花在編程的時間越來越少,但是思考的時間卻越來越多, 最后我實現解決方案的速度比以前越來越快,并且越來越簡單。
9.C++ 不是魔法
這部分只是提醒你我們可以對C++的各個方面進行推理, 它并不是一個黑盒,也并不是一個魔法。
如果你有問題, 你通常可以很簡單地去寫個實驗代碼,它將自己回答你的這些問題。
10.C++ 不是純面向對象的語言
Bjarne Stroustrup 在"The C++ Programming Language" 的第三版里面講到:
==C++ 是一個通用的編程語言但是偏向于系統編程, 它比C更好, 支持數據抽象, 支持面向對象, 支持泛型編程。==
你必須明白C++ 是一種多科目語言, 它很好的支持了當下的所有編程范式:
過程式
函數式
面向對象
泛型編程
運行時編程(constexpr 以及模板元變成) 理解什么時候去用這些工具是寫好C++的關鍵, 那些只執著用某一個編程范式的項目往往會錯過這個語言的最佳特性。
11.學習一門其他語言
考慮到C++并不是一個純面向對象的語言, 你必須掌握一些其它技術才能更好地使用C++,最好學會一門函數式語言比如Haskell.
12.const/constexpr 修飾一切常量
許多大佬說過這很多次了, 讓一個對象修飾為const
有兩個好處:
它迫使我們去思考這個對象的的初始化和生命周期, 這會影響程序的性能。可以向代碼的讀者傳達這是一個常量的意義 另外,如果它是一個static對象,編譯器現在可以自由地將它移到內存的常量區,這可能會影響優化器的行為。
13.constexpr 修飾一切在編譯階段就已知的值
使用# define
的日子早就一去不復返了, constexpr
應該成為你新的默認選擇。很不幸的是, 人們總是過份高估constexpr的復雜程度, 所以就讓我們把它拆解成最簡單的東西說起吧~
如果你看到像這樣的一些東西:
在編譯時期,數據就已經知道是static const 對象了:
static const std::vector<int> angles{-90,-45,0,45,90}
那這種情況就需要把它變成:
static constexpr std::array<int,5> angles{-90,-45,0,45,90}
static constexpr
在這里可以保證對象不會在每次這個函數/聲明遇到時都會重新初始化。由于static修飾了這個變量, 它將存在于整個程序的生命周期, 而且我們知道它不會被初始化兩次。
這兩個代碼的區別有三:
這個array的大小我們是能夠在編譯時期知道的
我們溢出了數組大小動態分配
我們不需要再對訪問static 對象付出額外代價(++這點我沒明白啥意思,原文是 We no longer pay the cost of accessing a static++) 主要收益來源于前兩點。
14.在大多數情況下使用auto來自動推斷類型
我其實并不是一個永遠使用auto的人, 但是讓我來問你一個問題:std::count 函數返回的類型是什么?
我的回答是:我不在乎。
const auto result = std::count( /* stuff */ );
````
使用auto 避免沒有必要的轉換和數據丟失, 同樣的場景經常在range-for 循環中出現,再舉個栗子:
```C++
有可能發生代價很高的轉換
const std::string value = get_string_value();
get_string_value()
的返回類型是什么?如果它是一個std::string_view
或者是一個const char *
, 我們將會有一個潛在的高代價的類型轉化。
不可能有高代價的性能轉化的寫法:
// 避免類型轉化
const auto value = get_string_value();
另外, auto作為返回值實際上可以大幅度簡化泛型代碼:
// C++98 template usage
template<typename Arithmetic>
Arithmetic divide(Arithmetic numerator, Arithmetic denominator) {
return numerator / denominator;
}
這個代碼強制我們分子的類型和分母的類型都是同一種類型,非常難受。但在C++98 里面怎么實現分子分母不同類型呢?
template<typename Numerator, typename Denominator>
/*what's the return type*/
divide(Numerator numerator, Denominator denominator) {
return numerator / denominator;
}
可以看到,我們沒有辦法提供返回值的類型, C++98沒有提供這種問題的解決方法,但是C++11通過尾置返回類型可以做到:
// use trailing return type
template<typename Numerator, typename Denominator>
auto divide(Numerator numerator, Denominator denominator)
-> decltype(numerator / denominator)
{
return numerator / denominator;
}
但是在C+14里面, 我們還可以把返回類型給省去,
// use trailing return type
template<typename Numerator, typename Denominator>
auto divide(Numerator numerator, Denominator denominator){
return numerator / denominator;
}
15.使用ranged-for循環而不是老的循環方法
我會用幾個例子來說明這一點:
當循環時, int
和 std::size_t
的問題
for (int i = 0; i < container.size(); ++i) {
// 糟糕,i 實際上不是int類型,是size_t類型,有類型不一致的問題
}
當循環時, 容器類型不一致的問題:
for (auto itr = container.begin();itr != container2.end();++itr) {
// 哦,我們大多數人都有過這種經歷
}
使用ranged-for的例子
for (const auto &element : container) {
// 消除了以上的兩種問題
}
注意:永遠不要在用ranged-for的時候修改容器自身
16.使用ranged-for時配合auto使用
不使用auto會讓你更容易不經意間犯一些錯誤:
意外的類型轉換:
for (const int value : container_of_double){
// 意外的轉換,很有可能會報warning
}
意外的繼承切片問題
for (const Base value : container_of_Derived){
// 意外的發生了繼承切片問題
}
正確做法
for (const auto &value : container){
// 不會發生意外的問題
}
優先考慮:
對于內部元素不可變類型的循環時選擇 const auto &
對于內部元素可變類型的循環時選擇 auto &
auto && 當且僅當你需要一些奇怪的類型比如std::vector 時, 或者將元素移出容器時(++這個地方沒懂, 原文:auto && only when you have to work with weird types like std::vector, or if moving elements out of the container++)
17.使用算法而不是循環
算法能夠傳達更多的含義并且能夠符合”const 一切" 的思想, 在C++20 中, 我們有ranges, 這會使得算法用起來更加舒服。
使用函數的方式并且配合使用算法, 這會使C++ 的代碼讀起來更想是一個句子。
比如,檢查一個container內是否有一個大于12的數:
const auto has_value = std::any_of(begin(container), end(container), greater_than(12));
在極少數情況,編譯器的靜態分析工具可以能夠提示你有現有的算法能夠使用。
18.不要害怕使用模板
模板能夠表現 C++ 中的DRY原則(Dont repeat yourself) , 模板它可能是復雜的,令人生畏的, 并且是圖靈完備的, 但是它們也不必如此。15年前, 業界似乎有一個盛行的態度是:“模板就不是給正常人寫的"。
幸運的是, 這種觀點放在今天越來越不正確了, 現在我們用更多的工具:concepts, generic lanbdas 等等。
我們將在接下來寫一個簡單的例子。假設我們想要寫一個函數它可以除任意兩個值:
// 除兩個double 類型的數
double divide(double numerator, double denominator){
return numerator / denominator;
}
// 你不希望分子的類型被提升到double類型:
float divide ( float numerator, float denominator){
return numerator / denominator;
}
// 當然,你還想兩個int類型的相除
int divide ( int numerator, int denominator){
return numerator / denominator;
}
// template 就是為了這種情況而設計的:
// 最基礎的template
template<typename T>
T divide(T numerator, T denominator){
return numerator / denominator;
}
// 大多數的例子都用T來表示, 就像我剛才做的那樣,但是不要這么做,給你的類型一個有意義的名字:
template<typename Arithmetic>
Arithmetic divide(Arithmetic numerator, Arithmetic denominator){
return numerator / denominator;
}
19.不要copy paste代碼
如果你發現自己正在選中一塊代碼并且復制它, 停下!
后退一步并且再看下這些代碼:
為什么你要復制它?
這個代碼和你的目標代碼有多少相似?
構造一個函數有意義嗎?
記住, 不要害怕使用模板!
我發現這個簡單的條例可以對我的代碼質量有著最直接的影響, 如果我們即將在當前的函數中進行粘貼一段代碼, 那就考慮使用lambda 。C++14 的lambda 配合上泛型參數(也叫auto),可以讓你更加容易寫出可復用的代碼還不要處理template 語法。
20.遵循“零法則"
在正確的情況下,沒有析構函數總是更好的。空的析構函數會損失一部分性能,并且:
它會讓類型不再簡單;
沒有函數用途;
會影響內聯的析構;
不經意間禁止了移動操作。如果你提供了一個自定義的刪除行為, std::unique_ptr 可以幫助你遵循0法則
21.如果你一定手動管理資源, 遵循“五法則”
如果你需要提供一個自定義的析構函數,那你必須同時對其它特殊成員函數進行 =delete, =default,或者實現它們。這個規則一開始叫“三法則”, 在C++11后變成了“五法則”
// 特殊的成員函數
struct S {
S(); // default constructor
// does not affect other special member functions
// If you define any of the following, you must deal with
// all the others.
S(const S &); // 拷貝構造
S(S&&); // 移動構造
S &operator=(const S &); // 拷貝賦值
S &operator=(S &&); // 移動賦值
};
當你不知道怎么去處理它們的時候,=delete
對于這些特殊的成員函數來說是一個非常安全的處理方法.
當你在聲明帶有虛函數的基類時,你也應該遵循”五法則“:
struct Base {
virtual void do_stuff();
// because of the virtual function we know this class
// is intended for polymorphic use, therefore our
// tools will tell us to define a virtual destructor
virtual ~Base() = default;
// and now we need to declare the other special members
// a good safe bet is to delete them, because properly and safely
// copying or assigning an object via a reference or pointer
// to a base class is hard / impossible
S(S&&) = delete;
S(const &S) = delete;
S &operator=(const S &) = delete;
S &operator=(S &&) = delete;
};
struct Derived : Base {
// We don't need to define any of the special members
// here, they are all inherited from `Base`.
}
22.不要調用未定義的行為(UB行為)
現在我們知道有很多未定義的行為是很難追蹤的, 在接下來的幾節中我將給出一些例子。最重要的事情是你需要理解,未定義行為的存在會破壞你整個程序。
一個符合規范的實現在運行一個格式良好的程序時,應該產生可以觀測的行為,相同的程序和相同的輸入應該產生與之相對應的行為。
但是, 如果一個程序中包含著一個未定義的行為, 這段代碼對執行輸入的程序也就沒有要求。(甚至對第一個未定義操作之前的操作也沒有要求)
如果你有未定義的行為, 整個程序的執行就會變得很詭異。
23.不要判斷this 為nullptr,這是UB行為
int Class::member() {
if (this == nullptr) {
// removed by the compiler, it would be UB
// if this were ever null
return 42;
} else {
return 0;
}
}
嚴格意義上說,這并不是對未定義行為的校驗。但是這個校驗不可能會失敗, 如果this等于nullptr
, 那你將會處于一個UB行為的狀態中。人們過去經常經常這么做,但這永遠是一個UB行為。你不能從一個對象的生命周期以外去訪問一個對象。從理論上來說, this=null的唯一可能就是你在調用一個null對象的成員。
24.不要判斷對象的引用是nullptr,這是UB行為
int get_value(int &thing) {
if (&thing == nullptr) {
// removed by compiler
return 42;
} else {
return thing;
}
}
不要嘗試它,這是一個UB行為, 永遠認為引用所指向的是一個存在的對象, 在你設計API時可以合理使用這一點。
25.避免在switch語句中使用default
這個問題可以用以下一系列的例子來闡明, 從這個開始:
enum class Values {
val1,
val2
};
std::string_view get_name(Values value) {
switch (value) {
case val1: return "val1";
case val2: return "val2";
}
}
如果你開啟了所有warning
, 你將會得到一個"not all code paths return a value" 的警告,這從技術上說是正確的。
我們可以調用 get_name(static_cast<Values>(15))
, 這并不違法任何C++的規定, 但函數不返回任何值這一點是個UB行為。
你可以嘗試這樣修復代碼:
enum class Values {
val1,
val2
};
std::string_view get_name(Values value) {
switch (value) {
case val1: return "val1";
case val2: return "val2";
default: return "unknown";
}
}
但是呢這樣會引入一個新的問題:
enum class Values {
val1,
val2,
val3, // 這里增加一個值
};
std::string_view get_name(Values value) {
switch (value) {
case val1: return "val1";
case val2: return "val2";
default: return "unknown";
}
// 編譯器不會診斷出來val3沒有處理
}
實際上,我們更傾向于這樣的代碼:
enum class Values {
val1,
val2,
val3, // 這里增加一個值
};
std::string_view get_name(Values value) {
switch (value) {
case val1: return "val1";
case val2: return "val2";
} // 沒有處理的enum 值這里會有warning
return "unknown"
}
26. 使用帶作用域的枚舉值
C++11 引入了帶作用域的枚舉值, 目的是為了解決很多從C繼承過來的問題。
C++98 enums
enum Choices {
option1 // value in the global scope
};
enum OtherChoices {
option2
};
int main() {
int val = option1;
val = option2; // no warning
}
enum Choices
和OtherChoices
它們倆很容易被搞混, 并且它們引入了全局命名空間下的標識符。
enum class Choices;
enum class OtherChoices;
在這些枚舉類中的值都是帶有限定作用域的,并且更加強類型。
C++11 scoped enumeration
enum class Choices {
option1
};
enum class OtherChoices {
option2
};
int main() {
int val = option1;
int val2 = Choices::option1;
Choices val = Choices::option1;
val = OtherChoices::option2;
}
這個帶enum class的版本沒有花太多功夫就可以讓它們不那么容易被搞混, 并且它們的標識符現在是帶作用域的,并不是全局的。
enum Struct
和 enum class
其實是等價的,只是邏輯上enum struct更加合理, 因為它的成員是public公開的。
27. 使用if constexpr 而不是SFINAE(Substituion Failure Is Not An Error)
SFINAE 是一種很難讀懂的代碼(++不懂SFINAE的參考知乎的這個文章文章) , if constexpr 沒有SFINAE那么靈活,但是當你能用它的時候就盡量用它.
我們來看一下之前在Prefer auto in Many Cases的文章中舉的相除的例子:
template<typename Numerator, typename Denominator>
auto divide(Numerator numerator, Denominator denominator)
{
return numerator / denominator;
}
好, 那當我們要做整數相除的時候, 我們現在想要加一個不同的行為該怎么做呢?在C++17以前, 我們會使用SFINAE("Substitution Failure is not An Error") 這個特性。基本意思就是說, 如果一個函數無法編譯, 那它就會從重載解析中刪除。舉個例子:
SFINAE 版本的刪除
#include <stdexcept>#include <type_traits>#include <utility>
template <typename Numerator, typename Denominator,
std::enable_if_t<std::is_integral_v<Numerator> &&
std::is_integral_v<Denominator>,int> = 0>
auto divide(Numerator numerator, Denominator denominator) {
// is integer division
if (denominator == 0) {
throw std::runtime_error("divide by 0!");
}
return numerator / denominator;
}
template <typename Numerator, typename Denominator,
std::enable_if_t<std::is_floating_point_v<Numerator> ||
std::is_floating_point_v<Denominator>,
int> = 0>
auto divide(Numerator numerator, Denominator denominator) {
// is floating point division
return numerator / denominator;
}
C++17 的 if constexpr 語法可以簡化這個代碼:
#include <stdexcept>#include <type_traits>#include <utility>
template <typename Numerator, typename Denominator>
auto divide(Numerator numerator, Denominator denominator) {
if constexpr (std::is_integral_v<Numerator> && std::is_integral_v<Denominator>) {
// is integral division
if (denominator == 0) {
throw std::runtime_error("divide by 0!");
}
}
return numerator / denominator;
}
注意,if constexpr塊中的代碼在語法上仍然必須是正確的. if constexpr 跟#define是不一樣的。
28. 用Concepts約束你的模板參數(C++20)
Concepts比起SFINAE會給你帶來更好的報錯信息以及更快的編譯時間, 另外還會比SFINAE有更好的可讀性。我們繼續構建我們上一章的例子 , 這是上一章用if constexpr 寫出來的代碼:
#include <stdexcept>#include <type_traits>#include <utility>
template <typename Numerator, typename Denominator>
auto divide(Numerator numerator, Denominator denominator) {
if constexpr (std::is_integral_v<Numerator> && std::is_integral_v<Denominator>) {
// is integral division
if (denominator == 0) {
throw std::runtime_error("divide by 0!");
}
}
return numerator / denominator;
}
用concept我們可以把它分解成兩個不同的函數。Concepts可以在許多不同的場景下使用, 這個版本在函數聲明以后用了一個簡單的requires從句:
#include <stdexcept>#include <type_traits>#include <utility>
// overload resolution will pick the most specific version
template <typename Numerator, typename Denominator>
auto divide(Numerator numerator, Denominator denominator) requires
(std::is_integral_v<Numerator>&& std::is_integral_v<Denominator>) {
// is integral division
if (denominator == 0) {
throw std::runtime_error("divide by 0!");
}
return numerator / denominator;
}
template <typename Numerator, typename Denominator>
auto divide(Numerator numerator, Denominator denominator) {
return numerator / denominator;
}
這個版本用了concepts作函數參數, C++20 甚至還有“auto concept" , 這是個隱式模板函數。
#include <stdexcept>#include <concepts>
auto divide(std::integral auto numerator,
std::integral auto denominator) {
// is integer division
if (denominator == 0) {
throw std::runtime_error("divide by 0!");
}
return numerator / denominator;
}
auto divide(auto numerator, auto denominator){
return numerator / denominator;
}
29. 將你的泛型代碼去模板化
盡可能地將代碼移出模板之外, 使用其它函數或者使用基類都可以,編譯器仍然可以內聯它們。(并不是不用模板,而是模板內的代碼盡可能移出去)
去模板化可以提高編譯速度并且減少二進制文件的大小,二者都很有用。它還可以消除模板膨脹。
每次函數模板實例化時都會生成一個新lambda函數
template<typename T>
void do_things()
{
// this lambda must be generated for each
// template instantiation
// 這個lambda函數在每個模板實例化的時候都會被生成一次
auto lambda = [](){ /* some lambda that doesn't capture */ };
auto value = lambda();
}
與之相對應的是
auto some_function(){ /* do things */
template<typename T>
void do_things()
{
auto value = some_function();
}
現在只編譯了一個版本的內部邏輯,編譯器決定它們是否應該內聯。在基類和模板派生類中會用到相似的技巧。
30. 使用Lippincott 函數
與“去模板化"相同的道理, 這是在異常處理中的一個DRY(Dont't repeat yourself) 原則。如果你有很多異常類型需要處理, 你可能會寫出如下代碼:
void use_thing() {
try {
do_thing();
} catch (const std::runtime_error &) {
// handle it
} catch (const std::exception &) {
// handle it
}
}
void use_other_thing() {
try {
do_other_thing();
} catch (const std::runtime_error &) {
// handle it
} catch (const std::exception &) {
// handle it
}
}
Lippincott 函數提供了一種中心化的異常處理套路:
void handle_exception() {
try {
throw; // re-throw exception already in flight
} catch (const std::runtime_error &) {
} catch (const std::exception &) { }
}
void use_thing() {
try {
do_thing();
} catch (...) {
handle_exception();
}
}
void use_other_thing() {
try {
do_other_thing();
} catch (...) {
handle_exception();
}
}
這個小技巧不是啥新東西, 它早在C++98時就可以使用了。
31. 擔心全局狀態(Global State)
對全局狀態進行推理是很難的, 任何非const
的static
值或者std::shared_ptr
都可能是一個潛在的全局狀態, 你永遠不知道誰可能會更新這個值或者它是否是線程安全的。
當一個函數改變了一個全局狀態時,它會導致不易察覺的并且很難去追溯的bug, 另一個函數要么會依賴這個變化,要么會受到它的負面影響。
32. 讓你的接口很難用錯
你的接口是你的第一道防線, 如果你提供了一個很容易用錯的接口, 你的用戶就會錯誤地使用它。如果你提供了一個很難用錯的接口, 你的用戶就會很難去把它用錯。但這是C++, 他們總能找到辦法的。
設計一個很難用錯的接口有時會導致代碼的冗長, 你必須選擇哪一個是最重要的, 是正確的代碼還是短的代碼?
33. 考慮如果調用錯了API是否會導致UB錯誤
你是否接受一個空指針?它是否是個可選參數?如果一個nullptr
傳入你的函數會發生什么?如果一個異常范圍的值傳入你的函數會發生什么?有些開發者會在內部接口和外部接口里面做個區分, 他們允許一些不安全的API在內部接口中使用。
不過你能保證外部的使用者永遠不調用內部的API嘛?你能保證內部使用者永遠不誤用API嘛?
34. 大量使用[[nodiscard]]
[[nodiscard]]
(C++17提供) 是一個C++的特性,它告訴編譯器如果返回值被放棄了則需要警告,
它可以用在函數上:
[[nodiscard]] int get_value();
int main()
{
// warning, [[nodiscard]] value ignored
get_value();
}
可以用在類型上:
struct [[nodiscard]] ErrorCode{};
ErrorCode get_value();
int main()
{
// warning, [[nodiscard]] value ignored
get_value();
}
35. 使用強類型
考慮一下POSIX socket的API
socket(int, int, int);
每個參數代表:
類型
協議
域名 這個設計是有問題的, 但是我們代碼里面隱藏了更多比這個更不明顯的問題。比如一個構造函數:
Rectangle(int, int, int, int);
這個函數可能是指(x,y,width, height)
, 也有可能是指(x1, y1, x2, y2)
, 或者不太可能但是仍然有可能出現的是含義是(width, height, x, y)
那你認為下面這個API怎么樣呢?
強類型API
struct Position {、
int x;
int y;
};
struct Size {
int width;
int height;
};
struct Rectangle {
Position position;
Size size;
};
Rectangle(Position, Size);
這可以延伸出來其它的具有操作符重載的組合語句:
// Return a new rectangle that has been
// moved by the offset amount passed in
Rectangle operator+(Rectangle, Position);
避免bool類型的參數
這章的預發布讀者指出, steve Maguire 在他的《編寫可靠的代碼》的第五章中說過:"要讓代碼在調用時易于理解“。在C++11中, enum class 提供給了你一種很容易的方法去添加強類型,從而避免布爾類型的參數, 這會使你的API更難用錯。
考慮以下代碼:
參數順序不明顯
struct Widget {
// this constructor is easy to use wrong, we
// can easily transpose the parameters
Widget(bool visible, bool resizable);
}
與如下代碼相比:
強類型帶作用域的枚舉值
struct Widget {
enum struct Visible { True, False };
enum struct Resizable { True, False };
// still possible to use this wrong, but MUCH harder
Widget(Visible visible, Resizable resizable);
}
36. 不要返回裸指針
返回一個裸指針會讓讀者和使用者很難去思考明白它的所有權。選擇引用智能指針, 非歸屬指針包裝器(?++沒明白,原文是non owning pointer wrapper++) ,或者考慮可選引用(++沒明白是啥,原文是optional reference++)。
返回一個裸指針的函數示例
int *get_value();
誰擁有這個返回值?是我嗎?當我用完它,我是否需要去delete掉這個指針?
或者考慮一種更壞的情況, 如果這個內存使用malloc來分配的,我是不是需要調用free來釋放它?
這是個指向單個int值的指針,還是一個int數組?
這個代碼有太多問題了, 甚至用[[no discard]]
都不能幫助我們
37. 優先用棧而不是堆
棧對象(非動態分配的作用域在本地的對象)對于優化器更加友好、緩存更加友好、并且可能被優化器完全刪除。正如Bj?rn Fahller所言, ”假設任何指針間接指向都是一次cache miss”
用最簡單的話來說:
ok,用棧并且這可以被優化
std::string make_string() {
return "Hello World";
}
不好, 使用了堆
std::unique_ptr<std::string> make_string() {
return std::make_unique<std::string>("Hello World");
}
OK
void use_string() {
// This string lives on the stack
std::string value("Hello World");
}
非常糟糕的寫法, 用了堆還內存泄露
void use_string() {
// The string lives on the heap
std::string *value = new std::string("Hello World");
}
記住,std::string
本身也會在內部分配內存, 并且用的是堆, 如果你的目標是不用任何堆, 你需要用一些其它方法來打到。其實我們的目標是不分配不必要的堆。
總體來說, 用new
創建的對象(或者用make_unique
或者make_shared
創建的對象) 都是堆對象, 并且擁有動態存儲周期(Dynamic Storage Duration), 在本地作用域下創建的對象都是棧對象, 并且具有自動存儲周期(Automactic Storage Duration)
38. 不要再使用new
你已經避免使用堆并且使用智能指針來管理內存資源了對吧。再進一步, 在少數一些你需要用堆的情況下,請確保使用 std::make_unique<>()(C++14)
,在很少見的情況下你需要共享對象的所有權,這時候使用std::make_shared<>()(c++11)
39. 了解你的容器
優先以這種順序選擇你的容器:
std::array<>
std::vector<>
std::array
一個固定大小的分配在棧上的連續容器, 數據的多少必須在編譯時期知道, 你必須擁有足夠的棧空間去承載數據。這個容器可以幫助我們優先使用棧而不是堆。已知的位置以及內存連續性會讓std::array<>是一個“負成本抽象"(negative cost abstraction)", 因為編譯器知道數據的大小和位置, 它可以用額外的一系列優化手段來優化。
std::vector
一個動態大小分配在堆上的連續容器, 盡管編譯器不知道數據最終會駐留在哪里, 但他知道元素的在內存中是緊密布局的。內存的連續性給了編譯器更多的優化空間并且對緩存更加友好。
幾乎任何其它事情都需要評論和解釋原因, 對于小型的容器, 帶有線性搜索的map可能比std::map
性能要更好。但是別對這點太癡迷了, 如果你需要kv查找, 用std::map
并且評估一下它是否有你想要的性能表現和特性。
40. 避免使用std::bind和std::function
盡管編譯器繼續提升,優化器也在繼續解決這些類型的復雜性, 這些仍然有很有可能去增加編譯時間和運行時間的開銷。C++14 的lambda
, 具有廣義的捕獲表達式功能, 能夠做到和std::bind
同樣的事情。
用std::bind
去改變參數的順序
#include <functional>
double divide(double numerator, double denominator) {
return numerator / denominator;
}
auto inverted_divide = std::bind(divide, std::placeholders::_2,std::placeholders::_1);
用lambda去改變參數的順序
#include <functional>
double divide(double numerator, double denominator) {
return numerator / denominator;
}
auto inverted_divide = [](const auto numerator, const auto denominator) {
return divide(denominator/numerator)
}
41. 跳過c++11版本
如果你在正在轉向”現代C++", 請跳過Cpp11版本, Cpp14修復了許多Cpp11的漏洞。
其中語言層面的特性包括:
C++11 版本的
constexpr
隱式地指定了所有成員函數為const(即不能修改this), 這在C++14已經被改變。C++11缺少對函數對auto 返回類型的推導(
lambdas
有)C++11沒有
auto
或者可變Lambda參數的C++14新增
[[deprecated]]
特性C++14新增了數字分隔符, 比如
1'000'000
在C++14里
constexpr
函數可以有多個return
庫層面特性包括:
std::make_unique
在C++14中加入C++11沒有
std::exchange
C++14新增了對
std::array
的constexpr
支持cbegin
,cend
,crbegin
, 和crend
這些自由函數(free function
,沒有入參的函數) 為了和begin 和end這些在C++11加入的標準容器中的自由函數保持一致而被加入了。
42. 對于重要的類型,不要使用initializer_list
Initializer List
在C++里面是一個重載項, Initializer Lists
被用于直接初始化數值。 initializer_list
被用于向函數或者構造器傳入一個value list。
接下來舉幾個例子講一下initializer_list
的一些異常行為(摘自本書作者的youtube上的講解視頻:
auto f(int i, int j, int k){
return std::initializer_list<int>{i, j, k};
}
int main() {
int argc = 1;
for (int i: f(argc+1, argc+2, argc+3)){
std::cout << i << ",";
}
}
最終的結果實際上是個UB行為, 因為上面這段代碼的函數f等價于
auto f(int i, int j, int k){
return std::initializer_list<int>{i, j, k};
}
auto f(int i, int j, int k){
const int __a[] = {i, j, k};
return std::initializer_list<int>{__a, __a+3}; // pointer local
}
因此同理,下面這段代碼是沒有辦法編譯成功的(因為unique_ptr不能轉讓所有權):
#include <vector>#include <memory>
std::vector<std::unique_ptr<int>> data{
std::make_unique<int>(40), std::make_unique<int>(2)
};
43. 使用工具:Build Generators
CMake
Meson
Bazel
Others
裸make file或者visual studio的項目文件讓上面列出的每個東西都很棘手并難以去實現。使用build tool工具去幫助你維護在不同平臺和編譯器之間的可移植性。對待你的build script就想對待你的其它code一樣, 它們也有自己的一套best practise, 并且非常容易就寫出一個不易維護的build sciprt, 就像寫出一個不可維護的C++代碼一樣。在使用cmake --build的情況下, Build generators同時也可以幫助抽象和簡化你的持續集成環境, 這樣無論你在用什么平臺開發,都可以做出正確的事情。
44. 使用包管理工具
最近幾年開發者對C++的包管理工具表現出了濃厚的興趣, 有兩個成為了其中最著名的包管理工具:
Vcpkg
Conan
使用一個包管理工具是絕對有好處的, 包管理工具可以提高可以提高可移植性并且降低開發人員的管理成本。
45. 縮短構建時間
對于減少構建時間帶來的痛苦,有以下一些很實用的建議:
將你的代碼盡可能地去模板化(不是不用模板)
在有意義的地方使用前向聲明(所謂前向聲明是指:A.h引用B.h, B.h又要引用A.h,這時候解決問題的辦法是在B.h里面只聲明一下A.h里面的A類)
在你的build system中開啟PCH(precompile headers)
使用ccache
了解unity builds
了解外部模板(extern template)能做什么以及它的局限性
使用構建分析工具去看構建時間被花費在了哪里
使用IDE
我所觀察到的使用現代IDE最令人驚喜的一個特性就是:IDE對你的代碼做了實時分析。實時分析就意味著你在鍵入代碼的時候編譯器就知道它是否要編譯,因此你會花上更少的時間去等待構建。
46. 使用工具: 支持多編譯器
在你的平臺上你至少要支持兩種編譯器。每個編譯器都會做不同的分析并且以一種略微不同的方式來實現標準。如果你使用Visual Studio,你應該能夠在clang和cl.exe之間靈活切換。你還可以使用WSL并且開啟遠程linux 構建。
如果你使用linux系統,你應該能夠在GCC和Clang之間能夠靈活切換。
注意, 在macOS上,確保你在使用的編譯器是你想使用的那個,因為gcc 命令很可能是一個蘋果公司安裝的clang的軟連接
47. 模糊測試(Fuzzing)和變異測試(Mutating)
你的想象力限制了你能夠構建的測試用例, 你是否有嘗試惡意調用API?你有故意去傳入一些格式錯誤的數據給你的輸入嗎?你是否處理一些未知或者未經信任的數據來源?
在所有可能的情況組合中為所有可能的函數調用生成所有可能的輸入是不可能的, 很幸運的是,有工具來為我們解決這些問題。
模糊測試
模糊測試工具能夠生成各種長度的隨機字符串,這種測試能夠約束你用合適的方法去處理這些數據, 模糊測試工具分析那些從你的測試執行過程中生成出來的覆蓋率數據并且使用那些信息去刪除多余的測試并且產生新且特殊的測試用例。
理論上來說,如果給它足夠的時間,模糊測試可以對你需要測試的代碼最終達到100%的代碼覆蓋率。結合AddressSanitizer, 它可以成為尋找你代碼中bug的強有力的工具。在一個很有趣的文章里面描述了模糊測試工具和AddressSainitzer的組合是如何在小于6個小時內發現OpenSSL的安全漏洞的。
變異測試
編譯測試是通過修改你代碼里的條件和變量來進行測試工作的,舉個例子:
bool greaterThanFive(const int value) {
return value > 5; // comparison
}
void tests() {
assert(greaterThanFive(6));
assert(!greaterThanFive(4));
}
編譯測試能夠修改你的常量5或者>運算符, 所以你的code變成:
bool greaterThanFive(const int value) {
return value < 5; // mutated
}
任何能夠繼續通過的測試用例都屬于"存活下來的變異測試用例", 這就預示著要么你的代碼存在bug要么這是一個有缺陷的測試。
48. 繼續你的C++學習
如果你想變得更強你就要持續不斷地學習, 世面上有許多你可以在你的C++學習上使用在資源。(++若干年后, 你就會發現,你變禿了,也變強了++)
往期推薦