书名:C语言最佳实践
ISBN:978-7-115-64514-2
本书由人民邮电出版社发行数字版。版权所有,侵权必究。
您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。
我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。
如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。
著 魏永明
责任编辑 郭泳泽
人民邮电出版社出版发行 北京市丰台区成寿寺路11号
邮编 100164 电子邮件 315@ptpress.com.cn
网址 http://www.ptpress.com.cn
读者服务热线:(010)81055410
反盗版热线:(010)81055315
本书是魏永明近30年来开发和维护MiniGUI、HVML等开源项目的经验总结,旨在帮助有一定C程序编写经验的软件工程师在短时间内有效提高设计能力和编码水平。全书分为 3 篇。基础篇从可读性和可维护性出发,阐述了如何提高代码的可读性、用好写好头文件、正确理解编译警告并消除潜在问题、定义和使用常量等,介绍了如何有效利用构建系统生成器(CMake)来维护项目;模式篇阐述了常见的C程序接口设计模式,说明了如何在C程序中解耦代码和数据、利用子驱动程序实现模型、设计可加载模块等,介绍了状态机的概念以及在C程序中如何利用状态机实现分词器、解析器等;质量篇从性能和单元测试出发,阐述了如何在C程序中避免编写低效代码、进行单元测试、使用常见的单元测试框架等,介绍了高效调试C程序的若干技巧和工具。
本书适合从事系统软件、嵌入式或物联网开发的C程序员、计算机相关专业高年级本科生和研究生阅读。
C语言历久弥新,仍然是编写系统软件的理想语言。魏永明先生精心打造的这本心血之作紧密贴合一线开发者的常见问题,以经典案例讲解C语言编程实践的方方面面,是学生和从业人员学习C语言的优秀参考书。
中国科学院软件研究所副所长、总工程师 武延军
C语言是接近汇编语言的高级编程语言。深入C语言,不仅能学习到数据结构、控制结构和内存管理等知识,更能理解计算机的运行机制,从而写出精巧的高性能程序。魏永明是国内第一批开源开发者之一,有着几十年C语言编程经验,他把C语言的编程最佳实践和心得写成本书。想成为高水平开发者,可以阅读本书来提升编程能力。
Linux Virtual Server开源项目创始人、阿里云前CTO 章文嵩
魏永明老师在普及Linux及开源软件方面贡献卓著,其多本著作也备受推崇,本书则与他以往的作品有所不同。本书是魏老师多年系统软件开发和维护经验的总结,旨在帮助已经具备一定C语言编程基础的读者快速完善设计思维、掌握编码技巧。书中不仅探讨了如何提高代码的可读性和可维护性,还深入讲解了C语言编程模式的理解与运用,强调了程序质量与性能优化的重要性。
如今涌现的Go、Rust等新兴编程语言各具特色,但在底层系统软件领域,C语言以其简洁、高效、灵活的特点依然占据着不可替代的地位。然而,这种特点也使得C语言在大型软件项目中成为一把双刃剑,若处理不当,则可能为后续开发埋下风险。为此,本书提供了许多宝贵的见解,包括对设计模式的深刻洞察、对状态机机制的剖析、多种测试方法和软件管理工具的应用等。这些内容对于希望进一步提高大规模软件开发能力的学生和工程师而言尤为宝贵。
清华大学长聘副教授、清华大学计算机系软件所副所长 陈渝
C语言是理解计算机系统的重要工具。C语言编程是系统编程的核心技术。本书作者从多年Linux系统经验出发,层层递进。本书具有很强的工程实用性,堪称“中国版Effective C”。强烈推荐有志成为优秀开发者的人阅读并动手实践。
CSDN创始人 蒋涛
作为Linux内核领域的深耕者,我深知实战经验与技术洞见的宝贵。作者以近30年的开源经验凝结成本书精华,不仅深刻诠释了C语言编程艺术,更是通过丰富的实例代码,让抽象概念跃然纸上。本书称得上值得每位追求写出卓越代码的开发者阅读的实战宝典。
西安邮电大学教授、Linux内核专家 陈莉君
介绍C语言的书很多,但它们多数聚焦于介绍C语言语法和编程技巧,很少能像本书这样从工程项目的角度全面介绍大型C语言项目应该如何规划、设计和实现。本书从基础编码规范讲起,逐步深入到代码与数据解耦的原则,再到自动测试系统的选择和构建,通过详细的用例展示了项目规划和设计技巧,展示了C语言编程艺术。
早在2000年,我就使用MiniGUI编写图形界面App,当时就对MiniGUI的简洁高效印象深刻。如今,魏老师的HVML更是将编程艺术推向了新的高度,在一些方面甚至超越了Linux内核。本书虽然不涉及C语言基础语法,但对于渴望提升编程技能的软件工程师和大型项目开发者来说,无疑是一本不可错过的设计与实践指南。
Linux内核峰会受邀嘉宾、Linux内核内存领域年度全球十大贡献者 时奎亮
作为一位长期使用Rust和C++的开发者,我深知C语言在构建现代高效软件系统中的地位。本书深入剖析了C语言的高级实践和优化技巧,对于精进编程技能具有重要意义。在阅读这本书时,我对C语言的深层次理解进一步加深。本书不仅优化了我在Rust和C++开发中的底层效率思维,也加强了我的跨语言架构设计能力。本书通过详尽的案例和实践,为追求极致性能的程序员提供了宝贵的资源。无论您是C语言新手还是经验丰富的开发者,本书都能使您的编程技术更上一层楼。强烈推荐阅读。
“深入理解Android”系列图书作者 邓凡平
C语言在计算机科学中占据着举足轻重的地位,是软件控制硬件的重要途径。在我二十多年的产品研发生涯中,无论是复杂的多功能复印机还是手机硬件产品,都需要C语言程序的驱动。即使是纯软件产品,也离不开C语言程序的支持。
本书不仅很好地总结了C语言编程的技巧,还充分考虑了实践中的细节,包含从调试到性能优化的方法,是开发人员的宝贵实践指南。
Xcalibyte和DeepComputing创始人 梁宇宁
C语言诞生于贝尔实验室。1972年,Dennis Ritchie在Kenneth Lane Thompson设计的B语言基础上发展出了C语言。在C语言的主体设计完成后,Thompson和Ritchie重写了UNIX操作系统。随着UNIX操作系统的发展,C语言也得到了进一步的完善,并在1989年诞生了第一个完备的C语言标准——C89,也就是广为人知的ANSI C。尽管C语言的诞生和UNIX操作系统密不可分,但 C 语言已经成为几乎所有操作系统支持的编程语言,广泛应用于操作系统内核、基础函数库、数据库、图形系统、密码系统、多媒体编解码器等的开发中。因此,在各类编程语言排行榜上,C语言始终名列前茅。
C语言广泛应用于系统底层的开发当中,这是因为C语言是最接近计算机处理器的编程语言。C语言可产生接近汇编语言执行效率的机器指令,同时保持非常好的跨平台特性。由于操作系统内核、基础函数库、数据库等大量的基础软件使用C语言开发,甚至许多脚本编程语言(如Python)的解释器也使用C语言开发,因此将C语言称作人类迄今为止创建的整个软件生态的底座,相信没有人会提出异议。
在笔者看来,C语言最大的优点在于其设计上的完备性和简洁性。完备性在于开发者可以使用C语言实现任何想得到的功能,而简洁性确保了C语言特性的稳定。相比其他编程语言,比如C++,C语言的关键词(又称关键字或保留字)、数据类型和语法等特性非常稳定。自从C89一字不落地被国际标准化组织吸纳成为ISO/IEC 9899标准之后,国际标准化组织和国际电工委员会仅在1999年、2011年和2018年对C语言标准做了一些修正和完善,分别发布了C99、C11和C18标准(现阶段被广泛接受和使用的C语言标准仍然是C99)。这些标准之间的差异很小,且向后兼容。但假如一名20年前的C++程序员穿越到现在阅读最新的C++程序代码,一定会惊呼“宛如天书”!
在半个多世纪的计算机软件发展史上,出现了众多的编程语言,大量的编程语言昙花一现,还没流行就消失在历史的长河中,还有一些编程语言曾经盛极一时,但随后默默无闻。由于 C 语言的特殊地位,我们有理由相信它将会长盛不衰。
近几年,国内开始重视操作系统、数据库、开发工具甚至编程语言等基础软件的国产化,这些基础软件哪一个离得开C语言呢?然而,我国有大量程序员任职于互联网公司,日常使用的是JavaScript、PHP、Java、Python等编程语言,C程序员则主要活跃于嵌入式和物联网领域,人数相对较少。
除了人员数量少,算得上“八级工”水平的C程序员更是凤毛麟角。笔者在近30年的软件开发生涯中,看到过很多C程序员,即便是有多年从业经验的程序员,也不能有效使用C语言编写程序。尽管很多C程序在语法上没有什么问题——毕竟可以运行的程序起码要通过编译器的编译——但在很多方面存在或多或少的问题,具体如下。
● 不注重代码的可读性,写出来的代码没有美感和章法。
● 不会给函数、变量、结构成员等取名,甚至使用拼音。
● 不注重细节,比如定义一个字符数组作为缓冲区,随意填写其大小。
● 不严谨,对编译时出现的大量警告视而不见。
以上这些其实都是一些基础性的问题,属于代码可读性和可维护性的范畴,作为一名合格的C程序员,必须给予充分重视并纠正。相比起来,这些问题还算好解决。但如果进一步谈到C程序的常见设计和实现模式,比如设计接口、解耦代码和数据、利用子驱动程序实现模型、设计状态机等,则绝大多数C程序员会感到难以理解和驾驭。
笔者在1993年接触C语言,起初在DOS下开发C程序,1998年年底为嵌入式Linux开发窗口系统MiniGUI,并领导团队持续开发和维护MiniGUI开源项目。20多年来,MiniGUI一直广泛应用于各类嵌入式设备当中。2020年,笔者提出了HVML编程语言,并领导团队使用C/C++开发HVML编程语言的开源解释器(PurC)和渲染器(xGUI Pro)。在开发PurC的过程中,笔者观察到团队成员在使用C语言方面的诸多问题,且发现这些问题具有普遍性,于是在2021年下半年利用视频号平台发起了一项公益性直播课程“C语言最佳实践”。此课程的回放随后在Bilibili平台上发布,并获得大量好评。2022 年年初,人民邮电出版社邀请笔者将直播课程整理成书,于是便有了本书。
可以说,本书是笔者近30年编码经验的总结。全书分3篇:基础篇、模式篇和质量篇。
基础篇(第1章~第5章)从可读性和可维护性出发,阐述了如何提高代码的可读性、用好写好头文件、正确理解编译警告并消除潜在问题、定义和使用常量等,介绍了如何有效利用构建系统生成器(CMake)来维护项目,比如提高代码的可移植性、处理可选的功能模块,以及自动生成某些源代码等。C语言最为接近计算机处理器,因而可以取得最佳的性能表现,但硬币的另一面则是容易写出一些存在缺陷和安全漏洞的代码。而这些缺陷或安全漏洞往往和程序员编码时不严谨有关。本篇的内容旨在帮助C程序员理解不严谨的编码风格和漠视编译警告所带来的风险,从而为写出高质量的C代码打下基础。
模式篇(第6章~第10章)是本书的核心内容,阐述了常见的C程序接口设计模式,说明了如何在C程序中解耦代码和数据、利用子驱动程序实现模型、设计可加载模块等,介绍了状态机的概念以及在C程序中如何利用状态机实现分词器、解析器等。第6章介绍的8种C程序接口设计模式是本书的重点,在其他C语言图书中鲜有介绍。本篇的内容旨在帮助C程序员开发出具有良好的接口设计且兼顾可维护性和可扩展性的C程序。
质量篇(第11章~第13章)从性能和单元测试出发,阐述了如何避免编写低效代码、进行单元测试、使用常见的单元测试框架等,介绍了高效调试C程序的若干技巧和工具。本篇的内容旨在帮助C程序员编写兼顾性能的高质量C代码。
本书的很多示例代码来自笔者主导的如下两个开源项目。
● MiniGUI。MiniGUI是一个开源的跨平台窗口系统,支持10多种操作系统,并提供丰富的小部件/控件和开源应用程序及工具。参见GitHub上的VincentWei/MiniGUI库。
● PurC。PurC是HVML的解释器,HVML是由笔者提出的一种描述式编程语言。参见GitHub上的HVML/PurC库。
本书带有配套示例程序,供读者阅读时同步参考,参见“资源与支持”页。完整的示例程序整理在GitHub上的VincentWei/bpcp库中。
读者亦可扫描书中所附二维码在线查看更多参考资料。
本书使用了如下术语。
● 结构体(structure):用于指代C程序中使用struct关键词定义的数据结构。
● 成员(member):结构体中的成员;为防止混淆,本书不使用字段(field)这一术语。
● 头文件(header file):特指.h文件。
● 源文件(source file):特指.c文件。
魏永明
2024年8月
本书提供如下资源:
● 配套示例程序;
● 本书思维导图;
● 异步社区7天VIP会员。
要获得以上资源,您可以扫描下方二维码,根据指引领取。
作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。
当您发现错误时,请登录异步社区(www.epubit.com),按书名搜索,进入本书页面,单击“发表勘误”按钮,输入勘误信息,单击“提交勘误”按钮即可(见下图)。本书的作者和编辑会对您提交的勘误信息进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。
我们的联系邮箱是contact@epubit.com.cn。
如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。
如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们。
如果您所在的学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。
如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。
“异步社区”是由人民邮电出版社创办的IT专业图书社区,于2015年8月上线运营,致力于优质内容的出版和分享,为读者提供高品质的学习内容,为作译者提供专业的出版服务,实现作者与读者在线交流互动,以及传统出版与数字出版的融合发展。
“异步图书”是异步社区策划出版的精品IT图书的品牌,依托于人民邮电出版社在计算机图书领域30余年的发展与积淀。异步图书面向IT行业以及各行业使用IT技术的用户。
对于编程而言,不论我们使用何种编程语言,编写具有良好可读性的代码都应该是程序员始终不变的追求目标。这一目标和追求零缺陷的代码一样重要。一方面,具有良好可读性的代码,通常意味着作者具有清晰的思路,也意味着代码具有较好的质量。另一方面,如今的编程活动越来越趋向于多人协作的模式,程序员经常要使用其他人开发的软件模块,因此需要花费很多时间阅读其他人编写的代码。如果代码的可读性不佳,就会导致其他人很难看懂代码,这无疑会极大地降低工作效率。反之,具有良好可读性的代码则能够有效地成为程序员之间的交流媒介,从而提高协作效率。
Linux内核创始人Linus有句名言——“Talk is cheap, show me the code!”,被网友们诠释为“空谈误国,放‘码’过来”。Linus还有一句名言——“Code is the best document.”,意思是“代码就是最好的文档”。这两句广为流传的名言,既从不同的角度阐述了代码本身的重要性,也从侧面说明了编写具有良好可读性代码的重要性。
本章首先定义了不同级别的代码可读性,然后讨论了坏代码的一些共同特点,讲述了编码风格的主要内容,并给出了C语言编码风格的一些最佳实践。最后,本章探讨了命名的艺术,亦可理解为取名的一些方法和套路。在讨论过程中,本章还提供了几个非常贴近实际的例子,它们可以帮助读者更好地理解这些概念或方法。
在讨论如何编写具有良好可读性的代码之前,我们首先讨论代码可读性的衡量标准。按照通俗的说法,代码可读性大致可以分为以下4个级别。
(1)初级可读性。代码能够顺利通过编译并运行,也能得到预期的结果。这表明计算机——或者更准确一点,编译器——“读懂”了我们编写的代码。但过了一段时间之后,我们再来查看这些代码时,会发现我们很难一眼看懂这些代码的逻辑和思路。相信初学编程的程序员都有过类似的经历。这便是具有初级可读性的代码。它们不是写给其他人或者三个月之后的自己阅读用的,而只是为了能编译生成可执行程序而已。显然,对其他程序员而言,维护一段连作者自己都看不懂的代码,比重写相同功能的代码还要痛苦很多倍。这种代码可以出现在初学阶段的练习题解答中,但绝对不应该出现在产品当中。
(2)中级可读性。这种代码在经过一段时间(如3个月)之后,仍然能被代码作者看懂。但若稍作思考,便可发现代码中存在很多值得改进的地方,因此常常会萌生重写代码的冲动。这意味着代码符合一定的章法,只是远远不够成熟。如果产品代码中存在大量具有中级可读性的代码,则说明产品代码的整体质量有待改进。
(3)高级可读性。这种代码易于被其他程序员理解,并且其他程序员能够在原有基础上做进一步的修改和完善(比如调试和优化),也就是所谓的“看得懂,改得动”。这里说代码“具有高级可读性”,通常意味着代码具有良好、清晰的模块划分和接口设计,也意味着代码作者具有良好的编码风格和命名习惯,只是在具体实现上还有一些不完善的地方,或者还有改进和优化的地方。也就是说,具有高级可读性的代码通常具有良好的可维护性和可测试性。修改这些代码不会对项目的其他模块产生负面影响。因此,编写具有高级可读性的代码,应该是每一位程序员的目标。成熟、严肃、认真的程序员编写的代码至少应该具有高级可读性。软件产品中的绝大部分代码也应该达到高级可读性。
(4)典范级可读性。在正确性、易读性、易用性、执行效率方面已经达到或者贴近最优的代码,删除其中任何一个字符或者修改其中任何一个字母都显得多余,甚至不需要因为编译器的改变而做任何调整或维护。显然,典范级可读性是一种非常高的标准,需要丰富的经验、高超的技巧才能编写,更需要代码作者多年的经验沉淀。让一个软件项目的所有源代码都达到这个标准是不切实际的,但在某些常用且基础的功能模块上,发展、积累或者采纳具有典范级可读性的代码,则相对容易做到。比如,在持续发展了30多年的Linux内核代码中,就存在大量堪称典范的代码片段。
根据以上代码可读性级别的定义,初级和中级可读性的代码被认为是“坏代码”,编写具有高级可读性的代码是对专业程序员的基本素养要求,而具有典范级可读性的代码应该是所有程序员心驰神往的目标。
举个大家熟知的例子,经典的“Hello, world!”程序的源代码如下:
#include <stdio.h> int main(void) { printf("Hello, world!\n"); return 0; }
这便是具有高级可读性的C语言代码:排版整齐、编码严谨、编译器不会报任何编译警告,而且具有优秀的可移植性,不论使用哪个编译器或者哪个版本的编译器,都能正常编译和运行。但请看下面的代码:
int main() {printf("Hello, world!\n");}
这便是具备初级可读性的代码:这段代码的排版没有章法,字符挤在一起不易阅读,没有包含正确的头文件,main()
函数的原型定义也不规范。当然,这段代码仍然可以编译并正确运行,但编译器可能会报一两个编译警告。
“幸福的人都是相似的,不幸的人各有各的不幸。”这句来自经典名著《安娜·卡列尼娜》的名言也可以延伸到编码风格上——“良好的编码风格遵循统一的规范,坏代码各有不同的表现”。话虽如此,但根据笔者对大量代码的观察,坏代码的“坏”大致体现在如下4个方面。
● 排版:所有与代码的外观相关的问题都可以归于排版问题,比如不正确的对齐和缩进、省略该有的空格导致书写拥挤等。在良好的编码风格中,代码的对齐、缩进、空格都应该遵循统一的标准。
● 命名:包括不正确的术语、不符合习惯的名称、错误的时态、使用拼音、含有中文等特殊字符的文件名、命名风格不统一等。C语言本身虽然是独立的,但在传统上和英语息息相关,因此C代码中的命名也应该尽量遵循正确的英语语法和用法习惯。另外,变量和函数等的命名风格虽然没有“唯一正确”的标准,但在同一个程序或者项目中,命名风格应该统一。
● 过度使用:不分场合地过度使用某种语法特性。过度使用typedef
(类型定义)便是一个典型的例子。过度使用类型定义,会导致其他程序员在阅读代码时无法快速知悉一个类型到底是结构体、枚举量、指针还是整数。
● 注释:注释是一种重要的代码文档工具,可以帮助程序员阅读自己或他人的代码。但是,不规范的注释常常会影响我们对代码的阅读和理解,如注释太多或太少、注释风格不统一、注释太花哨、注释的内容和代码脱节等。
具有上述这些问题的坏代码可以说是随处可见。程序清单1.1是一段C代码,它实现了一个简单的链表。
程序清单1.1 一个简单的链表实现(不良编码风格)
typedef struct Linklist{ ⇽--- 1.应避免使用typedef类型定义。2.Linklist的命名不当。3.字符{前应该有空格。 const char * elem; struct Linklist * next; }linklist; ⇽--- 4.字符}后应该有空格。5.linklist的命名不当。 //初始化链表的函数 ⇽--- 6.在C程序中,应避免使用C++风格的注释。7.应避免使用中文做注释。8.//后缺少必要的空格。 linklist * initlinklist(); ⇽--- 9.initlinklist的命名不当。10.函数声明不规范。11.表示指针的字符*应该紧贴后面的变量名或函数名。 const char * titles[]={"第1章 提高代码可读性","第2章 用好写好头文件", "第3章 消除编译警告","第4章 常量的定义和使用", "第5章 充分利用构建系统生成器"}; ⇽--- 12.书写拥挤,=两边应有空格。13.未适当换行和缩进。 int main() { ⇽--- 14.main函数的原型定义不规范。15.用于定义函数体的起始字符{应另起一行。 // 使用章节标题初始化链表 printf("初始化链表为:\n"); linklist *p=initlinklist(); ⇽--- 16.赋值运算符=的两边应有空格。 display(p); ⇽--- 17.未事先声明display()函数。18.display这一术语的选择不恰当,应考虑使用dump。 return 0; } linklist * initlinklist(){ ⇽--- 19.函数体之间应有空行,以便于阅读。20.函数原型的定义不规范。21.表示函数返回值类型为指针的字符*应该紧贴后面的函数名。22.字符{的前面应该有空格。 linklist * p=NULL; //创建头指针 ⇽--- 23.表示变量类型为指针的*应该紧贴变量名。24.赋值运算符=的两边应有空格。 linklist * temp = ( linklist*)malloc(sizeof(linklist)); ⇽--- 25.星号*和temp之间不应该有空格。26.左括号(和类型名称linklist之间有多余的空格。 // 先初始化首元节点 ⇽--- 27.使用了不规范的术语“首元节点”。 temp->elem = titles[0]; temp->next = NULL; p = temp; // 头指针指向首元节点 for (int i=1; i<5; i++) { ⇽--- 28.等号=和小于号<的两边应该有空格。 linklist *a=(linklist*)malloc(sizeof(linklist)); a->elem=titles[i]; a->next=NULL; temp->next=a; temp=temp->next; ⇽--- 29.以上5行中等号=的两边都应该有空格。 } 30.未缩进,应和定义循环体的for语句对齐。 return p; ⇽--- 31.return语句之前应有空行,用于分隔不同的功能块。32.应缩进。 }
可以看到,在区区几十行的代码中,我们罗列了30多个问题(当然,大多数问题是重复的)。我们可以将这些问题归纳如下。
● 排版问题:排版问题主要表现在对齐、缩进、空格和空行的不恰当使用上。比如上述代码在该使用空格的地方没有使用空格,有些不该使用空格的地方却使用空格。这一方面使代码不够清晰和整洁,另一方面也表现出代码的作者在编码时非常随意。
● 命名问题:这段代码中存在明显的命名不当问题。代码中变量和函数的命名既要注意语法,也要注意命名风格。在一开始的typedef
类型定义中,Linklist
这个名称同时存在这两个问题。链表的标准英语名称是linked list,在把这两个单词组合在一起构成类型名时,应该采用LinkedList
或linked_list
这样的形式。类似地,变量名称linklist
在风格和语法上都存在问题。再如initlinklist()
这个函数,其命名也不规范,几个小写单词挤在一起既拥挤又难看。如果使用init_linked_list()
作为函数名,用下画线将各个单词分隔,则会明显提高代码的可读性。
● 语法问题:这段代码存在两方面语法问题,一方面是不必要的typedef
类型定义,另一方面是错误的main
函数等的原型声明。比如这段代码中定义的链表结构体,并不需要使用typedef
定义为一种新的数据类型,在代码中直接使用struct linked_list
作为类型名称显然要比使用新定义的类型名称linklist
更加清晰。原因在于通常我们会将类型定义放到头文件中,当我们在某个源代码文件中阅读到使用struct linked_list *
的代码时,不需要查看头文件便可知悉该代码定义了一个结构体指针,而非整数、枚举量或者结构体。在本章的后面,我们将给出合理使用typedef
的几个建议。另外,在这段代码中,int main()
的写法并不规范,规范的写法要么是int main(void)
,要么是int main(int argc, const char *args[])
。
● 注释问题:这段代码中的原始注释采用了C++风格的注释(//
打头),另外注释内容使用了中文。尽管强制非英语母语的程序员使用英文写注释有些刻板或者严苛,但如果我们考虑到开源大势以及可能的国际交流,尽量用英语书写注释无疑是一种合理的要求。笔者不鼓励在产品级代码中使用中文注释的另外一个原因是,大量的计算机软硬件术语最初源自英文,当我们使用中文时,由于理解或者表述上的问题,就会出现各种偏差。比如在上述代码中,“首元节点”这一术语就显得非常怪异。我们可以轻松理解“首”,这里大概就是指第一个;但“首元”或者“首元节点”是何意?另外,在C代码中,仅在简短的行尾注释中使用C++引入的注释方法,也是应该遵循的一个原则——这看起来有点古板,却是优秀和专业的C程序员始终需要遵循的一项传统或者习惯。
拿到坏代码时,一个很好的习惯就是对代码按照编码规范的要求进行整理。这一过程本身就是阅读代码的过程,通过整理,我们可以使代码变得更加清晰易懂,也可能发现并解决一些潜在的问题。
程序清单1.2是按照符合惯例的编码风格对程序清单1.1中的代码进行修改后的版本。
程序清单1.2 一个简单的链表实现(整理后)
#include <stdio.h> #include <stdlib.h> struct linked_list { const char *title; struct linked_list *next; } /* Creates and initializes a new linked list. */ static struct linked_list *init_linked_list(void); /* Dumps the contents of a linked list */ static void dump_linked_list(struct linked_list *list); /* Destroys a linked list */ static void destroy_linked_list(struct linked_list *list); static const char *titles[] = { "第1章 提高代码可读性", "第2章 用好写好头文件", "第3章 消除编译警告", "第4章 常量的定义和使用", "第5章 充分利用构建系统生成器" }; int main(void) { /* Creates and initialize a new linked list with chapter titles */ struct linked_list *list = init_linked_list(); printf("A new linked list has been created and initialized: \n"); dump_linked_list(list); // dump the contents destory_linked_list(list); // destroy the list return 0; } struct linked_list *init_linked_list(void) { struct linked_list *head = NULL; /* allocates a node for the head of the linked list */ struct linked_list *head = (struct linked_list*)malloc(sizeof(*head)); /* initializes the head node */ head->title = titles[0]; head->next = NULL; struct linked_list *p = head; for (int i = 1; i < 5; i++) { struct linked_list *a; a = (struct linked_list*)malloc(sizeof(*a)); a->title = titles[i]; a->next = NULL; p->next = a; p = a; } return head; }
可以看到,修改之后的代码排版错落有致,逻辑清晰,给人一种赏心悦目的感觉。除了排版,我们还在如下6个方面对原有的代码做了调整,以便提高代码可读性以及代码质量。
● 将struct linked_list
结构体中的第一个成员(elem
)更名为更具实际意义的title
。
● 将init_linked_list()
函数声明为static
类型,避免命名污染。
● 将display()
函数重命名为dump_linked_list()
,并声明为static
类型。display
这个术语通常用于在窗口或者页面中显示一个图形,而在本例中,展示一个链表通常意味着将其内容转储(dump)到指定的文件或者标准输出(标准输出本质上也是文件),以便事后查看或者调试。因此,使用dump
这个术语来命名这个函数,显然要比使用display
强很多。
● 新增destroy_linked_list()
函数,用于销毁新创建的链表,并声明为static
类型。原有代码在 main()
函数返回时并未做内存的清理工作。尽管在这个简单的链表实现中不必如此严谨,但作为专业程序员,我们应该养成良好的习惯并逐渐形成条件反射:既然有链表的创建函数,就应该有对应的链表销毁函数。
● 在init_linked_list()
函数的实现中,移除了不必要且易混淆的temp
变量。
● 使用sizeof(*head)
的写法替代了sizeof(struct linked_list)
的写法,避免代码行过长。
注意,上述代码中未包含dump_linked_list()
和destroy_linked_list()
两个函数的实现,有兴趣的读者可自行实现。
相信读者已经大致了解具有良好可读性的代码应该是什么样子,或者可以大致体会出不同的编码风格对代码可读性带来的影响。
良好的编码风格(coding style)是极其重要的,Linux内核就是一个很好的例子。由于Linux内核的代码需要被成千上万的程序员阅读,因此为了保证代码的良好可读性,Linux内核在编码风格上的要求可以用严苛来形容。当我们向Linux内核提交一个拉取请求(pull request,我们可以将拉取请求理解为对代码进行修改之后的合并请求)时,如果编码风格不符合规定,则不管修改的内容如何,都会被拒绝。因此,我们在编写代码时一定要保证代码具有良好的编码风格。
那么,为了养成良好的编码风格,我们需要注意哪些方面呢?首先,代码应该满足良好的排版规则,包括缩进、空格、换行以及大括号的正确位置等。本章后面将以Linux内核的编码风格为例,详细介绍这方面的规则。
其次,代码应该遵循统一的命名规则。常见的命名规则有K&R命名法和驼峰命名法两种。K&R命名法是Kernighan和Ritchie在《C编程语言》一书中所采用的命名规则。这种规则用小写形式的单词和下画线组合对变量和函数进行命名,如 this_is_an_integer
。驼峰命名法源自匈牙利籍程序员Charles Simonyi在微软任职期间提出的匈牙利命名法,因微软在Win32 API及其示例代码中广泛采用这一命名规则而广为人知。匈牙利命名法约定了变量的命名由其类型缩写和首字母大写的单词组成,如iThisIsAnInterger
。随后这一命名法被广泛用于其他编程语言,如Java、JavaScript等。在使用匈牙利命名法时,由于大写字母通常会凸出来,因此这种命名法在用于其他编程语言时,常被称为驼峰命名法。
知识点:Win32
Win32是Windows操作系统发展到3.0版本,开始支持32位的80386处理器时,微软为Windows操作系统设计的底层基础API所取的名字,意指针对32位平台的Windows API。
K&R命名法和驼峰命名法都是常用的命名规则,至于哪个好哪个坏,实在是“萝卜白菜各有所爱”的事情。但是在同一项目中,应该采用统一的规则,一般不建议混用。通常,K&R命名法在C代码中更为常见;而驼峰命名法除在Win32代码中之外,在面向对象编程语言(如C++、Java、Python)中更为常见,但有细微差别,比如在面向对象编程语言中,对属性或方法则经常使用setLocale
这样的命名习惯(即首字母缩写)。
除了变量名和函数名,宏名、枚举常量名、全局变量名也应该遵循统一的命名规则。例如,宏名一般采用全大写形式,为避免混淆,通常会添加一些前缀,并使用下画线分隔不同的单词。枚举常量名则以字母K开头,表示常量(常量对应的constant一词的发音以k打头)。程序内部使用的全局变量,其名称前还会添加一两个下画线作为前缀,这样做是为了防止命名污染,避免一些潜在的问题(如符号冲突、链接错误等)。
知识点:命名污染
通俗来讲,命名污染(naming pollution)就是指“重名”。重复的函数名、变量名、宏名会造成很多问题。简单的如编译警告或错误,或者链接生成可执行文件时报“重复的符号”错误,复杂的如重复的全局变量名和局部变量名导致程序执行异常,这些问题均可能需要程序员浪费大量时间来排查。由于设计上的问题,C语言一直未引入类似C++或其他编程语言对命名空间(naming space)的支持,这容易导致命名污染的产生。
编码风格的其他规则还包括自定义类型的使用、条件编译的写法、注释的写法,以及一些常见的约定写法。约定的写法通常来自经验的总结,比如如何处理系统可移植性、如何处理处理器架构的可移植性、如何实现国际化和本地化等。
众所周知,Linux内核的发展已逾30年。作为目前全球使用最为广泛的开源操作系统,一方面,Linux已经成为使用C语言编写大型基础软件工程项目的全球典范;另一方面,Linux也是目前最为成功的全球性开源协作项目。可以说,Linux内核是每一位C程序员取之不尽、用之不竭的宝库。作为一名C程序员,学习Linux内核的编码风格,并有意向其靠近,是绝对正确的选择。
这里以缩进和注释为例介绍Linux内核的编码风格。完整的Linux内核编码风格文档,可通过在任意搜索引擎中搜索Linux kernel coding style
阅读。
Linux内核的编码风格规定,代码的缩进应该采用制表符(Tab),宽度为8个空格(和Windows操作系统中大多数编辑器或字处理软件常使用4个空格不同)。例如:
int system_is_up(void) { return system_state == SYSTEM_RUNNING; }
这个规定有什么好处呢?Linux内核的编码风格还规定每一行代码的长度不得超过80个字符。按照“Linux之父”Linus的说法,如果代码的缩进或嵌套超过3个层次,就要考虑对代码进行重构。因此 8 个空格的缩进可以有效地限制缩进或嵌套的层次,迫使程序员在编写代码时不要采用过多的嵌套或缩进。
当然,8个空格的缩进要求确实有些严苛,读者也可以酌情在自己的代码中采用4个空格的缩进,但4个空格应该是底线,再少就不合适了。
Linux内核的编码风格还禁止采用C编程语言的“//”注释方式(这和Linus不喜欢C++编程语言有一些关系)。Linux内核采用的单行注释形式如下:
/* This is a single-line comment. */
Linux内核采用多行注释形式如下:
/* * This is the preferred style for multi-line * comments in the Linux kernel source code. * Please use it consistently. * * Description: A column of asterisks on the left side, * with beginning and ending almost-blank lines. */
除此之外,在Linux内核的编码风格中,还特别说明不要针对结构体使用typedef
定义新的数据类型,而应该始终使用struct foo
这样的形式。
作为一个重要的经验性约定,Linux内核的编码风格要求所有的函数在末尾集中返回。也就是说,应避免在一个函数的头部或中部使用return
语句。因此,我们可以在Linux内核的源代码中看到被传统编程教材强烈否定的goto
语句的频繁使用。本章后面将解释这一做法的好处。这类规定属于经验性约定,在某种程度上可以规范某些编码行为,从而提高代码的一致性和质量,属于可维护性的范畴。
除了Linux内核的编码风格,流传较为广泛的还有GNU的C语言编码风格(后文简称GNU编码风格),以及Win32编码风格等。
知识点:GNU
GNU是GNU’s Not UNIX的递归缩写,它是由自由软件基金会(Free Software Foundation,FSF)于20世纪80年代发起的一个重要的自由软件项目。GNU项目的目标是开发一个自由的UNIX变种HURD。尽管HURD的开发处在停滞状态,但GNU项目开发和维护着许多高质量的基础软件和工具,其中包括基础库(Glibc)、编译器(GCC)、编辑器(Emacs)以及各类命令行工具。这些软件至今仍然在我们的软件世界中扮演着非常重要的角色。GNU项目的大部分软件是使用C语言开发的。
GNU编码风格同样要求每行代码的字符不能超过80个,但在排版上和Linux内核的编码风格有较大的区别,尤其在缩进、空格和大括号的位置方面区别较大。比如下面这段使用GNU编码风格的代码:
int lots_of_args (int x, long y, short z, double a_double, float a_float) { int haha = 0; if (x < foo (y, z)) haha = bar[4] + 5; else { while (z) { haha += foo (z, z); z--; } return ++x + bar (); } return haha; }
如果按照Linux内核的编码风格,则应修改为如下形式:
int lots_of_args(int x, long y, short z, double a_double, float a_float) { int haha = 0; if (x < foo(y, z)) { haha = bar[4] + 5; } else { while (z) { haha += foo(z, z); z--; } haha = ++x + bar(); } return haha; }
除了排版、注释、语法约定、命名等属于代码可读性范畴的内容,GNU的编码风格还包括如下方面的一些规定。
(1)系统可移植性:规定了如何处理跨操作系统的可移植性。
(2)处理器可移植性:规定了如何应对不同种类的处理器或处理器架构。
(3)系统函数:规定了如何应对不同平台在标准函数库上的差异。
(4)规定了国际化、字符集、引号的使用以及mmap
函数的使用等。
这些规定是经验性约定,属于代码可维护性的范畴。
Win32编码风格并不像GNU编码风格或者Linux内核的编码风格那样存在一个在线可查阅的版本,而是散见于Win32头文件以及各种示例程序中。我们可以将Win32编码风格理解成匈牙利命名法的集大成者。
在开源项目MiniGUI中,由于其API模仿Win32而来,因此其编码风格极具匈牙利命名法风格。下面是MiniGUI中ShowWindow()
函数的实现代码。
/* ** This function shows window in behavious by specified iCmdShow. */ BOOL GUIAPI ShowWindow (HWND hWnd, int iCmdShow) { MG_CHECK_RET (MG_IS_NORMAL_WINDOW(hWnd), FALSE); if (IsMainWindow (hWnd)) { ... } else { PCONTROL pControl; pControl = (PCONTROL)hWnd; if (pControl->dwExStyle & WS_EX_CTRLASMAINWIN) { ... } else { switch (iCmdShow) { case SW_HIDE: if (pControl->dwStyle & WS_VISIBLE) { pControl->dwStyle &= ~WS_VISIBLE; InvalidateRect ((HWND)(pControl->pParent), (RECT*)(&pControl->left), TRUE); } break; ... } } if (iCmdShow == SW_HIDE && pControl->pParent->active == pControl) { SendNotifyMessage (hWnd, MSG_KILLFOCUS, 0, 0); pControl->pParent->active = NULL; } } SendNotifyMessage (hWnd, MSG_SHOWWINDOW, (WPARAM)iCmdShow, 0); return TRUE; }
从上述代码中可以看出,Win32编码风格的典型特征如下。
● 使用匈牙利命名法命名变量,如上述代码中的iCmdShow
和pControl
,前者表示整型变量,后者表示指针。
● 使用匈牙利命名法命名函数,如上述代码中的SendNotifyMessage()
和InvalidateRect()
。
● 较多地使用了类型定义,如上述代码中的HWND
、BOOL
、WPARAM
、RECT
等;其中的RECT
是一个结构体,它定义了一个矩形的左上角和右下角两个顶点的坐标。
值得一提的是,上面的代码对字符数超过80的行做了绕行处理,但Win32编码风格对这一点并不作硬性限制。毕竟Windows平台为开发者提供了图形化的集成开发环境,每行字符超过80个并不会给程序员带来很大的困扰。另外,由于Win32 API和变量名通常较长,因此一行代码很容易超过80个字符。
随着Windows平台上的主流编程语言从C转为C++,而后又转为C#,现在使用Win32编码风格的C代码已经相对少见了。
在C语言的编码风格中,最重要的是代码的排版。排版决定了代码是否整洁和美观,对于代码的可读性具有非常重要的影响。
在编写C语言程序时,我们要守好“80列”这条红线,即每行代码不超过80个字符。由于程序结构的限制,像C++、Java这样的面向对象编程语言很难坚守这条红线。但由于C语言的结构特性,在C代码中坚守“80列”这条红线是可行的,而且很有必要。C程序员应该把每行代码不超过80个字符作为金科玉律。
坚守一行代码不超过80个字符,起初主要是为了方便在不同的字符终端查看源代码。早期电传字符终端的列数为80,行数为25,一旦一行代码超过80列,早期电传字符终端就会自动绕行显示,从而导致阅读困难。而如今,坚守“80列”这条红线还可以带来其他额外的好处。
首先,可以防止代码中出现过多的缩进和嵌套。如果代码的缩进层次达到4级或更多级,就很容易超出80个字符的限制(尤其是当按照Linux内核的编码风格,使用每级缩进8个空格的制表符时),这就要求程序员放弃太多的缩进或嵌套,而代码的缩进层次不超过3级,也正是广大C程序员约定俗定的目标。像C++和Java这样的面向对象编程语言之所以难以坚守“80列”这条红线,就是因为程序结构中多了类以及命名空间等新的层次,从而导致代码的缩进层次不超过3级几乎不可能实现。
其次,如今计算机屏幕的尺寸越来越大,24寸显示器屡见不鲜。这也逐渐体现出坚守“80列”这条红线的另一个优势:如果每行代码都不超过80个字符,就很容易在同一个窗口中同时查看多个源代码文件,只要在编辑器内竖直分隔显示多个源文件即可。这为程序员的工作提供了不小的便利。
适当添加空格、不滥用空格,对提高代码的可读性和维护代码的整洁性具有很大的帮助。这方面的最佳实践便是采纳Linux内核编码风格的相关规定。
(1)单目运算符和++
、--
运算符的前后不加空格,例如像下面这样的写法是正确的:
int *p = (int *)&a; p++; --a;
像下面这样的写法是不正确的:
a ++; ++ p;
(2)函数名称,包括可按函数方式调用的关键字(如sizeof
)和宏的后面,不要加空格,例如像下面这样的写法是正确的:
call_me(sizeof(int), MAX(x, y));
其中,参数列表中的逗号之后应该添加空格。
像下面这样的写法是不正确的:
call_me (sizeof (int),MAX (x,y));
(3)不要在行尾添加空格。这一点很重要,但很容易被忽视,因为行尾的空格在视觉上是看不出来的。为此,可以修改编辑器的设置,显示行尾的空白字符,比如将行尾的空格显示为灰色的句点。
(4)双目运算符或多目运算符的前后,以及关键字(如if
、else
、for
)的后面一般需要添加空格,例如:
if (a && b) { }
但也有一些例外,比如双目的成员访问操作符(.
和->
)的前后不需要添加空格,例如:
temp->next = NULL;
除空格之外,有关大括号的位置,还有一个建议是遵循Linux内核编码风格的相关规定。相较于其他编码风格,Linux内核编码风格的相关规定最为简洁。但究竟是使用制表符还是使用空格进行缩进,以及缩进宽度是4个空格还是8个空格,则由读者自行决定。在实践中,相较于将if、while等语句后的左大括号({
)单独一行书写,笔者更喜欢将其置于行尾,如下所示:
int lots_of_args(int x, long y, short z, double a_double, float a_float) { int haha = 0; if (x < foo(y, z)) { haha = bar[4] + 5; } else { /* 笔者不太喜欢 } else { 这种写法 */ while (z) { haha += foo(z, z); z--; } haha = ++x + bar(); } return haha; }
对于指针声明和定义中的星号位置,C和C++的习惯有所不同。对编译器来讲,两种写法都是正确的。例如,下面是C语言的风格:
void *get_context(struct node *node)
而C++一般采用下面的风格:
void* get_context(struct node* node)
建议C程序员坚守C语言的星号使用习惯。但是,相较于选择哪种风格,更重要的是在同一个程序的代码中,应该坚持使用同一种风格,不要在有些地方使用C语言的风格,而在另一些地方使用C++语言的风格。另外,不要使用下面这种兼顾两者但其实又两不像的风格:
void * get_context(...);
Linux内核强烈要求慎用类型定义(typedef),但在某些情形下使用类型定义可以带来很多便利。根据笔者多年的工作经验,应考虑在下列场合使用类型定义。
可以在函数库的接口定义中使用类型定义,尤其当需要隐藏类型的实现细节时。也就是说,使用接口的程序员不需要关心类型的内部细节。比如,在Win32 API中,存在很多称为句柄(handle)的类型,比如HWND
表示窗口句柄,代表一个窗口对象的值。在内部实现中,窗口句柄可能是一个指针,也可能是一个表示索引的整数。使用HWND
的程序员不需要关心窗口句柄的内部实现,也不允许应用程序通过窗口句柄直接访问内部的数据结构,而只需要传递某个API返回的句柄给其他API使用即可。这种情况是使用类型定义的绝佳场合。比如HWND
就可以用一个和指针等宽的无符号整数类型(uintptr_t
)来定义:
typedef uintptr_t HWND
假定在Windows操作系统的内部实现中,HWND
可直接作为指针使用,那么在具体使用时,只要做一次强制类型转换即可,例如:
static void foo(HWND hWnd) { WINDOW *pWin = (WINDOW *)hWnd; pWin->spCaption = strdup("Hello, world!"); ... }
可以对结构体指针使用类型定义,并使用_p
或者_t
后缀,例如:
struct list_node { const char *title; struct list_node *next; }; typedef struct list_node *list_node_p;
使用_p
后缀和_t
后缀的区别是,当结构体的内部细节暴露在外时,意味着外部代码可以访问结构体内的成员,此时使用_p
后缀;反之,当结构体的内部细节被隐藏时,意味着外部代码不可以访问结构体内的成员,此时结构体指针的作用类似于上面提到的句柄,对外部代码而言,结构体指针相当于一个普通的无符号整数值,因而使用_t
后缀。
相比使用句柄的情形,若对结构体指针使用类型定义,则可以带来一个额外的优势:在内部使用时,不用进行强制类型转换。为此,我们在头文件中作如下声明和定义:
struct list_node; typedef struct list_node *list_node_t; /* Returns the title in the specific node */ const char *list_node_get_title(list_node_t node);
然后在内部的头文件或者源文件中,定义结构体的细节并实现相应的接口:
struct list_node { const char *title; struct list_node *next; }; const char *list_node_get_title(list_node_t node) { return node->title; }
对结构体指针使用类型定义,即使头文件中声明的结构体名称不变,我们也可以在不同的源文件中为结构体定义不同的内部细节。这将带来极大的灵活性,详见第6章。
另外,这种做法在C标准库中十分常见,比如C标准库中全部大写的FILE
、DIR
等结构体,其内部细节不会暴露给应用程序。但用于描述目录项的结构体的细节则暴露给应用程序,并没有定义新的数据类型。
对枚举类型使用类型定义并使用_k
后缀,就可以和后缀为_t
或_p
的类型区分开来。例如,下面的代码定义了一个名为purc_document_type_k
的枚举类型来表示文档的类型:
typedef enum { PCDOC_K_TYPE_FIRST = 0, PCDOC_K_TYPE_VOID = PCDOC_K_TYPE_FIRST, PCDOC_K_TYPE_PLAIN, PCDOC_K_TYPE_HTML, PCDOC_K_TYPE_XML, PCDOC_K_TYPE_XGML, /* XXX: change this when you append a new operation */ PCDOC_K_TYPE_LAST = PCDOC_K_TYPE_XGML, } purc_document_type_k;
如果确实需要对结构体进行类型定义,则可以对类型定义名称采用全大写且不带下画线的命名法,以便提示它是一个结构体的类型定义名称,如LINKEDLIST
。这样就不会与采用全小写加下画线形式的变量名或函数名,以及采用全大写形式但使用下画线的常量名或宏名产生混淆了。
typedef struct LINKEDLIST { const char *title; struct linked_list *next; } LINKEDLIST;
如果能接受驼峰命名规则,那么也可以使用首字母大写的驼峰命名法来定义结构体的类型名称,例如:
struct LinkedList { const char *title; struct LinkedList *next; }; typedef struct LinkedList LinkedList;
但这里更推荐不使用后缀来定义结构体的类型名称,因为前面已经对整数类型、枚举类型和结构体指针类型使用了_t
或者_k
等后缀:
typedef struct linked_list { const char *title; struct linked_list *next; } linked_list; typedef struct linked_list *linked_list_t;
在早期的C代码中,由于当时的编译器不允许新的类型名称和已有的结构体类型名称相同,因此我们经常会看到下面的代码:
struct _LINKEDLIST { const char *title; struct linked_list *next; }; typedef struct _LINKEDLIST LINKEDLIST;
或者
struct tagLINKEDLIST { const char *title; struct linked_list *next; }; typedef struct tagLINKEDLIST LINKEDLIST;
上述代码在结构体的类型名称中使用下画线和tag
作为前缀以示区别,但现在已经不需要这样做了。
作为一个不建议自定义数据类型的例子,我们在新的C语言项目中,应避免对整数做类型定义。C99标准已经在<stdint.h>
头文件中针对不同宽度的整数类型定义了新的数据类型,比如uint8_t
、intptr_t
、intmax_t
等,因此我们没有必要再自行针对不同的整数类型自定义新的数据类型。
在代码中,应采用一致的命名规则(K&R命名法或匈牙利命名法),而不能混用命名规则。但是,C代码更倾向于采用K&R命名法。因此笔者建议,除用于接口的函数名称之外,最好在内部的实现代码中统一采用K&R命名法,并避免出现像spName
这样的命名风格。用于接口的函数名称采用匈牙利命名法或者带有小写前缀的驼峰命名法具有一定的优势,并且容易把这些接口和系统函数名或者其他函数库的接口名区分开来,从而在一定程度上避免命名污染。比如常用于解析JSON的开源函数库cJSON,其接口定义如下:
/* returns the version of cJSON as a string */ const char* cJSON_Version(void); /* Supply malloc, realloc and free functions to cJSON */ void cJSON_InitHooks(cJSON_Hooks* hooks); cJSON * cJSON_Parse(const char *value); cJSON * cJSON_ParseWithLength(const char *value, size_t buffer_length);
除源代码中的函数名、变量名之外,用于组织源代码的目录和文件的命名规则也需要得到重视。下面给出一些常规建议:
(1)仅使用ASCII可打印字符(文件系统不允许的字符除外),而不要使用中文、表情符号等特殊字符;
(2)使用-
连接多个单词而避免使用_
,这一点和C代码中的变量名不同;
(3)使用全小写的文件名和目录名。
如此,ordered-map.c
就是合乎上述建议的文件名,而ordered_map.c
就不是合乎上述建议的文件名。
虽然在编译时注释会被编译器忽略,但注释对代码的可读性具有极其重要的作用。代码如果没有注释或者注释很少,那么不仅其他人不容易看懂,即使是代码的作者,一段时间之后再次接触也会一头雾水。但代码中的注释要恰到好处,不宜过多或者画蛇添足。毕竟,如果一段代码的功能是清晰的,变量的命名符合习惯,则代码本身就可以说明其运行逻辑,而不必进行过多的注释。因此,我们更多看到的注释,通常存在于针对接口的说明中,比如函数的功能描述、各个参数的含义、有关返回值的说明、结构体中每个成员的含义等。
关于C代码中注释的书写,建议如下。
(1)为对外的接口提供详细的注释,并采用Doxygen(或其他文档生成工具,如GtkDoc)允许的格式来撰写注释。对于函数,应就其功能、参数以及返回值做详细说明;对于结构体,应就其公开的成员做详细说明。现在,越来越多的开源项目采用Doxygen允许的格式,以随同代码共存的注释形式来编写和维护接口的说明文档。这既方便开发者阅读,又便于维护,尤其在接口发生变化时,开发者可以在修改代码的同时完成对文档的修改。在本章的后面,读者可以看到采用Doxygen允许的格式撰写的接口注释。
(2)在不对外的内部模块中,应就文件的功能、内部函数的用途等做简要的注释。除非代码涉及复杂或重要的算法,否则不必过多地撰写注释。这是因为,当我们使用了合乎习惯的命名方法,并掌握了常见的接口设计模式之后,看接口及其实现就能知悉代码逻辑;此种情况下,额外地撰写注释就是画蛇添足。
(3)避免使用C++的注释形式。C++的注释形式(即“//
”形式)输入方便,具有一些优点。但是如前所述,代码最重要的特质就是风格统一,因此在C代码中还是应该统一采用传统的注释风格。在Linux内核以及大多数重要的C语言程序中,我们很少看到C++风格的注释。一个好的习惯是仅在行尾的简短注释中使用C++风格的注释。
(4)巧用XXX、FIXME、TODO等短语。XXX具有告诫意味,表示这段代码非常重要,必须认真阅读。FIXME一般表示这段代码不够完善,存在提高空间。TODO一般表示这段代码的功能还不完整,有待未来完成。有些智能的代码编辑器会将注释中的这些特殊短语用特殊的颜色加以显示,以提醒代码的阅读者。
(5)学习Linux内核编码风格提倡的注释编写风格(见本章前面的内容),而不要添加额外的装饰用字符。
下面的代码片段定义的枚举类型用于区别不同的变体类型,其中两次使用了XXX标记,用于特别提醒开发者。在现代编辑器中查看这段代码,XXX会显示为醒目的红色。
typedef enum purc_variant_type { PURC_VARIANT_TYPE_FIRST = 0, /* XXX: keep consistency with type names */ #define PURC_VARIANT_TYPE_NAME_UNDEFINED "undefined" PURC_VARIANT_TYPE_UNDEFINED = PURC_VARIANT_TYPE_FIRST, #define PURC_VARIANT_TYPE_NAME_NULL "null" PURC_VARIANT_TYPE_NULL, #define PURC_VARIANT_TYPE_NAME_BOOLEAN "boolean" PURC_VARIANT_TYPE_BOOLEAN, #define PURC_VARIANT_TYPE_NAME_NUMBER "number" PURC_VARIANT_TYPE_NUMBER, #define PURC_VARIANT_TYPE_NAME_STRING "string" PURC_VARIANT_TYPE_STRING, #define PURC_VARIANT_TYPE_NAME_OBJECT "object" PURC_VARIANT_TYPE_OBJECT, #define PURC_VARIANT_TYPE_NAME_ARRAY "array" PURC_VARIANT_TYPE_ARRAY, #define PURC_VARIANT_TYPE_NAME_SET "set" PURC_VARIANT_TYPE_SET, /* XXX: change this if you append a new type. */ PURC_VARIANT_TYPE_LAST = PURC_VARIANT_TYPE_SET, } purc_variant_type; #define PURC_VARIANT_TYPE_NR \ (PURC_VARIANT_TYPE_LAST - PURC_VARIANT_TYPE_FIRST + 1)
注意,在上述枚举量的定义中,还使用了将字符串常量和枚举量置于上下两行进行定义的技巧。这一技巧既方便了代码的维护,也能帮助我们在改变一个类型或新增一个类型时,确保对枚举量和对应的字符串常量做同步处理。
条件编译在C代码中十分常见。条件编译的常见用途有如下3种。
(1)当我们要将自己开发的软件运行在不同的操作系统中时,由于底层标准库在不同操作系统中的实现存在差异,我们需要使用条件编译来处理这种差异。
(2)类似地,当我们要针对不同的处理器或处理器架构编写特定的代码实现时,也需要使用条件编译。
(3)当我们要通过编译时配置选项来控制软件的功能(比如通过配置选项控制包含哪些功能模块)时,会经常使用条件编译。
如下代码来自MiniGUI的头文件,用于判断如何确定64位的整数数据类型,其中给出了条件编译的常见用法:
/* Figure out how to support 64-bit datatypes */ #if !defined(__STRICT_ANSI__) # if defined(__GNUC__) # define MGUI_HAS_64BIT_TYPE long long # endif # if defined(__CC_ARM) # define MGUI_HAS_64BIT_TYPE long long # endif # if defined(_MSC_VER) # define MGUI_HAS_64BIT_TYPE __int64 # endif #endif /* !__STRICT_ANSI__ */ /* The 64-bit datatype isn't supported on all platforms */ #ifdef MGUI_HAS_64BIT_TYPE typedef unsigned MGUI_HAS_64BIT_TYPE Uint64; typedef signed MGUI_HAS_64BIT_TYPE Sint64; #else /* This is really just a hack to prevent the compiler from complaining */ typedef struct { Uint32 hi; Uint32 lo; } Uint64, Sint64; #endif
如上述代码所示,条件编译会严重割裂代码的连续性,从而极大破坏代码的可读性。因此,我们应该尽量避免使用条件编译。当然,由于C语言主要用来开发底层基础软件,因此C程序员难免会因为上面所说的3种用途而使用条件编译。为此,下面是4条可供C程序员参考的处理原则。
第一,使用恰当的注释说明条件编译代码块的作用,如下所示:
#ifdef foo ... #else /* foo */ ... #endif /* not foo */ #ifdef foo ... #endif /* foo */ #ifndef bar ... #else /* not bar */ ... #endif /* bar */ #ifndef bar ... #endif /* not bar */
上述代码在#else
和#endif
代码行的末尾,使用了恰当的注释来说明条件编译代码块所对应的条件。
第二,在嵌套条件编译时,恰当地使用缩进来表示嵌套关系,如下所示:
#ifndef NULL # ifdef __cplusplus # define NULL (0) # else # define NULL ((void *)0) # endif #endif /* not defined NULL */
第三,避免使用过长的条件编译代码块,确保一个条件编译代码块不超过常见编辑器的最大行数(25~50行)。
第四,使用构建系统生成器提供的方法实现对软件功能的控制,避免使用条件编译。比如,在实现一个跨平台的文件系统操作接口时,需要考虑到不同操作系统之间(尤其是Windows操作系统和类UNIX操作系统之间)的巨大差异。这时,我们可以借助构建系统生成器,根据当前所要构建的目标系统,生成对应的构建文件,从而针对不同的目标平台编译不同的源文件。为此,我们可以将针对类UNIX操作系统(如Linux和macOS)的代码组织到一个源文件中,如filesystem-unix-like.c
;而将针对Windows操作系统的代码组织到另一个源文件中,如filesystem-windows.c
。这样就可以避免在源文件中使用大段的条件编译。
知识点:构建系统生成器
构建系统(build system)生成器(generator)是用于生成构建C、C++项目等所使用的构建系统的工具。这里的构建系统通常由一组Makefile组成。因此,我们可以将构建系统生成器理解成Makefile生成器。常见的构建系统生成器有GNU Autotools、CMake、Meson等。成熟的构建系统生成器通常具有跨平台的特征,可以帮助我们针对不同的平台组织我们的源文件,并按照目标构建系统的特性自动生成一些宏,从而提升代码的可移植性。
有关构建系统生成器的内容,我们将在第5章做详细阐述。
本节阐述的内容从严格意义上讲不属于代码的可读性范畴,而属于代码的可维护性或技巧性范畴。将这些内容置于本节,是因为这些方法或技巧在实践中十分常见,希望读者在看到类似代码的时候不要慌张。
下画线前缀的主要作用是防止命名污染,因此对静态变量或局部变量使用下画线前缀并无意义。在实践中,我们经常对非公开的extern
类型(也就是全局)的变量或函数使用下画线前缀,例如:
extern size_t __total_mem_use;
为一个变量加上一个或两个下画线符号作为前缀,通常表示这个变量是非公开的外部变量。对于静态变量或局部变量,使用下画线前缀只会给其他程序员造成困扰,因此不建议使用。
另外,在结构体的定义中,可能包括一些隐藏(或者保留内部使用的)成员。对于这些成员,我们也可追加下画线前缀。在下面这个结构体(pcrdr_msg
)的对外定义中,就包含这样的4个成员:
/** the renderer message structure */ struct pcrdr_msg { unsigned int __refcnt; purc_atom_t __origin; void *__padding1; // reserved for struct list_head void *__padding2; // reserved for struct list_head pcrdr_msg_type type; pcrdr_msg_target target; pcrdr_msg_element_type elementType; pcrdr_msg_data_type dataType; ... };
之所以这样做,是因为我们不希望外部模块访问这4个仅供内部模块使用的成员。在内部模块中,我们可通过另一个结构体(pcrdr_msg_hdr
)来访问这4个成员,只需要将结构体pcrdr_msg
的指针强制转换为结构体pcrdr_msg_hdr
的指针即可:
struct list_head { struct list_head *next; struct list_head *prev; }; /* the header of the struct pcrdr_msg */ struct pcrdr_msg_hdr { atomic_uint refcnt; purc_atom_t origin; struct list_head ln; };
如前所述,Linux内核的编码风格要求所有的函数应在末尾提供统一的出口,因此我们在Linux内核的源代码中看到goto
语句被频繁使用。实际上,除了Linux内核,其他基于C语言的开源软件也在使用这一经验性约定写法。
为了直观感受这种写法的优势,我们来看看程序清单1.3中的代码。
程序清单1.3 一个哈希表的创建函数
struct pchash_table *pchash_table_new(size_t size, pchash_copy_key_fn copy_key, pchash_free_key_fn free_key, pchash_copy_val_fn copy_val, pchash_free_val_fn free_val, pchash_hash_fn hash_fn, pchash_equal_fn equal_fn) { struct pchash_table *t; if (size == 0) size = PCHASH_DEFAULT_SIZE; t = (struct pchash_table *)calloc(1, sizeof(struct pchash_table)); if (!t) return NULL; t->count = 0; t->size = size; t->table = (struct pchash_entry *)calloc(size, sizeof(struct pchash_entry)); if (!t->table) { free(t); return NULL; } t->copy_key = copy_key; t->free_key = free_key; t->copy_val = copy_val; t->free_val = free_val; t->hash_fn = hash_fn; t->equal_fn = equal_fn; for (size_t i = 0; i < size; i++) t->table[i].key = PCHASH_EMPTY; if (do_other_initialization(t)) { free(t->table); free(t); return NULL; } return t; }
上述代码实现了一个用来创建哈希表的函数pchash_table_new()
。在这个函数中,我们需要执行两次内存分配,一次用于分配哈希表本身,另一次用于分配保存各个哈希项的数组。另外,该函数还调用了一次 do_other_initialization()
函数,以执行一次额外的初始化操作。如果第二次内存分配失败,或者额外的初始化操作失败,则需要释放已分配的内存并返回NULL
表示失败。可以想象,我们还需要执行其他更多的初始化操作,当后续的任何一次初始化操作失败时,我们就需要不厌其烦地在返回NULL
之前调用free()
函数来释放前面已经分配的内存,否则就会造成内存泄漏。
要想优雅地处理上述情形,可按如下代码(为节省版面,我们略去了部分代码)所示使用goto
语句,如此便能起到化腐朽为神奇的效果:
struct pchash_table *pchash_table_new(...) { struct pchash_table *t = NULL; ... t = (struct pchash_table *)calloc(1, sizeof(struct pchash_table)); if (!t) goto failed; ... t->table = (struct pchash_entry *)calloc(size, sizeof(struct pchash_entry)); if (!t->table) { goto failed; } ... if (do_other_initialization(t)) { goto failed; } return t; failed: if (t) { if (t->table) free(t->table); free(t); } return NULL; }
以上写法带来的好处显而易见:将函数中多个初始化操作失败时的处理统一集中到函数末尾,减少了return
语句出现的次数,方便了代码的维护。
还有一个技巧,我们可以通过定义多个goto
语句的目标标签(label),让以上代码变得更加简洁:
struct pchash_table *pchash_table_new(...) { struct pchash_table *t = NULL; ... t = (struct pchash_table *)calloc(1, sizeof(struct pchash_table)); if (!t) goto failed; ... t->table = (struct pchash_entry *)calloc(size, sizeof(struct pchash_entry)); if (!t->table) { goto failed_table; } ... if (do_other_initialization(t)) { goto failed_init; } return t; failed_init: free(t->table); failed_table: free(t); failed: return NULL; }
以上写法带来的好处是,调用free()
函数时不再需要作额外的判断。
在实践中,我们还可能遇到一种写法,就是在进行错误处理时避免使用有争议的goto
语句,例如:
struct pchash_table *pchash_table_new(...) { struct pchash_table *t = NULL; do { t = (struct pchash_table *)calloc(1, sizeof(struct pchash_table)); if (!t) break; ... t->table = (struct pchash_entry *)calloc(size, sizeof(struct pchash_entry)); if (!t->table) { break; } ... if (do_other_initialization(t)) { break; } return t; } while (0); if (t) { if (t->table) free(t->table); free(t); } return NULL; }
本质上,上述写法利用了do - while (0)
单次循环,因为我们可以使用break
语句跳出这一循环,从而避免goto
语句的使用。
但笔者并不建议使用这种写法,原因有二。
(1)大部分人看到do
语句的第一反应是循环。在看到while (0)
语句之前,很少有人会想到这段代码本质上不是循环,从而影响代码的可读性。
(2)这种写法额外增加了一次不必要的缩进。这一方面会让代码从感官上变得更为复杂,另一方面则会出现因为坚守“80列”这条红线而不得不绕行的情形。
需要说明的是,在定义宏时,我们经常使用do - while (0)
单次循环,尤其是当一个宏由多条语句组成时:
#define FOO(x) \ do { \ if (a == 5) \ do_this(b, c); \ } while (0)
很多细致的程序员会在每个函数的入口处检查所有传入参数的合法性,尤其是指针。比如,下面的函数会销毁一个映射表:
int pcutils_map_destroy(pcutils_map* map) { if (map == NULL) return -1; pcutils_map_clear(map); free(map); return 0; }
该函数首先判断传入的参数map
是否为空指针。可以预期,传入该函数的参数map
是由名为pcutils_map_create()
的函数返回的。作为创建对象的函数接口,一般返回空值(NULL
指针)表示失败,返回非空值则表示成功;如果pcutils_map_create()
函数返回空值,则不用再调用pcutils_map_destroy()
函数。换句话说,在调用pcutils_map_destroy()
函数时,除非误用,否则不会给这个函数传递一个空值。
因此,这种判断貌似有必要,但仔细考虑后就会发现意义不大。在上面的代码中,程序将NULL
作为非法值做了特别处理,但如果传入的指针值为1或者−1,它们显然也是非法值,那为何不对这两种情况做判断并返回对应的错误值呢?更进一步地,如何判断一个尚未分配的地址值呢?
实质上,C语言并没有提供任何能够判断一个指针的值是否合法的语言级能力或者机制。我们所知道的不合法的指针值通常就是0、−1,以及特定情况下和当前处理器的位宽不对齐的整数值。比如在32位系统中,对于指向32位整数的指针来讲,任何不能被4整除的指针值大概率是非法的。除此之外,我们没有其他有效的手段来判断一个指针值的合法性。因此,这类参数的有效性检查其实是多余的。
再者,在频繁调用的函数中执行此类不必要的参数有效性检查,会大大降低程序的执行效率。
因此,上述代码的最佳实现应该如下:
void pcutils_map_destroy(pcutils_map* map) { pcutils_map_clear(map); free(map); }
我们没有必要仅针对空值做参数的有效性检查。一方面,这种检查并不能覆盖所有的情形;另一方面,如果我们仅仅需要检查空值这种情形,那么程序会很快因为访问空指针而出错。后一种情况说明调用者误传了参数,在程序的开发阶段,借助调试器,我们可以迅速定位缺陷所在。
但在某些情况下,我们仍然希望在调用这类函数时,对传入的常见非法值NULL
做一些特殊处理,以便可以及时发现调用者的问题。为此,我们可以使用assert()
。assert()
本质上是一个宏,而非函数,而且这个宏的行为依赖于NDEBUG
宏。assert()
通常的定义如下:
#ifdef NDEBUG # define assert(exp) \ do { \ } while (0) #else /* defined NDEBUG */ # define assert(exp) \ do { \ if (!(exp)) \ abort(); \ } while (0) #endif /* not defined NDEBUG */
在上面的代码中,NDEBUG
是一个约定俗成的全局宏,通常由构建系统定义。当NDEBUG
宏被定义时,意味着程序将被构建为发布版本,assert()
不做任何事情;反之,当程序被构建为调试版本时,assert()
将判断表达式exp
的真假,若为假,则调用abort()
函数终止程序的运行。
如此一来,我们可以将上述代码进一步修改为如下形式:
#include <assert.h> void pcutils_map_destroy(pcutils_map* map) { assert(map != NULL); pcutils_map_clear(map); free(map); }
此外,还有一种针对参数的合法性检查,或者说针对常规条件分支的优化方法,常见于一些优秀的C语言开源项目中。程序清单1.4列出了glib(Linux系统常用的C工具函数库,在一些场景中也可写作GLib)中用于快速验证UTF-8编码有效性的函数。
程序清单1.4 使用UNLIKELY
宏优化条件分支
#define VALIDATE_BYTE(mask, expect) \ do { \ if (UNLIKELY((*(uint8_t *)p & (mask)) != (expect))) \ goto error; \ } while (0) /* see IETF RFC 3629 Section 4 */ static const char * fast_validate(const char *str) { size_t n = 0; const char *p; for (p = str; *p; p++) { if (*(uint8_t *)p < 128) { n++; } else { const char *last; last = p; if (*(uint8_t *)p < 0xe0) { /* 110xxxxx */ if (UNLIKELY (*(uint8_t *)p < 0xc2)) goto error; } else { if (*(uint8_t *)p < 0xf0) { /* 1110xxxx */ switch (*(uint8_t *)p++ & 0x0f) { ... } } else if (*(uint8_t *)p < 0xf5) { /* 11110xxx excluding out-of-range */ switch (*(uint8_t *)p++ & 0x07) { ... } p++; VALIDATE_BYTE(0xc0, 0x80); /* 10xxxxxx */ } else goto error; } p++; VALIDATE_BYTE(0xc0, 0x80); /* 10xxxxxx */ n++; continue; error: return last; } } return p; }
上述代码多次使用了 UNLIKELY
宏,用于判断一些不太可能出现在正常UTF-8编码中的字符。这个宏以及成对定义的 LIKELY
宏利用了现代编译器的一些特性,它们可以告诉编译器一个分支判断的结果为真或者为假的可能性是大还是小。利用这两个宏,我们可以协助编译器充分利用处理器的分支预测能力,提高编译后代码的执行效率。
因此,如果非要检查传入参数的有效性,我们可以利用UNLIKELY
宏,对旨在销毁映射表的代码作如下优化:
int pcutils_map_destroy(pcutils_map* map) { if (UNLIKELY(map == NULL)) return -1; pcutils_map_clear(map); free(map); return 0; }
这样编译器就会认为出现map == NULL
这一条件的可能性较低,从而在生成最终的机器指令时,通过适当的优化,将可能性较低的条件判断对性能的影响降到最小。
注意,LIKELY
和UNLIKELY
宏是非标准宏,目前仅GCC或兼容GCC的编译器支持。这两个宏通常定义如下:
/* LIKELY */ #if !defined(LIKELY) && defined(__GNUC__) #define LIKELY(x) __builtin_expect(!!(x), 1) #endif #if !defined(LIKELY) #define LIKELY(x) (x) #endif /* UNLIKELY */ #if !defined(UNLIKELY) && defined(__GNUC__) #define UNLIKELY(x) __builtin_expect(!!(x), 0) #endif #if !defined(UNLIKELY) #define UNLIKELY(x) (x) #endif
其中使用了__builtin_expect
这一GCC特有的优化指令。
在给代码中的变量和函数取名时,要尽量使用正确的英文术语及其简写。
但需要注意的是,不同的领域有不同的术语使用习惯。比如,当我们的代码涉及树形数据结构时,就会经常使用如下英文术语。
● node
:表示树形数据结构中的一个节点;不要用item
或element
表示节点,这两个词的准确含义为条目
和元素
。
● child
:表示子节点。
● sibling
:表示兄弟节点,不要用brother
表示兄弟节点。
● parent
、root
:分别表示父节点和根节点,不要使用father
或者mother
等表示上一级节点。
● first
、last
:分别表示头节点和尾节点。
● next
、previous
(或prev
):分别表示下一节点和前一节点。
● ascendant
、descendants
:分别表示祖先节点或子孙节点。
再比如,当我们的代码涉及链表(linked list)这种数据结构时,则会经常使用如下英文术语。
● node
:表示链表中的一个节点。
● head
、tail
:分别表示链表的头节点和尾节点。
● next
、previous
(或prev
):分别表示下一节点和前一节点。
针对相应的领域使用正确的英文术语并基于这些英文术语来命名,可以避免很多误解,这是提升代码可读性的一种重要手段。需要特别提醒的是,在有合适英文术语的情形下,要避免使用一些“万金油”名称,如item
、data
等含义广泛的名称。
为变量和函数取名时,还要考虑正确的时态和单复数形式。如前所述,表示链表的变量应该取名为linked_list
而不是link_list
。又如,双链表可以用dbl_linked_list
表示。表示单个元素的变量用单数形式,如node
表示一个节点、child
表示一个子节点。表示多个元素的变量则用复数形式,如nodes
表示节点数组、children
表示子节点数组等。
在代码中,缩写是十分常见的。采用约定俗成的缩写不仅可以提高代码的输入效率、节省版面空间,而且完全不会影响代码的可读性。但是,要尽量采用约定俗成的缩写,不要自创,更不要使用汉字拼音首字母缩写的名称。C代码中经常使用的一些缩写如表1.1所示。
表1.1 C代码中经常使用的一些缩写
缩写 |
完整单词 |
含义 |
---|---|---|
|
number |
数量 |
|
previous |
前一个 |
|
size |
大小、尺寸 |
|
temporary |
临时 |
|
double |
双 |
|
parameter |
形式参数(简称形参) |
|
triple |
三 |
|
argument |
实际参数(简称实参) |
|
length |
长度 |
|
argument count |
参数数量 |
|
maximum |
最大值 |
|
argument vector |
参数列表 |
|
minimum |
最小值 |
|
connection |
连接 |
|
buffer |
缓冲区 |
|
context |
上下文 |
|
version |
版本 |
|
error |
错误 |
|
identity |
身份标识 |
为局部变量命名时,应该尽量采用简洁的名称。例如,i
、j
、k
一般并不推荐作为变量的名称,但在for
或while
循环中,将这3个字母作为变量的名称非常方便,不会有任何歧义;而且循环变量一般仅限于这3个字母,如果一个循环中还需要更多的循环变量,则说明程序的结构存在问题,需要进行重构。
在局部代码块中,还可以用n
表示数量,用len
表示长度,用sz
表示大小或尺寸,用p
表示指针,用tmp
表示临时变量,用buf
表示临时缓冲区。这些都是约定俗成的简洁名称,不会影响代码的可读性。
对于函数或全局变量的命名,应采用下面的约定。
(1)函数库接口:<type> <lib prefix>_<short phrase>(...)
。例如:
void mylib_init_linked_list(struct linked_list *p);
(2)文件内:static <type><short phrase>(...)
。例如:
static void init_liked_list(struct linked_list *p);
(3)模块间:<type>_<module_prefix>_<short phrase>(...)
。例如:
void _mymodule_init_liked_list(struct linked_list *p);
此外,如果使用的是GCC这样的编译器,那么可以在声明模块间接口时添加属性,例如:
void _mymodule_init_liked_list(struct linked_list *p) __attribute__((visibility("hidden")));
这里的visibility
(可见性)属性取值为hidden
,表示这个符号(即函数名)在形成函数库后对外不可见,从而可以有效地防止命名污染。
下面通过查看笔者维护的开源HVML解释器PurC的purc.h
头文件中的部分内容,来对本章所述的优良编码风格做进一步的解释。
首先是一段Doxygen格式的注释,其中包含了文件名、作者、日期、简介、版权声明等信息。注意,Doxygen格式的注释一般以/**
开始。
/** * @file purc.h * @author Vincent Wei * @date 2021/07/02 * @brief The main header file of PurC. * * Copyright (C) 2021, 2022 FMSoft * * This file is a part of PurC (short for Purring Cat), an HVML interpreter. * Licensed under LGPLv3+. */
之后,作为PurC函数库的主头文件,其中包含了其他必要的系统头文件以及PurC函数库的其他头文件。然后是一个结构体的定义,该结构体被定义为一个新的数据类型,同时使用Doxygen格式的注释来描述其成员的含义。
/** * purc_instance_extra_info: * * The structure defines the extra information for a new PurC instance. */ typedef struct purc_instance_extra_info { /** * ... */ purc_rdrcomm_k renderer_comm; /** * ... */ const char *renderer_uri; /** The SSL certification if using Secured WebSocket. */ const char *ssl_cert; /** The SSL key if using Secured WebSocket. */ const char *ssl_key; /** The default workspace of this instance. */ const char *workspace_name; /** The title of the workspace. */ const char *workspace_title; /** * ... */ const char *workspace_layout; } purc_instance_extra_info;
我们可以清晰地看到,PurC函数库的接口使用了K&R命名法。注意上述结构体中的purc_rdrcomm_k renderer_comm
成员,从类型名使用_k
后缀可以看出,该成员是一个枚举量,对应的枚举类型则定义在另一个头文件(purc-pcrdr.h
)中:
/* Renderer communication types */ typedef enum { PURC_RDRCOMM_HEADLESS = 0, #define PURC_RDRCOMM_NAME_HEADLESS "HEADLESS" PURC_RDRCOMM_THREAD, #define PURC_RDRCOMM_NAME_THREAD "THREAD" PURC_RDRCOMM_SOCKET, #define PURC_RDRCOMM_NAME_SOCKET "SOCKET" PURC_RDRCOMM_HIBUS, #define PURC_RDRCOMM_NAME_HIBUS "HIBUS" } purc_rdrcomm_k;
回到purc.h
头文件。再往下是宏定义:
#define PURC_HAVE_UTILS 0x0001 #define PURC_HAVE_DOM 0x0002 #define PURC_HAVE_HTML 0x0004 #define PURC_HAVE_XML 0x0008 #define PURC_HAVE_VARIANT 0x0010 #define PURC_HAVE_EJSON 0x0020 #define PURC_HAVE_FETCHER 0x0200 #define PURC_HAVE_FETCHER_R 0x0400 #define PURC_HAVE_ALL ( \ PURC_HAVE_UTILS | \ PURC_HAVE_DOM | \ PURC_HAVE_HTML | \ PURC_HAVE_XML | \ PURC_HAVE_VARIANT | \ PURC_HAVE_EJSON | \ PURC_HAVE_FETCHER | \ PURC_HAVE_FETCHER_R) #define PURC_MODULE_UTILS (PURC_HAVE_UTILS) #define PURC_MODULE_DOM (PURC_MODULE_UTILS | PURC_HAVE_DOM) #define PURC_MODULE_HTML (PURC_MODULE_DOM | PURC_HAVE_HTML) #define PURC_MODULE_XML (PURC_MODULE_DOM | PURC_HAVE_XML) #define PURC_MODULE_VARIANT (PURC_MODULE_UTILS | PURC_HAVE_VARIANT) #define PURC_MODULE_EJSON (PURC_MODULE_VARIANT | PURC_HAVE_EJSON) #define PURC_MODULE_ALL 0xFFFF
所有的宏都采用下画线连接的全大写单词形式,并使用PURC_
作为前缀;代码行严守了“80列”这条红线,并通过空行、续行和适当的缩进,让排版整齐,使得整个代码清晰易读。
下面查看其中的3个函数接口:前两个用于初始化一个PurC实例,第三个用于清理一个PurC实例。
PCA_EXPORT int purc_init_ex(unsigned int modules, const char *app_name, const char *runner_name, const purc_instance_extra_info *extra_info); static inline int purc_init(const char *app_name, const char *runner_name, const purc_instance_extra_info *extra_info) { return purc_init_ex(PURC_MODULE_ALL, app_name, runner_name, extra_info); } PCA_EXPORT bool purc_cleanup(void);
这3个函数的原型定义符合本章所讨论的编码风格。比如,purc_init_ex()
和purc_init()
两个函数均具有purc_
前缀,表示指针的星号出现在形参名称的前面;为严守“80列”这条红线,函数原型声明中的参数列表被分行书写;等等。作为PurC函数库的公开接口,原头文件中包含了对以上函数的接口描述。
注意 purc_init()
函数被定义为调用 purc_init_ex()
函数的内联函数,因而使用 static
inline
关键词来修饰其原型。而_ex
后缀通常用于表示扩展(extended);也就是说,purc_init_ex()
函数是purc_init()
函数的扩展版本。
list_head
结构体及其接口在PurC的实现代码中,我们使用了很多经典的开源C代码片段。比如本节提到的list_head
结构体及其接口。
list_head
结构体定义了实现一个双向链表所需的两个指针:
struct list_head { struct list_head *next; struct list_head *prev; };
在使用时,我们只需要将该结构体嵌入表示节点内容的结构体即可。比如,下面的结构体定义了一个待处理的请求,我们可通过其中的list
成员,将多个待处理的请求组织成一个双向链表:
struct pending_request { struct list_head list; purc_variant_t request_id; pcrdr_response_handler response_handler; void *context; time_t time_expected; };
然后便可通过与list_head
结构体相关的接口来操控这个双向链表,而不需要再针对pending_
request
结构体设计新的链表操控接口。有关list_head
结构体及其接口的设计和使用,我们将在第7章中详细讲述。现在,我们观察list_head
结构体,其定义具有良好的编码风格:结构体和指针变量的名称采用K&R命名法,结构体成员的声明采用4个空格的缩进,表示指针的星号位于变量名之前,并且list_head
和{
之间的空格也符合规范。
读者可以继续欣赏下面几个围绕list_head
结构体的接口之定义和实现:
static inline void init_list_head(struct list_head *list) { list->next = list->prev = list; } static inline bool list_empty(const struct list_head *head) { return head->next == head; } static inline bool list_is_first(const struct list_head *list, const struct list_head *head) { return list->prev == head; } static inline bool list_is_last(const struct list_head *list, const struct list_head *head) { return list->next == head; }
注意,我们对list_is_first()
和list_is_last()
函数的参数列表做了分行处理,以坚守“80列”这条红线。
观察下面的两个函数:
static inline void _list_del(struct list_head *entry) { entry->next->prev = entry–>prev; entry->prev->next = entry->next; } static inline void list_del(struct list_head *entry) { _list_del(entry); entry->next = entry->prev = NULL; }
显然,list_del()
函数调用了_list_del()
函数;_list_del()
函数名的下画线前缀表示这个函数并不是公开的接口,不应该由用户直接调用,list_del()
函数才是用于删除链表节点的公开接口。这展示了针对某些函数名和变量名适当使用下画线作为前缀的意义——通常用来表明这些变量或者函数仅供内部使用。
下面的_list_add()
函数和_list_del()
函数一样,也是内部函数:
static inline void _list_add(struct list_head *_new, struct list_head *prev, struct list_head *next) { next->prev = _new; _new->next = next; _new->prev = prev; prev->next = _new; }
下面的两个函数才是用于添加节点到双向链表中的公开接口,它们都在内部调用了_list_add()
函数。注意,这两个函数的声明中都包含了inline
关键字,表示它们都是内联函数,会在原地编译展开,且不会产生函数调用的开销,从而可以提高代码的执行效率。
static inline void list_add(struct list_head *_new, struct list_head *head) { _list_add(_new, head, head->next); } static inline void list_add_tail(struct list_head *_new, struct list_head *head) { _list_add(_new, head->prev, head); }
可以发现,上面这些函数即使没有注释也很容易看懂,比如list_add()
和list_add_tail()
函数,前者将一个节点添加到链表的头部,而后者将一个节点添加到链表的尾部。
list_head
结构体及其接口可用于任何需要双向链表的场景,而且这些接口的执行效率很高,代码也非常简洁,几乎不需要任何修改和维护。显然,只要C语言存在,这样的代码就不会过时。可以说,这样的代码满足本章最初所说的“典范级代码”的标准。