https://www.zcfy.cc/article/composing-software-an-introduction-javascript-scene-medium原文鏈接: medium.com
注意:這是從頭開始在 JavaScript ES6+ 中學習函數式編程和組合軟件技術的“組合軟件”系列的介紹。敬請關注。還有更多!
目錄:
組合軟件:0. 簡介(本文)
組合:“組合部分或元素組成整體的行爲”〜“Dictionary.com”
在我第一節高中程序設計課上,我被告知軟件開發是“將複雜問題轉化爲較小問題,並組合簡單的解決方案以形成複雜問題的完整解決方案的行爲”。
我生命中最大的遺憾之一就是未能早日理解這一節課的意義。我太晚才學到軟件設計的本質。
我採訪過不少開發人員。從這些談話中我瞭解到我並非個例。很少有現職軟件開發人員很好地掌握了軟件開發的本質。他們不知道手頭現有的最重要的工具,或者如何善加利用它們。所有人一直在努力回答軟件開發領域中最重要的一個或兩個問題:
什麼是函數組合?
什麼是對象組合?
問題是,就算你不知道組合也沒法避開它。你仍然在這樣做 - 只不過做的很糟糕。你編寫的代碼有更多的缺陷,也讓其他開發人員更難理解。這是個大問題。代價非常昂貴。維護軟件所花的時間比從頭開始創建它們還要多,並且缺陷會影響到全球數十億人。
今天,整個世界都運行在軟件之上。每輛新車都是車輪上的一臺迷你超級計算機,而軟件設計的問題會導致真實的事故,以人類的生命爲代價。2013年,在一次事故調查後,陪審團發現豐田的軟件開發團隊的意大利麪條式代碼裏有10000個全局變量,犯有不計後果的罪過。
黑客和政府囤積軟件漏洞,以窺探人們,竊取信用卡,利用計算資源啓動分佈式拒絕服務(DDoS)攻擊,破解密碼,甚至操縱選舉。
我們必須做得更好。
每天都在組合軟件
如果你是軟件開發人員,無論你知道與否,其實你每天都在組合函數和數據結構。你要麼有意識地(及更好)地做,要麼漫不經心地到處修修補補。
軟件開發過程就是將大問題分解成較小的問題,創建解決這些較小問題的組件,然後將這些組件組合在一起,形成一個完整的應用程序。
組合函數
函數組合是將一個函數應用到另一函數的輸出的過程。在代數中,假設兩個函數:f
和 g
,(f ∘ g)(x) = f(g(x))
。這個圓點是組合運算符。它通常被念爲“...與...組合”或“...組合...之後”。你可以大聲說出來 “f與g組合等於x的g的f”,或“f組合g之後等於x的g的f”。我們在g
之後說f
,是因爲g
先被求值,然後其輸出作爲一個參數被傳遞給f
。
每次像這樣編寫代碼時,就是在組合函數:
const g = n => n + 1;
const f = n => n * 2;
const doStuff = x => {
const afterG = g(x);
const afterF = f(afterG);
return afterF;
};
doStuff(20); // 42
每次寫 promise 鏈時,就是在組合函數:
const g = n => n + 1;
const f = n => n * 2;
const wait = time => new Promise(
(resolve, reject) => setTimeout(
() => resolve(20),
time
)
);
wait(300)
.then(() => 20)
.then(g)
.then(f)
.then(value => console.log(value)) // 42
;
同樣,每次鏈接數組方法調用、lodash方法、observable(RxJS等)時,都是在組合函數。如果你正在用方法鏈,就是在組合函數。如果將返回值傳遞給其他函數,那麼就是在組合。如果在一個序列中調用兩個方法,就是用 this
爲輸入數據來組合。
如果你正在鏈接函數,就是在組合。
如果是下意識地組合函數,會做得更好。
下意識去組合函數的話,我們可以將我們的 doStuff()
函數改進爲一行搞定:
const g = n => n + 1;
const f = n => n * 2;
const doStuffBetter = x => f(g(x));
doStuffBetter(20); // 42
對這種形式的一個常見的異議是,它更難調試。例如,我們如何使用函數組合來寫這個?
const doStuff = x => {
const afterG = g(x);
console.log(`after g: ${ afterG }`);
const afterF = f(afterG);
console.log(`after f: ${ afterF }`);
return afterF;
};
doStuff(20); // =>
/*
"after g: 21"
"after f: 42"
*/
首先,我們來把“after f”、“after g” 抽出去記錄到稱爲一個trace()
:的小工具程序中:
const trace = label => value => {
console.log(`${ label }: ${ value }`);
return value;
};
現在我們可以這樣用它:
const doStuff = x => {
const afterG = g(x);
trace('after g')(afterG);
const afterF = f(afterG);
trace('after f')(afterF);
return afterF;
};
doStuff(20); // =>
/*
"after g: 21"
"after f: 42"
*/
像Lodash和Ramda這樣的熱門函數式編程庫包括了讓函數組合更容易的實用程序。你可以像這樣重寫上述函數:
import pipe from 'lodash/fp/flow';
const doStuffBetter = pipe(
g,
trace('after g'),
f,
trace('after f')
);
doStuffBetter(20); // =>
/*
"after g: 21"
"after f: 42"
*/
如果想嘗試不導入東西來實現這段代碼,你可以這樣定義 pipe:
// pipe(...fns: [...Function]) => x => y
const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
如果你還沒有搞清楚這是怎麼回事,請不要擔心。稍後我們會詳細探討函數組合。事實上,它是非常重要的,你會看到在本文中會多次定義和演示了它。關鍵是幫助你逐步對它瞭如指掌,讓其定義和用法變成習慣性的,成爲一個用組合的人。
pipe()
創建一個函數的管道,將一個函數的輸出傳遞給另一個函數的輸入。當使用pipe()
(及其孿生兄弟compose()
)時,不需要中間變量。編寫不涉及參數的函數稱爲 piont-free 風格。要做到這一點,你將調用一個返回新函數的函數,而不是顯式聲明函數。這意味着你不需要 function
關鍵字或箭頭語法(=>
)。(譯者按:關於point-free 風格,可以參考阮一峯的博文《Pointfree 編程風格指南》)
Point-free 風格可能太過了,但這裏有一點很不錯,因爲這些中間變量給你的函數增加了不必要的複雜性。
減少複雜性有幾個好處:
工作記憶
人腦平均只有少量共享資源用於工作記憶(Working Memeory)中的離散量子,而每個變量潛在地消耗這些量子之一。隨着更多變量的添加,我們精確回憶每個變量含義的能力就會降低。工作記憶模型通常涉及4-7個離散量子。超過這些數字的話,錯誤率就會顯着增加。
使用管道的形式,我們消除了3個變量 - 騰出幾乎一半的可用工作記憶去做其他事情。這顯著降低了我們的認知負擔。在將數據分割成工作記憶方面,軟件開發人員趨向於比普通人做得更好一些,不過也不是好很多,因爲分割會削弱貯存的重要性。
信噪比
簡潔的代碼也提高了代碼的信噪比。就像收聽收音機一樣 - 當收音機沒有正確調到電臺時,會產生很多幹擾噪音,更難聽到音樂。當將其調到正確的電臺時,噪點就消失,會得到更強的音樂信號。
代碼是一樣的。更簡潔的代碼表達可以提高理解能力。有些代碼給了我們有用的信息,有些代碼只佔用空間。如果你可以減少所用代碼量,而不會減少它傳輸的含義,那麼可以使代碼更容易被其它需要讀它的人解析和理解。
缺陷表面積
看看函數前後。看起來好像函數在節食減肥一樣。這很重要,因爲額外的代碼意味着額外的缺陷表面積可以隱藏,這意味着更多的缺陷會隱藏在其中。
較少的代碼=較少的缺陷表面積=更少的缺陷。
組合對象
四人幫《設計模式:可重用面向對象軟件的要素》:“對象組合優於類繼承”
“在計算機科學中,組合數據類型或者複合數據類型是可以用編程語言的基礎數據類型和其他複合類型在程序中構建的任何數據類型。[...]構建複合類型的行爲被稱爲組合。“〜維基百科
如下這些都是基礎數據類型:
const firstName = 'Claude';
const lastName = 'Debussy';
而如下是一個複合數據類型:
const fullName = {
firstName,
lastName
};
同樣,所有Array、Set、Map、WeakMap、TypedArray等都是複合數據類型。任何時候,只要你構建任何非基礎類型數據結構,就是在執行某種對象組合。
請注意,“四人幫”定義了一種稱爲組合模式的模式,該模式是組合的一種,使得每個組件成爲容器組件的一個自包含屬性。一些開發人員搞混了,認爲組合模式是對象組合的唯一形式。不要搞混了。
類繼承可以用於構造組合對象,但它是一種限制性和脆弱的方法。“四人幫”說“對象組合優於類繼承”時,是建議使用靈活的方式來組合對象構建,而不是用死板的、緊耦合的類繼承方式。
“四人幫”還定義了其他組合設計模式,包括 flyweight模式、委託模式、聚合模式等。
我們將使用來自《計算機科學中的分類方法:從拓撲學角度》(1989)一書中對象組合的更通用的定義:
“組合對象是通過將對象放在一起,使得後者是前者的一部分而形成的。"
另一個很好的參考是Glenford J Myers 1975年出版的《Reliable Software Through Composite Design》。這兩本書都已印刷很久了,不過如果你想以更深入的技術深度探索對象組合的主題,仍然可以在Amazon或eBay上找到賣家。
類繼承只是組合對象構造的一種類型。所有類都生成組合對象,但並非所有組合對象都是由類或類繼承生成的。“對象組合優於類繼承”意味着你應該從小組件部分形成組合對象,而不是從類層次結構中的祖先繼承所有屬性。後者會導致面向對象設計中衆多衆所周知的問題:
緊耦合問題:由於子類依賴於父類的實現,所以類繼承是面向對象設計中可用的最緊密的耦合。
脆弱的基類問題:由於緊耦合,對基類的更改會潛在破壞大量後代類 - 可能在第三方管理的代碼中。作者可能會沒有意識到會破壞代碼。
層級不靈活的問題:對於單祖先分類法,如果有足夠的時間和演化,所有類別分類法最終對新的用例都是錯的。
重複的必要性問題:由於層級不靈活,新的用例通常是通過重複而不是擴展來實現,導致出乎意料發散的相似類別。一旦重複設置,不清楚哪些類新類應該從哪裏派生,或爲什麼。
大猩猩/香蕉問題:“...面嚮對象語言的問題是它們總是得到語言運行環境的所有隱含信息。你想要一個香蕉,但是你所得到的是一隻拿着香蕉的大猩猩和整個叢林。“〜Joe Armstrong《編程人生》
最常見的對象組合形式稱爲 mixin 組合。它的作用如冰淇淋。你從一個對象(如香草冰淇淋)開始,然後混合你想要的功能。加入一些堅果、焦糖、巧克力漩渦,你搭配堅果焦糖巧克力漩渦冰淇淋。
用類繼承創建組合:
class Foo {
constructor () {
this.a = 'a'
}
}
class Bar extends Foo {
constructor (options) {
super(options);
this.b = 'b'
}
}
const myBar = new Bar(); // {a: 'a', b: 'b'}
用 mixin 組合創建組合:
const a = {
a: 'a'
};
const b = {
b: 'b'
};
const c = {...a, ...b}; // {a: 'a', b: 'b'}
稍後我們將深入探討其他風格的對象組合。現在,你的理解應該是:
能做到這一點的方法不止一種。
有些方法比其它方法更好。
你想爲手頭的任務選擇最簡單、最靈活的解決方案。
總結
本文並非討論函數式編程(FP)對面向對象編程(OOP)或一種語言對另一種語言。組件可以採取函數、數據結構、類等形式...不同的編程語言傾向於爲組件提供不同的原子元素。Java提供對象,Haskell提供函數等...但無論你喜歡什麼語言和什麼樣的範式,你都不能擺脫組合函數和數據結構。最後,這就是最終的結果。
我們將多討論函數式編程,因爲函數是JavaScript中用於組合的最簡單的事情,函數式編程社區已經投入了大量的時間和精力來規範化函數組合技術。
我們不會說函數式編程比面向對象編程更好,或者你必須選擇一個。OOP 與 FP是一種假對立。我近年來看到的每一個真正的Javascript應用程序都廣泛地混合了FP和OOP。
我們將使用對象組合來生成函數式編程的數據類型,而用函數式編程來生成 OOP 的對象。
_無論你如何編寫軟件,都應該很好地組合。
軟件開發的本質是組合。
不瞭解組合的軟件開發人員就像一個不瞭解螺栓或釘子的室內建築師。創建軟件而不知道組合就像室內建築師把牆壁用膠帶和瘋狂的膠水粘在一起一樣。
現在是時候簡化了,而簡化的最簡單的方法就是觸及本質。麻煩的是,業內幾乎沒有人對本質有很好的處理能力。我們作爲一個行業已經讓你,軟件開發者失望了。作爲一個行業,我們有責任更好地培訓開發人員。我們必須改善我們需要承擔責任。一切都在軟件上運行,從經濟到醫療設備。人類社會的方方面面都受到我們軟件質量的影響。我們需要知道我們在做什麼。
是時候學習如何組合軟件了。