從重複到重用

阿里云云棲社群發表於2019-01-19

前言

本文是我之前寫的文章——《你試過這樣寫C程式嗎》——的第二版,並把文章名改成更貼切的“從重複到重用”。

開發技術的發展,從第一次提出“函式/子程式”,實現程式碼級重用;到物件導向的“類”,重用資料結構與演算法;再到“動態連結庫”、“控制元件”等重用模組;到如今流行的雲端計算、微服務可重用整個系統。技術發展雖然日新月異,但本質都是重用,只是粒度不同。所以寫程式碼的動機都應是把重複的工作變成可重用的方案,其中重複的工作包括業務上重複的場景、技術上重複的程式碼等。合格的系統可以簡化當下重複的工作;優秀的系統還能預見未來重複的工作。

本文不談框架、不談架構,就談寫程式碼的那些事兒!後文始終圍繞一個問題的解決方案,不斷發現其中“重複”的程式碼,並提煉出“可重用”的抽象,持續“重構”。希望通過這個過程和大家分享一些發現重複程式碼和提煉可重用抽象的方法。

問題

作為貫穿全文的主線,這有一個任務需要開發一個程式來完成:有一份存有職員資訊(姓名、年齡、工資)的檔案“work.txt”,內容如下:

William 35 25000
Kishore 41 35000
Wallace 37 30000
Bruce 39 29999

要求從檔案(work.txt)中讀取員工薪酬,並輸出到螢幕上。
為所有工資小於三萬的員工漲 3000 元。
在螢幕上輸出薪資調整後的結果。
把調整後的結果儲存到原始檔案。
即執行的結果是螢幕上要有八行輸出,“work.txt”的內容將變成:

William 35 28000
Kishore 41 35000
Wallace 37 30000
Bruce 39 32999

測試

在明確了需求之後,第一步要做的是寫測試程式碼,而不是寫功能程式碼。《重構》一書中對重構的定義是:“在不改變程式碼外在行為的前提下,對程式碼做出修改,以改程式序的內部結構。”其中明確指出“程式碼外在行為”是不改變的!在不斷迭代重構時,“保證每次重構的行為不變”也是一項重複的工作,所以測試先行不僅能儘早地校驗對需求理解的正確性、還能避免重複測試。本文通過一段Shell指令碼完成以下工作:

初始化work.txt檔案。
檢查標準輸出的內容與期望的結果是否一致。
檢查修改後work.txt檔案的內容是否與期望一致。
清理現場。

#!/bin/sh

if [ $# -eq 0 ]; then
    echo "usage: $0 <c-source-file>" >&2
    exit -1
fi

input=$(cat <<EOF
William 35 25000
Kishore 41 35000
Wallace 37 30000
Bruce 39 29999
EOF
)

output=$(cat <<EOF
William 35 28000
Kishore 41 35000
Wallace 37 30000
Bruce 39 32999
EOF
)

echo "$input" > work.txt
echo "$input" > .expect.stdout.txt
echo "$output" >> .expect.stdout.txt
echo "$output" > .expect.work.txt
(gcc "$1" -o main && ./main | diff .expect.stdout.txt - && diff .expect.work.txt work.txt) && echo PASS || echo FAIL
rm -f main work.txt .expect.work.txt .expect.stdout.txt

將上述程式碼儲存成check.sh,待測試的原始檔名作為引數。如果程式通過,會顯示“PASS”,否則會輸出不同的行以及“FAIL”。

第一部分:可維護程式碼

第一版:It works

每位熟練的程式設計師都能快速地給出自己的實現。本文示例程式碼使用ANSI C99編寫,Mac下用gcc能正常編譯執行,其他環境未測試。選擇C語言是因為主流程式語言都或多或少借鑑它的語法,同時它的語法特性也足夠用於演示。

問題很簡單,簡單到把所有程式碼都塞到 main 函式裡也不覺得長:

#include <stdio.h>

int main(void) {
  struct {
    char name[8];
    int age;
    int salary;
  } e[4];
  FILE *istream, *ostream;
  int i;

  istream = fopen("work.txt", "r");
  for (i = 0; i < 4; i++) {
    fscanf(istream, "%s%d%d", e[i].name, &e[i].age, &e[i].salary);
    printf("%s %d %d
", e[i].name, e[i].age, e[i].salary);
    if (e[i].salary < 30000) {
      e[i].salary += 3000;
    }
  }
  fclose(istream);

  ostream = fopen("work.txt", "w");
  for (i = 0; i < 4; i++) {
    printf("%s %d %d
", e[i].name, e[i].age, e[i].salary);
    fprintf(ostream, "%s %d %d
", e[i].name, e[i].age, e[i].salary);
  }
  fclose(ostream);

  return 0;
}

其中第一個迴圈從 work.txt 中讀取4行資料,並把資訊輸出到螢幕(需求#1);同時為薪資小於三萬的職員增加三千元(需求#2);第二個迴圈遍歷所有資料,把調整後的結果輸出螢幕(需求#3),並儲存結果到 work.txt(需求#4)。

試試將上述程式碼儲存成1.c並執行./check.sh 1.c,螢幕上會輸出“PASS”,即通過測試。

第二版:清晰的程式碼,重構的基礎

第一版程式碼解決了問題,讓原來重複的調薪工作變成簡便的、可反覆使用的程式。如果它是C語言課堂作業的答案,看起來還不錯——至少縮排一致,也沒混用空格和製表符;但從軟體工程的角度來講,它簡直糟糕透了,因為沒有清晰的表達意圖:

魔法常量4重複出現,後續負責維護的程式設計師無法判斷它們是碰巧相等還是有其他原因必需相等。
檔名work.txt重複出現。
重複且不清晰的檔案指標型別定義,容易忽略ostream前面的*。
e和i變數命名不顧名思義。
變數的定義與使用離得太遠。
無異常處理,檔案可能不可讀。
借喬老爺子的話說:“看不見的地方也要用心做好”——這些程式碼的問題使用者雖然看不見也不在乎,但也要用心做好——已有幾處顯眼的地方出現重複。不過,在程式碼變得清晰之前,不應急著動手去重構,因為清晰的程式碼更容易找出重複!針對上述意圖不明的問題,準備對程式碼做以下調整:

確認數字4在三處的意義都是員工記錄數,因此定義共享常量#define RECORD_COUNT 4。
常量”work.txt”和4不同,內容雖然相同但意義不同:一個作輸入,一個作輸出。如果也只簡單的定義一個常量FILE_NAME共用,後續兩者獨立變化時,工作量並沒減少。所以去除重複程式碼時,切忌只看表面相同,背後意義相同的才是真正的相同,否則就像給所有常量1定義ONE別名一樣沒有意義。所以需要定義三個常量FILE_NAME、INPUT_FILE_NAME和OUTPUT_FILE_NAME。
用自定義的檔案型別typedef FILE File;替代FILE,可避免遺漏指標。
變數e是所有職員資訊,把變數名改成employees。
變數i是迭代過程的下標,把變數名改成index。
將index變數定義放到for語句中。
將File變數定義從頂部挪到各自使用之前的位置。
對檔案指標做異常檢查,當檔案無法開啟時輸出錯誤資訊並提前終止程式。
程式退出時用<stdlib.h>中更語義化的EXIT_FAILURE,正常退出時用

EXIT_SUCCESS。

你可能會問:“數字30000和3000也是魔法數字,為什麼不調整?”原因是此時它們即不重複也無歧義。整理後的完整程式碼如下:

#include <stdlib.h>
#include <stdio.h>

#define RECORD_COUNT 4

#define FILE_NAME "work.txt"
#define INPUT_FILE_NAME FILE_NAME
#define OUTPUT_FILE_NAME FILE_NAME

typedef FILE* File;

int main(void) {
  struct {
    char name[8];
    int age;
    int salary;
  } employees[RECORD_COUNT];

  File istream = fopen(INPUT_FILE_NAME, "r");
  if (istream == NULL) {
    fprintf(stderr, "Cannot open %s with r mode.
", INPUT_FILE_NAME);
    exit(EXIT_FAILURE);
  }
  for (int index = 0; index < RECORD_COUNT; index++) {
    fscanf(istream, "%s%d%d", employees[index].name, &employees[index].age, &employees[index].salary);
    printf("%s %d %d
", employees[index].name, employees[index].age, employees[index].salary);
    if (employees[index].salary < 30000) {
      employees[index].salary += 3000;
    }
  }
  fclose(istream);

  File ostream = fopen(OUTPUT_FILE_NAME, "w");
  if (ostream == NULL) {
    fprintf(stderr, "Cannot open %s with w mode.
", OUTPUT_FILE_NAME);
    exit(EXIT_FAILURE);
  }
  for (int index = 0; index < RECORD_COUNT; index++) {
    printf("%s %d %d
", employees[index].name, employees[index].age, employees[index].salary);
    fprintf(ostream, "%s %d %d
", employees[index].name, employees[index].age, employees[index].salary);
  }
  fclose(ostream);

  return EXIT_SUCCESS;
}

將以上程式碼儲存成2.c並執行./check.sh 2.c,得到期望的輸出PASS,證明本次重構沒有改變程式的行為。

第三版:程式碼對映需求

經過第二版的優化,單行程式碼的意圖已比較清晰,但還存在一些過早優化導致程式碼塊的含義不清晰。

例如第一個迴圈中耦合了“輸出到螢幕”和“調整薪資”兩個功能,好處是可減少一次迴圈,效能也許有些提升;但這兩個功能在需求中是相互獨立的,後續獨立變化的可能性更大。假設新需求是第一步輸出到螢幕後,要求使用者輸入命令,再決定是否要進行薪資調整工作。此時,對需求方而言只新增一個步驟,只有一個改動;但到了程式碼層面,卻不是新增一個步驟對應新增一塊程式碼,還會牽涉理論上不相關的程式碼塊;負責維護的程式設計師在不瞭解背景時,就不確定這兩段程式碼放在一起有沒有歷史原因,也就不敢輕易將它們拆開。當系統規模越大,這種與需求不是一一對應的程式碼就越讓維護人員手足無措!

回想日常開發,需求改動很小而程式碼卻牽一髮動全身,根源往往就是過早優化。“優化”和“通用”往往是對立的,優化的越徹底就與業務場景結合越緊密,通用性也越差。比如某個系統會在緩衝佇列中對收到的訊息進行排序,上線執行後發現因為產品設計等外部原因,訊息可能天然接近排好序,於是用插入排序代替快速排序等更通用的排序演算法,這就是一次不通用的優化:它讓系統的效能更好,但系統的適用面更窄。過早的優化就是過早的給系統能力設定天花板。

理想情況是程式碼塊與需求功能點一一對應,例如當前需求有4個功能點,得有4個獨立的程式碼塊與之對應。這樣做的好處是:當需求發生變化時,程式碼的修改也相對集中。因此,基於第二版本程式碼準備做以下調整:

拆分耦合的迴圈程式碼塊,每段程式碼塊都只完成一件事情。
用註釋明確標出每段程式碼塊對應的需求。
整理後的完整程式碼如下:

#include <stdlib.h>
#include <stdio.h>

#define RECORD_COUNT 4

#define FILE_NAME "work.txt"
#define INPUT_FILE_NAME FILE_NAME
#define OUTPUT_FILE_NAME FILE_NAME

typedef FILE* File;

int main(void) {
  struct {
    char name[8];
    int age;
    int salary;
  } employees[RECORD_COUNT];

  /* 從檔案讀入 */
  File istream = fopen(INPUT_FILE_NAME, "r");
  if (istream == NULL) {
    fprintf(stderr, "Cannot open %s with r mode.
", INPUT_FILE_NAME);
    exit(EXIT_FAILURE);
  }
  for (int index = 0; index < RECORD_COUNT; index++) {
    fscanf(istream, "%s%d%d", employees[index].name, &employees[index].age, &employees[index].salary);
  }
  fclose(istream);

  /* 1. 輸出到螢幕 */
  for (int index = 0; index < RECORD_COUNT; index++) {
    printf("%s %d %d
", employees[index].name, employees[index].age, employees[index].salary);
  }

  /* 2. 調整薪資 */
  for (int index = 0; index < RECORD_COUNT; index++) {
    if (employees[index].salary < 30000) {
      employees[index].salary += 3000;
    }
  }

  /* 3. 輸出調整後的結果 */
  for (int index = 0; index < RECORD_COUNT; index++) {
    printf("%s %d %d
", employees[index].name, employees[index].age, employees[index].salary);
  }

  /* 4. 儲存到檔案 */
  File ostream = fopen(OUTPUT_FILE_NAME, "w");
  if (ostream == NULL) {
    fprintf(stderr, "Cannot open %s with w mode.
", OUTPUT_FILE_NAME);
    exit(EXIT_FAILURE);
  }
  for (int index = 0; index < RECORD_COUNT; index++) {
    fprintf(ostream, "%s %d %d
", employees[index].name, employees[index].age, employees[index].salary);
  }
  fclose(ostream);

  return EXIT_SUCCESS;
}

將以上程式碼儲存成3.c並執行./check.sh 3.c,確保程式的行為沒有改變。

第二部分:物件導向風格

第四版:職員物件抽象

經過兩輪改造,程式碼結構已足夠清晰;現在可以開始重構,來梳理程式碼層次。

最顯眼的就是格式化輸出職員資訊:除了輸出流不同,格式、內容完全相同,四條需求中出現了三次。一般遇到相同/相似程式碼時,可以抽象出一個函式:相同的部分寫在函式體中,不同的部分作為引數傳入。此處,能抽象出一個以結構體資料和檔案流為入參的函式,但目前這個結構體還是匿名的,無法作為函式的引數,所以第一步得先給匿名的職員結構體取一個合適的型別名稱:

typedef struct _Employee {
  char name[8];
  int age;
  int salary;
} *Employee;

然後抽象公共函式用於格式化輸出Employee到File,這其中還耦合了兩個功能:

Employee序列化成字串。
序列化結果輸出到指定檔案流。
因為暫無獨立使用某項功能的場景,目前無需進一步拆分:

void employee_print(Employee employee, File ostream) {
  fprintf(ostream, "%s %d %d
", employee->name, employee->age, employee->salary);
}

Employee結構體+employee_print函式很容易聯想到物件導向的“類”。物件導向的本質是由一組功能獨立的物件組成系統,物件之間通過發訊息協作完成任務,不見得非要有class關鍵字,繼承、封裝、多型等語法糖。

物件的“功能獨立”,即高內聚,要求資料和運算元據的相關方法放在一起,大多數支援物件導向的程式語言都提供了class關鍵字,在語言層面強制捆綁,C語言並沒有這樣的語法,但可以制定編碼規範,讓資料結構與函式在物理上捱得更近。
“給物件發訊息”,不同的程式語言裡表現形式各不相同,例如在Java中foo.baz()就是向foo物件傳送baz訊息,C++中等價的語法是foo->baz(),Smalltalk中是foo baz,C語言則是baz(foo)。
綜上所述,雖然C語言通常被認為不是物件導向的語言,其實它也能支援物件導向風格。沿上述思路,可以抽象出職員物件的四個方法:

employee_read:建構函式,分配空間、輸入並反序列化,類似於Java的new。
employee_free:解構函式,釋放空間,即純手工的GC。
employee_print:序列化並輸出。
employee_adjust_salary:調整職員薪資,唯一的業務邏輯。
有了職員物件,程式不再只有一個main函式。假設把main函式看作應用層,其他函式看作類庫、框架或中介軟體,這樣程式有了層級,層間僅通過開放的介面通訊,即物件的封裝性。

在Java中有public、protected、default和private四種可見性修飾符,C語言的函式預設是公開的,加上static關鍵字後只在當前檔案可見。為避免應用層向物件隨意傳送訊息,約定只有在應用層用到的函式才公開,所以額外定義了public和private兩個修飾符,目前職員物件的四個方法都是公開的。

重構之後的完整程式碼如下:

#include <stdlib.h>
#include <stdio.h>

#define private static
#define public

#define RECORD_COUNT 4

#define FILE_NAME "work.txt"
#define INPUT_FILE_NAME FILE_NAME
#define OUTPUT_FILE_NAME FILE_NAME

typedef FILE *File;

/* 職員物件 */

typedef struct _Employee {
  char name[8];
  int age;
  int salary;
} *Employee;

public void employee_free(Employee employee) {
  free(employee);
}

public Employee employee_read(File istream) {
  Employee employee = (Employee) calloc(1, sizeof(struct _Employee));
  if (employee == NULL) {
    fprintf(stderr, "employee_read: out of memory
");
    exit(EXIT_FAILURE);
  }
  if (fscanf(istream, "%s%d%d", employee->name, &employee->age, &employee->salary) != 3) {
    employee_free(employee);
    return NULL;
  }
  return employee;
}

public void employee_print(Employee employee, File ostream) {
  fprintf(ostream, "%s %d %d
", employee->name, employee->age, employee->salary);
}

public void employee_adjust_salary(Employee employee) {
  if (employee->salary < 30000) {
    employee->salary += 3000;
  }
}

/* 應用層 */

int main(void) {
  Employee employees[RECORD_COUNT];

  /* 從檔案讀入 */
  File istream = fopen(INPUT_FILE_NAME, "r");
  if (istream == NULL) {
    fprintf(stderr, "Cannot open %s with r mode.
", INPUT_FILE_NAME);
    exit(EXIT_FAILURE);
  }
  for (int index = 0; index < RECORD_COUNT; index++) {
    employees[index] = employee_read(istream);
  }
  fclose(istream);

  /* 1. 輸出到螢幕 */
  for (int index = 0; index < RECORD_COUNT; index++) {
    employee_print(employees[index], stdout);
  }

  /* 2. 調整薪資 */
  for (int index = 0; index < RECORD_COUNT; index++) {
    employee_adjust_salary(employees[index]);
  }

  /* 3. 輸出調整後的結果 */
  for (int index = 0; index < RECORD_COUNT; index++) {
    employee_print(employees[index], stdout);
  }

  /* 4. 儲存到檔案 */
  File ostream = fopen(OUTPUT_FILE_NAME, "w");
  if (ostream == NULL) {
    fprintf(stderr, "Cannot open %s with w mode.
", OUTPUT_FILE_NAME);
    exit(EXIT_FAILURE);
  }
  for (int index = 0; index < RECORD_COUNT; index++) {
    employee_print(employees[index], ostream);
  }
  fclose(ostream);

  /* 釋放資源 */
  for (int index = 0; index < RECORD_COUNT; index++) {
    employee_free(employees[index]);
  }

  return EXIT_SUCCESS;
}

將程式碼儲存為4.c,照例執行./check.sh 4.c檢測是否有改變程式行為。

第五版:容器物件抽象

之前的重構,去除了詞法和句法上的重複,就像一篇文章裡的單詞和語句,接著可以看段落有沒有重複,即程式碼塊。

與employee_print類似,三段迴圈輸出職員資訊程式碼也是明顯的重複,可以抽象出employees_print,同時也抽象出另一個物件——職員列表——Employees。參考職員物件,可以抽象出四個與之對應的函式:

employees_read:建構函式,分配列表空間,並依次建立職員物件。
employees_free:解構函式,釋放列表空間,以及職員物件的空間。
employees_print:序列化並輸出列表中每一位職員資訊。
employees_adjust_salary:調整所有符合要求職員的薪資。
此時,main函式只需呼叫職員列表物件的方法,不再直接呼叫職員物件的方法,所以後者可見性從public降為private。

重構之後的完整程式碼如下:

#include <stdlib.h>
#include <stdio.h>

#define private static
#define public

#define RECORD_COUNT 4

#define FILE_NAME "work.txt"
#define INPUT_FILE_NAME FILE_NAME
#define OUTPUT_FILE_NAME FILE_NAME

typedef FILE *File;

/* 職員物件 */

typedef struct _Employee {
  char name[8];
  int age;
  int salary;
} *Employee;

private void employee_free(Employee employee) {
  free(employee);
}

private Employee employee_read(File istream) {
  Employee employee = (Employee) calloc(1, sizeof(struct _Employee));
  if (employee == NULL) {
    fprintf(stderr, "employee_read: out of memory
");
    exit(EXIT_FAILURE);
  }
  if (fscanf(istream, "%s%d%d", employee->name, &employee->age, &employee->salary) != 3) {
    employee_free(employee);
    return NULL;
  }
  return employee;
}

private void employee_print(Employee employee, File ostream) {
  fprintf(ostream, "%s %d %d
", employee->name, employee->age, employee->salary);
}

private void employee_adjust_salary(Employee employee) {
  if (employee->salary < 30000) {
    employee->salary += 3000;
  }
}

/* 職員列表物件 */

typedef Employee* Employees;

public Employees employees_read(File istream) {
  Employees employees = (Employees) calloc(RECORD_COUNT, sizeof(Employee));
  if (employees == NULL) {
    fprintf(stderr, "employees_read: out of memory
");
    exit(EXIT_FAILURE);
  }
  for (int index = 0; index < RECORD_COUNT; index++) {
    employees[index] = employee_read(istream);
  }
  return employees;
}

public void employees_print(Employees employees, File ostream) {
  for (int index = 0; index < RECORD_COUNT; index++) {
    employee_print(employees[index], ostream);
  }
}

public void employees_adjust_salary(Employees employees) {
  for (int index = 0; index < RECORD_COUNT; index++) {
    employee_adjust_salary(employees[index]);
  }
}

public void employees_free(Employees employees) {
  for (int index = 0; index < RECORD_COUNT; index++) {
    employee_free(employees[index]);
  }
  free(employees);
}

/* 應用層 */

int main(void) {
  /* 從檔案讀入 */
  File istream = fopen(INPUT_FILE_NAME, "r");
  if (istream == NULL) {
    fprintf(stderr, "Cannot open %s with r mode.
", INPUT_FILE_NAME);
    exit(EXIT_FAILURE);
  }
  Employees employees = employees_read(istream);
  fclose(istream);

  /* 1. 輸出到螢幕 */
  employees_print(employees, stdout);

  /* 2. 調整薪資 */
  employees_adjust_salary(employees);

  /* 3. 輸出調整後的結果 */
  employees_print(employees, stdout);

  /* 4. 儲存到檔案 */
  File ostream = fopen(OUTPUT_FILE_NAME, "w");
  if (ostream == NULL) {
    fprintf(stderr, "Cannot open %s with w mode.
", OUTPUT_FILE_NAME);
    exit(EXIT_FAILURE);
  }
  employees_print(employees, ostream);
  fclose(ostream);

  /* 釋放資源 */
  employees_free(employees);

  return EXIT_SUCCESS;
}

不要忘記執行./check.sh作迴歸測試。

第六版:輸入輸出抽象

此時的main函式已經比較清爽,剩下一處明顯的重複:開啟檔案並檢查檔案是否正常開啟。這屬於檔案相關的操作,可以抽象出一個file_open代替fopen:

private File file_open(char* filename, char* mode) {
  File stream = fopen(filename, mode);
  if (stream == NULL) {
    fprintf(stderr, "Cannot open %s with %s mode.
", filename, mode);
    exit(EXIT_FAILURE);
  }
  return stream;
}

接著可以繼續抽象職員列表物件的輸入和輸出方法:

employees_input:從檔案中獲取資料並建立職員列表物件。
employees_output:將職員列表物件的內容輸出到檔案。
重構後employees_read不再被main訪問,所以改成private。重構後的完整程式碼如下:

#include <stdlib.h>
#include <stdio.h>

#define private static
#define public

#define RECORD_COUNT 4

#define FILE_NAME "work.txt"
#define INPUT_FILE_NAME FILE_NAME
#define OUTPUT_FILE_NAME FILE_NAME

typedef FILE *File;
typedef char* String;

/* 職員物件 */

typedef struct _Employee {
  char name[8];
  int age;
  int salary;
} *Employee;

private void employee_free(Employee employee) {
  free(employee);
}

private Employee employee_read(File istream) {
  Employee employee = (Employee) calloc(1, sizeof(struct _Employee));
  if (employee == NULL) {
    fprintf(stderr, "employee_read: out of memory
");
    exit(EXIT_FAILURE);
  }
  if (fscanf(istream, "%s%d%d", employee->name, &employee->age, &employee->salary) != 3) {
    employee_free(employee);
    return NULL;
  }
  return employee;
}

private void employee_print(Employee employee, File ostream) {
  fprintf(ostream, "%s %d %d
", employee->name, employee->age, employee->salary);
}

private void employee_adjust_salary(Employee employee) {
  if (employee->salary < 30000) {
    employee->salary += 3000;
  }
}

/* 職員列表物件 */

typedef Employee* Employees;

private Employees employees_read(File istream) {
  Employees employees = (Employees) calloc(RECORD_COUNT, sizeof(Employee));
  if (employees == NULL) {
    fprintf(stderr, "employees_read: out of memory
");
    exit(EXIT_FAILURE);
  }
  for (int index = 0; index < RECORD_COUNT; index++) {
    employees[index] = employee_read(istream);
  }
  return employees;
}

public void employees_print(Employees employees, File ostream) {
  for (int index = 0; index < RECORD_COUNT; index++) {
    employee_print(employees[index], ostream);
  }
}

public void employees_adjust_salary(Employees employees) {
  for (int index = 0; index < RECORD_COUNT; index++) {
    employee_adjust_salary(employees[index]);
  }
}

public void employees_free(Employees employees) {
  for (int index = 0; index < RECORD_COUNT; index++) {
    employee_free(employees[index]);
  }
  free(employees);
}

/* I/O層 */

private File file_open(String filename, String mode) {
  File stream = fopen(filename, mode);
  if (stream == NULL) {
    fprintf(stderr, "Cannot open %s with %s mode.
", filename, mode);
    exit(EXIT_FAILURE);
  }
  return stream;
}

public Employees employees_input(String filename) {
  File istream = file_open(filename, "r");
  Employees employees = employees_read(istream);
  fclose(istream);
  return employees;
}

public void employees_output(Employees employees, String filename) {
  File ostream = file_open(filename, "w");
  employees_print(employees, ostream);
  fclose(ostream);
}

/* 應用層 */

int main(void) {
  Employees employees = employees_input(INPUT_FILE_NAME); /* 從檔案讀入 */
  employees_print(employees, stdout); /* 1. 輸出到螢幕 */
  employees_adjust_salary(employees); /* 2. 調整薪資 */
  employees_print(employees, stdout);/* 3. 輸出調整後的結果 */
  employees_output(employees, OUTPUT_FILE_NAME);/* 4. 儲存到檔案 */
  employees_free(employees); /* 釋放資源 */

  return EXIT_SUCCESS;
}

別忘記執行./check.sh。

第三部分:函數語言程式設計

第七版:容器迭代重用

現在,main裡只用到了職員列表相關的函式,且程式碼和需求幾乎一一對應。這些函式可以看成職員管理領域的DSL,領域特定語言是業務和技術雙方的共識,理論上需求不變,基於DSL開發的業務程式碼也不變。之前所有的改動僅要求main行為一致,後續的重構還要儘量保證main自身也無任何變化,即API向後相容。

回到繼續挖掘程式碼中重複的問題上,其中職員列表方法中幾乎都有一個for迴圈:for (int index = 0; index < RECORD_COUNT; index++) { … },例如調整薪資和釋放空間兩段程式碼:

for (int index = 0; index < RECORD_COUNT; index++) {
  employee_adjust_salary(employees[index]);
}

for (int index = 0; index < RECORD_COUNT; index++) {
  employee_free(employees[index]);
}

除了迴圈體中分別呼叫了employee_adjust_salary和employee_free,其餘都一摸一樣,即它們的迭代規則相同,而迴圈體不同。是否有可能自定義一個for語句代替這些重複的迭代?

在大多數程式語言中,if、for等控制語句是一種特殊的存在,開發者通常無法自定義。這是if和for在大多數語言中的樣子:

if (condition) {
  ...
}

for (init; term; inc) {
  ...
}
如果把它們想象成是函式,語法可以改成更熟悉的函式呼叫形式:

if (condition, {
  ...
});

for (init, term, inc, {
  ...
});

和普通函式呼叫相比,唯一不同的是允許花括號包圍的程式碼片段作為引數。因此,若程式語言允許程式碼作為函式的引數,那就能自定義新的控制語句!這句話隱含了兩個語言特性:

程式碼是一種資料型別。
程式碼型別的資料可作為函式的引數。
所有程式語言都包含一套型別系統,它決定資料的型別,而資料的型別又決定資料的功能。例如,數值型別可以做四則運算;字串型別的資料可以拼接、查詢、替換等;程式碼如果也是一種資料型別,就可以隨時“執行”它。C語言中具備“執行”能力的元素就是“函式”,函式之於程式碼型別,猶如int、double之於數值型別,都只是C這個特定程式語言對特定型別的特定實現,換成Visual Basic改叫“過程”,換成Java又稱作“成員方法”。

至於特性#2,它正是函數語言程式設計的本質!提到函式式風格,腦海中通常會閃過一些耳熟能詳的詞彙:無副作用、無狀態、易於並行程式設計,甚至是Lisp那扭曲的字首表示式。追根溯源,函數語言程式設計源自λ演算——函式能作為值傳遞給其他函式或由其他函式返回——其本質是函式作為型別系統中的“第一等公民”(First-Class),符合以下四項要求:

可以用變數命名。
可以提供給過程作為引數。
可以由過程作為結果返回。
可以包含在資料結構中。
對照之下會驚訝地發現,C語言這門看似與函數語言程式設計最遠的上古程式語言,利用函式指標,居然也完全符合上述條件。觀察employee_adjust_salary和employee_free兩個函式,都只有一個Employee型別的引數且沒有返回值,翻譯成C語言就是typedef void (*EmployeeFn)(Employee),把它作為函式的引數,就能抽象出:

private void employees_each(Employees employees, EmployeeFn fn) {
  for (int index = 0; index < RECORD_COUNT; index++) {
    fn(employees[index]);
  }
}

在函式式語言中,這類將函式作為引數或返回值的函式稱為高階函式,C語言裡稱為控制語句。用這個自定義的控制語句代替原生的for迴圈,則程式碼可以簡化成:

employees_each(employees, employee_adjust_salary);
employees_each(employees, employee_free);

不過,此時還只解決了一半問題:employees_read和employees_print中依然有重複的for迴圈,並無法用employees_each簡化。原因是這些迴圈體中函式呼叫的引數數目與型別和EmployeeFn不相容:

employee_read:包含File型別的引數,返回Employee型別。
employee_print:包含Employee和File兩類引數,無返回值。
EmployeeFn:包含Employee型別的引數,無返回值。
想涵蓋所有場景,最簡單的方法就是提取一個引數與返回結果的全集——Employee (*EmployeeFn)(Employee, File)——包含Employee和File兩個型別的引數,且返回Employee型別的結果。用新介面重構Employee的四個方法:

忽略無用的引數。
除了employee_free返回NULL,其他都返回Employee入參。
同時,需要改造employees_each去適應新介面:加入File引數,以及返回處理結果。在程式設計的語義中,單純利用副作用的迭代被稱為foreach,而關注迭代每個元素的處理結果則稱為map,即對映。因此,用employees_map取代之前的

employees_each:

private Employees employees_map(Employees employees, File stream, EmployeeFn fn) {
  for (int index = 0; index < RECORD_COUNT; index++) {
    employees[index] = fn(employees[index], stream);
  }
  return employees;
}

重構後的完整程式碼如下:

#include <stdlib.h>
#include <stdio.h>

#define private static
#define public

#define RECORD_COUNT 4

#define FILE_NAME "work.txt"
#define INPUT_FILE_NAME FILE_NAME
#define OUTPUT_FILE_NAME FILE_NAME

typedef FILE *File;
typedef char* String;

/* 職員物件 */

typedef struct _Employee {
  char name[8];
  int age;
  int salary;
} *Employee;

typedef Employee (*EmployeeFn)(Employee, File);

private Employee employee_free(Employee employee, File stream) {
  free(employee);
  return NULL;
}

private Employee employee_read(Employee employee, File istream) {
  employee = (Employee) calloc(1, sizeof(struct _Employee));
  if (employee == NULL) {
    fprintf(stderr, "employee_read: out of memory
");
    exit(EXIT_FAILURE);
  }
  if (fscanf(istream, "%s%d%d", employee->name, &employee->age, &employee->salary) != 3) {
    employee_free(employee, NULL);
    return NULL;
  }
  return employee;
}

private Employee employee_print(Employee employee, File ostream) {
  fprintf(ostream, "%s %d %d
", employee->name, employee->age, employee->salary);
  return employee;
}

private Employee employee_adjust_salary(Employee employee, File stream) {
  if (employee->salary < 30000) {
    employee->salary += 3000;
  }
  return employee;
}

/* 職員列表物件 */

typedef Employee* Employees;

private Employees employees_map(Employees employees, File stream, EmployeeFn fn) {
  for (int index = 0; index < RECORD_COUNT; index++) {
    employees[index] = fn(employees[index], stream);
  }
  return employees;
}

private Employees employees_read(File istream) {
  Employees employees = (Employees) calloc(RECORD_COUNT, sizeof(Employee));
  if (employees == NULL) {
    fprintf(stderr, "employees_read: out of memory
");
    exit(EXIT_FAILURE);
  }
  return employees_map(employees, istream, employee_read);
}

public void employees_print(Employees employees, File ostream) {
  employees_map(employees, ostream, employee_print);
}

public void employees_adjust_salary(Employees employees) {
  employees_map(employees, NULL, employee_adjust_salary);
}

public void employees_free(Employees employees) {
  employees_map(employees, NULL, employee_free);
  free(employees);
}

/* I/O層 */

private File file_open(String filename, String mode) {
  File stream = fopen(filename, mode);
  if (stream == NULL) {
    fprintf(stderr, "Cannot open %s with %s mode.
", filename, mode);
    exit(EXIT_FAILURE);
  }
  return stream;
}

public Employees employees_input(String filename) {
  File istream = file_open(filename, "r");
  Employees employees = employees_read(istream);
  fclose(istream);
  return employees;
}

public void employees_output(Employees employees, String filename) {
  File ostream = file_open(filename, "w");
  employees_print(employees, ostream);
  fclose(ostream);
}

/* 應用層 */

int main(void) {
  Employees employees = employees_input(INPUT_FILE_NAME); /* 從檔案讀入 */
  employees_print(employees, stdout); /* 1. 輸出到螢幕 */
  employees_adjust_salary(employees); /* 2. 調整薪資 */
  employees_print(employees, stdout);/* 3. 輸出調整後的結果 */
  employees_output(employees, OUTPUT_FILE_NAME);/* 4. 儲存到檔案 */
  employees_free(employees); /* 釋放資源 */

  return EXIT_SUCCESS;
}

這一系列的改造展示了“程式碼即資料”的一些好處:使用不支援函數語言程式設計的語言開發,將迫使我們永遠在語言恰好提供的基礎功能上工作;而“程式碼即資料”讓我們擺脫這樣的束縛,允許自定義控制語句。例如,Java 5引入foreach語法糖、Java 7引入try-with-resource語法糖,在Java 8之前想要任何新的語言特性只能等Oracle大發慈悲,Java 8之後想要任何語言特性就可以自給自足!

經過這麼大的改造,切勿忘記測試!

第八版:動態作用域與上下文包裝

上一版本的程式碼雖然可以工作,但也暴露出一個常見問題:函式的引數不斷膨脹。這個問題在程式的層次不斷增加過程會慢慢滋生。例如函式A會呼叫B、B又呼叫C,假設C需要一個檔案物件,假設B中並不建立檔案物件,就得從A依次傳遞到B再傳遞到C。函式呼叫的層次越深,資料逐層傳遞的問題就越嚴重,上層函式的入參就會爆炸!

這類函式引數過多且逐層傳遞的問題,最簡單的解決方法就是使用全域性變數。例如定義一個全域性的檔案物件,指向當前輸入/輸出的目標,這樣就能去除所有的檔案物件入參。全域性變數的弊端是很難判斷它的影響範圍,不加限制地使用全域性變數就和無約束地使用goto一樣,程式碼會迅速變成義大利麵條。所以,建議有節制地使用全域性變數:用完之後及時將值恢復。例如以下程式碼:

int is_debug = 0;

void a() {
  if (is_debug == 1) {
    printf("debug is enable
");
  }
  printf("call a()
");
}

void b() {
  a();
  printf("call b()
");
}

void c() {
  int original = is_debug;
  is_debug = 1;
  b();
  is_debug = original;
}

其中函式c臨時開啟了除錯選項,並在退出前恢復成原始值。一旦忘記恢復,後續所有除錯資訊就都會輸出,惡夢就會開始。為避免這種尷尬問題,可以利用上一版本中提到的函數語言程式設計的方法,將重複的開啟選項、恢復工作抽象成函式:

typedef void (*Callback)(void);

void with_debug(Callback fn) {
  int original = is_debug;
  is_debug = 1;
  fn();
  is_debug = original;
}

void c() {
  with_debug(b);
}

像with_debug這種負責資源分配再自動回收(或資源修改再自動恢復)工作的函式稱為上下文包裝器(wrapper),開啟除錯選項是一個常見的應用場景,還可以用於自動關閉開啟的檔案物件(例如Java 7的try-with-resources)。不過,目前的解決方案在多執行緒環境下依然有問題,為避免不同的執行緒之間相互衝突,理想的方案是採用類似Java中的ThreadLocal包裝所有全域性變數,C語言的多執行緒方案POSIX thread有Thread Specific元件實現類似的執行緒特有資料功能,此處就不展開討論。

綜上所述,我們真正需要的功能似乎是一種程式碼的包裝能力:全域性變數某個特定的值只在指定範圍內生效(包括範圍內程式碼呼叫的函式、呼叫函式的呼叫等等),類似於會話級別的變數。這種功能被裁剪的全域性變數在程式語言中稱為動態作用域(Dynamic Scope)變數。

大多數主流程式語言只支援靜態作用域——也叫詞法作用域——在編譯時靜態確定的作用域;但動態作用域是在執行過程中動態確定的。簡言之,靜態作用域由程式碼的層次結構決定,動態作用域由呼叫的堆疊層次結構決定。以下程式碼是Perl語言動態作用域變數的示例,儲存成demo.pl,執行perl demo.pl能輸出$v = 1:

sub foo {
    print "$v = $v
";
}

sub baz {
    local $v = 1;
    foo;
}

baz;

回到重構問題,利用動態作用域的思路,可以抽象出一個檔案物件包裝器:用指定檔案替換全域性的檔案流,退出時恢復。C語言提供了開啟指定檔案並替代標準輸入輸出流的函式——freopen——但卻沒自帶恢復的功能,因此不同的平臺恢復方法不同,本文以類UNIX環境為例,在unistd.h包下有dup和fdopen兩個函式,分別用於克隆和恢復檔案控制程式碼。示例程式碼如下:

void file_with(String filename, String mode) {
  int handler = dup(mode[0] == `r`? 0: 1); /* 克隆檔案控制程式碼 */
  File stream = freopen(filename, mode, mode[0] == `r`? stdin: stdout);
  if (stream == NULL) {
    fprintf(stderr, "Cannot open %s with %s mode.
", filename, mode);
    exit(EXIT_FAILURE);
  }
  /* TODO */
  fclose(stream);
  fdopen(handler, mode);                   /* 完成後恢復標準IO */
}

有了這個功能,可以刪除掉所有函式和介面的File file引數!唯一真正和檔案相關的只剩下employees_input和employees_output,它們分別呼叫Employees employees_read()和void employees_print(Employees),為了使用file_with做統一的重定向,利用上一版介面全集的方法,把它們的介面統一改成typedef Employees (*EmployeesFn)(Employees);。最終,重構後的完整程式碼如下:

#include <stdlib.h>
#include <stdio.h>

#include <unistd.h>

#define private static
#define public

#define RECORD_COUNT 4

#define FILE_NAME "work.txt"
#define INPUT_FILE_NAME FILE_NAME
#define OUTPUT_FILE_NAME FILE_NAME

typedef FILE *File;
typedef char* String;

/* 職員物件 */

typedef struct _Employee {
  char name[8];
  int age;
  int salary;
} *Employee;

typedef Employee (*EmployeeFn)(Employee);

private Employee employee_free(Employee employee) {
  free(employee);
  return NULL;
}

private Employee employee_read(Employee employee) {
  employee = (Employee) calloc(1, sizeof(struct _Employee));
  if (employee == NULL) {
    fprintf(stderr, "employee_read: out of memory
");
    exit(EXIT_FAILURE);
  }
  if (scanf("%s%d%d", employee->name, &employee->age, &employee->salary) != 3) {
    employee_free(employee);
    return NULL;
  }
  return employee;
}

private Employee employee_print(Employee employee) {
  printf("%s %d %d
", employee->name, employee->age, employee->salary);
  return employee;
}

private Employee employee_adjust_salary(Employee employee) {
  if (employee->salary < 30000) {
    employee->salary += 3000;
  }
  return employee;
}

/* 職員列表物件 */

typedef Employee* Employees;

typedef Employees (*EmployeesFn)(Employees);

private Employees employees_map(Employees employees, EmployeeFn fn) {
  for (int index = 0; index < RECORD_COUNT; index++) {
    employees[index] = fn(employees[index]);
  }
  return employees;
}

private Employees employees_read(Employees employees) {
  employees = (Employees) calloc(RECORD_COUNT, sizeof(Employee));
  if (employees == NULL) {
    fprintf(stderr, "employees_read: out of memory
");
    exit(EXIT_FAILURE);
  }
  return employees_map(employees, employee_read);
}

public Employees employees_print(Employees employees) {
  return employees_map(employees, employee_print);
}

public void employees_adjust_salary(Employees employees) {
  employees_map(employees, employee_adjust_salary);
}

public void employees_free(Employees employees) {
  employees_map(employees, employee_free);
  free(employees);
}

/* I/O層 */

private File file_open(String filename, String mode) {
  File stream = freopen(filename, mode, mode[0] == `r`? stdin: stdout);
  if (stream == NULL) {
    fprintf(stderr, "Cannot open %s with %s mode.
", filename, mode);
    exit(EXIT_FAILURE);
  }
  return stream;
}

private Employees file_with(String filename, String mode, Employees employees, EmployeesFn fn) {
  int handler = dup(mode[0] == `r`? 0: 1); /* 克隆檔案控制程式碼 */
  File stream = file_open(filename, mode);
  employees = fn(employees);
  fclose(stream);
  fdopen(handler, mode);                   /* 完成後恢復標準IO */
  return employees;
}

public Employees employees_input(String filename) {
  return file_with(filename, "r", NULL, employees_read);
}

public void employees_output(Employees employees, String filename) {
  file_with(filename, "w", employees, employees_print);
}

/* 應用層 */

int main(void) {
  Employees employees = employees_input(INPUT_FILE_NAME); /* 從檔案讀入 */
  employees_print(employees); /* 1. 輸出到螢幕 */
  employees_adjust_salary(employees); /* 2. 調整薪資 */
  employees_print(employees); /* 3. 輸出調整後的結果 */
  employees_output(employees, OUTPUT_FILE_NAME); /* 4. 儲存到檔案 */
  employees_free(employees); /* 釋放資源 */

  return EXIT_SUCCESS;
}

這一版本改動非常大,連應用層介面都有不向下相容的改動,所以不要忘記迴歸測試。

本節介紹了一個重構的黑科技——動態作用域。它很有用,Web系統中Session變數就是動態作用域;但它也會加大判斷程式碼所處上下文的難度,導致行為不易預測。比如JavaScript中的this是JS中唯一一個動態作用域的變數,看看社群對this的抱怨就知道它的可怕了,它的值由函式的呼叫方決定,很難預測後續的系統維護者會把這個函式繫結到哪個物件上。

簡言之,動態有風險,入坑需謹慎!

第九版:資料結構替換

前文都在討論如何讓程式碼變得更抽象、更加可維護,但到底有沒有取得期望的效果,需要一個例子來證明。

之前的版本中,職員列表物件採用的底層儲存方案是固定長度為4的陣列結構,如果未來”work.txt”檔案中的記錄數不固定,希望把底層的資料結構從陣列改成更合適的單連結串列結構。這個需求是底層資料結構的改造,理論上與應用層無關,類似從MySQL遷移到Oracle,理論上至多隻能影響持久層程式碼,業務邏輯層等不相關的程式碼是不應該有任何修改的。所以,先評估一下這個需求涉及的變更點:

資料結構變化,職員列表結構體struct _Employees必然發生變化。
接著,職員列表物件的建構函式employees_read也會發生變化。
然後,與建構函式對應的解構函式employees_print也會變化。
最後,資料結構的迭代方法也會變化employees_map。
除了以上四點,其他任何與資料結構本身無關的程式碼都不應該發生變化。所以,程式碼重構完並通過測試之後,如果所有的改動範圍確實只出現在上述四點中,證明前文所有的改造有效——只改動與需求相關的程式碼段;否則,證明程式碼抽象程度依舊不夠,一段程式碼中還耦合著多個業務邏輯,依舊牽一髮動全身。

最終重構後的完整程式碼如下,改造過程此處就不再詳述,大家可以一起動手試著重構看看。

#include <stdlib.h>
#include <stdio.h>

#include <unistd.h>

#define private static
#define public

#define FILE_NAME "work.txt"
#define INPUT_FILE_NAME FILE_NAME
#define OUTPUT_FILE_NAME FILE_NAME

typedef FILE *File;
typedef char* String;

/* 職員物件 */

typedef struct _Employee {
  char name[8];
  int age;
  int salary;
} *Employee;

typedef Employee (*EmployeeFn)(Employee);

private Employee employee_free(Employee employee) {
  free(employee);
  return NULL;
}

private Employee employee_read(Employee employee) {
  employee = (Employee) calloc(1, sizeof(struct _Employee));
  if (employee == NULL) {
    fprintf(stderr, "employee_read: out of memory
");
    exit(EXIT_FAILURE);
  }
  if (scanf("%s%d%d", employee->name, &employee->age, &employee->salary) != 3) {
    employee_free(employee);
    return NULL;
  }
  return employee;
}

private Employee employee_print(Employee employee) {
  printf("%s %d %d
", employee->name, employee->age, employee->salary);
  return employee;
}

private Employee employee_adjust_salary(Employee employee) {
  if (employee->salary < 30000) {
    employee->salary += 3000;
  }
  return employee;
}

/* 職員列表物件 */

typedef struct _Employees {
  Employee employee;
  struct _Employees *next;
} *Employees;

typedef Employees (*EmployeesFn)(Employees);

private Employees employees_map(Employees employees, EmployeeFn fn) {
  for (Employees p = employees; p; p = p->next) {
    p->employee = fn(p->employee);
  }
  return employees;
}

private Employees employees_read(Employees head) {
  Employees tail = NULL;
  for (;;) {
    Employee employee = employee_read(NULL);
    if (employee == NULL) {
      return head;
    }

    Employees employees = (Employees) calloc(1, sizeof(Employees));
    if (employees == NULL) {
      fprintf(stderr, "employees_read: out of memory
");
      exit(EXIT_FAILURE);
    }

    if (tail == NULL) {
      head = tail = employees;
    } else {
      tail->next = employees;
      tail = tail->next;
    }
    tail->employee = employee;
  }
}

public Employees employees_print(Employees employees) {
  return employees_map(employees, employee_print);
}

public void employees_adjust_salary(Employees employees) {
  employees_map(employees, employee_adjust_salary);
}

public void employees_free(Employees employees) {
  employees_map(employees, employee_free);
  while (employees) {
    Employees e = employees;
    employees = employees->next;
    free(e);
  }
}

/* I/O層 */

private File file_open(String filename, String mode) {
  File stream = freopen(filename, mode, mode[0] == `r`? stdin: stdout);
  if (stream == NULL) {
    fprintf(stderr, "Cannot open %s with %s mode.
", filename, mode);
    exit(EXIT_FAILURE);
  }
  return stream;
}

private Employees file_with(String filename, String mode, Employees employees, EmployeesFn fn) {
  int handler = dup(mode[0] == `r`? 0: 1); /* 克隆檔案控制程式碼 */
  File stream = file_open(filename, mode);
  employees = fn(employees);
  fclose(stream);
  fdopen(handler, mode);                   /* 完成後恢復標準IO */
  return employees;
}

public Employees employees_input(String filename) {
  return file_with(filename, "r", NULL, employees_read);
}

public void employees_output(Employees employees, String filename) {
  file_with(filename, "w", employees, employees_print);
}

/* 應用層 */

int main(void) {
  Employees employees = employees_input(INPUT_FILE_NAME); /* 從檔案讀入 */
  employees_print(employees); /* 1. 輸出到螢幕 */
  employees_adjust_salary(employees); /* 2. 調整薪資 */
  employees_print(employees); /* 3. 輸出調整後的結果 */
  employees_output(employees, OUTPUT_FILE_NAME); /* 4. 儲存到檔案 */
  employees_free(employees); /* 釋放資源 */

  return EXIT_SUCCESS;
}

首先執行check.sh檢查功能是否正確,然後執行diff檢查修改點是否有超出預期。

總結

本文對程式碼做了多次迭代,介紹如何使用物件導向、函數語言程式設計、動態作用域等方法不斷抽象其中重複的程式碼。通過這個過程,可以看到物件導向程式設計和函數語言程式設計兩者並非對立,都是為了提高程式碼的抽象,可以相輔相成:

函數語言程式設計重點是增強型別系統:常見的資料型別有數值型、字串型等,函數語言程式設計要求函式也是一種資料型別,即程式碼也是一種資料。
物件導向風格側重於程式碼的組織形式:把資料和運算元據的函式組織在類中,提高內聚;物件之間通過呼叫開放的介面通訊,降低耦合。
本文只是拋磚引玉,並不是標準答案,所以並不是要求後續所有的程式碼都要抽象多少次才能提交。因此,首次交付出去的程式碼,到底要到達第幾版本,這個問題留給大家自己思考。

在說再見之前,再分享兩個關於識別重複、抽象重用的tips。

編碼規範

編碼規範在很多地方被反覆強調,也特別容易引發聖戰(如花括號的位置);在我看來,編碼規範最大的價值是便於發現程式碼中的重複!

程式語言本身或多或少會有一些約束,例如檔案必須先open再close,這類問題一般不容易出現不一致;更多的問題並不會在語言層面做約束,例如if else中異常處理是放在if程式碼塊中還是else,這類問題沒有標準答案,公說公有理婆說婆有理。程式設計規範用於解決第二類問題:TOOWTDI(There is Only One Way To Do It)。

只有統一才能清晰,清晰的程式碼不一定是短的程式碼,但囉嗦的程式碼一定是不清晰的,勿忘清晰是重構的基礎。

重構順序

開始重構時,切記重構的元素一定要從小到大!

就像文章的元素,從單詞、句子、段落依次遞增,重構時也應遵循從小到大的原則,依次解決重複的常量/變數、語句、程式碼塊、函式、類、庫……發現重複不能只浮於表面相同,得理解其背後的意義,只有後續需要一起變化的重複才是真正的重複。從小到大的重構順序能幫助理解每一個重複的細節,而反之卻容易導致忽略這些背後的細節。

還記得”work.txt”這個重複的檔名嗎?如果採用從大到小的重構順序,極有可能馬上抽象了一個重用的file_open,把檔名寫死在這個公共函式裡。這樣做的確解決了重複問題,整段程式碼只有這一處出現”work.txt”;但是一旦輸入輸出的檔名變得不同,這個公共函式只能棄用。

傳遞接力棒

本文第九版的程式碼遠不是完美的程式碼,還存在不少重複:

employee_read和employees_read中都用到calloc分配記憶體空間,並檢查是否分配成功。
employees_print之於employee_print和employees_adjust_salary之於employee_adjust_salary,區別只是前者名稱多了一個s,是否有可能根據這個規則自動為Employees生成與Employee一一對應的函式?
……

試試有什麼辦法繼續抽象。第二個問題是讓程式碼生成程式碼,給個提示,可以用“巨集”。

附錄I:Common Lisp的解決方案

從函式式風格重構的過程中能體會到,如果C語言能支援動態型別,就不必在employee_read中做強制轉換;如果C語言支援匿名函式,亦不用寫這麼多小函式;如果C語言除了能讀入整型、字串等基礎型別,還能直接讀入陣列、結構體等複合型別,就無需employee_read和employee_print等輸入輸出函式……

其實許多程式語言(如Python、Ruby、Lisp等)已經讓這些“如果”變成現實!讓看看Common Lisp的解決方案:

;; 從檔案讀入
(defparameter employees
  (with-open-file (file #P"work.lisp") ; 內建檔案環繞包裝
    (read file))) ; 內建讀取列表等複雜結構

;; 1. 輸出到螢幕
(print employees) ; 內建輸出列表等複雜結構

;; 2. 調整薪資
(dolist (employee employees)
  (if (< (third employee) 30000)
    (incf (third employee) 3000))) ; 就地修改

;; 3. 輸出調整後的結果
(print employees)

;; 4. 儲存到檔案
(with-open-file (file #P"work.lisp" :direction :output)
  (print employees file)) ; print是多型函式,file取代預設標準輸出流

其中work.lisp的內容是:

((William 35 25000)
 (Kishore 41 35000)
 (Wallace 37 30000)
 (Bruce 39 29999))

資料檔案的格式是Common Lisp的列表結構,Lisp支援直接從流中讀取sexp複雜結構,猶如JavaScript直接讀寫JSON結構資料。



本文作者:redraiment

閱讀原文

本文為雲棲社群原創內容,未經允許不得轉載。

相關文章