標籤:

抽象的能力

人類的智商從低幼逐漸走向成熟的標誌之一就是認識和運用數字的能力。當我們三四歲的時候,數數雖然能夠熟練地對一百以內的數字隨心所欲地倒背如流,但數字對孩童時代的我們僅僅還是數字,即便剛數完了自己桌前有 12 粒葡萄,吃掉了一粒,我們還得費力地再數一遍才能確定是 11 粒(別問我為啥這都門清)。在這個年齡,數字離開了具體的事物,對我們而言便不再具有任何意義。

隨著年齡地增長,大腦的發育,和小學階段的不斷訓練,我們開始能夠隨心所欲地運用數字,於此同時,我們甚至無法感受到它是一種對現實生活中的抽象,一斤白菜八毛錢,一斤芹菜一塊錢,各買兩斤,再來一瓶兩塊四的醬油,我們都能熟練地掏出六塊錢,開開心心離開菜市場。

到了初高中,抽象已經從數字開始像更高層次遞進。平面幾何和解析幾何把我們從數字代入到圖形,而代數(從j具體的數字到抽象的字母)則把我們引領到了函數的層面。小學時代的難題:「我爸爸的年齡是我的三倍,再過十年,他的年齡就是我的兩倍,問我現在年芳幾許?」在這個時候就成了方程式的入門題目。如果我的年齡是x,我爸爸的年齡是y,可以這麼列方程:

y = 3xny + 10 = 2(x + 10)n

然而,這樣的方程式千千萬萬,每列一個我們就需要計算一個解。接下來我們進一步把問題正規化和抽象化:

y = a1x + b1ny = a2x + b2n

這樣我們就有了一個關於 x 的解: (b2 - b1) / (a1 - a2)。它就像一台 machine 一樣,對於任何類似的問題,我們只需要套用之,就可以生產出來一個解。這便是公式(或者定理)。公式(或者定理)和其求證過程貫穿著我們的中學時代。

到了大學,抽象的程度又上升了一個巨大的台階,我們從數字開始抽象出關係。微積分研究和分析事物的變化和事物本身的關係;概率論研究隨機事件發生的可能性;離散數學研究數理邏輯,集合,圖論等等。這些都是對關係的研究。中學時期的疑難雜症,到了大學階段,都是被秒殺沒商量的小兒科。

...

然而,並不是所有人都能適應這種抽象能力的升級。有時候你被困在某個層級(比如說,程序君的大學數學就沒折騰利索,逃課毀一生啊)而無法突破。這時候,只能通過不斷地練習去固化這種思維,然後在固化中找到其意義所在。這就像冰火島上的張無忌被謝遜逼著背誦武功秘籍或者三歲的你在努力地來回熟悉每一個數字一樣。它們在那個時刻沒有意義,等到需要有意義的那一天來臨,你會產生頓悟。

同樣地,寫代碼需要抽象能力,無比需要。如果你不想一輩子都做一個初級碼農,如果你想寫出來一些自己也感覺到滿意的代碼,如果你想未來不被更高級的編碼工具取代,你需要學會抽象。

抽象的第一重,是將具體問題抽象成一個函數(或者類)用程序解決。這個層次的抽象,相信每個自稱軟體工程師的程序員都能做到:一旦掌握了某個語言的語法,就能將問題映射成語法,寫出合格的代碼。這有些像小學生對數字的理解:可以隨心所欲地應用而不會困惑。

抽象的第二重,是撰寫出可以解決多個問題的函數,這好比前文中提到的二元一次方程的通解 (b2 - b1) / (a1 - a2) 一樣,你創建出一個 machine,這個 machine 能處理其能解決的一切問題。就像這樣的代碼:

function getData(col) {n var results = [];n for (var i=0; i < col.length; i++) {n if (col[i] && col[i].data) {n results.push(col[i].data);n }n }n return results;n}n

當我們將其獨立看待時,它似乎已經很簡潔,對具體要解決的問題的映射很精確。然而,當我將循環,循環中的過濾,過濾結果的處理抽象出來,就可以產生下面的代碼:

function extract(filterFn, mapFn, col) {n return col.filter(filterFn).map(mapFn);n}n

這就是一個通解,一台 machine。有了它,你可以解決任何數據集過濾和映射的問題。當然,你還可以這麼抽象:

function extract(filterFn, mapFn) {n return function process(col) {n return col.filter(filterFn).map(mapFn);n }n}n

注意,這兩者雖然抽象出來的結果相似,但應用範圍是不盡相同的。後者更像一台生產 machine 的 machine(函數返回函數),它將問題進一步解耦。這種解耦使得代碼不僅泛化(generalization),而且將代碼的執行過程分成兩階段,在時序上和介面上也進行了解耦。於是,你可以在上下文 A 中調用extract,在上下文 B 中調用 process,產生真正的結果。上下文 A 和上下文 B 可以毫不相干,A 的上下文只需提供 filterFn 和 mapFn(比如說,系統初始化),B 的上下文只需提供具體的數據集col(比如說,web request 到達時)。這種時序上的解耦使得代碼的威力大大增強。介面上的解耦,就像旅遊中用的萬國插座一樣,讓你的函數能夠一頭對接上下文 A 中的系統,另一頭對接上下文 B 中的系統。

(當然,對於支持 currying 的語言,兩者其實是等價的,我只是從思維的角度去看待二者的不同)。

抽象的第二重也並不難掌握,OOP裡面的各種 pattern,FP 裡面的高階函數,都是幫助我們進行第二重抽象的有力武器。

抽象的第三重,是基礎模型的建立。如果說上一重抽象我們是在需要解決的問題中尋找共性,那麼這一重抽象我們就是在解決方法上尋找共性和聯繫。比如說建立你的程序世界裡的 contract。在軟體開發中,最基本的 contract 就是類型系統。像 java,haskell 這樣的強類型語言,語言本身就包含了這種 contract。那像 javascript 這樣的弱類型語言怎麼辦?你可以自己構建這種 contract:

function array(a) {n if (a instanceof Array) return a;n throw new TypeError(Must be an array!);n}nnfunction str(s) {n if (typeof(s) === string || s instanceof String) return s;n throw new TypeError(Must be a string!);n}nnfunction arrayOf(type) {n return function(a) {n return array(a).map(str);n }n}nnconst arrayOfStr = arrayOf(str);n// arrayOfStr([1,2]) TypeError: Must be a string!n// arrayOfStr([1, 2]) [ 1, 2 ]n

再舉一個例子。在程序的世界裡,no ones perfect。你從資料庫里做一個查詢,有兩種可能(先忽略異常):結果包含一個值,或者為空。這種二元結果幾乎是我們每天需要打交道的東西。如果你的程序每一步操作都產生一個二元結果,那麼你的代碼將會在 if...else 的結構上形成一個金字塔:

function process(x) {n const a = step1(x);n if (a) {n const b = step2(a);n if (b) {n const c = step3(b);n if (c) {n const d = step4(c):n if (d) {n ...n } else {n ...n }n } else {n ...n }n } else {n ...n }n } else {n ...n }n}n

很難讀,也很難寫。這種二元結構的數據能不能抽象成一個數據結構呢,可以。你可以創建一個類型:Maybe,包含兩種可能:Some 和 None,然後制定這樣的 contracts:stepx() 可以接受 Maybe,也會返回 Maybe,如果接受的 Maybe 是一個 None,那麼直接返回,否則才進行處理。新的代碼邏輯如下:

function stepx(fn) {n return function(maybe) {n if (maybe isinstanceof None) return maybe;n return fn(maybe);n }n}nconst step1x = stepx(step1);nconst step2x = stepx(step2);nconst step3x = stepx(step3);nconst step4x = stepx(step4);nnconst process = R.pipe(step1, step2, step3, step4);n

至於這個 Maybe 類型怎麼撰寫,就先不討論了。

(註:Haskell 就有 Maybe 這種類型)

抽象的第四重,是制定規則,建立解決整個問題空間的一個世界,這就是元編程(metaprogramming)。談到元編程,大家想到的首先是 lisp,clojure 等這樣「程序即數據,數據即程序」的可以在運行時操作語法數的語言。其實不然。沒有很強的抽象能力的 clojure 程序員並不見得比一個有很強抽象能力的 javascript 程序員能寫出更好的元編程的程序。這就好比呂布的方天畫戟放在你我手裡只能用來自拍一樣。

元編程的能力分成好多階段,我們這裡說說入門級的,不需要語言支持的能力:將實際的問題抽象成規則的能力,換句話說,在創立一家公司(解決問題)之前,你先創立公司法(建立問題空間的規則),然後按照公司法去創立公司(使用規則解決問題)。

比如說你需要寫一個程序,解析各種各樣,規格很不統一的 feed,將其處理成一種數據格式,存儲在資料庫里。源數據可能是 XML,可能是 json,它們的欄位名稱的定義很不統一,就算同樣是 XML,同一個含義的數據,有的在 attribute 里,有的在 child node 里,我們該怎麼處理呢?

當然我們可以為每種 feed 寫一個處理函數(或者類),然後儘可能地復用其中的公共部分。然而,這意味著每新增一個 feed 的支持,你要新寫一部分代碼。

更好的方式是定義一種語言,它能夠將源數據映射到目標數據。這樣,一旦這個語言定義好了,你只需寫一個 Parser,一勞永逸,以後再加 feed,只要寫一個使用該語言的描述即可。下面是一個 json feed 的描述:

{n "video_name": "name",n "description": "longDescription",n "video_source_path": ["renditions.#max_renditions.url", "FLVURL"],n "thumbnail_source_path": "videoStillURL",n "duration": "length.#ms2s",n "video_language": "",n "video_type_id": "",n "published_date": "publishedDate",n "breaks": "",n "director": "",n "cast": "",n "height": "renditions.#max_renditions.frameHeight",n "width": "renditions.#max_renditions.frameWidth"n}n

這是一個 XML feed 的描述:

{n "video_name": "title.0",n "description": "description.0",n "video_source_path": "media:group.0.media:content.0.$.url",n "thumbnail_source_path": "media:thumbnail.0.$.url",n "duration": "media:group.0.media:content.0.$.duration",n "video_language": "",n "video_type_id": "",n "published_date": "",n "breaks": "",n "director": "",n "cast": "",n "playlist": "mcp:program.0",n "height": "media:group.0.media:content.0.$.height",n "width": "media:group.0.media:content.0.$.width"n}n

具體這個描述語言的細節我就不展開,但是通過定義語言,或者說定義規則,我們成功地把問題的性質改變了:從一個數據處理的程序,變成了一個 Parser;輸入從一個個 feed,變成了一種描述語言的源文件。只要 Parser 寫好,問題的解決是一勞永逸的。你甚至可以再為這個語言寫個 UI,讓團隊里的非工程師也能很方便地支持新的 feed。

先講這麼多,你是不是對抽象有了新的認識?歡迎關注我的公眾號程序人生(programmer_life)和我討論。


推薦閱讀:

學習的力量
當我旅行的時候,我在想些什麼(4)
[雜談] 談談讀書(續)
[讀者留言] 程序人生 - 且行且珍惜

TAG:迷思 |