1. 程式人生 > 實用技巧 >函數語言程式設計學習心得及教程

函數語言程式設計學習心得及教程

函數語言程式設計


前言

如果你已經從事開發有一段時間了,肯定多少對'函數語言程式設計'有一定的瞭解,很多人包括我自己在還沒有深入瞭解函數語言程式設計的時候,基本都會有一個誤解,函數語言程式設計就是用很多function函式去程式設計你的程式碼。最近正好我在學函數語言程式設計,於是就想著寫一篇文章去向一些像我一樣的初學者去解釋一下函數語言程式設計。

什麼是函數語言程式設計

我們來看一下百度百科對函數語言程式設計的定義:

簡單說,"函數語言程式設計"是一種"程式設計正規化"(programming paradigm),也就是如何編寫程式的方法論。
它屬於"結構化程式設計"的一種,主要思想是把運算過程儘量寫成一系列巢狀的函式呼叫。

我查了很多資料,於是我將他們歸納總結了一下:

  • 首先函數語言程式設計(Functional programming,FP)是一種與面向物件程式設計(Object-oriented programming)和麵向過程程式設計(Procedural programming)並列的程式設計正規化。
  • 函數語言程式設計中的函式不是程式中的函式(方法),而是數學中的函式即對映關係,例如:y=f(x)。
  • 函數語言程式設計最主要的特徵就是:函式式一等公民。
  • 對於一個函式,相同的輸入始終要得到相同的輸出,且沒有副作用(純函式的概念)。
  • 函數語言程式設計旨在講複雜的數學計算過程分解成可複用的函式。
  • 函數語言程式設計起源於範疇學,我們可以把範疇想象成一個容器,裡面包含兩樣東西:1.值;2.值的變形關係,也就是操作值的函式。

下面舉一個對比非函數語言程式設計和函數語言程式設計的例子:

// 非函式式
let a = 1
let b = 2
const sum = a + b

// 函式式
function add(a, b) {
    return a + b
}
const sum = add(1, 2)

函式是一等公民

程式語言中一等公民的概念是由英國計算機學家Christopher Strachey提出來的,特徵可以概括為以下幾點:

  • 函式可以作為引數
  • 函式可以儲存在變數中
  • 函式可以作為返回值
  1. 函式可以作為引數
    函式可以作為引數最典型的應用就是回撥函式,例如非同步程式設計中的setTimeout
setTimeout(function() {
    console.log('hello callback')
}, 0)
  1. 函式可以儲存在變數中
    函式可以暫時存在一個變數中,等待我們需要執行的時候再執行
const print = function() {
    console.log('hello var')
}
print()
  1. 函式可以作為返回值
    函式可以作為返回值,最常見的應用就是閉包
function autoAdd() {
    let a = 0
    return () => a+=1
}
const add1 = autoAdd()
console.log(add1()) // 1
console.log(add1()) // 2

我們在說函式是'一等公民'的時候,其實就是在說函式在JavaScript中就是普通物件,只不過函式的一些特性使得函式相比於不同物件有那麼一些特殊。

純函式

純函式的概念:純函式是這樣一種函式,即相同的輸入,永遠會得到相同的輸出,而且沒有任何可觀察的副作用。

const arr = [1,2,3,4,5,6]

// 純函式
console.log(arr.slice(0,3)) // [1,2,3]
console.log(arr.slice(0,3)) // [1,2,3]
console.log(arr.slice(0,3)) // [1,2,3]

// 不是純函式
console.log(arr.splice(0,3)) // [1,2,3]
console.log(arr.splice(0,3)) // [4, 5]
console.log(arr.splice(0,3)) // []

可以看出來slice是一個純函式,因為每一次執行的結果都是一樣的;而splice改變了原陣列,導致下一個函式執行的結果變化了,所以splice不是一個純函式
純函式的好處:

  • 可快取
    既然純函式的每一次執行的返回結果都是相同的,那麼我們是否可以將它執行的結果快取起來,這樣下次呼叫就可以直接返回上一次執行的結果,可以提升程式執行的效率
    lodash的函式庫中為我們提供了memoize()記憶函式
const _ = require('lodash')
function add1(a) {
    console.log('輸出', a)
    return a + 1
}
const addMomory = _.memoize(add1)
console.log(addMomory(10))
console.log(addMomory(10))
console.log(addMomory(10))


可以看到只輸出了一次10,證明從第二個列印的11是從快取中取出來的
下面我們來實現一個memoize函式

function memoize(fn) {
    const cache = {}

    return (...rest) => {
        const key = JSON.stringify(rest)
        console.log(cache)
        cache[key] = cache[key] || fn.apply(fn, rest)
        return cache[key]
    }
}
const addMomory = _.memoize(add1)
console.log(addMomory(10))
console.log(addMomory(10))
console.log(addMomory(10))


通過驗證我們能看到和上面的輸出是一樣的

  • 可測試
    • 純函式讓測試更方便
  • 並行處理
    • 在多執行緒環境下並行操作共享的記憶體資料很可能會出現意外情況
    • 純函式不需要訪問共享的記憶體資料,所以在並行環境下可以任意執行純函式 (Web Worker)

高階函式

高階函式是指至少要滿足下列條件之一的函式:

  • 函式可以作為引數被傳遞
  • 函式可以作為返回值輸出
  1. 函式可以作為引數被傳遞
let arr = [1,2,3]
const itemAdd = item => item += 2
arr = arr.map(itemAdd)
console.log(arr) // [3,4,5]
  1. 函式可以作為返回值輸出
function once(fn) {
    let done = false
    return function() {
        if (!done) {
            done = true
            return fn.call(this, ...arguments)
        }
    }
}

函式柯里化(curry)

curry的概念:只傳遞給函式一部分引數來呼叫它,讓它返回一個函式去處理剩下的引數。
函式柯里化將 f(x) 和 g(x) 合成為 f(g(x)),所謂“柯里化”,就是將一個多引數的函式轉化成多個單引數函式去執行

普通函式
function checkAge(min, age) {
    return age >= min
}
// 這樣呼叫
console.log(checkAge(18, 25))

柯里化
function checkAge(min) {
    return function (age) {
        return age >= min
    }
}
// 上面程式碼可以用箭頭函式簡寫為
const checkAge = min => age => age >= min
// 柯里化後的函式這樣呼叫
const checkAge18 = checkAge(18)
console.log(checkAge18(25))

上面的程式碼我們手動實現了一個函式柯里化,那麼有沒有一個通用的方法,幫助我們來實現函式柯里化呢,lodash函式庫中有一個curry函式自動實現了柯里化,我們來看看怎麼使用

const _ = require('lodash')

function getSum(a, b, c) {
    return a + b + c
}

getSumCurry = _.curry(getSum) // curry函式返回一個柯里化後的函式

console.log(getSumCurry(1,2,3))
console.log(getSumCurry(1)(2,3))
console.log(getSumCurry(1,2)(3))
console.log(getSumCurry(1)(2)(3))

下面我們來實現一個curry

// polyfill
function curry(fn) {
    return function curriedFn() { // 返回一個新的函式
        const args = arguments // 儲存新函式的引數
        if (args.length < fn.length) { // 如果新函式的引數小於fn的引數的長度,說明傳進來的不是所有引數
            return function() {
                // 繼續呼叫新函式
                return curriedFn(...Array.from(arguments).concat(Array.from(args)))
            }
        }
        return fn(...args) // 直到所有引數都入參,直接執行fn
    }
}
上面的程式碼還是比較繞的,可讀性也沒那麼高,我們可以用箭頭函式簡化成一行程式碼
const curry = fn => curriedFn = (...args) => args.length < fn.length ? (...rest) => curriedFn(...args.concat(rest)) : fn(...args)
小結
  • 柯里化可以讓我們給一個函式傳遞較少的引數得到一個已經記住了某些固定引數的新函式
  • 這是一種對函式引數的'快取'
  • 函式柯里化讓函式變的更靈活,讓函式的粒度更小,我本人更願意叫"函式顆粒化"
  • 可以把多元函式轉換成一元函式,可以組合使用函式產生強大的功能

函式組合

管道運算

在日常開發中,我們經常遇到需要將多個函式組合成一個函式,然後去處理一些特定的資料的場景,於是我們會寫出下面的程式碼

const arr = [1,6,3,4,5,2]

const addOne = (arr) => arr.map(item => item+1)
const filter = (arr) => arr.filter(item => item>2)
const sort = (arr) => arr.sort((a,b) => a-b)
const res = sort(filter(addOne(arr)))
console.log(res) // [3,4,5,6,7]

上面的程式碼就是典型的"洋蔥程式碼",將上一個函式的返回結果作為下一個函式的引數,我們可以把這個過程想象成管道,下面這張圖就是管道運算的過程

如果我們需要很多函式去處理一個值,那麼上面的程式碼會顯得很繁瑣,ECMAScript的最新提案增加了一個管道運算子,可以讓我們的程式碼顯得更加清晰,有興趣的可以看一看阮一峰的es6教程
通過管道運算子,我們可以將上面的程式碼簡化成這樣

const res = arr
            |> addOne
            |> filter
            |> sort
Compose組合函式

lodash函式庫中為我們提供了類似compose的函式,下面我們來實現一個取出陣列中第一個元素,並將這個元素轉換為大寫的函式

const { flowRight } = require("lodash")
const reverse = arr => arr.reverse()
const first = arr => arr[0]
const toUpper = arr => arr.toUpperCase()

const fn = flowRight(toUpper, first, reverse) // 順序為從右到左

const arr = ['aa', 'bb', 'cc', 'dd']

console.log(fn(arr)) // DD

我們可以自己實現一個flowRight函式,需要注意的是我們要滿足結合律,下面我們用一行程式碼來實現

const _flowRight = (...args) => val => args.reverse().reduce((res, fn) => fn(res), val)

const my_fn = _flowRight(toUpper, first, reverse)
const my_fn1 = _flowRight(_flowRight(toUpper, first), reverse)

const arr = ['aa', 'bb', 'cc', 'dd']
console.log(my_fn(arr)) // DD
console.log(my_fn1(arr)) // DD

其實合成函式有一個問題,就是當我們的程式碼出現問題的時候,我們如何去debug我們的程式碼呢?
我們可以"管道運算"的特性:函式上一次的執行結果將作為引數傳入下一個引數

const _ = require('lodash')
const { reduce } = require('lodash')

const split = _.curry((sep, str) => _.split(str, sep))
const join = _.curry((sep, arr) => _.join(arr, sep))

const log = _.curry((log, v) => {
    console.log(log, v)
    return v
})

const fn = _.flowRight(join('-') ,split(' ') , log('小寫 之後'), _.toLower)

console.log(fn('NEVER SAY DIE'))


這樣我們就可以打印出日誌來除錯我們的程式碼

Point Free

pointfree 模式指的是,永遠不必說出你的資料
一等公民的函式、柯里化(curry)以及組合協作起來非常有助於實現這種模式。

// 非 Point Free
// NEVER SAY DIE => never-say-die
var fn = function (word) {
  return word.toLowerCase().replace(/\s+/g, '-') // 匹配空格
}

// Point Free
const fp = require('lodash/fp')
const fn = fp.flowRight(fp.join('-') ,fp.split(' '), fp.toLower)

函子

函子的概念:

  • 函子是函數語言程式設計裡面最重要的資料型別,也是基本的運算單位和功能單位
  • 函子是一個容器,包含值和值的變形關係(這個變形關係就是函式)
  • 通過一個普通的物件來實現,該物件具有 map 方法,map 方法可以運
    行一個函式對值進行處理(變形關係)
Functor函子
class Container {
    static of (val) {
        return new Container(val)
    }

    constructor(value) {
        this._value = value
    }

    map(fn) {
        return Container.of(fn(this._value))
    }
}

let r = Container.of(5)
    .map(a => a+5)
    .map(a => a*2)

console.log(r)
小結
  • 函數語言程式設計的運算不直接操作值,而是由函子完成
  • 函子就是一個實現了 map 契約的物件
  • 函數語言程式設計一般約定,函子有一個of方法,用來生成新的容器,實際上就是用of來代替new關鍵字
MayBe 函子

我們在程式設計的過程中可能會遇到很多錯誤,需要對這些錯誤做相應的處理,例如如果容器內部是一個null,Functor函子就會出錯,於是就有了MayBe函子

class MayBe {
    static of (value) {
        return new MayBe(value)
    }

    constructor(value) {
        this._value = value
    }

    map(fn) {
        return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value))
    }

    isNothing() { // 判空處理
        return this._value === null || this._value === undefined
    }
}

const r = MayBe.of(null).map(x => x.toUpperCase())

console.log(r)

const s = MayBe.of('aaa')
            .map(x => x.toUpperCase())
            .map(x => null)
            .map(x => x.split('.'))

MayBe 函子的缺陷是當中間的 map 出現null,無法判斷哪一個 map 出現的 null

Either 函子
  • Either函子類似於if...else運算子
  • Either 函子內部有兩個值:左值(Left)和右值(Right),來處理異常程式碼
class Left {
    static of (value) {
        return new Left(value)
    }

    constructor(value) {
        this._value = value
    }

    map(fn) {
        return this
    }
}

class Right {
    static of (value) {
        return new Right(value)
    }

    constructor(value) {
        this._value = value
    }

    map(fn) {
        return Right.of(fn(this._value))
    }
}

function parseJSON(str) {
    try {
        return Right.of(JSON.parse(str))
    } catch(e) {
        return Left.of({error: e.message})
    }
}

// const r1 = Left.of(12).map(x => x+2)
// const r2 = Right.of(12).map(x => x+2)
// console.log(r1,r2)
// const r = parseJSON('{name: aa}')
// console.log(r)

const r = parseJSON('{"name": "aa"}')
console.log(r)
IO 函子
  • IO 函子中的 _value 是一個函式,這裡是把函式作為值來處理
  • IO 函子可以把不純的動作儲存到 _value 中,延遲執行這個不純的操作(惰性執行)
  • 把不純的操作交給呼叫者來處理
const fp = require('lodash/fp')

class IO {
    static of(value) {
        return new IO(function() {
            return value
        })
    }

    constructor(fn) {
        this._value = fn
    }

    map(fn) {
        return new IO(fp.flowRight(fn, this._value))
    }
}

const r = IO.of(process).map(p => p.execPath)
console.log(r._value())
Monad(單子)

函子是一個容器,我們也可以用函子來包含另一個函子,這樣就會出現多層巢狀的函子IO.of(IO.of(IO.of({name: 'maoxiaoxing'}))),我們需要不斷取 _value 的值,這樣我們在取值的時候會特別的不方便

const fp = require('lodash/fp')
const fs = require('fs')
const { functionsIn } = require('lodash')

class IO {
    static of(value) {
        return new IO(function() {
            return value
        })
    }

    constructor(fn) {
        this._value = fn
    }

    map(fn) {
        return new IO(fp.flowRight(fn, this._value))
    }

    join() {
        return this._value()
    }

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

const readFile = function(filename) {
    return new IO(function() {
        return fs.readFileSync(filename, 'utf-8')
    })
}

const print = function(x) {
    return new IO(function() {
        return x
    })
}

// 通過函子來讀取 package.json 檔案
const r = readFile('package.json')
            .map(x => x.toUpperCase())
            .flatMap(print)
            .join()
console.log(r)

上面程式碼中如果 fn 是一個函子,那麼 flatMap 中的 join 方法就保證了每次只返回一個單層的函子,這樣就將巢狀的函子平鋪了。

參考文章: