恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C语言数组传参全解析:指针退化、多维数组与跨语言避坑指南
首页
资讯中心
/
C语言数组传参全解析:指针退化、多维数组与跨语言避坑指南
C语言数组传参全解析:指针退化、多维数组与跨语言避坑指南
发布时间:2026/10/10 12:40:47
数组作为函数参数怎么传这个问题在C语言学习里几乎绕不开。很多人一开始以为函数接收了数组的副本结果发现函数里改了元素外面的数组也跟着变还有人写二维数组传参时被编译器报了一堆看不懂的警告。真正的问题是数组进了函数之后装备全都丢了——长度没了维度模糊了边界信息也归零。这篇文章我会从C/C这条主线把原理讲透再对照Python、Java、JavaScript、PHP、VBA这些日常场景里的数组参数行为最后用几个实际案例演示怎么写函数签名才不容易踩坑。适合在校学生打基础也适合工作中老被数组问题折腾、想系统梳理一遍的人。1. 数组传参的本质传的是地址不是整块数据先把最重要的一句话说清楚在C语言里数组作为函数参数传递时并不会把整个数组复制进函数而是把数组首元素的地址传进去。也就是说形参接收的是一个指针。这个行为在标准里叫“数组退化为指针”array decay。#include stdio.h void doubleAll(int *p, int n) { for (int i 0; i n; i) { p[i] p[i] * 2; } } int main(void) { int nums[4] {1, 2, 3, 4}; doubleAll(nums, 4); for (int i 0; i 4; i) { printf(%d , nums[i]); } return 0; }运行结果是2 4 6 8。函数内部通过p[i]修改元素实际上操作的是main里的nums数组。用一个生活化的类比你给朋友的不是整座城市的拷贝而是一张城市地图。他拿着地图去改建筑改的就是你这座城市的实体。1.1 为什么数组形参不能像普通变量那样按值复制普通变量传参时比如int a 3; f(a);函数拿到的是3这个值的副本在函数里改来改去外面的a纹丝不动。但数组为什么不能这样做原因是多方面的。第一是效率。数组是连续内存块可能很大。如果每次调用都整块复制到函数栈上开销会非常难看栈空间也可能直接爆掉。第二是C语言的设计理念。C语言把数组名定位成“内存块的起始地址”而不是一个可以整体搬运的“值”。第三是灵活性。传地址之后同一个函数可以处理任意长度的数组只要调用方把元素个数一并告诉你。也有例外。C里可以用std::array或者把数组包进struct里来按值传递因为这时候数组作为一个成员被整体复制语义上是可拷贝对象。但原生C数组本身没有赋值语义int b[5] a;这种代码根本编译不过。所以“数组不能按值传递”不是编译器的脾气而是语言设计层面的决定。1.2 数组长度是怎么在传参时丢掉的这是新手第一个大坑。看这段代码#include stdio.h void printArray(int arr[]) { printf(sizeof(arr) %zu\n, sizeof(arr)); } int main(void) { int nums[10] {0}; printf(sizeof(nums) %zu\n, sizeof(nums)); printArray(nums); return 0; }在64位系统上sizeof(nums)输出4010个int每个4字节而函数里的sizeof(arr)输出8一个指针的大小。原因就是arr已经退化成指针了sizeof对指针求字节数不会也不可能知道原来的数组有多大。所以C语言里几乎所有接收数组的函数都要附带一个长度参数这是约定俗成的规矩。比如int sumArray(const int arr[], int n) { int sum 0; for (int i 0; i n; i) sum arr[i]; return sum; }想省点事可以用宏在调用点算长度#define ARRAY_SIZE(a) ((int)(sizeof(a) / sizeof((a)[0]))) int nums[10] {0}; int s sumArray(nums, ARRAY_SIZE(nums));但注意这个宏只能在数组变量上使用。一旦数组名传进函数变成了指针再用ARRAY_SIZE就会得到一个荒谬的值。我在代码评审里见过好几个人把这个宏写进函数里然后怎么调试都不对其实就是没绕过指针退化这个机制。2. 多维数组传参为什么第二维必须写出来一维数组传参搞明白了二维数组才是最容易翻车的地方。先看一个标准写法void printMatrix(int mat[][4], int rows) { for (int i 0; i rows; i) { for (int j 0; j 4; j) { printf(%d , mat[i][j]); } printf(\n); } }调用时传printMatrix(matrix, 3)。注意形参写成了int mat[][4]第一维留空第二维必须写死为4。为什么因为二维数组在内存里是连续存储的一行4个int下一行紧跟其后。编译器要访问mat[i][j]它需要计算偏移量i * 4 j因此必须知道每行有4个元素。第一维反而无所谓因为函数只需要知道从哪一行开始而不需要知道一共有多少行——行数通过rows参数单独告诉它。用个更生活化的例子你去一座非常规整的楼里找人门牌号是“第几层、第几间”。如果不知道每层有多少个房间你就没法准确数出目标房间的位置。第二维就是“每层房间数”少了它整个寻址系统就瘫痪了。2.1 int** 不能当二维数组的形参这是最常见的类型不兼容不少人会写这样的代码void wrongPrint(int **mat, int rows) { for (int i 0; i rows; i) { for (int j 0; j 4; j) { printf(%d , mat[i][j]); } } } int matrix[3][4]; wrongPrint(matrix, 3); // 编译器警告incompatible pointer types警告不是编译器矫情而是真的不兼容。matrix退化成指针后类型是int (*)[4]也就是“指向含4个int数组的指针”。而int**是“指向int指针的指针”。这两者内存布局完全不同。举个例子int matrix[3][4]是一片连续的16个int而int **p通常指向一个指针数组每个元素又指向另外一块内存区域。用访问二级指针的方式去访问连续二维数组编译器根本算不对地址运行起来往往会读到奇怪的垃圾值甚至崩溃。正确的等价写法是void rightPrint(int (*mat)[4], int rows) { // 与 int mat[][4] 完全等价 }int (*mat)[4]就是数组指针指向“由4个int组成的一行”。int mat[][4]只是同一类型的另一种面孔编译器在形参层面处理成完全一样的指针。如果想连第二维也留成参数那就只能把矩阵展平成一维来传访问逻辑自己算下标void flatPrint(int *mat, int rows, int cols) { for (int i 0; i rows; i) { for (int j 0; j cols; j) { printf(%d , mat[i * cols j]); } } }这种写法牺牲了可读性但换来了灵活性适合输入矩阵尺寸不固定的算法函数。很多C语言矩阵库实际就是这么干的。2.2 数组指针和指针数组到底谁是谁这两个词长得像双胞胎含义完全不同面试和笔试出镜率极高。int (*p)[N]数组指针p是一个指针指向类型为int[N]的数组。通常用来指向二维数组的某一行或整个二维数组。int *p[N]指针数组p是一个数组里面装着N个int*指针。一个经典的字符串应用场景是char *strs[3] {hello, world, program}; void showStrings(char *list[], int n) { for (int i 0; i n; i) { printf(%s\n, list[i]); } }这里的char *list[]等价于char **list接收的是一个指针数组。每个元素是char*指向一个以\0结尾的字符串。打印时用%s操作list[i]即可。可能会有疑问char *strs[3]和char names[3][16]不都是二维结构吗确实都能装3个字符串但内存模型不一样char names[3][16]3块连续内存每块16字节元素可以直接修改。char *strs[3]数组里有3个指针指向字符串字面量或其他地址字符串字面量通常存储在只读区直接修改strs[0][0] H是非法的。二维字符数组传参时要写成void showNames(char names[][16], int rows)和普通二维数组规则一致。字符串指针数组传参时要写成char *names[]。两者千万别混。2.3 const 修饰数组参数能防多少事如果函数只读数组不修改形参最好加上constvoid printAll(const int arr[], int n) { for (int i 0; i n; i) { printf(%d\n, arr[i]); } }const int arr[]让编译器帮你约束代码函数里一旦出现arr[i] 1;立刻报错。原则是能不改成参数就尽量不修改。函数签名里写const不是做摆设而是跟调用方的一种承诺“我不碰你的数据”。要注意类型兼容方向普通数组可以传给const int arr[]形参因为隐式加上const是安全的反过来const数组不能随意传给非const形参那样等于放弃了只读保护。还有一个容易踩的坑int**不会自动转换成const int**因为双重指针的const传递涉及更隐蔽的别名问题。字符串数组作为参数时如果函数只读不写写成char * const argv[]这类形式可以避免一些类型警告。3. 跨语言视角Python、Java、JS、PHP、VBA里的数组参数C和C讲完后很多人会问为什么我在Python里写函数列表传进去改了之后外面也变但有时又不变本质上所有语言都在对抗同一个问题参数是“对象内容的修改”还是“对象引用的重新绑定”。3.1 Pythonlist是引用传递但重新赋值是局部行为Python的list是可变对象。函数参数拿到的是对象引用可以理解为指向同一个列表的指针所以def demo(lst): lst.append(100) # 外部列表会多一个100 lst[0] 99 # 外部列表第一个元素变成99 lst [1, 2, 3] # 这一行不会影响外部只是局部变量重新指向新列表很多新人误以为“Python传引用所以函数里所有操作都会影响外面”但上面的第三行并不会影响调用方的原列表。区分的标准很简单你在修改对象的内容还是给变量绑定新的对象。append、lst[i] x是修改内容lst new_list是重新绑定外部变量拿不到。Python里数组的切片同样值得注意。arr[:2]会创建新列表元素是原列表中对象引用的拷贝data [1, 2, 3, 4, 5] sub data[1:4] sub[0] 99 print(data) # 因为 sub 是新列表data 不受影响如果列表元素本身是可变对象切片后修改元素的内部状态仍会透过引用影响到原对象。这些细节在实际写代码时很容易埋雷。平时用VS Code写Python时鼠标悬停在函数名上能看到签名和文档字符串但工具只能告诉你形参长什么样无法帮你判断这个参数是会被修改还是只读。真正靠谱的还是理解可变对象与引用绑定的区别。3.2 Java和C#数组是对象方法里改内容一定穿透Java的数组是引用类型。方法内通过参数修改arr[0]调用方数组会同步变化但如果方法里执行arr new int[]{...}外部引用不会变public static void demo(int[] arr) { arr[0] 99; // 外部能看见 arr new int[] {1, 2, 3}; // 外部看不见参数引用的重新赋值是局部行为 }C#的数组同样是引用类型默认行为类似Java。但C#还提供了ref、out、params这些关键词让引用传递更显式void SetFirst(int[] arr) { arr[0] 5; } void PrintScores(params int[] scores) { for (int i 0; i scores.Length; i) { Console.Write(scores[i] ); } } PrintScores(90, 85, 88); // 编译器自动构造 int[3]params适合做可变参数ref int[] arr则可以把“替换整个数组”的效果传出去。理解了C数组传参的本质再来学这些关键词会很顺前者是操作对象内容后者是操作引用本身。3.3 JavaScript、PHP、VBA数组参数的“改内容”与“换引用”JavaScript里数组也是对象函数内arr.push(1)、arr.splice(...)会直接改原数组但完整赋值arr arr.slice(0, 2)不会影响外部因为arr参数只是一个指向原数组的引用变量给它重新赋值等于换了一个新地址。数组去重在JS里常用Set和filter。用const unique [...new Set(arr)]得到的是新数组原数组不受影响。这种“返回新数组”和“原地修改”的区分在链表、树这类复杂结构里尤其重要。我见过有人封装工具函数时一个sort把调用方的原数组排了序自己却不知道就是因为JS里sort是原地排序。如果不想影响原数组记得先arr.slice()再做操作。PHP的数组默认按值传递但有写时复制优化copy-on-write也就是只有函数内真正修改数组时才会发生拷贝function addItemNoEffect($arr) { $arr[] 100; // 外部 $arr 不受影响 } function addItemReal($arr) { $arr[] 100; // 加 才按引用传递外部会变 }VBA在Excel场景里非常常见而且区分ByRef和ByVal。VBA函数默认是ByRef数组传入函数后在函数里修改元素会直接影响调用方的数组Sub ChangeFirst(ByRef arr() As Long) arr(0) 99 End Sub如果写成ByVal arr() As LongVBA并不会做完整的深拷贝数组内部的修改依然可能透传回去这与普通Long、String参数的行为完全不同。所以在Excel VBA里操作数组参数别想当然地以为ByVal就能保护原数组真正要复制还是得手动Array逐元素拷贝或用Application.Transpose技巧。3.4 MATLAB传参与结构体封装的思路MATLAB的函数参数在细节上比较特殊它默认按引用传递但又配合写时复制所以绝大多数情况下可以当作“传值不影响外部”来理解除非函数里修改了数组内容。注意MATLAB切片取多列时A [1 2 3; 4 5 6; 7 8 9]; B A(:, [1 3]);B是新数组和A没有关系。如果不想让函数修改输入矩阵通常习惯在函数内部赋值给临时变量并返回结果。这也是MATLAB最常见的范式输入矩阵返回新矩阵副作用很少。C语言里没有原生动态数组项目里经常会用结构体把“指针长度”打包起来typedef struct { int *data; int len; } IntArray; void setAll(IntArray arr, int value) { for (int i 0; i arr.len; i) { arr.data[i] value; } }这里setAll接收结构体结构体本身是值拷贝但结构体里的data指针指向的还是同一块堆内存所以修改元素照样穿透。如果要真正的独立副本得写深拷贝函数重新分配内存并逐元素复制。C则推荐用std::vectorint传参写成const std::vectorint可以避免无谓的整体拷贝需要修改时再传引用。4. 实战场景拆解从字符串筛选到算法模板理论讲了一堆接下来用几个实际案例把数组参数设计串起来。这些场景覆盖了字符串数组、递归使用数组、队列结构、命令行参数以及算法模板基本就是日常开发里最常见的几类需求。4.1 字符串数组过滤筛选包含某个字符的元素假设有一个文件名数组要筛选出包含特定字符的元素并显示。在C语言里可以设计一个函数接收字符串数组、个数和关键字结果通过输出参数返回#include stdio.h #include string.h int filterContains(char *strs[], int n, const char *key, char *out[], int outCap) { int cnt 0; for (int i 0; i n cnt outCap; i) { if (strstr(strs[i], key) ! NULL) { out[cnt] strs[i]; } } return cnt; } int main(void) { char *files[] {report.xls, data.txt, notes.doc, index.html}; char *result[4]; int cnt filterContains(files, 4, x, result, 4); for (int i 0; i cnt; i) { printf(%s\n, result[i]); } return 0; }这里有两个数组参数输入数组char *strs[]和输出结果数组char *out[]。尤其是输出数组它其实就是一个“缓冲区”调用方负责分配空间并告诉函数容量上限函数负责往里面填指针并返回实际写入个数这样才能保证不越界。同样的需求在JavaScript里只需要一行const filtered files.filter(f f.includes(x));但理解C版本的实现能帮你明白.filter这类高阶方法背后其实也在维护一个内部结果数组并且需要控制遍历终止条件。4.2 已知固定值找出数组中哪些元素和等于目标值这个问题的经典表述是给定一个数组和一个固定数值列出所有元素组合使组合的和等于该数值。这是一个组合枚举问题递归实现时需要把一个“当前路径”数组作为参数不断传递。#include stdio.h void findSum(int nums[], int n, int target, int path[], int depth, int sum, int start) { if (sum target) { for (int i 0; i depth; i) printf(%d , path[i]); printf(\n); return; } if (sum target || start n) return; for (int i start; i n; i) { path[depth] nums[i]; findSum(nums, n, target, path, depth 1, sum nums[i], i 1); } } int main(void) { int nums[] {2, 3, 5, 7}; int path[4]; findSum(nums, 4, 7, path, 0, 0, 0); return 0; }输出结果是2 5和7。核心思路是每层递归尝试把nums[i]放进path然后继续回溯时不需要手动清空因为下一轮会覆盖path[depth]。depth同时承担“path当前长度”和“写入位置”两个职责。如果要求去重或输出所有不重复组合还要对原数组排序并在循环里跳过相同元素。如果要把结果收集起来而不是打印就更麻烦了结果本身是二维结构C语言里通常得用“数组指针数组”之类的方式或者动态分配。这类题目考得不是语法而是你对“数组当作输出缓冲区”有多熟。4.3 循环队列数组和几个同谋参数一起传数据结构里有一道经典题目用数组q[m]存放循环队列元素同时用rear和length分别指示队尾和队中元素个数。不封装结构体时的函数签名长这样int enqueue(int q[], int m, int *rear, int *length, int x) { if (*length m) return 0; // 队满 q[*rear] x; *rear (*rear 1) % m; (*length); return 1; } int dequeue(int q[], int m, int *rear, int *length, int *x) { if (*length 0) return 0; // 队空 int front (*rear - *length 1 m) % m; *x q[front]; (*length)--; return 1; }用length就能优雅地判断队空和队满length 0队空length m队满。队头位置可以通过(rear - length 1 m) % m反推出来。可以看到q[]作为数组参数退化成指针而rear、length是普通变量想让函数修改它们必须传int*进去。如果不注意只传rear和length的值函数里改了也没用主函数里的变量纹丝不动。更工程化的做法是把队列封装成结构体typedef struct { int q[8]; int rear; int length; } CircleQueue; int enqueue(CircleQueue *cq, int x) { if (cq-length 8) return 0; cq-q[cq-rear] x; cq-rear (cq-rear 1) % 8; cq-length; return 1; }这样函数参数从“数组若干计数器”简化成一个结构体指针调用逻辑清爽很多。我想表达的是数组传参的问题并不只在数组本身往往还牵扯到“和数组配套的状态量怎么传”。封装成结构体是治本办法。4.4 main函数的参数操作系统传给你的字符串数组main(int argc, char *argv[])本身就是“数组作为函数参数传递”的活例子。操作系统把命令行参数组织成一个字符串指针数组argc是元素个数argv是首地址#include stdio.h int main(int argc, char *argv[]) { for (int i 0; i argc; i) { printf(argv[%d] %s\n, i, argv[i]); } return 0; }编译后运行./demo hello world输出argv[0] ./demo argv[1] hello argv[2] worldargv[0]通常是可执行文件名从argv[1]开始才是真正的用户参数。这就是为什么argv的类型是char *argv[]而不是char argv[][N]每个命令行参数长度不同用指针数组比固定大小的二维数组灵活得多。项目里常见做法是解析参数for (int i 1; i argc; i) { if (strcmp(argv[i], --version) 0) { printf(version 1.0\n); return 0; } }理解了argv的数组本质再去读各种框架的命令行解析代码会容易很多。4.5 算法模板里的数组参数以树状数组为例树状数组在算法题里出场率很高。假设维护n 16的序列需要实现单点修改add(3, x)和前缀和查询sum(11)。常规写法是用全局数组int bit[17]; int lowbit(int x) { return x (-x); } void add(int i, int x, int n) { for (; i n; i lowbit(i)) { bit[i] x; } } int query(int i) { int s 0; for (; i 0; i - lowbit(i)) { s bit[i]; } return s; }如果要封装成可复用模块可以把bit[]作为函数参数传进去void add(int bit[], int n, int i, int x) { for (; i n; i lowbit(i)) bit[i] x; }你会发现这里数组参数又要配n因为函数不知道树状数组的范围。这也是很多“模板”函数不好看的原因接口参数又多又碎。但这不怪语法数组本来就是“裸数据”不携带长度信息。仿照C容器工程上更好的做法是定义一个结构体把bit指针和n放在一起。核心思想还是那一句把数组和配套信息打包函数签名才真正干净。5. 常见问题与排查技巧实录下面这些问题都是我在实际写代码和帮别人Review时反复见到的整理成速查形式遇到类似症状可以直接对照排查。5.1 函数里 sizeof(数组参数) 结果不对前面已经解释过本质这里再给出一个直观的排查方式。如果你在GDB或printf里打印形参类型会发现sizeof(arr)返回的是指针大小。先检查函数是不是把形参写成了int arr[]或int *arr如果是那就不能用sizeof求长度要么在外面算好长度传进来要么在C里用模板推导template size_t N void printArray(const int (arr)[N]) { for (size_t i 0; i N; i) { std::cout arr[i] std::endl; } }const int (arr)[N]是数组引用它不会退化成指针编译器能推导出N。但要注意这只适用于编译期已知大小的静态数组动态分配的数组依然要传长度参数。5.2 二维数组传 int** 报不兼容指针类型这个错误太典型了。看到incompatible pointer types passing int (*)[4] to parameter of type int **就说明你把二维数组传给了int **形参。解决方式就是改形参类型void f(int (*mat)[4], int rows); // 或等价 void f(int mat[][4], int rows);如果想通用就用展平方案int *mat, int rows, int cols。不要试图用强制类型转换硬压警告运行时的地址计算会给你好看。5.3 函数返回局部数组指针悬垂这段代码是经典错误int *badCreate(void) { int arr[3] {1, 2, 3}; return arr; // arr 是栈地址函数返回后失效 }调用后拿到的指针指向的是一块已经“归还”给栈的内存随时可能被其他调用覆盖。正确做法有几种调用者自己提供数组函数只填充用static修饰局部数组或者用malloc动态分配记得让调用者释放。我在实践中更推荐“调用者分配、函数填充”这样内存所有权清晰不容易泄漏。5.4 数组越界在传参后更容易发生函数里没有边界信息越界读写在C语言里不一定会立刻崩溃但会悄悄破坏相邻内存。常见原因是循环条件写错for (i 0; i n; i)应该用 n。二维数组访问mat[i * cols j]时i或j超出范围。在Codesys、PLC这类环境里数组越界往往直接触发运行时错误问题定位更痛苦。我的习惯是凡是数组操作的函数在入口处先做参数校验if (arr NULL || n 0) return;多写这一行能省下无数半夜调试的时间。5.5 字符串数组极容易踩的修改坑char *strs[]如果初始化为字符串字面量那么strs[i][j] X是未定义行为可能直接崩溃。想要可以修改的字符串列表用二维字符数组char strs[][16]或者改用动态分配的内存指针数组。数组转字符串时也要注意空间是否够strcat、sprintf写越界很常见。C里用std::stringPython用.join(list)这些语言层面的便利背后依然是“目标缓冲区是否足够”的老问题。5.6 数组“增删”操作与参数设计的连带问题很多文章会讨论“数组增加”“数组去重”“数组转字符串”这类操作。放在函数参数的语境下核心问题是这些操作到底会改变原始数组还是会返回新数组以去重为例C语言里常常是原地去重函数返回新长度int removeDuplicates(int nums[], int n) { if (n 1) return n; int k 1; for (int i 1; i n; i) { if (nums[i] ! nums[k - 1]) { nums[k] nums[i]; } } return k; }调用后数组前k个元素是去重结果逻辑长度变成k但数组的物理空间没变。这种“长度作为返回值”的设计在C里随处可见也是为什么很多函数签名看起来不太直观。如果要在函数外保护原数据就需要在函数内复制一份再操作。像JavaScript里arr.filter()返回新数组、arr.splice()原地修改本质上就是这两种模式的选择。设计自己的函数时最好一开始就明确你是要修改原数组还是返回新版本然后通过命名和文档把这个选择告诉调用者我见过太多事故都是因为这个约定不清楚。最后分享一个我自己的习惯数组参数的问题表面上是一堆语法细节核心其实是内存模型。只要真正记住“数组名在传参时退化成指针长度信息需要单独传递”这一条主线绝大多数坑都能避开。我Review代码时有个习惯只要看到数组参数先看有没有配套的长度参数没有的话再确认是不是C引用或者固定大小数组。在这个基础之上再去想修改会不会影响外部、边界条件是否齐全。写代码时也尽量用结构体把数组和长度打包或者用向量容器函数签名会干净很多。这个习惯帮我少踩了非常多隐形坑你可以直接把这条经验抄走。