news 2026/10/20 3:56:58

C++使用模板实现单链表

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++使用模板实现单链表

前言


单链表是理解指针操作和动态内存管理最合适的练手容器:每个节点持有一个值和一条指向后继的指针,插入删除只改指针,不搬移元素。把它写成类模板,则顺带练到了模板的定义位置、依赖名解析、实例化和「三法则/五法则」这几个 C++ 的核心知识点。


初学者常见的误解有几种。第一,把模板类的成员函数实现放进.cpp文件,然后在另一个翻译单元里使用,链接时报「undefined reference」——模板不是普通函数,编译器在看到使用点之前不会为具体类型生成代码。第二,以为析构函数只要delete head_就够了——那样只释放第一个节点,后面的全部泄漏。第三,拷贝一个链表时直接写head_ = other.head_,结果两个对象共享节点,析构时重复释放。


本文实现一个带迭代器、支持深拷贝和移动语义的SinglyLinkedList<T>,重点说明所有权转移、异常安全和模板代码的组织方式。代码基准为 C++17。


一、节点设计与模板的放置位置


1.1 节点结构


// 适用于 C++17
template <typename T>
class SinglyLinkedList {
private:
struct Node {
T value;
Node* next;

Node(const T& v, Node* n) : value(v), next(n) {}
Node(T&& v, Node* n) : value(std::move(v)), next(n) {}
};

Node* head_ = nullptr;
Node* tail_ = nullptr;
std::size_t size_ = 0;
};

Node放在private里,外部看不见它的布局,这是封装的关键:以后想把单链表改成带哨兵头节点、或者给节点加缓存字段,都不会影响使用者。代价是迭代器必须作为嵌套类定义在类内,才能访问Node。


tail_不是链表结构必需的,但有了它push_back才是 O(1);没有它每次尾部插入都要从头遍历。size_同理,用 O(1) 的空间换 O(1) 的size()。


1.2 为什么模板要写在头文件里


普通函数在.cpp里定义、在头文件里声明,链接器能在目标文件里找到符号。模板不同:它本身不是代码,只是生成代码的图纸。编译器在实例化点(通常是使用处)才用具体类型替换T并生成机器码。如果定义在另一个.cpp里,当前翻译单元只看到声明,无法实例化,链接阶段就找不到符号。




做法是否可行说明



声明和定义都放头文件可行,最常用每个使用点都能实例化

定义放.cpp,同文件内使用可行实例化点在同一条翻译单元内

定义放.cpp,显式实例化可行只能用于预先知道的类型

定义放.cpp,跨文件使用且无实例化链接错误最常见的新手问题



显式实例化的写法是template class SinglyLinkedList<int>;,它强制编译器在这里生成int版本的代码。缺点是每加一种类型都要改一次,适合类型集合固定的库。


1.3 依赖名与typename


在模板内部引用依赖于T的嵌套类型时,编译器默认按「值」而不是「类型」解析,必须加typename。类内定义时这不是问题,但在类外写成员函数就需要:


// 适用于 C++17
template <typename T>
typename SinglyLinkedList<T>::iterator
SinglyLinkedList<T>::begin() {
return iterator(head_);
}

这里的typename不能省,否则编译器会把SinglyLinkedList<T>::iterator当成一个静态数据成员。同理,如果嵌套的是模板(比如T::template rebind<U>),还要加template关键字。这些规则属于「两阶段名字查找」:不依赖T的名字在定义时查,依赖T的名字在实例化时查。


二、五法则:析构、拷贝、移动


2.1 析构必须是遍历释放


// 适用于 C++17
~SinglyLinkedList() { clear(); }

void clear() noexcept {
Node* cur = head_;
while (cur != nullptr) {
Node* next = cur->next; // 先存后继,再删当前
delete cur;
cur = next;
}
head_ = tail_ = nullptr;
size_ = 0;
}

顺序不能反:先delete cur再读cur->next是释放后使用(use-after-free),属于未定义行为。clear()标noexcept与析构函数默认的noexcept(true)一致,避免异常逃逸触发std::terminate。


2.2 拷贝构造要深拷贝


默认的拷贝构造函数做的是逐成员复制,复制完两个链表指向同一批节点。任何一个析构,另一个就悬垂;两个都析构,就是重复释放(double free)。必须自己写:


// 适用于 C++17
SinglyLinkedList(const SinglyLinkedList& other) {
for (Node* p = other.head_; p != nullptr; p = p->next) {
push_back(p->value);
}
}

逐个push_back在分配失败时会抛std::bad_alloc。此时构造函数未完成,析构函数不会运行,head_是裸指针也不会被销毁,已建的节点就泄漏了。严格写法要么在try/catch里clear()再抛,要么让节点由std::unique_ptr串起来。


更稳的写法是先用局部对象tmp构造,成功后swap(tmp):tmp在异常展开时会正常析构,this保持空链表状态。


2.3 拷贝赋值用 copy-and-swap


// 适用于 C++17
SinglyLinkedList& operator=(const SinglyLinkedList& other) {
if (this != &other) {
SinglyLinkedList tmp(other); // 先完整复制,失败则原对象不变
swap(tmp);
}
return *this;
}

void swap(SinglyLinkedList& other) noexcept {
std::swap(head_, other.head_);
std::swap(tail_, other.tail_);
std::swap(size_, other.size_);
}

自赋值检查不能省:a = a时如果不检查,先清空自己再读other就读到了已释放的内存。copy-and-swap 同时给出了强异常保证——复制失败时*this原封不动。


2.4 移动构造与移动赋值


移动的语义是「偷走指针,把源置空」:


// 适用于 C++17
SinglyLinkedList(SinglyLinkedList&& other) noexcept
: head_(other.head_), tail_(other.tail_), size_(other.size_) {
other.head_ = nullptr;
other.tail_ = nullptr;
other.size_ = 0;
}

SinglyLinkedList& operator=(SinglyLinkedList&& other) noexcept {
if (this != &other) {
clear();
head_ = other.head_;
tail_ = other.tail_;
size_ = other.size_;
other.head_ = nullptr;
other.tail_ = nullptr;
other.size_ = 0;
}
return *this;
}

移动构造必须把源对象置成合法的空状态,否则源对象析构时会把已经转移走的节点再删一遍。noexcept很重要:std::vector扩容时只有在移动构造是noexcept的情况下才会选择移动而不是拷贝。


注意std::move本身不移动任何东西,它只是把表达式转成右值引用,真正转移指针的是上面这些函数。


三、迭代器与边界操作


3.1 一个够用的前向迭代器


迭代器是一个只持有Node*的小类:operator*返回node_->value,operator->返回成员地址,前置++把指针推到next。再补上后置++、==、!=和默认构造,就满足前向迭代器(forward iterator)的最低要求。


end()用空指针表示「尾后位置」,因此对end()解引用是 UB。有了begin/end,基于范围的 for 循环就能直接用:


// 适用于 C++17
SinglyLinkedList<int> list;
for (int v : list) {
// ...
}

还缺const_iterator,以及cbegin/cend。真实实现里通常用一个模板化的迭代器基类同时生成两者,这里为了篇幅从简。


3.2 尾部插入与按下标插入


// 适用于 C++17
void push_back(const T& v) {
Node* n = new Node(v, nullptr);
if (tail_ == nullptr) {
head_ = tail_ = n;
} else {
tail_->next = n;
tail_ = n;
}
++size_;
}

所有插入函数都遵循同一个顺序:先分配、再改指针、最后改计数。如果new抛bad_alloc,链表结构完全没动,满足强异常保证。push_front是对称的:新节点的next指向原head_,若原链表为空则同时更新tail_。


按下标插入insert_at(index, value)需要先走到第index - 1个节点,循环条件是i + 1 < index,走完后prev正好是前驱。index == size_时prev是尾节点,新节点的next为nullptr,此时要更新tail_。index == 0直接转给push_front。


3.3 删除与尾指针维护


删除分两种情形。删头节点时把head_后移;若链表因此变空,tail_必须一并置空。删中间或尾部节点时先找到前驱,把prev->next指向victim->next,再delete victim。


最容易漏的是尾节点分支:如果被删的正好是tail_,要把tail_回退到前驱,否则tail_悬垂,下一次push_back就往已释放的内存里写。删除唯一节点时同时触发「head_变空」和「tail_需要置空」两个条件,这两处必须一起处理。


四、完整示例


// 适用于 C++17
#include <cstddef>
#include <iostream>
#include <string>
#include <utility>

template <typename T>
class SinglyLinkedList {
private:
struct Node {
T value;
Node* next;
Node(const T& v, Node* n) : value(v), next(n) {}
Node(T&& v, Node* n) : value(std::move(v)), next(n) {}
};

public:
class iterator {
public:
iterator() = default;
explicit iterator(Node* p) : node_(p) {}
T& operator*() const { return node_->value; }
T* operator->() const { return &node_->value; }
iterator& operator++() { node_ = node_->next; return *this; }
iterator operator++(int) {
iterator tmp = *this;
node_ = node_->next;
return tmp;
}
bool operator==(const iterator& o) const { return node_ == o.node_; }
bool operator!=(const iterator& o) const { return node_ != o.node_; }
private:
Node* node_ = nullptr;
};

SinglyLinkedList() = default;
~SinglyLinkedList() { clear(); }

SinglyLinkedList(const SinglyLinkedList& other) {
SinglyLinkedList tmp;
for (Node* p = other.head_; p != nullptr; p = p->next) {
tmp.push_back(p->value);
}
swap(tmp);
}

SinglyLinkedList& operator=(const SinglyLinkedList& other) {
if (this != &other) {
SinglyLinkedList tmp(other);
swap(tmp);
}
return *this;
}

SinglyLinkedList(SinglyLinkedList&& other) noexcept
: head_(other.head_), tail_(other.tail_), size_(other.size_) {
other.head_ = nullptr;
other.tail_ = nullptr;
other.size_ = 0;
}

SinglyLinkedList& operator=(SinglyLinkedList&& other) noexcept {
if (this != &other) {
clear();
head_ = other.head_;
tail_ = other.tail_;
size_ = other.size_;
other.head_ = nullptr;
other.tail_ = nullptr;
other.size_ = 0;
}
return *this;
}

void push_front(const T& v) {
Node* n = new Node(v, head_);
head_ = n;
if (tail_ == nullptr) tail_ = n;
++size_;
}

void push_back(const T& v) {
Node* n = new Node(v, nullptr);
if (tail_ == nullptr) {
head_ = tail_ = n;
} else {
tail_->next = n;
tail_ = n;
}
++size_;
}

bool insert_at(std::size_t index, const T& value) {
if (index > size_) return false;
if (index == 0) { push_front(value); return true; }
Node* prev = head_;
for (std::size_t i = 0; i + 1 < index; ++i) prev = prev->next;
Node* n = new Node(value, prev->next);
prev->next = n;
if (n->next == nullptr) tail_ = n;
++size_;
return true;
}

bool erase_at(std::size_t index) {
if (index >= size_) return false;
Node* victim = nullptr;
if (index == 0) {
victim = head_;
head_ = head_->next;
if (head_ == nullptr) tail_ = nullptr;
} else {
Node* prev = head_;
for (std::size_t i = 0; i + 1 < index; ++i) prev = prev->next;
victim = prev->next;
prev->next = victim->next;
if (victim == tail_) tail_ = prev;
}
delete victim;
--size_;
return true;
}

void clear() noexcept {
Node* cur = head_;
while (cur != nullptr) {
Node* next = cur->next;
delete cur;
cur = next;
}
head_ = tail_ = nullptr;
size_ = 0;
}

void swap(SinglyLinkedList& other) noexcept {
std::swap(head_, other.head_);
std::swap(tail_, other.tail_);
std::swap(size_, other.size_);
}

iterator begin() { return iterator(head_); }
iterator end() { return iterator(nullptr); }
bool empty() const { return head_ == nullptr; }
std::size_t size() const { return size_; }
T& front() { return head_->value; }

private:
Node* head_ = nullptr;
Node* tail_ = nullptr;
std::size_t size_ = 0;
};

int main() {
SinglyLinkedList<std::string> words;
words.push_back("alpha");
words.push_back("beta");
words.push_front("zero");

words.insert_at(1, "one");
words.erase_at(2);

for (const auto& w : words) {
std::cout << w << '\n';
}

SinglyLinkedList<std::string> copy = words;
copy.push_back("gamma");

SinglyLinkedList<std::string> moved = std::move(copy);
std::cout << "moved size = " << moved.size() << '\n';
return 0;
}

常见坑点



  1. ❌ 把模板成员函数定义放进.cpp再跨文件使用


✅ 定义和声明都放头文件,或在.cpp里显式实例化 编译器在实例化点必须看到完整定义,否则链接期报未定义符号。



  1. ❌ 析构函数只写delete head_;


✅ 循环遍历,先存next再删当前节点 只删头节点会让后续所有节点泄漏。



  1. ❌ 默认拷贝构造,让两个链表共享节点


✅ 手写深拷贝,逐节点复制值 默认拷贝是浅拷贝,析构时 double free 属于 UB。



  1. ❌ 拷贝赋值不检查自赋值a = a


✅if (this != &other)或直接用 copy-and-swap 先清空自己再读other会读到已释放内存。



  1. ❌ 移动构造后不把源对象的head_置空


✅ 源对象置为合法的空状态 源对象析构时会重复释放已经转移的节点。



  1. ❌ 删除尾节点后不更新tail_


✅ 若被删的是尾节点,tail_回退到前驱;链表空时置nullptr悬垂的tail_会让下一次push_back写已释放内存。



  1. ❌ 在模板里写T::value_type不加typename


✅typename T::value_type依赖名默认按值解析,编译器无法判断它是类型。


总结




关注点结论



代码组织模板定义必须对实例化点可见,通常全放头文件

节点所有权每个节点恰好被一个链表拥有,析构需遍历释放

拷贝必须深拷贝;赋值用 copy-and-swap 获得强异常保证

移动偷指针并把源置空,函数标noexcept

尾指针插入、删除、清空三处都要同步维护

迭代器只需Node*,end()用空指针表示尾后

复杂度头插/尾插 O(1),按下标操作 O(n)



模板单链表的价值不在链表本身——标准库的std::forward_list已经覆盖了绝大多数需求——而在于它把「模板定义位置、依赖名、五法则、异常安全、迭代器约定」压缩进一个几十行的类里。亲手写一遍并逐条推演边界,比背规则更能形成直觉。




版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 3:56:27

CEF 110 非官方编译包:开启 H.264 与 MP3 编解码的 Windows 集成指南

简介&#xff1a;CEF110.0.5481.180 Windows 非官方编译版面向需要在桌面应用中嵌入现代浏览器引擎的开发者&#xff0c;尤其适合构建网页浏览器、媒体中心或在线视频平台等场景。该版本针对64位Windows系统优化&#xff0c;重点补齐了MP3音频、MP4视频及H.264编码支持&#xf…

作者头像 李华
网站建设 2026/10/20 3:56:21

Maven settings.xml 阿里云镜像配置:加速依赖下载与避坑指南

简介&#xff1a;这份资源是一份可直接替换使用的Maven settings.xml配置文件&#xff0c;面向Java开发者、后端工程师及需要频繁构建项目的团队&#xff0c;用于解决默认中央仓库依赖下载缓慢的问题。通过将本地.m2目录下的settings.xml替换为阿里云镜像配置&#xff0c;即可让…

作者头像 李华
网站建设 2026/10/20 3:55:22

技能习得的底层逻辑:从知道到会做,构建可交付的技能体系

一直觉得“skills”这个词被说烂了。招聘网站上写着“要求熟练掌握”&#xff0c;朋友圈里有人晒“三十天速成”&#xff0c;收藏夹里堆着几十个“从入门到精通”。可你真要问一句&#xff1a;你掌握的技能到底是什么&#xff1f;能拿出来交付什么成果&#xff1f;很多人其实是…

作者头像 李华
网站建设 2026/10/20 3:55:09

AI Agent开发实战:从最小可用代码到接单交付的完整路径

最近大半年&#xff0c;我被问得最多的问题就是&#xff1a;AI Agent开发是不是真的成了最赚钱的编程方向&#xff1f;动不动就听到月薪3到8万的说法&#xff0c;各种社群里全是类似的标题&#xff0c;好像只要学会写Agent&#xff0c;钱就会自己往口袋里跑。我没法向你保证某个…

作者头像 李华
网站建设 2026/10/20 3:54:43

AI-Powered Markmap:用Markdown与AI生成思维导图,效率翻倍的新范式

先把结论放在前面&#xff1a;自从我把思维导图的制作方式从“拖拽节点”换成“写 Markdown 文本 AI 生成大纲”之后&#xff0c;处理结构化信息的效率翻了两倍不止。这篇文章要聊的&#xff0c;是某老牌建模工具厂商推出的 AI-Powered Markmap Studio&#xff0c;它把 AI 内容…

作者头像 李华
网站建设 2026/10/20 3:54:41

CANN ops-nn算子仓库深度解读:AI芯片异构计算与算子优化实战

这几年做AI基础设施相关的工作&#xff0c;被问得最多的一个问题就是&#xff1a;大模型跑起来到底是快在哪、慢在哪&#xff1f;很多人第一反应是看算力卡的数量、显存带宽&#xff0c;觉得只要卡够多&#xff0c;AIGC应用自然就能跑得飞快。但真正做过模型部署和推理优化的人…

作者头像 李华