标签 宏 下的文章

Go程序员拥抱C语言简明指南

本文永久链接 – https://tonybai.com/2022/05/16/the-short-guide-of-embracing-c-lang-for-gopher

本文是为于航老师的极客时间专栏《深入C语言和程序运行原理》写的加餐文章《Tony Bai:Go程序员拥抱C语言简明指南》,这里分享给大家,尤其是那些想学习C语言的Gopher们。


你好,我是Tony Bai。

也许有同学对我比较熟悉,看过我在极客时间上的专栏《Tony Bai ·Go语言第一课》,或者是关注了我的博客。那么,作为一个Gopher,我怎么跑到这个C语言专栏做分享了呢?其实,在学习Go语言并成为一名Go程序员之前,我也曾是一名地地道道的C语言程序员。

大学毕业后,我就开始从事C语言后端服务开发工作,在电信增值领域摸爬滚打了十多年。不信的话,你可以去翻翻我的博客,数一数我发的C语言相关文章是不是比关于Go的还多。一直到近几年,我才将工作中的主力语言从C切换到了Go。不过这并不是C语言的问题,主要原因是我转换赛道了。我目前在智能网联汽车领域从事面向云原生平台的先行研发,而在云原生方面,新生代的Go语言有着更好的生态。

不过作为资深C程序员,C语言已经在我身上打下了深深的烙印。虽然Go是我现在工作中的主力语言,但我仍然会每天阅读一些C开源项目的源码,每周还会写下数百行的C代码。在一些工作场景中,特别是在我参与先行研发一些车端中间件时,C语言有着资源占用小、性能高的优势,这一点是Go目前还无法匹敌的。

正因为我有着C程序员和Go程序员的双重身份,接到这个加餐邀请时,我就想到了一个很适合聊的话题——在 Gopher(泛指Go程序员)与C语言之间“牵线搭桥”。在这门课的评论区里,我看到一些同学说,“正是因为学了Go,所以我想学好C”。如果你也对Go比较熟悉,那么恭喜你,这篇加餐简直是为你量身定制的:一个熟悉Go的程序员在学习C时需要注意的问题,还有可能会遇到的坑,我都替你总结好了。

当然,我知道还有一些对Go了解不多的同学,看到这里也别急着退出去。因为C和Go这两门语言的比较,本身就是一个很有意思的话题。今天的加餐,会涉及这两门语言的异同点,通过对C与Go语言特性的比较,你就能更好地理解“C 语言为什么设计成现在这样”。

一. C语言是现代IT工业的根基

在比较C和Go之前,先说说我推荐Gopher学C的最重要原因吧:用一句话总结,C语言在IT工业中的根基地位,是Go和其他语言目前都无法动摇的

C语言是由美国贝尔实验室的丹尼斯·里奇(Dennis Ritchie)以Unix发明人肯·汤普森(Ken Thompson)设计的B语言为基础而创建的高级编程语言。诞生于上个世纪(精确来说是1972年)的它,到今年(2022年)已到了“知天命”的半百年纪。 年纪大、设计久远一直是“C语言过时论”兴起的根源,但如果你相信这一论断,那就大错特错了。下面,我来为你分析下个中缘由。

首先,我们说说C语言本身:C语言一直在演进,从未停下过脚步

虽然C语言之父丹尼斯·里奇不幸于2011年永远地离开了我们,但C语言早已成为ANSI(美国国家标准学会)标准以及ISO/IEC(国际标准化组织和国际电工委员会)标准,因此其演进也早已由标准委员会负责。我们来简单回顾一下C语言标准的演进过程:

  • 1989年,ANSI发布了首个C语言标准,被称为C89,又称ANSI C。次年,ISO和IEC把ANSI C89标准定为C语言的国际标准(ISO/IEC 9899:1990),又称C90,它也是C语言的第一个官方版本;
  • 1999年,ISO和IEC发布了C99标准(ISO/IEC 9899:1999),它是C语言的第二个官方版本;
  • 2011年,ISO和IEC发布了C11标准(ISO/IEC 9899:2011),它是C语言的第三个官方版本;
  • 2018年,ISO和IEC发布了C18标准(ISO/IEC 9899:2018),它是C语言的第四个官方版本。
    目前,ISO/IEC标准化委员会正在致力于C2x标准的改进与制定,预计它会在2023年发布。

其次,时至今日,C语言的流行度仍然非常高

著名编程语言排行榜TIOBE的数据显示,各大编程语言年度平均排名的总位次,C语言多年来高居第一,如下图(图片来自TIOBE)所示:

这说明,无论是在过去还是现在,C语言都是一门被广泛应用的工业级编程语言。

最后,也是最重要的一点是:C语言是现代IT工业的根基,我们说C永远不会退出IT行业舞台也不为过。

如今,无论是普通消费者端的Windows、macOS、Android、苹果iOS,还是服务器端的Linux、Unix等操作系统,亦或是各个工业嵌入式领域的操作系统,其内核实现语言都是C语言。互联网时代所使用的主流Web服务器,比如 Nginx、Apache,以及主流数据库,比如MySQL、Oracle、PostgreSQL等,也都是使用C语言开发的杰作。可以说,现代人类每天都在跟由C语言实现的系统亲密接触,并且已经离不开这些系统了。回到我们程序员的日常,Git、SVN等我们时刻在用的源码版本控制软件也都是由C语言实现的。

可以说,C语言在IT工业中的根基地位,不光Go语言替代不了,C++、Rust等系统编程语言也无法动摇,而且不仅短期如此,长期来看也是如此。

总之,C语言具有紧凑、高效、移植性好、对内存的精细控制等优秀特性,这使得我们在任何时候学习它都不会过时。不过,我在这里推荐Gopher去了解和系统学习C语言,其实还有另一个原因。我们继续往下看。

二. C与Go的相通之处:Gopher拥抱C语言的“先天优势”

众所周知,Go 是在C语言的基础上衍生而来的,二者之间有很多相通之处,因此 Gopher 在学习C语言时是有“先天优势”的。接下来,我们具体看看C和Go的相通之处有哪些。

1. 简单且语法同源

Go语言以简单著称,而作为Go先祖的C语言,入门门槛同样不高:Go有25个关键字,C有32个关键字(C89标准),简洁程度在伯仲之间。C语言曾长期作为高校计算机编程教育的首选编程语言,这与C的简单也不无关系。

和Go不同的是,C语言是一个小内核、大外延的编程语言,其简单主要体现在小内核上了。这个“小内核”包括C基本语法与其标准库,我们可以快速掌握它。但需要注意的是,与Go语言“开箱即用、内容丰富”的标准库不同,C标准库非常小(在C11标准之前甚至连thread库都不包含),所以掌握“小内核”后,在LeetCode平台上刷题是没有任何问题的,但要写出某一领域的工业级生产程序,我们还有很多外延知识技能要学习,比如并发原语、操作系统的系统调用,以及进程间通信等。

C语言的这种简单很容易获得Gopher们的认同感。当年Go语言之父们在设计Go语言时,也是主要借鉴了C语言的语法。当然,这与他们深厚的C语言背景不无关系:肯·汤普森(Ken Thompson)是Unix之父,与丹尼斯·里奇共同设计了C语言;罗博·派克(Rob Pike)是贝尔实验室的资深研究员,参与了Unix系统的演进、Plan9操作系统的开发,还是UTF-8编码的发明人;罗伯特·格瑞史莫(Robert Griesemer)也是用C语言手写Java虚拟机的大神级人物。

Go的第一版编译器就是由肯·汤普森(Ken Thompson)用C语言实现的。并且,Go语言的早期版本中,C代码的比例还不小。以Go语言发布的第一个版本,Go 1.0版本为例,我们通过loccount工具对其进行分析,会得到下面的结果:

$loccount .
all          SLOC=460992  (100.00%) LLOC=193045  in 2746 files
Go           SLOC=256321  (55.60%)  LLOC=109763  in 1983 files
C            SLOC=148001  (32.10%)  LLOC=73458   in 368 files
HTML         SLOC=25080   (5.44%)   LLOC=0       in 57 files
asm          SLOC=10109   (2.19%)   LLOC=0       in 133 files
... ...

这里我们看到,在1.0版本中,C语言代码行数占据了32.10%的份额,这一份额直至Go 1.5版本实现自举后,才下降为不到1%。

我当初对Go“一见钟情”,其中一个主要原因就是Go与C语言的语法同源。相对应地,相信这种同源的语法也会让Gopher们喜欢上C语言。

2. 静态编译且基础范式相同

除了语法同源,C语言与Go语言的另一个相同点是,它们都是静态编译型语言。这意味着它们都有如下的语法特性:

  • 变量与函数都要先声明后才能使用;
  • 所有分配的内存块都要有对应的类型信息,并且在确定其类型信息后才能操作;
  • 源码需要先编译链接后才能运行。

相似的编程逻辑与构建过程,让学习C语言的Gopher可以做到无缝衔接。

除此之外,Go 和C的基础编程范式都是命令式编程(imperative programming),即面向算法过程,由程序员通过编程告诉计算机应采取的动作。然后,计算机按程序指令执行一系列流程,生成特定的结果,就像菜谱指定了厨师做蛋糕时应遵循的一系列步骤一样。

从Go看 C,没有面向对象,没有函数式编程,没有泛型(Go 1.18已加入),满眼都是类型与函数,可以说是相当亲切了。

3. 错误处理机制如出一辙

对于后端编程语言来说,错误处理机制十分重要。如果两种语言的错误处理机制不同,那么这两种语言的代码整体语法风格很可能大不相同。

在C语言中,我们通常用一个类型为整型的函数返回值作为错误状态标识,函数调用者基于值比较的方式,对这一代表错误状态的返回值进行检视。通常,当这个返回值为0时,代表函数调用成功;当这个返回值为其他值时,代表函数调用出现错误。函数调用者需根据该返回值所代表的错误状态,来决定后续执行哪条错误处理路径上的代码。

C语言这种简单的基于错误值比较的错误处理机制,让每个开发人员必须显式地去关注和处理每个错误。经过显式错误处理的代码会更为健壮,也会让开发人员对这些代码更有信心。另外,这些错误就是普通的值,我们不需要额外的语言机制去处理它们,只需利用已有的语言机制,像处理其他普通类型值那样去处理错误就可以了。这让代码更容易调试,我们也更容易针对每个错误处理的决策分支进行测试覆盖。

C语言错误处理机制的这种简单与显式,跟Go语言的设计哲学十分契合,于是Go语言设计者决定继承这种错误处理机制。因此,当Gopher们来到C语言的世界时,无需对自己的错误处理思维做出很大的改变,就可以很容易地适应C语言的风格。

三. 知己知彼,来看看C与Go的差异

虽说 Gopher 学习C语言有“先天优势”,但是不经过脚踏实地的学习与实践就想掌握和精通C语言,也是不可能的。而且,C 和Go还是有很大差异的,Gopher 们只有清楚这些差异,做到“知己知彼”,才能在学习过程中分清轻重,有的放矢。俗话说,“磨刀不误砍柴功”,下面我们就一起看看C与Go有哪些不同。

1. 设计哲学

在人类自然语言学界,有一个很著名的假说——“萨丕尔-沃夫假说”。这个假说的内容是这样的:语言影响或决定人类的思维方式。对我来说,编程语言也不仅仅是一门工具,它还影响着程序员的思维方式。每次开始学习一门新的编程语言时,我都会先了解这门编程语言的设计哲学。

每种编程语言都有自己的设计哲学,即便这门语言的设计者没有将其显式地总结出来,它也真真切切地存在,并影响着这门语言的后续演进,以及这门语言程序员的思维方式。我在《Tony Bai · Go语言第一课》专栏里,将Go语言的设计哲学总结成了5点,分别是简单、显式、组合、并发和面向工程

那么C语言的设计哲学又是什么呢?从表面上看,简单紧凑、性能至上、极致资源、全面移植,这些都可以作为C的设计哲学,但我倾向于一种更有人文气息的说法:满足和相信程序员

在这样的设计哲学下,一方面,C语言提供了几乎所有可以帮助程序员表达自己意图的语法手段,比如宏、指针与指针运算、位操作、pragma指示符、goto语句,以及跳转能力更为强大的longjmp等;另一方面,C语言对程序员的行为并没有做特别严格的限定与约束,C程序员可以利用语言提供的这些语法手段,进行天马行空的发挥:访问硬件、利用指针访问内存中的任一字节、操控任意字节中的每个位(bit)等。总之,C语言假定程序员知道他们在做什么,并选择相信程序员。

C语言给了程序员足够的自由,可以说,在C语言世界,你几乎可以“为所欲为”。但这种哲学也是有代价的,那就是你可能会犯一些莫名其妙的错误,比如悬挂指针,而这些错误很少或不可能在其他语言中出现。

这里再用一个比喻来更为形象地表达下:从Go世界到C世界,就好比在动物园中饲养已久的动物被放归到野生自然保护区,有了更多自由,但周围也暗藏着很多未曾遇到过的危险。因此,学习C语言的Gopher们要有足够的心理准备。

2. 内存管理

接下来我们来看C与Go在内存管理方面的不同。我把这一点放在第二位,是因为这两种语言在内存管理上有很大的差异,而且这一差异会给程序员的日常编码带来巨大影响。

我们知道,Go是带有垃圾回收机制(俗称GC)的静态编程语言。使用Go编程时,内存申请与释放,在栈上还是在堆上分配,以及新内存块的清零等等,这一切都是自动的,且对程序员透明。

但在C语言中,上面说的这些都是程序员的责任。手工内存管理在带来灵活性的同时,也带来了极大的风险,其中最常见的就是内存泄露(memory leak)与悬挂指针(dangling pointer)问题。

内存泄露主要指的是程序员手工在堆上分配的内存在使用后没有被释放(free),进而导致的堆内存持续增加。而悬挂指针的意思是指针指向了非法的内存地址,未初始化的指针、指针所指对象已经被释放等,都是导致悬挂指针的主要原因。针对悬挂指针进行解引用(dereference)操作将会导致运行时错误,从而导致程序异常退出的严重后果。

Go语言带有GC,而C语言不带GC,这都是由各自语言设计哲学所决定的。GC是不符合C语言的设计哲学的,因为一旦有了GC,程序员就远离了机器,程序员直面机器的需求就无法得到满足了。并且,一旦有了GC,无论是在性能上还是在资源占用上,都不可能做到极致了。

在C中,手工管理内存到底是一种什么感觉呢?作为一名有着十多年C开发经验的资深C程序员,我只能告诉你:与内存斗,其乐无穷!这是在带GC的编程语言中无法体会到的。

3. 语法形式

虽然C语言是Go的先祖,并且Go也继承了很多C语言的语法元素,但在变量/函数声明、行尾分号、代码块是否用括号括起、标识符作用域,以及控制语句语义等方面,二者仍有较大差异。因此,对Go已经很熟悉的程序员在初学C时,受之前编码习惯的影响,往往会踩一些“坑”。基于此,我总结了Gopher学习C语言时需要特别注意的几点,接下来我们具体看看。

第一,注意声明变量时类型与变量名的顺序

前面说过,Go与C都是静态编译型语言,这就要求我们在使用任何变量之前,需要先声明这个变量。但Go采用的变量声明语法颇似Pascal语言,即变量名在前,变量类型在后,这与C语言恰好相反,如下所示:

Go:

var a, b int
var p, q *int

vs.

C:
int a, b;
int *p, *q;

此外,Go支持短变量声明,并且由于短变量声明更短小,无需显式提供变量类型,Go编译器会根据赋值操作符后面的初始化表达式的结果,自动为变量赋予适当类型。因此,它成为了Gopher们喜爱和重度使用的语法。但短声明在C中却不是合法的语法元素:

int main() {
    a := 5; //  error: expected expression
    printf("a = %d\n", a);
}

不过,和上面的变量类型与变量名声明的顺序问题一样,C编译器会发现并告知我们这个问题,并不会给程序带来实质性的伤害。

第二,注意函数声明无需关键字前缀

无论是C语言还是Go语言,函数都是基本功能逻辑单元,我们也可以说C程序就是一组函数的集合。实际上,我们日常的C代码编写大多集中在实现某个函数上。

和变量一样,函数在两种语言中都需要先声明才能使用。Go语言使用func关键字作为函数声明的前缀,并且函数返回值列表放在函数声明的最后。但在C语言中,函数声明无需任何关键字作为前缀,函数只支持单一返回值,并且返回值类型放在函数名的前面,如下所示:

Go:
func Add(a, b int) int {
    return a+b
}

vs.

C:
int Add(int a, int b) {
    return a+b;
}

第三,记得加上代码行结尾的分号

我们日常编写Go代码时,极少手写分号。这是因为,Go设计者当初为了简化代码编写,提高代码可读性,选择了由编译器在词法分析阶段自动在适当位置插入分号的技术路线。如果你是一个被Go编译器惯坏了的Gopher,来到C语言的世界后,一定不要忘记代码行尾的分号。比如上面例子中的C语言Add函数实现,在return语句后面记得要手动加上分号。

第四,补上“省略”的括号

同样是出于简化代码、增加可读性的考虑,Go设计者最初就取消掉了条件分支语句(if)、选择分支语句(switch)和循环控制语句(for)中条件表达式外围的小括号:

// Go代码
func f() int {
    return 5
}
func main() {
    a := 1
    if a == 1 { // 无需小括号包裹条件表达式
        fmt.Println(a)
    }

    switch b := f(); b { // 无需小括号包裹条件表达式
    case 4:
        fmt.Println("b = 4")
    case 5:
        fmt.Println("b = 5")
    default:
        fmt.Println("b = n/a")
    }

    for i := 1; i < 10; i++ { // 无需小括号包裹循环语句的循环表达式
        a += i
    }
    fmt.Println(a)
}

这一点恰恰与C语言“背道而驰”。因此,我们在使用C语言编写代码时,务必要想着补上这些括号:

// C代码
int f() {
        return 5;
}

int main() {
    int a = 1;
    if (a == 1) { // 需用小括号包裹条件表达式
        printf("%d\n", a);
    }

    int b = f();
    switch (b) { // 需用小括号包裹条件表达式
    case 4:
        printf("b = 4\n");
        break;
    case 5:
        printf("b = 5\n");
        break;
    default:
        printf("b = n/a\n");
    }

    int i = 0;
    for (i = 1; i < 10; i++) { // 需用小括号包裹循环语句的循环表达式
        a += i;
    }
    printf("%d\n", a);
}

第五,留意C与Go导出符号的不同机制

C语言通过头文件来声明对外可见的符号,所以我们不用管符号是不是首字母大写的。但在Go中,只有首字母大写的包级变量、常量、类型、函数、方法才是可导出的,即对外部包可见。反之,首字母小写的则为包私有的,仅在包内使用。Gopher一旦习惯了这样的规则,在切换到C语言时,就会产生“心理后遗症”:遇到在其他头文件中定义的首字母小写的函数时,总以为不能直接使用。

第六,记得在switch case语句中添加break

C 语言与Go语言在选择分支语句的语义方面有所不同:C语言的 case 语句中,如果没有显式加入break语句,那么代码将向下自动掉落执行。而Go在最初设计时就重新规定了switch case的语义,默认不自动掉落(fallthrough),除非开发者显式使用fallthrough关键字。

适应了Go的switch case语句的语义后再回来写C代码,就会存在潜在的“风险”。我们来看一个例子:

// C代码:
int main() {
    int a = 1;
    switch(a) {
        case 1:printf("a = 1\n");
        case 2:printf("a = 2\n");
        case 3:printf("a = 3\n");
        default:printf("a = ?\n");
    }
}

这段代码是按Go语义编写的switch case,编译运行后得到的结果如下:

a = 1
a = 2
a = 3
a = ?

这显然不符合我们输出“a = 1”的预期。对于初学C的Gopher而言,这个问题影响还是蛮大的,因为这样编写的代码在C编译器眼中是完全合法的,但所代表的语义却完全不是开发人员想要的。这样的程序一旦流入到生产环境,其缺陷可能会引发生产故障。

一些Clint 工具可以检测出这样的问题,因此对于写C代码的Gopher,我建议在提交代码前使用lint工具对代码做一下检查。

4. 构建机制

Go与C都是静态编译型语言,它们的源码需要经过编译器和链接器处理,这个过程称为构建(build),构建后得到的可执行文件才是最终交付给用户的成果物。

和Go语言略有不同的是,C语言的构建还有一个预处理(pre-processing)阶段,预处理环节的输出才是C编译器的真正输入。C语言中的宏就是在预处理阶段展开的。不过,Go没有预处理阶段。

C语言的编译单元是一个C源文件(.c),每个编译单元在编译过程中会对应生成一个目标文件(.o/.obj),最后链接器将这些目标文件链接在一起,形成可执行文件。

而Go则是以一个包(package)为编译单元的,每个包内的源文件生成一个.o文件,一个包的所有.o文件聚合(archive)成一个.a文件,链接器将这些目标文件链接在一起形成可执行文件。

Go语言提供了统一的Go命令行工具链,且Go编译器原生支持增量构建,源码构建过程不需要Gopher手工做什么配置。但在C语言的世界中,用于构建C程序的工具有很多,主流的包括gcc/clang,以及微软平台的C编译器。这些编译器原生不支持增量构建,为了提升工程级构建的效率,避免每次都进行全量构建,我们通常会使用第三方的构建管理工具,比如make(Makefile)或CMake。考虑移植性时,我们还会使用到configure文件,用于在目标机器上收集和设置编译器所需的环境信息。

5. 依赖管理

我在前面提过,C语言仅提供了一个“小内核”。像依赖管理这类的事情,C语言本身并没有提供跟Go中的Go Module类似的,统一且相对完善的解决方案。在C语言的世界中,我们依然要靠外部工具(比如CMake)来管理第三方的依赖。

C语言的第三方依赖通常以静态库(.a)或动态共享库(.so)的形式存在。如果你的应用要使用静态链接,那就必须在系统中为C编译器提供第三方依赖的静态库文件。但在实际工作中,完全采用静态链接有时是会遇到麻烦的。这是因为,很多操作系统在默认安装时是不带开发包的,也就是说,像 libc、libpthread 这样的系统库只提供了动态共享库版本(如/lib下提供了libc的共享库libc.so.6),其静态库版本是需要自行下载、编译和安装的(如libc的静态库libc.a在安装后是放在/usr/lib下面的)。所以多数情况下,我们是将****静态、动态****两种链接方式混合在一起使用的,比如像libc这样的系统库多采用动态链接。

动态共享库通常是有版本的,并且按照一定规则安装到系统中。举个例子,一个名为libfoo的动态共享库,在安装的目录下文件集合通常是这样:

2022-03-10 12:28 libfoo.so -> libfoo.so.0.0.0*
2022-03-10 12:28 libfoo.so.0 -> libfoo.so.0.0.0*
2022-03-10 12:28 libfoo.so.0.0.0*

按惯例,每个动态共享库都有多个名字属性,包括real name、soname和linker name。下面我们来分别看下。

  • real name:实际包含共享库代码的那个文件的名字(如上面例子中的libfoo.so.0.0.0)。动态共享库的真实版本信息就在real name中,显然real name中的版本号符合语义版本规范,即major.minor.patch。当两个版本的major号一致,说明是向后兼容的两个版本;
  • soname:shared object name的缩写,也是这三个名字中最重要的一个。无论是在编译阶段还是在运行阶段,系统链接器都是通过动态共享库的soname(如上面例子中的libfoo.so.0)来唯一识别共享库的。我们看到的soname实际上是仅包含major号的共享库名字;
  • linker name:编译阶段提供给编译器的名字(如上面例子中的libfoo.so)。如果你构建的共享库的real name跟上面例子中libfoo.so.0.0.0类似,带有版本号,那么你在编译器命令中直接使用-L path -lfoo是无法让链接器找到对应的共享库文件的,除非你为libfoo.so.0.0.0提供了一个linker name(如libfoo.so,一个指向libfoo.so.0.0.0的符号链接)。linker name一般在共享库安装时手工创建。
    动态共享库有了这三个名称属性,依赖管理就有了依据。但由于在链接的时候使用的是linker name,而linker name并不带有版本号,真实版本与主机环境有关,因此要实现C应用的可重现构建还是比较难。在实践中,我们通常会使用专门的构建主机,项目组将该主机上的依赖管理起来,进而保证每次构建所使用的依赖版本是可控的。同时,应用部署的目标主机上的依赖版本也应该得到管理,避免运行时出现动态共享库版本不匹配的问题。

6. 代码风格

Go语言是历史上首次实现了代码风格全社区统一的编程语言。它基本上消除了开发人员在代码风格上的无休止的、始终无法达成一致的争论,以及不同代码风格带来的阅读、维护他人代码时的低效。gofmt工具格式化出来的代码风格已经成为Go开发者的一种共识,融入到Go语言的开发文化当中了。所以,如果你让某个Go开发者说说gofmt后的代码风格是什么样的,多数Go开发者可能说不出,因为代码会被gofmt自动变成那种风格,大家已经不再关心风格了。

而在C语言的世界,代码风格仍存争议。但经过多年的演进,以及像Go这样新兴语言的不断“教育”,C社区也在尝试进行这方面的改进,涌现出了像clang-format这样的工具。目前,虽然还没有在全社区达成一致的代码风格(由于历史原因,这很难做到),但已经可以减少很多不必要的争论。

对于正在学习C语言,并进行C编码实践的Gopher,我的建议是:不要拘泥于使用什么代码风格,先用clang-format,并确定一套风格模板就好

四. 小结

作为一名对Go跟随和研究了近十年的程序员,我深刻体会到,Go的简单性、性能和生产力使它成为了创建面向用户的应用程序和服务的理想语言。快速的迭代让团队能够快速地作出反应,以满足用户不断变化的需求,让团队可以将更多精力集中在保持灵活性上。

但Go也有缺点,比如缺少对内存以及一些低级操作的精确控制,而C语言恰好可以弥补这个缺陷。C 语言提供的更精细的控制允许更多的精确性,使得C成为低级操作的理想语言。这些低级操作不太可能发生变化,并且C相比Go还提高了性能。所以,如果你是一个有性能与低级操作需求的 Gopher ,就有充分的理由来学习C语言。

C 的优势体现在最接近底层机器的地方,而Go的优势在离用户较近的地方能得到最大发挥。当然,这并不是说两者都不能在对方的空间里工作,但这样做会增加“摩擦”。当你的需求从追求灵活性转变为注重效率时,用C重写库或服务的理由就更充分了。

总之,虽然Go和C的设计有很大的不同,但它们也有很多相似性,具备发挥兼容优势的基础。并且,当我们同时使用这二者时,就可以既有很大的灵活性,又有很好的性能,可以说是相得益彰!

五. 写在最后

今天的加餐中,我主要是基于C与Go的比较来讲解的,对于Go语言的特性并没有作详细展开。如果你还想进一步了解Go语言的设计哲学、语法特性、程序设计相关知识,欢迎来学习我在极客时间上的专栏《Tony Bai ·Go语言第一课》。在这门课里,我会用我十年Gopher的经验,带给你一条系统、完整的Go语言入门路径。

感谢你看到这里,如果今天的内容让你有所收获,欢迎把它分享给你的朋友。


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2022年,Gopher部落全面改版,将持续分享Go语言与Go应用领域的知识、技巧与实践,并增加诸多互动形式。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 微信公众号:iamtonybai
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • “Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Recommended C Style and Coding Standards中文版全文

今天无意中打开了托管在Google Code上的“Recommended C Style and Coding Standards”翻译项目,忽感觉通过目录链接的方式查看译文缺少整体感,于是花了点时间将译文全文以single page的形式贴在博客里面,方便大家查看,也算是对该翻译内容的一个备份吧。

C语言编码风格和标准

0. 摘要

本文翻译自《Recommended C Style and Coding Standards》。

作者信息:

L.W. Cannon (Bell Labs)
R.A. Elliott (Bell Labs)
L.W. Kirchhoff (Bell Labs)
J.H. Miller (Bell Labs)
J.M. Milner (Bell Labs)
R.W. Mitze (Bell Labs)
E.P. Schan (Bell Labs)
N.O. Whittington (Bell Labs)
Henry Spencer (Zoology Computer Systems, University of Toronto)
David Keppel (EECS, UC Berkeley, CS&E, University of Washington)
Mark Brader (SoftQuad? Incorporated, Toronto)

本文是《Indian Hill C Style and Coding Standards》的更新版本,上面提到的最后三位作者对其进行了修改。本文主要介绍了一种C程序的推荐编码标准,内容着重于讲述编码风格,而不是功能 组织(Functional Organization)。

1. 简介

本文档修改于AT&T Indian Hill实验室内部成立的一个委员会的一份文档,旨在于建立一套通用的编码标准并推荐给Indian Hill社区。

本文主要讲述编码风格。良好的风格能够鼓励大家形成一致的代码布局,提高代码可移植性并且减少错误数量。

本文不关注功能组织,或是一些诸如如何使用goto的一般话题。我们尝试将之前的有关C代码风格的文档整合到一套统一的标准中,这套标准将适合于 任何使用C语言的工程,当然还是会有部分内容是针对一些特定系统的。另外不可避免地是这些标准仍然无法覆盖到所有情况。经验以及广泛的评价十分重 要,遇到特殊情况时,大家应该咨询有经验的C程序员,或者查看那些经验丰富的C程序员们的代码(最好遵循这些规则)。

本文中的标准本身并不是必需的,但个别机构或团体可能部分或全部采用该标准作为程序验收的一部分。因此,在你的机构中其他人很可能以一种相似的风 格编码。最终,这些标准的目的是提高可移植性,减少维护工作,尤其是提高代码的清晰度。

这里很多风格的选择都有些许武断。混合的编码风格比糟糕的编码风格更难于维护,所以当变更现有代码时,最好是保持与现有代码风格一致,而不是盲目 地遵循本文档中的规则。

"清晰的是专业的;不清晰的则是外行的" — Sir Ernest Gowers

2. 文件组织

一个文件包含的各个部分应该用若干个空行分隔。虽然对源文件没有最大长度限制,但超过1000行的文件处理起来非常不方便。编辑器很可能没有足够 的临时空间来编辑这个文件,编译过程也会因此变得十分缓慢。与回滚到前面所花费的时间相比,那些仅仅呈现了极少量信息的多行星号是不值得的,我们 不鼓励使用。超过79列的行无法被所有的终端都很好地处理,应该尽可能的避免使用。过长的行会导致过深的缩进,这常常是一种代码组织不善的症状。

2.1 文件命名惯例

文件名由一个基础名、一个可选的句号以及后缀组成。名字的第一个字符应该是一个字母,并且所有字符(除了句号)都应该是小写的字母和数字。基础名 应该由八个或更少的字符组成,后缀应该由三个或更少的字符组成(四个,如果你包含句号的话)。这些规则对程序文件以及程序使用和产生的默认文件都 适用(例如,"rogue.sav")。

一些编译器和工具要求文件名符合特定的后缀命名约定。下面是后缀命名要求:

    C源文件的名字必须以.c结尾
    汇编源文件的名字必须以.s结尾

我们普遍遵循以下命名约定:

    可重定位目标文件名以.o结尾
    头文件名以.h结尾
        在多语言环境中一个可供选择的更好的约定是用语言类型和.h共同作为后缀(例如,"foo.c.h" 或 "foo.ch")。

    Yacc源文件名以.y结尾
    Lex源文件名以.l结尾

C++使用编译器相关的后缀约定,包括.c,..c,.cc,.c.c以及.C。由于大多C代码也是C++代码,因此这里并没有一个明确的方案。

此外,我们一般约定使用"Makefile"(而不是"makefile")作为make(对于那些支持make的系统)工具的控制文件,并且使 用"README"作为简要描述目录内容或目录树的文件。

2.2 程序文件

下面是一个程序文件各个组成部分的推荐排列顺序:

文件的第一部分是一个序,用于说明该文件中的内容是什么。对文件中的对象(无论它们是函数,外部数据声明或定义,或是其他一些东西)用途的描述比 一个对象名字列表更加有用。这个序可选择地包含作者信息、修订控制信息以及参考资料等。

接下来是所有被包含的头文件。如果某个头文件被包含的理由不是那么显而易见,我们需要通过增加注释说明原因。大多数情况下,类似stdio.h这 样的系统头文件应该被放在用户自定义头文件的前面。

接下来是那些用于该文件的defines和typedefs。一个常规的顺序是先写常量宏、再写函数宏,最后是typedefs和枚举 (enums)定义。

接下来是全局(外部)数据声明,通常的顺序如下:外部变量,非静态(non-static)全局变量,静态全局变量。如果一组定义被用于部分特定 全局数据(如一个标志字),那么这些定义应该被放在对应数据声明后或嵌入到结构体声明中,并将这些定义缩进到其应用的声明的第一个关键字的下一个 层次(译注:实在没有搞懂后面这句的含义)。

最后是函数,函数应该以一种有意义的顺序排列。相似的函数应该放在一起。与深度优先(函数定义尽可能在他们的调用者前后)相比,我们应该首选广度 优先方法(抽象层次相似的函数放在一起)。这里需要相当多的判断。如果定义大量本质上无关的工具函数,可考虑按字母表顺序排列。

2.3 头文件

头文件是那些在编译之前由C预处理器包含在其他文件中的文件。诸如stdio.h的一些头文件被定义在系统级别,所有使用标准I/O库的程序必须 包含它们。头文件还用来包含数据声明和定义,这些数据不止一个程序需要。头文件应该按照功能组织,例如,独立子系统的声明应该放到独立的头文件 中。如果一组声明在代码从一种机器移植到另外一种机器时变动的可能性很大,那么这些声明也应该被放在独立的头文件中。

避免私有头文件的名字与标准库头文件的名字一样。下面语句:

#include "math.h"

当预期的头文件在当前目录下没有找到时,它将会包含标准库中的math头文件。如果这的确是你所期望发生的,那么请加上注释。包含头文件时不要使 用绝对路径。当从标准位置获取头文件时,请使用<name>包含头文件;或相对于当前路径定义它们。C编译器的"include- path"选项(在许多系统中为-l)是处理扩展私有库头文件的最好方法,它允许在不改变源码文件的情况下重新组织目录结构。

声明了函数或外部变量的头文件应该被那些定义了这些函数和变量的文件所包含。这样一来,编译器就可以做类型检查了,并且外部声明将总是与定义保持 一致。

在头文件中定义变量往往是个糟糕的想法,它经常是一个在文件间对代码进行低劣划分的症状。此外,在一次编译中,像typedef和经过初始化的数 据定义无法被编译器看到两次。在一些系统中,重复的没有使用extern关键字修饰的未初始化定义也会导致问题。当头文件嵌套时,会出现重复的声 明,这将导致编译失败。

头文件不应该嵌套。一个头文件的序应该描述其使用的其他被包含的头文件的实用特性。在极特殊情况下,当大量头文件需要被包含在多个不同的源文件中 时,可以被接受的做法是将公共的头文件包含在一个单独的头文件中。

一个通用的做法是将下面这段代码加入到每个头文件中以防止头文件被意外多次包含。

#ifndef EXAMPLE_H
#define EXAMPLE_H
 …    /* body of example.h file */
#endif /* EXAMPLE_H */

我们不应该对这种避免多次包含的机制产生依赖,特别是不应该因此而嵌套包含头文件。

2.4 其他文件

还有一个惯例就是编写一个名为"README"的文件,用于描述程序的整体情况以及问题。例如,我们经常在README包含程序所使用的条件编译 选项列表以及相关说明,还可以包含机器无关的文件列表等。

4. 声明

全局声明应该从第一列开始。在所有外部数据声明的前面都应该放置extern关键字。如果一个外部变量是一个在定义时大小确定的数组,那么这个数 组界限必须在extern声明时显示指出,除非数组的大小与数组本身编码在一起了(例如,一个总是以0结尾的只读字符数组)。重复声明数组大小对 于一些使用他人编写的代码的人特别有益。

指针修饰符*应该与变量名在一起,而不是与类型在一起。

char  *s, *t, *u;

替换

char*  s, t, u;

后者是错误的,因为实际上t和u并未如预期那样被声明为指针。

不相关的声明,即使是相同类型的,也应该独立占据一行。我们应该对声明对象的角色进行注释,不过当常量名本身足以说明角色时,使用#define 定义的常量列表则不需要注释。通常多行变量名、值与注释使用相同缩进,使得他们在一列直线上。尽量使用Tab字符而不是空格。结构体和联合体的声 明时,每个元素应该单独占据一行,并附带一条注释。{应该与结构体的tag名放在同一行,}应该放在声明结尾的第一列。

struct boat {
    int    wllength;    /* water line length in meters */
    int    type;        /* see below */
    long   sailarea;    /* sail area in square mm */
};

/* defines for boat.type */
#define    KETCH    (1)
#define    YAWL     (2)
#define    SLOOP    (3)
#define    SQRIG    (4)
#define    MOTOR    (5)

这些defines有时放在结构体内type声明的后面,并使用足够的tab缩进到结构体成员成员的下一级。如果这些实际值不那么重要的话,使用 enum会更好。

enum bt { KETCH=1, YAWL, SLOOP, SQRIG, MOTOR };
struct boat {
    int     wllength;    /* water line length in meters */
    enum bt type;        /* what kind of boat */
    long    sailarea;    /* sail area in square mm */
};

任何初值重要的变量都应该被显式地初始化,或者至少应该添加注释,说明依赖C的默认初始值0。空初始化"{}"应该永远不被使用。结构体初始化应 该用大括号完全括起来。用于初始化长整型(long)的常量应该使用显式长度。使用大写字母,例如2l看起来更像21,数字二十一。

int   x = 1;
char  *msg = "message";
struct boat    winner[] = {
    { 40, YAWL, 6000000L },
    { 28, MOTOR, 0L },
    { 0 },
};

如果一个文件不是独立程序,而是某个工程整体的一部分,那么我们应该最大化的利用static关键字,使得函数和变量对于单个文件来说是局部范畴 的。只有在有清晰需求且无法通过其他方式实现的特殊情况时,我们才允许变量被其他文件访问。这种情况下应该使用注释明确告知使用了其他文件中的变 量;注释应该说明其他文件的名字。如果你的调试器遮蔽了你需要在调试阶段查看的静态对象,那么可以将这些变量声明为STATIC,并根据需要决定 是否#define STATIC。

最重要的类型应该被typedef,即使他们只是整型,因为独立的名字使得程序更加易读(如果只有很少的几个integer的typedef)。 结构体在声明时应该被typedef。保持结构体标志的名字与typedef后的名字相同。

typedef struct splodge_t {
    int  sp_count;
    char *sp_name, *sp_alias;
} splodge_t;

总是声明函数的返回类型。如果函数原型可用,那就使用它。一个常见的错误就是忽略那些返回double的外部数学函数声明。那样的话,编译器就会 假定这些函数的返回值为一个整型数,并且将bit位逐一尽职尽责的注意转换为一个浮点数(无意义)。

"C语言的观点之一是程序员永远是对的" — Michael DeCorte

5. 函数声明

每个函数前面应该放置一段块注释,概要描述该函数做什么以及(如果不是很清晰)如何使用该函数。重要的设计决策讨论以及副作用说明也适合放在注释 中。避免提供那些代码本身可以清晰提供的信息。

函数的返回类型应该单独占据一行,(可选的)缩进一个级别。不用使用默认返回类型int;如果函数没有返回值,那么将返回类型声明为void。如 果返回值需要大段详细的说明,可以在函数之前的注释中描述;否则可以在同一行中对返回类型进行注释。函数名(以及形式参数列表)应该被单独放在一 行,从第一列开始。目的(返回值)参数一般放在第一个参数位置(从左面开始)。所有形式参数声明、局部声明以及函数体中的代码都应该缩进一级。函 数体的开始括号应该单独一行,放在开始处的第一列。

每个参数都应该被声明(不要使用默认类型int)。通常函数中每个变量的角色都应该被描述清楚,我们可以在函数注释中描述,或如果每个声明单独一 行,我们可以将注释放在同一行上。像循环计数器"i",字符串指针"s"以及用于标识字符的整数类型"c"这些简单变量都无需注释。如果一组函数 都拥有一个相似的参数或局部变量,那么在所有函数中使用同一个名字来标识这个变量是很有益处的(相反,避免在相关函数中使用一个名字标识用途不同 的变量)。不同函数中的相似参数还应该放在各个参数列表中的相同位置。

参数和局部变量的注释应该统一缩进以排成一列。局部变量声明应用一个空行与函数语句分隔开来。

当你使用或声明变长参数的函数时要小心。目前在C中尚没有真正可移植的方式处理变长参数。最好设计一个使用固定个数参数的接口。如果一定要使用变 长参数,请使用标准库中的宏来声明具有变长参数的函数。

如果函数使用了在文件中没有进行全局声明的外部变量(或函数),我们应该在函数体内部使用extern关键字单独对这些变量进行声明。

避免局部声明覆盖高级别的声明。尤其是,局部变量不应该在嵌套代码块中被重声明。虽然这在C中是合法的,但是当使用-h选项时,潜在的冲突可能性 足以让lint工具发出抱怨之声。

6. 空白

int i;main(){for(;i["] o, world!\n",'/'/'/'));}read(j,i,p){write(j/p+p,i—j,i/i);}
- 不光彩的事情,模糊C代码大赛,1984年。作者要求匿名。

通常情况下,请使用纵向和横向的空白。缩进和空格应该反映代码的块结构。例如,在一个函数定义与下一个函数的注释之间,至少应该有两行空白。

如果一个条件分支语句过长,那就应该将它拆分成若干单独的行。

if (foo->next==NULL && totalcount<needed && needed<=MAX_ALLOT
    && server_active(current_input)) { …

也许下面这样更好

if (foo->next == NULL
    && totalcount < needed && needed <= MAX_ALLOT
    && server_active(current_input))
{
    …

类似地,复杂的循环条件也应该被拆分为不同行。

for (curr = *listp, trail = listp;
    curr != NULL;
    trail = &(curr->next), curr = curr->next )
{
    …

其他复杂的表达式,尤其是那些使用了?:操作符的表达式,最好也能拆分成多行。

c = (a == b)
    ? d + f(a)
    : f(b) – d;

当关键字后面有放在括号内的表达式时,应该使用空格将关键字与左括号分隔(sizeof操作符是个例外)。在参数列表中,我们也应该使用空格显式 的将各个参数隔开。然而,带有参数的宏定义一定不能在名字与左括号间插入空格,否则C预编译器将无法识别后面的参数列表。

7. 例子

        /*
     * Determine if the sky is blue by checking that it isn't night.
     * CAVEAT: Only sometimes right.  May return TRUE when the answer
     * is FALSE.  Consider clouds, eclipses, short days.
     * NOTE: Uses 'hour' from 'hightime.c'.  Returns 'int' for
     * compatibility with the old version.
     */
    int  /* true or false */
    skyblue()
    {
        extern int hour;   /* current hour of the day */

        return (hour >= MORNING && hour <= EVENING);
    }

    /*
     * Find the last element in the linked list
     * pointed to by nodep and return a pointer to it.
     * Return NULL if there is no last element.
     */
    node_t *
    tail(nodep)
    node_t  *nodep;     /* pointer to head of list */
    {
       register node_t *np; /* advances to NULL */
       register node_t *lp; /* follows one behind np */

       if (nodep == NULL)
            return (NULL);
       for (np = lp = nodep; np != NULL; lp = np, np = np->next)
            ;       /* VOID */
       return (lp);
    }

8. 简单语句

每行只应该有一条语句,除非多条语句关联特别紧密。

case FOO:  oogle (zork);  boogle (zork);  break;
case BAR:  oogle (bork);  boogle (zork);  break;
case BAZ:  oogle (gork);  boogle (bork);  break;

for或while循环语句的空体应该单独放在一行并加上注释,这样可以清晰的看出空体是有意而为,并非遗漏代码。

while (*dest++ = *src++)
    ;    /* VOID */

不要对非零表达式进行默认测试,例如:

if (f() != FAIL)

比下面的代码更好

if (f())

即使FAIL的值可能为0(在C中0被认为是假)。当后续有人决定使用-1替代0作为失败返回值时,一个显式的测试将解决你的问题。即使比较的值 永远不会改变,我们也应该使用显式的比较;例如

if (!(bufsize % sizeof(int)))

应该被写成

if ((bufsize % sizeof(int)) == 0)

这样可以反映这个测试的数值(非布尔)本质。一个常见的错误点是使用strcmp测试字符串是否相同,这个测试的结果永远不应该被放弃。比较好的 方法是定义一个宏STREQ。

#define STREQ(a, b) (strcmp((a), (b)) == 0)

对谓词或满足下面约束的表达式,非零测试经常被放弃:

    0表示假,其他都为真。
    通过其命名可以看出返回真是显而易见的。

用isvalid或valid称呼一个谓词,不要用checkvalid。

一个非常常见的实践就是在一个全局头文件中声明一个布尔类型"bool"。这个特殊的名字可以极大地提高代码可读性。

typedef int    bool;
#define FALSE    0
#define TRUE    1

typedef enum { NO=0, YES } bool;

即便有了这些声明,也不要检查一个布尔值与1(TRUE,YES等)的相当性;可用测试与0(FALSE,NO等)的不等性替代。绝大多数函数都 可以保证为假的时候返回0,但为真的时候只返回非零。

if (func() == TRUE) { …

必须被写成

if (func() != FALSE) { …

如果可能的话,最好为函数/变量重命名或者重写这个表达式,这样就可以显而易见的知道其含义,而无需再与true或false比较了(例如,重命 名为isvalid())。

嵌入赋值语句也有用武之地。在一些结构中,在没有降低代码可读性的前提下,没有比这更好的方式来实现这个结果了。

while ((c = getchar()) != EOF) {
    process the character
}

++和–操作符可算作是赋值语句。这样,为了某些意图,实现带有副作用的功能。使用嵌入赋值语句也可能提高运行时的性能。不过,大家应该在提高 的性能与下降的可维护性之间做好权衡。当在一些人为的地方使用嵌入赋值语句时,这种情况会发生,例如:

a = b + c;
d = a + r;

不应该被下面代码替代:

d = (a = b + c) + r;

即使后者可能节省一个计算周期。在长期运行时,由于优化器渐获成熟,两者的运行时间差距将下降,而两者在维护性方面的差异将提高,因为人类的记忆 会随着时间的流逝而衰退。

在任何结构良好的代码中,goto语句都应该保守地使用。使用goto带来好处最大的地方是从switch、for和while多层嵌套中跳出, 但这样做的需求也暗示了代码的内层结构应该被抽取出来放到一个单独的返回值为成功或失败的函数中。

    for (…) {
        while (…) {
            …
            if (disaster)
                goto error;

        }
    }
    …
error:
    clean up the mess

当需要goto时候,其对应的标签应该被放在单独一行,并且后续的代码缩进一级。使用goto语句时应该增加注释(可能放在代码块的头)以说明它 的功用和目的。continue应该保守地使用,并且尽可能靠近循环的顶部。Break的麻烦比较少。

非原型函数的参数有时需要被显式做类型提升。例如,如果函数期望一个32bit的长整型,但却被传入一个16bit的整型数,可能会导致函数栈不 对齐。指针,整型和浮点值都会发生此问题。

9. 复合语句

复合语句是一个由括号括起来的语句列表。有许多种常见的括号格式化方式。如果你有一个本地标准,那请你与本地标准保持一致,或选择一个标准,并持 续地使用它。在编辑别人的代码时,始终使用那些代码中使用的样式。

control {
       statement;
       statement;
}

上面的风格被称为"K&R风格",如果你还没有找到一个自己喜欢的风格,那么可以优先考虑这个风格。在K&R风格中,if- else语句中的else部分以及do-while语句中的while部分应该与结尾大括号在同一行中。而其他大部分风格中,大括号都是单独占据 一行的。

当一个代码块拥有多个标签时,每个标签应该单独放在一行上。必须为C语言的switch语句的fall-through特性(即在代码段与下一个 case语句之前间没有break)增加注释以利于后期更好的维护。最好是lint风格的注释/指示。

switch (expr) {
case ABC:
case DEF:
     statement;
    break;
case UVW:
     statement;
     /*FALLTHROUGH*/
case XYZ:
    statement;
    break;
}

这里,最后那个break是不必要的,但却是必须的,因为如果后续另外一个case添加到最后一个case的后面时,它将阻止fall- through错误的发生。如果使用default case,那么应该该default case放在最后,且不需要break,如果它是最后一个case。

一旦一个if-else语句在if或else段中包含一个复合语句,if和else两个段都应该用括号括上(称为全括号(fully bracketed)语法)。

if (expr) {
    statement;
} else {
    statement;
    statement;
}

在如下面那样的没有第二个else的if-if-else语句序列里,括号也是不必可少的。如果ex1后面的括号被省略,编译器解析将出错:

if (ex1) {
    if (ex2) {
         funca();
    }
} else {
    funcb();
}

一个带else if的if-else语句在书写上应该让else条件左对齐。

if (STREQ (reply, "yes")) {
    statements for yes
    …
} else if (STREQ (reply, "no")) {
    …
} else if (STREQ (reply, "maybe")) {
    …
} else {
    statements for default
    …
}

这种格式看起来像一个通用的switch语句,并且缩进反映了在这些候选语句间的精确切换,而不是嵌套的语句。

Do-while循环总是使用括号将循环体括上。

下面的代码非常危险:

#ifdef CIRCUIT
#    define CLOSE_CIRCUIT(circno) { close_circ(circno); }
#else
#    define CLOSE_CIRCUIT(circno)
#endif


if (expr)
    statement;
else
    CLOSE_CIRCUIT(x)
++i;

注意,在CIRCUIT没有定义的系统上,语句++i仅仅在expr是假的时候获得执行。这个例子指出宏用大写命名的价值,以及让代码完全括号化 的价值。

有些时候,通过break,continue,goto或return,if可以无条件地进行控制转移。else应该是隐式的,并且代码不应该缩 进。

if (level > limit)
    return (OVERFLOW)
normal();
return (level);

平坦的缩进告诉读者布尔测试在密封块的其他部分是保持不变的。

10. 操作符

一元操作符不应该与其唯一的操作数分开。通常,所有其他二元操作符都应该使用空白与其操作树分隔开,但'.'和'->'例外。当遇到复杂表 达式的时候我们需要做出一些判断。如果内层操作符没有使用空白分隔而外层使用了,那么表达式也许会更清晰些。

如果你认为一个表达式很难于阅读,可以考虑将这个表达式拆分为多行。在接近中断点的最低优先级操作符处拆分是最好的选择。由于C具有一些想不到的 优先级规则,混合使用操作符的表达式应该使用括号括上。但是过多的括号也会使得代码可读性变差,因为人类不擅长做括号匹配。

二元逗号操作符也会被使用到,但通常我们应该避免使用它。逗号操作符的最大用途是提供多元初始化或操作,比如在for循环语句中。复杂表达式,例 如那些使用了嵌套三元?:操作符的表达式,可能引起困惑,并且应该尽可能的避免使用。三元操作符和逗号操作符在一些使用宏的地方很有用,诸如 getchar。在三元操作符?:前的逻辑表达式的操作数应该被括起来,并且两个子表达式的返回值应该是相同类型。

11. 命名约定

毫无疑问,每个独立的工程都有一套自己的命名约定,不过仍然有一些通用的规则值得参考。

    * 为系统用途保留以下划线开头或下划线结尾的名字,并且这些名字不应该被用在任何用户自定义的名字中。大多数系统使用这些名字用于用户不应 该也不需知道的名字中。如果你一定要使用你自己私有的标识符,可以用标识它们归属的包的字母作为开头。

    * #define定义的常量名字应该全部大写。

    * Enum常量应该大写或全部大写。

    * 函数名、typedef名,变量名以及结构体、联合体与枚举标志的名字应该用小写字母。

    * 很多"宏函数"都是全部大写的。一些宏(诸如getchar和putchar)使用小写字母命名,这事因为他们可能被当成函数使用。只有在宏的行为类似一 个函数调用时才允许小写命名的宏,也就是说它们只对其参数进行一次求值,并且不会给具名形式参数赋值。有些时候我们无法编写出一个具有函数行为的 宏,即使其参数也只是求值一次。

    * 避免在同一情形下使用不同命名方式,比如foo和Foo。同样避免foobar和foo_bar这种方式。需要考虑这样所带来的困惑。

    * 同样,避免使用看起来相似的名字。在很多终端以及打印设备上,'I'、'1'和'l'非常相似。给变量命名为l特别糟糕,因为它看起来十分像常量'1'。

通常,全局名字(包括enum)应该具有一个统一的前缀,通过该前缀名我们可以识别出这个名字归属于哪个模块。全局变量可以选择汇集在一个全局结 构中。typedef的名字通常在结尾加一个't'。

避免名字与各种标准库中的名字冲突。一些系统可能包含一些你所不需要的库。另外你的程序将来某天很可能也要扩展。

12. 常量

数值型常量不应该被硬编码到源文件中。应该使用C预处理器的#define特性为常量赋予一个有意义的名字。符号化的常量可以让代码具有更好的可 读性。在一处地方统一定义这些值也便于进行大型程序的管理,这样常量值可以在一个地方进行统一修改,只需修改define的值即可。枚举数据类型 更适合声明一组具有离散值的变量,并且编译器还可以对其进行额外的类型检查。至少,任何硬编码的值常量必须具有一段注释,以说明该值的来历。

常量的定义应该与其使用是一致的;例如使用540.0作为一个浮点数,而不是使用540外加一个隐式的float类型转换。有些时候常量0和1被 直接使用而没有用define进行定义。例如,一个for循环语句中用于标识数组下标的常量,

for (i = 0; i < ARYBOUND; i++)

上面代码是合理的,但下面代码

door_t *front_door = opens(door[i], 7);
if (front_door == 0)
    error("can't open %s\\\\n", door[i]);

是不合理的。在最后的那个例子中,front_door是一个指针。当一个值是指针的时候,它应该与NULL比较而不是与0比较。NULL被定义 在标准I/O库头文件stdio.h中,在一些新系统中它在stdlib.h中定义。即使像1或0这样的简单值,我们最好也用define定义成 TRUE和FALSE定义后再使用(有些时候,使用YES和NO可读性更好)。

简单字符常量应该被定义成字面值,不应该使用数字。不鼓励使用非可见文本字符,因为它们是不可移植的。如果非可见文本字符十分必要,尤其是当它们 在字符串中使用时,它们应该定义成三个八进制数字的转义字符(例如: '\007‘)而非一个字符。即使这样,这种用法也应该考虑其机器相关性,并按这里的方法处理。

13. 宏

复杂表达式可能会被用作宏参数,这可能会因操作符优先级顺序而引发问题,除非宏定义中所有参数出现的位置都用括号括上了。对这种因参数内副作用而 引发的问题,我们似乎也无能为例,除了在编写表达式时杜绝副作用(无论如何,这都是一个很好的主意)。如果可能的话,尽量在宏定义中对宏参数只进 行一次求值。有很多时候我们无法写出一个可像函数一样使用的宏。

一些宏也当成函数使用(例如,getc和fgetc)。这些宏会被用于实现其他函数,这样一旦宏自身发生变化,使用该宏的函数也会受到影响。在交 换宏和函数时务必要小心,因为函数参数是按值传递的,而宏参数则是通过名称替换。只有在宏定义时特别谨慎小心,才有可能减少使用宏时的担心。

宏定义中应该避免使用全局变量,因为全局变量的名字很可能被局部声明遮盖。对于那些对具名参数进行修改(不是这些参数所指向的存储区域)或被用作 赋值语句左值的宏,我们应该添加相应的注释以给予提醒。那些不带参数但引用变量,或过长或作为函数别名的宏应该使用空参数列表,例如:

#define    OFF_A()    (a_global+OFFSET)
#define    BORK()    (zork())
#define    SP3()    if (b) { int x; av = f (&x); bv += x; }

宏节省了函数调用和返回的额外开销,但当一个宏过长时,函数调用和返回的额外开销就变得微不足道了,这种情况下我们应该使用函数。

在一些情况下,让编译器确保宏在使用时应该以分号结尾是很有必要的。

if (x==3)
    SP3();
else
    BORK();

如果省略SP3调用后面的分号,后面的else将会匹配到SP3宏中的那个if。有了分号,else分支就不会与任何if匹配。SP3宏可以这样 安全地实现:

#define SP3() \\\\
     do { if (b) { int x; av = f (&x); bv += x; }} while (0)

手工给宏定以加上do-while包围看起来很别扭,而且很多编译器和工具会抱怨在while条件是一个常量值。一个用来声明语句的宏可以使得编 码更加容易:

#ifdef lint
    static int ZERO;
#else
#    define ZERO 0
#endif
#define STMT( stuff ) do { stuff } while (ZERO)

我们可以用下面代码来声明SP3宏:

#define SP3() \\\\
    STMT( if (b) { int x; av = f (&x); bv += x; } )

使用STMT宏可以有效阻止一些可以潜在改变程序行为的打印排版错误。

除了类型转换、sizeof以及上面那些技巧和手法,只有当整个宏用括号括上时才应该包含关键字。

14. 条件编译

条件编译在处理机器依赖、调试以及编译阶段设定特定选项时十分有用。不过要小心条件编译。各种控制很容易以一种无法预料的方式结合在一起。如果使 用#ifdef判断机器依赖,请确保当没有机器类型适配时,返回一个错误,而不是使用默认机器类型(使用#error并缩进一级,这样它可以一些老旧的编 译器下工作)。如果你#ifdef优化选项,默认情况下应该是一个未经优化的代码,而不是一个不兼容的程序。确保测试的是未经优化的代码。

注意在#ifdef区域内的文本可能会被编译器扫描(处理),即使#ifdef求值的结果为假。但即使文件的#ifdef部分永远不能被编译到(例如,#ifdef COMMENT),这部分也不该随意的放置文本。

尽可能地将#ifdefs放在头文件中,而不是源文件中。使用#ifdef定义可以在源码中统一使用的宏。例如,一个用于检查内存分配的头文件可能这样实现:(省略了REALLOC和FREE):

#ifdef DEBUG
    extern void *mm_malloc();
#    define MALLOC(size) (mm_malloc(size))
#else
    extern void *malloc();
#    define MALLOC(size) (malloc(size))
#endif

条件编译通常应该基于一个接一个的特性的。多数情况下,都应该避免使用机器或操作系统依赖。

#ifdef BSD4
    long t = time ((long *)NULL);
#endif

上面代码之所以糟糕有两个原因:很可能在某个4BSD系统上有更好的选择,并且也可能存在在某个非4BSD系统中上述代码是最佳代码。我们可以通过定义诸 如TIME_LONG和TIME_STRUCTD等宏作为替代,并且在诸如config.h的配置文件中定义一个合适的宏。

16. 可移植性

    "C语言结合了汇编的强大功能和可移植性" — 无名氏,暗指比尔.萨克。

可移植代码的好处是有目共睹的。这一节将阐述一些编写可移植代码的指导原则。这里"可移植的"是指一个源码文件能够在不同机器上被编译和执行,其 前提仅仅是在不同平台上可能包含不同的头文件,使用不同的编译器开关选项罢了。头文件包含的#define和typedef可能因机器而异。一般 来说,一个新"机器"是指一种不同的硬件,一种不同的操作系统,一个不同的编译器,或者是这些的任意组合。参考1包含了很多关于风格和可移植 性方面的有用信息。下面是一个隐患列表,当你设计可移植代码时应该考虑避免这些隐患:

    * 编写可移植的代码。只有当被证明是必要的情况下才考虑优化的细节。优化后的代码往往是模糊不清、难以理解的。在一台机器上经过优化后的代码,在其他机器上 可能变得更加糟糕。将采用的性能优化手段记录下来并尽可能多地本地化。文档应该解释这些手段的工作原理以及引入它们的原因(例如:"循环执行了无 数次")

    * 要意识到很多东西天生就是不可移植的。比如处理类似程序状态字这样的特定硬件寄存器的代码,以及被设计用于支持某特定硬件部件的代码,诸如汇编器以及 I/O驱动。即使在这种情况下,许多例程和数据仍然可以被设计成机器无关的。

    * 组织源文件时将机器无关与机器相关的代码分别放在不同文件中。之后如果这个程序需要被移植到一个新机器上时,我们就可以很容易判断出来哪些需要被改变。为 一些文件的头文件中机器依赖相关的代码添加注释。

    * 任何"实现相关"的行为都应该作为机器(编译器)依赖对待。假设编译器或硬件以一种十分古怪的方式实现它。

    * 注意机器字长。对象的大小可能不直观,指针大小也不总是与整型大小相同,也不总是彼此大小相同,或者可相互自由转换。下面的表中列举了C语言基本类型在不 同机器和编译器下的大小(以bit为单位)。

type    pdp11  VAX/11 68000  Cray-2 Unisys Harris 80386
        series       family          1100   H800
char     8      8      8       8      9     8      8
short    16    16     8/16   64(32)   18    24    8/16
int      16    32     16/32  64(32)   36    24    16/32
long     32    32      32    64       36    48    32
char*    16    32      32    64       72    24    16/32/48
int*     16    32      32    64(24)   72    24    16/32/48
int(*)() 16    32      32    64       576   24    16/32/48

有些机器针对某一类型可能有不止一个大小。其类型大小取决于编译器和不同的编译期标志。下面表展示了大多数系统的"安全"类型大小。无符号与带符 号数具有相同的大小(单位:bit)。

Type    Minimum  No Smaller
          # Bits   Than
char       8  
short      16    char
int        16    short
long       32    int
float      24  
double     38    float
any *      14  
char *     15    any *
void *     15    any *

    * void类型可以保证有足够位精度来表示一个指向任意数据对象的指针。void()()类型可以保证表示一个指向任意函数的指针。当你需要通用指针时 可以使用这些类型(在一些旧的编译器里,分别用char和char()()表示)。确保在使用这些指针类型之前将其转换回正确的类型。

    * 即使说一个int和一个char类型大小相同,它们仍可能具有不同的格式。例如,下面例子在一些sizeof(int)等于 sizeof(char)的机器上可能失败。其原因在与free函数期望一个char,但却传入了一个int。

    int *p = (int *) malloc (sizeof(int));
   free (p);

    * 注意,一个对象的大小不能保证这个对象的精度。Cray-2可能使用64位来存储一个整型,但一个长整型转换为一个整型并且再转换回长整型后可能会被截断 为32位。

    * 整型常量0可以强制转型为任何指针类型。转换后的指针称为对应那个类型的空指针,并且与那个类型的其他指针不同。空指针比较总是与常量0相当。空指针不应 该与一个值为0的变量比较。空指针不总是使用全0的位模式表示。两个不同类型的空指针有些时候可能不同。某个类型的空指针被强制转换为另外一个类 型的指针,其结果是该指针转换为第二个类型的空指针。

    * 对于ANSI编译器,当两个类型相同的指针访问同一块存储区时,则它们比较是相等的。当一个非0整型常量被转换为指针类型时,它们可能与其他指针相等。对 于非ANSI编译器,访问同一块存储区的两个指针比较可能并不相同。例如,下面两个指针比较可能相等或不相等,并且他们可能或可能没有访问同一块 存储区域。

    ((int *) 2 )
    ((int *) 3 )

如果你需要'magic'指针而不是NULL,要么分配一些内存,要么将指针视为机器相关的。

extern int x_int_dummy;    /* in x.c */
#define X_FAIL    (NULL)
#define X_BUSY    (&x_int_dummy)
#define X_FAIL    (NULL)
#define X_BUSY    MD_PTR1  /* MD_PTR1 from "machdep.h" */

    * 浮点数字既包含精度也包含范围。这些都是数据对象大小无关的。但是,一个32位浮点数在不同机器上溢出时的值有所不同。同时,4.9乘以5.1在不同的机 器上可能产生两个不同的数字。在圆整(rounding)和截断方面的差异将给出特别不同的答案。

    * 在一些机器上,一个双精度浮点数在精度或范围方面可能比一个单精度浮点数还要低。

    * 在一些机器上,double值的前半部分可能是一个具有相同值的float类型。千万不要依赖于此。

    * 提防带符号字符。例如,在某些VAX系统上,用在表达式中的字符是符号扩展的,但在其他一些机器上并非如此。对有符号和无符号有依赖的代码是不可移植的。 例如,如果假设c是正值,arrayc在c为有符号且为负值时将无法正常工作。如果你一定要假设signed或unsigned字符的话,请 用SIGNED或UNSIGNED为其加上注释。无符号字符的行为可由unsigned char保证。

    * 避免对ASCII做假设。如果你必须假设,那么请将其记录下来并本地化。请记住字符很可能用不止8位表示。

    * 大多数机器采用2的补码表示数,但我们在代码中不应该利用这一特点。使用等价移位操作替代算术运算的优化尤其值得怀疑。如果必须这么做,那么机器相关的代 码应该用#ifdef定义,或者操作应该在#ifdef宏判定下执行。你应该衡量一下使用这种难以理解的代码所节省的时间与做代码移植时找bug 所花费的时间相比孰多孰少。

    * 一般情况下,如果字长或值范围非常重要,应该使用typedef定义具有特定大小的类型。大型程序应该具有一个统一的头文件用于提供通用的、大小 (size)敏感的类型的typedef定义,这样更加便于修改以及在紧急修复时查找大小敏感的代码。无符号类型比有符号整型更加编译器无关。如 果既可以用16bit也可以用32bit标识一个简单for循环的计数器,我们应该使用int。因为对于当前机器来说,通过整型可以获取更高效 (自然)的存储单元。

    * 数据对齐也很重要。例如,在不同的机器上,一个四字节的整型数的可能以任意地址作为起始地址,也可能只允许以偶数地址作为起始地址,或者只能以4的整数倍 的地址作为起始地址。因此,一个特定的结构体的各个元素在不同的机器上的偏移量有不同,即使给定的这些元素在所有机器上的大小相同。事实上,一个 包含一个32位指针和一个8位字符的结构提在三个不同的机器上可能有三个不同的大小。作为一个推论,对象指针可能无法自由互换;通过一个指向起始 地址为奇数地址长度为4个字节的指针保存一个整型数有时可以正常工作,但有时则会导致产生core,有些时候静悄悄地失败了(在这个过程中会破坏 其他数据)。在那些不按字节寻址的机器上,字符指针更是"事故高发地区"。对齐考虑以及加载器的特殊性使得很容易轻率地认为两个连续声明的变量在 内存中也是连在一起的,或者某个类型的变量已经被适当对齐并可以用作其他类型变量使用了。

    * 在一些机器上,诸如VAX(小端),一个字的字节随着地址的增加,其重要性提高;而另外一些机器上,诸如68000(大端),随着地址的增加,其重要性降 低。字或更大数据对象(诸如一个双精度字)的字节顺序可能并不相同。因此,任何依赖对象内从左到右方向位模式的代码都值得特别细致的审查。只有当 结构体中两个不同的位字段不被连接以及不被当作一个单元时,这些位字段才具备可移植性。事实上,连接任意两个变量都是不可移植的行为。

    * 结构体中有一些未使用的空洞。猜想联合体用于类型欺骗。尤其是,一个值不应该在存储时使用一个类型,而在读取时使用另外一种类型。对联合体来说,一个显式 的标签(tag)字段可能会很有用。

    * 不同的编译器在返回结构体时使用不同的约定。这就会导致代码在接受从不同编译器编译的库代码中返回的结构体值时会出现错误。结构体指针不是问题。

    * 不要假设参数传递机制。特别是指针大小以及参数求值顺序,大小等。例如,下面的代码就不具备可移植性。

        c = foo (getchar(), getchar());

    char
    foo (c1, c2, c3)
    char c1, c2, c3;
    {
        char bar = *(&c1 + 1);
        return (bar);            /* often won't return c2 */
    }

    * 上面的例子有诸多问题。栈可能向上增长,也可能向下增长(事实上,甚至都不需要一个栈)。参数在传入时可能被扩大,例如一个char可能以int型被传 入。参数可能以从左到右,从右到左,或以任意顺序压入栈,或直接放在寄存器中(根本无需压栈)。参数求值的顺序也可能与压栈的次序有所不同。一个 编译器可能使用多种(不兼容的)调用约定。

    * 在某些机器上,空字符指针((char *)0)常被当作指向空字符串的指针对待。不要依赖于此。

   *  不要修改字符串常量。下面就是一个臭名昭著的例子

    s = "/dev/tty??";
  strcpy (&s[8], ttychars);

    * 地址空间可能有空洞。简单计算一个数组中未分配空间的元素(在数组实际存储区域之前或之后)的地址可能会导致程序崩溃。如果这个地址被用于比较,有时程序 可以运行,但会破坏数据,报错,或陷入死循环。在ANSI C中,指向一个对象数组的指针指向数组结尾后的第一个元素是合法的,这在一些老编译器上通常是安全的。不过这个"在外边"不可以被解引用。

    * 只有==和!=比较可用于某给定类型的所有指针。当两个指针指向同一个数组内的元素(或数组后第一个元素)时,使用<<、<=、& amp; gt;或>=对两个指针进行比较是可移植的。同样,仅仅对指向同一个数组内的元素(或数组后第一个元素)的两个指针使用算术操作符才是可移 植的。

    * 字长(word size)也影响移位和掩码。下面代码在一些68000机器上只会将一个整型数的最右三个位清0,而在其他机器上它还会将高地址的两个字节清零。x &= 0177770 使用 x &= ~07可以在所有机器上正常工作。位字段(bitfield)没有这些问题。

    * 表达式内的副作用可能导致代码语义是编译器相关的,因为在大多数情况下C语言的求值顺序是没有显式定义的。下面是一个臭名昭著的例子:

    a[i] = b[i++];

    在上面的例子中,我们只知道b的下标值没有被增加。a的下标i值可能是自增后的值也可能是自增前的值。

    struct bar_t { struct bar_t *next; } bar;
    bar->next = bar = tmp;

在第二个例子中,bar->next的地址很可能在bar被赋值之前被计算使用。

bar = bar->next = tmp;

第三个例子中,bar可能在bar->next之前被赋值。虽然这可能有悖于"赋值从右到左处理"的规则,但这确是一个合法的解析。考虑下 面的例子:

long i;
short a[N];
i = old
i = a[i] = new;

赋给i的值必须是一个按照从右到左的处理顺序进行赋值处理后的值。但是i可能在ai被赋值前而被赋值为"(long) (short)new"。不同编译器作法不同。

    * 质疑代码中出现的数值(“魔数”)。

    * 避免使用预处理器技巧。一些诸如使用/ /粘和字符串以及依赖参数字符串展开的宏会破坏代码可靠性。

    #define FOO(string)    (printf("string = %s",(string)))
    …
  FOO(filename);

只是在有些时候会扩展为

 (printf("filename = %s",(filename)))

小心。诡异的预处理器在一些机器上可能导致宏异常中断。下面是一个宏的两种不同实现版本:

  #define LOOKUP(chr)    (a['c'+(chr)])    /* Works as intended. */
  #define LOOKUP(c)    (a['c'+(c)])        /* Sometimes breaks. */

第二个版本的LOOKUP可能以两种不同的方式扩展,并且会导致代码异常中断。

    * 熟悉现有的库函数和定义(但不用太熟悉。与其外部接口相反,库基础设施的内部细节常会改变并且没有警告,这些细节常常也是不可移植的)。你不应该再自己重 新编写字符串比较例程、终端控制例程或为系统结构编写你自己的定义。自己动手实现既浪费你的时间,又使得你的代码可读性变差,因为另外一个读者需 要知道你是否在新的实现中做了什么特殊的事情,并尝试证实它们的存在。同时这样做会使得你无法充分利用一些辅助的微代码或其他有助于提高系统例程 性能的方法。更进一步,它将是一个bug的高产源头。如果可能的话,要知道公共库之间的差异(如ANSI、POSIX等等)。

    * 如果lint可用,请使用lint。这个工具对于查找代码中机器相关的构造、其他不一致性以及顺利通过编译器检查的程序bug时具有很高价值。如果你的编 译器具备打开警告的开关,请打开它。

    * 质疑在代码块内部的与代码块外部switch或goto有关联的标签(Label)。

    无论类型在哪里,参数都应该被转换为适当的类型。当NULL用在没有原型的函数调用时,请对NULL进行转换。不要让函数调用成为类型欺骗发生的地方。C 语言的类型提升规则很是让人费解,所以尽量小心。例如,如果一个函数接受一个32位长的长整型做为参数,但实际传入的却是一个16位长的整型数, 函数栈可能会无法对齐,这个值也可能会被错误提升。

    * 在混用有符号和无符号值的算术计算时请使用显式类型转换

    * 应该谨慎使用跨程序的goto、longjmp。很多实现"忘记"恢复寄存器中的值了。尽可能将关键的值声明为volatile,或将它们注释为 VOLATILE。

    * 一些链接器将名字转换为小写,并且一些链接器只识别前六个字母作为唯一标识。在这些系统上程序可能会悄悄地中断运行。

    * 当心编译器扩展。如果使用了编译器扩展,请将他们视为机器依赖并用文档记录下来。

    * 通常程序无法在数据段执行代码或者无法将数据写入代码段。即使程序可以这么做,也无法保证这么做是可靠的。

17. 标准C

现代C编译器支持一些或全部的ANSI提议的标准C。无论何时可能的话,尽量用标准C编写和运行程序,并且使用诸如函数原型,常量存储以及 volatile(易失性)存储等特性。标准C通过给优化器提供有有效的信息以提升程序的性能。标准C通过保证所有编译器接受同样的输入语言以及提供相关 机制隐藏机器相关内容或对于那些机器相关代码提供警告的方式提升代码的可移植性。

17.1 兼容性

编写很容易移植到老编译器上的代码。例如,有条件地在global.h中定义一些新(标准中的)关键字,比如const和volatile。标准编译器预 定义了预处理器符号STDC(见脚注8)。void类型很难简单地处理正确,因为很多老编译器只理解void,但不认识void。最简单的方法就是定义一 个新类型VOIDP(与机器和编译器相关),通常在老编译器下该类型被定义为char*。

#if __STDC__
    typedef void *voidp;
#   define COMPILER_SELECTED
#endif
#ifdef A_TARGET
#    define const
#    define volatile
#    define void int
    typedef char *voidp;
#    define COMPILER_SELECTED
#endif
#ifdef …
    …
#endif
#ifdef COMPILER_SELECTED
#    undef COMPILER_SELECTED
#else
    { NO TARGET SELECTED! }
#endif

注意在ANSI C中,#必须是同一行中预处理器指示符的第一个非空白字符。在一些老编译器中,它必须是同一行中的第一个字符。

当一个静态函数具有前置声明时,前置声明必须包含存储修饰符。在一些老编译器中,这个修饰符必须是"extern"。对于ANSI编译器,这个存储修饰符 必须为static,但全局函数依然必须声明为extern。因此,静态函数的前置声明应该使用一个#define,例如FWD_STATIC,并通 过#ifdef适当定义。

一个"#ifdef NAME"应该要么以"#endif"结尾,要么以"#endif / NAME /结尾,不应该用"#endif NAME"结尾。对于短小的#ifdef不应该使用注释,因为通过代码我们可以明确其含义。

ANSI的三字符组可能导致内容包含??的字符串的程序神秘的中断。

17.2 格式化

ANSI C的代码风格与常规C一样,但有两点意外:存储修饰符(storage qualifiers)和参数列表。

由于const和volatile的绑定规则很奇怪,因此每个const或volatile对象都应该单独声明。

int const *s;        /* YES */
int const *s, *t;    /* NO */

具备原型的函数将参数声明和定义归并在一个参数列表中了。应该在函数的注释中提供各个参数的注释。

/*
 * `bp': boat trying to get in.
 * `stall': a list of stalls, never NULL.
 * returns stall number, 0 => no room.
 */
int
enter_pier (boat_t const *bp, stall_t *stall)
{
    …

17.3 原型

应该使用函数原型使得代码更加健壮并且运行时性能更好。不幸地是原型的声明

extern void bork (char c);

与定义不兼容。

void
bork (c)
char c;
 …

原型中c应该以机器上最自然的类型传入,很可能是一个字节。而非原型化(向后兼容)的定义暗示c总是以一个整型传入。如果一个函数具有可类型提升的参数, 那么调用者和被调用者必须以相等地方式编译。要么都必须使用函数原型,要么都不使用原型。如果在程序设计时参数就是可以提升类型的,那么问题就可以被避 免,例如bork可以定义成接受一个整型参数。

如果定义也是原型化的,上面的声明将工作正常。

void
bork (char c)
{
    …

不幸地是,原型化的语法将导致非ANSI编译器拒绝这个程序。

但我们可以很容易地通过编写外部声明来同时适应原型与老编译器。

#if __STDC__
#    define PROTO(x) x
#else
#    define PROTO(x) ()
#endif

extern char **ncopies PROTO((char *s, short times));

注意PROTO必须使用双层括号。

最后,最好只使用一种风格编写代码(例如,使用原型)。当需要非原型化的版本时,可使用一个自动转换工具生成。

17.4 Pragmas

Pragmas用于以一种可控的方式引入机器相关的代码。很显然,pragma应该被视为机器相关的。不幸地是,ANSI pragmas的语法使得我们无法将其隔离到机器相关的头文件中了。

Pragmas分为两类。优化相关的可以被安全地忽略。而那些影响系统行为(需要pragmas)的Pragmas则不能忽略。需要的pragmas应该结合#ifdef使用,这样如果一个pragma都没有选到,编译过程将退出。

两个编译器可能通过两个不同的方式使用同一个给定的pragma。例如,一个编译器可能使用haggis发出一个优化信号。而另一个可能使用它暗示一个特 定语句,一旦执行到此,程序应该退出。不过,一旦使用了pragma,它们必须总是被机器相关的#ifdef包围。对于非ANSI编译器,Pragmas 必须总是被#ifdef。确保对#pragma的#进行缩进,否则一些较老的预处理器处理它时会挂起。

#if defined(__STDC__) && defined(USE_HAGGIS_PRAGMA)
    #pragma (HAGGIS)
#endif

    "ANSI标准中描述的'#pragma'命令具有任意实现定义的影响。在GNU C预处理中,'#pragma'首先尝试运行游戏'rogue';如果失败,它将尝试运行游戏'hack';如果失败,它将尝试运行GNU Emacs显示汉诺塔;如果失败,它将报告一个致命错误。无论如何,预处理将不再继续。"

    — GNU CC 1.34 C预处理手册。

18. 特殊考虑

这节包含一些杂项:‘做'与'不做'。

    * 不要通过宏替换来改变语法。这将导致程序对于所有人都是难以理解的,除了那个肇事者。

    * 不要在需要离散值的地方使用浮点变量。使用一个浮点数作为循环计数器无疑是搬起石头砸自己的脚。总是用<=或>=测试浮点数,对它们永远不要 用精确比较(==或!=)。

    * 编译器也有bug。常见且高发的问题包括结构体赋值和位字段。你无法泛泛的预测一个编译器都有哪些bug。但你可以在程序中避免使用那些已知的在所有编译 器上都存在问题的结构。你无法让你写的任何代码都是有用的,你可能仍然会遇到bug,并且在这期间编译器很可能会被修复。因此,只有当你被强制使 用某个特定的充斥bug的编译器时,你才应该"围绕"着编译器bug写代码。

    * 不要依赖自动代码美化工具。良好代码风格的主要受益者就是代码的编写者,并且尤其在手写算法或伪代码的早期设计阶段。自动代码美化工具只应该用在那些已经 完成、语法正确并且此后不能满足当空白和缩进被更为关注的要求时。伴随着对细致程序员的细节的关注,对于那些将函数或文件布局解释清楚的工作,程 序员们会做得更好(换句话说,一些视觉布局是由意图而不是语法决定的,美化工具无法了解到程序员的思想)。粗心的程序员应该学习成为一个细致的程 序员,而不是依赖美化工具让代码可读性更好。

    * 意外地遗漏逻辑比较表达式中的第二个=是一个常犯的问题。使用显式测试。避免对赋值使用隐式测试。

abool = bbool;
if (abool) { …

当嵌入的赋值表达式使用时,确保测试是显式的,这样后续它就无法被"修复"了。

while ((abool = bbool) != FALSE) { …
while (abool = bbool) { …    /* VALUSED */
while (abool = bbool, abool) { …

    显式地注释那些在正常控制流之外被修改的变量,或其他可能在维护过程中中断的代码。

    现代编译器会自动将变量放到寄存器中。对于你认为最关键的变量慎用寄存器。在极端情况下,用寄存器标记2-4个最为关键的值,并且将剩余的标记为 REGISTER。后者在那些具有较多寄存器的机器上可以#define为寄存器。

19. Lint

Lint是一个C程序检查工具,用于检查C语言源码文件,探测和报告诸如类型不兼容、函数定义与调用不一致以及潜在的bug等情况。强烈建议在所 有程序上使用lint工具,并且期望大多数工程将lint作为官方验收程序的一部分。

应该注意的是使用lint的最好方法不是将lint作为官方验收之前的一道必须跨过的栅栏,而是作为一个在代码发生添加或变更之后使用的工具。 Lint可以发现一些隐藏的bug并且可以在问题发生前保证程序的可移植性。lint产生的许多信息确实暗示了一些事情是错误的。一个有意思的故 事是关于一个漏掉了fprintf的一个参数的程序:

fprintf ("Usage: foo -bar <file>\n");

作者从未有过一个问题。但每当一个正常用户在命令行上犯错,这个程序就会产生一个core。许多版本的lint工具都能发现这个问题。

大多lint选项都值得我们学习。一些选项可能在合法的代码上给出警告,但它们也会捕捉到许多把事情搞遭的代码。注意'–p'只能为库的一个子 集检查函数调用和类型的一致性,因此程序为了最大化的覆盖检查,应该同时进行带–p和不带–p的lint检查。

Lint也可以识别代码里的一些特殊注释。这些注释可以强制让lint在发现问题时关闭警告输出,还可以作为一些特殊代码的文档。

20. Make

另外一个非常有用的工具是make。在开发过程中,make只会重新编译那些上次make后发生了改变的模块。它也可以用于自动化其他任务。一些 常见的约定包括:

all
执行所有二进制文件的构建过程

clean
删除所有中间文件

debug
构建一个测试用二进制文件a.out或debug

depend
制作可传递的依赖关系

install
安装二进制文件,库等

deinstall
取消安装

mkcat
安装手册

lint
运行lint工具

print/list
制作一个所有源文件的拷贝

shar
为所有源文件制作一个shar文件

spotless
执行make clean,并将源码存入版本控制工具。注意:不会删除Makefile,即便它是一个源文件。

source
撤销spotless所做的事情。

tags
运行ctags(建议使用-t标志)

rdist
分发源码到其他主机

file.c
从版本控制系统中检出这个文件

除此之外,通过命令行也可以定义Makefile使用的值(如"CFLAGS")或源码中使用的值(如"DEBUG")。

21. 工程相关的标准

除了这里提到内容外,每个独立的工程都期望能建立附加标准。下面是每个工程程序管理组需要考虑的问题中的一部分:

    * 哪些额外的命名约定需要遵守?尤其是,那些用于全局数据的功能归类以及结构体或联合体成员名字的系统化的前缀约定非常有用。

    * 什么样的头文件组织适合于工程特定的数据体系结构?

    * 应该建立什么样的规程来审核lint警告?需要确立一个与lint选项一致的宽容度,保证lint不会针对一些不重要的问题给出警告,但同时保证真正的bug或不一致问题不被隐藏。

    * 如果一个工程建立了自己的档案库,它应该计划向系统管理员提供一个lint库文件。这个lint库文件允许lint工具检查对库函数的兼容性使用。

    * 需要使用哪种版本控制工具?

22. 结论

这里描述了一套C语言编程风格的标准。其中最重要的几点是:

    * 合理使用空白和注释,使得我们通过代码布局就可以清楚地看出程序的结构。使用简单表达式、语句和函数,使他们可以很容易地被理解。

    * 记住,在将来某个时候你或其他人很可能会被要求修改代码或让代码运行在一台不同的机器上。精心编写代码,使得其可以移植到尚不确定的机器上。局部化你的优化,因为这些优化经常让人困惑,并且对于该优化措施是否适合其他机器我们持悲观态度。

    * 许多风格选择是主观武断的。保持代码风格一致比遵循这些绝对的风格规则更重要(尤其是与组织内部标准保持一致)。混用风格比任何一种糟糕的风格都更加糟糕。

无论采用哪种标准,如果认为该标准有用就必须遵循它。如果你觉得遵循某条标准时有困难,不要仅仅忽略它们,而是在和你当地的大师或组织内的有经验的程序员讨论后再做决定。

23. 参考资料

    1. B.A. Tague, C Language Portability, Sept 22, 1977. This document issued by department 8234 contains three memos by R.C. Haight, A.L. Glasser, and T.L. Lyon dealing with style and portability.
   
    2. S.C. Johnson, Lint, a C Program Checker, Unix Supplementary Documents, November 1986.
   
    3. R.W. Mitze, The 3B/PDP-11 Swabbing Problem, Memorandum for File, 1273-770907.01MF, September 14, 1977.
   
    4. R.A. Elliott and D.C. Pfeffer, 3B Processor Common Diagnostic Standards- Version 1, Memorandum for File, 5514-780330.01MF, March 30, 1978.

    5. R.W. Mitze, An Overview of C Compilation of Unix User Processes on the 3B, Memorandum for File, 5521-780329.02MF, March 29, 1978.

    6. B.W. Kernighan and D.M. Ritchie, The C Programming Language, Prentice Hall 1978, Second Ed. 1988, ISBN 0-13-110362-8.

    7. S.I. Feldman, Make — A Program for Maintaining Computer Programs, UNIXSupplementary Documents, November 1986.

    8. Ian Darwin and Geoff Collyer, Can't Happen or / NOTREACHED / or Real Programs Dump Core, USENIX Association Winter Conference, Dallas 1985
Proceedings.

    9. Brian W. Kernighan and P. J. Plauger The Elements of Programming Style. McGraw-Hill, 1974, Second Ed. 1978, ISBN 0-07-034-207-5.

    10. J. E. Lapin Portable C and UNIX System Programming, Prentice Hall 1987, ISBN 0-13-686494-5.

    11. Ian F. Darwin, Checking C Programs with lint, O'Reilly & Associates, 1989. ISBN 0-937175-30-7.

    12. Andrew R. Koenig, C Traps and Pitfalls, Addison-Wesley, 1989. ISBN 0-201-17928-8.
 

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats