Haskell: ภาษาขั้นตอนที่ยอดเยี่ยม
(entropicthoughts.com)การจัดการ side effect ให้เป็นค่าชั้นหนึ่ง
- ใน Haskell ผลข้างเคียง (เช่น การสร้างเลขสุ่ม การพิมพ์ออกจอ ฯลฯ) ถูกปฏิบัติราวกับเป็น “ค่าชั้นหนึ่ง (first class value)”
- กล่าวคือ การเรียกฟังก์ชันที่สร้างผลข้างเคียงอย่าง
randomRIO(1, 6)ไม่ได้ให้ผลลัพธ์สุดท้ายกลับมาทันที แต่คืนค่าเป็น “อ็อบเจ็กต์ที่อธิบายการกระทำที่จะถูกรันในภายหลัง” - อ็อบเจ็กต์นี้จะสร้างค่าสุ่มเมื่อมันถูกรันจริง แต่ก่อนหน้านั้นมันเป็นเพียงแผนการทำงานเท่านั้น
- ชนิดอย่าง
IO Intหมายถึง “การกระทำที่เมื่อรันจริงแล้วจะสร้าง Int ออกมา” โดยจะไม่ถูกรันทันทีตอนเรียก แต่จะรอจนถึงเวลาที่จำเป็นภายหลัง - ด้วยคุณสมบัตินี้ ต่างจากภาษาขั้นตอนแบบดั้งเดิมที่มองว่า “การเรียกฟังก์ชัน = การรันทันที” ใน Haskell เราสามารถประกอบผลข้างเคียงเข้าด้วยกันแล้วค่อยรันจริงในภายหลังได้
ทำความเข้าใจ do blocks ให้หายลึกลับ
doblock ไม่ใช่ไวยากรณ์มหัศจรรย์ แต่แท้จริงแล้วประกอบขึ้นจากโอเปอเรเตอร์สองตัวที่ใช้เชื่อมโยง (bind) ผลข้างเคียงและรันตามลำดับ (then)
then
- โอเปอเรเตอร์
*>จะรันผลข้างเคียงทางซ้ายก่อน ทิ้งค่าผลลัพธ์ของมัน แล้วจึงรันผลข้างเคียงทางขวาต่อ - ตัวอย่างเช่น
putStr "hello" *> putStrLn "world"จะสร้างการกระทำIO ()หนึ่งเดียวที่รวมการพิมพ์ทั้งสองครั้งให้เกิดขึ้นตามลำดับ - เมื่อเขียนหลายบรรทัดใน
doblock ภายในก็อาศัยโอเปอเรเตอร์การรันตามลำดับแบบนี้
bind
- โอเปอเรเตอร์
>>=มีหน้าที่รันผลข้างเคียงทางซ้าย แล้วส่งค่าที่ได้ไปยังฟังก์ชันทางขวา - ตัวอย่าง:
randomRIO(1, 6) >>= print_sideจะสร้างผลข้างเคียงที่ส่งผลลัพธ์ของลูกเต๋าไปให้print_sideเพื่อพิมพ์ออกมา - ใน
doblock รูปแบบ<-คือแนวคิดที่ใช้เขียนโอเปอเรเตอร์นี้ให้สะดวกขึ้น
โอเปอเรเตอร์สองตัวคือทั้งหมดของ do block
- สุดท้ายแล้ว
doblock ถูกสร้างขึ้นจากโอเปอเรเตอร์สองตัวนี้คือ*>และ>>= - แม้จะนิยมใช้ไวยากรณ์
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 ความคิดเห็น
พอไถ Reddit ก็เห็นโฆษณาเยอะเหมือนกัน.. แต่แค่ชื่อก็ทำให้รู้สึกมีอุปสรรคทางจิตใจนิดหน่อยแล้ว
ให้ความรู้สึกว่าเป็นภาษาที่ทั้งยากและทรงพลังมาก..
ความคิดเห็นจาก Hacker News
ระบบประเภทของ Haskell มีความซับซ้อนเมื่อเทียบกับภาษายอดนิยมอื่น ๆ โดยเฉพาะโอเปอเรเตอร์อย่าง
*>,<*>,<*ที่ทำให้เส้นโค้งการเรียนรู้สูงขึ้นตลอดทั้งโค้ดเบส>>=และ>>ใหม่เพื่อรักษาประสิทธิภาพการทำงานHaskell ช่วยพัฒนาการเขียนโปรแกรมเชิงคำสั่งให้ดีขึ้น
เวอร์ชัน generalized ของ
traverse/mapMมีประโยชน์มาก เพราะไม่ได้ทำงานได้แค่กับลิสต์ แต่ทำงานได้กับทุกประเภทTraversabletraverse :: Applicative f => (a -> f b) -> t a -> f (t b)Haskell มี monad ที่ทรงพลัง ซึ่งทำให้ Haskell มีความเป็นเชิงกระบวนวิธีมากขึ้น
doได้ซอฟต์แวร์ที่เขียนด้วย Haskell มีอย่างเช่น ImplicitCAD
โค้ดของ Haskell อ่านได้คล้ายภาษาขั้นตอน แต่ก็ยังให้ข้อดีเมื่อทำงานกับฟังก์ชันที่มีผลข้างเคียง
>>เป็นชื่อเก่าของ<i>>และทั้งสองโอเปอเรเตอร์เป็นตัวดำเนินการแบบ left-associative>>ถูกกำหนดเป็นinfixl 1และ<i>>ถูกกำหนดเป็นinfixl 4ทำให้<i>>จับกลุ่มแน่นกว่า>>IO aและaของ Haskell อาจให้ความรู้สึกคล้าย asynchronous กับ synchronousในภาษาอื่น สามารถทำ IO ง่าย ๆ ได้ด้วยฟังก์ชันอย่าง
console.log("abc")คนที่ยังไม่เคยลอง Haskell อาจรู้สึกว่า Haskell ที่ใช้งานจริงพร้อม GHC extensions นั้นซับซ้อนเกินไป