前言
最近打算写写cmu15445课程的project,但是对于c++的很多新特性和深入用法不够熟悉,这里从cmu15445-bootcamp入手,重新熟悉c++
一、文件结构
仓库README中介绍了files分类,一组内的files覆盖了c++一方面的知识点。
其中对于笔者来说难点在于移动语义、智能指针和锁。
二、References and Move Semantics
1. Move semantics(移动语义)
Move semantics(移动语义)是 C++11 引入的核心特性之一,它允许资源(如动态内存、文件句柄等)从一个对象高效地"移动"到另一个对象,而非进行昂贵的拷贝操作。其是一种优化技术,通过"窃取"临时对象(右值r-value)的资源来避免不必要的深拷贝。当源对象是临时对象(即将被销毁)时,直接接管其资源比创建新副本更高效。由于移动的特性,源对象则会处于有效但未指定状态。
特性 | 拷贝语义 | 移动语义 |
---|---|---|
操作 | 深拷贝所有数据 | 转移资源所有权 |
开销 | 高(分配新内存+复制数据) | 低(仅指针/句柄交换) |
源对象 | 保持不变 | 处于有效但未指定状态(通常为空) |
适用场景 | 需要两个独立副本时 | 源对象是临时对象或不再需要时 |
std::move
是 C++11 引入的一个标准库函数,用于将一个对象显式地转换为右值引用(rvalue reference),从而允许资源的“移动”而不是“复制”。它本质上并不移动任何东西,而是告诉编译器:这个对象可以被“移走”,你可以调用移动构造函数或移动赋值运算符来处理它。即启用移动语义。
右值(rvalue) 传统上指的是只能出现在赋值表达式右侧的值。右值通常是临时对象或字面量,没有持久的内存地址。其不可寻址且生命周期短暂(通常在表达式结束后就会被销毁)。C++11引入了右值引用(用
&&
表示),使得可以区分左值和右值:void func(int& lvalue); // 接受左值 void func(int&& rvalue); // 接受右值
简而言之,std::move的主要作用为两点:类型转换与启用移动语义。
一些注意事项:
(1)不是实际移动操作:
std::move
本身不移动任何东西,它只是将对象标记为可移动,对于一些没有移动语义的类型,实际上进行的操作还是拷贝。int x = 10; int y = std::move(x); // 实际上还是拷贝,因为int没有移动语义
再比如仓库里move_semantics.cpp文件中,这样两行代码。这里并不会报错,因为
std::move
本身不移动数据,它只是将左值转换为右值引用,真正的移动发生在移动构造函数或移动赋值操作时。左侧为右值引用类型时,并没有触发移动操作。std::vector<int> &&rvalue_stealing_ints = std::move(stealing_ints); std::cout << "Printing from stealing_ints: " << stealing_ints[1] << std::endl;
但是如下情况则会报错:
std::vector<int> stealing_ints = {1, 2, 3}; std::vector<int> new_ints = std::move(stealing_ints); // 实际移动发生 // 现在访问stealing_ints是危险的! std::cout << stealing_ints[1] << std::endl; // 未定义行为
(2)移动后的对象状态
对象仍处于有效但未定义状态
基本类型不受影响
标准库类型通常变为空状态
(3)不要重复使用
auto obj = std::move(x); auto obj2 = std::move(x); // 危险!x可能已被移动
文件move_semantics.cpp中设计这样一个函数:
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";
}
其中函数参数设计为右值引用类型(&&),主要作用为明确函数意图:"我将从这个参数中窃取资源"。且此时限定函数参数为右值引用,避免意外输入普通引用类型,而导致输入的vector被清空的情况。
2. Move constructors and Move assignment operators
移动构造函数和移动赋值运算符是在类内部实现的方法,用于有效地将资源从一个对象移动到另一个对象,通常使用 std::move。这些类方法接受另一个相同类型的对象,并将其资源移动到调用该方法的实例。
文件中示例:
Person(Person &&person)
: age_(person.age_), nicknames_(std::move(person.nicknames_)),
valid_(true) {
std::cout << "Calling the move constructor for class Person.\n";
// The moved object's validity tag is set to false.
person.valid_ = false;
}
// Move assignment operator for class Person.
Person &operator=(Person &&other) {
std::cout << "Calling the move assignment operator for class Person.\n";
age_ = other.age_;
nicknames_ = std::move(other.nicknames_);
valid_ = true;
// The moved object's validity tag is set to false.
other.valid_ = false;
return *this;
}
nicknames_为std::vector<std::string>类型,在移动构造函数中需要通过std::move以右值的方式传递,实现移动。
另外的注意点在于文件中通过= delete的方式禁用了负载构造函数和赋值运算,使得该类只能进行移动构造进行“复制”。
// We delete the copy constructor and the copy assignment operator,
// so this class cannot be copy-constructed.
Person(const Person &) = delete;
Person &operator=(const Person &) = delete;
三、C++ Standard Library (STL) Memory
1. unique_ptr
unique_ptr
是 C++11 引入的一种智能指针,用于管理动态分配的内存,它提供了独占所有权的语义,确保指针指向的对象有且只有一个所有者。其提供了更加安全的内存管理方法。当 unique_ptr
离开作用域时,会自动删除它所管理的对象,这是与常规指针有所不同的、更加安全的内存管理方法。独占则能避免双重释放等问题。
创建方法:
#include <memory>
// 创建一个指向int的unique_ptr
std::unique_ptr<int> ptr1(new int(10));
// 使用make_unique (C++14推荐)
auto ptr2 = std::make_unique<int>(20);
访问对象:
*ptr1 = 30; // 解引用
int x = *ptr2;
ptr1->method(); // 如果指向的是类对象
所有权转移。unique_ptr
不能复制,但可以通过移动语义转移所有权:
auto ptr3 = std::move(ptr1); // ptr1现在为nullptr
在 C++ 中,如果有两个
std::unique_ptr
,分别为a
和b
,并且两者都不为空,当执行以下语句:a = std::move(b);
会发生以下几件事:
1. 原来
a
拥有的资源会被释放在赋值之前,
a
原本所拥有的对象会被销毁(如果不是空指针)。这是因为std::unique_ptr
的赋值操作符会先释放自己当前拥有的资源。2.
a
获得b
的所有权赋值语句中的
std::move(b)
将b
转换为右值引用,使得a
可以接管b
所拥有的资源。3.
b
变为空
b
失去其资源的所有权,并被置为空(即内部指针变为nullptr
),这是unique_ptr
的语义:只允许一个所有者。
2. shared_ptr
std::shared_ptr
是 C++11 引入的智能指针,用于管理动态分配对象的共享所有权。多个 shared_ptr
可以指向同一个对象,并通过引用计数自动管理对象的生命周期。基本用法与unique_ptr类似,但其可以进行复制构造,且有引用计数统计指向该对象的shared_ptr数量:
std::cout << p1.use_count(); // 输出当前引用计数
当其引用计数为0时,自动销毁指针指向的对象。
四、C++ Standard Library (STL) Synch Primitives
1. mutex
互斥锁,用于实现资源访问的互斥。一般使用lock()与unlock()操作。
2. scoped_mutex
std::scoped_lock 是一个互斥锁包装类,它提供了一种 RAII 风格的获取和释放锁的方法。这意味着,当对象被构造时,会获取锁;当对象被析构时,会释放锁。
int count = 0;
std::mutex m;
void add_count() {
// The constructor of std::scoped_lock allows for the thread to acquire the
// mutex m.
std::scoped_lock slk(m);
count += 1;
// Once the function add_count finishes, the object slk is out of scope, and
// in its destructor, the mutex m is released.
}
与其他锁的对比:
类名 | 多锁支持 | 可手动 unlock | 用途 |
---|---|---|---|
std::lock_guard | ❌ | ❌ | 简单作用域锁 |
std::unique_lock | ❌ | ✅ | 灵活控制锁生命周期 |
std::scoped_lock | ✅ | ❌ | 同时锁多个互斥量 |
由此可知,scoped_lock特点在于一次性锁定多个互斥量(mutex),并在作用域结束时自动释放,其可以避免死锁。这是作用域锁(scoped lock)模式的一种实现。
3. condition variable
condition_variable
(条件变量)是C++标准库中用于线程同步的一种机制,定义在<condition_variable>
头文件中。它允许线程在某个条件不满足时进入等待状态,直到其他线程通知条件可能已改变。
主要作用:
-
一个线程可以在某个条件不满足时进入等待状态(阻塞),释放锁。
-
另一个线程在改变条件后,通知等待的线程继续执行。
关键成员函数:
函数 | 说明 |
---|---|
wait(std::unique_lock<mutex>&, Predicate pred) | 阻塞当前线程,直到被通知(并且条件满足) |
notify_one() | 唤醒一个等待的线程 |
notify_all() | 唤醒所有等待的线程 |
文件condition_variable.cpp中的例子:
void add_count_and_notify() {
std::scoped_lock slk(m);
count += 1;
if (count == 2) {
cv.notify_one();
}
}
// This function, ran by the waiting thread, waits on the condition
// count == 2. After that, it grabs the mutex m and executes code in
// the critical section.
// Condition variables need an std::unique_lock object to be constructed.
// std::unique_lock is a type of C++ STL synchronization primitive that
// gives more flexibility and features, including the usage with
// condition variables. Particularly, it is moveable but not copy-constructible
// or copy-assignable.
void waiter_thread() {
std::unique_lock lk(m);
cv.wait(lk, []{return count == 2;});
std::cout << "Printing count: " << count << std::endl;
}
其中,要使用 unique_lock
而不是 lock_guard
,是因为 condition_variable::wait
需要 unique_lock
(它可以解锁和重新加锁)。
对于 cv.wait ( lk, [ ]{ return count == 2; } ) 语句,在等待时,wait()
会:
-
自动释放互斥锁 m,让其他线程可以修改 count。
-
被唤醒时重新获取锁,并再次检查条件 count == 2(如果是虚假唤醒也能避免继续执行)。
4. std::shared_lock and std::unique_lock
这两个都是C++标准库中提供的锁管理类,定义在<mutex>
头文件中,用于更安全和方便地管理互斥锁。简单来说就是一个是进行锁的独占管理,一个是锁的共享管理。
特性 | unique_lock | shared_lock |
---|---|---|
所有权 | 独占 | 共享 |
配合的互斥量 | mutex , timed_mutex 等 | shared_mutex , shared_timed_mutex |
锁定方式 | 独占锁定 | 共享锁定 |
灵活性 | 高(可手动控制) | 中等 |
引入版本 | C++11 | C++14 |
文件rwlock.cpp中的实例为:
int count = 0;
std::shared_mutex m;
// This function uses a std::shared_lock (reader lock equivalent) to gain
// read only, shared access to the count variable, and reads the count
// variable.
void read_value() {
std::shared_lock lk(m);
std::cout << "Reading value " + std::to_string(count) + "\n" << std::flush;
}
// This function uses a std::unique_lock (write lock equivalent) to gain
// exclusive access to the count variable and write to the value.
void write_value() {
std::unique_lock lk(m);
count += 3;
}
其中可以看到,使用了shared_mutex共享锁。shared_mutex
是 C++17 引入的同步原语(定义在 <shared_mutex>
头文件中),它提供了比普通互斥量更精细的并发控制,特别适用于读多写少的场景。其主要使用场景就是上方代码中的读写同步问题,主要与shared_lock一起使用,实现共享锁。
需要注意:shared_lock
不能管理std::mutex
。
五、Misc(杂项)
1. wrapper class
C++ 包装类是管理资源的类。资源可以是内存、文件套接字或网络连接。包装类通常使用 RAII(资源获取即初始化)C++编程技术。使用此技术意味着资源的生命周期与其作用域绑定。当包装类的实例被构造时,这意味着它所管理的底层资源可用;而当此实例被析构时,该资源也不可用。简单总结就是“用一个类来管理一个资源,资源与类同生死”。
文件中举了一个int*指针的包装类,类似于一种智能指针的简单模拟实现。
2. iterator
文件iterator.cpp中主要介绍迭代器的实现,实现了基本双向链表的迭代器。
六、demo files
文件s24_my_ptr.cpp中对移动语义、智能指针举了更多的例子,进行了更多讲解。可以在最开始先看这个文件对一些概念有整体理解,也可以最后看当做总结。其中有一些关于临时变量的销毁、悬挂引用的问题,未来可以再通过看这部分代码来熟悉。