5.1 默认的移动构造和移动赋值
1. 默认成员函数概述
- C++98 的 6 个默认成员函数 :构造函数、析构函数、拷贝构造函数、拷贝赋值重载、取地址重载、const取地址重载。
- 核心重点:前 4 个函数最为重要,后 2 个用处不大。
- 默认行为:如果不手动编写,编译器会自动生成默认版本。
- C++11 新增 2 个默认成员函数:移动构造函数、移动赋值运算符重载。
2. 默认移动构造函数的生成与规则
- 生成条件 :如果没有自己实现移动构造函数,且没有实现析构函数、拷贝构造、拷贝赋值重载中的任意一个,编译器会自动生成一个默认移动构造。
- 执行规则 :
- 内置类型成员:执行逐成员按字节拷贝(浅拷贝)。
- 自定义类型成员:检查该成员是否实现了移动构造。如果实现了,则调用其移动构造;如果没有实现,则退而调用其拷贝构造。
3. 默认移动赋值运算符重载的生成与规则
- 生成条件 :如果没有自己实现移动赋值重载函数,且没有实现析构函数、拷贝构造、拷贝赋值重载中的任意一个,编译器会自动生成一个默认移动赋值。
- 执行规则 :与默认移动构造完全类似:
- 内置类型成员:执行逐成员按字节拷贝赋值。
- 自定义类型成员:检查该成员是否实现了移动赋值。如果实现了,则调用其移动赋值;如果没有实现,则退而调用其拷贝赋值。
4. 移动与拷贝函数的互斥规则
- 如果你手动提供了移动构造或者移动赋值,编译器将不会自动提供拷贝构造和拷贝赋值。
cpp
class Person
{
public:
Person(const char* name = "", int age = 0)
: _name(name)
, _age(age)
{}
/* 注释掉拷贝构造、拷贝赋值和析构函数,
编译器会自动生成默认的拷贝和移动操作 */
/*
Person(const Person& p)
: _name(p._name)
, _age(p._age)
{}
*/
/*
Person& operator=(const Person& p)
{
if (this != &p)
{
_name = p._name;
_age = p._age;
}
return *this;
}
*/
/*
~Person()
{}
*/
private:
bit::string _name; // 自定义类型
int _age; // 内置类型
};
int main()
{
Person s1;
Person s2 = s1; // 调用编译器默认生成的拷贝构造
Person s3 = std::move(s1); // 调用编译器默认生成的移动构造
Person s4;
s4 = std::move(s2); // 调用编译器默认生成的移动赋值
return 0;
}
5.2 default 和 delete 关键字
C++11 引入了 =default 和 =delete 关键字,让程序员能够更精细、更直观地控制类中默认成员函数的生成与使用。
1. =default:显式指定生成默认函数
-
使用场景 :当你需要某个默认函数,但由于某些原因(例如自定义了拷贝构造),编译器不再自动生成它时,可以使用
=default显式要求编译器生成该函数的默认版本。 -
典型示例 :根据 C++11 规则,如果类中提供了拷贝构造函数,编译器就不会自动生成移动构造函数。此时,可以使用
=default显式指定移动构造函数的生成:cppMyClass(MyClass&&) = default; // 显式要求编译器生成默认移动构造- 核心优势:
- 明确意图:明确表明你的意图,提高代码可读性,让其他开发者知道这是有意生成的。
- 保持性能:显式默认化的特殊成员函数仍被认为是"平凡的(trivial)",没有额外的性能开销,且类依然可以保持为 POD 类型。
2. =delete:显式删除函数(禁止生成/调用)
-
使用场景:如果想要限制某些默认函数的生成,禁止对象进行某种操作。
-
C++98 的旧做法 :将函数设置为
private并且只声明不定义。- 缺点:代码不够直观;错误检测被延迟到链接阶段(而非编译阶段);类的成员函数和友元函数依然可以调用它,只是链接时会报错。
-
C++11 的新做法 :在函数声明后加上
=delete,指示编译器不生成对应函数的默认版本。被=delete修饰的函数称为删除函数(deleted function) 。cppMyClass(const MyClass&) = delete; // 显式删除拷贝构造核心优势:
编译期报错:任何尝试调用删除函数的代码都会导致编译错误,错误信息比 C++98 的链接错误更清晰。
绝对禁止:使用 =delete 删除的函数无法通过任何方法调用,即使是成员函数或友元函数中的代码也无法调用。
适用范围广:不仅适用于成员函数,还可以用于非成员函数、运算符重载以及模板函数(例如禁止特定类型的隐式转换或模板实例化)。
5.4 final 与 override
1. override:显式标记重写基类虚函数
- 核心作用:用于派生类中,显式声明并强制校验该函数是否真正重写了基类的虚函数。
- 解决的问题 :防止因函数名拼写错误、参数列表不匹配、返回值类型错误或漏写
virtual等疏忽,导致"看似重写,实则未重写(变成隐藏或新增函数)"的隐性错误。 - 核心优势 :将原本运行时的多态失效问题,提前到编译期暴露并报错,极大降低了调试成本,提高了代码安全性。
2. final:禁止继承或禁止重写
- 作用于类 :标记在类名后,表示该类是"最终类",禁止被其他类继承(终止继承链)。
- 作用于虚函数 :标记在虚函数声明后,表示该函数是"最终实现",禁止在任何派生类中被重写。
- 核心优势 :
- 保障安全与明确意图:保护核心类或关键逻辑不被意外扩展或篡改。
- 编译器优化 :明确告知编译器该函数无拓展、无重写,编译器可执行去虚拟化优化(消除虚函数查表间接寻址开销,直接静态绑定调用),提升代码执行效率。
6. STL 中的一些变化
1. 新增容器
- 核心重点 :新增的容器中最具实际价值的是
unordered_map和unordered_set(基于哈希表实现,查找效率极高)。这两个容器在前面已经进行了非常详细的讲解。 - 其他新增容器 :如
std::array、std::forward_list、std::tuple等,大家只需了解其基本概念即可。
2. 容器的新接口
- 核心重点 :最重要的新接口与右值引用 和移动语义 相关。主要包括:
push_back/insert的右值引用版本(支持移动构造,减少拷贝)。emplace系列接口(如emplace_back,支持传入参数包直接在容器内存中构造对象,性能最优)。- 支持
std::initializer_list版本的构造函数(支持使用花括号{}进行统一初始化)。 - 以上核心接口在前面均已详细讲解。
- 其他小改动 :还有一些无关痛痒的接口,如
cbegin()/cend()(返回 const 迭代器)、crbegin()/crend()(返回 const 反向迭代器)等,在实际开发中遇到时查阅文档即可。
3. 遍历方式革新
- 范围 for 循环(Range-based for loop):极大地简化了容器的遍历操作,无需再手动编写复杂的迭代器或下标,该特性在容器部分也已详细讲解。
7. Lambda 表达式
7.1 Lambda 表达式语法
1. 本质与类型
- 本质 :Lambda 表达式本质上是一个匿名函数对象(闭包)。与普通函数不同的是,它可以直接定义在函数内部。
- 类型接收 :从语法使用层面而言,Lambda 表达式没有显式的类型名。因此,我们一般使用
auto关键字或者模板参数来定义对象,以接收 Lambda 对象。
2. 语法格式
cpp
[capture-list] (parameters) -> return_type { function_body }
各部分详细说明
-
[capture-list](捕获列表):- 位置 :总是出现在 Lambda 函数的开始位置,编译器正是根据
[]来判断接下来的代码是否为 Lambda 表达式。 - 作用:用于捕捉上下文(外部作用域)中的变量,供 Lambda 函数体内部使用。
- 方式:支持传值捕捉和传引用捕捉(具体细节在 7.2 中细讲)。
- 注意 :即使捕获列表为空,
[]也绝对不能省略。
- 位置 :总是出现在 Lambda 函数的开始位置,编译器正是根据
-
(parameters)(参数列表):- 作用:与普通函数的参数列表功能类似。
- 省略规则 :如果不需要传递参数,可以连同
()一起省略。
-
-> return_type(返回值类型):- 作用:使用追踪返回类型的形式声明函数的返回值类型。
- 省略规则:如果没有返回值,或者返回值类型明确可由编译器自动推导,此部分通常可以省略。
-
{ function_body }(函数体):- 作用:包含 Lambda 的具体实现逻辑,与普通函数完全类似。
- 内部访问:在函数体内,除了可以使用传入的参数外,还可以使用所有通过捕获列表捕捉到的外部变量。
- 注意 :即使函数体为空,
{}也绝对不能省略。
7.2 捕获列表详解
-
捕获的必要性 :
Lambda 表达式默认只能使用其函数体内部定义的变量和参数列表中的参数。如果需要在 Lambda 内部使用外层作用域中的变量,就必须通过捕获列表进行捕获。
-
捕获方式分类:
- 显式捕获 :在捕获列表中明确指定变量及其捕获方式,多个变量之间用逗号分隔。例如
[x, y, &z]表示x和y按值捕获,z按引用捕获。 - 隐式捕获 :在捕获列表中仅写
=或&。[=]表示隐式值捕获,[&]表示隐式引用捕获。编译器会自动捕获 Lambda 函数体内实际使用到的外部变量。 - 混合捕获 :结合隐式捕获与显式捕获使用。
[=, &x]:默认按值捕获,唯独x按引用捕获。[&, x, y]:默认按引用捕获,唯独x和y按值捕获。- 语法规则 :使用混合捕获时,第一个元素必须是
&或=。当默认捕获为&时,后续显式捕获的变量必须是值捕获;当默认捕获为=时,后续显式捕获的变量必须是引用捕获。
- 显式捕获 :在捕获列表中明确指定变量及其捕获方式,多个变量之间用逗号分隔。例如
-
捕获的作用域与限制:
- Lambda 表达式只能捕获定义在它之前的局部变量。
- 静态局部变量和全局变量不需要也不能被捕获,Lambda 内部可以直接访问它们。
- 如果 Lambda 表达式定义在全局作用域中,其捕获列表必须为空。
-
mutable关键字与常量性:- 默认情况下,Lambda 的函数调用运算符是
const的,这意味着按值捕获的变量在 Lambda 内部是只读的,无法被修改。 - 在参数列表后加上
mutable关键字可以解除这一限制,允许修改按值捕获的变量副本(注意:修改的仅是副本,不会影响外部原始变量)。 - 注意 :一旦使用了
mutable关键字,即使 Lambda 没有参数,参数列表()也绝对不能省略。
- 默认情况下,Lambda 的函数调用运算符是
7.3 Lambda 的应用
1. 替代传统可调用对象
在学习 Lambda 表达式之前,C++ 中常用的可调用对象主要是函数指针 和仿函数(函数对象):
- 函数指针:类型定义较为繁琐,且无法携带状态。
- 仿函数 :需要额外定义一个类并重载
operator(),代码量较大,相对麻烦。 - Lambda 的优势:使用 Lambda 定义可调用对象,既简单又方便,可以直接在需要的地方内联编写逻辑,极大地提高了代码的可读性和开发效率。
2. 广泛的应用场景
除了配合 STL 算法(如 sort、find_if 等)使用外,Lambda 在其他场景中也极为好用:
- 多线程编程 :在创建线程(如
std::thread)时,直接定义线程的执行逻辑。 - 智能指针 :在创建智能指针(如
std::shared_ptr)时,使用 Lambda 定制专属的删除器(Deleter)。
3. 实战案例:配合 STL 进行自定义排序
在需要实现多种自定义比较逻辑时,Lambda 的优势尤为明显。以下是一个商品排序的对比示例:
传统仿函数做法:
cpp
struct Goods {
string _name; // 名字
double _price; // 价格
int _evaluate; // 评价
Goods(const char* str, double price, int evaluate)
: _name(str), _price(price), _evaluate(evaluate) {}
};
// 需要为每一种比较规则单独定义一个结构体
struct ComparePriceLess {
bool operator()(const Goods& gl, const Goods& gr) {
return gl._price < gr._price;
}
};
struct ComparePriceGreater {
bool operator()(const Goods& gl, const Goods& gr) {
return gl._price > gr._price;
}
};
int main()
{
vector<Goods> v = { { "苹果", 2.1, 5 }, { "⾹蕉", 3, 4 }, { "橙⼦", 2.2, 3
}, { "菠萝", 1.5, 4 } };
// 类似这样的场景,我们实现仿函数对象或者函数指针⽀持商品中
// 不同项的⽐较,相对还是⽐较⿇烦的,那么这⾥lambda就很好⽤了
sort(v.begin(), v.end(), ComparePriceLess());
sort(v.begin(), v.end(), ComparePriceGreater());
sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) {
return g1._price < g2._price;
});
sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) {
return g1._price > g2._price;
});
sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) {
return g1._evaluate < g2._evaluate;
});
sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2) {
return g1._evaluate > g2._evaluate;
});
return 0;
}
7.4 Lambda 的底层原理
1. 本质:编译器生成的仿函数对象
Lambda 表达式和范围 for 循环一样,本质上都是语法糖 。从汇编指令层面来看,底层压根就没有 Lambda 和范围 for 这样的概念。范围 for 底层被展开为迭代器操作,而 Lambda 底层则是一个仿函数(Functor)对象。
2. 编译器的转换过程
当我们编写一个 Lambda 表达式时,编译器会在后台自动生成一个对应的匿名仿函数类。具体转换规则如下:
- 匿名类名 :编译器会按照一定的内部规则生成一个唯一的类名(类似于
__Lambda123的形式),以保证不同的 Lambda 表达式生成的类名互不冲突。 operator()重载 :Lambda 的参数列表、返回值类型和函数体,会被直接转换为该匿名仿函数类中operator()的参数、返回类型和函数体。这使得 Lambda 对象可以像普通函数一样被调用。- 捕获列表与成员变量 :捕获列表中的变量,本质上会被生成为该仿函数类的成员变量 。
- 按值捕获的变量,会成为类中的普通成员变量。
- 按引用捕获的变量,会成为类中的引用成员变量。
- 构造函数:编译器会自动为该匿名类生成一个构造函数。捕获列表中的变量就是构造函数的实参,用于在创建 Lambda 对象时初始化其内部的成员变量。
- 隐式捕获的处理 :对于隐式捕获(如
[=]或[&]),编译器会在编译期分析 Lambda 函数体,自动识别出实际使用了哪些外部变量,并将它们作为实参传递给构造函数。