《C 程序设计语言》读书笔记

我所在的专业没有开设 C 语言的课程,而大一虽然有开设 C++,但一是两门语言还是有一些区别,二是也过去了一年,很多东西都差不多忘干净了。这学期的系统级程序设计课程(即著名的“csapp”)和 C 语言密不可分,因此我借来了《C 程序设计语言》来补一补相关知识,并记了一些笔记。最终这门课拿了满绩,还是对得起自己的付出。

我认为《C 程序设计语言》是一本很好的 C 语言入门书籍,简洁清晰。这里的笔记也都是 C 语言最基础的语法。越往深里学,越会感觉 C 语言和计算机系统的相辅相成,博大精深,也越烧脑。但是现在都必须要按需所学了,还是希望有机会能够继续深入挖掘 C 语言的精粹。

序与引言

C 语言的 ANSI 标准的目的是制定“一个无歧义性的且与具体机器无关的 C 语言定义”。比起之前的标准,ANSI 标准要求对变量进行正确的声明和显式的强制类型转换,并为 C 语言定义了一个函数库。

指针提供了与具体机器无关的地址算术运算。

编译的预处理阶段将对程序文本进行宏替换包含其他源文件以及进行条件编译

C 语言不提供直接处理诸如字符串、集合、列表或数组等符合对象的操作。所有高层的机制必须由显式调用的函数提供。

第 1 章 导言

符号常量

1
2
3
4
#define 名字 替换文本
// 如:
#define LOWER 0

#define指令行的末尾没有分号。

参数-传值调用

在 C 语言中,所有函数参数都是“通过值”传递的。也就是说,传递给被调用函数的参数值存放在临时变量中。被调用函数不能直接修改主调函数中变量的值,而只能修改其私有的临时副本的值。

必要时,也可以让函数能够修改主调函数中的变量。这种情况下,调用者需要向被调用函数提供设置值的变量的地址(从技术角度看,地址就是指向变量的指针),而被调用函数则需要将对应的参数声明为指针类型,并通过它间接访问变量。

把数组名作为参数时,传递给函数的值是数组起始元素的位置或地址——它并不复制数组元素本身。在被调用函数中,可以通过数组下标访问或修改数组元素的值。

字符数组

当在 C 语言程序中出现类似"hello\n"的字符串常量时,它将以字符数组的形式存储,数组的各元素分别存储字符串的各个字符,并\0标志字符串的结束。因此,存储字符串的物理存储单元数比括在双引号中的字符数多一个。

外部变量与作用域

函数在使用外部变量之前,必须要知道外部变量的名字。如果在外部变量的作用范围内就无所谓,而如果在另外一个文件中使用,则需要在函数中使用 extern 类型的声明

人们通常把变量和函数的 extern 声明放在一个单独的文件中(习惯上称之为头文件),并在每个源文件的开头使用#include语句将所要用的头文件包含进来。

为了与老版本的 C 语言程序兼容,ANSI C 语言把空参数表看成老版本 C 语言的声明方式,并且对参数表不再进行任何检查。在 ANSI C 中,如果要声明空参数表,则必须使用关键字 void 进行显式声明

请注意分以下两个概念:

  • 定义(define):创建变量或分配存储单元;
  • 声明(declaration):说明变量的性质,但并不分配存储单元。

第 2 章 类型、运算符和表达式

数据类型及长度

short 类型通常为 16 位,long 类型通常为 32 位,int 类型可以为 16 位或 32 位。各编译器可以根据硬件特性自主选择合适的类型长度。

类型限定符 signed 与 unsigned 可用于限定 char 类型或任何整型。unsigned 类型的数总是正值或 0,而 signed 类型的数可为负值。例如对于 8 位的 char 对象,则 unsigned char 类型变量的取值范围为 0 ~ 255,而 signed char 类型变量的取值范围 -128 ~ 127(在采用对二的补码的机器上)。不带限定符的 char 类型对象是否带符号取决于具体机器,但可打印字符总是正值。

常量

无符号整数常量以字母 u 或 U 结尾。后缀 ul 或 UL 表明是 unsigned long 类型。

没有后缀的浮点数常量为 double 类型。后缀 f 或 F 表示 float 类型,而后缀 l 或 L 则表示 long double 类型。

字符常量\0表示值为 0 的字符,也就是空字符(null)。

声明

对数组而言,const 限定符指定数组所有元素的值都不能被修改。const 限定符也可配合数组参数使用,以表明函数不能修改数组元素的值。

算术运算符

取模运算符 % 不能应用于 float 或 double 类型。

关系运算符与逻辑运算符

关系运算符> >= < <=的优先级比算术运算符低。

不等于运算符!=的优先级比赋值运算符=的优先级要高。

类型转换

字符型转换为整型

C 语言没有指定 char 类型的变量是无符号变量(unsigned)还是带符号变量(signed)。当把一个 char 类型的值转换为 int 类型的值时,对于不同的机器,其结果有可能为负整数,这反映了不同机器结构之间的区别。

在某些机器中,如果 char 类型值的最左一位为 1,则转换为负整数(进行“符号扩展”)。而在另一些机器中,会在 char 类型值的左边添加 0,这样导致的转换结果值总是正值。

为了保证程序的可移植性,如果要在 char 类型的变量中存储非字符数据,最好指定 signed 或 unsigned 限定符。

转换规则

详见附录 A.6

表达式中 float 类型的操作数不会自动转换为 double 类型,这种设计考虑到节省空间和机器执行时间的需要。

在把参数传递给函数时也可能进行类型转换。在没有函数原型的情况下,char 与 short 类型都将被转换为 int 类型,float 类型也将被转换为 double 类型。因此,即使调用函数的参数为 char 或 float 类型,我们也把函数参数声明为 int 或 double 类型。

按位运算符

C 语言提供了 6 个只能作用于整型操作数(带符号或无符号的 char、short、int 与 long 类型)的操作运算符。

符号 作用
& 按位与(AND)
按位或(OR)
^ 按位异或(XOR)
<< 左移
>> 右移
~ 按位求反(一元运算符)

按位与运算符&常用于屏蔽某些二进制位,例如n = n & 0177将 n 中除 7 个低二进制位外的其他各位均置为 0(注意 0177 是八进制,转换为二进制位 1111111)。

按位或运算符|常用于将某些二进制位置为 1,例如x = x | SET_ON;将 x 中对应于 SET_ON 中为 1 的那些二进制位置为 1。

在对 unsigned 类型的无符号值进行右移位时,左边空出的部分用 0 填补;当对 signed 类型的带符号值进行右移时,某些机器将对左边空出的部分用符号位填补(即“算术移位”,正负不变),而另一些机器则对左边空出的部分用 0 填补(即“逻辑移位”,取绝对值)。

一元运算符~用于求整数的二进制反码,例如x = x & ~077将把 x 的最后六位设置为 0(注意 077 是八进制,转换为二进制位 111111)。

第 4 章 函数与程序结构

作用域规则

由于 C 语言不允许在一个函数中定义其他函数,因此函数本身是“外部的”。外部变量或函数的作用域从声明它的地方开始,到其所在的(待编译的)文件的末尾结束。

如果要在外部变量的定义之前使用该变量,或者外部变量的定义与变量的使用不在同一个源文件中,则必须在相应的变量声明中强制性使用关键字 extern。

外部变量的定义中必须指定数组的长度,但 extern 声明则不一定要指定数组的长度。

静态变量

用 static 声明限定外部变量与函数,可以将其后声明的对象的作用域限定为被编译源文件的剩余部分

static 也可用于声明内部变量。static 类型的内部变量与自动变量不同的是,不管其所在函数是否被调用,它一直存在,而不像自动变量那样,随着所在函数的被调用和退出而存在和消失。换句话说,static 类型的内部变量是一种只能在某个特定函数中使用但一直占据存储空间的变量

寄存器变量

register 声明只适用于自动变量以及函数的形式参数,它告诉编译器,它所声明的变量在程序中使用频率较高,可以放在寄存器中。然而,编译器可以忽略过量的或不支持的寄存器变量声明,所以每个函数中实际只有很少的变量可以保存在寄存器中,且只允许某些类型的变量。无论寄存器变量实际是不是存放在寄存器中,它的地址都不能访问。

程序块结构

C 语言有块作用域。在{}构成的程序块中,局部变量(块开头声明的变量)可以隐藏程序块外同名的变量。自动变量(包括形式参数)也可以隐藏同名的外部变量与函数。

初始化

在不进行显式初始化的情况下,外部变量和静态变量都将被初始化为 0,而自动变量和寄存器变量的初值则没有定义(即初值为无用的信息)。

对于外部变量与静态变量来说,初始化表达式必须是常量表达式,且只初始化一次。

对于自动变量和寄存器变量来说,初始化表达式可以不是常量表达式;表达式中可以包含任意在此表达式之前已经定义的值,包括函数调用。

C 预处理器

从概念上讲,预处理器是编译过程中单独执行的第一个步骤。

文件包含

#include "文件名"#include <文件名>的行都被替换为由文件名指定的文件的内容。#include "文件名"在源文件所在位置查找该文件;没有找到或#include <文件名>则根据相应规则查找该文件。

宏替换

想将一个较长的宏定义分成若干行,需要在持续的行末尾加上一个反斜杠符\

宏定义也可以带参数,例如:

1
2
3
4
5
6
#define max(A, B) ((A) > (B) ? (A) : (B))
// 使用语句
x = max(p+q, r+s);
// 将被替换为
// x = ((p+q) > (r+s) ? (p+q) : (r+s));

可以看到,要适当使用圆括号以保证计算次序的正确性。

可以通过#undef指令取消名字的宏定义。

如果在替换文本中,参数名以#作为前缀则结果将被扩展为由实际参数替换该参数的带引号的字符串。例如:

1
2
3
4
5
6
#define dprint(expr) printf(#expr " = %g\n", expr)
// 使用语句
dprint(x/y);
// 该宏将被扩展为
// dprint("x/y" " = %g\n", x/y)

预处理器运算符##为宏扩展提供了一种连接实际参数的手段。如果替换文本中的参数与##相邻,则该参数将被实际参数替换,##与前后的空白符将被删除,并对替换后的结果重新扫描。例如:

1
#define paste(front, back) front ## back

因此,宏调用paste(name, 1)的结果将建立记号name1

条件包含

#if语句对其中的常量整型表达式(其中不能包含sizeof、类型转换运算符或enum常量)进行求值,若该表达式的值不等于 0,则包含其后的各行,直到遇到#endif#elif#else语句为止。

#if语句中可以使用表达式defined(名字),当名字已经定义时其值为 1,否则为 0。

C 语言专门定义了两个预处理语句#ifdef#ifndef,它们用来测试某个名字是否已经定义。

第 5 章 函数与程序结构*

指针是一种保存变量地址的变量。

ANSI C 使用类型void*(指向void的指针)代替char*作为通用指针的类型。

指针与地址

指针是能够存放一个地址的一组存储单元(通常是两个或四个字节)。

一元运算符&可用于取一个对象的地址。因此,下列语句:

1
p = &c;

将把 c 的地址赋值给变量 p,我们称 p 为“指向” c 的指针。地址运算符&只能应用于内存中的对象,即变量和数组元素。它不能作用于表达式、常量或 register 类型的变量。

一元运算符*间接寻址间接引用运算符。当它作用于指针时,将访问指针所指向的对象。如果指针 ip 指向整型变量 x,那么在 x 可以出现的任何上下文中都可以使用*ip代替:

1
2
3
4
5
6
7
int x = 1, y = 2, z[10];
int *ip; /* ip 是指向 int 类型的指针 */
ip = &x; /* ip 现在指向 x */
y = *ip; /* y 的值现在为 1 */
*ip = 0; /* x 的值现在为 0 */
ip = &z[0]; /* ip 现在指向 z[0] */

注意:类似*++这样的一元运算符遵循从右至左的结合顺序。例如,语句(*ip)++中的圆括号是必需的,否则该表达式将对 ip 进行加一运算,而不是对 ip 指向的对象进行加一运算。

指针和函数参数

C 语言是以传值的方式将参数值传递给被调用函数,因此被调用函数不能直接修改主调函数中变量的值。但指针参数使得被调用函数能够访问和修改主调函数中对象的值。

由于一元运算符&用来取变量的地址,这样&a就是一个指向变量 a 的指针。

指针和数组

数组名所代表的就是该数组最开始的一个元素的地址,所以,赋值语句pa = &a[0]也可以写成pa = a*(pa+1)引用的是数组元素a[1]的内容。&a[i]a+i的含义也是相同的。

但是,数组名和指针之间有一个不同之处。指针是一个变量,而数组名不是。因此,类似于a = paa++形式的语句是非法的。

如果将数组名传递给函数,函数可以根据情况判定是按照数组处理还是按照指针处理。

地址算术运算

C 语言保证,0 永远不是有效的数据地址。因此,若需要返回指针的函数返回 0,则表示发生了异常事件。

指针与整数之间不能相互转换,但 0 是唯一的例外:常量 0 可以赋值给指针,指针也可以和常量 0 进行比较。程序中经常用符号 NULL 代替常量 0,这样便于更清晰地说明常量 0 是指针的一个特殊值。

如果指针 p 和 q 指向同一个数组的成员,那么它们之间就可以进行关系比较运算。同时,这两个指针的减法运算也有意义:若 p < q,那么 q-p+1 就是位于 p 和 q 指向的元素之间的元素的数目。

有效的指针运算包括:

  • 相同类型指针之间的赋值运算(两个指针之一是void*类型的情况除外);
  • 指针同整数之间的加法或减法运算;
  • 指向相同数组中元素的两个指针间的减法或比较运算;
  • 将指针赋值为 0 或指针与 0 之间的比较运算。

字符指针与函数

字符串常量是一个字符数组。

1
printf("hello, world\n");

当类似于这样的一个字符串出现在程序中时,实际上是通过字符指针访问该字符串的。在上述语句中,printf接受的是一个指向字符数组第一个字符的指针。也就是说,字符串常量可通过一个指向其第一个元素的指针访问。

语句pmessage = "now is the time";将把一个指向该字符数组的指针赋值给pmessage该过程并没有进行字符串的复制,而只是涉及到指针的操作。C 语言没有提供将整个字符串作为一个整体进行处理的运算符。

1
2
char amessage[] = "now is the time"; /* 定义一个数组 */
char *pmessage = "now is the time"; /* 定义一个指针 */

上述声明中,数组中单个字符可以进行修改,但amessage始终指向同一个存储位置;另一方面,pmessage是一个指针,之后可以被修改以指向其他地址,但如果试图修改字符串的内容,结果是没有定义的。

指针数组以及指向指针的指针

由于指针本身也是变量,所以它们也可以像其他变量一样存储在数组中。

1
char *lineptr[MAXLINES]

表示lineptr是一个指针数组。详细地说,它是一个具有 MAXLINES 个元素的一维数组,其中数组的每个元素是一个指向字符类型对象的指针。也就是说,lineptr[i]是一个字符指针,而*lineptr[i]是该指针指向的第 i 个文本行的首字母。

每执行一次自增运算,都使得*lineptr指向下一行。

多维数组*

1
daytab[i][j] /* [行][列] */

如果将二维数组作为参数传递给函数,那么在函数的参数声明中必须指明数组的列数。数组的行数没有太大关系,因为函数调用时传递的是一个指针,它指向由行向量构成的一维数组。因此:

1
f(int daytab[2][13]) { ... }

也可以写成:

1
f(int daytab[][13]) { ... }

还可以写成:

1
f(int (*daytab)[13]) { ... }

这种声明形式表明参数是一个指针,它指向具有 13 个整型元素的一维数组(包裹在外的第二维数组的首地址)。因为方括号[]的优先级高于*的优先级,所以上述声明中必须使用圆括号。如果去掉括号,则声明变为

1
int *daytab[13]

相当于声明了一个数组,该数组有 13 个元素,其中每个元素都是一个指向整型对象的指针。

一般来说,除数组的第一维(下标)可以不指定大小外,其余各维都必须明确指定大小。

指针与多维数组

假如有下面两个定义:

1
2
int a[10][20];
int *b[10];

从语法角度讲,a[3][4]b[3][4]都是对一个 int 对象的合法应用。但 a 是一个真正的二维数组,它分配了 200 个 int 类型长度的存储空间;

而对于 b 来说,该定义仅仅分配了 10 个指针,并且没有对它们初始化。假定 b 的每个元素都指向一个具有 20 个元素的数组,那么编译器就要为它分配 200 个 int 类型长度的存储空间以及 10 个指针的存储空间。

指针数组的一个重要优点在于,数组的每一行长度可以不同。

命令行参数

根据 C 语言的约定,argv[0]的值是启动该程序的程序名,因此argc的值至少为 1。ANSI 标准要求argv[argc]的值必须为一空指针。

指向函数的指针

在 C 语言中,函数本身不是变量,但可以定义指向函数的指针

和数组名一样,当一个变量为函数的地址时,前面不需要加上取地址符&

由于任何类型的指针都可以转换为void *类型,并且在将它转换为原来的类型时不会丢失信息,所以,调用函数时可以将参数强制转换为void*类型。比较函数的参数也要执行这种类型的转换。这种转换通常不会影响到数据的实际表示,但要确保编译器不会报错。

1
int (*comp)(void *, void *)

表明comp是一个指向函数的指针,该函数具有两个void*类型的参数,其返回值类型为int*comp代表一个函数。

复杂声明

C 语言的复杂声明容易让人混淆,因为 C 语言的声明不能从左至右阅读,并且使用了太多的圆括号。例如下面两个声明:

1
2
3
4
5
/* f: 是一个函数,它返回一个指向 int 类型的指针 */
int *f();
/* pf: 是一个指向函数的指针,该函数返回一个 int 类型的对象 */
int (*pf)();

他们之间的含义差别说明:*是一个前缀运算符,其优先级低于()

结构

ANSI 标准在结构方面最主要的变化是定义了结构的赋值操作–结构可以拷贝、赋值、传递给函数,函数也可以返回结构类型的返回值。

基本知识

1
2
3
4
struct point {
int x;
int y;
};

关键字 struct 引入结构声明。结构声明由包含在花括号内的一系列声明组成。关键字 struct 后面的名字是可选的,称为结构标记。结构标记用于为结构命名,在定义后,结构标记就代表花括号内的声明,可以用它作为该声明的简写形式。

结构中定义的变量称为成员结构成员、结构标记和普通变量(即非成员)可以采用相同的名字,因为通过上下文分析总可以对它们进行区分,因此不会冲突。另外,不同结构中的成员可以使用相同的名字。

struct 声明定义了一种数据类型。在标志结构成员表结束的右花括号之后可以跟一个变量表,这与其他基本类型的变量声明是相同的。例如:

1
struct { ... } x, y, z;

如果结构声明的后面不带变量表,则不需要为它分配存储空间,它仅仅描述了一个结构的模版或轮廓。但是,如果结构声明中带有标记,那么在以后定义结构实例时便可使用该标记定义,例如:

1
struct point pt;

结构的初始化可以在定义的后面使用初值表进行,初值表中同每个成员对应的初值必须是常量表达式,例如:

1
struct point maxpt = { 320, 200 };

自动结构也可以通过赋值初始化,还可以通过调用返回相应类型结构的函数进行初始化。

结构和函数

如果传递给函数的结构很大,使用指针方式的效率通常比复制整个结构的效率要高。

1
2
3
4
struct point origin, *pp;
pp = &origin;
printf("origin is (%d,%d)\n", (*pp).x, (*pp).y);

其中,(*pp).x中的圆括号是必须的,因为结构成员运算符.的优先级高于*的优先级。

鉴于结构指针的使用频率非常高,为了使用方便,C 语言对于使用指向结构的指针 p 引用相应结构成员提供另一种简写方式:

1
p->结构成员

类型定义(typedef)

typedef 声明并没有创建一个新类型,只是为某个已存在的类型增加了一个新的名称而已。实际上,typedef 类似于 #define 语句,但由于 typedef 是由编译器解释的,因此它的文本替换功能要超过预处理器的能力。

除了使表达方式更简洁之外,使用 typedef 还有两个重要原因:

  1. 使程序参数化,以提高程序的可移植性;
  2. 为程序提供更好的说明。

联合

联合是可以(在不同时刻)保存不同类型和长度的对象的变量,编译器负责跟踪对象的长度和对齐要求。联合提供了一种方式,以在单块存储区中管理不同类型的数据,而不需要在程序中嵌入任何同机器有关的信息。

联合的目的:一个变量可以合法地保存多种数据类型中任何一种类型的对象。

1
2
3
4
5
union u_tag {
int ival;
float fval;
char *sval;
} u;

变量 u 必须足够大,以保存这 3 种类型中最大的一种,具体长度同具体的实现有关。

实际上,联合就是一个结构,它的所有成员相对于基地址的偏移量都为 0,此结构空间要大到足够容纳最“宽”的成员,并且,其对齐方式要适合于联合中所有类型的成员。对联合允许的操作与对结构允许的操作相同。

联合只能用其第一个成员类型的值进行初始化。