更新时间: 2026-08-27
前面几篇说过"容器存多态用智能指针",这次更进一步——用 unique_ptr 来构建数据结构本身。经典的 C 链表是 struct Node { int data; struct Node* next; }; 加手动 malloc/free;这一篇用 std::unique_ptr<Node> 当 next,看所有权语义怎么让链表"自己管理自己"。
本文要回答:用 unique_ptr 当 next 指针,链表为什么不用写析构和释放代码?递归析构的风险是什么?它和裸指针版差在哪?
一、需求与设计
需求:
- 单链表:push_front、push_back、遍历打印、find、size
- 不写
delete、不写析构函数(除自定义)
设计:
@startmindmap
* 链表(unique_ptr 版)
** Node
*** int data
*** unique_ptr<Node> next(关键!)
** LinkedList
*** unique_ptr<Node> head
*** push_front / push_back / print / find / size
*** ~LinkedList(递归释放需要显式析构)
@endmindmap核心点:next 用 std::unique_ptr<Node> 而不是裸 Node*。这样每个节点"拥有"下一个节点,头节点"拥有"整个链——链表被谁持有,谁就负责销毁,所有权关系是一条直线,天然无环。
二、代码设计
#include <iostream>
#include <memory>
#include <utility>
struct Node {
int data;
std::unique_ptr<Node> next; // 关键:next 用 unique_ptr
explicit Node(int d) : data(d) {}
};
class LinkedList {
public:
// 头插:新节点接管 head,自己成为 head
void push_front(int d) {
auto node = std::make_unique<Node>(d);
node->next = std::move(head_);
head_ = std::move(node);
}
// 尾插:沿 next 走到尾部,挂上新节点
void push_back(int d) {
auto node = std::make_unique<Node>(d);
if (!head_) { head_ = std::move(node); return; }
Node* cur = head_.get();
while (cur->next) cur = cur->next.get();
cur->next = std::move(node);
}
// 遍历打印
void print() const {
for (const Node* cur = head_.get(); cur; cur = cur->next.get()) {
std::cout << cur->data << " -> ";
}
std::cout << "null\n";
}
// 查找
const Node* find(int d) const {
for (const Node* cur = head_.get(); cur; cur = cur->next.get()) {
if (cur->data == d) return cur;
}
return nullptr;
}
std::size_t size() const {
std::size_t n = 0;
for (const Node* cur = head_.get(); cur; cur = cur->next.get()) ++n;
return n;
}
// 析构:逐个摘节点,避免递归析构爆栈
~LinkedList() {
while (head_) {
head_ = std::move(head_->next); // 头结点释放,把 next 交给 head
}
}
private:
std::unique_ptr<Node> head_;
};知识点盘点:
| 知识点 | 用到的地方 |
|---|---|
unique_ptr(43) | next、head_ 的所有权 |
std::move | 所有权转移 |
get()(43) | 只读借用一个裸指针遍历 |
| 迭代模式 | for (Node* cur = head_.get(); cur; cur = cur->next.get()) |
两个关键点必须讲清:
1. 为什么 push_front 的顺序是"先 move 后 move"?
node->next = std::move(head_); // 旧 head 交给新节点
head_ = std::move(node); // 新节点成为 head顺序不能反:如果先 head_ = std::move(node),head_ 原有的链就丢了(node 还没接住它)。先"把旧链挂到新节点上",再"让新节点当 head"——这是链表操作最常见的顺序陷阱。
2. 为什么析构函数要显式写?
~LinkedList() 默认版本会调用 head_ 的析构 → head_->next 的析构 → next->next 的析构……递归析构。链表有 10 万个节点,递归深度 10 万层,栈溢出崩溃。所以显式写析构:循环摘头节点,每次只释放一个,深度恒为 1。这是"unique_ptr 链表"最容易踩的坑——不用智能指针要手写释放,用了智能指针要防递归释放。
三、实验预期
- push_front 后链表顺序是"反着"的(新元素在前)
- push_back 后顺序保持插入顺序
- find 找到返回节点指针、找不到返回 nullptr
- size 等于节点数
- 大链表(如 10 万节点)析构不崩(循环析构生效)
四、实验数据
实际编译运行输出(g++ 13,-std=c++17):
$ ./linked_list
=== push_front 1 2 3 ===
3 -> 2 -> 1 -> null
=== push_back 4 5 ===
3 -> 2 -> 1 -> 4 -> 5 -> null
=== find(2) ===
找到: 2
=== find(99) ===
未找到: 99
=== size ===
5
=== 压测:插入 100000 个节点后析构 ===
链表销毁完成,无崩溃各操作验证:
| 操作 | 输入 | 输出 | 说明 |
|---|---|---|---|
| push_front | 1, 2, 3 | 3 -> 2 -> 1 | 头插逆序 |
| push_back | 4, 5 | 3 2 1 4 5 | 尾插保序 |
| find | 2 | 找到 | 存在 |
| find | 99 | 未找到 | 不存在返回 nullptr |
| size | — | 5 | 计数正确 |
| 大链表析构 | 100000 节点 | 无崩溃 | 循环析构生效 |
五、实验分析
1. 所有权模型的"直线"
链表的 ownership 是"每个节点拥有它的下一个"——一条直线、没有分叉、没有环。unique_ptr 完美表达这种关系:独占、可移动、不能拷贝(拷贝链表是另一回事,那要深拷贝所有节点)。设计数据结构时先画所有权图,所有权是直线 → unique_ptr;有共享/有环 → shared_ptr/weak_ptr。这就是 47 篇选型在数据结构上的应用。
2. 裸指针只做"借用"
遍历时用 Node* cur = head_.get()——这是借用的裸指针,不是所有权。get() 语义(43 篇):只读借用、不转移、绝不 delete。这个区分在链表里特别清晰:unique_ptr<Node> 拥有节点,Node* 只是"暂时看一眼"。两者并存、各司其职。
3. 递归析构 vs 循环析构
这是练习里最有价值的教训:unique_ptr 链表的默认析构是递归的,长链表会爆栈。解法是显式析构循环摘头。更广的启示:"自动管理"不等于"零思考"——RAII 帮你释放,但释放方式(递归 vs 循环)还是要你设计。真正常见的解法还有两个方向:改用 std::list(标准库已经处理好了),或接受递归但限制链表长度。
4. 和 std::list 的关系
这练习练的是"理解原理",日常开发直接用 std::list(35 篇)。标准库的 list 是双向链表,同样的增删改查做得更完善。自己实现一遍的意义在于:你会真正理解 unique_ptr 的所有权语义、std::move 的转移时机,以及为什么标准库值得信任。
六、C 对照
| 维度 | C 裸指针链表 | C++ unique_ptr 链表 |
|---|---|---|
| 节点 | struct Node* next | unique_ptr<Node> next |
| 创建 | malloc + 手动初始化 | make_unique |
| 释放 | 手动遍历 free | 自动(循环析构) |
| 所有权 | 无概念(人肉约定) | 类型表达(独占) |
| 空指针 | NULL | nullptr |
| 忘记 free | 泄漏 | 不可能(除非逻辑错) |
C 链表的每个释放路径都要手写:删一个节点、删整条链、链表被重新赋值——漏一处就泄漏。C++ 版所有权跟着类型走:节点析构自动释放 next,链表析构循环摘完所有节点。差别不在"能不能写对",在"写错的概率"。
七、扩展练习
- 删除指定值:实现
remove(int d)(注意:要改前一个节点的next,不能用被删节点自己的指针——想想为什么) - 深拷贝:实现
LinkedList(const LinkedList&),逐节点拷贝(make_unique<Node>(other->data)) - reverse:原地反转链表(练习 move 的时机把握)
- 双向链表:加
prev指针——注意"两个 unique_ptr 指向同一节点"是不允许的,想想 prev 该用什么
八、与本站主线衔接
- cpp
// 综合练习 5:链表(智能指针版) // 对应文档: languages/cpp/beginner/62-practice-linked-list.md // 编译: g++ -O0 -g -std=c++17 linked_list.cpp -o linked_list #include <iostream> #include <memory> #include <utility> struct Node { int data; std::unique_ptr<Node> next; explicit Node(int d) : data(d) {} }; class LinkedList { public: void push_front(int d) { auto node = std::make_unique<Node>(d); node->next = std::move(head_); head_ = std::move(node); } void push_back(int d) { auto node = std::make_unique<Node>(d); if (!head_) { head_ = std::move(node); return; } Node* cur = head_.get(); while (cur->next) cur = cur->next.get(); cur->next = std::move(node); } void print() const { for (const Node* cur = head_.get(); cur; cur = cur->next.get()) { std::cout << cur->data << " -> "; } std::cout << "null\n"; } const Node* find(int d) const { for (const Node* cur = head_.get(); cur; cur = cur->next.get()) { if (cur->data == d) return cur; } return nullptr; } std::size_t size() const { std::size_t n = 0; for (const Node* cur = head_.get(); cur; cur = cur->next.get()) ++n; return n; } ~LinkedList() { while (head_) { head_ = std::move(head_->next); } } private: std::unique_ptr<Node> head_; }; int main() { LinkedList list; std::cout << "=== push_front 1 2 3 ===\n"; list.push_front(1); list.push_front(2); list.push_front(3); list.print(); // 3 -> 2 -> 1 -> null std::cout << "=== push_back 4 5 ===\n"; list.push_back(4); list.push_back(5); list.print(); // 3 -> 2 -> 1 -> 4 -> 5 -> null std::cout << "=== find(2) ===\n"; if (list.find(2)) std::cout << "找到: 2\n"; else std::cout << "未找到\n"; std::cout << "=== find(99) ===\n"; if (list.find(99)) std::cout << "找到: 99\n"; else std::cout << "未找到: 99\n"; std::cout << "=== size ===\n"; std::cout << list.size() << "\n"; std::cout << "=== 压测:插入 100000 个节点后析构 ===\n"; { LinkedList big; for (int i = 0; i < 100000; ++i) big.push_front(i); std::cout << "链表大小: " << big.size() << "\n"; } // big 在此析构(循环摘节点,不会递归爆栈) std::cout << "链表销毁完成,无崩溃\n"; return 0; } - 43 篇 unique_ptr 的所有权语义、47 篇选型决策是理论;本练习是实战
- 链表的缓存局部性差(35 篇讲过),性能场景优先 vector
- 下一篇综合练习是命令行参数解析,
argv/argc与字符串处理的组合
九、一句话总结
用 unique_ptr 实现链表是所有权语义的实战课:next 用智能指针让"每个节点拥有下一个",head_ 拥有整条链,std::move 精确控制转移时机,get() 只做借用遍历;最大的坑是默认析构会递归爆栈,须显式循环析构——"自动管理"依然需要设计释放方式。