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

资讯详情

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

一文理清C++指针、数组与函数:从内存模型到工程实战

一文理清C++指针、数组与函数:从内存模型到工程实战 做过几年C的人基本都有同一种体验指针、数组、函数单个拎出来看都能看懂放在一起就开始头大。指针到底指向谁数组名到底是不是指针为什么函数里传进一个数组sizeof的结果就变了问三个问题大多数人卡在第二个。这篇文章是我在实际带新人过程中反复讲过很多遍的内容这次干脆整理成文把这三块的关系彻底理顺。内容偏向工程实战从内存模型讲起配合可以直接跑通的代码、典型错误场景以及我自己调试到深夜的踩坑记录。不管你是刚入门还停留在改 bug 阶段还是写了两年想系统补一遍基础这篇的思路应该都能对你有帮助。尤其在“两数交换用指针”“多维数组指针访问”“指针数组存放字符串”这类被反复搜索的问题上我会给你一套能举一反三的解释方式。1. 先把内存模型讲明白指针本质是门牌号很多人学指针时卡住不是因为看不懂*和的语法而是因为脑中没有一个清晰的内存模型。其实指针不是玄学它只是存放“别人地址”的变量而已。要理解这一点先从内存是什么说起。1.1 变量、地址与内存布局为什么指针不是玄学内存可以想象成一条很长很长的街道每个字节是一个房间每个房间都有一个门牌号这个门牌号就是地址。你声明int a 10时编译器做两件事在内存里挑一个能装下int的房间然后把数字10放进去。a这个名字只是这个房间的别名方便人写代码最终机器指令访问的其实还是那个门牌号。下面这行代码在绝大多数 64 位系统上输出的是一个类似0x7ffd8f1a2b3c的地址而不是10本身#include iostream int main() { int a 10; std::cout a std::endl; return 0; }是取地址运算符它的作用是把这个房间的门牌号拿出来。只要你理解了“变量名是别名地址是门牌号”指针的第一个坎就过去了。顺着这个思路走指针变量是什么它就是一个用来存门牌号的变量。int *p a;读作“p是一个指针里面存放的是a的地址”。而*p则是根据p里存的门牌号找到那个房间然后可以读里面的值也可以往里写新的值。所以*p 20;这句话本质上就是“跑到a的房间门口把里面的 10 换成 20”。1.2 指针变量自己也有地址和大小别忽略这层很多人一听到“指针变量也是变量”就理解了但真到用的时候又忽略了一层指针变量自己也有门牌号。int a 10; int *p a; int **pp p;这里pp是二级指针它存的是p的地址。我经常跟新人说指针变量就像一个存着别人门牌号的格子这个格子本身在内存里也占位置也有门牌号。二级指针就是“存着存门牌号格子的门牌号”听起来绕但画一张三层的小图就全明白了。还有一个值得注意的点指针变量的大小通常是固定的64 位系统下一般是 8 字节32 位下是 4 字节。它跟指向的类型无关int*、double*、char*的大小都是一样的因为地址本身长度固定。类型信息不是为了决定指针有多大而是决定两件事解引用时怎么解释内存里的字节以及指针加减时一次跨多大步长。这个“步长”概念在数组部分会反复出现先记住。1.3 为什么 C 要保留指针从传参和动态内存两个角度看有些人会问Java 和 Python 都没有裸指针为什么 C 非要留着最直接的回答是C 要给程序员直接操作内存的能力而传参和动态内存管理这两个场景没有地址是做不到的。先看传参场景。C 默认是值传递调用一个函数时实参的值会被拷贝一份交给形参。如果你想让函数修改外部的变量就需要把那个变量的地址传给函数函数通过解引用去操作原始内存。后面第 4 章的两数交换就是最经典的例子。再看动态内存场景。new在堆上分配一块内存返回的是这块内存的起始地址。如果你不把这个地址存在指针里分配完就再也找不到这块内存了既没法用也没法释放程序跑着跑着内存就被你丢光了。所以从内存管理的角度说指针本质上是一个句柄——你拿着它才能找到并操作那块堆内存。顺带提一句C 里的引用可以理解成一个“会自动解引用的常量指针”。理解好指针之后再看引用会轻松很多。2. 数组名与指针的纠葛能当指针用又不能完全当指针用数组和指针的关系大概是 C 初学者遇到的第一个大坑。网上流传着一句话“数组名就是指针”严格说这句话是错的但它在很多场景下又“碰巧能用”于是把人彻底绕晕了。这里我把它拆开讲清楚。2.1 数组名的本质一个只读的“首地址”声明int arr[5] {1, 2, 3, 4, 5};之后arr的类型其实是int[5]是一个数组对象不是一个指针变量。只不过在大多数表达式中数组名会“退化”成指向首元素的指针也就是arr[0]。所以你可以写int *p arr;但反过来不行——你不能直接给arr重新赋值也不能对它做arr或arr p这种操作。原因很简单arr不是一个可修改的变量它代表整个数组对象本身。你见过哪个数组能被“搬到别处去”的不能。要区分数组名和指针有一个很实用的技巧看sizeof的结果。int arr[5] {1, 2, 3, 4, 5}; int *p arr; std::cout sizeof(arr) std::endl; // 输出 205 个 int每个 4 字节 std::cout sizeof(p) std::endl; // 输出 8 或 4指针大小sizeof(arr)返回的是整个数组占用的字节数而sizeof(p)返回的是指针变量本身的大小。只要你看到函数内部sizeof一个“看起来像数组”的参数却得到 8基本就能断定这里发生了数组到指针的退化。2.2 p[i] 的本质是 *(p n)下标运算只是一层语法糖有一个知识点很多人第一次听到会愣住arr[i]在编译器眼里其实就是*(arr i)。你可以把下标运算符[]理解为一种语法糖它的真实含义是“先做指针加法再解引用”。既然arr[i]是*(arr i)而加法满足交换律那么i[arr]也是合法的因为*(i arr)和*(arr i)完全等价。这是一个经典的知识点展示方式但我在实际工程里非常不建议写i[arr]这种代码可读性太差纯粹是炫技没有任何维护价值。指针加法的步长值得多说两句。p 1不一定是地址加 1而是“加一个sizeof(*p)”。如果p是int*p 1会跳过 4 个字节直接指向下一个int的起始位置。如果p是double*那就跳过 8 个字节。这个步长规则是数组能用指针遍历的根基也是后面多维数组行指针能工作的根本原因。用指针遍历数组的标准写法是这样的int arr[5] {1, 2, 3, 4, 5}; for (int *p arr; p ! arr 5; p) { std::cout *p std::endl; }这里的p ! arr 5用的是尾后指针的判等指向数组最后一个元素之后的位置不访问那块内存只用于结束循环。这个习惯能帮你避开一堆边界问题。2.3 多维数组用指针访问层级千万别乱二维数组到底是什么它其实是“数组的数组”。int a[2][3]可以理解成a是一个长度为 2 的数组每个元素又是一个长度为 3 的int数组。从这个角度看a退化后的类型并不是int*而是int (*)[3]——一个指向“含 3 个 int 的数组”的指针也就是第 3 章要讲的数组指针。这里很多人会踩第一个坑把二维数组名直接赋给int*。int a[2][3] {{1, 2, 3}, {4, 5, 6}}; int *p a; // 编译报错cannot convert int (*)[3] to int*类型不匹配报错信息可能会让新手一脸茫然。正确的扁平化访问写法是int *p a[0][0];a[0][0]取的是第一行第一个int元素的地址类型是int*这才对得上。多维数组用指针访问时层级关系是理解的重中之重。a[i][j]可以写成*(*(a i) j)先拿到a i这是一个指向第i行的行指针再*(a i)取到这一行等价于一个int*最后加上j再解引用得到a[i][j]。每一层解引用都对应一个维度。下面这张对照表能帮你理清层级表达式类型假设int a[2][3]含义aint (*)[3]指向第一行含 3 个 int 的数组a 1int (*)[3]指向第二行地址偏移 12 字节*(a 1)int*第二行的首元素地址即a[1][0]*(a 1) 2int*第二行第三个元素的地址*(*(a 1) 2)inta[1][2]的值2.4 数组作为函数参数退化成指针后一切都不一样了这个坑几乎人人踩过。当你写这个函数时void printArray(int arr[]) { std::cout sizeof(arr) std::endl; }你以为arr是数组sizeof(arr)应该输出整个数组的大小但实际跑出来是一个指针的大小8 或 4。原因是在 C 的函数参数列表中int arr[]会被编译器悄悄改写成int *arr。数组传参的本质是传了首元素的地址而不是拷贝整个数组。为什么会这样设计因为 C 和 C 在传数组时为了效率默认选择“传引用式的指针”避免把整个数组复制一遍。这个设计语言诞生第一天就定下了一直到今天都没变。所以正确的做法是要么同时传长度参数要么用模板推断数组长度template size_t N void printArray(int (arr)[N]) { for (size_t i 0; i N; i) { std::cout arr[i] ; } }这种模板写法保留了完整的数组类型sizeof(arr)不再是谜题。但工程上更推荐直接用std::array或std::vector省心太多。模板只适合处理定长数组的静态场景。多维数组作参数时有一个让人百思不得其解的语法规定int a[][3]合法int a[][]不合法。原因其实很朴素函数拿到的是一个指向行的指针必须知道每行有多少个元素才能计算行与行之间的地址偏移。第一维是几行并不影响寻址第二维是每行的长度缺了它就寸步难行。3. 指针数组 vs 数组指针一字之差理解成本天差地别“指针数组”和“数组指针”这两个词几乎是 C 初学者最容易搞混的概念。一个字的顺序不同含义完全相反。这个坑我在面试里见多了能 30 秒内说明白的人基本功基本过关想半天还含含糊糊的通常是对类型声明优先级不熟。这里给你一个分分钟分清的方法。3.1 一秒区分两者的方法先看变量名和谁结合关键点在运算符优先级[]的优先级高于*。所以有两种写法int *p[3]; // p 先和 [3] 结合说明 p 是一个长度为 3 的数组 // 数组里的每个元素都是 int*所以叫“指针数组” int (*p)[3]; // 括号让 * 先和 p 结合说明 p 是一个指针 // 指针指向的类型是“含 3 个 int 的数组”所以叫“数组指针”我习惯用一个口诀帮自己记忆先看变量名右边[]优先就说明它是个数组右边没有东西、只有括号里的*那它就是个指针。这个拆解方式和函数指针的声明其实是同一套思维你都是在回答“这个变量到底是什么”的问题而不是被一长串符号吓住。3.2 指针数组的实际案例为一组字符串服务指针数组最经典的应用场景之一就是存放一组字符串。网上经常搜到的“指针数组存放字符串”说的就是这个const char *names[] { Alice, Bob, Cindy };names是一个长度为 3 的数组数组里每个元素都是const char*。内存布局上栈上有一个数组数组中存着三个指针分别指向字符串常量区里的三个字符串首地址。它并不真正持有字符串内容只保存“去哪里找字符串”的门牌号。这里有一个现代 C 的坑必须提醒字符串字面量的类型是const char[]所以拿char*去指它会得到一堆编译告警甚至在 C11 之后某些环境下直接报错。正确写法是const char*或const auto。如果你需要可修改的字符串集合更推荐std::vectorstd::string动态扩容、内存自动管理省下大量精力。指针数组适合追求极致性能和内存可控的场景不适合日常业务开发。3.3 数组指针的实际案例面向二维数组的行操作数组指针最常见的归宿就是二维数组。前面提到int a[2][3]的a会退化成int (*)[3]这个类型就是数组指针。我们用它可以一行一行地处理数据void printRow(int (*row)[3], int rowCount) { for (int i 0; i rowCount; i) { for (int j 0; j 3; j) { std::cout row[i][j] ; } std::cout std::endl; } } int main() { int a[2][3] {{1, 2, 3}, {4, 5, 6}}; printRow(a, 2); return 0; }这里的参数也可以写成int row[][3]编译器内部会把它解释成int (*row)[3]语义完全一致。第二维不能省略原因在前面已经说过没有第二维编译器就不知道每行有多长无法计算row 1应该偏移多少字节。3.4 工程中怎么选看你的数据形状指针数组适合的场景是你需要一个能通过下标访问的集合但集合元素本身要么是字符串要么是较大的对象指针不希望复制本体。数组指针适合的场景是你需要把一个二维数组传给函数处理或者希望按行来遍历数据。一句话总结变量本身是数组元素是指针——指针数组变量本身是指针指向的是一个数组——数组指针。这句话背熟面试笔试都能救你。4. 函数与指针的合作方式传参、返回、函数指针数组讲完轮到函数。函数和指针的合作方式其实比大多数人想象的丰富得多指针作参数、指针作返回值、函数指针作回调。每一层都有自己容易踩的雷。4.1 两数交换的经典解法值传递为什么不行网上被搜索无数次的“C 两数交换 指针”本质是在考察你对值传递的理解。看这段反面教材void wrongSwap(int x, int y) { int tmp x; x y; y tmp; }调用wrongSwap(a, b);之后a和b不会变。因为函数参数是按值传递的x和y是a和b的副本。函数内交换的是两张复印件原件纹丝不动。正确的写法是把a和b的地址传进去让函数通过解引用直接操作原件void swap(int *x, int *y) { int tmp *x; *x *y; *y tmp; } int main() { int a 10, b 20; swap(a, b); return 0; }调用swap(a, b)时x存的是a的地址*x *y这句话等价于a b改的是a的真实内存。小函数大道理它考验的是你对“副本 vs 原件”的直觉是否准确。等理解了指针你还可以用 C 引用写出更优雅的版本void swap(int x, int y)但每次遇到“两数交换”问题我都建议先在心里把指针版本默写一遍——它能检验你是否真的懂内存寻址。顺带一提std::swap已经封装了通用交换逻辑实际工程里不需要自己写但面试和学习阶段手动实现的价值在于理解原理。4.2 指针作返回值的红线别把局部变量的地址交出去指针返回值最容易踩的坑是返回了局部变量的地址。看这段int* getValue() { int local 10; return local; } int main() { int *p getValue(); std::cout *p std::endl; return 0; }这段代码在第一次运行时往往能正常打出一个10看起来好像没事。但它的行为在标准里属于“未定义行为”意味着程序可能在某个不相关的函数调用之后突然变成随机值也可能在某次优化后直接崩溃。原因很简单local是栈上变量函数一旦返回它的栈帧就被回收内存物理上还在但状态已经不在程序的控制范围内了。下一次别的函数调用会复用这块栈内存数据就会被覆盖。排查这种 bug 的经验是如果一个函数返回值是指针先问三句话——它指向的内存是堆还是栈如果是栈这个栈帧的生命周期能延续到调用方使用完这个指针吗如果都不确定就别返回裸指针。安全方案有几种用new分配堆内存并返回由调用方负责delete但容易漏导致内存泄漏把输出通过指针参数带出来或者用std::unique_ptr包裹返回让生命周期自动管理。我个人的建议是现代 C 中返回值尽量用值返回或智能指针裸指针只保留在需要极低层内存操作的场景。你每少写一个裸指针就少了一份要半夜起来排查内存泄漏的潜在工作。4.3 函数指针把一段逻辑当成变量来传递函数名在表达式里也会退化成一个地址——它指向的是函数机器码在代码段中的起始位置。所以你可以把一个函数赋值给一个“函数指针”然后用这个指针去调用它。int add(int a, int b) { return a b; } int main() { int (*fp)(int, int) add; int result fp(3, 4); return 0; }声明int (*fp)(int, int)时fp先和*结合表示它是一个指针指向的对象是“接受两个int、返回int的函数”。这个拆解方式和前面数组指针的声明如出一辙都是“先看变量名和谁结合再看它指向的类型长什么样”。函数指针类型必须精确匹配返回类型、参数列表、参数顺序都得对上。写错一个编译器直接报类型不匹配不会含糊。如果你觉得手写这种类型太丑C11 之后可以用using别名using BinaryOp int(*)(int, int); BinaryOp fp add;4.4 回调机制函数指针的用武之地与更现代的替代函数指针最大的用处是回调。比如你要写一个遍历数组并对每个元素做处理的函数但处理逻辑不确定于是可以把它作为函数指针传进来void forEach(int *arr, int n, void (*fn)(int)) { for (int i 0; i n; i) { fn(arr[i]); } } void print(int x) { std::cout x ; } int main() { int arr[3] {1, 2, 3}; forEach(arr, 3, print); return 0; }这就是标准库qsort的工作模式你需要传一个比较函数进去库函数用这个函数比较两个元素的大小。std::sort则更进一步接受函数、仿函数或者 lambda。C 里 lambda 比裸函数指针灵活得多因为它可以捕获上下文变量本质是一个匿名函数对象。比如上面这个例子用 lambda 可以直接把std::cout的格式也一并捕获无需额外定义全局函数。所以我的建议是在新代码里优先用std::function或模板加 lambda裸函数指针保留在需要与 C 接口交互、或者极端强调性能且逻辑简单的场景。理解裸函数指针的意义在于它能帮助你彻底搞懂“代码也是内存里的一段数据”这个概念——函数地址本身也是可以存储和传递的。5. 实战复盘那些让我调试到深夜的指针问题理论知识讲完来点实际的。以下这几个问题都是我这些年真实踩过、或者带新人时反复见到的。它们的共性是表面现象千奇百怪背后原因都指向同一个根——没有把“地址、生命周期、类型”这三个词想清楚。5.1 未初始化指针直接解引用不知道哪里来的崩溃场景还原某天同事跑一个程序跑着跑着突然崩了崩溃位置每次都不一样。用调试器一看堆栈停在某个普通的赋值语句*p 10;上而p是一个没有初始化过的指针。int *p; *p 10;p里存的是什么是栈上这块内存里残留的随机值。这句代码实际是在向一个随机地址写入10。运气差一点直接访问非法内存进程崩溃运气好一点写进了别的变量或者堆块里的某个角落程序当时不崩但数据被改坏了留下一堆无法解释的诡异 bug。排查思路很简单凡是声明指针一律初始化。不知道指向哪里那就初始化为nullptr。解引用之前先判断是否为空。这个习惯能把一大类崩溃直接消灭在源头。如果你遇到一个“随机崩溃、可复现性极差”的内存问题先列出所有指针变量逐个检查初始化路径。这种问题用 AddressSanitizer 能更快定位——下一节会讲。5.2 返回局部变量地址的隐藏炸弹第一次对第二次错这是 4.2 节那个问题在实际中的表现。我记忆最深的一次排查一个导出数据的函数第一次调用结果完全正确第二次调用拿到的数据就变成了乱码。当时的代码大概长这样char* getBuffer() { char buf[256]; snprintf(buf, sizeof(buf), some data %d, value); return buf; }第一次调用时栈上还有一大片干净空间buf里的数据完整。第二次调用前别的函数顺手就覆盖了这段栈空间结果拿到的buf已经面目全非。这种问题最难的地方在于“第一次是好的”这会让很多人排除掉这段代码反而跑到别的方向去排查。排查技巧是把注意力放在“这个地址从哪里来”上。打印返回指针的值看它落在哪个内存段。如果它落在栈空间范围内十有八九就是返回了局部变量的地址。修复方式是把缓冲区交给调用方提供或者用std::string值返回让堆内存接管生命周期。5.3 指针类型不匹配数组指针和元素指针一字之差再回到二维数组。很多人写完int *p a;看到编译器的报错cannot convert int (*)[3] to int*第一反应是“编译器坏了”。其实编译器在用类型信息保护你a退化成行指针而int*是元素指针两者语义完全不同。如果你确实想用一级指针遍历整个二维数组正确写法是int *p a[0][0];然后通过p i * 3 j定位到a[i][j]。因为二维数组在内存中是连续排列的第一行之后紧接着第二行。但这里有个重要提醒这种扁平化访问方式依赖存储连续性标准规定数组是连续存储的所以可行不过从可读性角度我更推荐用行指针或直接a[i][j]方式访问。每次遇到cannot convert这类报错时最好的应对不是硬转类型而是先打印两边的类型想清楚你到底想要“指向行的指针”还是“指向元素的指针”。5.4 调试三板斧让指针问题“显形”的工具光靠肉眼读代码排查指针问题效率太低。我一般按这套流程来第一步打开 AddressSanitizerASan。在 GCC/Clang 下编译时加-fsanitizeaddress -g在 MSVC 下开启/fsanitizeaddress。ASan 能检测堆缓冲区溢出、栈缓冲区溢出、use-after-free 等大量内存问题并直接告诉你出错的那一行代码。我见过太多“跑着跑着崩溃”的问题开了 ASan 之后秒变一行清晰的报错信息。当年没有 ASan 的时代这类问题要靠 Valgrind 和手动猜苦不堪言。第二步用调试器看内存和类型。GDB 里p *p看解引用结果x/10wx p看p指向地址的连续 10 个字wordVisual Studio 里则在调试状态下打开“内存”窗口输入p可以直接看到内存内容。这些是直接观察内存最高效的方式能让你对“指针指向哪里、那里到底存了什么”有直观感受。第三步排查内存泄漏。Linux 下用 ValgrindWindows 下可以用 Visual Leak Detector 或 CRT 提供的泄漏检测头文件。C11 之后更推荐用std::unique_ptr、std::shared_ptr来管理动态内存从根上减少new和delete的配对压力。最后附一个自查清单我每次 code review 时都会过一遍指针有没有初始化没有就一律置nullptr。函数的返回值是不是栈上局部变量的地址数组下标有没有越界多维数组的层数和类型是否匹配new和delete是否配对能不能换成智能指针类型检查是指针数组还是数组指针函数指针和函数签名是否完全一致指针问题本质上不是语法问题而是内存模型理解问题。一旦你习惯把“这个指针指向哪块内存”“这块内存的生命周期是谁负责”这两句话挂在嘴边绝大多数经典错误都能在写代码时提前避开。我带新人的这几年最常用来判断 C 基础的检验题就是两个数组名和指针到底是什么关系函数能不能返回局部变量的地址能把这两题说清楚的人写代码时出内存类问题的概率会低很多。指针的知识点看着多但底层其实只有三个词——地址、生命周期、类型。把这三个词想清楚你的直觉大部分时候都是对的。
返回列表