cmu15445 C++引入笔记
01 左值引用&
C++中的引用是一种为变量创建别名的方法,这些别名引用内存中的相同数据。引用在跟踪状态、将参数传递给函数以及提高总体性能方面非常有用。
在函数传递中,使用引用,避免了不必要的拷贝。
#include <iostream>
void add_three(int &a) { a = a + 3; }
int main() {
int a = 10;
int &b = a;
std::cout << "b is " << b << std::endl;
add_three(a);
std::cout << "a is " << a << std::endl;
std::cout << "b is " << b << std::endl;
return 0;
}
/*
b is 10
a is 13
b is 13
*/
02 移动语义move
为了规避重复拷贝带来的性能开销,现代C++的策略不再是暴力的内存拷贝,而是利用左值右值及其引用实现的移动语义,直接进行资源的交接
值类别
C++11 以后标准把表达式分成了 5 种值类别
表达式 (Expression)
│
┌──────────┴──────────┐
│ │
glvalue rvalue
(广义左值) (右值)
│ │
┌─────┴─────┐ ┌─────┴─────┐
│ │ │ │
lvalue xvalue xvalue prvalue
左值 将亡值 将亡值 纯右值
左值右值
左值(lvalue)指的是一个指向特定内存的具有名称的值,即具名对象,比如声明的变量都是左值。
右值(rvalue,也叫天生的纯右值,pure rvalue, prvalue)指的是那些临时的短暂的表达式或值,声明周期较短,没有稳定可识别的内存地址。右值通常代表一个计算的中间结果,一个字面量或一个即将被销毁的临时对象,凡是临时的即用即弃的值就是右值。根据是否存在特定的内存地址,可以用取地址符用程序验证。可以发现对于左值,程序可以正常运行,对于右值取地址,则直接报错。
注意:通常字面量都是右值,但字符串字面量是左值,可以对其取地址。这是因为字符串字面量在C++中是一个常量字符数组,编译器会将字符串字面量,存储到程序的只读数据段中,这个区域的数据会从程序的运行到结束会一直存在。并且有每个字符串字面量在只读数据段都有一个独一无二的固定地址
右值引用&&
在左值引用中,需要在类型后添加一个&,在右值引用中添加两个&。
C++很多操作都会产生临时的没有名字的右值对象,比如函数的按值返回表达式计算的中间结果。左值引用并不能规避对象的拷贝开销。但右值引用可以实现移动语义,在处理临时对象时,用高效的资源窃取,取代深度拷贝。
移动语义是把临时对象直接拿走,将原来的被复制的对象内容直接掏空。需要注意的是,如果传入的实参是左值,那么编译器只会使用拷贝于语义下的构造函数。如果是右值会优先选择移动构造函数,也可以选择拷贝构造函数
左值的移动语义
右值引用只能绑定右值,如果直接绑定左值,会导致编译错误,但是可以通过static_cast,将其强制转换为右值引用,不会改变变量的声明周期和内存地址(称为将亡值 expired lvalue,xvalue,也可以被称为rvalue),但是可以让左值也能使用移动语义,右值引用也相当于被引用对象的别名,即无论是左值引用还是右值引用,引用即别名一旦绑定,不可更改。为了防止数据的意外丢失,左值是不能隐式移动的,但可以通过move函数将左值显示地转换为右值引用(其实也是基于static_cast的强制类型转换),从而允许编译器对其执行移动操作。被移动后的对象处于一个有效但未指定的状态,对于string来说是一个空字符串,不应该依赖一个被移动后的对象的值,但可以给这个对象附新值。
理解:
它没有复制数据,没有移动数据,没有修改对象,它只是告诉编译器,要不这个对象当作右值。这样就不会报错了。另外,static_cast<T&&>(也就是 std::move)只是把表达式变成了 xvalue(将亡值),并不是把对象真的变成了"快死了","将亡值(xvalue)"这个名字其实有点误导,它并不是说对象真的马上要销毁,而是告诉编译器:你可以把它当成一个可以移动的对象来处理。
std::string s = "hello";
std::move(s);
std::cout << s << std::endl;
很多人以为输出不了。
实际上输出就是
hello
为什么?
因为
std::move(s);
等价于
static_cast<std::string&&>(s);
这只是产生了一个临时表达式:
s -------------> "hello"
│
│ static_cast
▼
临时表达式(xvalue)
注意:
这个 xvalue 只是一个表达式。
表达式求值结束以后,它就没了。
而
s
一直还在那里。
真正会发生变化是什么时候?
例如
std::string s = "hello";
std::string t = std::move(s);
这里流程变成:
s
│
▼
std::move(s)
│
▼
xvalue
│
▼
调用移动构造函数
│
▼
资源被偷走
真正修改 s 的不是
std::move
而是
std::string(std::string&&)
移动构造函数。
它内部可能做的是
ptr = other.ptr;
other.ptr = nullptr;
于是
s
ptr = nullptr
所以你看到 s 变空了。
#include <iostream>
#include <utility>
#include <vector>
void move_add_three_and_print(std::vector<int> &&vec) {
std::vector<int> vec1 = std::move(vec);
vec1.push_back(3);
for (const int &item : vec1) {
std::cout << item << " ";
}
std::cout << "\n";
}
void add_three_and_print(std::vector<int> &&vec) {
vec.push_back(3);
for (const int &item : vec) {
std::cout << item << " ";
}
std::cout << "\n";
}
int main() {
int a = 10;
std::vector<int> int_array = {1, 2, 3, 4};
//这里其实发生了资源转移
std::vector<int> stealing_ints = std::move(int_array);
std::cout << "Printing from size of int_array: " << int_array.size() << std::endl;
// 和左值引用一样,这里就是起了个别名
std::vector<int> &&rvalue_stealing_ints = std::move(stealing_ints);
std::cout << "Printing from stealing_ints: " << stealing_ints[1] << std::endl;
std::cout << "Printing from rvalue_stealing_ints: " << rvalue_stealing_ints[1] << std::endl;
std::vector<int> int_array2 = {1, 2, 3, 4};
std::cout << "Calling move_add_three_and_print...\n";
move_add_three_and_print(std::move(int_array2));
std::cout << "Printing from size of int_array2: " << int_array2.size() << std::endl;
//如果只是函数传递的话,只是形参绑定,实际上就是右值引用,并没有发生资源转移
std::vector<int> int_array3 = {1, 2, 3, 4};
std::cout << "Calling add_three_and_print...\n";
add_three_and_print(std::move(int_array3));
std::cout << "Printing from int_array3: " << int_array3[1] << std::endl;
return 0;
}
/*
Printing from size of int_array: 0
Printing from stealing_ints: 2
Printing from rvalue_stealing_ints: 2
Calling move_add_three_and_print...
1 2 3 4 3
Printing from size of int_array2: 0
Calling add_three_and_print...
1 2 3 4 3
Printing from int_array3: 2
*/
03 移动构造函数
初始化列表
形式是在构造函数后加冒号,然后是数据成员和一个小括号,里面可以用参数或值初始化,多个成员直接用都好隔开。成员不用全部都要初始化。空格是随意的
Person(uint32_t age, std::vector<std::string> &&nicknames)
: age_(age), nicknames_(std::move(nicknames)), valid_(true) {}
如果成员是引用成员或是const成员,则必须使用初始化,因为引用和const成员不能通过赋值,必须是定义的时候进行初始化
初始化列表与构造赋值的区别
-
初始化在前,赋值在后
int a = 10; //初始化 a = 20; //赋值 -
初始化列表和直接初始化(定义成员变量时写的)相比,初始化列表更灵活
-
更高效,相比赋值,初始化时就直接更具值构造。相比赋值,避免了默认构造(int 默认 0)带来的开销,
-
必要性是引用成员或是const成员,则必须使用初始化。赋值的话不行
/**
* @file move_constructors.cpp
* @author Abigale Kim (abigalek)
* @brief 移动构造函数与移动赋值运算符教程代码
*/
// 移动构造函数和移动赋值运算符是类内部实现的成员函数,
// 作用是高效地将资源从一个对象转移到另一个对象,通常配合 std::move 使用。
// 这两个成员函数接收同类型的另一个对象,将其持有的资源转移到调用该函数的实例上。
// 在本文件中,我们将学习如何实现并使用移动构造函数与移动赋值运算符。
// 引入 std::cout(打印输出)用于代码演示。
#include <iostream>
// 引入 utility 头文件,用于 std::move。
#include <utility>
// 引入 C++ 标准字符串库。
#include <string>
// 引入 uint32_t 类型所需头文件。
#include <cstdint>
// 引入 std::vector 容器头文件。容器相关内容会在 containers.cpp 中详细讲解,
// 目前只需知道 vector 本质是动态数组,std::vector<std::string> 代表字符串数组。
// vector 会占用可观的内存,这里用来直观展示 std::move 带来的性能提升。
#include <vector>
// 基础 Person 类:自定义实现移动构造函数、移动赋值运算符,
// 同时禁用拷贝构造函数和拷贝赋值运算符。
// 这意味着 Person 对象一旦创建,就不能被拷贝,只能通过移动将资源从左值转移到其他对象。
// 禁用拷贝的类适用于“全局只能存在一份有效实例”的场景。
// 举例:如果一个类管理一块动态分配的内存,若允许拷贝且处理不当,
// 会引发重复释放内存、内存泄漏等问题。
class Person {
public:
// 默认构造函数:年龄初始化为0,昵称列表为空,对象标记为有效
Person() : age_(0), nicknames_({}), valid_(true) {}
// 该构造函数接收 std::vector<std::string> 右值作为参数。
// 这种写法提升效率,构造 Person 对象时不会对 vector 执行深拷贝。而是转移所有权
Person(uint32_t age, std::vector<std::string> &&nicknames)
: age_(age), nicknames_(std::move(nicknames)), valid_(true) {}
// Person 类的移动构造函数:接收 Person 类型右值,
// 将传入对象的内部数据转移到当前 Person 实例。
// 注意 std::move 的使用:为了保证 person 对象里的 nicknames_ 是转移而非深拷贝,
// 需要用 std::move 将左值 person.nicknames_ 转换为右值。
// 这里没有对 age_ 使用 std::move:整型数据体积很小,拷贝开销可以忽略不计。
// 通用规则:数值类型直接拷贝即可;字符串、容器等复杂类型,若无特殊需求优先转移。
Person(Person &&person)
: age_(person.age_), nicknames_(std::move(person.nicknames_)),
valid_(true) {
std::cout << "调用 Person 类的移动构造函数。\n";
// 被转移资源的原对象,标记为无效
person.valid_ = false;
}
// Person 类的移动赋值运算符重载
Person &operator=(Person &&other) {
std::cout << "调用 Person 类的移动赋值运算符。\n";
age_ = other.age_;
nicknames_ = std::move(other.nicknames_);
valid_ = true;
// 被转移资源的原对象,标记为无效
other.valid_ = false;
return *this;
}
// 删除拷贝构造函数与拷贝赋值运算符,禁止本类对象拷贝构造
Person(const Person &) = delete;
Person &operator=(const Person &) = delete;
// 获取年龄
uint32_t GetAge() { return age_; }
// 返回值后的 & 代表返回 vector 内字符串的引用,
// 不会拷贝字符串,底层直接返回 vector 存储字符串的内存地址。
std::string &GetNicknameAtI(size_t i) { return nicknames_[i]; }
// 打印当前对象是否有效
void PrintValid() {
if (valid_) {
std::cout << "对象有效。" << std::endl;
} else {
std::cout << "对象无效。" << std::endl;
}
}
private:
uint32_t age_; // 年龄
std::vector<std::string> nicknames_; // 昵称数组
// 标记对象数据是否有效:true 代表资源未被转移到其他实例
bool valid_;
};
int main() {
// 演示移动构造、移动赋值的实现与使用。
// 首先创建 Person 对象 andy,此时 andy 是有效对象。
Person andy(15445, {"andy", "pavlo"});
std::cout << "打印 andy 对象有效性:";
andy.PrintValid();
// 使用 std::move 将 andy 的资源转移到另一个对象,有两种写法。
// 下面这行代码会调用移动赋值运算符。
Person andy1;
andy1 = std::move(andy);
// 转移完成后:andy1 有效,原对象 andy 变为无效。
std::cout << "打印 andy1 对象有效性:";
andy1.PrintValid();
std::cout << "打印 andy 对象有效性:";
andy.PrintValid();
// 下面这行代码调用移动构造函数。
// 执行后,andy1 的资源转移到 andy2,andy、andy1 两个左值都失效,
// 失效对象不建议继续使用,除非重新初始化。
Person andy2(std::move(andy1));
// 转移完成后:andy2 有效,andy1 变为无效。
std::cout << "打印 andy2 对象有效性:";
andy2.PrintValid();
std::cout << "打印 andy1 对象有效性:";
andy1.PrintValid();
// 注意:因为拷贝赋值运算符被删除,下面代码无法编译。
// 第一行通过默认构造创建 andy3,第二行试图调用拷贝赋值,
// 深度复制 andy2 的数据给 andy3。可以取消注释,观察编译器报错。
// Person andy3;
// andy3 = andy2;
// 同理,拷贝构造函数已被删除,这段代码同样编译失败。
// 取消注释可查看编译错误。
// Person andy4(andy2);
return 0;
}
/*
Printing andy's validity: Object is valid.
Calling the move assignment operator for class Person.
Printing andy1's validity: Object is valid.
Printing andy's validity: Object is invalid.
Calling the move constructor for class Person.
Printing andy2's validity: Object is valid.
Printing andy1's validity: Object is invalid.
*/
04 模板函数
可以在调用时显示指定类型,也可以不指定,编译器可以根据传入实参自动推导模板类型
模板参数不一定只能是类型
还支持特化,根据不同的类型,做出不同的逻辑
/**
* @file templated_functions.cpp
* @author Abigale Kim (abigalek)
* @brief 模板函数教学示例代码
*/
// 引入 std::cout 用于控制台打印演示
#include <iostream>
// 模板是 C++ 提供的语言特性,它允许你编写一套代码,
// 可以适配多种数据类型,而无需提前指定具体类型。
// 在 C++ 中,我们可以编写模板函数和模板类。本文件只讲解模板函数。
// 下面是一个基础模板函数,实现两个数字相加
// 语法说明:你会见到两种写法 template<class T> 和 template<typename T>
// 二者作用完全等价,但 class 和 typename 关键字存在细微区别。
// 这篇博客讲解了二者差异,本节课不需要深入了解:
// https://mariusbancila.ro/blog/2021/03/15/typename-or-class/
template <typename T> T add(T a, T b) { return a + b; }
// 模板函数可以接收多个类型参数。
// 这个函数会把传入的两个值都打印出来
template<typename T, typename U>
void print_two_values(T a, U b) {
std::cout << a << " and " << b << std::endl;
}
// 我们还可以对模板函数做特化,针对不同类型执行不同逻辑。
// 看下面这个简单示例:如果传入的模板类型是 float,就打印对应提示;
// 其余所有类型,统一打印 Hello world。
template <typename T> void print_msg() { std::cout << "Hello world!\n"; }
// 模板特化函数:专门针对 float 类型做定制实现
template <> void print_msg<float>() {
std::cout << "print_msg 函数使用 float 类型调用!\n";
}
// 最后补充:模板参数不一定只能是类型。
// 下面这个示例比较刻意,它接收布尔值作为模板参数,
// 并根据这个布尔模板参数对入参执行不同计算逻辑。
template <bool T> int add3(int a) {
if (T) {
return a + 3;
}
return a;
}
int main() {
// 首先演示 add 函数分别传入 int 和 float 类型使用
std::cout << "执行 add<int>(3, 5) 结果:" << add<int>(3, 5) << std::endl;
std::cout << "执行 add<float>(2.8, 3.7) 结果:" << add<float>(2.8, 3.7)
<< std::endl;
// 编译器可以根据传入实参自动推导模板类型。
// 但现代 C++ 新手不推荐依赖自动推导,因为你很难确定函数实际推导出的类型。
std::cout << "执行 add(3, 5) 结果:" << add(3, 5) << std::endl;
// 接下来演示 print_two_values,传入两种不同类型参数
std::cout << "执行 print_two_values<int, float>(3, 3.2):";
print_two_values<int, float>(3, 3.2);
// 分别用普通类型和 float 类型调用 print_msg,观察效果。
// 不出意外,第一次调用走通用模板逻辑;
// 第二次传入 float 模板参数,会匹配 float 专属特化版本。
std::cout << "调用 print_msg<int>():";
print_msg<int>();
std::cout << "调用 print_msg<float>():";
print_msg<float>();
// add3 依靠布尔模板参数区分行为,运行效果如下
std::cout << "执行 add3<true>(3) 结果:" << add3<true>(3) << std::endl;
std::cout << "执行 add3<false>(3) 结果:" << add3<false>(3) << std::endl;
// 最后说明:上面这些例子都比较刻意,
// 其中部分逻辑完全可以不用模板实现(比如把布尔值作为普通函数参数)。
// 但课程后续代码库中会大量出现类似模板写法,
// 因此有必要理解这种场景下模板函数的用法!
return 0;
}
/*
Printing add<int>(3, 5): 8
Printing add<float>(2.8, 3.7): 6.5
Printing add(3, 5): 8
Printing print_two_values<int, float>(3, 3.2): 3 and 3.2
Calling print_msg<int>(): Hello world!
Calling print_msg<float>(): print_msg called with float type!
Printing add3<true>(3): 6
Printing add3<false>(3): 3
*/
05 模板类
/**
* @file templated_classes.cpp
* @author Abigale Kim (abigalek)
* @brief 模板类教学示例代码
*/
// 引入 std::cout 用于打印输出
#include <iostream>
// 模板同样可以用于实现类。举个例子,下面是一个基础模板类,
// 它可以存储一个模板类型的成员变量,调用 print 函数时会打印该变量。
template<typename T>
class Foo {
public:
Foo(T var) : var_(var) {}
void print() {
std::cout << var_ << std::endl;
}
private:
T var_;
};
// 模板类也可以传入多个类型参数。
// 比如下面这个基础模板类,存储两个不同模板类型的变量,调用 print 时打印两者。
template<typename T, typename U>
class Foo2 {
public:
Foo2(T var1, U var2)
: var1_(var1)
, var2_(var2) {}
void print() {
std::cout << var1_ << " and " << var2_ << std::endl;
}
private:
T var1_;
U var2_;
};
// 我们还可以对模板类进行特化,针对不同类型实现不同逻辑。
// 下面这个示例比较刻意:当模板类型不是 float 时,print 函数直接打印存储的变量值;
// 如果模板实例化类型是 float,则会打印 hello float 以及类内部存储的变量。
template<typename T>
class FooSpecial {
public:
FooSpecial(T var) : var_(var) {}
void print() {
std::cout << var_ << std::endl;
}
private:
T var_;
};
// 模板类特化版本:专门针对 float 类型定制实现
template<>
class FooSpecial<float> {
public:
FooSpecial(float var) : var_(var) {}
void print() {
std::cout << "hello float! " << var_ << std::endl;
}
private:
float var_;
};
// 模板参数不一定是类型,也可以是常量数值!
template<int T>
class Bar {
public:
Bar() {}
void print_int() {
std::cout << "print int: " << T << std::endl;
}
};
int main() {
// 首先演示模板类创建对象。Foo 模板类使用 int 作为模板参数实例化,
// 此时对象 a 的真实类型是 Foo<int>,而非 Foo。a 的 print 函数正常工作。
Foo<int> a(3);
std::cout << "调用 Foo<int> a(3) 的 print 函数:";
a.print();
// 编译器可以根据构造函数实参自动推导模板类的类型参数。
再次提醒新手:如果你不确定类会被推导成什么类型,谨慎使用自动推导。
Foo b(3.4f);
std::cout << "调用 Foo b(3.4f) 的 print 函数:";
b.print();
// 第二部分:使用带有多个类型参数的模板类创建对象
Foo2<int, float> c(3, 3.2f);
std::cout << "调用 Foo2<int, float> c(3, 3.2f) 的 print 函数:";
c.print();
// 分别用普通类型和 float 类型实例化 FooSpecial,观察效果。
// d 是 int 版本实例,调用 print 只会直接打印变量,不会输出 hello float。
// e 是 float 特化版本的实例,调用 print 会打印 hello float!
FooSpecial<int> d(5);
std::cout << "调用 FooSpecial<int> d(5) 的 print 函数:";
d.print();
FooSpecial<float> e(4.5);
std::cout << "调用 FooSpecial<float> e(4.5) 的 print 函数:";
e.print();
// 最后演示:使用非类型参数(常量值)的模板类创建对象
Bar<150> f;
std::cout << "调用 Bar<150> f 的 print_int 函数:";
f.print_int();
// 补充说明:上面这些示例都比较简化刻意,但仍需要理解。
// 后续项目代码库中会出现大量同类写法,掌握模板类有助于看懂工程代码!
return 0;
}
/*
Calling print on Foo<int> a(3): 3
Calling print on Foo b(3.4f): 3.4
Calling print on Foo2<int, float> c(3, 3.2f): 3 and 3.2
Calling print on FooSpecial<int> d(5): 5
Calling print on FooSpecial<float> e(4.5): hello float! 4.5
Calling print_int on Bar<150> f: print int: 150
*/
06 包装类
RAII
资源获取即初始化(Resource Acquisition Is Initialization)或 RAII,是一种 C++编程技术 [1] [2] 。这种技术将需要在使用之前被获取的资源的生命周期(如分配的堆内存、正在运行的线程、打开的套接字、打开的文件、锁定的互斥量、磁盘空间、数据库连接等,任何有限资源的获取过程)与对象的生命周期绑定在一起。
RAII 机制确保无论哪个函数访问该对象,都能获得该资源。资源的可获得性是类的不变式,因此无需在运行时进行冗余的测试。同时,当控制该资源的对象生命周期结束时,所有资源都会按照获取的顺序相反的方式被释放。如果资源获取失败(即构造函数因异常而终止),那么所有已完全构造的成员和子对象所获取的资源也会按照初始化顺序相反的方式被释放。这种机制利用了语言的核心特性——对象的生命周期、作用域的终止、初始化的顺序以及堆栈的撤销——来避免资源泄漏,并保障异常的安全性。这种技术也被称为“作用域绑定资源管理”(SBRM),因为其基本原理就是当 RAII 对象的作用域结束时,该对象及其资源也会随之释放。
/**
* @file wrapper_class.cpp
* @author Abigale Kim (abigalek)
* @brief 包装类教学示例代码
*/
// C++ 中的包装类是用于管理资源的类。资源可以是内存、文件句柄、网络套接字或网络连接。
// 包装类通常会使用 RAII(资源获取即初始化)这套 C++ 编程范式。
// 使用该范式意味着:资源的生命周期与对象作用域绑定。
// 当包装类对象构造完成时,它所管理的底层资源就处于可用状态;
// 当该对象被析构时,对应的资源也会被释放、变为不可用。
// 下面是两篇很有用的 RAII 参考资料:
// https://en.cppreference.com/w/cpp/language/raii (C++ 官方文档关于 RAII 的说明)
// Stack Overflow 上关于“什么是 RAII?”的优质问答:
// https://stackoverflow.com/questions/2321511/what-is-meant-by-resource-acquisition-is-initialization-raii
// 在本文件中,我们将实现一个基础包装类,用来管理 int* 动态内存,同时演示该类的使用方式。
// 引入 std::cout 用于控制台打印演示
#include <iostream>
// 引入 utility 头文件,用于 std::move
#include <utility>
// IntPtrManager 是一个包装类,用于管理 int* 指针。
// 该类管理的资源是指针 ptr_ 指向的堆内存。
// 根据 RAII 设计原则,包装类对象不允许拷贝,因为一份资源只能由一个对象负责管理。
// 因此我们删除了拷贝构造函数与拷贝赋值运算符。
// 但该类支持移动操作,可以把资源所有权从一个左值转移给另一个对象,为此实现了移动构造和移动赋值运算符。
// 禁止拷贝的另一个关键原因:类的析构函数会释放资源,如果多个对象同时管理同一块内存,
// 析构时会出现重复释放内存的严重错误。
class IntPtrManager {
public:
// 包装类的所有构造函数都必须完成资源初始化工作。
// 本构造函数会分配一块 int 堆内存,默认值置为 0。
IntPtrManager() {
ptr_ = new int;
*ptr_ = 0;
}
// 另一个构造函数,接收初始值来初始化堆内存
IntPtrManager(int val) {
ptr_ = new int;
*ptr_ = val;
}
// 包装类的析构函数:必须释放自身管理的资源,本例中就是释放指针指向的堆内存!
~IntPtrManager() {
// 注意:移动构造时会把原对象的 ptr_ 置空标识失效,
// 因此析构函数需要做判空处理,不能对 nullptr 执行 delete!
if (ptr_) {
delete ptr_;
}
}
// 移动构造函数:调用后,原对象 other 的全部资源转移到新创建的对象中,
// other 会变成无效对象,不再持有可管理的内存。
IntPtrManager(IntPtrManager&& other) {
ptr_ = other.ptr_;
other.ptr_ = nullptr;
}
// 移动赋值运算符,逻辑与移动构造思路一致
IntPtrManager &operator=(IntPtrManager &&other) {
// 防止自移动(自己移动给自己)
if (ptr_ == other.ptr_) {
return *this;
}
// 先释放当前对象持有的旧内存
if (ptr_) {
delete ptr_;
}
// 接管对方的内存指针
ptr_ = other.ptr_;
// 原对象置空,失去资源所有权
other.ptr_ = nullptr;
return *this;
}
// 删除拷贝构造、拷贝赋值运算符,禁止对象拷贝
IntPtrManager(const IntPtrManager &) = delete;
IntPtrManager &operator=(const IntPtrManager &) = delete;
// 设置值接口
void SetVal(int val) {
*ptr_ = val;
}
// 获取值接口,const 修饰保证不修改对象
int GetVal() const {
return *ptr_;
}
private:
int *ptr_;
};
int main() {
// 创建 IntPtrManager 对象 a,此时该对象持有并管理一块 int 堆内存
IntPtrManager a(445);
// 正常读取内存中的数值
std::cout << "1. a 存储的值为 " << a.GetVal() << std::endl;
// 修改内存值后可以正常读取
a.SetVal(645);
std::cout << "2. a 存储的值为 " << a.GetVal() << std::endl;
// 通过移动构造,将 a 的资源所有权转移给对象 b
IntPtrManager b(std::move(a));
// b 现在接管了原本 a 创建的堆内存,可以正常读取数值;
// 此时如果调用 a.GetVal() 会触发段错误,a 已经失效,不能再使用。
std::cout << "b 存储的值为 " << b.GetVal() << std::endl;
// 函数执行结束时,a 和 b 的析构函数都会自动调用。
// a 的 ptr_ 是空指针,析构时不会执行释放操作;
// b 的析构函数释放它持有的堆内存,不会出现重复释放问题。
return 0;
}
/*
1. Value of a is 445
2. Value of a is 645
Value of b is 645
*/
其中
// 删除拷贝构造函数
IntPtrManager(const IntPtrManager &) = delete;
// 删除拷贝赋值运算符
IntPtrManager &operator=(const IntPtrManager &) = delete;
= delete; 是 C++11 新增语法,含义:显式告诉编译器,这个函数被禁用,禁止调用。
作用:彻底禁止类对象拷贝。不允许这两种操作:
IntPtrManager a(10);
// 1. 拷贝构造(被禁用)
IntPtrManager b = a;
IntPtrManager c(a);
// 2. 拷贝赋值(被禁用)
IntPtrManager d;
d = a;
只要写上面任意一行,编译器直接报错,编译不通过。
07 迭代器
主要还是靠指针实现的
/**
* @file iterator.cpp
* @author Abigale Kim (abigalek)
* @brief 迭代器使用与自定义实现教学代码
*/
// C++ 迭代器是一种指向容器内部元素的对象,可用于遍历容器中的所有元素。
// 你早已接触过的指针就是迭代器的一种。指针可以用来遍历 C 风格数组。
// 看下面这段 C 语言示例代码:
// int *array = malloc(sizeof(int) * 10);
// int *iter = array;
// int zero_elem = *iter;
// iter++;
// int first_elem = *iter;
// 可以看到,自增运算符 ++ 可以遍历数组,解引用运算符 * 能取出迭代器指向位置的值。
// C++ 迭代器最核心的两个运算符:
// 1. 解引用运算符 *:返回迭代器当前指向位置的元素值
// 2. 后置自增运算符 ++:将迭代器向后移动一位
// 用指针遍历 C 数组时,同样遵循这套规则。
// 在 vectors.cpp、sets.cpp、unordered_maps.cpp、auto.cpp 这些文件中,
// 有大量使用迭代器访问 STL 容器元素的示例。
// 在 C++ 中使用迭代器读写容器元素是推荐规范写法,因此多个教程文件都会涉及。
// 本文件重点讲解迭代器的底层实现。我们将手动实现双向链表(DLL)的迭代器,
// 带你理解 C++ 迭代器的设计逻辑。
// 引入 std::cout 用于打印演示
#include <iostream>
// 双向链表节点结构体定义
struct Node {
Node(int val)
: next_(nullptr)
, prev_(nullptr)
, value_(val) {}
Node* next_; // 后继节点指针
Node* prev_; // 前驱节点指针
int value_; // 节点存储数值
};
// 该类实现适配双向链表 DLL 的 C++ 风格迭代器。
// 构造函数接收一个节点指针,作为迭代遍历的起始位置。
// 类中重载了自增运算符(用于移动到链表下一个元素)、相等判断运算符,
// 通过比较内部 curr_ 指针地址来判断两个迭代器是否相同。
class DLLIterator {
public:
DLLIterator(Node* head)
: curr_(head) {}
// 重载前置自增运算符(++iter)
DLLIterator& operator++() {
curr_ = curr_->next_;
return *this;
}
// 重载后置自增运算符(iter++)。
// 前置和后置自增的核心区别是返回值不同:
// 前置自增返回自增完成后的迭代器;
// 后置自增返回自增操作之前的迭代器副本。
DLLIterator operator++(int) {
DLLIterator temp = *this;
++*this;
return temp;
}
// 迭代器相等运算符重载
// 判断两个迭代器的当前节点指针是否完全一致
bool operator==(const DLLIterator &itr) const {
return itr.curr_ == this->curr_;
}
// 迭代器不等运算符重载
// 判断两个迭代器的当前节点指针不相同
bool operator!=(const DLLIterator &itr) const {
return itr.curr_ != this->curr_;
}
// 解引用运算符重载
// 返回迭代器当前指向节点存储的值。
// 迭代器当前位置由 curr_ 记录,通过 value_ 成员取出数值。
int operator*() {
return curr_->value_;
}
private:
Node* curr_; // 迭代器当前指向的节点指针
};
// 基础双向链表实现,包含 Begin()、End() 两个迭代器接口,
// 二者会返回 DLLIterator 对象,用于遍历链表。
class DLL {
public:
// 双向链表构造函数
DLL()
: head_(nullptr)
, size_(0) {}
// 析构函数:遍历链表,释放所有节点内存
~DLL() {
Node *current = head_;
while(current != nullptr) {
Node *next = current->next_;
delete current;
current = next;
}
head_ = nullptr;
}
// 在链表头部插入数值
void InsertAtHead(int val) {
Node *new_node = new Node(val);
new_node->next_ = head_;
if (head_ != nullptr) {
head_->prev_ = new_node;
}
head_ = new_node;
size_ += 1;
}
// Begin():返回指向链表头节点的迭代器,是遍历的起始位置
DLLIterator Begin() {
return DLLIterator(head_);
}
// End():返回尾后迭代器,代表链表最后一个元素的下一个位置。
// 本实现中,尾后迭代器内部指针置为 nullptr。
DLLIterator End() {
return DLLIterator(nullptr);
}
Node* head_{nullptr}; // 链表头节点指针
size_t size_; // 链表节点总数
};
// main 函数演示双向链表迭代器的使用方式
int main() {
// 创建双向链表,并插入多个数据
DLL dll;
dll.InsertAtHead(6);
dll.InsertAtHead(5);
dll.InsertAtHead(4);
dll.InsertAtHead(3);
dll.InsertAtHead(2);
dll.InsertAtHead(1);
// 分别使用前置自增、后置自增两种方式遍历链表
std::cout << "使用前置自增运算符遍历双向链表元素\n";
for (DLLIterator iter = dll.Begin(); iter != dll.End(); ++iter) {
std::cout << *iter << " ";
}
std::cout << std::endl;
std::cout << "使用后置自增运算符遍历双向链表元素\n";
for (DLLIterator iter = dll.Begin(); iter != dll.End(); iter++) {
std::cout << *iter << " ";
}
std::cout << std::endl;
return 0;
}
/*
Printing elements of the DLL dll via prefix increment operator
1 2 3 4 5 6
Printing elements of the DLL dll via postfix increment operator
1 2 3 4 5 6
*/
08 命名空间
如果abc和abc::def这两个命名空间中出现同名函数,不会出现报错。编译器区分函数靠完整命名空间路径 + 函数名,两个函数标识符完全不同,不存在重定义。
对于下面样例,如果B中也有eggs
namespace B {
void foo(int a) {}
void peloton(int a) {}
void eggs(int a) {} // B::eggs
}
namespace C {
void eggs(int a) {} // C::eggs
}
using namespace B;
using C::eggs;
using namespace B 让 B::eggs 全局可见;
using C::eggs 让 C::eggs 全局可见;
裸写 eggs 编译器分不清要调用 B::eggs 还是 C::eggs,产生二义性。
std的理解
注意:可能会有个问题,vector、memory、iostream 是不同头文件,为什么都能用 std::?原因如下:
-
std是 standard(标准) 的缩写,它是 C++ 标准库唯一的顶层命名空间。 -
每个头文件内部,都是把自己的内容写在
namespace std {}内部; -
头文件只是拆分代码方便管理,不改变它们归属同一个命名空间;
举个简化版头文件逻辑:
// <vector> 内部大致结构
namespace std {
template<typename T>
class vector { ... };
}
// <memory> 内部大致结构
namespace std {
template<typename T>
class unique_ptr { ... };
}
std有两种用法
- 在大工程项目中:每次都写
std::,不会产生命名冲突 - 小demo中:开头写
using namespace std;,后面直接简写
/**
* @file namespaces.cpp
* @author Abigale Kim (abigalek)
* @brief 命名空间使用教学代码
*/
// 命名空间为各类标识符(函数名、类型名、变量名)提供独立作用域。
// 它可以将代码按逻辑模块分组(例如一个第三方库整体放在一个命名空间内),
// 同时避免不同标识符之间出现命名冲突。
// 举个例子:C++标准库全部存放在 std 命名空间中,
// 所以打印输出对象写作 std::cout,因为 cout 定义在 std 命名空间里。
// C++ 使用 :: 作用域解析运算符,用来区分函数、类型、类分别属于哪个命名空间。
// 本文件会讲解命名空间的定义语法、using 关键字,以及如何调用其他命名空间内的函数。
// 引入 std::cout 用于打印演示
#include <iostream>
// 定义命名空间的基础语法
namespace ABC {
// 在 ABC 命名空间内定义函数 spam,后文第57行会调用它
void spam(int a) {
std::cout << "来自 ABC::spam 的输出:" << a << std::endl;
}
// DEF 是嵌套命名空间,定义在 ABC 内部。
// 嵌套命名空间的声明语法和普通命名空间完全一致。
namespace DEF {
// 在 ABC::DEF 命名空间内定义函数 bar
void bar(float a) {
std::cout << "来自 ABC::DEF::bar 的输出:" << a << std::endl;
}
// 在 ABC::DEF 命名空间内定义 uses_bar 函数。
// 由于 bar 和 uses_bar 处于同一个命名空间,
// 在 uses_bar 内部可以直接写 bar 调用,无需加完整命名空间前缀。
void uses_bar(float a) {
std::cout << "来自 uses_bar 的输出:";
bar(a);
}
// 在 ABC::DEF 命名空间内定义 uses_spam 函数。
// 如果要在 ABC::DEF 内部访问 ABC::spam,必须写完整限定名。
// 如果直接写 spam(a) 会编译报错,编译器找不到名为 spam 或 ABC::DEF::spam 的函数。
// 虽然所有函数都可以用完整限定名调用,但全部写全名会降低开发效率。
void uses_spam(int a) {
std::cout << "来自 uses_spam 的输出:";
ABC::spam(a);
// 可以取消下面这行注释测试,直接调用spam(a)会编译失败
// spam(a);
}
}
// 在 ABC 命名空间(不在 DEF 内部)定义 uses_DEF_bar 函数。
// bar 属于 ABC 下的 DEF 子命名空间,因此在 uses_DEF_bar 中需要写 DEF::bar 调用。
void uses_DEF_bar(float a) {
std::cout << "来自 uses_DEF_bar 的输出:";
DEF::bar(a);
}
}
// 命名空间 A 和 B 中都存在同名函数 foo,参数、返回值完全相同。
// 代码可以正常编译,因为两个foo的完整限定名不同:A::foo 和 B::foo。
namespace A {
void foo(int a) {
std::cout << "来自 A::foo 的输出:" << a << std::endl;
}
}
namespace B {
void foo(int a) {
std::cout << "来自 B::foo 的输出:" << a << std::endl;
}
void peloton(int a) {
std::cout << "来自 B::peloton 的输出:" << a << std::endl;
}
}
namespace C {
void eggs(int a) {
std::cout << "来自 C::eggs 的输出:" << a << std::endl;
}
}
// using 关键字第一种用法:将整个命名空间引入当前作用域。
// 这行代码把命名空间 B 引入当前作用域,在该行之后的代码里,
// B::foo 可以直接简写为 foo。main 函数中会演示两种写法。
using namespace B;
// using 关键字第二种用法:只引入命名空间中的某一个成员到当前作用域。
// 这行代码单独把 C::eggs 引入当前作用域,后续代码可以直接写 eggs 调用。
using C::eggs;
int main() {
// 下面调用spam函数。直接写 spam(2) 会报错,不存在全局spam,只有 ABC::spam。
ABC::spam(2);
// 调用嵌套命名空间中的 bar 函数
ABC::DEF::bar(4.45);
// 调用 uses_bar 函数,内部会先打印提示,再调用 ABC::DEF::bar
ABC::DEF::uses_bar(6.45);
// 调用 uses_spam 函数,内部会先打印提示,再调用 ABC::spam
ABC::DEF::uses_spam(37);
// 调用 uses_DEF_bar 函数,内部会先打印提示,再调用 ABC::DEF::bar
ABC::uses_DEF_bar(3.12);
// 下面看两个同名foo函数。A::foo 和 B::foo 参数完全一致,但限定名不同,因此可以共存。
A::foo(122);
B::foo(150);
// 第106行代码已经将整个B命名空间引入作用域,
// 访问B内所有内容都不需要加B::前缀,因此 B::foo 可直接简写为 foo。
foo(440);
// 同理,整个B命名空间被引入,B中的另一个函数 peloton 也可以直接简写调用。
peloton(721);
// 注意:using namespace 把整个命名空间全部引入是有风险的,
// 稍有不慎就会引发命名冲突,因此不推荐这么写。
// 更推荐只引入需要的单个成员,就像第111行的 using C::eggs。
// 这里我们直接简写 eggs 调用 C::eggs。
eggs(999);
return 0;
}
/*
Hello from ABC::spam: 2
Hello from ABC::DEF::bar: 4.45
Hello from uses_bar: Hello from ABC::DEF::bar: 6.45
Hello from uses_spam: Hello from ABC::spam: 37
Hello from uses_DEF_bar: Hello from ABC::DEF::bar: 3.12
Hello from A::foo: 122
Hello from B::foo: 150
Hello from B::foo: 440
Hello from B::peloton: 721
Hello from C::eggs: 999
*/
09 vectors
理解下面代码的只是补充与回顾
内联函数
在函数前加关键字 inline,即为内联函数:
编译器遇到调用该函数的地方,不会生成函数调用指令,而是直接把函数内部代码原地展开、嵌入到调用位置。
普通函数:调用时要执行「跳转、保存现场、参数压栈、返回恢复」等开销;
内联函数:直接替换代码,省去函数调用开销。
Lambda表达式
需要注意的是Java的Lambda和C++的Lambda的语法、底层实现、能力、限制几乎全都不一样
Java Lambda
可以根据下面来理解
我们知道,一个值可以赋值给一个变量,那我们想一下把一个函数也复制给一个变量
op = int op(int a, int b) { return a + b; }
如果函数返回类型、函数名、参数类型固定,可以进行简化
op = (a, b) { return a + b; }
再加个箭头就编程lambda表达式了
op = (a, b) -> { return a + b; }
关于具体如何实现函数返回类型、函数名、参数类型固定,可以用接口用抽象方法提前约定其值。因为需要自动识别,需要这个接口是有唯一的抽象方法。
// Java 函数式接口
interface Op {
int calc(int a, int b);
}
// lambda 本质是这个接口的实现类实例
Op op = (a,b) -> a + b;
-
必须存在一个只有一个抽象方法的接口(函数式接口);
-
编译器不会生成新类文件,运行时动态生成代理类;
-
捕获外部变量有强限制:只能捕获 final / effectively final 变量,不能修改外部局部变量。
C++ Lambda
完全不依赖接口、抽象类,编译器编译期直接生成一个匿名仿函数 struct:
// 这段lambda
auto op = [](int a, int b) { return a + b; };
// 编译器自动生成等价代码
struct __Lambda_xxxx {
int operator()(int a, int b) const {
return a + b;
}
};
__Lambda_xxxx op;
-
捕获的外部变量会直接变成这个结构体的成员;
-
无任何接口约束,不需要提前定义任何类型;
-
捕获灵活,值捕获、引用捕获、移动捕获全都支持,可以修改外部变量。
语法是:
//[捕获列表](参数) -> 返回类型 { 函数体 };
auto f = [](int a) -> int { return a + 1; };
很多时候,lambda 表达式的返回值是非常明显的,比如这个例子。因此,C++11 中允许省略 lambda 表达式的返回值定义:
auto f = [](int a){ return a + 1; };
这样编译器就会根据 return 语句自动推导出返回值类 型。
需要注意的是,初始化列表不能用于返回值的自动推导:
auto x1 = [](int i){ return i; }; // OK: return type is int
auto x2 = [](){ return { 1, 2 }; }; // error: 无法推导出返回值类型
另外,lambda 表达式在没有参数列表时,参数列表是可以省略的。
auto f1 = [](){ return 1; };
auto f2 = []{ return 1; }; // 省略空参数表
lambda 表达式的捕获列表精细地控制了 lambda 表达式能够访问的外部变量,以及如何访问这些变量。
- [] 不捕获任何变量。
- [&] 捕获外部作用域中所有变量,并作为引用在函数体中使用(按引用捕获)。
- [=] 捕获外部作用域中所有变量,并作为副本在函数体中使用(按值捕获)。
- [=,&foo] 按值捕获外部作用域中所有变量,并按引用捕获 foo 变量。
- [bar] 按值捕获 bar 变量,同时不捕获其他变量。
- [this] 捕获当前类中的 this 指针,让 lambda 表达式拥有和当前类成员函数同样的访问权限。如果已经使用了 & 或者 =,就默认添加此选项。捕获 this 的目的是可以在 lamda 中使用当前类的成员函数和成员变量。
更多的lambda知识见https://blog.csdn.net/weixin_62671257/article/details/127813581
/**
* @file vectors.cpp
* @author Abigale Kim (abigalek)
* @brief C++标准库(STL) vector 容器教学代码
*/
// C++ STL 提供了容器库,里面包含各类通用数据结构与算法实现,
// 让开发者可以便捷使用栈、队列、哈希表等数据结构。
// 每种容器都有独立头文件与使用规范。截至 C++23 标准,STL 一共有 20 种容器,
// 无法在此全部讲解。本文件重点介绍 std::vector。
// std::vector 本质是通用动态数组(长度可自动扩容的数组)。
// 不会讲解 vector 全部接口,但会覆盖最常用基础用法。
// 所有容器完整接口文档可查阅:https://en.cppreference.com/w/cpp/container
// 完成课程作业时一定会用到这份参考文档,建议你收藏查看!
// 引入 std::remove_if,用于筛选并删除 vector 内元素
#include <algorithm>
// 引入 std::cout 用于控制台打印演示
#include <iostream>
// 引入 vector 容器头文件
#include <vector>
// 基础坐标点类,后文会使用
class Point {
public:
// 默认构造函数
Point() : x_(0), y_(0) {
std::cout << "调用 Point 类默认构造函数\n";
}
// 自定义带参构造函数
Point(int x, int y) : x_(x), y_(y) {
std::cout << "调用 Point 类自定义构造函数\n";
}
// 内联函数,获取 x 坐标,const 代表不修改对象
inline int GetX() const { return x_; }
inline int GetY() const { return y_; }
inline void SetX(int x) { x_ = x; }
inline void SetY(int y) { y_ = y; }
// 打印当前点坐标,const 保证只读访问
void PrintPoint() const {
std::cout << "当前点坐标:(" << x_ << ", " << y_ << ")\n";
}
private:
int x_;
int y_;
};
// 工具函数:打印存储 int 的 vector 所有元素
// 实现逻辑和 main 中遍历 vector 的代码思路一致,便于理解
void print_int_vector(const std::vector<int> &vec) {
for (const int &elem : vec) {
std::cout << elem << " ";
}
std::cout << "\n";
}
int main() {
// 声明存储 Point 对象的 vector
std::vector<Point> point_vector;
// 也可以通过初始化列表直接给 vector 赋初始值
std::vector<int> int_vector = {0, 1, 2, 3, 4, 5, 6};
// 向 vector 尾部追加元素有两个接口:push_back、emplace_back
// emplace_back 性能通常略高:直接把构造参数转发给对象(如Point对象)构造函数,在 vector 内存原地构造对象;
// push_back 是先创建完整对象,再移动拷贝到 vector 内存中。
// 下面演示两种方式向 vector 添加 Point 对象
std::cout << "使用 push_back 向 point_vector 尾部添加元素:\n";
point_vector.push_back(Point(35, 36));
std::cout << "使用 emplace_back 向 point_vector 尾部添加元素:\n";
point_vector.emplace_back(37, 38);
// 继续向 point_vector 尾部新增更多元素
point_vector.emplace_back(39, 40);
point_vector.emplace_back(41, 42);
// 遍历 vector 有多种方式,第一种:通过下标索引循环遍历
// 规范写法:数组/vector 下标建议使用无符号类型 size_t
std::cout << "遍历打印 point_vector 内所有点:\n";
for (size_t i = 0; i < point_vector.size(); ++i) {
point_vector[i].PrintPoint();
}
// 第二种:范围 for 循环(foreach)遍历
// 这里使用引用 & 遍历,操作的是 vector 内部原始对象;
// 如果使用引用遍历元素,还可以修改 vector 里的数据
for (Point &item : point_vector) {
item.SetY(445);
}
// 验证修改是否生效,这里使用 const 引用,保证只读访问,无法修改元素
for (const Point &item : point_vector) {
item.PrintPoint();
}
// 下面演示 vector 删除元素的用法。第一种:通过 erase + 迭代器按位置删除
// 例如要删除 int_vector[2],调用 erase 时需要传入迭代器参数 std::vector<int>::iterator
// STL 容器迭代器是指向容器内元素的对象。int_vector.begin() 是指向容器第一个元素的迭代器。
// vector 迭代器支持加法运算:迭代器 + 整数,代表向后偏移对应个位置。
// int_vector.begin() + 2 等价指向第三个元素,也就是下标为 2 的元素。
// 如果你对迭代器不熟悉,可以查看 iterator.cpp 教程文件。
int_vector.erase(int_vector.begin() + 2);
std::cout << "删除下标2的元素(数值2)后,打印 int_vector:\n";
print_int_vector(int_vector);
// erase 也支持区间删除:删除从起始迭代器到结束迭代器之间的所有元素
// 例:删除下标1到末尾全部元素。int_vector.end() 是尾后迭代器,
// 它不指向任何有效元素,仅标记容器末尾,不能解引用取值。
int_vector.erase(int_vector.begin() + 1, int_vector.end());
std::cout << "删除下标1至末尾所有元素后,打印 int_vector:\n";
print_int_vector(int_vector);
// 第二种删除方式:按条件筛选删除。
// 需要引入 algorithm 头文件,使用 std::remove_if 函数,
// 它会在迭代器区间内筛选出满足条件的元素。整套写法看起来复杂,拆解如下:
// std::remove_if 接收三个参数:
// 1、2:区间首尾迭代器 point_vector.begin()、point_vector.end(),代表操作整个 vector;
// 3:lambda 条件表达式,接收一个容器元素,返回布尔值;
// 返回 true 代表该元素需要被移除,false 保留。
// std::remove_if 的返回值是一个迭代器,指向第一个待删除元素的起始位置。
// 它内部会交换元素,把所有要删除的元素统一挪到容器后半段;
// 再调用 erase 区间删除,一次性清空后半段待删除元素。
// 外层 erase 接收区间迭代器,和前文区间删除用法一致。
point_vector.erase(
std::remove_if(point_vector.begin(), point_vector.end(),
[](const Point &point) { return point.GetX() == 37; }),
point_vector.end());
// 筛选删除完成后,vector 剩余3个元素,坐标(37,445)已被移除
std::cout << "删除坐标为(37, 445)的点后,打印 point_vector:\n";
for (const Point &item : point_vector) {
item.PrintPoint();
}
// auto.cpp 文件会讲解更简洁易读的容器遍历写法,感兴趣可以去看!
return 0;
}
/*
Appending to the point_vector via push_back:
Custom constructor for the Point class is called.
Appending to the point_vector via emplace_back:
Custom constructor for the Point class is called.
Custom constructor for the Point class is called.
Custom constructor for the Point class is called.
Printing the items in point_vector:
Point value is (35, 36)
Point value is (37, 38)
Point value is (39, 40)
Point value is (41, 42)
Point value is (35, 445)
Point value is (37, 445)
Point value is (39, 445)
Point value is (41, 445)
Printing the elements of int_vector after erasing int_vector[2] (which is 2)
0 1 3 4 5 6
Printing the elements of int_vector after erasing all elements from index 1 through the end
0
Printing the point_vector after (37, 445) is erased:
Point value is (35, 445)
Point value is (39, 445)
Point value is (41, 445)
*/
10 set
/**
* @file sets.cpp
* @author Abigale Kim (abigalek)
* @brief C++标准库(STL)set 容器教学代码
*/
// 本文件将介绍 std::set 容器。不会讲解该容器的全部接口,仅覆盖基础使用方法。
// 关于 STL 容器的整体概述,可以查看 vectors.cpp 文件开头注释。
// std::set 是一种数据结构,内部存储一组**有序、唯一**的同类型元素。
// 它底层通常由红黑树实现,你可以借助红黑树的概念理解 set 的特性。
// std::set 适用于需要保存不重复元素集合的场景。
// 所有容器与接口的完整文档:https://en.cppreference.com/w/cpp/container
// 完成课程作业时一定会用到这份参考文档,建议你查阅学习!
// 引入 std::cout 用于打印演示
#include <iostream>
// 引入 set 容器头文件
#include <set>
int main() {
// 声明存储 int 类型的 set 容器
std::set<int> int_set;
// 使用 insert 函数向容器插入元素,这里循环插入 1~10。
// 还有 emplace 函数,它可以直接在容器内存原地构造对象,用于插入元素。
// emplace 的详细介绍可以看 vectors.cpp 第73行注释。
for (int i = 1; i <= 5; ++i) {
int_set.insert(i);
}
for (int i = 6; i <= 10; ++i) {
int_set.emplace(i);
}
// 查找元素可以使用 find 函数,它会返回一个迭代器,指向集合中匹配传入键值的元素。
// 我们可以判断返回的迭代器是否等于 end() 尾后迭代器:
// 如果相等,代表该元素不存在。
std::set<int>::iterator search = int_set.find(2);
if (search != int_set.end()) {
std::cout << "元素 2 存在于 int_set 中\n";
}
// 也可以使用 count 函数,它会返回集合中等于指定键值的元素数量。
if (int_set.count(11) == 0) {
std::cout << "元素 11 不在集合内\n";
}
if (int_set.count(3) == 1) {
std::cout << "元素 3 存在于集合内\n";
}
// 删除元素可以使用 erase 函数,erase 支持多种传参方式:
// 第一种:直接传入要删除的键值。例如删除数字4:
int_set.erase(4);
// 验证4已经被删除
if (int_set.count(4) == 0) {
std::cout << "元素 4 不在集合内\n";
}
// 第二种:传入迭代器,删除迭代器指向位置的元素。
// 例如删除集合第一个元素,把 begin() 迭代器传给 erase:
int_set.erase(int_set.begin());
// 验证1已经被删除
if (int_set.count(1) == 0) {
std::cout << "元素 1 不在集合内\n";
}
// 第三种:传入一段迭代器区间,批量删除区间内所有元素。
// 例如删除大于等于9的元素(9和10):
int_set.erase(int_set.find(9), int_set.end());
// 验证9、10已被删除
if (int_set.count(9) == 0 && int_set.count(10) == 0) {
std::cout << "元素 9 和 10 不在集合内\n";
}
// 只能通过迭代器遍历 set,set 不支持下标索引访问。
std::cout << "通过迭代器打印集合所有元素:\n";
for (std::set<int>::iterator it = int_set.begin(); it != int_set.end();
++it) {
// 解引用迭代器即可获取当前元素
std::cout << *it << " ";
}
std::cout << "\n";
// 和 std::vector 一样,set 也支持范围 for 循环(foreach)遍历
std::cout << "使用范围for循环打印集合所有元素:\n";
for (const int &elem : int_set) {
std::cout << elem << " ";
}
std::cout << "\n";
// auto.cpp 文件会讲解更简洁、可读性更高的容器遍历写法,感兴趣可以查看!
return 0;
}
/*
Element 2 is in int_set.
Element 11 is not in the set.
Element 3 is in the set.
Element 4 is not in the set.
Element 1 is not in the set.
Elements 9 and 10 are not in the set.
Printing the elements of the iterator:
2 3 5 6 7 8
Printing the elements of the iterator with a for-each loop:
2 3 5 6 7 8
*/
11 unordered_map
/**
* @file unordered_maps.cpp
* @author Abigale Kim (abigalek)
* @brief C++标准库(STL)unordered_map 哈希表容器教学代码
*/
// 本文件介绍 std::unordered_map 容器。不会覆盖该容器全部接口,仅讲解基础核心用法。
// 关于 STL 容器整体介绍,可以查看 vectors.cpp 文件开头注释。
// 所有容器完整接口文档地址:https://en.cppreference.com/w/cpp/container
// 完成课程作业时一定会用到这份参考文档,建议你查阅学习!
// 引入 std::cout 用于控制台打印演示
#include <iostream>
// 引入 unordered_map 容器头文件
#include <unordered_map>
// 引入 C++ 标准字符串库
#include <string>
// 引入 std::make_pair 工具函数
#include <utility>
int main() {
// std::unordered_map 存储键值对,其中键是唯一不重复的。
// 本质上,它就是代码里可用的哈希表。
// 声明一个键为 string、值为 int 的无序哈希表
std::unordered_map<std::string, int> map;
// insert 函数用于向哈希表插入键值对
map.insert({"foo", 2});
// insert 也接收 std::pair 作为参数。
// std::pair 是通用二元对类型,调用 std::make_pair 传入两个参数即可创建。
// std::make_pair 定义在 <utility> 头文件中,用于构造通用 pair 对象。
map.insert(std::make_pair("jignesh", 445));
// 也可以一次性传入初始化列表,批量插入多个键值对
map.insert({{"spam", 1}, {"eggs", 2}, {"garlic rice", 3}});
// 还可以使用数组下标语法插入元素,即使这个键原本不存在也能直接创建
map["bacon"] = 5;
// 同样的下标语法,也可以修改已有键对应的数值
map["spam"] = 15;
// find 函数用于查找哈希表内的键。
// 如果键存在,返回指向该键值对的迭代器;不存在则返回容器尾后迭代器 end()。
std::unordered_map<std::string, int>::iterator result = map.find("jignesh");
if (result != map.end()) {
// 第一种通过迭代器访问键值对的方式:使用 -> 访问成员
std::cout << "找到键:" << result->first << ",对应值:"
<< result->second << std::endl;
// 第二种方式:对迭代器解引用,得到完整 pair 对象再访问
std::pair<std::string, int> pair = *result;
std::cout << "解引用方式:找到键:" << pair.first << ",对应值:"
<< pair.second << std::endl;
}
// count 函数返回哈希表中匹配指定键的元素数量。
// 因为键唯一,所以返回值只能是 0(不存在)或 1(存在)。
size_t count = map.count("spam");
if (count == 1) {
std::cout
<< "哈希表中存在键为 spam 的键值对。\n";
}
// erase 函数用于删除哈希表内数据,第一种用法:直接传入要删除的键
map.erase("eggs");
// 验证 eggs 这条键值对已被删除
if (map.count("eggs") == 0) {
std::cout << "哈希表中不存在键为 eggs 的键值对。\n";
}
// 第二种删除方式:传入迭代器,删除迭代器指向的元素。
// 下面代码删除键为 "garlic rice" 的条目。
// std::next 是迭代器工具函数,返回传入迭代器的下一个迭代器。
map.erase(map.find("garlic rice"));
// 验证 garlic rice 这条键值对已被删除
if (map.count("garlic rice") == 0) {
std::cout << "哈希表中不存在键为 garlic rice 的键值对。\n";
}
// 只能通过迭代器遍历 unordered_map,它不支持下标数字索引访问
std::cout << "使用迭代器打印哈希表所有元素:\n";
for (std::unordered_map<std::string, int>::iterator it = map.begin();
it != map.end(); ++it) {
// 解引用迭代器就能拿到完整键值对
std::cout << "(" << it->first << ", " << it->second << "), ";
}
std::cout << "\n";
// 和 std::vector 一样,unordered_map 也支持范围 for 循环遍历
std::cout << "使用范围for循环打印哈希表所有元素:\n";
for (const std::pair<const std::string, int> &elem : map) {
std::cout << "(" << elem.first << ", " << elem.second << "), ";
}
std::cout << "\n";
// auto.cpp 文件会讲解更简洁、可读性更高的容器遍历写法,感兴趣可以查看!
return 0;
}
/*
Found key jignesh with value 445
DEREF: Found key jignesh with value 445
A key-value pair with key spam exists in the unordered map.
Key-value pair with key eggs does not exist in the unordered map.
Key-value pair with key garlic rice does not exist in the unordered map.
Printing the elements of the iterator:
(bacon, 5), (spam, 15), (jignesh, 445), (foo, 2),
Printing the elements of the iterator with a for-each loop:
(bacon, 5), (spam, 15), (jignesh, 445), (foo, 2),
*/
12 auto
/**
* @file auto.cpp
* @author Abigale Kim (abigalek)
* @brief auto 关键字使用教学代码
*/
// 引入 std::cout 用于打印演示
#include <iostream>
// 引入 std::set 容器头文件
#include <set>
// 引入 C++ 标准字符串库
#include <string>
// 引入 std::vector 动态数组容器头文件
#include <vector>
// 引入 std::unordered_map 哈希表容器头文件
#include <unordered_map>
// C++ 的 auto 关键字作用:让编译器通过变量的初始化表达式自动推导变量类型。
// 它能极大提升开发效率,开发者无需手写冗长、复杂的长类型名。
// 在范围for循环中使用 auto 也十分便捷。
// 但使用 auto 存在隐患:开发者可能不清楚实际推导出来的类型,
// 容易写出存在 bug、不符合预期的代码,使用时务必多加留意!
// 基础模板类,类名极其冗长,用来演示 auto 的优势
template <typename T, typename U> class Abcdefghijklmnopqrstuvwxyz {
public:
Abcdefghijklmnopqrstuvwxyz(T instance1, U instance2)
: instance1_(instance1), instance2_(instance2) {}
void print() const {
std::cout << "(" << instance1_ << "," << instance2_ << ")\n";
}
private:
T instance1_;
U instance2_;
};
// 模板函数,返回上面这个超长类的对象
template <typename T>
Abcdefghijklmnopqrstuvwxyz<T, T> construct_obj(T instance) {
return Abcdefghijklmnopqrstuvwxyz<T, T>(instance, instance);
}
int main() {
// 使用 auto 定义变量 a,编译器会自动推导类型为 int
auto a = 1;
// 更多基础类型使用 auto 的示例
// 大部分 IDE 会提示 a、b、c 实际推导出来的类型
auto b = 3.2;
auto c = std::string("Hello");
// 对于上面这些简单类型,auto 优势不明显。
// 直接写 int a = 1; float b = 3.2; std::string c = "Hello"; 并不会增加多少代码量。
// 但如果类型名称很长、嵌套多层模板时,auto 的便捷性就会体现出来。
Abcdefghijklmnopqrstuvwxyz<int, int> obj = construct_obj<int>(2);
auto obj1 = construct_obj<int>(2);
// 只看一行代码差距不大,但如果代码中要频繁使用这个超长类,
// auto 能省去大量重复书写类型名的工作。
// auto 有一个重要特性:默认会推导成值类型,产生拷贝,可能带来性能损耗。
// 下面示例创建一个 int 数组 vector,我们想要定义一个引用指向它。
std::vector<int> int_values = {1, 2, 3, 4};
// 下面代码会完整深拷贝 int_values 到 copy_int_values,
// auto 推导出来的类型是 std::vector<int>,而不是引用 std::vector<int>&
auto copy_int_values = int_values;
// 如果想要引用,需要写成 auto&,此时 ref_int_values 是原 vector 的引用,不会发生拷贝
auto& ref_int_values = int_values;
// auto 遍历 STL 容器也非常好用。
// 下面创建一个 key 为字符串、value 为 int 的哈希表,对比两种遍历写法
std::unordered_map<std::string, int> map;
map.insert({{"andy", 445}, {"jignesh", 645}});
// unordered_map.cpp 里演示过传统迭代器循环,对比下面两种写法的简洁度
std::cout << "遍历打印 map 所有元素(传统迭代器写法):\n";
for (std::unordered_map<std::string, int>::iterator it = map.begin();
it != map.end(); ++it) {
std::cout << "(" << it->first << "," << it->second << ")"
<< " ";
}
std::cout << std::endl;
std::cout << "使用 auto 迭代器遍历打印 map:\n";
for (auto it = map.begin(); it != map.end(); ++it) {
std::cout << "(" << it->first << "," << it->second << ")"
<< " ";
}
std::cout << std::endl;
// vector、set 同样可以用 auto 配合范围for循环遍历
std::vector<int> vec = {1, 2, 3, 4};
std::cout << "使用 auto 遍历 vector:\n";
for (const auto& elem : vec) {
std::cout << elem << " ";
}
std::cout << std::endl;
std::set<int> set;
for (int i = 1; i <= 10; ++i) {
set.insert(i);
}
std::cout << "使用 auto 遍历 set:\n";
for (const auto &elem : set) {
std::cout << elem << " ";
}
std::cout << std::endl;
// 总结:auto 是实用关键字,能简化代码、提升可读性、提高编码效率。
// 实际开发中推荐用 auto 遍历容器,代码更简洁清晰。
// 但如果你无法确定 auto 推导出来的实际类型,也可以手动写出完整类型,不用强行使用 auto。
return 0;
}
/*
Printing elements in map...
(jignesh,645) (andy,445)
Printing elements in map with auto...
(jignesh,645) (andy,445)
Printing elements in vector with auto...
1 2 3 4
Printing elements in set with auto...
1 2 3 4 5 6 7 8 9 10
*/
13 unique_ptr
/**
* @file unique_ptr.cpp
* @author Abigale Kim (abigalek)
* @brief std::unique_ptr 独占智能指针使用教学代码
*/
// 智能指针是用于内存管理的数据结构(还附带一些拓展功能),专门用于 C++ 这类没有内置垃圾回收机制的语言。
// Java、Python 这类自带垃圾回收的语言则不需要手动管理内存。
// C++11 标准库提供两种主流智能指针,也是课程中会用到的:std::unique_ptr 和 std::shared_ptr。
// 两者都能自动管理内存的分配与释放,底层封装了原生裸指针,本质是裸指针的包装类。
// 本文件讲解 std::unique_ptr:它是独占型智能指针,对管理的对象拥有唯一所有权。
// 也就是说,同一个堆对象不能同时被两个 unique_ptr 管理。
// 引入 std::cout 用于打印演示
#include <iostream>
// 智能指针头文件,提供 std::unique_ptr
#include <memory>
// 字符串库,辅助打印输出
#include <string>
// utility 头文件,用于 std::move
#include <utility>
// 基础坐标点类,下文代码会使用
class Point {
public:
Point() : x_(0), y_(0) {}
Point(int x, int y) : x_(x), y_(y) {}
inline int GetX() { return x_; }
inline int GetY() { return y_; }
inline void SetX(int x) { x_ = x; }
inline void SetY(int y) { y_ = y; }
private:
int x_;
int y_;
};
// 接收 unique_ptr 的引用,修改内部 Point 的 x 坐标为 445
void SetXTo445(std::unique_ptr<Point> &ptr) { ptr->SetX(445); }
int main() {
// 方式1:创建空的 std::unique_ptr<Point>,不管理任何堆内存
std::unique_ptr<Point> u1;
// 方式2:使用默认构造函数创建 Point 对象,交给 unique_ptr 管理
std::unique_ptr<Point> u2 = std::make_unique<Point>();
// 方式3:调用自定义构造函数创建 Point 对象,交给 unique_ptr 管理
std::unique_ptr<Point> u3 = std::make_unique<Point>(2, 3);
// unique_ptr 内置布尔类型转换运算符,因此可以直接在布尔判断中使用。
// 如果智能指针持有有效对象,转换为 true;空指针则为 false。
// 下面代码通过三元表达式判断指针是否持有数据。
if (u1) {
// u1 是空指针,这段代码不会执行
std::cout << "u1 存储的 x 坐标:" << u1->GetX() << std::endl;
}
if (u2) {
// u2 持有有效的 Point 对象,代码会正常执行打印
std::cout << "u2 存储的 x 坐标:" << u2->GetX() << std::endl;
}
// u1 为空,u2、u3 通过 make_unique 创建,持有有效对象
std::cout << "指针 u1 状态:" << (u1 ? "持有对象" : "空指针") << std::endl;
std::cout << "指针 u2 状态:" << (u2 ? "持有对象" : "空指针") << std::endl;
std::cout << "指针 u3 状态:" << (u3 ? "持有对象" : "空指针") << std::endl;
// unique_ptr 只允许唯一所有者,因此删除了拷贝构造函数,下面代码无法编译。
// 取消注释即可测试编译报错
// std::unique_ptr<Point> u4 = u3;
// 可以通过 std::move 转移堆对象的所有权
std::unique_ptr<Point> u4 = std::move(u3);
// u3 原本是左值,经过 move 转移所有权后,u3 变为空智能指针,不再管理任何对象。
// 重新检测两者状态
std::cout << "指针 u3 状态:" << (u3 ? "持有对象" : "空指针") << std::endl;
std::cout << "指针 u4 状态:" << (u4 ? "持有对象" : "空指针") << std::endl;
// 最后讲解 unique_ptr 作为函数参数的传参规范:
// 推荐传入引用,避免所有权发生转移。示例见第44行的 SetXTo445 函数。
SetXTo445(u4);
// 打印 u4 的 x 坐标验证修改生效,同时所有权仍然保留在 u4 手中
std::cout << "指针 u4 存储的 x 坐标:" << u4->GetX() << std::endl;
return 0;
}
/*
u2's value of x is 0
Pointer u1 is empty
Pointer u2 is not empty
Pointer u3 is not empty
Pointer u3 is empty
Pointer u4 is not empty
Pointer u4's x value is 445
*/
14 share_ptr
std::shared_ptr 管理的堆内存对象(资源)从头到尾只有一份,不会因为拷贝、值传递复制出新资源。所有 shared_ptr 实例都指向同一块堆内存。
-
拷贝 / 值传递只会新增 shared_ptr 实例,只修改引用计数。
-
std::move 转移所有权,计数不变。
-
和 unique_ptr 最大区别:shared_ptr 支持值传递。
/**
* @file shared_ptr.cpp
* @author Abigale Kim (abigalek)
* @brief std::shared_ptr 共享智能指针使用教学代码
*/
// 本文件讲解 std::shared_ptr,C++ 的一种智能指针。
// 智能指针基础介绍可以查看 unique_ptr.cpp 文件开头注释。
// std::shared_ptr 是共享所有权的智能指针:多个 shared_ptr 可以同时管理同一个堆对象,
// 并且 shared_ptr 支持拷贝操作。
// 引入 std::cout 用于打印演示
#include <iostream>
// 引入 std::shared_ptr 智能指针功能头文件
#include <memory>
// utility 头文件,用于 std::move
#include <utility>
// 基础坐标点类,下文代码会使用
class Point {
public:
Point() : x_(0), y_(0) {}
Point(int x, int y) : x_(x), y_(y) {}
inline int GetX() { return x_; }
inline int GetY() { return y_; }
inline void SetX(int x) { x_ = x; }
inline void SetY(int y) { y_ = y; }
private:
int x_;
};
// 接收 shared_ptr 左值引用,修改内部 Point 的坐标
void modify_ptr_via_ref(std::shared_ptr<Point> &point) { point->SetX(15); }
// 接收 shared_ptr 右值引用,修改内部 Point 的坐标
void modify_ptr_via_rvalue_ref(std::shared_ptr<Point> &&point) {
point->SetY(645);
}
// 函数参数为值传递,会拷贝一份 shared_ptr,内部打印引用计数
void copy_shared_ptr_in_function(std::shared_ptr<Point> point) {
std::cout << "当前共享指针引用计数:" << point.use_count()
<< std::endl;
}
int main() {
// 创建空的 std::shared_ptr<Point>,不管理任何堆对象
std::shared_ptr<Point> s1;
// 使用默认构造函数创建 Point,交由 shared_ptr 管理
std::shared_ptr<Point> s2 = std::make_shared<Point>();
// 调用自定义构造函数创建 Point,交由 shared_ptr 管理
std::shared_ptr<Point> s3 = std::make_shared<Point>(2, 3);
// 判断智能指针是否为空的写法在 unique_ptr.cpp 第56行有讲解。
// s1 是空指针,s2、s3 持有有效对象。
std::cout << "指针 s1 状态:" << (s1 ? "持有对象" : "空指针") << std::endl;
std::cout << "指针 s2 状态:" << (s2 ? "持有对象" : "空指针") << std::endl;
std::cout << "指针 s3 状态:" << (s3 ? "持有对象" : "空指针") << std::endl;
// shared_ptr 支持拷贝构造、拷贝赋值,拷贝时会增加底层对象的引用计数。
// shared_ptr 提供 use_count() 方法,用于统计当前有多少个 shared_ptr 实例共享同一份堆数据。
// 初始时只有 s3 管理这份数据,引用计数为 1
std::cout
<< "当前共享 s3 所管理数据的指针实例数量:"
<< s3.use_count() << std::endl;
// 通过拷贝构造创建 s4,复制 s3
std::shared_ptr<Point> s4 = s3;
// 现在 s3、s4 同时管理同一份数据,引用计数变为2
std::cout << "完成一次拷贝后,共享 s3 数据的指针实例数量:"
<< s3.use_count() << std::endl;
// 再通过拷贝构造创建 s5,复制 s4
std::shared_ptr<Point> s5(s4);
// s3、s4、s5 三份指针共享数据,引用计数变为3
std::cout << "完成两次拷贝后,共享 s3 数据的指针实例数量:"
<< s3.use_count() << std::endl;
// 修改 s3 内部的对象,s4、s5 读取到的值也会同步变化,因为三者指向同一个堆对象
s3->SetX(445);
std::cout << "s3 中的 x 值:" << s3->GetX() << std::endl;
std::cout << "s4 中的 x 值:" << s4->GetX() << std::endl;
std::cout << "s5 中的 x 值:" << s5->GetX() << std::endl;
// shared_ptr 同样支持 std::move 转移所有权,move 后原指针变为空
std::shared_ptr<Point> s6 = std::move(s5);
// move 之后 s5 为空;s6 接管资源,但引用计数不会新增,仍然是3份持有者
std::cout << "指针 s5 状态:" << (s5 ? "持有对象" : "空指针") << std::endl;
std::cout << "两次拷贝+一次move后,共享 s3 数据的指针实例数量:"
<< s3.use_count() << std::endl;
// 和 unique_ptr 类似,shared_ptr 可以左值引用、右值引用传参。
// unique_ptr 引用传参见 unique_ptr.cpp 第89行;
// 普通引用相关知识看 references.cpp;右值引用、移动语义看 move_semantics.cpp。
// 下面代码分别以左值引用、右值引用方式传入 s2,修改内部 Point
modify_ptr_via_ref(s2);
modify_ptr_via_rvalue_ref(std::move(s2));
// 执行完毕后,s2 内部对象 x=15,y=645
std::cout << "指针 s2 存储的 x=" << s2->GetX() << ",y=" << s2->GetY()
<< std::endl;
// 和 unique_ptr 最大区别:shared_ptr 支持值传递。
// 值传递会在函数内生成一份 shared_ptr 副本,函数执行结束后副本自动销毁。
// 本例中传入前 s2 引用计数为1;进入函数后副本产生,计数变为2;
// 函数执行完毕,副本销毁,计数回落至1。
std::cout
<< "共享 s2 所管理数据的指针实例初始数量:"
<< s2.use_count() << std::endl;
copy_shared_ptr_in_function(s2);
std::cout << "调用 copy_shared_ptr_in_function 后,共享 s2 数据的指针实例数量:"
<< s2.use_count() << std::endl;
return 0;
}
/*
Pointer s1 is empty
Pointer s2 is not empty
Pointer s3 is not empty
Number of shared pointer object instances using the data in s3: 1
Number of shared pointer object instances using the data in s3 after one copy: 2
Number of shared pointer object instances using the data in s3 after two copies: 3
Printing x in s3: 445
Printing x in s4: 445
Printing x in s5: 445
Pointer s5 is empty
Number of shared pointer object instances using the data in s3 after two copies and a move: 3
Pointer s2 has x=15 and y=645
Number of shared pointer object instances using the data in s2: 1
Use count of shared pointer is 2
Number of shared pointer object instances using the data in s2 after calling copy_shared_ptr_in_function: 1
*/
15 mutex
线程
-
创建并启动线程的代码
std::thread t1(add_count);执行这一行时:
-
操作系统创建新线程;
-
立刻后台并行运行
add_count函数;
线程在这里就已经启动执行了。
-
-
join () 的作用:等待线程结束
t1.join(); t2.join();join()是阻塞等待函数:- 主线程执行到
t1.join()时卡住不动,一直等到 t1 线程全部运行完毕、销毁; - t1 结束后,再执行
t2.join(),继续等待 t2 线程执行完成; - 两个线程都跑完,主线程才继续往下执行打印代码。
- 主线程执行到
/**
* @file mutex.cpp
* @author Abigale Kim (abigalek)
* @brief C++标准库 std::mutex 互斥锁教学代码
*/
// 本程序简单演示 std::mutex 的使用方式,std::mutex 是互斥同步原语。
// 引入 std::cout 用于打印演示
#include <iostream>
// 引入互斥锁头文件
#include <mutex>
// 引入线程库头文件
#include <thread>
// 定义全局计数变量,以及供两个线程共用的互斥锁
int count = 0;
// 声明并默认初始化一个互斥锁的标准写法
std::mutex m;
// add_count 函数:让线程安全地将全局count变量自增1,保证操作原子性
void add_count() {
// 访问共享资源count前,先加锁
m.lock();
count += 1;
// 访问完毕共享资源后,释放锁
m.unlock();
}
// main 函数创建两个线程,并行执行 add_count 函数。
// 两个线程执行完毕后打印count值,验证两次自增操作都能正确生效。
// std::thread 是 C++ STL 的线程库,用来创建线程,
// 可以把它看作 C 语言 pthread 库的 C++ 版本。
int main() {
std::thread t1(add_count);
std::thread t2(add_count);
std::cout << "Printing count: " << count << std::endl;
t1.join();
t2.join();
std::cout << "打印count的值:" << count << std::endl;
return 0;
}
// Printing count: 2
// Printing count: 2
// 第一个输出可能是0,1,2
16 scoped_lock
/**
* @file scoped_lock.cpp
* @author Abigale Kim (abigalek)
* @brief C++标准库 std::scoped_lock 作用域锁教学代码
*/
// 本程序简单演示 std::scoped_lock 的用法。
// std::scoped_lock 是互斥锁包装类,基于 RAII 机制自动加锁、释放锁。
// 含义:创建该对象时自动获取锁;对象销毁时自动释放锁。
// 引入 std::cout 用于打印演示
#include <iostream>
// 引入互斥锁头文件
#include <mutex>
// 引入线程库头文件
#include <thread>
// 定义全局计数变量,以及两个线程共用的互斥锁
int count = 0;
std::mutex m;
// add_count 函数:线程安全地将全局count自增1,保证操作原子性
void add_count() {
// std::scoped_lock 构造函数会自动获取互斥锁 m
std::scoped_lock slk(m);
count += 1;
// 当 add_count 函数执行完毕,slk 对象离开作用域,触发析构函数,自动释放互斥锁 m
}
// main 函数逻辑和 mutex.cpp 完全一致:创建两个线程并行执行 add_count,
// 线程全部执行完毕后打印 count 的最终结果。
int main() {
std::thread t1(add_count);
std::thread t2(add_count);
t1.join();
t2.join();
std::cout << "打印count的值:" << count << std::endl;
return 0;
}
// Printing count: 2
17 condition_variable和unique_lock
条件变量必须用 unique_lock
根本原因:wait 内部会自动解锁、加锁,而 scoped_lock 不支持中途手动释放锁。
执行 wait 时底层三步:
- 自动释放 lk 持有的互斥锁 m,其他线程可以拿到锁修改共享变量 count;
- 当前线程阻塞休眠,等待别的线程调用
notify_one/notify_all唤醒; - 收到唤醒信号后,重新自动获取锁 m,再去判断条件是否成立。
关键需求是锁对象必须支持中途解锁、后续重新上锁。
std::unique_lock与std::scoped_lock完整区别
| 对比维度 | std::scoped_lock | std::unique_lock |
|---|---|---|
| 头文件 | <mutex> |
<mutex> |
| 支持锁数量 | 支持多锁同时上锁scoped_lock(m1, m2, m3) |
仅能管理单个互斥锁 |
| 手动锁操作 | 完全不支持:无 lock() / unlock()作用域内锁全程持有,无法中途释放 |
支持手动控制:lock() / unlock() / try_lock()可中途释放、重新加锁 |
| 移动语义 | 不可移动、不可拷贝 | 不可拷贝,但支持 std::move 转移锁所有权 |
| 延迟上锁 | 不支持,构造时必须立刻上锁 | 支持延迟锁:unique_lock lk(m, std::defer_lock)先创建对象,后续手动上锁 |
| 条件变量适配 | 不能传给 cv.wait()wait 需要中途释放锁,scoped_lock 做不到 |
唯一推荐搭配 std::condition_variablewait 内部自动 unlock/relock |
| 共享读写锁场景 | 仅能做独占写锁,无法做读锁 | 写锁用 unique_lock;读锁搭配 std::shared_lock |
| 额外状态开销 | 无额外状态,轻量,性能最优 | 内部保存锁持有状态,轻微性能损耗 |
| 支持 try 系列 | 无 try_lock 重载 | 支持 try_lock()、try_lock_for()、try_lock_until() 非阻塞加锁 |
| 析构行为 | 只要持有锁,析构必解锁 | 仅当前持有锁时,析构才解锁;已手动 unlock 则无操作 |
| C++ 版本 | C++17 新增 | C++11 就存在 |
/**
* @file condition_variable.cpp
* @author Abigale Kim (abigalek)
* @brief C++标准库 std::condition_variable 条件变量教学代码
*/
// 本程序简单演示 std::condition_variable 的使用。
// std::condition_variable 是条件变量同步原语。
// 条件变量的作用:让线程阻塞等待,直到满足某个特定条件后再去获取互斥锁;
// 同时其他线程可以发送信号,唤醒正在等待的线程,告知它条件可能已成立。
// 想要了解C语言风格条件变量的详细介绍,可以参考这份文档:
// https://pages.cs.wisc.edu/~remzi/OSTEP/threads-cv.pdf
// 程序一共创建3个线程:
// 其中两个线程执行同一个函数,原子性地给全局计数变量自增1;
// 当count等于2时,唤醒等待线程。
// 等待线程被唤醒后,获取锁并打印count的值。
// 引入条件变量头文件
#include <condition_variable>
// 引入 std::cout 用于打印演示
#include <iostream>
// 引入互斥锁头文件
#include <mutex>
// 引入线程库头文件
#include <thread>
// 定义全局计数变量、互斥锁、条件变量,供所有线程共用
int count = 0;
std::mutex m;
// 声明并默认初始化条件变量的标准写法
std::condition_variable cv;
// 该函数会被两个线程执行:对count自增1;
// 如果自增后count等于2,就唤醒一个正在等待的线程。
void add_count_and_notify() {
std::scoped_lock slk(m);
count += 1;
if (count == 2) {
cv.notify_one();
}
}
// 等待线程执行该函数:阻塞等待,直到满足 count == 2 的条件。
// 条件达成后获取互斥锁m,执行临界区代码。
// 使用条件变量时,必须搭配 std::unique_lock。
// std::unique_lock 是STL同步工具,相比scoped_lock灵活性更高,专门适配条件变量。
// 它支持移动语义,但不允许拷贝构造、拷贝赋值。
void waiter_thread() {
std::unique_lock lk(m);
// 阻塞等待,第二个参数是判断条件,条件成立才会解除阻塞
cv.wait(lk, []{return count == 2;});
std::cout << "打印count的值:" << count << std::endl;
}
// main函数创建3个线程:其中两个并行执行add_count_and_notify,
// 第三个是等待线程。所有线程执行完毕后,等待线程会打印count,
// 验证两次自增操作、条件等待唤醒逻辑都正常运行。
int main() {
std::thread t1(add_count_and_notify);
std::thread t2(add_count_and_notify);
std::thread t3(waiter_thread);
t1.join();
t2.join();
t3.join();
return 0;
}
// Printing count: 2
18 rwlock
/**
* @file rwlock.cpp
* @author Abigale Kim (abigalek)
* @brief C++标准库 std::shared_lock 与 std::unique_lock 教学代码
* (演示二者作为读写锁的用法)
*/
// C++标准库没有单独提供读写锁专用类,但可以通过 std::shared_mutex、std::shared_lock、std::unique_lock 组合实现读写锁。
// 本程序简单演示实现方式。
// std::shared_mutex 支持两种锁模式:共享只读锁、独占写锁。
// std::shared_lock 是 RAII 风格的读锁;std::unique_lock 是 RAII 风格的写锁。
// 关于 C++ RAII 自动锁机制可以查看 scoped_lock.cpp。
// 想要复习读写锁的概念与读者写者问题,可以查看 CMU 课程幻灯片:
// https://www.cs.cmu.edu/afs/cs/academic/class/15213-s23/www/lectures/25-sync-advanced.pdf
// 引入 std::cout 用于打印演示
#include <iostream>
// 普通互斥锁头文件
#include <mutex>
// 共享互斥锁头文件(读写锁底层)
#include <shared_mutex>
// 线程库头文件
#include <thread>
// 定义全局计数变量与共享互斥锁,供所有线程使用
// std::shared_mutex 同时支持共享读锁、独占写锁两种模式
int count = 0;
std::shared_mutex m;
// 该函数使用 std::shared_lock(读锁),以共享只读方式访问 count 并读取数值
void read_value() {
std::shared_lock lk(m);
std::cout << "读取当前数值:" + std::to_string(count) + "\n" << std::flush;
}
// 该函数使用 std::unique_lock(写锁),独占访问 count 并修改数值
void write_value() {
std::unique_lock lk(m);
count += 3;
}
// main 函数创建6个线程并行执行:2个写线程、4个读线程。
// 程序输出结果不固定,取决于操作系统调度、线程抢占锁的先后顺序。
// 多运行几次程序,可以观察到不同的打印顺序。
int main() {
std::thread t1(read_value);
std::thread t2(write_value);
std::thread t3(read_value);
std::thread t4(read_value);
std::thread t5(write_value);
std::thread t6(read_value);
t1.join();
t2.join();
t3.join();
t4.join();
t5.join();
t6.join();
return 0;
}
/*
Reading value 0
Reading value 0
Reading value 3
Reading value 6
*/
附加示例
#include <iostream>
#include <memory>
#include <utility>
// 本文件是2024春季15-445/645 C++入门训练营配套代码
// 从零简易实现 unique_ptr,深入讲解C++新特性:移动构造函数、移动赋值运算符、移动语义、unique_ptr、shared_ptr、包装类等
// **重要前置说明**:
// 1. 阅读本文件前,请先看完 src 目录下 move_semantics.cpp 和 move_constructors.cpp!
// 2. 建议从 main 主函数开始阅读!
// 我们自己实现的简易版 std::unique_ptr<T>,标准库真实实现会更加复杂!
// 模板可以让我们后续替换任意类型 T
template <typename T>
class Pointer {
public:
// 默认构造:堆上创建T对象,初始值置0
Pointer() {
ptr_ = new T;
*ptr_ = 0;
std::cout << "堆上新建对象,值:" << *ptr_ << std::endl;
}
// 带初始值的构造函数
Pointer(T val) {
ptr_ = new T;
*ptr_ = val;
std::cout << "堆上新建对象,值:" << val << std::endl;
}
// 析构函数:对象离开作用域(栈销毁)时自动调用
~Pointer() {
if (ptr_) {
std::cout << "释放堆内存,原值:" << *ptr_ << std::endl;
delete ptr_;
}
}
// 显式删除拷贝构造函数,禁止拷贝
Pointer(const Pointer<T> &) = delete;
// 显式删除拷贝赋值运算符,禁止拷贝赋值
Pointer<T> &operator=(const Pointer<T> &) = delete;
// 移动构造函数:用于转移资源,延长堆对象生命周期
Pointer<T>(Pointer<T> &&another) : ptr_(another.ptr_) { another.ptr_ = nullptr; }
// 移动赋值运算符:用于转移资源,延长堆对象生命周期
Pointer<T> &operator=(Pointer<T> &&another) {
// 防止自赋值:p = std::move(p);
if (ptr_ == another.ptr_) {
return *this;
}
// 覆盖原有指针前必须先释放旧内存,否则内存泄漏!
if (ptr_) {
delete ptr_;
}
// 接管传入对象的裸指针
ptr_ = another.ptr_;
// 原对象置空,析构时不会重复释放
another.ptr_ = nullptr;
return *this;
}
// 重载解引用运算符 *,让 Pointer<T> 使用体验和原生指针一致
// 示例:p1.set_val(10) 等价于 *p1 = 10
T &operator*() { return *ptr_; }
T get_val() { return *ptr_; }
void set_val(T val) { *ptr_ = val; }
private:
T *ptr_;
};
// 错误的生成器函数版本
template <typename T>
Pointer<int> &dumb_generator(T init) {
Pointer<T> p(init);
return p; // 严重错误!返回局部对象引用,产生悬垂引用
}
template <typename T>
Pointer<T> smart_generator(T init) {
Pointer<T> p(init);
return std::move(p);
// 其实直接 return p 也能正常运行,C++编译器会自动识别并调用移动构造
// 详情参考:https://www.learncpp.com/cpp-tutorial/move-constructors-and-move-assignment/
// 章节《从函数返回左值会自动移动而非拷贝》
}
// 接管所有权:参数值传递,会移动智能指针
void take_ownership(std::unique_ptr<int> p) {
// 执行相关逻辑……
}
// 不接管所有权:仅接收原生裸指针,函数内绝不执行delete
void not_take_ownership(int *p) {
// 禁止在这里 delete p!!
}
int main() {
/* ======================================================================
=== 第一部分:课程中高频踩坑问题 ==================
====================================================================== */
// 在C++/本课程开发中,你会频繁见到 unique_ptr
std::unique_ptr<int> ptr = std::make_unique<int>(3);
// 这是什么?为什么不直接用 int *p = new int 这种原生裸指针?答案在第二部分
// 当你想把 unique_ptr 传入函数时,你可能会写出下面代码(取消注释试试)
// take_ownership(ptr);
// 编译报错:隐式调用了已被删除的 unique_ptr 拷贝构造函数
// 上网搜索后,别人告诉你加上 std::move
take_ownership(std::move(ptr));
// 编译通过,看起来没问题,继续写代码……
// 之后你再次想用 ptr(取消注释)
// *ptr = 3;
// 直接段错误崩溃
// 这背后到底发生了什么?我们通过手写简易 unique_ptr 完整拆解原理!
/* ======================================================================
=== 第二部分:为什么优先用 unique_ptr,而非原生裸指针 =======
====================================================================== */
// 原生裸指针存在什么问题?
int *p = new int; // 堆内存分配
*p = 456 * 12 / 34 + 23;
if (*p == 76) {
delete p; // 很容易忘记写这句,造成内存泄漏
return 0;
}
delete p; // 手动释放
// 原生裸指针风险极高:稍有不慎就出现内存泄漏、重复释放、野指针访问等问题
// 根源:C++原生裸指针没有自动清理机制,堆内存分配释放全靠程序员手动管控,极易出错
// 我们发现:栈上局部变量会自动创建、自动销毁,无需手动管理
// 能不能把裸指针封装到栈局部对象中?
// 对象创建时自动分配堆内存,对象销毁时自动释放堆内存(相关概念:RAII)
// 用C++类实现这个逻辑!
// 设计一个类,唯一职责是持有并拥有一块堆内存,对象离开作用域时自动释放内存
// 这种封装类统称为智能指针,unique_ptr 是其中一种
// 那为什么 unique_ptr 不能拷贝?std::move 又是什么?
/* ======================================================================
=== 第三部分:从零手写简易 unique_ptr 类 ==========
====================================================================== */
// 这里只展示最终完整版本,实现思路分三阶段迭代:
// 1. 第一版:默认生成拷贝构造/拷贝赋值,无移动构造、移动赋值
// 缺陷:Pointer p2 = p1 会导致同一块内存被两个对象管理,析构时重复释放
// 解决方案:禁用拷贝构造、拷贝赋值
// 2. 第二版:删除拷贝构造/赋值,无移动构造、移动赋值
// Pointer p2 = p1 直接编译报错,这一点是安全的;但存在新问题:
// 无法实现 dumb_generator、smart_generator 这类返回对象的函数
// 解决方案:新增移动构造、移动赋值
// 3. 最终版:禁用拷贝,提供移动构造、移动赋值
// Pointer p4 = std::move(p3);
// std::move 强制触发移动构造(而非拷贝构造),把裸指针所有权从 p3 转移到 p4
// 转移后 p3 失效,内部指针置空,除非重新赋值否则不要再使用 p3
// 看完这里你就能理解 std::move 的作用、为什么拷贝函数被删除、unique_ptr 使用规范!
// 参考资料:Learncpp 教程第22章
// (https://www.learncpp.com/cpp-tutorial/introduction-to-smart-pointers-move-semantics/)
Pointer<int> p1(4);
std::cout << "p1 初始值:" << p1.get_val() << std::endl;
p1.set_val(10);
std::cout << "修改后 p1 值:" << p1.get_val() << std::endl;
{
// 下面代码会出现严重问题:两个对象同时持有同一块堆内存,析构重复释放!
// Pointer<int> p2 = p1; // 第一版实现的错误代码
// 解决方案:禁止拷贝所有权,不允许复制智能指针
// 删除拷贝构造/赋值后,可以改用原生指针引用
Pointer<int> *p2 = &p1; // 第二版实现方案
std::cout << "通过指针读取 p1 值:" << p2->get_val() << std::endl;
// 但这样又回到了原生裸指针,并不优雅,C++引用更安全
// 语义等价于 Pointer<int> *p2 = &p1,只是使用者看不到底层地址
Pointer<int> &p22 = p1; // 第二版实现方案
std::cout << "通过引用读取 p1 值:" << p22.get_val() << std::endl;
}
// 但引用无法解决所有场景需求
// 有时我们需要把栈上对象的生命周期延长,比如 dumb_generator 这种函数
// 典型场景:线程之间传递数据
// 取消下面代码注释测试:
// Pointer<int>& dumb_pointer = dumb_generator(2); // 严重错误,悬垂引用
// dumb_pointer.set_val(10); // 程序崩溃
// 我们需要所有权转移机制,请看 Pointer 类里的移动构造/移动赋值
// 把错误的 dumb_generator 改成正确的 smart_generator
// 最终版代码演示:
Pointer<int> p3 = smart_generator<int>(2);
p3.set_val(10);
Pointer<int> p4 = std::move(p3);
// 进一步优化使用体验
// 1. 模板:支持任意数据类型
Pointer<float> p5(5.1);
std::cout << "浮点型 p5 值:" << p5.get_val() << std::endl;
// 2. 运算符重载:支持 * 解引用
Pointer<char> c1('a');
*c1 = 'b';
std::cout << "字符对象 c1 值:" << *c1 << std::endl;
// 你可能会疑惑两种引用的区别:
// 移动构造/赋值的 Pointer<T> && VS 拷贝构造的 Pointer<T> &
// 两种理解方式:
// 方式一:先记住语法区分拷贝/移动,直接跳到第四部分;
// 方式二:基础概念补充:
// 1. 分清左值、右值:简化定义——左值拥有内存地址,右值无持久内存地址
// 2. Pointer<T> && 是右值引用;Pointer<T> & 是左值引用
// 3. std::move(p) 会将左值p强制转换为右值
// 4. Pointer p2 = p1:p1是左值,调用拷贝构造
// 5. Pointer p2 = std::move(p1):转换后为右值,调用移动构造
/* ======================================================================
=== 第四部分:unique_ptr 与 shared_ptr 核心总结 =====
====================================================================== */
// unique_ptr 重点知识点(参考: https://www.learncpp.com/cpp-tutorial/stdunique_ptr/ )
// 1. 创建 unique_ptr 优先使用 std::make_unique()
std::unique_ptr<int> up{std::make_unique<int>(1)};
// 禁止下面这种写法!
// int *rp = new int;
// std::unique_ptr<int> up1{ rp };
// std::unique_ptr<int> up2{ rp }; // 严重错误,两个智能指针管理同一块内存
// 2. unique_ptr 两种传参方式
not_take_ownership(up.get());
// 此处 up 仍然有效,只是传入裸指针,不转移所有权
take_ownership(std::move(up));
// 执行move后,up 失效,不能再使用
// shared_ptr 重点知识点(参考: https://www.learncpp.com/cpp-tutorial/stdshared_ptr/ )
// 0. 多个 shared_ptr 可以同时管理同一块堆内存
// 内部维护引用计数,只有计数减为0时,才释放堆内存
std::shared_ptr<int> sp1{std::make_shared<int>(1)};
{
// shared_ptr 允许拷贝构造、拷贝赋值
std::shared_ptr<int> sp2 = sp1;
std::cout << "当前引用计数:" << sp1.use_count() << std::endl; // 输出:2
}
std::cout << "当前引用计数:" << sp1.use_count() << std::endl; // 输出:1
// 1. 创建新shared_ptr必须拷贝已有shared_ptr对象
int *rp = new int;
std::shared_ptr<int> sp3{rp};
// std::shared_ptr<int> sp4{ rp }; // 错误,两个shared_ptr独立计数,会重复释放
std::shared_ptr<int> sp4{sp3};
// 2. 创建shared_ptr优先使用 std::make_shared()
return 0;
}

浙公网安备 33010602011771号