《C陷阱与缺陷》学习笔记(上):词法陷阱、语法陷阱、语义陷阱

  自从上大学起,接触C也很久了,但是一直不怎么深入,也疏于练习。课程学习之余,专门的C只看过《C程序设计语言》、《C primer plus》,现在终于有了点时间看看更多的书了。本文主要记录阅读和学习《C陷阱与缺陷》的一些心得体会。

11月15日

前言和导读

  “得心应手的工具在初学时的困难程度往往超过那些容易上手的工具。”比较认同这句话。我至今觉得自己其实还是个刚入了门的初学者。

第一章  “词法”陷阱

  由于之前学过编译原理,对编译器词法分析(主要是符号识别过程)比较了解,理解起来不困难。

  在讲到"="和"=="、"|"和"||"、"&"和"&&"时,联想起以前见过一些程序中出现了类似于"#define ||  OR"这样的语句。当时以为可能是为了照顾习惯其他语言的使用者的阅读偏好,现在看来这样做确实可以避免一些错误。当然使用不使用这种编程风格就是另外一回事了。对于词法分析的运用到的贪心法,之前虽然知道原理和规则,但确实没有意识到这是种贪心法。这样一来,对于容易引起编译器错误的格式,写成另一种格式更好一些。

y = x/*p // 本需要进行指针取值、除法和赋值,这个颜色已表示编译器并不这么认为,因此只能写成下一种形式
y = x/(*p)

  至于为整型常量用0补首位以便对齐,反而使得编译器将其误认为八进制数的情况倒是从来遇到也没考虑到过。

  “单引号' '中的字符上代表一个整数,双引号" " 引起的字符串代表的是一个指向无名数组起始字符的指针。”前半句以前就知道,后半句有点意思。后半句解释了为什么char *slash = '/' 这个语句有错误。同时,书中指出整型一般为16位或32位,可以容纳多个字符(一般为8位),所以用单引号的'yes'或许能够被一些编译器正确识别,只是巧合而已。有的编译器会将'yes'多余字符忽略,只取第一个整数'y';有的则依次取值、覆盖再取值,最后结果是只取了最后一个整数相当于's'。

11月16日

第二章  “语法”陷阱

  模拟调用首地址为0的子例程的语句(*(void(*)())0) ()以及它从(*fp)()生成而来的过程。把常数0转型为“指向返回值为void的函数的指针”的类型就是(void(*)()) 0,用它代替fp就生成了这个语句。使用typedef会更方便直观。根据补充阅读,在编程中使用typedef目的一般有两个,一个是给变量一个易记且意义明确的新名字,另一个是简化一些比较复杂的类型声明。前者早已知道,后者确实是个盲点,因此以前总把typedef当做另一种#define。

typedef void (*funcptr)();
(*(funcptr)0)();

利用这个特性,不难理解signal函数的声明。它接受两个参数(一个整型的信号编号和指向用户定义的信号处理函数的指针),返回值是一个指向调用前的用户信号处理函数的指针。

void (*signal(int,void(*)(int)))(int)
/*下面是简化后的函数声明*/
typedef void (*HDNDLER)(int);
HANDLER signal(int, HANDLER)

  关于运算符优先级,以前的处理方式是无脑加括号,完全不关注;但是括号太多了确实会造成阅读困难。根据原书所示做出归纳。

1.优先级最高者其实并不是真正意义上的运算符,包括数组下标、函数调用操作符、结构成员选择操作符,它们自左向右结合;
2.单目运算符次之,自右向左结合;
3.双目运算符又次之,其中算术运算符最高,移位运算符次之,关系运算符再次之,然后是逻辑运算符,赋值运算符,最后是三目运算符。
     其中最重要的有两点:逻辑运算符低于关系运算符、移位运算符比算术运算符低但比关系运算符高。其他一些细节:
    * / %高于+ - ,+ -高于移位;
    关系运算符中==和!=低于其他4中(< <= > >=);
    无论是逻辑运算还是按位运算,“与”总比“或”优先级高。
4.三目运算符更低,所有运算符中逗号最低

  多余的和缺失的分号会造成的错误一般不会出现。但是所给的例子比较醒目地提醒了结构定义后如果不加分号的结果——可能导致其后定义的函数返回类型为这种结构:

struct logrec{
......
}
main() //当然一般使用void main()

  有时候,switch...case...结构中不一定每一个分支语句都需要break,书中字符处理的例子就不再重复。

  没有参数的函数调用也应该有一个空的参数列表。

  else总与最近的if配对。

  其他的一些感想:对于词法分析,空格、换行确实是可行的分隔方法;然而在语法分析时却不那么好用了。多余的空格和换行符往往会被删掉,因此必须明白语句结构的配对原则和分号" ; "及逗号" , "的正确使用,还有大括号" { } "的配对。当然这个结论应该是初学伊始就牢牢树立的观点了。有的语言特点和编程风格(特别是通过宏定义改变语法结构的外观)不少是从包括C语言的前身以及其他语言的编程风格继承而来,但后者并不是必要的。

11月16日~17日

第三章  “语义”陷阱

  数组和指针:数组名实际上是一个指向数组首元素的指针,只有在作为sizeof()的参数时例外。

  虽然C语言只有一维数组,但用一维数组模拟多维数组是可以的。

  a[i]是*(a+i)的简记,这也就可以解释为什么数组首元下标从0开始。也正因此,a[i]和i[a]具有相同的含义。汇编中后者并不少见,但作者不推荐这种写法。在多维数组中,下标表示法比*(*(calender+4)+7)这样的表示方法简单多了。

  字符串拷贝中暗含的陷阱:malloc分配空间可能失败;分配后需要及时释放;分配内存大小的限定。三者结合起来的例子:

三个陷阱

  C中无法将一个数组作为函数参数直接传递。如果使用数组名作为参数,那么数组名会立刻被转换为指向该数组第1个元素的指针。char hello[] = "hello"声明了一个字符数组后,printf("%s\n",hello)和printf("%s\n",&hello[0])是等效的(同样是在终端显示hello)。但这种情况限于其作为函数参数的情况,书中指出,假设这种自动转换在其他情形下成立是错误的,"extern char *hello"与"extern char hello[]"有着天壤之别,这是第四章内容,尚未涉及。由此延伸而来,如果一个指针参数并不实际代表一个数组,即使从技术上而言是正确的,采用数组形式的记法经常会起到误导作用。反之,如果一个指针参数代表一个数组,以main函数第二个参数为例来说明:

main(int argc, char* argv[]) {  } 
//强调argv是一个指向某数组的起始元素的指针,该数组的元素为字符指针类型

main(int argc, char** argv) { }
//两种写法完全等价,可以任选一种最能清楚反应自己意图的写法

  避免“举隅法”(不必深究这个语言学名词),包含的“陷阱”即为混淆指针和指针所指的数据。比如char *p,*q;p="ABC";q=p;。
  除了0,C语言将一个整数转换为一个指针。最后得到的结果取决于编译器实现。而0,编译器保证其转换而来的指针不等于任何有效的指针。#define NULL 0 是出于代码文档化的考虑。因此是不能使用赋值为0的指针变量所指向的内存中存储的内容的,这是未定义的。相关的语句在不同计算机上会有不同效果,第七章会详细讨论这个问题。

  在“边界计算和不对称计算”这个“陷阱”处,作者以int i,a[10]举例,说明了为什么for(i=0; i<10; i++)   a[i] = 0 ;比for(i=0; i<=9; i++)   a[i] = 0 ;要好:入界点和出界点恰好为0和10,并且对于下标为从0开始的C语言,出界点恰是数组元素个数。对于这个问题的另一种考虑方法:把上界视作某序列第一个被占用的元素,把下界视作第一个被释放的元素。这种考虑方式处理不同类型的缓冲区时很有用,所举例子是一个指向缓冲区的指针,让它总是指向第一个未占用的字符,这样对其赋值就有 *bufptr++ = c;的形式。对于例子还有更多细节可以揣摩。

边界计算与不对称边界的偏好
#define N 1024
static char buffer[N];

void bufwrite(char *p, int n)
{
while (--n >= 0)
/*大多数C语言实现中--n>=0至少与等效的n-->0一样快,某些C实现中甚至更快。前者首先n减去1,结果与0比较;后者先保存n,从n中减1,然后比较保存值与0*/
if (bufptr == &buffer[N])
/*虽然buffer[N]并不存在,但目的是比较bufptr与缓冲区后第一个字符的地址,&buffer[N]确实是这个地址。它的地址是真实存在的,数组中实际不存在的“溢界”元素地址位于数组所占内存之后,是可以用于赋值和比较的。当然如果要引用这个元素,就是非法操作了*/
flushbuffer();
*bufptr++ = *p++;
}
}

  在之后的另一个例子中,作者认为,技巧性很强的代码,“如果没有很好的理由,我们不应该尝试去做。但如果是‘师出有名’,那么理解这样的代码应该如何写就很重要了。”对于那个具体的例子,“只要我们记住前面的两个原则,特例外推法和仔细计算边界,我们应该完全有信心做对。”

另一个例子
/*变量定义同上一个例子*/
void memcpy(char *dest, const char *source, int k)
{
while (--k>= 0)
*dest++ = *source++;
}
/*memcpy是个库函数,这里是一个简易实现*/

/*本例是利用memcpy一次转移一批字符到缓冲区*/
void bufwrite(char *p, int n)
{
while (n > 0) {
int k, rem;
if (bufptr == &buffer[N])
flushbuffer();
rem = N -(bufptr - buffer);
k = n > rem?rem: n;
memcpy(bufptr, p, k);
bufptr += k ;
p += k;
n -= k;
}
}

  作者提到,之前讨论了运算符优先级的问题,“求值顺序则完全是另一码事”。前者是保证a + b * c应该解释成a + (b * c)而不是(a+b) * c这样一类的规则,求值顺序是保证if (cout != 0 && sum/count < smallaverage) {...}即使count为0也不会产生用0作除数的错误的规则。这里有一篇别人的日志可以参考。要点在于,C语言中只有四个运算符(&&、||、? :和,)存在规定的求值顺序。特别指出用于分隔函数参数的逗号并非逗号运算符。其他运算符对其操作数求值的顺序是未定义的,赋值运算符并不能保证任何求值顺序。

i = 0;
while (i < n)
y[i] = x[i++];
//这里有个假设:y[i]的地址在i的自增操作执行前被求值,但实际并没有任何保证。有的C语言实现可能如此,有的则相反。
// y[i++] = x[i];同样有这种问题

/*改进*/
i = 0;
while (i < n) {
y[i] = x[i++];
i++;
}
/*简写*/
for (i = 0; i < n; i++)
y[i] = x[i];

  作者特别提到,虽然用&、|、~和&&、||、!对应运算相互替代时,程序运行结果可能是正确的,并详细解释了一下,但这侥幸成分很大而且绝少有C编译器能够检测出,因此这种错误还是应该要避免。

  无符号数运算不会溢出:所有无符号运算都是以2的n次方为模,在这个意义上确实没有“溢出”这一说。相关解释可以参考这里。对有符号数运算溢出的检测方法:

if ((unsigned)a + (unsigned)b > INTMAX) 
//INT_MAX代表可能的最大数值,ANSI C在<limits.h>定义
//其他C语言实现中也许要自己定义
complain();

/*另一种可行方法*/
if (a > INT_MAX - b)
complain();

  为main提供返回值的原因:大多数C语言实现都通过main返回值来告诉操作系统该函数执行是成功还是失败,一般0代表成功,非0为失败。不给出返回值的结果是隐含地返回了某个“垃圾”整数(未显式声明返回类型则默认为整型)。

原文地址:https://www.cnblogs.com/wuyuegb2312/p/2250144.html