ARTICLE DETAIL

资讯详情

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

C/C++指针详解:从地址本质到智能指针,一文掌握核心机制

C/C++指针详解:从地址本质到智能指针,一文掌握核心机制 搞过C/C的人大概都经历过这么一段时光明明代码里全是*和翻来覆去就是闹不清到底谁指向谁数组名和指针纠缠在一起学了一个星期还是晕再遇上const int *p和int *const p这种换位游戏干脆直接摆烂。如果你搜的是鼠标指针那上面这些内容与屏幕上的小箭头无关这篇文章说的是C/C里的内存指针——它确实是很多初学者嘴里说的劝退点但一旦把底层逻辑捋顺你会发现指针真的就一句话地址也是一种数据可以存储、拷贝、传递和运算。这篇文章不是再给你背一万条语法而是从指针变量到指针的指针、从数组纠缠到函数指针、从裸指针到智能指针把C/C体系里所有和指针有关的知识节点串成一条容易理解的线顺带把工作里最容易踩的空指针、悬垂指针坑也一起说了。1. 先搞清楚指针变量到底存的是什么1.1 指针就是装着地址的普通变量很多人学指针第一课就懵是因为教材喜欢把指针描述得很玄指针就是指向变量的变量指针就是地址……听着云里雾里。我换个说法指针就是一个普普通通的变量只不过它的值是另一个变量的地址。你用int age 26;在内存里申请了一块4字节空间这块空间有一个编号就像小区门口的楼栋号这个编号就是地址。int *p;则是又申请了一块空间里面存的就是age的楼栋号。所以int age 26; int *p age;age取的是age的地址把地址存到p里。你可以打印一下试试printf(%p\n, (void*)age); printf(%p\n, (void*)p);两次打印结果一模一样。这说明p和age就是同一个值。如果非要说指针指向了age这句话的主语其实是地址值而不是什么神秘力量。理解了这一层后面所有东西都能推出来因为指针是变量所以指针也有自己的地址因为指针存的是地址所以可以拷贝、比较、加减因为地址指向某块内存所以可以通过地址反过来操作那块内存。整个过程没有任何魔法。1.2 指针赋值到底赋了什么代码写久了你会发现指针相关的bug有一大半出在赋值这个词上。看这段int num 10; int *p num; int *q p;q p是把p里存的地址拷贝给了q。现在q和p指向同一个num也就是两块指针变量里装的是同一个门牌号。这里拷贝的是地址不是把p这个变量本身拷贝过去更不是创建了一个新的num。这点特别容易和普通变量赋值混淆。int a 10; int b a;是把10复制一份给b之后改a不影响b。但指针赋值完全不同q p之后通过*q 100num就变成100了用*p看也是100因为它们操作的是同一块内存。还有一种赋值是解引用后的赋值*p 100;这里不是给指针赋值而是通过指针里存的地址找到那块内存把100写进去。很多新手把p x和*p x搞混我现在给你一个自救方法看到*出现在变量名前且不在声明语句里你就脑子翻译成解引用/找到那个地址对应的内存而不是给指针赋值。声明语句里的int *p*只是类型标注的一部分告诉你p是一个存着int地址的变量。1.3 和*一对相反的运算符是取地址*是解引用它们互为逆操作。*x等于x*p等于p。这个性质特别有用。假设函数签名是这样的void swap(int *a, int *b) { int tmp *a; *a *b; *b tmp; }调用时swap(x, y);参数传递的是x和y的地址函数内部通过解引用直接改写那块内存。如果你只传x和y那交换的是副本函数结束什么都留不下。这也是值传递还是引用传递问题的根源——C语言里所有形参都是值拷贝但拷贝的对象是地址时函数就能通过地址修改外部数据。看清这层指针在函数传参里的地位就明白了。2. 数组与指针的爱恨纠缠指针数组、数组指针与多维数组2.1 指针数组和数组指针运算符优先级一锤定音指针数组和数组指针是C语言社区经久不衰的辨析题其实把优先级表格摆出来5分钟就能分清。[]的优先级高于*。所以int *p[3]; // 先看p右边是[]说明p是一个数组数组元素是int*所以这是指针数组 int (*p)[3]; // 括号改变顺序p先和*结合说明p是一个指针指向的是含3个int的数组所以这是数组指针第一行一个长度为3的数组每个元素是一个int *。你可以用它放三个int变量的地址int a, b, c; int *p[3] {a, b, c}; p[1] b; // 给第二个元素赋值第二行一个指针它指向的是一个整体。这个整体是3个int排成一排而不是3个指针int arr[3] {1, 2, 3}; int (*p)[3] arr; (*p)[1] 100; // 等价于 arr[1] 100;注意取值必须用(*p)[1]括号不能省。*p得到的是数组名其实是指向首元素的指针再[1]取第二个元素。平时写代码用数组指针的机会确实不多但它在二维数组传参和动态分配二维数组时很好用后面马上提到。我教别人记这个知识的时候喜欢用一句话先看变量名右边再回来看左边。p[3]说明是个数组数组里装的是int*(*p)说明变量是p、p是指针指针指的是一个int数组整体。2.2 指针数组存放字符串不浪费一滴内存指针数组最常见的实际用途是存放一组字符串。C语言没有真正的字符串类型字符串本质上是一个char数组而字符串名就是char*。所以把多组字符串用指针数组串起来是这样的char *fruits[] {apple, banana, cherry, durian};这个fruits数组的每个元素都是一个char *分别指向各字符串字面量的首字符。好处是你不用提前知道每条字符串有多长也不用给最长的预留空间。你用char table[4][20]这种二维数组当然也行但固定20字节一行的空间浪费非常明显apple只占6字节还要空出14字节。我之前写一个命令解析器时用过这个结构char *cmd_list[] {help, quit, status, send};用户输入命令后遍历cmd_list找匹配项然后调用对应的处理函数。配合函数指针数组后面会说一个命令分发中枢就做完了。这种字符串表驱动的设计在协议解析、菜单系统、测试工具里到处可见。而且你可以用sizeof(cmd_list) / sizeof(cmd_list[0])算出元素个数不需要魔法数字。2.3 C多维数组与指针每层解引用都是一次降维一维数组名是一个指向首元素的指针二维数组名呢很多人以为int a[3][4]就是int **这是天大的误区我见过太多人在这一步栽跟头。int a[3][4]中a的类型实际上是int (*)[4]——指向一个长度为4的int数组的指针。原因很简单C/C的数组名会退化为指向其首元素的指针而二维数组的首元素是a[0]a[0]本身是一个长度为4的int数组所以a退化为指向这个数组的指针。int a[3][4] {{1,2,3,4},{5,6,7,8},{9,10,11,12}}; int (*p)[4] a; // p指向第一行 p 1; // 跳过一整行(4个int)指向第二行这里的加法运算和指针步长有关。p 1不是地址加1字节而是加sizeof(int[4])也就是16字节。理解这个指针加整数的步长由它指向的类型大小决定多维数组和指针的交互就不再神秘了。那C里怎么把二维数组传给函数答案就藏在类型里。函数签名必须写清楚数组有几列行数可以省略void printMatrix(int (*m)[4], int rows); // 等价写法 void printMatrix(int m[][4], int rows); // 也是对的如果函数莫名其妙写了个void printMatrix(int **m, int rows)编译器通常会给你个警告因为类型不匹配。想用int**接收二维数组你得手动构造一个指针数组把每行的首地址放进去本质是另一种数据结构不是同一个东西。3. 函数指针、指针函数与函数指针数组星号决定身份3.1 指针函数返回指针的函数先看最简单的int *func()。优先级上func()先结合说明func是一个函数返回值是int *。这种函数叫作指针函数你天天在写只是没注意malloc就是典型的void *返回而它的调用者经常用指针来接收。写指针函数最常见的坑是返回局部变量的地址。比如int *bad_func() { int local 42; return local; // 危险local是栈上的变量函数返回后就失效了 }这个指针在函数结束后就成了悬垂指针访问它属于未定义行为可能会得到莫名其妙的值可能直接崩溃也可能暂时看起来正常。正确做法是要么返回全局变量地址要么用malloc/new分配堆内存由调用者管理要么接收调用者传入的缓冲区指针。3.2 函数指针把函数地址存下来int (*funcPtr)(int, int)则完全不同。括号让funcPtr先和*结合于是它是一个指针指向的是返回int、接收两个int参数的函数。函数编译后也有地址函数名就是它的入口地址。于是int add(int a, int b) { return a b; } int (*funcPtr)(int, int) add; // 或者写完整一点 int (*funcPtr)(int, int) add; // 调用 int result funcPtr(3, 4);funcPtr这个名字让人不适应的地方在于声明语法怪。我理解这种从内向外的阅读顺序(*funcPtr)说明它是指针往右看(int, int)说明它指向的函数接收两个int往左看int说明函数返回int。函数指针最大的价值是当作参数传递也就是回调机制。标准库的qsort就靠这个int cmp_int(const void *a, const void *b) { return (*(int*)a) - (*(int*)b); } qsort(arr, n, sizeof(int), cmp_int);你把cmp_int的地址传给qsortqsort在内部合适时机调用它来决定排序规则。这就是策略模式在C语言里的朴素实现。3.3 函数指针数组表驱动编程的基础再往前走一步函数指针也能放进数组。声明方式就是和int *p[3]一样的思路只是元素类型是函数指针int (*handlers[])(int, int) {add, sub, mul, div_op};有了这个数组你就能根据下标直接调用对应函数替代一大串if-else或switch。这种写法在状态机、命令分发、消息处理里是标配。我之前写网络协议解析器时收到不同消息类型就查一张函数指针表typedef void (*msg_handler)(Msg *msg); static msg_handler handlers[MSG_TYPE_COUNT] { [MSG_LOGIN] on_login, [MSG_LOGOUT] on_logout, [MSG_HEARTBEAT] on_heartbeat, [MSG_DATA] on_data, };收到消息后用type当索引handlers[type](msg)一行搞定分发。新增一种消息类型只需要增加一个枚举值和一个函数再往表里注册回调逻辑不需要改动。这就是表驱动的好处——数据驱动代码而不是代码堆数据。3.4 双指针法指针在算法里的另一种姿态热搜词里有个双指针法严格说它不只是指针变量而是一整套算法思想。但既然点到了就展开说一下。最经典的是链表探测环快指针每次走两步慢指针每次走一步如果链表有环快慢指针终会在环里相遇。没有环快的先走到头。这个算法不需要额外空间O(1)复杂度就能判断面试题里经久不衰。bool hasCycle(struct ListNode *head) { struct ListNode *slow head; struct ListNode *fast head; while (fast fast-next) { slow slow-next; fast fast-next-next; if (slow fast) return true; } return false; }另一个常见场景是有序数组里找两数之和等于目标值。左指针指向头部、右指针指向尾部根据和与目标的大小关系移动某一边复杂度从O(n^2)降到O(n)。双指针的智慧在于把两个变量间的某种关系用两个游标来表达谁该动、往哪动由当前局面决定。4. const一掺和指针就变复杂常量指针与指针常量4.1 两种const修饰位置的语义const的位置不同锁定的对象不同。常见三种写法声明名称指针的指向可以改吗指向的值可以改吗const int *p;指向常量的指针常量化所指对象可以不可以int *const p;常量指针指针本身就是常量不可以可以const int *const p;二者都不可变不可以不可以第一种写法也有人写成int const *p和const int *p完全等价。第三种就是const int *const p或int const *const p两头都锁死。第二行的理解关键是*const修饰的是p本身p是一个const变量也就是指针变量一旦初始化就不能再指向别处但可以通过它修改它指向的值。4.2 实用记忆法和传参语义网上流传过一句口诀const在星号左边锁的是指针所指的值const在星号右边锁的是指针本身。我验证过无数次简单好使。你甚至可以再直接一点从左往右读const后面跟的是int就是值不能动跟的是p就是指向不能动。把const指针用在函数签名里是C/C工程里的基本素养void printMsg(const char *msg);这个意思是printMsg只能读取msg指向的字符串不能修改它。既可以防止函数内部意外篡改调用者的数据也是自文档化——调用者一看签名就知道这个函数只读不改。而如果哪天你传了一个char *给一个期望const char *的函数编译器是允许的反过来传const char *给char *参数编译器会警告甚至报错因为那等于放弃const承诺。4.3 指针的指针二级指针到底什么时候需要热搜词里也有指针的指针这是让很多人头大的知识点。二级指针确实少见但需要它的时候绕不开。一个核心应用场景是函数里要修改指针变量本身。看个经典例子——在链表头部插入节点void insertNode(struct Node **ppHead, struct Node *newNode) { newNode-next *ppHead; *ppHead newNode; }为什么非要struct Node **不可因为你要修改head指针变量本身的值指向新的头节点而C语言形参是值拷贝传struct Node *进来你只能修改指针指向的节点内容无法让调用者的head指向新节点。传二级指针*ppHead解引用后就是调用者的head变量本身改它就能影响外部。判断该不该用二级指针有个简单标准一级指针能改的是它指向的内容二级指针能改的是一级指针本身。凡是需要把某个指针变量重新指向一个新的地方并且这个改动要保留到函数外面就得考虑二级指针。如果你只是修改已存在节点的字段一级指针足够。C里很多人用引用来替代这种需求因为Node* head做空指针拉起更安全后面第五节细说。5. 引用、值传递、指针传递C传参的三选一5.1 三种方式的底层对比C出现后传参的问题多了一个选项引用。这三者的关系如果只看表面会觉得很乱但从内存视角看非常清晰传递方式形参是什么函数内改实参形参能为空吗典型场景值传递实参副本不能无所谓读小对象指针传递实参地址的值拷贝能通过解引用能传nullptr修改实参或允许空引用传递实参的别名能不能必须绑定对象修改实参、避免拷贝值传递本质是复制一份数据修改形参不影响实参指针传递是复制一份地址指针自己还是值拷贝但地址指向的是同一个对象引用传递没有拷贝地址这个动作引用是实参的别名编译器一般就把它当成实参本身来生成代码。这有个经典问题可以给新手练手void func(int *p)和void func(int *p)有什么区别答案是前者函数内给p重新赋值不影响调用者后者可以。这正好接上4.3的二级指针——引用让修改指针变量本身变得赤裸裸地安全。5.2 什么时候用引用什么时候用指针很多人问现代C里到底该用哪个我的建议很直接参数对象不能为空传给函数后只需要读/写对象内容就是const T或T参数可能是nullptr比如表示可选或失败那就用指针需要重新绑定目标比如循环遍历中让指针在容器元素间移动用指针大对象避免值拷贝用const T最经济实惠。还有一个直接原因引用语法没有*和-代码更干净。但引用也有它的短板——必须初始化且不能为空这就意味着你没法用引用表达没有东西这种语义。所以我的原则是**优先引用确实需要空语义时再退回指针。**这条对日常业务代码完全够用。5.3 数组参数隐式退化为指针C/C里函数形参如果写int arr[]编译器会把它当成int *arr处理因为数组太大不可能整体拷贝。所以void f(int arr[]) { ... } // 本质是 void f(int *arr)这意味着函数内部没有数组的长度信息你必须另外传一个长度参数。这也是C语言无数bug的来源之一sizeof(arr)在函数内部拿到的是指针的大小而不是数组的大小C可以考虑用std::array、std::vector或std::span来根治这个问题。写代码时遇到为什么我传了数组函数里算出来的元素个数不对十有八九就是这个退化机制在作祟。6. 结构体指针与文件指针C语言里绕不开的两种指针6.1 结构体指针与-运算符struct和指针结合是链表、树、图等一切动态数据结构的基石。-运算符的历史地位在于它把(*p).member这个又丑又容易漏括号的写法压缩成了p-member。struct Student { int id; char name[32]; int score; }; struct Student stu {101, Zhang, 88}; struct Student *ps stu; printf(%s\n, ps-name); // 等价于 (*ps).name ps-score 95; // 等价于 (*ps).score 95;结构体指针最喜欢的场景是动态分配内存创建节点struct Node *createNode(int val) { struct Node *n (struct Node*)malloc(sizeof(struct Node)); if (n NULL) { /* 处理内存分配失败 */ } n-val val; n-next NULL; return n; }注意malloc后一定要判断返回值是否为NULL。我见过不少新手直接不判断然后继续解引用内存不足时程序就莫名其妙崩溃了。严谨的工程代码里malloc后的判空是基本功而不是可选项。写链表类代码时还有个细节为什么用struct LinkNode **而不是struct LinkNode *来插入头部节点前面4.3讲过了——因为你要改head指针变量本身的指向必须拿到它的地址。6.2 文件指针的实操要点FILE *fp大概是初学者接触到的第一个状态型指针。fopen成功返回一个指针失败返回NULL。这个指针不只是文件内容它背后是操作系统管理的文件句柄、读写位置、错误标志等一整个状态。基本套路很简单FILE *fp fopen(data.txt, r); if (fp NULL) { perror(fopen failed); return -1; } char line[256]; while (fgets(line, sizeof(line), fp)) { // 处理一行 } fclose(fp);这里有几个经验级别的教训值得说fopen必须配fclose漏掉它会导致文件句柄泄漏在循环里反复打开文件迟早把句柄耗尽feof(fp)不能用来判断文件读完了它只在读取操作尝试越过文件末尾后才置位所以标准写法是先读取再判断返回值而不是先判断feof再读取文件指针也遵循要修改底层状态时传指针只读取时传 const的原则例如fprintf(FILE *stream, ...)把FILE *放在第一位就是因为它要修改文件状态。如果项目是C我推荐优先考虑std::ifstream/std::ofstream或fopen_s这类带安全边界的方式手动管理FILE *始终有忘关、双关、悬垂三个风险用RAII包装后楼下要讲的智能指针思路同样适用。7. 从timer空指针报错看空指针排查套路7.1 问题现场与根因分析timer执行查询时报告空指针这类问题在服务器端、GUI程序里都很常见。前两天我调一个定时任务时也踩过一模一样的坑一个定时器每5秒触发一次回调里会去访问一个全局指针指向的对象然后某天运行到一半崩溃日志里明明白白写着null pointer dereference。排查的第一个动作不是猜而是用调试器把崩溃点的所有变量值打出来。通常做法是先看堆栈崩在哪个函数、哪一行、访问了哪个地址。如果是0x0或者接近0基本就是空指针。接下来要问自己三个问题这个指针变量在哪里初始化的在哪里可能被置成NULL有没有可能对象已经被释放指针还没来得及置NULL定时器场景最容易出问题的点是生命周期不同步。对象是在主线程创建的可能在某次关闭流程里被释放并置成NULL了但定时器回调在另一个线程或者稍晚时刻还在引用它。此时你看到它为NULL实际是对象已经被销毁。我那次排查的记录大致是这样的崩溃位置: TimerTask::execute() - queryData() 指向对象: DataSource *ds_ 报错值: ds_ 为 0x0 线索: ds_ 在 StopDataService() 里被释放但没有反注册定时器 结论: 关闭服务时先停止了数据源却忘了停止定时器导致一次“晚到的定时回调”访问了已释放对象解决方式也很简单关闭数据源之前先停掉定时器或者进入回调时先检查ds_是否为NULL并加锁。这个案例说明空指针从来不只是加个if判空这么简单它背后往往是对象生命周期管理的问题。7.2 空指针防御的正确姿势行业内对空指针有一系列成熟的防御措施按优先级排序第一道防线对象创建后立即初始化指针杜绝未初始化的野指针第二道防线所有能返回NULL的调用malloc、fopen、new、查询接口都要检查返回值第三道防线析构或释放的时候把指针置NULLrelease-and-null这样别的模块访问时至少能得到明确的空而不是野地址第四道防线使用智能指针或引用让空这个状态从类型层面消失能编译出错的就不留给运行时。判空本身有个细节最好把可能NULL的指针放在等式左边比如if (NULL p)防止手滑写成if (p NULL)。这个经典错误在C语言里连编译都通过坏在运行时。很多现代编译器会对if (p NULL)报警告但习惯写NULL p可以从源头刹住这辆车。还要提醒一句在某处判空了不代表另一处安全。多线程环境下第一次判空通过、第二次访问前对象已经被别的线程释放这叫TOCTOU时间检查和使用的时间差。完全靠判空是防不住的要从生命周期管理层面解决。8. 智能指针现代C给裸指针的收尸服务8.1 裸指针的三个老大难裸指针最大的问题不是指针这个概念而是资源所有权不清晰。你new了一个对象到底谁负责delete函数A创建了对象传给函数BB用完了要不要释放如果B里某条路径抛了异常delete还能执行到吗这三个问题在大型项目里会被放大成类别的bug内存泄漏、双重释放、悬垂指针。C社区为此搞出了RAII思想——资源管理绑定到对象生命周期。你创建一个对象它构造时申请资源、析构时释放资源使用者根本不用手动调delete。智能指针就是RAII思想最经典的代表。8.2 unique_ptr、shared_ptr、weak_ptr怎么选三种智能指针最常见的对比表我直接整理出来智能指针所有权模型引用计数典型场景std::unique_ptr独占所有权无工厂函数返回新对象、类持有成员资源std::shared_ptr共享所有权有多个模块共同使用同一个对象、缓存std::weak_ptr观察者/弱引用不计数打破循环引用、缓存会话、检测对象是否存活选型时的判断顺序很简单默认优先unique_ptr它开销最小、语义最清晰一个对象只能被一个人拥有当确实有多个人要一起持有时升级成shared_ptr如果只是想看它一眼但不希望因此延长它的生命周期用weak_ptr。这里必须讲循环引用的坑。考虑两个对象互相持有shared_ptr它们的引用计数永远降不到0谁也释放不了内存泄漏了但代码看起来毫无问题。解决办法是让其中一方持有weak_ptr打破环struct Node { std::shared_ptrNode next; std::weak_ptrNode prev; // 这一侧用弱引用避免循环引用 };weak_ptr还有一个用处判断某个对象是否已经被释放。你lock()一下能拿到shared_ptr就说明对象还活着拿不到说明已经销毁。这是排查timer回调访问已释放对象的好帮手。8.3 自己动手实现一个简化版shared_ptr很多人面试被问智能指针实现我建议动手写一个简化版理解就会非常深。shared_ptr的核心就一句话维护一个引用计数拷贝时计数1析构时计数-1计数归零时释放资源。class MySharedPtr { public: MySharedPtr(int* p) : ptr(p), count(new int(1)) {} MySharedPtr(const MySharedPtr other) : ptr(other.ptr), count(other.count) { (*count); } ~MySharedPtr() { if (--(*count) 0) { delete ptr; delete count; } } int operator*() { return *ptr; } private: int* ptr; int* count; };这个示例简化了线程安全、自定义删除器、类型推导等但核心逻辑就是引用计数RAII。你拿着这个思路去看标准库源码很多看不懂的地方会豁然开朗——无非是它把这种模式做成了通用的、线程安全的、支持各种类型的模板罢了。顺带一提我们平时说的智能指针实现还有另一层含义利用栈对象的析构函数在作用域结束时自动执行保证任何路径——包括异常路径——都能释放资源。理解这一点比背十个API都值钱。踩过几次坑之后的总结写硬核知识点容易越写越长最后说点掏心窝的经验。指针这东西恐惧感多半来自抽象层级跳跃一会儿把它当值一会儿把它当地址一会儿又要解除引用。我的经验是把所有指针问题都降到地址值这个层面去想**指针变量存的是数一个地址数*是根据地址去访问内存是获取某个变量的地址[]就是基础地址加偏移量的语法糖。**所有复杂的指针数组、函数指针、多维数组、二级指针本质都是这一条线的排列组合。第二个经验是永远不要忽略生命周期。几乎所有的空指针报错、异常崩溃、内存越界最后都指向同一个根源——对象生命周期和引用它的时机不匹配。裸指针时代靠脑子和规范硬撑C11之后就大胆用智能指针把所有权关系写进代码里让编译器替你看着这份契约。最后一个建议比较实际想系统攻克指针手头放一本经典的《C和指针》或者《C Primer》遇到绕不清的优先级、语义、陷阱别用碎片化搜索去碰运气认认真真翻到对应章节读一遍、敲一遍例子。我对指针的一次次通透都不是记住了多少语法而是靠自己动手写代码、自己造bug、自己解bug换来的。纸上谈兵解决不了指针问题多写几次链表、多跑几次内存检测工具比谁讲得天花乱坠都管用。
返回列表