本章将介绍常用的 STL 类型和函数,使用这些通用函数可以大大减少我们的代码量
9.1 站在 STL 的肩膀上 #
这是 牛顿与胡克 █████·█████ 与丹尼斯·里奇关于语言哲学地通信(大雾)
亲爱的先生:
读到你的来信,我非常高兴也非常满足。感谢你如此坦率地表达自己的想法,我认为你展现了真正的程序员精神。我不希望在代码风格上产生争执,也厌恶任何形式的争论,因此我很乐意接受你提出的私下通信的建议。毕竟,很多人在公开场合交流时,往往难免掺杂其他因素,而朋友之间私下探讨,更有助于追求真理。所以我希望我们之间可以保持这样的交流方式。
你的批评意见我会非常欢迎。虽然我过去对重复造轮子感到有些厌倦,到现在也没能重新提起兴趣,可能以后也不会,但如果有人通过简洁清晰的代码实现 最全面 的功能,那我依然很乐意看看。在这方面,我认为或许还是你更合适。如果你能帮助我,我会非常感激。
如果你认为我在某些语言哲学中过于武断,或者有做得不够好的地方,希望你能把你的看法告诉我。我并不是那么执着于自己的观点,只要讲得有道理,我愿意作出调整,以体现公正和友好。
不过我想说的是,不要过分看重我在这方面的能力。Bjarne Stroustrup 与 Alexander Stepanov 已经为我们打下了良好的基础 ¹。而你,在特定领域的性能优化方面,亦做出了诸多关键的推进。如果我的代码泛用性更强,那是因为我站在 STL 的肩膀上。当然,我知道你的确做出了很多不可替代的贡献,这些贡献大部分我都 乐于参考,至少有二:其一是核心范式和主要标准;其二,特别一提,是
strcut结构对数据的封装思想。除此之外还有很多,我也懒得一一统计了。因此在这些问题上,我还要多向你借鉴,特别是考虑到你用那全然暴露、毫无防护的struct,竟能构建出如此健壮的系统,这份在刀尖上跳舞的技艺,实在令我钦佩不已。²不过,这封信我就不想多展开了。你在信中提到让我保持语言简单的语法,但我已经 不小心 创建了分支 ³,当时我只是想临时引入一个
class方便我复用代码,所以没能按你的安排去做。³ 我本来想邀请你一起参与代码贡献,可惜没能联系到你。如果你仍希望我去保持原有的设计哲学,可以把具体的指示发给我,我会尽量参照你的要求去完成。
你忠实的朋友 █████·█████
寄给亲爱的朋友 丹尼斯·里奇 新泽西州,默里山,贝尔实验室 ⁴
¹ Bjarne Stroustrup,C++之父;Alexander Stepanov,STL 之父 ² 指 C 的
strcut内部完全暴露,容易被自由函数破坏;Unix 系统是 Dennis Ritchie 等人使用 C 写的 ³ C++一开始叫做 C with class ⁴ Dennis Ritchie,C 语言之父之一
接下来,我们将摆脱 C 语言的桎梏,进入 C++强大的模板世界。
9.2 真正的一人千役:STL 与泛型编程 #
我们之前提过 C 语言的泛型写起来比较笨重,需要反复写一样的代码:
// 必须为每种类型写重复代码
void sort_int(int arr[], int n);
void sort_double(double arr[], int n);
void sort_string(char* arr[], int n);
// 最多再来一个手工泛型
#define SORT(arr, n) _Generic((arr), \
int*: sort_int, \
double*: sort_double, \
char**: sort_string \
)((arr), (n))
// 具体实现就不写了
但是 STL 提供了与类型无关的算法(使用了 template 模板,参见第八章),并且提过相对完善的编译期优化:
#include <algorithm>
#include <vector>
#include <list>
#include <string>
// C数组的快排
// 但是需要手工计算大小
int c_array[] = {3, 1, 4, 1, 5};
int size = sizeof(c_array) / sizeof(c_array[0]);
std::sort(c_array, c_array + size);
如果是 C++全家桶的话,利用迭代器,甚至不需要模板,可以做到彻底的数据与算法分离。 ______自动爆炸
#include <algorithm>
#include <vector>
#include <deque>
#include <set>
// 新高考,新背景(大雾)
// 要掌握通性通法(弥天大雾)
std::vector<int> vec = {1, 2, 3, 4, 5};
std::deque<int> deq = {1, 2, 3, 4, 5};
std::set<int> tree = {1, 2, 3, 4, 5};
// 《完 全 一 致》
auto vec_it = std::find(vec.begin(), vec.end(), 3);
auto deq_it = std::find(deq.begin(), deq.end(), 3);
auto tree_it = std::find(tree.begin(), tree.end(), 3);
STL 的妙处就在此,使用“迭代器”让容器书同文、车同轨 秦始皇:打钱,并且使用类似指针的方式访问迭代器指向的元素(其实是覆写*运算符达成的引用,但是花火导演有言
才没有!
:如果一个东西长得像指针,符号像指针,效果像指针,那它就是指针!)。这样一来,C++类型初步实现了大一统,给更复杂的继承和多态奠定了基石。これが 本当 の 一人千役 です!这才是真正的一人千役啊!
9.3 La receptacle aux mille forme 别查了,就是“千面容器”啦 #
STL 容器有很多,主要分为:顺序性容器、关联式容器、容器适配器 三类。顺序性容器(如定/变长数组 array / vector、双向队列 deque、单/双向链表 forward_list / list)是最常用的容器类型;关联式容器(集合/多重集合 set / mutliset、映射/多重映射 map /mutlimap 等)常用于查找、管理关系;而容器适配器(堆栈 stack、单向队列 queue、优先队列 priority_queue 等)则像是外壳,将不同的数据以特定方式和接口组织起来。
我们主要介绍 vector、list、map 这三种,其实 STL 容器很多方法是互通的。依照传统,我们主要介绍它们的 CRUD(增删改查:Create、Read、Update、Delete)
如果要使用 xxx 类,一般就需要 #include<xxx>
9.3.1 向量 vector:顺序 の 快男 所以插入删除是萎货
#
vector 是 STL 的“向量”类型,不过多数时候我们把它当成无限数组使用。
9.3.1.1 创建 #
vector 的定义很简单:
// 记得 #include <vector>
std::vector<类型> 变量名;
// 比如
// 空数组
std::vector<int> intArray1;
// C风格导入
// 或者说“初始化列表”
std::vector<int> intArray2 = {1, 2, 3, 4, 5};
// 构造器语法1
// 5个元素,每一个都是0(默认值)
std::vector<int> intArray3(5);
// 构造器语法1
// 5个元素,每一个都是42
std::vector<int> intArray4(5, 42);
vector 不需要预先定义大小。因为 vector 是可扩展的:
// 使用push_back在结尾追加
intArray2.push_back(6);
vector 当然可以嵌套:
// 九九乘法表
vector<vector<int>> MultiplicationTable;
for (int i = 1; i <= 9; i++) {
vector<int> row; // 创建新行
for (int j = 1; j <= 9; j++) {
row.push_back(i * j); // 计算并添加乘积
}
MultiplicationTable.push_back(row); // 将行添加到乘法表
}
// 打印
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
cout << i + 1 << " × " << j + 1 << " = "
<< MultiplicationTable[i][j] << "\t";
}
cout << endl;
}
9.3.1.2 访问和遍历 #
访问具体元素的方法和 C 完全一致。当然你也可以采取 .at() 的 C++方法,后者不会出现下标越界(抛出 out_of_range 异常,接住了程序就不会崩,我们下一章讲):
int x = intArray2[0]; // 获取第一个元素
int y = intArray2.at(1); // 获取第二个元素
当然,vector 也会出现下标越界错误:
// 如果你试图修改:
// 空数组
std::vector<int> intArray1;
intArray1[0] = 999;
// 输出:
// 段错误 (核心已转储)
所以我们必须知道它的大小。不需要也不能用 sizeof() 运算,vector 有它的成员方法,很直接:
int size = intArray2.size();
使用 vector::empty() 来判空也不失为简洁的做法。
使用来得到开头和结尾的元素 引用(可以直接拿来修改的):
std::vector<int> a = {1, 2, 3, 4, 5};
a.back() = a.front();
// -> {5, 2, 3, 4, 5}
可以使用 迭代器(Iterator)进行顺序访问,迭代器的使用和指针几乎一致,可以形而上地理解为一种“高级指针”,你甚至可以将两个迭代器相减得到两元素之间的距离(9.3.3.6):
// auto推断出的具体类型是:std::vector<int>::iterator 很长,所以我们直接写auto
for (auto it = intArray2.begin(); it != intArray2.end(); it++) {
std::cout << *it << " ";
}
可以看到 vector::begin() 和 vector::end() 分别对应起始和结尾的迭代器。使用的是 != 而非 <,因为迭代器无法比较大小,这一点一开始使用的时候要注意。如果要倒序遍历,就要使用 vector::rbegin() 和 vector::rend(),对应类型 std::vector<类型名>::reverse_iterator,其中 r 表示反向(R everse)。同样使用的是 it++ 而非 it--。
注意:
vector.end()不指向任何一个实际的元素。举一个例子:
std::vector<int> vec = {10, 20, 30}; // vec 的布局:[10] [20] [30] ↑ // ↑ // 可以理解为 end() 指向这里所以访问
vector.end()会造成类似下标越界的错误。实际最后一个元素是vector.end() - 1这个看似反直觉的特性和 C++的左闭右开区间有关系,比如从
vector.begin()到vector.end()指的就是开头到结尾的所有元素。
C++11 还提供了一种更简洁的方式——范围循环,就完全不需要写开头和结尾了,这些交给编译器处理:
for (int element : intArray2) {
std::cout << element << " ";
}
9.3.1.3 增加内容 #
最直接的方法是在末尾插入,使用 vector::push_back() 方法,重复前面的例子:
intArray2.push_back(6);
// 返回插入位置的迭代器
碎碎念:
push_back和emplace_back(C++11)
push_back和emplace_back效果上完全相同,但是后者在处理类对象成员时候快一些。因为emplace_back优先考虑 移动语义 而不是深拷贝(参见:8.4.3 碎碎念),某些时候能提高运行效率。
如果要在中间插入元素,使用 vector::insert() 方法(返回值都是指向新元素(首个)的迭代器),有几种方式:
std::vector<int> intArray5 = {1, 4, 5};
// insert(m, x)
// 在指定位置m之前(以下统一说“m处”)插入元素x,插入的x就位于了m位置
// 可以使用C风格,但是语义有点不清晰
// intArray5.insert(1, 2);
intArray5.insert(intArray5.begin() + 1, 2);
// -> {1, 2, 4, 5}
// insert(m, n, x)
// 在指定位置m处插入n个元素x
// 也可以使用C风格,我就不写了
intArray5.insert(intArray5.begin() + 2, 4, 3);
// -> {1, 2, 3, 3, 3, 3, 4, 5}
// insert(m, a, b)
// 在m处插入其他容器(只要有数字下标[]都行)a~b之间的元素
// std::array和C数组一样是定长的(只是多了很多成员方法)
// 这里创建了int类型定长为3的数组
std::array<int,3> test = { 1, 4 };
// 所以这里intArray5.begin() + 8 == intArray5.end(),所以就是push_back啦
intArray5.insert(intArray5.begin() + 8, test.begin(), test.end());
// -> {1, 2, 3, 3, 3, 3, 4, 5, 1, 4}
// insert(m, {初始化列表,或者说C数组一样的东西})
intArray5.insert(intArray5.begin() + 1, {1, 4, 5, 1, 4});
// -> {1, 1, 4, 5, 1, 4, 2, 3, 3, 3, 3, 4, 5, 1, 4}
9.3.1.4 删除内容 #
使用 vector::erase()。用法很相似,总共有两种(接着之前的 intArray5),返回值是删除的(最后一个)元素的后一个元素的迭代器:
// erase(m)
// 删除m处的元素
intArray5.erase(intArray5.begin() + 6);
// 删掉了2,返回的是第一个3的迭代器
// -> {1, 1, 4, 5, 1, 4, 3, 3, 3, 3, 4, 5, 1, 4}
// erase(m,n)
// 删除[m, n)这个左闭右开区间的元素
// 注意前面说过`vector.end()`相当于最后一个元素的后面那块
// 不是实际的元素
// 删掉了{5, 1, 4, 3, 3, 3, 3, 4},返回指向5的迭代器
intArray5.erase(intArray5.begin() + 3, intArray5.begin() + 11);
// -> {1, 1, 4, 5, 1, 4}
可以使用 vector::clear() 一键清空:
intArray5.clear();
碎碎念:std:: erase
C++20 引入了新的
std::erase和std::erase_if,可以一键删除特定的元素:// v = {...}也行 std::vector<int> v{0, 1, 2, 3, 4, 5, 6, 7, 2, 8, 2, 9}; // 删除所有等于2的元素 std::erase(v, 2); // ->{ 0, 1, 3, 4, 5, 6, 7, 8, 9 } // 删除区间 std::erase(v, v.begin() + 3, v.begin() + 5); // ->{ 0, 1, 3, 6, 7, 8, 9 }
std::erase_if也差不多,只是第二个参数是一个函数指针,允许传入一个返回bool的判别函数,删除这个函数返回true时对应的所有元素:std::vector<int> v{0, 1, 2, 3, 4, 5, 6, 7, 2, 8, 2, 9}; // 删除所有偶数 std::erase(v, [](int n){ return n % 2 == 0; }); // ->{ 1, 3, 5, 7, 9 }
9.3.1.5 修改内容 #
vector::resize 可以直接调整大小:
std::vector<int> myvector;
// 初始内容设置
for (int i = 1; i <= 10; i++) myvector.push_back(i);
// 调整大小为5,多余的元素被删除
myvector.resize(5);
// 调整大小为8,新元素用100填充
myvector.resize(8, 100);
// 调整大小为12,新元素默认初始化
myvector.resize(12);
碎碎念:
vector::resize和vector::reserve二者都具有上面第三种语法,但是行为有差别。一句话,
vector::resize直接增加元素;vector::reserve会预分配空间但是不增加元素总数。后者是为了加快访问速度做出的预分配
如果要替换整个数组,可以使用 assign :
std::vector<int> vec = {1, 2, 3};
vec.assign({4, 5, 6, 7}); // 完全替换内容
// 当然,指定迭代器区间assign(xxx.begin() + 5, xxx.end())是完全可以的
// 这是C++ STL的通用语言
如果我们要替换某个元素,该怎么办呢?很遗憾,没有子方法可以做到,但这并不意味着我们要手写。我们可以使用 std::replace 和 std::replace_if。现在我们学着阅读下函数签名,看看它支持怎样的语法:
template <class ForwardIterator, class T>
void replace (ForwardIterator first, ForwardIterator last,
const T& old_value, const T& new_value)
很简单了吧?std::replace(查找区间开始, 查找区间结束, 查找值, 替换值)。我们再试试 std::replace_if:
// 解释:UnaryPredicate 一元谓词
// 指的是接受一个参数,返回true和false的函数
template < class ForwardIterator, class UnaryPredicate, class T >
void replace_if (ForwardIterator first, ForwardIterator last,
UnaryPredicate pred, const T& new_value)
这个稍难,门槛在于 UnaryPredicate,理解之后就没有压力了。
碎碎念:一个很深的坑
考虑下面的程序:
#include <iostream> int main() { int a[] = {1, 1, 9, 5, 1 ,9}; // C风格指针会被转化为迭代器 std::replace(a, a + 6, a[2], 4); for (int element : a) { std::cout << element << " "; } return 0; }试试,输出是:
1 1 4 5 1 9大失所望是吧
(大雾)。为什么呢?我们再看看template <class ForwardIterator, class T> void replace (ForwardIterator first, ForwardIterator last, const T& old_value, const T& new_value)看到了吗,签名是
const T& old_value!传递的是引用!也就是你本想std::replace(a, a + 6, 9, 4);但是第一次修改后a[2]从 9 变成了 4,于是语句就相当于一句废话:std::replace(a, a + 6, 4, 4);自然没效果了。
如果你想要排序,就使用 std::sort 吧:
std::sort(myVector.begin(), myVector.end());
// 同样可以传入比较函数
// 这样会反着排
// 更复杂的例子参见6.4.2函数指针
std::sort(myVector.begin(), myVector.end(), [](int a, int b){return a > b;});
// C数组我习惯这样写
// size预先计算好
// +1相当于从最后一个元素推到end()的位置
std::sort(arr, arr + size + 1);
9.3.1.6 查找内容 #
使用 std::find 来查找具体的值:
std::vector<int> v = { 1, 9, 19, 8, 10, 233 };
// 返回得到的第一个元素
auto it = std::find(v.begin(), v.end(), key);
// 如果到返回结尾,那肯定是没找到
if (it != v.end()) {
// 获取位置(从0开始计数)
// 迭代器相减可以得到元素间的距离
int position = it - v.begin();
std::cout << "Element found at position: " << position;
// 输出具体的值
std::cout << ", value: " << *it;
}
else {
std::cout << "Element not found";
}
std::find_if 用法类比前面,不再赘述。
你一定注意到了这些函数命名都有规律。如果你觉得 STL 有 xx 功能,这个功能可以 yy 扩展,但是书里面一点没提,那么你完全可以 自行搜索 xx 与 yy 来确认你的想法。面向百度/必应/谷歌编程 也是一种技能——如果你可以筛选合理的信息,就能少敲很多东西。
可以使用 std::count 来知道容器里面有多少指定的东西,这也是一种低效的检验元素存在性的方法(因为会遍历整个容器):
std::vector<int> v = {1, 1, 4, 5, 1 ,4};
// 返回3
std::count(v.begin(), v.end(), 1);
C++11 还有 std::any_of 和 std::none_of,都类似于 std::find_if,只是返回值是 true 和 false。std::any_of 是找到匹配的值就返回 true;而 std::none_of 是找到匹配的值就返回 false。
另外还有去重函数 std::unique:
// 使用默认的相等比较
template<class ForwardIt>
ForwardIt unique(ForwardIt first, ForwardIt last);
// 使用自定义的二元谓词(自定义“相等”的概念)
template<class ForwardIt, class BinaryPredicate>
ForwardIt unique(ForwardIt first, ForwardIt last, BinaryPredicate p);
std::string:std::vector异父异母的亲兄弟这里的意思是两者都是动态数组,支持
push_back()、pop_back()、size()、[]访问,很多方法都通用。但是std::string还是有它的个性:std::string s = "hello"; s.find("ell"); // 字符串查找 s.substr(1, 3); // 子串提取 -> "ell" s += " world"; // 重载的字符串拼接
9.3.2 链表 list:插入 拔 移出 の 神 所以随机访问是杂鱼
#
std::list 是 STL 的双向链表类型(单向链表是 std::forward_list)。链表的特性决定了它可以方便地断开节点之间的连接,从而插入删除给定的节点:
9.3.2.1 创建 #
这个和 vector 差不多:
std::list<int> lst1; // 空
std::list<int> lst2(5); // 5个默认元素(0)
std::list<int> lst3(5, 10); // 5个10
std::list<int> lst4 = {1, 2, 3, 4}; // 初始化列表
9.3.2.2 特色方法 #
STL 容器有很多通用方法,在 vector 里面我们已经讲了很大一部分了,我们接下来只介绍链表的特殊方法,下表 供参考而非背诵:
(这个是我抄的 某教程)
| 函数 | 说明 | 再说几句 |
|---|---|---|
push_back(const T& val) |
在链表末尾添加元素 | 和 vector 一致 |
push_front(const T& val) |
在链表头部添加元素 | 链表特有的,因为可以方便地直接接上首个元素而不需要像 vector 一样一个个向后移动腾位置 |
pop_back() |
删除链表末尾的元素 | 和 vector 一致 |
pop_front() |
删除链表头部的元素 | 弹出首个元素(链表里面没了),返回那个元素的值。 链表特有的,理由同 push_back |
insert(iterator pos, val) |
在指定位置插入元素 | |
erase(iterator pos) |
删除指定位置的元素 | |
clear() |
清空所有元素 | |
size() |
返回链表中的元素数量 | |
empty() |
检查链表是否为空 | |
front() |
返回链表第一个元素 | |
back() |
返回链表最后一个元素 | |
remove(const T& val) |
删除所有等于指定值的元素 | |
sort() |
对链表中的元素进行排序 | std::list 是一个双向链表。它的元素在内存中不是连续存储的,因此无法进行像快速排序 std::sort 这样的需要连续内存、随机访问的高效排序。所以 std::list 自己开了个小灶。 |
merge(list& other) |
合并另一个 已排序 的链表 | 没排序是 未定义行为! |
reverse() |
反转链表 | |
begin() / end() |
返回链表的起始/结束迭代器 |
9.3.3 映射 map:捆绑 playの 大师 而且会自动排序
#
std::map 是 STL 的关联容器,存储 键值对(key-value pairs),基于红黑树实现自动排序。
9.3.3.1 创建和基本操作 #
#include <map>
#include <string>
std::map<std::string, int> ageMap = {
{"Alice", 20},
{"Bob", 25},
{"Charlie", 30}
};
// 插入元素
ageMap["David"] = 28;
ageMap.insert({"Eve", 22});
// 访问元素
cout << "Alice's age: " << ageMap["Alice"] << endl;
cout << "Bob's age: " << ageMap.at("Bob") << endl;
// 检查键是否存在
if (ageMap.find("Frank") == ageMap.end()) {
cout << "Frank not found!" << endl;
}
9.3.3.2 特色方法 #
| 函数 | 说明 | 多说两句 |
|---|---|---|
operator[] |
访问或插入元素 | 注意上面的例子(line: 11),严格说不算是下标了 如果键不存在,会创建新元素(值默认初始化) |
at() |
安全访问元素 | 键不存在时抛出 std::out_of_range 异常注意差异!! 这个在 map 里面尤其重要 |
find() |
查找元素 | 返回迭代器,找不到返回 end() |
count() |
统计键出现次数 | 被削了 (如果按速度算也可以说加强了) 对于 map 只能是 0 或 1(因为键唯一) |
erase() |
删除元素 | 可以按键删除或按迭代器删除 |
clear() |
清空所有元素 | |
size() |
返回元素数量 | |
empty() |
检查是否为空 | |
begin()/end() |
返回迭代器 | 遍历时按键排序顺序 |
9.4 STL 算法库 #
相信经过前面和容器的深 ♂ 入 ♂ 交 ♂ 流,读者已经记住了 STL 的形状了。我们直接一表流:
| 算法 | 功能 | 头文件 | 示例用法 | ??? |
|---|---|---|---|---|
std::sort |
排序 | <algorithm> |
sort(vec.begin(), vec.end()) |
快排仙人 |
std::find |
查找元素 | <algorithm> |
find(vec.begin(), vec.end(), 42) |
只找第一个 找不到就摆烂 |
std::count |
统计出现次数 | <algorithm> |
count(vec.begin(), vec.end(), 42) |
老实人 会数完所有 |
std::copy |
复制区间 | <algorithm> |
copy(src.begin(), src.end(), dest.begin()) |
容器的 Ctrl+C/V 将 src 的内容复制到 dest.begin() 处(前) |
std::accumulate |
累加计算 | <numeric> |
accumulate(vec.begin(), vec.end(), 0) |
一键累加 |
std::max_element |
找最大值 | <algorithm> |
max_element(vec.begin(), vec.end()) |
打擂台! (Oler 可能熟悉这说的是啥) |
std::reverse |
反转序列 | <algorithm> |
reverse(vec.begin(), vec.end()) |
!阿米诺斯 |
std::unique |
去重相邻重复 | <algorithm> |
unique(vec.begin(), vec.end()) |
合并同类项 |
std::transform |
对每个元素操作 | <algorithm> |
transform(vec.begin(), vec.end(), result.begin(), [](int x){return x*2;}) |
容器地图炮 |
std::remove |
删除元素 | <algorithm> |
remove(vec.begin(), vec.end(), 2) |
做事做一半的杂鱼 * |
* 删除满足条件的元素,但 不改变容器大小,所以前面会有“幽灵元素”
本质上是将找到的元素扔到前部,返回前面那一坨元素的结尾。所以需要配合 std::erase 才能真正 remove:
// 假设有一个vector<int> v
auto logic_end = std::remove_if(v.begin(), v.end(), [](int x) { return x % 2 == 0; });
v.erase(logic_end, v.end());
// 等价于v.erase(std::remove_if(v.begin(), v.end(), [](int x) { return x % 2 == 0; }), v.end());
另外,std::find、std::count、std::copy、std::remove 都有 _if 版本;std::unique 本身带有 _if 的功能(可选的第三个参数,自定义“相等概念”),这里不再列出。
9.5 STL 智能指针 #
你,是否因为野指针的“深夜突袭”而彻夜难眠? 是否在内存泄漏的迷宫中反复横跳,眼睁睁看着程序在崩溃边缘疯狂试探?
那么,STL智能指针——今日正式为你降临!
C++ 独占版·超能三件套,总有一款让你尖叫!
unique_ptr——内存界的绝对领域霸主! “我的地盘,连我自己都不能复制!” 独占式守护,让拷贝行为彻底从你的人生消失,从此告别指针打架的混乱时代!shared_ptr——社交牛逼症患者的终极神器! “用过都说好,用完还能传家宝!” 引用计数黑科技,让资源在128个对象间疯狂流转,最后一个离开的靓仔还会顺手关灯清内存!weak_ptr——顶级海王的防绿码系统! “随时观察,绝不纠缠!” 智能监视而不增加负担,专治循环引用导致的内存殉情惨案,让相爱相杀的对象从此获得永生!🔥 现在选择STL智能指针,你将同时获得: ✅ 自动内存管理——比初恋更贴心的“随叫随到,用完即走”服务 ✅ 异常安全防护——就算程序崩溃到面目全非,资源照样潇洒归位 ✅ 零额外性能损耗——比德芙更丝滑的底层操作,原地开启性能狂暴模式
还在等什么?立即拨打编译器热线
#include <memory>,告别手动delete的史前时代!让你的代码在 年彻底实现——内存自由!
智能指针其实是一个类。只不过重载了 *、-> 等运算符让它操作起来像是指针。如果一个东西声明像指针、赋值像指针、解引用像指针……那它就是指针(lambda:在说我的事?),或者说,智能指针——安全管理、自动释放等特性可是裸指针学不来的。
智能指针需要 #include <memory>。
9.5.0 “太上皇”:auto_ptr
#
这是已弃用的特性
这是智能指针的始祖。基本 实现了指针的智能管理,以及对所持有对象的独占权。虽然问题其实不少,但是的确适合作为介绍智能指针的开始。
智能指针操作起来 某种意义上 真的很像 C 指针,真的:
#include <iostream>
#include <memory>
class Test {
public:
Test(int val) : value(val) {
std::cout << "Test constructed,value = " << value << std::endl;
}
~Test() {
std::cout << "Test deconstructed,value = " << value << std::endl;
}
void print() {
std::cout << "Value: " << value << std::endl;
}
private:
int value;
};
int main() {
// 创建
std::auto_ptr<Test> p1(new Test(10));
p1->print();
// 转移所有权
// 此时 p1 变为空,所有权转移给 p2
std::auto_ptr<Test> p2 = p1;
if (p1.get() == nullptr) {
std::cout << "p1 now empty" << std::endl;
}
p2->print();
return 0;
// p2 离开作用域,自动释放内存
}
看起来……很方便,不是吗?那么问题在哪呢?
第一个问题出在转移所有权上。它是无条件的,也就是这样的函数会出问题:
// 且先不讨论 T 能否合法构造,能不能赋值等问题
template<typename T>
void swap(std::auto_ptr<T> a, std::auto_ptr<T> b) {
T temp;
temp = *a;
*a = *b;
*b = temp;
}
// 某处
Test p1(1);
Test p2(2);
swap(p1, p2)
你本想仿写 C 的做法,通过指针交换值,但是所有权转移链条导致了所有人受伤的世界达成了:
在调用 swap(p1, p2) 之前,指针 p1 和 p2 分别指向 Test(1) 和 Test(2) 对象。进入 swap 函数进行参数传递时,p1 和 p2 的所有权被转移给了函数形参 a 和 b,导致它们自身变为 nullptr。在函数体内,a 和 b 所指向的对象内容的确被成功交换了。然而,当 swap 函数结束时,形参 a 和 b 因离开作用域而析构,分别删除了现在持有的 Test(2) 和 Test(1) 对象,最终导致函数外部的 p1 和 p2 都成为了空的 nullptr 指针,这显然是一个不符合预期的错误结果。
调用 swap(p1, p2) 前:
p1 -> Test(1)
p2 -> Test(2)
进入 swap 时(参数传递):
p1 → nullptr (所有权给了 a)
p2 → nullptr (所有权给了 b)
a → Test(1)
b → Test(2)
swap 函数体内:
a → Test(2) // 交换了对象内容
b → Test(1) // 交换了对象内容
swap 函数结束时:
a 析构 → 删除 Test(2)
b 析构 → 删除 Test(1)
p1 → nullptr // 空的!
p2 → nullptr // 空的!
如果是 swap(p1, p1); 甚至没有出函数体就会发生错误。具体原因读者可以自行推导。打补丁的话,使用引用传递会好一些:std::auto_ptr<T> a,但治标不治本。总之不推荐使用 auto_ptr。
另外,以下代码是危险的:
std::auto_ptr<Test> p(new Test[5]);
你希望分配一个数组给 auto_ptr,但是 auto_ptr 只会一味的使用 delete 删除,而不是 delete[]。轻则内存泄漏(释放不彻底),重则程序崩溃。
最后,auto_ptr 缺乏引用计数,所以使用多个 auto_ptr 指向同一对象也会出问题:你希望等到最后一个指针离开作用域才释放,但是 auto_ptr 的特性决定了它会在第一个指针离开作用域时释放,进而导致空指针。
上面的介绍,并不是为了批判 auto_ptr,相反,auto_ptr 是 C++ 智能指针的早期尝试,其中的思想和规划孕育了其它智能指针的出现。当然,由于 auto_ptr 的确坑太多,所以我们还是尽量避免它的使用,让它安心当好智能指针的太上皇吧!
9.5.1 “病娇”:unique_ptr
#
unique_ptr 是 C++ 11 引入的 auto_ptr 的替代品,auto_ptr 有的 unique_ptr 基本都有 bug除外,包括更完善的对持有对象的独占权(两个 unique_ptr 不能指向一个对象)实现——这也是 unique_ptr 得名的原因 病娇可爱捏。以下是一些基本操作:
// ==================== 创建操作 ====================
// 1. 创建空智能指针
std::unique_ptr<int> up1(nullptr);
// 2. 创建时直接指定管理对象
std::unique_ptr<int> up2(new int(1));
// 也可以 auto up2 = std::make_unique<int>(1)
// 3. 使用自定义删除器替代默认的 std::default_delete<T>
// 注:D 可以是任何函数对象类型 - 普通函数、lambda、重载operator()的类等
std::unique_ptr<int, CustomDeleter> up3(new int(1)); // 使用默认构造的删除器
std::unique_ptr<int, CustomDeleter> up4(new int(1), CustomDeleter()); // 传递删除器实例
// ==================== 所有权管理 ====================
// 4. 释放所有权:返回裸指针,智能指针变为空
// 注意:调用者需要手动管理返回的裸指针的生命周期
int *raw_ptr = up2.release();
// 5. 转移所有权:通过移动语义
std::unique_ptr<int> up5 = std::move(up2); // up2 变为空指针
// 6. 转移所有权:通过 release + reset 组合
up5.reset(up3.release()); // up3 释放所有权,up5 接管
// ==================== 资源管理 ====================
// 7. 重置管理对象:销毁当前对象,绑定新对象
up5.reset(new int(1)); // 先销毁原对象,再管理新对象
// 8. 显式销毁对象:将智能指针置为空,销毁当前管理对象
up5 = nullptr; // 等价于 up5.reset()
// ==================== 访问操作 ====================
// 9. 获取裸指针(但不释放所有权)
int* ptr = up5.get();
// 10. 访问管理对象
// 这一点与裸指针完全一致
int value = *up5; // 解引用
up5->a_method(); // 箭头操作符
// 11. 检查是否为空
if (up5) { // 或 up5 != nullptr
// 非空处理
}
碎碎念:你可能想这么干
如果你一身反骨(bushi),非要挑战“独占性”的极限,想知道以下代码的运行结果:
#include <memory> int main () { // 如果我这样干? int* p = new int(20180302); std::unique_ptr<int> up1(nullptr), up2(nullptr); up1.reset(p); up2.reset(p); return 0; }答案是会发生严重的双重释放问题:
free(): double free detected in tcache 2 已中止 (核心已转储)(Windows 不会产生明显现象,微软又一次为糟糕的程序员擦了屁股 \o/)
是的,病娇也有娇的一面:
unique_ptr不会,也不能检查传入的指针到底有没有被占用,但它单纯地认为没有。于是最终,p的地址会被 释放两次,继续运行会造成严重的问题。这或许就是病娇“病”的一面吧。
9.5.2 “修罗场”:shared_ptr
#
使用 unique_ptr 只能独占一个对象。如果我们希望对象 沦❤为❤玩❤物 被多个对象持有,就需要 shared_ptr 了。shared_ptr 通过一个私有计数器来计算引用数量,以保证在最后一个指针释放时,才销毁对象。由于shared_ptr 接口与 unique_ptr 差不多,这里的例子只展示特有的功能。
#include <iostream>
#include <memory>
class Resource {
public:
Resource(int id) : id(id) {
std::cout << "创建资源 " << id << std::endl;
}
~Resource() {
std::cout << "销毁资源 " << id << std::endl;
}
void use() {
std::cout << "使用资源 " << id << std::endl;
}
private:
int id;
};
int main() {
// 创建 shared_ptr
std::shared_ptr<Resource> sp1 = std::make_shared<Resource>(19690714);
// 共享所有权
std::shared_ptr<Resource> sp2 = sp1; // 引用计数 +1
std::shared_ptr<Resource> sp3 = sp2; // 引用计数再 +1
std::cout << "引用计数: " << sp1.use_count() << std::endl; // 输出 3
// 所有 shared_ptr 都可以使用资源
sp1->use();
sp2->use();
sp3->use();
// 释放部分引用
sp2.reset(); // 引用计数 -1
std::cout << "sp2 释放后引用计数: " << sp1.use_count() << std::endl;
sp3.reset(); // 引用计数 -1
std::cout << "sp3 释放后引用计数: " << sp1.use_count() << std::endl;
// 当 sp1 也离开作用域时,引用计数归零,资源被销毁
return 0;
}
输出结果:
创建资源 19690714
引用计数: 3
使用资源 19690714
使用资源 19690714
使用资源 19690714
sp2 释放后引用计数: 2
sp3 释放后引用计数: 1
销毁资源 19690714
循环引用问题:二人幸终 #
shared_ptr 虽然强大,但也有一个致命的弱点:循环引用。当两个或多个 shared_ptr 相互引用时,会导致引用计数永远无法归零,从而产生内存泄漏。
#include <iostream>
#include <memory>
class Wife; // 嵌套成员需要前向声明,参见 9.2.1 碎碎念
class Husband {
public:
std::string name;
std::shared_ptr<Wife> wife_ptr; // 丈夫持有妻子的 shared_ptr
Husband(const std::string& n) : name(n) {
std::cout << name << "'s constructor" << std::endl;
}
~Husband() {
std::cout << name << "'s destructor" << std::endl;
}
void setWife(std::shared_ptr<Wife> wife) {
wife_ptr = wife;
}
};
class Wife {
public:
std::string name;
std::shared_ptr<Husband> husband_ptr; // 妻子持有丈夫的 shared_ptr
Wife(const std::string& n) : name(n) {
std::cout << name << "'s constructor" << std::endl;
}
~Wife() {
std::cout << name << "'s destructor" << std::endl;
}
void setHusband(std::shared_ptr<Husband> husband) {
husband_ptr = husband;
}
};
int main() {
auto nasa = std::make_shared<Husband>("Yuzaki Nasa");
auto tsukasa = std::make_shared<Wife>("Yuzaki Tsukasa");
std::cout << "=== Yuzaki couple got married ===" << std::endl;
// 建立婚姻关系 - 创建循环引用
nasa->setWife(tsukasa); // tsukasa 引用计数变为 2
tsukasa->setHusband(nasa); // nasa 引用计数变为 2
std::cout << nasa->name << "'s reference count: " << nasa.use_count() << std::endl; // 2
std::cout << tsukasa->name << "'s reference count: " << tsukasa.use_count() << std::endl; // 2
std::cout << "The Yuzaki couple lived happily..." << std::endl;
std::cout << nasa->name << " loves his wife " << nasa->wife_ptr->name << std::endl;
std::cout << tsukasa->name << " loves her husband " << tsukasa->husband_ptr->name << std::endl;
return 0;
}
输出:
Yuzaki Nasa's constructor
Yuzaki Tsukasa's constructor
=== Yuzaki couple got married ===
Yuzaki Nasa's reference count: 2
Yuzaki Tsukasa's reference count: 2
The Yuzaki couple lived happily...
Yuzaki Nasa loves his wife Yuzaki Tsukasa
Yuzaki Tsukasa loves her husband Yuzaki Nasa
在 main 函数结束时,nasa 和 tsukasa 离开作用域,引用计数的确减少了,但是由于相互引用只减了1,此时引用计数为 1,没有归零,不会释放,于是 二人变成不死的魔阴身幸终❤ 两对象都在等着对方释放,导致了内存泄漏,所以不会看到析构函数的输出。
当然,这里的情况还算好,最后程序终止时操作系统会帮忙回收。但是,假设这一过程发生在某一个函数、对象内部,这个函数一再被调用……那么因此产生的苦命鸳鸯肯定不在少数。所以,为了 不让由绮夫妇变成魔阴身 解决这个问题,我们使用到了 weak_ptr 弱指针。
9.5.3 “电灯泡”:weak_ptr
#
为了解决 shared_ptr 循环引用导致的内存泄漏问题,C++ 引入了 weak_ptr。weak_ptr 就像一个“电灯泡”,它默默照亮 shared_ptr 管理的对象,但不会增加引用计数,且会在对象销毁时自动销毁,需要时可以通过 lock() 方法临时获得一个 shared_ptr 来使用对象。也可以把 weak_ptr 看成快捷方式——本体被删除,快捷方式也会失效。这就与硬链接(作用于文件,效果与 shared_ptr 十分相似)不同了。
#include <iostream>
#include <memory>
class Wife; // 前向声明
class Husband {
// 下面已经定义了完整的 setter 和 getter 了
// 就没必要也最好不把 wife_ptr 做成公有的了
private:
std::weak_ptr<Wife> wife_ptr; // 改为弱引用,不增加引用计数
public:
std::string name;
Husband(const std::string& n) : name(n) {
std::cout << name << "'s constructor" << std::endl;
}
~Husband() {
std::cout << name << "'s destructor" << std::endl;
}
void setWife(std::shared_ptr<Wife> wife) {
wife_ptr = wife;
}
// 获取妻子指针(可能为空)
std::shared_ptr<Wife> getWife() {
return wife_ptr.lock(); // 尝试拿到 shared_ptr
}
};
class Wife {
private:
std::weak_ptr<Husband> husband_ptr; // 改为弱引用,不增加引用计数
public:
std::string name;
Wife(const std::string& n) : name(n) {
std::cout << name << "'s constructor" << std::endl;
}
~Wife() {
std::cout << name << "'s destructor" << std::endl;
}
void setHusband(std::shared_ptr<Husband> husband) {
husband_ptr = husband;
}
// 获取丈夫指针(可能为空)
std::shared_ptr<Husband> getHusband() {
return husband_ptr.lock(); // 尝试拿到 shared_ptr
}
};
int main() {
auto nasa = std::make_shared<Husband>("Yuzaki Nasa");
auto tsukasa = std::make_shared<Wife>("Yuzaki Tsukasa");
std::cout << "=== Yuzaki couple got married ===" << std::endl;
// 建立婚姻关系
nasa->setWife(tsukasa);
tsukasa->setHusband(nasa);
std::cout << "Yuzaki Nasa's reference count: " << nasa.use_count() << std::endl; // 1
std::cout << "Yuzaki Tsukasa's reference count: " << tsukasa.use_count() << std::endl; // 1
std::cout << "The Yuzaki couple lived happily..." << std::endl;
if (auto wife = nasa->getWife()) {
std::cout << nasa->name << " loves his wife " << wife->name << std::endl;
}
if (auto husband = tsukasa->getHusband()) {
std::cout << tsukasa->name << " loves her husband " << husband->name << std::endl;
}
return 0;
}
输出:
Yuzaki Nasa's constructor
Yuzaki Tsukasa's constructor
=== Yuzaki couple got married ===
Yuzaki Nasa's reference count: 1
Yuzaki Tsukasa's reference count: 1
The Yuzaki couple lived happily...
Yuzaki Nasa loves his wife Yuzaki Tsukasa
Yuzaki Tsukasa loves her husband Yuzaki Nasa
Yuzaki Tsukasa's destructor
Yuzaki Nasa's destructor
现在终于能看到正常析构。两人正常幸终
此外,weak_ptr 还有 电灯泡的 独家方法 expired() 用于检测 嗑的 cp 是否完结 对象是否已销毁:
if (husband->expired()) {
// 完结散纸花 (T⌓T)
}
总之,智能指针可以自动管理对象生命周期,在正确使用的免去了忘记手动释放的烦恼,也让对象的独占、多占更为规范安全。所以
赶紧 打电话 #include 使用吧!
课后作业:
主要的目的是学会使用工具 和面向百度编程(或者DeepSeek) ,所以出现没讲的东西或者现炒现卖是故意的。请一定用一切手段完成
但是别无脑扔给 DeepSeek 然后复制粘贴,请教了 D 老师也是要交作业的(自己再写一遍)。 而且一日为师,________
-
词语频率统计器
读取一段英文文本,使用合适的容器统计每个单词出现的次数,按字母顺序输出结果。 -
成绩管理系统
使用合适的容器存储学生信息(姓名、成绩、学号),实现按成绩排序、按姓名查找等功能。提示:
std::tuple<int, double, std::string> tpl1 = {1, 1.4, "514"}; // 也可以直接塞进构造器,看着像套娃: /* { {"Alice", 85, "S001"}, {"Bob", 92, "S002"}, {"Charlie", 78, "S003"}, {"David", 95, "S004"} } */ auto tpl2 = std::make_tuple(1, 2.3, "hello"); // -->得到(1, 2.3, "hello") // 具体类型是std::tuple<int, double, std::string> std::cout << std::get<1>(tpl2) << std::endl; // 输出2.3 -
铠甲合体
给定整数数组,使用 STL 算法实现:过滤偶数 → 每个元素平方 → 去重 → 求和。 -
智障指针
下面的代码有若干错误,请找出并修正。
#include <iostream> #include <memory> class Resource { public: Resource(int id) : id(id) { std::cout << "Creating Resource " << id << std::endl; } ~Resource() { std::cout << "Destroying Resource " << id << std::endl; } void use() { std::cout << "Using Resource " << id << std::endl; } private: int id; }; class NodeA; class NodeB; class NodeA { public: std::shared_ptr<NodeB> b_ptr; ~NodeA() { std::cout << "NodeA destroyed" << std::endl; } }; class NodeB { public: std::shared_ptr<NodeA> a_ptr; ~NodeB() { std::cout << "NodeB destroyed" << std::endl; } }; int main() { int* raw_ptr = new int(42); std::unique_ptr<int> up1(raw_ptr); std::unique_ptr<int> up2(raw_ptr); std::unique_ptr<Resource> r1(new Resource(1)); std::unique_ptr<Resource> r2 = std::move(r1); r1->use(); auto nodeA = std::make_shared<NodeA>(); auto nodeB = std::make_shared<NodeB>(); nodeA->b_ptr = nodeB; nodeB->a_ptr = nodeA; std::unique_ptr<int> arr_ptr(new int[10]); std::shared_ptr<Resource> shared_res = std::make_shared<Resource>(2); Resource* raw_res = shared_res.get(); delete raw_res; std::shared_ptr<FILE> file_ptr(fopen("test.txt", "w")); fclose(file_ptr.get()); return 0; }