iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

DAY 5
3
Software Development

Functional Programming in JS系列 第 5

Buzz word 2 : Side Effect

什麼是 Side Effect?

https://ithelp.ithome.com.tw/upload/images/20200905/20106426NMCF7j9Vhc.jpg
吃藥時,藥包上都會註明可能會有什麼副作用,Side Effect 就是這個意思,也就是除了主要的作用外,可能會產生的額外作用

function 內服藥(症狀){
  console.log('肚子痛')
  return `治療${症狀}`;
}

內服藥('流鼻水'); 
// '肚子痛'
// '治療流鼻水'

主要的作用

function 本身做的功能,也就是治療流鼻水

額外作用 Side Effect

任何在運算的過程中改變了 function 內服藥 以外的變動就是 Side Effects,console.log('肚子痛') 會印在 function 外瀏覽器的 console tab 所以這就是 Side Effect

其實 Side Effect 不止是運用在 Function 裡,會變動到原本的變數值也算是 Side Effect

var x = 0;
x += 3

num++
y = "Hello " + x

Side Effect 會導致不易維護程式碼、得到無法預期結果等等。而平常撰寫 javaScript 容易造成的 Side Effect 非常之多,就先列常見的

  • 修改外部的 state

    // 改了 global 變數
    var a = 0;
    a++;
    
  • 發送 HTTP Request

  • Rendering screen

  • 使用會改變原陣列/物件的 JS method (eg. splice)

  • 修改任何外部變數

  • DOM 操作

  • 讀取 input 的值

  • Changing DB value

  • logging & console: 改變了系統狀態

驚! 問題來了,以上根本就是我們平常在做的事啊!改一下 DOM 然後點擊按鈕發出 HTTP Request 再來印出結果改動頁面,這些

全部都有 SIDE EFFECT

javaScript 世界太容易有 Side Effects,但它又是造成 Bug 的主要來源之一,我們很難完全避掉但可以運用一些技巧,例如把 Side Effect 用 Monad 封裝起來、讓 Side Effect 只作用在一定的範圍內、避免使用本身就有 Side Effect 的原生方法,讓程式碼是可預期且穩定的。

如何避免 Side Effects

除了避免使用有 Side Effect 的 js 原生方法 (去年曾經分享過哪些 Array 原生方法會造成 Side Effect 哪些不會)

let xs = [1, 2, 3, 4, 5]
❌
xs.splice(0, 3) // xs -> [1, 2, 3]
xs.splice(0, 3) // xs -> [4, 5]
xs.slice(0, 3) // xs -> []

✅
xs.slice(0, 3) // xs -> [1, 2, 3]
xs.slice(0, 3) // xs -> [1, 2, 3]
xs.slice(0, 3) // xs -> [1, 2, 3]

我們要學的是只是作一下弊

  • Dependency injection: 把問題丟給別人
  • Using an Effect functor: 拖延戰術

Dependency injection

這個方法我們把所有 impure code (會造成 Side Effect 的) 都放到參數裡,然後就丟著不管了,把責任丟給別人

function logSomething(something) {
    const dt = (new Date()).toISOString();
    console.log(`${dt}: ${something}`);
    return something;
}

可以看到上面 impurities code 有

  • new Date(): 你每次執行結果都有可能不同
  • console.log

若把以上兩個抓起來丟進參數裡

function logSomething(d, cnsl, something) {
    const dt = d.toISOString();
    return cnsl.log(`${dt}: ${something}`);
}
const something = 'Hannah';
const d = new Date();;
logSomething(d, console, something)

這個 function 就變 Pure 了?!其實不然,因為你每次丟進去的 input 還是會導致不同的 output,哪門子解決方法。

寫成下面就才真的是 Pure,"one input, one output"。不去訪問 global 物件例如 console 或 Date,讓資料維持穩定。

const d = {toISOString: () => '1865-11-26T16:00:00.000Z'};
const cnsl = {
    log: (msg) => {
        return msg;
    },
};
function logSomething(d, cnsl, something) {
    const dt = d.toISOString();
    return cnsl.log(`${dt}: ${something}`);
}
logSomething(d, cnsl, "Off with their heads!");
//  1865-11-26T16:00:00.000Z: Off with their heads!

但你可能會說怎麼可能不用 Date,我就是要偵測今天日期啊,請看下一個解法

lazy functions

把 impure function 再包進另一個 function 就好了!
怎麼做呢?先來看一個例子

function logSomething() {
    console.log('Hello World');
    return 0;
}
logSomething(); 
// 'Hello World'
// 0

上面很清楚是 impure function,因為 log 會造成 Side Effect,那若換成

function logSomething() {
    function Something() {
	    console.log('Hello World');
        return 0;
	}
    return Something;
}

// ----- 或更常見改成 arrow function

function logSomething = () => () => {
	console.log('Hello World');
    return 0;
}

logSomething(); // Just return the same function
logSomething(); // Just return the same function
logSomething(); // Just return the same function

就完全變 Pure Function 了耶,因為符合

  • One Input, One output 不管輸入幾次同樣值,回傳結果永遠相同
  • 沒有 Side Effect
  • 不依賴任何外部的狀態

自己認為是偷吃步,但完全可以運用在實務中
Note. 感謝 良葛格 補充說明這種將副作用的世界與無副作用世界連結在一起的邊界手法,只是最低限度為了滿足函式有輸入有輸出特性,而不得不為的方式。因為嚴格說,若你的函式傳回的函式是不純的,那麼該函式也會是不純的。

The effect functor

這個比第一個複雜很多,會在講到 Functor 時再說

小結

FP 要追求的…其實不是整個程式都是 Pure,而是 Pure 與 Impure 有明顯的界線,Side Effect 不全然是壞事也不是說有 Side Effect 的函式就不要使用,而是儘可能讓 Side Effect 能有好的管控,不要出現預期外的 side effect。


參考文章

如有錯誤或需要改進的地方,拜託跟我說。
我會以最快速度修改,感謝您

上一篇
Do Everything with Function
下一篇
Buzz Word 3 : Immutable vs. Mutable Data
系列文
Functional Programming in JS30

1 則留言

3
良葛格
iT邦新手 3 級 ‧ 2020-09-05 10:53:52

FP 要追求的…其實不是整個程式都是 Pure,而是 Pure 與 Impure 有明顯的界線。

其實這個範例還是 Impure 的,畢竟 log 本身代表的就是 Impure 的動作,也就是對外界的輸出(螢幕、檔案…):

const d = {toISOString: () => '1865-11-26T16:00:00.000Z'};
const cnsl = {
    log: () => {
        // do something
    },
};
function logSomething(d, cnsl, something) {
    const dt = d.toISOString();
    return cnsl.log(`${dt}: ${something}`);
}
logSomething(d, cnsl, "Off with their heads!");
//  ← "Off with their heads!"

Dependency injection 是 OO 的概念,FP 其實在分離純與不純,因此我會建議,接下來的手法可以說是「抽取副作用」,從你的這個起始範例開始:

function logSomething(something) {
    const dt = (new Date()).toISOString();
    console.log(`${dt}: ${something}`);
    return something;
}

依你想表達的內容,若要令 logSomething 成為 Pure,我會這麼做:

// 改個名以符合其作用
function formatMessage(dt, something) {
    return `${dt}: ${something}`;
}

// 以上是純的
// 以下是不純的

const something = 'Hannah';
const d = new Date();
console.log(formatMessage(d, something));

你的這個範例:

function logSomething() {
    function Something() {
	    console.log('Hello World');
        return 0;
	}
    return Something;
}

logSomething(); // Just return the same function
logSomething(); // Just return the same function
logSomething(); // Just return the same function

某些程度上,這種手法類似 Haskell 中的 IO(參考 Haskell Tutorial(20)初探 IO 型態),將副作用的世界與無副作用世界連結在一起的邊界手法,只是最低限度為了滿足函式有輸入有輸出特性,而不得不為的方式(也就是你提及的 Effect Functor,Haskell 中,IO 就是一種 Functor)。

在 Haskell 中,傳回 IO 的就是不純的,而一個函式若呼叫的另一函式會傳回 IO,那麼它也是不純的,在 Haskell 中,迫使你這個函式也得傳回 IO,也就是說,若你的函式傳回的函式是不純的,那麼該函式也會是不純的。

也就是說,回到你的範例,並不會因為 logSomething 傳回函式,而令其變成純的,不談 Haskell,純就 JavaScript 來看,意義上其實等同於:

function logSomething() {
    console.log('Hello World');
    return 0;
}

logSomething(); // 不純的,因為對外界輸出了
logSomething(); // 不純的,因為對外界輸出了
logSomething(); // 不純的,因為對外界輸出了

如一開始我說的,FP 要追求的…其實不是整個程式都是 Pure,而是 Pure 與 Impure 有明顯地界線,只要一個函式中有不純的操作,那麼該函式就也是不純的,並不會因為函式可以傳遞而變成純綷的,你把它改為以下也是不純的:

function logSomething(log, message) {
    log(message);
    return 0;
}

// 也許你有不同的 log 方式
logSomething(console.log, 'Hello World'); // 不純的,因為 console.log 不純
logSomething(abc.log, 'Hello XXX'); // 不純的,因為 abc.log 不純
logSomething(xyz.log, 'Hello OOO'); // 不純的,因為 xyz.log 不純

在 Haskell 中,函式中呼叫了傳回 IO 的函式,該函式也必須傳回 IO,這是個極為強制性的手法,會逼你一定要劃分純與不純的兩個世界。

不過,JavaScript 並沒有這種強制性,使用這種手法的話,我個人覺得沒有實質意義,也令人困惑。

也許你分出 logSomething 的目的,是為了對 message 做些額外處理,那麼會建議這麼寫:

function prefix_logging(message) {
    return 'LOGGING:' + message;
}

const prefixed1 = prefix_logging('Hello, World');
const prefixed2 = prefix_logging('Hello, XXX');
const prefixed3 = prefix_logging('Hello, OOO');

// 以上是純的
// 以下是不純的
console.log(prefixed1);
abc.log(prefixed2);
xyz.log(prefixed3);
hannahpun iT邦新手 5 級 ‧ 2020-09-05 19:30:55 檢舉

在 js 世界真的是沒這麼容易去處理啊
謝謝指教,先把以下改成 return 就不會產生 Side Effect

const cnsl = {
    log: (msg) => {
        return msg;
    },
};

然後有補充進去您解釋的部分了 /images/emoticon/emoticon41.gif

我要留言

立即登入留言