มารู้จักกับ Function Composition กัน

Function Composition in JavaScript

Pallop Chaoputhipuchong
Jitta Engineering
4 min readSep 18, 2017

--

เกริ่นนำ

ในปัจจุบัน 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)

ขั้นตอนการทำงาน

  1. ตัดช่องว่างที่ไม่จำเป็นหน้าหลังของคำ (trim)
  2. แปลงคำทั้งหมดเป็นตัวพิมพ์เล็ก (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'

สำหรับ composable function ทั้งหลายแหล่จริง ๆ แล้ว เราไม่จำเป็นต้องเขียนเองก็ได้นะครับ เราสามารถทำได้โดยการ import จาก library ดัง ๆ อย่าง lodash/fp หรือ ramda เอาก็ได้นะครับ

แล้วทำไมเราต้อง 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 แบบง่าย ๆ ประกอบไปด้วยขั้นตอนดังต่อไปนี้

  1. ตัด ช่องว่าง (space) หน้าหลัง
  2. แยกคำแต่ละคำออกมาจากกัน
  3. เชื่อมคำกลับเข้าไปด้วยกัน ขีด (-)
  4. แปลงคำทั้งหมดเป็นตัวพิมพ์เล็ก (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 กันยิ่งขึ้นไปนะครับ 👏 ขอบคุณครับ 👏

--

--