這篇文章中,咱們會探索一些高階函數,去思考如何用這些函數來讓咱們的程序更具表達性;同時,咱們也要在程序可感知複雜度(perceived complexity) 和表達性之間達到折中和平衡。html
編程中最基礎的概念之一就是函數能夠調用其它函數。前端
當一個函數能調用其它函數,且當一個函數能被多個其它函數調用時,咱們就能夠幹不少有意思的事情。而函數之間多對多的關係,比起一對多的關係,能讓程序具備更強的表達能力。咱們能夠給每一個函數單一的職責,而後命名這個職責;咱們還能確保有且僅有一個函數承擔某項職責。程序員
多對多的函數關係使得函數與職責之間一對一的關係成爲可能。面試
程序員常常說某個語言很是具備表達性,然而並無一個普世單一的標準來定義什麼是表達性。大多數程序員贊成,決定某個語言具備「表達性」的一個重要特徵是,該語言讓程序員能很容易避免代碼中不必的囉嗦。若是函數擁有多個職責,這會讓函數自身變得龐大笨拙。若是同一個職責要被屢次實現,這就形成了程序的冗餘。算法
若是程序中的函數都具備單一職責,且全部職責都僅被單一函數實現一次,這樣的程序就避免了不必的囉嗦。編程
綜上,函數之間多對多的關係,讓編寫高表達性程序成爲可能。而那些無此特徵的程序,在「表達性」上則是很是糟糕的。數據結構
然而,能力越大,責任越大。多對多函數關係的負面就是,隨着程序體積增加,該程序能幹的事情就急劇增多。「表達性」常常與「可感知複雜度」存在衝突。架構
爲了更容易理解上面的論斷,咱們畫個關係圖來類比來思考下。每一個函數是一個節點,函數之間的調用關係是連線。假設程序中沒有死代碼,那麼每一個結構化程序都造成一個鏈接圖。框架
給定已知數量的節點,在這些節點中能畫的鏈接圖數量造成 A001187 整數序列。(譯者注:這個太硬核數學了,不懂)...(數學,不懂,省略翻譯)... 總之,僅僅 10 個函數就能造成多於 34 兆個程序組合方式……ide
程序靈活性的爆炸性增加讓程序員不得不剋制一下。函數和職責之間一對一關係帶來的好處,被由此而形成的無限複雜度而抵消。試想理解這種複雜度的程序多麼困難。
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/互動營銷/數據可視化等);學習上善於沉澱、持續學習;性格上樂觀開朗、活潑外向。
若有興趣加入咱們,歡迎發送簡歷至郵箱:ray.hl@antfin.com
本文做者:螞蟻保險-體驗技術組-草津
掘金地址:serialcoder