ARTICLE DETAIL

资讯详情

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

C语言二维数组为何不能省略列数:报错与指针寻址原理

C语言二维数组为何不能省略列数:报错与指针寻址原理 写 C 的时候几乎每个人都撞过这么一条报错array type has incomplete element type或者更直白的array size missing in a。触发它的代码通常长这样int a[3][];或者int a[][];而旁边那个只省了行数的int a[][4];却能安安稳稳编译过去。第一次遇到的人基本都会愣一下——行数和列数不都是数组的形状吗凭什么只让省一个这个问题的答案其实藏在你天天写的a[i][j]里。它不是一个二维操作编译器从头到尾都只在做一件事拿一个基地址加上一个偏移量。行数可以省略是因为它只是有多少个偏移量可以事后数出来列数不能省略是因为它直接决定了每个偏移量乘几而这个乘数一旦缺失编译器连一条合法的机器指令都生成不出来。下面我按自己当年踩坑的顺序把这件事从报错信息一路拆到动态数组的替代方案顺便把sizeof、函数传参、变长数组这几个连带问题一起讲透适合刚学完数组语法、正准备写第一个矩阵程序的人也适合被形参里的int a[][4]绕晕过的老手回头对一遍账。1. 报错那天我想不通同样是省略为什么行数行、列数不行先把话说死C 语言里对二维数组的省略规则从来不是行数可以省、列数不可以省这么一句口诀而是只有最外层的那一维允许不写内层维度必须给出完整的类型。二维数组int a[3][4]的类型读法是由 3 个int[4]组成的数组int[4]是它的元素类型。元素类型必须是一个完整类型int[4]完整int[]不完整就这么简单。行数之所以能省是因为它对应的正是这个最外层而最外层可以在有初始化列表或者只是暂时声明的时候由编译器补出来。1.1 四种写法放在一起编译器给的判决完全不同我习惯先把合法与不合法的情形摆在一张表里比背口诀靠谱得多。注意作用域不同结论也会变这一点很多人没注意写法写在函数外文件作用域写在函数内关键原因int a[3][4];合法合法两个维度都完整int a[][4];合法暂定定义末尾补成 1 行非法最外层可推导但函数内无初始化列表时无法确定大小int a[][4] {1,2,3,4};合法合法由初始化列表数出 2 行int a[3][];非法非法元素类型int[]不完整int a[][];非法非法元素类型不完整外层也无从推导提示int a[][4];写在函数外面属于暂定定义如果整个翻译单元里再没有别的定义来补全它标准规定它按只有 1 行处理sizeof(a)会等于4 * sizeof(int)。这个冷门规则我是在翻标准文档的示例时才发现平时写代码别依赖它容易把自己绕进去。1.2 内存里根本没有二维只有一整条连续的地址这是理解后面所有内容的地基。int a[3][4]在内存里不是三块分开的区域而是 12 个连续的int从a[0][0]开始一路排到a[2][3]。所谓二维只是我们脑子里的索引方式。你可以用下面这段代码验证我在教学和朋友互相讲题时经常用它#include stdio.h int main(void) { int a[3][4] {0}; printf(a %p\n, (void *)a); printf(a[0][0] %p\n, (void *)a[0][0]); printf(a[1][0] %p\n, (void *)a[1][0]); printf(a[2][0] %p\n, (void *)a[2][0]); printf(sizeof(int[4]) %zu\n, sizeof(int[4])); return 0; }跑出来的结果里a[1][0]和a[0][0]的差值一定是sizeof(int[4])也就是 16在int占 4 字节的平台上a[2][0]再往后推 16。这个 16 就是列数乘以单个元素大小它是整个二维寻址里唯一被写死的常量。列数一旦删掉这个 16 就算不出来后面全崩。1.3a[i][j]展开之后列数是那个必须存在乘数把下标运算完整展开事情就一清二楚了。C 标准里a[i]等价于*(a i)所以a[i][j]等价于*(*(a i) j)。而a i这一步编译器要按int[4]这个类型来做指针算术也就是a i 等价于 (int (*)[4])((char *)a i * sizeof(int[4])) 等价于 (int (*)[4])((char *)a i * 4 * sizeof(int))把两层合起来看实际地址就是基地址 (i * 4 j) * sizeof(int)。这个i * 4里的 4 就是列数它会以乘法常量的形式直接编进目标代码。编译器在生成指令的那一刻就必须知道这个乘数是 4 还是别的什么它不可能等到运行时再去猜。所以列数不能省不是语法上的洁癖而是没有这个数就没法翻译成机器码。行数则完全不同它只影响最多能偏移几次在遍历时由你写的循环边界决定编译器并不需要提前知道。2. 行数能被数出来初始化列表推导规则的边界在哪里允许省略行数的合法场景其实就两类一是有初始化列表编译器去数二是文件作用域的暂定定义暂时不填。第二类前面已经提过这里重点讲第一类因为它是日常写得最多的形式也最容易产生误解。2.1 从int a[] {1,2,3}说起一维数组的推导规则是根int a[] {1,2,3};里编译器数出初始化列表有 3 个元素于是把a定义成int[3]。这条规则在标准里的表述大意是数组大小未给出且存在初始化列表时由初始化列表的元素个数确定。注意它推导的是最外层那个未知的维度这是关键限定词。到了二维同样的事情发生在最外层int a[][2] {1,2,3,4};是合法的编译器把 4 个初始化值按每行 2 个切分得到 2 行。这里有个特别容易问的点——为什么是每行 2 个而不是每行 30 个因为列数2是你写死在方括号里的切分依据来自它。列数如果不写编译器手上既没有切分依据也没有一个唯一的行数答案int a[][] {1,2,3,4}既可以理解成 1 行 4 列也可以理解成 2 行 2 列还可以理解成 4 行 1 列。语言不可能让同一个写法有多个合法解释所以只能规定列数必须写。2.2 花括号省略了也不影响结果但会影响你的判断C 允许初始化二维数组时省略内层花括号int a[][3] {1,2,3,4};是完全合法的。编译器会先按列数 3 分组第一组拿1,2,3第二组拿4然后再补两个 0最终得到 2 行。我见过不少人被这个写法误导以为逗号可以随便断行于是写出下面这种和预期完全不符的代码/* 想表达 2 行 2 列实际得到 2 行 3 列第二行还有一个 0 */ int a[][3] {1, 2, 3, 4}; /* 想表达 3 组数据实际因为花括号位置不同结果完全不同 */ int b[][3] {{1, 2}, {3}, {4, 5, 6}}; /* 3 行未给出的位置补 0 */提示我自己的习惯是只要初始化数据超过一行就一定把内层花括号全部写全。多敲几个字符换来的是所见即所得比事后拿调试器数下标省事得多。2.3 推导只发生在最外层这条限制是硬性的有人会问既然编译器能数元素个数那列数为什么不能也数出来因为一旦允许两个维度都推导整个定义就没有唯一解了前面已经说明。更深一层的原因是数组元素的类型必须是完整类型这样才能算sizeof、才能做指针算术。int[4]是完整类型int[]不是。所以规则并不是针对二维数组的特殊照顾而是不完整类型的数组不能作为另一个数组的元素这一条通则的直接结果。3. 形参列表里的二维数组写在方括号里的第一维数字其实是摆设如果说声明和定义还能靠初始化列表兜底那函数传参就是真正的重灾区。写形参void f(int a[3][4])的人十个里有八个以为那个 3 能帮编译器做点事实际上它一点用都没有。3.1 数组名作参数时第一维会在参数调整中被丢掉C 的规则是数组类型的形参会被调整成指向其首元素的指针。一维的void f(int a[5])等价于void f(int *a)5 被丢掉。二维的void f(int a[3][4])里元素类型是int[4]指向首元素的指针就是int (*)[4]所以它等价于void f(int (*a)[4])那个 3 同样被丢掉。三种写法的真实身份完全相同void f1(int a[3][4]); /* 等价于 int (*a)[4] */ void f2(int a[][4]); /* 等价于 int (*a)[4] */ void f3(int (*a)[4]); /* 本来就是 int (*a)[4] */现在就能看出为什么void f(int a[][])编译不过了调整之后它会变成int (*a)[]一个指向不完整类型的指针指针算术没法算a[i]也就无法翻译。而void f(int a[][4])变成int (*a)[4]步长是 16一切正常。3.2 把列数写成宏或常量别在形参里留裸数字列数必须写这个事实没法绕过但可以在工程上管住它。我常见的做法有两种。一种是定义宏#define COLS 4 #define ROWS 3 void print_matrix(int a[][COLS], int rows) { for (int i 0; i rows; i) { for (int j 0; j COLS; j) { printf(%4d, a[i][j]); } putchar(\n); } }另一种是用枚举常量enum { COLS 4 };好处是带类型调试时能看见符号。把列数抽出去的价值在于当你哪天把宽度从 4 改成 8 时只需要动一个地方而不会漏掉某个形参里的[4]那种漏掉的错误编译器不一定报但程序一定会错。3.3 一个必须把行数单独传进去的实测例子形参里的行数被丢掉了遍历时就需要自己带进来。下面的代码我特意去掉行数参数看看会发生什么#include stdio.h static void bad_sum(int a[][3], int rows) /* 即使这里写 int a[4][3]rows 该传还得传 */ { int s 0; for (int i 0; i rows; i) { for (int j 0; j 3; j) { s a[i][j]; } } printf(sum %d\n, s); } int main(void) { int m[2][3] {{1, 2, 3}, {4, 5, 6}}; bad_sum(m, 2); /* 第二个参数一旦传 3就越界读到别的栈内容 */ return 0; }把2改成3编译照样通过运行时也不会立刻崩只会读出一片你控制不了的内存。这就是int a[][3]这种形参最危险的地方——列数被编译器管住了行数却完全交给你自觉。我后来养成一个习惯任何接收二维数组的函数第一个参数一定是行数而且尽量让调用处的行数用sizeof算出来而不是手写数字。4. 一次完整的排查链路少传一个行数越界了却没有报错讲个我自己真栽过的跟头。当时写一个图像灰度统计的小程序数据是 64×64 的unsigned char矩阵函数签名是void hist(unsigned char a[][64], int rows, int bins[])。测试时用一张 32×64 的图结果显示的直方图总数比像素总数多了将近一倍但没有任何崩溃或报错。下面是我当时的排查顺序你可以直接照着复现。4.1 第一步先确认行数参数的真实值很多人一上来就怀疑算法其实应该先怀疑边界。我在遍历循环开头加了一行打印printf(rows%d, first%d, last%d\n, rows, (int)a[0][0], (int)a[rows - 1][63]);打印出来rows32看起来没问题。但注意最后那个a[rows-1][63]它读的是第 31 行最后一列——如果实参的矩阵其实只有 16 行这一读就已经越界了而打印出来的值依然是个合理的数字因为那片内存恰好还没被覆盖。这一步的价值在于它不能证明没问题但能暴露明显错误。4.2 第二步把sizeof搬出来对账真正定位到问题靠的是这一行printf(sizeof m %zu, expected %zu\n, sizeof(m), (size_t)rows * 64 * sizeof(unsigned char));结果sizeof m是 2048而rows * 64算出来是 4096正好差一倍。原因是调用处传的行数写错了实际矩阵只有 32 行我传了 32而矩阵是 16 行……传多了。修正后数值对上直方图总数立刻正常。这里有个必须强调的点sizeof只在数组还是数组的时候管用。如果m是形参sizeof(m)会退化成指针大小这个对账手法就失效了必须回到调用现场去算。4.3 第三步用边界哨兵把隐患变成显式报错对账只能解决这一次。为了避免以后再犯我在函数入口加了断言#include assert.h #include stddef.h static void hist(const unsigned char a[][64], int rows, int bins[256]) { assert(a ! NULL); assert(rows 0); /* 如果调用方能提供总元素个数就在这里交叉验证 */ for (int i 0; i rows; i) { for (int j 0; j 64; j) { bins[a[i][j]]; } } }assert不会让越界变成编译错误但它能把静默错数据变成当场停下来。我的经验是凡是行数靠手工传递的二维数组接口入口处至少要有rows 0和a ! NULL两条断言成本极低收益极大。4.4 这类问题的本质编译器信任你你也得对得起这份信任把整个排查链路拉直看根因就一句话a[][64]里编译器只知道列宽行数这种信息在参数调整时被完全丢弃语言层面不再提供任何保护。这不是缺陷是 C 一贯的设计取向——它把边界检查的责任交还给程序员。理解这一点之后你写二维数组接口时就会自然地问自己一句调用方到底凭什么知道有几行5. 列宽写死之后几种替代写法与它们的代价既然列数必须固定那我就是要动态决定列数的需求怎么办常见有四条路各有各的坑我按实际使用频率从低到高排一下顺便说清代价。5.1 一维数组模拟手工算下标最土也最稳的办法是把二维压成一维int *a malloc(rows * cols * sizeof(int));取元素用a[i * cols j]。它的优势是内存完全连续、只需要传一个指针和行列两个数、跨函数传递毫无障碍代价是所有下标都要自己算写错一个cols就到处对不上。我在性能敏感的场合更喜欢这种写法因为行主序的连续访问对缓存非常友好。顺便说一句遍历顺序也很值得注意同样的矩阵for (i) for (j)和for (j) for (i)的耗时可能差好几倍原因就是前者按内存顺序走后者每步都跨一整行。5.2 行指针申请法一行一次malloc第二种写法是int (*p)[4] malloc(rows * sizeof(int[4]));。这里的关键是int (*p)[4]与int *p[4]的区别前者是指向 4 元素数组的指针后者是4 个指针组成的数组方括号优先级高于星号写反了编译器未必报错但语义天差地别。这种写法申请到的内存是连续的可以直接传给void f(int a[][4])这是它比int **最大的优势。要注意malloc之后必须判空以及free(p)只调用一次。5.3 指针数组看起来最像二维其实不是第三种是int **p先申请一组指针再给每行单独申请。它可以实现每行长度不同的锯齿结构做字符串表时很有用但它不能直接传给期望int (*)[4]的函数。这两者内存布局完全不同前者是先放一排地址、地址再指向各行后者是整块连续内存。我见过最典型的 bug 就是拿int **去填int (*)[4]的参数编译期可能只是警告运行起来读到的是把指针当整数用的垃圾值。如果确实需要这种结构函数签名就老老实实写int **a, int rows, int cols并且明确告诉调用者每一行必须是单独申请的。5.4 四种方案放在一起对比方案内存是否连续能否传给int a[][N]行宽是否可各行不同主要代价一维模拟int *是不能需改签名或另行封装否下标要手工算行指针int (*)[N]是可以否N 仍写死指针数组int **否不能可以行数、列数都要额外传释放要循环变长数组 VLA是可以C99 起否编译器支持不一致栈空间风险大6. 字符串二维数组、变长数组与几个容易记混的自测点前面讲的都是int换成char之后规则一模一样只是场景变了。6.1char s[][16]为什么在字符串场景特别常见存一组定长名称时char names[][16] {alice, bob, carol};是最省事的写法行数由编译器数列宽 16 是你能接受的最大名字长度。这里同样不能省列宽因为char[16]必须是完整类型。需要留意两个细节一是字符串末尾的\0会占一个字节所以留给字符的其实只有 15 个二是如果某个名字超过 15 个字符初始化时会被截断编译器不一定报错。如果名字长度差异很大用char *names[]存指针会省内存但那些字符串字面量是只读的想改内容就得用char names[][16]这种可写副本。6.2 变长数组VLA能不能彻底解决问题C99 引入了变长数组允许void f(int rows, int cols, int a[rows][cols])这种写法看起来终于把列数也动态化了。实际用起来有几个前提参数顺序不能颠倒rows和cols必须写在数组参数之前支持程度因编译器而异一些常见工具链在 C11 之后把 VLA 降为可选特性跨平台项目里要慎重如果行列很大VLA 很可能放在栈上容易把栈撑爆。我的建议是教学或小型工具里可以用用于展示列数也可以来自变量这件事生产代码里宁可回到malloc加一维模拟行为更可控。6.3 几个我用来检验自己有没有真懂的自测点下面这几个问题能完整答出来说明这条知识点已经长在你身上了。int a[][4];写在文件作用域为什么合法写在函数内为什么非法void f(int a[3][4])里的 3在函数体内部还能通过什么方式得到a[i][j]展开成指针表达式之后乘法常量从哪里来int (*p)[4]和int *p[4]分别是什么类型能不能互相赋值int a[][3] {1,2,3,4};一共有几行最后一行存了什么把这五个问题在纸上写一遍答案比读十遍规则都管用。我自己当初就是靠第 3 题彻底想通的只要你能自己把这个乘法常量推导出来就再也不会去纠结为什么不能省列数了。6.4 最后分享一个小技巧如果你手头有现成的编译器最直观的验证方式不是看报错而是看汇编。写一个int main(void){ int a[3][4] {0}; a[2][3] 7; return a[2][3]; }用gcc -S -O0生成汇编找到那个赋值语句对应的几行指令你会清楚地看到基址加上2 * 16 3 * 4这样的常量组合。看到编译器真的把 16 这种列数乘元素大小的常量烧进指令前面所有关于列数不能省的解释就都落地了。这个习惯我一直保留着凡是遇到语言规则为什么这么定的疑问先去看编译器实际生成了什么往往比争论规则本身更快得到答案。
返回列表