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
。
C23中如果参数列表为空,则默认是void
,跟C++保持一致。
一般而言,第一个参数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]
确定程序所在目录。
以(char *)0
作为参数向量结尾与字符串末尾的\0
有相同的妙用。
有趣的是,即使main
写为int main(void);
,表明不接受参数,但在终端调用程序时仍然可以输入参数,并且这些参数也会传递给程序,只是我们在程序内无法使用这些参数。
switch 语句
switch
语句大概是C语言中最令人讨厌的语句了,不仅几乎用不到,语法还特别麻烦。(所以某些语言删除了switch
语句)
switch
语句的原理其实就是匹配标签与跳转,跟goto语
句类似,可以看成是goto
语句的增强魔改版。
首先,switch
语句只支持整数匹配,比如int
、char
,不支持float
、double
等浮点数。因为浮点数在计算机中使用二进制表示,并不能完全准确地表示十进制小数,尤其在进行计算后尾数很不确定,几乎不可能按照预期匹配。
switch
语句独创了case
标签。在“语言结构”那篇我讲过,case
标签只能用在switch
语句内,标签作用域只是当前switch
语句而不是当前函数,并且case
标签允许且只允许标签名使用整数类型常量表达式。整数可以理解,上一段已经说了switch
只支持整数匹配;因为要进行匹配,要求一个返回值,所以得是表达式;至于常量,case
标签毕竟是个标签,在编译时就必须得到确定的值,而变量的值在运行时才能确定,所以只能是常量表达式。
case
标签中的表达式在编译时进行计算,所以下面两个标签相同,如果在同一个switch
语句中编译会报错。
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);
}
C23取消了这个限制,但是如果把申明直接写在switch
最外层,会面临和goto
一样的未初始化问题。
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
数组。
这个数组拥有静态生存期,不管是在哪里定义的,程序开始运行它就被创建,程序退出时它才销毁。
这个数组的元素是不能被修改的,如果对其进行修改,行为是未定义的,一般会在运行时发生段错误,因为编译器通常把这个数组存储在只读内存中。
两个完全相同或者结尾相同的字符串字面量,可能共用同一块内存空间,这个行为是由实现定义的。
因为数组名会自动转换成指向其第一个元素的指针,所以指针运算在字符串字面量上也适用。
"string"[0] == 's';
"string"[6] == 0;
char *ch = "string";
字符串字面量也可以像其他指针一样作为if
、while
等的判断条件。
不过,和数组一样,对字符串字面量使用sizeof
运算符得到的是所占内存空间的大小。对字符串字面量取地址得到的是一个行指针。(二者都包括末尾的\0
)
sizeof("1234") == 5;
char (*pt)[5] = &"1234";
需要注意的是,可以用字符串字面量对char
数组进行初始化,这仅仅是一种语法糖,而不是用数组对数组进行初始化(C语言不允许这么做)。下面两个申明是等价的。
char array[6] = "hello";
char array[6] = { 'h', 'e', 'l', 'l', 'o', '\0' };
结构体
定义
首先,谁都知道但经常忽略的一点,定义结构体末尾必须加分号。
结构体定义由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语言也几乎不使用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中使用布尔类型没有实际意义。
在C23中bool
、true
、false
都成为了关键字,而_Bool
和stdbool.h被弃用。但条件表达式和逻辑表达式的返回值依然是int
类型。
PS:C++有对布尔类型的完整支持。
auto
注意:C中的auto
与C++中的auto
完全不同。
C中的auto
表示自动生存期。
auto
大概是C中最没有存在感的东西了,事实上,它确实完全没用。
局部变量(且不带static
)默认为自动生存期,无需指定auto
;而全局变量默认静态生存期,不能指定auto
。
正因如此,C++才将auto
的作用改为了自动判断类型。
C23也将auto
的作用改为了自动判断类型,但跟C++的使用方式略有不同。同时也保留了auto
原本的作用,根据上下文推断要扮演什么角色。
函数申明
C中有两种申明函数的方式。传统的“函数申明”以及“函数原型”。
函数申明仅需指定返回值类型和函数名,参数列表为空。
函数原型还需指定参数类型(参数名可选)。
int fn1(); // 传统申明
int fn2(int, double) // 函数原型
int fn3(void) // 函数原型
由于在C中函数参数列表为()
表示不确定参数,所以编译器不会在函数调用时检查参数。
建议在申明函数时使用函数原型,最好连参数名都加上。这样可以让编译器来检查函数调用的正确性,如果使用比较智能的编辑器,还可以在编写代码时获得正确的参数提示。
C23中只有函数原型一种申明方式,如果函数参数列表为空则默认为void
,跟C++保持一致。