從重複到重用

張澤鵬
2018-01-22程序員

前言

本文是我以前寫的文章——《你試過這樣寫C程序嗎》——的第二版,並把文章名改爲更貼切的「從重複到重用」。算法

開發技術的發展,從第一次提出「函數/子程序」,實現代碼級重用;到面向對象的「類」,重用數據結構與算法;再到「動態連接庫」、「控件」等重用模塊;到現在流行的雲計算、微服務可重用整個系統。技術發展雖然突飛猛進,但本質都是重用,只是粒度不一樣。因此寫代碼的動機都應是把重複的工做變成可重用的方案,其中重複的工做包括業務上重複的場景、技術上重複的代碼等。合格的系統能夠簡化當下重複的工做;優秀的系統還能預見將來重複的工做。編程

本文不談框架、不談架構,就談寫代碼的那些事兒!後文始終圍繞一個問題的解決方案,不斷髮現其中「重複」的代碼,並提煉出「可重用」的抽象,持續「重構」。但願經過這個過程和你們分享一些發現重複代碼和提煉可重用抽象的方法。數組

問題

做爲貫穿全文的主線,這有一個任務須要開發一個程序來完成:有一份存有職員信息(姓名、年齡、工資)的文件「work.txt」,內容以下:數據結構

William 35 25000
Kishore 41 35000
Wallace 37 30000
Bruce 39 29999
  1. 要求從文件(work.txt)中讀取員工薪酬,並輸出到屏幕上。
  2. 爲全部工資小於三萬的員工漲 3000 元。
  3. 在屏幕上輸出薪資調整後的結果。
  4. 把調整後的結果保存到原始文件。

即運行的結果是屏幕上要有八行輸出,「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」。app

第一部分:可維護代碼

初版: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\n", 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\n", e[i].name, e[i].age, e[i].salary);
    fprintf(ostream, "%s %d %d\n", 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語言課堂做業的答案,看起來還不錯——至少縮進一致,也沒混用空格和製表符;但從軟件工程的角度來說,它簡直糟糕透了,由於沒有清晰的表達意圖:

  1. 魔法常量4重複出現,後續負責維護的程序員沒法判斷它們是碰巧相等仍是有其餘緣由必需相等。
  2. 文件名work.txt重複出現。
  3. 重複且不清晰的文件指針類型定義,容易忽略ostream前面的*
  4. ei變量命名不顧名思義。
  5. 變量的定義與使用離得太遠。
  6. 無異常處理,文件可能不可讀。

借喬老爺子的話說:「看不見的地方也要用心作好」——這些代碼的問題用戶雖然看不見也不在意,但也要用心作好——已有幾處顯眼的地方出現重複。不過,在代碼變得清晰以前,不該急着動手去重構,由於清晰的代碼更容易找出重複!針對上述意圖不明的問題,準備對代碼作如下調整:

  1. 確認數字4在三處的意義都是員工記錄數,所以定義共享常量#define RECORD_COUNT 4
  2. 常量"work.txt"4不一樣,內容雖然相同但意義不一樣:一個做輸入,一個做輸出。若是也只簡單的定義一個常量FILE_NAME共用,後續二者獨立變化時,工做量並沒減小。因此去除重複代碼時,切忌只看表面相同,背後意義相同的纔是真正的相同,不然就像給全部常量1定義ONE別名同樣沒有意義。因此須要定義三個常量FILE_NAMEINPUT_FILE_NAMEOUTPUT_FILE_NAME
  3. 用自定義的文件類型typedef FILE* File;替代FILE*,可避免遺漏指針。
  4. 變量e是全部職員信息,把變量名改爲employees
  5. 變量i是迭代過程的下標,把變量名改爲index
  6. index變量定義放到for語句中。
  7. File變量定義從頂部挪到各自使用以前的位置。
  8. 對文件指針作異常檢查,當文件沒法打開時輸出錯誤信息並提早終止程序。
  9. 程序退出時用<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.\n", 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\n", 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.\n", OUTPUT_FILE_NAME);
    exit(EXIT_FAILURE);
  }
  for (int index = 0; index < RECORD_COUNT; index++) {
    printf("%s %d %d\n", employees[index].name, employees[index].age, employees[index].salary);
    fprintf(ostream, "%s %d %d\n", 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.\n", 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\n", 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\n", 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.\n", OUTPUT_FILE_NAME);
    exit(EXIT_FAILURE);
  }
  for (int index = 0; index < RECORD_COUNT; index++) {
    fprintf(ostream, "%s %d %d\n", 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;

而後抽象公共函數用於格式化輸出EmployeeFile,這其中還耦合了兩個功能:

  1. Employee序列化成字符串。
  2. 序列化結果輸出到指定文件流。

由於暫無獨立使用某項功能的場景,目前無需進一步拆分:

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

Employee結構體+employee_print函數很容易聯想到面向對象的「類」。面向對象的本質是由一組功能獨立的對象組成系統,對象之間經過發消息協做完成任務,不見得非要有class關鍵字,繼承、封裝、多態等語法糖。

  • 對象的「功能獨立」,即高內聚,要求數據和操做數據的相關方法放在一塊兒,大多數支持面向對象的編程語言都提供了class關鍵字,在語言層面強制捆綁,C語言並無這樣的語法,但能夠制定編碼規範,讓數據結構與函數在物理上捱得更近。
  • 「給對象發消息」,不一樣的編程語言裏表現形式各不相同,例如在Javafoo.baz()就是向foo對象發送baz消息,C++中等價的語法是foo->baz()Smalltalk中是foo bazC語言則是baz(foo)

綜上所述,雖然C語言一般被認爲不是面向對象的語言,其實它也能支持面向對象風格。沿上述思路,能夠抽象出職員對象的四個方法:

  • employee_read:構造函數,分配空間、輸入並反序列化,相似於Javanew
  • employee_free:析構函數,釋放空間,即純手工的GC
  • employee_print:序列化並輸出。
  • employee_adjust_salary:調整職員薪資,惟一的業務邏輯。

有了職員對象,程序再也不只有一個main函數。假設把main函數看做應用層,其餘函數看做類庫、框架或中間件,這樣程序有了層級,層間僅經過開放的接口通信,即對象的封裝性。

Java中有publicprotecteddefaultprivate四種可見性修飾符,C語言的函數默認是公開的,加上static關鍵字後只在當前文件可見。爲避免應用層向對象隨意發送消息,約定只有在應用層用到的函數才公開,因此額外定義了publicprivate兩個修飾符,目前職員對象的四個方法都是公開的。

重構以後的完整代碼以下:

#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\n");
    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\n", 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.\n", 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.\n", 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\n");
    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\n", 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\n");
    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.\n", 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.\n", 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.\n", 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\n");
    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\n", 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\n");
    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.\n", 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_salaryemployee_free,其他都一摸同樣,即它們的迭代規則相同,而循環體不一樣。是否有可能自定義一個for語句代替這些重複的迭代?

在大多數編程語言中,iffor等控制語句是一種特殊的存在,開發者一般沒法自定義。這是iffor在大多數語言中的樣子:

if (condition) {
  ...
}

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

若是把它們想象成是函數,語法能夠改爲更熟悉的函數調用形式:

if (condition, {
  ...
});

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

和普通函數調用相比,惟一不一樣的是容許花括號包圍的代碼片斷做爲參數。所以,若編程語言容許代碼做爲函數的參數,那就能自定義新的控制語句!這句話隱含了兩個語言特性:

  1. 代碼是一種數據類型。
  2. 代碼類型的數據可做爲函數的參數。

全部編程語言都包含一套類型系統,它決定數據的類型,而數據的類型又決定數據的功能。例如,數值類型能夠作四則運算;字符串類型的數據能夠拼接、查找、替換等;代碼若是也是一種數據類型,就能夠隨時「執行」它。C語言中具有「執行」能力的元素就是「函數」,函數之於代碼類型,猶如intdouble之於數值類型,都只是C這個特定編程語言對特定類型的特定實現,換成Visual Basic改叫「過程」,換成Java又稱做「成員方法」。

至於特性#2,它正是函數式編程的本質!提到函數式風格,腦海中一般會閃過一些耳熟能詳的詞彙:無反作用、無狀態、易於並行編程,甚至是Lisp那扭曲的前綴表達式。追根溯源,函數式編程源自λ演算——函數能做爲值傳遞給其餘函數或由其餘函數返回——其本質是函數做爲類型系統中的「第一等公民」(First-Class),符合如下四項要求:

  1. 能夠用變量命名。
  2. 能夠提供給過程做爲參數。
  3. 能夠由過程做爲結果返回。
  4. 能夠包含在數據結構中。

對照之下會驚訝地發現,C語言這門看似與函數式編程最遠的上古編程語言,利用函數指針,竟然也徹底符合上述條件。觀察employee_adjust_salaryemployee_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_reademployees_print中依然有重複的for循環,並沒有法用employees_each簡化。緣由是這些循環體中函數調用的參數數目與類型和EmployeeFn不兼容:

  • employee_read:包含File類型的參數,返回Employee類型。
  • employee_print:包含EmployeeFile兩類參數,無返回值。
  • EmployeeFn:包含Employee類型的參數,無返回值。

想涵蓋全部場景,最簡單的方法就是提取一個參數與返回結果的全集——Employee (*EmployeeFn)(Employee, File)——包含EmployeeFile兩個類型的參數,且返回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\n");
    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\n", 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\n");
    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.\n", 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會調用BB又調用C,假設C須要一個文件對象,假設B中並不建立文件對象,就得從A依次傳遞到B再傳遞到C。函數調用的層次越深,數據逐層傳遞的問題就越嚴重,上層函數的入參就會爆炸!

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

int is_debug = 0;

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

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

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 7try-with-resources)。不過,目前的解決方案在多線程環境下依然有問題,爲避免不一樣的線程之間相互衝突,理想的方案是採用相似Java中的ThreadLocal包裝全部全局變量,C語言的多線程方案POSIX threadThread Specific組件實現相似的線程特有數據功能,此處就不展開討論。

綜上所述,咱們真正須要的功能彷佛是一種代碼的包裝能力:全局變量某個特定的值只在指定範圍內生效(包括範圍內代碼調用的函數、調用函數的調用等等),相似於會話級別的變量。這種功能被裁剪的全局變量在編程語言中稱爲動態做用域(Dynamic Scope)變量。

大多數主流編程語言只支持靜態做用域——也叫詞法做用域——在編譯時靜態肯定的做用域;但動態做用域是在運行過程當中動態肯定的。簡言之,靜態做用域由代碼的層次結構決定,動態做用域由調用的堆棧層次結構決定。如下代碼是Perl語言動態做用域變量的示例,保存成demo.pl,執行perl demo.pl能輸出$v = 1

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

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

baz;

回到重構問題,利用動態做用域的思路,能夠抽象出一個文件對象包裝器:用指定文件替換全局的文件流,退出時恢復。C語言提供了打開指定文件並替代標準輸入輸出流的函數——freopen——但卻沒自帶恢復的功能,所以不一樣的平臺恢復方法不一樣,本文以類UNIX環境爲例,在unistd.h包下有dupfdopen兩個函數,分別用於克隆和恢復文件句柄。示例代碼以下:

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.\n", filename, mode);
    exit(EXIT_FAILURE);
  }
  /* TODO */
  fclose(stream);
  fdopen(handler, mode);                   /* 完成後恢復標準IO */
}

有了這個功能,能夠刪除掉全部函數和接口的File file參數!惟一真正和文件相關的只剩下employees_inputemployees_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\n");
    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\n", 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\n");
    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.\n", 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中的thisJS中惟一一個動態做用域的變量,看看社區對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\n");
    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\n", 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\n");
      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.\n", 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檢查修改點是否有超出預期。

總結

本文對代碼作了屢次迭代,介紹如何使用面向對象、函數式編程、動態做用域等方法不斷抽象其中重複的代碼。經過這個過程,能夠看到面向對象編程和函數式編程二者並不是對立,都是爲了提升代碼的抽象,能夠相輔相成:

  1. 函數式編程重點是加強類型系統:常見的數據類型有數值型、字符串型等,函數式編程要求函數也是一種數據類型,即代碼也是一種數據。
  2. 面向對象風格側重於代碼的組織形式:把數據和操做數據的函數組織在類中,提升內聚;對象之間經過調用開放的接口通信,下降耦合。

本文只是拋磚引玉,並非標準答案,因此並非要求後續全部的代碼都要抽象多少次才能提交。所以,首次交付出去的代碼,到底要到達第幾版本,這個問題留給你們本身思考。

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

編碼規範

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

編程語言自己或多或少會有一些約束,例如文件必須先openclose,這類問題通常不容易出現不一致;更多的問題並不會在語言層面作約束,例如if else中異常處理是放在if代碼塊中仍是else,這類問題沒有標準答案,公說公有理婆說婆有理。編程規範用於解決第二類問題:TOOWTDI(There is Only One Way To Do It)。

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

重構順序

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

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

還記得"work.txt"這個重複的文件名嗎?若是採用從大到小的重構順序,極有可能立刻抽象了一個重用的file_open,把文件名寫死在這個公共函數裏。這樣作的確解決了重複問題,整段代碼只有這一處出現"work.txt";可是一旦輸入輸出的文件名變得不一樣,這個公共函數只能棄用。

傳遞接力棒

本文第九版的代碼遠不是完美的代碼,還存在很多重複:

  • employee_reademployees_read中都用到calloc分配內存空間,並檢查是否分配成功。
  • employees_print之於employee_printemployees_adjust_salary之於employee_adjust_salary,區別只是前者名稱多了一個s,是否有可能根據這個規則自動爲Employees生成與Employee一一對應的函數?
  • ……

試試有什麼辦法繼續抽象。第二個問題是讓代碼生成代碼,給個提示,能夠用「宏」。

附錄I:Common Lisp的解決方案

從函數式風格重構的過程當中能體會到,若是C語言能支持動態類型,就沒必要在employee_read中作強制轉換;若是C語言支持匿名函數,亦不用寫這麼多小函數;若是C語言除了能讀入整型、字符串等基礎類型,還能直接讀入數組、結構體等複合類型,就無需employee_reademployee_print等輸入輸出函數……

其實許多編程語言(如PythonRubyLisp等)已經讓這些「若是」變成現實!讓看看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結構數據。

相關文章
相關標籤/搜索