Effective Modern C++ 笔记
本文记录了 《Effective Modern C++》 的阅读笔记,涵盖了现代 C++ 的理解和实践。
类型推导
item1 理解模版类型推导
- 在模板类型推导时,有引用的实参会被视为无引用,他们的引用会被忽略
- 对于通用引用的推导,左值实参会被特殊对待
- 对于传值类型推导,
const
和/或volatile
实参会被认为是non-const
的和non-volatile
的- 在模板类型推导时,数组名或者函数名实参会退化为指针,除非它们被用于初始化引用
考虑伪代码的函数模板:
template<typename T>
void f(ParamType param);
// 调用
f(expr);
在编译期间使用 expr 进行两个类型推导:一个是针对 T 的,一个是针对 ParamType,后者通常是包含修饰的 T。
情景一:ParamType 是一个指针或引用,但不是通用引用
template<typename T> void f(T& param); int x = 27; // f(x) T: int; param: int& const int cx = x; // f(cx) T: const int; param: const int& const int& rx = x; // f(rx) T: const int; param: const int& // 即使 rx 是个引用,但 T 在推导中忽略引用性,右值引用同理 template<typename T> void f(const T& param); // reference-to-const int x = 27; // f(x) T: int; param: const int& const int cx = x; // f(cx) T: int; param: const int& const int& rx = x; // f(rx) T: int; param: const int& // 即使 rx 是个引用,但 T 在推导中忽略引用性,右值引用同理 template<typename T> void f(T* param); int x = 27; // f(&x) T: int; param: int* const int *px = &x; // f(px) T: const int; param: const int*
情景二:ParamType 是一个通用引用(通用引用,模板 T,声明形式为 T&&)
template<typename T> void f(T&& param); // 通用引用考虑传参的参数是左值还是右值 // - 左值:那么 T 和 param 都会被推导为左值引用 // - 右值:那么 T 会按照情景一进行推导,param 为相应的右值引用 int x = 27; // f(x) T: int&; param: int& const int cx = x; // f(cx) T: const int&; param: const int& const int &rx = cx; // f(rx) T: const int&; param: const int& // f(27) T: int; param: int&&
情景三:ParamType 既不是指针也不是引用(传值)
template<typename T> void f(T param); // param 传参为拷贝新对象,既然是新对象,则引用、顶层const、volatile 都会被忽略 int x = 27; const int cx = x; const int& rx = cx; // T 和 param 的类型都是 int
数组实参与函数实参:数组和函数在传参时会退化成指针,但在接收引用 param 时可以接收指向数组的引用。
template<typename T> void f(T& param); int arr[] = {1, 2, 3}; // f(arr) T: int[3], param int (&)[3] // 通过接收数组的引用,还能创建模板函数来推导数组的大小 template<typename T, std::size_t N> constexpr std::size_t arraySize(T (&)[N]) noexcept { return N; }
item2 理解 auto 类型推导
auto
类型推导通常和模板类型推导相同,但是auto
类型推导假定花括号初始化代表std::initializer_list
,而模板类型推导不这样做- 在C++14中
auto
允许出现在函数返回值或者lambda函数形参中,但是它的工作机制是模板类型推导那一套方案,而不是auto
类型推导
auto 推导与 item1 模板推导大部分一致,三个情景稍微修改就能使用 auto
- 情景一:类型说明符是一个指针或引用但不是通用引用
- 情景二:类型说明符一个通用引用
- 情景三:类型说明符既不是指针也不是引用
但在有初始化列表时表现不一致
auto x = {1, 2, 3}; // x 的类型为 std::initializer_list<int> template<typename T> void f(T param); // 等价形式无法推导 f({1, 2, 3}) // 如果模板需要推导此类需要 template<typename T> void f(std::initializer_list<T> initList); // f({1, 2, 3}) T 为 int,initList 为 std::initializer_list<int> // 对于 lambda 中的 auto 推导在实际上是模板推导那套而不是 auto 类型 auto createInitList() { return { 1, 2, 3 }; //错误!不能推导{ 1, 2, 3 }的类型 } std::vector<int> v; // … auto resetV = [&v](const auto& newValue){ v = newValue; }; //C++14 … resetV({ 1, 2, 3 }); //错误!不能推导{ 1, 2, 3 }的类型
item3 理解 decltype
decltype
总是不加修改的产生变量或者表达式的类型。- 对于
T
类型的不是单纯的变量名的左值表达式,decltype
总是产出T
的引用即T&
。- C++14支持
decltype(auto)
,就像auto
一样,推导出类型,但是它使用decltype
的规则进行推导。
decltype 相比 auto 或者模板推导,它只是简单的返回名字或者表达式的类型。
用一个完善的例子理解 lambda 中的 decltype:
template<typename Container, typename Index> //最终的C++14版本 decltype(auto) // decltype(auto)理解: auto 代表返回值需要推导,decltype 代表按照 decltype 的方式来推导返回值 authAndAccess(Container&& c, Index i) // 通用引用可以传递右值 { authenticateUser(); return std::forward<Container>(c)[i]; // forward 实现完美转发 }
C++11 需要显示指定函数返回类型:
template<typename Container, typename Index> //最终的C++11版本 auto authAndAccess(Container&& c, Index i) ->decltype(std::forward<Container>(c)[i]) { authenticateUser(); return std::forward<Container>(c)[i]; }
如果一个不是单纯变量名的左值表达式的类型是
T
,那么decltype
会把这个表达式的类型报告为T&
。decltype(auto) f1() { int x = 0; … return x; //decltype(x)是int,所以f1返回int } decltype(auto) f2() { int x = 0; return (x); //decltype((x))是int&,所以f2返回int& UB!! }
item4 学会查看类型推导结果
- 类型推断可以从IDE看出,从编译器报错看出,从Boost TypeIndex库的使用看出
- 这些工具可能既不准确也无帮助,所以理解C++类型推导规则才是最重要的
Boost TypeIndex 库
#include <boost/type_index.hpp>
template<typename T>
void f(const T& param)
{
using std::cout;
using boost::typeindex::type_id_with_cvr;
//显示T
cout << "T = "
<< type_id_with_cvr<T>().pretty_name()
<< '\n';
//显示param类型
cout << "param = "
<< type_id_with_cvr<decltype(param)>().pretty_name()
<< '\n';
}
auto
item5 优先考虑 auto 而非显式类型声明
用 auto
的几个好处:
auto 变量必须要初始化,所以避免了忘记初始化带来的 ub
闭包的类型只有编译器知道,而 auto 能够表示。在 C++14 中,形参也可以使用 auto。
- 如果不使用
auto
,std::function
确实能够将闭包存放在该对象中,但用此方法语法冗长、重复写形参、实例化的 function 对象额外空间,有可能还会在堆上分配空间
- 如果不使用
可以避免一些可移植性问题,例如
std::vector<int>::size_type
在 win32 是 32 位,在 win64 是 64 位在某些情况更有效率。考虑哈希表的 range-base 的遍历
std::unordered_map<std::string, int> m; for (const std::pair<std::string, int>& p: m) { // do something // 实际上 m 里的 pair 是 std::pair<const std::string, int> 的 // 编译器会拷贝一个临时对象使得这个语法有效 // 即类型不匹配会降低效率 } // 而 auto 避免了此问题 for (const auto& p: m) { // do something }
当然,auto 是可选项,在 item2 和 6 中展示了有些坑。如果认为 auto 影响可读性,那可以好好配置一下 IDE。
item6 auto 推导不顺心则使用显式类型
auto 在某些时候会失去效用,例如隐式的代理类,以及想要的隐式转换。
隐式代理类的错误推导:以
std::vector<bool>::operator[]
为例,C++ 为其实现了一个特化版本,因为内存是按字节寻址的,bool 不需要一个字节存储,所以底层实质上是位图,那么operator[]
自然而然不是返回一个引用,而是使用了一个代理类来实现类似其他std::vector::operator[]
的功能。具体而言,代理类存储了一个原序列的指针和一个掩码,在调用
operator[]
时生成一个代理类对象和其掩码,代理类实现了operator bool()
的函数,即static<bool>(xxx)
那么考虑下列代码,
std::vector<bool> features(const Widget& w);
bool bit = features(w)[5]; // 类型为 std::vector<bool> 的临时对象 (features(w))[5] => reference(temp, mask(5)) => static_cast<bool>(reference)
auto bit_ref = features(w)[5]; // 类型为 std::vector<bool> 的临时对象 (features(w))[5] => reference(temp, mask(5))
// 这里 bit_ref 类型为 bit_reference,其中的 temp 指针和 mask 被拷贝至此
bool bit_ = static_cast<bool>(bit_ref); // 此时会访问 *seg & mask,但 seg 指针是 std::vector<bool> 临时对象的指针,在上一个语句完成后被析构了,所以这里是悬挂指针,未定义行为
// 所以说正确形式应该为
auto bit__ = static_cast<bool>(features(w)[5]);
- 有时候用会使用显式的类型说明,例如
float a = 1.;
来隐式地表示“我要降低精度”,这种情况用auto a = static_cast<float>
代替,可读性会更高,可理解性也更高
移步现代 C++
item7 区别使用 ()
与 {}
创建对象
- 花括号初始化是最广泛使用的初始化语法,它防止变窄转换,并且对于C++最令人头疼的解析有天生的免疫性
- 在构造函数重载决议中,编译器会尽最大努力将括号初始化与
std::initializer_list
参数匹配,即便其他构造函数看起来是更好的选择- 对于数值类型的
std::vector
来说使用花括号初始化和圆括号初始化会造成巨大的不同- 在模板类选择使用圆括号初始化或使用花括号初始化创建对象是一个挑战。
C++11 的
{}
被称作统一初始化(uniform initialization),因为在以下三种情况都适用- 容器初始元素
std::vector<int> v{1,3,5};
- 类的非静态数据成员初始化
class A { int x{0}; int y = 0; int z(0);/*圆括弧不可以*/ }
- 不可拷贝对象不能用初始化
std::atomic<int> ai1{0}; // (0) 也可以,但 = 0 不行
- 容器初始元素
统一初始化不允许内置类型的隐式变窄转换:
double x, y, z; int sum1{x + y + z}; // ❌ 变窄转换 int sum2(x + y + z); int sum3 = x + y + z; // OK
避免 C++ 的“最令人头疼的解析问题”,即想要用默认构造函数创建对象,却不小心变成函数声明
Widget w1(10); // 带参 OK Widget w1(); // 本意是 Widget w1; 但不小心变成了函数声明 Widget w1{}; // OK,同 Widget w1;
有缺点,item2 中有
auto
解析{}
为std::initializer_list
如果类有
{}
构造函数,如果以{}
形式调用,往往会选择该函数,即使不能调用;边界情况是 空{}
调用会调用默认构造函数。而({})
才是调用{}
构造函数的空形式。std::vector<int> a(10, 20)
与std::vector<int> a{10, 20}
完全不一致,注意区分在模板内部使用
()
还是{}
行为取决于外部传入的类,std::make_unique
采用了()
并说明文档
item8 优先使用 nullptr
而非 0 和 NULL
item9 优先使用别名声明而非typedef?
在C++中,类型别名能够简化复杂类型的书写,但传统的typedef
在C++11后有了更强大的替代者——别名声明(alias declarations),即using
关键字。以下是优先使用别名声明的关键原因:
1. 基本用法对比
- 传统
typedef
:typedef std::unique_ptr<std::unordered_map<std::string, std::string>> UPtrMapSS;
- 别名声明(更清晰):两者功能相同,但别名声明语法更直观,尤其是对于函数指针:
using UPtrMapSS = std::unique_ptr<std::unordered_map<std::string, std::string>>;
// typedef typedef void (*FP)(int, const std::string&); // 别名声明(类型名在左侧,更自然) using FP = void (*)(int, const std::string&);
2. 模板场景下的绝对优势
别名声明支持模板化(称为别名模板,alias templates),而typedef
无法直接实现。
- 场景:定义一个链表的别名,使用自定义分配器
MyAlloc<T>
。- 别名声明(直接简洁):
template<typename T> using MyAllocList = std::list<T, MyAlloc<T>>; // 别名模板 MyAllocList<Widget> lw; // 直接使用
typedef
的笨拙实现:template<typename T> struct MyAllocList { // 必须包裹在结构体中 typedef std::list<T, MyAlloc<T>> type; }; MyAllocList<Widget>::type lw; // 使用时需加::type
- 别名声明(直接简洁):
3. 避免typename
的强制要求
当在模板内部使用依赖类型(dependent type)时,typedef
需要显式使用typename
,而别名声明不需要。
- 示例:在模板类
Widget
中使用MyAllocList<T>
作为成员。typedef
的繁琐写法:template<typename T> class Widget { private: typename MyAllocList<T>::type list; // 必须加typename };
- 别名声明的简洁写法:原因:编译器知道
template<typename T> class Widget { private: MyAllocList<T> list; // 直接使用,无需typename };
MyAllocList<T>
是别名模板,必然是类型;而MyAllocList<T>::type
可能因特化被改为非类型成员(如数据成员),需typename
明确。
4. 与类型特性(Type Traits)的协作
C++11的<type_traits>
(如std::remove_const<T>::type
)通过typedef
实现,需冗长的::type
和typename
。C++14引入别名模板简化写法。
- C++11的繁琐操作:
std::remove_const<T>::type // 移除const std::remove_reference<T>::type // 移除引用
- C++14的别名模板改进:若使用C++11,可自行实现别名模板:
std::remove_const_t<T> // 等价于C++11的remove_const<T>::type std::remove_reference_t<T>
template<typename T> using remove_const_t = typename std::remove_const<T>::type;
总结
- 别名声明的优势:
- 支持模板化:直接定义别名模板,无需嵌套结构体。
- 代码简洁:避免
::type
和typename
的冗余。 - 类型明确:编译器直接识别别名模板为类型,避免歧义。
- 迁移建议:
- 新项目优先使用
using
定义类型别名。 - 旧代码可逐步替换
typedef
为别名声明,尤其在模板元编程中。
- 新项目优先使用
通过别名声明,C++代码在表达复杂类型时更简洁、安全,尤其在泛型编程中优势显著。
item10 优先考虑限域enum而非未限域enum
1. 作用域污染问题
- 未限域enum(unscoped enum):枚举名会泄漏到外层作用域,可能导致命名冲突。
enum Color { black, white, red }; auto white = false; // 错误:white已存在
- 限域enum(scoped enum,
enum class
):枚举名必须通过作用域访问,避免污染。enum class Color { black, white, red }; auto white = false; // 正确 Color c = Color::white; // 需显式限定作用域
2. 类型安全性
- 未限域enum:枚举值可隐式转换为整型,可能导致非预期行为。
enum Color { red }; Color c = red; if (c < 14.5) { ... } // 合法:Color隐式转换为整型
- 限域enum:无隐式转换,必须显式使用
static_cast
。enum class Color { red }; Color c = Color::red; if (static_cast<int>(c) < 14.5) { ... } // 需显式转换
3. 前置声明能力
- 限域enum:默认支持前置声明,底层类型可指定(默认
int
)。enum class Status; // 前置声明,底层类型为int enum class Status : std::uint32_t; // 显式指定底层类型
- 未限域enum:仅当显式指定底层类型时才支持前置声明。
enum Color : std::uint8_t; // 前置声明需指定底层类型 enum Color : std::uint8_t { ... }; // 定义时指定类型
4. 适用场景权衡
- 推荐限域enum:多数情况下更安全,避免命名冲突和隐式转换错误。
- 未限域enum的例外场景:与
std::tuple
结合时,简化索引访问。若用限域enum,需借助工具函数转换:enum UserInfoFields { uiEmail }; // 未限域enum隐式转换为索引 auto val = std::get<uiEmail>(userInfo); // 直接使用枚举名
其中enum class UserInfoFields { uiEmail }; auto val = std::get<toUType(UserInfoFields::uiEmail)>(userInfo); // 显式转换
toUType
为自定义的编译期转换函数:template<typename E> constexpr auto toUType(E e) noexcept { return static_cast<std::underlying_type_t<E>>(e); }
记住的要点
- 限域enum通过
enum class
声明,枚举名受作用域限制,无隐式转换,可前置声明。 - 未限域enum枚举名泄漏到外部作用域,支持隐式转换,仅指定底层类型后可前置声明。
- 权衡代码简洁性与类型安全:多数情况下优先使用限域enum,特定场景(如
std::tuple
索引)可考虑未限域enum。
通过优先选择限域enum,可提升代码的健壮性,减少命名冲突和类型错误的风险。
Item11 优先使用deleted函数而非未定义的私有声明
核心思想
在C++11中,应使用= delete
明确删除函数,而非C++98中将函数声明为私有且不定义的旧方法。这种方式更安全、更灵活,并提供更清晰的错误反馈。
关键点解析
C++98的局限性
- 通过将拷贝构造函数、赋值运算符等声明为
private
且不定义,可阻止外部调用,但存在缺陷:- 友元或成员函数调用时,错误在链接阶段才暴露。
- 错误信息不明确,可能仅提示私有权限而非函数未定义。
- 通过将拷贝构造函数、赋值运算符等声明为
C++11的改进:deleted函数
- 使用
= delete
标记函数为删除状态:- 编译时错误:任何调用(包括成员、友元)直接触发编译错误,而非链接时。
- 适用范围更广:可删除任何函数(包括非成员函数和模板实例)。
- 清晰的错误信息:将删除函数声明为
public
,编译器优先提示“函数已删除”。
- 使用
应用场景示例
- 禁止拷贝语义
class BasicIOS { public: BasicIOS(const BasicIOS&) = delete; BasicIOS& operator=(const BasicIOS&) = delete; };
- 过滤函数参数类型
bool isLucky(int); bool isLucky(char) = delete; // 禁止char类型调用 bool isLucky(bool) = delete; // 禁止bool类型调用 bool isLucky(double) = delete; // 禁止double/float隐式转换
- 禁用特定模板实例
template<typename T> void processPointer(T* ptr); template<> void processPointer<void>(void*) = delete; // 禁用void*处理
- 禁止拷贝语义
模板与访问权限的注意事项
- 类内模板函数无法通过
private
限制特定实例化,但可通过外部删除:class Widget { public: template<typename T> void process(T* ptr) { /*...*/ } }; template<> void Widget::process<void>(void*) = delete; // 显式删除void*实例
- 类内模板函数无法通过
结论
- 优先使用
= delete
:替代C++98的私有未定义方法,提升代码健壮性。 - 公开声明删除函数:确保编译器优先提示“函数已删除”,而非访问权限问题。
- 灵活禁用函数:支持非成员函数、重载函数及模板实例的删除,覆盖更多场景。
通过这一条款,开发者能更高效地约束代码行为,减少潜在错误,并提升代码可维护性。
item12 声明覆盖函数时使用override
在C++中,派生类覆盖基类虚函数需满足严格条件,但手动检查容易出错。C++11引入的override
关键字能强制编译器检查覆盖的有效性,避免潜在错误。
覆盖(Overriding)的关键条件:
- 基类函数为虚函数(
virtual
)。 - 函数名完全一致(析构函数除外)。
- 参数类型完全相同。
- 常量性(
const
)一致。 - 返回类型和异常说明兼容。
- C++11新增:引用限定符必须一致(左值
&
或右值&&
)。
若未满足条件,派生类函数将隐藏基类函数而非覆盖,导致多态行为异常,但编译器可能不报错。
使用override
的优势:
- 显式声明覆盖意图:通过在派生类函数后添加
override
,要求编译器检查是否满足覆盖条件。若条件不满足(如参数类型错误、常量性不一致等),编译失败,快速定位错误。 - 提高代码健壮性:当基类虚函数签名改变时,所有未正确覆盖的派生类函数会触发编译错误,避免运行时未定义行为。
- 与重载(Overloading)区分:
override
明确表示覆盖而非重载,增强代码可读性。
示例分析:
class Base {
public:
virtual void mf1() const;
virtual void mf2(int);
virtual void mf3() &;
void mf4() const; // 非虚函数
};
class Derived : public Base {
public:
virtual void mf1() override; // 错误:缺少const
virtual void mf2(unsigned int) override; // 错误:参数类型不匹配
virtual void mf3() && override; // 错误:引用限定符不匹配
void mf4() const override; // 错误:基类mf4非虚函数
};
添加override
后,编译器会检查所有覆盖条件,上述错误将导致编译失败,而非静默隐藏问题。
引用限定符(Reference Qualifiers)的作用:
- 根据对象值类别(左值/右值)重载成员函数:
class Widget { public: void doWork() &; // 仅当*this为左值时调用 void doWork() &&; // 仅当*this为右值时调用 };
- 优化资源管理:通过区分左值/右值,避免不必要的拷贝。例如,右值对象可安全转移资源:
DataType data() && { return std::move(values); } // 移动而非拷贝
其他注意事项:
final
关键字:可修饰虚函数禁止派生类覆盖,或修饰类禁止继承。- 上下文关键字:
override
和final
仅在特定位置被视为关键字,不影响历史代码中的标识符使用。
总结:
- 始终为派生类覆盖函数添加
override
,确保编译器检查覆盖有效性。 - 注意引用限定符一致性,以支持基于对象值类别的函数重载。
- 结合
final
和override
,增强接口设计的清晰度和安全性。
通过遵循这些实践,可显著减少因错误覆盖导致的潜在问题,提升代码的可靠性和可维护性。
item13:优先使用const_iterator而非iterator
在C++中,const_iterator
类似于指向常量的指针(pointer-to-const
),用于表示迭代器指向的内容不可修改。优先使用const_iterator
能够提升代码的安全性和表达意图的清晰性,尤其是在不需要修改容器元素的场景中。
C++98中const_iterator的局限性
- 难以获取:从非
const
容器中获取const_iterator
需要显式类型转换或间接操作(如绑定到const
引用)。// C++98中获取const_iterator的繁琐方式 typedef vector<int>::const_iterator ConstIterT; ConstIterT ci = static_cast<ConstIterT>(values.begin()); // 需要强制转换
- 使用受限:STL操作(如
insert
、erase
)仅接受iterator
参数,导致const_iterator
需转换为iterator
,但此转换不可移植且可能失败。values.insert(static_cast<IterT>(ci), 1998); // 可能编译失败
C++11的改进
- 直接获取const_iterator:新增
cbegin()
、cend()
等成员函数,即使对非const
容器也能直接获取const_iterator
。auto it = find(values.cbegin(), values.cend(), 1983); // 无需转换
- 支持const_iterator操作:STL成员函数(如
insert
、erase
)开始接受const_iterator
参数,避免强制转换。values.insert(it, 1998); // C++11允许直接传递const_iterator
通用代码的注意事项
- 优先使用非成员函数版本:为兼容原生数组和第三方容器,应优先调用非成员函数
begin()
、end()
而非成员函数版本。template<typename C> void process(const C& container) { auto it = std::begin(container); // 兼容数组和容器 }
- C++14的补充:C++14引入非成员函数
cbegin()
、cend()
等,但C++11中需自行实现:template <class C> auto cbegin(const C& container) -> decltype(std::begin(container)) { return std::begin(container); // 通过const引用调用begin()返回const_iterator }
- 对
const
容器,非成员begin()
返回const_iterator
。 - 对
const
数组,返回指向const
元素的指针(即const_iterator
)。
- 对
关键实践
- 默认使用const_iterator:只要不修改元素,优先使用
const_iterator
以明确意图并防止误修改。 - 通用代码中避免依赖成员函数:使用非成员
begin()
、end()
等函数,确保代码适用于数组、STL容器及第三方数据结构。 - C++11自行补全缺失函数:若需支持C++11且标准库未提供非成员
cbegin()
,可自行实现模板函数。
总结
- 优先使用
const_iterator
:增强代码安全性和表达力,遵循“能加const
则加”的原则。 - 利用C++11/14改进:直接通过
cbegin()
/cend()
获取const_iterator
,避免C++98中的繁琐和风险。 - 编写通用代码:依赖非成员函数
begin()
、end()
,并为C++11补充缺失的非成员cbegin()
实现。
通过优先选择const_iterator
,开发者能够减少潜在错误,同时提升代码的可维护性和跨容器兼容性。
item14:若函数不抛出异常,请使用noexcept
关键点总结
接口设计的一部分
noexcept
是函数接口的重要声明,向调用者明确保证函数不会抛出异常,影响调用代码的异常安全性和效率。优化机会
编译器对noexcept
函数进行更多优化,如省略栈展开代码,提升性能。移动操作、swap
、析构函数等高频操作尤其受益。移动语义与容器优化
容器(如std::vector
)在扩容时,若元素移动操作声明为noexcept
,则使用移动而非拷贝,显著提升性能。移动操作应尽量标记为noexcept
。条件性noexcept
noexcept
可依赖表达式结果,如noexcept(noexcept(swap(a, b)))
,使高阶操作的异常说明与底层操作一致,鼓励为底层组件提供noexcept
。异常中立函数
大多数函数不直接抛出异常,但可能传递内部调用的异常(即异常中立),此类函数不应声明noexcept
,以免意外终止程序。析构函数与内存释放
析构函数和内存释放函数(operator delete
)默认为noexcept
,除非显式指定noexcept(false)
。违反此规则将导致未定义行为。契约类型的影响
- 宽泛契约:无前置条件,可安全声明
noexcept
。 - 严格契约:有前置条件,若违反导致未定义行为,需谨慎使用
noexcept
,避免掩盖调试信息。
- 宽泛契约:无前置条件,可安全声明
错误使用后果
错误标记noexcept
的函数若抛出异常,程序直接终止,可能引发资源泄漏。确保函数内部及调用链均不抛异常。
代码示例
移动构造函数与noexcept
class Widget {
public:
Widget(Widget&& rhs) noexcept { /* 移动资源,确保不抛异常 */ }
// ...
};
标记移动操作为noexcept
,使std::vector
在扩容时使用移动而非拷贝,提升性能。
条件性noexcept的swap
class Resource {
public:
void swap(Resource& other) noexcept {
// 交换资源,确保不抛异常
std::swap(ptr_, other.ptr_);
}
private:
int* ptr_;
};
template<typename T>
void swap(T& a, T& b) noexcept(noexcept(a.swap(b))) {
a.swap(b);
}
模板swap
根据成员swap
的noexcept
状态动态决定,鼓励实现不抛异常的swap
。
结论
- 正确性优先:仅在确保函数及所有依赖调用绝不抛异常时使用
noexcept
。 - 性能敏感处必用:移动操作、
swap
、析构函数等高频操作应优先考虑noexcept
。 - 接口明确性:通过
noexcept
传达设计意图,提升代码可读性和可维护性。
合理使用noexcept
在接口设计和性能优化间取得平衡,是编写高效且健壮C++代码的关键。
Item 15 — 尽可能使用 constexpr
核心概念
constexpr
对象- 本质是编译期可知的常量,比普通
const
更严格。 - 必须用编译期常量初始化,可用于需要编译期常量的场景(如数组大小、模板参数、对齐修饰符等)。
- 所有
constexpr
对象都是const
,但反之不成立(const
对象可能运行时初始化)。
- 本质是编译期可知的常量,比普通
constexpr
函数- 若传入参数是编译期常量,函数结果在编译期计算;否则在运行时计算。
- C++11 限制:函数体仅含一条
return
语句(可用递归或三元运算符实现逻辑)。 - C++14 放宽:允许循环、局部变量等,写法更灵活。
- 返回类型和参数必须是字面值类型(Literal Type),即编译期可确定值的类型。
用户自定义类型
- 类的
constexpr
支持- 构造函数和成员函数可声明为
constexpr
,允许在编译期构造和操作对象。 - C++11:
constexpr
成员函数隐式为const
,且不能修改对象状态。 - C++14:允许
constexpr
成员函数修改对象状态,甚至void
返回类型。
- 构造函数和成员函数可声明为
使用场景与优势
编译期计算优化
- 将计算移至编译期(如
pow(3, n)
用于数组大小),减少运行时开销。 - 支持复杂编译期逻辑(如几何计算、状态组合)。
- 将计算移至编译期(如
更广泛的适用性
constexpr
对象和函数可用于编译期和运行时两种场景,无需重复实现。
模板元编程与常量表达式
- 在模板参数、枚举值等需要编译期常量的上下文中直接使用
constexpr
结果。
- 在模板参数、枚举值等需要编译期常量的上下文中直接使用
注意事项
- 接口契约:
constexpr
是接口的一部分,后续移除可能导致客户端代码破坏。 - 编译时间:过度使用可能增加编译时间,需权衡优化收益。
- 兼容性:注意 C++11 和 C++14 对
constexpr
函数实现的差异。
关键结论
- 尽可能使用
constexpr
:- 为对象和函数添加
constexpr
可最大化其适用范围(编译期和运行时)。 - 利用编译期计算提升性能,同时保持代码简洁。
- 为对象和函数添加
- 权衡长期维护:一旦声明
constexpr
,需谨慎修改,因其成为接口契约。
示例代码片段
// 编译期计算 3^n 的 constexpr 函数(C++14)
constexpr int pow(int base, int exp) noexcept {
auto result = 1;
for (int i = 0; i < exp; ++i) result *= base;
return result;
}
// 用户自定义类型的 constexpr 支持
class Point {
public:
constexpr Point(double x = 0, double y = 0) noexcept : x(x), y(y) {}
constexpr double getX() const noexcept { return x; }
constexpr void setX(double newX) noexcept { x = newX; } // C++14
private:
double x, y;
};
// 使用 constexpr 函数初始化编译期对象
constexpr Point midpoint(const Point& p1, const Point& p2) noexcept {
return { (p1.getX() + p2.getX()) / 2, (p1.getY() + p2.getY()) / 2 };
}
constexpr Point p1(10.0, 20.0);
constexpr Point p2(30.0, 40.0);
constexpr auto mid = midpoint(p1, p2); // 编译期计算中点
通过合理使用 constexpr
,开发者能在编译期完成更多计算,提升运行时效率,同时增强代码的灵活性和表达能力。
item 16:让const成员函数线程安全
关键点:
const成员函数的线程安全必要性:
- const成员函数可能修改mutable成员变量(如缓存),在多线程环境下会导致数据竞争,需确保线程安全。
使用互斥量(mutex):
- 当需要同步多个变量或复杂操作时,使用
std::mutex
加锁。 - 示例:多项式根值缓存中,通过
lock_guard
保护缓存状态和数据的修改。 - 注意:
mutex
应声明为mutable
,因const函数中需修改其状态。包含mutex
的类不可复制或移动。
- 当需要同步多个变量或复杂操作时,使用
原子变量(std::atomic)的适用场景:
- 适用于单变量原子操作(如计数器),比mutex更高效。
- 示例:统计函数调用次数时,使用
std::atomic<unsigned>
。 - 局限性:无法处理多个相关变量的原子性,此时仍需mutex。
避免错误同步:
- 多个原子变量无法保证整体操作的原子性,需用mutex。例如,缓存有效标志和缓存值需同时更新时,必须加锁。
线程安全的默认要求:
- 除非明确单线程环境,否则const成员函数应设计为线程安全,以适应并发上下文。
建议:
- 优先分析并发需求:若const函数可能被多线程调用,必须保证线程安全。
- 选择同步机制:
- 单一变量原子操作 →
std::atomic
。 - 多变量或复杂操作 →
std::mutex
。
- 单一变量原子操作 →
- 注意类语义影响:包含
mutex
或atomic
的类可能失去复制/移动能力,需权衡设计。
结论:
确保const成员函数线程安全是现代C++多线程编程的基本要求。根据操作复杂度选择互斥量或原子变量,避免数据竞争,保障程序正确性。
item17:理解特殊成员函数的生成
特殊成员函数:
- 默认构造函数:没有用户定义构造函数时生成
- 析构函数:没有用户定义移动操作时生成、若基类析构函数为虚则虚。默认标记为
noexcept
- 拷贝操作(构造、赋值):没有用户定义移动操作时生成
- 移动操作(构造、赋值):没有用户定义析构/拷贝函数时生成
记住:
- 使用显示
= default
= delete
来显示生成/删除默认成员函数 - 遵循三法则 “析构/拷贝/移动” 同时出现。由于历史原因 【析构/拷贝】不会干扰生成,但 【析构/拷贝】和【移动】会互相干扰
- 模板不会阻止特殊成员函数的生成,需注意可能引发的隐式生成冲突。
智能指针
- 原始指针不受青睐的原因:声明无法表明指向对象类型,未说明是否拥有所指对象及销毁方式,确定销毁方式后可能因 delete 形式用错导致未定义行为,难以确保所有执行路径上都正确执行一次销毁操作,无法判断是否成为悬空指针,使用时易因疏忽导致问题。
- 智能指针的优势及种类:智能指针包裹原始指针,可避免原始指针诸多陷阱。C++11 中有四种智能指针:std::auto_ptr、std::unique_ptr、std::shared_ptr、std::weak_ptr,用于管理动态对象生命周期,防止资源泄露和异常行为。
- std::auto_ptr 与 std::unique_ptr:std::auto_ptr 是 C++98 遗留物,已废弃,因 C++98 无移动语义,它拉拢拷贝操作实现移动意图,导致奇怪代码和使用限制。std::unique_ptr 能做 std::auto_ptr 所有事且更高效,各方面都优于 std::auto_ptr,除使用 C++98 编译器外,应将 std::auto_ptr 替换为 std::unique_ptr。
- 智能指针 API:各种智能指针 API 差异大,仅默认构造函数功能相似。后续将关注 API 概览未提及内容,如使用场景、运行时性能分析等,以高效使用智能指针。
item18 对于独占资源使用std::unique_ptr
核心特性
独占所有权
std::unique_ptr
表示对资源的唯一所有权,不可拷贝(避免重复释放),仅支持移动语义(所有权转移后原指针置空)。- 析构时自动释放资源,默认通过
delete
实现,但支持自定义删除器(如日志记录后删除)。
性能优势
- 与原始指针大小相同,操作(解引用、移动等)性能等同原始指针,适合内存或时间敏感场景。
典型应用场景
工厂函数返回值
- 工厂函数返回
std::unique_ptr
,确保调用者获得资源所有权,资源在离开作用域时自动释放。 - 支持所有权链传递(如存入容器、转移至对象成员),异常安全保证资源释放。
- 示例:
template<typename... Ts> std::unique_ptr<Investment> makeInvestment(Ts&&... params);
- 工厂函数返回
继承体系与多态
- 基类需声明虚析构函数,确保通过基类指针删除派生类对象时正确调用析构函数:
class Investment { public: virtual ~Investment() = default; // ... };
- 基类需声明虚析构函数,确保通过基类指针删除派生类对象时正确调用析构函数:
自定义删除器
实现方式
- 通过函数、函数对象或lambda定义删除逻辑(如记录日志后
delete
):auto delInvmt = [](Investment* p) { makeLogEntry(p); delete p; };
- 需将删除器类型作为
std::unique_ptr
的第二个模板参数:std::unique_ptr<Investment, decltype(delInvmt)> pInv(nullptr, delInvmt);
- 通过函数、函数对象或lambda定义删除逻辑(如记录日志后
性能影响
- 无状态删除器(如不捕获变量的lambda)不增加
std::unique_ptr
大小,函数指针或含状态的删除器可能增加额外开销。
- 无状态删除器(如不捕获变量的lambda)不增加
其他注意事项
数组形式
std::unique_ptr<T[]>
用于管理动态数组,但优先使用标准容器(如std::vector
、std::array
)。
与
std::shared_ptr
互操作- 可隐式转换为
std::shared_ptr
,灵活支持共享所有权场景:std::shared_ptr<Investment> sp = makeInvestment(args);
- 可隐式转换为
最佳实践
- 默认使用
std::unique_ptr
管理独占资源,优先于原始指针。 - 自定义删除器时,优先使用无状态lambda以减少开销。
- 工厂函数返回
std::unique_ptr
,为调用者提供所有权灵活性。
通过std::unique_ptr
,C++实现了高效、安全的资源管理,兼顾性能与异常安全,是现代C++资源管理的基石。
Item19 使用std::shared_ptr管理共享所有权的资源
共享所有权与引用计数
std::shared_ptr
通过引用计数管理共享资源的生命周期。当最后一个指向资源的shared_ptr
被销毁或重置时,资源会被释放。- 引用计数的修改是原子的,确保多线程安全,但可能带来性能开销。
性能与实现细节
std::shared_ptr
大小是原始指针的两倍(包含指向对象和控制块的指针)。- 控制块动态分配,包含引用计数、自定义删除器、分配器等。使用
std::make_shared
可合并对象与控制块的内存分配。 - 移动操作不修改引用计数,比拷贝操作更高效。
自定义删除器的灵活性
- 删除器类型不影响
std::shared_ptr
的类型,允许不同删除器的shared_ptr
共存于容器中。 - 删除器存储在控制块中,不增加
shared_ptr
对象的大小。
- 删除器类型不影响
避免控制块重复创建
- 禁止从原始指针直接构造多个
shared_ptr
:会导致多个控制块,引发重复析构。应使用std::make_shared
或传递已存在的shared_ptr
。 - 处理
this
指针:若类需在成员函数中返回自身的shared_ptr
,应继承std::enable_shared_from_this
,并通过shared_from_this()
安全获取。
- 禁止从原始指针直接构造多个
安全使用模式
- 使用工厂函数和私有构造函数,强制通过
shared_ptr
创建对象,避免直接操作原始指针。 - 示例:
class Widget : public std::enable_shared_from_this<Widget> { public: template<typename... Ts> static std::shared_ptr<Widget> create(Ts&&... params) { return std::shared_ptr<Widget>(new Widget(std::forward<Ts>(params)...)); } void process() { processedWidgets.emplace_back(shared_from_this()); // 安全使用 } private: Widget() = default; // 强制通过工厂创建 };
- 使用工厂函数和私有构造函数,强制通过
与
std::unique_ptr
的对比- 独占资源时优先使用
std::unique_ptr
,更轻量且性能接近原始指针。 shared_ptr
不可转换为unique_ptr
,设计时需明确所有权策略。
- 独占资源时优先使用
不适用于数组
std::shared_ptr
默认不支持数组管理(C++17前),建议使用std::vector
、std::array
等容器替代。
关键点总结
- 优先使用
std::make_shared
:避免显式new
和重复控制块。 - 避免原始指针转换:传递已存在的
shared_ptr
而非原始指针。 - 多线程安全:原子引用计数确保线程安全,但需注意竞争条件。
- 启用
enable_shared_from_this
:在需要从this
生成shared_ptr
时使用,避免未定义行为。
遵循这些准则可确保资源安全共享,同时兼顾性能与正确性。
item20:当std::shared_ptr
可能悬空时,使用std::weak_ptr
std::weak_ptr
是一种不参与资源所有权共享的智能指针,用于解决std::shared_ptr
可能悬空的问题。它在不增加引用计数的前提下,跟踪对象生命周期,并在对象销毁时感知悬空状态。以下是关键要点:
std::weak_ptr
的核心特性
- 不管理所有权:
std::weak_ptr
不控制对象的生命周期,不影响引用计数。 - 检测悬空状态:通过
expired()
检查指针是否悬空(对象已被销毁)。 - 安全访问对象:通过
lock()
或构造函数转换为std::shared_ptr
,确保访问时对象存活。
使用场景
- 缓存系统
- 问题:缓存需要持有对象的指针,但不应阻止对象销毁。
- 解决:缓存使用
std::weak_ptr
,检查过期后重新加载。std::shared_ptr<const Widget> fastLoadWidget(WidgetID id) { static std::unordered_map<WidgetID, std::weak_ptr<const Widget>> cache; auto objPtr = cache[id].lock(); // 尝试获取缓存的shared_ptr if (!objPtr) { // 缓存过期 objPtr = loadWidget(id); // 重新加载 cache[id] = objPtr; // 更新缓存 } return objPtr; }
- 观察者模式
- 问题:Subject持有Observer的指针,但需避免悬空访问。
- 解决:Subject使用
std::weak_ptr
存储Observer,使用前检查有效性。class Subject { std::vector<std::weak_ptr<Observer>> observers; void notify() { for (auto& weakObs : observers) { if (auto obs = weakObs.lock()) { // 转换为shared_ptr obs->onUpdate(); // 安全调用 } } } };
- 打破
std::shared_ptr
循环引用
- 问题:对象互相持有
std::shared_ptr
导致内存泄漏。 - 解决:将单向指针改为
std::weak_ptr
,避免循环引用。class B { std::weak_ptr<A> a_ptr; // 使用weak_ptr代替shared_ptr public: void setA(std::shared_ptr<A> a) { a_ptr = a; } void useA() { if (auto a = a_ptr.lock()) { // 安全访问A // 使用a... } } };
std::weak_ptr
的操作
构造与赋值:从
std::shared_ptr
创建,不增加引用计数。auto sp = std::make_shared<Widget>(); std::weak_ptr<Widget> wp(sp); // 指向同一对象,引用计数不变
检查悬空:
expired()
返回true
表示对象已销毁。if (wp.expired()) { /* 处理悬空 */ }
安全访问对象:
lock()
:返回std::shared_ptr
,悬空时返回空。if (auto sp = wp.lock()) { /* 使用sp */ }
- 构造
std::shared_ptr
:悬空时抛出std::bad_weak_ptr
。try { std::shared_ptr<Widget> sp(wp); // 可能抛出异常 } catch (const std::bad_weak_ptr&) { /* 处理异常 */ }
性能与实现
- 效率:
std::weak_ptr
与std::shared_ptr
大小相同,操作涉及原子引用计数。 - 控制块:对象销毁后,控制块仍存在至所有
std::weak_ptr
释放(见Item21)。
总结
- 何时使用:需要非拥有性指针,且需检测对象是否存活时。
- 优势:避免悬空指针、内存泄漏,解决循环引用问题。
- 原则:优先用于缓存、观察者、循环引用场景,确保资源安全管理。
item21:优先使用std::make_unique和std::make_shared而非new
核心优势
代码简洁性
- 避免重复类型声明(如
make_unique<Widget>()
vsunique_ptr<Widget>(new Widget)
),减少冗余,提升可维护性。
- 避免重复类型声明(如
异常安全性
- 使用
new
时,若参数计算顺序导致异常(如processWidget(new Widget, computePriority())
),可能引发内存泄漏。make
函数将对象构造与智能指针绑定原子化,避免中间态泄漏。
- 使用
性能优化
std::make_shared
将对象内存与控制块(含引用计数等)合并为单次内存分配,减少开销,提高效率。std::allocate_shared
同理。
不适用make函数的场景
自定义删除器或分配器
make
函数不支持指定自定义删除器(如shared_ptr<Widget>(new Widget, customDeleter)
)或分配器,需直接使用new
。
花括号初始化
make
函数默认使用圆括号转发参数,若需花括号初始化(如vector<int>{1,2,3}
),需通过auto initList = {1,2,3}
间接传递。
类重载operator new/delete
- 类若定制了内存管理(如精确分配对象大小的内存),
make_shared
的合并内存分配可能破坏设计,导致未定义行为。
- 类若定制了内存管理(如精确分配对象大小的内存),
大对象与std::weak_ptr的生命周期
- 使用
make_shared
时,对象内存与控制块共存。若存在长期存活的std::weak_ptr
,即使对象已销毁,其内存仍延迟释放。直接使用new
可在shared_ptr
销毁后立即释放对象内存,仅保留控制块。
- 使用
替代方案与注意事项
- 异常安全的手动管理:若必须使用
new
,确保在独立语句中构造智能指针(如shared_ptr<Widget> spw(new Widget);
),避免参数计算期间的异常。 - 性能调优:传递右值(
std::move(spw)
)代替左值,避免不必要的引用计数原子操作。
结论
- 默认优先使用
make
函数(std::make_unique
/std::make_shared
),以提升代码健壮性、简洁性和性能。 - 例外情况:需要自定义删除器、花括号初始化、特定内存管理或处理大对象时,直接使用
new
并谨慎管理智能指针生命周期。
item22 当使用Pimpl惯用法,请在实现文件中定义特殊成员函数
条款22的核心在于正确使用Pimpl惯用法时管理特殊成员函数的定义位置,以避免因不完整类型导致的编译错误。以下是关键要点:
Pimpl的优势
- 通过将实现细节隐藏在指针后,减少头文件依赖,缩短编译时间。
- 实现改动时,客户端代码无需重新编译。
使用
std::unique_ptr
的注意事项- 析构函数定义:必须在实现文件中显式定义析构函数,确保析构时
Impl
类型完整。// 头文件声明 ~Widget(); // 实现文件定义 Widget::~Widget() = default;
- 移动操作:需显式声明并定义在实现文件中,避免隐式生成时
Impl
不完整。// 头文件声明 Widget(Widget&& rhs); Widget& operator=(Widget&& rhs); // 实现文件定义 Widget::Widget(Widget&& rhs) = default; Widget& Widget::operator=(Widget&& rhs) = default;
- 拷贝操作:手动实现深拷贝,因
std::unique_ptr
不可复制。Widget::Widget(const Widget& rhs) : pImpl(std::make_unique<Impl>(*rhs.pImpl)) {} Widget& Widget::operator=(const Widget& rhs) { *pImpl = *rhs.pImpl; return *this; }
- 析构函数定义:必须在实现文件中显式定义析构函数,确保析构时
std::shared_ptr
的差异- 使用
std::shared_ptr
时无需在头文件定义析构函数或移动操作,因其删除器类型在运行时确定,不要求Impl
立即完整。但Pimpl通常更适合std::unique_ptr
的所有权语义。
- 使用
核心原理
std::unique_ptr
的删除器在编译时绑定,要求析构时类型完整;std::shared_ptr
的删除器在运行时绑定,允许类型稍后完整。
右值引用、移动语义、完美转发
Item23: 理解 std::move
和 std::forward
为了更好地理解和应用std::move和std::forward,以下是对它们的总结:
1. std::move
- 功能:将实参转换为右值引用,不移动任何对象。
- 应用场景:
- 当对象的构造函数或赋值操作需要一个右值引用时,使用std::move可以避免不必要的复制。
- 例如,当传递一个左值引用对象到一个右值重载函数中时,std::move自动将其转换为右值引用。
- 优点:
- 简化代码,减少手动转换的复杂性。
- 提高代码的清晰度和可读性。
- 实现:
template<typename T> using ReturnType = std::remove_reference<T>::type&&; return std::move<T>(param);
2. std::forward
- 功能:有条件地将实参转换为右值引用,仅在满足特定条件时执行转换。
- 应用场景:
- 当函数形参为右值引用时,std::forward可以自动处理左值引用的情况。
- 例如,在模板函数中传递通用引用形参时,std::forward根据实参的类型自动决定是否转换。
- 优点:
- 提供更高的灵活性,适用于复杂的模板和函数重载。
- 自动处理左值引用的情况,避免手动转换。
- 实现:
template<typename T> using ReturnType = std::remove_reference<T>::type&&; return std::forward<T>(param);
3. 选择何时使用
- 使用std::move:
- 当对象的构造函数或赋值操作需要右值引用时。
- 需要简化代码,避免手动处理引用转换。
- 使用std::forward:
- 当需要处理左值引用的情况,且函数有右值重载版本时。
- 需要更高的灵活性和自动处理能力。
4. 注意事项
- 无条件转换:std::move总是执行转换,而std::forward仅在条件满足时执行。
- 性能:两者在运行时几乎不执行操作,仅进行转换,因此性能差异不大。
- 代码清晰度:使用std::move和std::forward可以使代码更简洁和易读。
通过合理选择和应用std::move和std::forward,可以有效提升代码的效率和可读性,避免手动引用转换带来的复杂性和潜在错误。
Item24: 区分通用引用(Universal References)与右值引用(Rvalue References)
核心概念
右值引用(Rvalue References)
- 形式为
T&&
,但 不涉及类型推导。 - 仅绑定到右值,用于识别可移动对象。
- 示例:
void f(Widget&& param); // 右值引用(无类型推导) Widget&& var1 = Widget(); // 右值引用(无类型推导)
- 形式为
通用引用(Universal References)
- 形式为
T&&
或auto&&
,且 涉及类型推导。 - 可绑定到左值或右值,根据初始化值决定最终类型。
- 示例:
template<typename T> void f(T&& param); // 通用引用(T需推导) auto&& var2 = var1; // 通用引用(auto推导)
- 形式为
关键区分点
类型推导的存在
- 通用引用必须满足:
- 形式为
T&&
或auto&&
。 - 类型
T
需要推导(如函数模板参数或auto
变量)。
- 形式为
- 若无推导,
T&&
为右值引用。template<typename T> void f(std::vector<T>&& param); // 右值引用(形式非 T&&)
- 通用引用必须满足:
初始化值决定引用类型
- 若用左值初始化,通用引用为左值引用。
- 若用右值初始化,通用引用为右值引用。
Widget w; f(w); // param 为左值引用(Widget&) f(std::move(w)); // param 为右值引用(Widget&&)
特殊场景与注意事项
模板中的成员函数
std::vector::push_back(T&&)
是右值引用(实例化后无推导)。std::vector::emplace_back(Args&&...)
是通用引用(每次调用推导Args
)。
auto&&
的灵活性- 常见于 C++14 的 lambda 表达式,处理任意类型参数:
auto timeFuncInvocation = [](auto&& func, auto&&... params) { // 使用 std::forward<decltype(func/params)> 保持值类别 };
- 常见于 C++14 的 lambda 表达式,处理任意类型参数:
修饰符的影响
const T&&
会强制为右值引用,失去通用性。template<typename T> void f(const T&& param); // 右值引用(非通用引用)
总结要点
通用引用条件:
- 函数模板参数为
T&&
且T
需推导,或对象声明为auto&&
。 - 形式必须严格为
T&&
(如vector<T>&&
不满足)。
- 函数模板参数为
右值引用条件:
- 无类型推导,或形式不符合
T&&
(如const T&&
)。
- 无类型推导,或形式不符合
应用场景:
- 通用引用用于完美转发(结合
std::forward
)。 - 右值引用用于移动语义(绑定到临时对象)。
- 通用引用用于完美转发(结合
通过区分二者,可更精准控制代码行为(如避免误用移动语义),并理解模板中的类型推导机制。
item25:对右值引用使用 std::move
,对通用引用使用 std::forward
核心原则
- 右值引用:总是绑定到可移动对象,应无条件使用
std::move
转换为右值。 - 通用引用(
T&&
):可能绑定到左值或右值,应有条件使用std::forward
保留值类别。
右值引用示例
- 移动构造函数中,将成员变量通过
std::move
转移所有权:Widget(Widget&& rhs) : name(std::move(rhs.name)), p(std::move(rhs.p)) {}
通用引用示例
- 模板函数中,通过
std::forward
有条件转发值类别:template<typename T> void setName(T&& newName) { name = std::forward<T>(newName); }
错误使用的后果
通用引用误用
std::move
- 若对通用引用使用
std::move
,可能导致左值被意外移动:template<typename T> void setName(T&& newName) { name = std::move(newName); // 错误!左值可能被移动 }
std::string n = "test"; w.setName(n); // n 的值被意外移动,变为未定义
- 若对通用引用使用
过度使用重载函数的缺点
- 需为左值和右值分别重载(如
setName
),导致:- 代码冗余(参数数量增加时,重载数量指数级增长)。
- 性能损失(临时对象构造/析构)。
- 需为左值和右值分别重载(如
最佳实践
最后一次使用时转换
- 若需多次使用引用参数,仅在最后一次使用
std::move
或std::forward
:template<typename T> void process(T&& text) { log(text); // 保留原值 save(std::forward<T>(text)); // 最后一次使用时转换 }
- 若需多次使用引用参数,仅在最后一次使用
返回值的处理
- 返回右值引用或通用引用时,使用
std::move
/std::forward
:Matrix operator+(Matrix&& lhs, const Matrix& rhs) { lhs += rhs; return std::move(lhs); // 确保移动而非拷贝 }
- 返回局部对象时,依赖 返回值优化(RVO),避免使用
std::move
:Widget makeWidget() { Widget w; return w; // 编译器自动优化(RVO),无需 std::move(w) }
- 返回右值引用或通用引用时,使用
关键注意事项
- 避免抑制 RVO:对局部对象使用
std::move
会阻止编译器进行返回值优化。 - 通用引用的优势:处理任意数量和类型的参数时(如
make_shared
),通用引用是唯一可行方案。 - 移动安全性:在可能抛出异常的场景,考虑
std::move_if_noexcept
(参考条款14)。
总结
- 右值引用 → 无条件
std::move
。 - 通用引用 → 有条件
std::forward
。 - 返回值 → 优先依赖 RVO,仅在返回引用参数时显式转换。
item26:避免在通用引用上重载
核心问题
- 通用引用重载函数过于贪婪:能精确匹配几乎所有类型参数,导致重载决议时频繁胜出,引发意外行为。
- 完美转发构造函数问题:可能劫持编译器生成的拷贝/移动构造函数,干扰派生类构造过程。
函数重载引发的匹配问题
初始版本(
const std::string&
参数)- 传递左值时拷贝效率低,右值和字面量存在不必要的临时对象构造。
- 优化:改用通用引用模板函数,通过
std::forward
转发,提升效率。template<typename T> void logAndAdd(T&& name) { names.emplace(std::forward<T>(name)); }
新增
int
重载版本- 支持通过索引查找名字:
void logAndAdd(int idx) { names.emplace(nameFromIdx(idx)); }
- 问题:传递
short
类型时,通用引用版本优先匹配,导致类型错误。
- 支持通过索引查找名字:
类构造函数中的完美转发问题
Person
类的完美转发构造函数:template<typename T> explicit Person(T&& n) : name(std::forward<T>(n)) {}
- 问题场景:
- 传递非
int
整型(如short
):调用通用引用构造函数而非int
版本,因精确匹配优先。 - 拷贝
non-const
对象:实例化的模板构造函数(Person(Person&)
)优先于编译器生成的拷贝构造函数。Person p("Nancy"); auto cloneOfP(p); // 调用完美转发构造函数而非拷贝构造函数!
const
对象拷贝:正确调用拷贝构造函数,因const
匹配更佳。- 派生类构造问题:派生类的拷贝/移动构造函数错误调用基类的完美转发构造函数。
class SpecialPerson : public Person { SpecialPerson(const SpecialPerson& rhs) : Person(rhs) {} // 调用基类完美转发构造,导致错误 };
- 传递非
关键结论
- 通用引用重载的贪婪性:几乎匹配所有类型参数,包括隐式转换场景。
- 完美转发构造函数的风险:
- 抑制编译器生成拷贝/移动构造函数。
- 劫持派生类对基类构造函数的调用。
- 替代方案:避免在通用引用上重载,需特殊处理时参考条款27(如使用标签分派或约束模板)。
总结
- 避免在通用引用上重载函数,尤其是构造函数。
- 使用通用引用时需警惕重载决议的意外行为,优先考虑其他设计模式(如单一函数模板或类型约束)。
item27:熟悉通用引用重载的替代方法
核心问题
- 使用通用引用(Universal Reference)进行函数重载可能导致意外的重载解析结果(如条款26所述)。
- 构造函数重载尤其容易引发问题,需谨慎处理。
替代方法
1. 放弃重载
- 方法:为不同参数类型的函数赋予不同名称(如
logAndAddName
和logAndAddNameIdx
)。 - 限制:不适用于构造函数(名称固定),且牺牲了重载的便利性。
2. 传递 const T&
- 方法:退回到C++98,使用常量左值引用参数。
- 优点:简单,避免通用引用问题。
- 缺点:无法利用移动语义和完美转发,可能降低效率。
3. 传值
- 方法:按值传递参数,结合
std::move
避免拷贝。class Person { public: explicit Person(std::string n) : name(std::move(n)) {} explicit Person(int idx) : name(nameFromIdx(idx)) {} private: std::string name; };
- 适用场景:参数本身需要拷贝时(参考条款41)。
- 优点:平衡性能和实现复杂度,避免重载冲突。
4. 标签分发(Tag Dispatch)
- 方法:通过额外标签参数(如
std::true_type
/std::false_type
)引导重载解析。template<typename T> void logAndAdd(T&& name) { logAndAddImpl(std::forward<T>(name), std::is_integral<std::remove_reference_t<T>>()); } // 处理非整型 template<typename T> void logAndAddImpl(T&& name, std::false_type) { ... } // 处理整型 void logAndAddImpl(int idx, std::true_type) { ... }
- 核心思想:通用引用参数函数作为分发入口,具体实现通过标签选择重载。
- 优点:允许通用引用与重载共存,避免参数匹配冲突。
5. 约束模板(使用 std::enable_if
)
- 方法:通过
std::enable_if
限制通用引用模板的实例化条件。class Person { public: template<typename T, typename = std::enable_if_t< !std::is_base_of<Person, std::decay_t<T>>::value && !std::is_integral<std::remove_reference_t<T>>::value >> explicit Person(T&& n) : name(std::forward<T>(n)) {} explicit Person(int idx) : name(nameFromIdx(idx)) {} private: std::string name; };
- 关键点:
- 使用
std::decay
移除引用和cv限定符。 - 结合
std::is_base_of
排除派生类,避免继承中构造函数误调用。 - 使用
std::enable_if_t
(C++14)简化代码。
- 使用
- 优点:精确控制通用引用的触发条件,避免与拷贝/移动构造函数冲突。
方法对比与折中
完美转发的权衡:
- 优点:高效,避免临时对象创建。
- 缺点:
- 某些类型无法完美转发(参考条款30)。
- 错误信息复杂(如传递不兼容类型时)。
- 可通过
static_assert
提前验证参数有效性:static_assert(std::is_constructible_v<std::string, T>, "Parameter cannot construct std::string");
选择建议:
- 优先考虑简单性:若性能非关键,使用传值或
const T&
。 - 需要完美转发时,结合标签分发或
std::enable_if
约束模板。 - 对于构造函数,推荐使用
std::enable_if
避免与编译器生成的函数冲突。
- 优先考虑简单性:若性能非关键,使用传值或
总结
- 避免通用引用重载的关键在于限制其适用范围或引导重载解析。
- 标签分发和
std::enable_if
是高级技巧,适用于需要完美转发且必须重载的场景。 - 根据实际需求权衡性能、代码复杂度及错误信息的可读性。
item28: 理解引用折叠
核心概念
- 引用折叠:当引用的引用出现在允许的上下文中(如模板实例化、
auto
推导等),编译器会将其折叠为单个引用。 - 折叠规则:若任一引用为左值引用,结果为左值引用;否则为右值引用。
- 通用引用本质是满足以下条件的右值引用:
- 类型推导区分左值(推导为左值引用)和右值(推导为非引用)。
- 引用折叠发生。
模板实例化中的引用折叠
推导规则
- 当传递左值给通用引用参数时,模板参数
T
被推导为左值引用。 - 传递右值时,
T
被推导为非引用类型。
示例:
template<typename T>
void func(T&& param);
Widget w;
func(w); // T推导为Widget& → param类型为Widget&(左值引用)
func(WidgetFactory()); // T推导为Widget → param类型为Widget&&(右值引用)
引用折叠机制
- 若模板实例化产生引用的引用(如
Widget& &&
),引用折叠将其转化为合法引用。void func(Widget& && param); → 折叠为Widget& param
std::forward
的实现与引用折叠
核心作用
- 当且仅当传入右值时,将参数转换为右值。
实现原理
template<typename T>
T&& forward(remove_reference_t<T>& param) {
return static_cast<T&&>(param);
}
- 左值传入:
T
为Widget&
,折叠后返回左值引用。 - 右值传入:
T
为Widget
,返回右值引用。
示例:
// 左值调用时:
Widget& forward(Widget& param) { return param; } // 无转换
// 右值调用时:
Widget&& forward(Widget& param) { return static_cast<Widget&&>(param); }
引用折叠的四种场景
1. 模板实例化
- 通用引用参数推导时,引用的引用通过折叠解决。
2. auto
类型推导
- 规则与模板推导相同。
auto&& w1 = w; // w为左值 → auto推导为Widget& → w1为Widget& auto&& w2 = WidgetFactory();// w2为右值 → auto推导为Widget → w2为Widget&&
3. typedef
与别名声明
- 在定义或使用类型别名时触发折叠。
template<typename T> class Widget { public: typedef T&& RvalueRefToT; // 若T=int& → int& && → 折叠为int& };
4. decltype
分析
- 若
decltype
推导出引用的引用,触发折叠。
关键总结
- 引用折叠发生场景:
- 模板实例化、
auto
推导、typedef
/别名、decltype
。
- 模板实例化、
- 规则:
- 任一左值引用存在则结果为左值引用;否则为右值引用。
- 通用引用本质:
- 右值引用在类型推导(区分左/右值)和引用折叠共同作用下的表现。
std::forward
依赖引用折叠:- 根据传入实参类型(左值/右值)决定转发行为,保证完美转发的正确性。
item29:假定移动操作不存在,成本高,未被使用
移动语义是C++11的重要特性,但盲目依赖其高效性可能导致误解。以下原因说明为何需谨慎对待移动操作:
1. 移动操作可能不存在
- 旧代码或未适配类型:若类型未针对C++11优化(如未显式支持移动操作),即使编译器支持移动语义,也无法自动提升性能。
- 编译器生成限制:若类定义了拷贝操作、移动操作或析构函数,或基类/成员禁用了移动操作,编译器不会生成默认移动操作(见条款17)。此时移动退化为拷贝。
2. 移动操作未必高效
std::array
的线性移动:与基于指针的容器(如std::vector
)不同,std::array
存储数据于对象内部,移动需逐个元素操作,时间复杂度为线性。- 小字符串优化(SSO):短字符串常存储于对象内部缓冲区,移动并不比拷贝更快,因其本质仍需复制数据。
3. 异常安全导致移动不可用
noexcept
约束:为兼容C++98的异常安全保证,标准库容器(如std::vector
)在重新分配内存时,仅当元素类型的移动操作声明为noexcept
时,才使用移动(见条款14)。否则,退化为拷贝。
4. 上下文限制
- 左值无法移动:除极少数情况(如
std::move
强制转换右值),移动操作仅适用于右值。
总结与建议
- 通用代码:需假设移动操作可能不存在、成本高或未被使用,沿用C++98的保守拷贝策略,确保兼容性。
- 已知类型:若明确类型支持高效移动(如
std::vector
),可安全使用移动语义提升性能。
关键点:移动语义并非万能,需结合类型特性和上下文判断。在不确定时,优先依赖拷贝操作;在明确支持高效移动的场景中,充分利用其优势。