【譯】從高階函式到庫和框架
這篇文章中,我們會探索一些高階函式,去思考如何用這些函式來讓我們的程式更具表達性;同時,我們也要在程式可感知複雜度(perceived complexity) 和表達性之間達到折中和平衡。
什麼是表達性
程式設計中最基礎的概念之一就是函式可以呼叫其它函式。
當一個函式能呼叫其它函式,且當一個函式能被多個其它函式呼叫時,我們就可以幹很多有意思的事情。而函式之間多對多的關係,比起一對多的關係,能讓程式具有更強的表達能力。我們可以給每個函式單一的職責,然後命名這個職責;我們還能確保有且僅有一個函式承擔某項職責。
多對多的函式關係使得函式與職責之間一對一的關係成為可能。
程式設計師經常說某個語言非常具有表達性,然而並沒有一個普世單一的標準來定義什麼是表達性。大多數程式設計師同意,決定某個語言具有“表達性”的一個重要特徵是,該語言讓程式設計師能很容易避免程式碼中沒必要的囉嗦。如果函式擁有多個職責,這會讓函式自身變得龐大笨拙。如果同一個職責要被多次實現,這就造成了程式的冗餘。
如果程式中的函式都具有單一職責,且所有職責都僅被單一函式實現一次,這樣的程式就避免了沒必要的囉嗦。
綜上,函式之間多對多的關係,讓編寫高表達性程式成為可能。而那些無此特徵的程式,在“表達性”上則是非常糟糕的。
另一面:可感知複雜度
然而,能力越大,責任越大。多對多函式關係的負面就是,隨著程式體積增長,該程式能幹的事情就急劇增多。“表達性”經常與“可感知複雜度”存在衝突。
為了更容易理解上面的論斷,我們畫個關係圖來類比來思考下。每個函式是一個節點,函式之間的呼叫關係是連線。假設程式中沒有死程式碼,那麼每個結構化程式都形成一個連線圖。
給定已知數量的節點,在這些節點中能畫的連線圖數量形成 A001187 整數序列。(譯者注:這個太硬核數學了,不懂)...(數學,不懂,省略翻譯)... 總之,僅僅 10 個函式就能形成多於 34 兆個程式組合方式……
程式靈活性的爆炸性增長讓程式設計師不得不剋制一下。函式和職責之間一對一關係帶來的好處,被由此而造成的無限複雜度而抵消。試想理解這種複雜度的程式多麼困難。
JavaScript 能提供工具幫忙緩解這個問題。它的塊建立了名稱空間,ES Modules 也具有這個功能。它很快就會具有私有物件屬性。(譯者注:公有和私有類屬性已經進入 State 3 草案了)
名稱空間將本可能大的關係圖限制到小的圖裡面,每一個小圖與其它小圖(模組)連線的方式數量可控。用這種方式,你得到的依然是一張大圖,但是你這張圖的可組合可能性小了很多。這樣,你就更容易弄清楚它能做什麼,怎麼做。
我們剛剛以靠近直覺的方式來描述一種設計優秀軟體系統的方式:給予程式設計師因實體間多對多關係帶來的靈活性,同時讓程式設計師可以主動限定實體間可連線的方式。
但是請注意我們沒有說有某種機制能同時幹這兩件事。不,我們只是說有一個工具能幫我們提升表達性,另一個工具幫我們限制程式中的可感知複雜度;而這兩者之間存在衝突。
現在,我們直覺上能明白這個問題了,那就讓我們來看一些高階函式。從這些函式上,我們試著能不能看出表達性和可感知複雜度的同時存在。
高階函式
如果一個函式接受其它若干函式作為引數,且/或將函式作為值返回,我們稱這種函式為高階函式,或 HOFs. 支援 HOFs 的語言同時也支援一等公民函式,而且幾乎都會支援動態建立函式。
高階函式給了程式設計師更多解構和組合程式的方式,由此,程式設計師有了更多編寫職責 -- 函式一對一關係的方式。讓我們來看個例子。
傳說好的公司總會要求畢業生應聘者進行 coding 面試。
比如,把兩個已經排好序的列表合併到一起。這種問題不至於太難,同時也有現實應用場景。下面是一個天真的答案:
function merge({ list1, list2 }) { if (list1.length === 0 || list2.length === 0) { return list1.concat(list2); } else { let atom, remainder; if (list1[0] < list2[0]) { atom = list1[0]; remainder = { list1: list1.slice(1), list2, }; } else { (atom = list2[0]), (remainder = { list1, list2: list2.slice(1), }); } const left = atom; const right = merge(remainder); return [left, ...right]; } } merge({ list1: [1, 2, 5, 8], list2: [3, 4, 6, 7], }); //=> [1, 2, 3, 4, 5, 6, 7, 8] 複製程式碼
下面是一個對數字組成列表求和的函式:
function sum(list) { if (list.length === 0) { return 0; } else { const [atom, ...remainder] = list; const left = atom; const right = sum(remainder); return left + right; } } sum([42, 3, -1]); //=> 44 複製程式碼
我們故意把這兩個函式以同一種結構來寫。這種結構叫線性遞迴。我們可以把這種共有結構抽離出來嗎?
線性遞迴
線性遞迴形式很簡單:
- 觀察函式輸入值,我們能把這個值的其中一個元素抽離開來嗎?
- 如果不能,我們應該返回什麼值?
- 如果能,那我們就把這個值分離成一個元素和剩下的元素。
- 把剩下的元素放進同一個線性遞迴函式執行,然後
- 把前面分離出來的元素,和後面對剩下元素進行線性遞迴的結果進行某種連線
我們剛剛展示的兩個函式都有這個形式,那我們就寫個高階函式來實現線性遞迴。我們就以其中一個函式為例,來抽離出共有部分:
function sum(list) { const indivisible = (list) => list.length === 0; const value = () => 0; const divide = (list) => { const [atom, ...remainder] = list; return { atom, remainder }; }; const combine = ({ left, right }) => left + right; if (indivisible(list)) { return value(list); } else { const { atom, remainder } = divide(list); const left = atom; const right = sum(remainder); return combine({ left, right }); } } 複製程式碼
還差一點就實現我們想要的高階函數了,最關鍵的一部是重新命名幾個變數:
function myself(input) { const indivisible = (list) => list.length === 0; const value = () => 0; const divide = (list) => { const [atom, ...remainder] = list; return { atom, remainder }; }; const combine = ({ left, right }) => left + right; if (indivisible(input)) { return value(input); } else { const { atom, remainder } = divide(input); const left = atom; const right = myself(remainder); return combine({ left, right }); } } 複製程式碼
最後一步是將這些常量函式改成一個最終返回 myself
的函式的形參:
function linrec({ indivisible, value, divide, combine }) { return function myself(input) { if (indivisible(input)) { return value(input); } else { const { atom, remainder } = divide(input); const left = atom; const right = myself(remainder); return combine({ left, right }); } }; } const sum = linrec({ indivisible: (list) => list.length === 0, value: () => 0, divide: (list) => { const [atom, ...remainder] = list; return { atom, remainder }; }, combine: ({ left, right }) => left + right, }); 複製程式碼
現在我們就能利用 sum
和 merge
之間的相同屬性了。讓我們用 linrec
來實現 merge
吧:
const merge = linrec({ indivisible: ({ list1, list2 }) => list1.length === 0 || list2.length === 0, value: ({ list1, list2 }) => list1.concat(list2), divide: ({ list1, list2 }) => { if (list1[0] < list2[0]) { return { atom: list1[0], remainder: { list1: list1.slice(1), list2, }, }; } else { return { atom: list2[0], remainder: { list1, list2: list2.slice(1), }, }; } }, combine: ({ left, right }) => [left, ...right], }); 複製程式碼
我們還可以更進一步!
二元遞迴
我們來實現一個叫 binrec
的函式,這個函式實現了二元遞迴。我們一開始舉例子是合併兩個已經排好序的列表,而 merge
函式經常被用在合併排序(merge sort)中。
binrec
實際上比 linrec
更簡單。 linrec
還要將輸入值分為單個元素和剩餘元素, binrec
將問題分成兩部分,然後將同一個演算法應用到這兩個部分中:
function binrec({ indivisible, value, divide, combine }) { return function myself(input) { if (indivisible(input)) { return value(input); } else { let { left, right } = divide(input); left = myself(left); right = myself(right); return combine({ left, right }); } }; } const mergeSort = binrec({ indivisible: (list) => list.length <= 1, value: (list) => list, divide: (list) => ({ left: list.slice(0, list.length / 2), right: list.slice(list.length / 2), }), combine: ({ left: list1, right: list2 }) => merge({ list1, list2 }), }); mergeSort([1, 42, 4, 5]); //=> [1, 4, 5, 42] 複製程式碼
腦洞再開大點,基於二元遞迴,我們還能擴展出多元遞迴,即將問題分成隨意數量的對稱部分:
function mapWith(fn) { return function*(iterable) { for (const element of iterable) { yield fn(element); } }; } function multirec({ indivisible, value, divide, combine }) { return function myself(input) { if (indivisible(input)) { return value(input); } else { const parts = divide(input); const solutions = mapWith(myself)(parts); return combine(solutions); } }; } const mergeSort = multirec({ indivisible: (list) => list.length <= 1, value: (list) => list, divide: (list) => [ list.slice(0, list.length / 2), list.slice(list.length / 2), ], combine: ([list1, list2]) => merge({ list1, list2 }), }); 複製程式碼
我們還可以繼續探索無數多個高階函式,不過我剛剛展示的這幾個已經夠了。讓我們回過頭再來思考下表達性和可感知複雜度。
高階函式,表達性,和複雜度之間的關係
...(太囉嗦,重複之前的內容,不翻譯了)…… 如果兩個函式實現了同一項職責,那我們的程式就不夠 DRY (don't repeat yourself),表達性也差。
高階函式和這個有什麼關係?如我們剛看到的, sum
和 merge
在解決域裡面有不同的職責,一個是合併列表,一個是列表求總。但是兩者共享同一個實現結構,那就是線性遞迴。所以,他們都負責實現線性遞迴演算法。
通過把線性遞迴演算法抽離出來,我們確保有且僅有一個實體 -- linrec
-- 負責實現線性遞迴。由此,我們發現了,一等公民函式通過建立函式間的多對多關係,確實幫助了我們實現更強大的表達性。
然而,我們也知道,如果不利用某些語言特性或者架構設計來將函式進行分組管理,這種高階函式的用法會增加程式的可感知複雜度。分組之後,組內函式依然存在豐富的相互關係,但是組之間的關係是限定的。
一對多和多對多
我們來比較下分別用 binrec
和 multirec
來實現 mergeSort
:
const mergeSort1 = binrec({ indivisible: (list) => list.length <= 1, value: (list) => list, divide: (list) => ({ left: list.slice(0, list.length / 2), right: list.slice(list.length / 2), }), combine: ({ left: list1, right: list2 }) => merge({ list1, list2 }), }); const mergeSort2 = multirec({ indivisible: (list) => list.length <= 1, value: (list) => list, divide: (list) => [ list.slice(0, list.length / 2), list.slice(list.length / 2), ], combine: ([list1, list2]) => merge({ list1, list2 }), }); 複製程式碼
我們傳給 linrec 和 multirec 的函式挺有趣,來給他們命名下:
const hasAtMostOne = (list) => list.length <= 1; const Identity = (list) => list; const bisectLeftAndRight = (list) => ({ left: list.slice(0, list.length / 2), right: list.slice(list.length / 2), }); const bisect = (list) => [ list.slice(0, list.length / 2), list.slice(list.length / 2), ]; const mergeLeftAndRight = ({ left: list1, right: list2 }) => merge({ list1, list2 }); const mergeBisected = ([list1, list2]) => merge({ list1, list2 }); 複製程式碼
觀察下函式名和函式的實際功能,你能發現某些函式,如 hasAtMostOne
, Identity
和 bisect
感覺像是通用目的函式,我們在寫當前應用或其它應用時都會用到這種函式。事實上,這些函式確實能在一些通用目的函式工具庫裡找到。他們表達了在列表上的通用操作。(【譯者注】:Ramda 裡面的 identity
函式和這裡一樣。 identity
函式,以及類似的 const always = x => y => x
一點都不無厘頭,他們在特定上下文才有意義)
而 bisectLeftAndRight
和 mergeLiftAndRight
則顯得目的更特殊。他們不大可能被用在其它地方。 mergeBisected
則混合一點,我們可能在其它地方能用到它,也可能用不到。
如本文一開始就一再強調的,這種多對多的函式關係,能幫助我們提升程式碼表達性,以及在程式實體和職責之間建立一對一的關係。例如, bisect
的職責就是把列表分成兩部分。我們可以讓程式碼其它所有部分都呼叫 bisect
,而不是一直反覆實現這個功能。
如果一個函式提供的介面或“行為協議”越通用,一個函式承擔的職責越集中和簡單,此函式建立多對多關係的能力就越強。因此,當我們寫像 multirec
這樣的高階函式時,我們應當如此設計這些函式,使得它們接收通用目的函式為引數,而這些通用目的函式只承擔簡單職責。
我們同時也可以寫像 bisectLeftAndRight
和 mergeLeftAndRight
這種函式。當我們這樣寫的時候,程式中就會存在一對多關係,因為除了在 merge
函式中有用外,它們沒什麼通用功能。這限制了我們程式的表達性。
不幸的是,這種限制並不必然意味著程式的可感知複雜度的隨之降低。通過仔細閱讀程式碼,我們能看出 bisectLeftAndRight
這種函式在程式其它地方並沒有什麼用。如果我們沒有另外使用模組作用域等機制去限制這些函式的範圍,讓其易於發現,我們並不能降低程式的可感知複雜度。
由此,我們可以觀察到,某些程式設計技巧,比如那種為函式寫高度專一的介面,或者讓函式承擔複雜的職責的程式設計技巧,會讓程式的表達性降低,但並不能降低程式的可感知複雜度。
高階函式和框架和庫有什麼關係
粗略來講,框架和庫不過是一些類,函式和其它程式碼的集合。 區別是,框架被設計成來呼叫我們的程式碼,庫被設計成被我們的程式碼呼叫。
框架通常期待我們寫出帶有非常具體而專一介面和行為協議的函式或者其它程式實體。例如,Ember 要求我們去擴充套件它的基類去建立元件,而不是使用普通的 ES6 Class。如我們上面已闡明的,當我們寫出專一的介面時,我們就限制了程式的表達性,但並沒有因此而降低程式複雜度。
這意味著我們是在為框架寫程式碼,這樣框架的作者就不用操心去在框架程式碼和使用者程式碼之間建立多對多的關係。例如,我們在寫 Ember 類時,是沒法使用 JavaScript mixins, subclass factories, 和 method advice 這些程式碼組合方式的。我們不得不使用 Ember 提供的專一的超程式設計工具,或者使用專為 Ember 開發的外掛。
面向框架的程式碼更具有一對多特性,而不是多對多,這就降低了其表達性。
相比之下,庫是被設計成被我們的程式碼呼叫的。最重要的是,庫是被很多很多個程式設計風格迥異的團隊呼叫的,這讓庫的作者們有動力去編寫具有通用介面和簡單職責的函式。
面向庫的程式碼更具有多對多的特性,而不是一對多,這就使得它更有表達性。
那是不是面向框架的程式碼都是壞的?其實並不一定,只是取捨而已。框架提供了做事的標準方式。框架承諾幫我們幹更多事情,特別是幫我們幹很複雜的事。
理想情況下,雖然我們的程式碼在框架之下會變得表達性很低,我們的目的是寫更少的程式碼。而我們使用其它手段來降低程式的可感知複雜度。
從我們對 linrec
, binrec
和 multirec
這些高階函式的探索中,我們發現專一介面和通用介面的對比,框架和庫的取捨。
【原文】 From Higher-Order Functions to Libraries And Frameworks
譯後記
此文舉例的高階函式,是用遞迴實現的。大多數情況下, merge
和 sum
是用迭代實現的。那麼,這些例子還有什麼用嗎? multirec
多元遞迴的使用場景是什麼?敬請期待下一篇譯文《遞迴資料結構與影象處理》
關於我們
我們是螞蟻保險體驗技術團隊,來自螞蟻金服保險事業群。我們是一個年輕的團隊(沒有歷史技術棧包袱),目前平均年齡92年(去除一個最高分8x年-團隊leader,去除一個最低分97年-實習小老弟)。我們支援了阿里集團幾乎所有的保險業務。18年我們產出的相互寶轟動保險界,19年我們更有多個重量級專案籌備動員中。現伴隨著事業群的高速發展,團隊也在迅速擴張,歡迎各位前端高手加入我們~
我們希望你是:技術上基礎紮實、某領域深入(Node/互動營銷/資料視覺化等);學習上善於沉澱、持續學習;性格上樂觀開朗、活潑外向。
如有興趣加入我們,歡迎傳送簡歷至郵箱:[email protected]
本文作者:螞蟻保險-體驗技術組-草津
掘金地址:serialcoder