1、左值右值
在 C++ 中,左值(lvalue)和右值(rvalue)是用于描述表达式的术语,它们与赋值操作和内存中对象的生命周期有关。
**左值(lvalue)**是指可以出现在赋值操作符左侧的表达式,它通常具有持久的身份(identity)和内存位置。左值可以是变量、对象、函数或表达式,它们具有可寻址(addressable)的属性,可以获取其地址。例如:
int x = 42; // x 是左值
int* ptr = &x; // &x 是左值,表示 x 的地址
int& ref = x; // ref 是左值引用,绑定到 x
**右值(rvalue)**是指不能出现在赋值操作符左侧的表达式,它通常是临时的、没有持久身份的值。右值可以是字面量、临时对象、表达式的结果等。右值不能直接获取其地址,因为它们可能没有明确的内存位置。例如:
int y = 10 + 20; // 10 + 20 是右值
int&& rref = 10; // 10 是右值,rref 是右值引用,绑定到 10
在 C++11 中引入了右值引用(rvalue reference)的概念,它允许我们绑定到右值,并提供了移动语义和完美转发的能力。通过使用右值引用,我们可以对临时对象进行高效的转移语义操作,如移动构造和移动赋值。例如:
std::string str1 = "Hello"; // "Hello" 是右值,str1 是左值
std::string str2 = std::move(str1); // std::move(str1) 是右值,使用移动构造将 str1 的内容转移到 str2
左值和右值的区分对于理解 C++ 的语义和语法非常重要。例如,在函数重载和模板参数推导时,左值和右值的区别可以决定调用哪个函数或模板实例。在 C++11 之后,右值引用的引入使得我们能够更好地利用右值,并实现高效的资源管理和移动语义操作。
为了加深理解,再来看下面的例子:
class Student {
private:
string info = "AAA";
public:
// 返回 info 的副本,而不是 info 成员本身
string getInfo() {
return this->info;
}
// 返回 info 的引用
string &getInfoRef() {
return this->info;
}
};
int main() {
Student student;
// 第一种情况,修改 getInfo() 返回的副本
student.getInfo() = "BBB";
cout << "第一种情况结果:" << student.getInfo() << endl;
// 第二种情况,修改 getInfoRef() 返回的引用
student.getInfoRef() = "CCC";
cout << "第二种情况结果:" << student.getInfoRef() << endl;
return 0;
}
输出结果:
第一种情况结果:AAA
第二种情况结果:CCC
第一种情况,由于 getInfo() 实际上返回的是一个临时变量,是右值,不能被修改;第二种情况,由于 getInfoRef() 返回的是对象本身,是左值,因此修改后会直接影响 info 成员本身。
2、线程
C++ 的线程大致分为两种,原始的 pthread 和 C++11 新增的 std::thread:
- pthread(POSIX Threads)是跨平台的线程库,不是 C++ 标准库的一部分。它是基于 POSIX 标准定义的线程接口,提供了一组函数用于创建、控制和同步线程;std::thread 是 C++ 标准库提供的跨平台线程支持的一部分,它提供了一种面向对象的方式来创建和管理线程,使得多线程编程更加方便和可移植
- pthread 是 C 的库,而 std::thread 是 C++ 库中的类
- pthread 是面向过程的,你需要使用函数指针作为线程的入口点,并手动管理线程的生命周期;std::thread 提供了面向对象的方式来创建和管理线程。你可以使用函数对象、成员函数、Lambda 表达式等作为线程的入口点,线程的生命周期由 std::thread 对象管理
- pthread 是一个跨平台的 POSIX 标准库,可以在支持 POSIX 接口的操作系统上使用,但在某些平台上可能需要进行适当的配置和设置;std::thread 是 C++ 标准库的一部分,因此在支持 C++11 的编译器上是可用的,可以在不同的操作系统和平台上运行
- pthread 需要手动处理线程中的异常;std::thread 提供了对异常的支持,如果在线程函数中抛出了异常并未被捕获,std::thread 会自动终止线程并将异常传播到主线程
- pthread 具有更广泛的使用,AndroidNDK、Linux、JDK、JVM、Java 线程和 Native C++ 基本上还是在用 pthread;std::thread 本质上是对 pthread 的封装,并且封装的还不是很好
- pthread 在 AndroidNDK 和 Linux 下是默认就支持的环境,在 CLion 配置了 Cygwin 的环境下也可使用,但是在 VS 和 mingw 就需要手动配置才能使用了
2.1 std::thread 的使用
thread 简单使用示例:
// 相当于 Java 中运行任务的 run()
void run(int number) {
for (int i = 0; i < 5; ++i) {
cout << "run:" << number << endl;
}
}
int main() {
// 第一种方式:main() 通过 sleep() 等待一段时间
thread thread1(run, 100);
sleep(3); // unistd.h
cout << "main 弹栈了" << endl;
}
运行结果:
run:100
run:100
run:100
run:100
run:100
main 弹栈了
terminate called without an active exception
第二种方式:
int main() {
// 第二种方式:main() 等 thread2 执行完毕后再执行
thread thread2(run, 666);
thread2.join();
cout << "main 弹栈了" << endl;
}
运行结果:
run:666
run:666
run:666
run:666
run:666
main 弹栈了
使用方式跟 Java 非常像。不过注意,正式开发不要使用第一种方式让主线程等待子线程,要用第二种。
2.2 pthread 的使用
通过 pthread_create() 创建 pthread 对象:
int pthread_create(pthread_t *th, const pthread_attr_t *attr, void *(* func)(void *) start_routine, void *arg);
该函数的四个参数说明如下:
参数 | 描述 |
---|---|
th | 指向 pthread_t 类型的指针,用于存储新创建线程的标识符。创建成功后,该标识符可用于对线程进行操作,如等待或终止 |
attr | 指向 pthread_attr_t 类型的指针,用于指定新线程的属性。可以通过该参数设置线程的属性,例如线程的栈大小、调度策略等。如果不需要特别的线程属性,可以将该参数设置为 nullptr |
start_routine | 指向函数指针,表示新线程的入口点函数。该函数必须具有以下签名:void *(*start_routine)(void *) 。新线程将从该函数开始执行 |
arg | 传递给 start_routine 函数的参数。可以使用该参数向新线程传递任意类型的数据。通常可以将参数封装为一个结构体,并进行类型转换运行函数的参数。必须通过把引用作为指针强制转换为 void 类型进行传递。如果没有传递参数,则使用 nullptr |
第三个参数的类型为 void *(* func)(void *)
是一个参数和返回值类型均为 void *
,名称为 func 的函数指针。void *
可以接收任何数据类型的指针。
pthread_create()
的返回值是一个整数,表示创建线程的成功与否。如果成功创建新线程,返回值为 0;否则,返回的是一个非零的错误码,用于指示发生的错误类型。
使用 pthread_create()
函数时,一般的步骤如下:
-
创建一个
pthread_t
类型的变量,用于存储新线程的标识符。 -
可选地创建一个
pthread_attr_t
类型的变量,并设置线程的属性。如果不需要特殊的属性,可以将其设置为nullptr
。 -
定义一个函数作为新线程的入口点函数,并确保其具有正确的参数和返回值。
-
调用
pthread_create()
函数,传递相应的参数。在成功创建线程后,thread
指向的变量将包含新线程的标识符。 -
处理创建线程时可能发生的错误,检查
pthread_create()
的返回值。 -
在必要时,使用线程标识符(
pthread_t
)对新线程进行操作,如等待线程完成、取消线程等。
示例代码:
#include <iostream>
#include <pthread.h>
using namespace std;
// 线程要执行的函数,相当于 Java 中的 run()
void *pthreadTask(void *pVoid) {
// 将 pVoid 静态转换成 int *,然后再 * 取出 int
int number = *static_cast<int *>(pVoid);
cout << "异步线程执行了:" << number << endl;
// 要返回 nullptr 或者 0,否则可能会出现未知错误
return nullptr;
}
int main() {
pthread_t pthreadId;
int number = 666;
pthread_create(&pthreadId, nullptr, pthreadTask, &number);
// 让 pthreadId join 到主线程中,使主线程等待 pthreadId 执行
// 完毕后再执行,否则可能看不到 pthreadId 的运行结果
pthread_join(pthreadId, nullptr);
return 0;
}
在使用 pthread 时要注意如下几点:
- pthread_t 相当于线程句柄,在 pthread_create() 创建线程成功后,pthreadId 才有一个有效值
- pthreadTask 这个函数指针,接收的参数和返回值类型都为
void *
(因为要传给 pthread_create() 作为第三个参数,所以类型要符合 pthread_create() 的参数声明),那么函数内部就需要把参数的void *
转换成真实的数据类型,并且在最后要有返回值 nullptr 或 0(nullptr 更好) - number 作为 pthread_create() 的第四个参数,实际上是传给 pthreadTask() 作为其参数的。如果 pthreadTask() 没有形参,则第四个参数就可以传 nullptr
- nullptr 是 C++11 标准引入的关键字,是 C++ 标准库的一部分,用来表示空指针的字面值常量。在早期的 C++ 标准中,使用
NULL
或0
来表示空指针。然而,nullptr
的引入提供了更好的类型安全性,因为它没有与整数类型之间的隐式转换。使用nullptr
可以明确指示一个指针不指向任何对象,在条件判断中,nullptr
可以和指针进行比较,判断指针是否为空 - main() 中使用 pthread_join() 让主线程在 pthreadId 线程执行完毕后再执行,这样可以避免 main() 先于 pthreadId 执行完而看不到 pthreadId 内输出的文字
2.3 分离线程与非分离线程
在 std::thread 多线程编程中,线程可以分为分离线程(detached thread)和非分离线程(joinable thread)两种类型。这两种类型的线程在生命周期和资源管理方面有所不同。
-
非分离线程(joinable thread):
- 非分离线程是默认类型的线程。当线程创建时,它是非分离的。
- 非分离线程在其执行结束后仍然存在并保持其状态,直到其他线程调用
std::thread::join
或std::thread::detach
来回收其资源。 - 通过调用
std::thread::join
,主线程可以等待非分离线程执行完成,以确保线程的执行完毕。 - 如果不调用
std::thread::join
或std::thread::detach
,并且线程对象的析构函数被调用,则程序会终止并引发std::terminate
。 - 非分离线程可以访问主线程栈上的局部变量,但需要注意线程安全问题。
-
分离线程(detached thread):
- 分离线程是通过调用
std::thread::detach
将非分离线程转换为分离线程。 - 分离线程在其执行结束后会自动释放所有资源,无需其他线程调用
std::thread::join
或std::thread::detach
。 - 分离线程不会阻塞主线程的执行,主线程也不会等待分离线程的结束。
- 分离线程在执行完毕后自行回收资源,因此不能访问主线程栈上的局部变量。
- 分离线程是通过调用
下面是一个示例,展示了分离线程和非分离线程的用法:
#include <iostream>
#include <thread>
void ThreadTask() {
std::cout << "执行线程任务" << std::endl;
}
int main() {
// 非分离线程
std::thread nonDetachedThread(ThreadTask);
nonDetachedThread.join(); // 等待非分离线程执行完成
// 分离线程
std::thread detachedThread(ThreadTask);
detachedThread.detach(); // 转换为分离线程
// 主线程继续执行其他任务...
return 0;
}
在上述示例中,首先创建了一个非分离线程 nonDetachedThread
,并调用 join
等待其执行完成。然后,创建了一个分离线程 detachedThread
,并调用 detach
将其转换为分离线程。在主线程中,可以继续执行其他任务,而不需要等待分离线程的结束。
如果需要等待线程执行完成并获取其结果,或者希望确保线程的执行顺序,那么非分离线程是更合适的选择。而如果线程的执行与主线程无关,且不需要等待其执行完成,那么可以选择分离线程以简化资源管理。
2.4 线程安全 —— 互斥锁
互斥锁英文以为 mutex lock,其中这个 mutex 是互斥量的简写,我们先简单了解一下互斥量。
2.4.1 什么是互斥量
mutex
是互斥量(mutual exclusion)的缩写,是一种用于实现线程同步的机制。它是一种可以协调多个线程对共享资源进行访问的工具,以避免并发访问导致的数据竞争和不一致问题。
互斥量的作用是确保在任意时刻只有一个线程可以访问被保护的共享资源。当一个线程想要访问共享资源时,它必须先获取互斥量的锁(lock),以确保其他线程不能同时访问该资源。当线程完成对共享资源的访问后,它释放互斥量的锁,以便其他线程可以获取锁并访问资源。
在 std::thread 中,互斥量的锁操作通常由两个函数完成:
-
std::mutex::lock()
:尝试获取互斥量的锁。如果锁已被其他线程占用,则当前线程被阻塞,直到锁被释放。 -
std::mutex::unlock()
:释放互斥量的锁,允许其他线程获取锁并访问共享资源。
下面是一个简单的示例,展示了互斥量的使用:
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx; // 创建一个互斥量
void ThreadTask() {
mtx.lock(); // 获取互斥量的锁
// 访问共享资源
std::cout << "线程执行开始" << std::endl;
// ...
std::cout << "线程执行结束" << std::endl;
mtx.unlock(); // 释放互斥量的锁
}
int main() {
std::thread t1(ThreadTask);
std::thread t2(ThreadTask);
t1.join();
t2.join();
return 0;
}
在上述示例中,创建了一个互斥量 mtx
,然后在 ThreadTask
函数中使用 mtx.lock()
获取互斥量的锁,以确保线程之间互斥地访问共享资源。在访问共享资源结束后,通过 mtx.unlock()
释放互斥量的锁。
需要注意的是,获取互斥量的锁后,其他线程将被阻塞,直到锁被释放。这样可以确保同一时间只有一个线程访问共享资源,从而避免了数据竞争和不一致问题。
2.4.2 互斥锁
pthread 互斥锁使用步骤:
- 声明一个互斥锁对象 pthread_mutex_t mutex
- 初始化互斥锁 pthread_mutex_init(&mutex, nullptr)
- 对共享资源上锁 pthread_mutex_lock(&mutex),使用完毕后解锁 pthread_mutex_unlock(&mutex)
- 销毁互斥锁 pthread_mutex_destroy(&mutex)
示例代码:
// 互斥锁对象,在 Cygwin 平台中该对象不能有野指针
pthread_mutex_t mutex;
// 子线程数量
int num_thread = 10;
// 线程的共享资源 —— 队列
queue<int> threadQueue;
void *task(void *pVoid) {
// 上锁,与解锁操作 pthread_mutex_unlock 配对
pthread_mutex_lock(&mutex);
int thread_num = *static_cast<int *>(pVoid);
if (!threadQueue.empty()) {
cout << thread_num << "号线程正在消费队列元素" << threadQueue.front() << endl;
threadQueue.pop();
} else {
cout << "队列数据已被消费完毕~" << endl;
}
// 解锁
pthread_mutex_unlock(&mutex);
return nullptr;
}
int main() {
// 构造队列中的数据
for (int i = 0; i < num_thread; ++i) {
threadQueue.push(i);
}
// 初始化互斥锁,与销毁互斥锁的 pthread_mutex_destroy 要成对出现
pthread_mutex_init(&mutex, nullptr);
// 创建 10 个子线程去访问队列中的数据
pthread_t pthreadIds[num_thread];
for (int i = 0; i < num_thread; ++i) {
pthread_create(&pthreadIds[i], nullptr, task, &i);
}
// 等这 10 个子线程全执行完再执行主线程
for (int i = 0; i < num_thread; ++i) {
pthread_join(pthreadIds[i], nullptr);
}
// 销毁互斥锁,与 pthread_mutex_init 成对出现
pthread_mutex_destroy(&mutex);
cout << "main函数即将弹栈..." << endl;
return 0;
}
输出结果:
3号线程正在消费队列元素0
5号线程正在消费队列元素1
6号线程正在消费队列元素2
6号线程正在消费队列元素3
7号线程正在消费队列元素4
8号线程正在消费队列元素5
8号线程正在消费队列元素6
9号线程正在消费队列元素7
10号线程正在消费队列元素8
10号线程正在消费队列元素9
main函数即将弹栈...
可以看到队列中的元素按照顺序被消费了,没有发生线程安全问题。
2.4.3 互斥锁 + 条件变量
通过互斥锁 + 条件变量可以实现 Java 中 wait-notify 的效果。以生产者消费者为例:
template<typename T>
class SafeQueue {
private:
queue<T> queue;
int capacity = 10; // 队列容量,默认为 10
pthread_mutex_t mutex; // 定义互斥锁(不允许有野指针)
// 条件变量,用于实现等待、读取等功能(不允许有野指针)
pthread_cond_t condFull; // 队列已满时的条件
pthread_cond_t condEmpty; // 队列为空时的条件
public:
SafeQueue() {
// 初始化锁和条件对象
pthread_mutex_init(&mutex, nullptr);
pthread_cond_init(&condFull, nullptr);
pthread_cond_init(&condEmpty, nullptr);
}
SafeQueue(int capacity) : SafeQueue()/*, capacity(capacity)*/ {
this->capacity = capacity;
}
~SafeQueue() {
// 回收锁和条件对象
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&condFull);
pthread_cond_destroy(&condEmpty);
}
// 数据入队
void enqueue(T data);
// 队首数据出队
void dequeue(T &t);
};
在 safe_queue.h 中初始化了一个锁 mutex 和两个条件 condFull、condEmpty,队列入队出队操作实现如下:
// 数据入队
template<typename T>
void SafeQueue<T>::enqueue(T data) {
pthread_mutex_lock(&mutex);
// 要用 while 循环而不是 if
while (queue.size() >= capacity) {
cout << "队列已满" << endl;
// 让线程在 condFull 条件上等待,相当于 Java 的 wait
pthread_cond_wait(&condFull, &mutex);
}
queue.push(data);
// 已经成功将数据入队,广播通知等待在 condEmpty 条件
// 上的所有线程,相当于 Java 中的 notifyAll
pthread_cond_broadcast(&condEmpty);
pthread_mutex_unlock(&mutex);
}
// 队首数据出队
template<typename T>
void SafeQueue<T>::dequeue(T &t) {
pthread_mutex_lock(&mutex);
while (queue.empty()) {
// 队列为空时,外部线程无法消费数据,让它们等待
cout << "队列为空,等待中..." << endl;
pthread_cond_wait(&condEmpty, &mutex);
}
t = queue.front();
cout << "消费了队首元素:" << t << endl;
queue.pop();
// 消费了元素,队列中有空位了,通知所有在队列已满
// 条件下等待的线程
pthread_cond_broadcast(&condFull);
// 唤醒一个线程
// pthread_cond_signal(&condFull);
pthread_mutex_unlock(&mutex);
}
最后测试:
SafeQueue<int> safeQueue;
void *consume(void *) {
int value;
while (true) {
safeQueue.dequeue(value);
// 如果消费了 -1 就结束循环
if (value == -1) {
break;
}
}
return nullptr;
}
void *product(void *) {
int value;
while (true) {
cout << "输入要生产的信息:"<<endl;
cin >> value;
safeQueue.enqueue(value);
// 如果生产了 -1 就结束循环
if (value == -1) {
break;
}
}
return nullptr;
}
int main() {
pthread_t consumerThread;
pthread_t producerThread;
pthread_create(&consumerThread, nullptr, consume, nullptr);
pthread_create(&producerThread, nullptr, product, nullptr);
pthread_join(consumerThread, nullptr);
pthread_join(producerThread, nullptr);
return 0;
}
3、智能指针
3.1 概述
C++11 开始提供了智能指针用来管理动态分配的对象的指针,它们可以自动处理内存的分配和释放,帮助避免内存泄漏和悬挂指针等问题。在 C++ 标准库中,有三种主要的智能指针类型:
-
std::unique_ptr
:std::unique_ptr
是独占指针,它拥有对动态分配对象的唯一所有权。当std::unique_ptr
被销毁时(例如,超出其作用域),它会自动释放所拥有的对象。std::unique_ptr
不能被复制,但可以通过移动语义转移所有权。示例:std::unique_ptr<int> ptr = std::make_unique<int>(42); std::cout << *ptr << std::endl; // 输出: 42 ```
-
std::shared_ptr
:std::shared_ptr
是共享指针,它可以多个指针共享对同一个对象的所有权。它使用引用计数来管理对象的生命周期,当最后一个std::shared_ptr
被销毁时,它会自动释放所拥有的对象。std::shared_ptr
可以被复制和赋值。示例:std::shared_ptr<int> ptr1 = std::make_shared<int>(42); std::shared_ptr<int> ptr2 = ptr1; std::cout << *ptr1 << std::endl; // 输出: 42 std::cout << *ptr2 << std::endl; // 输出: 42 ```
-
std::weak_ptr
:std::weak_ptr
是一种弱引用指针,它指向一个由std::shared_ptr
管理的对象,但不会增加引用计数。因此,它不影响对象的生命周期。std::weak_ptr
可以通过std::shared_ptr
转换为临时的std::shared_ptr
以访问对象。示例:std::shared_ptr<int> sharedPtr = std::make_shared<int>(42); std::weak_ptr<int> weakPtr = sharedPtr; // 使用 weak_ptr 访问对象 if (auto shared = weakPtr.lock()) { std::cout << *shared << std::endl; // 输出: 42 } ```
这些智能指针类型提供了更安全和方便的内存管理方式,可以减少手动管理内存的复杂性和错误。在使用智能指针时,应避免使用裸指针直接操作拥有智能指针的对象,以免破坏智能指针的引用计数和导致不确定的行为。
3.2 工作原理
三种指针与引用计数的关系:
- shared_ptr 修饰的对象,其引用计数会加 1,只要对象的引用计数不为 0,该对象就不会执行析构函数被回收
- weak_ptr 只能指向 shared_ptr 管理的对象,不会增加引用计数
- unique_ptr 独占一个对象,也就不需要引用计数了
shared_ptr 和 weak_ptr 可以通过 use_count() 获取到一个对象的引用计数,示例如下:
#include <iostream>
#include <memory> // 智能指针的头文件引入
using namespace std;
class Person {
public:
~Person() {
cout << "执行 Person 析构函数" << endl;
}
};
int main() {
// 在堆区创建 Person 并在栈中分配指针指向它们
Person *person1 = new Person();
Person *person2 = new Person();
// 栈区开辟智能指针
// unique_ptr 独占 person1 对象
unique_ptr<Person> uniquePtr(person1);
// shared_ptr 会使 person2 对象的引用计数加 1
shared_ptr<Person> sharedPtr(person2);
weak_ptr<Person> weakPtr = sharedPtr;
cout << "person2 引用计数:" << sharedPtr.use_count() << ","
<< "person2 添加 weak_ptr 后的引用计数:" << weakPtr.use_count() << endl;
return 0;
// main 弹栈后会自动执行 person1 和 person2 的析构函数
}
流程分析:
- unique_ptr 独占式的持有 person1,当 main() 弹栈时,unique_ptr 的生命周期结束,它就会自动释放 person1
- shared_ptr 共享式的持有 person2,每有一个 shared_ptr 持有 person2 时,person2 的引用计数就会加 1,因此这里 person2 的引用计数为 1;weak_ptr 不会增加对象的引用计数
- 当 main() 弹栈时,shared_ptr 会结束自己的生命周期,同时对 person2 的引用计数减 1,使得 person2 的引用计数变为 0,此时就会调用 person2 的析构函数对其进行回收
测试结果:
person2 引用计数:1,person2 引用计数:1
执行 Person 析构函数
执行 Person 析构函数
3.3 shared_ptr 的循环引用问题
shared_ptr 通过引用计数的方式决定是否释放对象有一个问题,就是两个互相引用的对象会因为引用计数永远不会为 0 而无法被释放:
class Person2;
class Person1 {
public:
shared_ptr<Person2> sharedPtr;
~Person1() {
cout << "执行 Person1 的析构函数" << endl;
}
};
class Person2 {
public:
shared_ptr<Person1> sharedPtr;
~Person2() {
cout << "执行 Person2 的析构函数" << endl;
}
};
int main() {
// 创建 Person1、Person2 对象
auto *person1 = new Person1();
auto *person2 = new Person2();
// 创建 person1、person2 的 shared_ptr 指针
shared_ptr<Person1> sharedPtr1(person1);
shared_ptr<Person2> sharedPtr2(person2);
// 查看引用计数
cout << "前 sharedPtr1的引用计数是:" << sharedPtr1.use_count() << endl;
cout << "前 sharedPtr2的引用计数是:" << sharedPtr2.use_count() << endl;
// 让 person1 的 sharedPtr 指向 sharedPtr2,反之亦然
person1->sharedPtr = sharedPtr2;
person2->sharedPtr = sharedPtr1;
// 再次查看引用计数
cout << "后 sharedPtr1的引用计数是:" << sharedPtr1.use_count() << endl;
cout << "后 sharedPtr2的引用计数是:" << sharedPtr2.use_count() << endl;
return 0;
}
输出结果:
前 sharedPtr1的引用计数是:1
前 sharedPtr2的引用计数是:1
后 sharedPtr1的引用计数是:2
后 sharedPtr2的引用计数是:2
你可以看到,并没有执行 Person1、Person2 的析构函数,原因是在进行互相引用之后,main() 弹栈之后两个对象的引用计数由 2 减为 1。由于不是 0,因此并不会调用析构函数回收这些对象。画个图便于理解:
以 Person1 为例,它会被 main() 中的 person1 指针引用,还会被 Person2 对象中的成员指针 sharedPtr 引用,使得它的引用计数为 2。main() 弹栈后,person1 指针被销毁,销毁时会对 Person1 对象的引用计数减 1,但是因为 Person2 的 sharedPtr 还保持着对 Person1 的引用,因此 Person1 的引用计数为 1 不会被销毁。但实际上已经没有栈的指针指向 Person1 对象了,此时它应该被回收,但是因为在堆上的相互引用而导致 Person1 无法被回收,Person2 同理。
3.4 使用 weak_ptr 解决循环引用问题
解决方法很简单,既然是堆上的相互引用导致了对象无法被回收,那么就利用 weak_ptr 不会增加引用计数的特性,让两个类内部的智能指针从 shared_prt 改为 weak_prt:
class Person2;
class Person1 {
public:
// shared_ptr<Person2> sharedPtr;
// 使用 shared_ptr 可能会导致循环引用而无法回收,改用 weak_prt
weak_ptr<Person2> weakPtr;
~Person1() {
cout << "执行 Person1 的析构函数" << endl;
}
};
class Person2 {
public:
// shared_ptr<Person1> sharedPtr;
// 使用 shared_ptr 可能会导致循环引用而无法回收,改用 weak_prt
weak_ptr<Person1> weakPtr;
~Person2() {
cout << "执行 Person2 的析构函数" << endl;
}
};
int main() {
// 创建 Person1、Person2 对象
auto *person1 = new Person1();
auto *person2 = new Person2();
// 创建 person1、person2 的 shared_ptr 指针
shared_ptr<Person1> sharedPtr1(person1);
shared_ptr<Person2> sharedPtr2(person2);
// 查看引用计数
cout << "前 sharedPtr1的引用计数是:" << sharedPtr1.use_count() << endl;
cout << "前 sharedPtr2的引用计数是:" << sharedPtr2.use_count() << endl;
// 使用 weakPtr 互相引用
person1->weakPtr = sharedPtr2;
person2->weakPtr = sharedPtr1;
// 再次查看引用计数
cout << "后 sharedPtr1的引用计数是:" << sharedPtr1.use_count() << endl;
cout << "后 sharedPtr2的引用计数是:" << sharedPtr2.use_count() << endl;
return 0;
}
再次运行程序:
前 sharedPtr1的引用计数是:1
前 sharedPtr2的引用计数是:1
后 sharedPtr1的引用计数是:1
后 sharedPtr2的引用计数是:1
执行 Person2 的析构函数
执行 Person1 的析构函数
3.5 自己实现一个 shared_ptr
手写智能指针的目的在于夯实前面的 C++ 基础,因为这里会用到很多前面学到过的知识。我们一步一步来。
最基本功能
基本功能有如下几点:
- 我们需要一个能存放任意类型的指针对象 object 以及一个引用计数器 count
- 当智能指针本身结束了声明周期时,应该将引用计数器减 1,并且如果引用计数为 0 还要释放所持有的指针所指向的对象
- 提供获取引用计数的函数 use_count()
代码如下:
template<typename T>
class custom_shared_ptr {
private:
// 智能指针持有的普通数据指针
T *object;
// 对 object 的引用计数
int *count;
public:
custom_shared_ptr(T *obj) : object(obj) {
count = new int(1);
}
~custom_shared_ptr() {
if (--(*count) == 0) {
// 回收 object 指向的对象
if (object) {
delete object;
object = nullptr;
}
// 回收在堆上声明的 count
delete count;
count = nullptr;
}
}
int use_count() {
return *count;
}
};
重写拷贝构造函数
除了声明时直接传入一个指针之外,还可以通过如下形式为智能指针赋值:
auto *student2 = new Student();
custom_shared_ptr<Student> sharedPtr3(student2);
custom_shared_ptr<Student> sharedPtr4 = sharedPtr3;
sharedPtr4 不会调用构造函数,而是通过拷贝构造函数获取到 sharedPtr3 的内容。这种情况下,是 sharedPtr4 指向了 sharedPtr3,因此 sharedPtr3 的引用计数是要加 1 的,这个应该重写拷贝构造函数:
// 拷贝构造函数,传入的是被拷贝的指针对象
custom_shared_ptr(const custom_shared_ptr<T> &ptr) {
cout << "执行 custom_shared_ptr 的拷贝构造函数" << endl;
if (count == nullptr) {
count = new int(0);
}
*count = ++(*ptr.count);
object = ptr.object;
}
重载 = 运算符
还有一种赋值的情况会对引用计数造成影响:
auto *student1 = new Student();
auto *student2 = new Student();
custom_shared_ptr<Student> sharedPtr1(student1);
custom_shared_ptr<Student> sharedPtr2(student2);
sharedPtr2 = sharedPtr1;
// 视频里将 custom_shared_ptr<Student> sharedPtr2; 也算作一种情况,实际上和上述代码的处理方式是一样的。
// 只不过是调用了有参还是无参的构造函数的区别,但是在重载 = 的处理方式上是一样的。而且我自定义的例子中
// 根本也没有提供 custom_shared_ptr 的空参构造函数,因为空参会导致内部的 object 没有具体的指向对象,容易
// 出现野指针的问题……
sharedPtr2 = sharedPtr1 使得 sharedPtr2 指向 sharedPtr1,导致 student1 的引用计数加 1,student2 的引用计数减 1,需要通过重载运算符 = 来实现更新引用计数的逻辑:
custom_shared_ptr &operator=(const custom_shared_ptr &ptr) {
cout << "= 运算符重载" << endl;
// 当前 custom_shared_ptr 会指向 ptr,因此
// 给 ptr 的引用计数加 1
++(*ptr.count);
// 当前 custom_shared_ptr 从所持有的指针的对象
// 上移走了,因此引用计数要减 1
if (--(*count) == 0) {
if (object) {
delete object;
object = nullptr;
}
delete count;
count = nullptr;
}
// 更新 object 和 count 为 ptr 的
object = ptr.object;
count = ptr.count;
return *this;
}
4、类型转换
C++ 的类型转换可以分为隐式转换和显式转换,隐式转换是指在某些情况下,C++ 编译器会自动执行的类型转换,比如隐式的将 int 转换为 double:
int num = 10;
double value = num;
而显式转换需要通过 C++ 提供的四种显式类型转换操作符进行转换。
4.1 const_cast
常量转换,用于添加或移除变量的常量性:
void sample1() {
const Person *p1 = new Person();
// 报错:不能通过常量指针去修改对象
// p1->name = "person1";
// 解决方法:通过 const_cast 将 p1 转换为非常量指针的 p2
Person *p2 = const_cast<Person *>(p1);
p2->name = "person2";
// 输出 p1.name = person2,成功修改了 p1 对象的内容
cout << "p1.name = " << p1->name << endl;
}
4.2 static_cast
静态转换,用于基本类型之间的转换,以及具有继承关系的类型之间的转换:
class Parent {
public:
void show() {
cout << "Parent show" << endl;
}
};
class Child : public Parent {
public:
void show() {
cout << "Child show" << endl;
}
};
void sample2() {
// 1.将 void * 转换为 int *
int number = 99;
void *pVoid = &number;
int *pInt = static_cast<int *>(pVoid);
cout << *pInt << endl;
// 2.父类转换为子类
auto *parent = new Parent;
parent->show();
// 静态转换是在编译期,转换后的类型是 = 左边声明的类型
Child *child = static_cast<Child *>(parent);
child->show();
// 回收规则,一定是谁 new 了就回收谁
delete parent;
}
输出:
99
Parent show
Child show
静态转换发生在编译期,因此在使用 static_cast 进行转换时,= 左侧声明的类型就是转换后的实际类型。
4.3 dynamic_cast
动态转换,用于在继承层次结构中进行安全的向下转型(downcast)。
使用时需要注意如下几点:
-
进行动态转换的前提是父类开启了多态,否则编译会报错
'Parent' is not polymorphic
:void test() { auto *parent = new Parent; // 'Parent' is not polymorphic auto *child = dynamic_cast<Child *>(parent); }
-
能够成功向下转型的前提是,对象本身就是子类对象,只不过前面用父类的指针指向了子类对象,在需要时才将父类的指针转换为子类指针。例如:
void test() { // 1.不行,对象本身是父类,而不是子类 auto *parent = new Parent; auto *child = dynamic_cast<Child *>(parent); // 2.可以,对象本身是子类 auto *parent1 = new Child; auto *child1 = dynamic_cast<Child *>(parent1); }
-
动态转换有返回值,返回 null 表示转换失败
-
动态转换是运行时转换,对象的实际类型都要看 = 右侧的类型:
void sample3() { // *parent 的实际类型是 = 右侧的 Parent auto *parent = new Parent; // auto *parent = new Child; // 试图将 *Parent 转换为 *Child,编译不会报错,但是 // 转换会失败,只有将上面的注释打开,即 parent 本身 // 实际上就是 Child 才能转换成功 auto *child = dynamic_cast<Child *>(parent); if (child) { cout << "转换成功" << endl; child->show(); } else { cout << "转换失败" << endl; } }
以下是正确的示例代码:
class Parent {
public:
// 动态转换需要父类开启了多态
virtual void show() {
cout << "Parent show" << endl;
}
};
class Child : public Parent {
public:
void show() {
cout << "Child show" << endl;
}
};
void sample3() {
auto *parent = new Child;
auto *child = dynamic_cast<Child *>(parent);
if (child) {
cout << "转换成功" << endl;
child->show();
} else {
cout << "转换失败" << endl;
}
}
另外,动态转换子类转父类是可以的,还是以运行时看 = 右侧的标准为准,转换后实际上是子类型(即 = 右侧类型):
void sample4() {
auto *child = new Child;
auto *parent = dynamic_cast<Parent *>(child);
if (parent) {
cout << "转换成功" << endl;
// 输出:转换成功 Child show
parent->show();
} else {
cout << "转换失败" << endl;
}
}
4.4 reinterpret_cast
用于将指针或引用转换为不同类型的指针或引用,通常用于低级操作或与底层代码的交互。需要谨慎使用,因为它不会进行任何类型检查。
示例代码:
void sample5() {
int value = 42;
// 使用 reinterpret_cast 进行指针类型转换
int *intValuePtr = &value;
char *charValuePtr = reinterpret_cast<char *>(intValuePtr);
cout << charValuePtr << endl;
// 使用 reinterpret_cast 进行引用类型转换
int &intValueRef = value;
char &charValueRef = reinterpret_cast<char &>(intValueRef);
cout << charValueRef << endl;
}
两个输出都是 42 对应的 ASCII 字符 *。
reinterpret_cast 还有一种常用方法,就是在数值和对象之间相互转换:
void sample6() {
// 将对象转换成值
auto *parent = new Parent;
auto parentValue = reinterpret_cast<uintptr_t >(parent);
// 将值转换成对象
auto *anotherParent = reinterpret_cast<Parent *>(parentValue);
cout << parent << "," << anotherParent << endl;
}
输出的 parent 和 anotherParent 是同一个地址,证明转换前后是同一个 Parent 对象。此外,将对象转换成数值时使用了 uintptr_t 类型,而没有使用 long,因为在某些平台上,long 可能只能存储较小范围的整数值,无法容纳一个指针的完整地址。如果强行使用 long 会编译报错:
auto *parent = new Parent;
// Cast from pointer to smaller type 'long' loses information
long parentLongValue = reinterpret_cast<long >(parent);
为了避免这个问题,我们才使用 uintptr_t
类型,它是一个无符号整数类型,足够大以容纳指针的完整地址。
5、nullptr
C++11 引入的关键字,用于表示空指针。它是一个字面常量,用于代表指针不指向任何有效对象或函数。
在早期的 C++ 版本中,通常使用整数常量 0 来表示空指针。然而,这样的表示方式可能引起一些问题,因为 0 也可以被解释为整数类型,导致在某些情况下发生歧义。
为了解决这个问题,C++11 引入了 nullptr
。它具有以下特点:
- 类型安全:
nullptr
是一个特殊的空指针类型,不会被隐式地转换为其他类型,从而避免了与整数类型的混淆 - 可用于指针和布尔上下文:
nullptr
可以在指针上下文和布尔上下文中使用。在指针上下文中,nullptr
表示空指针;在布尔上下文中,nullptr
被解释为false
。
nullptr
本意是替代 NULL 的,除此之外还有额外功能:
void show(int *i) {
cout << " show(int * i) " << endl;
}
void show(int i) {
cout << " show(int i) " << endl;
}
int main() {
show(6);
show(nullptr);
return 0;
}
输出:
show(int i)
show(int * i)
这是因为 nullptr
被解释为空指针常量,其类型是 nullptr_t
。在函数重载的情况下,编译器会选择最匹配的函数版本。由于 nullptr
可以隐式转换为任何指针类型,包括 int*
,因此应该调用 void show(int* i)
函数。
此外,nullptr 还有一个重要作用就是作为指针的初始值。因为很多指针在初始化的时候并不知道要指向哪个对象,此时可以使用 nullptr 为其初始化,避免野指针的同时在后续判断指针是否已经初始化过也很方便:
const char *name = nullptr;
...
if (name == nullptr) {
*name = "xxx";
}