C语言中有很多有趣的特性,这些东西不知道也无妨,若知道了则可以锦上添花,提高生产力或减少bug。
由于这些东西比较杂,内容也不多,不值得专门去介绍他们,也没有合适的地方在其他文章内安插这些,所以就以番外篇的形式做个大杂烩。
申明位置
在C89(C90)以及之前,块内的申明(比如函数内)必须放在块开头(即所有申明在语句之前)。而从C99开始,申明可以放在任何位置。比如以下代码,在C99中可以通过编译,而在C89(C90)中不行。
int main(void) {
int var1 = 0;
var1 = 5;
int var2 = 3; // 不在块开头
}
虽然标准这么规定,但在实现中仍然采用C89(C90)的方法,“可以放在任何位置”只是编译器对代码进行重排的结果。如果用调试器跟踪变量,会发现在进入函数时两个变量就已经申明,只是在各自申明的位置进行初始化。
main函数
受早期没有规范以及谭浩强的影响,很多人喜欢写void main();
。其实这是一种完全错误的写法。
从C89(C90)开始,C标准就规定main
函数必须返回int
类型,正常情况下应返回0
。
C语言代码从main
函数开始执行,为什么返回值如此重要?
代码执行从main
开始,但编译后的二进制程序的执行却并不是从main
开始。在进入main
之前,操作系统还有很多工作要做,如初始化环境和传递参数等,main
的返回值用于判断程序是否正常退出,这对于Shell很重要。
如果main
函数忘记写return 0;
,那么编译时会自动补上,这也是C标准规定的。(但是其他本该有返回值的函数如果没有return
,其行为是未定义的)
说到传递参数,C标准规定了两种main
函数参数形式:
int main(void);
int main(int argc, char **argv); // int main(int argc, char *argv[]);
参数可以为空或者两个参数,参数名可以任意,但类型必须为第一个int
,第二个char **
。
同时,C标准规定可以针对不同平台扩展参数。(以下内容摘抄自维基百科:Entry_point)
比如在Unix和Windows中可以通过第三个参数传递环境变量:
int main(int argc, char **argv, char **envp);
基于Darwin的操作系统(例如macOS)具有第四个参数,该参数包含操作系统提供的任意信息:
int main(int argc, char **argv, char **envp, char **apple);
此外,int main();
也是可以通过编译的。在C++中int main();
与int main(void);
相同,但在C中是不同的。C语言中如果函数的参数列表为()
,表示不确定参数的数量,可以向函数传递任意数量的参数。如果要表示没有参数,需要添加关键词void
。
一般而言,第一个参数argc
表示操作系统调用此程序的参数计数,包括文件名。第二个参数argv
表示参数向量,为一个指针数组,数组中的每个指针指向一个字符串(此处的数组和字符串都不是严谨的意义)。第一个字符指针所指的字符串是文件名,具体内容与调用程序时终端的写法一致;最后一个字符指针是个空指针,也就是说argv[argc] == (char *)0
。
比如,调用一个名为a.out
的程序:
$ ./a.out I love C
argc == 4;
argv[0] == "./a.out";
argv[1] == "I";
argv[2] == "love";
argv[3] == "C";
argv[4] == (char *)0;
argv[0]
的内容与终端的写法完全一致,如果执行程序通过PATH找到,argv[0]
不会把PATH路径添加到文件名前,所以不能通过argv[0]
确定程序所在目录。
以0作为参数向量结尾与字符串末尾的0有相同的妙用。
有趣的是,即使main
写为int main(void);
,表明不接受参数,但在终端调用程序时仍然可以输入参数,并且这些参数也会传递给程序,只是我们在程序内无法使用这些参数。
switch 语句
switch语句大概是C语言中最令人讨厌的语句了,不仅几乎用不到,语法还特别麻烦。(所以某些语言删除了switch语句)
swith语句的原理其实就是匹配标签与跳转,跟goto语句类似,可以看成是goto语句的增强魔改版。
首先,switch语句只支持整数匹配,比如int
、char
,不支持float
、double
等浮点数。因为浮点数在计算机中使用二进制表示,并不能完全准确地表示十进制小数,尤其在进行计算后尾数很不确定,几乎不可能按照预期匹配。
switch语句独创了case
标签。在“语言结构”那篇我讲过,case
标签只能用在switch语句内,标签作用域只是当前switch语句而不是当前函数,并且case
标签允许且只允许标签名使用整数类型常量表达式。整数可以理解,上一段已经说了switch只支持整数匹配;因为要进行匹配,要求一个返回值,所以得是表达式;至于常量,case
标签毕竟是个标签,在编译时就必须得到确定的值,而变量的值在运行时才能确定,所以只能是常量表达式。
case
标签中的表达式在编译时进行计算,所以下面两个标签相同,如果在同一个swith语句中编译会报错。
case 0 + 2:
case 1 + 1:
由于switch语句只是如goto般的匹配标签与跳转,跳转后就按照顺序结构继续执行了,不再理会其他标签,所以才通常在switch语句内使用break跳出,只执行需要的部分。不过,也可以利用这个特性让多个匹配结果执行相同的操作。
switch (var) {
case 0:
case 1:
case 2:
printf("var < 3");
break;
default:
printf("var >= 3");
}
此外,switch语句内的申明必须写在块内,以避免跳过初始化。如下:
switch (var) {
case 0:
{
int a = 5; // 编译通过
printf("%d\n", a);
}
case 1:
int a = 5; // 编译报错
printf("%d\n", a);
}
for 语句
for语句的存在是为了简化循环,三元运算符 ? :
也是这样。
for语句紧跟的括号内是由分号;
分隔的三个表达式(是的,此处的;
是分隔符而不是语句结尾,三个分量都是表达式而不是语句),三个表达式都可以省略(直接空着就行,不能写void
),如果中间的表达式为空,默认为1。
PS:if
、while
等语句括号内的判断条件不能省略。
虽然申明函数时在函数名前加void
表示没有返回值,但函数调用表达式仍然会返回一个void
类型的值,但这个值不能被使用,只能丢弃。
for语句括号内的第二个表达式是循环的判断条件,如果此处调用了一个void
函数,那么void
值就被使用了,编译就会出错,除此之外,这个位置可以是任意表达式。至于另外两个表达式则没有任何限制。
从C99开始,for语句括号内的第一个表达式可以换成一个不完全的申明(完全的申明以分号结尾,而此处的分号是分隔符,不是申明的一部分)。
for语句括号内变量的作用域小于for所在的块,大于for语句内部的块。举例如下:
int iter = 0;
for (int iter = 0; iter < 5; iter++) {
int iter = 0;
printf("%d\n", iter++);
}
printf("%d\n", iter);
在这段代码中申明了三个iter
变量,分别在for语句外,for语句的括号内,for语句的块内。
首先申明了最外层的iter
,接着进入for语句,又申明了一个iter
。
括号内的空间可以看作是外层的一个子域,所以可以申明和外层同名的变量,并且屏蔽掉了外层的iter
。
括号中另外两个iter
都是在括号内申明的那个iter
。
然后进入for语句内的块,又申明了一个iter
。
这个块内部可以看作是括号内空间的子域,所以此处申明的iter
又屏蔽掉了外层的两个iter
。
每次循环都会重新申明一个iter
并初始化为0,所以每次循环输出一个0
,自增操作没有实际作用。
由于块内的iter
屏蔽掉了括号内的iter
,所以控制条件不受块内代码的影响,循环执行5次。
跳出循环后,for语句的两个iter
都被销毁,打印的是最外层的的iter
。
所以程序的执行结果是输出6个0
。
如果for语句的块内有continue;
语句,那么在执行此语句后会跳过块内的剩余代码,并执行for语句括号内的第三个表达式,之后才是判断条件。
如果for语句的块内有break;
语句,那么在执行此语句后会直接跳出循环,不执行for语句括号内的第三个表达式。
所以在for语句内使用continue;
和break;
语句都是安全的。
字符串常量
所谓字符串常量,就是指"fake_string"
这种类型的东西。
众所周知,C语言没有真正意义上的字符串类型,只是以0
结尾的字符数组。
"fake_string"
并没有它看上去那么简单,实际上它做了很多事。首先,申请一段连续的内存空间,依次放入char
字符f
、a
、k
、e
、_
、s
、t
、r
、i
、n
、g
、\0
(注意最后补了个0),然后返回char
数组。与数组一样,字符串常量也可以当指针来使用,类型为char *
,且这个指针有顶层const
属性。
既然可以当指针来用,那么指针运算在字符串常量上也适用。
"string"[0] == 's';
"string"[6] == 0;
char *ch = "string";
字符串常量也可以像其他指针一样作为if
、while
等的判断条件。
不过,和数组一样,对字符串常量使用sizeof
运算符得到的是所占内存空间的大小。
对字符串常量取地址得到的是一个行指针。
(二者都包括末尾的0)
sizeof("1234") == 5;
char (*pt)[5] = &"1234";
结构体
定义
首先,谁都知道但经常忽略的一点,定义结构体末尾必须加分号。
结构体定义由4部分组成,struct
关键字、结构体名、成员列表、结构体变量。
一个完整的结构体定义可以写为:
struct StructName {
int member1;
char member2;
} var, *pt;
如果要使用此结构体来申明变量,可以写为:
struct StructName var2;
其中struct StructName
是一个完整的类型名。
在结构体定义中,结构体名和结构体变量可以省略。如果省略结构体名,就没有办法在其他地方使用此结构体,所以不应该同时省略结构体名和结构体变量。
结构体定义仅仅是类型定义,不能对结构体成员初始化。(C++可以指定成员默认值)
除了定义,也可以申明一个结构体。仅需struct
关键字和结构体名。
struct StructName;
申明的结构体甚至可以没有定义(只要不去使用它)。
结构体也可以和typedef
结合使用,该类型定义可以在结构体定义之前。同样,如果没有使用此类型,结构体也可以没有定义。
typedef struct StructName TypeName;
结构体名,类型名分别属于不同的名字空间,也就是说,这两个名称可以相同。如下:
typedef struct TreeNode TreeNode;
struct TreeNode {
int data;
TreeNode *left;
TreeNode *right;
};
不过还是建议使用不同的名字,避免混淆。
结构体的成员可以是结构体变量。如:
struct S1 {
int member1;
int member2;
};
struct S2 {
struct S1 member1;
int member2;
};
或者
struct S1 {
struct S2 {
int member1;
int member2;
} member1;
int member2;
};
C语言的名字空间不能嵌套,在第二例中,尽管S2
在S1
内部定义,但二者的作用域相同。(这点与C++不同)
初始化
和数组一样,结构体也可以使用列表初始化。列表按照成员的申明顺序依次初始化,没有指定的成员初始化为0。
对于包含结构体成员的结构体,可以使用类似多维数组那样的初始化方法。
struct S1 {
struct S2 {
int member1;
int member2;
} member1;
int member2;
};
struct S2 var1 = { 1, 2 };
struct S1 var2 = { { 1, 2 }, 3 };
从C99开始,结构体也支持指定初始化,如下:
struct S1 var = { .member1.member2 = 1, .member2 = 2 };
PS:C++从C++20开始支持指定初始化,用法稍有不同。
运算
显然,结构体可以进行'&'
运算与'.'
运算,结构体指针还可以进行将'*'
和'.'
结合起来的'->'
运算。
特别地,相同类型的结构体可以进行赋值运算。这将产生两个完全一样的结构体。
除此之外,结构体不能进行算术运算、条件运算、逻辑运算等,也不能作为if等语句的判断条件。
内存对齐
如果对一个结构体使用sizeof
运算符,会发现它的大小并不一定等于成员大小之和。
C为了提高效率,采用了内存对齐,是一种牺牲空间换时间的方案。
内存对齐可以简述为:
- 结构体的第一个成员的偏移量为0
- 每个成员相对于结构体起始地址的偏移量是该成员大小的整数倍
- 结构体的最终大小是体积最大成员的整数倍
- 结构体的最终大小是满足上述条件的最小值
关于结构体的内存对齐,网上有很多很详细的说明,我就不再赘述。
其实,在C中处处都有内存对齐,比如定义变量,只是我们一般无需关心。
整数提升
为了提高运算效率,C在运算时会将比int
小的整数类型提升为int
类型。
这是因为int
的大小一般就是处理器的字长。虽然内存按字节编址,但处理器是以字为单位处理数据的,这种提升是硬件层面的语言优化。
整数提升也能在一定程度上避免短类型运算产生溢出。
char a = 0, b = 0;
printf("%d\n", sizeof(a + b));
上面的代码将输出4
。
事实上,除了在字符串里为了节省空间而使用char
类型以外,C几乎不会使用char
类型。
比如字符常量'a'
,其实是int
类型。(C++中为char
)
sizeof('a') == 4;
此外,我们熟知的getchar
、fgetc
等函数也是返回int
类型。
C中的字符常量还有另外一个特性,比如有这些写法:'ab'
、'abc'
、'abcd'
。
这些可都不是字符串,而是int
类型整数。因为int
类型的大小一般是char
类型的4倍,所以可以容纳最多4个ASCII
字符。
暂且不管大小端存储,只从逻辑上分析。以‘abcd’
为例,将这4个字符的二进制码依次排列,再组合成一个数,就是‘abcd’
的值。为了简便,我用十六进制表示二进制。
'a' == 0x61;
'b' == 0x62;
'c' == 0x63;
'd' == 0x64;
'abcd' == 0x61626364;
'abcd' == 1633837924;
关于浮点数,C也几乎不使用float
,因为其精度太低。
我们常用的浮点数常量如0.5
其实是double
类型,math,h中接受或返回浮点数的函数也几乎都是double
类型。
布尔类型
C并不原生支持布尔类型,直到c99才引入了关键字及类型_Bool
。
而我们常用的stdbool.h头文件的主要内容只有:
#define bool _Bool
#define true 1
#define false 0
虽然理论上布尔类型只有1bit,但实际上它占用1个字节。
_Bool
类型只有0
和1
两个值,任何非0数值赋值给_Bool
变量都转换为1
。
因为并没有原生支持布尔类型,所以在C中条件表达式如1 < 2
、逻辑表达式如1 && 0
的返回值都是int
类型。事实上,一般情况下,在C中使用布尔类型没有实际意义。
PS:C++有对布尔类型的完整支持。
auto
注意:C中的auto
与C++中的auto
完全不同。
C中的auto
表示自动生存期。
auto
大概是C中最没有存在感的东西了,事实上,它确实完全没用。
局部变量(且不带static
)默认为自动生存期,无需指定auto
;而全局变量默认静态生存期,不能指定auto
。
正因如此,C++才将auto
的作用改为了自动判断类型。
函数申明
C中有两种申明函数的方式。传统的“函数申明”以及“函数原型”。
函数申明仅需指定返回值类型和函数名,参数列表为空。
函数原型还需指定参数类型(参数名可选)。
int fn1(); // 传统申明
int fn2(int, double) // 函数原型
int fn3(void) // 函数原型
由于在C中函数参数列表为()
表示不确定参数,所以编译器不会在函数调用时检查参数。
建议在申明函数时使用函数原型,最好连参数名都加上。这样可以让编译器来检查函数调用的正确性,如果使用比较智能的编辑器,还可以在编写代码时获得正确的参数提示。