JavaScript 函數式編程探索與思考

gd5302 8年前發布 | 25K 次閱讀 函數式編程 JavaScript開發 JavaScript

最近一段時間,函數式編程又開始活躍起來了。函數式編程是一種編程范式,它將電腦運算視為數學上的函數計算,并且避免使用程序狀態以及可變數據。函數式編程強調程序執行的結果而非執行的過程,倡導利用若干簡單的執行單元讓計算結果不斷漸進,逐層推導復雜的運算,而不是設計一個復雜的執行過程。

今天我試圖用js去梳理函數式編程相關的一些知識。文中代碼風格采用Standard,使用ES6語法。

函數式編程初試

首先我們來看一個例子,計算數組元素的和。

我們可以快速用命令式來實現,代碼如下:

const arr = [1, 2, 3, 4]
let sum = 0

for (let i = 0, len = arr.length; i < len; i++) {
  sum += arr[i]
}

利用ES5種提供的reduce方法,代碼如下:

const arr = [1, 2, 3, 4]
let sum = arr.reduce((x, a) => x + a)

可以看到,reduce的函數式代碼與前面命令式代碼解決問題的方式和角度是完全不同的。命令式需要關心所有的過程,如何遍歷以及如何組織結果數據。而reduce由于將遍歷、操作以及結果數據的組織的過程封裝至Array中,我們真正關心的邏輯就是reduce的參數里的匿名函數中的過程。

函數式編程特性

函數是一等公民

函數是一等公民,指的是函數與其它數據類型一樣,可以賦值給其他變量,也可以作為參數傳遞,或者作為別的函數的返回值。

舉例來說,下面代碼中log就是一個函數,作為另一個函數的參數。

function log (str) {
  const date = new Date()
  console.log('[' + date.getHours() + ':' + date.getMinutes() + ':' + date.getSeconds() + ']:', str)
}

['debug', 'info', 'warn'].forEach(log)

純函數

純函數是這樣的一類函數,相同的輸入,永遠會得到相同的輸出,而且沒有任何可觀察的副作用。函數式編程強調沒有“副作用”,意味著函數要保持獨立,每次都返回同樣結果,沒有其他行為,尤其是不能修改外部變量的值。

// 純函數
function increase (x) {
  return x + 1
}

// 不純函數
let seed = 2
function increaseX (x) {
  return x + seed
}

從上面的代碼中,我們可以明顯看到increaseX依賴于外部可變變量seed,也就是說,increaseX的返回值取決于系統狀態,這會導致一些bug變得難以捉摸。

追求純函數其實還是因為純函數帶來的一些好處,比如說能夠根據輸入來緩存;純函數是完全自給自足的,可移植性較強;讓測試更加容易;可并行運行,而不用擔心進入競爭狀態。

不可變數據

JavaScript一共有6種原始類型,分別是Boolean,Null,Undefined,Number String和Symbol(ES6 新增)。除了這些原始類型,其他的都是 Object,而 Object 都是可變的。

const person = {name: 'wen'}

Object.assign(person, {age: 25})
console.log(person); // => {name: 'wen', age: 25}

不過,我們可以使用一個函數作為狀態突變的邊界,完全隔絕于外部代碼的變化。比如說,我們采用merge來隱藏person的可變性。

function merge (...args) {
  let obj = {}
  args.unshift(obj)

  return Object.assign.apply(null, args)
}

const person = {name: 'wen'}
const wen = merge(person, {age: 25}, {telephone: 123456})
console.log(person); // => {name: 'wen'}
console.log(wen); // => {name: 'wen', age: 25, telephone: 123456}

ES5中提供了Object.freeze()來凍結一個對象,但是它是淺操作,也就是說凍結只會發生在最頂層。如果想要深度凍結一個對象,就需要使用遞歸數據結構。

function deepFreeze (obj) {
  let propNames = Object.getOwnPropertyNames(obj)

  propNames.forEach((name) => {
    let prop = obj[name]

    if (typeof prop === 'object' && prop !== null) {
      deepFreeze(prop)
    }
  })

  return Object.freeze(obj)
}

除此之外,Immutable.js也是解決Javascript Immutable Data的一種方案。

惰性求值

惰性求值是指盡可能地推遲求解表達式,它不會預先算好所有的元素,而是在用到的時候才去計算。這樣做可以使昂貴的計算到了必要才會執行,同時可以將值緩存起來,產生更高效的代碼。源于惰性求值特性,Lazy.js相對于lodash、underscore有更好的性能。

ES6中提供了Generator和iterator特性,下面我們利用這兩個特性來實現惰性求值。

function * calulate () {
  yield 1 * 2
  yield 3 * 4
  yield 5 * 6
}

let cal = calulate()
console.log(cal.next().value) // 2
console.log(cal.next().value) // 12
console.log(cal.next().value) // 30

yield之后的表達式不會立即執行,會等到next移到這一句的時候才會執行。

尾調用優化

遞歸最大的好處就簡化代碼,可以把一個復雜的問題用很簡單的代碼描述出來。但是,如果遞歸很深的話,stack會受不了,會導致性能急劇下降。這就需要使用尾調用優化,每次遞歸時都會重用stack,這樣一來能夠提升性能,當然,這需要語言或編譯器的支持。

ES6中提供了尾調用優化,它必須在嚴格模式下,使用它時還必須記住以下一些東西:

  • 尾調用函數不能在當前堆棧中引用其他變量,也即非閉包;
  • 在尾調用返回后,并不需要處理記住還得做哪些操作(比如下方的 n*factorial(n-1) 相乘);
  • 最后一個尾調用函數的返回值也就是函數的值,并不需要再往上計算。

下面我們以階乘為例:

function factorial (n) {
  if (n <= 1) {
    return 1
  } else {
    // 未優化 - 調用中引用了變量n
    return n * factorial(n - 1)
  }
}

在上面的代碼中,我們可以看到調用函數中n一直在變化,需要一直去記錄,那么棧會越來越大。通過參數將尾調用結果存起來,一直在尾部調用下去,最后一次尾部調用完之后并不需要繼續計算,達到尾調用優化的效果,代碼如下:

function factorial (n, p = 1) {
  if (n <= 1) {
    return 1 * p
  } else {
    let result = n * p

    // 優化
    return factorial(n - 1, result)
  }
}

常見的函數式操作及實現

映射map

map操作隊對原集合的每個元素執行給定的元素,從而變成一個新的集合。一般來說,如果需要變換一個集合的時候,就用map。以下代碼為map的簡單實現

function map (obj, callback) {
  const results = []

  if (obj === undefined || obj === null) {
    return results
  }

  if (typeof obj.map === 'function') {
    return obj.map(callback)
  }

  const keys = Object.keys(obj)
  const length = (keys || obj).length

  for (let i = 0; i < length; i++) {
    results[i] = callback(obj[keys[i]], keys[i], obj)
  }

  return results
}

export default map

折疊reduce

reduce操作是用一個累積量來收集集合元素,reduce函數一般在需要為累計量設定一個初始值的時候使用,一般用在需要將集合分成一小塊一小塊來處理的時候。實現如下:

function reduce (obj, callback, initial) {
  if (obj === undefined || obj === null) {
    throw new TypeError('reduce called on null or undefined')
  }

  if (typeof callback !== 'function') {
    throw new TypeError('callback not a function')
  }

  const keys = Object.keys(obj)
  const length = (keys || obj).length
  let value = initial
  let i = 0

  if (!initial) {
    value = obj[keys[0]]
    i = 1
  }

  if (!initial && length === 0) {
    throw new TypeError('Reduce of empty array with no initial value')
  }

  for (; i < length; i++) {
    value = callback(value, obj[keys[i]], keys[i], obj)
  }

  return value
}

export default reduce

篩選filter

filter是根據用戶定義的條件來篩選列表中的條目,并由此產生一個較小的新列表,在需要根據篩選條件來產生一個子集合的時候使用,代碼實現如下:

function filter (obj, callback, context) {
  if (obj === undefined || obj === null) {
    throw new TypeError('obj cannot be undefined or null')
  }

  if (typeof callback !== 'function') {
    throw new TypeError('callback should be function')
  }

  const keys = Object.keys(obj)
  const length = (keys || obj).length
  const ret = []

  for (let i = 0; i < length; i++) {
    if (callback.call(context, obj[keys[i]], keys[i], obj)) {
      ret.push(obj[keys[i]])
    }
  }

  return ret
}

export default filter

組合compose

compose使我們能夠從簡單的、通用的函數建立復雜的函數。通過把函數作為其它函數的構建單元, 我們可以建立真正模塊化的應用,使其具有的可讀性和可維護性。組合最重要的一個方面是,他們使用純函數,那么代碼的可測試、可維護性、可擴展性更強。以下為compose的代碼實現:

function compose (...args) {
  let start = args.length - 1
  return (...applyArgs) => {
    let i = start
    let result = args[start].apply(this, applyArgs)

    while (i--) {
      result = args[i].call(this, result)
    }
    return result
  }
}

export default compose

柯里化curry

curry通過將一個函數集合部分應用到函數中,從而動態創建一個新函數,這個新函數將會保存重復的參數,并且還會使用預填充原始函數所期望的完整參數列表。當我們發現正在調用同一函數,并且傳遞的參數大部分都是相同的,那么該函數可能是用于curry化的一個很好的候選參數。代碼實現如下:

function curry (fn, ...args) {
  return (...newArgs) => {
    return fn.apply(null, args.concat(newArgs))
  }
}

export default curry

記憶memoize

memoize通過將函數返回結果保存起來,在下一次調用該函數時就不用重做潛在的繁重計算,從而提高性能。使用函數屬性以便使得計算過的值無須再次計算,代碼實現如下:

function memoize (fn) {
  const memoizeFn = (...args) => {
    const cache = memoizeFn.cache
    const key = JSON.stringify(args)

    if (!cache[key]) {
      cache[key] = fn.apply(this, args)
    }

    return cache[key]
  }

  memoizeFn.cache = {}
  return memoizeFn
}

export default memoize

函數式語言中的設計模式

函數式語言的重用發生于較粗的細粒度級別上,著眼于提取一些共通的運作機制,并參數化地調整其行為。以下簡單地探討在函數式編程中的一些設計模式,提供解決辦法的另一種思路。

工廠方法

工廠方法通常在類的靜態方法中實現,主要用于創建相似對象時執行重復操作以及為客戶端提供創建對象的接口。

在函數式編程中,curry化相當于產出函數的工廠,我們可以很容易地設立一個條件來返回其他函數的函數,也就是函數工廠。我們來看下面一個例子,假設有一個兩數相加的普通函數,經過柯里化加工,我們可以制造出在其參數上加一的遞增函數。

const adder = (x, y) => {x + y}

const incrementer = curry(adder, 1)

享元模式

運行共享技術有效地支持大量細粒度的對象,避免大量擁有相同內容的小類的開銷(如耗費內存),使大家共享一個類(元類)。

在函數式編程中,被記憶(memoize)的函數允許運行時緩存其結果,能夠避免大量非常相同內容的開銷。

策略模式

策略模式定義了算法家族,分別封裝起來,讓他們之間可以互相替換,此模式讓算法的變化不會影響到使用算法的客戶。

在函數式編程中,因為函數是第一等公民,可以使建立和操作各種策略的工作變得更為簡單。

總結

在實際的工作中,我們不用囿于函數式或者面向對象,通常是兩者混合使用,結合兩者的優勢,目的是使代碼可以更加簡單、優美,而且更易于調試。

代碼地址: https://github.com/wengeek/examples/tree/master/functional-programming

 

來自: https://wenjs.me/p/javascript-functional-programming

 

 本文由用戶 gd5302 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!