程序的本質(zhì)復雜性和元語言抽象
組件復用技術的局限性
常聽到有人講“我寫代碼很講究,一直嚴格遵循DRY原則, 把重復使用的功能都封裝成可復用的組件,使得代碼簡短優(yōu)雅,同時也易于理解和維護”。顯然,DRY原則和組件復用技術是最常見的改善代碼質(zhì)量的方法,不 過,在我看來以這類方法為指導,能幫助我們寫出“不錯的程序”,但還不足以幫助我們寫出簡短、優(yōu)雅、易理解、易維護的“好程序”。對于熟悉Martin Fowler《重構》和GoF《設計模式》的程序員,我常常提出這樣一個問題幫助他們進一步加深對程序的理解:
如果目標是代碼“簡短、優(yōu)雅、易理解、易維護”,組件復用技術是***的方法嗎?這種方法有沒有根本性的局限? |
雖然基于函數(shù)、類等形式的組件復用技術從一定程度上消除了冗余,提升了代碼的抽象層次,但是這種技術卻有著本質(zhì)的局限性,其根源在于 每種組件形式都代表了特定的抽象維度,組件復用只能在其維度上進行抽象層次的提升。比如,我們可以把常用的HashMap等功能封裝為類庫,但是不管怎么封裝復用類永遠是類,封裝雖然提升了代碼的抽象層次,但是它永遠不會變成Lambda,而實際問題所代表的抽象維度往往與之并不匹配。
以常見的二進制消息的解析為例,組件復用技術所能做到的只是把讀取字節(jié),檢查約束,計算CRC等功能封裝成函數(shù),這是遠遠不夠的。比如,下面的表格定義了二進制消息X的格式:
它的解析函數(shù)大概是這個樣子:
- 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的定義非常簡單,但是它的解析函數(shù)卻顯得很繁瑣,需要小心翼翼地處理很多細節(jié)。在處理其他消息Y時,雖然雖然Y和X很相似,但是 卻不得不再次在解析過程中處理這些細節(jié),就是組件復用方法的局限性,它只能幫我們按照函數(shù)或者類的語義把功能封裝成可復用的組件,但是消息的結(jié)構特征既不 是函數(shù)也不是類,這就是抽象維度的失配。
程序的本質(zhì)復雜性
上面分析了組件復用技術有著根本性的局限性,現(xiàn)在我們要進一步思考:
如果目標還是代碼“簡短、優(yōu)雅、易理解、易維護”,那么代碼優(yōu)化是否有一個理論極限?這個極限是由什么決定的?普通代碼比起***代碼多出來的“冗余部分”到底干了些什么事情? |
回答這個問題要從程序的本質(zhì)說起。Pascal語言之父Niklaus Wirth在70年代提出:Program = Data Structure + Algorithm,隨后邏輯學家和計算機科學家R Kowalski進一步提出:Algorithm = Logic + Control。誰更深刻更有啟發(fā)性?當然是后者!而且我認為數(shù)據(jù)結(jié)構和算法都屬于控制策略,綜合二位的觀點,加上我自己的理解,程序的本質(zhì) 是:Program = Logic + Control。換句話說,程序包含了邏輯和控制兩個維度。
邏輯就是問題的定義,比如,對于排序問題來講,邏輯就是“什么叫做有序,什么叫大于,什么叫小于,什么叫相等”?控制就是如何合理地安排時間和空間 資源去實現(xiàn)邏輯。邏輯是程序的靈魂,它定義了程序的本質(zhì);控制是為邏輯服務的,是非本質(zhì)的,可以變化的,如同排序有幾十種不同的方法,時間空間效率各不相 同,可以根據(jù)需要采用不同的實現(xiàn)。
程序的復雜性包含了本質(zhì)復雜性和非本質(zhì)復雜性兩個方面。套用這里的術語, 程序的本質(zhì)復雜性就是邏輯,非本質(zhì)復雜性就是控制。邏輯決定了代碼復雜性的下限,也就是說不管怎么做代碼優(yōu)化,Office程序永遠比Notepad程序復雜,這是因為前者的邏輯就更為復雜。如果要代碼簡潔優(yōu)雅,任何語言和技術所能做的只是盡量接近這個本質(zhì)復雜性,而不可能超越這個理論下限。
理解”程序的本質(zhì)復雜性是由邏輯決定的”從理論上為我們指明了代碼優(yōu)化的方向:讓邏輯和控制這兩個維度保持正交關系。來看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上的定義;控制的部分完全交給方法實現(xiàn)者,可以有多種不同的實現(xiàn),這就是 邏輯和控制解耦。同時,我們也可以斷定,這個設計已經(jīng)達到了代碼優(yōu)化的理論極限,不會有本質(zhì)上比它更簡潔的設計(忽略相同語義的語法差異),為什么?因為 邏輯決定了它的本質(zhì)復雜度,Comparator和Collections.sort的定義完全是邏輯的體現(xiàn),不包含任何非本質(zhì)的控制部分。
另外需要強調(diào)的是,上面講的“控制是非本質(zhì)復雜性”并不是說控制不重要,控制往往直接決定了程序的性能,當我們因為性能等原因必須采用某種控制的時 候,實際上被固化的控制策略也是一種邏輯。比如,當你的需求是“從進程虛擬地址ptr1拷貝1024個字節(jié)到地址ptr2“,那么它就是問題的定義,它就 是邏輯,這時,提供進程虛擬地址直接訪問語義的底層語言就與之完全匹配,反而是更高層次的語言對這個需求無能為力。
介紹了邏輯和控制的關系,可能很多朋友已經(jīng)開始意識到了上面二進制文件解析實現(xiàn)的問題在哪里,其實這也是 絕大多數(shù)程序不夠簡潔優(yōu)雅的根本原因:邏輯與控制耦合。上面那個消息定義表格就是不包含控制的純邏輯,我相信即使不是程序員也能讀懂它;而相應的代碼把邏輯和控制攪在一起之后就不那么容易讀懂了。
#p#
熟悉OOP和GoF設計模式的朋友可能會把“邏輯與控制解耦”與經(jīng)常聽說的“接口和實現(xiàn)解耦”聯(lián)系在一起,他們是不是一回事呢?其實,把這里所說的 邏輯和OOP中的接口劃等號是似是而非的, 而GoF設計模式***的問題就在于有意無意地讓人們以為“what就是interface, interface就是what”,很多朋友一想到要表達what,要抽象,馬上寫個接口出來,這就是潛移默化的慣性思維,自己根本意識不到問題在哪里。 其實,接口和前面提到的組件復用技術一樣,同樣受限于特定的抽象維度,它不是表達邏輯的通用方法,比如,我們無法把二進制文件格式特征用接口來表示。
另外,我們熟悉的許多GoF模式以“邏輯與控制解耦”的觀點來看,都不是***的。比如,很多時候Observer模式都是典型的以控制代邏輯,來看一個例子:
對于某網(wǎng)頁的超鏈接,要求其顏色隨著狀態(tài)不同而變化,點擊之前的顏色是#FF0000,點擊后顏色變成#00FF00。 |
基于Observer模式的實現(xiàn)是這樣的:
- $(a).css('color', '#FF0000');
- $(a).click(function() {
- $(this).css('color', '#00FF00');
- });
而基于純CSS的實現(xiàn)是這樣的:
- a:link {color: #FF0000}
- a:visited {color: #00FF00}
通過對比,您看出二者的差別了嗎?顯然,Observer模式包含了非本質(zhì)的控制,而CSS是只包含邏輯。理論上講,CSS能做的事情,JavaScript都能通過控制做到,那么為什么瀏覽器的設計者要引入CSS呢,這對我們有何啟發(fā)呢?
元語言抽象
好的,我們繼續(xù)思考下面這個問題:
邏輯決定了程序的本質(zhì)復雜性,但接口不是表達邏輯的通用方式,那么是否存在表達邏輯的通用方式呢? |
答案是:有!這就是元(Meta),包括元語言(Meta Language)和元數(shù)據(jù)(Meta Data)兩個方面。元并不神秘,我們通常所說的配置就是元,元語言就是配置的語法和語義,元數(shù)據(jù)就是具體的配置,它們之間的關系就是C語言和C程序之間 的關系;但是,同時元又非常神奇,因為元既是數(shù)據(jù)也是代碼,在表達邏輯和語義方面具有***的靈活性。至此,我們終于找到了讓代碼變得簡潔、優(yōu)雅、易理 解、易維護的***方法,這就是: 通過元語言抽象讓邏輯和控制徹底解耦!
比如,對于二進制消息解析,經(jīng)典的做法是類似Google的Protocol Buffers, 把消息結(jié)構特征抽象出來,定義消息描述元語言,再通過元數(shù)據(jù)描述消息結(jié)構。下面是Protocol Buffers元數(shù)據(jù)的例子,這個元數(shù)據(jù)是純邏輯的表達,它的復雜度體現(xiàn)的是消息結(jié)構的本質(zhì)復雜度,而如何序列化和解析這些控制相關的部分被 Protocol Buffers編譯器隱藏起來了。
- message Person {
- required int32 id = 1;
- required string name = 2;
- optional string email = 3;
- }
元語言解決了邏輯表達問題,但是最終要與控制相結(jié)合成為具體實現(xiàn),這就是元語言到目標語言的映射問題。通常有這兩種方法:
1) 元編程(Meta Programming),開發(fā)從元語言到目標語言的編譯器,將元數(shù)據(jù)編譯為目標程序代碼;
2) 元驅(qū)動編程(Meta Driven Programming),直接在目標語言中實現(xiàn)元語言的解釋器。
這兩種方法各有優(yōu)勢,元編程由于有靜態(tài)編譯階段,一般產(chǎn)生的目標程序代碼性能更好,但是這種方式混合了兩個層次的代碼,增加了代碼配置管理的難度, 一般還需要同時配備Build腳本把整個代碼生成自動集成到Build過程中,此外,和IDE的集成也是問題;元驅(qū)動編程則相反,沒有靜態(tài)編譯過程,元語 言代碼是動態(tài)解析的,所以性能上有損失,但是更加靈活,開發(fā)和代碼配置管理的難度也更小。除非是性能要求非常高的場合,我推薦的是元驅(qū)動編程,因為它更輕 量,更易于與目標語言結(jié)合。
下面是用元驅(qū)動編程解決二進制消息解析問題的例子,meta_message_x是元數(shù)據(jù),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對象表示的語言中,實現(xiàn)元驅(qū)動編程最為簡單。如果是Java 或C++語言,語法上稍微繁瑣一點,不過本質(zhì)上是一樣的,或者引入JSON配置文件,然后解析配置,或者定義MessageConfig類,直接把這個類 對象作為配置信息。
二進制文件解析問題是一個經(jīng)典問題,有Protocol Buffers、Android AIDL等大量的實例,所以很多人能想到引入消息定義元語言,但是如果我們把問題稍微變換,能想到采用這種方法的人就不多了。來看下面這個問題:
某網(wǎng)站有新用戶注冊、用戶信息更新,和個性設置等Web表單。出于性能和用戶體驗的考慮,在用戶點擊提交表單時,會先進行瀏覽器端的驗證,比如:name 字段至少3個字符,password字段至少8個字符,并且和repeat password要一致,email要符合郵箱格式;通過瀏覽器端驗證以后才通過HTTP請求提交到服務器。 |
普通的實現(xiàn)是這個樣子的:
- 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' };
- }
#p#
上面的實現(xiàn)就是按照組建復用的思想封裝了一下檢測email格式之類的通用函數(shù),這和剛才的二進制消息解析非常相似,沒法在不同的表單之間進行大規(guī)模復用,很多細節(jié)都必須被重復編寫。下面是用元語言抽象改進后的做法:
- 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);
過定義表單屬性元語言,整個邏輯頓時清晰了,細節(jié)的處理只需要在check_form中編寫一次,完全實現(xiàn)了“簡短、優(yōu)雅、易理解、以維護”的目 標。其實,不僅Web表單驗證可以通過元語言描述,整個Web頁面從布局到功能全部都可以通過一個元對象描述,完全將邏輯和控制解耦。此外,我編寫的用于 解析命令行參數(shù)的lineparser.js庫也是基于元語言的,有興趣的朋友可以參考并對比它和其他命令行解析庫的設計差異。
***,我們再來從代碼長度的角度來分析一下元驅(qū)動編程和普通方法之間的差異。假設一個功能在系統(tǒng)中出現(xiàn)了n次,對于普通方法來講,由于邏輯和控制的 耦合,它的代碼量是n * (L + C),而元驅(qū)動編程只需要實現(xiàn)一次控制,代碼長度是C + n * L,其中L表示邏輯相關的代碼量,C表示控制相關的代碼量。通常情況下L部分都是一些配置,不容易引入bug,復雜的主要是C的部分,普通方法中C被重復 了n次,引入bug的可能性大大增加,同時修改一個bug也可能要改n個地方。所以,對于重復出現(xiàn)的功能,元驅(qū)動編程大大減少了代碼量,減小了引入bug 的可能,并且提高了可維護性。
總結(jié)
《人月神話》的作者Fred Brooks曾在80年代闡述了它對于軟件復雜性的看法,即著名的No Silver Bullet。他認為不存在一種技術能使得軟件開發(fā)在生產(chǎn)力、可靠性、簡潔性方面提高一個數(shù)量級。我不清楚Brooks這一論斷詳細的背景,但是就個人的開發(fā)經(jīng)驗而言,元驅(qū)動編程和普通編程方法相比在生產(chǎn)力、可靠性和簡潔性方面的確是數(shù)量級的提升,在我看來它就是軟件開發(fā)的銀彈!