表驅動法是一種程式設計模式,從表裡面查詢資訊而不是使用邏輯語句(if…else…switch),當是很簡單的情況時,用邏輯語句很簡單,但如果邏輯很複雜,再使用邏輯語句就很麻煩了。
比如查詢一年中每個月份的天數,如果用表驅動法,完全不需要寫一堆if…else…語句,直接把每個月份的天數存到一個陣列裡就行了,取值的時候直接下標訪問,最多針對二月判斷一下閏年。這麼算的話,平時用的的HashMap,SparseArray也可以算是表驅動
表裡可以存資料,也可以存指令,或函式指標等都可以。
示例
看一個例子,計算保險的保險費率,不同年齡的人費率是不同的,當判斷費率時就要寫很長的if…else…來判斷不同的年齡段對應的費率,如果要區分性別,那麼判斷語句就會增加一倍,如果再判斷是否吸菸,是否已結婚,每一個條件都會使判斷增加一倍。雖然可以通過給是否已結婚,是否吸菸等設定一個比例係數,這樣就不用使判斷加倍,但這個係數可不能保證對不同年齡段或不同性別的人是相同的,而且如果要改變,就要很麻煩地改程式。
如果用表驅動法解決這個問題,直接拋棄邏輯判斷,使用一個表儲存各個條件的費率,比如只考慮是性別,是否吸菸和年齡,就可以定義一個三維陣列儲存各個條件的費率
double[][][] rate = { {{1,2,3}, {1,2,3}}, {{1,2,3}, {1,2,3}} }; for (int gender = 0; gender < rate.length; gender++) { System.out.println("Gender:" + gender); double[][] genderRate = rate[gender]; for (int smoke = 0; smoke < genderRate.length; smoke++) { System.out.println("\tSmoke:" + smoke); double[] ageRate = genderRate[smoke]; System.out.print("\t\tAge:"); for (int age = 0; age < ageRate.length; age++) { System.out.print(ageRate[age] + " "); } System.out.println(); } } // Output Gender:0 Smoke:0 Age:1.0 2.0 3.0 Smoke:1 Age:1.0 2.0 3.0 Gender:1 Smoke:0 Age:1.0 2.0 3.0 Smoke:1 Age:1.0 2.0 3.0
當想取一種情況的費率時直接訪問陣列就行了,比如封裝成下面的方法
private static double getRate(int gender, int smoke, int age) { return rate[gender][smoke][age]; }
使用表驅動法的好處是,當費率改變的時候,我們用不著依次改每個條件,直接修改這個費率表就行了,即使是新加條件,也只需要把這個費率表再加一維,修改一下根據條件獲取費率的函式就行了
還有另一個好處就是,完全可以把這個資料表儲存到檔案中,在程式執行的時候讀取這個檔案,這樣如果條件不變只是各個條件對應的資料變化時,直接修改這個檔案就行了,甚至不用修改程式。
另一個複雜的例子是列印檔案中儲存的資訊
一個檔案中儲存了很多資訊,這些資訊可以分為幾十種,每個資訊通過首部的一個資訊ID分割槽
如果用傳統的邏輯方法,步驟大概是:
- 讀取每條訊息的ID
- 然後大量的if…else…或switch判斷訊息型別
- 針對每種訊息呼叫相應的處理函式
即使是物件導向的方法,也會為每種訊息定義一個類,這樣每新增一種訊息都需要新增一個條件條件判斷或新增一個類。
那麼用表驅動法怎麼解決呢?
每條訊息都由一些欄位組成,這些欄位是有限的,比如數字,字串,布林型別,日期等。我們可以用另外一個檔案記錄每個訊息對應的欄位,如下所示
“Message Description”
Field1 Float “Prompt”
Field2 Date “Created Date”
每一種訊息都通過上面的方式描述,所有訊息型別被組織成一張表,這樣讀取訊息的步驟就變為了:
- 讀取訊息ID
- 找到訊息ID定義的訊息描述
- 讀取描述中的每一個欄位,根據欄位的型別呼叫相應的列印方法
這樣就只需要為每個欄位型別定義一個列印方法,所有的訊息列印方法都是一樣的,除非增加欄位的種類,否則即使新增訊息型別也不需要修改程式碼
表資料的訪問
表資料的訪問方法我不打算按《程式碼大全》中的分類方法劃分為:直接訪問、索引訪問與階梯訪問,這些只是針對特定的表的較優的訪問方法,像費率計算中的年齡條件,由於年齡是分段的,但也只需要進行一個轉換,可以說是直接訪問,根據年齡的區間可以把年齡分為不同的段,也可以說是階段訪問。(可以把各段的端點也儲存到檔案中增加靈活性)
像前面的計算每月的天數的問題,直接以月份為下標就能訪問需要的資料,或者像費率計算中的年齡條件,通過一個轉換就可以取到需要的資料
還有的情況是不方便直接訪問到資料的問的情況是,比如你有100個商品,編號為0-9999,這些編號是無規律的,你無法根據商品編號獲取表鍵,如果直接用商品編號為鍵,那就需要建立一個10000項的表,而其中只有100項有意義,如果商品相關的資料項很大會浪費很多空間,此時可以使用索引技術,建立一個100項的表儲存商品,然後建立一個10000項的索引表儲存商品編號到商品表的表鍵的對映。這樣會浪費一個索引表,但所幸是索引表的表項一般很小,問題不大。
即使應用索引沒有節約空間而是浪費了空間,應用索引也可能會節約時間,比如查詢資料庫。
當然,上面的例子只是為了說明情況,如果資料不是儲存在檔案中而是在記憶體中,HashMap就行了,不過如果資料結構很複雜,計算HashCode也需要時間,當然可以優化HashCode的計算,如進行快取等。還有稀疏矩陣等方法。
實際應用
表驅動法有沒有實際的應用?平時我們肯定或多或少想到了這個方法,只是就像設計模式一樣,大多數時候的思考只停留在當下的問題中,而沒有形成一個思想。當看到這個方法時我第一個想到的是Android啟動init時的init.rc。
以下是init.rc中zygote相關配置項:
service zygote /system/bin/app_process -Xzygote/system/bin --zygote --start-system-server class main socket zygote stream 660 root system onrestart write /sys/android_power/request_state wake onrestart write /sys/power/state on onrestart restart media onrestart restart netd
像這段程式碼中的write和restart關鍵字,我們很容易看出這是一些指令,但是系統是怎麼將這些關鍵字對應到相應的指令上的?這就要看一個有意思的檔案:keywords.h
#ifndef KEYWORD int do_chroot(int nargs, char **args); int do_chdir(int nargs, char **args); ... int do_restart(int nargs, char **args); ... int do_write(int nargs, char **args); int do_copy(int nargs, char **args); ... int do_wait(int nargs, char **args); #define __MAKE_KEYWORD_ENUM__ #define KEYWORD(symbol, flags, nargs, func) K_##symbol, enum { K_UNKNOWN, #endif KEYWORD(capability, OPTION, 0, 0) KEYWORD(chdir, COMMAND, 1, do_chdir) ... KEYWORD(restart, COMMAND, 1, do_restart) ... KEYWORD(write, COMMAND, 2, do_write) KEYWORD(copy, COMMAND, 2, do_copy) ... KEYWORD(ioprio, OPTION, 0, 0) #ifdef __MAKE_KEYWORD_ENUM__ KEYWORD_COUNT, }; #undef __MAKE_KEYWORD_ENUM__ #undef KEYWORD #endif
使用keywords.h的地方在init_parser.c
#include "keywords.h" #define KEYWORD(symbol, flags, nargs, func) \ [ K_##symbol ] = { #symbol, func, nargs + 1, flags, }, static struct { const char *name; int (*func)(int nargs, char **args); unsigned char nargs; unsigned char flags; } keyword_info[KEYWORD_COUNT] = { [ K_UNKNOWN ] = { "unknown", 0, 0, 0 }, #include "keywords.h" }; #undef KEYWORD
在init_parser.c中include了兩次keywords.h
第一次時還沒有#defile KEYWORD
,所以會定義do_restart
、do_write
等函式,並且在內部字義KEYWORD
#define KEYWORD(symbol, flags, nargs, func) K_##symbol,
enum { K_UNKNOWN,
KEYWORD(restart, COMMAND, 1, do_restart) -> K_restart,
enum { K_UNKNOWN, ... K_restart, ... K_write, ... KEYWORD_COUNT, }
#include "keywords.h"
後,init_parser.c中DEFINE了KEYWORD,並宣告瞭一個結構陣列#define KEYWORD(symbol, flags, nargs, func) \ [ K_##symbol ] = { #symbol, func, nargs + 1, flags, }, static struct { const char *name; int (*func)(int nargs, char **args); unsigned char nargs; unsigned char flags; } keyword_info[KEYWORD_COUNT] = { [ K_UNKNOWN ] = { "unknown", 0, 0, 0 }, #include "keywords.h" }; #undef KEYWORD
#include "keywords.h"
時#ifndef KEYWORD
內的語句就不會走,這一串KEYWORD巨集被被這樣轉化:KEYWORD(restart, COMMAND, 1, do_restart) -> [ K_restart ] = { "restart", do_restart, 2, COMMAND },
#define SECTION 0x01 #define COMMAND 0x02 #define OPTION 0x04
這樣第二次include之後,就宣告瞭一個結構陣列keyword_info,結構的成員分別是操作名、操作對應的處理函式、引數個數、操作型別。
通過對keywords.h的兩次include,init程式成功定義了一個列舉和一張表,並且以列舉為鍵查詢表可以找到相應的處理函式,這樣就不用每次獲得操作型別後查收處理函式了,直接讀表就行了,這就是前面說的,表中不只可以存資料,還可以存指令、函式指標等。雖然從init.rc的命令名找到對應的列舉名也需要查詢,但從列舉名到處理函式的查詢方便多了。