读书摘要—C专家编程

第一章    穿越时空的迷雾

1.2    C语言的早期体验

    由于设计哲学的不同,C语言排斥强系统系统,它允许程序员需要时可以在不同类型的对象间赋值。

     除了类型系统之外,C语言的许多其它特性时为了方便编译器设计者而建立的——毕竟开始几年C语言的主要用户就是那些编译器设计者。

    基于编译器设计者的思路而发展形成的语言特性包括:

    1).数组下标从0而不是1开始:编译器设计者选择从0开始计数,因为偏移量的概念在他们心中已是根深蒂固。

    2).C语言的基本数据类型直接与底层硬件相对应。

    3).auto关键字明显是摆设。

    4).表达式中的数组名可以看作是指针。

    5).不允许函数的嵌套定义,这简化了编译器的工作。

    6).register关键字用于给编译器设计者提供线索,程序中的那些变量属于热门变量。使用register关键字,简化了编译器的工作,却把包袱对给了程序员。这个设计可以说是一个失误。

1.6    ANSI C标准中的术语

A.不可移植的代码(unportable code):
   
       由编译器定义的(implementation-defined):由编译器设计者决定采取何种行动,并提供相关文档——尽管由不同的编译器所采取的行为可能是不同的,但他们都是正确的。
       例如:当整形数向右移位时,要不要扩展符号位。
   
      未确定的(unspecified):对于某些正确的语法,标准并未明确规定应该怎么作。
       例如:参数计算的顺序。

B.坏代码(bad code):

       未定义的(undifined):在错误出现时,标准并未明确规定应该怎么作,编译器可以采取任何行动,包括无行动。
       例如:一个带符号整数溢出时会采取什么行动。

     约束(constraint):标准给出的必须遵守的要求。如果不遵守的话,就属于未定义的。

     有意思的事情:标准只明确要求编译器必须对违法约束的代码给出错误和警告信息。这意味着所有不属于约束条件的文法规则你都可以不遵守,而且由于这输入未定义行为,编译器可以在实现中作任何事情。


C.可移植的代码(portable code)

     严格遵守标准的(strict-conforming):

         只使用已确定的特性。
         不突破任何由编译器实现的限制。
         不含任何由编译器定义的、未确定的、未定义的代码。

    遵循标准的(conforming):一个遵循标准的程序可以以来一些某种编译器特有的不可移植的特性。


1.9    仔细阅读ANSI C的收益

     下面这段代码在使用ANSI C 编译器编译时会无法通过。

     1    foo ( const char ** p) { }
     2
     3   main(int argc,    char **argv)
     4  {
     5            foo(argv);
     6   }

     编译器会发出错误信息,提示函数参数与函数原型不匹配。

    疑惑:既然实参char * 与形参 const char * 是相容的(标准库中所有的字符串处理函数都印证了这一点),为什么实参char ** 与形参 const char **就不相容呢?

     回答这个问题需要仔细理解ANSI C中给出的约束条件.ANSI C 标准6.3.2.2节中讲述描述条件的小节中有这么一句话:

    “ 每个实参应该有自己的类型,这样它的值就可以赋值给与它所对应的形参类型的对象(该对象的类型不能含有限定符)"
 
   也就是说 参数传递过程类似于赋值。因此我们需要理解什么样的赋值是合法的。ANSI C标准的6.3.16.1节,描述了下列约束条件:

   ” (两个指针之间的赋值须满足以下条件)
    两个操作数都是指向有限定符或无限定符的相容类型的指针,并且左边指针所指向的类型必须具有右边指针所指向类型的全部限定符。“

   按照标准的表述,标准库中字符串函数调用中实参 char * 能够与形参 const char *相容(匹配),因为在例如下卖弄的代码中:
   char * cp;
   const char * cpp;
   cpp=cp;

  左操作数是一个指向有const限定符的char的指针。
  右操作数是一个指向没有限定符的char的指针。
  char类型和char类型是相容的,且左操作数指向的类型具有右操作数所指向类型的限定符(无),在加上自身的限定符(const)。

  然而,上述赋值等式反过来就不可以。
   
  类戏的,char **是一个没有限定符的指针类型,它的类型是”指向无限定符的char类型的指针的指针“。
   const char **也是一个没有限定符的指针类型,它的类型是"指向有const限定符的char类型的指针的指针"。
 
   由于char **的 const char **所指向的类型不一样(前者指向char *,后者指向 const char *),因此它们是不相容的,所以类型未char **的实参与类型未const char** 的形参是不相容的。

   简单的总结以上内容: 指针的相容性是不能传递的
 

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


    分析语言缺陷的一种常见方法就是把所有的缺陷归为三类:不该做的做了;该做的没做;该做的做了但做的不好。

2.2  多做之过

     多做之过:语言中存在某些不应该存在的特性,包括容易出错的switch语句、相邻字符串常量的自动连接和名字缺省的全局范围可见。

2.2.1 由于存在Fall Through,switch语句会带来麻烦

    在C语言中,几乎从来不进行运行时错误检查。 运行时检查与C语言的设计理念相违背。按照C语言的理念,程序员应该知道自己正在干什么,而且保证自己的所作所为是正确的。

   我们应该已经明白,C语言中的const关键词并不意味着其声明的对象为常量( 从语义上将,const换成readonly更为合适),所以在case后 使用一个const类型的整形数是错误的,会产生编译错误。

   switch语句最大的缺点是它不会在每个case标签后的语句执行完毕后自动跳出switch的作用范围——除非遇到break语句,程序将依次执行后面的所有语句(无论后面的case是否成立)。这一缺陷就是Fall Through。

2.2.2相邻的字符串常量自动合并

   ANSI C的一个特性就是相邻的字符串常量将被自动合并为一个字符串,这就省掉了过去在书写多行字符信息是必须在行尾加”/"的做法。然而,这种自动合并意味着字 符串数组在初始化时,如果在初始化表中不小心漏掉一个逗号,编译器不会发出任何错误信息,而是悄无声息的将两个字符串合并在一起——你看,初始化出错了, 这是多名严重的后果!


2.2.3 太多的符号缺省可见性

    定义C函数时,在缺省情况下函数的名字时全局可见的。如果想限制对这个函数的访问,必须加上static关键字。事实上,几乎所有人都没有在函数名前添加存储类型说明符的习惯,所以绝大部分函数都是全局可见的。
 
   根据实际经验, 这种缺省的全局可见性多次被证明是个错误。软件对象在大多数情况下应该缺省的采用有限可见性。当程序员需要让它全局可见是,应该采用显式的手段。

   这种太大范围的全局可见性会与C语言的另一个特性 Interpositioning相互产生影响。Interpositioning就是用户编写与库函数同名的函数并取而代之的行为。

  一个符号要么全局可见,要么对于其它文件不可见。在C语言中,对符号可见性的选择就是这么有限。

  由于C语言不支持Pascal中在函数中嵌套定义函数的特性,使得这个问题变得更加糟糕。


2.3误做之过

   有些与C语言的简洁(关键字的过度复用)有关,有些则与操作符的优先级有关。

2.3.1 骆驼背上的重载

    C语言的一个问题是它太简洁了,这是建立在许多符号被“重载"的基础上的。

    你让一个符号表达的意思越多,编译器就越难检测到这个符号在你的使用中所存在的异常情况。

2.3.2 C语言中有些运算符的优先级设定是错误的

    记住,在优先级和结合性规则告诉你那些符号组成一个意群的同时,这些意群内部如何进行计算的次序始终是未定义的。

    在表达式 ” x=f() + g() * h() ;“中:
    g() 和h()的返回值先组成一个意群,执行乘法运算。但g()和h()的调用可能以任何顺序出现——g()的调用未必先于h()。类似的,f()可能在乘法运算 之前也可能在乘法运算之后调用,也可能在g()和h()之间调用。唯一能确定的就是乘法会在加法之前进行。
   如果编写的程序依赖于这些意群计算的先后次序,那就是不好的编程风格。大部分编程语言并未明确规定操作数计算的顺序,之所以未定义,是想让编译器充分利用自身架构的特点,或者充分利用存储于寄存器中的值。
 
   运算符的结合性扮演仲裁者的角色,在几个操作符具有相同的优先级时决定先执行哪一个。

2.3.3 早期gets()中的漏洞导致了internet蠕虫(缓冲区溢出)
   

2.4 少做之过

     lint程序绝不应该被分离出来

     把lint程序从编译器中分离出来作为一个独立的程序是一个严重的失误,人们知道现在才意识到整个问题。确实,在将lint程序分离出来后,编译器变得更 小,目标也更专一。但是,它所付出的巨大代价是:代码中悄悄掺进了大量的Bug和不可靠的编码风格。现在,许多lint程序中的检查措施又重新出现在编译 器中。
  

第三章    分析C语言中的声明

    C 语言的声明所存在的最大问题是,你无法以一种人们所习惯的自然方式从左至右阅读一个声明。在ANSI C引入volatile和const关键字后,情况就更糟糕了,由于这些关键字只能出现在声明而不是使用中,这就使得如今声明形式和使用形式对的上号的例 子越来越少。

3.2 声明是如何形成的。

   声明器(declarator)是所有声明的核心。简单的说,声明器就是标识符以及与它组合在一起的所有指针、函数标号、数组下标等。

3.3 优先级规则

    C语言声明中的优先级规则

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

    B  优先级由高到低依次是:
        B.1  声明中被括号括起来的那部分。
        B.2  后缀操作符
            圆括号( ) 表示这是一个函数;
            方括号[  ]表示这是一个数组。
        B.3 前缀操作符: 星号*表示 ”指向 ....的指针“。

    C   如果const和(或)volatile关键字的后面紧跟类型说明符,则它作用于类型说明符,在其它情况下,它们作用于它们左边近邻的指针星号。

     根据上述方法分析下面的声明

     char * const * (*next ) () ;

     这个声明表示:”next是一个指针,它指向一个函数,该函数的返回值也是一个指针,该指针指向一个类型未char的常量指针。“

3.5 typedef 与宏定义的区别
  
    正确思考这个问题的方法就是 将typedef看成是一种彻底的"封装"类型——在声明之后不能再往里面增加别的东西。

    typedef与宏定义的区别体现在两个方面:

    首先,可以用其它类型说明符(如unsigned,const)对宏进行扩展,而对typedef所"定义"的类型名却不能这样做。

    #define peach int
    unsigned peach i;       //ok

    typedef  int banana;
    unsigned banana j; //error

   其次,在连续几个变量的声明中,typedef能保证声明中所有的变量均为同一类型,而用#define则无法保证。

3.7 令人疑惑的部分

    C语言存在多种 名字空间

        A.标签名(label name):用于goto语句

        B.标签(tag):这个名字空间用于所有的结构、枚举和联合

        C.成员名:每个结构或联合都有其自身独立的名字空间

        D.其它。

    同一个名字空间里,任何名字必须具有唯一性,但在不同的名字空间里可以存在相同的名字。由于在不同的名字空间里使用相同的名字是合法的,因此有时会看到这样的声明:
       strucg foo { int foo; } foo;

    适合使用 typedef的场合:

    1.数组、结构、指针以及函数的组合类型。
    2.可移植类型
    3.强制类型转换的简写



第四章    数组和指针并不相同


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

    定义是一个特殊的声明,它创建了一个对象并分配内存;
    声明简单的说明了在其它地方创建的对象的名字,它允许你使用整个名字。

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

     首先要注意"地址y"和"地址y的内容"之间的区别。这是一个相当微妙之处,因为在大多数编程语言种我们用同一个符号来表示这两个东西,由编译器根据上下文语境来判断其具体含义。

    以一个简单的赋值为例: X=Y;

    符号X的含义是变量X对应的地址,被称为左值;
    符号Y的含义是变量Y对应地址的内容,被称为右值。

    左值在编译的时候即可确定,表示存储结果的位置;
    右值在运行时才确定,表示存储的内容。
  
    C语言引入了"可修改的左值”整个术语。它表示左值允许出现在赋值语句的左边。这个奇怪的术语是为了与数组名区分。数组名也用于确定变量在内存中的位置,也是左值,但它不能作为赋值的对象。

    编译器为每个变量分配一个地址(左值),这个地址在编译时确定,而且在变量的生命期内一直保存不变;相反,变量的值(右值)只有在运行时才知道,而且是可变的。如果需要用到变量中存储的值,编译器会生成指令从指定地址读入变量值并将它存入寄存器。
  
     这里的关键在于每个符号的地址在编译时即确定。因此,如果编译器需要一个地址(可能还需要加上偏移量)来执行某种操作,它就可以直接进行内存操作,并不需 要先执行获取地址的指令。相反,对于指针,必须在运行时先获取指针的当前值(访问内存),然后才能从这个值指向的地址中获取需要的值(访问内存)。

    简单的说, 使用指针比使用数组名要多一次对内存的访问

4.3.2 当定义和声明不一致会产生什么后果?
  
   若定义为指针(如char * p),但以数组的形式(如p[i])来使用,则编译器在生成指令时会先取得指针的内容(地址),再加上偏移量,按这个地址获取值。
   若定义为数组,而在使用时声明为指针,则会出现大问题:编译器会首先按照间接引用的规则获取指针(数组名)的内容,然后以这个值作为基准进行下一步的内存访问。试想以下:一个原本表示计数值的32位整数被解释为地址,这会是什么后果?

4.4 数组和指针的其它区别

    数组和指针都可以在定义时用字符串常量进行初始化,尽管看上去一样,但底层的机制却不相同。

    用 字符串常量 定 义指针时,编译器并不负责为指针指向的对象分配空间(而且该对象之前应该已经被分配空间才对),它只分配指针本身的存储空间——除非在定义时用一个字符串 常量对指针进行初始化。在ANSI C中,初始化指针时所创建的字符串常量被定义为只读,如果试图使用指针来修改整个字符串的内容,程序会出现未定义的行为。在某些编译器中,字符串常量被存 放在只允许读取的文本段中,防治被修改。

   用字符串常量定义 数组时,与指针的情况不同,由字符串常量初始化的数组时可以修改的。
   
 
第五章    对链接的思考

5.2    动态链接的优点


    动态链接的目的之一是ABI(Application Binary Interface)。动态链接的主要目的就是把程序和它们使用的特定函数库版本分离开来。取而代之的是,我们约定由系统向程序提供一个接口,该接口保持稳定。

    缺省情况下,编译器并不生成位置无关代码,因为这样会导致代码运行速度的下降。然而,如果不使用与位置无关的代码,所产生的代码就会被对 应到固定地址。这对于可执行文件来说确实很好,但对于共享库就是个问题,因为现在每个全局引用必须在运行时进行重定位,这使得共享库的代码页面无法在进程之间被共享。对于共享库,位置无关代码显得格外有用,因为每个使用共享库的进程一 般都会把它映射到不同的虚拟地址。

    与动态库相比,静态库中的符号提取的方法限制更严。 对于静态连接,在处理一个archive时,只在该archive中查找链接器当前所知道的所有未定义符号。简而言之,在编译器命令行中各个静态链接库出 现的顺序时非常重要的。 因此,如果命令行中在自己代码对应的目标文件之前引入静态库,则出现这样的问题:因为此时尚未出现未定义的符号,因此不会从静态库中提取任何符号;接着,当 目标文件被链接器处理时,它所有的函数库中符号的引用都将是未定义的!

    例如对于使用了math库的程序main.c

    cc -lm main.c     // 提示有未定义的符号
    cc main.c -lm     // OK  


第六章    运行时数据结构

    编程语言理论的经典对立之一就是代码和数据的区别。有些语言如LISP将二者视为一体,而其他语言(如C)通常维持两者之间的差异。代码和数据的区别也可以认为时编译期和运行期的分界线。编译器的大部分工作都跟翻译代码有关,而 绝对部分 必要的数据存储管理工作都在运行时进行。

     学习运行时系统的三个理由:

    A. 有助于优化代码,提高效率。
    B. 更高的理解深层次的问题。
    C. 出现问题时,使分析闻听更容易。

6.2 段

    BSS段用于存放未初始化的全局或静态变量,换句话说BSS段只保存没有(确定)值的变量,因此事实上并不需要在可执行文件中保存这些变量的影响。运行时所需要的BSS的段大小被记录在可执行文件中,但不同与其它段,BSS段并不占据可执行文件的任何空间。

     堆栈的三个主要用途:

    A    堆栈为函数内部声明的局部变量提供存储空间。用C语言的术语,这些变量叫做自动变量。
    B   执行函数调用使,堆栈中存储与之相关的维护性信息,被称为堆栈结构或过程活动记录,通常包括返回地址、任何不适合装入寄存器的参数以及一些寄存器的副本。
    C   堆栈用于暂时存储区,保存中间计算结果。

     事实上,C语言中的运行时函数非常少,且短小精悍。相反的例子是C++或Ada。如果C程序需要动态存储分配之类的服务,必须进行显示请求( malloc)。这使得C称为一种非常高效的语言,但也向程序员施加了一个额外的负担。

     C语言不允许函数的嵌套定义,所有函数在词法角度都是处于最高层。

6.8 setjmp 和longjmp

    这二者协同工作,部分弥补了C语言有限的转移能力。

    setjmp( jmp_buf j) 必须首先被调用,它表示"使用变量j记录现在的位置(状态)“,函数的返回值为0。
    longjmp(jmp_buf j ,int i)在其后被调用,它表示"回到j所记录的位置(状态),使得程序看上去是刚从原先的setjmp()函数返回一样;但是区别在于此时setjmp()的 返回值为i,以表明实际上是通过在别处调用longjmp()转移到此处的。

    longjmp()会导致转移,但与goto又有所不同,区别如下:

    1.goto不能跳出当前函数,而longjmp()可以跳的很远,甚至可以跳到其它文件的函数中。
    2.longjmp()只能跳回到代码层执行到的位置。

    setjmp/longjmp的最大用途是错误恢复。

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

    和goto一样,setjmp/longjmp使得程序难以理解和调试。
 
第八章    为什么程序员无法分清楚万圣节和圣诞节

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

    在K&R C中存在着自动类型提升的概念,即在表达式计算和函数参数的传递过程中,char和short被自动提升为int,而float则被自动提升为double。

    ANSI C延续了 K&R C中自动类型提升的概念,尽管在许多地方它已经褪色——例如,ANSI C允许在保证计算结果一致的前提下,省略类型提升的操作;又如在函数原型已知的情况下,参数传递的过程中不会发生自动类型提升。

    对于自动类型提升这样的隐式类型转换,有三个重要的方面需要注意:

    1).隐式类型转换是语言中的一种临时手段,起源于简化编译器实现的想法。
    2).即使对隐式类型转换一无所知,也不影响使用C语言进行大量的编程工作。
    3).在理解隐式类型转换之前,不能称自己为专家级C程序员。隐式类型转换在涉及到函数原型的上下文中显得非常重要。

8.4    原型之痛

    在K&R C中,如果向函数传递一个char,函数实际接受到的是int;如果传递一个float,函数实际接收到的是double。在被调用的函数内部,函数会根据函数定义中的参数类型对收到的参数进行合适的剪裁。
    为什么要先不嫌麻烦的在传递过程中将参数提升到更大的类型,而在函数内部又再将其剪裁为原有大小呢?这样的目的是简化编译器的实现,因为所有的值都具有统一尺寸(int或是double),这将大大降低编译器实现的复杂度。

    而在ANSI C中,如果使用了函数原型,则自动提升和剪裁都不会出现。


8.5    K&R C 和ANSI C的交叉使用


    概括的说,原型决定了参数传递过程中的提升是否出现,而函数定义决定了函数体是否进行参数剪裁。



第九章    再论数组


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

    数组的声明本身可以进一步分成三种情况:

    A.外部数组的声明
    B.数组的定义
    C.函数参数中的数组声明

    所有作为函数参数的数组名总是可以通过编译器转换为指针。在其它所有情况下,数组的声明就是声明,指针的声明就是指针,两者不能混淆。

9.2 数组与指针的使用规则

    规则1 "表达式“中的数组名就是指针

    int a[10], *p, i=2;
    对数组元素的引用如a[i]在编译时总是被编译器改写称*(a+i)的形式。C语言标准要求编译器必须具备这个概念性的行为。也许遵循这个规则的捷径就是 记住方括号[ ]表示一个取下标操作符,该操作符的操作数为一个整数和一个指向类型T的指针,运算结果的类型是T。

    总之,在表达式中,指针和数组是可以互换的,因为它们在编译后的最终形式都是指针。

    另外,就像加法一样,取下标操作符的操作数是可以互换先后顺序的的,因此a[6]和6[a]都是合法的且表达的意思相同。

    编译器自动将下标值的步长调整值数组元素的大小。

    规则2 C语言将数组下标作为指针的偏移量

    规则3 作为函数参数的数组名等同于指针


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

    1.用a[ i ]这样的形式对数组元素进行访问总是被编译器转换为 *(a+i)这样的指针访问。
    2. 指针使用就是指针,绝不可以改写成数组。在你可以使用下标形式访问指针是,一般都是指针作为函数参数,而且确定实际传递给函数的是一个数组。
    3. 函数声明中的数组形参可以看作是一个指针。
    4 .因此,当把一个数组定义为函数的参数时,可以选择在形参中定义为数组,也可以定义为指针。不管选择哪种方法,在函数内部获得的都是一个指针。
    5.在其它情况中,定义和声明必须匹配。

9.8 C语言的多维数组

    在不同的程序语言中,多维数组和数组的数组二者的关系是不同的:

        Ada中,数组的数组与多维数组是不一样的;
        Pascal中,数组的数组和多维数组是一样的;
        在C中,定义和引用多维数组的唯一方法就是使用数组的数组。

    尽管术语上称作”多维数组“,但C语言实际上只支持"数组的数组".
    C语言中多维数组的最大用途是存储多个字符串。


第十章    再论指针


   "作为函数参数的数组名被改写成一个指针"这个规则并不具有递归性。数组的数组被改写为"数组的指针",而不是"指针的指针"

   实参                                                        所匹配的形参

   数组的数组    char c[8][10];                 char(*x)[10]       数组指针
   指针数组       char * c[15];                  char **    x        指针的指针
   数组指针       char (*c)[64];                 char (*x)[64]     不改变   
   指针的指针    char ** c;                       char ** x;         不改变

10.7 C语言中的动态数组

   很遗憾,在ANSI C中数组是静态的——数组的长度在编译时就已确定,甚至不能用const类型的整形来声明数组,而这在C++中是可以的。

附录A.程序员工作面试的秘密
  
不要在一行代码中实现太多的功能

    这种做法并不能使编译器产生的代码更有效率,而且还会为调试代码增加困难。正如Kernighan和Plauger所指出的那样:"人人都知道Debug比第一次编写代码要难上一倍。所以如果在编写代码时把自己的聪明发挥到极致,那么在调试时又该怎么办呢?"

文件描述符和文件指针的区别?

  前者属于操作系统的PCB(进程控制块)中的索引下标。
  后者是ANSI C库函数使用的数据结构,用于表示文件。

如何确定一个变量的类型是有符号还是无符号的?

    注意避免理解上的误区:不是让你判断给定变量是否为负值。

     无法通过函数来实现目的——因为ANSI C要求函数声明中必须给出形参类型,所以无法穿越函数调用这一关(实参会根据给定的形参进行类型转化,会导致是否有符号的信息遭到改变)。因此, 只有编写一个宏来实现给定的要求

   无符号数的本质特征是它永远不会取负值——无论你对它进行如何的位操作。而有符号数在对最高位取反后会改变其正负性。
   因此,可以用下面的宏实现给定要求
   #define ISUNSIGNED(a) (a >= 0 && ~a >= 0)

   若给定的参数为类型,则可以使用下面的宏
   #define ISUNSIGNED(type) ((type)0 - 1 > 0)

你可能感兴趣的:(读书摘要—C专家编程)