มารู้จักกับ Function Composition กัน
Function Composition in JavaScript
เกริ่นนำ
ในปัจจุบัน FP (Functional Programming) เข้ามามีบทบาทมากขึ้นเรื่อย ๆ ในโลกของ JavaScript
สิ่งที่ผมจะมานำเสนอในวันนี้คืออีก 1 พื้นฐานสำคัญ ในโลกของ FP ที่เรียกว่า Function Composition
Function Composition
คือกระบวนการรวมกันของ functions มากกว่า 1 ขึ้นไป และก่อให้เกิด function ใหม่ขึ้นมา
หรือรูปแบบหนึ่งที่ใช้ในการอธิบายที่ง่ายที่สุดก็คือ f(g(x))
หรือการรวมกันของ function f
และ g
มากระทำกับ x
Compose Function
วิธีที่เราจะใช้ในการรวม function เข้าด้วยกัน นั้นเรียกว่า compose function
ก่อนอื่นเรามาดูโค้ดตัวอย่างการเขียน compose function กันก่อน
const compose = function(f, g) {
return function(x) {
return f(g(x))
}
}
หรือเขียนใน syntax es6 ได้โดย
const compose = (f, g) => x => f(g(x))
ภายในบทความนี้ผมจะใช้ arrow function แทนการเขียน function เพื่อความกระชับของโค้ดนะครับ หากใครไม่ทราบการทำงานของ arrow function สามารถอ่านเพิ่มเติมได้ ที่นี่ ครับ
จากโค้ดข้างต้น การทำงานของ compose function คือการรับ parameters ที่เป็น function เข้ามา 2 ตัว (ตัวแปร f
และ g
) และทำการคืน function ใหม่ออกไป
หาก function ใหม่มีการเรียกใช้
สิ่งที่เกิดขึ้นคือ มันจะทำการ apply functions ก่อนหน้านี้ที่ใส่เข้ามา (f
และ g
) กับ input (ตัวแปร x
)
โดยค่อย ๆ apply functions ไล่ไปจาก ขวา ไป ซ้าย
x ถูก apply กับ function g ก่อน และผลลัพธ์ทั้งหมดค่อยถูกนำไป apply กับ function f ในภายหลัง
ตัวอย่าง
จินตนาการว่า เราจะสร้าง function ทำความสะอาด String ก่อนนำไปใช้งาน (sanitize function)
ขั้นตอนการทำงาน
- ตัดช่องว่างที่ไม่จำเป็นหน้าหลังของคำ (trim)
- แปลงคำทั้งหมดเป็นตัวพิมพ์เล็ก (trim)
สามารถเขียนเป็นโค้ดแบบปกติได้ดังนี้
function sanitize(str) {
return str.trim().toLowerCase()
}sanitize(' Hello My Name is Ham ') // 'hello my name is ham'
ทีนี้ เราลองเขียนแบบ FP กันดู
const trim = s => s.trim()
const toLowerCase = s => s.toLowerCase()function sanitize(str) {
return toLowerCase(trim(str))
}sanitize(' Hello My Name is Ham ') // 'hello my name is ham'
อธิบายเพิ่มเติมนิดนึง ในการเขียนแบบ FP จากตัวอย่างด้านบน ผมได้มีการใช้งาน function ที่สร้างขึ้นมาเอง 2 ตัว คือ
trim
และtoLowerCase
เหตุผลที่ผมทำการสร้าง 2 ตัวนี้ขึ้นผม เนื่องจากการเขียนแบบ FP นั้น เราจะเขียนโดยให้ function ที่ใช้งานนั้นมีคุณสมบัติที่สามารถ compose ได้ (composable function)
วิเคราะห์
ทีนี้ลองสังเกตดูจากโค้ดแบบ FP ด้านบนนะครับ การทำงานของมัน มีความคล้ายคลึงกับอะไรบางอย่างที่ผมได้อธิบายไปก่อนหน้านี้ไหมครับ?
.
.
.
คำตอบนั่นก็คือ มันเหมือนกันกับ compose function ที่ผมได้อธิบายไปก่อนหน้านี้นั่นเองครับ
ลองแทนค่า functions ต่าง ๆ ดังต่อไปนี้นะครับ
toLowerCase
=f
trim
=g
str
=x
เมื่อทั้งหมดมารวมกัน
จากบรรทัด toLowerCase(trim(str))
ก็จะมีค่าเหมือนกันกับ f(g(x))
ซึ่ง หมายความว่าเราสามารถนำเอา compose function มาช่วยในกรณีนี้ได้ดังนี้
const compose = (f, g) => x => f(g(x))
const sanitize = compose(toLowerCase, trim)sanitize(' Hello My Name is Ham ') // 'hello my name is ham'
แล้วทำไมเราต้อง compose ล่ะ?
ข้อดีของการสร้าง function ย่อย ๆ และ compose เข้าด้วยกัน คือมันจะทำให้เราสามารถตรวจสอบ หรือควบคุม data pipeline
กล่าวคือ flow หรือความเปลี่ยนแปลงที่จะเกิดขึ้นกับ data ได้
รวมไปถึงยังเป็นส่วนช่วยให้ function ต่าง ๆ ของเรา สามารถนำกลับมาใช้ใหม่ (reusuable) ได้โดยง่ายอีกด้วย
Compose กับการใช้งานจริง
ในการใช้งานจริง เราอาจจะไม่จบแค่การ compose functions เพียง 2 functions
แต่อาจจะ compose functions ไป 3–4 functions หรือมากกว่านั้นก็ได้
เพราะฉะนั้น เราลองมาเขียน compose function ที่เหมาะแก่การใช้งานจริง โดยรองรับการ compose functions ได้อย่างไม่จำกัด ตามแต่ตัวแปรที่ใส่เข้ามาดูครับ
ทำได้โดย
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x)
เท่านี้เอง เราก็จะสามารถ compose functions กี่ตัวเข้าไปแล้วก็ได้ครับ
ตัวอย่าง
ทีนี้เรามาดูตัวอย่างการใช้งานจริงที่ยากขึ้นกันบ้าง
จินตนาการว่าเราต้องการสร้าง function ที่รับ String เข้ามา และเปลี่ยนมันออกมาให้เป็น slug
function ที่ผมจะทำนั้นจะเป็น slug แบบง่าย ๆ ประกอบไปด้วยขั้นตอนดังต่อไปนี้
- ตัด ช่องว่าง (space) หน้าหลัง
- แยกคำแต่ละคำออกมาจากกัน
- เชื่อมคำกลับเข้าไปด้วยกัน ขีด (-)
- แปลงคำทั้งหมดเป็นตัวพิมพ์เล็ก (lower case)
ซึ่งสามารถเขียนเป็นโค้ดปกติง่าย ๆ ได้ดังนี้
ปกติ
const toSlug = word =>
word.trim()
.split(' ')
.join('-')
.toLowerCase()toSlug(' THIS is SluG ') // 'this-is-slug'
Functional Programming
งั้นเรามาลองเขียนกันแบบ FP ดูดีกว่า
const trim = s => s.trim()
const toLowerCase = s => s.toLowerCase()
const join = separator => arr => arr.join(separator)
const split = separator => arr => arr.split(separator)const toSlug = word =>
toLowerCase(
join('-')(
split(' ')(
trim(word)
)
)
)toSlug(' THIS is SluG ') // 'this-is-slug'
โอ้ว~ เลวร้ายกว่าเดิมอีก nesting กันเป็นแถบ เขียนโค้ดแบบนี้ไม่ดีแน่ อ่านยากสุด ๆ
Functional Programming with Compose
งั้นเราลองนำเอา compose มาช่วยดู
const trim = s => s.trim()
const toLowerCase = s => s.toLowerCase()
const join = separator => arr => arr.join(separator)
const split = separator => arr => arr.split(separator)const toSlug = compose(
toLowerCase,
join('-'),
split(' '),
trim,
)toSlug(' THIS is SluG ') // 'this-is-slug'
อืมม์ ดูดีขึ้นเป็นกอง…. แต่ช้าก่อน!!!
เราลองมาอ่านโค้ดกันดูดี ๆ ก่อนนะครับ
ในมุมมองของคณิตศาสตร์ compose นั้นก็เป็นอะไรที่ดีงาม แต่ในเรื่องของการอ่านโค้ดนั้น compose กลับไม่ใช่ทางเลือกที่ดีที่สุด
เพราะอะไร?
หากเรามองย้อนกลับขึ้นไปที่โค้ดข้างบน เพื่อน ๆ ลองพยายามอ่านดูกันนะครับว่า function toSlug
นั้นมีขั้นตอนการทำงานยังไงบ้าง
1…. 2…. 3…..
การที่เราจะรู้ขั้นตอนการทำงานของมัน step 1, 2, 3 ไล่ไปได้นั้น
อย่างที่ผมกล่าวไปก่อนหน้า จากการ compose เราต้องเริ่มอ่านไล่จาก ขวา ไป ซ้าย (หรือในที่นี้ผมขึ้นบรรทัดใหม่ก็คือจาก ล่าง ขึ้น บน)
เช่นในที่นี้ ขั้นตอนที่เกิดขึ้นจากการ compose
compose(
toLowerCase,
join('-'),
split(' '),
trim,
)
คือ trim
-> split(' ')
-> join('-')
-> toLowerCase
ซึ่งแน่นอนว่าการอ่านโค้ดย้อนกลับแบบนี้นั้น ผมคิดว่ามันเป็นอะไรที่ไม่น่าอภิรมย์ซักเท่าไหร่เลยครับ
งั้นเราจะทำยังไง?
แทนที่ เราจะเขียนโค้ดและเรียกใช้งาน compose
ให้ต้องมานั่งอ่านย้อนกลับ
เราจะใช้อีกสิ่งหนึ่งที่ทำให้เราสามารถอ่านโค้ดอย่างลื่นไหล ไล่ไปเรื่อย ๆ โดยง่าย ซึ่งเราจะเรียกกันว่า pipe
นั่นเองครับ
Pipe คืออะไร?
หากใครเคยเขียน หรือใช้งาน shell script มาบ้าง น่าจะเคยผ่านตาในการใช้งาน pipe เรียกใช้งานโดย โดยใน shell script จะใช้งานโดยอักขระ |
ลักษณะการทำงานของ pipe คือ การส่งต่อ ผลลัพธ์ ที่ได้จากการ คำสั่งก่อนหน้า ไปให้แก่ คำสั่งด้านหลัง
ซึ่งอธิบายง่าย ๆ pipe นั้นมีการทำงาน เหมือนกัน กับ compose นั้นเอง เพียงแต่ว่าการนำเอา function เข้ามา apply นั้น สลับกันจาก
compose
: apply function จาก ขวา ไป ซ้ายpipe
: apply function จาก ซ้าย ไป ขวา
เราลองมา implement pipe พร้อมเปรียบเทียบกับ compose ได้กันดูครับ โดย
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x)const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x)
เพราะฉะนั้นจากโค้ด function toSlug
ก่อนหน้า
const toSlug = compose(
toLowerCase,
join('-'),
split(' '),
trim,
)toSlug(' THIS is SluG ') // 'this-is-slug'
เราสามารถเปลี่ยนใหม่โดยใช้ pipe กลายเป็น
const toSlug = pipe(
trim,
split(' '),
join('-'),
toLowerCase,
)toSlug(' THIS is SluG ') // 'this-is-slug'
เป็นไงครับ? อ่านง่ายขึ้นเป็นกองเลยใช่ไหมล่า~
Data Pipeline
ข้อดีหนึ่งที่ผมได้กล่าวเป็นก่อนหน้าของการ compose function หลาย ๆ ตัวเข้าด้วยกันคือ
เราสามารถ ตรวจสอบ และควบคุม data pipeline หรือ flow การทำงานต่าง ๆ ที่เกิดขึ้นได้โดยง่าย
เช่นยังไง?
เพิ่มขั้นตอน
จินตนาการต่อว่า function toSlug
ของเรานั้น เราต้องการ เพิ่มขั้นตอนการลบ อักขระพิเศษ เข้าไปใน flow การทำงานด้วย
เราสามารถเพิ่มโค้ดเข้าไปง่าย ๆ ได้โดย
const map = fn => arr => arr.map(fn)
const removeSpecialChar = s => s.replace(/[^a-zA-Z0-9]/g, '')const toSlug = pipe(
trim,
split(' '),
map(removeSpecialChar),
join('-'),
toLowerCase,
)toSlug(' THIS is SluG♥ ') // 'this-is-slug'
ก็เป็นอันเสร็จครับ
เช่นเดียวกัน หากว่าต้องการลดการทำงานส่วนไหนชั่วคราว ก็แค่ไป comment ออกแค่นั้นเองครับ
ตรวจสอบ
อีกสิ่งหนึ่งที่ผมพูดถึงคือ การตรวจสอบ
เช่นผมต้องการทราบผลลัพธ์หลังการ map
ผมสามารถทำได้โดย function หนึ่งเรียกว่า trace
const trace = label => x => {
console.log(`==> ${label}: ${x}`)
return x
}
และใช้งานโดยการเพิ่มเข้าไปใน pipeline ดังนี้
const map = fn => arr => arr.map(fn)
const removeSpecialChar = s => s.replace(/[^a-zA-Z0-9]/g, '')
const trace = label => x => {
console.log(`==> ${label}: ${x}`)
return x
}const toSlug = pipe(
trace('Input'),
trim,
split(' '),
map(removeSpecialChar),
trace('After mapped'),
join('-'),
toLowerCase,
)toSlug(' THIS is SluG♥ ')
// '==> Input: THIS is SluG♥ '
// '==> After mapped: THIS,is,SluG'
// 'this-is-slug'
Conclusion
หลังจากอ่านบทความนี้จบ ผมคาดว่าเพื่อน ๆ หลายคน คงเริ่มเข้าใจ concept หรือการทำงานของ FP (Functional Programmign) มากขึ้นไม่มากก็น้อยแล้วนะครับ
จริง ๆ เรื่องการของ Function Composition ก็ไม่มีอะไร ให้คิดว่าเป็นเหมือนกับการที่เราเขียน function หลาย ๆ ตัว แล้วจับมันมามัดมาทำงานร่วมกันเท่านั้นเองครับ
หากใครมีคำถาม หรือติชมอะไร ก็มาคอมเม้นท์พูดคุยกันได้ข้างล่างนี้นะครับ
สุดท้ายนี้หวังว่าบทความนี้จะช่วยทำให้เพื่อน ๆ เข้าใจ และหลงใหลใน FP กันยิ่งขึ้นไปนะครับ 👏 ขอบคุณครับ 👏