ว่าด้วยเรื่อง Pure Function มันคืออิหยังวะ?

fResult
6 min readSep 17, 2022

--

บนโลกกลมๆ ใบนี้มีบทความและวิดีโอที่อธิบายถึงเรื่อง Pure Function อยู่มากมาย~~~
(นึกถึงเพลงประกอบละครเหล็กไหลของอาหลองเลยตรู)

ผมได้ยินคำนี้ครั้งแรกจากวิดีโอ Functional Programming ep.1 โดยเพจ PasaComputer

และบทความนี้เป็น Note ส่วนตัวเพื่อสรุปเรื่อง Pure Function (ในอีกแบบหนึ่งที่ในคลิปข้างบนยังไม่ได้กล่าวถึง) ตามความเข้าใจของตัวเอง รวมทั้งมีการใส่ความคิดเห็นของตัวเองลงไปบางส่วน

โดยพยายามเขียนให้เข้าใจง่ายที่สุดเท่าที่ผมพอจะทำได้

หากเนื้อหาหรือความเข้าใจของผมผิดพลาดตรงไหน รบกวนชี้แนะกันด้วยนะครับ

คำเตือน

  • เรื่องนี้ไม่เหมาะกับผู้เริ่มต้นเขียนโปรแกรม
  • ไม่เหมาะกับผู้ที่ไม่รู้ว่า Primitive Type กับ Reference Type คืออะไรและแตกต่างกันยังไง (หรือไม่รู้จักคำว่า Pass by value กับ Pass by reference ว่าคืออะไร, แตกต่างกันยังไง)

เนื่องจากผมจะไม่ได้เขียนอธิบายปูพื้นฐานมากนัก

มี Source ให้สำหรับคนที่อยากไปปูพื้นมาก่อน
-
https://justjavascript.com อันนี้โคตรตัวปูพื้นฐานเรื่องวิธีคิดในการเขียนและดีบั๊กโค้ด JavaScript (สอนว่าเราควรคิดยังไงเมื่อเขียน code แต่ไม่ได้สอนว่าตัวแปร, ฟังก์ชั่น, หรือ loop คืออะไร), มีแบบฝึกหัดให้ทำเยอะมาก ขอแนะนำให้ไปตำอย่างยิ่ง ผมค่อนข้างมั่นใจว่ามีประโยชน์กับเราอย่างมาก
- Difference of Pass by value vs. Pass by reference — google
- Difference of Primitive type vs. Reference type — google

Code ที่ใช้ยกตัวอย่างในบทความนี้จะใช้ภาษา TypeScript นะคร้าบบบบบบ

Pure Function คืออิหยังวะ !?

Pure Function เราอาจแปลตรงตัวได้ว่า ฟังก์ชัน (ที่มีการทำงาน) บริสุทธิ์
ไม่ว่าจะถูกเรียกใช้กี่ครั้งก็ตาม เมื่อ input เป็นค่าเดียวกัน แล้วต้องได้ output เหมือนเดิมเสมอ

และภายในฟังก์ชันต้องไม่มี statement หรือ expression ใดๆ ที่ก่อให้เกิดค่าที่เราคาดเดาไม่ได้ (Side Effect)

ตัวอย่าง Statement หรือ Expression ที่คาดเดาไม่ได้ เช่น

  • new Date(), Date.now()
  • fetch(...), ajax(…), axios(…)
  • Math.random()
  • ฯลฯ

ซึ่งพวกมันข้างต้นนี้มีโอกาสทำให้ฟังก์ชันของเราคืนค่า output ไม่เหมือนเดิม
(แต่ในบางกรณีเราอาจทำให้เป็นข้อยกเว้นได้เพราะมันเป็น Side Effect ที่เราไม่สามารถหลีกเลี่ยงได้จริงๆ เช่นการใช้ fetch, ajax, axios)

Input เดียวกัน ต้องได้ Output เดียวกันเสมอ

นอกจากนี้ ยังมีเรื่องที่สรุปเพิ่มเติมให้กับ Pure Function คือ

  • การเปลี่ยนแปลงใดๆ จากภายในฟังก์ชัน ต้องไม่มีผลกระทบถึงค่าใดๆ ที่อยู่ภายนอกฟังก์ชัน
  • การเปลี่ยนแปลงใดๆ ที่เกิดจากภายนอกฟังก์ชัน ก็ต้องไม่มีผลกระทบถึงค่าใดๆ ที่อยู่ภายในฟังก์ชันเช่นกัน

คำว่า ผลกระทบที่เกิดจากภายใน/ภายนอก ในที่นี้มีศัพท์เทคนิคว่า Side Effect

พิจารณา code นี้

const nine = 9

function double(x: number) {
return x * 2
}

double(nine) // 18
double(nine) // 18
double(nine) // 18
double(nine) // 18

จาก code snippet ข้างบน จะเห็นได้ว่า เราส่งเลข 9 เข้าไปในฟังก์ชัน double ทั้งหมด 4 ครั้งก็ยังคงได้ผลลัพธ์เป็น 18 เช่นเดิม
ดังนั้น ฟังก์ชัน double จึงเป็น Pure Function

Impure Function

ทีนี้ลองมาดูฟังก์ชันที่ไม่บริสุทธิ์บ้างซิ

ผลกระทบจากภายในสู่ภายนอก

ตัวอย่าง การทำงานภายในฟังก์ชันที่ทำให้ค่าที่อยู่ในตัวแปรภายนอกเปลี่ยนแปลงไป
ซึ่งอาจมีผลกระทบให้การคำนวณภายในฟังก์ชันผิดเพี้ยนได้ด้วย
(โดยเฉพาะฟังก์ชันที่สามารถทำงานได้แบบ parallel หรือ asynchronous (อันนี้ผมคิดตัวอย่าง code ไม่ออกนะ))

พิจารณา code นี้

const numberList: number[] = [1, 3, 5, 7, 9]// เราใช้ for loop เพื่อยกตัวอย่าง (สมมติว่าโลกนี้ไม่เคยมี Array.map)

function doubleToEveryNumber(xs: number[]) {
for (let idx = 0; idx < xs.length; idx++) {
// เรียกใช้ฟังก์ชั่น double ที่ได้เขียนไว้ก่อนหน้านี้
// และมีการ mutate data ในพารามิเตอร์ `xs` ซึ่ง ref ไปที่ตัวแปร `numberList`
xs[idx] = double(xs[idx])
}
return xs
}

doubleToEveryNumber(numberList) // [2, 6, 10, 14, 18]
doubleToEveryNumber(numberList) // [4, 12, 20, 28, 36]
doubleToEveryNumber(numberList) // [8, 24, 40, 56, 72]
doubleToEveryNumber(numberList) // [16, 48, 80, 112, 144]

เห็นมั้ยครับว่ามีการเรียกใช้ doubleToEveryNumber ถึง 4 ครั้ง
แล้วผลลัพธ์ไม่เท่าเดิมแม้แต่ครั้งเดียว
เพราะฉะนั้น ฟังก์ชัน doubleToEveryNumber จึงไม่ใช่ Pure Function เราเรียกได้อีกอย่างว่า Impure Function นั่นเอง

มันจะเป็นอย่างนั้นได้ยังไงกัน? เดี๋ยวผมเล่าเป็นการ์ตูนให้ฟังเอง

1. ประกาศตัวแปร numberList และ assign อาร์เรย์[1, 3, 5, 7, 9] เข้าไป
ทำให้ตัวแปรดังกล่าวชี้ (ref) ไปที่ reference ของอาร์เรย์ที่มีสมาชิก 5 ตัวซึ่งเก็บค่า 1, 3, 5, 7, และ 9 ตามลำดับ
โดยมี 0, 1, 2, 3, 4 ที่อยู่ตรงเส้นลูกศรคือค่าดัชนี (index) ของอาร์เรย์

2. เรียกใช้ฟังก์ชัน doubleToEveryNumber พร้อมกับส่ง arguement numberList ไปด้วย ทำให้ xs ซึ่งเป็นพารามิเตอร์ของฟังก์ชันดังกล่าวชี้ไปที่ reference เดียวกับตัวแปร numberList

3. เมื่อวน loop สมาชิกในอาร์เรย์ของพารามิเตอร์ xs เรามีการสร้างผลลัพธ์ที่เกิดจากการคูณสอง re-assign กลับเข้าไปในสมาชิกแต่ละตัวของ xs คือ

xs[0] = 1 * 2, xs[1] = 3 * 2, xs[2] = 5 * 2, xs[3] = 7 * 2,
และ xs[4] = 9 * 2 ตามลำดับสมาชิกของอาร์เรย์ xs

แต่จำได้ไหมว่า ในข้อ 2 เรามีการทำให้ xs ชี้ไปที่ reference เดียวกับ numberList มีผลให้ค่าทุกตัวใน numberList ถูกเปลี่ยนแปลงตาม xs ไปด้วย

และนั่นจึงเป็นที่มาของการที่ทุกครั้งที่เราเรียกใช้ doubleToEveryNumber(numberList) แล้วแต่ละครั้งได้ผลลัพธ์ไม่เหมือนเดิม
(เพราะว่าเมื่อสมาชิกใน xs เปลี่ยนแปลง ก็เท่ากับว่าสมาชิกใน numberList ถูกเปลี่ยนไปด้วยนั่นเอง)

และเมื่อเรียกใช้ doubleToEveryNumber(numberList) รอบต่อๆ ไป
จึงส่งผลให้ numberList และ xs ถูกเปลี่ยนแปลงค่าของสมาชิกทุกตัวให้กลายเป็น [4, 12, 20, 28, 36], [8, 24, 40, 56, 72], และ [16, 48, 80, 112, 144] ตามลำดับ

ผลกระทบจากภายนอกสู่ภายใน

ตัวอย่าง การทำงานภายในฟังก์ชันที่ทำให้ค่าที่อยู่ในตัวแปรภายนอกเปลี่ยนแปลงไป

ให้พิจารณา code ต่อไปนี้

const numberList: number[] = [1, 3, 5, 7, 9]

// เราใช้ for loop เพื่อยกตัวอย่างแทน (สมมติว่าโลกนี้ไม่เคยมี Array.map)
function doubleToEveryNumber(xs: number[]) {
for (let idx = 0; idx < xs.length; idx++) {
// เรียกใช้ฟังก์ชั่น double ที่ได้เขียนไว้ก่อนหน้านี้
xs[idx] = double(xs[idx])
}
return xs
}

const result = doubleToEveryNumber(numberList)

// คั่นด้วยการ mutate สมาชิกตัวสุดท้ายจาก 18 + 1 = 19
numberList[numberList.length - 1]++
// สมมติว่า console.log(...) ค่า (expression) ต่อไปนี้

numberList // [2, 6, 10, 14, 19] <-- variable `numberList` ที่อยู่ภายนอก ถูก mutate value โดยไม่ได้ตั้งใจ
doubleToEveryNumber(numberList) // [2, 6, 10, 14, 19]

1. Increase สมาชิกตัวสุดท้ายในตัวแปรอาร์เรย์ numberList ด้วย statement numberList[numberList.length-1]++ หรืออาจเขียนด้วย statement numberList[numberList.length—1] = numberList[numberList.length-1] + 1 ก็ได้

2. เรียกใช้ฟังก์ชัน doubleToEveryNumber พร้อมกับส่ง arguement numberList ไปด้วย ทำให้ xs ซึ่งเป็นพารามิเตอร์ของฟังก์ชันดังกล่าวชี้ไปที่ reference เดียวกับตัวแปร numberList

เมื่อคำนวณเสร็จแล้วจะได้ผลลัพธ์เป็น [2, 6, 10, 14, 19] ตามลำดับ

รูปข้างบนผมลำดับการอธิบายผิดไปนิดนะครับ ที่จริงแล้ว [1, 3, 5, 7, 9] ถูกทำให้เป็น [2, 6, 10, 14, 18] ก่อน แล้วค่อยบวกค่าสุดท้ายเพิ่มขึ้น 1 กลายเป็น 19 เดี๋ยวว่างๆ ค่อยกลับมาแก้ไขใหม่ครับ

พูดถึง Impure Function มาระยะหนึ่งแล้วผู้อ่านบางท่านอาจยังไม่อิน
อยากให้ลองจินตนาการภาพว่า ถ้าตัวแปร numberList ที่อยู่ภายนอกถูกนำไปใช้ในหลายๆ ที่ แล้วทุกที่ที่เรียกใช้ numberList ได้รับผลข้างเคียงจากการที่สมาชิกใน array ถูกเปลี่ยนแปลงไป แน่นอนเลยว่าจะเกิดความชิปหายทันทีฮะะ

วิธีการแก้ไข

เราสามารถขจัดความไม่บริสุทธิ์ของฟังก์ชันข้างต้นนี้ด้วยการเปลี่ยน reference value ให้กับพารามิเตอร์ xs โดย Re-assign xs.slice() หรือ [...xs] ให้มัน เพื่อไม่ให้อ้างอิงกับตัวแปร numberList ที่อยู่ข้างนอกฟังก์ชัน

และเมื่อพารามิเตอร์ xs ภายในฟังก์ชัน ไม่ได้อ้างอิงไปที่ Reference เดียวกับ ตัวแปร numberList ที่อยู่ภายนอกฟังก์ชัน จึงไม่ได้ไปแก้ไขค่าใดๆ ที่ตัวแปร numberList

const numberList: number[] = [1, 3, 5, 7, 9]

function doubleToEveryNumber(xs: number[]): number[] {
// Re-assign reference data อันใหม่ให้กับตัวแปร `xs`
xs = xs.slice() // หรือใช้ `xs = [...xs]` สำหรับ es6 ขึ้นไปก็ได้

for (let idx = 0; idx < xs.length; idx++) {
double(xs[idx])
}

return xs
}

// สมมติว่า console.log(...) ค่า (expression) ต่อไปนี้
doubleToEveryNumber(numberList) // [2, 6, 10, 14, 18]
numberList // [1, 3, 5, 7, 9] <-- `numberList` ไม่โดน mutate โดยไม่ได้ตั้งใจ
doubleToEveryNumber(numberList) // [2, 6, 10, 14, 18]
doubleToEveryNumber(numberList) // [2, 6, 10, 14, 18]
doubleToEveryNumber(numberList) // [2, 6, 10, 14, 18]

เกิดอะไรขึ้นตอน code ถูกรันผ่านบรรทัดที่เขียน xs = xs.slice()
(หรือ
xs = […xs])?

มาดูแผนภาพต่อไปนี้

จากภาพข้างบน ข้อ 1) และ 2) เราเคยอธิบายไปแล้วในตัวอย่างก่อนๆ จึงขออธิบายที่ข้อ 3) เลย

ในข้อ 3) เรามีการใช้ xs = xs.slice()
โดย xs.slice เป็นการสร้างอาร์เรย์ก้อนใหม่ที่มีจำนวนสมาชิกเท่ากับ xs หรือ numberList และมีข้อมูลเหมือนกันด้วย
แล้วจึง re-assign อาร์เรย์ [1, 3, 5, 7, 9] ก้อนใหม่ให้พารามิเตอร์ xs อีกที

ย้อนกลับไปดูแผนภาพจะเห็นได้ว่า xs ชี้เส้นสีแดงไปที่อาร์เรย์ก้อนใหม่
และตัดความสัมพันธ์จากอาร์เรย์ก้อนเดิม (ref เดียวกับ numberList)

เพราะฉะนั้น เมื่ออาร์เรย์ในพารามิเตอร์ xs ถูกทำให้ค่าเปลี่ยนไป
มีผลให้ผู้ที่เรียกใช้ฟังก์ชัน doubleToEveryNumber(...) ก็ไม่ต้องกังวลอีกต่อไปว่าค่าใดๆที่อยู่ใน numberList จะเปลี่ยนตามโดยที่ไม่ได้ตั้งใจให้เป็นเช่นนั้นด้วย

Pure Function, No Side effect, and Predictable output

Note:
หนึ่งในวิธีที่ใช้แก้บ่อยที่สุดก็คือการทำสิ่งที่เรียกว่า Shallow Copy ตามตัวอย่างที่เพิ่งกล่าวไป (แต่ถ้าข้างในมี Object หรือ Array ซ้อนลงไปอีกก็ต้อง Deep Copy ด้วยนะ)

การทำ Shallow Copy…
ถ้าเป็นอาร์เรย์
ให้ใช้ array.slice() หรือ […array] เพื่อสร้างอาร์เรย์ใหม่
ถ้าเป็นออบเจ็กต์หรืออินสแตนซ์ของคลาสให้ใช้ Object.assign({}, obj)
หรือ { …obj } เพื่อสร้างออบเจ็กต์ใหม่
(ที่จริงแล้ว instance of class ก็เป็นออบเจ็กต์เหมือนกัน)

Reference:
Array.prototype.slice — Mozilla Developer Network
Object.prototype.assign — Mozilla Developer Network

นอกจากวิธีการ Shallow/Deep Copy แล้ว เรายังอาจใช้เทคนิค Closure มาช่วยเพิ่มเติมได้อีกด้วย
ผมไม่ได้เขียนเรื่อง Closureในบทความนี้เนื่องจากเป็นเทคนิคที่ advanced กว่านี้
หากต้องการดูว่าเป็นอย่างไร สามารถเข้าไปดูได้ที่นี่ครับ
https://gitlab.com/fResult/fp-libs-sandbox/-/blob/main/src/mylib/cache.ts
. . . . ^ ^ ^
. . . . ^ ^ ^
ใน code ตัวอย่างนี้ คือกรณีที่สร้าง function เองตั้งแต่ต้น
ถ้าเป็นกรณีที่ต้องเอา Impure function มาใช้ต่อโดยไม่ต้องการ Refactor ฟังก์ชันเดิมโดยตรง เราอาจ purify โดยใช้เทคนิคการทำ closure ห่อหุ้มการเรียกใช้ Impure Function อันเดิมไว้ได้ครับ
ตัวอย่าง:
- https://github.com/getify/Functional-Light-JS/blob/master/manuscript/ch5.md/#covering-up-effects
- https://github.com/getify/Functional-Light-JS/blob/master/manuscript/ch5.md/#this-revisited
- https://github.com/getify/Functional-Light-JS/blob/master/manuscript/ch5.md/#evading-effects

วิธีแก้ไขข้างต้น ยังมีข้อควรระวังในการนำไปใช้อยู่นะ

เมื่อนำไปใช้กับ Frontend Framework ใดๆ โดยเฉพาะ Vue, Angular, Svelte ที่มัก set state ด้วยเครื่องหมายเท่ากับ (=)
หรือบางทีเราใช้วิธี mutate state โดยตรงก็ได้ (เช่น state.key = value) ซึ่งการ mutate state โดยตรงใน Vue, Angular, Svelte อาจไม่จำเป็นต้องใช้ท่านี้ (ให้พิจารณาใช้งานเป็น case-by-case แล้วกัน)

หรือถ้าจำเป็นต้องใช้ท่า Shallow/Deep Copy จริงๆ ก็ให้ re-assign ค่าใหม่กลับไปที่ state ด้วย
ไม่งั้นโปรแกรมจะบ้งเอานะบอกไว้ก่อน (มันอาจไม่ re-render)

ส่วน React ส่วนตัวผมได้ลองใช้วิธีนี้ไปบ้างแล้วยังไม่พบปัญหาอะไรนะ

สรุป

เมื่อสร้างฟังก์ชันหรือเมธอด (method) ใดๆ ขึ้นมา ให้พึงระวัง Side Effect ที่เกิดจากการ mutate value ทั้งจากภายในสู่ภายนอก และจากภายนอกสู่ภายในเอาไว้เสมอครับ

เมื่อทำได้แบบนี้แล้ว bug ในโปรแกรมของเราจะลดลงไปมาก เนื่องจากไม่มีสิ่งใดมาทำให้ค่าภายในหรือภายนอกถูกแก้ไขโดยที่เราไม่ได้ตั้งใจทำให้เกิดขึ้น

อีกทั้ง คนที่เรียกใช้ฟังก์ชันที่เราสร้างขึ้นก็จะมีความมั่นใจในการใช้งานได้มากขึ้นด้วย ว่ามันจะไม่ก่อให้เกิด bug ข้างนอก และในขณะเดียวกันปัจจัยภายนอกก็จะไม่ทำให้ bug เกิดขึ้นในฟังก์ชันของเราด้วย

ความมั่นใจที่เกิดขึ้นก็มาจาก Pure Function ที่เราเพียรสร้างขึ้นมานั่นเองครับบบบ

Reference:

บทความนี้แทบจะเขียนขึ้นไม่ได้เลย ถ้าไม่ได้แรงบันดาลใจจากเนื้อหาใน link นี้
ถ้าอยากดำดิ่งสู่ห้วงลึกลงไปอีก ก็ตามไปตำกันได้เลยนะครับ

Functional Light หน่วยที่ 5โดยลุง Kyle Simpson เจ้าของหนังสือเล่มดัง “You don’t know JS” (ซึ่งผมยังไม่เคยอ่าน 5555):
https://github.com/getify/Functional-Light-JS/blob/master/manuscript/ch5.md

ผมขอตัวล่ะ สวัสดีครับ

บทความที่เกี่ยวข้อง

1. How to deal with the Impure Function in your application

Thanks for reading until the final line.
If you’re confused or have any questions, don’t hesitate to leave the comments.

Feel free to connect with me and stay updated on the latest insights and discussions.
👉 https://linkedin.com/in/fResult 👈

--

--

fResult
fResult

Written by fResult

ชื่อเล่นว่ากร เขามี background มาจากอาชีพเด็กวิ่งเอกสารในอาคารของธนาคาร โดยเรียนไปด้วยจนจบจากมหาลัยเปิดแห่งหนึ่ง และปัจจุบันทำงานเป็น Web Developer ครับทั่นน