《The Practice of Programming》读书笔记(一)

绝地灬酷狼 2022-06-05 01:09 222阅读 0赞

最近在看《程序设计实践》,据说这书是一个被名字毁了的好书。看了之后表示认同。其中的很多最佳实践我之前已经在使用,但其中给了很好的归纳。另外还有一些以前没有想到的,让我感觉眼前一亮的。掌握这些最佳实践能够大大提高编程效率、可读性,大大减少bug的概率。其中的实践都是从工作中总结出来的,所以即使没有看过这本书,有经验的程序员也会自己摸索出很多实践,因此对于越有经验的程序员,看这本书的收获就会相对越小;因此,建议新手程序员在掌握最基本技能后就直接学习此书,必会受益良多(即使现在可能有些东西不能理解,以后工作时也会慢慢明白这样做的好处)。

这里记录下来书中的要点,主要为自己以后复习使用。

前言

像测试、调试、可移植性、性能、设计取舍以及风格 —— 这些话题统称程序设计实践

简单性、清晰性和通用性才是优质软件的基石。

简单性:保持程序短小精悍、方便管理。
清晰性:保证程序无论对人还是机器都易于理解。
通用性:让程序在众多场景下都能工作,并很好的适配新情形。
自动化:让机器完成工作,把我们从琐碎的工作中解放出来。

用户、程序和程序组件之间的接口,是程序设计中的基础,而衡量软件成功程度的主要指标就是看接口设计和实现得如何。

编程风格

编程不只是让语法正确、不出bug并运行足够快。程序不只是给机器读的,它还是给程序员读的。编写良好的程序更加易于理解和修改。良好的编程风格能降低bug概率。

命名

变量或函数的名字能够表达其用途。命名应该尽可能做到能传达信息、简洁、易记并且可读。

全局对象使用描述性名字,本地对象使用短名字
全局对象可能出现在任何地方,所以名字应该足够长并且足够直观能让读者看出其用途。

而本地对象因为有上下文提示,使用短名字就足够了,特别是那些常用变量名如i、j、n这些大家都知道什么用的变量,这时使用长名字就画蛇添足了。

简洁常会带来清晰。

有很多命名规范。常用的比如:

  1. 通过在头或尾加p来代表指针,比如nodep。
  2. 全局变量的首字母大写,Globals
  3. 常量的全部字母大小,CONSTATS
  4. ……

一致性比你具体使用哪种规范更重要,只要选择了其中一种,就要保证(起码在同一个模块中)一直使用下去。

保持一致
这里的一致是指命名时相关对象的名字应该能表现出他们的关系以及差别。还有,同一个意思的词的表达尽可能一致。如:

java代码:

  1. class UserQueue{
  2. int noOfItemsInQ, frontOfTheQueue, queueCapacity;
  3. public int noOfUsersInQueue(){...}
  4. }

其中不应同时使用Q、Queue和queue,另外其实根本不需要写queue:

  1. class UserQueue{
  2. int nitems, front, capacity;
  3. public int nusers(){...}
  4. }

这样已经足够清晰了,不信你看:

  1. queue.capacity++;
  2. n = queue.nusers();

另外, “items” 和 “users” 是同个东西,最好决定只使用其中一个。

函数名使用主动动词
函数名应该基于主动动词,可能要根据口语:

  1. now = data.getTime();
  2. putchar('\n');

另外对比:

  1. if(checkoctal(c)) ...
  2. if(isoctal(c)) ...

第二个不仅读的更顺,而且还明确了返回结果。

准确
要按照名字正确实现,否则会误导用户。

表达式和语句

就像好的名字有助于读者理解,表达式和语句也要写的让读者足够容易理解。保持代码一致的格式是会花点时间,但这是特别值得的。

用缩进表达结构
一致的缩进风格是表达程序结构的最简单方式。
差的示例:

  1. for(n++;n<100;field[n++]='\0');
  2. *i = '\0'; return('\n');

改为:

  1. for (n++; n < 100; n++)
  2. field[n] = '\0';
  3. *i = '\0';
  4. return '\n';

表达式使用最自然的形式
使用那种你能够读出来的形式。

  1. if(!(block_id < actblks) || !(block_id >= unblocks))
  2. ...;
  3. // 改为
  4. if((block_id >= actblks) || (block_id < unblocks))
  5. ...;

使用括号来准确表达意思
括号可以表达分组,这样可以使得意图更加明显,即使有的时候并不需要括号。就像上一个代码中的括号就不是必须的,但是这样可以让人更快的理解逻辑。

另外,使用括号还能避免因运算符优先级导致的逻辑问题。如下面两个if并不等价:

  1. if(x&MASK == BITS)...;
  2. if((x&MASK) == BITS)...;

拆分复杂表达式
行数并不总是越少越好的,易懂更重要。比起:

  1. *x += (*xp=(2*k < (n-m)? c[k+1] : d[k--]));

你肯定更愿意看到:

  1. if (2*k < n-m)
  2. *xp = c[k+1];
  3. else
  4. *xp = d[k--];
  5. *x += *xp;

做到清晰
程序员会有写最简洁的代码的冲动,但是不要滥用各种技巧。要的是清晰的代码而不是聪明的代码。

“?:”运算符可以用于把四行的if-else缩减为一行,它很好用,但是不要滥用。

小心副作用
在C和C++中,并没有定义副作用的执行顺序,所以以下语句的结果是不确定的:

  1. // 问题语句1
  2. str[i++] = str[i++] = ' ';
  3. // 问题语句2
  4. array[i++] = i;

另外,以下语句并不会根据第一个读入的yr值来决定第二个参数写入的位置,因为在调用scanf时,第二个参数就已经确定了。

  1. scanf("%d %d", &yr, &profit[yr]);

一致性和俗语

一致性产生更好的程序。如果对同一件事写出来的程序几乎一样,那么要是哪里有语法错误可能一眼就可以看出来。

使用一致的缩进和括号风格
缩进可以表达结构。
很多人争辩哪种代码布局风格更好,但是特定风格的好坏其实远没有一致的风格更重要。选择一种风格,一致的使用它,不要浪费时间来争辩。

在使用if语句时,我们倾向于在单行语句时不使用括号,但是要小心else和if间的逻辑关系,有的时候不得不使用括号来明确else对应的是哪一个if。

如果你在修改的程序不是你写的,请使用程序中现有的风格,即使你更喜欢你的风格。程序的一致性比你自己的风格重要,因为这会让后面接手的人舒服。

使用俗语达成一致性
像自然语言一样,编程语言也有俗语。
学习任何语言的一大要点就是熟悉这个语言的俗语。

c语言中的俗语:

遍历数组

  1. for (i = 0; i < n; i++)
  2. array[i] = 1.0;

遍历链表

  1. for (p = list; p != NULL; p = p->next)
  2. ...

无限循环

  1. for (;;)
  2. ...
  3. // 或者
  4. while (1)
  5. ...

在循环条件中嵌套赋值语句

  1. while ((c = getchar()) != EOF)
  2. putchar(c);

缩进也是俗语,不要使用不寻常的垂直布局:

  1. for(
  2. ap = arr;
  3. ap < arr + 128;
  4. *ap++=0
  5. )
  6. {
  7. ;
  8. }
  9. // 清晰性远不如
  10. for (ap = arr; ap < arr+128; ap++)
  11. *ap = 0;

使用俗语的一大优势是让你更容易发现不标准的语句,不标准的语句经常存在问题:

  1. int i,*Array, nmemb;
  2. iArray = malloc(nmemb * sizeof(int));
  3. for (i = 0; i <= nmemb; i++)
  4. iArray[i] = i;

如果没发现上面的代码错在哪了,回去对照下标准的循环语句。

另外C和C++中还有一个用于分配空间给字符串然后进行操作的俗语,不照着这样写经常会有bug:

  1. char *p, buf[256];
  2. gets(buf); // 其实这句不健全
  3. p = malloc(strlen(buf)+1);
  4. // C++: p = new char[strlen(buf)+1];
  5. if (p == NULL){
  6. // 严重错误,内存不足,分配失败,进行相应处理
  7. }
  8. strcpy(p, buf);

永远不要使用gets,因为没有办法限制输入的大小,这会导致安全问题。可能使用fgets来代替。其中的+1是因为C风格字符串最后有一个 ‘\0’,java中不存在这个问题。另外还可以用strdup来代替上面的分配内存并拷贝操作,但strdup不属于ANSI C。

在实际的程序中,malloc、realloc、strdup或其他分配内存的例程的返回值一定要如上进行检查。

在多路选择中使用else-if语句
眼前一亮的改写:

  1. if (argc == 3)
  2. if ((fin = fopen(argv[1], "r")) != NULL)
  3. if ((fout = fopen(argv[2], "w")) != NULL){
  4. while ((c = getc(fin)) != EOF)
  5. putc(c, fout);
  6. fclose(fin); fclose(fout);
  7. } else{
  8. print("Can't open output file %s\n", argv[2]);
  9. fclose(fin);
  10. }
  11. else
  12. print("Can't open input file %s\n", argv[1]);
  13. else
  14. printf("Usage: cp inputfile outputfile\n");

  1. if (argc != 3)
  2. printf("Usage: cp inputfile outputfile\n");
  3. else if ((fin = fopen(argv[1], "r")) == NULL)
  4. print("Can't open input file %s\n", argv[1]);
  5. else if ((fout = fopen(argv[2], "w")) == NULL){
  6. print("Can't open output file %s\n", argv[2]);
  7. fclose(fin);
  8. } else {
  9. while ((c = getc(fin)) != EOF)
  10. putc(c, fout);
  11. fclose(fin);
  12. fclose(fout);
  13. }

这种写法的基本原则就是尽可能在每个判断后面直接进行其对应的行为。

对于switch-case语句,case应该总是使用break结束,少数例外要加上注释。一个可以接受的贯穿多个case的情况是几个case有一样的对应代码:

  1. case '0':
  2. case '1':
  3. case '2':
  4. ...
  5. break;

函数宏

年长的C程序员喜欢把较短的函数写为宏(虽然我不年长但我也喜欢这么做-_-||),主要是由于省掉了函数调用的开销,效率更高了。其实用函数宏带来的麻烦远比好处多。

避免函数宏
函数宏带来的最大问题之一是:在定义中出现超过一次的参数可能会导致多次赋值。如:

  1. #define isupper(c) ((c) >= 'A' && (c) <= 'Z')

然后调用:

  1. while (isupper(c = getchar()))
  2. ...

自己思考会有什么问题。

使用ctype函数总是比自己实现的好。不嵌套具有副作用的例程,如getchar,也会使代码更加保险。这样改写会使代码更加清晰同时还能捕捉EOF:

  1. while ((c = getchar()) != EOF && isupper(c))
  2. ...

有时,多次赋值还会带来性能问题:

  1. #define ROUND_TO_INT(x) ((int) ((x)+(((x)>0)?0.5:-0.5)))
  2. ...
  3. size = ROUND_TO_INT(sqrt(dx*dx + dy*dy));

用括号括起来宏函数体和参数
如果坚持要用宏函数的话,记住:

  1. 宏函数的每个参数在表达式中都要用括号括起来。
  2. 宏函数的表达式本身也要用括号括起来。

下面的每个括号都是必须的:

  1. #define square(x) ((x) * (x))

即使这样做了也无法解决多次赋值问题。

在C++中,使用inline函数能避免这些问题,同时可能还能提供和宏一样的效率。

幻数

幻数:出现在程序中的常量、数组大小,字符位置、变换参数和其他文本数字值。

给幻数起个名字
源代码中的裸数字无法给出关于它自己的信息,这也增加了理解程序的难度。

任何除了0和1外的数字都有可能难以理解,应该给它起个名字。

如以下画柱状图的程序:

  1. fac = lim / 20; /* set scale factor */
  2. if (fac < 1)
  3. fac = 1;
  4. /* generate histogram */
  5. for (i = 0,col = 0; i < 27; i++, j++){
  6. col += 3;
  7. k = 21 - (let[i] / fac);
  8. star = (let[i] == 0)?' ': '*';
  9. for (j = k; j < 22;j++)
  10. draw(j, col, star);
  11. }
  12. draw(23, 2, ' '); /* label x axis */
  13. for (i = 'A'; i <= 'Z'; i++)
  14. printf("%c", i);

看的很艰难吧。那改成下面这样呢:

  1. enum {
  2. MINROW = 1,
  3. MINCOL = 1,
  4. MAXROW = 24,
  5. MAXCOL = 80,
  6. LABELROW = 1,
  7. NLET = 26,
  8. HEIGHT = MAXROW - 4,
  9. WIDTH = (MAXCOL-1)/NLET
  10. };
  11. ...
  12. fac = (lim + HEIGHT - 1) / HEIGHT; /* set scale factor */
  13. if (fac < 1)
  14. fac = 1;
  15. for (i = 0; i < NLET; i++){ /* generate histogram */
  16. if (let[i] == 0)
  17. continue;
  18. for (j = HEIGHT - let[i]/fac; j < HEIGHT;j++)
  19. draw(j+1 + LABELROW, (i+1)*WIDTH, '*');
  20. }
  21. draw(MAXROW-1, MINCOL+1, ' '); /* label x axis */
  22. for (i = 'A'; i <= 'Z'; i++)
  23. printf("%c", i);

程序改写之后,MAXROW之类的名字本身就会提醒我们它的作用,这样就更好理解程序了。更重要的是,现在你想要改任何参数就变得十分简单,只要改一下名字对应的值就好了。

定义数字为常量,不要定义为宏
C程序员传统上习惯用#define来管理幻数。C预处理器十分强大,但是也很蠢,宏由于会改变程序的结构,存在一定的风险。

在C和C++中,整型常量可以定义在enum中。C++中还可以使用const定义任意类型的常量,而Java中可以使用final。

C中虽然也有const,但它不能用作数组边界,所以C中更推荐enum。

注:本人对这个观点不是很认同。首先,目前自己还没碰到因为用宏定义数字而导致程序结构出问题的情况,另外,由于是直接的文本替换,因此有助于编译器把直接的计算在编译器就完成了,如果使用的是常量,就会多出来在运行时取常量值然后再计算的开销了,当然,有没有这个开销还和编译器的智能程度和实际怎么使用有关:

  1. #define LENGTH 30
  2. #define WIDTH 40
  3. // 这句编译器会在编译时直接进行计算,最终效果相当于
  4. // int area = 1200;
  5. int area = LENGTH * WIDTH;
  6. // 如果使用的是常量,就有可能实际要到flash中取两次值了

使用字符常量值,而不是整数
虽然字符常量值本质上是一个整数,但是明显看字符常量值更舒服和直观:

  1. if (c > 65)
  2. ...;
  3. if (c > 'A')
  4. ...;

另外,即使都是0,准确的写出其类型会有助于读者理解程序:

  1. str = 0;
  2. name[i] = 0;
  3. x = 0;
  4. // 改为
  5. str = NULL;
  6. name[i] = '\0';
  7. x = 0.0;

推荐将0用作整型字面值0,而其他的0则准确写出其类型,这直接就提供了一部分文档的作用。

使用语言特性来计算对象的大小
不要假定任意类型的大小,比如应该使用sizeof(int)而不是用2或4。

基于类似的理由,sizeof(array[0])可能比sizeof(int)更好,因为这样当改变数组类型时就少做一项修改了。

在Java中为数组提供了length字段:

  1. char buf[] = new char[1024];
  2. for (int i = 0; i < buf.length; i++)
  3. ...

而在C和C++中可以这么玩:

  1. #define NELEMS(array) (sizeof(array) / sizeof(array[0]))
  2. double dbuf[100];
  3. for (i = 0; i < NELEMS(dbuf); i++)
  4. ...

这里没有多重赋值问题,而且实际上在程序编译的时候计算已经完成了,因此效率非常高。这是对宏的一个很好的使用,因为它做的事情是函数做不了的:根据数组的声明计算它的大小。

嵌入式中使用确定大小的对象
这条是自己加的。

在嵌入式中,空间非常宝贵,为了最大化存储空间的使用,我们需要掌握当前使用的这个类型是多少位的以及有无符号。由于在不同设备上的同个类型的位数可能是不同的,因此为了写出与平台无关的代码,经常的做法是使用typedef或#define给不同基本类型起个带大小的别名,然后程序中直接使用这个别名,这样,在移植到不同的平台时只需要修改这个别名对应的基本类型就行了。

如,这是移植到MC9S12XEP100上的uCOS-II嵌入式操作系统中对不同类型的定义:

  1. /*
  2. **************************************************************************************************
  3. * DATA TYPES
  4. **************************************************************************************************
  5. */
  6. typedef unsigned char BOOLEAN;
  7. typedef unsigned char INT8U; /* Unsigned 8 bit quantity */
  8. typedef signed char INT8S; /* Signed 8 bit quantity */
  9. typedef unsigned int INT16U; /* Unsigned 16 bit quantity */
  10. typedef signed int INT16S; /* Signed 16 bit quantity */
  11. typedef unsigned long INT32U; /* Unsigned 32 bit quantity */
  12. typedef signed long INT32S; /* Signed 32 bit quantity */
  13. typedef float FP32; /* Single precision floating point */
  14. typedef double FP64; /* Double precision floating point */
  15. typedef unsigned char OS_STK; /* Each stack entry is 8-bit wide */
  16. typedef unsigned short OS_CPU_SR; /* Define size of CPU status register (PSW = 16 bits) */

注释

注释是为了帮助阅读程序。最棒的注释有助于理解程序,其会指出微妙的细节或者提供代码所做事情的综述。

别讲显而易见的事情
注释不是用来说显而易见的信息的。比如以下的注释是把读者当傻子?

  1. /*
  2. * default
  3. */
  4. default:
  5. break;
  6. /* return SUCCESS */
  7. return SUCCESS;
  8. zerocount++; /* Increment zero entry counter */
  9. /* Initialize "total" to "number_received" */
  10. node->total = node->number_received;

注释应该给出无法直接从代码得到的信息,或者把分散在源代码中的信息放到一起。
以下代码段中,代码本身已经够清楚了,这些注释没有什么意义:

  1. while ((c = getchar()) != EOF && isspace(c))
  2. ; /* skip white space */
  3. if (c == EOF) /* end of file */
  4. type = endoffile;
  5. else if (c == '(') /* left paren */
  6. type = leftparen;
  7. else if (c == ')') /* right paren */
  8. type = rightparen;
  9. else if (c == ';') /* semicolon */
  10. type = semicolon;
  11. else if (is_op(c)) /* operator */
  12. type = operator;
  13. else if (isdigit(c)) /* number */
  14. ...

注释函数和全局变量
全局变量趋于出现在程序的各个地方,因此注释它有助于提醒其作用。

每条函数的注释是阅读代码的一部分,如果代码不长,可能一行就够了。

有时代码会特别复杂,比如用到了一些高级算法或数据结构,这时注释中可以给出一些有助于理解代码的资源。

不要注释差的代码,重写它
当注释和代码一样难理解时,很可能需要修改代码了。

注释不应该与代码冲突
注释在一开始肯定是和代码一致的,但随着代码的重构,可能注释和代码就逐渐不同步了。

注释与代码的冲突会给读者带来困惑,许多不必要的bug就是由错误的注释带来的。所以在修改代码时随时保持注释和代码的一致性。

注释不止要和代码作用一致,还要支持代码。

以下注释的确解释了后面两行,但是它看上去和代码不符,代码说的是空格,而注释说的是新行。

  1. time(&now);
  2. strcpy(date,ctime(&now));
  3. /* get rid of trailing newline character copied from ctime */
  4. i = 0;
  5. while(date[i] >= ' ') i++;
  6. date[i] = 0;

这样就符合了:

  1. time(&now);
  2. strcpy(date,ctime(&now));
  3. /* get rid of trailing newline character copied from ctime */
  4. for (i = 0; date[i] != '\n'; i++)
  5. ;
  6. date[i] = '\0';

在C中甚至可以改进为这样(C中移除字符串中最后一个字符的俗语):

  1. time(&now);
  2. strcpy(date,ctime(&now));
  3. /* ctime() puts newline at end of string; delete it */
  4. date[strlen(data)-1] = '\0';

清晰,不要混淆
注释应该要帮助读者度过最困难的部分,而不是制造麻烦。

人们常常被要求注释所有东西。但是如果只是盲目的遵从规定就偏离了注释的初衷。注释是为了帮助读者理解程序中无法直接通过阅读而理解的部分。尽可能的写出易于理解的代码;你做的越好,需要的注释就越少。好代码比垃圾代码需要的注释少。

把好的编程风格变为习惯

如果你从一开始就考虑你的编程风格并花时间不断完善它,你就会养成这个好习惯。一旦成了骨子里的东西,你的潜意识就能帮助你解决大部分的细节,这样即使是在冲刺deadline时写出来的代码也不会太糟。

发表评论

表情:
评论列表 (有 0 条评论,222人围观)

还没有评论,来说两句吧...

相关阅读