點燈坊

失くすものさえない今が強くなるチャンスよ

FP 之 Pure Function 與 Side Effect

Sam Xiao's Avatar 2020-02-01

Functional Programming 要求 Data 與 Function 分離,其中 Data 要求的是 Immutable,而 Function 則是 Pure Function。這是 FP 的兩大基石,所有其他的特性都是由這兩個基本原則展開。

Version

macOS Catalina 10.15.2
VS Code 1.40.2
Quokka 1.0.259
ECMAScript 2015

Pure Function

  • 與數學 function f(x) 一致
  • Ouput 僅與 argument 有關
  • 不會產生 side effect

與 pure function 相對的,就是 impure function:

  • 傳統 function
  • 除了 argument 外,尚有其他因素影響 output (class field、exception、I/O)
  • 會產生 side effect

我們可發現 pure function 與 impure function 是相對的,其他定義都很容易理解,唯獨 side effect 需要另外解釋。

Side Effect

  • Global state
  • Input argument
  • Throw exception
  • I/O operation

Global State

class Counter {
  #count = 0

  constructor(count) {
    this.#count = count
  }

  get count() {
    return this.#count
  }

  addOne() {
    this.#count++
    return this;
  }
}

let counter = new Counter(0)
counter.addOne().addOne().count // ?

凡在 function 以外的 scope,就算是 global state,如 OOP 的 field,也被視為 global state,修改 global state 被視 side effect。

OOP 強調 data 與 function 合一,所以會將 count 視為 field,封裝在 Counter class 內。

addOne() 被視為有 side effect,因為其修改了 method 外部的 count field,屬於 global state。

pure001

class Counter {
  #count = 0

  constructor(count) {
    this.#count = count
  }

  get count() {
    return this.#count
  }
}

let addOne = ({ count }) => new Counter(count + 1)

let counter = new Counter(0)
let counter_ = addOne(counter)
addOne(counter_).count // ?

FP 強調 data 與 function 分家,因此 data 屬於 Counter class,而 function 屬於 addOne()

addOne() 沒有 side effect,因為根據 argument 得到目前的 object,計算後回傳新的 object,完全與 function 外部無關,所以是 pure function。

這種寫法稱為 value object pattern,將 object 視為 immutable,是 FP + OOP 整合的 best practice

pure002

import { pipe } from 'ramda'

class Counter
{
  #count = 0

  constructor(count) {
    this.#count = count
  }

  get count() {
    return this.#count
  }
}

let addOne = ({ count }) => new Counter(count + 1)

let fn = pipe(
  addOne,
  addOne
)

fn(new Counter(0)).count // ?

18 行

let fn = pipe(
  addOne,
  addOne
)

fn(new Counter(0)).count // ?

由於 addOne() 是 pure function,因此也可將兩次 addOne() 組合成 fn() 後再一起執行。

pure003

Input Argument

import { pair, reduce, filter, converge } from 'ramda'

let data = [
  { title: 'FP in JavaScript', price: 100 , quantity: 10 },
  { title: 'RxJS in Action', price: 200, quantity: 0 },
  { title: 'Speaking JavaScript', price: 300, quantity: 20 }
]

let computeTotal = (arr, deleted) => {
  let result = 0

  for(let x of arr) {
    if (x.quantity === 0)
      deleted.push(x)
    else
      result += x.price * x.quantity
  }

  return result
}

let deleted = []
computeTotal(data, deleted) // ?
deleted // ?

修改 function 的 argument,而造成 function 外界的 data 被修改,也視為 side effect。

computeTotal 被視為有 side effect,因為直接修改了 deleted argument,造成 function 外部 data 改變。

pure004

import { pair, reduce, filter, converge } from 'ramda'

let data = [
  { title: 'FP in JavaScript', price: 100 , quantity: 10 },
  { title: 'RxJS in Action', price: 200, quantity: 0 },
  { title: 'Speaking JavaScript', price: 300, quantity: 20 }
]

let computeTotal = converge(
  pair, [
    reduce((a, x) => a + x.price * x.quantity)(0),
    filter(({ quantity}) => quantity === 0)
  ]
)

computeTotal(data) // ?

由於 computedTotal() 主要處理兩件任務:計算 total() 與回傳被刪除 data,因此改回傳 tuple,如此 function 外部 data 則不受影響,因此沒有 side effect。

pure005

Throw Exception

因為以下兩個原因,throw exception 也被視為 side effect:

  • Exception 屬於 function 外部 scope,也算 global state
  • Throw exception 後,其他 fuction 必須去 try catch 處理,由於不是來自於 function argument,會使得其他 function 不是 pure function

FP 可改用 Maybe 取代 exception

I/O Operation

在現實世界中,不可能所有 function 都在計算或者 mapping,如

  • 呼叫 API
  • 寫入資料庫、寫到 console
  • 讀出系統時間

pure000

這些都屬於無法避免的 side effect,但我們可將 side effect 集中在 pure function 的前後,而不是在 function 內隨意的 side effect,如此將降低程式碼的複雜性,也較易維護。

Conclusion

  • Pure function 與 side effect 為 FP 的起手式,看似基本但實務上並不容易實現,常一不小心就寫出 impure function
  • I/O 是無法避免的 side effect,但 FP 能將 side effect 集中在 pure function 前後,不要散佈到各處,降低其所造成傷害

Reference

Enrico Buonanno, Functional Programming in C#