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,减少运行时崩溃