让自己习惯C++
条款1:视C++为一个语言联邦
C++不是单一语言,而是多个子语言组成的联邦:
- C
- Object-Oriented C++
- Template C++
- STL(包含container、algorithm和iterator)
每个子语言有不同规则和设计思想, 写代码时必须明确自己在使用哪一种。
条款2:尽量以const,enum,inline 替换 #define
核心思想:宁可用编译器,不用预处理器
1. 三大替换规则
-
常量定义:
#define→const- 好处:有类型检查、符号表可见、代码更小
-
类内常量:
#define→static const或enumenum hack:想要“不能取地址”的常量时使用
3.宏函数:#define → inline模板函数
- 好处:类型安全、无副作用、可调试
2.enum和const本质区别
- enum:就是整数,编译器直接替换成字面值,编译期常量
- const:是真正的变量(只是不允许修改)
3.一句话记住
凡是用#define定义常量或“函数”,都用const/enum/inline替代
条款3:尽可能使用const
核心思想:const告诉编译器和程序员某个值应该保持不变,尽可能使用它
1.四大应用场景
1. const修饰指针/变量
char greeting[] = "Hello";
char* p = greeting; // 指针可变,数据可变
const char* p = greeting; // 数据不可变(指向const)
char* const p = greeting; // 指针不可变
const char* const p = greeting; // 都不可变
记忆口诀:const在*左边→数据不可变;在*右边→指针不可变
2. STL迭代器中的const
std::vector<int> vec;
const std::vector<int>::iterator iter = vec.begin(); // 类似 T* const(指针本身是const)
std::vector<int>::const_iterator cIter = vec.begin(); // 类似 const T*(指向的对象是const)
3. const成员函数
class TextBlock {
public:
const char& operator[](size_t pos) const { return text[pos]; } // const对象调用
char& operator[](size_t pos) { return text[pos]; } // 非const对象调用
};
目的:
- 让const对象也能调用
- 提高代码安全性
- 两个版本可重载
4. const与重载的“常量性转除”(mutable)
cpp
class CTextBlock {
private:
char* pText;
mutable size_t textLength; // mutable成员即使在const函数中也能修改
mutable bool lengthValid;
};
2.核心结论
- 声明变量为const:编译器帮你确保不被修改
- const成员函数:知道哪些函数可以操作const对象
- 按引用传递:const引用避免不必要的拷贝
3.编译器的“位常量”与程序员的“逻辑常量”
- 编译器:只检查成员变量是否被修改(位常量)
- 程序员:关心对象状态是否改变(逻辑常量)
4.一句话记住
凡是“不应该变”的,都用const锁死,让编译器帮你盯着
条款4:确定对象在使用前已先被初始化
核心思想:永远在使用对象前初始化它,C++本身只保证部分情况
1. 内置类型:必须手动初始化
int x; // ❌ 危险!值是垃圾
int x = 0; // ✅ 必须自己动手
double d; // ❌ 危险!
规则:C++不保证初始化内置类型(如int、double、指针),必须自己赋值
2. 构造函数:用初始化列表,别用赋值
class Phone { ... };
class Person {
private:
string name; // 自定义类型
Phone phone; // 自定义类型
int age; // 内置类型
const int id; // const成员
string& ref; // 引用成员
};
❌ 赋值版本(低效)
Person::Person(string& n, Phone& p, int a) {
name = n; // 先调用string默认构造,再赋值
phone = p; // 先调用Phone默认构造,再赋值
age = a; // 内置类型赋值
// id = ? 编译错误!const不能赋值
// ref = ? 编译错误!引用不能赋值
}
✅ 初始化列表版本(高效、必须)
Person::Person(string& n, Phone& p, int a)
// ① 隐藏的初始化阶段:这里没有写初始化列表
// 编译器自动为每个成员调用**默认构造函数**
: name(n), // 直接拷贝构造,一步到位
phone(p), // 直接拷贝构造
age(a), // 初始化内置类型
id(++lastId), // const成员必须初始化列表
ref(name) // 引用成员必须初始化列表
{ }
关键规则:
- 所有成员变量都应该在初始化列表中初始化,初始化列表决定构造方式
- const和引用:必须用初始化列表(无法赋值)
- 顺序问题:初始化顺序只由声明顺序决定,不是初始化列表顺序
3. “不同编译单元的非局部静态变量”初始化顺序问题
问题本质
两个不同源文件中的静态变量,谁先初始化?C++不保证!
// File1.cpp
extern int getX(); // 声明来自File2的静态变量
int y = getX(); // ❌ 危险!getX()依赖的x可能还没初始化
// File2.cpp
int x = 42; // 静态变量
int getX() { return x; }
File1和File2谁先编译? 不知道!如果File1先编译,y会使用未初始化的x!
解决方案:Singleton模式(用局部静态变量替代)
cpp
// File2.cpp(改造后)
int& getX() {
static int x = 42; // 局部静态变量:第一次调用才初始化
return x;
}
// File1.cpp
int y = getX(); // ✅ 安全!调用函数时x已经初始化
原理:函数内的局部静态变量,在第一次执行到该行时初始化,全局静态变量是在程序启动时初始化。
好处:彻底解决初始化顺序问题,且第一次使用时才构造(懒加载)
4. 一句话总结
构造函数用初始化列表,const/引用必须放,跨文件静态变量用函数包一层
第二章 构造/析构/赋值运算
条款5:了解C++默默编写并调用那些函数
一、编译器会生成哪些函数?(C++11)
| 函数 | 作用 |
|---|---|
| 默认构造函数 | Widget() |
| 析构函数 | ~Widget() |
| 拷贝构造函数 | Widget(const Widget&) |
| 拷贝赋值操作符 | operator=(const Widget&) |
| 移动构造函数 | Widget(Widget&&)(C++11新增) |
| 移动赋值操作符 | operator=(Widget&&)(C++11新增) |
条款6:若不想使用编译器自动生成的函数,就该明确拒绝
核心:不想让对象被拷贝,就要主动禁止,编译器可不会帮你拒绝
两种方法
C++98 风格:private + 不实现
class HomeForSale {
private:
HomeForSale(const HomeForSale&); // 只有声明
HomeForSale& operator=(const HomeForSale&);
};
- 外部调用 → 编译错误(private)
- 友元/成员调用 → 链接错误(没实现)
C++11 风格:=delete(推荐)
cpp
class HomeForSale {
public:
HomeForSale(const HomeForSale&) = delete;
HomeForSale& operator=(const HomeForSale&) = delete;
};
- 编译期报错
- 意图明确
- 可用于任何函数
一句话总结
C++11用 =delete,C++98用 private + 不声明实现,总之别指望编译器帮你拒绝
条款7: 为多态基类声明virtual析构函数
想当多态基类,就把析构函数声明为virtual;不想当基类,就别乱加virtual
条款8:别让异常逃离析构函数
一、核心原则
析构函数绝对不要抛出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕获任何异常,然后吞下它们(不传播)或结束程序。
二、为什么必须遵守?
问题1:直接抛出会导致程序崩溃
cpp
class Widget {
public:
~Widget() {
throw std::exception(); // 异常逃离析构函数
}
};
// 使用
{
Widget w;
} // w析构时抛出异常 → 程序终止
问题2:双重异常导致未定义行为
cpp
std::vector<Widget> widgets(10); // 10个Widget对象
// vector析构时,会依次析构每个Widget
// 如果第一个Widget析构抛出异常,第二个还要继续析构
// 如果第二个也抛出异常 → 两个异常同时存在 → 程序调用terminate
C++规则:析构函数抛出异常且逃离,而程序又处于另一个异常处理中 → terminate() → 程序崩溃
三、解决方案(两种策略)
策略A:捕获异常,吞下它(简单兜底)
cpp
class DBConn {
public:
~DBConn() {
try {
db.close(); // 可能抛出异常
} catch (...) {
// 方案1:吞掉异常(记录日志但不传播)
std::cerr << "关闭失败,但程序继续" << std::endl;
// 方案2:强制终止(很少用)
// std::abort();
}
}
private:
DBConnection db;
};
策略B:提供关闭函数,让客户处理(推荐)
cpp
class DBConn {
public:
void close() { // 供客户主动调用
db.close();
closed = true;
}
~DBConn() {
if (!closed) {
try {
db.close(); // 客户忘了关,这里兜底
} catch (...) {
// 只能吞掉异常(无法通知客户)
std::cerr << "析构时关闭失败" << std::endl;
}
}
}
private:
DBConnection db;
bool closed = false;
};
四、两种策略对比
| 维度 | 策略A:直接吞掉 | 策略B:提供关闭函数 |
|---|---|---|
| 异常处理时机 | 析构时 | 客户主动调用时 |
| 客户能否知道失败 | ❌ 不能 | ✅ 能(可catch) |
| 接口复杂度 | 简单 | 稍复杂 |
| 安全性 | 保证不崩溃 | 更好(客户可处理) |
| 适用场景 | 资源释放不重要 | 资源释放重要 |
五、最佳实践(组合使用)
class DBConn {
public:
void close() {
db.close();
closed = true;
}
~DBConn() noexcept { // C++11明确标记不抛异常
if (!closed) {
try {
db.close();
} catch (...) {
// 记录日志,但必须吞掉
std::cerr << "析构关闭失败" << std::endl;
}
}
}
private:
DBConnection db;
bool closed = false;
};
六、C++11的改进:noexcept
~MyClass() noexcept; // 默认就是noexcept
- C++11开始,析构函数默认是
noexcept - 如果析构函数抛出异常,程序直接调用
terminate - 好处:编译器可以做更多优化
一句话总结
析构函数中可能抛出的异常,要么让客户提前处理(提供close函数),要么自己吞掉(try-catch),绝不能让异常逃出去
条款9:绝不再构造和析构过程中调用virtual函数
一、为什么构造函数中调用虚函数不行?
class Base {
public:
Base() {
// 此时vptr指向Base的虚函数表
// Derived部分还没构造,vptr不会指向Derived
foo(); // 调用Base::foo()
}
virtual void foo() { cout << "Base\n"; }
};
class Derived : public Base {
public:
Derived() {
// 此时vptr才更新为指向Derived的虚函数表
}
virtual void foo() override { cout << "Derived\n"; }
};
二、为什么析构函数中调用虚函数不行?
class Derived : public Base {
public:
~Derived() {
// Derived析构后,vptr被重置指向Base的虚函数表
}
};
class Base {
public:
~Base() {
// 此时vptr指向Base的虚函数表
foo(); // 调用Base::foo()
}
virtual void foo() { cout << "Base\n"; }
};
三、核心本质
- 构造时:vptr先指向当前类的虚表,子类构造完才更新
- 析构时:子类析构后vptr被重置回父类的虚表
- 结果:在构造/析构期间,虚函数调用的是当前类版本,不是最终子类版本
条款10:令operator=返回一个 reference to *this
一、核心观点
为了实现连锁赋值(链式赋值),赋值操作符应返回指向左侧对象的引用。
二、为什么这样做?
int x, y, z;
x = y = z = 15; // 连锁赋值,等价于 x = (y = (z = 15))
为了实现这种语法,赋值必须返回左侧对象的引用:
z = 15返回z的引用y = (z = 15)才能继续工作
三、实现方式
class Widget {
public:
// 标准形式
Widget& operator=(const Widget& rhs) {
// 赋值操作...
return *this; // 返回左侧对象的引用
}
// 其他赋值相关操作符也应遵守
Widget& operator+=(const Widget& rhs) {
// ...
return *this;
}
Widget& operator=(int rhs) { // 特殊版本也适用
// ...
return *this;
}
};
四、总结
- 这是约定,不是强制 - 即使不这样写也能编译
- 但应该遵守 - 为了与内置类型行为一致,提高代码可读性
- 适用于所有赋值操作 -
=、+=、-=等都应该返回*this的引用 - 标准库也遵循 - STL 容器都采用这种约定
条款11:在operator= 中处理“自我赋值”
一、问题
// 错误的实现
Widget& operator=(const Widget& rhs) {
delete pb; // 删除自己的内存
pb = new Bitmap(*rhs.pb); // rhs和自己是同一对象时→访问已删除内存
return *this;
}
自我赋值时(如 *px = *py 指向同一对象),上述代码会崩溃。
二、三种解决方法
方法1:证同测试
Widget& operator=(const Widget& rhs) {
if (this == &rhs) return *this; // 自我赋值检查
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
缺点:仍不是异常安全的(new 失败时 pb 指向已删除内存)
方法2:精心排列语句(推荐)
Widget& operator=(const Widget& rhs) {
Bitmap* pOrig = pb; // 记住原指针
pb = new Bitmap(*rhs.pb); // 让 pb 指向新副本
delete pOrig; // 删除原内存
return *this;
}
优点:自我赋值安全 + 异常安全(new失败时原对象不变)
方法3:copy and swap(常用)
void swap(Widget& rhs) { /* 交换 pb 指针 */ }
Widget& operator=(const Widget& rhs) {
Widget temp(rhs); // 创建副本
swap(temp); // 交换数据
return *this;
}
或传值版本:
Widget& operator=(Widget rhs) { // 传值即创建副本
swap(rhs);
return *this;
}
条款12:复制对象时勿忘每一个成分
要点
- 拷贝构造函数和拷贝赋值运算符应确保复制所有成员变量
- 派生类必须调用基类的相应复制函数
- 不要尝试用一个拷贝函数调用另一个(会造成无限递归或部分复制)
- 可将共同代码放到
init()私有函数中复用
第三章 资源管理
条款13:以对象管理资源
将资源放进对象内,依靠析构函数自动释放资源(RAII:资源获取即初始化),利用shared_ptr和unique_ptr
条款14:在资源管理类中小心copying行为
一、核心问题
当你自定义 RAII 类管理非内存资源(如互斥锁、文件句柄)时,对象复制时该如何处理底层资源?
二、四种可选策略
1. 禁止复制
class Lock {
private:
Mutex* m_mutex;
Lock(const Lock&); // 私有化拷贝构造
Lock& operator=(const Lock&); // 私有化赋值
public:
explicit Lock(Mutex* m) : m_mutex(m) { lock(m_mutex); }
~Lock() { unlock(m_mutex); }
};
2. 引用计数
class Lock {
shared_ptr<Mutex> m_mutex;
public:
explicit Lock(Mutex* m) : m_mutex(m, unlock) { // 指定删除器
lock(m_mutex.get());
}
}; // 复制时shared_ptr自动管理引用计数
3. 复制底层资源
class StringResource {
char* m_data;
public:
StringResource(const char* s) { /* 分配内存并复制内容 */ }
StringResource(const StringResource& rhs) {
m_data = new char[strlen(rhs.m_data) + 1]; // 深拷贝
strcpy(m_data, rhs.m_data);
}
~StringResource() { delete[] m_data; }
};
4. 转移所有权
class FileHandle {
FILE* m_file;
public:
explicit FileHandle(FILE* f) : m_file(f) {}
FileHandle(FileHandle&& rhs) : m_file(rhs.m_file) { // 移动语义
rhs.m_file = nullptr;
}
~FileHandle() { if (m_file) fclose(m_file); }
};
三、要点
- 复制 RAII 对象时,必须决定如何处理底层资源
- 四种选择:禁止复制、引用计数、深拷贝、转移所有权
- 优先使用标准库组件(shared_ptr 等)简化实现
- 核心原则:资源管理类的行为应与其管理的资源特性一致
条款15:在资源管理类中提供对原始资源的访问
RAII类封装资源后,仍需提供原始资源访问方式以兼容旧API。主要有三种机制:显式访问(如get()函数)最安全但需手动调用;隐式转换(如operator FontHandle())更方便但易意外出错;运算符重载(如->和*)让对象模拟指针行为。智能指针采用运算符重载模拟指针语法 + get()提供显式访问的组合方式,兼顾安全与便利。
条款16:成对使用new和delete时要采取相同形式
要点
- new 和 delete 形式必须一致
- 使用
new[]必须用delete[] - 使用
new必须用delete - 优先使用
vector和string替代动态数组
条款17:以独立语句将newed对象置入智能指针
一、核心问题
将 new 出的对象直接放入智能指针时,如果中途发生异常,可能导致资源泄漏。
问题示例
// 函数声明
int priority();
void processWidget(std::shared_ptr<Widget> pw, int priority);
// 危险调用方式
processWidget(std::shared_ptr<Widget>(new Widget), priority());
编译器可能按这个顺序执行:
new Widget(分配内存)priority()调用- 构造
shared_ptr<Widget>
问题所在:如果第 2 步 priority() 抛出异常,第 1 步 new Widget 分配的内存就没人管理,发生泄漏。
二、解决方案
用独立语句创建智能指针,再传入函数
// ✅ 正确方式
std::shared_ptr<Widget> pw(new Widget); // 独立语句
processWidget(pw, priority()); // 再调用函数
这样 new Widget 和智能指针构造在同一语句中完成,priority() 在之后调用,即使异常也不会泄漏。
三、要点
- 以独立语句将 newed 对象存入智能指针
- 不要将 new 操作混合在其他表达式中
- 确保一旦对象被 new 出来,立刻被智能指针接管
第四章 设计与声明
条款18:让接口容易被正确使用,不易被误用
核心思想
设计接口时应引导用户正确使用,阻止常见错误,让错误用法难以通过编译。
一、常见误用场景与解决方法
1. 参数类型混淆
// ❌ 容易传错顺序
class Date {
public:
Date(int month, int day, int year);
};
Date d(30, 3, 2024); // 月、日传反了,但能编译
// ✅ 使用类型封装
struct Month { explicit Month(int m) : val(m) {} int val; };
struct Day { explicit Day(int d) : val(d) {} int val; };
struct Year { explicit Year(int y) : val(y) {} int val; };
class Date {
public:
Date(Month m, Day d, Year y);
};
Date d(Month(3), Day(30), Year(2024)); // 顺序强制正确
2. 限制取值范围
// ✅ 用枚举限制有效值
enum class Month { Jan=1, Feb, Mar, /*...*/ };
Date d(Month::Mar, Day(30), Year(2024));
3. 防止资源泄漏
// ❌ 要求用户记得删除
Investment* createInvestment();
// ✅ 返回智能指针,自动管理
std::shared_ptr<Investment> createInvestment();
4. 一致性设计
// ❌ 命名不一致
a.remove(); // 删除某个元素
b.erase(); // 同样是删除,名字不同
// ✅ 与STL保持一致
a.erase(); // STL容器都用erase
二、要点
- 类型安全:用不同类区分不同参数
- 限制操作:用
const、枚举等约束使用方式 - 主动管理:返回智能指针而非原始指针
- 保持一致性:与标准库或既有接口风格一致
- 好的接口让正确用法简单自然,错误用法无法编译
条款19: 设计class犹如设计type
一、核心思想
设计 class 时,要像语言设计者设计内置类型一样,全面考虑类型应有的行为和约束。
二、需要思考的问题
1. 如何创建和销毁?
- 构造函数、析构函数、内存分配/释放
- 拷贝构造、移动构造(C++11)
2. 初始化和赋值有什么区别?
- 构造函数和赋值操作符的行为差异
3. 传值还是传引用?
- 拷贝构造函数如何实现(深拷贝/浅拷贝)
4. 成员访问权限?
- public/protected/private 如何划分
- 哪些接口对内,哪些对外
5. 是否支持继承?
- 有无虚函数、虚析构函数
6. 类型转换规则?
- 隐式转换(单参构造函数、类型转换运算符)
- 显式转换(explicit 关键字)
7. 支持哪些操作符?
- 运算符重载的取舍
8. 标准函数该拒绝还是默认?
- 哪些用 default,哪些用 delete
9. 成员接口声明?
- 函数参数的异常说明、const 属性
10. 泛型支持?
- 是否作为模板,能否被模板特化
条款20:宁以pass-by-reference-to-const替换pass-by-value
一、核心问题
按值传递会导致不必要的拷贝和性能损失,尤其是对象大的时候。
二、为什么用 const 引用?
1. 避免拷贝构造
// ❌ 按值传递:每次调用都拷贝
bool validate(Student s); // 调用拷贝构造函数
// ✅ const引用:无拷贝,只传指针
bool validate(const Student& s); // 传引用,加const保护
2. 避免切割问题(slicing)
// ❌ 按值传递会导致派生类信息丢失
void display(Window w) { // 按值传递
w.draw(); // 调用的是Window::draw,不是派生类的
}
// ✅ const引用保留派生类特性
void display(const Window& w) {
w.draw(); // 调用派生类的draw(多态)
}
三、特殊情况:小类型
内置类型、STL迭代器、函数对象可以按值传递,通常效率更高。
四、要点
- 优先用
const T&代替T作为函数参数 - 避免不必要的构造/析构调用
- 避免对象切割问题(slicing problem)
- 小类型(int、指针、迭代器)仍可考虑按值传递
条款21:必须返回对象时,别妄想返回其reference
一、核心问题
不要返回指向局部对象或堆上对象的引用/指针,否则会导致未定义行为或内存泄漏。
二、三种错误做法
1. 返回局部对象的引用
// ❌ 严重错误:返回局部对象的引用
const Rational& operator*(const Rational& lhs, const Rational& rhs) {
Rational result(lhs.n * rhs.n, lhs.d * rhs.d); // 局部对象
return result; // 函数结束 result 销毁,引用悬空
}
Rational a, b;
Rational c = a * b; // c 拿到的是已销毁的对象 → 未定义行为
2. 返回堆上对象的引用
// ❌ 内存泄漏风险
const Rational& operator*(const Rational& lhs, const Rational& rhs) {
Rational* result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
return *result; // 谁负责 delete?
}
Rational a, b;
Rational c = a * b; // 堆对象地址丢失,无法 delete
3. 返回静态局部对象的引用
// ❌ 线程安全 + 判等问题
const Rational& operator*(const Rational& lhs, const Rational& rhs) {
static Rational result; // 静态对象
result = ...; // 修改静态对象
return result;
}
if ((a * b) == (c * d)) // 永远为 true?(两边引用同一对象)
三、正确做法
// ✅ 直接返回新对象(编译器会优化)
inline const Rational operator*(const Rational& lhs, const Rational& rhs) {
return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}
四、要点
- 必须返回新对象时,就返回新对象(值返回)
- 不要妄想通过返回引用避免拷贝——编译器有返回值优化(RVO)
- 绝不要返回局部对象的引用或指针
- 绝不要返回堆上对象的引用(除非想造成内存泄漏)
条款22:将成员变量声明为private
一、核心原则
成员变量永远应该是private,protected并不比public更有封装性。
二、三个理由
1. 语法一致性
- 用户访问数据统一用函数(
obj.size()) - 不用纠结加不加括号
2. 精确的访问控制
public:任何人都可读写 → 无控制protected:派生类可读写 → 仍无控制private:只有类本身可访问 → 完全控制
3. 封装性
class SpeedData {
private:
int speed; // 实现细节隐藏
public:
int getSpeed() const; // 接口稳定
};
- 内部实现可随意改变(计算值/缓存值)
- 外部代码不受影响
三、protected的真相
- protected变量暴露给所有派生类
- 修改protected变量会破坏所有派生类
- 封装性与public没有本质区别
四、要点总结
- 所有成员变量都应为private
- 通过成员函数提供访问(getter/setter)
- 保持接口稳定,实现灵活变化
条款23:宁以non-member、non-friend替换member函数
一、核心思想
封装性的本质:当改变类的内部实现时,需要修改的代码越少,封装性越好。
因此,能通过 public 接口完成的功能,应放在类外部作为 non-member、non-friend 函数。
二、重新理解封装性
1. 封装 ≠ 隐藏功能
- 两个版本实现的功能完全相同
- 区别在于:谁直接操作 private 成员
2. 关键对比
class WebBrowser {
private:
list<Url> history; // 内部实现
public:
void clearHistory() { history.clear(); } // 直接操作
};
// member 版本:直接操作 private
void WebBrowser::clearEverything() {
history.clear(); // 直接访问 private
// 如果 history 从 list 改成 vector,这里必须改
}
// non-member 版本:只调 public 接口
void clearBrowser(WebBrowser& wb) {
wb.clearHistory(); // 通过 public 接口
// 如果 history 从 list 改成 vector,这里不用改
}
3. 核心差异
| 场景 | member 版本 | non-member 版本 |
|---|---|---|
| 访问方式 | 直接操作 private 成员 | 通过 public 接口调用 |
| 内部改变时 | 必须修改(直接依赖实现) | 无需修改(只依赖接口) |
| 受影响范围 | 所有直接操作 private 的函数 | 只有修改的那个 public 函数本身 |
三、为什么 non-member 更好?
1. 降低耦合
- member 函数与类的内部实现紧密耦合
- non-member 函数只与 public 接口耦合
2. 隔离变化
- 改变内部实现(如 list→vector)
- member 版本:所有直接操作的地方都要改
- non-member 版本:只需改对应的 public 函数
条款24:若所有参数都需要类型转换,请为此采用non-member函数
这个条款主要讨论了当类的对象需要与其它类型进行混合运算,并且所有参数都需要隐式类型转换时,应该将函数定义为非成员函数(通常是友元) 。
1. 为什么成员函数不行?
问题场景:假设有一个有理数类 Rational,支持与整数的混合乘法。
class Rational {
public:
Rational(int numerator = 0, int denominator = 1); // 允许隐式转换
int numerator() const;
int denominator() const;
const Rational operator*(const Rational& rhs) const; // 成员函数版本
};
Rational oneHalf(1, 2);
Rational result = oneHalf * 2; // ✅ 通过:2 隐式转换为 Rational(2,1)
result = 2 * oneHalf; // ❌ 错误:2 不是 Rational 对象
问题分析:
oneHalf * 2成功:oneHalf调用成员函数,2通过构造函数隐式转换为Rational2 * oneHalf失败:2是整数类型,没有对应的operator*成员函数
2. 解决方案:非成员函数
const Rational operator*(const Rational& lhs, const Rational& rhs) {
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}
// 现在两边都可以转换了
Rational oneHalf(1, 2);
Rational result = oneHalf * 2; // ✅ OK
result = 2 * oneHalf; // ✅ OK
3. 为什么不用友元?
- 如果可以避免友元,就应该避免(封装性更好)
- 上述
operator*完全可以用公有接口实现,不需要友元 - 只有无法通过公有接口实现时才考虑友元
条款25:考虑写出一个不抛异常的swap函数
1. 为什么需要自定义swap?
默认swap的问题:标准库的std::swap通过拷贝实现,效率低下。
namespace std {
template<typename T>
void swap(T& a, T& b) {
T temp(a); // 三次拷贝!对于复杂类型开销巨大
a = b;
b = temp;
}
}
2. 高效swap的三种实现方式
方式一:对于非模板类,提供成员函数swap + 特化std::swap
class Widget {
public:
void swap(Widget& other) {
using std::swap;
swap(pImpl, other.pImpl); // 只交换指针(高效)
}
private:
Resource* pImpl; // 资源指针
};
// 特化std::swap
namespace std {
template<>
void swap<Widget>(Widget& a, Widget& b) {
a.swap(b); // 调用成员swap
}
}
方式二:对于类模板,在类所在命名空间提供非成员swap
template<typename T>
class WidgetImpl {
// ... 存储大量数据的类
};
template<typename T>
class Widget {
public:
void swap(Widget& other) {
using std::swap;
swap(pImpl, other.pImpl); // 高效交换指针
}
private:
WidgetImpl<T>* pImpl;
};
// 在Widget所在命名空间提供非成员swap
namespace WidgetStuff {
template<typename T>
void swap(Widget<T>& a, Widget<T>& b) {
a.swap(b); // 调用成员swap
}
}
方式三:使用Pimpl(pointer to implementation)惯用法(最推荐)
class Widget {
public:
void swap(Widget& other) noexcept {
std::swap(pImpl, other.pImpl); // 交换指针,保证不抛异常
}
private:
struct Impl; // 前置声明
std::unique_ptr<Impl> pImpl; // 智能指针
};
3. 调用swap的正确方式
// 在泛型代码中应该这样调用
template<typename T>
void doSomething(T& a, T& b) {
using std::swap; // 让std::swap成为候选
swap(a, b); // 如果T有特定swap,通过ADL找到;否则用std::swap
}
// 不要这样调用
std::swap(a, b); // 强制使用std版本,可能错过自定义的优化版本
第五章 实现
条款26:尽可能延后变量定义式的出现时间
1. 避免不必要的构造
// ❌ 错误:过早定义
std::string encrypted; // 调用默认构造(浪费)
if (password.length() < 6) {
throw ...; // 如果抛出异常,encrypted白构造了
}
encrypted = encrypt(password); // 赋值操作
// ✅ 正确:延后定义
if (password.length() < 6) {
throw ...;
}
std::string encrypted = encrypt(password); // 直接构造,最理想
2. 直接初始化 vs 默认构造+赋值
// ❌ 低效:默认构造 + 赋值
Widget w; // 1次构造
w = getValue(); // 1次赋值
// ✅ 高效:直接初始化
Widget w = getValue(); // 1次构造(拷贝或移动)
条款27:尽量少做转型动作
一、两种转型方式
旧式转型(C风格)
(T)expression // C风格强制转型
T(expression) // 函数风格强制转型
新式转型(C++风格)
const_cast<T>(expression) // 移除常量性
dynamic_cast<T>(expression) // 安全向下转型
reinterpret_cast<T>(expression) // 底层强制转型
static_cast<T>(expression) // 隐式转换的逆转换
二、为什么尽量少用转型
- dynamic_cast速度慢:可能遍历继承体系,成本高
- 转型容易出错:特别是涉及继承时,可能产生临时对象
- 代码难以维护:隐藏真实意图
三、关键陷阱
陷阱:转型可能创建临时对象
class Window {
public:
virtual void onResize() { }
};
class SpecialWindow : public Window {
public:
virtual void onResize() {
static_cast<Window>(*this).onResize(); // ❌ 错误!
// 这创建了新的临时Window对象,不是调用基类部分
}
};
// ✅ 正确方式
SpecialWindow::onResize() {
Window::onResize(); // 直接调用基类版本
}
四、核心结论
- 如果可以避免转型,绝对要避免
- 必须转型时,用C++风格转型代替C风格转型
- 优先考虑用虚函数重新设计,避免使用dynamic_cast
- 不要假设转型无成本,特别是在继承体系中
条款28:避免返回handles指向对象内部成分
核心规则:
不要返回对象内部数据的handles(引用、指针、迭代器)
这样做的理由:
- 保护封装性:外部代码不能绕过接口直接修改内部数据
- 提高安全性:避免悬空handles导致的未定义行为
- 维持const正确性:const成员函数应该真正const
条款29: 为“异常安全”而努力是值得的
一、异常安全的三项保证
| 保证级别 | 含义 | 类比 |
|---|---|---|
| 基本保证 | 抛出异常后,程序处于有效状态,无资源泄漏 | 数据库事务回滚,数据可能变了但仍是有效的 |
| 强烈保证 | 抛出异常后,程序状态完全不变(原子性) | 要么成功,要么回滚到开始前状态 |
| 不抛异常保证 | 函数绝不会抛出异常 | 最理想的保证 |
二、问题示例:不安全的代码
class PrettyMenu {
public:
void changeBackground(std::istream& imgSrc);
private:
Mutex mutex; // 互斥锁
Image* bgImage; // 背景图片
int imageChanges; // 修改次数
};
void PrettyMenu::changeBackground(std::istream& imgSrc) {
lock(&mutex); // 1. 加锁
delete bgImage; // 2. 删除旧图
++imageChanges; // 3. 修改次数+1
bgImage = new Image(imgSrc); // 4. 创建新图(可能抛异常!)
unlock(&mutex); // 5. 解锁(如果前面抛异常,这里不会执行)
}
问题:如果new Image抛出异常:
bgImage变成空悬指针imageChanges已增加但图片没变- 互斥锁永远不解锁(资源泄漏)
三、改进方案
步骤1:用对象管理资源
void PrettyMenu::changeBackground(std::istream& imgSrc) {
Lock m1(&mutex); // RAII:构造时加锁,析构时解锁
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc); // 异常抛出时,Lock自动解锁
}
步骤2:重新排序操作
void PrettyMenu::changeBackground(std::istream& imgSrc) {
Lock m1(&mutex);
Image* newBg = new Image(imgSrc); // 1. 先创建新图(如果失败,原状态不变)
delete bgImage; // 2. 删除旧图
bgImage = newBg; // 3. 替换指针
++imageChanges; // 4. 最后更新计数器
}
四、实现强烈保证的三种策略
策略1:copy-and-swap
class PrettyMenu {
void swap(PrettyMenu& other) noexcept; // 交换函数
};
void PrettyMenu::changeBackground(std::istream& imgSrc) {
Lock m1(&mutex);
PrettyMenu newMenu(*this); // 1. 拷贝副本
newMenu.bgImage = new Image(imgSrc); // 2. 修改副本(可能抛异常)
newMenu.imageChanges++;
swap(newMenu); // 3. 交换(不抛异常)
} // 原对象被替换,若异常发生,原对象不变
策略2:pimpl + copy-and-swap
struct PMImpl {
std::shared_ptr<Image> bgImage;
int imageChanges;
};
class PrettyMenu {
private:
std::shared_ptr<PMImpl> pImpl;
};
void PrettyMenu::changeBackground(std::istream& imgSrc) {
Lock m1(&mutex);
auto newImpl = std::make_shared<PMImpl>(*pImpl); // 拷贝副本
newImpl->bgImage.reset(new Image(imgSrc)); // 修改副本
newImpl->imageChanges++;
std::swap(pImpl, newImpl); // 交换指针
}
策略3:移动语义(C++11)
void PrettyMenu::changeBackground(std::istream& imgSrc) {
Lock m1(&mutex);
auto newBg = std::make_unique<Image>(imgSrc); // 创建新图
std::swap(bgImage, newBg); // 交换指针
++imageChanges;
}
五、函数提供哪种保证?
重要原则:函数的异常安全保证不能高于其调用的函数
// 这个函数最多只能提供基本保证
// 因为f1()、f2()、f3()都可能抛出异常
void doSomething() {
f1(); // 可能抛异常
f2(); // 可能抛异常
f3(); // 可能抛异常
}
决定函数异常安全级别的因素:
- 调用的其他函数提供什么保证
- 函数自身的逻辑设计
条款30: 透彻了解inlining的里里外外
一、什么是inlining?
概念:编译器将函数调用替换为函数本体代码的过程,避免函数调用开销。
二、inline的申请方式
方式1:隐式inline
在类定义内实现的函数自动成为inline申请:
class Person {
public:
int age() const { return age_; } // ✅ 隐式inline申请
private:
int age_;
};
方式2:显式inline
使用inline关键字明确申请:
template<typename T>
inline const T& max(const T& a, const T& b) { // 显式inline
return a < b ? b : a;
}
三、inline的优缺点
| 优点 | 缺点 |
|---|---|
| ✅ 消除函数调用开销 | ❌ 增加目标码体积 |
| ✅ 可能触发更多优化 | ❌ 导致指令缓存不命中 |
| ✅ 对短小函数效果显著 | ❌ 过度使用降低性能 |
| ❌ 调试困难(断点失效) | |
| ❌ 修改需重新编译所有调用者 |
四、虚函数通常不能被inlining
虚函数在运行时才确定调用哪个版本,而inline是在编译期将函数调用替换为函数体:
五、核心结论
将大多数inline限制在小型、频繁调用的函数上。这样可使日后的调试和二进制升级更容易,也可最小化潜在的代码膨胀问题,最大化提升程序速度的几率。
条款31: 将文件间的编译依存关系降至最低
将接口与实现分离
函数声明(参数和返回值)只需要前向声明,只有定义对象成员时才需要完整定义(#include)。
一、核心理念:为什么需要降低编译依存?
1. 问题本质:C++的编译耦合
- C++编译器需要知道对象大小才能分配内存,因此头文件通常需要包含所有依赖的完整定义
- 如果头文件A包含了头文件B,那么修改B会导致所有包含A的文件重新编译
- 这种连锁反应在大型项目中可能导致数十分钟甚至数小时的编译时间
2. 设计目标:分离接口与实现
- 接口部分:应该保持稳定,只暴露必要的声明
- 实现部分:可以自由修改,不影响接口的使用者
- 核心原则:依赖于声明,而非依赖于定义
3. 两种主流解耦手法
- pimpl模式(Pointer to Implementation):用指针指向实现类
- 接口类模式(抽象基类):只提供纯虚函数接口
二、pimpl模式:编译防火墙
【理念】
将类的私有成员全部移到一个嵌套的实现类中,原类只保留一个指向实现类的指针。这样原类的头文件只需要前向声明实现类,不需要包含任何实现相关的头文件。
【例子:日期类解耦】
❌ 反面例子(高耦合)
// Date.h
class Date {
public:
Date(int y, int m, int d) : year(y), month(m), day(d) {}
private:
int year, month, day; // 修改这里会触发连锁编译
};
// Person.h - 问题所在
#include "Date.h" // !强依赖:必须包含Date的定义
class Person {
Date birthday; // 直接使用Date对象,必须知道完整定义
};
// 修改Date.h → Person.h变更 → 所有包含Person.h的文件重新编译
✅ 正面例子(pimpl解耦)
// Person.h - 稳定的接口
#include <memory> // 只需要标准库
class Date; // 前向声明:告诉编译器有这么一个类
class PersonImpl; // 前向声明实现类
class Person {
public:
Person(const std::string& name, const Date& birthday);
~Person(); // 必须声明,但在cpp中定义
std::string getName() const;
std::string getBirthdayString() const; // 返回字符串,不暴露Date
private:
std::unique_ptr<PersonImpl> pImpl; // 指针大小固定,不依赖实现
};
// Person.cpp - 真正的实现在这里
#include "Person.h"
#include "Date.h" // 只有在cpp中才需要Date的完整定义
// 实现类完全隐藏在cpp文件中
class PersonImpl {
public:
PersonImpl(const std::string& name, const Date& birthday)
: name(name), birthday(birthday) {}
std::string getName() const { return name; }
std::string getBirthdayString() const {
// 格式化日期为字符串
return std::to_string(birthday.year) + "/" + ...;
}
private:
std::string name;
Date birthday; // 真正的数据成员在这里
};
// Person接口只是转发给pImpl
Person::Person(...) : pImpl(std::make_unique<PersonImpl>(name, birthday)) {}
Person::~Person() = default; // 必须在这里,因为PersonImpl在此处完整
std::string Person::getName() const { return pImpl->getName(); }
【效果对比】
- 修改Date.h:反面例子需要重新编译Person和所有使用者;正面例子只需重新编译Person.cpp
- 添加Date的新功能:反面例子暴露所有细节;正面例子完全封装在cpp中
- 二进制兼容性:反面例子修改成员会改变对象大小;正面例子对象大小固定(仅指针)
三、接口类模式:完全抽象
【理念】
将类定义为纯抽象基类(只有虚函数,没有数据成员),通过工厂函数返回具体派生类的指针。使用者只依赖于不变的抽象接口。
【例子:用接口类解耦】
// Person.h - 抽象接口
class Person {
public:
virtual ~Person() = default;
virtual std::string getName() const = 0;
virtual std::string getBirthdayString() const = 0;
// 工厂函数:返回具体实现的智能指针
static std::shared_ptr<Person> create(
const std::string& name, const Date& birthday);
};
// Person.cpp - 具体实现
#include "Person.h"
#include "Date.h"
class RealPerson : public Person {
public:
RealPerson(const std::string& name, const Date& birthday)
: name(name), birthday(birthday) {}
std::string getName() const override { return name; }
std::string getBirthdayString() const override {
// 格式化日期
return std::to_string(birthday.year) + "-" + ...;
}
private:
std::string name;
Date birthday; // 具体实现在这里才依赖Date
};
// 工厂函数实现
std::shared_ptr<Person> Person::create(const std::string& name, const Date& birthday) {
return std::make_shared<RealPerson>(name, birthday);
}
// main.cpp - 使用者
#include "Person.h"
#include "Date.h" // 需要Date来创建对象,但不需要知道RealPerson
int main() {
Date birthday(1990, 1, 1);
auto p = Person::create("张三", birthday);
std::cout << p->getName() << ": " << p->getBirthdayString();
return 0;
}
【接口类 vs pimpl】
| 对比维度 | pimpl模式 | 接口类模式 |
|---|---|---|
| 实现复杂度 | 较低(需要管理指针) | 较高(需要工厂函数) |
| 运行效率 | 无虚函数调用开销 | 有虚函数调用开销 |
| 内存占用 | 多一个指针的内存 | 虚表指针开销 |
| 适用场景 | 大多数普通类 | 需要多态、有多种实现的类 |
四、实践要点与总结
【关键设计原则】
1. 头文件最小化原则
- 能用前向声明,就不要用#include
- 能用指针/引用,就不要用对象(指针大小固定,不需要完整定义)
- 把类的实现细节尽可能移到cpp文件中
2. 依赖方向原则
text
稳定 ← 不稳定
接口(头文件) ← 实现(cpp文件)
Date声明 ← Date定义
Person接口 ← PersonImpl实现
【最终总结】
条款31的核心思想:
让头文件成为一份稳定的"合同",通过pimpl或接口类将实现细节隐藏,使修改实现不会引发接口使用者的连锁编译。
第六章:继承与面向对象设计
条款32:确定你的public继承塑模出is-a关系
public 继承派生类对象就是一个基类对象。
条款33:避免遮掩继承而来的名称 188
派生类的名称会遮掩基类中所有同名函数(不管参数是否相同),这不是重载,是名称遮掩。
核心问题:作用域的嵌套
class Base {
public:
virtual void func(int x) { cout << "Base::func(int)"; }
void func() { cout << "Base::func()"; } // 重载
};
class Derived : public Base {
public:
void func(string s) { cout << "Derived::func(string)"; }
// 问题:Base中所有名为func的函数都被遮掩了!
};
Derived d;
d.func(10); // ❌ 编译错误!Derived的func遮掩了Base版本
d.func(); // ❌ 编译错误!
d.func("hello"); // ✅ 正常
名称查找规则:编译器先在Derived作用域找func,找到后就停止,不再去Base查找
条款34:区分接口继承和实现继承
C++的继承有三种形式:只继承接口、继承接口和默认实现、继承接口并强制实现,设计时要明确意图。
三种继承类型
class Shape {
public:
// 1. 纯虚函数:只继承接口
virtual void draw() const = 0;
// 2. 虚函数:继承接口和默认实现
virtual void error(const string& msg) {
cout << "Error: " << msg;
}
// 3. 非虚函数:继承接口和强制实现
int objectID() const { return id; }
};
条款35: 考虑virtual函数以外的其他选择
略
条款36: 绝不重新定义继承而来的non-virtual函数
1. 条款核心观点
这个条款提出了一个绝对性的设计原则:永远不要在派生类中重新定义(override)一个继承自基类的 非虚函数(non-virtual function) 。
为什么这么绝对?因为一旦你这么做了,程序就会陷入逻辑矛盾和未定义行为之中。
2. 问题演示
假设有如下类定义:
class Base {
public:
void func() { // non-virtual 函数
std::cout << "Base::func" << std::endl;
}
};
class Derived : public Base {
public:
void func() { // 重新定义了 non-virtual 函数(大忌!)
std::cout << "Derived::func" << std::endl;
}
};
现在,看看这段代码会发生什么:
Derived d;
Base* pb = &d;
Derived* pd = &d;
pb->func(); // 调用的是 Base::func
pd->func(); // 调用的是 Derived::func
同一个对象 d,通过不同的指针调用同一个函数,却得到了不同的行为!
3. 为什么会这样?
这个现象的背后是 C++ 的两个核心机制:
A. 静态绑定 vs 动态绑定
-
non-virtual 函数:采用静态绑定(static binding)
- 编译器在编译期就决定了调用哪个函数
- 依据的是指针/引用的静态类型(即声明时的类型),而不是对象的实际类型
pb的静态类型是Base*,所以pb->func()永远调用Base::func
-
virtual 函数:采用动态绑定(dynamic binding)
- 运行时根据对象的实际类型决定调用哪个函数
- 如果
func是 virtual,那么无论通过pb还是pd调用,都会执行Derived::func
条款37: 绝不重新定义继承而来的缺省参数值
这个条款提出了另一个绝对性的设计原则:永远不要在派生类中重新定义(override)一个继承而来的 虚函数(virtual function)的缺省参数值(default parameter values) 。
为什么只针对虚函数?因为条款36已经禁止重新定义 non-virtual 函数,所以这里讨论的自然是虚函数的情况。
2. 问题演示
class Shape {
public:
enum Color { RED, GREEN, BLUE };
virtual void draw(Color color = RED) const { // 缺省参数为 RED
std::cout << "Shape::draw with color " << color << std::endl;
}
};
class Rectangle : public Shape {
public:
virtual void draw(Color color = GREEN) const { // 重新定义了缺省参数(大忌!)
std::cout << "Rectangle::draw with color " << color << std::endl;
}
};
看看这段代码的执行结果:
Shape* ps; // 静态类型 = Shape*
Shape* pr = new Rectangle(); // 静态类型 = Shape*,动态类型 = Rectangle*
Rectangle* pr2 = new Rectangle(); // 静态类型 = Rectangle*
ps = pr;
ps->draw(); // 调用的是 Rectangle::draw,但使用的缺省参数是 RED!
pr->draw(); // 调用的是 Rectangle::draw,但使用的缺省参数是 RED!
pr2->draw(); // 调用的是 Rectangle::draw,使用的缺省参数是 GREEN
这个令人困惑的现象源于 C++ 的一个设计决策:虚函数是动态绑定的,但缺省参数是静态绑定的。
A. 静态绑定 vs 动态绑定的混合
- 虚函数本身:动态绑定(运行时决定调用哪个函数)
- 缺省参数值:静态绑定(编译期决定使用哪个缺省值)
条款38: 通过复合塑模出has-a或“根据某物实现出”
1. 复合是什么?
一个类包含另一个类的对象(成员变量)。
2. 复合的两种含义(核心!)
| 含义 | 领域 | 解释 | 例子 |
|---|---|---|---|
| has-a | 应用域(业务逻辑) | 整体-部分关系 | 人有地址、车有引擎 |
| is-implemented-in-terms-of | 实现域(技术实现) | 用A实现B(复用代码) | 用list实现Set |
条款39:明智而审慎地使用private继承
一、private继承的核心含义
| 维度 | 说明 |
|---|---|
| 语义 | "is-implemented-in-terms-of"(根据某物实现出) |
| 与public继承的区别 | public继承表达"is-a",private继承表达实现层面的复用 |
| 访问权限变化 | 基类所有public/protected成员在派生类中变成private |
| 类型转换 | 派生类对象不能隐式转换为基类对象(外界不知道继承关系) |
二、private继承的行为特征
1. 编译期行为
c++
class Base {};
class Derived : private Base {};
Derived d;
Base* p = &d; // ❌ 错误!外界无法转换
2. 多态特性(重要!)
| 作用域 | 多态是否有效 | 原因 |
|---|---|---|
| 派生类内部 | ✅ 有效 | 内部知道继承关系,可以转换 |
| 外部代码 | ❌ 无效 | 转换被禁止,多态无从发生 |
| 孙类内部 | ❌ 无效 | private继承的基类在派生类中是private,孙类看不见 |
class Base {
public:
virtual void func() {}
};
class Derived : private Base {
public:
virtual void func() override {}
void test() {
Base* p = this; // ✅ 内部可以转换,多态有效
p->func(); // 调用Derived::func
}
};
class GrandChild : public Derived {
void test() {
Base* p = this; // ❌ 错误!孙类看不到Base
}
};
三、private继承 vs 复合(核心对比)
| 比较维度 | private继承 | 复合 |
|---|---|---|
| 表达语义 | is-implemented-in-terms-of | is-implemented-in-terms-of / has-a |
| 耦合度 | 较高(继承关系紧密) | 较低(成员对象) |
| 访问权限 | 可访问基类protected成员 | 只能访问public成员 |
| 虚函数重写 | ✅ 可以重写基类虚函数 | ❌ 无法重写 |
| 设计原则 | 特殊工具,谨慎使用 | 默认首选 |
四、何时该用private继承?(只有两种场景)
场景1:需要访问基类的protected成员
class Widget {
protected:
void internalFunc();
};
// 复合做不到(无法访问protected成员)
class MyWidget : private Widget {
public:
void doSomething() {
internalFunc(); // ✅ 可以访问protected成员
}
};
场景2:需要重写基类的虚函数
class Timer {
public:
virtual void onTick() const;
};
// 复合无法重写虚函数
class Widget : private Timer {
private:
virtual void onTick() const override { // ✅ 重写虚函数
// 定时器逻辑
}
};
五、空基类优化(EBO - Empty Base Optimization)
问题:空类也占内存
class Empty {}; // sizeof ≥ 1
class HoldsInt {
int x;
Empty e; // 占用额外内存
}; // sizeof ≥ sizeof(int) + 1
解决方案:用private继承实现零开销
class HoldsInt : private Empty {
int x;
}; // sizeof = sizeof(int)(优化生效)
适用场景:当继承空类(traits类、policy类)时,EBO消除内存开销。
六、继承体系的多态可见性总结
| 继承类型 | 外部代码 | 派生类内部 | 孙类内部 |
|---|---|---|---|
| public继承 | ✅ 有多态 | ✅ 有多态 | ✅ 有多态 |
| protected继承 | ❌ 无多态 | ✅ 有多态 | ✅ 有多态 |
| private继承 | ❌ 无多态 | ✅ 有多态 | ❌ 无多态 |
| 承类型 | 外部代码 | 派生类内部 | 孙类内部 |
|---|---|---|---|
| public | Base* p = &gc; ✅ | Base* p = this; ✅ | Base* p = this; ✅ |
| protected | Base* p = &gc; ❌ | Base* p = this; ✅ | Base* p = this; ✅ |
| private | Base* p = &d; ❌ | Base* p = this; ✅ | Base* p = this; ❌ |
条款40:明智而审慎地使用多重继承
一、多重继承的两大问题
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 名字冲突 | 不同基类有同名成员 | 显式指定调用哪个基类:obj.Base1::func() |
| 钻石型继承 | 数据重复(两份拷贝) | 虚继承(virtual public) |
二、虚继承的代价(重要!)
| 代价 | 说明 |
|---|---|
| 体积变大 | 需要额外的指针(vbptr) |
| 访问变慢 | 通过虚基类表间接访问 |
| 初始化复杂 | 最底层派生类直接初始化虚基类 |
原则:非必要不使用虚继承;如果必须用,尽量让虚基类不带数据。
第七章: 模板与泛型编程
条款41: 了解隐式接口和编译器多态
一、核心概念对比
| 维度 | 面向对象(OO) | 模板(Templates) |
|---|---|---|
| 接口类型 | 显式接口(Explicit Interface) | 隐式接口(Implicit Interface) |
| 多态类型 | 运行时多态(Runtime Polymorphism) | 编译期多态(Compile-time Polymorphism) |
| 绑定时机 | 运行时(虚函数表) | 编译期(模板实例化) |
二、显式接口 vs 隐式接口
1. 显式接口(面向对象)
class Widget {
public:
virtual void draw() const;
virtual int size() const;
};
void process(const Widget& w) {
w.draw(); // 显式调用
w.size(); // 显式调用
}
- 定义方式:源代码中明确写出(函数签名)
- 特点:必须继承自某个基类,明确声明了支持哪些操作
2. 隐式接口(模板)
隐式接口在被调用时,生成相应的函数实例代码
template<typename T>
void process(T& w) {
w.draw(); // 要求T支持draw()
w.size(); // 要求T支持size()
}
条款42:了解typename的双重意义
一、typename 的两个用途
-
声明模板类型参数
在模板参数列表中,class和typename可以互换,都表示一个类型参数。template<typename T> class Widget; // 等同于 template<class T> template<class T> class Widget; // 与上一行完全等价从语义上讲,
typename更准确(因为它不一定是个类),但class是历史遗留写法,两者无差别。 -
标识嵌套依赖类型
这是typename的关键作用:告诉编译器“某个嵌套名称是一个类型,而不是静态成员或变量”。template<typename C> void print2nd(const C& container) { if (container.size() >= 2) { // 错误:编译器默认认为 C::const_iterator 不是类型 // C::const_iterator it(container.begin()); // 正确:用 typename 声明它是类型 typename C::const_iterator it(container.begin()); ++it; int value = *it; std::cout << value; } }- 嵌套依赖类型:形式为
T::xxx,且T是模板参数。编译器在解析模板时不知道T是什么,所以默认认为T::xxx不是类型(除非用typename显式指定)。
- 嵌套依赖类型:形式为
二、需要 typename 的场景
-
只有在嵌套依赖类型名称前需要加
typename。 -
但有两个例外:
-
基类列表中不能加
typename。 -
成员初始化列表中不能加
typename。
template<typename T> class Derived : public Base<T>::Nested { // 基类列表,不能加 typename public: explicit Derived(int x) : Base<T>::Nested(x) // 成员初始化列表,不能加 typename { typename Base<T>::Nested temp; // 其他地方需要加 } }; -
条款43:学习处理模板化基类内的名称
一、核心问题
在模板类继承中,派生类模板无法直接访问基类模板中的名称(成员函数、类型、数据等),因为编译器在解析派生类模板时,不知道基类具体是什么(基类依赖于模板参数),所以默认不会在基类中查找名称。
template<typename T>
class Base {
public:
void sendClear() { ... }
};
template<typename T>
class Derived : public Base<T> {
public:
void clear() {
sendClear(); // 编译错误!无法找到 sendClear
}
};
二、问题原因
编译器在解析 Derived<T> 时,基类 Base<T> 依赖于模板参数 T,而 T 尚未确定。编译器无法知道:
Base<T>是否有sendClear成员- 是否存在特化版本没有这个成员
因此,编译器采取保守策略:不在模板化基类中查找名称。
三、三种解决方案
1. 在基类成员调用前加上 this->
template<typename T>
class Derived : public Base<T> {
public:
void clear() {
this->sendClear(); // 通过 this 告诉编译器这是一个成员函数
}
};
this-> 暗示该名称是成员函数,编译器会延迟查找直到模板实例化。
2. 使用 using 声明
template<typename T>
class Derived : public Base<T> {
public:
using Base<T>::sendClear; // 将基类名称引入作用域
void clear() {
sendClear(); // 现在可以找到了
}
};
3. 显式指定基类作用域
template<typename T>
class Derived : public Base<T> {
public:
void clear() {
Base<T>::sendClear(); // 显式调用,但会关闭虚函数机制
}
};
注意:这种方式如果 sendClear 是虚函数,会绕过虚函数绑定机制
条款44: 将与参数无关的代码抽离templates
模板可以生成多份代码,但如果不加控制,每个模板实例化都会生成一份完整的代码副本,导致代码膨胀(code bloat) 。该条款指导如何识别并抽离与模板参数无关的代码,避免二进制文件过大。
条款45: 运用成员函数模板接受所有兼容类型
一、核心思想
使用成员函数模板(member function templates) 来生成能够接受所有兼容类型的函数,实现类型之间的隐式转换,特别是处理智能指针等封装类型时的转换问题。
二、问题背景
1. 普通指针的隐式转换
C++中,派生类指针可以隐式转换为基类指针:
class Base {};
class Derived : public Base {};
Derived* d = new Derived;
Base* b = d; // 隐式转换,合法
2. 智能指针的困境
但自定义的智能指针无法自动支持这种转换:
template<typename T>
class SmartPtr {
public:
explicit SmartPtr(T* ptr) : ptr_(ptr) {}
// 没有转换构造函数
private:
T* ptr_;
};
SmartPtr<Derived> spd = new Derived;
SmartPtr<Base> spb = spd; // 编译错误!类型不匹配
问题:SmartPtr<Derived> 和 SmartPtr<Base> 是两个完全不同的类型,无法自动转换。
三、解决方案:成员函数模板
1. 基本实现
使用成员模板实现泛化拷贝构造函数:
template<typename T>
class SmartPtr {
public:
// 泛化拷贝构造函数:接受任何兼容类型的智能指针
template<typename U>
SmartPtr(const SmartPtr<U>& other)
: ptr_(other.get()) {} // 需要 U* 可以转换为 T*
T* get() const { return ptr_; }
private:
T* ptr_;
};
// 现在可以工作了
SmartPtr<Derived> spd(new Derived);
SmartPtr<Base> spb(spd); // 合法!通过成员模板构造
2. 支持隐式转换的赋值操作
同样可以为赋值操作提供成员模板:
template<typename T>
class SmartPtr {
public:
// 泛化拷贝构造函数
template<typename U>
SmartPtr(const SmartPtr<U>& other)
: ptr_(other.get()) {}
// 泛化拷贝赋值操作符
template<typename U>
SmartPtr& operator=(const SmartPtr<U>& other) {
ptr_ = other.get();
return *this;
}
T* get() const { return ptr_; }
private:
T* ptr_;
};
四、实际案例:智能指针的完整实现
template<typename T>
class SmartPtr {
public:
// 构造函数:接受原始指针
explicit SmartPtr(T* ptr = nullptr) : ptr_(ptr) {}
// 析构函数
~SmartPtr() { delete ptr_; }
// 泛化拷贝构造函数:支持向上转换
template<typename U>
SmartPtr(const SmartPtr<U>& other)
: ptr_(other.get()) {} // 要求 U* 可转换为 T*
// 泛化拷贝赋值
template<typename U>
SmartPtr& operator=(const SmartPtr<U>& other) {
if (this != &other) {
delete ptr_;
ptr_ = other.get();
}
return *this;
}
// 解引用操作符
T& operator*() const { return *ptr_; }
T* operator->() const { return ptr_; }
// 获取原始指针
T* get() const { return ptr_; }
private:
T* ptr_;
};
// 使用示例
class Base { public: virtual ~Base() = default; };
class Derived : public Base {};
SmartPtr<Derived> spd(new Derived);
SmartPtr<Base> spb(spd); // 向上转换:Derived* -> Base*
SmartPtr<Base> spb2 = spd; // 同样合法
// 容器中使用
std::vector<SmartPtr<Base>> vec;
vec.push_back(spd); // 自动转换
条款46: 需要类型转换时请为模板定义非成员函数
一、核心问题
模板参数推导不考虑隐式转换
template<typename T>
class Rational { ... };
template<typename T>
const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs);
Rational<int> oneHalf(1, 2);
Rational<int> result = oneHalf * 2; // ❌ 编译失败
oneHalf→ 推导出T = int2(int) → 无法推导为Rational<int>(模板推导不考虑隐式转换)
二、解决方案
将 operator* 定义为类内友元函数
template<typename T>
class Rational {
public:
Rational(const T& numerator = 0, const T& denominator = 1);
// 友元函数(在类内定义)
friend const Rational operator*(const Rational& lhs, const Rational& rhs) {
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}
};
三、一句话总结
模板类要支持隐式类型转换,就把非成员函数写成类内友元,让它随着类实例化变成普通函数
条款47: 请使用traits classes 表现类型信息
一、核心思想
traits = 编译期类型信息提取技术
让模板代码能够在编译期知道类型的特性,从而做出最优决策。在现代C++中,<type_traits>已是标准库核心,与if constexpr、Concepts完美配合。
二、标准库常用traits
| 类别 | 示例 | 作用 |
|---|---|---|
| 类型查询 | std::is_integral_v<T> | 是否为整数 |
std::is_pointer_v<T> | 是否为指针 | |
std::is_class_v<T> | 是否为类 | |
| 类型比较 | std::is_same_v<T,U> | 类型相同? |
| 类型转换 | std::remove_const_t<T> | 去掉const |
std::decay_t<T> | 退化类型(数组→指针等) |
三、典型用法
template<typename T>
void process(T value) {
if constexpr (std::is_integral_v<T>) {
// 整数版本:快速整数运算
std::cout << "整数处理\n";
} else if constexpr (std::is_floating_point_v<T>) {
// 浮点版本:精度处理
std::cout << "浮点数处理\n";
} else {
// 通用版本
std::cout << "其他类型\n";
}
}
四、一句话总结
traits让模板代码在编译期就能“看见”类型特性,实现零开销的类型决策,是现代C++模板元编程的基石
条款48: 认识template元编程
一、什么是模板元编程(TMP)?
模板元编程 = 用C++模板在编译期执行计算的编程技术
- 代码在编译期运行
- 输入:类型、编译期常量
- 输出:类型、编译期常量、优化后的代码
二、核心特点
| 特点 | 说明 |
|---|---|
| 编译期执行 | 程序运行前完成计算,零运行时开销 |
| 函数式编程 | 没有变量,用递归代替循环,用特化代替分支 |
| 类型即数据 | 类型是操作对象,模板是函数 |
三、经典示例:编译期阶乘
cpp
// 主模板:递归计算
template<unsigned n>
struct Factorial {
static constexpr unsigned value = n * Factorial<n-1>::value;
};
// 特化:终止条件
template<>
struct Factorial<0> {
static constexpr unsigned value = 1;
};
// 使用
static_assert(Factorial<5>::value == 120); // 编译期计算
计算过程:Factorial<5>::value → 5 * Factorial<4>::value → ... → 120
四、现代C++简化(C++14+)
// C++14:constexpr函数更简洁
template<unsigned n>
constexpr unsigned factorial() {
if constexpr (n <= 1) return 1;
else return n * factorial<n-1>();
}
static_assert(factorial<5>() == 120);
对比:传统TMP用
struct + value,现代TMP用constexpr函数更直观
五、TMP的三类输出
| 输出类型 | 示例 | 作用 |
|---|---|---|
| 类型 | std::remove_const<T> | 从类型中去掉const |
| 常量值 | Factorial<5>::value | 编译期计算数值 |
| 代码生成 | std::vector<T>::iterator | 根据类型生成不同代码 |
六、典型应用场景
1. 类型转换与萃取(traits)
// 编译期去除const
using NoConst = std::remove_const_t<const int>; // int
2. 编译期断言与优化
// 确保类型是整数
template<typename T>
void process(T value) {
static_assert(std::is_integral_v<T>, "T必须是整数类型");
// ...
}
3. 递归展开循环
// 编译期展开数组初始化
template<size_t I, typename T, size_t N>
struct Unroll {
static void init(T (&arr)[N], const T& val) {
arr[I] = val;
Unroll<I+1, T, N>::init(arr, val);
}
};
template<typename T, size_t N>
struct Unroll<N, T, N> {
static void init(T (&arr)[N], const T& val) {}
};
七、优缺点
| 优点 | 缺点 |
|---|---|
| 零运行时开销 | 编译时间增加 |
| 类型安全 | 代码可读性差 |
| 编译期优化 | 错误信息难以理解 |
| 元函数可组合 | 调试困难 |
八、现代C++演进趋势
| 版本 | 演进 |
|---|---|
| C++98 | 传统TMP(struct+递归+特化) |
| C++11 | <type_traits>标准库,constexpr初现 |
| C++14 | constexpr函数更灵活 |
| C++17 | if constexpr简化分支 |
| C++20 | Concepts减少TMP复杂度 |
| 未来 | 静态反射可能取代部分TMP |
九、一句话总结
TMP是“编译期编程”:用模板在编译期计算类型和常量,换取零运行时开销;现代C++中可用constexpr和if constexpr大幅简化,但核心思想不变
第八章: 定制new和delete
条款49:了解new-handler 的行为
一、什么是new-handler
当operator new分配内存失败时,会重复调用一个用户指定的错误处理函数,直到:
- 分配成功
- 抛出异常
- 终止程序
cpp
namespace std {
using new_handler = void(*)();
new_handler set_new_handler(new_handler p) noexcept;
}
二、new-handler的四种典型行为
| 行为 | 代码示例 | 说明 |
|---|---|---|
| 释放内存 | delete[] reserveMemory; | 释放预占的内存后重试 |
| 抛异常 | throw bad_alloc(); | 转交给异常处理 |
| 终止 | abort(); | 立即结束程序 |
| 换handler | set_new_handler(another); | 下次调用不同的handler |
三、类专属new-handler(核心技巧)
class Widget {
public:
static void* operator new(size_t size) {
NewHandlerGuard guard(currentHandler); // RAII临时替换
return ::operator new(size); // 失败时会调用currentHandler
}
static new_handler set_new_handler(new_handler p) {
swap(currentHandler, p);
return p;
}
private:
static new_handler currentHandler;
};
关键点:用RAII类保存并恢复全局handler,实现专属handler效果。
条款50:了解new和delete的合理替换实机
一、为什么替换operator new/delete
| 动机 | 说明 |
|---|---|
| 检测错误 | 超额分配内存记录边界,检测下溢/上溢 |
| 性能优化 | 通用分配器慢,可为特定场景定制 |
| 内存统计 | 统计分配/释放情况,定位内存泄漏 |
| 对齐控制 | 确保对象在特定对齐边界(如缓存行) |
| 线程安全 | 使用更轻量的线程局部分配器 |
二、替换的三种层次
// 1. 全局替换(影响所有)
void* operator new(size_t size) {
std::cout << "Global new\n";
return malloc(size);
}
// 2. 类专属替换(仅对该类及其派生类)
class Widget {
public:
static void* operator new(size_t size);
static void operator delete(void* ptr);
};
// 3. 模板专属替换
template<typename T>
class Pool {
public:
static void* operator new(size_t size);
static void operator delete(void* ptr);
};
三、实现要点
1. operator new的规范
void* operator new(size_t size) {
if (size == 0) size = 1; // 0字节请求返回1字节合法指针
while (true) {
void* ptr = malloc(size);
if (ptr) return ptr;
// 分配失败,调用new-handler
auto handler = std::get_new_handler();
if (!handler) throw std::bad_alloc();
handler();
}
}
2. operator delete的规范
void operator delete(void* ptr) noexcept {
if (ptr == nullptr) return; // 删除空指针是安全的
free(ptr);
}
3. 数组版本
void* operator new[](size_t size);
void operator delete[](void* ptr) noexcept;
四、常见定制场景
1. 边界标记检测内存泄漏/溢出
struct Header { size_t size; char magic[4]; }; // magic = "MEM"
void* operator new(size_t size) {
Header* h = (Header*)malloc(size + sizeof(Header));
h->size = size;
memcpy(h->magic, "MEM", 4);
return h + 1; // 返回Header之后的内存给用户
}
void operator delete(void* ptr) {
Header* h = (Header*)ptr - 1;
assert(memcmp(h->magic, "MEM", 4) == 0); // 检测溢出
free(h);
}
2. 对象池(固定大小分配)
class Widget {
static void* pool;
static void* alloc() { /* 从池中取 */ }
static void dealloc(void* p) { /* 归还池中 */ }
public:
static void* operator new(size_t size) {
assert(size == sizeof(Widget));
return alloc();
}
static void operator delete(void* ptr) {
dealloc(ptr);
}
};
3. 对齐控制(缓存行友好)
void* operator new(size_t size) {
void* ptr;
if (posix_memalign(&ptr, 64, size) != 0)
throw std::bad_alloc();
return ptr;
}
条款51:编写new和delete时需固守常规
一、核心原则
自定义operator new/delete必须模仿标准库的行为,否则产生未定义行为。
二、operator new的3个规则
| 规则 | 说明 |
|---|---|
| 0字节处理 | size==0时返回1字节合法指针 |
| 循环handler | 失败时循环调用new_handler直至成功或抛异常 |
| 只抛bad_alloc | 不允许抛出其他异常 |
void* operator new(size_t size) {
if (size == 0) size = 1;
while (true) {
if (void* p = malloc(size)) return p;
auto h = std::get_new_handler();
if (!h) throw std::bad_alloc();
h(); // 用户handler可能改变状态
}
}
三、operator delete的2个规则
| 规则 | 说明 |
|---|---|
| 空指针安全 | nullptr时什么都不做 |
| 不抛异常 | 声明为noexcept |
void operator delete(void* p) noexcept {
if (p == nullptr) return;
free(p);
}
四、继承时的陷阱
class Base {
public:
static void* operator new(size_t size) {
if (size != sizeof(Base)) // 关键检查!
return ::operator new(size); // 派生类交给全局
// 否则用自己的池
}
};
Derived* d = new Derived(); // 会调用Base::operator new
必须检查size,因为派生类会继承基类的operator new。
条款52: 写了placemen new 也要写 placement delete
一、什么是placement new
placement new 是带有额外参数的operator new,用于在指定内存或自定义方式上构造对象。
// 标准placement new(在已分配内存上构造)
Widget* pw = new (buffer) Widget();
// 自定义placement new
static void* operator new(size_t size, ostream& log);
Widget* pw = new (cerr) Widget();
二、核心问题
使用placement new时,若构造函数抛异常,系统会调用参数匹配的placement delete释放内存。
缺少 → 内存泄漏!
class Widget {
static void* operator new(size_t, ostream&);
// 缺少对应的placement delete ❌
Widget() { throw 1; }
};
new (cerr) Widget(); // 构造抛异常 → 内存泄漏
三、必须配对
class Widget {
public:
// placement new
static void* operator new(size_t size, ostream& log) {
return malloc(size);
}
// placement delete(参数必须完全匹配)
static void operator delete(void* ptr, ostream& log) noexcept {
free(ptr);
}
// 普通delete(也要提供)
static void operator delete(void* ptr) noexcept {
free(ptr);
}
};
四、名称隐藏问题
placement new会隐藏普通new:
class Base {
static void* operator new(size_t, ostream&);
};
new Base(); // ❌ 错误!普通new被隐藏
new (cerr) Base(); // ✓ 只能用这个
解决:用using引入标准版本
class Base {
static void* operator new(size_t, ostream&);
using ::operator new; // 让普通new可见
};
五、要点总结
| 要点 | 说明 |
|---|---|
| placement new | 带额外参数的operator new |
| placement delete | 参数必须完全匹配,处理构造异常 |
| 必须配对 | 写了placement new就必须写对应的placement delete |
| 提供普通版本 | placement new会隐藏普通版本 |
| 构造异常 | 系统自动调用placement delete,否则泄漏 |
第九章: 杂项讨论
条款53:不要轻忽编译器的警告
略
条款54:让自己熟悉包含TR1在内的标准程序库
略
条款55: 让自己熟悉boost
略