程式設計正規化 —— 函數語言程式設計入門

牧云云發表於2019-02-16

該系列會有 3 篇文章,分別介紹什麼是函數語言程式設計、剖析函數語言程式設計庫、以及函數語言程式設計在 React 中的應用,歡迎關注我的 blog

指令式程式設計和宣告式程式設計

拿泡茶這個事例進行區分指令式程式設計和宣告式程式設計

  • 指令式程式設計

1.燒開水(為第一人稱)
2.拿個茶杯
3.放茶葉
4.沖水

  • 宣告式程式設計

1.給我泡杯茶(為第二人稱)

舉個 demo

// 指令式程式設計
const convert = function(arr) {
  const result = []
  for (let i = 0; i < arr.length; i++) {
    result[i] = arr[i].toLowerCase()
  }
  return result
}

// 宣告式程式設計
const convert = function(arr) {
  return arr.map(r => r.toLowerCase())
}

什麼是函數語言程式設計

函數語言程式設計是宣告式程式設計的正規化。在函數語言程式設計中資料在由純函式組成的管道中傳遞。

函數語言程式設計可以用簡單如交換律、結合律、分配律的數學之法來幫我們簡化程式碼的實現。

它具有如下一些特性:

  • 純粹性: 純函式不改變除當前作用域以外的值;
// 反面示例
let a = 0
const add = (b) => a = a + b // 兩次 add(1) 結果不一致

// 正確示例
const add = (a, b) => a + b
  • 資料不可變性: Immutable
// 反面示例
const arr = [1, 2]
const arrAdd = (value) => {
  arr.push(value)
  return arr
}

arrAdd(3) // [1, 2, 3]
arrAdd(3) // [1, 2, 3, 3]

// 正面示例
const arr = [1, 2]
const arrAdd = (value) => {
  return arr.concat(value)
}

arrAdd(3) // [1, 2, 3]
arrAdd(3) // [1, 2, 3]

在後記 1 中對陣列字串方法是否對原值有影響作了整理

  • 函式柯里化: 將多個入參的函式轉化為一個入參的函式;
const add = a => b => c => a + b + c
add(1)(2)(3)
  • 偏函式: 將多個入參的函式轉化成兩部分;
const add = a => (b, c) => a + b + c
add(1)(2, 3)
  • 可組合: 函式之間能組合使用
const add = (x) => x + x
const mult = (x) => x * x

const addAndMult = (x) => add(mult(x))

柯里化(curry)

如下是一個加法函式:

var add = (a, b, c) => a + b + c

add(1, 2, 3) // 6

假如有這樣一個 curry 函式, 用其包裝 add 函式後返回一個新的函式 curryAdd, 我們可以將引數 a、b 進行分開傳遞進行呼叫。

var curryAdd = curry(add)

// 以下輸出結果都相同
curryAdd(1, 2, 3) // 6
curryAdd(1, 2)(3) // 6
curryAdd(1)(2)(3) // 6
curryAdd(1)(2, 3) // 6

動手實現一個 curry 函式

核心思路: 若傳進去的引數個數未達到 curryAdd 的個數,則將引數快取在閉包變數 lists 中:

function curry(fn, ...args) {
  const length = fn.length
  let lists = args || []

  let listLen
  return function (..._args) {
    lists = [...lists, ..._args]
    listLen = lists.length

    if (listLen < length) {
      const that = lists
      lists = []
      return curry(fn, ...that)
    } else if (listLen === length) {
      const that = lists
      lists = []
      return fn.apply(this, that)
    }
  }
}

程式碼組合(compose)

現在有 toUpperCasereversehead 三個函式, 分別如下:

var toUpperCase = (str) => str.toUpperCase()
var reverse = (arr) => arr.reverse()
var head = (arr) => arr[0]

接著使用它們實現將陣列末位元素大寫化輸出, 可以這樣做:

var reverseHeadUpperCase = (arr) => toUpperCase(head(reverse(arr)))

reverseHeadUpperCase([`apple`, `banana`, `peach`]) // "PEACH"

此時在構建 reverseHeadUpperCase 函式的時候, 必須手動宣告傳入引數 arr, 是否能提供一個 compose 函式讓使用者更加友好的使用呢? 類似如下形式:

var reverseHeadUpperCase = compose(toUpperCase, head, reverse)

reverseHeadUpperCase([`apple`, `banana`, `peach`]) // "PEACH"

此外 compose 函式符合結合律, 我們可以這樣子使用:

compose(compose(toUpperCase, head), reverse)
compose(toUpperCase, compose(head, reverse))

以上兩種寫法與 compose(toUpperCase, head, reverse) 的效果完全相同, 都是依次從右到左執行傳參中的函式。

此外 composemap 一起使用時也有相關的結合律, 以下兩種寫法效果相等

compose(map(f), map(g))
map(compose(f, g))

動手實現一個 compose 函式

程式碼精華集中在一行之內, 其為眾多開源庫(比如 Redux) 所採用。

var compose = (...args) => (initValue) => args.reduceRight((a, c) => c(a), initValue)

範疇論

範疇論是數學中的一個分支。可以將範疇理解為一個容器, 把原來對值的操作,現轉為對容器的操作。如下圖:

學習函數語言程式設計就是學習各種函子的過程。

函數語言程式設計中, 函子(Functor) 是實現了 map 函式的容器, 下文中將函子視為範疇,模型可表示如下:

class Functor {
  constructor(value) {
    this.value = value
  }

  map(fn) {
    return new Functor(fn(this.value))
  }
}

但是在函數語言程式設計中, 要避免使用 new 這種物件導向的程式設計方式, 取而代之對外暴露了一個 of 的介面, 也稱為 pointed functor

Functor.of = value => new Functor(value)

Maybe 函子

Maybe 函子是為了解決 this.value 為 null 的情形, 用法如下:

Maybe.of(null).map(r => r.toUpperCase()) // null
Maybe.of(`m`).map(r => r.toUpperCase())  // Maybe {value: "M"}

實現程式碼如下:

class Maybe {
  constructor(value) {
    this.value = value
  }

  map(fn) {
    return this.value ? new Maybe(fn(this.value)) : null
  }
}

Maybe.of = value => new Maybe(value)

Either 函子

Either 函子 是為了對應 if...else... 的語法, 即非左即右。因此可以將之拆分為 LeftRight 兩個函子, 它們的用法如下:

Left.of(1).map(r => r + 1)  // Left {value: 1}

Right.of(1).map(r => r + 1) // Right {value: 2}

Left 函子實現程式碼如下:

class Left {
  constructor(value) {
    this.value = value
  }

  map(fn) {
    return this
  }
}

Left.of = value => new Left(value)

Right 函子實現程式碼如下(其實就是上面的 Functor):

class Right {
  constructor(value) {
    this.value = value
  }

  map(fn) {
    return new Right(fn(this.value))
  }
}

Right.of = value => new Right(value)

具體 Either 函式只是對呼叫 Left 函子Right 函子 作一層篩選, 其接收 fg 兩個函式以及一個函子(Left or Right)

var Either = function(f, g, functor) {
  switch(functor.constructor) {
    case `Left`:
      return f(functor.value)
    case `Right`:
      return g(functor.value)
    default:
      return f(functor.value)
  }
}

使用 demo:

Either((v) => console.log(`left`, v), (v) => console.log(`def`, v), left)   // left 1
Either((v) => console.log(`rigth`, v), (v) => console.log(`def`, v), rigth) // rigth 2

Monad 函子

函子會發生巢狀, 比如下面這樣:

Functor.of(Functor.of(1)) // Functor { value: Functor { value: 1 } }

Monad 函子 對外暴露了 joinflatmap 介面, 呼叫者從而可以扁平化巢狀的函子。

class Monad {
  constructor(value) {
    this.value = value
  }

  map(fn) {
    return new Monad(fn(this.value))
  }

  join() {
    return this.value
  }

  flatmap(fn) {
    return this.map(fn).join()
  }
}

Monad.of = value => new Monad(value)

使用方法:

// join
Monad.of(Monad.of(1).join()) // Monad { value: 1 }
Monad.of(Monad.of(1)).join() // Monad { value: 1 }

// flatmap
Monad.of(1).flatmap(r => r + 1)  // 2

Monad 函子可以運用在 I/O 這種不純的操作上將之變為純函式的操作,目前比較懵懂,日後補充。

後記 1: 陣列字串方法小結(是否對原值有影響)

不會對原陣列有影響的方法

slice
var test = [1, 2, 3]
var result = test.slice(0, 1)

console.log(test)   // [1, 2, 3]
console.log(result) // [1]
concat
var test = [1, 2, 3]
var result = test.concat(4)

console.log(test)   // [1, 2, 3]
console.log(result) // [1, 2, 3, 4]

對原陣列有影響的方法

splice(這個需要特別記一下)
var test = [1, 2, 3]
var result = test.splice(0, 1)

console.log(test)   // [2, 3]
console.log(result) // [1]
sort
var arr = [2, 1, 3, 4]
arr.sort((r1, r2) => (r1 - r2))

console.log(arr) // [1, 2, 3, 4]
reverse
var test = [1, 2, 3]
var result = test.reverse()

console.log(test)   // [3, 2, 1]
console.log(result) // [3, 2, 1]
push/pop/unshift/shift
var test = [1, 2, 3]
var result = test.push(4)

console.log(test)   // [1, 2, 3, 4]
console.log(result) // 4

不會對原字串造成影響的方法

substr/substring/slice
// substr
var test = `abc`
var result = test.substr(0, 1)

console.log(test)   // `abc`
console.log(result) // a

// substring
var test = `abc`
var result = test.substring(0, 1)

console.log(test)   // `abc`
console.log(result) // a

// slice
var test = `abc`
var result = test.slice(0, 1)

console.log(test)   // `abc`
console.log(result) // a

參考

相關文章