时间过的飞快,转眼间,这个学期就要结束了。 在忙完手头的各种事情之后,打开自己的博客,细细的数数,自己仿佛已经有一个多月都没有发表新博文了。
这段时间以来,前后在不停的在操作系统课程设计和数据库课程设计之间来回忙和着。 早在之前学习到一些新的编程知识时我有已经有一些模糊的想法了,可我这个人一直比较懒, 当时并没有把自己的想法努力明确并且动手实现。正好,这次趁着操作系统课程设计, 选了一个比较满意的题目,来动手写个激动人心的项目。
经过这段时间的投入,终于在不久之前将整个项目完工了。 在此之间,我也因为这个项目的锻炼,学习并领悟到了不少的东西。
这些收获,由深入浅,大概可分为思想上的,设计上的,和实现细节上的。 由于思想上和设计上的实在太难表达出来,组织起来也非常耗精力, 又考虑到实现细节上的属于那种比较烦琐而且容易忘记的知识, 所以这篇博文暂且偷个懒总结下实现细节上的一些东西吧。
1. 用好 C99
虽然截止到写博时,春节还没来临,在部分中国人心目中现在还是 2016 年。 但是如果按照公元纪年的话,现在已经是公元 2017 年了。
追溯 C 语言标准版本的历史,C99 标准早在上世纪 90 年代就已经发布了, 这么长的时间,各大主流编译器对 C99 的支持早就非常完善了。
其实像//注释方式和能够在变量使用处声明变量是直到 C99 才有的特性, 但是主流的编辑器都扩展支持了这个特性,所以,即使是用 C89 似乎没见过有编译器因为这个报错的。
言归正传,C99 的一些新特性,给代码编写提供了更有效方便的方式。 下面我总结以下 C99 的一些很常用很方便的新特性。
1.1. for 循环变量初始化
在之前的 C89 标准中,使用 for 循环的例子:
|
|
其中的变量 i 必须要在 for 语句之前定义。
在 C99 中,你终于可以在 for 内部定义循环变量了:
|
|
曾经我以为 C 语言是支持上面这样使用 for 循环的,当时我还在使用 Windows,拿 C++编辑器编译 C 代码。 后来切换到 linux 下,有一次我用 gcc 报错了,我才知道原来这是 C99 起才有的特性。
C99 支持的这种写法很明显要方便许多。可能你以为仅仅是少敲点字符的区别,哦不,看下面这个例子:
|
|
对于 for 里面的用完就丢的局部变量,很多人会习惯性的命名为 i。 上面的例子,如果把循环变量定义在外面,那么会出现变量重复定义的问题。
而 C99 支持的这种写法,循环变量的作用域仅仅是 for 语句内,因此就没有这个问题。
还有,for 里面的循环变量一般都是在 for 内使用,出了 for 语句就用不上了。 从语法上把循环变量的作用域局限在 for 语句内,能够阻止程序员脑袋一糊涂一粗心,犯一些低级错误。
1.2. 标准 bool 型
在 C89 标准的 C 语言中,想要表达”真“,”假“这种数理逻辑上的概念,是用 int 型的 1,0 来表示的。
但是众所周知,int 和 bool 在概念上本来就是两种不同的类型, 虽然 C 语言由于某些原因没有考虑到,但是程序员不能犯糊,在脑子里就应该把这两种类型区分开来。
正因为如此,像下面这种利用 C 语言不区分 int 和 bool 的特性少敲几个字符的代码, 被认为是不好的不清晰的,是在耍小聪明:
|
|
也正因为 bool 类型是编程中的一种需求,所以很多 C 程序员为了代码更清晰会定义自己的 bool 类型,比如我以前这样干:
|
|
现在,有了 C99,那些千奇百怪的自定义 bool 可以省掉了。现在只需要 include 头文件 stdbool.h
,就能使用 C99 定义的 bool 类型。
|
|
实际上,为了防止和已有代码冲突,C99 中真正的类型名是 _Bool
,那个 bool
其实只是个宏。
1.3. 标准 int 型
C 语言里有 short int, int, long int 等整型类型,但是它们具体是多大?C 标准并没有明确规定,只是说了: sizeof(short int) <= sizeof(int) <= sizeof(long int),并且 short int 至少应为 16 位,long int 至少应为 32 位。
但是,比如说 int,具体占多少位,这得看具体实现。不同的编译器实现可能不一样,不同的硬件平台也可能不同。
但是在具体的代码编写时,是有用到特定位数,比如说 32 位的整数类型这种需求的。 在以往有这种需求的时候,都得是程序员先调查出在自己可能用到编译器和平台上具体实现中,各个整型具体的位数。 然后用条件编译和类型别名自己定义出一堆固定位数的整型。
现在,在 C99 中,只需 include 头文件 stdint.h
, 就能轻松使用到固定位数的整型了。见下表:
有符号 | 无符号 | 有符号数最小值 | 有符号数最大值 | 无符号数最大值 | 备注 |
---|---|---|---|---|---|
int8t | uint8t | INT8MIN | INT8MAX | UINT8MAX | |
int16t | uint16t | INT16MIN | INT16MAX | UINT16MAX | |
int32t | uint32t | INT32MIN | INT32MAX | UINT32MAX | |
int64t | uint64t | INT64MIN | INT64MAX | UINT64MAX | 可选支持 |
intmaxt | uintmaxt | INTMAXMIN | INTMAXMAX | UINTMAXMAX | 支持的最大位数整数 |
可以看到,C99 定义了 8 位,16 位,32 位的无符号整型和有符号整型, 还有 64 位的是可选支持,这个主要是考虑到部分硬件不支持。但是主流的 x86, amd64 之类的硬件是支持的。
1.4. inline 函数
inline 函数是个非常好的特性。这个特性的优点在于,即能享受函数所带来的抽象优点,而又能避免函数调用所带来的性能问题。
inline 函数一开始是出现在 C++中,C++这门语言的目标是零开销抽象。 也就是说,C++一方面要给程序员提供各种抽象工具,另一方面要保证这些工具不会带来额外的运行时开销。
函数在 C 和 C++中是一个重要的抽象工具,但是在部分性能要求很高的地方函数调用会带来额外的开销。 在之前的 C 语言中,是使用宏来避免函数调用的开销的,像:
|
但是宏只是一个简单的字符串替换而已,它的缺点也很明显:
- 没有类型检查
- 编写函数宏时要小心,否则容易出诡异的 bug(比如忘记了在整个表达式外套括号)
- 传进入的参数是个表达式,结果被计算了两次。不仅有额外性能开销,而且表达式有副作用的会出问题
C++的内联函数解决了这个问题。这需要你在函数定义前加上 inline 关键字:
|
|
这样,C++在调用 maxint 函数的地方,可能会做内联展开,也即把函数的实现 copy 到调用处,免去了函数调用的开销。
现在,C99 把 inline 函数这个特性引入到了 C 语言中。但是值得注意的是 C99 的 inline 和 C++的 inline 的异同点。
首先和 C++的 inline 一样,你得把 inline 函数的定义写到.h 文件中,不然起不到 inline 的效果。 由于 C/C++中是每个编译单元独立编译然后链接到一起,不把定义写到.h 里面的话,在调用点可能编译器不知道函数的定义,那还怎么内联展开?
好了,说完了共同点,C99 的 inline 还有一点和 C++的不一样。 现在你有一个 compare.h 文件,里面写了上面示例的代码。然后你在 module1.c 和 module2.c 里面使用这个头文件的 maxint 函数。
然后使用 gcc module1.c module2.c
编译。令人惊讶的是 gcc 报错了:
/tmp/ccnwN5Zq.o:在函数‘max_int’中:
module2.c:(.text+0x0): multiple definition of `max_int'
/tmp/ccSzx1Cy.o:module1.c:(.text+0x0):第一次在此定义
collect2: error: ld returned 1 exit status
这是因为编译单元 module1.c 和编译单元 module2.c 在 include 头文件 compare.h 之后,各自有一份函数 maxint 的定义。 所以在链接时报出了函数重复定义错误。C++的 inline 能处理这个问题,但是很遗憾 C99 并不能。
所以,C99 的 inline 特性的正确使用方式是,把 inline 函数设为 static,使函数局部于编译单元可见:
|
|
以我个人观点,在编写代码时不用太早关心这些对性能影响是时间复杂度常数的地方。 inline 函数的优点在于,你可以先按照正常方式编写代码设计函数, 之后通过分析发现某几个函数调用的开销是影响系统性能的关键部分,到时候再无缝修改为 inline,优化性能。
1.5. 变参宏
C 语言是支持变参函数的。通过配合使用标准库 stdarg.h 里面的工具,C 语言中是能够定义变参函数的,像标准库的 printf 函数就是变参的。 比如下面代码,谈笑间,我们就定义了一个自己的 myprintf 函数:
|
|
但是遗憾的是 C 语言的函数宏却不支持多参数。比如我想用宏把上面的函数简单包装一下做一个简易的日志输出, 可以灵活的进行格式化输出。
现在有了 C99,这个需求得到了满足:
|
上面演示了两种写法, __VA_ARGS__
和 ##__VA_ARGS__
,它们的区别是,后者允许可变参数的部分为空。
2. 标准库的小坑
C 语言的标准库历史已经很古老了,其中有些设计使得使用上有点麻烦也就算了, 最头疼的是其实里面蕴含你不知道的小坑,写出来的代码乍一看无懈可击,等到运行的时候出了问题又百般摸不着头脑。 直到调试到最后才忽然发现,原来标准库的这个函数的行为和我认为的不一致!
下面我就总结下我遇到过的标准库的小坑。
2.1. 对同一文件指针进行写入和读出
先看下面的代码:
|
|
首先我们从文件偏移 0 开始读取 512 个字节的数据,然后在从偏移 512 开始往文件写入了 512 个 '\0'
。 乍一看,似乎没什么问题,直觉上,标准库的 I/O 函数应该是能够这么用的。
坑点就在这里。实际上,C 语言的标准库由于某些原因(《C 陷阱与缺陷》中说是为了以往程序的兼容性), 在输入操作后直接进行输出操作,或者在输出操作后直接进行输入操作,是不行的。
如果想同时进行输入操作和输出操作,必须在其中插入 fseek 调用:
|
|
fseek 的原本作用是移动文件内部指针的位置,但是这里 fseek 并没有移动文件内部指针的位置,看上去什么也没做。 但是实际上 fseek 改变了文件的一些内部状态,使得后面能够进行写入操作了。
2.2. strncpy 函数
标准库中有一个 strcpy
函数,进行 C 风格字符串的拷贝。如下所示:
|
|
但是 strcpy 容易造成缓冲区溢出问题。因为实际使用中你不清楚存放拷贝的字符数组 str 的大小能否容纳字符串 s。 即使 str 的大小不足以储存字符串 s,strcpy 函数仍然会把后面的字符往后面拷贝,这个时候,拷贝的位置可能已经超过了 str 数组的边界了!
为了避免缓冲区溢出造成的安全性问题,需要对超出长度的部分进行截断,一查标准库有一个 strncpy
函数,于是这样写:
|
|
目前 str 的大小足够容纳字符串 s,所以没什么问题。可如果不够呢?
|
|
期望的行为应该是,str 是个合法的 C 风格字符串(以’\0’作为字符串结束符),并且超出 str 所能容纳最大大小的部分会被丢弃。
然而,上面这段代码的输出,其实是:
tes
strncpy 的确如预料中的那样截断超出部分,但是它并没能保证拷贝之后的 str 是个 C 风格的字符串,也即以’\0’作为字符串结束符。 虽然从这个函数的名字来看,似乎应该能保证拷贝之后的字符数组是个合法的字符串。
为了弄清楚 strncpy
函数的具体行为,仔细阅读下 strncpy
的文档,有这么一段:
char *strncpy( char *restrict dest, const char *restrict src, sizet count );
Copies at most count characters of the character array pointed to by src (including the terminating null character, but not any of the characters that follow the null character) to character array pointed to by dest.
If count is reached before the entire array src was copied, the resulting character array is not null-terminated.
也就是说,这个函数的行为是从 src 往 dest 里拷贝字符,最多拷贝 count 个字符。 它根本就不额外处理’\0’字符,如果 count 够了就顺便把’\0’拷进去,不够的话,不够的话就直接截断。
_
所以,正确使用 strncpy 避免缓冲区溢出的做法是:
|
|
这样,如果 str 不够大,strncpy 会将其截断。既然坑爹的 strncpy 没有额外处理’\0’字符,那么我们就额外的把末尾的’\0’给补上。
3. 最后
C 语言是个接近系统底层的语言,拿它编写系统级程序非常合适。 但是 C 语言同时也是门非常古老的语言了,其中不乏有一些历史遗留问题,一些设计上的缺陷,一不留神,就踩到坑了。
对于 C 语言设计之初没有考虑到的特性,后人无奈只能用各种奇淫技巧来弥补。 但是这些奇淫技巧不仅晦涩难懂,不符合人类的思维习惯,还很容易出错。 好在 C 语言的标准是在不断进步的,抛弃传统的奇淫技巧,采用新的标准进行编程,方便又不容易出错。
至于一些设计上的缺陷,语法上的缺陷那没有办法只能自己绕过。 标准库的缺陷很好解决,在了解了缺陷之后,可以自己写一个小小的函数将标准库的函数包装起来并屏蔽其有的小坑即可。