1. 程式人生 > >程式的本質複雜性和元語言抽象

程式的本質複雜性和元語言抽象

元件複用技術的侷限性

常聽到有人講“我寫程式碼很講究,一直嚴格遵循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這論斷更詳細的背景,但是就我個人的開發經驗而言,元驅動程式設計和普通程式設計方法相比在生產力、可靠性和簡潔性方面的確是數量級的提升,在我看來它就是軟體開發的銀彈!