C语言篇:语法细节杂谈(番外篇)

987 阅读11分钟

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语句只支持整数匹配,比如intchar,不支持floatdouble等浮点数。因为浮点数在计算机中使用二进制表示,并不能完全准确地表示十进制小数,尤其在进行计算后尾数很不确定,几乎不可能按照预期匹配。

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:ifwhile等语句括号内的判断条件不能省略。

虽然申明函数时在函数名前加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字符fake_string\0(注意最后补了个0),然后返回char数组。与数组一样,字符串常量也可以当指针来使用,类型为char *,且这个指针有顶层const属性。

既然可以当指针来用,那么指针运算在字符串常量上也适用。

"string"[0] == 's';
"string"[6] == 0;

char *ch = "string";

字符串常量也可以像其他指针一样作为ifwhile等的判断条件。

不过,和数组一样,对字符串常量使用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语言的名字空间不能嵌套,在第二例中,尽管S2S1内部定义,但二者的作用域相同。(这点与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为了提高效率,采用了内存对齐,是一种牺牲空间换时间的方案。

内存对齐可以简述为:

  1. 结构体的第一个成员的偏移量为0
  2. 每个成员相对于结构体起始地址的偏移量是该成员大小的整数倍
  3. 结构体的最终大小是体积最大成员的整数倍
  4. 结构体的最终大小是满足上述条件的最小值

关于结构体的内存对齐,网上有很多很详细的说明,我就不再赘述。

其实,在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;

此外,我们熟知的getcharfgetc等函数也是返回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类型只有01两个值,任何非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中函数参数列表为()表示不确定参数,所以编译器不会在函数调用时检查参数。

建议在申明函数时使用函数原型,最好连参数名都加上。这样可以让编译器来检查函数调用的正确性,如果使用比较智能的编辑器,还可以在编写代码时获得正确的参数提示。