VectorLu

C 语言 0 从入门到并不精通

C 语言是古老而强大的语言,时至今日,应该怎样写 C 程序呢?

C 语言基础回顾与思考。

第 2 章 C 基本概念

一个 C 语言程序

编译和链接

要把程序转化成机器可以执行的形式。通常包含下列 3 个步骤

预处理

程序被送给预处理器 (preprocessor)。预处理器执行以 # 开头的命令——通常称为指令 (directive)。预处理器有点类似于编辑器,可以给程序添加内容、进行修改。

编译

编译器 (compiler) 按 .c 文件为单位,将每个文件翻译成机器指令。

链接

最后,链接器 (linker) 把由编译器产生的目标代码和所需的其他附加代码整合在一起,这样才产生完全可执行的程序。这些附加代码包括程序中用到的标准库函数等。

简单程序的一般形式

即时是最简单的 C 程序也依赖 3 个关键的语言特性:指令(在编译前修改程序的编辑命令)、函数(被命名的可执行代码块,如 main 函数)和语句(程序运行时执行的命令)。

指令 directive

在编译 C 程序之前,预处理器会首先会首先对其进行编辑。把预处理器执行的命令称为指令。

函数 function

事实上,C 程序就是函数的集合。函数分为两大类:一类是程序员编写的函数,另一类是作为 C 语言实现的一部分提供的函数,后者被称为库函数,因为它们属于一个由编译器提供的函数“库”。

语句 statement

语句是程序运行时执行的命令。每条语句要以分号结尾(但是符合语句不以分号结尾)。指令通常只占一行,不需要用分号结尾。

注释

各种形式的注释,比如“盒形”注释。

C99 提供 // This is a comment. 的注释。
这种注释风格的两个主要优点:

  1. 注释会在行末自动终止,所以不会出现未终止的注释意外吞噬部分程序的情况;
  2. 因为每行前面都必须有 //,所以多行的注释更加醒目。

变量和赋值

类型

每个变量都必须有一个类型 type

声明

使用变量之前,必须先对其进行声明。
注意:
在 C 语言中,声明的语法并非

1
类型 标识符;

而是

1
基本类型 生成基本类型的东西

赋值

定义常量的名字

当程序含有常量时,建议给这些常量命名。建议使用宏定义的特性给常量命名:

1
#define INCHES_PER_POUND 166

当宏包含运算符时,必须用括号把表达式括起来。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Name: celsius.c
// Purpose: Convert a Fahrenheit temperature to Celsius.
#include <stdio.h>
#define FREEZING_PT 32.0f
#define SCALE_FACTOR (5.0f / 9.0f)
int main(void)
{
float fahrenheit, celsius;
printf("Enter Fahrenheit temperature: ");
scanf("%f", &fahrenheit);
celsius = (fahrenheit - FREEZING_PT) * SCALE_FACTOR;
printf("Celsius equivalent: %.1f\n", celsius);
return 0;
}

标识符

C89 标准声称标识符可以任意长,但却只要求编译器记住前 31 个字符(C99 中是 63 个字符)。

对于具有外部链接的标识符:
C89 中只有前 6 个字符有效,且不区分大小写。
C99 中,前 31 个字符有效,且字母区分大小写。

C 程序的书写规范

每条预处理指令都要求独立成行。

程序中记号的空格没有严格限制,除非两个记号合并后会产生第三个记号。

gcc

gcc 的常用选项:

-Wall 使编译器检测到可能的错误时生成警告消息。

-W 除了 -Wall 生成的警告信息外,还需要针对具体情况的额外警告消息。

-pedantic 根据 C 标准的要求生成警告消息。这样可以避免在程序中使用非标准特性。

-ansi 禁用 gcc 的非标准特性,并启用一些不太常用的标准特性。

-std=c89-std=c99 指明使用哪个版本的 C 编译器来检查程序。

第 3 章 格式化输入/输出

TODO

printf()

转换说明

%m.pX 格式或 %-m.pX 格式

m 表示最小字段宽度 minimum field width
p 表示精度
X 表示格式

第 4 章 表达式

算术运算符

% 要求操作数是整数,否则编译无法通过。

当运算符 / 和运算符 % 用于负操作数时,结果难以确定。

根据 C89 标准,如果两个操作数中有一个为负数,那么除法的结果可能是向上取整也可能是向下取整。(例如,-9/7 的结果既可以是 -1 也可以是 -2。)取决于编译器。i%j 的符号与具体实现有关。(例如,-9 % 7 的值可能是 -2 或者 5。)

根据 C99 标准,除法的结果总是向零截取(因此 -9/7 的结果是 -1),i%j 的值的符号与 i 的符号相同(因此 -9%7 的值是 -2)。

赋值运算符

在许多编程语言中,赋值是语句;然而在 C 语言中,赋值就像 + 那样是运算符。换句话说,赋值操作产生结果,这就如同两个数相加产生结果一样。赋值表达式 v=e 的值就是赋值运算后 v 的值。因此,下面的表达式 i = 72.99f 的值是 72(不是 72.99)。将值赋给 i 是赋值运算符的副作用。

1
2
3
4
5
int i;
float f;
i = 72.99f; // i is now 72
f = 136; // f is now 136.0

既然赋值是运算符,那么多个赋值可以串联在一起(但是笔者不建议这样做),比如:

1
i = j = k = 0;

注意隐式类型转换,串在一起的赋值运算的最终结果可能不是预期的结果:

1
2
3
4
int i;
float f;
f = i = 33.3f;

由上可以发现串联赋值容易引发一些隐蔽的错误,建议不要这样做。

左值

赋值运算符要求其左操作数必须是左值

左值表示存储在计算机内存中的对象,而不是常量或计算的结果。变量是左值。

在赋值表达式的左侧放置任何其它类型的表达式都是不合法的:

1
2
3
12 = i; // wrong
i + j = 0; // wrong
-i = j;

编译器会检测出这种错误,并给出诸如 “invalid lvalue in assignment” 这样的错误消息。

复合赋值

v += e 不等价于 v = v + e
从优先级的角度看:表达式 i *= j + k 和表达式 i = i * j + k 是不一样的。

自增自减

后缀 ++ 和后缀 -- 比一元的正号、符号优先级高,且为左结合。
前缀 ++ 和前缀 -- 和一元的正号、符号优先级相同,且为右结合。

看懂了也懒得记,记住了也懒得在程序中分辨对吗?那就不要在复杂的表达式中滥用 ++--

计算 v += e 只会求一次 v 的值,而计算 v = v + e 会计算两次 v 的值。注意下面这个例子:

1
2
3
a[i++] += 2;
a[i++] = a[i++] + 2;

复杂表达式求值

这个部分可以先跳过不看。

TODO

也许会碰到很令人苦恼的复杂表达式(所以真的不要写这样的表达式哦(・ω・)ノ),那就借助官方的 C 语言运算符表,找到最高优先级的运算符,用圆括号将运算符和相应的操作数扩起来,将其看作一个单独的操作数。重复上述操作直到将表达式完全加上圆括号。

1
a = b += c++ - d + --e / -f ;

后缀 ++ 优先级最高,在后缀 ++ 和相关操作数的周围加上圆括号

1
a = b

子表达式的求值顺序

表达式语句

任何表达式都可以作为语句

键盘上的误操作很容易造成“什么也不做”的表达式语句。例如本想输入 i = j;,但是却错误地输入 i + j;,因为 =+ 两个字符在键盘的同一键上,很可能发生这样的错误,某些编译器可能会检查出无意义的表达式语句,会显示类似 “statement with no effect” 的警告。

选择语句

逻辑表达式

关系运算符

优先级低于算术运算符,左结合。

if 语句

C89 中的布尔值

由于 C89 标准中没有定义布尔类型,需要自己定义,较好的方案是:

1
2
3
4
5
6
7
8
#define TRUE 1
#define FALSE 0
int flag;
flag = FALSE;
...
flag = TRUE;

C99 中的布尔值

C99 提供了 _Bool 型,_Bool 是无符号整型,所以其实际上就是整型变量;但是和一般的整型不同,_Bool 只能赋值为 0 或 1,一般往 _Bool 变量中存储非零值会导致变量赋值为 1。

1
_Bool flag;

除了 _Bool 类型的定义,C99 还提供了一个新的头 <stdbool.h>,这使得操作布尔值更加容易。该头提供了 bool 宏,用来代表 _Bool。该头文件还提供 truefalse 两个宏。如果程序中包含了 <stdbool.h>,可以这样写:

1
2
3
4
5
6
7
8
9
#include <stdbool.h>
...
bool flag;
flag = true;
...
flag = false;

switch 语句

1
2
3
4
5
6
switch (controlExpression){
case constantExpression0: statements;
...
case constantExpression0: statements;
default: statement
}

三个组成部分:

  1. controlExpression 控制表达式。switch 后边必须跟着由圆括号扩起来的整数表达式。C 语言把字符当做整数来处理,因此在 switch 语句中可以对字符进行判定。但是,不能用浮点数和字符串。
  2. 分支符号。每个分支开头的 case constantExpression,constantExpression 常量表达式像是很普通的表达式,但是不能包含变量和函数调用。因此,5 是常量表达式,5+10 也是常量表达式,但是,n + 10 不是常量表达式(除非 n 是表示常量的宏)。分支符号的常量表达式的值必须是整数(字符也可以)。
  3. 语句。每个分支标号的后边可以跟任意数量的语句。不需要用花括号把这些语句括起来。每组语句的最后一句通常是 break 语句。

忘记使用 break 语句是编程时常犯的错误。虽然有时会忽略 break 以便多个分支共享代码,但通常情况下省略 break 是因为疏忽。

故意从一个分支跳转到下一个分支的情况是非常少见的,因此最好明确指出省略 break 语句的情况。

1
2
3
4
5
6
7
8
9
switch (grade){
case 4: case 3: case 2: case 1:
num_passing++;
// FALL THROUGH
case 0:
total grades++;
break;
}

循环

基本类型

这句话实在是太棒了,摘自《C 语言程序设计 现代方法》

计算机处理的是数而不是符号。我们用对行为的算术化程度来衡量我们的理解力。

读/写

读/写浮点数

读取 double 类型的值时,在 e, f, g 前放置字母 l,只在 scanf 函数格式串中使用 1,不能在 printf函数格式串中使用,e, f, g 用来写 float 和 double 类型的值。C99 允许 printf 函数调用时使用 %le 等,但是字母 l 不起作用。

读/写字符

scanf() 不会跳过空白字符,如果要强制 scanf() 在读入字符前跳过空白字符,需要在格式串中的转换说明 %c 前面加上一个空格:

1
scanf(" %c", &ch);

类型定义

1
typedef int Bool;

将类型名的首字母大写不是必须的,只是 一些 C 语言程序员的习惯。

类型定义的优点

如果选择了有意义的类型名,类型定义时程序更加容易理解。例如,假设变量 cash_in 和变量 cash_out 将用于存储美元数量。把 Dollars 声明成

1
typedef float Dollars;

并且随后写出

1
Dollars cash_in, cash_out;

这样的写法比下面的写法更有实际意义:

1
float cash_in, cash_out;

类型定义还可以使程序更容易修改。如果稍后决定 Dollars 实际应该定义为 double 类型,那么只需要改变类型定义就可以了:

1
typedef double Dollars;

数组

一维数组

数组下标

数列反向。在下面的程序中将看到,宏和数组联合使用非常有效,如果以后要改变数组的大小,只需要编辑 N 的定义并且重新编译程序就可以了,甚至连提示也仍然是正确的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Name: reverse.c
// Purpose: Reverses a series of numbers.
#include <stdio.h>
#define N 10
int main(void)
{
int a[N], i;
printf("Enter %d numbers: ", N);
for (i = 0; i < N; i++)
{
scanf("%d", &a[i]);
}
printf("In reverse order:");
for (i = N-1; i >= 0; i--)
{
printf(" %d", a[i]);
}
printf("\n");
return 0;
}

数组初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <stdio.h>
int main()
{
// 整形数组的初始化
int arrayInt[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
// 如果初始化式比数组短,那么数组中剩余的元素赋值为 0
int a[10] = {1, 2, 3, 4};
// initial value of a is {1, 2, 3, 4, 0, 0, 0, 0, 0, 0}
// 利用这一特性,可以很容易地把数组初始化为全 0
// 初始化式完全为空是非法的,所以要在大括号中放上一个 0
int arrayLazy[10] = {0};
int i;
// 字符型数组的初始化
// 无尺寸数组,C 自动完成计算数组大小的工作
char arrayChar1[] = "How are you?";
char arrayChar2[13] = "How are you?";
char arrayChar3[13] = {'H', 'o', 'w',
' ', 'a', 'r', 'e', ' ', 'y', 'o', 'u', '!', '\0'};
int array2D[4][4] = {
{12, 18, 6, 25},
{23, 10, 32, 16},
{25, 63, 1, 63},
{0, 0, 27, 98}
};
// 对于二维无尺寸数组的初始化,
// 数组第二个下标的尺寸是必须给出的
int array2Dx6[][2] = {
{1, 50},
{45, 2},
{2, 0},
{12, 32},
{42, 33},
{15, 18}
};
for (i = 0; i < 10; i++)
{
printf("%d ", arrayLazy[i]);
}
printf("\n");
return 0;
}

指定初始化式

经常有这样的情况:数组中只有相对较少的元素需要进行显示的初始化,而其他元素可以进行默认赋值。

1
int a[15] = {0, 0 , 29, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, 48};

如果对于较大的数组,这样的情况下赋值就更为麻烦——两个非 0 元素之间有 200 个 0 的情况。

C99 中的指定初始化式可以用于解决这一问题。上面的额例子可以使用指定初始化写为:

1
int a[15] = {[2] = 29, [9] = 7, [14] = 48};

除了可以使赋值变得更简短、更易读之外,指定初始化式还有一个优点:赋值的顺序不再是一个问题,我们也可以将先前的例子重新写为:

1
int a[15] = {[14] = 48, [9] = 7, [2] = 29};

指示符必须是整型常量表达式。如果待初始化的数组长度为 n,则么个指示符的值都必须在 0 到 n - 1 之间。但是如果数组的长度是省略的,指示符可以是任意非负整数;对于后一种情况,编译器将根据最大的提示符推断出数组的长度。在接下来的例子中,指示符最大值为 23,因此数组的长度为 24:

1
int b[] = {[5] = 10, [23] = 13, [11] = 33, [15] = 29};

初始化式中可以同时使用老方法和新方法,但是个人觉得这样会有一点混乱:

1
2
int mix[10] = {5, 1, 9, [4] = 3, 7, 2, [8] = 6};
// 5 1 9 0 3 7 2 0 6 0

其实介绍这种特性只是为了能看懂别人这样写的程序,如果不支持 C99 的环境,其实,用笨办法 for 和 if 的组合依然能达到这样的效果,虽然写法上没有这么简洁,本质上都是一样的。

检查数中重复出现的数字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Name: repdigit.c
// Checks numbers for repeated digits
#include <stdbool.h>
#include <stdio.h>
int main(void)
{
bool digit_seen[10] = {false};
int digit;
long n;
printf("Enter a number: ");
scanf("%ld", &n);
while (n > 0) {
digit = n % 10;
if (digit_seen[digit])
{break;}
digit_seen[digit] = true;
n /= 10;
}
if (n > 0)
{printf("Repeated digit\n");}
else
{printf("No repeated digit\n");}
return 0;
}

这个程序用到了 bool truefalse 等名称,它们在 C99 的 <stdbool.h 头中定义。如果编译器不支持,需要自己定义,在 main() 的上面加上以下代码:

1
2
3
#define true 1
#define false 0
typedef int bool;

对数组使用 sizeof 运算符

运算符 sizeof 可以确定数组的大小(字节数)。如果数组 a 有 10 个整数,那么 sizeof(a) 通常为 40。用数组大小除以数组元素的大小可以得到数组的长度:

1
sizeof(a)/sizeof(a[0]);

当需要数组长度时,可采用上述表达式,比如,数组清零操作:

1
2
3
4
5
6
#define SIZE ((int)(sizeof(a)/sizeof(a[0])))
for (i = 0; i < SIZE; i++)
{
a[i] = 0;
}

如果使用这种方法,即使数组长度在日后需要改变,也不需要改变循环。当然,利用宏也有类似的效果。

二维数组

多维数组初始化

如果初始化式没有达到足以填满整个多维数组,那么把数组中剩余的元素赋值为 0。例如,下面的初始化式只填充了数组 m 的前三行,后面两行将赋值为 0:

1
2
3
4
5
int m [5][9] = {
{1, 1, 1, 1, 1, 1, 1, 1, 1},
{1, 1, 1, 1, 1, 1, 1, 1, 1},
{1, 1, 1, 1, 1, 1, 1, 1, 1}
};

如果内层的列表没有达到足以填满数组的一行,那么把此行剩余的元素初始化为 0:

1
2
3
4
5
6
int m[5][9] = {
{1, 1, 1, 1, 1, 1, 1, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 1, 1, 1, 1, 1},
{1, 1, 1, 1, 1, 1, 1}
};

C99 的指定初始化式对多维数组也有效。例如:

1
double ident[2][2] = {[0][0] = 1.0, [1][1] = 1.0};

其中没有指定的值都默认设置为 0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// Name: deal.c
// deals a random deck of cards
// 程序负责发一副标准纸牌
// 每张标准纸牌都有一个花色
//(梅花♣️ clubs、方块♦️ diamonds、红心♥️ hearts 或黑桃♠️ spades)
// 为了避免两次都拿到同一张牌,需要记录已经选择过的牌
// 用 in_hand 的二维数组,4 row - 13 column
// 数组中每个元素对应着 52 张纸牌中的一张。
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define NUM_SUITS 4
#define NUM_RANKS 13
int main(void)
{
bool in_hand[NUM_SUITS][NUM_RANKS] = {{false}};
int num_cards, rank, suit;
const char rank_code[] = {'3', '4', '5',
'6', '7', '8', '9', 't', 'j', 'q', 'k', 'a', '2'};
const char suit_code[] = {'c', 'd', 'h', 's'};
srand((unsigned) time(NULL));
printf("Enter number of cards in hand: ");
scanf("%d", &num_cards);
printf("Your hand:");
while (num_cards > 0)
{
// picks a random suit
suit = rand() % NUM_SUITS;
// picks a random rank
rank = rand() % NUM_RANKS;
if (!in_hand[suit][rank])
{
in_hand[suit][rank] = true;
num_cards--;
printf(" %c%c", rank_code[rank], suit_code[suit]);
}
}
printf("\n");
return 0;
}

C99 中的变长数组

主要限制是它们没有静态存储期限,另一个限制是变长数组没有初始化式。

C99 不允许 goto 语句绕过变长数组的声明。因为在程序执行的过程中,遇到变长数组时通常就为该变长数组分配内存空间了。用 goto 语句绕过变长数组的声明可能会导致程序对未分配空间的数组中的元素进行访问。

数组的复制

逐个复制

1
2
3
4
for (i = 0; i < N; i++)
{
a[i] = b[i]'
}

memcpy()

使用来自 <string.h> 头的函数 memcpy(),它是一个底层函数,把字节从一个地方简单复制到另一个地方。将数组 b 复制到数组 a 中,使用的格式如下:

1
memcpy(a, b, sizeof(a));

许多程序员倾向于使用 memcpy(),特别式处理大型数组时,因为它潜在的速度比普通循环更快。

第 9 章 函数

函数的定义和调用

函数定义

1
2
3
4
5
返回类型 函数名(形式参数列表)
{
声明
语句
}

如果返回类型很冗长,比如 unsigned long int 类型,那么把返回类型单独放在一行是非常有用的。

函数调用

如果丢失圆括号(没有参数的时候有可能就忘记了写括号)就无法进行函数调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Name: prime.c
// Purpose: Test whether a number is prime.
#include <stdbool.h>
#include <stdio.h>
bool is_prime(int n)
{
int divisor;
if (n <= 1){return false;}
for (divisor = 2; divisor*divisor <= n; divisor++)
{
if (n % divisor == 0){return false;}
}
return true;
}
int main(void)
{
int n;
printf("Enter a number: ");
scanf("%d", &n);
if (is_prime(n))
{printf("Prime\n");}
else
{printf("Not prime\n");}
return 0;
}

函数声明

最好不要省略形式参数的名字,因为这些名字可以说明每个形式参数的目的,不过
《C 语言程序设计 现代方法》中提到:

问:为什么有的程序员在函数原型中故意省略参数名字?保留这些名字不是更方便吗?
答:省略原型中的参数名字通常是出于防御目的。如果恰好有一个宏的名字跟参数一样,预处理时参数的名字会被替换,从而导致相应的原型被破坏。这种情况在一个人编写的小程序中不太可能出现,但是在很多人编写的大型应用程序是可能出现的。

实际参数

实际参数通过值传递,形式参数的修改不会影响到相应的实际参数。

实际参数的转换

TODO

变长数组的形式参数

多种函数原型写法:

1
int sum_array0(int n, int a[n]);

另一种写法是用 *(星号)取代数组长度:

1
int sum_array1(int n, int a[*]);

使用 * 的理由是:函数声明时,形式参数的名字时可选的。如果第一个参数定义被省略了,那么就没有办法说明数组 a 的长度是 n,而星号的使用则为我们提供一个线索——数组的长度与形式参数列表中前面的参数相关

1
int sum_array(int, int[*]);

如果变长数组参数是多维的则更加实用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int sum_two_dimensional_array(int n, int m, int a[n][m])
{
int i, j, sum = 0;
for (i = 0; i < n; i++)
{
for (j = 0; j < m; j++)
{
sum += a[i][j];
}
}
return sum;
}

递归

递归经常作为分治法 (divide and conquer) 的结果自然地出现。

TODO

第 10 章 程序结构

您的支持将鼓励我继续创作!

热评文章