C专家编程

第一章 C:穿越时空的迷雾

1.2 C语言早期体验

C语言排斥强类型,即其是弱类型。它允许程序员需要时可以在不同类型的对象间赋值。

C语言的许多特性是为了方便编译器设计者而建立的:
1.数组下表从0开始
2.基本数据类型直接和底层硬件相关
3.auto 关键字是摆设(它是缺省的内存分配模式,其只对创建符号表入口的编译器设计者有用)
4.float被自动扩展为double(但在ANSI C中不再这样)
5.不允许嵌套,即函数内部包含另外一个函数
6.register关键字。告诉编译器,哪些变量经常被使用,可以把他们放在寄存器中。

1.3 K&R C
1.4 今日之ANSI C

ANSI C是美国国家标准化组织审定的一个C语言标准,其在1989年发布,并在次年被ISO(国际标准化组织)接纳,ISO稍作修改后发布,所以ANSI C也可以称ISO C。目前ISO已经发布了ISO C99和ISO C11。K&R C早于ANSI C,是对C语言经典名著《The C Programming Language》的两位作者,Brain Kernighan和Dennis Ritchie的名字的缩写。

1.8 ANSI C标准的结构
ANSI C 与 K&R C 的不同

1.新的、非常不同,且重要的(仅一个)
ANSI C 把函数原型作为函数声明的一部分,原型的形式,其两者也有了很大的变化

2.新增的关键字
ANSI C 增加了 enum, const, volatile, signed, void等关键字
弃掉了K&R C中的entry等关键字

3.“安静的改变”

4.符号粘贴(token-pasting)
三字母词(trigraph),即用3个字符表示一个单独的字符,如两字母词\t表示“tab”, 三字母词??<表示“开放的花括号”。

1.9 阅读ANSI C标准,寻找乐趣和裨益

如下代码会报一条warning,“argument #1 is imcompatible with prototype…”,为什么?

   foo(const char **p) {}

   main(int argc, char **argv) 
   {
       foo(argv);
   }

原因分析(摘自ANSI C标准):
1.每个实参都应该具有自己的类型,这样它的值就可以复制给与它所对应的形参类型的对象(该对象的类型不能含有限定符)
要使得上述赋值形式合法,必须满足下列条件之一:
1.两个操作数都是指向有限定符或无限定符的相容类型指针
2.左边指针所指向的类型必须具有右边指针所指向类型的全部限定符
基于上述描述故实参char 能和型参const char匹配

但是:const float *类型并不是一个有限定符的指针类型,它的类型是“指向一个具有const限定符的float类型的指针,即const修饰的是指针指向的类型而不是指针本身。
基于上条描述故char * 和 const char * 都是没有限定符的指针类型,但它们所指向的类型不一样,(前者指向char ,后者指向 const char ,这是两个不同类型的对象),进而不相容, 所以报错。

1.10安静的转变究竟有多安静

寻常算数转换(usual arithmetic conversation)

K&R C 采用的是无符号保留(unsigned preserving)原则,即当一个无符号类型与int或更小的整型混合使用时,
结果类型是无符号类型。

ANSI C 采用的是值保留(value preserving)原则,即当执行算数运算时,如果类型不同,就会发生转换。
数据类型朝着浮点精度更高、长度更长的方向转换,整形数如果转换为signed不会丢失信息,就转换为signed,
否则转换为unsigned —— 即包括整型升级和寻常算数转换。
main()
{
      if(-1 < (unsigned char)1)
          printf("-1 is less than (unsigned char)1: ANSI semantics");
      else
          printf("-1 NOT less than (unsigned char)1: K&R C semantics");
  }

一个例子:

int array[] = {23,34,12};
#define TOTAL_ELEMENTS (sizeof(array) / sizeof(array[0]))

main()
{
    int d = -1,x;
    /* ... */

    if(d < TOTAL_ELEMENTS - 2)
        x = array[d + 1];
    /* ... */
}

if语句永远不会进入。因为sizeof返回一个unsigned int 类型,if语句在signed int类型和unsigned int类型之间比较,int会被提升为unsigned int,-1转换为unsigned int会是一个很大的值,所以if语句永远不会进入。
修正问题的方法是:

if(d < (int)TOTAL_ELEMENTS - 2)

小启发:
尽量不要在你的代码中使用无符号类型,以免增加不必要的复杂性。
尽量使用像int那样的有符号类型,这样在涉及升级混合类型的复杂细节时,不必担心边界情况。
只有在使用位段和二进制掩码时,才可以用无符号数。应该在表达式中使用强制类型转换,使操作数均为有符号或无符号数,这样就不必由编译器来选择结果的类型。
关于符号提升,可参考:https://blog.csdn.net/konghanyan/article/details/26866857

第二章 这不是Bug,而是语言特性

2.2多做之过

1.确保在使用switch语句时,存在default选项。
在缺少break的switch-case结构中,在97%的情况下都是错误的。
注意break并不是跳出了条件语句,而是跳出了离它最近的while、for、switch语句。
2.几乎没有人习惯在函数名前添加存储类型说明符,所以绝大多数函数都是全局可见。如果有必要,在内部函数前加上static关键字。

2.3误做之过

1.C语言有些符号,放在不同的语境中有不同的意思,要注意区分。
2.注意运算符优先级及运算符的结合顺序。
可参考:https://blog.csdn.net/hmxz2nn/article/details/80150195中2.2节的“运算符优先级问题”。

2.4少做之过

例:编译器日期被破坏
注意函数中声明的局部变量指针返回的问题。

一般的来说,函数是可以返回局部变量的。 局部变量的作用域只在函数内部,在函数返回后,局部变量的内存已经释放了。因此,如果函数返回的是局部变量的值,不涉及地址,程序不会出错。但是如果返回的是局部变量的地址(指针)的话,程序运行后会出错。因为函数只是把指针复制后返回了,但是指针指向的内容已经被释放了,这样指针指向的内容就是不可预料的内容,调用就会出错。准确的来说,函数不能通过返回指向栈内存的指针(注意这里指的是栈,返回指向堆内存的指针是可以的)。
解决办法:
1.返回一个指向字符串常量的指针
字符串常量存储于静态存储区,在函数结束后不会释放内存。缺点是不能修改。

2.使用全局声明的数组
缺点是任何人都可以修改,且下次调用也会覆盖该数组内容,且可能会造成内存的闲置和浪费。

3.使用静态数组
缺点是下次调用也会覆盖该数组内容,可能会造成内存的闲置和浪费。

4.显式分配一些内存,保存返回值
优点:每次调用都会创建一个新的缓冲区,所以该函数以后的调用不会覆盖以前的返回值,适用于多线程的代码。
复杂点:内存由程序员自己分配释放,要注意不要造成“内存泄漏”。
建议:程序员最好确保在同一块代码中进行malloc和free操作,这样内存管理是最轻松的。

第三章 分析C语言的声明

3.2声明是如何形成的

1.C语言中的存储类型说明符(storage-class)有:extern static register auto typedef。
2.C语言中的类型限定符(type-qualifier)有: const volatile。
3.位段的类型必须是int,unsigned int 或 signed int(或加上限定词)。
4.“在函数调用时,参数按照从右到左的次序压到堆栈里”这种说法过于简单,参数在传递时首先尽可能地存放到寄存器中(追求速度)。int型变量i跟只包含一个int型成员的结构变量s在参数传递时的方式可能完全不同。一个int型参数一般会被传递到寄存器中,而结构参数则很可能被传递到堆栈中。
5.联合一般被用来节省空间,因为有些数据项是不可能同时出现的,如果同时存储他们,显得颇为浪费。
6.枚举把一串名字和一串整型值联系在一起。枚举具有一个优点:#define定义的名字一般在编译时被丢弃,而枚举名字则通常一直在调试器中可见,可以在调试代码时使用它们。

3.3 优先级规则

规则如下:
A 声明从它的名字开始读取,然后按照优先级顺序依次读取。

B 优先级从高到低依次是:

B.1  声明中被括号括起来的部分;

B.2  后缀操作符:括号()表示这是一个函数,而方括号[]表示这是一个数组;

B.3  前缀操作符:*号表示“指向...的指针”

C 如果const和(或)volatile关键字的后面紧跟类型说明符(如int,long等),那它作用于类型说明符。在其他情况下,const和(volatile)关键字作用于左边紧邻的指针星号。
补充:volatile关键字
volatile是一个类型修饰符(type specifier),就像大家更熟悉的const一样,它是被设计用来修饰被不同线程访问和修改的变量。volatile的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值。

3.5 typedef可以成为你的朋友

1.typedef关键字并不是创建一个变量,而是宣称“这个名字是指定类型的同义词”。

3.6 typedef和#define的区别

1.可以用其他类型说明符对宏类型进行扩展,但对typedef所定义的类型名却不能这样做。

#define peach int
unsigned peach int;//没问题

typedef int banana;
unsigned banana i;//错误,非法声明

2.#define无法保证连续几个变量声明类型的一致性。

3.7 typedef struct foo{…foo;}的含义

建议:应该始终在结构体的定义中使用结构标签,即使它并非所需。这种做法可以使代码更为清晰。

第4章 令人震惊的事实:数组和指针并不相同

4.1 数组和指针并不相同
extern int *x;
extern int y[];

第一条语句声明x是个int型指针;第二条语句声明y是一个int型数组,长度尚未确定,其存储在别处定义。

4.3 什么是声明,什么是定义

声明相当于普通的声明:它所说明的并非自身,而是描述其他地方创建的对象。包括对象的类型和名字。
定义相当于特殊的声明:除声明对象的类型和名字外,它还为对象分配内存。

由于并未在声明中为数组分配内存,所以并不需要提供关于数组长度的信息。对于多维数组,需要提供除最左边一维之外的其他维的长度—–这就给编译器足够的信息产生相应的代码。

4.3.1 数组和指针是如何访问的

左值和右值:
左值表示一个地址;左值在编译时可知,其表示存储结果的地方。
右值表示一个地址里所存储的内容;右值直到运行时才可知,如无特殊说明,一个右值变量Y表示“Y的内容”。

C语言引入了“可修改的左值”这个术语,它表示左值允许出现在赋值语句的左边。这个奇怪的术语是为与数组名区分,数组名也用于确定对象在内存中的位置,也是左值,但它不能作为赋值的对象。因此,数组名是个不可修改的左值。标准规定,智能给可以修改的东西赋值。
上面的内容总结起来就是:数组名是不可修改的左值,不能直接对其赋值(定义时除外)。但可以用memcpy,strcpy等来进行间接“赋值”。

extern char a[100]与extern char *p的区别:
对编译器而言,一个数组就是一个地址,一个指针就是一个地址的地址。
对于数组,其每个符号的地址在编译时是可知的。如果编译器需要一个地址来执行某种操作,就可以直接进行操作,并不需要增加指令首先取得具体的地址。

对于extern char *p,它将告诉编译器p是一个指针,它指向的对象是一个字符。为了得到这个字符,必须得到地址p的内容,这个内容就是一个地址,再利用指针解引用符获得具体字符。

4.3.2 当你“定义为指针,但以数组方式引用”时会发生什么

当书写了extern char *p,然后以p[3]来引用其中的元素时,编译器将会:
1.取得符号表中p的地址,提取存储于此处的指针。
2.把下标所表示的偏移量与指针的值相加,产生一个地址。
3.访问上面的地址,取得字符。

4.4 使声明和定义相匹配
int mango[100];
int *raisin;

指针变量raisin本身始终位于同一个地址,但它的内容在任何时候都可以不同,进而可以指向不同地址的int变量。
mango数组的地址并不能改变,在不同时候其内容可以不同,但它总表示100个连续的内存空间。

4.5 数组和指针的其他区别

定义指针时,编译器并不为指针所指向的对象分配空间,它只是分配指针本身空间,除非在定义时同时赋给指针一个字符串常量进行初始化。
如:char *p = "breadfruit";
在ANSI C中,初始化指针时所创建的字符串常量被定义为只读。如果试图通过指针修改这个字符串的值,程序就会出现未定义的行为。

数组也可以用字符串常量进行初始化,但初始化后,是可以修改的。

char a[] = "gooseberry";
strncpy(a,"black",5);

第五章 对链接的思考

5.1 函数库、链接和载入

编译器中单独分离出来的程序包括:预处理器(preprocessor)、语法和语义检查器(syntactic and semantic checker)、代码生成器(code generator)、汇编程序(assembler)、优化器(optimizer)、链接器(linker)等。

动态链接和静态链接:
如果函数库的一份拷贝是可执行文件的物理组成部分,那么我们称之为静态链接;
如果可执行文件只是包含了文件名,让载入器在运行时能够寻找程序所需要的函数库,那么我们称之为动态链接。

收集模块准备执行的三个阶段的规范名称是:链接-编辑、载入和运行时链接。静态链接的模块被链接编辑并载入以便运行。动态链接的模块被链接编辑后载入,并在运行时进行链接以便运行。

5.2 动态链接的优点

动态链接的主要目的就是把程序与它们使用的特定函数库版本中分离出来。取而代之的是,我们约定由系统向程序提供一个接口,该接口保持稳定,不随时间和操作系统的后续版本发生变化。这种介于应用程序和函数库二进制可执行文件所提供的服务之间的接口,称之为二进制接口(Application Binary Interface, ABI)。

动态链接的优点:
1.动态链接可执行文件比功能相同的静态链接可执行文件的体积小。它能够节省磁盘空间和虚拟内存,因为函数库只有在需要时才被映射到进程中。
2.可执行文件共享函数库的一个单独拷贝。函数库可以被所有使用他们的进程共享。这将提供更好的IO和空间交换利用率,从而提高了系统的整体性能。

任何人都可以创建静态或动态链接的函数库。只需简单的编译一些不包含main函数的代码,并把编译所生成的.o文件用正确的实用工具进行处理——如果是静态库,实用“ar”,如果是动态库,实用“ld”。

静态库称作为archive,通过ar来创建和更新,后缀名约定以.a结尾。
动态链接库,可由ld创建,后缀名约定以.so结尾,表示shared object(共享对象)。

5.3 函数库链接的5个特殊秘密

1.动态库文件的扩展名是“.so”,而静态库文件的扩展名是“.a”。
2.例如,你通过-lthread选项,告诉编译链接到libthread.so。
3.编译器期望在确定的目录找到库。
4.观察头文件,确认所使用的函数库。
5.与提取动态库中的符号相比,静态库中的符号提取的方法限制更严。
在编译器命令行中各个静态链接库出现的顺序是非常重要的。

5.4 警惕Interpositioning

Interpositioning就是通过编写与库函数同名的函数来取代该库函数的行为。
编译器注意到库函数被另外一个定义覆盖时,它通常不会给出错误信息,它认为程序员所做的都是对的。正因为如此,要非常谨慎的使用与库函数同名的函数。

第六章 运动的诗章:运行时数据结构

6.2 段

Unix中,段表示一个二进制文件相关的内容块。
Intel x86的内存模型中,段表示一个设计的结果,其中地址空间并非一个整体,而是分成了一些64K大小的区域,称之为段。

编程挑战

分析“编程挑战”结果,使自己确信:
1.数据段保存在目标文件中。
2.BSS段不保存在目标文件中(除了记录bss段在运行时所需要的大小)。
3.文本段是最容易受优化措施影响的段。
4.a.out文件的大小受调试状态下编译的影响,但段不受影响。

6.3 操作系统在a.out文件里干了些什么

关于这一部分,可参考之前的博客:C程序的存储区及堆与栈区别;https://blog.csdn.net/hmxz2nn/article/details/77435169
在此强调几点:
1.数据段包含经过初始化的全局和静态变量及它们的值。
2.BSS段包含未初始化的全局和静态变量,这些变量并不占用可执行程序的镜像的空间,只是记录了变量及其所占内存空间的大小,这些变量的内存分配,是在执行时进行的。

6.5 当函数被调用时发生了什么:过程活动记录

C语言自动提供的服务之一就是跟踪调用链。那些函数调用了那个函数,当return语句执行后,控制将返回何处。解决这个问题的经典机制使堆栈中的过程活动记录。当每个函数调用时,都会产生一个过程活动记录,过程活动记录是一种数据结构,用于支持过程调用。其结构图如下图所示:
这里写图片描述
活动记录的描述很具有说明性。结构的具体细节在不同编译器中各不相同。如下实例:
这里写图片描述

6.6 auto和static关键字

编译器设计者一般会尽可能地把过程活动记录的内容放到寄存器中(因为可以提高速度)!尽管我们谈到了“将过程活动记录压到堆栈中”,但是过程活动记录并不一定要存在于堆栈中。事实上,尽可能地把过程活动记录的内容放到寄存器中会使函数调用的速度更快,效果更好。CPU拥有一组寄存器,成为“寄存器窗口-register window”,它们只用于保存过程活动记录中的参数。每当函数调用的时候,空的活动记录就会压入栈中,当函数调用链非常深而寄存器窗口不够用时,寄存器的内容就会被保存到堆栈中保留的活动记录空间中。

6.8 setjmp和longjmp

setjump和longjump,因为它们是通过操作过程活动记录实现的。是C语言独有的,它弥补了C语言有限的转移能力。
这两个函数协同工作,如下所示:
(1)setjump(jmp_buf j)必须首先被调用。它表示“使用变量j记录现在的位置。函数返回值为0”;
(2)longjmp(jmp_buf j,int i)可以接着被调用。它表示“回到j所记录的位置,让它看上去像是从原先setjmp()函数返回一样,函数返回值为i,是代码能够知道它实际上是通过longjmp返回的”。

注意:当使用longjmp时,j的内容被销毁。setjmp保存了一份程序的计数器和当前的栈顶指针,如果喜欢也可以保存一些初始值。longjmp恢复这些值,有效地转移控制并把状态重置回保存状态的时候。这被称作“展开堆栈unwinding stack”,因为你从堆栈中展开过程活动记录,直到取得保存在其中的值。尽管longjmp能导致转移,但它和goto又不同。

longjmp和goto的区别:
(1)goto语句不能跳出C语言当前的函数(这也是“longjmp”取名的由来),它可以跳的很远,甚至可以调到其它文件中去。
(2)用longjmp只能跳回到曾经到过的地方。在执行setjmp的地方任然留一个过程活动记录。从这个角度讲:longjmp更像是“从哪儿来,不是往哪儿去”,但是goto语句更像是“往哪儿去”。longjmp接收一个额外的整形参数并返回它的值,这可以知道是由longjmp转移到这里,还是从上一条语句自然运行到这里。

setjmp和longjmp的主要作用是错误恢复。只要还没有从函数返回,一旦发现一个不可恢复的错误,可以把控制转移到主输入循环,并从那里重新开始运行。
setjmp和longjmp在C++中变异为更普遍的异常处理机制“catch”和“throw”。
像goto语句一样,setjmp和longjmp使得程序难以理解和调试。如果不是出于特殊需要,最好避免使用它们。

6.9 Unix中的堆栈段

在Unix中,当进程需要更多空间时,堆栈会自动生长。程序员可以想象堆栈是无限大的。在Unix中的实现一般是某种形式的虚拟内存。当试图访问当前系统分配给堆栈的空间之外时,它将产生一个硬件中断,称为页错误。

本章部分内存参考自:https://blog.csdn.net/gogokongyin/article/details/51525661

第七章 对内存的思考

7.3 虚拟内存

SunOS中的进程执行于32位地址空间。操作系统负责具体细节,使每个进程都以为自己拥有整个地址空间的独家访问权。这个幻觉是通过“虚拟内存”实现的。所有进程共享机器的物理内存,当内存用完时就用磁盘保存数据。在进程运行时,数据在磁盘和内存之间来回移动。内存管理硬件负责把虚拟地址翻译为物理地址,并让一个进程始终运行于系统的真正内存中。

虚拟内存通过“页”的形式组织。页就是操作系统在磁盘和内存之间移来移去或进行保护的单位,一般为几K字节。当内存的映像在磁盘和物理内存间来回移动时,称他们是page in或page out。

进程只能操作位于物理内存中的页面。当进程引用一个不在物理内存中的页面时,MMU就会产生一个页错误。内核对此事件做出响应,并判断该引用是否有效。如果无效,内核向该进程发出一个段违规的信号。如果有效,内核从磁盘取出该页,换入到内存中。一旦页面进入内存,进程便被解锁,可以重新运行——进程本身并不知道它曾经因为页面换入事件等待了一会儿。

虚拟内存现已成为一项操作系统中不可或缺的技术,它允许多个进程运行于较小的物理内存中。

7.4 Cache存储器

Cache存储器位于CPU和内存之间,是一种极快的存储缓冲区。
Cache包含一个地址的列表以及他们的内容。随着处理器不断引用新的内存地址,Cache的地址列表也一直处于变化中。所有对内存的读取和写入操作都要经过Cache。当处理器需要从一个特定的地址读取数据时,这个请求首先递交给Cache。如果数据已经存在于Cache中,它就可以立即被提取。否则,Cache向内存传递这个请求,于是就要进行较慢的访问内存操作。内存读取的数据以行为单位,在读取的同时也装入到Cache中。

7.5 数据段和堆

堆内存的回收不必与它所分配的顺序一致(它甚至可以不回收),所以无序的malloc/free最终会产生堆碎片。堆对它的每块区域都要密切留心,那些是已经分配了的,那些是尚未分配的。其中一种策略是建立一种可用块的链表,每块由malloc分配的内存块都在自己的前面标明自己的大小。
被分配的内存总是经过对齐,以适合机器上最大尺寸的原子访问,一个malloc请求申请的内存大小方便起见一般被元整为2的乘方。

7.6 内存泄漏

堆经常出现的问题:
释放或改写仍在使用的内存(称为“内存损坏”)
未释放不再使用的内存(称为“内存泄漏”)

检测内存泄漏:
1.netstat, vmstat查看;
2.swap -s查看交换空间大小;
3.ps -lu 用户名 显示所有进程大小,其中SZ表示的是进程页面数;
4.使用第三方的malloc库。

7.7 总线错误

程序运行时的常见错误:
1.bus error (core dumped) 总线错误(信息已转储)。
总线错误基本是由于未对齐的读或写操作引起的,(出现未对齐的内存访问请求时,被堵塞的组件是地址总线)。
对齐(alignment) 数据项只能存储在地址是数据项大小的整数倍的内存位置上。
只要对齐了,就能保证一个原子数据项不会跨越一个页或Cache块的边界。

 union 
 { 
    char a[10];
    int i;
 } u;
 int *p = (int *)&(u.a[1]);
 *p = 17; // p中未对齐的地址会引起一个总线错误!

2.segmentation fault (coure dumped) 段错误
段错误或段违规(segmentation violation)是由于内存管理单元的异常导致,而该异常通常是由于解除引用一个未初始化或非法值的指针引起的。

int *p = 0;
*p = 17;/*引起一个段错误*/

如果未初始化的指针恰好有未对齐的值,它将产生总线错误而不是段错误。

导致段错误的直接原因:
1.解除引用一个包含非法值的指针;
2.解除引用一个空指针;
3.未得到正确的权限时进行访问,如往只读的文本段存储值就会引起段错误;
4.用完了堆栈或栈空间;

导致段错误的常见编程错误:
1.坏指针值错误(指针赋值前就被引用了;向库函数传递一个坏指针;对指针进行释放后再访问其内容)。
建议在指针释放后再将其置为NULL,如下:

free(p); 
p = NULL; 

这样,在指针释放后继续使用该指针,至少程序能在终止前进行core dump。
2.改写错误(overwrite) :越过数组边界谢姑数据,在动态分配的内存两端写入数据,改写一些堆管理数据结构。

3.指针释放引起的错误:释放一个内存块两次,释放一块未曾使用malloc分配的内存,释放仍在使用的内存。例如循环释放链表就容易出现这种情况。

检测内存泄漏的方法,可参考以下内容:
1.https://jingyan.baidu.com/article/fdffd1f873cd73f3e88ca14f.html
2.https://www.cnblogs.com/carsonzhu/p/5934951.html

第八章 为什么程序员无法分清万圣节和圣诞节

8.3 在等待时类型发生了变化

整型提升就是char、shortint和位段类型(无论是signed还是unsigned)以及枚举类型都将被提升为int,前提是int能够完整的容纳原先的数据,否则将被转换为unsigned int。
类型提升就是在整型提升的基础上,将float提升为double,任何数组提升为相应类型的指针。

C语言中的类型转换远比其他语言更为常见,其他语言往往将类型转换只用于操作数上,是操作符两端数据类型一致。C语言也执行这项任务,但它们同时也提升比规范类型int或double更小的数据类型(即使它们类型匹配)。在隐式类型转换方面,有3个重要的地方需要注意:
1.隐式类型转换是语言中的一种临机手段,其目的是把所有的操作数转化为统一的长度,这样将极大地简化代码的生成。
2.即使不理睬缺省的类型转换,也可以用C语言进行大量的编程工作。
3.隐式类型转换在涉及原型的上下文中显得非常重要。

8.4 原型之痛
8.5 原型在什么地方会失败

小启发:
不要在函数的声明和定义中混用两种不同的风格。
建议只使用ANSI C风格声明和定义函数。

8.6 不需要按回车键就能得到一个字符

每次在使用系统调用之后,检查一下全局变量errno是一种好的做法。

8.7 用C语言实现有限状态机

其基本思路是用一张表保存所有可能的状态,并列出进入每个状态时可能执行的所有动作,其中最后一个动作就是计算下一个应该进入的状态。你从一个“初始状态”开始。在这一过程中,翻译表可能会告诉你进入了一个错误的状态,表示一个预期之外的或错误的输入。你不停的在各种状态间进行转换,直到到达结束状态。
一般基于函数指针数组实现有限状态机。

8.8 软件比硬件更困难

可调试性编码意味着把系统分成几个部分,先让程序总体结构运行。只有基本的程序能够运行之后,你才为那些复杂的细节完善、性能调整和算法优化进行编码。

8.9 如何进行强制类型转换,为何要进行类型强制转换

复杂的类型转换可以按下面3个步骤编写:
1.一个对象的声明,它的类型就是想要转换的结果类型。
2.删去标识符(以及任何如extern之类的存储限定符),并把剩余的内容放在一对括号里。
3.把第二步产生的内容放在需要进行类型转换的对象的左边。

第九章 再论数组

9.1 什么时候数组和指针相同

数组和指针在编译器处理时是不同的,在运行时的表示形式也是不一样的,并可能产生不同的代码。对编译器而言,一个数组就是一个地址,而一个指针就是一个地址的地址。

9.2 为什么会发生混淆

注意,在作为函数定义的形式参数时,数组和指针可以互换。这个前提条件不能丢。
什么时候数组和指针是相同的:
1.“表达式中的数组名”就是指针;
2.C语言把数组下标作为指针的偏移量;
3.“作为函数参数的数组名”等同于指针。

9.3 为什么C语言把数组形参当作指针

把作为形参的数组和指针等同起来是处于效率原因考虑的。用引用传递而不是值传递可以减少时间及内存上的开销

我们倾向于始终把参数定义为指针,因为这是编译器内部所使用的形式。
注意,有一样操作只能在指针里进行而无法在数组中进行,那就是修改它的值。数组名是不可修改的左值,它的值是不能改变的。

9.5 数组和指针可交换性的总结

1.用a[i]这样的形式对数组进行访问总是被编译器“改写”或解释为像*(a+1)这样的指针访问。

2.指针始终就是指针。它绝不可以改写成数组。你可以用下标形式访问指针,一般都是指针作为函数参数时,而且你知道实际传递给函数的是一个数组。

3.在特定的上下文中,也就是它作为函数的参数时(也只有这样情况),一个数组的声明可以看做时一个指针。作为函数参数的数组(就是在一个函数调用中)始终会被编译器修改成为指向数组第一个元素的指针。

4.因此,当把一个数组定义为函数的参数时,可以选择把它定义为数组,也可以定义为指针。不管选择哪种方法,在函数内部事实上获得的都是一个指针。

5、在其他所有情况中,定义和声明必须匹配。如果定义了一个数组,在其他文件对它进行声明时也必须把它声明为数组,指针也是如此。

9.6 C语言的多维数组

C语言的数组就是一维数组。
在C语言的多维数组中,最右边的下标是最先变化的,这个约定被称为“行主序”。
对数组进行初始化时,只能省略最左边的下标。

可以通过建立指针数组的方式来初始化一个二维字符串数组。
只有字符串常量才可以初始化指针数组。指针数组不能由非字符串的类型直接初始化。

第十章 再论指针

10.3 在锯齿状数组上使用指针

使用字符串指针数组,而不是二维字符串数组,可以根据需要为每个字符串分配内存,将大大节省系统资源。这也被称为“锯齿状数组”。

有时候的数据共享和移动,只要可能,尽量不要拷贝整个字符串,拷贝一个指针比拷贝整个数组要快的多,而且还大大节省了内存空间。

数组和指针参数是如何被编译器修改的

实参实参举例所匹配的形式参数形参举例
数组的数组char c[8][10]数组指针char (*)[10]
指针数组char *c[15]指针的指针char **c
数组指针char (*c)[64]不改变char (*c)[64]
指针的指针char **c不改变char **c

你之所以能在main函数中看到char **argv这样的形参,是因为argv是一个指针数组(即char *argv[ ])。这个表达式被编译器改写为一个指向数组第一个元素的指针,也就是一个指向指针的指针。

10.4 向函数传递一个一维数组

对于一个一维的数组而言,形参被改写成指向第一个元素的指针,所以需要一个约定来提示数组的长度。
一般有两个方法:
1.增加一个额外的参数,表示元素的数目,这也是我们最常规的一种做法。例如argc就是起这个作用。
2.赋予数组最后一个元素一个特殊的值。提示它是数组的尾部(字符串‘\0’的结尾就是这个作用)。这个特殊值必须不会作为正常的元素值出现在数组中。

10.5 使用指针向函数传递一个多维数组

在C语言中无法传递一个普通的多维数组。
这是因为我们需要知道每一维的长度,以便在地址运行的时候提供正确的长度单位。在C语言中,我们没有办法在实参和形参之间交流这种数据。因此我们必须提供除了最左边一维以外的所有长度信息,不然就会出错。

向函数传递一个多维数组的方法:
方法一:定义一个包含完整的数组维度的函数

void my_function(int array[3][5])

这样最简单,但是作用也是最小的,因为它只能处理3行5列的数组数据。其实多维数组的最左边的一维长度可以省略,因为函数知道了其他维的信息,它就可以一次跳过一个完整的行,到达下一行。

方法二:定义一个省略数组第一维的函数

void my_function(int array[][5])

这也就是方法一中提到的省略最左边第一维的数据。这样的做法表示每一行都必须正好是5个整数的长度。
函数也可以类似的声明:

void my_function(int array(*p)[5]);

其中的括号是必须要的,这样确保它是一个指向5个int类型的数组指针,而不是一个5个int指针元素的数组。

方法三:放弃传递二维数组,创建一个一维数组,数组中的元素是指向其他东西的指针

回想一下前面介绍的main函数里,我们已经习惯了char *argv[]参数的形式,有时候我们也能看见,char **argv的形式。所以我们可以简单地传递一个指向数组参数的第一个元素的指针,如下:

void my_function(char **array);  

注意:只有把二维数组改为一个指向向量的指针数组的前提下才可以这样做,也就是函数的实参必须是指针数组,而且必须是指向字符串的指针数组。这是因为字符串和指针都有一个显示的越界值,可以作为结束标志。至于其他类型,并没有一个可靠的值来记录数组某一维的结束位置。并且,即使是指向字符串的指针数组,通常也需要一个计数参数argc,记录字符串的数量。

10.7 使用指针创建和使用动态数组

使用malloc申请一块内存,供动态数组使用。
使用realloc函数,实现对一个现有的内存块的重新分配,同时不会丢失原先内存块的内容。

数据结构动态增长的另一种方法是使用链表,但链表的缺点是不能进行随机访问。

第十一章 你懂得C,所以C++不在话下

11.1 初识OOP

几个关键概念:
抽象(Abstruct):它是一个去除对象中不重要的细节的过程,只有那些描述了对象本质特征的关键点才被保留。抽象是一种设计活动,其他的概念都是提供抽象的OOP(Object-oriented paradigm)特性。
在软件中,抽象是十分有用的,它允许程序员实现下列目标:
1.隐藏不相关的细节,把注意力集中在本质特征上。
2.向外部世界提供一个“黑盒子接口”,接口确定了施加在对象之上的有效操作集合,但它并不提示对象在内部是怎样实现他们的。
3.把一个复杂的系统分解成几个相互独立的组成部分。这可以做到分工明确,避免组件之间不符合规则的相互作用。
4.重用和代码共享。
抽象建立了一种抽象数据类型,C++使用类(class)这个特性来实现它。

类:就是用户定义类型加上对该类型进行操作。
类是一种用户定义的类型,就好像是int这样的内置类型一样。内置类型已经有了一套完善的针对它的操作(如算术运算)等,类机制也必须允许程序员规定他所定义的类能够进行的操作。类里面的任何东西被称为类的成员。
C++的类机制实现了OOP的封装要求,类就是封装的软件实现。类也是一种类型,就像是char,int,double和struct st*一样。因此在使用之前必须要声明类的变量,类和类型一样,你可以对它进行很多操作,如取得它的大小或声明它的变量等。对象和变量一样,可以对它进行很多操作,如取得它的地址、把它作为参数传递、把它作为函数的返回值、使它成为常量值等。

对象(Object):某个类的一个特定变量,就像j可能是int类型的一个变量一样。对象也可以被称作类的实例(instance)。

封装(encapsulation):把类型、数据和函数组合在一起,组成一个类。在C语言中,头文件就是一个非常脆弱的封装的实例。它之所以是一个微不足道的封装例子,是因为它的组合形式是纯词法意义上的,编译器并不知道头文件是一个语义单位。

11.5 访问控制

public:属于public的声明在类外部可见,并可按需要进行设置、调用和操纵。一般的原则是不要把类的数据做成public。
protected:属protected的声明内容只能被类本身的函数以及该类的派生类的函数使用。
private:属于private的声明只能被该类的成员函数使用。private声明在类外部是可见的,但却不能访问。

friend:属于friend的函数不属于类的成员函数,但可以像成员函数一样访问类的private和protected成员。friend可以是一个函数或者一个类。
virtual:

11.7 如何调用成员函数

构造函数(Constructor):绝大多数类至少有一个构造函数。当类的一个对象被创建时,构造函数被隐式的调用,它负责对象的初始化。构造函数是必要的,因为类通常包含一些结构,结而构又包含很多字段,这就需要复杂的初始化。当类的一个对象被创建时,构造函数会被自动调用。

析构函数(DesConstructor):与构造函数相对应的,类也存在一个清理函数,称为析构函数。当对象被销毁(超出其生命周期或进行delete操作,回收它所使用的堆内存)时,析构函数被自动调用。有人把析构函数当作一种保险方法来确保当对象离开适当的范围时,同步锁总能够释放。所以它们不仅能清除对象,还清理 对象所持有的锁。构造函数和析构函数都是必要的,因为类外部的任何函数都不能访问类的私有成员。因此,你需要类内部有一个特权函数来创建一个对象并对其进行初始化。但是构造函数和析构函数都违背了C语言中“一切工作自己负责”的原则。

11.8 继承—复用已经定义的操作

单继承(ingheritance):这是一个很大的概念–允许类从一个更简单的基类中接收数据结构和函数。派生类获得基类的数据和操作,并可以根据需要对它们进行改写,也可以在派生类中增加新的数据和函数成员。在C语言里不存在继承的概念,没有任何东西可以模拟这个特性。当一个类沿用或定制它的唯一基类的数据结构和成员函数时,它就成了单继承。不要把在一个类内部嵌套另一个类与继承混淆。嵌套只是把一个类嵌入另一个类的内部,称内部类。

11.9 多重继承—从两个或更多的基类派生

多继承:多重继承允许把两个类组合成一个类,这样的结果类对象的行为类似于这两个类的对象中的一个。

11.10 重载—作用于不同类型的同一操作具有相同的名字

重载(overload)就是简单的复用一个现存的名字,但使它操作一个不同的类型。它可以是函数的名字,也可以是一个操作符。
重载总是在编译时进行解析。编译器查看操作数的类型,并核查是否是该操作符所声明的类型之一。

11.13 多态—运行时绑定

多态(polymorphism):源于希腊语,意思是“多种形状”。在C++中,它的意思是支持相关的对象具有不同的成员函数(但原型相同),并允许对象与适当的成员函数进行运行时绑定。C++通过覆盖(override)支持这种机制—所有的多态成员函数具有相同的名字,由运行时系统判定哪一个最合适。
当使用继承时就要用到这种机制:有时你无法在编译时分辨所拥有的对象到底是基类对象还是派生类对象。这个判断并调用正确的函数的过程被称为“后期绑定”。在成员函数前加上关键字virtual告诉编译器该成员函数是多态的。
在寻常的编译时重载中,函数的原型必须显著不同,这样编译器才能通过查看参数的类型判断需要调用哪个函数。但在虚拟函数中,函数的原型必须相同,由运行时系统进行解析调用哪个函数。
本章总结部分参考了:https://blog.csdn.net/gogokongyin/article/details/51707350

附录A

A.4库函数调用和系统调用区别何在

参考:https://blog.csdn.net/hmxz2nn/article/details/80537895

A.5文件描述符和文件指针有何不同

文件描述符是开放文件的每个进程表的一个偏移量(如“3”)。它用于Unix**系统调用**中,用于标识文件。

文件指针保存了一个FILE结构的地址。FILE结构用于表示开放的I/O流。它用于标准I/O库调用中,用于标识文件。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值