1. 程式人生 > >《The Practice of Programming》讀書筆記(一)

《The Practice of Programming》讀書筆記(一)

最近在看《程式設計實踐》,據說這書是一個被名字毀了的好書。看了之後表示認同。其中的很多最佳實踐我之前已經在使用,但其中給了很好的歸納。另外還有一些以前沒有想到的,讓我感覺眼前一亮的。掌握這些最佳實踐能夠大大提高程式設計效率、可讀性,大大減少bug的概率。其中的實踐都是從工作中總結出來的,所以即使沒有看過這本書,有經驗的程式設計師也會自己摸索出很多實踐,因此對於越有經驗的程式設計師,看這本書的收穫就會相對越小;因此,建議新手程式設計師在掌握最基本技能後就直接學習此書,必會受益良多(即使現在可能有些東西不能理解,以後工作時也會慢慢明白這樣做的好處)。

這裡記錄下來書中的要點,主要為自己以後複習使用。

前言

像測試、除錯、可移植性、效能、設計取捨以及風格 —— 這些話題統稱程式設計實踐

簡單性、清晰性和通用性才是優質軟體的基石。

簡單性:保持程式短小精悍、方便管理。
清晰性:保證程式無論對人還是機器都易於理解。
通用性:讓程式在眾多場景下都能工作,並很好的適配新情形。
自動化:讓機器完成工作,把我們從瑣碎的工作中解放出來。

使用者、程式和程式元件之間的介面,是程式設計中的基礎,而衡量軟體成功程度的主要指標就是看介面設計和實現得如何。

程式設計風格

程式設計不只是讓語法正確、不出bug並執行足夠快。程式不只是給機器讀的,它還是給程式設計師讀的。編寫良好的程式更加易於理解和修改。良好的程式設計風格能降低bug概率。

命名

變數或函式的名字能夠表達其用途。命名應該儘可能做到能傳達資訊、簡潔、易記並且可讀。

全域性物件使用描述性名字,本地物件使用短名字
全域性物件可能出現在任何地方,所以名字應該足夠長並且足夠直觀能讓讀者看出其用途。

而本地物件因為有上下文提示,使用短名字就足夠了,特別是那些常用變數名如i、j、n這些大家都知道什麼用的變數,這時使用長名字就畫蛇添足了。

簡潔常會帶來清晰。

有很多命名規範。常用的比如:
1. 通過在頭或尾加p來代表指標,比如nodep。
2. 全域性變數的首字母大寫,Globals
3. 常量的全部字母大小,CONSTATS
4. ……

一致性比你具體使用哪種規範更重要,只要選擇了其中一種,就要保證(起碼在同一個模組中)一直使用下去。

保持一致
這裡的一致是指命名時相關物件的名字應該能表現出他們的關係以及差別。還有,同一個意思的詞的表達儘可能一致。如:

java程式碼:

class UserQueue{
   int noOfItemsInQ, frontOfTheQueue, queueCapacity;
   public int noOfUsersInQueue(){...}
}

其中不應同時使用Q、Queue和queue,另外其實根本不需要寫queue:

class UserQueue{
   int nitems, front, capacity;
   public int nusers(){...}
}

這樣已經足夠清晰了,不信你看:

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

另外, “items” 和 “users” 是同個東西,最好決定只使用其中一個。

函式名使用主動動詞
函式名應該基於主動動詞,可能要根據口語:

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

另外對比:

if(checkoctal(c)) ...
if(isoctal(c)) ...

第二個不僅讀的更順,而且還明確了返回結果。

準確
要按照名字正確實現,否則會誤導使用者。

表示式和語句

就像好的名字有助於讀者理解,表示式和語句也要寫的讓讀者足夠容易理解。保持程式碼一致的格式是會花點時間,但這是特別值得的。

用縮排表達結構
一致的縮排風格是表達程式結構的最簡單方式。
差的示例:

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

改為:

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

表示式使用最自然的形式
使用那種你能夠讀出來的形式。

if(!(block_id < actblks) || !(block_id >= unblocks))
   ...;
// 改為
if((block_id >= actblks) || (block_id < unblocks))
   ...;

使用括號來準確表達意思
括號可以表達分組,這樣可以使得意圖更加明顯,即使有的時候並不需要括號。就像上一個程式碼中的括號就不是必須的,但是這樣可以讓人更快的理解邏輯。

另外,使用括號還能避免因運算子優先順序導致的邏輯問題。如下面兩個if並不等價:

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

拆分複雜表示式
行數並不總是越少越好的,易懂更重要。比起:

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

你肯定更願意看到:

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

做到清晰
程式設計師會有寫最簡潔的程式碼的衝動,但是不要濫用各種技巧。要的是清晰的程式碼而不是聰明的程式碼。

“?:”運算子可以用於把四行的if-else縮減為一行,它很好用,但是不要濫用。

小心副作用
在C和C++中,並沒有定義副作用的執行順序,所以以下語句的結果是不確定的:

// 問題語句1
str[i++] = str[i++] = ' ';
// 問題語句2
array[i++] = i;

另外,以下語句並不會根據第一個讀入的yr值來決定第二個引數寫入的位置,因為在呼叫scanf時,第二個引數就已經確定了。

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

一致性和俗語

一致性產生更好的程式。如果對同一件事寫出來的程式幾乎一樣,那麼要是哪裡有語法錯誤可能一眼就可以看出來。

使用一致的縮排和括號風格
縮排可以表達結構。
很多人爭辯哪種程式碼佈局風格更好,但是特定風格的好壞其實遠沒有一致的風格更重要。選擇一種風格,一致的使用它,不要浪費時間來爭辯。

在使用if語句時,我們傾向於在單行語句時不使用括號,但是要小心else和if間的邏輯關係,有的時候不得不使用括號來明確else對應的是哪一個if。

如果你在修改的程式不是你寫的,請使用程式中現有的風格,即使你更喜歡你的風格。程式的一致性比你自己的風格重要,因為這會讓後面接手的人舒服。

使用俗語達成一致性
像自然語言一樣,程式語言也有俗語。
學習任何語言的一大要點就是熟悉這個語言的俗語。

c語言中的俗語:

遍歷陣列

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

遍歷連結串列

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

無限迴圈

for (;;)
   ...
// 或者
while (1)
   ...

在迴圈條件中巢狀賦值語句

while ((c = getchar()) != EOF)
   putchar(c);

縮排也是俗語,不要使用不尋常的垂直佈局:

for(
   ap = arr;
   ap < arr + 128;
   *ap++=0
   )
{
  ;
}
// 清晰性遠不如
for (ap = arr; ap < arr+128; ap++)
   *ap = 0;

使用俗語的一大優勢是讓你更容易發現不標準的語句,不標準的語句經常存在問題:

int i,*Array, nmemb;

iArray = malloc(nmemb * sizeof(int));
for (i = 0; i <= nmemb; i++)
   iArray[i] = i;

如果沒發現上面的程式碼錯在哪了,回去對照下標準的迴圈語句。

另外C和C++中還有一個用於分配空間給字串然後進行操作的俗語,不照著這樣寫經常會有bug:

char *p, buf[256];

gets(buf);  // 其實這句不健全
p = malloc(strlen(buf)+1);
// C++: p = new char[strlen(buf)+1];
if (p == NULL){
   // 嚴重錯誤,記憶體不足,分配失敗,進行相應處理
}
strcpy(p, buf);

永遠不要使用gets,因為沒有辦法限制輸入的大小,這會導致安全問題。可能使用fgets來代替。其中的+1是因為C風格字串最後有一個 ‘\0’,java中不存在這個問題。另外還可以用strdup來代替上面的分配記憶體並拷貝操作,但strdup不屬於ANSI C。

在實際的程式中,malloc、realloc、strdup或其他分配記憶體的例程的返回值一定要如上進行檢查。

在多路選擇中使用else-if語句
眼前一亮的改寫:

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

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

這種寫法的基本原則就是儘可能在每個判斷後面直接進行其對應的行為。

對於switch-case語句,case應該總是使用break結束,少數例外要加上註釋。一個可以接受的貫穿多個case的情況是幾個case有一樣的對應程式碼:

case '0':
case '1':
case '2':
   ...
   break;

函式巨集

年長的C程式設計師喜歡把較短的函式寫為巨集(雖然我不年長但我也喜歡這麼做-_-||),主要是由於省掉了函式呼叫的開銷,效率更高了。其實用函式巨集帶來的麻煩遠比好處多。

避免函式巨集
函式巨集帶來的最大問題之一是:在定義中出現超過一次的引數可能會導致多次賦值。如:

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

然後呼叫:

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

自己思考會有什麼問題。

使用ctype函式總是比自己實現的好。不巢狀具有副作用的例程,如getchar,也會使程式碼更加保險。這樣改寫會使程式碼更加清晰同時還能捕捉EOF:

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

有時,多次賦值還會帶來效能問題:

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

用括號括起來巨集函式體和引數
如果堅持要用巨集函式的話,記住:

  1. 巨集函式的每個引數在表示式中都要用括號括起來。
  2. 巨集函式的表示式本身也要用括號括起來。

下面的每個括號都是必須的:

#define square(x) ((x) * (x))

即使這樣做了也無法解決多次賦值問題。

在C++中,使用inline函式能避免這些問題,同時可能還能提供和巨集一樣的效率。

幻數

幻數:出現在程式中的常量、陣列大小,字元位置、變換引數和其他文字數字值。

給幻數起個名字
原始碼中的裸數字無法給出關於它自己的資訊,這也增加了理解程式的難度。

任何除了0和1外的數字都有可能難以理解,應該給它起個名字。

如以下畫柱狀圖的程式:

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

看的很艱難吧。那改成下面這樣呢:

enum {
   MINROW = 1,
   MINCOL = 1,
   MAXROW = 24,
   MAXCOL = 80,
   LABELROW = 1,
   NLET = 26,
   HEIGHT = MAXROW - 4,
   WIDTH = (MAXCOL-1)/NLET
};
   ...
   fac = (lim + HEIGHT - 1) / HEIGHT;        /* set scale factor */
   if (fac < 1)
      fac = 1;
   for (i = 0; i < NLET; i++){    /* generate histogram */
      if (let[i] == 0)
         continue;
      for (j = HEIGHT - let[i]/fac; j < HEIGHT;j++)
         draw(j+1 + LABELROW, (i+1)*WIDTH, '*');
   }
   draw(MAXROW-1, MINCOL+1, ' ');  /* label x axis */
   for (i = 'A'; i <= 'Z'; i++)
      printf("%c", i);

程式改寫之後,MAXROW之類的名字本身就會提醒我們它的作用,這樣就更好理解程式了。更重要的是,現在你想要改任何引數就變得十分簡單,只要改一下名字對應的值就好了。

定義數字為常量,不要定義為巨集
C程式設計師傳統上習慣用#define來管理幻數。C前處理器十分強大,但是也很蠢,巨集由於會改變程式的結構,存在一定的風險。

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

C中雖然也有const,但它不能用作陣列邊界,所以C中更推薦enum。

注:本人對這個觀點不是很認同。首先,目前自己還沒碰到因為用巨集定義數字而導致程式結構出問題的情況,另外,由於是直接的文字替換,因此有助於編譯器把直接的計算在編譯器就完成了,如果使用的是常量,就會多出來在執行時取常量值然後再計算的開銷了,當然,有沒有這個開銷還和編譯器的智慧程度和實際怎麼使用有關:

#define LENGTH 30
#define WIDTH  40

// 這句編譯器會在編譯時直接進行計算,最終效果相當於
// int area = 1200;
int area = LENGTH * WIDTH;
// 如果使用的是常量,就有可能實際要到flash中取兩次值了

使用字元常量值,而不是整數
雖然字元常量值本質上是一個整數,但是明顯看字元常量值更舒服和直觀:

if (c > 65)
   ...;
if (c > 'A') 
   ...;

另外,即使都是0,準確的寫出其型別會有助於讀者理解程式:

str = 0;
name[i] = 0;
x = 0;
// 改為
str = NULL;
name[i] = '\0';
x = 0.0;

推薦將0用作整型字面值0,而其他的0則準確寫出其型別,這直接就提供了一部分文件的作用。

使用語言特性來計算物件的大小
不要假定任意型別的大小,比如應該使用sizeof(int)而不是用2或4。

基於類似的理由,sizeof(array[0])可能比sizeof(int)更好,因為這樣當改變陣列型別時就少做一項修改了。

在Java中為陣列提供了length欄位:

char buf[] = new char[1024];

for (int i = 0; i < buf.length; i++)
   ...

而在C和C++中可以這麼玩:

#define NELEMS(array) (sizeof(array) / sizeof(array[0]))

double dbuf[100];

for (i = 0; i < NELEMS(dbuf); i++)
   ...

這裡沒有多重賦值問題,而且實際上在程式編譯的時候計算已經完成了,因此效率非常高。這是對巨集的一個很好的使用,因為它做的事情是函式做不了的:根據陣列的宣告計算它的大小。

嵌入式中使用確定大小的物件
這條是自己加的。

在嵌入式中,空間非常寶貴,為了最大化儲存空間的使用,我們需要掌握當前使用的這個型別是多少位的以及有無符號。由於在不同裝置上的同個型別的位數可能是不同的,因此為了寫出與平臺無關的程式碼,經常的做法是使用typedef或#define給不同基本型別起個帶大小的別名,然後程式中直接使用這個別名,這樣,在移植到不同的平臺時只需要修改這個別名對應的基本型別就行了。

如,這是移植到MC9S12XEP100上的uCOS-II嵌入式作業系統中對不同型別的定義:

/*
**************************************************************************************************
*                                          DATA TYPES
**************************************************************************************************
*/

typedef unsigned char  BOOLEAN;
typedef unsigned char  INT8U;         /* Unsigned  8 bit quantity                           */
typedef signed   char  INT8S;         /* Signed    8 bit quantity                           */
typedef unsigned int   INT16U;        /* Unsigned 16 bit quantity                           */
typedef signed   int   INT16S;        /* Signed   16 bit quantity                           */
typedef unsigned long  INT32U;        /* Unsigned 32 bit quantity                           */
typedef signed   long  INT32S;        /* Signed   32 bit quantity                           */
typedef float          FP32;          /* Single precision floating point                    */
typedef double         FP64;          /* Double precision floating point                    */

typedef unsigned char  OS_STK;        /* Each stack entry is 8-bit wide                     */
typedef unsigned short OS_CPU_SR;     /* Define size of CPU status register (PSW = 16 bits) */

註釋

註釋是為了幫助閱讀程式。最棒的註釋有助於理解程式,其會指出微妙的細節或者提供程式碼所做事情的綜述。

別講顯而易見的事情
註釋不是用來說顯而易見的資訊的。比如以下的註釋是把讀者當傻子?

/*
 * default
 */
 default:
    break;

/* return SUCCESS */
return SUCCESS;

zerocount++;  /* Increment zero entry counter */

/* Initialize "total" to "number_received" */
node->total = node->number_received;

註釋應該給出無法直接從程式碼得到的資訊,或者把分散在原始碼中的資訊放到一起。
以下程式碼段中,程式碼本身已經夠清楚了,這些註釋沒有什麼意義:

while ((c = getchar()) != EOF && isspace(c))
   ;                                     /* skip white space */
if (c == EOF)                            /* end of file */
   type = endoffile;
else if (c == '(')                       /* left paren */
   type = leftparen;
else if (c == ')')                       /* right paren */
   type = rightparen;
else if (c == ';')                       /* semicolon */
   type = semicolon; 
else if (is_op(c))                       /* operator */
   type = operator;
else if (isdigit(c))                     /* number */
...

註釋函式和全域性變數
全域性變數趨於出現在程式的各個地方,因此註釋它有助於提醒其作用。

每條函式的註釋是閱讀程式碼的一部分,如果程式碼不長,可能一行就夠了。

有時程式碼會特別複雜,比如用到了一些高階演算法或資料結構,這時註釋中可以給出一些有助於理解程式碼的資源。

不要註釋差的程式碼,重寫它
當註釋和程式碼一樣難理解時,很可能需要修改程式碼了。

註釋不應該與程式碼衝突
註釋在一開始肯定是和程式碼一致的,但隨著程式碼的重構,可能註釋和程式碼就逐漸不同步了。

註釋與程式碼的衝突會給讀者帶來困惑,許多不必要的bug就是由錯誤的註釋帶來的。所以在修改程式碼時隨時保持註釋和程式碼的一致性。

註釋不止要和程式碼作用一致,還要支援程式碼。

以下注釋的確解釋了後面兩行,但是它看上去和程式碼不符,程式碼說的是空格,而註釋說的是新行。

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

這樣就符合了:

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

在C中甚至可以改進為這樣(C中移除字串中最後一個字元的俗語):

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

清晰,不要混淆
註釋應該要幫助讀者度過最困難的部分,而不是製造麻煩。

人們常常被要求註釋所有東西。但是如果只是盲目的遵從規定就偏離了註釋的初衷。註釋是為了幫助讀者理解程式中無法直接通過閱讀而理解的部分。儘可能的寫出易於理解的程式碼;你做的越好,需要的註釋就越少。好程式碼比垃圾程式碼需要的註釋少。

把好的程式設計風格變為習慣

如果你從一開始就考慮你的程式設計風格並花時間不斷完善它,你就會養成這個好習慣。一旦成了骨子裡的東西,你的潛意識就能幫助你解決大部分的細節,這樣即使是在衝刺deadline時寫出來的程式碼也不會太糟。