1.3 标准I/O库和C预处理器
宏最好只用于命名常量,并为一些适当的结构提供简捷的记法。宏名应该大写,这样便很容易与函数调用区分开来。千万不要使用C预处理器来修改语言的基础结构
1.9 阅读ANSI C标准,寻找乐趣和裨益
char *cp;
const char *ccp;
ccp = cp;
左操作数是一个指向有const限定符的char的指针;
右操作数是一个指向没有限定符的char的指针;
char类型与char类型是相容的,左操作数所指向的类型具有右操作数所指向类型的限定符(无),再加上自身的限定符(const)。
注意,反过来就不能进行赋值。如果不信,试试下面的代码:
cp = ccp; /*结果产生编译警告*/
标准第6.3.16.1节有没有说char *
实参与const char*
形参是相容的?没有。
标准第6.1.2.5节中讲述实例的部分声称:
左操作数是一个指向有const限定符的char的指针;
const float *
类型并不是一个有限定符的类型——它的类型是“指向一个具有const限定符的float类型的指针”,也就是说const限定符是修饰指针所指向的类型,而不是指针本身。
这个符号不能被赋值
1.10 “安静的改变”究竟有多少安静
尽量不要在代码中使用无符号类型,以免增加不必要的复杂性。尤其是不要仅仅因为无符号数不存在负值(如年龄、国债)就用它来表示数量。尽量使用像int那样的有符号类型,这样在涉及升级混合类型的复杂细节时,不必担心边界情况(如−1被翻译为非常大的正数)。
只有在使用位段和二进制掩码时,才可以用无符号数。
应该在表达式中使用强制类型转换,使操作数均为有符号数或者无符号数,这样就不必由编译器来选择结果的类型。
2.1 这关语言特性何事,在Fortran里这就是Bug呀
无论在什么时候,如果遇见了这样一条语句malloc(strlen(str))
;,几乎可以断定它是错误的,而malloc(strlen(str)+1)
才是正确的。这是因为其他的字符串处理库函数几乎都包含一个额外空间,用于容纳字符串结尾的‘\0’
字符。
分析编程语言缺陷的一种方法就是把所有的缺陷归于3类:不该做的做了;该做的没做;该做但做得不合适。
ACSII字符中零的位模式被称为“NUL”。表示哪里也不指向的特殊的指针值则是“NULL”。
2.2 多做之过
一旦一个指针进行解除引用操作时所引用的内存地址超出了虚拟内存的地址空间,操作系统就会中止这个进程
const其实并不是真正的常量。
2.3 误做之过
当sizeof的操作数是一个类型名时,两边必须加上括号(这常常使人误以为它是个函数),但操作数如果是变量则不必加括号。
这里有一个更为复杂的例子:
apple = sizeof(int) * p;
这代表什么意思?是int的长度乘以p?或者是把未知类型的指针p强制转换为int,然后进行sizeof操作?或者还有其他更奇怪的解释?
首先,
sizeof(int)
是一个编译时运算符,它用于计算类型int
在当前编译环境中所占的字节数。在大多数现代系统中,int
类型通常占用 4 字节(32位系统)或 8 字节(64位系统)。接下来,
*p
表示解引用指针p
。这意味着p
必须是一个指针,指向某个内存位置。这里的操作实际上是获取p
所指向的值。综上所述,表达式
apple = sizeof(int) * p;
的意思是将int
类型的大小(以字节为单位)乘以指针p
所指向的数值,然后将结果赋值给变量apple
。因此,如果
p
指向的是一个整数(比如它之前被赋予了某个整数变量的地址),这个表达式并不直接涉及类型转换或对p
本身的sizeof
运算,而是利用p
指向的值进行计算。注意,这个表达式的含义明确依赖于
p
的具体情境。如果上下文没有提供足够的信息来确保p
正确地指向了一个可以安全解引用并合理参与数学运算的值,那么这段代码可能会导致未定义行为或运行时错误。
一个符号所表达的意思越多,编译器就越难检测到这个符号在你的使用中所存在的异常情况。C语言似乎比其他语言更靠近标记歧义性的曲折边缘。
2.3.2 “有些运算符的优先级是错误的”
在表达式中如果有布尔操作、算术运算、位操作等混合计算,始终应该在适当的地方加上括号,使之清楚明了。
x = f() + g() * h();
g()和h()的返回值先组成一个意群,执行乘法运算,但g()和h()的调用可能以任何顺序出现(g()的调用不一定早于h())。类似,f()可能在乘法之前也可能在乘法之后调用,还可能在g()和h()之间调用。唯一可以确定的就是乘法会在加法之前执行(因为乘法的结果是加法运算的操作数之一)。如果编写程序时要依赖这些意群计算的先后次序,那就是不好的编程风格。大部分编程语言并未明确规定操作数计算的顺序。之所以未作定义,是想让编译器充分利用自身架构的特点,或者充分利用存储于寄存器中的值。
有些专家建议在C语言中记牢两个优先级就够了:乘法和除法先于加法和减法,在涉及其他的操作符时一律加上括号。我认为这是一条很好的建议。
所有的赋值符(包括复合赋值符)都具有右结合性
具有左结合性的操作符(如位操作符“&”和“|”)则是从左至右依次执行。
这两个操作符严格按照从左到右的顺序依次计算两个操作数,当结果提前得知时便忽略剩余的计算(短路)。但是,在函数调用中,各个参数的计算顺序是不确定
2.4 少做之过
也许最好的解决方案就是要求调用者分配内存来保存函数的返回值。为了提高安全性,调用者应该同时指定缓冲区的大小(就像标准库中fgets()
函数所要求的那样)。
void func( char * result, int size)
{
...strncpy(result, "That’d be in the data segment, Bob", size);
}
buffer = malloc(size);func(buffer, size);
...free(buffer);
如果程序员可以在同一代码块中同时进行malloc
和free
操作,内存管理是最为轻松的。这个解决方案就可以实现这一点。
3.2 声明是如何形成的
函数的返回值不能是一个函数,所以像
foo()()
这样是非法的; 函数的返回值不能是一个数组,所以像foo()[]
这样是非法的; 数组里面不能有函数,所以像foo
这样是非法的。 但像下面这样则是合法的: 函数的返回值允许是一个函数指针,如int(* fun())();
函数的返回值允许是一个指向数组的指针,如int(* foo())[];
数组里面允许有函数指针,如int (* foo[])();
数组里面允许有其他数组,所以你经常能看到int foo[][]
。
变量的声明应该与类型的声明分开。
在典型情况下,并不会频繁地对整个数组进行赋值操作。但是如果需要这样做,可以通过把它放入结构中来实现。
[!结构体与联合体] 在结构中,每个成员依次存储;而在联合中,所有的成员都从偏移地址零开始存储。这样,每个成员的位置都重叠在一起:在某一时刻,只有一个成员真正存储于该地址。
这个联合允许程序员既可以提取整个32位值(作为int),也可以提取单独的字节字段,如value.byte.c0等。
union bits32_tag{
int whole; /* 一个32位的值 */
struct { char c0, c1, c2, c3; } byte; /* 4个8位的字节 */
} value;
采用其他的方法也能达到这个目的,但联合不需要额外的赋值或强制类型转换。在实际工作中,你遇见结构的次数将远远多于联合。
3.2.3 关于枚举
枚举(enum
)通过一种简单的途径,把一串名字与一串整型值联系在一起。对于像C这样的弱类型语言而言,很少有什么事只能靠枚举来完成而不能用#define
来解决。所以,在大多数早期的K&R C编译器中,都省掉了枚举。但是枚举在其他大多数语言中都存在,所以C语言最终也实现了它。
枚举具有一个优点:*#define
定义的名字一般在编译时被丢弃*,而枚举名字则通常一直在调试器中可见,可以在调试代码时使用它们。
3.3 优先级规则
[!理解C语言声明的优先级规则] A 声明从它的名字开始读取,然后按照优先级顺序依次读取。 B 优先级从高到低依次如下。 B.1 声明中被括号括起来的那部分。 B.2 后缀操作符:
括号()
表示这是一个函数,而方括号[]
表示这是一个数组。 B.3 前缀操作符:星号*
表示“指向……的指针”。 C 如果const和(或)volatile关键字的后面紧跟类型说明符(如int、long等),那么它作用于类型说明符。在其他情况下,const和(或)volatile关键字作用于它左边紧邻的指针星号。
[!用优先级规则分析该声明
char * const * (* next)()
] next是一个指针 它指向一个函数 该函数返回另一个指针 该指针指向一个类型为char的常量指针
[!分析声明流程] 一开始,我们从左边开始向右寻找,直到找到第一个标识符。 当声明中的某个符号与图中所示匹配时,便把它从声明中处理掉,以后不再考虑。 在具体的每一步骤上,我们首先查看右边的符号,然后再看左边。 当所有的符号都被处理完毕后,便宣告大功告成。
3.5 typedef
可以成为你的朋友
- 语法参考:typdef
如果现在回过头去看看3.2节,会发现typedef关键字可以是一个常规声明的一部分,可以出现在靠近声明开始部分的任何地方。事实上,typedef的格式与变量声明完全一样,只是多了这个关键字,用来说明它的实质。
由于typedef看上去跟变量声明完全一样,它们读起来也是一样的。前面一节描述的分析技巧也同样适用于typedef。普通的声明表示“这个名字是一个指定类型的变量”
3.9 轻松一下——驱动物理实体的软件
char *(* c[10])(int **p);
“c是一个数组[0…9],它的元素类型是函数指针,其所指向的函数的返回值是一个指向char的指针”。
顺利完工。
[! 注意:]
在数组中被函数指针所指向的所有函数都把一个指向指针的指针作为它们的唯一参数。
4.3 什么是声明,什么是定义
语言中的对象必须有且只有一个定义,但它可以有多个extern声明。
对于多维数组,需要提供除最左边一维之外其他维的长度——这就给编译器足够的信息产生相应的代码。
地址(左值)和地址的内容(右值)之间的区别
编译器为每个变量分配一个地址(左值)。这个地址在编译时可知,而且该变量在运行时一直保存于这个地址。相反,存储于变量中的值(它的右值)只有在运行时才可知。如果需要用到变量中存储的值,编译器就发出指令从指定地址读入变量值并将它存于寄存器中。
这里的关键之处在于每个符号的地址在编译时可知。所以,如果编译器需要一个地址(可能还需要加上偏移量)来执行某种操作,它就可以直接进行操作,并不需要增加指令首先取得具体的地址。相反,对于指针,必须首先在运行时取得它的当前值,然后才能对它进行解除引用操作
如果声明extern char *p
,它将告诉编译器p是一个指针(在许多现代的机器里它是个4字节的对象
),它指向的对象是一个字符。为了取得这个字符,必须得到地址p的内容,把它作为字符的地址并从这个地址中取得这个字符。
编译器已被告知p是一个指向字符的指针(相反,数组定义告诉编译器p是一个字符序列)。p[i]表示“从p所指的地址开始,前进i步,每步都是一个字符(即每个元素的长度为一个字节)”。如果是其他类型的指针(如int或double等),其步长(每步的字节数)也各不相同。
既然把p声明为指针,那么不管p原先是定义为指针还是数组,都会按照上面所示的3个步骤进行操作,但是只有当p原来定义为指针时这个方法才是正确的。
[!数组的声明] 考虑一下p在这里被声明为
extern char *p
;,而它原先的定义却是char p[10];这种情形。当用p[i]这种形式提取这个声明的内容时,实际上得到的是一个字符。但按照上面的方法,编译器却把它当成是一个指针,把ACSII字符解释为地址显然是牛头不对马嘴。如果此时程序宕掉,你应该额手称庆。
4.5 数组和指针的其他区别
定义指针时,编译器并不为指针所指向的对象分配空间,它只是分配指针本身的空间,除非在定义时同时赋给指针一个字符串常量进行初始化。例如,下面的定义创建了一个字符串常量(为其分配了内存):char *p = "breadfruit";
注意只有对字符串常量才是如此。
在ANSI C中,初始化指针时所创建的字符串常量被定义为只读。如果试图通过指针修改这个字符串的值,程序就会出现未定义的行为。在有些编译器中,字符串常量被存放在只允许读取的文本段中,以防止它被修改。
与指针相反,由字符串常量初始化的数组是可以修改的。其中的单个字符在以后可以改变
必须熟练掌握malloc()
函数,并且学会用指针操纵匿名内存。
5.1 函数库、链接和载入
动态链接允许系统提供一个庞大的函数库集合,可以提供许多有用的服务。但是,程序将在运行时寻找它们,而不是把这些函数库的二进制代码作为自身可执行文件的一部分。
如果函数库的一份副本是可执行文件的物理组成部分,那么我们称之为静态链接;如果可执行文件只是包含了文件名,让载入器在运行时能够寻找程序所需要的函数库,那么我们称之为动态链接。
收集模块准备执行的3个阶段的规范名称是链接-编辑(link-editing)、载入(loading)和运行时链接(runtime linking)。
即使是在静态链接中,整个libc.a文件也并没有被全部装入到可执行文件中,所装入的只是所需要的函数。
5.2 动态链接的优点
动态链接的主要目的就是把程序与它们使用的特定的函数库版本中分离开来
动态链接允许用户在运行时选择需要执行的函数库。这就使为了提高速度或提高内存使用效率或包含额外的调试信息而创建新版本的函数库是完全可能的,用户可以根据自己的喜好,在程序执行时用一个库文件取代另一个库文件。
“静态共享库”(static shared libraries)。在生命期内,它们的地址始终固定,这样它们就可以直接绑定到应用程序中,较之动态链接少了一层中间环节。
对于函数库应该始终使用与位置无关的代码。对于共享库,与位置无关的代码显得格外有用,因为每个使用共享库的进程一般都会把它映射到不同的虚拟地址(尽管共享同一份物理副本)。
5.3 函数库链接的5个特殊秘密
始终将-l函数库选项放在编译命令行的最右边。
在PC上,当Borland的编译器驱动器试图猜测需要链接的浮点库时,也会出现类似的问题。不幸的是,它们有时会猜测错误,从而导致下面的错误:scanf : floating point formats not linkedAbnormal program termination(scanf:浮点格式未链接,程序异常中止)当程序在scanf()或printf()中使用浮点数格式,但并不调用任何其他浮点数函数时,就有可能猜测错误。工作区可以在将被载入链接器的模块里声明像下面这样的函数,从而向链接器提供更多的线索:
static void forcefloat(float *p){ float f =*p; forcefloat(&f); }
不要实际调用这个函数,只要保证它被链接即可。这样就能给Borland PC的链接器提供一个足够可靠的线索,即该浮点库确实是需要的。另外还有一条类似的信息,当软件需要数值协处理器而计算机却未安装它时,Microsoft C运行时系统会打印出一条信息,表示“浮点数未载入”。可以使用浮点数仿真库重新链接程序来解决这个问题。
5.4 警惕Interpositioning
不要让程序中的任何符号成为全局的,除非有意把它们作为程序的接口之一。
5.5 产生链接器报告文件
可以在ld程序中使用-m选项,让链接器产生一个报告。它里面包括了被Interpose的符号的说明
6.1 a.out及其传说
“assembler output”
(汇编程序输出)
这里有一个问题:它不是汇编程序输出,而是链接器输出!
6.2 段
数据段保存在目标文件中;BSS段不保存在目标文件中(除了记录BSS段在运行时所需要的大小);文本段是最容易受优化措施影响的段;a.out文件的大小受调试状态下编译的影响,但段不受影响。
6.3 操作系统在a.out文件里干了些什么
段可以方便地映射到链接器在运行时可以直接载入的对象中!载入器只是取文件中每个段的映像,并直接将它们放入内存中
从本质上说,段在正在执行的程序中是一块内存区域,每个区域都有特定的目的
文本段包含程序的指令。链接器把指令直接从文件复制到内存中(一般使用mmap()系统调用),以后便再也不用管它。因为在典型情况下,程序的文本无论是内容还是大小都不会改变。有些操作系统和链接器甚至可以向段中不同的section赋予适当的属性,例如,文本可以被设置为read-and-execute-only(只允许读和执行),有些数据可以被设置为read-write-no-execute(允许读和写,但不允许执行),而另外一些数据则被设置为read-only(只读)等。
数据段包含经过初始化的全局和静态变量以及它们的值。BSS段的大小从可执行文件中得到,然后链接器得到这个大小的内存块,并把它紧放在数据段之后。当这个内存区进入程序的地址空间后全部清零。包括数据段和BSS段的整个区段此时通常统称为数据区。
文本段
数据段
数据区
包括数据段和BSS段的整个区段此时通常统称为数据区。这是因为在操作系统的内存管理术语中,段就是一片连续的虚拟地址,所以相邻的段被接合起来。一般情况下,在任何进程中数据段是最大的段。
需要一些内存空间,用于保存局部变量、临时数据、传递到函数中的参数等。堆栈段(stack segment)就是用于这个目的
还需要堆(heap)空间,用于动态分配的内存。只要调用malloc()函数,就可以根据需要在堆上分配内存。
6.4 C语言运行时系统在a.out里干了些什么
堆栈为函数内部声明的局部变量提供存储空间。按照C语言的术语,这些变量被称为“自动变量”。进行函数调用时,堆栈存储与此有关的一些维护性信息,这些信息被称为堆栈结构(stack frame),另外一个更常用的名字是过程活动记录(precedure activation recorded)。
只要知道它包括函数调用地址(即所调用的函数结束后跳回的地方)、任何不适合装入寄存器的参数以及一些寄存器值的保存即可。
堆栈也可以被用作暂时存储区。有时候程序需要一些临时存储,比如计算一个很长的算术表达式时,它可以把部分计算结果压到堆栈中,当需要时再把它从堆栈中取出。通过alloca()
函数分配的内存就位于堆栈中。如果想让内存在函数调用结束之后仍然有效,就不要使用alloca()来分配(它将被下一个函数调用所覆盖)。
除了递归调用之外,堆栈并非必需的。因为在编译时可以知道局部变量、参数和返回地址所需空间的固定大小,并可以将它们分配于BSS段。
BASIC、COBOL和FORTRAN的早期编译器并不允许函数的递归调用,所以它们在运行时并不需要动态的堆栈。允许递归调用意味着必须找到一种方法,在同一时刻允许局部变量的多个实例存在,但只有最近被创建的那个才能被访问,这很像堆栈的经典定义。
事实上在绝大多数处理器中,堆栈是向下增长的,也就是朝着低地址方向生长。
6.5 当函数被调用时发生了什么:过程活动记录
编译器设计者会尽可能地把过程活动记录的内容放到寄存器中(因为可以提高速度)
6.6 auto和static关键字
对堆栈怎样实现函数调用的描述也同时解释了为什么不能从函数中返回一个指向该函数局部自动变量的指针。
如果想返回一个指向在函数内部定义的变量的指针时,要把那个变量声明为static。这样就能保证该变量被保存在数据段中而不是堆栈中。
程序
过程活动记录并不一定要存在于堆栈中。
放到寄存器中会使函数调用的速度更快,效果更好
6.8 setjmp和longjmp
从这个角度讲,longjmp更像是“从何处来”(come from)而不是“往哪里去”(go to)。longjmp接受一个额外的整型参数并返回它的值,这可以知道是由longjmp转移到这里的还是从上一条语句执行后自然而然来到这里的。
下面的代码显示了setjmp()和longjmp()一例。
#include <setjmp.h>
jmp_buf buf;
#include <setjmp.h>
banana() {
printf("in banana() \n");
longjmp(buf, 1);
/*以下代码不会被执行*/
printf("you’ll never see this, because i longjmp’d");
}
main()
{
if(setjmp(buf))
printf("back in main\n");
else {
printf("first time through\n");
longjmp
更像是“从何处来”(come from)而不是“往哪里去”(go to)。
声明为volatile(这适用于那些值在setjmp执行和longjmp返回之间会改变的变量)
最大的用途是错误恢复
处理段违规信号,后者进行相应的longjmp(jbuf, 1)操作
setjmp和longjmp在C++中变异为更普通的异常处理机制catch和throw。
6.9 UNIX中的堆栈段
在UNIX中,当进程需要更多空间时,堆栈会自动生长
附加的虚拟内存紧随当前堆栈的尾部映射到地址空间中。内存映射硬件确保你无法访问操作系统分配给你的进程之外的内存。
6.11 有用的C语言工具
6.11 有用的C语言工具
本节包括了一些你应该知道的有用的C语言工具,并描述了它们的作用(见表6-1~表6-4)。我们已经在前面的内容中讲到了其中一些工具,用于帮助你窥探进程和a.out文件的内部。有些工具是SunOS所特有的。本节提供了一个易于阅读的汇总材料,告诉你这些工具中的每一个是用来干什么的以及可以在哪里找到它们。在学完这个汇总材料之后,请接着阅读每个工具的主文档,并在几个不同的a.out中运行每个工具。你既可以使用hello world程序,也可以使用其他较大的程序。
请仔细研究这些工具,如果你花15分钟时间对每个工具进行试验,将来在解决Bug问题时,它会大大节约你的时间。
6.13 只适用于高级学员阅读的材料
可以把汇编代码嵌入到C代码中。这通常只用于深入操作系统核心且非常依赖机器的任务。
7.4 cache存储器
使用笨拷贝(dump copy)的程序的性能有显著的下降。
在这个source和destination都使用同一cache行的特殊情况下,会导致每次对内存的引用都无法命中cache,使CPU的利用率大大降低,因为它不得不等待常规的内存操作完成。库函数memcpy()经过特别优化以提高性能。它把先读取一个cache行再对它进行写入这个循环分解开来,这就避免了上述问题。使用聪明拷贝(smart copy)可以大幅度地提高性能。
7.5 数据段和堆
堆区域用于动态分配的存储
也就是通过malloc
(内存分配)函数获得并通过指针访问的内存。
为方便起见,一个malloc请求申请的内存大小一般被取整为2的乘方。回收的内存可供重新使用
malloc
和free
— 从堆中获得内存以及把内存返回给堆;
7.6 内存泄漏
释放或改写仍在使用的内存(称为“内存损坏”);未释放不再使用的内存(称为“内存泄漏”)。
观察内存泄漏是一个两步骤的过程。首先,使用swap命令观察还有多少可用的交换空间:[插图]
total: 17228k bytes allocated + 5396K reserved = 22624K used, 29548K available(共计:177228KB已分配+5396KB用于保留=22624KB已用,29548KB可用)在一两分钟内输入该命令三到四次,看看可用的交换区是否在减少。还可以使用其他一些/usr/bin/*stat
工具,如netstat、vmstat
等。如果发现不断有内存被分配且从不释放,一个可能的解释就是有个进程出现了内存泄漏。
内存泄漏最简单的形式是:[插图]这是软件的exxon valdex[7],它把你给它的所有东西都泄漏出去。在每一次成功的迭代之后,p的内容被改写,它原先所指向的那块内存便“泄漏”了。由于现在不存在指向它的指针,它既无法被访问,也无法被释放。大多数的内存泄漏并不像改写唯一指向该块内存的指针的内容(在该块内存释放之前)那么明显,所以它们更难确定和调试。
7.7 总线错误
当硬件告诉操作系统一个有问题的内存引用时,就会出现这两种错误。操作系统通过向出错的进程发送一个信号与之交流。信号就是一种事件通知或一个软件中断,在UNIX系统编程中使用很广,但在应用程序编程中几乎不使用。
总线错误几乎都是由未对齐的读或写引起的。它之所以称为总线错误,是因为出现未对齐的内存访问请求时,被堵塞的组件就是地址总线。对齐(alignment)的意思就是数据项只能存储在地址是数据项大小的整数倍的内存位置上。
数据对齐,因为与任意的对齐有关的额外逻辑会使整个内存系统更大且更慢。通过迫使每个内存访问局限在一个cache行或一个单独的页面内,可以极大地简化(并加速)如cache控制器和内存管理单元这样的硬件。
保证一个原子数据项不会跨越一个页或cache块的边界。
通常是由解除引用一个未初始化或非法值的指针引起的。 如果指针引用一个并不位于你的地址空间中的地址,操作系统便会对此进行干涉。
一个更糟糕的微妙之处是,如果未初始化的指针恰好具有未对齐的值(对于指针所要访问的数据而言),它将会产生总线错误,而不是段错误。对于绝大多数架构的计算机而言确实如此,因为CPU先看到地址,然后再把它发送给MMU。
千万不要在一个条件操作符内嵌套另一个条件操作符。如果这样做了,你很快就会发现要想明白代码的确切意思可不是件容易的事情。
坏指针值错误
在指针赋值之前就用它来引用内存,或者向库函数传送一个坏指针
对指针进行释放之后再访问它的内容。可以修改free语句,在指针释放之后再将它置为空值。[插图]
2.改写(overwrite)错误:越过数组边界写入数据,在动态分配的内存两端之外写入数据,或改写一些堆管理数据结构(在动态分配的内存之前的区域写入数据就很容易发生这种情况)。[插图]
3.指针释放引起的错误:释放同一个内存块两次,或释放一块未曾使用malloc分配的内存,或释放仍在使用中的内存,或释放一个无效的指针。一个极为常见的与释放内存有关的错误就是在for(p = start; p; p = p → next)这样的循环中迭代一个链表,并在循环体内使用free(p)语句。这样,在下一次循环迭代时,程序就会对已经释放的指针进行解除引用操作,从而导致不可预料的结果。
在遍历链表时正确释放元素的方法是使用临时变量存储下一个元素的地址。这样就可以安全地在任何时候释放当前元素,而不必担心在取下一个元素的地址时还要引用它
[插图]
8.2 根据位模式构筑图形
图标(icon)或者图形(glyph)是一种小型的位模式映射于屏幕后产生的图像。一个位代表图像上的一个像素。如果一个位被设置,那么它所代表的像素就是“亮”的。如果一个位被清除,那么它所代表的像素就是“暗”的。所以一系列的整数值能够用于为图像编码。
8.3 在等待时类型发生了变化
字符常量的类型是int,根据提升规则,它由char转换为int。
在表达式中,每个char都被转换为int……注意所有位于表达式中的float都被转换为double……由于函数参数也是一个表达式,所以当参数传递给函数时也会发生类型转换。具体地说,char和short转换为int,而float转换为double。
整型提升就是char、short int和位段类型(无论signed或unsigned)以及枚举类型将被提升为int,前提是int能够完整地容纳原先的数据,否则将被转换为unsigned int。ANSI C提到,如果编译器能够保证运算结果一致,也可以省略类型提升——这通常出现在表达式中存在常量操作数的时候。
警惕!真正值得的注意之处——参数也会被提升!另一个会发生隐式类型转换的地方就是参数传递。
在ANSI C中,如果使用了适当的函数原型,类型提升便不会发生,否则也会发生。在被调用函数的内部,提升后的参数被裁减为原先声明的大小。
隐式类型转换在涉及原型的上下文中显得非常重要
8.4 原型之痛
ANSI C函数原型的目的是使C语言成为一种更加可靠的语言。建立原型就是为了消除一种普通(但很难发现)的错误,即形参和实参之间类型不匹配。
8.7 用C语言实现有限状态机
FSM可以用作程序的控制结构。FSM对于那些以输入为基础,在几个不同的可选动作中进行循环的程序尤其合适。投币售货机就是一个FSM的好例子,它具有“接受硬币”“选择商品”“发送商品”和“找零钱”等数种状态。它的输入是硬币,输出是待售商品。
在C语言中,有好几种方法可以用来表达FSM,但它们绝大多数都是基于函数指针数组。一个函数指针数组可以像下面这样声明:[插图]
8.8 软件比硬件更困难
debugging hook你知不知道绝大多数调试器都允许从调试器命令行调用函数?如果你拥有十分复杂的数据结构,它将会非常有用。你可以编写并编译一个函数,用于遍历整个数据结构并把它打印出来。这个函数不会在代码的任何地方被调用,但它却是可执行文件的一部分。它就是debugging hook。
散列表是一种被广泛使用和严格测试过的表查找优化方法,在系统编程中到处都有它的踪影:数据库、操作系统和编译器。
8.9 如何进行强制类型转换,为何要进行类型强制转换
[!复杂的类型转换可以按以下3个步骤编写。] 1.查看一个对象的声明,它的类型就是想要转换的结果类型。 2.删去标识符(以及任何如extern之类的存储限定符),并把剩余的内容放在一对括号里。 3.把第2步产生的内容放在需要进行类型转换的对象的左边。
9.1 什么时候数组与指针相同
定义是声明的一种特殊情况,它分配内存空间,并可能提供一个初始值
[插图]
对编译器而言,一个数组就是一个地址,一个指针就是一个地址的地址。你应该根据情况做出选择。
9.2 为什么会发生混淆
当一个数组名出现在一个表达式中时,它会被转换为一个指向该数组第一个元素的指针。
[!什么时候数组和指针是相同的—C语言标准对此做了如下说明。] 规则1.表达式中的数组名(与声明不同)被编译器当作一个指向该数组第一个元素的指针[1](具体释义见ANSI C标准第6.2.2.1节)。 规则2.下标总是与指针的偏移量相同(具体释义见ANSI C标准第6.3.2.1节)。 规则3.在函数参数的声明中,数组名被编译器当作指向该数组第一个元素的指针(具体释义见ANSI C标准第6.7.1节)。
编译器自动把下标值的步长调整到数组元素的大小。如果整型数的长度是 4 字节,那么a[i+1]和a[i]在内存中的距离就是4(而不是1]
这就是为什么指针总是有类型限制,每个指针只能指向一种类型的原因所在——因为编译器需要知道对指针进行解除引用操作时应该取几个字节,以及每个下标的步长应取几个字节。
9.3 为什么C语言把数组形参当作指针
有一种操作只能在指针里进行而无法在数组中进行,那就是修改它的值。数组名是不可修改的左值,它的值是不能改变的
9.6 C语言的多维数组
当提到C语言中的数组时,就把它看作是一种向量(vector),也就是某种对象的一维数组,数组的元素可以是另一个数组。
如果元素的类型是float,那么它们被初始化为0.0。
0.0和0的位模式是完全一样的
9.7 轻松一下——软件/硬件平衡
数组参数的地址和数组参数的第一个元素的地址竟然不一样,但事实就是如此。
详见:数组参数与指针参数
10.1 多维数组的内存布局
数组下标的规则告诉我们如何计算左值
pea[i][j]:
首先找到pea[i]
的位置,然后根据偏移量[j]
取得字符。因此,pea[i][j]
将被编译器解析为:
指向字符串的一维指针数组
10.2 指针数组就是Iliffe向量
可以通过声明一个一维指针数组,其中每个指针指向一个字符串来取得类似二维字符数组的效果。这种形式的声明如下:
这种数组必须用这样一种指针进行初始化,即指针指向为字符串分配的内存。既可以在编译时用一个常量初始值,也可以在运行时用下面这样的代码进行初始化:
当然,出于同样的理由:作为左值的数组名被编译器当作是指针。
10.3 在锯齿状数组上使用指针
存储各行长度不一的表以及在一个函数调用中传递一个字符串数组。如果需要存储50个字符串,每个字符串的最大长度可以达到255个字符,可以声明下面的二维数组:
它声明了50个字符串,其中每一个都保留256字节的空间,即使有些字符串的实际长度只有一两个字节。如果经常这样做,内存的浪费会很大
一种替代方法就是使用字符串指针数组,注意它的所有第二级数组并不需要长度都相同
“数组名被改写成一个指针参数”的规则并不是递归定义的。数组的数组会被改写为“数组的指针”
10.5 使用指针向函数传递一个多维数组
在C语言中,没有办法向函数传递一个普通的多维数组
这是因为我们需要知道每一维的长度,以便为地址运算提供正确的单位长度。在C语言中,我们没有办法在实参和形参之间交流这种数据(它在每次调用时会改变)。因此,你必须提供除了最左边一维以外的所有维的长度。这样就把实参限制为除最左边一维外所有维都必须与形参匹配的数组。
10.6 使用指针从函数返回一个数组
千万要注意,不能从函数中返回一个指向函数的局部变量的指针
10.7 使用指针创建和使用动态数组
在C语言中如何实现动态数组
基本思路就是使用malloc()库函数(内存分配)来得到一个指向一大块内存的指针。然后,像引用数组一样引用这块内存,其机理就是一个数组下标访问可以改写为一个指针加上偏移量。
11.1 初识OOP
面向对象编程的特点是继承和动态绑定。C++通过类的派生支持继承,通过虚拟函数支持动态绑定。虚拟函数提供了一种封装类体系实现细节的方法
把焦点集中于抽象概念而不是底层实现细节中
11.2 抽象——取事物的本质特性
隐藏不相关的细节,把注意力集中在本质特征上。向外部世界提供一个“黑盒”接口。接口确定了施加在对象之上的有效操作的集合,但它并不提示对象在内部是怎样实现它们的。把一个复杂的系统分解成几个相互独立的组成部分。这可以做到分工明确,避免组件之间不符合规则的相互作用。
重用和共享代码
C语言通过允许用户定义新的类型(struct、enum
)来支持抽象。用户定义类型几乎和预定义类型(int、char
等)一样方便,使用形式也几乎一样。我们说“几乎一样方便”是因为C语言并不允许在用户定义类型中重新定义*、<<、[]、+
等预定义操作符。
11.3 封装——把相关的类型、数据和函数组合在一起
当把抽象数据类型和它们的操作捆绑在一起的时候,就是在进行“封装”。
通过把用户定义的数据结构和用户定义的能够在这些数据结构上进行操作的函数捆绑在一起,实现了数据的完整性。别的函数无法访问用户定义类型的内部数据。这样,强类型就从预定义类型扩展到用户定义类型。
11.4 展示一些类——用户定义类型享有和预定义类型一样的权限
类的名字以大写字母开头是一个很好的习惯。
类就是用户定义类型加上所有对该类型进行的操作。
形式:一个包含多个数据的结构,加上对这些数据进行操作的函数的指针。
编译器施行强类型——确保这些函数只会被该类的对象调用,而且该类的对象无法调用除它们之外的其他函数。
11.6 声明
::
被称为全局范围分解符。跟在它前面的标识符就是进行查找的范围。如果::前面没有标识符,就表示查找范围为全局范围。
11.7 如何调用成员函数
析构函数当作一种保险方法来确保当对象离开适当的范围时,同步锁总能够被释放。所以他们不仅清除对象,还清理对象所持有的锁。
类外部的任何函数都不能访问类的private
数据成员。因此,你需要类内部有一个特权函数来创建一个对象并对其进行初始化。
构造函数的名字总是和类的名字一样
当创建类的一个对象时,会自动调用构造函数,程序员永远不应该显式地调用构造函数。至于全局和静态对象,会在程序开始时自动调用它们的构造函数,而当程序终止时,会自动调用它们的析构函数。
11.8 继承——复用已经定义的操作
从一个类派生另外一个类,使前者所有的特征在后者中自动可用。它可以声明一些类型,这些类型可以共享部分或全部以前所声明的类型。它也可以从多个基类型中共享一些特征。
继承允许程序员使类型体系结构显式化,并利用它们之间的关系来控制代码。
不要把在一个类内部嵌套另一个类与继承混淆。嵌套只是把一个类嵌入另一类的内部,它并不具有特殊的权限,跟被嵌套的类也没有什么特殊的关系。嵌套通常用于实现容器类(就是实现一些数据结构的类,如链表、散列表、队列等)。现在C++
增加了模板(template
)这个特性,它也用于实现容器类。
11.10 重载——作用于不同类型的同一操作具有相同的名字
重载(按照它的定义)总是在编译时进行解析。编译器查看操作数的类型,并核查它是否是该操作符所声明的类型之一。
11.11 C++如何进行操作符重载
重载后的操作符的优先级和操作数(编译器行话中的arity
)与原先的操作符相同。
#第一次复习标记
11.13 多态——运行时绑定
在C++
中,它的意思是支持相关的对象具有不同的成员函数(但原型相同),并允许对象与适当的成员函数进行运行时绑定
有时无法在编译时分辨所拥有的对象到底是基类对象还是派生类对象。这个判断并调用正确的函数的过程称为“后期绑定”(late binding
)
在成员函数前面加上virtual
关键字,可以告诉编译器该成员函数是多态的(也就是虚拟函数)。
多态是指一个函数或操作符只有一个名字,但它可以用于几个不同的派生类型。每个对象都实现该操作的一种变型,表现一种最适合自身的行为。它始于覆盖一个名字——对同一个名字进行复用,代表不同对象中的相同概念。
多态非常有用,因为它意味着可以给类似的东西取相同的名字。运行时系统在几个名字相同的函数中选择正确的一个进行调用,这就是多态。
11.14 解释
当想用派生类的成员函数取代基类的同名函数时,C++
要求你必须预先通知编译器。通知的方法就是在可能会被取代的基类成员函数前面加上virtual
关键字。
11.15 C++如何表现多态
多态是一种运行时效果。它是指C++
对象在运行时决定应该调用哪个函数来实现某个特定操作的过程。
单继承通常通过在每个对象内包含一个vptr
指针来实现虚拟函数。vptr
指针指向一个叫作vtbl
的函数指针向量(称为虚拟函数表,也称为V
表)。
11.16 新奇玩意儿——多态
有时候,成员函数在编译时并不知道它是作用于本类的对象还是派生于本类的子类对象。多态必须保证这种情况能够正确地工作。
11.17 C++的其他要点
异常
异常(exception
):C++的这个概念源于Ada,也源于Clu(MIT所开发的一种实验性的语言,它的关键思想是cluster
[集群])。它用于在错误处理时改变程序的控制流。
异常通过发生错误时把处理自动切换到程序中用于处理错误的那部分代码,以简化错误处理。
内联(inline
)函数
内联(inline
)函数:程序员可以规定某个特定的函数在行内以指令流的形式展开(就像宏一样),而不是产生一个函数调用
new与malloc
new
和delete
操作符:用于取代malloc()
和free()
函数。这两个操作符用起来更方便一些(如能够自动完成sizeof
的计算工作,并会自动调用合适的构造函数和析构函数)。new
能够真正地建立一个对象,则malloc()
函数只是分配内存
引用
传引用调用(
call-by-rererence
,相当于传址调用):C语言只使用传值调用(call-by-value
)。C++在语言中引入了传引用调用,可以把对象的引用作为参数传递。
11.18 如果我的目标是那里,我不会从这里起步
编程语言有一个特性,称为正交性(orthogonality)。它是指不同的特性遵循同一个基本原则的程度(也就是学会一种特性有助于学习其他的特性)。
C++的一个简单子集尽量使用的C++特性:类;构造函数和析构函数,但只限于函数体非常简单的例子;重载,包括操作符重载和I/O;单重继承和多态。避免使用的C++特性:模板;异常;虚基类(virtual base class);多重继承。
11.19 它或许过于复杂,但却是唯一可行的方案
在C语言中,初始化一个字符数组的方式很容易产生这样一个错误,即数组很可能没有足够的空间存放结尾的NULL字符。C++对此作了一些改进,像char b[3] = “Bob”这样的表达式被认为是一个错误,但它在C语言中却是合法的。
在C++中存在,但在C语言中却不存在的限制有:在C++中,用户代码不能调用main()函数,但在C语言中却是允许的(不过这种情况极为罕见);完整的函数原型声明在C++中是必须的,但在C语言中却没这么严格;在C++中,由typedef定义的名字不能与已有的结构标签冲突,但在C语言中却是允许的(它们分属不同的名字空间);
当void* 指针赋值给另一个类型的指针时,C++规定必须进行强制类型转换,但在C语言中却无必要。
在C++中,声明可以出现在语句可以出现的任何地方。在C语言中的代码块中,所有的声明必须出现在所有语句的前面
在C++中,一个内层作用域的结构名将会隐藏外层空间中相同的对象名。在C语言中则非如此。
在C++中,字符常量的类型是char,但在C语言中,它们的类型是
int
。也就是说,在C++中,sizeof('a')
的结果是1,而在C语言中,它的值要大一些
private destructor就是一个对象离开其生存范围时所调用的函数。private表示它只能被本类的成员函数或友元[8](friend)访问。
pure virtual函数本身没有代码,但它可以通过继承作为派生类虚拟函数实现的指导准则。
pure virtual destructor只有在被派生类覆盖以后才有意义。由于析构函数能够自动进行类缺省的清理工作,如同调用成员或基类的析构函数一样,所以通常并不需要在析构函数的定义中显式地编写任何代码。
abstract virtual base表示基类是被多个多重继承的类所共享(它是虚基类),它至少包含一个纯虚函数(pure virtual function),其他的类通过继承从它派生(所谓抽象基类)。虚基类也有其特殊的初始化语义。
protected abstract virtual base类是指我们的类是以protected形式派生的。该类的后续派生类可以访问父类的信息,但其他的类则不允许。
A.4 库函数调用和系统调用区别何在
库函数调用和系统调用区别何在
函数库调用是语言或应用程序的一部分,而系统调用是操作系统的一部分。
系统调用是在操作系统内核发现一个trap或中断后进行的。
表A-1 函数库调用vs.系统调用[插图]
系统调用比库函数调用还要慢很多,因为它需要把上下文环境切换到内核模式。
A.5 文件描述符与文件指针有何不同
所有操纵文件的系统调用都接受一个文件描述符作为参数,或者把它作为返回值返回。
文件描述符就是开放文件的每个进程表的一个偏移量(如3)。它用于UNIX系统调用中,用于标识文件。
-
文件描述符(File Descriptor):
- 文件描述符是一个非负整数,它唯一地标识一个打开的文件或套接字。
- 每个进程在打开文件时,操作系统会分配一个文件描述符。
- 文件描述符用于在系统调用中标识文件,比如在读取或写入文件时。
- 它是一个系统级别的资源,通常在文件打开时由操作系统自动管理。
- 文件描述符可以被复制或传递给其他进程,使得多个进程可以访问同一个文件。
-
文件指针(File Pointer):
- 文件指针是一个在用户空间(即应用程序的内存)中维护的指针,用于跟踪文件的当前读取或写入位置。
- 文件指针通常由应用程序在打开文件时初始化,并在文件操作过程中更新。
- 文件指针可以是一个整数,也可以是一个结构体,具体取决于操作系统和编程语言。
- 文件指针用于控制文件的读取和写入操作,比如跳转到文件的某个位置或移动到文件的末尾。
- 文件指针是应用程序级别的概念,通常与文件描述符一起使用,但它们是两个不同的实体。
区别:
- 作用域:文件描述符是系统级别的,用于操作系统内部管理;文件指针是应用程序级别的,用于应用程序内部管理。
- 用途:文件描述符用于标识文件,文件指针用于控制文件的读写位置。
- 管理:文件描述符由操作系统管理,文件指针由应用程序管理。
- 共享:文件描述符可以被复制或传递给其他进程,文件指针通常是进程私有的。
A.6 编写一些代码,确定一个变量是有符号数还是无符号数
无法用函数实现目的。函数形式参数的类型是在函数内部定义的,所以它无法穿越调用这一关。因此,必须编写一个宏,根据参数的声明对它进行处理。
A.7 打印一棵二叉树的值的时间复杂度是多少
关于复杂度理论其次需要知道的是在一棵二叉树中,所有操作的时间复杂度都是O(log(n))。
干扰、混淆
要打印一棵二叉树所有节点的值,必须对它们逐个访问,所以时间复杂度为O(N)。
A.8 从文件中随机提取一个字符串
从文件中随机提取一个字符串