OOP vs Functional Programing
前一章提到了封裝與繼承兩個物件導向原則,這篇來看看 JS 的物件導向還有函式程式設計,物件導向讓所有程式語言更易讀、擴展性、複用性、維護性更高,而函式設計則是以 function 為主體的設計模式,目的也是讓程式更易讀好用。
OOP vs Functional Programing
OOP 像機器人,能把頭手腳分開與組裝,頭手腳可以分別做不同的動作,FP 則不同,FP 像個工廠,將資料放進去後,出來的會是新的東西,這兩者其實是不衝突的,你可以同時使用 OOP + FP 來打造你的 Design Pattern。
OOP
OOP 基本結構
OOP 目的就是將程式模組化,來看看上篇提到的物件
let dragon = {
name: 'Tanya',
fire(){
return true
}
}
我們把 name 稱為 state 狀態,fire 為 behavior 行為,兩者加在一起就是物件導向基本結構
Factory function
工廠函式有點像是世紀帝國裡建村民的教堂,建士兵的兵工廠,你發現有好幾個相同方法狀態的物件,我們就能用工廠函式把這些物件統一由一個函式建造出來
// factory function make/createfunction createElf(name, weapon) {//we can also have closures here to hide properties from being changed. return { name: name, weapon: weapon, atack() { return 'atack with ' + weapon } }}const sam = createElf('Sam', 'bow');const peter = createElf('Peter', 'bow');sam.atack()
這讓程式更省空間,我們同時共享了一個函式方法,就不用同時宣告不同的記憶體來放相同的方法了
Object.create()
在進入真正的 OOP 前 ,我們先來看看 Object.create ,依照字面上的意思他幫助你創造一個物件
const elfFunctions = {attack: function() {return 'atack with ' + this.weapon}}function createElf(name, weapon) {return {name,weapon}}const sam = createElf('Sam', 'bow');sam.attack = elfFunctions.attack ///將一個物件賦予一個方法const peter = createElf('Peter', 'bow');sam.attack()
我們用工廠函式創造 sam 賦予屬性後,用 sam.attack = elfFunctions.attack 賦予方法,此時如果有很多方法時,這sam就會一直賦值下去,整個程式碼就被拉得無比的長,如果我們改用 Object.create 呢
function createElf(name, weapon) {//Object.create creates __proto__ linknewElf = Object.create(elfFunctions)newElf.name = name;newElf.weapon = weaponreturn newElf}const sam = createElf('Sam', 'bow');const peter = createElf('Peter', 'bow');sam.attack()
Object.create 在工廠函式內創造了 elfFunctions 與他的原型鏈,這樣只要把 create 出來的新物件賦予方法就能將方法與屬性封裝起來,但這樣還是不夠好,總覺得在裡面多宣告一個方法有點怪怪的。
Constructor function
建構函式,我們可以用建構函式來做上面的工廠函式,既方便又直觀
//Constructor Functionsfunction Elf(name, weapon) {this.name = name;this.weapon = weapon;}Elf.prototype.attack = function() {return 'atack with ' + this.weapon}const sam = new Elf('Sam', 'bow');const peter = new Elf('Peter', 'bow');sam.attack()
利用 new 關鍵字創造這函式物件,代表說我們創造了一個有著 name, weapon 屬性的物件,少了 new 關鍵字,這函式不是創造一個新的物件而是執行這 function ,所以這關鍵字跟 object.create 是一樣的
既然創造了物件,就一樣有 prototype,我們可以用 function.prototype 賦予這物件方法,讓創造的物件使用,注意一點是必須使用普通函式 function()而不是箭頭函式,在前面幾章講到箭頭函式是作用於創造它的環境,所以 this 會是 prototype 這層自然沒有 weapon,而function 是動態的作用域,作用於呼叫他的環境,所以才能將weapon帶進去
ES6 Class
在 JS 類別還沒出來以前,prototype base 的物件導向讓開法者覺得用起來滿怪的,到了 ES6 終於出了 Class 類別這個關鍵字
class Elf {constructor(name, weapon) {this.name = name;this.weapon = weapon;}attack() {return 'atack with ' + this.weapon}}const fiona = new Elf('Fiona', 'ninja stars');console.log(fiona instanceof Elf) //const ben = new Elf('Ben', 'bow');fiona.attack()
我們省去了 Elf.prototype.attack = function(){}
類別讓 JS 與 JAVA 一樣能夠封裝整個物件,但創造這個類別物件只有用 new 才行,new 完後我們才創造一個 Elf 實例
其實 Class 只是一個語法糖方便開法者使用,讓程式碼更簡潔,骨子裡當然還是 Prototype based,類別只是讓我們的封裝看起來更加優雅
this 的四種用法
先重新複習一下之前講過的 this,物件內有的幾種用法
1. 狀態屬性,把要創造的狀態或屬性用 this 綁定於該物件函式作用域
// new bindingfunction Person(name, age) {this.name = name;this.age =age;console.log(this);}
2. 內部方法,在自身物件方法內調用自身物件屬性
const person = {name: 'Karen',age: 40,hi() {console.log('hi' + this.name)}}
3. 外部方法,想要在自身方法內調用外部方法,使用 bind 做綁定 this
const person3 = {name: 'Karen',age: 40,hi: function() {console.log('hi' + this.setTimeout)}.bind(window)}
4. 箭頭函式,在創造函式時也把作用域綁定進去,用於在自身方法內又需要創造一個函式
const person4 = {name: 'Karen',age: 40,hi: function() {var inner = () => {console.log('hi ' + this.name)}return inner()}}
繼承
在 Class 還沒出現前,JS 的繼承是相當麻煩的,就像前面一篇提到的 prototype 地獄還有object.create,而 Class 出現後 ,只要使用 extends 就能優雅乾淨的繼承另一個物件
class Character {constructor(name, weapon) {this.name = name;this.weapon = weapon;}attack() {return 'attack with ' + this.weapon}}class Elf extends Character {constructor(name, weapon, type) {// console.log('what am i?', this); this gives an errorsuper(name, weapon)console.log('what am i?', this);this.type = type;}}class Ogre extends Character {constructor(name, weapon, color) {super(name, weapon);this.color = color;}makeFort() { // this is like extending our prototype.return 'strongest fort in the world made'}}const houseElf = new Elf('Dolby', 'cloth', 'house')//houseElf.makeFort() // errorconst shrek = new Ogre('Shrek', 'club', 'green')shrek.makeFort()
我們可以看到在Character constructor 內創造了幾個屬性,name, weapon,然後我們用 Elf 去繼承 Character,就像 JAVA 一樣,你必須使用 super 關鍵字來繼承上層的屬性,如果沒有宣告 super,繼承的物件也不會有 this
Public & Private
在 JAVA 中,私有成員與公開成員都有相對的關鍵字可以使用,而 JS 則沒有,所以只能用閉包或是其他方法來實現,其實 JS 一直都有準備將 private 納入標準中,也已經有 private 的保留字,至於一些相關開發能在 github 上面看到
OOP 小結與四大特性
回過頭來小結一下 JS 在物件導向四大特性的表現
1. 封裝
用 ES6 類別封裝或是使用函式物件這兩種封裝方法,也可以另外再使用閉包來實現私有成員
2. 抽象
在 JS 當中還沒有抽象 abstract 類別,雖然已有保留字,抽象有點像前面提到的工廠函式 ,但不能完全是,因為抽象不能創造物件,這樣的話你的工廠函式就必須一定得繼承然後實作抽象類別內的方法才能創造物件,而 JS 不會去限制說你的類別一定不能創造物件,所以你只能用coding style 去限制說是不是抽象
3. 繼承
來複習一下剛剛提到的,類別的 extends 與函式物件的 Object.create
4. 多型
Erlang 的開發者Joe Armstrong 對繼承有個比喻:「你只是要根香蕉,結果卻得到一隻拿著香蕉的大猩猩,還有整座叢林」,所以多型就是要解決這個問題。
多型是將相同的方法傳遞給不同的物件,讓他們有不同的行為,雖然 JS 本身是不具備多型的特性,畢竟沒有抽象類別沒辦法實作我們要多型的方法,但我們可以將 JS 的多型用 instanceof 來簡單實現
Dog.prototype=new Animal(); ///inherit Animal
function AnimalBite(animal){
if(animal instanceof Animal) ///instanceof bite Dog.bite() and Cat.bite()
animal.bite();
}
var cat=new Cat();
var dog=new Dog();
AnimalBite(cat);
AnimalBite(dog);
利用判斷 instanceof 的方法來讓不同的物件有著相同的方法,另一種是使用等等要談到的 FP
function AnimalBite(animal) {
return Object.assign({},animal,{bite:()=>{}})
}
class Dog extends Animal{
AnimalBite(Dog)
}
Functional Programing
Pure function
在純函數中,輸入與輸出資料必須是顯性的,也就是一定要有參數與回傳值,反之非純函數就是會動到外部變數的,就像例子一樣,裡面的 arr.pop 改動了外部的 array,我們稱之為函式的副作用
//Side effects:const array = [1,2,3];function mutateArray(arr) {arr.pop()}function mutateArray2(arr) {arr.forEach(item => arr.push(1))}//The order of the function calls will matter.mutateArray(array)mutateArray2(array)array
如何解決這副作用,你得在 function 裡面宣告新的變數再賦值,最後 return 這新的變數
Referential Transparency
引用透明也是解決函式副作用的方法,如果程式中任意兩處具有相同輸入值的函數調用能夠互相置換,而不影響程式的動作,那麼該程式就具有引用透明性,講的很饒口,來看看例子
function a(num1,num2){
return num1 + num2
}
function b(num){
return num*2
}
b(7) //14
b(a(3,4)) //14
你的參數並不會因為你是7還是a(3,4)而改變程式的動作,所以純函式也是具有引用透明性的,這就是函式程式設計原則
純函式目的
函式程式設計真的是讓全部的函式變成純函式嗎? 其實不是,你不能避免掉從外面調用變數進 function 修改值,所以純函式的目的是減少 function side effects,函式的副作用減少了,debug 起來也會相對快速許多,耦合性也沒那麼重
FP 7 個原則
- 1 Task // function只做一件事
- return Statement // 會回傳結果狀態
- Pure //純函式
- No shared state //兩個函式不共用同個狀態
- Immutable State //不可變的
- Composable //可被組合
- Predictable // 可預期的
Immutable State 不可變的,函式不會改變外部變數狀態,意旨說你返回的結果需為新的資料
idempotent
冪等元素是指被自己重複運算的結果等於它自己的元素
先來看看下面例子
// Idempotence:function notGood() {return Math.random()// new Date();}function good() {return 5}Math.abs(Math.abs(10))
Math.random 每次呼叫時會 return 不同的數字,很明顯他是不可預期的,而 Math.abs 絕對值運算你就算呼叫多少次都是一樣的數值,就像是 call api 每次回傳都是同樣格式的結果,這代表說你不會因為重複呼叫函式而去改變結果
Imperative vs Declarative
指令式 vs 宣告式
指令式就像是命令,你命令 a 對 b 做加法這個動作,或是條件與迴圈等運算判斷,都是指令式的一種
宣告式像是你要拿到什麼,而不是如何一步一步地去做計算,宣告式已經將步驟已經封裝好了,你只要宣告我要獲得什麼樣的結果即可,例如說正則表達、SQL都是宣告後所要取得的資料是什麼
函數式程式設計就是一種宣告式程式設計
Immutability
不變性,剛剛提到了函式不可修改外部物件或變數,但為何要這樣做呢?
const obj = {name: 'Andrei'}function clone(obj) {return {...obj}; // this is pure}function updateName(obj) {const obj2 = clone(obj);obj2.name = 'Nana'return obj2}const updatedObj = updateName(obj)console.log(obj, updatedObj)
首先我們對物件做一個拷貝clone的函式,然後只要對這物件進行操作我們都使用他拷貝過後的替身做動作,然後再返回做完動作後的新物件,如此一來我們就有兩個新的物件
但問題來了,還要創造一個新的物件,這會造成相當大的記憶體浪費,此時就有了 Immutable.js
Immutable.js 他使用了 structural sharing 結構共享,所有的更新都會產生一個新的值,但相同的部分共享,下面是詳述這個套件
HOF 高階函式
當一個函式返回另外一個函式,我們稱之為高階函式
複習前幾章,我們可以用高階函式來做我們的閉包,把變數鎖在閉包內,高階函式是 Functional Programing 的一種方法
//HOFconst hof = (fn) => fn(5);hof(function a(x){ return x})//Closureconst closure = function() {let count = 55;return function getCounter() {return count;}}const getCounter = closure()getCounter()getCounter()getCounter()
Curry
在前幾章高階函式有提到函式 Curry 化 ,我們把函式像俄羅斯娃娃一樣包起來,函式 Curry 化也是 FP 很重要的觀念
//Curryingconst multiply = (a, b) => a * bconst curriedMultiply = (a) => (b) => a * bcurriedMultiply(5)(20)const multiplyBy5 = curriedMultiply(5)multiplyBy5(20)
Partial Application
函數局部調用,跟 Curry 化不同,局部調用顧名思義就是固定某幾個參數,讓函式只能調用局部的參數,以減少參數個數
//Partial Applicationconst multiply = (a, b, c) => a * b * cconst partialMultiplyBy5 = multiply.bind(null, 5)partialMultiplyBy5(10, 20)
Curry : 每個函式只傳一參數
Partial Application : 每個參數分批傳
我們用這兩種方法讓函式邏輯切得更小,減少函式調用次數,輕鬆達到 Keep it simple and stupid !
Cache & memorization
我們可以用 FP 來實作快取機制以優化效能,記得前面說的引用透明 Referential Transparency 嗎? 當你輸入相同值,你返回的值會相同,所以我們可以把快取做一個物件,key 是輸入,值是輸出,當輸入後我們會先找有沒有相對應的值,有的話就會立即返回值,不須重新做計算
function memoizeAddTo80(n) {let cache = {};return function(n) {if (n in cache) {return cache[n];} else {console.log('long time');const answer = n + 80;cache[n] = answer;return answer;}}}const memoized = memoizeAddTo80();console.log(1, memoized(6))// console.log(cache)// console.log('-----------')console.log(2, memoized(6))
Pipe & Compose
Pipe 與 Compose 兩個目的相同,都是讓 FP 合成在一起,只是 Pipe 是由左而右, Compose 反之,首先提到鏈式調用,什麼是鏈式調用呢? 其實 promise 就是最常看到的鏈式調用
doSomething()
.then(result => doSomethingElse(result))
.then(newResult => doThirdThing(newResult))
.then(finalResult => {
console.log(`Got the final result: ${finalResult}`);
})
.catch(failureCallback);
可以看到當 doSomething 完成後我們用 then 變成一個鏈子做動作完後連結下去
Pipe & Compose就是要解決鏈式調用的問題
簡單的 compose 例子
double = (x) => x * 2add1 = (x) => x + 1compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x)compose(double, add1)(100) // 202
首先我們有兩個函式一個是add1 & double ,compose 是從右到左,所以順序是 (double, add1),參數是 100 ,答案就是 202
compose 裡面 …fns 來解析每個 function 之後通過 reduceRight 方法來將每個 function 返回的數值做加總
const compose = (f, g) => (a) => f(g(a))const pipe = (f, g) => (a) => g(f(a))const multiplyBy3AndAbsolute = pipe((num) => num*3, Math.abs)console.log(multiplyBy3AndAbsolute(-50))
FP 小結與設計模式
所以 FP 要怎麼設計,這麼多的方法種類,我想來歸納一下全部的方法,再用例子去實現這些方法,這裡是 FP 範例
- HOF 高階函式
- Curry
- Partial Application
- Pipe & Compose
首先有別於物件導向是以物件為出發點, FP 是以動作去發想的,我們可以用 HOF 搭配 Curry 做一個動作,ex:
const user = {name: 'Kim',active: true,cart: [],purchases: []}function itemToCart(user, item){const updateCart = user.cart.concat(item)return Object.assign({},user,{cart:updateCart})}
我們用網購為例子,使用者買東西的時候第一件事是將物品放置於購物車,我們當作第一個動作,裡面我們用到 HOF 返回了一個新的物件
此時可以看到 immutable 原則,const updateCart = user.cart.concat(item) 因為我們不能直接操作外面的 user 物件,所以我們在裡面用新的變數,然後再用 Object.assign 去重新返回一個新的 user 物件與新的資料
接下來還有增加手續費、購買、購物車清空三個動作,因為都是相同操作原則 immutable 這裡就不贅述了
再來重頭戲,我們需要把這一連串動作串在一起
const compose = (f, g) => (...args) => f(g(...args))const pipe = (f, g) => (...args) => g(f(...args))const purchaseItem = (...fns) => fns.reduce(compose);const purchaseItem2 = (...fns) => fns.reduce(pipe);purchaseItem2(addItemToCart,applyTaxToItems,buyItem,emptyUserCart,)(user, {name: 'laptop', price: 60})
我們用 Partial Application 局部調用參數,一個一個將上一動作返回結果去對下一動作進行操作,可以用擴展運算子 "…",讓所有加入的參數函式都能解構賦值,reduce 對返回結果與下一個結果做操作
fns.reduce((f, g) => (...args) => f(g(...args)))
最後前面提到的 compose 登場,使用到函式的 Curry 化將 g 包在 f 內,pipe 反之,f 為上一函式的返回,g 為目前要做的
fns.reduce(function(f,g)=>{ return function(...args){
return f(g(...args))
}})
總結
OOP 因為對應的現實生活的物件,所以比較好理解,而 FP 則需要更多的範例,尤其是 compose 與 curry ,其實滿難閱讀的,之後應該也會寫一篇從 React + Redux 的 Functional Programing 來加深對 FP 的觀念,再比較一下何時該用 OOP 以及何時該用 FP 這樣。