C.45: 不要定義一個僅僅初始化成員變量的默認構造函數,而是使用類內成員初始化器
如果你有一個默認構造函數,它的唯一作用是給成員變量賦默認值(如 1、2、3),這更清晰、簡單的方法是直接在成員變量聲明時使用類內初始化器(in-class initializers):
C.48: 優先使用類內初始化器,而不是在構造函數中初始化常量初值
解釋:
當初始值是固定的(比如總是初始化為 1、2、3),直接放在成員定義里更清楚、更少重復。
? 重構你的代碼
class Simple {
public:Simple() = default;Simple(int aa, int bb, int cc = -1) : a(aa), b(bb), c(cc) {}Simple(int aa) {a = aa;b = 0;c = 0;}
private:int a{1};int b{2};int c{3};
};
優點:
a{1}
、b{2}
、c{3}
讓默認初值更直觀。- 默認構造函數
Simple() = default;
是自動生成的,不用你寫。 - 更少代碼,更清晰意圖。
原代碼問題:
Simple() : a(1), b(2), c(3) {}
這只是在做初始化,跟寫在類內沒區別,應該刪掉改為類內初始化器。
總結一句話:
如果成員有固定初始值,不要在構造函數里寫,直接在類內初始化。構造函數就用來處理變化的參數。這樣代碼更清晰、更易維護。
你提供的這兩段代碼展示了**使用類內成員初始化器(in-class initializers)**的好處,結合后面列出的 Benefits(好處),我們來逐條解釋。
示例代碼含義解析
class Simple {
public:Simple() {} // 用戶定義的默認構造函數Simple(int aa, int bb, int cc) : a(aa), b(bb), c(cc) {}Simple(int aa) : a(aa) {}
private:int a = -1;int b = -1;int c = -1;
};
class Simple {
public:Simple() = default; // 編譯器自動生成默認構造函數Simple(int aa, int bb, int cc) : a(aa), b(bb), c(cc) {}Simple(int aa) : a(aa) {}
private:int a = -1;int b = -1;int c = -1;
};
在這兩個版本中:
- 類內初始值
int a = -1;
是默認值,會被任何沒特別賦值的構造函數所使用。 - 第二個版本通過
= default
,使默認構造函數由編譯器自動生成,而不是手寫空函數體。
優點(Benefits)詳解
No arguing about “equivalent” ways to do it
不再爭論各種“等價”的初始化寫法。
統一使用類內初始化器可以避免這樣的問題:
Simple() : a(-1), b(-1), c(-1) {} // 和
int a = -1; int b = -1; int c = -1; // 哪個更好?
→ 統一方式更清晰,減少團隊代碼風格爭議。
May prevent some bugs
可以防止某些初始化遺漏的 bug。
比如:
Simple(int aa) : a(aa) {} // 如果 b 和 c 沒在構造函數中初始化
→ 它們就會用類內初始值 -1
,不會變成未定義值或垃圾值。
May put you back in “compiler generates constructors” land
有可能讓你回到“讓編譯器自動生成構造函數”的美好世界。
你就可以用 = default
自動生成默認構造函數,不必手寫:
Simple() = default;
→ 更少代碼,更少出錯。
Potentially marginally faster in some circumstances
在某些情況下,這種做法可能更快(邊際提升)。
尤其在使用 std::vector<Simple>
等容器時,類內初始化器有時能讓構造路徑更優化,因為編譯器可能內聯或避免重復初始化。
總結一句話:
類內初始化器 + 默認構造函數 = 更簡單、更安全、更一致的 C++ 代碼。
這符合現代 C++(C++11 起)的最佳實踐,尤其在構造函數中不重復寫相同的默認值。
你提供的是 C++ 核心準則中的一條重要建議:
F.51:如果可以選擇,優先使用默認參數而不是函數重載
示例比較
使用重載實現默認行為(冗余):
class Reactor {
public:double Offset(double a, double b, double ff);double Offset(double a, double b); // 重載一份只是為了給 ff 默認值
};
double Reactor::Offset(double a, double b, double ff) {// 復雜計算return whatever;
}
double Reactor::Offset(double a, double b) {return Offset(a, b, 1.0); // 手動添加默認值
}
使用默認參數(簡潔):
class Reactor {
public:double Offset(double a, double b, double ff = 1.0);
};
double Reactor::Offset(double a, double b, double ff /* = 1.0*/) {// 復雜計算return whatever;
}
為什么默認參數更好?(Benefits)
No arguing about “equivalent” ways to do it
避免對兩種“看似等效”的實現方式的爭論。
不用再爭論是要重載兩個版本,還是默認參數好——直接默認參數就行。
Will not forget to make same change to both copies
修改參數邏輯時,不會忘記同步另一個版本。
例如改成使用 ff = 0.95
,只要改一處:
double Offset(double a, double b, double ff = 0.95);
→ 避免因為忘改重載函數導致不一致或 bug。
Difference between the two “versions” is crystal clear
不再出現“兩個版本”之間的模糊區別,調用者一目了然。
Offset(10, 20); // 用默認 ff = 1.0
Offset(10, 20, 0.75); // 顯式給出 ff
相比重載版本:
Offset(10, 20); // 哪個版本?(看簽名)
Offset(10, 20, 0.75); // 不明顯差異
總結一句話:
用默認參數,寫得更少,錯得更少,讀得更清楚。
這體現了現代 C++ 的風格傾向:減少重復代碼,提升可維護性與表達力。
C.47:定義和初始化成員變量時,應按照它們在類中聲明的順序
為什么這很重要?
在 C++ 中,即使你在構造函數的初始化列表中按照你喜歡的順序寫初始化語句,編譯器實際上仍然會按成員在類中聲明的順序來初始化!
示例:存在隱患的代碼
class Wrinkle {
public:Wrinkle(int i) : a(++i), b(++i), x(++i) {}
private:int a;int x;int b;
};
初始化順序實際上是:a → x → b
但你寫的是:a → b → x
這樣會導致:
- 成員變量
b
被初始化時,依賴了i
的值(可能和你想的不一樣) - 代碼看起來正確,但行為會出錯或讓人困惑
- 編譯器可能發出警告:“warning: field ‘x’ will be initialized after field ‘b’”
更清晰的正確寫法
class Wrinkle {
public:Wrinkle(int i) : a(++i), x(++i), b(++i) {}
private:int a;int x;int b;
};
更真實的例子
假設:
class Person {
public:Person(string first, string last) : firstName(first), lastName(last), fullName(first + " " + last) {}
private:string firstName;string lastName;string fullName;
};
fullName
依賴于 firstName
和 lastName
,但必須確保它在它們后面聲明,否則會使用未初始化的值!
誰可能打亂順序?
- **“熱心的新人”**試圖按字母順序排列變量
- 工具可能自動整理字段
- 有人想按“邏輯分組”整理變量,卻不看構造函數順序
建議(總結)
- 始終按類中聲明的順序編寫構造函數初始化列表
- 不要依賴初始化順序之外的副作用(比如
++i
) - 如果成員間存在初始化依賴,應在聲明順序上表達清晰的意圖
好處
- 避免初始化順序 bug
- 不需要每個開發者都記住 C++ 的這個“怪癖”
- 鼓勵你重新思考類的設計,減少成員之間的耦合依賴
一句話總結:
在初始化列表中改變順序沒用 —— 編譯器會按聲明順序來初始化。為了安全和可讀性,讓你的初始化列表和成員聲明保持一致的順序。
你提到的內容是 C++ 核心準則中的一條設計建議:
I.23: Keep the number of function arguments low
I.23:盡量減少函數參數數量
舉例說明:
糟糕的設計(太多參數):
int area(int x1, int y1, int x2, int y2);
int a = area(1, 1, 11, 21);
- 參數太多,難記憶,容易出錯。
- 沒有抽象,含義不清晰(哪個是左上角?哪個是右下角?)。
更好的設計(引入抽象):
int area(Point p1, Point p2);
int a = area({1, 1}, {11, 21});
- 使用
Point
類型,更清晰地表達意圖。 - 減少調用者負擔,不需要記位置。
- 抽象可復用。
進一步示例:構造 Customer
糟糕設計:
Customer(string pfirst, string plast, string pph,string sfirst, string slast, string sph, string sid);
- 多達 7 個字符串參數,難維護。
- 非常容易傳錯。
改進設計:
class Customer {Person details;Salesrep rep;
public:Customer(Person p, Salesrep s);
};
- 將數據封裝進合適的結構(如
Person
,Salesrep
)。 - 調用清晰,代碼更易維護。
核心好處
優點 | 說明 |
---|---|
更低的認知負擔 | 用戶不用記住那么多參數順序 |
更清晰的意圖表達 | 結構化參數讓含義更明確 |
抽象可以復用 | Point , Person 可以在別處使用 |
降低未來代碼變更影響 | 只需改結構體,函數簽名不動 |
小結一句話:
函數參數越少越好,如果超過 3-4 個,應該考慮把它們組合進結構體或類里。
ES.50: Don’t cast away const
不要去除 const 限定符(不要“cast away const”)
背景問題
我們有一個 Stuff
類,其中包含一個緩存機制 cachedValue
,希望在 getValue()
中使用它。
但 getValue()
是 const
函數,不能修改任何成員變量。
錯誤做法:
int Stuff::getValue() const {if (!cacheValid) {cachedValue = LongComplicatedCalculation(); // 編譯錯誤,修改了成員cacheValid = true;}return cachedValue;
}
如果你想讓它編譯,有人可能會寫:
int Stuff::getValue() const {auto self = const_cast<Stuff*>(this); // cast away const!if (!self->cacheValid) {self->cachedValue = LongComplicatedCalculation();self->cacheValid = true;}return self->cachedValue;
}
這是不推薦的做法!
為什么不能去掉 const
?
- 違反接口契約:
getValue()
承諾不修改對象狀態,卻偷偷修改了。 - 讓頭文件成為謊言:你聲稱不變,其實在改。
- 代碼難以維護:別人調用你的
const
函數,會以為它是線程安全的、無副作用的,但其實不是。 - 容易出 bug,尤其是涉及優化、多線程、拷貝等。
正確做法:使用 mutable
class Stuff {
private:int number1;double number2;int LongComplicatedCalculation() const;mutable int cachedValue;mutable bool cacheValid;
public:Stuff(int n1, double n2): number1(n1), number2(n2), cachedValue(0), cacheValid(false) {}bool Service1(int x);bool Service2(int y);int getValue() const;
};
int Stuff::getValue() const {if (!cacheValid) {cachedValue = LongComplicatedCalculation();cacheValid = true;}return cachedValue;
}
為什么 mutable
是好的解決方案?
優點 | 說明 |
---|---|
保持 const 函數語義 | 只有緩存變量能被修改,接口保持誠實 |
可讀性好 | 一眼能看出哪些成員可能在 const 函數中被改 |
編譯器優化友好 | 保留 const 語義,有助于優化和靜態分析 |
更安全 | 避免了錯誤地修改非緩存成員的風險 |
小結:
當你需要在
const
函數中修改內部緩存狀態時,請使用mutable
,而不是 const_cast。
永遠不要 cast away const 除非你非常清楚代價,并且這是最后手段。
mutable
是安全地修改緩存的標準做法。
如果你還想探討 mutable
的使用邊界或緩存設計模式,可以繼續問我。
你提到的是 C++ 核心準則中非常重要的一條資源管理原則:
I.11: Never transfer ownership by a raw pointer (T*
)
永遠不要用裸指針(
T*
)傳遞資源所有權
違反規則的錯誤示例:
Policy* SetupAndPrice(args) {Policy* p = new Policy{...}; // 手動分配內存// ...return p; // 通過裸指針傳遞所有權
}
- 🔺 誰來 delete? 不清楚。
- 🔺 極易造成 內存泄漏。
- 🔺 調用方不知道是否需要釋放。
- 🔺 所有權不明確,違反了現代 C++ 的資源管理理念。
更安全的替代方案:
1. 返回值傳遞(by value)
Policy SetupAndPrice(args); // 編譯器可優化掉復制(RVO/NRVO)
- 簡潔。
- 現代編譯器通常會 自動省略拷貝。
- 如果不擔心復制代價,這是首選方式。
2. 使用非 const 引用傳入已有對象
void SetupAndPrice(Policy& policy); // 調用方自己擁有 policy
- 函數不會創建資源,只修改它。
- 最適用于:調用前就已有對象。
3. 返回智能指針(推薦!)
std::unique_ptr<Policy> SetupAndPrice(args);
- 明確表示“我擁有這個對象”。
- 調用方拿到智能指針后,對象會自動銷毀。
- 避免忘記 delete。
4. 使用 gsl::owner<T*>
gsl::owner<Policy*> SetupAndPrice(args);
- 并不自動管理內存。
- 作用是標記:“這個裸指針的所有權轉移了”
- 更易被工具分析/被人理解。
template <class T, class = std::enable_if_t<std::is_pointer<T>::value>>
using owner = T;
小結:
做法 | 安全性 | 所有權是否清晰 |
---|---|---|
裸指針返回 T* | 高風險 | 不明確 |
返回值 T | 安全 | 明確 |
智能指針 unique_ptr<T> | 安全 | 明確 |
引用參數 T& | 安全 | 明確 |
gsl::owner<T*> | 輔助作用 | 明確但不自動 |
核心觀點:
內存管理太重要,不能只靠記憶。
不要手動管理內存,應該:
- 用值語義(復制或移動)
- 用智能指針管理所有權
- 或至少用
owner<T*>
明確所有權
F.21: To return multiple “out” values, prefer returning a tuple or struct
返回多個“輸出”值時,優先返回
tuple
或struct
,不要用輸出參數(out-params)
傳統寫法:輸出參數
int foo(int inValue, int& outValue) {outValue = inValue * 2;return inValue * 3;
}
int main() {int number = 4;int answer = foo(5, number);return 0;
}
問題:
number
是隱含的輸出值,看起來像輸入。- 函數返回值和“副作用輸出”分開,閱讀困難。
- 不符合現代 C++ 倡導的值語義風格。
更好方式 1:自定義 struct
struct twoNumbers {int value1;int value2;
};
twoNumbers fooStruct(int inValue) {return twoNumbers{ inValue * 2, inValue * 3 };
}
int main() {twoNumbers result = fooStruct(6);int number = result.value1;int answer = result.value2;return 0;
}
優點:
- 清晰表達含義(用字段名說明含義)
- 編譯器可優化拷貝(RVO)
- 接口干凈,沒有輸出引用
更好方式 2:返回 std::tuple
std::tuple<int, int> fooTwo(int inValue) {return std::make_tuple(inValue * 2, inValue * 3);
}
使用 std::tie
拆解:
int number, answer;
std::tie(answer, number) = fooTwo(9);
使用結構化綁定(C++17 起):
auto [answer, number] = fooTwo(9);
優點:
- 表達力強,代碼簡潔
- 結構化綁定讓讀取 tuple 更方便
但 tuple 缺點是:字段無名稱,不夠語義化。如果語義重要,還是struct
更好。
可選方式:std::optional<T>
如果你只是返回一個對象和一個“是否有效”的布爾值,可以用 std::optional<T>
:
std::optional<int> maybeDivide(int a, int b) {if (b == 0) return std::nullopt;return a / b;
}
int main() {auto result = maybeDivide(10, 0);if (result)std::cout << "Result: " << *result << '\n';elsestd::cout << "Division failed.\n";
}
為什么要避免輸出參數?
問題 | 原因 |
---|---|
不清晰 | 輸出參數隱藏在函數簽名中 |
副作用 | 函數修改了外部變量,閱讀成本高 |
可讀性差 | 調用者必須準備變量來傳入修改 |
無法組合 | 輸出參數難以用于鏈式表達式或惰性計算 |
總結:建議優先順序
目的 | 推薦做法 |
---|---|
返回多個有語義的值 | 自定義 struct |
返回多個簡單值 | std::tuple + 結構化綁定 |
返回可選單值 | std::optional<T> |
不建議 | 用引用參數 (int& out ) 作為輸出 |
你提到的是現代 C++ 中推薦使用的 enum class
(作用域枚舉),這是 C++11 引入的一項重要特性。下面逐條解釋你貼出的內容,并說明其意義。
使用 enum class
的好處
enum class Error { OK, FileNotFound, OutOfMemory };
enum class Ratings { Terrible, OK, Terrific };
enum oldStyle { OH, OK, OR };
傳統的 enum
(如 oldStyle
)的問題:
oldStyle Oklahoma = OK;
- 你可以直接寫
OK
,沒有作用域前綴。 - 名字沖突:多個枚舉如果都有
OK
,只能有一個能叫 OK。 - 自動轉換為
int
,可能造成隱式錯誤:
int x = OK; // 自動轉 int,危險
enum class
的優勢
Error result = Error::OK;
Ratings stars = Ratings::OK;
int r = static_cast<int>(result);
- 名字必須加作用域限定,例如
Error::OK
,防止沖突。 - 不會自動轉換為
int
,必須顯式轉換:
int r = static_cast<int>(result);
- 可以在不同枚舉里重復名字(每個都有自己的作用域)
更強類型、更安全
特性 | enum | enum class |
---|---|---|
作用域限定 | 否 | 是 |
隱式轉為 int | 是 | 否 |
可以重名(如都叫 OK) | 否 | 是 |
類型安全(可當作獨立類型) | 差 | 好 |
推薦 | 舊風格 | 強烈推薦 |
可指定底層類型(C++11 起)
enum class Error : uint8_t { OK, FileNotFound, OutOfMemory };
- 默認底層類型是
int
,但可以用更小(或大)的類型。 - 適用于節省空間或做序列化通信協議。
實踐建議
- 永遠使用
enum class
,除非你明確需要與 C API 兼容。 - 避免老式的
enum
,尤其是放在頭文件里的(容易污染命名空間)。 - 使用
static_cast<int>(e)
明確轉為整型。
示例總結:
enum class Error { OK, FileNotFound, OutOfMemory };
enum class Ratings { Terrible, OK, Terrific };
Error result = Error::OK;
Ratings stars = Ratings::OK;
// Cannot do this:
// int x = result; 錯誤
// Must be explicit:
int x = static_cast<int>(result); //
你提供的內容出自 C++ Core Guidelines(由 Kate Gregory 和 Bjarne Stroustrup 等人推動),主題是提高代碼的安全性、可讀性和意圖表達。我們逐條來解釋并 翻譯理解這些條目。
I.12: 使用 not_null
明確指針不能為空
Service s(1);
Service* ps = &s;
i = ps->DoSomething();
ps = nullptr; // 潛在空指針異常
i = ps->DoSomething(); // 崩潰
使用 GSL(Guidelines Support Library)中的 not_null
:
#include <gsl/gsl>
gsl::not_null<Service*> ps = &s;
ps = nullptr; // 編譯失敗或運行時斷言
好處
- 防止空指針解引用
- 提升性能(不需要反復檢查指針是否為
nullptr
) - 表達意圖:這個指針不能為 null,不是“可能為 null”
避免不安全的類型轉換(ES.46)
C++ 中隱式轉換可能丟失信息,例如:
int x = 300;
char c = x; // 隱式縮窄,char 只有 8 位,丟失數據
使用 GSL 中的 narrow
或 narrow_cast
:
#include <gsl/gsl>
int x = 300;
char c = gsl::narrow<char>(x); // 拋異常(值改變了)
char c2 = gsl::narrow_cast<char>(x); // 允許丟失數據,但開發者明確知道這事
narrow
vs narrow_cast
區別
功能 | narrow<T>(x) | narrow_cast<T>(x) |
---|---|---|
類型轉換 | ||
運行時檢查 | 拋出異常 | 不檢查 |
有信息丟失時 | 報錯 | 安靜執行 |
使用目的 | 安全性第一(調試優先) | 性能優先,但我知道后果 |
總結:為什么要用這些工具
工具 | 目的 | 幫助 |
---|---|---|
gsl::not_null<T*> | 明確一個指針絕不能是 null | 編譯或運行時強制檢查 |
gsl::narrow<T> | 類型轉換必須安全 | 運行時防止隱式精度丟失 |
gsl::narrow_cast<T> | 允許轉換但表達開發者意圖 | 編譯時不報錯,清晰表達風險 |
最終目標
- 編譯器和工具 幫你發現錯誤
- 表達清晰的意圖,讓別人 看得懂你的代碼
- 提前發現 bug,減少運行時崩潰