多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

从名字占位符到螺旋法则:C/C++复杂声明解密

从名字占位符到螺旋法则:C/C++复杂声明解密 很多人在 C/C 上栽的第一个跟头就是读复杂声明。看到int (*p[10])(int)这种代码第一反应是这写的什么鬼。面试的时候考官喜欢拿这种东西试探你平时读开源代码也会被拦一道。其实这个问题的解法早就有人总结过一句话就能概括名字当占位符括号用来改变结合关系。这篇文章我准备把这句话拆开揉碎讲透从声明语法的底层设计说到螺旋法则的边界再讲到 const、typedef 这些跟复杂声明组合后的实战陷阱让你在半小时内建立一套稳定的解读流程从简单指针一路到函数指针数组的变态声明都能从容应对。我自己也经历过从死记硬背到真正理解的转变希望这篇文章能帮你少走弯路。1. 名字当占位符从找锚点开始读声明1.1 声明模仿使用C 语法反直觉设计的真正根源C 语言的声明语法一直被吐槽反人类但它的设计思路其实非常朴素你平时怎么用这个变量声明就怎么写。这是 C 早期设计者为了降低编译器负担而采用的一个很巧妙的策略。比如你要解引用一个指针p使用时写*p那么声明这个指针时就把*放在p的左边写作int *p。p的名字摆在那里*表示这个变量是一个指针int表示解引用之后得到的是一个 int。再看数组你访问某个元素时写a[0]声明时就写作int a[10]。函数调用也是同理你调用时写f(1, 2)声明函数时就写作int f(int, int)。这些符号*、[]、()本质上是运算符在声明空间里的重演。你在表达式里怎么用声明里就怎么布局这种声明模仿使用的原则是理解复杂声明的总钥匙。很多人一开始就死记int *p是p 指向 int却没想过为什么*偏偏要贴在名字左边其实就是因为在表达式里*p和p长在一起。1.2 为什么一定要先把名字找出来读声明的时候最忌讳的事情是从左往右读。int *p[10]从左往右读很容易在一个 int 指针的数组和一个指向 int 数组的指针之间来回摇摆脑子直接糊掉。正确做法是先把变量名找出来把它当作一个占位符然后看它被哪些符号包围、修饰。变量名本身不携带类型信息它是一个锚点。声明语句里所有出现在它周围的符号都是在描述这个名字代表了什么类型。这个思路就像剥洋葱名字在最外层还是被括号包在最里层它先跟哪个运算符结合决定了整个声明的类型框架。我建议新手在心理上给变量名画一个圈。从圈出发一层层往外看第一步找到标识符变量名或函数名它通常是最容易识别的英文单词不在关键字列表里第二步看名字右边和左边最近的符号分别是什么第三步结合括号规则判断谁先跟名字结合第四步把整个声明替换成一句名字是一个 XX的完整描述。这就是名字当占位符的操作流程。我自己在实际读代码时哪怕是遇到很复杂的声明也一定会先把名字圈出来再去看周围的符号几乎不会错。2. 括号改变结合关系的底层逻辑优先级和结合律的复制2.1 默认结合顺序后缀运算符优先于前缀*C 声明里面[]和()是后缀运算符*是前缀运算符。在没有任何括号干预的情况下后缀运算符的优先级比前缀*高也就是说变量名会先跟右边的[]或()结合形成一个数组或函数类型然后才被左边的*修饰成指向该类型的指针。举例int *p[10]按默认规则p先跟[10]结合表示p 是数组数组里面装的是什么看左边每个元素是int *。所以它是一个包含 10 个int指针的数组简称指针数组。这时候你问p 是指针吗答案是p 本身是数组名不是指针只不过数组元素是指针数组名在表达式里退化成指向首元素的指针时才表现出指向指针的指针这种效果。这种默认优先级的设计并不是故意的它只是忠实模仿了表达式里的计算顺序*p[10]在表达式里因为[]优先先做下标访问再做解引用所以声明也照抄。理解这一点你就明白为什么 C 的声明看起来那么拧巴——它不是一种独立的描述语言而是表达式的镜像。2.2 括号的拆绑作用把默认优先级倒过来int (*p)[10]中(*p)被括号包住p先跟*结合表示p 是指针然后这个指针再被右侧的[10]修饰表示指针指向的类型是一个int[10]数组。所以它是一个指向含 10 个 int 元素的数组的指针简称数组指针。括号在这里的作用就是改变结合关系没有括号时后缀运算符优先加了括号后前缀运算符先和名字结合成一个复合声明。用数学类比a b * c默认先算乘法如果你要改变关系就加括号(a b) * c。声明里的括号就是我们控制先绑定谁的唯一手段。这里有个非常容易被忽略的细节int (*p)[10]里的括号并不是强制让*和p在一起这么简单它同时破坏了p与[10]的默认结合然后*p整体作为一个表达式再去跟[10]发生关系。也就是说括号把一个声明切成了两个层级这也是改变结合关系的真正含义。2.3 括号还能聚组把一段符号打包成一个整体复杂声明里括号不只会让某个运算符优先绑定它还会把一段符号打包成一个整体类型然后这个整体再被外层的*、[]、()继续修饰。这就是函数指针声明的基本结构。int (*p)(int);括号让(*p)先结合形成p 是一个指针外层的(int)表示这个指针指向的是一个函数函数的参数是int返回值由最左边的int决定。如果没有括号int *p(int);p先跟(int)结合表示p 是一个函数参数为 int返回值是int *。所以这两者的差异是巨大的。做一个对比表就很清晰声明是否加括号读出的类型一句话解释int *p[10]默认int* 的数组p 是数组元素是指针int (*p)[10]加括号指向 int[10] 的指针p 是指针指向数组int *p(int)默认返回 int* 的函数p 是函数返回指针int (*p)(int)加括号指向函数的指针p 是指针指向函数把这张表记熟复杂声明的大门基本就打开了。3. 六组经典声明逐一拆解从指针数组一路拆到函数指针数组3.1 第一组指针数组和数组指针的实战差异先看int *a[3]的拆解流程标识符a右侧紧跟[3]按默认优先级先结合所以a是一个含 3 个元素的数组数组元素的类型是int *完整解释a是由 3 个 int 指针构成的数组。再看int (*a)[3]括号内*a先结合a是指针右侧[3]表示该指针指向的类型是长度为 3 的 int 数组完整解释a是指向 int[3] 数组的指针。这里有一个特别容易犯的错把int (*a)[3]当成普通一维数组指针用。它指向的是int[3]递增a时实际跳过的是 12 字节假设 int 占 4 字节而不是 4 字节。很多二维数组遍历代码写错的根源就出在这。int arr[2][3] {{1,2,3},{4,5,6}}; int (*pa)[3] arr; // pa 指向第一行步长是 3 个 int for (int i 0; i 2; i) { for (int j 0; j 3; j) { printf(%d , pa[i][j]); // pa[i] 等价于 *(pai) } }如果你把pa的类型错读成指向 int 的指针那么pa[0][1]这样的写法就会让你彻底迷路因为编译器会认为这是一个二维数组的数组访问和实际分配的内存布局完全对不上。3.2 第二组函数指针的读法与真实调用int (*fp)(double, int)是工程里最常见的函数指针声明拆解如下括号内fp与*结合说明fp是指针右侧(double, int)表示它指向一个函数该函数接受两个参数类型分别是double和int最左边int表示这个函数的返回类型是 int完整解释fp是一个指向函数的指针这个函数接受double和int两个参数返回int。实际使用中这样初始化int add(double a, int b) { return (int)a b; } int (*fp)(double, int) add; int result fp(9.5, 2); // 调用时直接 fp(...)关于调用语法有个历史遗留问题fp(9.5, 2)和(*fp)(9.5, 2)是等价的C 标准保证两者都能工作。很多人坚持写(*fp)其实是早期编译器支持不完善时留下的习惯。现在完全可以直接写fp可读性更好。函数指针最典型的使用场景是回调函数。比如写一个遍历数组并对每个元素执行某种操作的函数就可以把操作作为函数指针传进去。这种用法在 C 语言里是模拟多态的主要手段。3.3 第三组返回指针的函数和函数的指针这个不存在的类型char *get_name(int id)怎么读get_name名字后面紧跟着(int id)所以先跟函数调用符号绑定表示get_name是一个函数参数类型是int左边最近的*指示返回类型于是它的返回值是char *。所以这是一个返回 char 指针的函数。那能不能声明一个返回函数的函数不能C 语法里函数的返回值不能是函数类型但可以是指向函数的指针。于是出现了这种写法char *(*get_func(int id))(double);看起来很可怕拆解其实不复杂标识符get_func右侧紧跟(int id)所以get_func是一个函数参数是 int左侧的*指示返回值但是注意(*get_func(int id))被括号包住了这个括号整体右侧还有(double)说明*get_func(int id)的结果是一个函数参数是 double最左侧char *是这个函数指针所指函数的返回类型。连起来的解释get_func是一个函数接受int参数返回一个函数指针这个函数指针指向一个接受double参数、返回char *的函数。实际工程里几乎不会直接这样写大家都会用typedef包一层但在面试题里它经常作为你究竟懂不懂声明语法的判题标准。3.4 第四组signal 原型的逐层解剖void (*signal(int sig, void (*func)(int)))(int);是 C 标准库signal函数的原型堪称复杂声明界的珠穆朗玛峰。拿到这一长串务必把先找名字的原则贯彻到底。找标识符signal右侧是(int sig, void (*func)(int))先结合所以signal是一个函数再看参数void (*func)(int)func被括号包住先与*结合说明func是指针右侧(int)表示它指向一个接受 int、返回 void的函数所以func是一个函数指针现在看返回值signal左边是*且有外层括号(*signal(...))包住说明signal返回的是一个指针这个指针右侧还有(int)表示返回的指针指向一个函数该函数接受 int 参数最外层void表示这个函数返回 void。连起来的解释signal是一个函数接受两个参数一个是 int 类型的信号编号一个是void(*)(int)类型的信号处理函数指针返回值是一个void(*)(int)类型的函数指针。读出来之后你会发现逻辑其实并不复杂复杂的是语法包装。这种例子最大的价值是让人意识到括号把函数调用整个包进去之后函数的返回值就可以是一个函数指针。想读懂底层库的接口这一关绕不过去。3.5 第五组指向函数指针数组的指针继续往上叠最经典的对比是void (*p[10])(void)和void (*(*p)[10])(void)。先看第一个p右侧紧跟[10]所以p是数组(*p[10])这个括号内部表示解引用说明数组元素是指针类型外层(void)和void限定这个指针指向的函数的签名无参数、返回 void。完整解释p是一个含 10 个元素的数组每个元素都是无参数、返回 void的函数指针。再看第二个void (*(*p)[10])(void);找标识符pp左边是*并且被括号(*p)包住所以p是指针右侧[10]表示p指向一个数组这个数组有 10 个元素数组的元素类型是什么看外围(*(*p)[10])和再外层的(void)、void元素是无参数、返回 void的函数指针。完整解释p是指向一个含有 10 个函数指针的数组的指针。这种类型在真实代码里几乎不会出现但如果你能把这一层理解透基本可以宣布C 声明语法你毕业了。它存在的意义更多是作为面试题和声明语法哪家强的擂台。从内存布局角度看p存放的是数组的首地址数组里存放的是 10 个函数地址这种两级跳转的思想在解释虚拟表、回调表的时候并不少见。3.6 第六组C 引用与指针的结合C 里最常让人困惑的声明之一是int *r。找标识符r右侧没有后缀符号左侧最近是表示r是一个引用再往左是int *表明r引用的对象类型是指向 int 的指针。所以r是对 int* 指针的引用。这在 C 中常用来实现函数内部修改调用方的指针这类需求。void reset(int *rp, int newVal) { rp new int(newVal); // 修改的是调用方指针本身 }那int *q合法吗不合法。指向引用的指针无法声明因为引用不是对象没有地址。很多人会在指针的引用和引用的指针之间来回绕晕记住一个判断就够了和*都写在名字左侧时谁离名字近谁就先绑定。int *r离r更近所以r首先是引用再谈引用对象类型。4. 螺旋法则的完整流程、适用边界与替代方案4.1 顺时针螺旋法七步读出任何声明David Anderson 提出的顺时针螺旋法是很多程序员用来读复杂声明的手工技巧也被称为 Clockwise/Spiral Rule。操作步骤如下第一步从标识符变量名开始第二步向右看遇到右括号或声明结束就停下记录遇到的符号第三步继续向右遇到右括号则回到对应左括号的左侧继续向左看第四步按顺时针方向一圈圈往外扫依次记录*表示指向...的指针、[]表示...的数组、()表示...的函数第五步直到所有符号都被处理完毕第六步最后一层补上最左边的类型限定符如int、char、void。以int (*p[3])(double)为例螺旋法能快速得到p是数组元素是指针指针指向的是一类函数函数参数是double返回int。这个方法对纯 C 的声明非常有效因为它完全模拟了运算符的优先级走向。4.2 螺旋法的盲区一const 的位置const不是运算符它不参与螺旋走位因此螺旋法遇到 const 时容易卡壳。const int *p和int const *p都表示p指向一个const int而int *const p表示p本身是 const 指针。这三个声明在螺旋法眼里几乎长得一样因为螺旋法只关心*、[]、()的位置不关心 const 的修饰对象。我的经验是用 const 的向左看规则单独处理不放进螺旋流程里。规则非常简单如果const在*的左边修饰指针所指的对象如果const在*的右边修饰指针本身。这个规则我会在第 5 章详细展开但你先记在心里。4.3 螺旋法的盲区二C 的引用和*类似也是前缀运算符但螺旋法对引用的处理并不友好尤其是int *r这种指针的引用组合。因为螺旋法默认你是从一个圈往外转但引用声明读起来更像是从名字往左一层层剥不太符合螺旋的直觉。建议在 C 下改用从名字开始先右侧再左侧的读法把当成一级修饰符处理即可。比如int *r右侧没有东西左侧从近到远依次是、int *于是r是引用引用对象是int *。这样比硬套螺旋法快得多。4.4 螺旋法的盲区三auto、模板和 using现代 C 里大量使用auto和模板螺旋法在这些场景下基本帮不上忙。比如std::vectorstd::functionvoid(int)类型是通过模板实例化出来的而不是靠声明符拼出来的。处理这类复杂类型正确姿势是先读最外层的容器类型弄清楚这是一个 vector、map 还是其他容器再区分数据部分和函数部分例如std::functionvoid(int)是一个可调用对象的类型借助 IDE 的悬停提示或 cdecl 工具确认。螺旋法天生是为C 风格的声明符设计的。到了 C 泛型的世界里它最大的价值反而成了理解std::function背后的函数指针本质这个底层能力依然很重要。5. const、typedef、using 与复杂声明组合的实战陷阱5.1 const 的向左看规则很多人学 const 时背过一句口诀const 优先修饰左边紧邻的类型如果左边没有就修饰右边的类型。这个规则在复杂声明里特别好用。int *const pconst 左边是*所以修饰*表示指针本身是 constp不能被重新赋值int const *pconst 左边是int所以修饰 int表示指针指向的是 const intconst int *pconst 左边没有类型限定符合所以修饰右边的int效果同上const int *const p右侧那个 const 修饰*指针本身不可变左侧 const 修饰 int指向对象也不可变。结合括号时要注意 const 跟括号的位置关系。比如int (* const p)(double)括号内是(* const p)const 在*的右边、p的左边向左看最近的类型符号是*所以p是一个 const 指针它指向一个函数。也就是说p一旦初始化就不可再指向别的函数了。这个向左看规则在实际代码审查中能快速发现问题。很多人在写回调表时希望数组里的函数指针本身不可变却常常把 const 加错位置结果要么是指向 const 函数的指针这种合法但没意义的类型要么直接编译报错。5.2 typedef 包装复杂声明的正确姿势与隐蔽坑面对复杂声明工程上最正确的做法永远是拿typedef或using把它包装成短名。比如typedef void (*sighandler_t)(int);然后signal的原型就可以写成sighandler_t signal(int sig, sighandler_t func);可读性直接提升一个量级。但typedef也有一个非常隐蔽的坑。看这个例子typedef int *PINT; const PINT p;问题来了p到底是什么类型很多人以为是const int *也就是指向 const int 的指针。实际答案是int *const p也就是p 本身是 const 指针指向 int。因为typedef创建的是一个类型别名const修饰的是整个PINT类型而PINT已经是int *了所以 const 被应用到了指针本身上。如果你想表达指向 const int 的指针不能靠const PINT达成必须写typedef const int *PCINT;这是 C 语言里最经典的类型别名陷阱之一。在 C 里用using也一样有这个坑只不过 C20 的约束概念和更丰富的类型系统会让错误更早暴露。5.3 现代 C 的 using 别名与 auto 的简化方向C11 之后using比typedef更直观因为它更像普通的变量赋值语句using FnPtr int(*)(double, int); using HandlerArray void(*)(void)[10]; // 等等这个写法不对实际上函数指针数组的别名要写成using HandlerArray void(*[10])(void);或者更清晰地分成两步using Handler void(*)(void); using HandlerArray Handler[10];这样读起来就顺理成章了。auto则能写进更简洁的代码auto fp add; // fp 自动推导为 int(*)(int,int) auto rr ptr; // 指针的引用但auto是一把双刃剑。过度使用auto会让类型变得隐晦尤其在公共接口处建议显式写出类型在局部变量处可以用auto降低拼写负担。我的个人习惯是复杂声明绝对不能靠记忆要会拆解也要会两个字——封装。能封装成别名就不留裸声明这既是对自己负责也是对整个代码库的可维护性负责。6. 从读懂到写对CDECL 工具、IDE 辅助与自测清单6.1 cdecl一个帮你翻译声明的小工具如果你不想每次都在脑子里转圈就交给工具。Unix 环境下有个老牌工具cdecl专门把 C 声明翻译成英文。用法很简单cdecl explain int (*p[10])(void); declare p as array 10 of pointer to function(void) returning int也有在线版本可以直接在浏览器里用。把它当成参考答案先自己拆解再交给 cdecl 验证练习效果会好很多。在 VS Code 里配置 C/C 环境时我发现 IntelliSense 本身就是一个很好的练习工具。你把鼠标悬停到一个变量上VS Code 会显示完整的类型描述这一长串描述就是你心中拆解结果的标准答案。如果跟你想的不一致那就是结合顺序判断出错了如果一致说明你真的读懂了。6.2 在 VS Code 里边读边验证的实战习惯很多人配置好 VS Code 的 C/C 扩展之后就只把它当成编辑器用但其实 IntelliSense 的类型提示对学习复杂声明极有帮助。我建议采取这样的工作流在源代码里写一个复杂声明悬停到变量名上看 IntelliSense 给出的类型展开先不看提示自己在心里拆解一遍对比提示和结果不匹配时用 cdecl 复查。比如你写void (*signal(int, void (*)(int)))(int)悬停提示会显示signal的类型仔细一字一句地读这个提示其实就是在做一次完整的声明拆解训练。由于 VS Code 的底层是基于语言服务器协议LSP的 C/C 扩展它在展示类型时通常会去掉冗余括号按逻辑结构组织这对理解括号到底改了什么非常有帮助。6.3 自测清单从易到难的十二道题下面这组声明我常用来检验自己的理解也推荐给团队新人。你可以在心里默读答案然后跟后面的解释对照1. int *p; 2. const int *p; 3. int *const p; 4. int (*p)[5]; 5. int *p[5]; 6. int (*fp)(int); 7. int *(*fp)(int); 8. void (*signal(int, void (*)(int)))(int); 9. char (*(*x())[])();对应解释如下题号完整解释1p 是指向 int 的指针2p 是指向 const int 的指针指针本身可变3p 是 const 指针指向 int指针本身不可变4p 是指向 int[5] 数组的指针5p 是含 5 个 int* 元素的数组6fp 是指向函数的指针函数参数为 int返回 int7fp 是指向函数的指针函数参数为 int返回 int*8signal 是函数参数是 int 和 void()(int)返回 void()(int)9x 是函数返回一个指针该指针指向一个数组数组元素是指向返回 char、无参数的函数的指针第 9 题如果读出来了你对 C 声明语法的掌握程度已经超过大多数工程开发者的平均水平。6.4 写声明时的工程习惯先定义别名再写变量最后分享一个我踩过坑换来的经验写复杂声明时从不用裸声明。先typedef或using一个别名再写变量。这不仅能减少读代码的负担也能避免括号写错位置这种低级错误。比如要定义一个指向函数指针数组的指针千万别直接写void (*(*p)[10])(void)这种完美主义的行为艺术而是先封装typedef void (*handler_t)(void); typedef handler_t handler_array_t[10]; handler_array_t *p; // p 指向包含 10 个 handler_t 的数组这样写审查代码的人会对你感激涕零。C/C 的类型系统足够强大但也是整个语言中最容易产生认知错位的地方。我自己在做代码审查时凡是遇到超过两层嵌套的裸声明一定拦下来要求对方用别名封装后再提交——这不是教条而是实打实被坑过的总结。之前有位同事在 signal 处理函数上多写了一层括号结果参数类型整个变了运行时才暴露问题排查花了整整半天。把名字当占位符括号改变结合关系这两条规则记牢再把复杂声明封装成有意义的别名你在 C/C 这条路上会少踩很多坑。
返回列表