C++类、结构体与指针链表:从数据封装到动态数据结构实战

发布时间:2026/7/30 8:44:41
C++类、结构体与指针链表:从数据封装到动态数据结构实战 1. 从“数据打包”到“行为封装”为什么我们需要类和结构体在C的世界里混久了你可能会发现一个有趣的现象很多初学者在学完基础数据类型和数组后面对稍微复杂一点的数据组织需求比如要同时管理一个学生的姓名、学号和成绩第一反应往往是创建三个独立的数组string names[100];、int ids[100];、double scores[100];。然后通过下标i来关联这三个数组中属于同一个学生的数据。这种方法在逻辑上可行但维护起来简直是灾难。想象一下当你需要增加一个“年龄”字段或者需要按成绩排序时你得小心翼翼地同时操作四个数组稍有不慎就会导致数据错位张三的分数被安到了李四头上。这种“数据分散、逻辑耦合”的困境正是C中结构体struct和类class要解决的核心问题。它们本质上是一种“数据打包”机制允许你将逻辑上属于一个实体的多个数据成员成员变量捆绑在一起形成一个自定义的复合数据类型。比如我们可以定义一个Student结构体struct Student { string name; int id; double score; };现在一个学生就是一个Student类型的变量他的所有信息都封装在这个变量内部。排序时直接交换整个Student对象即可完美解决了数据错位的问题。这不仅仅是语法糖更是一种思维方式的转变从面向过程的、零散的数据操作转向面向对象的、以实体为中心的数据管理。而类class则在结构体的“数据打包”基础上更进一步实现了“行为封装”。它不仅能包含数据成员变量还能包含操作这些数据的函数成员函数。例如一个BankAccount类除了有balance余额这个数据还会有deposit存款、withdraw取款等操作余额的函数。更重要的是类通过访问控制public, private, protected来规定哪些成员可以被外部直接访问哪些只能通过内部函数间接操作。这就好比你的银行账户你不能直接用手去修改数据库里的余额private必须通过ATM机或柜台public成员函数这个受控的接口来完成从而保证了数据的安全性和一致性。所以简单回顾结构体默认所有成员是publicC中与C的struct主要区别侧重于数据的简单聚合类默认所有成员是private侧重于数据与行为的封装与隐藏。在实际项目中如果只是纯粹的数据集合如坐标点Point、RGB颜色常用struct如果需要封装复杂逻辑并隐藏实现细节则必须用class。2. 类与结构体的核心语法从定义到使用理解了为什么需要它们之后我们来快速过一遍定义和使用的核心语法点。这部分是硬核基础必须牢固掌握。2.1 定义与实例化无论是class还是struct定义方式类似// 使用 struct 定义默认成员为 public struct Point { // 数据成员成员变量 double x; double y; // 成员函数方法 void print() { cout ( x , y ) endl; } }; // 使用 class 定义默认成员为 private class Rectangle { private: // 访问说明符以下成员为私有 double width; double height; public: // 访问说明符以下成员为公有 // 构造函数与类同名无返回类型用于初始化对象 Rectangle(double w, double h) { width w; // 注意这里直接赋值更好的做法是使用初始化列表 height h; } // 成员函数 double area() { return width * height; } void setWidth(double w) { if (w 0) { // 可以加入数据校验 width w; } } };实例化对象也就是创建某个类/结构体类型的变量// 栈上创建自动管理内存 Point p1; // 调用默认构造函数编译器生成 p1.x 10.0; p1.y 20.0; p1.print(); Rectangle rect(3.0, 4.0); // 调用带参数的构造函数 cout Area: rect.area() endl; // rect.width 10; // 错误width 是 private 成员无法在类外直接访问 rect.setWidth(10); // 正确通过公有成员函数修改 // 堆上创建动态内存需手动管理 Point* p2 new Point(); p2-x 5.0; // 指针访问成员使用 - p2-print(); delete p2; // 切记释放内存2.2 构造函数与初始化列表构造函数是类中一个非常特殊的成员函数它在对象创建时自动调用用于初始化对象的数据成员。如果你没有定义任何构造函数编译器会为你生成一个默认的无参构造函数但如果你定义了任何构造函数编译器就不再生成默认构造函数。上面Rectangle的构造函数写法width w;是可行的但在C中更推荐使用初始化列表因为它效率更高对于非内置类型避免先默认构造再赋值。class Rectangle { private: double width; double height; string name; // 增加一个 string 成员 public: // 使用初始化列表的构造函数 Rectangle(double w, double h, const string n) : width(w), height(h), name(n) { // 函数体可以为空或执行一些校验等操作 if (w 0 || h 0) { cerr Invalid dimensions! endl; // 实际项目中可能需要抛出异常 } } };初始化列表在冒号:之后以逗号分隔每个成员后面跟括号内的初始值。它会在进入构造函数体之前就完成成员的初始化。2.3 访问控制与封装思想这是类区别于结构体的精髓。三个访问说明符public公有类内、类外通过对象都可以访问。private私有只有类自己的成员函数以及友元可以访问。类外无法直接访问。这是实现封装和信息隐藏的关键。protected保护与private类似但允许派生类子类访问。一个良好的类设计通常会将数据成员设为private然后提供一系列public的成员函数常被称为“接口”或“API”来读写这些数据。这样做的好处是数据保护防止外部代码随意修改内部状态导致对象处于无效或不一致的状态比如将width设为负数。实现隐藏外部只需要知道“能做什么”接口不需要知道“怎么做”内部实现。以后即使内部实现如计算面积的算法改变了只要接口不变使用这个类的所有代码都无需修改。便于维护和调试所有对数据的修改都通过有限的几个函数进行一旦数据出错很容易定位问题所在。3. 指针通往内存世界的“遥控器”在深入链表之前必须彻底搞懂指针。很多C的初学者对指针感到恐惧其实把它想象成一个内存地址的“遥控器”或者“导航仪”就很好理解了。一个普通变量比如int a 10;系统会在内存中找一块地方假设地址是0x7ffeedc0把值10存进去。变量名a就是我们给这块内存起的别名。而指针变量是用来存储另一个变量的内存地址的变量。定义指针int* p a;。这里a取地址运算符获取变量a的内存地址0x7ffeedc0。int* p声明一个指向int类型数据的指针变量p。p a将a的地址存入指针p。现在p这个“遥控器”就指向了存储10的那个内存单元。通过指针访问它指向的内存内容使用解引用运算符*cout *p; // 输出 10。*p就相当于“按下遥控器上的按钮操作它指向的那个电视机内存单元”。指针的核心操作int a 10, b 20; int* p a; // p 指向 a cout *p endl; // 输出 10 p b; // 指针可以改变指向现在 p 指向 b cout *p endl; // 输出 20 *p 30; // 通过指针修改它指向的变量的值 cout b endl; // 输出 30b 的值被改变了指针与数组数组名在大多数情况下可以看作一个指向数组首元素的常量指针。int arr[5] {1, 2, 3, 4, 5}; int* ptr arr; // 等价于 int* ptr arr[0]; cout *ptr endl; // 输出 1 cout *(ptr 2) endl; // 输出 3指针算术运算指针的指针二级指针指针本身也是变量它也有地址所以可以有指向指针的指针。这在动态二维数组或者需要修改指针本身而不仅仅是指针指向的内容的函数参数中很有用。int a 10; int* p a; int** pp p; // pp 是一个指向指针 p 的指针 cout **pp endl; // 输出 10两次解引用理解指针的关键是画内存图。在纸上画出一个个小格子代表内存标上地址和值把指针变量画成一个箭头指向它存储的地址对应的格子。多画几次指针的概念就清晰了。4. 链表指针与结构体/类的经典结合数组是一种连续的、静态的数据结构。它的优点是随机访问快O(1)但插入和删除元素尤其是中间位置效率低O(n)因为需要移动大量元素。链表则是一种非连续的、动态的数据结构完美弥补了数组在插入删除方面的短板。4.1 链表的核心思想链表的每个元素称为“节点”Node都是一个独立的结构体或类对象包含两部分数据域data存储实际的数据。指针域next存储下一个节点在内存中的地址。通过每个节点的next指针就像一根链条将散落在内存各处的节点串联起来。第一个节点称为“头节点”head最后一个节点的next指针指向nullptr空指针表示链表结束。// 典型的单向链表节点定义 struct ListNode { int val; // 数据域 ListNode* next; // 指针域指向下一个节点 // 构造函数方便创建节点 ListNode(int x) : val(x), next(nullptr) {} };4.2 链表的基本操作图解与代码实现我们以实现一个简单的整数单向链表为例涵盖创建、遍历、插入和删除。4.2.1 创建链表与遍历创建链表通常从创建头节点开始。注意我们常常使用一个哑节点dummy node或直接用一个ListNode* head指针来代表整个链表它指向第一个有效节点。// 手动创建链表 1 - 2 - 3 - nullptr ListNode* head new ListNode(1); head-next new ListNode(2); head-next-next new ListNode(3); // 遍历链表 ListNode* current head; // 用一个临时指针 current 来遍历避免丢失头指针 while (current ! nullptr) { cout current-val - ; current current-next; } cout nullptr endl; // 输出1 - 2 - 3 - nullptr重要技巧遍历链表时永远使用一个临时指针如current不要直接用head遍历否则你会丢失链表的入口导致内存泄漏因为无法再找到头节点来释放内存。4.2.2 在链表头部插入节点在头部插入是最简单的时间复杂度O(1)。ListNode* newNode new ListNode(0); // 创建新节点值为0 newNode-next head; // 新节点指向原头节点 head newNode; // 更新头指针指向新节点 // 现在链表变为 0 - 1 - 2 - 3 - nullptr4.2.3 在链表尾部插入节点需要先遍历到最后一个节点next为nullptr的节点。ListNode* newNode new ListNode(4); if (head nullptr) { // 如果链表为空新节点就是头节点 head newNode; } else { ListNode* current head; while (current-next ! nullptr) { // 找到最后一个节点 current current-next; } current-next newNode; // 最后一个节点的 next 指向新节点 } // 链表变为 0 - 1 - 2 - 3 - 4 - nullptr4.2.4 在链表中间插入节点例如在值为2的节点后插入需要先找到目标节点的位置。ListNode* target head; while (target ! nullptr target-val ! 2) { // 寻找值为2的节点 target target-next; } if (target ! nullptr) { // 找到了 ListNode* newNode new ListNode(99); newNode-next target-next; // 新节点指向原节点的下一个 target-next newNode; // 原节点指向新节点 } // 链表变为 0 - 1 - 2 - 99 - 3 - 4 - nullptr4.2.5 删除链表节点例如删除值为99的节点删除节点需要找到待删除节点的前驱节点因为需要修改前驱节点的next指针。ListNode* dummy new ListNode(-1); // 使用哑节点简化头节点删除操作 dummy-next head; ListNode* prev dummy; // prev 指向待删除节点的前一个节点 ListNode* curr head; // curr 指向当前待检查节点 while (curr ! nullptr) { if (curr-val 99) { prev-next curr-next; // 前驱节点跳过待删除节点 delete curr; // 释放内存 // curr prev-next; // 更新curr继续循环如果需要删除所有值为99的节点 break; // 只删除第一个找到的 } else { prev curr; curr curr-next; } } head dummy-next; // 更新头指针可能头节点被删除 delete dummy; // 删除哑节点踩坑实录删除节点时最容易犯的错误就是直接delete curr;然后curr curr-next;。这会导致访问已释放的内存野指针引发程序崩溃。正确的顺序永远是1. 让前驱节点的next绕过当前节点2. 保存当前节点的next地址如果需要3. 删除当前节点4. 移动指针。4.3 链表 vs. 数组选择与权衡特性数组单向链表内存布局连续内存非连续内存节点散落大小固定静态数组或可调动态数组但涉及拷贝动态按需分配访问元素O(1)随机访问快O(n)必须从头遍历插入/删除头部O(n)需移动后续元素O(1)修改指针即可插入/删除已知位置O(n)O(1)如果已有前驱指针空间开销小只有数据本身大每个节点额外包含指针如何选择需要频繁随机访问元素 -数组或向量vector。需要频繁在头部或中间插入/删除元素且元素数量变化大 -链表。在内存受限的嵌入式环境或者需要绝对稳定的内存地址时如硬件寄存器映射常用数组。在大多数C标准库应用中std::vector动态数组因其缓存友好性连续内存和综合性能比std::list双向链表更常用。链表通常用在特定的算法场景如LRU缓存实现或底层数据结构中。5. 实战避坑指针与链表操作中的常见“雷区”结合我多年的调试经验新手在玩转指针和链表时几乎百分百会踩下面这几个坑。提前认识它们能省下大量抓狂的时间。5.1 空指针解引用Null Pointer Dereference这是最经典、最常见的崩溃原因。ListNode* ptr nullptr; cout ptr-val; // 崩溃试图访问 nullptr 指向的内存如何避免在对指针进行解引用操作-或*之前务必检查其是否为nullptr。if (ptr ! nullptr) { cout ptr-val; } // 或者更简洁的写法C11及以上 if (ptr) { cout ptr-val; }5.2 内存泄漏Memory Leak在堆上new分配了内存却忘了释放delete。链表由多个new出来的节点组成如果只删除头节点后面的节点就丢失了造成泄漏。// 错误示例 void createList() { ListNode* head new ListNode(1); head-next new ListNode(2); // ... 函数结束head 指针消亡但两个节点占用的内存永远无法释放 }正确做法写一个专门的销毁链表的函数遍历每个节点并delete。void destroyList(ListNode* head) { // 使用引用以便将head置为nullptr ListNode* current head; while (current ! nullptr) { ListNode* nextNode current-next; // 先保存下一个节点 delete current; // 删除当前节点 current nextNode; // 移动到下一个节点 } head nullptr; // 避免野指针 }更现代的做法是使用智能指针如std::unique_ptrListNode让它们自动管理内存但这需要更复杂的节点定义因为unique_ptr不可拷贝需要处理链表连接的问题。5.3 野指针Dangling Pointer指针指向的内存已经被释放但指针本身还在被使用。ListNode* node new ListNode(10); ListNode* alias node; // alias 和 node 指向同一块内存 delete node; // 释放内存 node nullptr; // 好习惯释放后立即置空 // 此时 alias 变成了野指针 cout alias-val; // 未定义行为可能崩溃或输出乱码如何避免指针被delete后立即将其置为nullptr。避免多个指针指向同一块动态内存除非你非常清楚它们的生命周期。如果需要共享考虑使用引用计数智能指针std::shared_ptr。5.4 链表操作中的指针丢失在插入或删除节点时没有正确保存必要的指针导致链表断裂或内存泄漏。// 错误示例在节点 cur 后插入新节点 ListNode* cur ...; // 假设 cur 指向链表中某个节点 ListNode* newNode new ListNode(100); // 错误顺序 cur-next newNode; // 步骤1cur-next 原来指向的节点假设为X丢失了 newNode-next cur-next; // 步骤2这行等价于 newNode-next newNode; 形成了自环正确顺序插入时先连接新节点与后继再断开原连接。newNode-next cur-next; // 新节点指向原后继 cur-next newNode; // 原节点指向新节点删除节点时如前所述必须先保存后继节点地址再调整指针最后释放内存。5.5 循环链表与无限循环如果链表节点的next指针设置错误形成了环比如尾节点指向了之前的某个节点那么遍历链表就会陷入无限循环。// 不小心创建了一个环1 - 2 - 3 - 2 - 3 - ... ListNode* node2 head-next; head-next-next-next node2; // 形成了环检测方法使用“快慢指针”法Floyd判圈算法。一个指针每次走一步慢指针另一个每次走两步快指针。如果链表有环快慢指针最终会相遇如果快指针走到了nullptr则无环。bool hasCycle(ListNode* head) { if (head nullptr) return false; ListNode* slow head; ListNode* fast head-next; while (fast ! nullptr fast-next ! nullptr) { if (slow fast) return true; slow slow-next; fast fast-next-next; } return false; }6. 从链表到更复杂的数据结构一个自然的延伸掌握了单向链表和指针操作你就拿到了理解许多更高级数据结构的钥匙。它们大多是在链表的基础上进行扩展。双向链表每个节点不仅有指向后继的next指针还有指向前驱的prev指针。这使得从后向前遍历、在已知节点前插入、删除当前节点等操作更加高效O(1)。std::list就是一个双向链表。struct DoublyListNode { int val; DoublyListNode* prev; DoublyListNode* next; DoublyListNode(int x) : val(x), prev(nullptr), next(nullptr) {} };循环链表尾节点的next指针不指向nullptr而是指向头节点形成一个环。常用于需要循环处理数据的场景如操作系统中的进程调度队列。树如二叉树可以看作一个“每个节点最多有两个next指针左孩子、右孩子”的链表变种。树的遍历前序、中序、后序本质上是递归或栈辅助的指针操作。struct TreeNode { int val; TreeNode* left; TreeNode* right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} };图更进一步每个节点可以有任意多个指向其他节点的指针邻接点。链表、树都可以看作是特殊的图。理解这些数据结构的关键依然是画图。在纸上画出节点和指针箭头模拟插入、删除、遍历的过程。代码只是对这幅图的一种精确描述。当你能在脑中清晰地构建出这些指针的指向关系时相关的代码实现就水到渠成了。最后关于智能指针unique_ptr,shared_ptr,weak_ptr它们是现代C管理动态内存、避免内存泄漏和野指针的利器。但在学习数据结构的初期我强烈建议先使用原始指针手动管理new/delete。这个过程虽然痛苦但能让你深刻地理解内存的生命周期和指针的本质。等你对底层机制了然于胸后再使用智能指针来提升开发效率和安全性你会更加感激它们带来的便利。