บทความชุด: Functional Programming
รวมเนื้อหาเกี่ยวกับการเขียนโปรแกรมแนว Functional และหัวข้ออื่นๆ ที่เกี่ยวข้อง
- ตอนที่ 1 Pure, First-Class, High-Order, พื้นฐานแห่ง Functional Programming
- ตอนที่ 2 Lambda Function and Closure ฟังก์ชันทันใจและพื้นที่ปิดล้อม!
- ตอนที่ 3 map filter reduce และเพื่อนๆ พระเอกแห่งโลก Functional
- ตอนที่ 4 โครสร้างแบบ Pair และ Either กับ List Comprehension การสร้างลิสต์ฉบับฟังก์ชันนอล
- ตอนที่ 5 Lazy Evaluation ขี้เกียจไว้ก่อนแล้วดีเอง?!
- ตอนที่ 6 Recursive Function ฟังก์ชันเวียนเกิด, เขียนลูปไม่ได้ทำยังไง? มาเขียนฟังก์ชันเรียกตัวเองแทนกันเถอะ!
- ตอนที่ 7 Curry, Partial Function
- ตอนที่ 8 Functor, Monad
- [บทความปี 2015] มารู้จักกับ Functional Programming สิ่งที่คุณต้องรู้ในตอนนี้กันเถอะ
- Function ทำงานยังไง?, ในมุมมองของโปรแกรมเมอร์สาย Imperative
Category Theory & Lambda Calculus
เนื้อหาเกี่ยวกับคณิตศาสตร์ในบทความนี้ได้รับคำปรึกษาและตรวจทานจาก muitsfriday.dev
บนนำ
ก่อนจะเข้าเรื่อง Functor เรามาพูดเรื่องนี้กันก่อน ... เรื่องของกล่องและการแพ็กของ!
จากในบทที่แล้ว เราได้อธิบายเรื่อง object และ arrow (หรือ morphism) ในเรื่อง category กันไปแล้วด้วยเรื่องของมันฝรั่ง
เช่น ถ้าเรามีมันฝรั่ง (object) อยู่ เราสามารถเอาไปทอด (arrow) แล้วเราก็จะได้มันฝรั่งทอด (object) ออกมา
ซึ่งถ้ามีแค่นี้มันก็ยังไม่มีอะไร ดังนั้นในบทนี้เราจะมาพูดถึงตัวละครใหม่อีกหนึ่งตัวนั่นคือ...
Just wrap it! ..ด้วย"กล่อง"
ขอเทียบกับโลกแห่งมันฝรั่งเหมือนเดิมละกัน
ถ้าเราไปซื้อมันฝรั่งที่ร้านค้า ร้านมันจะเอามันฝรั่งเราใส่ถุงกลับบ้านให้ ... แต่ในยุคนี้ที่เรากำลังลดการใช้ถุงพลาสติกกัน ร้านค้าเลยบอกว่าเดี๋ยวจะเอามันฝรั่งใส่กล่องกลับบ้านให้แทนละกัน
ได้แบบนี้
คือจาก มันฝรั่ง ก็กลายเป็น มันฝรั่งในกล่อง แทน
มันฝรั่ง --> กล่อง(มันฝรั่ง)
value --> box(value)
ซึ่งการเอาของใส่กล่องเนี่ย เราเรียกว่า
Lift หรือ Unit ความสามารถคือการห่อของอย่างเดียว ทำอย่างอื่นไม่ได้เลย
แต่การที่เราเอามันฝรั่งใส่กล่องแบบนี้ ก็ทำให้เกิดปัญหาตามมา คือ
สถานการณ์อย่างนี้, การทอดเดิมของเรา ไม่สามารถเอามาใช้งานได้แล้ว เพราะเราไม่สามารถทอดกล่องได้นั่นเอง
ดังนั้นเราเลยต้องการผู้ช่วยเพิ่มอีกหนึ่งคนที่จะเอาของในกล่องของเราไปทอดให้เราได้ (และให้ผลลัพธ์ออกมาเป็นมันฝรั่งทอดในกล่องเหมือนเดิมด้วย!) ซึ่งเราจะเรียกผู้ช่วยคนที่ 2 นี้ว่า
Map หรือ Fmap ความสามารถคือการรับงาน (job) อะไรบางอย่างเข้าไป แล้วเอาทำกับของที่อยู่ข้างในกล่อง
สรุปอีกครั้งคือถ้าเราต้องการ ทอดมันฝรั่ง(ที่อยู่ในกล่อง) เราต้องการผู้ช่วย 2 คน
- Lift: เป็นคนเริ่มนำมันฝรั่งเข้าไปใส่ในกล่อง
- Map: รับงานเข้าไปทำกับมันฝรั่งในกล่อง
เท่านี้เราก็สามารถที่จะห่อมันฝรั่งและก็ทอดมันฝรั่งในกล่องได้แล้ว
แล้วถ้าเราเอาผู้ช่วย 2 คนนี้มารวมร่างกัน เราจะได้ผู้ช่วยคนใหม่ออกมา 1 คนที่มีความสามารถทั้ง Lift + Map
ในโลก category เราจะเรียกผู้ช่วยที่ทำหน้าที่แบบนี้ได้ว่า "Functor" นั่นเอง!!
Functor ในมุมมองโปรแกรมเมอร์
มาพูดเรื่องของฟังก์เตอร์ในมุมของภาษาคอมพิวเตอร์กันบ้าง
ถ้าเรามี value อยู่ เราสามารถห่อมันได้ด้วยการสร้างฟังก์ชันที่ชื่อว่า Just
ขึ้นมา
Just
กล่องที่เราพูดถึงกันเมื่อกี้ก็คือ Container ชนิดหนึ่ง ที่เราสามารถเอามา wrap ค่าของเราได้ ซึ่งเราจะเรียกมันว่า Just
แปลง่ายๆ ก็ประมาณ "ก็แค่ห่อมันเอาไว้~"
const Just(v) => {
value: v
}
let one = 1
let oneInTheBox = Just(x)
print(oneInTheBox.value) // 1
โอเค ไม่ยาก สร้างฟังก์ชันที่รับค่าเข้าไป 1 ค่าแล้วรีเทิร์นกลับมาเป็น object ที่ห่อหุ้มค่านั้นเอาไว้!
หรือถ้าเราใช้ภาษาโปรแกรมแบบ static type อาจจะเขียนแบบนี้ก็ได้
class Just<T> {
T value;
Just(T v) {
this.value = v;
}
}
int one = 1;
Just oneInTheBox = new Just(one);
print(oneInTheBox.value); // 1
แต่!
สมมุติเรามีฟังก์ชันสำหรับบวกเลขอยู่ เราสามารถเอาตัวเลขไปเข้าฟังก์ชันนี้ได้ แต่ถ้าตัวเลขนั้นอยู่ในกล่องละก็ จะไม่สามารถบวกได้
function plusTwo(x) {
return x + 2
}
let one = 1
print( plusTwo(one) ) // 3
let oneInTheBox = Just(x)
print( plusTwo(oneInTheBox) ) // Error! ไม่สามารถนำ Just มาบวกเลขได้!
ทางแก้ก็คือ
เมื่อกี้เราเราบอกว่านอกจากการ "ห่อ/แกะห่อ" แล้วเนี่ย มันยังต้องมีอีกความสามารถหนึ่งนั่นคือการรับ job เข้าไปทำงานกับ item นั้น ซึ่งเราเรียกความสามารถนี้ว่า
"map" หรือใน "fmap"
ก็จัดการเพิ่ม method map
ลงไปใน Just
ที่เราเขียนไว้เมื่อกี้
const Just(v) => {
value: v,
map(f) {
return Just( f(v) )
}
}
สร้างฟังก์ชัน map
ซึ่งสามารถ
- รับฟังก์ชันหรือ job เข้าไป
- เอาฟังก์ชันนั้นไป apply กับ value ที่เก็บไว้
- ผลลัพธ์ที่ได้ก็เอาไปใส่กล่อง (ห่อด้วย Just) ใหม่อีกรอบ
Note
สำหรับภาษาสไตล์ Functional เช่น Haskell เราสามารถสร้าง Functor ได้ด้วยโค้ดหน้าตาประมาณนี้ (ตัวอย่าง ต้องการจะสร้าง Functor f
)
class Functor f where
fmap :: (a -> b) -> f a -> f b
-- │ │ └─output: Functor f ที่หุ้ม b อยู่
-- │ └─ input: Functor f ที่หุ้ม a อยู่
-- └─ input: รับ function ที่รับค่า a เข้าไปแล้วคำนวณค่า b กลับมาให้
ต่อไป เรามาดูตัวอย่างการใช้งาน
function plusTwo(x) {
return x + 2
}
let oneInTheBox = Just(1)
let threeInTheBox = one.map(plusTwo)
ดังนั้น, การเอา กล่อง(1)
ไป map
ด้วยฟังก์ชัน plusTwo
ผลที่ได้ก็คือ 3 ที่อยู่ใน กล่อง(3)
ละ
Nothing
เรามี value
ไปแล้ว
และเราก็มีกล่องสำหรับห่อค่าเอาไว้คือ Just
แล้ว
คำถามคือ .. เป็นไปได้มั้ย ที่มันจะมีแค่กล่องเปล่าๆอย่างเดียว !?
คำตอบ .. เกริ่นมาซะขนาดนี้ แน่นอนว่ามันต้องมีสิ! และเราก็เรียกกล่องเปล่าพวกนั้นว่า Nothing
นั่นคือการไม่มีค่าอะไรเก็บอยู่เลย
แต่ Nothing หรือกล่องเปล่าเนี่ย มันก็ทำให้เกิดปัญหาได้ พอมันไม่มี value
ตอนเราสั่ง map
มันก็จะไม่มีค่าให้เอาไป apply กับฟังก์ชันยังไงล่ะ
const Nothing() => {
value: null,
map(f) {
return Just(f(null))
}
}
การที่ไม่มีค่า ทำให้ตอน apply f()
อาจจะเกิดปัญหาขึ้นได้
วิธีแก้คือหาก functor ของเราเป็นแบบ Nothing
การ apply function อะไรก็ตามจะถูก ignore (ทำเป็นไม่สนใจ) ทั้งหมดเลย
และหากเราเอาทั้ง Just
และ Nothing
มารวมกัน ก็จะได้กล่องชนิดใหม่ขึ้นมาอีก นั่นคือ
"Maybe"
ซึ่งมีโครงสร้างประมาณนี้
const Maybe = (v) => {
value: v,
map(f) {
if(v != null) {
return Maybe(f(v))
} else {
return Maybe(null)
}
}
}
// หรือจะย่อให้สั้นลงอีกหน่อยก็ได้
const Maybe = (v) => {
value: v,
map(f) {
return Maybe(v != null ? f(v) : null)
}
}
นั่นคือ Maybe
จะเพิ่มการเช็กว่าถ้า value
ไม่มี ก็จะไม่รันฟังก์ชัน แต่ตอบเป็น Maybe(null)
= Nothing
นั่นเอง
ต่อมา เราสามารถเพิ่ม method สำหรับเอาไว้เช็กว่า Maybe
ตัวนี้เป็น Just(x)
หรือเป็น Nothing
กันแน่
const Maybe = (v) => {
value: v,
map(f) {
return Maybe(v != null ? f(v) : null)
},
isNothing() {
return v == null
}
}
แล้วเอาไปใช้อะไรได้?
ต่อไปลองมาดูตัวอย่างการทำ Functor ไปใช้งานบ้าง
สมมุติว่าเรามีฟังก์ชันอยู่ตัวนึง ให้เป็นฟังก์ชันที่ทำการคำนวณค่าอะไรสักอย่าง
function calculateSomething(x) {
return 50 / (x - 100) + 20
}
ซึ่งถ้าเราลองมาวิเคราะห์ดู ถ้าค่า x
ที่ใส่เข้าไปมีค่าเป็น 100
จะทำให้เกิดการ Dividing by Zero หรือการหารด้วย 0 นั่นเอง
วิธีการแก้แบบง่าย เราก็จะเติม if
เพื่อเช็กเข้าไปก่อนที่จะทำการคำนวณ แบบนี้
function calculateSomething(x) {
if(x == 100) return null
return 50 / (x - 100) + 20
}
ฟังก์ชันแบบนี้เราเรียกว่า "MayError" นั่นคือเป็นฟังก์ชันที่ไม่ได้ให้คำตอบออกมาเสมอไป แต่อาจจะเกิดการ Error ได้ (ซึ่งในเคสนี้ เราตั้งไว้ว่าถ้ามีอะไรผิดพลาด ให้ตอบกลับมาเป็น null
แทน)
ตัวอย่างต่อมา เรากำหนดให้มีฟังก์ชันแบบ MayError แบบนี้สัก 3 ตัวแบบนี้ .. ก็คือเป็นฟังก์ชันที่สามารถคำนวณค่าอะไรบางอย่างออกมาได้ แต่ก็มีโอกาสบางเคสที่สามารถเกิด Error ได้เช่นกัน
function calculateThis(x) {
return ...
}
function calculateThat(x) {
return ...
}
function calculateThose(x) {
return ...
}
หากเราต้องคำนวณค่าคำตอบจากฟังก์ชันทั้ง 3 ตัวแบบเรียงตามลำดับ calculateThis
--> calculateThat
--> calculateThose
ถ้าเราเขียนโปรแกรมแบบธรรมดา ในแต่ละสเต็ปที่เราทำการคำนวณ เราจะต้องมีการเช็กคำตอบในแต่ละสเต็ปก่อน ว่าคำตอบที่ออกมาสามารถเอาไปใช้งานต่อในขั้นต่อไปได้หรือไม่
let a = 100
let b = calculateThis(a)
if(b) {
let c = calculateThat(b)
if(c) {
let ans = calculateThose(c)
if(ans) {
print('answer is ' + ans)
}
}
}
หรืออาจจะจัดรูปใหม่ให้ไม่เป็นโค้ดแบบ Pyramid of doom ได้แบบนี้
let a = 100
let b = calculateThis(a)
let c = b == null ? null : calculateThat(b)
let ans = c == null ? null : calculateThose(c)
if(ans) {
print('answer is ' + ans)
} else {
print('no answer')
}
แต่ถ้าเราเอา Functor ในรูปของ Maybe
มาใช้งาน เราก็จะได้โค้ดแบบนี้
let a = Maybe(100)
let b = a.map(calculateThis)
let c = b.map(calculateThat)
let ans = c.map(calculateThose)
// หรือเขียนแบบต่อกันเป็น chaining
let ans = Maybe(100)
.map(calculateThis)
.map(calculateThat)
.map(calculateThose)
if( ans.isNothing() ) {
print('no answer')
} else {
print('answer is ' + ans.value)
}
เราสามารถสั่งให้ object Maybe
ทำงานตามฟังก์ชันแต่ละสเต็ปแบบไม่ต้องกลัวว่ามันจะเกิดการ Error หรือไม่ เพราะตัว Functor Maybe
นั้นมีการเช็กว่าค่าตอนนี้เป็น Nothing
อยู่หรือไม่ ถ้าไม่ก็ไม่ทำงาน
สรุป
Functor แบบสรุปง่ายๆ ก็คือ Abstract Model ของ Container ที่เอาไว้ห่อ value
และก็ต้องมี map สำหรับให้ใส่ฟังก์ชันเข้าไปทำงานกับค่าข้างในได้