• C陷阱与缺陷 第8章 建议与答案 8.2 答案


    练习0-1 你是否原因购买厂家所生产的一辆返修率很高的汽车?如果厂家声明已经对他进行了改进,你的态度是否会改变?用户为你找出程序中的bug,你真正损失的是什么?
    解析:
    我们之所以选择一种产品而不选择另一种产品,其中重要的考虑因素就是厂商的信誉。如果信誉一旦失去,就很难获得。我们需要认真思考一个问题,即企业最近产品的高质量是真实的,还是纯属偶然。
    大多数人在已经知道一个产品有可能存在重大设计缺陷时,不会去购买该产品---除非这是一个软件产品。很多人写过一些给其他人用的程序。人们对软件产品不能工作的情况习以为常,见怪不怪。我们应该用产品的高质量来让这些人大吃一惊。
    练习0-2 修改一个100英尺(约30.5米)长的护栏,护栏的栏杆之间相距10英尺(约3.05)米,需要用到多少根栏杆?
    解析:
    11根。围栏一共分成10段,但需要11根栏杆。3.6节讨论了这个问题与一类常见的程序设计错误的关系。
    练习0-3 在烹饪时你是否失手用菜刀切伤自己的手?怎样改进菜刀会让使用更安全?你是否愿意使用这样一把经过改良的菜刀?
    解析:
    我们很容易想到办法来让一个工具更安全,但代价是原来简单的工具现在要变得复杂一些。视频加工机一般有连锁装置,保护使用人员不让手指受伤。但是菜刀不同,给这样一个简单、灵活的工具附加保护手指免于受伤的装置,只能让它失去简单灵活的特点。实际上,这样做最后得到的也许更像一台食品加工机,而不是一把菜刀。
    使其难于做“傻事”常常会使其难于做“聪明事”,正所谓“弄巧成拙”。

    练习1-1 某些C编译器允许嵌套注释。请写一个测试程序,要求无论是对允许嵌套注释的编译器,还是不允许嵌套注释的编译器,该程序都能正常通过编译(无错误信息出现),但是这两种情况下程序执行的结果不相同。
    提示:在用双引号括起的字符串中,注释符/*属于字符串的一部分,而在注释中出现的双引号" "又属于注释的一部分。
    */解析:
    为了判断编译器是否允许嵌套注释,必须找到这样一组符号序列,使得无论是对于允许嵌套注释的编译器,还是不允许嵌套注释的编译器,它都是合法的。但是,对于两类不同的编译器,它却意味着不同的事物。这样一组符号序列不可避免地要涉及嵌套注释,让我们从这里开始讨论:
    /*/**/
    对于一个允许嵌套注释的C编译器,无论上面的符号序列跟什么,都属于注释的一部分;而对于不允许嵌套注释的C编译器,后面跟的就是实实在在的代码内容。也许有人因此想到,可以在后面跟一个用一对引号引起的注释结束符:
    /*/**/"*/"
    如果允许嵌套注释,上面的符号就等效于一个引号;如果不允许,就等效于一个字符串"*/"。因此,我们可以接着在后面跟一个注释开始符以及一个引号:
    /*/**/"*/"/*"
    如果允许嵌套注释,上面就就等效于用一对引号引起的注释开始符"/*";如果不允许,就等效于一个用引号括起的注释结束符,后跟一段未结束的注释。我们可以简单地让最后的注释结束:(*/只是为了不影响下面代码的测试) 
    /*/**/"*/"/*"/**/
    这样,如果允许嵌套注释,上面的表达式就等效于"/*";如果不允许,就等效于"*/" 
    在我用类似于上面的形式解决这个问题之后,Doug Mcllroy发现了下面这个让人拍案叫绝的解法:
    /*/*/0*/**/1
    这个解法主要利用了编译器进行词法分析时使用的“大嘴法”规则。如果编译器允许嵌套注释,则上式将被解释为:
    /* /* /0 */ * */ 1
    两个/*符号与两个符号正好匹配,所以上式的值就是1。如果不允许嵌套注释,注释中的/*将被忽略。因此,即使是/出现在注释中,也没有特殊的函数;上面的表达式因此将被这样解释:
    /* / */ 0* /**/ 1
    它的值就是0*1,也就是0。 
    练习1-2 如果由你来实现一个C编译器,你是否允许嵌套注释?如果你使用的C编译器允许嵌套注释,你会使用编译器的这一特性吗?你对第二个问题的回答是否会影响到你对第1个问题的回答?
    解析:
    嵌套注释对于暂时移除一块代码很有用:在这块代码之前加上一个注释开始符,在代码之后加上一个注释结束符,就一切OK了。然后,这样做也有缺点:如果用注释的方式从程序中移除一大块代码,很容易让人注意不到代码已经被移除了。
    但是,C语言定义并不允许嵌套注释,因此一个完全遵守C语言标准的编译器就别无选择了。而且,一个编程人员如果依赖嵌套注释,那么他所得到的程序在很多编译器上将无法通过。这样,任何嵌套注释的使用,都不可避免地只能限制在那些不准备以源代码形式分发的程序之中。此外,在新的C语言实现上,或者当原来的C语言有了改动时,这样的程序还将有不能运行的风险。 
    出于这些原因,如果让我来编写一个C编译器,我将不会选择实现嵌套注释;而且,即使我所用的编译器允许嵌套注释,我也不会在程序中用到这一特性。当然,最终的决定还是由读者自己做出。
    练习1-3 为什么n-->0的含义是n-- > 0,而不是n- -> 0?
    解析: 
    根据“大嘴法”规则,在编译器读入>之前,就已经将--作为单个符号了。 
    练习1-4 a+++++b的含义是什么? 
    上式唯一有意义的解析方式是:
    a++ + ++b
    可是,我们也注意到,根据“大嘴法”规则,上式应该被分解为:
    a++ ++ +b
    这个式子从语法上来说是不正确的,它等价于:
    ((a++)++)+b
    但是,a++的结果不能作为左值,因此编译器不会接受a++作为后面的++运算符的操作数。这样,如果我们遵循了解析词法二义性问题的规则,上例的解析从语法上来说又没有意义。当然,在编程实践中,谨慎的做法就是尽量避免使用类似的结构,除非编程人员非常清楚这些结构的含义。

    练习2-1 C语言允许初始化列表中出现多余的逗号,例如: 
    int days[] = { 31, 28, 31, 30, 31, 30,
                   31, 31, 30, 31, 30, 31,}; 
    为什么这种特性是有用的? 
    解析: 
    int days[] = 
    {
        31, 28, 31, 30, 31, 30,
        31, 31, 30, 31, 30, 31,
    }
    现在我们可以很容易看出,初始化列表都是以逗号结尾的。正因为每一行语法上的这种相似性,自动化的程序设计工具才能够更方便地处理很大的初始化列表。 
    练习2-2 2.3节指出了在C语言中以分号作为语句结束的标志所带来的的一些问题。虽然我们现在考虑改变C语言的这个规定已经太迟了,但是设想一下是否还有其他办法来分隔语句却是一件饶有趣味的事情。其他语言是如何分隔语句呢?这些方法是否也存在它们固有的缺陷呢?
    解析: 
    一个代码行的含义要受到其后续代码行的影响,这一点多少显得有些“怪异”。因此,某些程序语言改为在第n行中使用某种指示标志,以表示第n+1行代码应该被当做同一个语句的一部分。例如,UNIX系统的Shell(如bash、ksh、csh等)在代码行的结尾使用字符\来作为指示标志,表示下一个代码行是同一个语句的一部分。C语言在预处理器中以及字符串内部,沿用了UNIX系统中的这一惯例。 

    练习3-1 假定对于下标越界的数组元素,取其地址是非法的,那么3.6节中的bufwrite程序应该如何写呢? 
    bufwrite程序实际上隐含了这样一个假定:即使在缓冲区完全填满时,bufwrite函数也仍然可以返回,并留待下一次bufwrite函数被调用时再刷新。如果指针变量bufptr不能指向缓冲区以外的位置,这个问题突然变得棘手起来:我们应该如何指示缓冲区已满这种情形呢?
    解析: 
    最不麻烦的情况似乎是,避免在缓冲区已满时从bufwrite函数中返回。要做到这一点,我们需要把最后一个进入缓冲区的字符作为特例处理。
    除非我们已经知道指针p指向的并不是某个数组的最后一个元素,否则,我们必须避免对p进行递增操作。也就是说,在最后一个输入字符被送进缓冲区之后,我们就不应该再递增p了。此处,我们是通过在循环的每次迭代中增加一次额外的测试来做到这一点的;另一种可选的方案就是重复整个循环。
    void bufwrite(char *p, int n) {
        while (--n >= 0) {
            if (bufptr == &buffer[N-1]) {
                *bufptr = *p;
                flushbuffer();
            } else {
                *bufptr++ = *p;
            }
            if (n > 0) {
                p++;
            }
        }
    }
    读者可能注意到,这里我们小心翼翼地避免在缓冲区填满时对bufptr进行递增操作,是为了不生成非法地址&buffer[N]。 
    bufwrite程序的第二个版本改起来就更加棘手了。在进入程序时,我们知道缓冲区中至少还有一个字符的位置尚未填满,因此一开始我们并不需要清空缓冲区;但是,在程序结束时,我们就有可能需要清空缓冲区了。与对bufwrite程序的第一个版本的处理相同,我们在循环的最后一次迭代时也必须避免对p进行递增操作: 
    void bufwrite(char *p, int n) {
        while (n > 0) {
            int k, rem;
            rem = N - (bufptr - buffer);
            k = n > rem ? rem : n;
            memcpy(bufptr, p, k);
            if(k == rem) {
                flushbuffer();
            } else {
                bufptr += k;
            }
            n -= k;
            if(n) { /*判断本次迭代是否为循环的最后一次迭代,避免对p进行递增操作 */ 
                p += k;
            }
        }
    }
    我们把k与rem进行比较,前者是本次循环迭代中需要复制的字符数,后者是缓冲区尚未填满的字符数。这个比较的目的是看在复制操作后缓冲区是否已经填满,如果缓冲区已满,则需要清空。在对p进行递增操作之前,我们首先检查n是否为0,以判断本次迭代是否为最后一次迭代。 
    练习3-2 比较3.6节函数flush的最后一个版本与以下版本: 
    void flush() {
        int row;
        int k = bufptr - buffer;
        if (k > NROWS) {
            k = NROWS;
        }
        for (row = 0; row < k; row++) {
            int *p;
            for (p = buffer + row; p < bufptr; p += NROWS) {
                printnum(*p);
            }
            printnl();
        }
        if (k > 0) {
            printpage();
        }
    }
    解析:
    flush函数这两个不同版本之间的区别是:上面的flush函数在测试k是否大于0的语句中只包括了对printpage函数的调用,而3.6节的flush函数在测试语句中还包括了整个for循环。3.6节的flush函数的版本,用自然语言描述就是这样的:“如果缓冲区中有需要打印的内容,就把它们打印出来,然后开始新的一页。”此处的flush函数的版本,用自然语言描述就是,“不管缓冲区中的是否有剩余的内容,都先打印;如果缓冲区中确有剩余,则开始新的一页。”与3.6节中flush函数的版本相比,这个版本的k在for循环里的作用就不甚明显。在3.6节的版本中,我们可以很容易看出k的作用:当k
    为0时,将跳过循环。
    虽然从技术上说flush函数的这两个版本是等价的,但是它们所表达的编程意图却有细微的差别。最能够反应程序员实际编程意图的版本,就是最好的版本。
    练习3-3 编写一个函数,对一个已排序的整数执行二分查找。函数的输入包括一个指向表头的指针、表中的元素个数以及待查找的数值。函数的输出是一个指向满足查找要求的元素的指针;当未查找到要求的数值时,输出一个NULL指针。
    解析: 
    二分查找从概念上来说非常简单,但是在编程实践中人们经常不能正确实现。这里,我们将开发二分查找的两个版本,它们都用到了不对称边界。第一个版本用的是数组下标,第二个版本用的是指针。
    不妨假定待搜索的元素为x,如果x存在于数组中的话,那么我们假定它在数组中的下标为k。最开始,我们只知道0<=k 为了做到这一点,我们把x与位于可能范围中间位置的元素进行比较。如果x与该元素相等,我们就大功告成。如果两者不相等,位于该元素的“错误”一侧的所有元素,我们就不与考虑,这样就缩小了搜索的范围。
    任何时候,我们都假定lo和hi是不对称边界的两头。也就是说,我们要求lo<=k 如果lo小于hi,那么可能范围中至少存在一个元素。我们不妨假定mid为可能范围的中值,然后比较x与整数表中的下标为mid的元素。如果x比该元素小,那么mid就是位于可能范围之外的最大下标,因此我们可以设置hi=mid。如果x比该元素大,那么mid+1就是位于新的已缩减的可能范围以内的最小下标,因此我们可以设置lo=mid+1。最后,如果x与该元素相等,我们就完成了搜索。
    我们是否可以设置mid=(hi+lo)/2,这样设置会带来什么问题吗?如果hi与lo相隔较远,这样做显然不会有什么问题。但是,如果hi和lo隔得很近,又是怎样的情况呢?
    hi等于lo的情况根本用不着考虑。因为此时我们已经知道x的可能范围为空,我们甚至不需要设置mid。当hi=lo+2时,这也不是问题:hi+lo等于2*lo+2,这是一个偶数,因此(hi+lo)/2等于lo+1。当hi=lo+1时,情况又如何呢?在这种情况下,可能范围中的唯一元素即使lo,因此如果(hi+lo)/2等于lo,这个结果才是我们可接受的。
    幸运的是,由于hi+lo恒为正数,(hi+lo)/2会得到我们希望的结果lo。因为在这种情况下,整数除法肯定将会被截断处理。因此,(hi+lo)/2等价于((lo+1)+lo)/2,亦即(2*lo+1)/2,这个式子的结果就是lo。
    根据上面的讨论,这个程序大致如下: 
    int *bsearch(int *t, int n, int x) {
        int lo = 0, hi = n;
        while (lo < hi) {
            int mid = (lo + hi) / 2;
            if (x < t[mid]) {
                hi = mid;
            } else if (x > t[mid]) {
                lo = mid + 1;
            } else {
                return t + mid;
            }
        }
        return NULL;
    }
    值得注意的是,下面求值表达式:
    int mid = (hi + lo) / 2;
    中的除法运算可以用移位运算代替: 
    int mid = (hi + lo) >> 1;
    这样做确实会提高程序的运行速度。现在还是让我们首先去掉一下寻址运算,原因是在很多机器上下标运算都要比指针运算慢。我们把t+mid的值存储在一个局部变量中,这样就不需要每次都重新计算,从而可以稍微减少一些寻址运算:
    int *bsearch(int *t, int n, int x) {
        int lo = 0, hi = n;
        while (lo < hi) {
            int mid = (hi + lo) / 2;
            int *p = t + mid;
            if (x < *p) {
                hi = mid;
            } else if (x > *p) {
                lo = mid + 1;
            } else {
                return p;
            }
        } 
        return NULL;
    }
    又假定我们希望进一步减少寻址运算,可以通过在整个程序中用指针代替下标来做到。乍一看,我们似乎只要按部就班地把程序中凡用到下标的地方,统统改用为指针的形式重写一遍即可: 
    int *bsearch(int *t, int n, int x) {
        int *lo = t, *hi = t + n;
        while (lo < hi) {
            int *mid = (hi + lo) / 2;
            if (x < *mid) {
                hi = mid;
            } else if (x > *mid) {
                lo = mid + 1;
            } else {
                return t + mid;
            }
        }
        return NULL;
    }
    实际上,这个程序是“功败垂成”,还差一点就可以工作了。问题出在下面的语句: 
    mid = (lo + hi) / 2;
    这个语句非法的。因为它试图把两个指针相加。正确的做法是,先计算出lo与hi之间的距离(这可以由指针减法得到,并且结果是一个整数),然后把这个距离的一半(也仍然是整数)与lo相加:
    mid = lo + (hi - lo) / 2; 
    上面的hi-lo计算出结果后,还要对它做除法运算。虽然大多数C编译器都足够“智能”,会自动地把这类除法运算实现为移位运算以优化程序性能,但对于这里的除2运算,这些编译器还不够智能,不会把它实现为移位运算。因为编译器所知道的只是hi-lo可能为负,而对于负数来说,除2运算和移位运算会得到不同的结果。因此,我们确实应该自己手动把它写成移位运算的形式。 
    mid = lo + (hi - lo) >> 1;
    很不幸,这样写还是不对。因为算术运算符优先级高于移位运算符优先级!因此,我们必须写成: 
    mid = lo + ((hi - lo) >> 1);
    最后,完整的程序如下: 
    int *bsearch(int *t, int n, int x) {
        int *lo = t, *hi = t + n;
        while (lo < hi) {
            int *mid = lo + ((hi - lo) >> 1);
            if (x < *mid) {
                hi = mid;
            } else if (x > *mid) {
                lo = mid + 1;
            } else {
                return mid;
            }
        }
        return NULL;
    }
    二分查找经常用对称边界来表达。因为采用对称边界后,最后得到的程序看上去要整齐许多: 
    int *bsearch(int *t, int n, int x) {
        int lo = 0, hi = n - 1;
        while (lo <= hi) {
            int mid = (hi + lo) / 2;
            if (x < t[mid]) {
                hi = mid - 1;
            } else (x > t[mid]) {
                lo = mid + 1;
            } else {
                return t + mid;
            }
        }
        return NULL;
    }
    然而,如果我们试图把上面的程序写成“纯指针”的形式,就会遇到麻烦。问题在于,我们不能把hi初始化为t+n-1。因为n为0时,这是个无效地址!因此,如果我们还想把程序写成指针形式,就必须对n=0的情形单独测试。

    练习4-1 假定一个程序在一个源文件中包含了声明: 
    long foo;
    而在另一个源文件中包含了: 
    extern short foo;
    又进一步假定,如果给long类型的foo赋一个较小的值,例如37,那么short类型的foo就同时获得了一个值37。我们能够对该程序的硬件做出什么样的推断?如果short类型的foo得到的值不是37而是0,我们又能够做出什么样的推断?
    解析:
    如果把值37赋给long型的foo,相当于同时把值37页赋给了short型的foo,那么这意味着short型的foo与long型的foo包含了值37的有效位的部分,两者在内存中占用的是同一区域。这有可能是因为long型和short型被实现为同一类型,但很少有C语言的实现会这样做。更有可能的是,long型的foo的低位部分与short型的foo共享了相同的内存空间,一般情况下,这个部分所处的内存地址较低;因此我们的一个可能推论就是,运行该程序的硬件是一个低位优先(little_endian)的机器。同样道理,如果在long型的foo中存储了值37,而short型的foo却是0,我们所用的硬件可能是一个高位优先(big-endian)的机器。
    endian的意思是“数据在内存中的字节排列顺序”,表示一个字在内存中或传送过程中的字节顺序。在微处理中,像long/DWORD(32 bit)0x12345678这样的数据总是按照高位优先方式存放的。但在内存中,数据存放顺序则因微处理器厂商的不同而不同。一种顺序称为big-endian,即内存中把最高位字节放在最前面;另一种顺序就称为little-endian,即把 内存中把最低位字节放在最前面。 
    big-endian:最低地址存放高位字节,可称为高位优先。内存从最低地址开始,按顺序存放。这种存放方式正是我们书写的方式,高数位数字先写,而且所有处理器都是按照这个顺序存放数据的。
    little-endian:最低地址存放低位字节,可称为低位优先。内存从最低地址开始,顺序存放。little-endian处理器是通过硬件将内存中的little-endian排列顺序转换为寄存器的big-endian排列顺序,因此没有数据加载/存储的开销。
    练习4-2 4.4节中讨论的错误程序,经过适当简化后如下所示: 
    #include
    int main() {
        printf("%g\n", sqrt(2));
    }
    在某些系统中,打印出的结果是:
    %g
    请为这是为什么?
    解析:
    在某些C语言实现中,存在着两种不同版本的printf函数:其中一种实现了用于表示浮点格式的项,如%e、%f、%g等;另一种却没有实现这些浮点格式。库文件中同时提供了printf函数的两种版本,这样的话,那些没有用到浮点运算的程序,就可以使用不提供浮点格式支持的版本,从而节省程序空间,减少程序大小。
    在某些系统上,编程人员必须显式地通知编译器是否用到了浮点运算;而另一些系统,则是通过编译器来告知链接器在程序中是否出现了浮点运算,以自动地做出选择。
    上面的程序没有进行浮点运算!它既没有包含math.h头文件,也没有声明sqrt函数,因此编译器无从得知sqrt是一个浮点函数。这个程序甚至没有传送一个浮点参数给sqrt函数。所以,编译器“自认合理”地通知链接器,该程序没有进行浮点运算。
    那sqrt函数又怎么解释呢?难道“sqrt函数是从库文件中取出的”这个事实还不足以证明该程序用到了浮点运算?当然,“sqrt函数是从库函数取出的”这一点没错,但是,链接器可能在从库文件中取出sqrt函数之前,就已经做出了使用何种版本的printf函数的决定。

    练习5-1 当一个程序异常终止时,程序输出的最后几行常常会丢失,原因是什么?我们能够采取怎样的措施来解决这个问题?
    解析:
    一个异常终止的程序可能没有机会来清空其输出缓冲区。因此,该程序生成的输出可能位于内存的某个位置,但却永远不会被写出了。在某些系统上,这些无法被写出的输出可能长达好几页。
    对于试图调试这类程序的编程人员来说,这种丢失输出的情况经常会误导他们,因为这会造成这样一种印象,即程序发生失败的时刻比实际上运行失败的真正时刻要早得多。解决方案是在调试时强制不允许对输出进行缓冲。要做到这一点,不同的系统有不同的做法,这些做法虽然存在细微差别,但大致如下: 
        setbuf(stdout, (char *)0);
    这个语句必须在任何输出被写入stdout之前执行。该语句最恰当的位置就是作为main函数的第一个语句。 
    练习5-2 下面程序的作用是把它的输入复制到输出: 
    #include
    int main() {
        register int c;
        while ((c = getchar()) != EOF) {
            putchar(c);
        }
    }
    从这个程序中移除#include语句,将导致程序不能通过编译,因为这时EOF是未定义的。假定我们手工定义了EOF(当然,这是一种不好的做法): 
    #define EOF -1
    int main() {
        register int c;
        while ((c = getchar()) != EOF) {
            putchar(c);
        }
    }
    这个程序在许多系统中仍然能够运行,但是在某些系统运行起来却慢得多。这时为什么? 
    函数调用需要花费较长的程序执行时间,因此getchar经常被实现为宏。这个宏在stdio.h头文件中定义,因此如果一个程序没有包含stdio.h头文件,编译器对getchar的定义就一无所知。在这种情况下,编译器会假定getchar是一个返回类型为整型的函数。
    实际上,很多C语言实现在库文件中都包含getchar函数,部分原因是预防编程人员的粗心大意,另外部分原因是为了方便那些需要得到getchar地址的编程人员。因此,程序中忘记包含stdio.h头文件的结果就是,在所有getchar宏出现的地方,都用getchar函数调用来替换getchar宏。这个程序之所以运行变慢,就是因为函数调用所导致的开销增多。同样的依据也完全适用于putchar。 

    练习6-1 请使用宏来实现max的一个版本,其中max的参数都是整数,要求在宏max的定义中这些整型参数只被求值一次。
    解析: 
    max宏的每个参数的值都有可能使用两次:一次是在两个参数做比较时;一次是在把它作为结果返回时。因此,我们有必要把每个参数存储在一个临时变量中。 
    遗憾的是,没有直接的办法可以在一个C表达式的内部声明一个临时变量。因此,如果要在一个表达式中使用max宏,就必须在其他地方声明这些临时变量,比如可以在宏定义之后,但不是将这些变量作为宏定义的一部分进行声明。如果max宏用于不止一个程序文件,我们应该把这些临时变量声明为static,以避免命名冲突。不妨假定这些定义将出现在某个文件中: 
    static int max_temp1, max_temp2;
    #define max(p, q) (max_temp1 = (p), max_temp2 = (q),\
    max_temp1 > max_temp2 ? max_temp1 : max_temp2)
    只要不是嵌套调用max宏,上面的定义都能正常工作;在嵌套调用max宏的情况下,我们不可能做到让它正常工作。 
    练习6-2 6.1节中提到的“表达式” 
    (x) ((x)-1)
    能否成为一个合法的C表达式? 
    一种可能是,如果x是类型名,例如x被这样定义: 
    typedef int x;
    在这种情况下, 
    (x) ((x)-1)
    等价于 
    (int) ((int)-1)
    这个式子的含义是把常数-1转换为int类型两次。我们也可以通过预处理器指令来定义x为一种类型,以达到同样的效果:
    #define x int 
    另一种可能是当x为函数指针时。如果某个上下文中本应需要函数而实际上用了函数指针,那么该指针所指向的函数将会自动地被取得并替换这个函数指针。因此,本题中可以被解释为调用x所指向的函数,这个函数的参数是(x)-1。为了保证(x)-1是一个合法的表达式,x必须实际指向一个函数指针数组中的某个元素。
    我们假定x的类型是T,因此可以如下声明x:
    T x;
    显而易见,x必须是一个指针,所指向的函数的参数类型是T。这一点让T比较难以定义。下面是最容易想到的方法,但却没有用:
    typedef void (*T)(T);
    因为只有当T已经被声明之后,才能这样定义T!不过,x所指向的函数的参数类型不一定要是T,它可以是任何T可以被转换成的类型。具体来说,void *类型就完全可以:
    typedef void (*T)(void *); 
    (void (*)(void *))((void (*)(void *)) - 1)
    这个练习的用意在于说明,对于那些看上去无从着手、形式“怪异”的结构,我们不应该轻率地一律将其作为错误来处理。

    练习7-1 7.3节中讲到,如果一个机器的字符长度为8位,那么其整数长度可能是16位或32位。请问原因是什么? 
    解析:
    某些机器为每个字符分配一个唯一的内存地址,而另一些机器却是按字来对内存寻址。按字寻址的机器通常都存在不能有效处理字符数据的问题,因为要从内存中取得一个字符,就必须读取整个字的内容,然后把不需要用到的部分都丢弃。 
    由于按字符寻址的机型在字符处理方面具有效率优势,它们相对于按字寻址的机型,近年来更为流行。然后,即使对于按字符寻址的机器,在进行整数运算时字的概念也仍然是重要的。因为字符在内存中的存储位置是连续的,所以一个字中包含的字符数,将决定在内存中连续存放的字的地址。
    如果一个字中包含的字符数是2的某次幂,因为乘以2的某次幂的运算可以转换为移位运算,所以计算机硬件就能很容易地完成从字符地址到字地址的转换。因为,我们可以合理地预期,字的长度是字符长度的2的某次幂。
    那么整数的长度为什么不是64位呢?当然,某些时候这样做无疑是有用的。但是,对于那些具有浮点运算硬件的机器,这样做的意义就不大了,而且考虑到并不经常需要用到64位整数这样的精度,实现64位整数的代价就过于昂贵。如果只是偶尔用到,完全可以用软件来模拟64位(或者更长)的整数,而且丝毫不影响效率。
    练习7-2 函数atol的作用是接受一个指向以null结尾的字符串的指针作为参数,返回一个对应的long型整数值。在下面这些假设情况下,请写出atol函数的一个可移植版本。 
    *作为输入参数的指针,指向的字符串总是代表一个合法的long型整数值,因此atol函数无须检查该输入是否越界。 
    *唯一合法的输入字符是数字和正负号。在遇到第一个非法字符时输入结束。
    假定机器的排序序列中数字是连续排列的:任何一种现代计算机都是这样实现的,而且ANSI C标准也是这样要求的。
    因此,我们面临的主要问题是避免中间结果发生溢出,即使最终的结果在取值范围之内也是如此。
    正如printnum函数中的情形,如果long型负数的最小可能取值与正数的最大可能取值并不相匹配,问题就变得棘手了。特别是如果我们先把一个值作为正数处理,然后再使它为负,对于负数的最大可能取值的情况,在很多机器上都会发生溢出。 
    解决办法就是只使用负数(和零)来得到函数的结果,从而避免了溢出: 
    long atol(char *s) {
        long r = 0;
        int neg = 0;
        switch(*s) {
            case '-':
                neg = 1;
                /*此处没有break语句*/
            case '+':
                s++;
                break; 
        }
        while (*s >= '0' && *s <= '9') {
            int n = *s++ - '0';
            if (neg) {
                n = -n;
            }
            r = r * 10 + n; 
        }
        return r;
    }

  • 相关阅读:
    网页制作课作业基于HTML+CSS+JavaScript+jquery仿慕课网教学培训网站设计实例 企业网站制作
    SQLite自动建库建表
    ubuntu18.0安装搜狗输入法无法显示中文
    百度飞桨公布最新成果:凝聚535万开发者,服务20万家企事业单位
    【C++】基础语法(中)
    Python练习之选择与循环
    在本地运行Kusto服务器
    Oracle集群管理-19C集群禁用numa和大页内存特性
    springcloud二手交易平台系统源码
    STM32复习笔记(五):FSMC连接外部SRAM
  • 原文地址:https://blog.csdn.net/weixin_40186813/article/details/126048321