跳过正文

丙加·第10章·Jack of All Trades, And Master of All(STL类型和函数)

·3143 字·15 分钟·
目录

本章将介绍常用的 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 等)则像是外壳,将不同的数据以特定方式和接口组织起来。

我们主要介绍 vectorlistmap 这三种,其实 STL 容器很多方法是互通的。依照传统,我们主要介绍它们的 CRUD(增删改查:CreateReadUpdateDelete

如果要使用 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_backemplace_back(C++11)

push_backemplace_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::erasestd::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::resizevector::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::replacestd::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_ofstd::none_of,都类似于 std::find_if,只是返回值是 truefalsestd::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::stringstd::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)。链表的特性决定了它可以方便地断开节点之间的连接,从而插入删除给定的节点:

第 10 章-正文-链表示意图

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::findstd::countstd::copystd::remove 都有 _if 版本;std::unique 本身带有 _if 的功能(可选的第三个参数,自定义“相等概念”),这里不再列出。

9.5 STL 智能指针
#

你,是否因为野指针的“深夜突袭”而彻夜难眠? 是否在内存泄漏的迷宫中反复横跳,眼睁睁看着程序在崩溃边缘疯狂试探?

那么,STL智能指针——今日正式为你降临!

C++ 独占版·超能三件套,总有一款让你尖叫! 

  1. unique_ptr——内存界的绝对领域霸主! “我的地盘,连我自己都不能复制!” 独占式守护,让拷贝行为彻底从你的人生消失,从此告别指针打架的混乱时代!
  2. shared_ptr——社交牛逼症患者的终极神器! “用过都说好,用完还能传家宝!” 引用计数黑科技,让资源在128个对象间疯狂流转,最后一个离开的靓仔还会顺手关灯清内存!
  3. 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) 之前,指针 p1p2 分别指向 Test(1)Test(2) 对象。进入 swap 函数进行参数传递时,p1p2 的所有权被转移给了函数形参 ab,导致它们自身变为 nullptr。在函数体内,ab 所指向的对象内容的确被成功交换了。然而,当 swap 函数结束时,形参 ab 因离开作用域而析构,分别删除了现在持有的 Test(2)Test(1) 对象,最终导致函数外部的 p1p2 都成为了空的 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 函数结束时,nasatsukasa 离开作用域,引用计数的确减少了,但是由于相互引用只减了1,此时引用计数为 1,没有归零,不会释放,于是 二人变成不死的魔阴身幸终❤ 两对象都在等着对方释放,导致了内存泄漏,所以不会看到析构函数的输出。

当然,这里的情况还算好,最后程序终止时操作系统会帮忙回收。但是,假设这一过程发生在某一个函数、对象内部,这个函数一再被调用……那么因此产生的苦命鸳鸯肯定不在少数。所以,为了 不让由绮夫妇变成魔阴身 解决这个问题,我们使用到了 weak_ptr 弱指针。

9.5.3 “电灯泡”:weak_ptr
#

为了解决 shared_ptr 循环引用导致的内存泄漏问题,C++ 引入了 weak_ptrweak_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 老师也是要交作业的(自己再写一遍)。 而且一日为师,________

  1. 词语频率统计器
    读取一段英文文本,使用合适的容器统计每个单词出现的次数,按字母顺序输出结果。

  2. 成绩管理系统
    使用合适的容器存储学生信息(姓名、成绩、学号),实现按成绩排序、按姓名查找等功能。

    提示:

    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
    
  3. 铠甲合体
    给定整数数组,使用 STL 算法实现:过滤偶数 → 每个元素平方 → 去重 → 求和。

  4. 智障指针

    下面的代码有若干错误,请找出并修正。

    #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;
    }
    
命令提示符@CommandPrompt-Wang
作者
命令提示符@CommandPrompt-Wang