3 คะแนน โดย GN⁺ 2025-01-20 | 2 ความคิดเห็น | แชร์ทาง WhatsApp

การจัดการ side effect ให้เป็นค่าชั้นหนึ่ง

  • ใน Haskell ผลข้างเคียง (เช่น การสร้างเลขสุ่ม การพิมพ์ออกจอ ฯลฯ) ถูกปฏิบัติราวกับเป็น “ค่าชั้นหนึ่ง (first class value)”
  • กล่าวคือ การเรียกฟังก์ชันที่สร้างผลข้างเคียงอย่าง randomRIO(1, 6) ไม่ได้ให้ผลลัพธ์สุดท้ายกลับมาทันที แต่คืนค่าเป็น “อ็อบเจ็กต์ที่อธิบายการกระทำที่จะถูกรันในภายหลัง”
  • อ็อบเจ็กต์นี้จะสร้างค่าสุ่มเมื่อมันถูกรันจริง แต่ก่อนหน้านั้นมันเป็นเพียงแผนการทำงานเท่านั้น
  • ชนิดอย่าง IO Int หมายถึง “การกระทำที่เมื่อรันจริงแล้วจะสร้าง Int ออกมา” โดยจะไม่ถูกรันทันทีตอนเรียก แต่จะรอจนถึงเวลาที่จำเป็นภายหลัง
  • ด้วยคุณสมบัตินี้ ต่างจากภาษาขั้นตอนแบบดั้งเดิมที่มองว่า “การเรียกฟังก์ชัน = การรันทันที” ใน Haskell เราสามารถประกอบผลข้างเคียงเข้าด้วยกันแล้วค่อยรันจริงในภายหลังได้

ทำความเข้าใจ do blocks ให้หายลึกลับ

  • do block ไม่ใช่ไวยากรณ์มหัศจรรย์ แต่แท้จริงแล้วประกอบขึ้นจากโอเปอเรเตอร์สองตัวที่ใช้เชื่อมโยง (bind) ผลข้างเคียงและรันตามลำดับ (then)

then

  • โอเปอเรเตอร์ *> จะรันผลข้างเคียงทางซ้ายก่อน ทิ้งค่าผลลัพธ์ของมัน แล้วจึงรันผลข้างเคียงทางขวาต่อ
  • ตัวอย่างเช่น putStr "hello" *> putStrLn "world" จะสร้างการกระทำ IO () หนึ่งเดียวที่รวมการพิมพ์ทั้งสองครั้งให้เกิดขึ้นตามลำดับ
  • เมื่อเขียนหลายบรรทัดใน do block ภายในก็อาศัยโอเปอเรเตอร์การรันตามลำดับแบบนี้

bind

  • โอเปอเรเตอร์ >>= มีหน้าที่รันผลข้างเคียงทางซ้าย แล้วส่งค่าที่ได้ไปยังฟังก์ชันทางขวา
  • ตัวอย่าง: randomRIO(1, 6) >>= print_side จะสร้างผลข้างเคียงที่ส่งผลลัพธ์ของลูกเต๋าไปให้ print_side เพื่อพิมพ์ออกมา
  • ใน do block รูปแบบ <- คือแนวคิดที่ใช้เขียนโอเปอเรเตอร์นี้ให้สะดวกขึ้น

โอเปอเรเตอร์สองตัวคือทั้งหมดของ do block

  • สุดท้ายแล้ว do block ถูกสร้างขึ้นจากโอเปอเรเตอร์สองตัวนี้คือ *> และ >>=
  • แม้จะนิยมใช้ไวยากรณ์ do เพราะอ่านง่ายและสะดวก แต่ถ้าใช้ฟังก์ชันที่หลากหลายกว่าสำหรับประกอบผลข้างเคียง ก็จะดึงจุดแข็งของ Haskell ออกมาได้มากกว่า

ฟังก์ชันที่ทำงานกับผลข้างเคียง

  • ใน standard library มีฟังก์ชันหลายตัวสำหรับจัดการผลข้างเคียงได้อย่างหลากหลายยิ่งขึ้น

pure

  • pure x จะสร้าง “การกระทำที่ให้ค่า x เป็นผลลัพธ์ โดยไม่มีผลข้างเคียงเพิ่มเติมใด ๆ”
  • ตัวอย่าง: loaded_die = pure 4 จะสร้าง IO Int ที่คืนค่า 4 เสมอ

fmap

  • มีรูปแบบ fmap :: (a -> b) -> IO a -> IO b โดยเป็นการนำฟังก์ชันบริสุทธิ์ไปใช้กับค่าผลลัพธ์ของผลข้างเคียง เพื่อสร้างการกระทำที่ให้ผลลัพธ์ใหม่
  • ตัวอย่าง: length <$> getEnv "HOME" สามารถสร้างการกระทำที่ดึง environment variable แล้วนำ length ไปคำนวณความยาวของมัน

liftA2, liftA3, …

  • ฟังก์ชันอย่าง liftA2, liftA3 ใช้รวมผลลัพธ์จากผลข้างเคียงหลายตัวเข้าด้วยกันด้วยฟังก์ชันบริสุทธิ์ตัวเดียว เพื่อสร้างผลข้างเคียงใหม่
  • ตัวอย่าง: liftA2 (+) (randomRIO(1,6)) (randomRIO(1,6)) จะสร้างผลข้างเคียงที่รวมค่าลูกเต๋าสองลูกเข้าด้วยกัน
  • งานเดียวกันนี้ทำได้ด้วยการใช้ <$> และ <*> ร่วมกันด้วย

พักสักครู่: แล้วประเด็นสำคัญคืออะไร?

  • วิธีนี้อาจดูเหมือนเป็นความสามารถธรรมดาที่ภาษาอื่นก็ทำได้ แต่ใน Haskell มีข้อดีที่ว่าเราสามารถดึงการกระทำที่มีผลข้างเคียงออกมาเก็บในตัวแปรหรือประกอบใหม่ได้ทุกเมื่อ โดยไม่ทำให้จังหวะการรันหรือผลลัพธ์เปลี่ยนไป
  • การแยกผลข้างเคียงออกมาจัดการอย่างอิสระช่วยลดความสับสนเวลาทำ refactor และทำให้สามารถนำกลับมาใช้ใหม่ได้อย่างปลอดภัยบนพื้นฐานของการให้เหตุผลเชิงสมการ (equational reasoning)

sequenceA

  • sequenceA [IO a] -> IO [a] ใช้แปลง “ลิสต์ของการกระทำที่มีผลข้างเคียง” ให้เป็น “การกระทำที่มีผลข้างเคียงเพียงตัวเดียวซึ่งให้ผลลัพธ์เป็นลิสต์”
  • ตัวอย่าง: สามารถเก็บการกระทำ log หลายตัวไว้ในลิสต์ แล้วค่อยใช้ sequenceA เพื่อรันทั้งหมดทีเดียวในภายหลัง
  • แม้แต่ผลข้างเคียงที่ทำซ้ำได้ไม่สิ้นสุด (เช่น repeat (randomRIO(1,6))) ก็สามารถเก็บไว้เป็นลิสต์ แล้วค่อย take n เฉพาะส่วนที่ต้องการก่อนนำไป sequenceA เพื่อรันได้

ช่วงคั่น: ฟังก์ชันอำนวยความสะดวก

  • void, sequenceA_, replicateM, replicateM_ เป็นต้น มีประโยชน์เมื่อต้องการไม่ใช้ค่าผลลัพธ์หรือเมื่อต้องการรันซ้ำหลายครั้ง
  • ตัวอย่าง: replicateM_ 500 (putStrLn "I will not cheat again.") ช่วยให้รันผลข้างเคียงหลายครั้งโดยไม่ต้องนับรอบเองโดยตรง

traverse

  • traverse :: (a -> IO b) -> [a] -> IO [b] จะสร้างการกระทำที่นำฟังก์ชันที่มีผลข้างเคียงไปใช้กับแต่ละสมาชิกในลิสต์ แล้วรวบรวมผลลัพธ์เป็นลิสต์
  • ที่จริงแล้ว sequenceA ก็คือ traverse id และ traverse_ คือเวอร์ชันที่ทิ้งผลลัพธ์

for

  • for มีความสามารถเหมือน traverse แต่รับอาร์กิวเมนต์สลับลำดับกัน

  • ตัวอย่าง: เขียนในรูป for numbers $ \n -> ... เพื่อสื่อความหมายแบบ “for loop” ได้อย่างเป็นธรรมชาติ

  • ด้วยการประกอบแบบนี้ สิ่งอย่างการวนซ้ำ การไล่ข้อมูล และการแปลงโครงสร้างข้อมูล ซึ่งในภาษาอื่นอาจต้องมีไวยากรณ์เฉพาะ แทนที่จะทำเช่นนั้น Haskell สามารถทำได้ด้วยการประกอบฟังก์ชันจากไลบรารี

เอนเอียงเข้าหาความเป็นค่าชั้นหนึ่งของเอฟเฟ็กต์

  • หากใช้ผลข้างเคียงใน Haskell ในฐานะค่าชั้นหนึ่งอย่างจริงจัง ก็จะช่วยลดการเขียนโค้ดซ้ำหรือปรับปรุงโครงสร้างของโปรแกรมได้
  • ตัวอย่างเช่น ในลอจิกแยกตัวประกอบจำนวนมากโดยใช้แคช อาจใช้ State แทน IO เพื่อสร้างโครงสร้างแบบ “มีผลข้างเคียงอยู่ แต่ไม่กระทบต่อโลกภายนอก”
  • ผลข้างเคียงที่ถูกจัดโครงสร้างเช่นนี้จะถูกใช้เฉพาะในส่วนที่จำเป็น ส่วนโค้ดที่เหลือยังคงเป็นฟังก์ชันบริสุทธิ์ได้ จึงได้ทั้งความปลอดภัยและความยืดหยุ่นพร้อมกัน
  • สุดท้ายสามารถใช้ evalState เป็นต้น เพื่อรันผลข้างเคียงจริงและแปลงผลลัพธ์ให้เป็นค่าบริสุทธิ์ได้

เรื่องที่คุณแทบไม่ต้องสนใจเลย

  • ชื่อหลายอย่างจากยุค Haskell เก่า (>>, return, mapM ฯลฯ) สามารถแทนที่ได้ด้วยฟังก์ชันปัจจุบัน (*>, pure, traverse ฯลฯ)
  • สิ่งเหล่านี้มีที่มาจาก “ชื่อแบบเก่า หรือการออกแบบที่ยึด monad เป็นศูนย์กลาง” ขณะที่ปัจจุบันมักแนะนำแนวทางที่อิง Applicative หรือ Functor ที่ทั่วไปกว่า

ภาคผนวก A: การหลีกเลี่ยงความสำเร็จและความไร้ประโยชน์

  • คำพูดว่า “Haskell หลีกเลี่ยงความสำเร็จ” หมายถึง “ภาษาไม่ยอมเสียสละคุณค่าพื้นฐานเพียงเพื่อความนิยมหรือความสะดวก”
  • วลี “Haskell is useless” สื่อถึงบริบทที่ในช่วงแรกมันดูเหมือนเป็นภาษาที่ทำอะไรไม่ได้เลย เพราะอนุญาตเฉพาะฟังก์ชันบริสุทธิ์อย่างสมบูรณ์ แต่ต่อมาจึงได้ความเป็นประโยชน์เชิงปฏิบัติจากการนำวิธีจัดการผลข้างเคียงแบบ ‘ค่าชั้นหนึ่ง’ เข้ามาใช้

ภาคผนวก B: ทำไม fmap จึง map ได้ทั้งผลข้างเคียงและลิสต์

  • fmap มีรูปแบบทั่วไปมาก (Functor f => (a -> b) -> f a -> f b) จึงใช้ร่วมกันได้กับคอนเทนเนอร์หรือชนิดของผลข้างเคียงหลายแบบ เช่น ลิสต์, Maybe และ IO
  • เมื่อนำ fmap ไปใช้กับลิสต์ มันจะนำฟังก์ชันไปใช้กับทุกสมาชิก และเมื่อนำไปใช้กับ IO มันจะนำฟังก์ชันไปใช้กับค่าผลลัพธ์
  • ด้วยเหตุนี้ โครงสร้างโดยรวมที่ “สามารถนำฟังก์ชันไปใช้ได้” จึงถูกเรียกว่า Functor

ภาคผนวก C: Foldable และ Traversable

  • Foldable คือโครงสร้างที่สามารถไล่ประมวลผลสมาชิกได้
  • Traversable คือโครงสร้างที่นอกจากจะไล่ประมวลผลได้แล้ว ยังสามารถสร้างโครงสร้างรูปเดิมขึ้นใหม่ด้วยสมาชิกชุดใหม่ได้
  • ถ้า sequenceA หรือ traverse จะรวบรวมค่าโดยคงโครงสร้างเดิมไว้ โครงสร้างนั้นต้องเป็น Traversable
  • โครงสร้างข้อมูลอย่าง tree หรือ Set อาจมีโครงสร้างที่เปลี่ยนไปตามค่า จึงต้องแยกระหว่างกรณีที่เพียงไล่ดูได้ (Foldable) กับกรณีที่สร้างโครงสร้างจริงกลับขึ้นมาได้ (Traversable)
  • ตามความจำเป็น เราสามารถแปลงเป็นลิสต์ก่อนแล้วใช้ traverse เพื่อจัดการผลข้างเคียงได้อย่างยืดหยุ่น

2 ความคิดเห็น

 
bbulbum 2025-01-21

พอไถ Reddit ก็เห็นโฆษณาเยอะเหมือนกัน.. แต่แค่ชื่อก็ทำให้รู้สึกมีอุปสรรคทางจิตใจนิดหน่อยแล้ว
ให้ความรู้สึกว่าเป็นภาษาที่ทั้งยากและทรงพลังมาก..

 
GN⁺ 2025-01-20
ความคิดเห็นจาก Hacker News
  • ระบบประเภทของ Haskell มีความซับซ้อนเมื่อเทียบกับภาษายอดนิยมอื่น ๆ โดยเฉพาะโอเปอเรเตอร์อย่าง *>, <*>, <* ที่ทำให้เส้นโค้งการเรียนรู้สูงขึ้นตลอดทั้งโค้ดเบส

    • ถ้าไม่ได้ใช้ Haskell เป็นเวลาหนึ่งเดือน อาจต้องกลับไปทบทวนโอเปอเรเตอร์อย่าง >>= และ >> ใหม่เพื่อรักษาประสิทธิภาพการทำงาน
    • หากศึกษาคอนเซปต์ของ Haskell คนเดียวโดยไม่ได้พูดคุยแลกเปลี่ยนกับผู้อื่น จะรู้สึกว่ายาก
  • Haskell ช่วยพัฒนาการเขียนโปรแกรมเชิงคำสั่งให้ดีขึ้น

    • สามารถใช้ first-class effects และแพตเทิร์นเพื่อลดโค้ด boilerplate ได้
    • ด้วย type safety ทำให้เขียนโค้ดที่มีบั๊กน้อยได้ค่อนข้างรวดเร็ว
  • เวอร์ชัน generalized ของ traverse/mapM มีประโยชน์มาก เพราะไม่ได้ทำงานได้แค่กับลิสต์ แต่ทำงานได้กับทุกประเภท Traversable

    • ใช้งานได้ในรูปแบบ traverse :: Applicative f => (a -> f b) -> t a -> f (t b)
    • ในภาษาอื่น ต้องเขียนโค้ดด้วยมือจำนวนมากเพื่อให้ได้ผลลัพธ์คล้ายกัน
  • Haskell มี monad ที่ทรงพลัง ซึ่งทำให้ Haskell มีความเป็นเชิงกระบวนวิธีมากขึ้น

    • สามารถใช้ตัวแปรกลางภายในบล็อก do ได้
  • ซอฟต์แวร์ที่เขียนด้วย Haskell มีอย่างเช่น ImplicitCAD

  • โค้ดของ Haskell อ่านได้คล้ายภาษาขั้นตอน แต่ก็ยังให้ข้อดีเมื่อทำงานกับฟังก์ชันที่มีผลข้างเคียง

    • การทำงานร่วมกับ IO monad มีความซับซ้อน และจะยิ่งซับซ้อนขึ้นอีกเมื่ออยากใช้ monad ประเภทอื่น
  • >> เป็นชื่อเก่าของ <i>> และทั้งสองโอเปอเรเตอร์เป็นตัวดำเนินการแบบ left-associative

    • >> ถูกกำหนดเป็น infixl 1 และ <i>> ถูกกำหนดเป็น infixl 4 ทำให้ <i>> จับกลุ่มแน่นกว่า >>
  • IO a และ a ของ Haskell อาจให้ความรู้สึกคล้าย asynchronous กับ synchronous

    • อย่างแรกจะคืนค่าเป็น promise/future ที่ต้องรอ
  • ในภาษาอื่น สามารถทำ IO ง่าย ๆ ได้ด้วยฟังก์ชันอย่าง console.log("abc")

    • มีข้อสงสัยว่ามันต่างจาก IO ของ Haskell อย่างไร
  • คนที่ยังไม่เคยลอง Haskell อาจรู้สึกว่า Haskell ที่ใช้งานจริงพร้อม GHC extensions นั้นซับซ้อนเกินไป

    • สิ่งนี้อาจทำให้ความสนใจใน Haskell ลดลง