查看原文
其他

C语言三剑客之《C专家编程》一书精华提炼

C语言的设计哲学:

一切工作程序员自己负责。
语言中的所有特性都不需要隐式的运行时支持。
程序员所做的都是对的。
程序员应该知道自己在干什么,并保证自己的所作所为是正确的。

第1章-- C: 穿越时空的迷雾

小即是美。事物发展都有个过程,由简入繁,不能一开始就想得太复杂,Multics, IBM的OS/360都是因此而失败。

C语言的许多特性是为了方便编译器设计者而建立的。----唉,怎么这个样子

C语言的基本数据类型直接与底层硬件相对应。----确实如此

register关键字,这个设计可以说是一个失误,如果让编译器在使用各个变量时自动处理寄存器的分配工作,显然比一经声明就把这类变量在生命周期内始终保留在寄存器里要好,使用register关键字,简化了编译器,却把包袱丢给了程序员。

C编译器不曾实现的一些功能必须通过其他途径实现----标准I/O库和C预处理器。

在宏扩展中,空格会对扩展的结果造成很大的影响。宏后面不可加';',它不是C语句。宏最好只用于命名常量,并为一些适当的结构提供简捷的记法。宏名应该大写这样便很容易与函数调用区分开来。

const关键字原先如果命名为readonly就好多了。

const int *p;是指不能够通过通过p来改变int的值,即:*p = 30和p[3] = 4都是错误,但p是可以改变。

const int *与int *是相容的,都是指向int的指针;const int **与int **不相容,前者是指向const int *的指针,int **是指向int *的指针。

尽量不要在你的代码中使用无符号类型,以免增加不必要的复杂性。只有在使用位段和二进制掩码时,才可以使用无符号数。应该在表达式中使用强制类型转换,使操作数均为有符号数,或者无符号数,这样就不必由编译器来选择结果的类型。有个例子,在ANSI C中,-1 < (unsigned char)1为真,而-1 < (unsigned int)1 为假。

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

进步——是计算机软件工程和编程语言设计艺术逐步发展的重要动因。这也是为什么C++语言令人失望的原因:它对C语言中存在的一些最基本问题没有什么改进,而它对C语言最重要的扩展(类)却是建立在脆弱的C类型模型上。

按照C语言的理念,程序员应该知道自己在干什么,而且保证自己的所作所为是正确的。

多做之过:fall through作为switch的默认行为是个失误;相邻的字符串自动合并成一个字符串;太多的缺省可见性,全局可见,一个大型函数一群“内部”函数不得不在该函数的外部进行定义。没有人会记得在它们之前加上static限定符,所以他们在缺省情况下是全局可见的。

误做之过:

C语言中符号重载:static 在函数内部,表示该变量的值在各个调用间一直保持延续性;在函数这一极,表示该函数只对文本文件可见。extern用于函数定义表示全局可见(属于冗余),用于变量,表示它在其他地方定义。

运算符优先级存在的问题:.优先级高于*, p.f表示(p.f);函数()高于*;==和!=高于位运算符(val & mask != 0)表示val & (mask != 0);==和!=高于赋值符,c = getchar() != EOF表示c = (getchar() != EOF);算数运算符高于移位运算符 msb<<4 + lsb表示msb<<(4+lsb);逗号最低。

有些专家建议在C语言中记牢两个优先级就够了:乘除先于加减,在涉及其他的操作符时一律加括号。

结合性,在几个操作符具有相同优先级时决定先执行哪一个。

计算的次序之所以未定义,是想让编译器充分利用自身架构的特点,或者充分利用存储于寄存器的值。

如果对于堆栈的每次访问之前都要检查其大小和访问权限,对于软件来说代价太大了,根本不可行。

gets(char *s),不检查缓冲区的空间,而fgets(char *s, int n, FILE *stream)可以对读入的字符数设置一个上限n。fgets对缓冲大小进行限制的方式,更为安全。

少错之过,标准参数的处理以及把lint程序错误的从编译器中分离出来。

Lint Early, Lint Often Lint is your software conscience. It tells you when you are doing bad things. Always use lint. Listen to your conscience.gcc as lint,使用-Wall:enable a bunch of warning。gcc --help=warning查询。

linux上可以使用splint。

让充满Bug的代码快速通过编译实在是不划算。----我习惯于写过代码后用眼睛看一遍,确认无误后再编译调试,看来以后可以在中间加上一步用lint检查。

大型缓冲区如果闲置不用是非常浪费空间的。

如果程序员可以在同一代码块中同时进行malloc和free操作,内存管理是最轻松的。

深刻教训:即使可以保证你的编程语言100%可靠,你仍然可能成为算法中灾难的牺牲品。----确实如此,学好算法。

第3章-- 分析C语言的声明

声明器(declarator), 就是标识符以及与它组合与它组合在一起的任何指针,函数括号,数组下标等。以下形式: 标识符

或 标识符[下标]

或 标识符(参数)

或 (声明器)

----注意括号不能乱加,就两个地方可以加括号

声明格式:类型说明符 声明器[,声明器];

类型说明符: int char void等

存储类型: extern static register auto

类型限定符: const volatile

理解C语言声明的优先级规则

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

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

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

B.2 后缀操作符:

括()表示一个函数,

[]表示这是一个数组。

B.3 前缀操作符:

*表示指向...的指针

C 如果const和(或)volatile关键字与类型说明符(如int,long等)相邻,它作用于类型说明符;其他情况下const和(或)volatile关键字作用于它左边紧邻的指针*号。

用优先级规则分析C语言声明:

char * const *(*next)();
char *(* c[10])(int **p);

如果需要频繁地对整个数组进行赋值操作,可以通过把它放入struct中。

在调用函数中,参数传递时首先尽可能地存放到寄存器中(追求速度)。

union也可以把同一个数据解释成两种不同的东西,不用强制类型转换。

typedef和宏文本替换之间存在一个关键性的区别:typedef看成是一种彻底的"封装"类型——在它声明后不能再往里面增加别的东西。首先,可以用其他类型说明符对宏类型名进行扩展,但对typedef所定义的类型名称不能这样做。typedef int banana; unsigned banana i; /*错误!非法 */;其次连续几个变量声明。

----由于typedef由编译器解释的,而宏是由预处理器解释的

typedef void (*ptr_to_func)(int);//这样来定义函数指针的别名。

不要为了方便起见对结构使用typedef,这样做唯一的好处是能使你不必书写struct关键字,但这个关键字可以向你提示一些信息。

应该始终在struct的定义中使用结构标签,即使它并非必须。这种做法可以使代码更为清晰。结构标签的名字可以取一个以"_tag"结尾的名字。

C语言中存在多种名字空间:

  • 标签名(label name)
  • 标签(tag): 这个名字空间用于所有的结构、枚举和联合
  • 成员名:每个结构或联合都有自身的名字空间
  • 其他

在同一个名字空间,任何名字必须具有唯一性。

----C中也有名字空间,没注意啊。

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

extern对象声明告诉编译器对象的类型和名字,对象的内存分配则在别处进行。

X = Y;

在这个上下文环境里,符号X的含义是X所代表的地址。这被成为左值。

在这个上下文环境里,符号Y的含义是Y所代表的地址的内容。这被称为右值。

左值在编译时可知,左值表示存储结果的地方。

右值直到运行时才知。如无特别说明,Y的值是指右值。

数组名是个左值,但不是可修改的左值。

指针是间接寻址,数组名是直接寻址,这就是两者在访问数据时的区别。指针的值是运行时从内存取得的,数名的值是编译时已经确定的。

专业的C程序员必须熟练的掌握malloc()函数,并且学会用指针操纵匿名内存。

第5章-- 对链接的思考

动态链接优点:

  • 1.可执行文件的体积小,节省磁盘空间和虚拟内存。
  • 2.所有动态链接到某个特定函数库的可执行文件在运行时共享该函数库在内存中的一个单独拷贝。

只使用动态链接。

gcc创建动态链接库和使用

创建:gcc tomato.c -fPIC -shared -o libfruit.so

使用:gcc test.c -Wl,--rpath,. -L. -lfruit

这样只要a.out和libfruit.so放在同一个目录就可以了

与位置无关的代码(position-independent code),对于共享库显得格外有用,因为每个使用共享库的进程一般都会把它映射到不同的虚拟地址(尽管共享同一份物理拷贝),只要修改一下偏移量表就可以了。

grep很有用啊!

始终将-l函数库选项放在编译命令行的最右边。

警惕Interpositoning。缺省全局作用域。

准则:不要让程序中的任何符号成为全局的,除非有意把他们作为程序的接口之一。

ldd程序print shared library dependencies。

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

编程语言理论的经典对立之一就是代码和数据的区别。

代码和数据的区别也可以是编译时和运行时的分界线。编译器的绝大部分工作都跟翻译代码有关;必要的数据存储管理的绝大部分都在运行时进行。

linux可执行文件用文件第一个字节来标注,7F开头,紧跟后面的是"ELF",代表Executable and Linking Format.

可执行文件由文本段、数据段和bss段组成,运行size a.out可查看各段大小。

bss段保存没有值的变量,事实上只是,给出了运行时所需要的bss段大小。

运行时数据结构有好几种:堆栈,过程活动记录,数据,堆等。

堆栈有3个用处:

堆栈为函数内部声明的局部变量提供存储空间。

进行函数调用时,堆栈存储与此有关的一些维护信息。

堆栈也可以被看作暂时存储区。比如计算表达式,存储中间结果。

alloca()函数分配的内存位于堆栈中,函数结束后自动销毁。

发现数据段和文本段的位置,以及位于数据段中的堆,方法是声明位于这些段的变量,并打印它们的地址。

过程活动记录:局部变量,参数,指向先前结构的指针,返回地址。

Fedora上测了下,一个只有一个int参数的函数调用,要用32个字节,参数4个,返回地址4,esp和ebp其他不知道。fame.h中是汇编,没太看懂。

编译器的设计者会尽可能地把过程活动记录的内容放到寄存器中,这样可以提高速度。

static变量保存在数据段,而不是堆栈中。

auto关键字几乎没什么用处,因为它只能用于函数内部,但是在函数内部声明的数据缺省就是这种分配。

setjmp和longjmp,在C++中变异为更普通的异常处理机制“catch”和“throw”。

对于如何在进程中支持不同的控制线程,只要简单地为每个控制线程分配不同的堆栈即可。

有用的C语言工具:

indent 代码缩进工具

默认GNU风格,使用-kr选项按K&R风格。还有各种各样选项,可以定制。

语法: indent [选项] [源文件列表]

indent [选项] [源文件] [-o 输出文件]

ldd 用来查看程式运行所需的共享库,常用来解决程式因缺少某个库文件而不能运行的一些问题。

nm 打印目标文件的符号表。

strace 工具trace system calls and signals

用法:strace [选项] command

gdb---哈哈,常用

time显示程序所使用的实际时间和CPU时间

gprof列出程序的运行时分析图。

标准的代码优化技巧包括:消除循环,函数代码就地扩展,公共子表达式消除,改进寄存器分配,省略运行时对数组边界的检查,循环不变量代码移动,操作符长度削减(把指数操作转变为乘法操作,把乘法操作转变为移位操作或加法操作)等。

第7章-- 对内存的思考

内存泄漏(leak)检查工具:

mtrace

valgrind

malloc所分配的内存通常会圆乘为下一个大于申请数的2的整数次方。

总线错误,几乎都是由于未对齐的读或写引起的。----目前linux好像不出现错误

段错误是由于MMU(内存管理单元,负责支持虚拟内存的硬件)的异常所致,而该异常通常是由于解除引用(查看指针所指地址的内容)一个未初始化或非法值的指针引起的。

Keep it Simple, Stupid !

条件操作符简洁,允许我们高高兴兴的在一行内写下代码,而无需不必要的代码膨胀。

最可能导致段错误的常见编程错误是:


  1. 坏指针的错误。free(p);后值空 p = NULL;

    1. 改写错误。如数组越界。

    1. 指针释放引起的错误。

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

    很无厘头的开始。

    类型提升:在任何表达式中,并不局限于涉及操作符和混合类型的操作数的表达式。

    char, 位段, enum, unsigned char, short, unsigned char -> int

    float -> double

    任何数组 -> 相应类型的指针。----注意

    函数的参数也是表达式,所以也会发生类型提升。不用函数原型,会先提升再自动剪裁。

    如果使用了函数原型,缺省参数提升就不会发生,与实际类型相符合。----但数组到指针的提升仍会发生

    不需要按回车键就能得到一个字符,单字符I/O----用于游戏编程,这个我就不看了

    有限自动机(FSM)可以用作程序的控制结构。它的基本思路是用一张表保存所有可能的状态,并列出进入每个状态时可能执行的所有动作,其中最后一个动作就是计算(通常在当前状态和下一次输入字符的基础上,另外再经过一次表查询)下一个应该进入的状态。你从一个“初始状态”开始。在这一过程中,翻译表可能告诉你进入了一个错误的状态,表示一个预期之外的或错误的输入。你不停地在各种状态间转换,直到到达结束状态。

    在C语言中,有好几种方法可以用来表达FSM,但他们绝大多数都是基于函数指针数组。一个函数指针数组可以像下面这样声明:

    void (*state)MAX_STATES;

    debugging hooks

    调试器调试时可以调用函数,比如gdb用call 函数名,对于复杂的数据结构可以编写一个函数,用于遍历数据结构并打印出来。----时过境迁,现在强大的GUI调试器,这个已不怎么有用了。

    可调式性编码

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

    有时候,花点时间把编程问题分解成几个部分往往是解决它的最快办法。----确实得花时间,动脑筋来分解。

    作者描写其同事,写散列表就是个例子啊。最初,使散列函数返回0,这样所有元素都存储于第0个位置后面的链表中。----这使得程序很容易调试

    复杂类型转换,先写一个对象的声明,然后删去标识符,最后放在左面,如int (*compar)(int *)。

    不加类型说明符,声明变量默认是int;函数默认返回值是int, 一般放在eax(第一个寄存器)中。int几乎是C语言所有的默认方式。应该也是C最善于处理的数据类型。

    qsort函数原型:void qsort(void *base, size_t count, size_t size, int (*compar)(const void* element1, const void *element2));

    compar函数参数可以定义为(const void *)类型,这需要在compar函数内部cast为所处理类型;也可以直接定义为所处理类型的指针,在调用qsort函数时需要将compar函数cast为(int (*)(const void *, constvoid *),一开始我以为这样不正确(因为qsort函数内部还是会调用compar的,这样类型就不匹配了啊),其实是正确的,因为这种类型检查是编译时做的(gcc 使用-c选项),链接时不做类型检查,只要能找到那个函数名就行,运行时取参数更不管这些东西了,是用ebp+offset直接抓来的。

    第9章-- 再论数组

    数组的声明就是数组,指针的声明就是指针,两者不能混淆。声明与定义必须对应。

    对于编译器而言,一个数组就是一个地址,一个指针就是一个地址的地址。----左值

    什么时候数组和指针是相同的?

    C语言标准对此作了如下说明:

    • 规则1. 表达式中的数组名(与声明不同)被编译器当作一个指向该数组第一个元素的指针。
    • 规则2. 下标(subscript)总是与指针的偏移(an offset from a pointer相同
    • 规则3. 在函数参数的声明中,数组名被编译器当作指向该数组的第一个元素的指针----这里数组是指一维数组

    指针有类型限制,是因为编译器需要知道对指针进行解除引用时应该取几个字节,以及每个下标的步长。

    sizeof(数组名)结果是数组所占字节数(真正的数组,不是函数形参),由此可见是可以数组名包含了长度信息,并可以通过sizeof取得,所以C中检查数组是否越界访问是能够做到的,但是很容易用指针避开,就像用指针可以修改const一样。我觉得编译器可以打开一个选项,是否检查数组越界访问。

    把作为形参的数组和指针等同起来是出于效率原因的考虑。在C语言中,所有非数组形式数据实参均以传值形式。如果要copy整个数组,无论在时间上还是内存空间上的开销都可能是非常大的。

    int apricot[2][3][5]; // apricot 两个[3][5]的数组,2*3个[5]的数组,2*3*5个int

    int (*p)[3][5] = apricot; // 步长 3 * 5

    int (*r)[5] = apricot[0]; // 步长 5

    int *t = apricot[0][0]; // 步长 1

    int u = apricot[0][0][0];

    指向数组第一个元素的指针与数组名等同。

    内存中数组的布局

    C语言中,最右边的下标最先变化,这个约定被称为"行主序"。

    只有字符串常量才可以初始化指针数组,因为可执行文件中字符串常量是作为数据存储。而161这样的字面常量只出现在代码中。

    数组与指针可交换性的总结:


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

    1. 指针始终就是指针。它绝不可以改写成数组。只是可以使用下标形式访问指针。

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

    第10章-- 再论指针

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

    “数组名被改写成一个指针参数”规则并不是递归定义的。数组的数组会被改写成“数组的指针”,而不是“指针的指针”。

    数组的数组 char c[8][10]; char (*c)[10]; 数组的指针

    指针数组 char *c[15]; char**c; 指针的指针

    指针的指针 char **c; char **c; 不改变----指针与指针不用修改

    数组的指针 char (*c)[64]; char (*c)[64]; 不改变----注意,指向一个长度为64的char数组的数组名的指针,访问数组中元素这样做:(*c)[0]。

    int a[20];

    int **p = &a; // 错误,指针的指针与数组的指针不兼容

    int (*t)[20] = &a; // 正确,t为由20个int的数组的指针。

    ----此处括号是必须的,因为[]的优先级比*高

    Iliffe向量,创建一个一维数组,数组中的元素是指向其他东西的指针。

    例如main(int argc, char *argv[]),第二个参数会被改写成char **。(注意,只有把二维数组改为一个指向向量的指针数组的前提下才可以这么做!)

    在C语言中,传递多维数组必须提供除最左面一维以外的所有维的长度。

    可以放弃多维数组的形式,提供自己的下标方式,如char_array[row_size*i + j] = ...

    模拟动态数组,当表满后,用realloc()对数组重新分配内存,并确保realloc操作成功。

    重分配操作很可能把原先的整个内存块移到一个不同的位置,这样表格中元素的地址便不再有效。为了避免麻烦,应该使用下标而不是元素的地址。----这也是STL中引入迭代器的一个原因吧

    “增加”和“删除”操作都必须通过函数来进行,这样才能维持表的完整性。

    第11章-- 你懂得C,所以C++不在话下

    类内部定义的函数是inline函数

    重载是编译时解析的。

    多态——运行时绑定。latebinding

    new和delete操作符,用于取代malloc()和free()函数,能够自动完成sizeof的计算工作,并会自动调用合适的构造函数和析构函数。new能真正的创建一个对象,malloc()函数只是分配内存。

    C++的设计受限于严格的兼容性、内部一致性和高效率。

    复用是软件科学的一个崇高而又朦胧的目标。----很多时候不如另起炉灶从头开始

    管理和市场状况是导致许多公司破产的原因,比单纯的技术失败更为常见。那些不时刻注意顾客需求的公司终究难以为继,最能掌握这项艺术的公司往往能获得成功。

    附录A-- 程序员工作面试的秘密

    面试的关键在于正确理解问题!你需要仔细地听,如果不理解问题或者觉得它的定义不清,可以要求一个更好的解释。

    提供一种寻找可靠答案的好方法。

    链表环的检测。

    mango[i++] += y; // i++仅执行一次

    优秀的程序员将会休息的更好,精力更加充沛,而蹩脚的程序员则很可能困得脑袋常常和桌子打架。

    人类的最高目标是奋斗、寻求、创造。

    往期精彩

    学习嵌入式可以带娃,不信你们看

    第10期 | ringbuff,通用FIFO环形缓冲区实现库

    代码写得很牛逼但UI界面却搞得很丑?来,杨工带你!

    分享一个近期开源火爆全网的额温枪方案(硬件+源码)

    火爆全网开源额温枪同平台之华大HC32L136 SDK开发入门

    觉得本次分享的文章对您有帮助,随手点[在看]并转发分享,也是对我的支持。

      您可能也对以下帖子感兴趣

      文章有问题?点此查看未经处理的缓存