本文章同時發佈於:
嗨大家好,最近因為鐵人賽的關係,許多優秀的 FP 文章被產出,例如: Functional Programming in JS、mostly:functional 從零開始的異世界程式觀 --- 函數式程式設計的試煉
裡面都寫得非常好,我也受到了許多大大指點。
而我這篇主要是受到Clean Architecture此書啟發,想要說明:
結構化、OOP 物件導向、FP 函數式並不相違,事實上他們都有同一個目標,就是避免程式暴走
我並不是什麼程式專家,但是我想我這些粗淺的觀點,或許可以對同樣身在 FP 雲霧裡的你造成一些幫助,因為我也在霧中探索許久,可能比較能用新手的角度來描述
XD!
就是我們人類所想的與程式執行的結果,不!相!同!
那要怎麼避免呢,這很簡單:
就是加上約束,而這些約束後來慢慢的被歸類在一塊就變成了一種典範(paradigm)
如果有一個小學,沒有什麼規定,導致學生們都在奇怪的時間來上課,那怎麼辦,很簡單,那就規定 8 點要上課呀,這是很自然的事情,而程式也是一樣的。
程式的典範,換言之可以把他比喻為一種約束
,如良葛格的程式語言的特性本質(二)類別與原型的物件管理學所說:「給予物件個體化能力,就必然要有某些慣例上的約束。(以 OOP 來說)」。
而程式從古至經大致分為三個典範,書中也提到幾乎沒再看過其他典範了
,
取自 Clean Architecture 一書 P46
如果書中講得太抽象,我可以用白話把他的精神表達給大家:
goto
,避免程式亂跳我都不知道跑到哪惹Q口Q。此物件根本沒有的變數/方法
。根本不知道變數在何時變化了
。結構化相信大家沒有爭議,goto
有害論早在大學時老師就說:「這會造成程式亂跑所以母湯啊!」
物件導向(OOP)相信大家也沒有爭議,最近出的 Vue3.0 也是增強了 TypeScript 的支援,用 JavaScript 舉個例子:
在沒 TypeScript 之前:
function printName(a) {
console.log(a.name);
}
const a = "Apple";
printName(a);
// undefined
有天使用者不小心帶錯 a 成字串,這樣 undefined 爆炸就會出現,但如果改用 TypeScript 就會在編譯時就告訴你,a必須要有name啊!
:
interface A {
name: string;
}
function printName(a: A) {
console.log(a.name);
}
const a = {
name: "Apple",
};
printName(a);
// Apple
函數式(FP)能解決的問題就比較少見到舉例,就算舉例了也很難讓人信服,比如說:
可讀性好
: 有些人反駁:「那個...我根本看不懂你在寫蝦咪,code review 退回 orz」無副作用
: 有些人反駁:「假設一個會員系統根本不會儲存
(這是副作用,因為有狀態變化),那這個系統有什麼用」我這邊舉一個厲害的前輩告訴我的例子,我認為最直觀,他主要是在說明:
程式演算時產生的賦值問題
用 JavaScript 來說明,如果我們要在[0, 1, 2]
通加 1:
let a = [0, 1, 2]
for(let i = 0; i < 3; i++) {
a[i] += 1
}
// ... 其他一堆程式碼
接下來如果同事問你:「這個 a 算完之後會是多少啊?」
你只能說:「應該是[1, 2, 3]
吧,因為我不知道下面其他一堆程式碼
有沒有再對他做變化」。
而 FP 就是要跟你保證,a 的值在你看到的演算範圍即是他的結果
const a = [0, 1, 2].map(item => item + 1)
Object.freeze(a) // JS特性,必須在用freeze讓array真的被const(固定)住
// ... 其他一堆程式碼
而這個時候同事問你,你可以保證:「a 肯定是[1, 2, 3]
,因為 a不可變
了,下面的其他一堆程式碼
都無法再更改他」。
傳統的for迴圈
方法,你非得要讓變數可變
,才能透過迴圈一次一次的賦值以更新值,而map
這樣 FP 的方法是透過function遞迴
直接生出一個新的值,所以變數可以不可變
,
但這代表整個系統中都沒有賦值嗎?並沒有,因為const a
就是一個賦值了,我們期待的是演算處地方全都是沒有賦值的
,但需要使用此演算結果的地方還是要賦值。
良葛格大大的解開對函數式的誤解有一句話非常精闢:
其實純函數式要求的是,副作用函式與純函式有個明確的界線,一邊是完全純粹的世界,不純的世界是另一邊
而例子中的const a {不純的副作用世界} = {純粹的演算世界}
正有這樣的概念在裡頭。
所以說,物件導向(OOP)可以對整個系統做介面的約束
,讓整個系統有約定的溝通。而函數式(FP)負責整個系統演算的純淨
,開發者可以有把握的說明數值的變化,他們並不相違。
結構化、OOP 物件導向、FP 函數式都是在約束,只是要約束的爆走事項不同。
那就透過 function 遞迴,複製他重新做一個新的
這...這聽起來應該是滿荒唐的,大學老師都在教你不要亂複製與遞迴導致記憶體大爆滿,如果 Stack Overflow 怎麼辦,你現在跟我說有變動就給他複製?
但你可以回想一下,你現在什麼時候真的 Stack Overflow 過了?正常情況下幾乎沒有,或許只有在遞迴忘記 return 的時候或者Event Listener
忘記釋放的狀況才會發生。
FP 是非常早就有的典範,他的思想來自於 Lisp,而 Lisp 誕生的甚至比程式還早(你沒聽錯 XD),當初會被遺忘的原因是當時電腦太爛,所以我們必須嚴格掌控記憶體與運算資源
,所以 FP 很快就消失了。
但現在記憶體與運算資源
都不太是什麼大問題了,比較大的問題是變數的變化在大型系統中難以掌控
,比如說:「剛提到的 deadlock、JavaScript 單純的變數變化也是」。
你可能會說記憶體與運算資源
怎麼可能不重要,但實際上軟體開發的變遷中,許多系統發現變數掌控
價值更高一些,所以 FP 又出現了。
我們的應用程式不是 CRUD 的;他們只具備 CR。此外,因為在資料儲存空間中不會發生更新或刪除的動作,所以不會有任何平行化問題。 - 來自 Clean Architecture 一書 P46
JavaScript 不會有平行化問題
,因為只有單執行緒,但你可以把此字換成變數變化大暴走問題
,那就能夠呼應了 XD。
當然,我們也可以透過immutable.js
來解決記憶體分配問題,但不可變可解決的問題比較是這裡想討論的@@!
推廣 FP 的人常會說 FP 好閱讀,但不熟的人去看,反而像天書,為什麼會這樣呢
我認為是因為語法(syntax),不同典範所追求的目標會導致 syntax 的不同
FP 並不是難,麻煩的是我們看到了一個從沒碰過的 syntax,
如果要前 25 個數計算平方,用純正的 FP 語言 - Clojure 會如下:
# 取自 Clean Architecture 一書 P42
(println(take 25(map (fn [x] (\* x x))(range))))
# or
(println
(take 25
(map
(fn [x] (\* x x))
(range))))
看起來可能很天書,但實際上就跟我第一次看到物件導向(OOP)一樣,我一開始也是不懂 OOP syntax 的重點是什麼,
因為我沒有理解他想要解決、約束什麼問題
但理解物件導向(OOP)是為了用介面以確保程式溝通的穩定,就會覺得合理許多,而函數式(FP)你需要理解是為了刪除賦值的動作
,那這個 syntax 就會更加合理,因為沒有賦值的話當然是把演算都透過 function 串起來呀!
(題外話: 其實 FP 有許多語言是可以賦值,但約束難度都很高,所以本質上還是希望開發者不在演算中賦值,只在一些真的要輸出的狀況下,比如說 DB 儲存之類)
競賽條件(race condition)、deadlock 條件(死結條件)和平行更新問題(concurrent update problem) - 來自 Clean Architecture 一書 P43
這些問題的產生都是變數
,多個執行緒在更新變數都會造成以上問題,但在 JavaScript 其實是單執行緒(不討論 Node.js worker threads),所以以上問題都較難遇到。
在 JavaScript 中使用 FP 其實最主要就是透過不變性來讓變數不可變,藉此達到很好的變數掌控,因為call by sharing導致 JavaScript 變數變化難以察覺,所以用 FP 是有好處的。
但 JavaScript 並沒有發揮 FP 所有的長處,所以使用的 JavaScript 開發者聽到 FP 可以解決 deadlock 會感受不夠深切,至少我在前端專案是體會不出來的 orz。
其實他什麼都不是,等等不要拿舉起程式人的法槌...!因為這也代表他是什麼都是的混合語言(Hybrid Language)
大家可以看看 Rober C. Martin 大師的影片 - The Last Programming Language 之 39:57 處
當我們要歸類一種語言的類型的時候,其實就代表此語言要更加符合此類型的約束
如影片所說:
但以上語言我都可以選擇適合他們的典範來約束,只要可以幫助我們即可!
所以大家可以想想,JavaScript 有很多語法約束嗎?沒有,因為一些歷史性的因素,導致它極為彈性,開發者難以掌握,為了約束這樣的彈性,衍伸出 TypeScript、ClojureScript 具有更多語法約束的語言,試圖讓開發者可以基於這些約束來開發出能在 JavaScript 環境運行的程式。(感謝良葛格大大提供講解建議)
所以當你試著在純 JavaScript 中尋找純粹 OOP 或者 FP 的理由時,這是很不好的,你很快會迷失在其中(就像這幾年的我 XD),你應該思考的是:
如何在 JavaScript 或者其他混合語言(Hybrid Language)中找到合適的時機使用結構化、物件導向(OOP)、函數式(FP)的招式
終於打完了,其實我是一直不太敢發 FP 相關的文章,因為 Clojure、Haskell 等純正的 FP 語言我並沒有真的在實戰中運用,所以實在是很怕不了解其中道理。
但在看了 Rober C. Martin 的Clean Architecture、The Last Programming Language,讓我稍微有把握可以分享,至少文章幾乎所有內容都是從中延伸的,所以也算有個依據...吧?
但還是希望 FP 的大師們,如果文章有錯誤,請告訴我我會盡快改正他,謝謝~
謝謝你的閱讀~
是說 Rober C. Martin 講話真的是冷不防的暴走,非常有趣。
這一段…
JavaSript 有很多規範約束嗎?其實沒有,因為他極為彈性,這是非常好的優點也是缺點,我自己認為這也使得 JavaScript 很好衍伸出 TypeScript、ClojureScript 這樣不同規範的語言,因為沒有約束包袱,這導致很好去約束他。
建議「規範」改為「文法」、「語法」或者是「限制」之類的字眼,看到「規範」一般會想成是規範書、規格書(specification)之類的東西,或者你指的是典範(paradigm)?
標準化後的 JavaScript,規格書不少。
另一方面,不知道「JavaScript 很好衍伸出 TypeScript、ClojureScript」中,所謂的「衍生」是指什麼…
假設你指的是 A 可以轉譯為 B 的原始碼,然後在 B 可以執行的環境中執行。
就我目前的認識而言,這類的轉譯通常意謂著,A 轉譯為 B的話,兩個語言之間會有可以對照的部份,更便於寫 A 時,可以大致對照至 B 的部份,必要時可以在 A 引用 B 的功能。
也就是 A 可以轉譯為 B,通常是特意為之,兩個語言間往往會有相近的語法或元素,目的是封裝 B 不好的語法特性,或者在 A 中提供比 B 更好的語法特性。
就這點來說,記得 TypeScript 實作是符合 ES 規範的,也就是 TypeScript 的子集實現了 ES,ClojureScript 是種 Lisp 方言,JavaScript 本身也有 Lisp 的元素。
如果是這樣的話,我也可以說「Java 很好衍伸出 Scala、Kotlin」這類語言…
就你這段而言:
JavaSript 有很多規範約束嗎?其實沒有,因為他極為彈性,這是非常好的優點也是缺點,我自己認為這也使得 JavaScript 很好衍伸出 TypeScript、ClojureScript 這樣不同規範的語言,因為沒有約束包袱,這導致很好去約束他。
看看這段的上下文,感覺你其實是在講:
「JavaScript 有很多語法約束嗎?沒有,因為一些歷史性的因素,導致它極為彈性,開發者難以掌握,為了約束這樣的彈性,衍伸出 TypeScript、ClojureScript 具有更多語法約束的語言,試圖讓開發者可以基於這些約束來開發出能在 JavaScript 環境運行的程式。
另一方面,這也表示,你可以在 JavaScript 中,視情況而定,採取某個(典範)約束,而不是追求純綷的結構化、OOP 或 FP。
既然這篇文章是在談 FP,當然也就是鼓勵你找尋適當時機來使用 FP。」
文中「聽說 FP 是古老的語言,什麼事情讓他重返戰場?」 <- FP 是個典範(paradigm),不是語言。
感謝建議,文中多次引用的良葛格大大竟然來留言了,在您的Haskell系列文章被開導很多,真心感謝,
JavaSript 有很多規範約束嗎?其實沒有,因為他極為彈性,這是非常好的優點也是缺點,我自己認為這也使得 JavaScript 很好衍伸出 TypeScript、ClojureScript 這樣不同規範的語言,因為沒有約束包袱,這導致很好去約束他。
後來思索許久,我把您的話直接引用了,因為條理太清楚了XD,理由跟說法都比原文更加充分,也表達了我想說的意思。
另外我把文中的規範
一律改為典範(paradigm)
,並在第一次提到典範的時候加上英文,以供讀者查詢原意。
再次感謝。