C语言学习笔记

第一章 C语言概述

C语言是20世纪70年代初期,在贝尔实验室开发出来的一种用途广泛的编程语言。


C语言的历史

C语言是Unix系统开发过程中的一个副产品。它被用来重写Unix系统。

到20世纪80年代,许多计算机开始使用C语言开发程序,为了保证其程序的可移植性,建立标准成为了共识。

1989年,通过C89标准。

1999年,通过C99标准,但这个标准还没有被普遍使用。

C++ 语言

虽然采纳了 ANSI/ISO 标准以后C语言自身不再发生变化。但是,从某种意义上说,随着基于C语言的新式语言的产生,C语言的演变还在继续。新式语言包括C++。它在许多方面对C语言进行了扩展,尤其是增加了面向对象编程的特性。

随着C++语言的迅速普及,在不久的将来你很可能会用C++语言编写程序。果真如此,为何还要费心学习C语言呢?首先,C++语言比C语言更加难学,因此在掌握C++语言前,最好先精通C语言;其次,我们身边存在着大量的C语言代码,需要去维护和阅读;最后,不是每个人都喜欢改用C++编程,例如对于编写小规模的程序,使用C++反而不会获得多少好处。

C语言的优缺点

C语言的优缺点都源于它最初的用途,以及其基础理论体系。

  • C语言是一种底层语言。它提供了对内存访问的功能。C程序的许多服务都依赖于操作系统提供的接口。

  • C语言是一种小型语言。C语言的特性不多,应用程序的绝大部分功能依赖于标准库。

  • C语言是一种包容性语言。C语言假设用户知道自己在做什么,因此有编写自由度。C语言不强制进行错误检查。

C语言的优点

  1. 高效。发明C语言的目的是为了替代汇编语言。

  2. 可移植。有标准库的存在。

  3. 功能强大、灵活。C语言的数据类型和运算符集合有足够强大的表达能力。

  4. 与Unix集成。

C语言的缺点

  1. C程序更容易隐藏错误。由于其灵活性,导致编写的代码令编译器很难检查错误。

  2. C程序可能会难以理解。

  3. C程序可能会难以修改。因为它设计时没考虑到维护的问题。C语言没有提供类,包等模块化概念。

高效地使用C语言

  1. 学习规避C语言的缺陷。比如越界问题。
  2. 使用软件工具。
  3. 利用现有的代码库。
  4. 采用切合实际的编码规范。
  5. 避免“投机取巧”和极度复杂的代码。
  6. 使用标准C,少用经典C。标准C即是 ANSI C ,本书采用的是标准C。
  7. 避免不可以移植性。# 第二章 C语言基本概念

编写一个简单的C程序

程序:显示双关语

这是经典C的一个示例:

// pun.c
#include <stdio.h>

main()
{
    printf("To C, or not to C: that is the question.\n");
}

编译和链接

首先,需要一个.c文件保存程序代码,接下来需要把程序转换为机器可以执行的形式。通常包含下列三个步骤:

  • 预处理。首先会把程序送交给预处理器( preprocessor )。预处理器执行以#开头的命令。

  • 编译。修改后的程序现在可以进入编译器( compiler )了。编译器会把程序翻译成机器指令(即目标代码, object code )。

  • 链接。链接器( linker )把由编译器产生的目标代码和任何其他附加代码整合在一起,产生完全可执行的程序。

这个过程可以一步完成,即:

cc pun.c

在编译和链接好程序后,编译器 cc 会把可执行程序放到默认名为 a.out 的文件中。编译器 cc 有许多选项,其中 -o 允许给可执行程序选择一个名字:

cc -o pun pun.c

如果使用 gcc 进行编译,那么建议在编译时采用 -Wall 选项:

gcc -Wall -o pun pun.c

也可以手动分布完成:

cc -o main.i -E main.c # 预编译
cc -o main.o -c main.i # 编译
cc -o main main.o      # 链接

简单程序的一般形式

形式如:

指令

int main()
{
    语句
}

指令

在编译C程序之前,预处理器会首先对C程序进行编辑。我们把预处理器执行的命令称为指令。这里只关注 #include 指令。

#include <stdio.h>

这条指令说明,在编译前把 stdio.h 中的信息“包含”到程序中。这段程序中包含 stdio,h 的原因是:C语言没有内置的“读”和“写”命令。因此,进行输入/输出操作就需要用标准库中的函数来实现。

这里是指预所有指令都是以#开头。一条指令必须占据一行,且不留分号结尾。

函数

函数是用来构建程序的一个构建块。C程序就是函数的集合。函数分为两大类:一类是程序员编写的函数,另一类则是由C语言的实现所提供的函数。后者可以称为库函数( library function )。

在C语言中,函数仅仅是一系列组合在一起并且赋予了名字的语句。某些函数计算一个值,而某些函数不是。计算出一个值的函数可以用 return 语句来指定所“返回”的值。

每个程序必须有一个 main 函数。 main 函数是非常特殊的:在执行程序时系统会自动调用 main 函数。

main 函数在程序终止时向操作系统返回一个状态码。 pun 程序始终让 main 函数返回0,0表明程序正常终止。

建议加入 return 语句,如果不这样做,某些编译器可能会产生一条警告信息:

// pun.c
#include <stdio.h>

main()
{
    printf("To C, or not to C: that is the question.\n");
    return 0;
}

语句

语句是程序运行时执行的命令。每条语句都要以分号结尾。

一条语句可以占据多行。

程序 pun.c 只用到了两种语句。一种是返回语句,一种则是函数调用( function call )语句。为了在屏幕上显示一条字符串就调用了 printf 函数。

显示字符串

我们用 printf 函数显示了一条字符串字面量( string literal )。字符串字面量是用一对双引号包围的一系列字符。

当打印结束时, printf 函数不会自动跳转到下一输出行。为了让 printf 跳转到下一行,必须要在打印的字符串中包含一个 \n (换行符)。写换行符就意味着终止当前行,然后把后续的输出转到下一行进行。

换行符可以在一个字符串字面量中出现多次。比如:

printf("Brevity is the soul of wit.\n -- Shakespeare\n");

注释

注释就是代码的说明。在预编译后,注释会移除出代码。

例如:

/* This is a comment */

为 pun.c 增加注释:

/*    Name: pun.c
    Purpose: Prints a bad pun.
    Author: K.N.King
    Data written: 5/21/95
*/

变量和赋值

变量( variable )就是用来存储数据的存储单元。

类型

一个变量必须有一个类型。类型决定了存储单元的大小和对变量的操作方式。

int 型变量可以存储整数,例如0、1、392或者-2553,但是,整数的取值范围是受限制的。某些计算机上,int型数值的最大取值仅仅是32767。

float 型变量可以存储更大的数值,而且,float型变量可以存储带小数位的数据,例如379.125。但是,float 型变量有一些缺陷,即这类变量需要的存储空间要大于 int 型变量。而且,进行算术运算时 float 型变量通常比 int 型变量慢。另外, float 型变量所存储的数值往往只是实际数值的一个近似值。

声明

在使用变量前,必须对其进行声明,这也是为了便于编译器工作。例如,声明变量 height 的方式如:

int height;

如果几个变量具有相同的类型,就可以把它们的声明合并:

int height, length, width;

当 main 函数包含声明时,必须把声明放置在语句之前:

main()
{
    声明
    语句
}

赋值

变量可以通过赋值( assignment )的方式获得一个值。例如:

height = 8;
volume = height * length * width;

赋值运算符的右侧可以是一个含有常量、变量和运算符的公式(表达式, expression )。

显示变量的值

用 printf 可以显示当前变量的值。

printf("Height: %d\n", height);

占位符 %d 用来指明在打印过程中变量 height 的值的显示位置。 %d 仅用于 int 型变量,如果要打印 float 型变量,需要用 %f 来代替。默认情况下, %f 会显示小数点后6位数字,若需要显示小数点后n位数字,则可以把 .n 放置在%和f之间。

printf("Profit: $%.2f\n", profit);

初始化

当程序开始执行时,某些变量会被自动设置为0,而大多数变量则不会。没有默认值并且尚未在程序中被赋值的变量是未初始化的( uninitialized )。

使用初始化式对其变量进行初始化,如:

int a = 0;

读入输入

为了获取输入,就要用到 scanf 函数。 scanf 中的字母f和 printf 中的f含义相同,都是表示“格式化”的意思。 scanf 和 printf 函数都需要使用格式串( format string )来说明输入或输出的样式。

为了读取一个 int 型数值,可以使用如下的 scanf 函数调用。

scanf("%d", &i);

字符串“%d”说明 scanf 读入的是一个整数,i是一个 int 型变量,用来存储读入的输入。

读入一个 float 型数值时,需要这样的 scanf 调用:

scanf("%f", &x);

%f只适用于 float 型变量。

定义常量

常量( constant )是在程序执行过程中固定不变的量。当程序含有常量时,建议给这些常量命名。方式是使用宏定义( macro defination )。

#define N 4

这里的 #define 是预处理指令。当程序进行编译时,预处理器会把每一个宏用其表示的值替换回来。

此外,还可以利用宏来定义表达式:

#define SCALE_FACTOR (5.0 / 9.0)

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

宏的名字一般用大写字母,这是大多数程序员遵守的规范。

标识符

标识符就是函数、变量、宏等实体的名字。

标识符由字母、数字和下划线组成,且区分大小写。必须以字母或下划线开头。

为了使名字清晰,可以使用两种命名风格:

symbol_table
SymbolTable

关键字

关键字( keyword )对编译器而言都有特殊的含义,因此标识符不能和关键字一样。

所有的关键字见书本p19

C语言程序的布局

C程序可以被看成一连串的记号( token )。记号就是无法分割的字符组。

标识符、关键字、运算符、字符串等都是记号。

记号之间可以有空格,换行等字符。

有记号的概念后,C程序就可以这样书写:

  • 语句可以放到多行内。对于很长的语句这样很合适。

  • 记号间的空格可以更容易区分记号。比如运算符两边加空格方便阅读。

  • 缩进有助于识别程序嵌套。

  • 空行可以把程序划分为逻辑单元。# 第三章 格式化输入输出

scanf 函数和 printf 函数是C语言使用最频繁的两个函数,它们用来支持格式化的读和写。


printf 函数

printf 函数被设计用来显示格式串( format string )的内容,并且在字符串指定位置插入可能的值。

printf(格式串, 表达式1, 表达式2, ...);

格式串包含普通字符和转换说明( conversion specification ),其中转换说明以字符%开头。

!!!warning
C语言编译器不会检测格式串中转换说明的数量是否和输出项的数量相匹配。

转换说明

在通用的情况下,转换说明可以有%m.pX格式或%-m.pX格式,这里的m和p都是整型常量,X是字母。m和p都是可选项。

在转换说明%10.2f中,m是10,p是2,X是f。

最小字段宽度( minimum field width ) m指定了要显示的最小字符数量。如果要打印的数值比m个字符少,那么值在字段内是右对齐的。如果要多,那么字段宽度会自动扩展为需要的尺寸。

精度( precision ) p的含义依赖于转换说明符X( conversion specifier )的选择。对数来说,最常用的转换说明符有:

  • d,表示十进制形式的整数。p说明可以显示的数字的最少个数(如果需要,就在数前加上额外的零);如果忽略掉p,默认它的值为1。

  • e,表示指数形式的浮点数。p说明小数点后应该出现的数字的个数(默认为6)。如果p为0,则不显示小数点。

  • f,表示“定点十进制”形式的浮点数,没有指数。p的含义与在说明符e中的一员。

  • g,表示指数形式或者定点十进制形式的浮点数,形式的选择根据数的大小决定。p可以说明显示的有效数字的最大数量。与转换说明符f不同,g的转换将不显示尾随零。

转义序列

我们经常把在格式串中用的代码\n称为转义序列( escape sequence )。转义序列使字符串包含一些特殊字符而又不会使编译器引发问题。

详细的说明:https://zh.cppreference.com/w/cpp/language/escape

scanf 函数

scanf 函数也根据特定的格式读取输入, scanf 函数转换说明符的用法和 printf 函数转换说明符的用法本质上是一样的。

scanf 函数有一些不易察觉的陷阱。使用 scanf 时,程序员必须检查转换说明的数量是否与输入变量的数量相匹配,并且检查每个转换是否适合相对应的变量。另一个陷阱涉及符号&,通常把符号&放在 scanf 函数调用的每个变量的前面。

调用 scanf 函数是读取数据的一种有效但不理想的方法。许多专业C程序员避免用 scanf 函数,而是采用字符格式读取所有数据,然后再把它们转换成数值形式。

scanf 函数的工作方法

scanf 函数本质上是一种“模式匹配”函数,也就是试图把输入的字符组与转换说明匹配成组。

scanf 调用时,从左边开始处理字符串中的信息。对于格式串中的每一个转换说明, scanf 函数努力从输入的数据中定位适当类型的项,并且跳过必要的空格。然后, scanf 函数读入数据项,并且在遇到不可能属于此项的字符时停止。

在寻找数的起始位置时, scanf 函数会忽略空白( white-space )字符(空格符、横向和纵向制表符、换页符、换行符)。

格式串中的普通字符

处理格式串中普通字符时, scanf 函数采取的动作依赖于这个字符是否为空白字符。

  • 空白字符。当在格式串中遇到一个或多个连续的空白字符时, scanf 函数从输入中重复读空白字符直到遇到一个非空白字符(把该字符“放回原处”)为止。

  • 其他字符。当在格式串中遇到一个非空白字符时, scanf 函数将把它与下一个输入字符进行比较。如果两个字符相匹配,那么 scanf 函数会放弃输入字符而继续处理格式串。如果两个字符不匹配,那么 scanf 函数会把不匹配的字符放回输入中,然后异常退出。# 第四章 表达式

表达式是显示如何计算值的公式。最简单的表达式是变量和常量。变量表示程序运行时计算出的值;常量表示不变的值。

运算符是构建表达式的基本工具。C语言提供了基本的运算符:

  • 算术运算符。
  • 关系运算符。
  • 逻辑运算符。

算术运算符

算术运算符有:

一元运算符 二元运算符
+ - + - * / %

二元运算符要求有两个操作数,而一元运算符只要有一个操作数。

一元运算符+无任何操作,它主要是为了强调某数值常量是正的。

%被称之为 mod (求模)或 rem (取余)。 i % j 的数值是i除以j后的余数。

除了%,二元运算符既允许操作数是整数也允许操作数是浮点数,或者允许两者的混合。当把 int 型操作数和 float 型操作数混合在一起时,运算结果是 float 型的。

运算符/和%需要特别注意:

  • /可能产生意外的结果。当两个操作数都是整数时,运算符/通过丢掉分数部分的方法截取结果,因此1/2的结果是0。
  • %要求整数操作数;如果两个操作数中有一个不是整数,那么程序将无法通过编译。
  • 当/和%用于负数时,其结果与具体实现有关。如果操作数中有一个为负数,那么除法的结果既可以向上取整也可以向下取整。

!!!note “由实现定义”
术语由实现定义( implementation-defined )出现频率很高,意思是指软件在特定的平台上编译、链接和执行。根据实现的不同,程序的行为可能会稍有差异。
C语言的目的之一是达到高效率,这经常意味着要与硬件行为相匹配。当-9除以7时,一些机器可能产生的结果是-1,而另一些机器的结果为-2,C标准简单地反映了这一现实。
最好避免编写与实现定义的行为相关的程序。

运算符的优先级和结合性

C语言允许在所有表达式中用圆括号进行分组。但如果不使用圆括号,就采用运算符优先级( operator precedence )的规则来解决问题。算术运算符有下列相对优先级:

  • 最高优先级:+ -(一元运算符)
  • 中级优先级:* / %
  • 最低优先级:+ -(二元运算符)

例如:

i + j * k;    // 等价于 i + (j * k)

当一个表达式包含两个以上相同优先级的运算符时,单独的运算符优先级的规则是不够的。这种情况下,运算符的结合性( associativity )开始发挥作用。如果运算符是从左向右结合的,那么称这种运算符是左结合的( left associative )。二元算术运算符都是左结合的,所以:

i - j - k;    // 等价于 (i - j) - k

如果运算符是从右向左结合的,那么称为右结合的( right associative )。一元运算符都是右结合的。

赋值运算符

一旦计算出表达式的值就常常需要把这个值存储在变量中,以便后面使用。C语言的=运算符(assignment)可以用于此目的。

简单赋值

表达式v = e的赋值效果是求出表达式e的值,并把此值复制给v。e可以是常量、变量或较为复杂的表达式:

i = 5;
j = i;
k = 10 * i + j;

如果v和e的类型不同,那么赋值运算发生时会把e的值转化为v的类型:

int i;
i = 72.99; /* i is now 72 */

赋值操作产生结果,赋值表达式v=e的值就是赋值运算后v的值。因此,表达式i = 72.99的值是72。

!!!note “副作用”
大多数C语言运算符不会改变操作数的值,但是也有一些会改变。由于这类运算符所做的不再仅仅是计算出值,所以称它们有副作用( side effect )。简单的赋值运算符就是一个有副作用的运算符,它改变了运算符左边的操作数。表达式i=0产生的结果为0,作为副作用,把0赋值给i。

运算符=是右结合的。所以:

i = j = k = 0;
i = (j = (k = 0)); // 等价

左值

大多数C语言运算符允许它们的操作数是变量、常量或者包含其他运算符的表达式。然而,赋值运算符要求它左边的操作数必须是左值( lvalue )。左值表示存储在计算机内存中的对象,而不是常量或计算结果。变量是左值,而诸如10或2*i这样的表达式则不是左值。

复合赋值

利用变量原有值计算出新值并重新赋值给这个变量在C语言程序中是非常普遍的。例如:

i = i + 2;

C语言的复合赋值运算符( compound assignment operator )允许缩短这种语句和其他类似的语句。

i += 2;

+=运算符把右侧操作数的值加上左侧的变量,并把结果赋值给左侧的变量。还有另外的9种复合赋值运算符,包括:

-= *= /= %=

自增运算符和自减运算符

++表示操作数加1,–表示操作数减1。++和–既可以作为前缀( prefix )运算符,也可以作为后缀( postfix )运算符使用。

++和–也有副作用,它们会改变操作数的值。计算表达式++i的结果是i+1,副作用是自增i。计算表达式i++的结果是i,副作用是自增i。

这个自增操作一定会在下一条语句执行前完成。

表达式求值

上述总结的运算符在下表列出了其优先级、结合性。更多讨论见书本p39。

优先级 类型名称 符号 结合性
1 后缀自增、自减 ++ – 左结合
2 前缀自增、自减,一元正负 ++ – + - 右结合
3 乘法类 * / % 左结合
4 加法类 + - 左结合
5 赋值 = *= /= %= += -= 右结合

子表达式的求值顺序

C语言没有定义子表达式的求值顺序(除了含有逻辑与运算符及逻辑或运算符、条件运算符以及逗号运算符的子表达式)。# 第五章 选择语句

根据语句执行过程中顺序所产生的影响方式,C语言的其他语句大多属于以下三类:

  • 选择语句( selection statement )。 if 语句和 switch 语句允许程序在一组可选项中选择一条特定的执行路径。

  • 循环语句( iteration statement )。 while 语句、 do 语句和 for 语句支持重复操作。

  • 跳转语句( jump statement )。 break 语句、 continue 语句和 goto 语句引起无条件地跳转到程序中的某个位置。( return 语句也属于此类)

C语言还有其他两类语句,一类是由几条语句组合成一条语句的复合语句,一类是不执行任何操作的空语句。


逻辑表达式

包括 if 语句在内的某些C语句都必须测试表达式的值是“真”还是“假”。诸如i<j这样的比较运算会产生整数:0(假)或1(真)。

关系运算符

C语言的关系运算符( relational operator )用在C语言中时产生的结果是0(假)或1(真)。

符号 含义
< 小于
> 大于
<= 小于或等于
>= 大于或等于

关系运算符的优先级低于算术运算符,关系运算符都是左结合的。表达式i+j<k-1意味着(i+j)<(k-1)

判等运算符

符号 含义
== 等于
!= 不等于

判定运算符是左结合的,也是产生0或1作为结果。然而,判等运算符的优先级低于关系运算符。例如表达式i<j == j<k等价于表达式(i<j) == (j<k)

逻辑运算符

符号 含义
! 逻辑非
&& 逻辑与
|| 逻辑或

逻辑运算符所产生的结果是0或1。逻辑运算符将任何非零值操作数作为真值来处理,同时将任何零值操作作为假值来处理。

运算符&&和||都对操作数进行“短路”计算。

运算符!的优先级和一元正号、负号的优先级相同。运算符&&和||的优先级低于关系运算符和判等运算符。运算符!是右结合的,而运算符&&和||是左结合的。

if 语句

if 语句允许程序通过测试表达式的值从两种选项中选择一种。 if 语句的最简单的格式如下:

if (表达式) 语句

执行 if 语句时,先计算圆括号内的表达式的值。如果表达式的值非零,那么接着执行括号后面的语句,C语言把非零值解释为真值。

复合语句

如果想用 if 语句处理两条或更多语句,该怎么办呢?可以引入复合语句( compound statement )。复合语句有如下格式:

{ 多条语句 }

else 子句

if 语句可以有 else 子句:

if (表达式) 语句 else 语句

如果在圆括号内的表达式的值为0,那么就执行 else 后边的语句。

条件表达式

C语言提供了一种特殊的运算符,这种运算符允许表达式依据条件的值产生两个值中的一个。

条件运算符( conditional operator )由符号?和符号:组成,两个符号必须按如下格式一起使用:

表达式1 ? 表达式2 : 表达式3

条件运算符是C运算符中唯一一个要求3个操作数的运算符。因此,经常把它称为三元运算符。

条件表达式的求值步骤是:首先计算出表达式1的值,如果此值不为零,那么计算表达式2的值,并且计算出来的值就是整个条件表达式的值;如果表达式1的值为零,那么计算表达式3的值,并且此值就是整个条件表达式的值。

布尔值

因为许多程序需要变量能存储假值和真值,所以C语言缺少适当的布尔类型可能会很麻烦。可以使用 int 型变量来模拟布尔类型:

int flag;
flag = 0;
flag = 1;

为了使程序更加便于理解,一个好的方法是用类似 TRUE 和 FALSE 这样的名字定义宏:

#define TRUE 1
#define FALSE 0

flag = FALSE;
flag = TRUE;

为了更进一步实现这个想法,甚至可以定义用作类型的宏:

#define BOOL int

BOOL flag;

switch 语句

C语言提供了 switch 语句作为级联式 if 语句的替换:

switch (grade) {
    case 4: printf("Excellent"); break;

    case 3: printf("Good"); break;

    case 2: printf("Average"); break;

    case 1: printf("Poor"); break;

    case 0: printf("Failing"); break;

    default: printf("Illegal grade"); break;
}

switch 语句的最常用的格式如下:

switch (表达式) {
    case 常量表达式: 多条语句
    ...
    case 常量表达式: 多条语句

    default: 多条语句
}

switch 语句的组成部分:

  • 控制表达式。 switch 后面必须跟着右圆括号括起来的整型表达式。C语言把字符当成整数来处理,因此可以在 switch 语句中对字符进行判定。但是,不能用浮点数和字符串。

  • 情况标号。常量表达式( constant expression )更像是普通的表达式,5是常量表达式,5 + 10也是,而n + 10不是(除非n是表示常量的宏)。

  • 语句。每个情况标号的后边可以跟任意数量的语句,不需要用大括号括起来。每组语句的最后通常是 break 语句。# 第六章 循环

循环( loop )是重复执行某些语句(循环体)的一种语句。在C语言中,每个循环都有一个控制表达式( controlling expression )。每次执行循环体时都要对控制表达式进行计算。如果表达式为真,也就是值不为零,那么继续执行循环。

C语言提供了3种循环语句: while 语句、 do 语句和 for 语句。


while 语句

while 语句的格式如下所示:

while (表达式) 语句

执行 while 语句时,首先计算控制表达式的值。如果值不为零(即真值),那么执行循环体,接着再次判定表达式。

do 语句

do 语句的格式如下所示:

do 语句 while (表达式);

和处理 while 语句一样, do 语句的循环体也必须是一条语句(当然可以用复合语句)。

执行 do 语句时,先执行循环体,再计算控制表达式的值。如果表达式的值是非零的,那么再次执行循环体。

for 语句

for 语句的格式如下所示:

for (表达式1; 表达式2; 表达式3) 语句

循环开始执行前,表达式1是初始化步骤,并且只执行一次,表达式2用来控制循环的终止(只要表达式2不为零,那么将继续执行循环),而表达式3是在每次循环的最后被执行的一个操作。

逗号运算符

有些时候,我们可能喜欢编写有两个(或更多个)初始表达式的 for 语句,或者希望在每次循环时一次对几个变量进行自增操作。使用逗号表达式( comma expression )作为 for 语句中的第一个或第三个表达式可以实现这些想法。

逗号表达式的格式如下所示:

表达式1, 表达式2

逗号表达式的计算要通过两步来实现:第一步,计算表达式1并且扔掉计算出的值。第二步,计算表达式2,把这个值作为整个表达式的值。计算表达式1始终会有副作用;如果没有,那么表达式1就没有了存在的意义。

逗号运算符的优先级低于所有其他运算符。

退出循环

break 语句

break 语句还可以用于跳出 while、 do 或 for 循环。

continue 语句

continue 语句无法跳出循环,它把程序控制正好转移到循环体结束之前的一点。 break 语句可以用于 switch 语句,而 continue 语句只能用于循环。

goto 语句

goto 语句可以跳转到函数中任何有标号的语句处。

标号只是放置在语句开始处的标识符:

标识符: 语句

goto 语句自身的格式如下:

goto 标识符;

执行 goto 语句可以把控制转移到标号后的语句上,而且这些语句必须和 goto 语句本身在同一个函数中。

空语句

语句可以为空,也就是除了末尾处的分号以外什么符号也没有。# 第七章 基本类型


整型

C语言支持两种根本不同的数值类型:整型和浮点型。整型的值都是数,而浮点型可能还有小数部分。整型还分为有符号的和无符号的。

!!!note “有符号整数和无符号整数”
在有符号数中,如果数为正数或零,那么最左边的位(符号位)为0,如果是负数,符号位则为1。默认情况下,C语言中的整型变量都是有符号的。

C语言提供了不同尺寸的整型, int 是正常的尺寸。可以指明变量是 long 型或 short 型, signed 型或 unsigned 型。可以有这些类型组合:

short int, unsigned short int, int, unsigned int, long int, unsigned long int

可以把 int 省略掉,即 short int 可以写成 short 。

不同类型的整型表示的取值范围根据机器的不同而不同。

整型常量

这里说的常量表示在程序中以文本形式显示的数。C语言允许用十进制、八进制和十六进制形式书写整型常量。

十进制常量包含数字0~9,但是一定不能以零开头:15 255 32767

八进制常量只包含数字0~7,但是必须以零开头:017 0377 07777

十六进制常量包含数字09和字母af,而且总是以0x开头:0xf 0xff 0xffff

当程序中出现整型常量时,如果它属于 int 类型的取值范围,那么编译器会把此常量作为普通整数来处理,否则作为长整型处理。为了迫使编译器把常量作为长整型来处理,只需在后边加上一个字母L:15L

为了指明是无符号常量,可以在常量后边加上字母U:15U

还可以把UL写在一起:0xffffUL

读写整数

读写无符号、短的或长的整数需要一些新的转换说明符。

  • 读写无符号整数时,使用字母u、o或x代替转换说明符d。u代表十进制、o八进制、x十六进制。
  • 读写短整数时,在d、o、u或x前面加字母h。
  • 读写长整型时,在d、o、u或x前面加字母l。

浮点型

有时候需要变量存储带有小数的数,或者能存储极大数或极小数。这类数可以用浮点格式进行存储(因小数点是浮动的而得名)。C语言提供3种浮点型,它们对应不同的浮点格式:

  • float :单精度浮点数
  • double :双精度浮点数
  • long double :扩展双精度浮点数

浮点常量

浮点常量有许多书写方式:57.0 57. 57E0 5.7E1

用指数表示的是10的幂。

默认情况下,浮点常量都以 double 的形式存储。为了表明只需要单精度,可以在常量的末尾处加上字母f,如 57.0f 。

读写浮点数

转换说明符 %e %f 和 %g 用于读写单精度浮点数,当读取 double 时,需要用 %lf ,而写 double 时,不需要加l。

字符型

给 char 类型的变量赋值:

char ch;
ch = 'a';

C语言会按小整数的方式处理字符。

转义序列

一些特殊的符号无法书写,比如换行符,这时候需要用C语言提供的特殊符号转义序列( escape sequence )。

转义序列分成两种:字符转义序列和数字转义序列。

字符处理函数

可以使用 toupper 库函数把小写字母转成大写字母:

ch = toupper(ch);

被调用时,函数检查参数是否是小写字母,如果是,那么将它转换成大写字母,否则,函数返回参数的值。

读写字符

转换说明符 %c 允许 scanf 函数和 printf 函数对单独一个字符进行读写操作。

char c;
scanf("%c", &c);
printf("%c", c);

在读入字符前, scanf 不会跳过空白字符。为了强制 scanf 函数在读入字符前跳过空白字符,需要在格式串转换说明符 %c 前面加上一个空格。

char c;
scanf(" %c", &c);

C语言还提供了读写单独字符的其他方法。可以使用 getchar 和 putchar 函数来替代调用 scanf 函数和 printf 函数。每次调用 getchar 函数时,它会读入一个字符,并返回这个字符。

ch = getchar();

putchar 函数用来写单独一个字符:

putchar(ch);

getchar 和 putchar 会比较快,原因是它们的实现比较简单,并且通常用宏来实现。

sizeof 运算符

运算符 sizeof 允许程序确定用来存储指定类型值所需的空间的大小。

sizeof(类型名)

上述表达式的值是无符号整数,这个整数表示用来存储属于类型名的值所需的字节数。

表达式 sizeof(char) 的值始终为1。

通常情况下, sizeof 运算符也可以应用于常量、变量和表达式。

既然 sizeof 返回的是无符号的整型,所以最安全的做法是把 sizeof 表达式转换成 unsigned long 型。然后用转换说明符 %lu 进行。

类型转换

为了让计算机执行算术运算,通常要求操作数有相同的大小(即位的数量相同),并且要求存储的方式也相同。

C语言允许表达式中混合使用基本数据类型,这种情况下编译器可能需要生成一些指令将某些操作数转换成不同类型,使得硬件可以对表达式进行计算。这类转换是隐式转换( implicit conversion )。C语言还允许程序员通过强制运算符执行显式转换( explicit conversion )。

当发生下列情况时会进行隐式转换:

  • 当算术表达式或逻辑表达式中操作数类型不同时。
  • 当赋值运算符右侧表达式的类型和左侧变量的类型不匹配时。
  • 当函数调用中使用的参数类型与其对应的参数类型不匹配时。
  • 当 return 语句中表达式的类型和函数返回值的类型不匹配时。

常用的算术转换

常用的算术转换包括算术运算符、关系运算符和判等运算符。

为了统一操作数的类型,通常把相对较狭小的操作数转换成另一个操作数的类型来实现(这就是所谓的提升)。最常用的是整型提升(integral promotion),它把字符或短整数转换成 int 。

两种转换规则:

  • 任意操作数是浮点型的情况: float -> double -> long double
  • 两个操作数都不是浮点类型: int -> unsigned int -> long int -> unsigned long int

赋值中的转换

C语言遵循一个简单的规则:把赋值运算符右侧的表达式转换成左边变量的类型。把浮点数赋值给整型变量会丢掉小数点后的部分。如果取值在变量的类型范围之外,那么把值赋值给一个较小的类型变量将会得到无意义的结果(甚至更糟)。# 第八章 数组

数组是一种聚合(aggregate)变量,可以存储数值的集合。C语言中一共有两种聚合类型:数组和结构。


一维数组

数组是含有多个数据值的数据结构,并且每个数据值具有相同的数据类型。这些数据值被称为元素。

一维数组中的元素一个接一个地编排在单独一行。

声明一个一维数组:

#define N 10
int a[N];

数组下标

长度为n的数组元素的索引范围是0到n-1。

使用数组元素:

a[0] = 1;

!!!warning
C语言不要求检查下标的范围,当下标超出范围时,程序可能执行不可预知的行为。

数组初始化

数组可以在声明时获得一个初始值。

数组初始化式( array initializer ):

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

/* initial value of a is {1, 2, 3, 4, 5, 6, 0, 0, 0, 0} */
int a[10] = {1, 2, 3, 4, 5, 6};

/* initial value of a is {0, 0, 0, 0, 0, 0, 0, 0, 0, 0} */
int a[10] = {0};

/* 可以忽略数组长度,编译器自行确认 */
int a[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

对数组使用 sizeof 运算符

运算符 sizeof 可以确定数组的大小(字节数)。

利用 sizeof 来计算数组元素的大小:

#define SIZE sizeof(a) / sizeof(a[0])

多维数组

数组可以有任意维数。

声明一个二维数组(或称为矩阵):

int m[5][9];

数组m有5行9列。

为了在i行j列中存取数组m的元素,需要写成m[i][j]的形式。

C语言按照行主序存储数组,也就是从第0行开始,接着第1行,如此下去。

多维数组初始化

通过嵌套一维数组的方法可以产生二维数组的初始化式:

int a[3][3] = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};

常量数组

在数组定义前加const使得数组变成一个常量数组,表示不能修改数组里面的元素的值。但这样的数组必须在程序运行前就定义好数组内容,一般用于字符串数组。

const int months[] = 
{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
```# 第九章 函数

函数是C语言中的构建块,本质上就是一个由语句组成的小程序。有了函数,程序就可以被划分成许多小块。

---

## 函数的定义和调用

由一个案例说明函数中的一些基本概念:

double average(double a, double b)
{
return (a + b) / 2;
}


**返回类型**:即函数开始处放置的double,表示每次调用该函数返回的类型。

**形式参数(parameter)**:标识符a和b

**函数体**:花括号里的内容。

**实际参数(argument)**:调用函数时,传递给形式参数的表达式。

### 函数定义

函数定义的一般格式:

返回类型 函数名(形式参数)
{
声明
语句
}


如果返回类型很长,可以把它单独放到一行。

C89标准中,声明必须放在语句之前。

### 函数调用

这样调用函数:

average(x, y);


注意,圆括号不能省略,否则无法正确调用。

返回类型若非void,则会返回一个临时值,这个值可以保存到变量里,也可以丢弃。

## 实际参数

形式参数(parameter)出现在函数的定义中,表示函数调用时要提供的值。

实际参数(argument)是出现在函数调用中的表达式。

实际参数是通过**值传递**的。形参是实参的副本。

### 实际参数的转换

形参和实参的类型不一致时,会发生转换。应该先声明函数原型,然后再执行调用。

这种转换属于隐式转换。实际参数将会转换成形式参数的类型。

### 数组型实际参数

数组名可以作为函数的参数,但是函数无法得知数组的长度,只能传递第二个参数以表明长度。

通过此数组名就可以访问数组元素了。

如果形式参数是多维数组,那么必须指明列的长度:

void sum(arr[][LEN], int n);


但这种用法比较少见。

## return语句

非void的函数必须用return语句来指定要返回的值:

return 表达式;


如果表达式的值得类型与返回值的类型不一致,那么会把表达式的值转换成返回值的类型。

## 程序终止

在main函数中执行return可以终止程序。

main函数的返回值是状态码,用于提供给操作系统,以表明程序的执行结果。正常结束返回0,异常结束返回非0值。

**exit函数**

另一种终止程序的办法是使用exit函数。此函数属于`<stdlib.h>`头。

传递给exit的参数就是程序结束的状态码:

exit(0); // 正常终止
exit(EXIT_SUCCESS); // 正常终止,此值为0
exit(EXIT_FAILURE); // 异常终止,此值为1


## 递归

如果函数调用它自己,那么此函数就是递归的(recursive)。

为了防止无限递归,一定要有一个终止递归的条件。# 第十章 程序结构

---

## 局部变量

在函数体内或程序块内声明的变量叫局部变量。

局部变量有如下性质:

- 自动存储期限。局部变量的存储单元在函数被调用时分配的,在函数返回时收回。

- 块作用域。作用域是可以引用该变量的代码文本部分。局部变量的作用域在程序块中,外部不可访问。

### 静态局部变量

使用static声明局部变量使其成为静态局部变量,它具有静态存储期限,而不再是自动存储期限。

静态变量拥有永久的存储单元,在整个程序的执行期间都会保留。

静态局部变量的作用域仍是块作用域。

### 形式参数

形式参数拥有和局部变量一样的性质,即自动存储期限和块作用域。

其区别在于它是被实参赋值的。

## 外部变量

参数是给函数传递信息的一种方法。另一种方法就是使用外部变量(external variable)。

外部变量声明于函数体外,也叫全局变量。它有如下性质:

- 静态存储期限。如同在函数体内声明的static局部变量,静态存储期限的变量都会永久保留。

- 文件作用域。外部变量的作用域为其声明处到文件末尾。

### 外部变量的利弊

使用外部变量容易引发的问题:

- 在维护期间,如果改变了外部变量,那么就要检查引用了该外部变量的所有函数,以确认此变化对这些函数的影响

- 如果外部变量的值错误,那么比较难定位是哪个函数赋予它错误的值

- 使用了外部变量的函数难以复用,因为此函数不再独立,而是依赖于此外部变量

要给外部变量起一个健全的名字,这样才不容易和其他变量混淆。

## 程序块

程序块就是这样的代码结构:

{ 多条声明 多条语句 }


在程序块中声明的变量具有自动存储期限,出块时收回存储单元,其作用域在块内。

函数体就是一个程序块。

## 作用域

当程序块内的声明命名了一个标识符时,如果此标识符已经可见(被其它地方声明并且可引用),那么新的声明就会”隐藏“旧的声明。

## 构建C程序

一个可能的编排程序的顺序:

- \#include指令

- \#define指令

- 类型定义

- 外部变量的声明

- 除main函数之外的函数的原型

- main函数的定义

- 其它函数的定义# 第十一章 指针

---

## 指针变量

现代计算机把**内存分隔为字节(byte)**。每个字节都有唯一的地址(address)。

变量占有一个或多个字节的内存,把第一个字节的地址称为变量的地址。

地址是一个整数,用指针类型(pointer)的变量来存储。

**指针变量的声明**

在变量名前加星号,来声明一个指针变量。如:

int *p;


此声明说明p是指向int类型的对象。

## 取地址运算符和间接寻址运算符

### 取地址运算符

得到一个变量的地址在它前面加'&'(取地址)。如:

int i;
int *p = &i;


### 间接寻址运算符

获取指针变量指向的存储空间首地址在它前面加`*`(间接寻址)。如:

*p = 10;


这个操作(\*p)得到的就是变量的**别名**。该操作会修改变量的值。

## 指针赋值

只要是相同类型的指针,就可以相互赋值。这样它们就指向了相同的对象。

## 指针作为参数

指针可以做为函数的参数或返回值。

这些情况,可能会用到指针类型的形参:

0. 需要得到多个结果,故而用指针传出去

0. 传入的对象太大,没有必要执行拷贝操作

一般只有这些情况,返回一个指针类型才是安全的:

0. 返回的是指针类型的参数

0. 返回的是一个全局变量地址

0. 返回的是一个static变量的地址# 第十二章 指针和数组

---

## 指针和数组

当指针指向数组元素时,它可以操作数组元素。此时,可以对指针进行算术运算(加、减、比较)。

数组名即是指向数组中第一个元素的指针。

## 指针的算术运算

指针指向了数组元素,之后对这个指针做算术运算就是合法的操作。但不要越界。

- 指针加、减整数,代表移动元素指向

- 指针相减,代表指针指向元素之间的距离

- 指针比较,比较的是指针指向元素谁前谁后

!!!warning
    不能相加两个指针。

## 指针用于数组处理

指针可以操作数组中的元素,比如遍历数组:

```c
int *p = 0;
for (p = &a[0]; p < &a[N]; ++p)
{
    // ...
}

这里N是数组a的长度,虽然a[N]不存在,但对它取地址是合法且安全的操作。

使用*和++组合

如,*p++

++操作的优先级高于*,所以这样的组合操作,会先操作指针p,然后获取其指向的内容。

后置++会先返回p,然后对p递增,可以用这个操作遍历数组:

int *p = &a[0];
while (p < &a[LEN])
{
    int n = *p++;
    // ...
}

用数组名作为指针

数组名即第一个元素的地址。即a与&a[0]等价。

数组名是一个指针常量,不能改变其值,应该把它赋值给一个指针变量。

因此可以这样遍历数组:

int *p = 0;
for (p = a; p < a + LEN; ++p)
{
    // ...
}

指针和多维数组

二维数组在内存中实际上是一维的连续存储的。其首元素如果这样写:

  • &a[0],代表第一行的首地址

  • &a[0][0],代表第一个元素的首地址

这俩地址的值是一样的,但意义不同,因为其指向元素的类型不一样。前者是int*,后者是int

多维数组名作为指针

int a[n][m]这样的二维数组,其数组名a代表的是a[0]的地址,a[i]得到的是第i行的地址。

这样的指针类型是:

int (*p)[m] = a;

typedef int (*Line)[m];
Line p = a;

这里定义了一个指向数组的指针,这个数组的元素有m个。# 第十三章 字符串


字符串字面量

字符串字面量( string literal )是用一对双括号括起来的字符序列:

"Hello World"

字符串字面量中的转义序列

字符串字面量可以包含转义序列:

"Hello\tWorld\n"

延续字符串字面量

字符串字面量可能太长,以至于无法放置在单独一行内可以把第一行用字符\结尾,那么C语言就允许在下一行延续字符串字面量:

printf("put a disk in drive A, then \
press any key to continue");

不只是字符串,字符\可以用来分割任何长的符号。

但\有一个缺陷:字符串字面量必须从下一行的起始位置继续,从而破坏了程序的缩进结构。

一个更好的办法是通过C语言的标准解决这个问题,也就是当两个或更多字符串字面量相连时(仅用空白字符分割),编译器必须把它们合并成单独一条字符串:

printf("put a disk in drive A, then"
       "press any key to continue\n");

如何存储字符串字面量

从本质上而言,C语言把字符串字面量作为字符数组来处理。当C语言编译器在程序中遇到长度为n的字符串字面量时,它会为字符串字面量分配长度为n+1的内存空间。+1存储的是额外的空字符,它用来标志字符串的末尾,用转义序列\0来表示,其数值为0。

例如,字符串字面量”abc”是一个有4个字符的字符数组:

字符串存储

字符串字面量可以为空。字符串””表示一个空串,仅有一个空字符。

既然字符串字面量是作为数组来存储的,那么编译器会把它看作是char*类型的指针。

字符串字面量的操作

通常情况下可以在任何C语言允许使用char*指针的地方使用字符串字面量。例如:

char *p;
p = "abc"; /* 并非复制abc中的字符,而仅仅是使p指向字符串的第一个字符 */

char ch;
ch = "abc"[1]; /* C语言允许对指针添加下标,因此可以给字符串字面量添加下标 */

!!!warning
对于一些编译器而言,改变字符串字面量的内容可能会导致运行异常。因此不推荐这么做。

字符串字面量与字符常量

只包含一个字符的字符串串字面量不同于字符串常量。字符串字面量”a”是指针,指向存放字符a以及后续空字符的内存单元。字符常量’a’是一个整数。

字符串变量

C语言只要保证字符串是以空字符结尾的,任何一维的字符数组都可以用来存储字符串。

定义字符串变量的惯用法:

#define STR_LEN 80
char str[STR_LEN + 1]; /* 强调的事实是 str 可以存储最多80个字符 */

初始化字符串变量

字符串变量可以在声明时进行初始化:

char date1[8] = "June 14";

char date2[8] = {'J', 'u', 'n', 'e', '1', '4', '\0'};

char date3[] = "June 14"; /* 编译器自动计算长度 */

字符数组与字符指针

比如:

char date1[] = "June 14"; /* 字符数组 */
char *date2 = "June 14"; /* 字符串字面量的指针 */

任何期望传递字符数组或字符指针的函数都将接受这两种声明的 date 作为参数。

然而,需要注意,不能错误地认为上面两种 date 可以互换。两者之间有显著的差异:

  • 在声明为数组时,就像任意数组元素一样,可以修改存储在 date 中的字符。但不可以修改字符串字面量。

  • 在声明为数组时, date 是数组名。在声明为指针时, date 是变量,它可以指向其他字符串。

字符串的读/写

使用 printf 和 puts 函数来读写字符串。

使用 scanf 和 gets 函数来读字符串。但 gets 函数不安全。

使用C语言的字符串库

C语言的运算符无法操作字符串,C语言的库函数为字符串的操作提供了丰富的函数集。这些函数的原型驻留在 string.h 头文件中。

strcpy 函数可以拷贝字符串。

strcat 函数可以追加字符串。

strcmp 函数可以比较字符串。

strlen 函数可以取得字符串的长度。

字符串数组

比如:

char *planets[] = {
    "Mercury",
    "Venus",
    "Earth",
    "Mars",
    "Jupiter",
    "Saturn",
    "Uranus",
    "Neptune",
    "Pluto",
};

planets 数组的每一个元素是一个字符串的指针。

命令参数

运行程序时,经常需要提供一些信息给程序,这是命令行参数( command-line argument )。必须把 main 函数定义为含有两个参数的函数:

int main(int argc, char* argv[])
{

}

argc (参数计数)是命令行参数的数量(包括程序名本身,最少为1)。 argv (“参数向量”)是指命令行参数的指针数组。 argv[0] 指向程序名, argv[n] 表示第n个参数。

argv[argc] 始终是一个空指针。

如果用户输入了下面的命令:

ls -l remind.c

那么 argc 和 argv 是:

  • argc: 3

  • argv: {“ls”, “-l”, “remind.c”, NULL}# 第十四章 预处理器


预处理器的工作方式

预处理器的行为是由指令控制的。这些指令是由#字符开头的一些命令。比如 #define 和 #include 。

#define定义了一个宏——用来代表其他东西的一个名字。当宏在后面的程序中用到时,预处理器扩展它,将宏替换为它所定义的值。

#include指令告诉预处理器打开一个特定的文件,将它的内容作为正在编译的文件的一部分。

!!!note “my note”
可以使用gcc -E src.c指令来查看预编译结果。

预处理指令

常见预处理指令包括:

  • 宏定义。#define定义一个宏,#undef删除一个宏。

  • 文件包含。即#include

  • 条件编译。#if #ifdef #ifndef #elif #else #endif

指令的通用规则有:

  • 都以#开始。

  • 在指令的符号之间可以插入任意数量的空格或横向制表符。

  • 指令总是在第一个换行符处结束,除非明确地指明要继续,用\字符换行。

  • 指令可以出现在程序的任何地方。

  • 注释可以与指令放在同一行。

测试代码

宏定义

宏定义的作用范围从定义处开始到本文件末尾。

简单宏定义

简单宏定义的格式如:

#define <宏名> [替换列表]

替换列表中可以有空格。甚至可以没有替换列表,此时宏替换后,就等于删除了这个宏一样。

简单的宏定义一般用于:

  • 给字面量取一个别名
  • 辅助条件编译

带参数的宏定义

格式如:

#define <宏名>(x1, x2, ..., xn) [替换列表]

注意点:

  • 宏名和参数列表的括号之间不能有空格,不然就是一个简单宏了

  • 参数列表可以为空,这样的宏使用起来就像一个函数

  • 参数只会替换记号,字符串内的同名单词并不会被替换

带参数的宏一般用于:

  • 替代一些小的函数,这样程序的执行效率会高一些,并且函数可能更加通用,因为宏不必检查参数类型

#号和##号

宏替换列表中有两个特殊符号:#和##,它们有如下的意义:

  • #号代表参数会被替换成一个字符串字面量,例如 :
#define PRINT_INT(n) printf(#n " = %d\n", n)

#n会被替换成”n”,相邻的字符串字面量可以连起来形成一个字符串字面量,所以PRINT_INT(a)的宏替换结果是:

printf("a = %d\n", a);
  • ##代表将两边的记号连接在一起,成为一个记号,一个典型的例子:
#define GENERIC_MAX(type)               \
type type##_max(type x, type y)         \
{                                       \
        return x > y ? x : y;           \
};

这个宏定义定义了一个取最大值的函数,可以方便的为这个函数指定比较类型。

值得注意的是,#和##都在简单的宏替换后起作用

宏定义中的圆括号

如果宏定义的替换列表是一个表达式,那么为其增加圆括号是必不可少的工作。

这是因为如果不加圆括号,在宏替换后,新的表达式可能会破坏替换列表表达式的运算优先级。

在替换列表表达式中使用圆括号有两条规则:

  1. 用圆括号将替换列表括起来
  2. 用圆括号把每个宏参数括起来

一个安全的宏的例子:

#define SUM(x, y) ((x) + (y))

创建较长的宏

一些废话:

宏函数展开后,实际上只有一行。而编写的时候为了好看,可以用\作为换行连接符号。

另外,宏函数使用时看上去应该像普通函数一样:后面也要加分号。所以宏函数的替换列表的末尾应该没有分号。

直接上书上所给的解决方案:

#define ECHO(str)    \
do                   \
{                    \
    gets(str);       \
    puts(str);       \
} while(0)

// use
ECHO(str);

预定义宏

常用预定义宏:

说明
__LINE__ 行号,十进制常数
__FILE__ 文件名
__DATE__ 文件编译时的日期
__TIME__ 文件编译时的时间

文件名,日期,时间的预定义宏展开后都是一个字符串变量。行号是一个整型变量。

另外,不同的系统会定义不同的预定义宏,来标识其编译平台。如:

  • Linux下,__unix

  • Windows下,_WIN32

这种预定义宏配合条件编译就可以做到跨平台编译代码。

测试代码

特殊的预定义宏__VA_ARGS__

C99标准中,有一个特殊的预定义宏,它的作用是替换可变参数列表(…),但它要和##符号配合使用,此时##的意义不再是连接,而是:当可变参数列表为空的时候,去除__VA_ARGS__前面的逗号,从而避免编译错误。

一个典型的例子:

#define CONSOLE_DEBUG(fmt, ...)\
    printf("FILE: "__FILE__", LINE: %05d "fmt"\n", __LINE__, ##__VA_ARGS__);

__FUNCTION__

这个宏代表了当前执行函数的函数名字符串。

条件编译

条件编译指令排除了不应该出现的文本。只有通过了条件编译的文本块才会被交给编译器编译。

条件一般是一个普通的宏。

书写格式如:

#if MACRO
code
#elif MACRO
code
#else
code
#endif

defined 运算符

defined 运算符仅用于预处理器。

#if defined(DEBUG)
...
#endif

如果标识符 DEBUG 是一个定义过的宏,则返回1,否则返回0。 defined 返回1意味着通过条件。

指令说明:

  • #if, #elif可以判断这个宏的值,如果是0就不会通过条件编译

  • #ifdef, #ifndef可以判断这个宏是否被定义

条件编译的作用一般是:

  • 为了支持跨平台编译

  • 排除一些调试代码

其他指令

#error 指令

如果预处理器遇到一个#error指令,它会显示一个出错消息,大多数编译器会立即终止编译。

#error You can not include this file

#pragma指令

#pragma指令为要求编译器执行某些特殊操作提供了一种方法。

使用#pragma pack预处理指令来设置字节对齐。具体用法如:

#pragma pack(push)    // 保存现在的字节对齐状态
#pragma pack(4)       // 设置4字节对齐
// 这里定义的结构体最好以4字节对齐
#pragma pack(pop)     // 恢复字节对齐状态

这里字节对齐的意思是,将结构体中最大内置类型的成员的长度与默认字节对齐数(比如是4)对比,如果谁小,那么就按谁来对齐。# 第十五章 编写大规模程序

源文件包含函数的定义和外部变量,而头文件包含可以在源文件之间共享的信息。


源文件

可以把程序分割成一定数量的源文件,源文件的扩展名为.c。源文件主要包含函数的定义和变量。其中一个源文件必须包含名为 main 的函数,作为程序的起始点。

把程序分成多个源文件有许多显著的优点:

  • 把相关的函数和变量集合在单独一个文件中可以帮助明了程序的结构。

  • 可以单独对每一个源文件进行编译。如果程序规模很大而且需要频繁改变的话,这种方法可以极大地节约时间。

  • 利于复用。

头文件

当把程序分割成几个源文件时,问题也随之产生:某文件的函数如何能调用定义在其他文件中的函数?函数如何能访问其他文件中的外部变量?两个文件如何能共享同一个宏定义或类型定义?答案取决于#include指令。

#include指令告诉预处理器打开指定的文件,并且把此文件的内容插入到当前文件中。这种打开的文件称为头文件,其扩展名为.h

include 指令

#include指令有两种书写格式:

  • #include <文件名> 搜索系统头文件所在目录,比如在 UNIX 系统中,通常是在 /usr/include

  • #include "文件名" 搜索当前目录,然后搜索系统目录

利用加上诸如-I这样的命令行选项可以添加搜索头文件的位置。

共享宏定义和类型定义

大规模的程序包含用于几个源文件共享的宏定义和类型定义,这些定义应该放在头文件中。

比如下图的例子:

宏定义和类型定义

有两个源文件包含了 boolean.h

把宏定义和类型定义放到头文件中有如下的好处:

  1. 不必把定义复制到需要的源文件,节约时间。

  2. 程序变得更加容易修改,改变定义只需要改变头文件。

  3. 不用担心源文件包含了相同的宏或类型而其定义不同。

共享函数原型

没有原型依赖的函数调用是很危险的,编译器的假设可能是错误的。当调用定义在其他文件中的函数时,要始终确保编译器在调用之前看到函数f的原型。

解决办法就是把函数的原型放进头文件中,然后在所有调用函数f的地方包含头文件。

其包含的方式可能如图所示:

共享函数原型

共享变量的声明

变量可以在文件中共享。

为了声明变量而不定义,需要在变量声明的开始处放置关键字 extern :

/* in heaeder file */
extern int i;

为了共享i,需要在一个源文件中定义i:

/* in source file */
int i;

保护头文件

如果一个源文件同时包含一个头文件两次,那么可能产生编译错误(比如包含了两次相同的类型定义)。

因此要用到一种保护头文件的方法:

#ifndef BOOLEAN_H
#define BOOLEAN_H

/*
 real content
*/

#endif

如果再次包含此头文件,预处理器就不会再扩展真实的内容。

头文件中的#error指令

经常把#error指令放置在头文件中是用来检查不应该包含头文件的条件。例如:

#ifndef DOS
#error Graphics supported only under DOS
#endif

如果非DOS的程序试图包含此头文件,那么编译将在#error指令处终止。

构建多文件程序

构建大程序的基本步骤:

  • 编译,必须对每一个源文件进行编译。不需要编译头文件。编译器产生一个文件,此文件包含来自源文件的目标代码,称为目标文件(object file)。

  • 链接,链接器把目标文件和库文件结合在一起生成一个可执行程序。

大多数编译器允许用单独一步来构建程序:

cc -m fmt fmt.c line.c word.c

makefile

使用 makefile 更易于构建大型程序。 makefile 列出了作为程序的部分文件,并描述了它们之间的依赖性。

更多讨论见书本。

!!!note “my note”
一种自动生成依赖性的说明的方法是键入命令:gcc -MM *.c

在程序外定义宏

大多数 UNIX 编译器支持-D选项,允许在命令行指定一个宏定义:

cc -DDEBUG=1 foo.c

定义了宏 Debug ,在 foo.c 程序中,且值为1。如同在 foo.c 中的开始出现:

#define DEBUG 1
```# 第十六章 结构、联合和枚举

---

## 结构变量

结构的元素可能具有不同的类型,而且,每个成员都有名字。

### 结构变量的声明

一个声明结构变量的例子:

```c
struct {
    int number;
    char name[NAME_LEN + 1];
    int on_hand;
} part1, part2;

每个结构变量都有三个成员:number, name, on_hand 。

struct {...}指明了类型,而 part1 和 part2 则是具有这种类型的变量。

结构的成员在内存中是按照声明的顺序存储的。第一个声明的变量放在存储位置的最前面。

每个结构表示一种新的名字空间( name space )。 part1 的 number 和 part2 的 number 不会有冲突。

结构变量的初始化

结构变量可以在声明的同时进行初始化:

struct {
    int number;
    char name[NAME_LEN + 1];
    int on_hand;
} part1 = { 528, "Disk drive", 10 },
  part2 = { 914, "Printer cable", 5 };

结构初始化式的表达式必须是常量。初始化式可以短于它所初始化的结构,这样任何“剩余”成员都用0作为它的初始值。

对结构的操作

为了访问结构内的成员,首先写出结构的名字,然后写出成员的名字:

printf("Part number: %d\n", part1.number);

结构成员的值是左值:

part1.number = 258;

用于访问结构成员的句点是一个运算符,其优先级比较高:

/* &计算的是 part1.on_hand 的地址 */
scanf("%d", &part1.on_hand);

另一种主要的结构操作是赋值运算:

part2 = part1;
/* 现在 par1 和 part2 每个成员的值都一样 */

可以用结构来复制数组:

struct { int a[10]; } a1, a2;
a1 = a2;

运算符=仅仅用于类型一致的结构。

结构类型

如果在两个地方编写了:

struct {
    int number;
    char name[NAME_LEN + 1];
    int on_hand;
} part1;

struct {
    int number;
    char name[NAME_LEN + 1];
    int on_hand;
} part2;

那么 part1 和 part2 就不是同一个类型,这样就不能执行赋值操作。为了解决这个问题,需要为表示结构的类型定义名字。方法有两种:

  • 使用结构标记

  • 使用 typedef 定义类型名

结构标记的声明

结构标记( structure tag )即:

struct part {
    int number;
    char name[NAME_LEN + 1];
    int on_hand;
};

/* 用标记 part 声明变量 */
struct part part1, part2;

结构类型的定义

即:

typedef struct {
    int number;
    char name[NAME_LEN + 1];
    int on_hand;
} Part;

/* 声明变量 */
Part part1, part2;

联合

联合( union )也是由一个或多个成员构成的,而且这些成员可能具有不同的数据类型。但是,编译器只为联合中最大的成员分配足够的内存空间,联合的成员在这个空间内彼此覆盖。

对于:

union {
    int i;
    float f;
} u;

struct {
    int i;
    float f;
} s;

他们的存储如:

联合和结构的存储

其中 u.i 和 u.f 具有相同的地址。

枚举

枚举( enumeration )是一种由程序员列出的值,而且程序员必须为每种值命名(枚举常量):

enum { CLUBS, DIAMONDS, HEARTS, SPADES } s1, s2;

虽然枚举和结构没什么共同的地方,但是它们的声明方式很类似。

枚举常量的名字必须不同于闭合作用域内声明的其他标识符。

枚举标记和枚举类型

为了定义枚举标记,可以写成:

enum suit { CLUBS, DIAMONDS, HEARTS, SPADES };

/* 声明枚举变量 */
enum suit s1, s2;

用 typedef 给枚举命名:

typedef enum { CLUBS, DIAMONDS, HEARTS, SPADES } Suit;

/* 声明枚举变量 */
Suit s1, s2;

枚举作为整数

在系统内部,C语言会把枚举变量和常量作为整数来处理。枚举常量的值可以是任意整数:

enum suit { CLUBS = 1, DIAMONDS = 2, HEARTS = 3, SPADES = 4 };

两个或多个枚举常量具有相同的值甚至也是合法的。

当没有为枚举常量指定值时,它的值是一个大于前一个常量的值的值(大1)。默认第一个枚举常量的值为0。# 第十七章 指针的高级应用


动态存储分配

任何单纯的数据结构(各种内置类型,数组,结构体),其大小在程序开始时已经确定了,且不能改变。而一些数据结构可能需要动态的改变其数据长度,比如链表。这就要用到动态存储分配(dynamic storage allocation)。

使用动态存储分配的数据块存放在“堆”上,和其它存储区域不同的是,“堆”里的数据应该让程序员来控制释放(free)时机。

为了动态地分配存储空间,将需要调用3种内存分配函数中的一种,这些函数都是声明在stdlib.h中的:

  1. malloc,分配内存块,但是不初始化它

  2. calloc,分配内存块,并对其清零

  3. realloc,调整先前分配的内存块

由于malloc函数不需要对分配的内存块进行清除,所以它比calloc函数更高效。

空指针

当调用内存分配函数时,无法定位满足我们需要的足够大的内存块,这种问题始终可能出现。如果真的发生了这类问题,函数会返回空指针。

空指针(null pointer)是指一个区别于所有有效指针的特殊值。

!!!warning
程序员的责任是测试任意内存分配函数的返回值,并且在返回空指针时采取适当的操作。通过空指针试图访问内存的效果是未定义的,程序可能会崩溃或者出现不可预测的行为。

用名为NULL的宏来表示空指针,可用下列方式测试malloc函数的返回值:

p = malloc(10000);
if (p == NULL) {
    /* allocation failed; take appropriate action */
}

动态分配字符串

动态内存分配经常用于字符串操作。字符串始终存储在固定长度的数组中,而且可能很难预测这些数组需要的长度。通过动态地分配字符串,可以推迟到程序运行时才作决定。

使用malloc函数为字符串分配内存

函数原型:

void *malloc(size_t size);

size_t是无符号整型,malloc分配了一段size个字节的连续空间,并返回该空间首地址。如果分配失败就返回NULL。

因为C语言保证char型值确切需要一个字节的内存,为了给n个字符的字符串分配内存空间,可以写成:

p = malloc(n + 1);

通常情况下,可以把void*型值赋给任何指针类型的变量。然而,一些程序员喜欢强制转换malloc函数的返回值:

char *p = (char*)malloc(n + 1);

由于使用malloc函数分配内存不需要清除或者以任何方式初始化,所以p指向带有n+1个字符的未初始化的数组。

可以调用strcpy函数对上述数组进行初始化:

strcpy(p, "abc");

数组中前4个字符分别为a, b, c和空字符。

动态分配数组

编写程序时,常常为难数组估计合适的大小。较方便的做法是等到程序运行时再来确定数组的实际大小。

虽然malloc函数可以为数组分配内存空间,但calloc函数确实是最常用的一种选择。因为calloc函数对分配的内存进行初始化。realloc函数允许根据需要对数组进行“扩展”或“缩减”。

使用malloc函数为数组分配存储空间

当使用malloc函数为数组分配存储空间时,需要使用sizeof运算符来计算出每个元素所需要的空间数量。

使用sizeof计算是必须的,因为这样计算的结果在不同平台下都是正确的。

int *a = malloc(n * sizeof(int));

这里的n可以在程序执行期间计算出来。

一旦a指向了动态分配的内存块,就可以把它用作数组的名字。这都要感谢C语言中数组和指针的紧密关系。可以使用下列循环对此数组进行初始化:

for (i = 0; i < n; ++i)
    a[i] = 0;

calloc函数

函数原型:

void *calloc(size_t nmemb, size_t size);

nmemb是数据单元的个数, size是一个数据单元的大小。返回成功申请的数据块首地址,失败返回NULL。

calloc不仅会从“堆”申请存储区域,还会把这段区域清零。也因此其执行效率没有malloc高。

下列calloc函数的调用为n个整数的数组分配存储空间,并且保证全部初始为0:

a = calloc(n, sizeof(int));

通过调用以1作为第一个实际参数的calloc函数,可以为任何类型的数据项分配空间:

struct point { int x, y; } *p;
p = calloc(1, sizeof(struct point));

此语句执行后,p指向结构,且此结构的成员x和y都会被设置为0。

realloc函数

一旦为数组分配完内存,稍后可能会发现数组过大或过小。realloc函数可以调整数组的大小使它更适合需要。

函数原型:

void *realloc(void *ptr, size_t size);

ptr必须指向内存块,且此内存块一定是先通过malloc函数、calloc函数或realloc函数的调用获得的。size表示内存块的新尺寸,新尺寸可能会大于或小于原有尺寸。

C标准列出几条关于realloc函数的规则:

  • 当扩展内存块时,realloc函数不会对添加进内存块的字节进行初始化。

  • 如果realloc函数不能按要求扩大内存块,那么它会返回空指针,并且在原有内存块中的数据不会发生改变。

  • 如果realloc函数调用时以空指针作为第一个实际参数,那么它的行为就像malloc函数一样。

  • 如果realloc函数调用时以0作为第二个实际参数,那么它会释放掉内存块。

!!!warning
一旦realloc函数返回,请一定要对指向内存块的所有指针进行更新,因为可能realloc函数移动了其他地方的内存块。

实际使用时,realloc应该始终对ptr指向的存储区域进行扩展。

realloc不是一个好用的函数,要很小心才行。这是因为原来的存储区域会被释放掉(虽然新的存储区域会可能和原来的重叠),其指针很可能都变的无效。

释放存储

malloc函数和其他内存分配函数所获得的内存块都来自一个称为(heap)的存储池。调用这些函数经常会耗尽堆,或者要求大的内存块也可能耗尽堆,这会导致函数返回空指针。

更糟的是,程序可能分配了内存块,然后又丢失了这些块的追踪路径,因而浪费了空间。如下例子:

p = malloc(...);
q = malloc(...);
p = q;

由于没有指针指向第一个内存块,所以再也不能使用此内存块了。

对于程序而言,不再访问到的内存块被称为是垃圾(garbage)。在后边留有垃圾的程序有内存泄漏(memory leak)。一些语言提供了垃圾收集器(garbage collector),但C语言不提供。每个C程序负责回收各自的垃圾,方法是调用free函数来释放不需要的内存。

如上例子,就是一个内存泄漏。第一块内存再也访问不到了,这应该就是上文所说的留有垃圾。

free

只有一个方法释放由动态存储分配函数分配的内存空间。就是使用free函数,如果不释放,那么这块资源就一直放在“堆”里,直到程序退出。

函数原型:

void free(void *ptr);

使用free函数很容易,只是简单地把指向不再需要的内存块的指针传递给free函数就可以了:

p = malloc(...);
free(p);

调用free函数来释放p所指向的内存块。然后会把这个释放的内存返回给堆,使此内存块可以被复用。

“悬空指针”问题

free操作会生成悬空指针(dangling pointer)。即调用free(p)函数会释放p指向的内存块,但是不会改变p本身。如果忘记了p不再指向有效内存块(而使用它),后果很严重。

悬空指针是很难发现的,因为几个指针可能指向相同的内存块。在释放内存块时,全部的指针都会留有悬空。

指向函数的指针

函数也有地址,所以就可以有指针指向。一些功能强大的函数(像模板一样)都是通过函数指针和void*实现的。

函数指针

函数指针主要被存放在:

  • 数组里,方便日后调用
  • 形参,成为模板函数的实现,比如qsort

定义一个函数指针类型的例子:

typedef void (*Func)();

函数入口地址

函数名就是函数地址,但通常会对函数名做&运算,其实得到的结果是一样的。同样对函数指针做*运算(解引用)和直接拿函数指针用也是一样的,都是代表了函数的入口地址。

一般会对函数名做&操作,对函数指针做*操作,让它们看上去比较像指针的使用。# 第十八章 声明


什么是声明

一个变量或者函数应该首先被声明,才会被使用。因为声明会告诉编译器这个变量或者函数的信息,然后编译器就可以检查其存储空间和作用域,以及使用时的语法是否正确。

声明的语法

声明的格式(声明式)是:

声明说明符 声明符

声明说明符描述了变量或者函数的性质,声明符代表变量名或者函数名,并且可以指明它的额外信息(如是一个数组or指针or函数)。

声明说明符分为以下3类:

  1. 存储类型。四种:auto(块内默认存储类型,无需显示声明),static,extern和register(已经被现代编译器优化,一般不需要声明)

  2. 类型限定符,有const和volatile

  3. 类型说明符,诸如int,long,unsighed,或者自定义数据类型等,对于函数,类型说明符就是返回类型。

声明符就是一个标识符,然后可以用星号(代表指针),方括号(代表数组),圆括号(代表函数)修饰。

可以看到,声明没有赋值的内容。

存储类型

变量的性质

C程序中,变量都有三种性质:

  1. 存储期限,决定了变量的生存周期。具有自动存储期限的变量在第一次执行时获得内存单元,出块时释放内存单元;具有静态存储期限的变量在程序的生命周期内都拥有内存单元。

  2. 作用域,拥有块作用域的变量只能在块内被使用;拥有文件作用域的变量从声明变量开始到文件结束的范围都可以使用。

  3. 链接,拥有外部链接的变量(内存单元)可以被程序的其它文件访问;拥有内部链接的变量只可以在文件内访问;无链接的变量只能在一个函数内访问。

上述三种性质取决于变量的声明位置以及变量的存储类型,比如不指明存储类型的话:

  • 在块内声明的变量,具有自动存储期限,块作用域,无链接

  • 在程序的最外层声明的变量,具有静态存储期限,文件作用域,外部链接。

作用域

决定一个变量的作用域的,仅在于它的声明位置:

  1. 在块内声明的,具有块作用域

  2. 在文件最外层声明的,具有文件作用域

作用域是编译级别的(非链接)语法,编译器根据变量的作用域检查其使用的位置是否正确。

static存储类型

当static作用于一个块内声明的变量时,将改变它的存储期限为静态存储期限

当static作用于一个最外层声明的变量时,将改变它的链接为内部链接,使这个变量的内存单元不能被其它文件所访问

extern存储类型

用extern来声明一个变量,不会让编译器为它分配内存单元,它只是告诉编译器,这个变量是在别的地方定义的。因此:当extern作用于一个变量时,这个变量必须拥有静态存储期限且一般有外部链接。一般这个变量都是一个在最外层定义的变量。

函数存储类型

默认情况下,函数存储类型都是extern的,代表此函数的链接是外部链接,可以被其它文件访问。

如果给函数加上static声明,那么这个函数的链接就会被修改成内部链接,只能在文件内访问。如果一个函数不需要被多个模块共享,那么就应该声明成static的。

const限定符

声明一个编译器维度上的常量,但却不能看做一个常量表达式,从而不能定义一个数组的边界(应该用#define)。

const主要用于保护一个指针指向的对象不被修改,也就是定义一个常量指针,使其指向的空间不允许被修改。

声明符

声明符是由标识符和三个特殊符号组成的,这三个特殊符号是:

  • 放在标识符前面的*

  • 放在标示符后面的()或者[]

解释复杂声明

有时候声明符包含了多个特殊符号,这就要通过两条规则进行解释才能理解。它们是:

  • 始终从内向外读声明符,也就是先定位标识符,然后往外读

  • []()始终优先于*,但()可以强制修改优先级

初始化式

在声明一个变量时,可以给它=一个初始值,这叫初始化式,而不是赋值。

需要注意的几点:

  • 静态变量只能用常量表达式初始化,如果没有初始化,那么就是0

  • 拥有自动存储期限的变量如果没有初始化,其值就是未定义的(包括数组)

  • 数组的初始化(大括号闭合)必须用常量表达式初始化每一个元素# 第十九章 程序设计

虽然C语言不是专门用来编写大规模程序的,但许多大规模程序的确是用C语言编写的。相对于小型程序,编写一个大规模的程序需要更仔细的设计和更详细的计划。


模块

当设计一个C程序(或其他任何语言的程序)时,最好将它看作是一些独立的模块。模块是一组功能(服务)的集合,其中一些功能可以被程序的其他部分(称为客户)使用。每个模块都有一个接口来描述所提供的功能。模块的细节,包括这些功能自身的源代码,都包含在模块的实现中。

在C语言环境下,这些“功能”就是函数,模块的接口就是头文件,头文件中包含那些可以被其他文件调用的函数的原型。模块的实现就是包含该模块中函数的定义的源文件。

将程序分割成模块有一系列好处:

  • 抽象。我们知道模块会做什么,但不需要知道这些功能是如何被实现的。因为抽象的存在,使我们不必为了修改部分程序而了解整个程序是如何工作的。

  • 可复用性。每一个提供一定功能的模块,都有可能在另一个程序中复用。

  • 可维护性。将程序模块化后,程序中的错误通常只会影响一个模块,因为更容易找到并解决错误。在解决了错误后,重新编译程序只需要将该模块的实现进行编译即可。

一旦我们已经认同了模块化程序设计是正确的方向,接下来的问题就是设计程序的过程中究竟应该定义哪些模块。

内聚性与耦合性

一个好的模块并不是随意的一组声明。好的模块应该具有下面两个性质:

  • 高内聚性。模块中的元素应该相互紧密相关。

  • 低耦合性。模块之间应该尽可能相互独立。低耦合性可以使程序更便于修改,并方便以后复用模块。

模块的类型

由于需要高内聚性、低耦合性,模块通常会属于下面几类:

  • 数据池。表示一些相关变量或常量的集合。通常这类模块是一些头文件。

  • 库。库是一组相关函数的集合。

  • 抽象对象。一个抽象对象是指对于隐藏的数据结构进行操作的一组函数的集合。

  • 抽象数据类型。将具体数据的实现方式隐藏起来的数据类型称为抽象数据类型。作为客户的模块可以使用该类型来声明变量,但不会知道这些变量的具体数据结构。如果客户模块需要对变量进行操作,则必须调用抽象数据类型所提供的函数。

信息隐藏

一个设计良好的模块经常对它的客户隐藏一些信息。例如我们的栈模块的使用者就不需要知道究竟栈是用数组实现的还是用链表。信息隐藏有两大优点:

  • 安全性。数据必须通过模块自身提供的函数来操作,而这些函数都是经过测试的。

  • 灵活性。无论对模块的内部机制进行多大的改动,都不会很复杂。不需要改变模块的接口。

在C语言中,可以用于强行信息隐藏的工具是 static 存储类型。将一个函数声明成 static 类型可以使函数内部链接,从而阻止其他文件(包括模块的客户)调用这个函数。将一个带文件作用域的变量声明成 static 类型可以达到类似的效果,使该变量只能被同一个文件中的其他函数访问。

抽象数据类型

对于作为抽象对象的模块,有一个缺点:不可能对同一个对象有多个实例。为了达到这个目的,需要进一步创建一个新的类型。这就是抽象数据类型。然后模块的接口函数需要传入这个类型对象的指针对其进行操作。

但C语言不提供封装的功能,客户可以访问抽象数据类型的成员。确实有技巧可以达到类似的目的,但使用起来笨拙。

实现封装的最佳方法是使用C++语言。实际上,C++语言产生的原因之一就是因为C语言不能很好的支持抽象数据类型。

C++语言

略。# 第二十章 低级程序设计

前面几章中讨论的是C语言中高级的、与机器无关的特性。有一些程序需要进行位级别的操作。位操作和其他一些低级运算在编写系统程序、加密程序、图形程序以及一些需要高执行速度或高效地使用空间的程序时非常有用。

本章描述的一些技术需要用到数据在内存中如何存储的知识,这对不同机器和编译器可能会不同。依赖于这些技术很可能会使程序丧失可移植性。


按位运算符

移位运算符

移位运算符可以改变数的二进制形式,将它的位向左或者向右移动。

符号 含义
<< 左移位
>> 右移位

运算符<<>>的操作数可以是任意整数型或字符型的。对两个操作数都会进行整数提升,返回值的类型是左边操作数提升后的类型。

i<<j的值是将i中的位左移j位后的结果。每次从i的最左端溢出一位,在i的最右端补一个0位。i>>j的值是将i中的位右移j位后的结果。如果i是无符号数或非负数,则需要在i的最左端补0。如果i是负值,其结果是由实现定义的。一些补0,一些补1。

!!!note
可移植性技巧:最好仅对无符号数进行移位运算。

按位求反、按位与运算符、按位异或运算符和按位或运算符

符号 含义
~ 按位求反
& 按位与
^ 按位异或
| 按位或

上面的顺序也是运算符优先级的顺序。

结构中的位域

C语言提供了声明成员为位域的结构。

比如:

struct file_date {
    unsigned int day:5;
    unsigned int month:4;
    unsigned int year:7;
};

struct file_data fd;
fd.day = 28;
fd.month = 12;
fd.year = 8; /* represents 1988 */

这个结构占据32个比特,每个成员后面的数字指定了它所占用位的长度。

位域有一个限制,C语言不允许将&运算符作用于位域。

位域之间没有间隙,直到剩下的空间不够用来放下一个位域了,这时,一些编译器会跳到下一个存储单元继续存放位域,而另一些则会将位域拆开跨存储单元存放。位域的存放的顺序也是由实现定义的。# 第二十一章 标准库


标准库的使用

C89标准库有15个部分,即15个头文件。

标准头主要由函数原型、类型定义和宏定义组成。

<assert.h> 诊断

允许程序插入自我检查,一旦检查失败,程序就被终止。

<ctype.h> 字符处理

提供用于字符分类及大小写转换的函数。

<errno.h> 错误

提供了error number,它是一个左值,可以在调用特定库函数后进行检测,来判断调用过程中是否有错误发生。

<float.h> 浮点类型的特性

提供了用于描述浮点类型特定的宏,包括值得范围及精度。

<limits.h> 整数类型的大小

提供了用于描述整数类型特性的宏,包括他们的最大值和最小值。

<locale.h> 本地化

提供一些函数来帮助程序适应针对某个国家或地区的特定行为方式。包括显示数的方式、货币的格式、字符集以及日期和时间的表示形式。

<math.h> 数学计算

提供常见的数学函数。

<setjmp.h> 非本地跳转

提供了setjmp和longjmp函数,setjmp会标记程序中的一个位置,随后可以用longjmp返回标记的位置。可以实现从一个函数跳转到另一个函数中,绕过正常的函数返回机制。主要用来处理程序中的严重问题。

<signal.h> 信号处理

提供了用于处理异常的函数,包括中断和运行时错误。signal可以设置一个函数,使系统信号到达时自动调用该函数;raise函数用来产生信号。

<stdarg.h> 可变参数

提供一些工具用于编写参数个数可变的函数。

<stddef.h> 常用定义

提供经验使用的类型和宏定义。

<stdio.h> 输入与输出

提供大量的输入和输出函数,包括对文件的顺序访问和随机访问操作。

<stdlib.h> 常用实用程序

包含大量无法划归其它头的函数。包含函数:将字符串转换成数,产生伪随机数,执行内存管理任务,与操作系统通信,执行搜索与排序等。

<string.h> 字符串处理

包含操作字符串的函数。

<time.h> 日期和时间

提供相应的函数来获取时间,操纵时间,以及格式化时间。

对标准库中所用名字的限制

只要包含了标准头(没有不包含的情况吧?),必须遵循两条规则:

  1. 不用自己定义标准头已定义过的宏

  2. 具有文件作用域的库名也不可以在文件层次重定义

还有一些命名规则,不要与标准库有冲突:

  • 由一个下划线和一个大写字母开头,或由两个下划线开头的标识符,是标准库保留的标识符

  • 由一个下划线开头的标识符,被保留用作具有文件作用域的标识符和标记,只可用于函数内部声明

  • 在标准库中所有具有外部链接的标识符被保留用作具有外部链接的标识符,比如printf

使用宏隐藏的函数

C语言允许在头中定义与库函数同名的宏。从而使得宏隐藏掉函数。

使用宏可能会提高程序的运行速度。如果有不想使用宏的情况,可能是因为想缩小可执行代码的大小。

若要删掉宏,可用如下方法:

#include <stdio.h>
#undef getchar

还可以禁用宏:

(getchar)()

stddef.h 常用定义

此头提供了常用的类型和宏的定义。定义的类型包括以下几个:

  • ptrdiff_t,指针相减的结果类型,是有符号整数

  • size_t,sizeof运算符返回的类型,是无符号整数

  • wchar_t,一种足够大的,可以用来表示所有支持的地区的所有字符的类型

其中一个宏是:offsetof,其意思是求得结构体的起点到指定成员间的字节数。

比如,有下面的结构体:

struct s {
    char c;
    int b[2];
    float c;
};

offsetof(struct s, a)的值一定是0,因为结构体的首元素的地址一定是结构体的地址;

offsetof(struct s, b)的值可能是1,但也可能是4(考虑到字节对齐)。# 第二十二章 输入 输出


流意味着任意输入的源或任意输出的目的地。输入流通常和键盘相关,输出流通常和屏幕相关。

流还可以表示为磁盘上的文件,以及其他设备。

文件指针

流的访问是通过 文件指针(file pointer) 实现的。此指针的类型为FILE*

stdio.h提供了3种标准流,这三个标准流是备用的,不能声明、打开、关闭它们。

文件指针 默认的含义
stdin 标准输入 键盘
stdout 标准输出 屏幕
stderr 标准错误 屏幕

重定向(redirection)

操作系统允许通过重定向机制来改变标准流默认的含义。

例如:

demo < in.data

称为输入重定向(input redirection),即把stdin流表示为文件in.dat,而非键盘。对于程序demo而言,它并不知道输入流是来自键盘还是文件。

这样子是输出重定向(output redirection)

demo > out.dat

如此一来,写入stdout的内容将不再输出到屏幕,而是文件out.dat。

文本文件与二进制文件

文件就是字节的序列。

文本文件中,字节表示字符。

二进制文件中,字节就是字节,可以用于表示任意类型的数据。

DOS系统中,这两种文件之间有如下差异:

  • 行的结尾。文本文件写入换行符时,换行符扩展成一对字符,即回行符和跟随的回车符。如果把换行符写入二进制文件时,它就是一个单独的字符(换行符)。

  • 文件末尾。文本文件中,文件的结束标记是CTRL+Z字符(\x1a)。二进制文件中,此字符没有特别的含义,跟其它任何字符一样。

在Unix操作系统中,二进制文件和文本文件不进行区分,其存储方式一样。

文件操作

打开文件

使用 fopen 函数。

关闭文件

使用 fclose 函数。

从命令行获取文件名

当程序需要打开一个文件时,通常通过命令行参数把文件名传给程序,这样更具灵活性。

主函数:

int main(int argc, char *argv[]);

argc是命令行实际参数的数量(非数组长度),argv是参数字符串数组。

argv[0]是程序名,argv[1] ~ argv[argc-1]是剩余参数。

argv[argc]是空指针。

临时文件

tmpfile 函数生成临时文件。

tmpnam 函数生成一个临时的文件名。

文件缓冲

向磁盘直接读写数据相对比内存读写慢。使用缓冲区(buffer)来解决这个问题。写入流的数据首先放到缓冲区里面,当缓冲区满了(或关闭流)时,刷新缓冲区,把数据写入文件。

输入流可以使用类似的方法进行缓冲:缓冲区包含来自输入设备的数据。

使用 fflush 函数刷新缓冲区。

其它文件操作

remove 函数删除文件,rename 函数重命名文件。如果是用 fopen 和 tmpnam 产生的临时文件,可以使用 remove 把它删除,或者用 rename 使其成为永久文件。

格式化的输入与输出

即 …printf 类函数 和 …scanf 类函数的使用。

检测文件末尾和错误条件

每个流都有与之相关的两个指示器:错误指示器(error indicator),文件末尾指示器(end-of-file indicator)。

打开流时,会清除这些指示器;流上的操作失败时会设置某个指示器。

遇到文件末尾就设置文件末尾指示器,遇到错误就设置错误指示器。

一旦设置了指示器,它就会保持这种状态,直到可能由 clearerr 调用而引发的明确清除操作为止。 clearerr 可以清除文件末尾指示器和错误指示器。

如果设置了文件末尾指示器, feof 返回非零值。

如果设置了错误指示器, ferror 返回非零值。

字符的输入/输出

输入输出的字符类型应使用int,原因之一是由于函数通过返回EOF说明文件末尾or错误情况,EOF是一个负的整型常量。

输出函数

int fputc(int c, FILE *stream);
int putc(int c, FILE *stream);
int putchar(int c);

输入函数

int fgetc(FILE *stream);
int getc(FILE *stream);
int getchar(void);

行的输入/输出

输出函数

int fputs(const char *s, FILE *stream);
int puts(const char *s);

puts函数向标准输出输出一行字符串,会自动添加一个换行符。

fputs不会自动添加换行符。

输入函数

char *fgets(char *s, int size, FILE *stream);
char *gets(char *s);

gets函数逐个读取字符,存储到s中,直到读到换行符时停止,并把换行符丢弃。

fgets当读入了size-1个字符时或读到换行符时停止,且会存储换行符。

如果出现错误,或者在存储任何字符之前达到了输入流的末尾,函数返回空指针。否则返回第一个实参。

函数会在字符串的末尾存储空字符。

块的输入输出

size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);

size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);

fread函数和fwrite函数允许程序在单步中读写大的数据块。

fwrite函数被设计用来把数组复制给流。第一个参数是数组首元素的地址,第二个参数是每个数组元素的大小(以字节为单位),第三个参数是要写的元素的数量,第四个参数是文件指针,说明了要写的数据位置。

fwrite返回实际写入的元素数量,如果写入错误,此数就会小于第三个参数。

fread函数从流读入数组的元素。其参数类似fwrite。

fread返回实际读入的元素数量,此数应该等于第三个参数。否则可能达到了文件末尾或者出现了错误。使用feof和ferror确定出问题的原因。

检查fread的返回值是非常重要的。

文件的定位

每个流都有相关联的文件位置(file position)。打开文件时,根据模式可以在文件的起始处或者末尾处设置文件位置。

在执行读或写操作时,文件位置会自动推进。

stdio.h提供了一些函数,用于确定当前的文件位置或者改变文件位置:

int fseek(FILE *stream, long offset, int whence);

long ftell(FILE *stream);

void rewind(FILE *stream);

int fgetpos(FILE *stream, fpos_t *pos);
int fsetpos(FILE *stream, fpos_t *pos);

fseek函数改变第一个参数相关的文件的位置。第二个参数说明新位置是根据文件的起始处、当前位置还是文件末尾来计算,也就是第三个参数来计算。

第三个参数可取值为:

  • SEEK_SET,文件的起始处。

  • SEEK_CUR,文件的当前位置。

  • SEEK_END,文件的末尾处。

ftell函数返回当前文件位置。如果发生错误,ftell返回-1L,并且把错误码存储到errno。

rewind函数会把文件位置设置到起始处。rewind还会为fp清除错误指示器。

fgetposfsetpos用于处理大的文件,使用fpos_t表示文件位置,它可能是一个结构。函数成功返回0,失败返回非0值并把错误码存放到errno中。

字符串的输入/输出

sprintf和snprintf函数将按写到数据流一样的方式写字符到字符串。

sscanf函数从字符串中读出数据就像从数据流中读数据一样。

输出函数

int sprintf(char *str, const char *format, ...);
int snprintf(char *str, size_t size, const char *format, ...);

类似于printf函数,唯一不同是sprintf函数把输出写入字符数组而不是流。当完成向字符串写入的时候,sprintf函数会添加一个空字符,并返回所存储的字符数量(不计空字符)。如果遇到错误,返回负值。

snprintf写入的字符数量不会超过size-1,结尾空字符不计。只要size不是0,都会有空字符。

输入函数

int sscanf(const char *str, const char *format, ...);

sscanf与scanf类似,唯一的不同就是sscanf从字符数组中读取数据而不是流。

sscanf函数返回成功读入并存储的数据项的数量,如果在找到第一个数据项之前到达了字符串的末尾,那么sscan函数返回EOF。# 第二十三章 库对数值和字符数据的支持


float.h:浮点型的特性

提供了用来定义浮点型的范围及精度的宏。

limits.h:整型取值范围

仅定义了每种整数类型的取值范围的宏。

math.h:数学计算

math.h里的函数处理的都是浮点类型的数值。

在 UNIX/Linux 下编译,需要指明连接 math 库:-lm

math.h中定义的函数包含下面5种类型:

  • 三角函数 sin cos tan acos asin atan atan2

  • 双曲函数 cosh sinh tanh

  • 指数和对数函数 exp log …

  • 幂函数 pow sqrt

  • 就近去整函数,绝对值函数和取余函数 ceil fabs floor fmod

错误

在math.h里声明的函数,如果出现错误(可能是参数不对),会把错误码存到errno。且若函数返回值大于double的最大值,那么函数会返回一个特殊值HUGE_VAL(double类型表示无穷大,Linux下输出成inf)。

errno有两种可能值:

  1. EDOM:代表定义域错误(Linux下值为33),即参数取值不对,比如给sqrt传一个负数。
  2. ERANGE:代表取值范围错误(返回值)(Linux下值为34),无法用double来表示了。比如exp(1000)。(PS:不是所有的数学函数出现返回值为无穷大时都会置errno为ERANGE)

ctype.h:字符处理

ctype.h提供了两类对字符进行处理的:

  1. 测试字符性质
  2. 对字符进行大小写转换

string.h:字符串处理

这些函数的参数的合法性需要程序员来保证。

复制函数

void *memcpy(void *dest, const void *src, size_t n);
char *strcpy(char *dest, const char *src);
char *strncpy(char *dest, const char *src, size_t n);

拼接函数

char *strcat(char *dest, const char *src);
char *strncat(char *dest, const char *src, size_t n);

比较函数

int strcmp(const char *s1, const char *s2);
int strncmp(const char *s1, const char *s2, size_t n);

搜索函数

char *strchr(const char *s, int c);
void *memchr(const void *s, int c, size_t n);
char *strrchr(const char *s, int c);
char *strtok(char *str, const char *delim);

其它函数

void *memset(void *s, int c, size_t n);
size_t strlen(const char *s);
```# 第二十四章 错误处理

---

## assert.h: 诊断

```c
void assert(int expression);

assert声明在assert.h中,实际上是一个宏。其参数必须是一种“断言”,即被认为正常情况下一定为真的表达式。

每次执行assert时,判断此断言,若为假(0),则显示一条错误信息,并调用abort函数终止程序执行。

这条错误信息包含了:断言(以文本格式)、包含assert调用的文件名、assert调用所在的行号。

禁止assert调用

方法是:在包含assert.h之前,定义宏NDEBUG。如:

#define NDEBUG
#include <assert.h>

errno.h: 错误

标准库中的一些函数通过向errno.h中声明的errno变量存储一个错误代码来表示有错误发生。

大部分使用errno变量的函数集中在math.h,但也有一些在标准库的其他部分。

如果errno不为0,则说明函数调用过程中有错误发生。

errno在程序开始的时候值为0,通常在调用函数前把errno置为0,库函数不会将errno清零,这是程序员的责任。

用法如:

y = sqrt(x); // x为负数则出错
if (errno != 0) {
    fprintf(stderr, "sqrt error, terminated. \n");
    exit(EXIT_FAILURE);
}

perror函数和strerror函数

void perror(const char *s);
char *strerror(int errnum);

当库函数向errno存储了一个非零值时,通过perror函数和strerror函数可以得到描述这种错误的信息。

perror函数声明在stdio.h中,它会按照如下顺序把错误信息输出到stderr:

调用perror的参数: 出错消息(内容根据errno的值决定)

strerror函数声明在string.h中,它传入errno,返回一个指针,指向描述出错消息的字符串。

signal.h: 信号处理

signal.h提供了处理异常的工具,即信号(signal)。信号有两种类型:运行时错误(例如除以0),程序以外导致的事件(例如用户输入了ctrl+c)。

当有错误或外部事件发生时,我们称产生了一个信号。大多数信号是异步的:它们可以在程序执行过程中的任意时刻发生。

信号宏

signal.h定义了一系列宏,用于表示不同的信号。参见书本。

signal函数

typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

signal函数安装一个信号处理函数。第一个参数是信号的代码,第二个参数是一个指向信号处理函数的指针。

一旦随后在程序执行过程中出现了对应的信号,信号处理函数就会被自动调用,信号的代码作为参数传递给信号处理函数。

除非信号是由调用abort函数或raise函数引发的,否则信号处理函数不应该调用任何库函数,或者试图使用一个静态存储期限的变量。

一旦信号处理函数返回,程序会从信号发生点恢复并继续执行。但是,如果信号是SIGABRT,当处理函数返回时程序会异常终止。如果信号是SIGFPE,那么处理函数返回的结果是UB(即不要使用它)。

signal函数的返回值是指定信号的前一个处理函数的指针。

预定义的信号处理函数

signal.h提供了两个预定义的信号处理函数(都用宏表示):

  • SIG_DFL。表示按默认方式处理信号,大多数情况下会导致程序终止。

  • SIG_IGN,指明随后当信号SIGINT发生时,忽略该信号。

当程序刚开始执行时,根据不同的实现,每个信号的处理函数都会被初始化为SIG_DFL和SIG_IGN。

如果signal调用失败(即无法对指定信号安装处理函数),就返回SIG_ERR(不是一个函数),并在errno中存入错误代码。

C语言要求,除了SIGILL以外,当一个信号的处理函数被调用时,该信号的处理函数要被重置为SIG_DFL,或者以其他方式加以封锁。

raise函数

int raise(int sig);

通常信号都是自然产生的,但也可以通过raise函数触发。

raise函数的返回值可以用来测试调用是否成功:0代表成功,非0代表失败。

setjmp.h: 非局部跳转

#include <setjmp.h>
int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int val);

通常情况下,函数会返回到它被调用的位置。setjmp.h可以使一个函数直接跳转到另一个函数,而不需要返回。

setjmp宏“标记”程序中的一个位置,随后可以用longjmp函数跳转到该位置。这一机制主要用于错误处理。

setjmp宏会将当前“环境”保存到一个jmp_buf类型的变量中,然后返回0。如果要返回setjmp宏所标记的位置,可以使用longjmp函数,调用的参数是调用setjmp宏时使用的同一个jmp_buf类型的变量。longjmp函数首先会根据jmp_buf变量的内容恢复当前环境,然后从setjmp宏调用中返回。这次setjmp宏的返回值是val,即调用longjmp函数时的第二个参数(如果val是0,那么返回1)。

如果longjmp的参数未被setjmp初始化,调用longjmp的结果是UB。# 第二十五章 国际化特性


locale.h 本地化

locale.h提供的函数用于控制标准库中对于不同的地区会不一样的部分。

地区通常指一个国家,或者一个国家的不同区域。

在标准库中,依赖地区的部分包括:

  • 数值的格式。比如一些地区的小数点是用逗号表示

  • 货币的格式。不同国家的货币符号不同。

  • 字符集。字符集依赖于地区使用的语言。亚洲国家通常比西方国家需要更大的字符集。

  • 日期和时间的表示格式。

类别

通过修改地区,程序可以改变它的行为来适应不同地区。

可以使用一些宏来指定一个类型

  • LC_COLLATE。影响两个字符串比较函数的行为(strcoll和strxfm)。

  • LC_CTYPE。影响ctype.h中函数的行为,除了isdigit和isxdigit。同时还影响stdlib.h中的多字节函数。

  • LC_MONETRAY。影响由localeconv函数返回的货币格式信息。

  • LC_NUMERIC。影响格式化输入/输出函数使用的小数点字符以及stdlib.h中的字符串转换函数(atof和strtod),还会影响localeconv函数返回的非货币格式信息。

  • LC_TIME。影响strftime函数的行为,该函数将时间转换成字符串。

setlocale函数

char *setlocale(int category, const char *locale);

setlocale函数修改当前的地区。如果第一个参数是LC_ALL,就会影响所有的类型。C语言标准对第二个参数仅定义了两种可能值:”c”和””。其余的由实现定义,比如, gcc 对于简体中文的地区,可以是”zh_CN.UTF-8”

程序执行开始时,都会隐含调用:setlocale(LC_ALL, "C");

如果用””作为第二个参数,就切换到了本地模式(native locale),这种模式下程序会适应本地的环境。

如果调用成功,返回一个关于地区名字的字符串指针。如果调用失败,返回空指针。

setlocale函数也可以当作搜索函数使用,如果第二个参数是空指针,setlocale函数会返回一个指向字符串的指针,这个字符串与当前地区类型的设置相关联。

localeconv函数

struct lconv *localeconv(void);

函数返回的struct lconv结构包含了当前地区的详细信息,此结构具有静态存储期限。

详细信息参考书本。

多字节字符和宽字符

因为定义已经把char型值的大小限制为一个字节,所以通过改变char类型的含义来处理更大的字符集显然是不可能的。

C语言提供了两种可扩展字符集的编码:多字节字符(multibyte character)和宽字符(wide character)。

C标准要求0字节始终用来表示空字符。

多字节字符

在多字节字符编码中,一个或多个字节表示一个可扩展字符。C语言要求的基本字符是单字节的。

一些多字节字符集依靠依赖状态编码(state-dependent encoding)。在这类编码中,每个多字节字符序列都以初始移位状态(initial shift state)开始。序列中稍后遇到的一些多字节字符会改变移位状态,并且影响后续字节的含义。

MB_LEN_MAX和MB_CUR_MAX说明了多字节字符中字节的最大数量。MB_LEN_MAX定义在limits.h中,给出了任意支持地区的最大值。MB_CUR_MAX定义在stdlib.h中,给出了当前地区的最大值。

宽字符

宽字符是一种其值表示字符的整数,所有宽字符要求相同的字节数。

宽字符具有wchar_t类型。

一个宽字符常量可以写成:L'a'

一个宽字符字符串可以写成:L"abc"

即在普通字符常量前用字母L作为前缀。

!!!note “my note”
注意,在使用宽字符前,需要设置好本地环境,比如,要使用简体中文的宽字符,那么要先执行 setlocale(LC_ALL, "zh_CN.UTF-8") ,这样才能正确地解析宽字符。

多字节字符函数

#include <stdlib.h>
int mblen(const char *s, size_t n);
int mbtowc(wchar_t *pwc, const char *s, size_t n);
int wctomb(char *s, wchar_t wc);

多字节字符串函数

#include <stdlib.h>
size_t mbstowcs(wchar_t *dest, const char *src, size_t n);
size_t wcstombs(char *dest, const wchar_t *src, size_t n);

三字符序列

见书本简要介绍。

!!!note
这就是一种字符替换方式,由于某些国家不支持C语言的标准的字符书写方式。# 其他库函数


stdarg.h 可变长度实参

void va_start(va_list ap, last);
type va_arg(va_list ap, type);
void va_end(va_list ap);

stdarg.h提供了一种工具可以让我们自行编写的函数具有可变长的参数列表(varying number of arguments of varying types)。stdarg.h定义了一种va_list类型和三种宏,名为va_start, va_arg, va_end, 可以把这些宏看成是带有上述原型的函数。

书中使用了此例进行讲解:

int max_int(int n, ...)    // n must be at least 1
{
    va_list ap;
    int i, current, largest;

    va_start(ap, n);
    largest = va_arg(ap, int);

    for (i = 1; i < n; ++i) {
        current = va_arg(ap, int);
        if (current > largest)
            largest = current;
    }

    va_end(ap);
    return largest;
}

函数的第一个实参n说明了跟随其后的其他参数的数量。

形参列表中的…符号表示可变数量的参数。带有可变参数的函数必须至少有一个“正常的”形参,在最后一个正常的参数后边始终会有省略号出现在参数列表的末尾。

va_list ap声明了一个变量,使得函数可以访问到可变参数。

va_start(ap, n)指出了实参列表中可变长度开始的位置。

va_arg(ap, int)把获取当前的可变参数,然后自动前进到下一个可变参数处。int说明希望此参数是int类型的。

函数返回前,使用语句va_end(ap)进行清扫。

调用带有可变实参列表的函数

调用带有可变实参列表的函数是一个固有的风险提议。

这里主要的难点就是带有可变实参列表的函数很难确定传递过来的参数的数量或类型。所以必须把这个信息传递给函数,并且函数假设知道了这个信息。上述max_int函数依靠第一个实参来指明跟随其后的其他参数的数量,并且它还设定参数是int类型的。

另一个问题就是不得不处理NULL作为参数的情况,具体见书本。

v…printf类函数

int vprintf(const char *format, va_list ap);
int vfprintf(FILE *stream, const char *format, va_list ap);
int vsprintf(char *str, const char *format, va_list ap);

不同于printf等函数,v…printf类函数具有固定数量的实参,每个v…printf类函数的最后一个实参都是一个va_list型值。这个类型的值意味着此函数可以由带有可变实参列表的函数调用。

实际上,v…printf类函数主要用于编写“包装”函数。包装函数接收可变数量的实参,并稍后把这些参数传递给v…printf类函数(通过va_list)。

这种包装函数的核心内容是:

  • va_start(ap, arg)

  • 把ap传递给v…printf

  • va_end(ap)

stdlib.h 通用的实用工具

字符串转换函数

int atoi(const char *nptr);
long long atoll(const char *nptr);

long int strtol(const char *nptr, char **endptr, int base);
long long int strtoll(const char *nptr, char **endptr, int base);

伪随机生成函数

伪随机数的生成方法是:

  • 先设置一个随机种子(srand)

  • 调用rand函数根据随机种子生成一个伪随机数

如果每次程序运行的随机种子都一样,那么rand出来的数就会一样。因此通常采用当前时间戳作为随机种子(但如果两次启动间隔不足一秒,时间戳也是一样滴)。

void srand(unsigned int seed);
int rand(void);

与环境的通信

与外部通信的标准库函数可以:

  • 为操作系统返回一个程序结束的状态码

  • 获取环境变量

  • 执行操作系统的命令

返回状态码

在main中执行return语句,即返回了一个状态码给操作系统;或者在程序的任意处执行exit函数,也可以终止程序并返回一个状态码给操作系统。

exit是正常性质的结束程序,可以清理程序打开的资源。

atexit函数还可以注册一个函数,在程序正常结束前,执行这个注册函数。可以注册多个atexit函数,调用顺序和注册顺序一致。

void exit(int status);
int atexit(void (*function)(void));

获取环境变量

环境变量是一组存放到静态存储区的字符串,描述了操作系统的环境,比如PATH。使用getenv就可以获取它的值。

char *getenv(const char *name);

执行命令

主要是通过system函数执行一个外部的命令。system函数返回该命令的终止状态码。

int system(const char *command);

搜索和排序工具

用于搜索的工具是:bsearch(实现为二分查找),用于排序的工具是:qsort(实现为快速排序)。

void qsort(void *base, size_t nmemb, size_t size,
                  int(*compar)(const void *, const void *));

void *bsearch(const void *key, const void *base,
                     size_t nmemb, size_t size,
                     int (*compar)(const void *, const void *));

整数算术运算函数

abs求绝对值

函数原型:

int abs(int j);

div求除法运算结果

函数原型:

div_t div(int numerator, int denominator);

结果是第一个实参除以第二个实参。结果是一个div_t类型,它包含了商和余数,定义如下:

typedef struct
{
    int quot;    /* Quotient.  */
    int rem;     /* Remainder. */
} div_t;

但第二个实参一定不能为0,不然就会出现段错误。因此判断除数是否合法的责任就交给了程序员。

time.h 日期和时间

标准库提供了三种表示时间的类型:

  1. clock_t:按照“时钟滴答”进行测量的时间值

  2. time_t:日历时间(时间戳),由于这个类型在不同平台下定义不同(unsigned int or long),因此输出的时候应当做一个强制转换。

  3. struct tm:分解时间,一种适合人类理解的时间格式

clock_t和time_t是支持算术运算的,但是它们具体是整型还是浮点型并没有被C标准说明。但struct tm的类型定义很清楚:

成员 说明
tm_sec 分后的秒,[0, 61],允许两个额外的闰秒
tm_min 时后面的分,[0, 59]
tm_hour 午夜后的时,0到23
tm_mday 月份中的第几天,[1,31]
tm_mon 一月份以后的月,[0,11]
tm_year 从1900年开始的年
tm_wday 星期日以后的天,[0,6]
tm_yday 一月一日后的天,[0,365]
tm_isdst 夏令时标记,夏令时有效为正数,否则为0,如果未知,可为-1

时钟滴答

clock_t clock(void);

clock函数返回处理器的时间(时钟滴答),即程序开始运行到执行到此的消耗的时间。但它的单位不是秒,为了将它转换成秒,可以给它除以宏CLOCK_PER_SEC。

clock_t不能表示日期,只是善于表示时间区间(两个clock_t相减获得比较精准的时间差)。

(clock() - start_clock) / (double)CLOCK_PER_SEC

加double强制转换的理由是,标准C没有指明宏CLOCK_PER_SEC的类型,也没有说明clock_t的类型,所以必须用强制转换明确一下类型。

日历时间

time_t time(time_t *t);
double difftime(time_t time1, time_t time0);

time用来获取时间戳(UNIX从1970年为纪元),difftime获取两个时间戳的间隔,但这种计算间隔的方式没有用clock_t计算精准。

分解时间

time_t mktime(struct tm *tm);
struct tm *localtime(const time_t *timep);
char *asctime(const struct tm *tm);
  • mktime将分解时间转换成日历时间(时间戳)。但它有一个很好得到地方,除了转换成日历时间,它还会先修正分解时间,如果分解时间中的某些值不正确的话。修正的规则就是“进位”,把溢出的时间补给高位的时间。比如tm_mday超过了31,那么tm_mon就会增加至少1。可以利用这一个修正的规则来计算未来的日期。见代码案例。

  • localtime根据日历时间,获得本地的分解时间

  • asctime获取分解时间的字符串格式,末尾还会有一个换行符

时间转换函数

有 ctime strftime 等。


 上一篇
「网易云音乐」登录流程还原 「网易云音乐」登录流程还原
登录流程是比较基础的流程之一,很常见,看起来也很简单,但很多时候,这种基础流程的体验往往也最容易被忽略。所以希望大家从基础着手,关注细节体验,为后续产品设计打好基础。
2019-10-03
下一篇 
markdown文件和Word文件的转换 markdown文件和Word文件的转换
在使用markdown和Word时存在转换格式的问题,整理了快速转换的方法。
2019-09-30
  目录