Category Theory (for Programmer!) 2: Functor~คืออะไร

developer

บทความชุด: Functional Programming

รวมเนื้อหาเกี่ยวกับการเขียนโปรแกรมแนว Functional และหัวข้ออื่นๆ ที่เกี่ยวข้อง


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 คน

  1. Lift: เป็นคนเริ่มนำมันฝรั่งเข้าไปใส่ในกล่อง
  2. 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 ซึ่งสามารถ

  1. รับฟังก์ชันหรือ job เข้าไป
  2. เอาฟังก์ชันนั้นไป apply กับ value ที่เก็บไว้
  3. ผลลัพธ์ที่ได้ก็เอาไปใส่กล่อง (ห่อด้วย 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 สำหรับให้ใส่ฟังก์ชันเข้าไปทำงานกับค่าข้างในได้

423 Total Views 3 Views Today
Ta

Ta

สิ่งมีชีวิตตัวอ้วนๆ กลมๆ เคลื่อนที่ไปไหนโดยการกลิ้ง .. ถนัดการดำรงชีวิตโดยไม่โดนแสงแดด
ปัจจุบันเป็น Senior Software Engineer อยู่ที่ Centrillion Technology
งานอดิเรกคือ เขียนโปรแกรม อ่านหนังสือ เขียนบทความ วาดรูป และ เล่นแบดมินตัน

You may also like...

ใส่ความเห็น

อีเมลของคุณจะไม่แสดงให้คนอื่นเห็น ช่องที่ต้องการถูกทำเครื่องหมาย *