拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

链表初始化用LinkList还是LinkList *L?C语言值传递与二级指针深度解析

链表初始化用LinkList还是LinkList *L?C语言值传递与二级指针深度解析

链表初始化到底该用LinkList还是LinkList *L?这个问题困扰了我很长一段时间。说实话,当年我学C语言的时候,在链表初始化上踩过的坑,比后面工作中所有bug加起来都多。明明照着书上敲的代码,一运行就崩溃;加了二级指针之后,稀里糊涂就对了——但为什么对,却完全说不清楚。等到后来真正理解了函数参数传递的本质之后,才明白这背后的逻辑其实非常清晰,甚至可以用一句话总结:

C语言的函数参数传递是值传递,永远只复制一份实参的“数值”进去。

这句话听起来简单,但很多人在学到链表的时候,恰恰是把这句话给忘了。本文就围绕这个问题,把一级指针、二级指针、链表初始化之间的爱恨情仇彻底讲透,让你以后再也不会在这个环节上翻车。

1. 先看现象:一个让人摸不着头脑的崩溃

1.1 教科书式的错误代码

很多初学者拿到链表定义之后,会这样写初始化函数:

#include <stdio.h> #include <stdlib.h> typedef struct Node { int data; struct Node *next; } Node, *LinkList; void InitList(LinkList L) { L = (LinkList)malloc(sizeof(Node)); if (L == NULL) { exit(-1); } L->next = NULL; } int main() { LinkList head = NULL; InitList(head); if (head == NULL) { printf("初始化失败,head 仍然是 NULL!\n"); } else { printf("初始化成功!\n"); } return 0; }

这段代码在逻辑上看起来非常顺畅:你定义了一个链表头指针head,把它传给了InitList,在InitList内部给它分配了内存,还设置好了next = NULL。一切都那么自然,仿佛应该返回一个可用的链表。

1.2 崩溃现场与诡异输出

运行结果却让人匪夷所思:

初始化失败,head 仍然是 NULL!

更诡异的是,你稍微改一下代码,在InitList内部加一个printf打印指针的值:

void InitList(LinkList L) { L = (LinkList)malloc(sizeof(Node)); printf("函数内部 L = %p\n", L); }

输出:

函数内部 L = 0x55d5f8a09260 主函数内 head = (nil)

L在内部分到了内存,地址也打印出来了,但回到main函数,head还是NULL。这就像你托朋友帮你保管一把钥匙,朋友在电话里说“钥匙拿到了,放口袋里了”,等你回到家一摸,口袋还是空的。

2. 值传递的底层逻辑:函数参数到底是怎么传的

2.1 C语言的参数传递是“复制”,不是“共享”

要理解这个诡异现象,必须先弄清楚C语言函数参数传递的机制。C语言所有的参数传递都是值传递,意思是:调用函数时,实参的值会被复制一份,然后把这个副本传给形参。

回到上面的代码:

LinkList head = NULL; // head 的值是 NULL InitList(head); // 把 head 的值(NULL)复制一份给 L

在InitList内部,L是head的副本。你在函数内部执行L = malloc(...),修改的是L本身的存储单元,把它从NULL改成某个地址。但head自己的存储单元没有任何人有权限去碰——它照样是NULL。

这个机制可以用一个类比来理解:你把家里的地址写在纸条上,然后把纸条复印了一份交给快递员。快递员按照复印件上的地址送快递,送完之后他在自己的那份上画了个勾,但你的原件不会因此自动出现一个勾。想让你原件上的地址改变?除非你默默把原件上那行字擦掉重写,或者说服谁拥有你原件的操作资格。

2.2 为什么大家都默认“指针就能改外部变量”?

这里有一个很常见的思维误区:既然传的是指针,为什么不能改?

其实很多初学者对“传指针能修改变量”有过非常愉快的体验,比如:

void swap(int *a, int *b) { int tmp = *a; *a = *b; *b = tmp; }

然后他们得出结论:传指针就能修改外部数据。于是遇到链表初始化时,就以为LinkList L也是指针,肯定能改。这种思维把“指针”和“能修改外部变量”画了等号,却没有区分两件事:

  1. 修改指针变量本身的值——让指针变量指向一个新的地址。
  2. 修改指针变量所指向的内容——通过*p访问目标内存并写入。

swap函数能修改外部变量,是因为它通过*a去修改了“指针指向的那个内存单元里的值”,它并没有改变a和b这两个指针变量自己的值。而你执行L = malloc(...),恰恰是第1种操作——改变了指针变量自身。由于L只是副本,这个修改对外部的head毫无影响。

提示:把指针变量本身看成一个“装着地址的盒子”。值传递复制的是盒子里的纸条,而不是盒子本身。你只有拿到盒子本身,才有资格换掉里面的纸条。

2.3 那用LinkList *L又为什么能行?

二级指针LinkList *L的意思是:L是一个指针,它指向LinkList类型的变量。换句话说,L指向的就是main函数里的head这个变量的存储地址。

调用:

LinkList head = NULL; InitList(&head); // 把 head 的地址传进去

函数参数:

void InitList(LinkList *L) { *L = (LinkList)malloc(sizeof(Node)); // 通过 *L 修改 head }

在这个版本里,L是实参&head的副本,也就是head的地址。函数内部通过*L,就能直接操作main里的head变量,把它改成malloc返回的地址。这次不是改L自己,而是改L指向的那个变量——也就是说,你手里的纸条上写着“head 藏在家里的保险柜地址”,你拿着这把新钥匙去打开了保险柜,把里面的纸条换了。

这下head真的变了。

3. 一级指针与二级指针:修改“盒子”与修改“盒子里的纸条”

3.1 重新审视指针的“房子模型”

为了彻底斩断这个纠缠,我来画一个结构图。假设内存是这样的:

  • 变量head:一个盒子,里面存放地址值,初始为0(NULL)。
  • 变量L(形参):另一个盒子,里面存放head的副本地址(也是0)。
  • 变量pL(形参,二级指针版本):一个盒子,里面存放head这个盒子的房间号。

情况一:InitList(LinkList L)

操作盒子变化
malloc返回地址0x1000把这个值塞进L盒子
函数返回L盒子销毁
head盒子从未被动过,依然是0

情况二:InitList(LinkList *L)

操作盒子变化
实参传&head,形参L盒子存放head的房间号
*L = malloc(...),拿到0x1000
通过房间号找到head盒子,把它的内容改成0x1000
函数返回,L盒子销毁,但head盒子已经是新地址

3.2 图解:为什么 LinkList L 行不通

如果还不清楚,我们换一个更熟悉的场景:你有一个盒子,叫head,里面是空的。你想让一个函数往盒子里放一张写着“链表头地址”的纸条。

你如果只是把纸条复印一份丢给对方,对方在复印件上写了一个新地址,原件还是空白——这就是LinkList L。

你如果告诉对方你盒子的位置,然后对方走到那个位置,打开盒子,把新纸条塞进去——这就是LinkList *L。

很多人在swap里用一级指针觉得能行,是因为swap只需要“修改盒子里纸条指向的内容”,不需要“换掉盒子里的纸条”。打一个不严谨的比方:*a = *b是修改纸条指向的对象,而a = ...是换信封里的纸条。

3.3 指针的“两重身份”与“两重指向”

C语言的指针有一重身份是“指向数据的地址”,二重身份是“指针本身也是变量,它也有地址”。二级指针利用的正是指针的第二重身份。

在链表初始化这个场景里,你需要修改的恰恰是“指针变量head本身的内容”,也就是把NULL改成malloc分配出来的地址。所以必须拿到head的地址,也就是&head,而这正是二级指针LinkList *L接收的东西。

注意:这里有一个非常关键的辨析——LinkList本身已经是指针类型(typedef struct Node *LinkList)。所以LinkList *就是指向“指针类型”的指针,即二级指针。这也是LinkList *L这个写法显得特别绕的原因:很多初学者看到两个星号,就会本能地以为“这是指向结构体的指针的指针”,于是心里发怵。

4. 完整实操:正确与错误的代码对照

理论讲完了,必须落到代码上。我整理了一份能直接编译运行的对照参考,方便你上手亲自验证。

4.1 错误写法及运行结果

#include <stdio.h> #include <stdlib.h> typedef struct Node { int data; struct Node *next; } Node, *LinkList; void InitList(LinkList L) { L = (LinkList)malloc(sizeof(Node)); if (L == NULL) { exit(-1); } L->next = NULL; } int main() { LinkList head = NULL; InitList(head); if (head == NULL) { printf("初始化失败,head 仍然是 NULL\n"); } else { printf("初始化成功\n"); } return 0; }

编译运行结果:

初始化失败,head 仍然是 NULL

4.2 正确写法:二级指针版

#include <stdio.h> #include <stdlib.h> typedef struct Node { int data; struct Node *next; } Node, *LinkList; void InitList(LinkList *L) { *L = (LinkList)malloc(sizeof(Node)); if (*L == NULL) { exit(-1); } (*L)->next = NULL; } int main() { LinkList head = NULL; InitList(&head); if (head == NULL) { printf("初始化失败\n"); } else { printf("初始化成功,head 指向了有效内存\n"); } return 0; }

编译运行结果:

初始化成功,head 指向了有效内存

4.3 另一种常用写法:返回值法

除了二级指针,有一种更简洁、更容易被新手接受的方案:让初始化函数直接返回新创建的头指针。

LinkList InitList(void) { LinkList L = (LinkList)malloc(sizeof(Node)); if (L == NULL) { exit(-1); } L->next = NULL; return L; } int main() { LinkList head = NULL; head = InitList(); if (head == NULL) { printf("初始化失败\n"); } else { printf("初始化成功\n"); } return 0; }

这个方案的本质是:不在函数内部修改外部变量,而是让函数把新地址作为返回值交给你,由你自己去给head赋值。这在功能上也实现了“初始化”,而且在很多场景下可读性更高。

4.4 两种方案如何选择

场景推荐方案原因
初始化一个空链表头返回值法代码简洁,不涉及二级指针,新手友好
需要在函数内多次修改头指针本身二级指针比如链表的“头插法”系列操作,或者需要修改头指针所指向的节点
需要把链表置空二级指针返回一个NULL比返回值操作上更连贯
维护一个“链表头数组”一类的情况二级指针传地址本身更清晰

实际工程中,我不仅见人用二级指针初始化,还见人用宏或者结构体包装来绕开二级指针的复杂性,比如把链表头放在一个结构体里:

typedef struct { Node *head; int count; } LinkedList;

然后所有函数都传LinkedList *list。这样做的好处是:你不会在调用链上看到一大堆*L和(&head)的混淆;坏处是结构体本身多了一层间接引用。但这是一种更现代的写法,适合代码规模变大后追求可读性的场景。

5. 判断标准:什么时候必须用二级指针(或者用返回值)

很多人学完链表初始化之后,又会在链表的其他操作上犯晕:为什么插入节点有时候传一级指针就能改,有时候又必须传二级指针?这里提供一个通用的判断标准,不用死记硬背。

5.1 三条判断依据

依据一:看你要不要“重新给头指针赋值”。

如果你需要在函数内部,对“链表的头指针变量”本身进行写入,比如head = malloc(...)、head = head->next这类操作,那你必须拿到这个头指针变量的地址,也就是二级指针。如果只是对头指针指向的节点进行修改,比如head->data = 1,那么一级指针就够。

依据二:看你的操作有没有“可能改变链表根节点”。

在插入、删除操作中:

  • 在链表中间插入或删除节点,只需要一级指针(因为头指针通常不变)。
  • 在链表头部插入或删除节点,可能会改变头指针的指向。这时候如果希望这个改变“能传回外部”,普通的一级指针做不到。

依据三:看你的函数返回值被没被占用。

如果函数本来就要返回一些状态码,比如成功/失败、插入位置、长度等,那你就很难再用返回值去返回新头指针。这时候二级指针就变成了一种更自然的选择。反之,如果你的函数可以用返回值的方式返回新头指针,那就可以绕开二级指针。

5.2 常见操作速查表

操作推荐形参原因
初始化链表(分配头节点)LinkList *L或返回LinkList头指针本身要变
头插法插入新节点LinkList *L插入后头指针指向新节点
尾插法插入新节点LinkList L插入操作不改变头指针变量
删除中间节点LinkList L只需找到前驱,改前驱的next
删除头节点LinkList *L删除后头指针指向第二个节点
销毁整表LinkList *L最后要把L置为NULL
遍历打印链表LinkList L只读不写,不会修改头指针变量

5.3 把“指针的地址”当作“房间的门牌号”

再说直白一点:C语言里没有引用参数,想修改一个变量本身,唯一的途径就是拿到“这个变量的地址”。你需要修改的是一个指针变量,那就要拿到“指针变量的地址”,这就是二级指针。这里不存在“为什么二级指针也能初始化”的玄学,它和“为什么swap要用int *a”完全是一个逻辑。

想想看,swap函数里你要修改的是int变量的值,所以传int *;链表初始化里,你要修改的是LinkList指针变量的值,所以传LinkList *。一个是“指向int的指针”,一个是“指向LinkList的指针”,只是恰好LinkList本身是指针,所以看起来变成了“二级指针”。理解到这层,二级指针就一点都不神秘了。

6. 常见问题与排查技巧实录

6.1 初始化后 head 依然为 NULL

现象:函数内部毫不怀疑能打印出分配后的地址,但主函数里head还是NULL。

排查思路:检查形参里的指针,看它有没有被修改。

  • 如果形参是LinkList L,那L只是实参的副本,改它没用。
  • 如果形参是LinkList *L,检查函数内部是用*L = malloc(...),还是误写成了L = malloc(...)。后者依然是错误的。

6.2 插入/删除操作之后链表丢失

现象:执行头插法时,明明插入了节点,但返回主函数后链表还是原来的状态,或者整个链表“断掉”。

排查思路:多数情况下是插入操作改变了局部变量里的头指针,但外部不知道。比如:

void HeadInsert(LinkList L, int val) { LinkList newNode = (LinkList)malloc(sizeof(Node)); newNode->data = val; newNode->next = L; // 这里改了形参 L 的指向 L = newNode; // 改的也是形参的指向,外部没有感知 }

修正方式:传入LinkList *L,把newNode->next = *L与*L = newNode写好。

6.3 段错误(Segmentation Fault)

现象:程序在链表操作中直接崩溃,最典型的场景是把NULL当成有效节点访问了。

排查思路:

  • 打印关键步骤的指针值,确认是否为(nil)。
  • 检查是否忘了给节点分配内存就直接赋值。
  • 检查->next在某个时刻是否为NULL,而你还在继续访问它的next。

注意:写链表操作时有一个习惯性动作,我强烈建议你养成——任何操作之前,先打印头指针和当前节点指针,尤其是第一次写链表的时候。不要觉得 printf 太多影响代码美感,等你调试完删掉就行。我用这个方法排查过无数个链表 bug。

6.4 代码审查清单

为了彻底告别二级指针相关的初始化问题,我总结了一份自检清单,每次写完链表相关代码,逐条核对:

  1. 这个操作是否会修改头指针变量本身?(会 → 二级指针或返回值)
  2. 实参传递时有没有加&?(InitList(&head)而不是InitList(head))
  3. 函数内部用的是*L = ...还是L = ...?(记住前者才改得到外部)
  4. 返回值法里,函数返回的指针有没有被正确赋值给外部变量?
  5. 有没有多余的&或者遗漏的*?(对照声明检查每一层)

写在最后的几点经验

从我个人的学习经历来看,链表的二级指针问题,是C语言学习过程里一个非常典型的分水岭。跨过去之后,你会突然发现指针的各种“陷阱”不再可怕,因为它们都归结为一个本质问题:你到底想修改“变量”还是“变量指向的内容”?想修改变量本身,就必须拿到变量地址,哪怕这个变量本身就是指针。

最后分享一个小技巧:如果你在写链表时反复纠结要不要用二级指针,不妨先把函数原型写出来,然后问自己一句——这个函数结束后,我期望外部的头指针会不会变?如果会变,就传二级指针;如果不会变,就用一级指针。这个问题一问完,绝大多数纠结都会迎刃而解。等你把这条思路吃透了,以后无论是二叉树、还是各种基于指针的数据结构里的“根节点修改问题”,就都不会再困住你了。

返回列表