熟悉 Linux 操作系统的朋友都知道,差不多 95% 以上的 Linux 内核代码都是用 C 完成的。对于混迹于 Linux 内核(驱动)的软件开发人员来说,C 的重要性不言而喻。如果把操作系统中的进程管理和文件系统比作屠龙刀倚天剑,那么 C 就是九阳神功,只有内功扎实了,我们才有可能一窥内核中各大子系统的源码级实现原理。所以对于 C 的学习再怎么强调其重要性都不为过。本人有感于之前学习了各位高人前辈的 C 语言著书,结合平时工作,遂整理此文,谈谈那些年我们在 C 开发中踩过的坑,以及如何避免这些坑。如无特殊说明,文中所有例子均使用 Ubuntu14.04、gcc 4.8.2 环境编译测试。闲话不啰嗦,先上几个问题开开胃:
1、如下代码段运行后 a 和 b 的值分别是多少?
int a = 1, b = 2, c = 0;
c = a+++++b;
2、如下代码段打印结果是多少?
unsigned int result;
unsigned char test[2] = {4, 2};
result = test[1]<<8 + test[0];
printf("result = %d\n", result);
3、变量 ptr 分别是什么含义?
const int *ptr;
int const *ptr;
int * const ptr;
const int * const ptr;
4、变量 pf 的是个什么东东?
char * (*(*pf)[3])(char *a);
5、下面的代码有什么问题吗?
struct student {
char *name;
int score;
} stu, *pstu;
int main()
{
pstu = (struct student *)malloc(sizeof(struct student));
strcpy(pstu->name, "Linus");
pstu->score = 99;
free(pstu);
return 0;
}
6、下面的代码打印结果是多少?
int main()
{
int a[5] = { 0x1234, 0x5678, 0x9abc, 0xdef0, 0x1234 };
int *ptr1 = (int *)(&a + 1);
int *ptr2 = (int *)((unsigned long)a + 1);
printf("%x, %x\n", ptr1[-1], *ptr2);
return 0;
}
说到符号那就得先谈谈 C 语言中的字符。在我们学习英语的时候,我们总是以单词为基本单位来分析一个句子,而不是以组成一个单词的一个个字母,因为字母本身并没有什么意义。同样,类比到 C 语言中,单个字符单独拿出来看是没有意义的,必须要结合上下文才能知道其具体含义。比如在《C陷阱与缺陷》一书中,作者举例 p->s = "->"; 这条语句,两个 "-" 字符分别是不同符号的组成部分:第一个 "-" 是取成员运算符的组成部分,第二个 "-" 是一个字符串的组成部分。而且,符号 -> 的含义与组成该符号的字符 "-" 或字符 ">" 的含义也完全不同。
术语符号指的是程序中的一个基本组成单元,其作用相当于一个句子中的单词。
符号 | 名称 | 符号 | 名称 |
---|---|---|---|
, | 逗号 | > | 右尖括号 |
. | 圆点 | ! | 感叹号 |
; | 分号 | | | 竖线 |
: | 冒号 | / | 斜杠 |
? | 问号 | \ | 反斜杠 |
’ | 单引号 | ~ | 波折号 |
“ | 双引号 | # | 井号 |
( | 左圆括号 | ) | 右圆括号 |
[ | 左方括号 | ] | 右方括号 |
{ | 左大括号 | } | 右大括号 |
% | 百分号 | & | and(与) |
^ | xor(异或) | * | 乘号 |
- | 减号 | = | 等于号 |
< | 左尖括号 | + | 加号 |
C 语言中符号可以分为单字符符号和多字符符号。比如+、-、*、/ 是单字符符号,而 /*、->、==、?: 等则是多字符符号。
关于符号,《C陷阱与缺陷》中的经典例子就不得不拿出来说道说道。
y = x/*p /* p指向除数 */;
上面的语句本意似乎是用 x 除以 p 指向的值,把所得的商再赋值给 y。而实际上,符号 /* 被编译器理解为一段注释的开始,编译器将不断的读入字符,直到 */ 出现为止。把上面的表达式修改一下:
y = x / *p /* p指向除数 */;
或者
y = x/(*p) /* p指向除数 */;
这样得到的实际效果才是语句注释所表达的原意。
上面例子的 "罪魁祸首" 其实是编译器,具体来说是编译器中的 "词法分析器"。词法分析器主要负责将程序分解为一个一个符号。当读入 / 字符后紧接着又跟了一个 * ,这个时候编译器必须做出判断:是将其作为两个单独的字符对待,还是作为一个完整的符号对待。以下引用摘自《C陷阱与缺陷》:
C语言对这个问题的解决可以归纳为一个很简单的规则:每一个符号应该包含尽可能多的字符。也就是说,编译器将程序分解成符号的方法是,从左到右一个字符一个字符的读入,如果该字符可能组成一个符号,那么再读入下一个字符,判断已经读入的两个字符组成的字符串是否可能是一个符号的组成部分:如果可能,继续读入下一个字符,重复上述判断,直到读入的字符组成的字符串已不再可能组成一个有意义的符号。这个处理策略有时被称为 "贪心法"。
那么,热身运动中的第一题 a 和 b 的值分别是多少呢?按照 "贪心法" 规则,语句无疑会被解析成 (a++)++ + b,但是我用自己的编译环境测试,发现编译不过:lvalue required as increment operand。第二个自增运算符的前面要求是一个增量操作数,而编译器认为 a++ 并不是一个增量操作数。这个例子取自《C陷阱和缺陷》,早期的编译器可能不会报错,随着更多的 C 标准完善,编译器对这种二义性的表达式应该是做了更严格的检查。举这个例子是为了说明贪心法,绝不是为了推荐大家这么写代码。
下面代码段会输出什么?
int i = 3, j = 2;
while (i-- > 0 || j-- > 0)
printf("i = %d, j = %d\n", i, j);
结果输出:
i = 2, j = 2
i = 1, j = 2
i = 0, j = 2
i = -1, j = 1
i = -2, j = 0
运算符 || 是只要有一个条件为真,那么就为真,但是如果前面条件为真了,那么后面的条件就不会去判断了。同样,运算符 && 是只要前面的条件为假,那么后面的条件也不会去判断。
下面代码段有问题吗?如果非要这个结构应该怎么实现?
int a = 0, b = 0;
int i = 1, j = 2;
(i > j ? a : b) = i * j;
条件操作符跟多数操作符一样,生成一个值,而这个值不能被再次赋值。换言之,不能生成一个 "左值"。如果你真的需要这种结构,可以试试下面这样的代码:
*(i > j ? &a : &b) = i * j;
尽管这毫无优雅可言。
另外,尽量避免嵌套使用条件运算符,这无疑会让代码变得晦涩难懂。
++i 和 i++ 自不必多说,但是并不表示没什么可说。1998就不用回答了,请回答下面的问题:
int i = 1, j = 1, k = 1;
int a = 0, b = 0, c = 0;
a = (i++) + (i++) + (i++);
printf("a = %d, i = %d\n", a, i);
b = (++j) + (++j) + (++j);
printf("b = %d, j = %d\n", b, j);
c = (k++, k++, k++);
printf("c = %d, k = %d\n", c, k);
a 打印出来的值是多少?b 呢?c 呢?
结果输出:
a = 6, i = 4
b = 10, j = 4
c = 3, k = 4
按照我们的习惯性理解,比如根据 a 的输出结果,b 的值难道不应该是 9 吗?关于 b 的表达式有一个特点,那就是 j 被修改了以后又被再次修改,最后赋值给了 b。如果一个对象在一个计算单位内被多次修改,那么这样的代码行为是无法保证其确定性的,不同的编译器输出结果是完全可能不同的。以我的编译环境来说,先计算前两个 j 的和,这时候 j 自加两次,两个 j 的和为 6,然后再加上第三次自加的 j 得 10。那么何为一个计算单位呢?大体上一个计算单位以分号或者逗号结束的表达式,或者是在一个判断语句里,比如while()、if()、switch()等。
类似的求值顺序的例子还有:
int i = 0;
int x[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int y[10] = { 0 };
while (i < 10)
y[i] = x[i++];
代码的本意是把 x 数组的元素一一拷贝到数组 y 中,乍一看也没啥问题,但是这种做法是不对的。上面的代码假设了 y[i] 的地址将在 i 的自增操作执行之前被求值,这一点并没有任何保证!有些C语言的实现中可能会这么做,但是有些可能与此相反。
C语言中只有四个运算符存在规定的求值顺序,即 ||、&&、? : 和,其他所有运算符对操作数的求值顺序都是未定义的。特别地,赋值运算符并不保证任何求值顺序。
C 语言从来没有规定过赋值运算符的左边会先计算,右边的后计算;同样,加减乘除运算符也没有这么规定过。
除了上面的两个例子以外,类似这样的例子还有很多:
int f1(void)
{
...
}
int f2(void)
{
...
}
int f3(void)
{
...
}
int main()
{
f1() + f2() * f3();
return 0;
}
虽然乘法运算的优先级高于加法运算,但是这并不意味着函数 f1() 会最后被调用,或者因为它的返回值是表达式的第一个值而会最先被计算。任何先预设这样的先后计算顺序的代码逻辑都是无法保证其确定性的!尤其是当这三个函数会修改同一个变量的时候。尽管我用我的编译环境测试再三,三个函数按照从左至右的顺序完成了计算,我们还是不推荐这种写法。如果你需要确保子表达式的计算顺序,建议最好是使用明确的临时变量或者独立的语句。这么做的好处很显然避免了代码移植性带来的问题。
下面这张 C 语言的运算符优先级表摘自《C语言深度剖析》一书:
优先级 | 运算符 | 名称或含义 | 使用形式 | 结合方向 | 说明 |
---|---|---|---|---|---|
1 | [] | 数组下标 | 数组名[常量表达式] | 左到右 | - |
() | 圆括号 | (表达式)/函数名(参数表) | |||
. | 成员选择(对象) | 对象.成员名 | |||
-> | 成员选择(指针) | 对象指针->成员名 | |||
2 | - | 负号运算符 | -表达式 | 右到左 | 单目运算符 |
(类型) | 强制类型转换 | (数据类型)表达式/变量名 | - | ||
++ | 自增运算符 | ++变量名/变量名++ | 单目运算符 | ||
-- | 自减运算符 | --变量名/变量名-- | 单目运算符 | ||
* | 取值运算符 | *指针变量 | 单目运算符 | ||
& | 取地址运算符 | &变量名 | 单目运算符 | ||
! | 逻辑非运算符 | !变量名 | 单目运算符 | ||
~ | 按位取反运算符 | ~表达式 | 单目运算符 | ||
sizeof | 长度运算符 | sizeof(表达式) | - | ||
3 | / | 除 | 表达式 / 表达式 | 左到右 | 双目运算符 |
* | 乘 | 表达式 * 表达式 | 双目运算符 | ||
% | 取余 | 整型表达式 % 整型表达式 | 双目运算符 | ||
4 | + | 加 | 表达式 + 表达式 | 左到右 | 双目运算符 |
- | 减 | 表达式 - 表达式 | 双目运算符 | ||
5 | << | 左移 | 变量 << 表达式 | 左到右 | 双目运算符 |
>> | 右移 | 变量 >> 表达式 | 双目运算符 | ||
6 | > | 大于 | 表达式 > 表达式 | 左到右 | 双目运算符 |
>= | 大于等于 | 表达式 >= 表达式 | 双目运算符 | ||
< | 小于 | 表达式 < 表达式 | 双目运算符 | ||
<= | 小于等于 | 表达式 <= 表达式 | 双目运算符 | ||
7 | == | 等于 | 表达式 == 表达式 | 左到右 | 双目运算符 |
!= | 不等于 | 表达式 != 表达式 | 左到右 | 双目运算符 | |
8 | & | 按位与 | 表达式 & 表达式 | 左到右 | 双目运算符 |
9 | ^ | 按位异或 | 表达式 ^ 表达式 | 左到右 | 双目运算符 |
10 | | | 按位或 | 表达式 | 表达式 | 左到右 | 双目运算符 |
11 | && | 逻辑与 | 表达式 && 表达式 | 左到右 | 双目运算符 |
12 | || | 逻辑或 | 表达式 || 表达式 | 左到右 | 双目运算符 |
13 | ? : | 条件运算符 | 表达式1 ? 表达式2 : 表达式3 | 右到左 | 三目运算符 |
14 | = | 赋值运算符 | 变量 = 表达式 | 右到左 | - |
/= | 除后赋值 | 变量 /= 表达式 | |||
*= | 乘后赋值 | 变量 *= 表达式 | |||
%= | 取余后赋值 | 变量 %= 表达式 | |||
+= | 加后赋值 | 变量 += 表达式 | |||
-= | 减后赋值 | 变量 -= 表达式 | |||
<<= | 左移后赋值 | 变量 <<= 表达式 | |||
>>= | 右移后赋值 | 变量 >>= 表达式 | |||
&= | 按位与后赋值 | 变量 &= 表达式 | |||
^= | 按位异或后赋值 | 变量 ^= 表达式 | |||
|= | 按位或后赋值 | 变量 |= 表达式 | |||
15 | , | 逗号运算符 | 表达式, 表达式, ... | 左到右 | 从左向右顺序运算 |
热身运动里的第二题做对了吗?+ 的优先级高于 << !可以说运算符优先级是 C 语言中最容易出错的地方之一。但是这张优先级表也不是特别容易记住,不过我觉得也不用特意去记,用得多自然就记住了。请记住一条铁律:不确定优先级的地方一律加括号。但是也有人说了,自己写代码加括号当然是没问题,但是如果看别人写的代码呢?所以记住这张表还是很有帮助的。
下表是前辈们整理出来的易错的优先级问题,非常具有代表性:
优先级问题 | 例子 | 经常误认为的结果 | 实际结果 |
.的优先级高于* ->操作符用于消除这个问题 |
*p.f | p 所指对象的字段 f (*p).f |
对 p 取 f 偏移,作为指针, 然后进行解除引用操作。*(p.f) |
[ ]高于* | int *ap[ ] | ap 是个指向 int 数组的指针 int (*ap)[ ] |
ap 是个元素为 int 指针的数组 int *(ap[ ]) |
函数()高于* | int *fp() | fp 是个函数指针,所指函数返回 int int (*fp)() |
fp 是个函数,返回 int * int *(fp()) |
== 和 != 高于位操作 | (val & mask != 0) | (val & mask) != 0 | val & (mask != 0) |
== 和 != 高于赋值符 | c = getchar() !=EOF | (c = getchar()) !=EOF | c = (getchar() != EOF) |
算术运算符高于位移运算符 | msb << 4 + lsb | (msb << 4) + lsb | msb << (4 + lsb) |
逗号运算符在所有运 算符中优先级最低 |
i = 1, 2 | i = (1, 2) | (i = 1), 2 |
++高于* | *p++ | 取指针p所指的对象,然后将该对象自增1 (*p)++ |
取指针p所指的对象,然后将p自增1 *(p++) |
C语言易错点汇总(二)链接:
https://blog.csdn.net/weixin_43555423/article/details/89319795