組件復用技術的局限性
常聽到有人講“我寫代碼很講究,一直嚴格遵循DRY原則,把重復使用的功能都封裝成可復用的組件,使得代碼簡短優雅,同時也易於理解和維護”。顯然,DRY原則和組件復用技術是最常見的改善代碼質量的方法,不過,在我看來,以這類方法為指導,能幫助我們寫出“不錯的程序”,但還不足以幫助我們寫出簡短、優雅、易理解、易維護的“好程序”。對於熟悉Martin Fowler《重構》和GoF《設計模式》的程序員,我常常提出這樣一個問題幫助他們進一步加深對程序的理解:
如果目標是代碼“簡短、優雅、易理解、易維護”,組件復用技術是最好的方法嗎?這種方法有沒有根本性的局限?
雖然基於函數、類等形式的組件復用技術從一定程度上消除了冗余,提升了代碼的抽象層次,但是這種技術卻有着本質的局限性,其根源在於 每種組件形式都代表了特定的抽象維度,組件復用只能在其維度上進行抽象層次的提升。比如,我們可以把常用的HashMap等功能封裝為類庫,但是不管怎么封裝復用類永遠是類,封裝雖然提升了代碼的抽象層次,但是它永遠不會變成Lambda,而實際問題所代表的抽象維度往往與之並不匹配。
以常見的二進制消息的解析為例,組件復用技術所能做到的只是把讀取字節,檢查約束,計算CRC等功能封裝成函數,這是遠遠不夠的。比如,下面的表格定義了二進制消息X的格式:
Message X:
--------------------------------------------------------
| ID | Name | Type | Size | Constraints |
--------------------------------------------------------
| 1 | message type | int | 1 | = 0x01 |
--------------------------------------------------------
| 2 | payload size | int | 2 | > 0 |
--------------------------------------------------------
| 3 | payload | bytes | <2> | |
--------------------------------------------------------
| 4 | CRC | int | 4 | |
--------------------------------------------------------
它的解析函數大概是這個樣子:
bool parse_message_x(char* data, int32 size, MessageX& x) {
char *ptr = data;
if (ptr + sizeof(int8) <= data + size) {
x.message_type = read_int8(ptr);
if (0x01 != x.message_type) return false;
ptr += sizeof(int8);
} else {
return false;
}
if (ptr + sizeof(int16) <= data + size) {
x.payload_size = read_int16(ptr);
ptr += sizeof(int16);
} else {
return false;
}
if (ptr + x.payload_size <= data + size) {
x.payload = new int8[x.payload_size];
read(ptr, x.payload, x.payload_size);
ptr += x.payload_size;
} else {
return false;
}
if (ptr + sizeof(int32) <= data + size) {
x.crc = read_int32(ptr);
ptr += sizeof(int32);
} else {
delete x.payload;
return false;
}
if (crc(data, sizeof(int8) + sizeof(int16) + x.payload_size) != x.crc) {
delete x.payload;
return false;
}
return true;
}
很明顯,雖然消息X的定義非常簡單,但是它的解析函數卻顯得很繁瑣,需要小心翼翼地處理很多細節。在處理其他消息Y時,雖然雖然Y和X很相似,但是卻不得不再次在解析過程中處理這些細節,就是組件復用方法的局限性,它只能幫我們按照函數或者類的語義把功能封裝成可復用的組件,但是消息的結構特征既不是函數也不是類,這就是抽象維度的失配。
程序的本質復雜性
上面分析了組件復用技術有着根本性的局限性,現在我們要進一步思考:
如果目標還是代碼“簡短、優雅、易理解、易維護”,那么代碼優化是否有一個理論極限?這個極限是由什么決定的?普通代碼比起最優代碼多出來的“冗余部分”到底干了些什么事情?
回答這個問題要從程序的本質說起。Pascal語言之父Niklaus Wirth在70年代提出:Program = Data Structure + Algorithm
,隨后邏輯學家和計算機科學家R Kowalski進一步提出:Algorithm = Logic + Control
。誰更深刻更有啟發性?當然是后者!而且我認為數據結構和算法都屬於控制策略,綜合二位的觀點,加上我自己的理解,程序的本質是:Program = Logic + Control
。換句話說,程序包含了邏輯和控制兩個維度。
邏輯就是問題的定義,比如,對於排序問題來講,邏輯就是“什么叫做有序,什么叫大於,什么叫小於,什么叫相等”?控制就是如何合理地安排時間和空間資源去實現邏輯。邏輯是程序的靈魂,它定義了程序的本質;控制是為邏輯服務的,是非本質的,可以變化的,如同排序有幾十種不同的方法,時間空間效率各不相同,可以根據需要采用不同的實現。
程序的復雜性包含了本質復雜性和非本質復雜性兩個方面。套用這里的術語, 程序的本質復雜性就是邏輯,非本質復雜性就是控制。邏輯決定了代碼復雜性的下限,也就是說不管怎么做代碼優化,Office程序永遠比Notepad程序復雜,這是因為前者的邏輯就更為復雜。如果要代碼簡潔優雅,任何語言和技術所能做的只是盡量接近這個本質復雜性,而不可能超越這個理論下限。
理解"程序的本質復雜性是由邏輯決定的"從理論上為我們指明了代碼優化的方向:讓邏輯和控制這兩個維度保持正交關系。來看Java的Collections.sort方法的例子:
interface Comparator<T> {
int compare(T o1, T o2);
}
public static <T> void sort(List<T> list, Comparator<? super T> comparator)
使用者只關心邏輯部份,即提供一個Comparator對象表明序在類型T上的定義;控制的部分完全交給方法實現者,可以有多種不同的實現,這就是邏輯和控制解耦。同時,我們也可以斷定,這個設計已經達到了代碼優化的理論極限,不會有比本質上比它更簡潔的設計(忽略相同語義的語法差異),為什么呢?因為邏輯決定了它的本質復雜度,Comparator和Collections.sort的定義完全是邏輯的體現,不包含任何非本質的控制部分。
另外需要強調的是,上面講的“控制是非本質復雜性”並不是說控制不重要,控制往往直接決定了程序的性能,當我們因為性能等原因必須采用某種控制的時候,實際上被固化的控制策略也是一種邏輯。比如,當你的需求是“從進程虛擬地址ptr1拷貝1024個字節到地址ptr2“,那么它就是問題的定義,它就是邏輯,這時,提供進程虛擬地址直接訪問語義的底層語言就與之完全匹配,反而是更高層次的語言對這個需求無能為力。
介紹了邏輯和控制的關系,可能很多朋友已經開始意識到了上面二進制文件解析實現的問題在哪里,其實這也是 絕大多數程序不夠簡潔優雅的根本原因:邏輯與控制耦合。上面那個消息定義表格就是不包含控制的純邏輯,我相信即使不是程序員也能讀懂它;而相應的代碼把邏輯和控制攪在一起之后就不那么容易讀懂了。
熟悉OOP和GoF設計模式的朋友可能會把“邏輯與控制解耦”與經常聽說的“接口和實現解耦”聯系在一起,他們是不是一回事呢?其實,把這里所說的邏輯和OOP中的接口划等號是似是而非的, 而GoF設計模式最大的問題就在於有意無意地讓人們以為“what就是interface, interface就是what”,很多朋友一想到要表達what,要抽象,馬上寫個接口出來,這就是潛移默化的慣性思維,自己根本意識不到問題在哪里。其實,接口和前面提到的組件復用技術一樣,同樣受限於特定的抽象維度,它不是表達邏輯的通用方法,比如,我們無法把二進制文件格式特征用接口來表示。
另外,我們熟悉的許多GoF模式以“邏輯與控制解耦”的觀點來看,都不是最優的。比如,很多時候Observer模式都是典型的以控制代邏輯,來看一個例子:
對於某網頁的超鏈接,要求其顏色隨着狀態不同而變化,點擊之前的顏色是#FF0000,點擊后顏色變成#00FF00。
基於Observer模式的實現是這樣的:
$(a).css('color', '#FF0000');
$(a).click(function() {
$(this).css('color', '#00FF00');
});
而基於純CSS的實現是這樣的:
a:link {color: #FF0000}
a:visited {color: #00FF00}
通過對比,您看出二者的差別了嗎?顯然,Observer模式包含了非本質的控制,而CSS是只包含邏輯。理論上講,CSS能做的事情,JavaScript都能通過控制做到,那么為什么瀏覽器的設計者要引入CSS呢,這對我們有何啟發呢?
元語言抽象
好的,我們繼續思考下面這個問題:
邏輯決定了程序的本質復雜性,但接口不是表達邏輯的通用方式,那么是否存在表達邏輯的通用方式呢?
答案是:有!這就是元(Meta),包括元語言(Meta Language)和元數據(Meta Data)兩個方面。元並不神秘,我們通常所說的配置就是元,元語言就是配置的語法和語義,元數據就是具體的配置,它們之間的關系就是C語言和C程序之間的關系;但是,同時元又非常神奇,因為元既是數據也是代碼,在表達邏輯和語義方面具有無與倫比的靈活性。至此,我們終於找到了讓代碼變得簡潔、優雅、易理解、易維護的終極方法,這就是: 通過元語言抽象讓邏輯和控制徹底解耦!
比如,對於二進制消息解析,經典的做法是類似Google的Protocol Buffers,把消息結構特征抽象出來,定義消息描述元語言,再通過元數據描述消息結構。下面是Protocol Buffers元數據的例子,這個元數據是純邏輯的表達,它的復雜度體現的是消息結構的本質復雜度,而如何序列化和解析這些控制相關的部分被Protocol Buffers編譯器隱藏起來了。
message Person {
required int32 id = 1;
required string name = 2;
optional string email = 3;
}
元語言解決了邏輯表達問題,但是最終要與控制相結合成為具體實現,這就是元語言到目標語言的映射問題。通常有這兩種方法:
1) 元編程(Meta Programming),開發從元語言到目標語言的編譯器,將元數據編譯為目標程序代碼;
2) 元驅動編程(Meta Driven Programming),直接在目標語言中實現元語言的解釋器。
這兩種方法各有優勢,元編程由於有靜態編譯階段,一般產生的目標程序代碼性能更好,但是這種方式混合了兩個層次的代碼,增加了代碼配置管理的難度,一般還需要同時配備Build腳本把代碼生成自動集成到Build過程中,此外,和IDE的集成也是問題;元驅動編程則相反,沒有靜態編譯過程,元語言代碼是動態解析的,所以性能上有損失,但是更加靈活,開發和代碼配置管理的難度也更小。除非是性能要求非常高的場合,我推薦的是元驅動編程,因為它更輕量,更易於與目標語言結合。
下面是用元驅動編程解決二進制消息解析問題的例子,meta_message_x是元數據,parse_message是解釋器:
var meta_message_x = {
id: 'x',
fields: [
{ name: 'message_type', type: int8, value: 0x01 },
{ name: 'payload_size', type: int16 },
{ name: 'payload', type: bytes, size: '$payload_size' },
{ name: 'crc', type: crc32, source: ['message_type', 'payload_size', 'payload'] }
]
}
var message_x = parse_message(meta_message_x, data, size);
這段代碼我用的是JavaScript語法,因為對於支持Literal的類似JSON對象表示的語言中,實現元驅動編程最為簡單。如果是Java或C++語言,語法上稍微繁瑣一點,不過本質上是一樣的,或者引入JSON配置文件,然后解析配置,或者定義MessageConfig類,直接把這個類對象作為配置信息。
二進制文件解析問題是一個經典問題,有Protocol Buffers、Android AIDL等大量的實例,所以很多人能想到引入消息定義元語言,但是如果我們把問題稍微變換,能想到采用這種方法的人就不多了。來看下面這個問題:
某網站有新用戶注冊、用戶信息更新,和個性設置等Web表單。出於性能和用戶體驗的考慮,在用戶點擊提交表單時,會先進行瀏覽器端的驗證,比如:name字段至少3個字符,password字段至少8個字符,並且和repeat password要一致,email要符合郵箱格式;通過瀏覽器端驗證以后才通過HTTP請求提交到服務器。
普通的實現是這個樣子的:
function check_form_x() {
var name = $('#name').val();
if (null == name || name.length <= 3) {
return { status : 1, message: 'Invalid name' };
}
var password = $('#password').val();
if (null == password || password.length <= 8) {
return { status : 2, message: 'Invalid password' };
}
var repeat_password = $('#repeat_password').val();
if (repeat_password != password.length) {
return { status : 3, message: 'Password and repeat password mismatch' };
}
var email = $('#email').val();
if (check_email_format(email)) {
return { status : 4, message: 'Invalid email' };
}
...
return { status : 0, message: 'OK' };
}
上面的實現就是按照組件復用的思想封裝了一下檢測email格式之類的通用函數,這和剛才的二進制消息解析非常相似,沒法在不同的表單之間進行大規模復用,很多細節都必須被重復編寫。下面是用元語言抽象改進后的做法:
var meta_create_user = {
form_id : 'create_user',
fields : [
{ id : 'name', type : 'text', min-length : 3 },
{ id : 'password', type : 'password', min-length : 8 },
{ id : 'repeat-password', type : 'password', min-length : 8 },
{ id : 'email', type : 'email' }
]
};
var r = check_form(meta_create_user);
通過定義表單屬性元語言,整個邏輯頓時清晰了,細節的處理只需要在check_form中編寫一次,完全實現了“簡短、優雅、易理解、以維護”的目標。其實,不僅Web表單驗證可以通過元語言描述,整個Web頁面從布局到功能全部都可以通過一個元對象描述,完全將邏輯和控制解耦。
此外,我編寫的用於解析命令行參數的lineparser.js庫也是基於元驅動編程的,它通過一個元對象就描述了整個命令行參數規范,如:
var meta = {
program : 'adb',
name : 'Android Debug Bridge',
version : '1.0.3',
subcommands : [ 'connect', 'disconnect', 'shell', 'push', 'install' ],
options : {
flags : [
[ 'h', 'help', 'print program usage' ],
[ 'r', 'reinstall', 'reinstall package' ],
[ 'l', 'localhost', 'localhost' ]
],
parameters : [
[ null, 'host', 'adb server hostname or IP address', null ],
[ 'p', 'port', 'adb server port', 5037 ]
]
},
usages : [
[ 'connect', ['host', '[port]'], null, 'connect to adb server', adb_connect ],
[ 'connect', [ 'l' ], null, 'connect to the local adb server', adb_connect ],
[ 'disconnect', null, null, 'disconnect from adb server', adb_disconnect ],
[ 'shell', null, ['[cmd]'], 'run shell commands', adb_shell ],
[ 'push', null, ['src', 'dest'], 'push file to adb server', adb_push ],
[ 'install', ['r'], ['package'], 'install package', adb_install ],
[ null, ['h'], null, 'help', adb_help ],
[ null, null, null, 'help', adb_help ]
]
};
最后,我們再來從代碼長度的角度來分析一下元驅動編程和普通方法之間的差異。假設一個功能在系統中出現了n次,對於普通方法來講,由於邏輯和控制的耦合,它的代碼量是n * (L + C),而元驅動編程只需要實現一次控制,代碼長度是C + n * L,其中L表示邏輯相關的代碼量,C表示控制相關的代碼量。通常情況下L部分都是一些配置,不容易引入bug,復雜的主要是C的部分,普通方法中C被重復了n次,引入bug的可能性大大增加,同時修改一個bug也可能要改n個地方。所以,對於重復出現的功能,元驅動編程大大減少了代碼量,減小了引入bug的可能,並且提高了可維護性。
總結
《人月神話》的作者Fred Brooks曾在80年代闡述了他對於軟件復雜性的看法,即著名的No Silver Bullet,他認為不存在一種技術能使得軟件開發在生產力、可靠性、簡潔性方面提高一個數量級。我不知道Brooks這論斷更詳細的背景,但是就我個人的開發經驗而言,元驅動編程和普通編程方法相比在生產力、可靠性和簡潔性方面的確是數量級的提升,在我看來它就是軟件開發的銀彈!