การเขียนโปรแกรมแบบมุ่งเน้นทางรถไฟ
(fsharpforfunandprofit.com)- Railway Oriented Programming เป็นแนวทางที่ใช้จัดการเส้นทางความล้มเหลวที่เกิดซ้ำในแอปพลิเคชันจริง เช่น การตรวจสอบความถูกต้อง การล็อก และข้อผิดพลาดของเครือข่าย ภายในฟังก์ชันนัลไปป์ไลน์
- ประเด็นสำคัญคือการอธิบายข้อจำกัดของตัวอย่างที่เชื่อมต่อเฉพาะเส้นทางสำเร็จด้วย อุปมารางรถไฟ ทำให้ผู้ใช้ F# เข้าใจลำดับการไหลของข้อผิดพลาดได้ง่ายในเชิงภาพ
- ในมุมมองของ Haskell แนวคิดนี้ใกล้เคียงกับชนิด
Eitherและ Kleisli composition แต่เน้นที่สูตรสำเร็จในการจัดการข้อผิดพลาดและแพตเทิร์นการนำไปใช้ มากกว่าทฤษฎีนามธรรม - เพื่อการใช้งานจริง มีทั้งไลบรารี F# สำหรับ NuGet ชื่อ Chessie, ตัวอย่างบน GitHub ที่เปรียบเทียบ C# กับ F#, และตัวอย่างการประยุกต์ใช้กับ FizzBuzz
- แม้จะเป็นแนวทางที่มีประโยชน์ แต่ไม่ควรนำไปใช้เกินความจำเป็นกับทุกปัญหา และเนื่องจาก F# ไม่มี type class ไลบรารีตัวอย่างจึงนิยามฟังก์ชันที่ต้องใช้ขึ้นมาเอง
เอกสารประกอบการบรรยายและโค้ดตัวอย่าง
- หน้า Railway Oriented Programming เป็นแหล่งรวม สไลด์และโค้ด ของการบรรยายไว้ในที่เดียว
- จุดเริ่มต้นมาจากปัญหาที่ว่าตัวอย่างฟังก์ชันนัลโปรแกรมมิงมักสมมติว่ามีเพียง “happy path” ซึ่งไม่เพียงพอสำหรับการสร้างแอปพลิเคชันจริง
- แอปพลิเคชันที่แข็งแรงต้องรองรับทั้งการตรวจสอบความถูกต้อง การล็อก ข้อผิดพลาดเครือข่าย ข้อผิดพลาดของบริการ และสถานการณ์ยกเว้นอื่น ๆ
- เป้าหมายคือแนะนำวิธีจัดการข้อผิดพลาดเหล่านี้ด้วยแนวทางฟังก์ชันนัลที่สะอาด ผ่าน อุปมารางรถไฟ แบบสั้นและเข้าใจง่าย
- หัวข้อใกล้เคียงกันมีต่อในซีรีส์ recipe for a functional app
- โค้ดจริงดูได้จาก โปรเจกต์ GitHub ที่เปรียบเทียบแนวทาง ROP ของ C# และ F#
- แม้จะมีประโยชน์ต่อการจัดการข้อผิดพลาด แต่ก็มีคำเตือนไม่ให้ใช้แบบสุดโต่ง และแนะนำให้อ่าน “Against Railway-Oriented Programming” ควบคู่กัน
วิดีโอและสไลด์
- หัวข้อนี้ถูกนำเสนอในงาน NDC London 2014
- ยังมีวิดีโอของการบรรยายเดียวกันในเวอร์ชันอื่นเผยแพร่อีกด้วย
- มีสไลด์ของการบรรยายในงาน Functional Programming eXchange วันที่ 14 มีนาคม 2014
- สามารถดาวน์โหลดสไลด์ PowerPoint ได้จาก GitHub และมีการระบุว่าสามารถยืมไปใช้ได้อย่างอิสระ
Either โมนาดและ Kleisli composition
- สำหรับผู้ใช้ Haskell แนวทางนี้อาจดูเหมือนชนิด
Either - ที่นี่มีการปรับให้เฉพาะทาง โดยใช้รายการชนิดข้อผิดพลาดแบบกำหนดเองในกรณี
Left- ตัวอย่างชนิดคือ
type TwoTrack a b = Either [a] (b,[a])
- ตัวอย่างชนิดคือ
- สิ่งที่สร้างขึ้นใหม่ไม่ใช่แนวคิดเรื่องการจัดการข้อผิดพลาดโดยตัวมันเอง แต่ใกล้เคียงกับ อุปมารางรถไฟ ที่ใช้ในการอธิบายมากกว่า
- เหตุผลที่ไม่ยกคำศัพท์มาตรฐานของ Haskell ขึ้นนำมีความชัดเจน
- เอกสารนี้ไม่ใช่ บทสอนโมนาด แต่เน้นที่การแก้ปัญหาการจัดการข้อผิดพลาด
- ผู้ที่เริ่มใช้ F# จำนวนมากยังไม่คุ้นกับโมนาด ดังนั้นคำอธิบายที่เป็นภาพและดูไม่น่ากลัวจึงอาจเข้าใจได้ตรงกว่า
- การเรียก two-track type ที่มี
bindว่าเป็นโมนาดทันทีนั้นไม่แม่นยำ และไม่ได้ครอบคลุมถึงกฎของโมนาด Eitherเป็นเครื่องมือที่กว้างมากเกินไป ดังนั้นที่นี่จึงพยายามนำเสนอ สูตรสำเร็จ มากกว่าตัวเครื่องมือเอง
องค์ประกอบของสูตรสำเร็จในการจัดการข้อผิดพลาด
- แนวทางนี้เป็นชุดเทคนิคที่ครอบคลุมกว่าการบอกว่า “แค่ใช้
Eitherกับbindก็พอ” - เทคนิคที่รวมอยู่มีดังนี้
- ใช้ รายการชนิดข้อผิดพลาดแบบกำหนดเอง ทั้งฝั่งซ้ายและขวา แทนรูปแบบง่าย ๆ อย่าง
Either String a - รวมฟังก์ชันแบบ monadic เข้ากับไปป์ไลน์ด้วย
bind(>>=) - ใช้ Kleisli composition(
>=>) เพื่อประกอบฟังก์ชันแบบ monadic - รวมฟังก์ชันที่ไม่ใช่ monadic เข้ากับไปป์ไลน์ด้วย
map(fmap) - เนื่องจาก F# ไม่ใช้ IO monad จึงใช้
teeเพื่อรวมฟังก์ชันแบบ unit เข้ากับไปป์ไลน์ - แมป exception ให้เป็นกรณีข้อผิดพลาด
- ใช้
&&&เพื่อรวมฟังก์ชันแบบ monadic แบบขนานในกรณีอย่างการตรวจสอบความถูกต้อง - ใช้ประโยชน์จากชนิดข้อผิดพลาดแบบกำหนดเองใน domain-driven design
- ส่วนขยายอย่างการล็อก domain event และ compensating transaction
- ใช้ รายการชนิดข้อผิดพลาดแบบกำหนดเอง ทั้งฝั่งซ้ายและขวา แทนรูปแบบง่าย ๆ อย่าง
- เป้าหมายคือการสร้าง เทมเพลต ที่อเนกประสงค์พอจะใช้ได้แทบทุกสถานการณ์ แต่ก็มีข้อจำกัดพอที่จะบังคับให้เกิดสไตล์ที่สม่ำเสมอ
- หากรูปแบบการเขียนโค้ดบรรจบกันแทบเหลือแบบเดียว ผู้ที่มาดูแลต่อภายหลังก็จะเข้าใจโครงสร้างโค้ดได้รวดเร็วขึ้น
- วิธีนี้ไม่ใช่คำตอบเดียว แต่สามารถเป็นจุดเริ่มต้นที่ดีได้
- แม้แต่ในชุมชน Haskell เอง วิธีจัดการข้อผิดพลาดก็ยังไม่เป็นเอกภาพ ซึ่งอาจทำให้ผู้เริ่มต้นสับสนได้
วิธีนำไปใช้กับโค้ดของฉัน
- หากต้องการไลบรารี F# ที่เตรียมไว้แล้ว สามารถดู Chessie project ที่ใช้ร่วมกับ NuGet ได้
- ตัวอย่างเว็บเซอร์วิสที่ใช้เทคนิคนี้มีให้ใน GitHub project
- ตัวอย่างการนำแนวทาง ROP ไปใช้กับ FizzBuzz ดูได้ที่ Railway Oriented Programming: carbonated
- F# ไม่มี type class จึงไม่มีวิธีทั่วไปสำหรับนำโมนาดกลับมาใช้ซ้ำ
- FSharpX library มีแนวทางที่มีประโยชน์ แต่ไลบรารี
Rop.fsนิยามทุกฟังก์ชันขึ้นมาเองตั้งแต่ต้น - การแยกแบบนี้มีข้อดีคือไม่มีการพึ่งพาภายนอกเลย
อ่านเพิ่มเติม
- การนิยามโมนาดอย่างถูกต้องนั้นไม่พอเพียงหากแค่ทำ
bindและreturn - โมนาดเป็นโครงสร้างเชิงพีชคณิตที่ต้องเป็นไปตาม กฎของโมนาด และกฎเหล่านี้เกี่ยวข้องกับกฎของมอโนอยด์ในบางสถานการณ์
- หากสนใจ
Eitherและ Kleisli composition สามารถดูแหล่งข้อมูลต่อไปนี้- Monads in general
- The
Eithermonad - Kleisli categories and composition
- Comprehensive error handling approaches
2 ความคิดเห็น
Railway-Oriented Programming เพื่อสร้างโปรแกรมที่ปราศจากข้อบกพร่อง
ความคิดเห็นบน Hacker News
Elixir จัดการปัญหานี้ได้ค่อนข้างเรียบร้อยด้วยคีย์เวิร์ด/มาโคร
withมันรันฟังก์ชันจากบนลงล่าง เช่น
with {:ok, file_handle} <- File.open(filename), ... do {:ok, parsed} endและถ้าค่าที่คืนมาแต่ละค่าไม่ตรงกับแพตเทิร์นทางซ้าย ก็จะ return ก่อนกำหนด ด้วยค่านั้นดังนั้นแต่ละฟังก์ชันจึงไม่จำเป็นต้องถูกทำให้รับได้ทั้ง
{:ok, value}และ{:error, reason}; ฟังก์ชันรับเฉพาะค่าที่สนใจก็พอ แล้วปล่อยให้ pattern matching ของบล็อกwithจัดการการส่งต่อข้อผิดพลาดตัวอย่างเช่น ถ้า
File.openคืนค่า{:error, reason}ก็จะไม่รันIO.readและผลลัพธ์ของwithทั้งหมดจะเป็น{:error, reason}สุดท้ายคือเขียนเฉพาะเส้นทางสำเร็จ ส่วนเส้นทางล้มเหลวก็ให้ผู้เรียกนำไป match เองหากต้องการรู้สึกเหมือนเป็นการประดิษฐ์ exception handling ขึ้นมาใหม่ครึ่งหนึ่ง และถ้าตั้งชื่อว่า
elseแทนcatchก็แค่ทำให้ดูเหมือน exception handling น้อยลงเท่านั้นwhereของ Haskell และletของ Lispมันเหมือนเป็นการตั้ง precondition ของฟังก์ชัน และใน Elixir ยังสามารถใช้บล็อก
elseเพิ่มได้ด้วยIO.readล้มเหลว ใครเป็นคนปิด file handle ก็เป็นอีกปัญหาหนึ่งผู้เขียนยังเขียนบทความต่อเนื่องในอีกไม่กี่ปีต่อมาชื่อ Against Railway Oriented Programming: https://fsharpforfunandprofit.com/posts/against-railway-orie...
Railway Oriented Programming เป็นแนวคิดที่น่าสนใจและมีที่ให้ใช้ แต่ผมมองว่าต้องมีคำเตือนตัวโต ๆ กำกับไว้ด้วย ในทางปฏิบัติผมเห็นบ่อยว่ามันถูกใช้เพื่อคิดค้น exception handling แบบแย่ ๆ ขึ้นมาใหม่ ทั้งที่ถ้าเข้าใจและใช้ exception อย่างถูกต้อง ก็จะจัดการเงื่อนไขข้อผิดพลาดส่วนใหญ่ได้สะอาดและมีประสิทธิภาพกว่า
ข้อดีของ exception คือในกรณีส่วนใหญ่ ทางเลือกที่ปลอดภัยเป็นค่าเริ่มต้น เงื่อนไขข้อผิดพลาดคือสัญญาณว่าโค้ดไม่สามารถทำตามสเปกได้ และถ้ายังเดินหน้าต่อในสภาพนั้น ข้อมูลอาจเสียหายจากสมมติฐานที่ผิดได้ โดยพื้นฐานแล้ว exception จะทำการเก็บกวาดแล้วแพร่ต่อไปยังผู้เรียก แต่แนวทางอย่าง Railway Oriented Programming ต้องใช้ boilerplate จำนวนมากที่ลืมได้ง่ายและผิดพลาดได้ง่าย
อย่างไรก็ดี มันมีประโยชน์ในสองกรณี กรณีหนึ่งคือเมื่อต้องจัดการข้อผิดพลาดที่คาดหมายไว้ ซึ่งเฉพาะเจาะจงและนิยามชัดเจน ณ จุดที่เกิด เช่น validation อีกกรณีคือสภาพแวดล้อมที่ใช้ exception handling ไม่ได้ โค้ด asynchronous แบบ Promise ของ jQuery ในอดีตแทบจะใกล้เคียงกับการ implement Railway Oriented Programming แต่ใน JavaScript ยุคใหม่ที่มี
async/awaitก็กลับมาใช้ exception handling ได้อีกครั้งผลตอบแทนใหญ่ของ Railway Oriented Programming คือแค่ดู signature ของฟังก์ชันก็รู้ได้ทันทีว่าอาจเกิดข้อผิดพลาดอะไรบ้าง
boilerplate สามารถบรรเทาได้ขึ้นอยู่กับภาษา Haskell มี
donotation และใน F# ก็เขียนได้สะอาดมากด้วย result computation expression ตัวอย่างเช่นนิยามLoginError = InvalidUser | InvalidPwd | Unauthorized of AuthErrorแล้วประกอบ flow ด้วยResult.requireSome,Result.requireTrue,Result.mapErrorได้ในทางกลับกันก็สงสัยว่าข้อเสียของ exception จะบรรเทาได้ไหม อยากรู้ว่ามี linter หรือเครื่องมือ static analysis ที่ผสานกับ Java IDE แล้วแสดง exception ที่ยังไม่ถูกจับ ซึ่งอาจถูก throw จากบรรทัดโค้ดหนึ่ง ๆ โดยอัตโนมัติหรือไม่
https://demystifyfp.gitbook.io/fstoolkit-errorhandling/fstoo...
อย่างแรก หลายภาษาบังคับให้ต้องใช้ scope ที่ฝืนธรรมชาติและไม่จำเป็นเพื่อจับ exception เช่น ถ้าจะประกาศและ initialize ตัวแปรจากผลลัพธ์ของฟังก์ชันที่อาจ throw exception แล้วอยากใส่ค่าอื่นเมื่อ fail ก็ต้องแยกการประกาศกับการ initialize ออกจากกัน
ปัญหาที่ใหญ่กว่าคือ ควรจัดการเงื่อนไขข้อผิดพลาดใน ระดับต่ำที่สุด ที่มีบริบทเพียงพอจะจัดการได้อย่างถูกต้อง แต่ค่าเริ่มต้นของ exception คือ “โยนขึ้นไปจนถึงบนสุด” abstraction จึงรั่วได้ง่าย เช่น แค่เพิ่ม file cache ตัวหนึ่ง ฟังก์ชัน
HandleRequestระดับบนก็จู่ ๆ อาจ throw I/O exception ได้ผมมองว่าทุกอย่างที่ฟังก์ชันสามารถ return ได้ควรเป็นส่วนหนึ่งของ signature อย่างชัดเจน และผู้เรียกควรต้องจัดการอย่างชัดเจน หรืออย่างน้อยก็แสดงว่าโยนต่อขึ้นไป ภาษาไม่จำเป็นต้องบังคับ boilerplate จำนวนมากเพื่อสิ่งนี้
Resultของ Rust ทำ error handling ได้ถูกทางแม้แต่กรณีไฟล์หรือ record ไม่พบ ก็ขึ้นกับว่าคาดหมายไว้หรือไม่ ถ้าเป็น request ที่ค้นหาด้วย ID ที่ผู้ใช้ให้มา การค้นหานั้นเองก็เป็นการตรวจสอบ input จากผู้ใช้ แต่ถ้าเอาชื่อ blob ที่เก็บอยู่ใน database record ไปค้น storage แล้วไม่มี blob นั่นเป็นสถานการณ์ที่เหมาะจะจัดการเป็น exception
เส้นแบ่งระหว่าง error กับ exception ค่อนข้างพร่าเลือน และขึ้นกับทีมกับพื้นที่ปัญหา แต่ผมมองว่านี่เป็น rule of thumb ที่ใช้ได้
ในโค้ด C++ อาจจัดการได้โดยตรวจข้อมูลตั้งแต่ต้น ถ้าตรวจแล้วไม่ผ่านก็ทำให้แอปพลิเคชัน crash แล้วใช้สถานะข้อผิดพลาดที่พบมา debug และแก้โค้ดตรวจสอบช่วงต้น
เนื่องจากข้อมูลที่จัดการเป็นข้อมูลที่ค่อนข้างชัดเจนตาม รูปแบบข้อมูล CAD หรือ topology ทางเรขาคณิตที่รู้จัก จึงค่อนข้าง “ง่าย” ในการจัดการเงื่อนไขข้อผิดพลาด เพราะสามารถทำความเข้าใจได้ก่อนว่าข้อมูลที่ถูกต้องคืออะไร
พูดตรง ๆ ทั้งไซต์นี้เหมือนคลังสมบัติ ถึงไม่ได้คิดจะใช้ภาษาเชิงฟังก์ชัน มันก็ให้มุมมองอีกแบบ และส่วนตัวแล้วช่วยผมได้มาก แนะนำให้อ่านบทความอื่น ๆ ด้วย
ถึงอย่างนั้นผมก็คงไม่มีวันรู้ว่า monad คืออะไรกันแน่
https://fsharpforfunandprofit.com/series/understanding-parse...
สงสัยว่าข้ออ้างที่ว่า “การผลักการจัดการข้อผิดพลาดให้ออกไปไกลจากจุดเรียกใช้นั้นไม่ดี” ใช้ได้จริงหรือไม่ เพราะผู้เรียกน่าจะเป็นฝ่ายที่จัดการข้อผิดพลาดได้ดีที่สุด จึงควรจัดการเองแทนที่จะส่งต่อไปข้างล่างหรือเปล่า
ตัวอย่างเช่น ในโฟลว์
validate and-then update-db and-then send-emailถ้าvalidateล้มเหลว อาจส่งกลับไปให้ผู้เรียกได้โดยไม่มีปัญหา แต่ถ้าupdate-dbล้มเหลว ต้องตัดสินใจว่าจะ retry, ใช้บริการอื่น, ใส่กลับเข้า queue หรือแจ้งผู้ใช้ ส่วนกรณีsend-emailล้มเหลวก็เช่นกันสุดท้ายมันอาจกลายเป็นโครงสร้างแตกแขนงที่ซับซ้อนกว่าเดิม เช่น
ValidationErrorให้แจ้งผู้ใช้,DBErrorให้ retry หลัง 5 นาที หรือ retry ผ่าน remote แล้วค่อยแจ้ง,EmailFailedให้ใส่อีเมลกลับเข้า queue แล้วถือว่าสำเร็จ บางทีแบบนั้นก็อาจโอเคก็ได้คนที่ไม่ชอบ Go มักบ่นว่าต้องตรวจเงื่อนไขข้อผิดพลาดบ่อยเกินไป และ checked exception ของ Java ก็คล้ายกัน ถ้าใช้
Eitherจะไม่ถูกบังคับให้ตรวจ แต่จะตรวจเองหรือส่งต่อให้ผู้เรียกก็ได้คนที่ไม่ชอบ unchecked exception ของ Java มักบ่นว่าไม่รู้ว่าอะไรจะถูก throw เมื่อไร ถ้าใช้
Eitherสิ่งนี้จะถูกทำให้ชัดเจนประเด็นหลักคือเมื่อเกิดข้อผิดพลาด เราไม่รู้ว่าควรทำอะไร และ
Eitherก็เข้ากับความไม่รู้นั้นได้ดี ใน codebase Java ปัจจุบัน ถ้าLimit getUserLimit(User)ไม่พบข้อมูล มันอาจ throw exceptionNotFound, อาจคืนค่าnullหรืออาจคืนค่า default ก็ได้ ซึ่งก่อนจะขุดเข้าไปอ่านโค้ด เราไม่อาจรู้ได้ถ้าเป็น
Eitherผู้เรียกจะเชื่อถือได้โดยไม่ต้องอ่านโค้ดภายใน และสามารถแปลงเป็นเงื่อนไขล้มเหลวแบบอื่นได้ง่ายด้วยเครื่องมือในตัว เช่นorElse(null)หรือorElseThrow(...)นอกจากนี้Eitherยังเป็น expression จึงเหมาะกับการทำ abstraction ทำให้เขียน retry logic แบบทั่วไปได้ แทนที่จะมีโค้ด retry เฉพาะทางอยู่ภายในDbUpdatertry-catchของ Java แล้ว แต่tryซ้อนกัน,catch(DBError),sleep(5), การ retry ผ่าน remote, การ requeueEmailFailedError, และการแจ้งValidationErrorพัวพันกันจนดูแย่กว่ามากhttps://gist.github.com/Andrewp2/9d97bd213b061166d6df565ce26...
อาจมีใครเขียนเวอร์ชัน
try-catchที่ดีกว่านี้ได้ แต่ผมมองว่ามันด้อยกว่าเวอร์ชันแบบ railway ที่เสนอไว้มากผมชอบ DDD แต่โปรแกรมเมอร์ธรรมดาอย่างผมต้องการรอบการบำรุงรักษาที่สมเหตุสมผลและจัดการได้ สิ่งที่ทำให้ TDD ดีในโค้ดที่เปลี่ยนแปลงได้ คือมันช่วยลด ภาระทางความคิด ในการบำรุงรักษา
Railway-oriented programming สามารถพาบริบทจำนวนมากผ่านการแปลงหลายขั้นได้ และผมอยากหลีกเลี่ยงสถานการณ์ที่ปลายรางได้รับรถไฟที่บรรทุกบริบททางธุรกิจและระบบจำนวนมากซึ่งยังไม่พร้อม
ผมมองว่า DDD และ onion architecture ให้ข้อดีทั้งสองด้าน สุดท้ายก็จะมี railway เกิดขึ้นอยู่ดี แต่ไม่ได้ฝังอยู่ภายใน micro app หากอยู่ด้านนอกมากกว่า ประเด็นแบบ railway ควรถูกเขียนให้ชัดเจนและอาจยุ่งยากเล็กน้อยจะดีกว่า และถ้าทำได้ดี การตัดสินใจส่วนใหญ่แบบ railway คือ การตัดสินใจทางธุรกิจ ที่ไม่ควรเลี่ยงด้วยโครงสร้างโค้ดอันชาญฉลาด
การเขียนโปรแกรมแบบ monad นั้นยอดเยี่ยม แต่มีแรงยั่วยวนให้นำไปใช้เพื่อหลบเลี่ยงการตัดสินใจทางธุรกิจที่จำเป็น การใส่ความกำกวมไว้ใน type system อาจนำไปสู่สถานการณ์ที่ยากหรือเป็นไปไม่ได้ในภายหลัง
try/catchเฉพาะทางอยู่ภายในtry/catchขนาดใหญ่ตัวอย่างเช่น ภายในฟังก์ชัน
send-emailอาจเลือกจัดการข้อผิดพลาดชั่วคราวที่เกิดขึ้นเร็วมากเองได้ หากเชื่อมต่อได้ภายในเวลาที่อนุญาต ก็กลับเข้าสู่เส้นทางสำเร็จ มิฉะนั้นก็คืนกลับไปให้ผู้เรียกหากมี rule ที่ชัดเจน ก็คือการจัดการใด ๆ ที่ต้องอาศัย การแทรกแซงของมนุษย์ ต้องถูกห่อแล้วส่งขึ้นไปด้านบนเสมอ และแม้จะกลับเข้าสู่เส้นทางสำเร็จ ก็ควรทิ้ง warning log ไว้ด้วย
notify-userรับผิดชอบการแจ้งผู้ใช้แยกตามValidationErrorและDBError,try-update-dbรวมupdate-db, retry หลัง 5 นาที และ retry ผ่าน remote เข้าด้วยกัน ส่วนtry-send-emailสามารถใส่อีเมลกลับเข้า queue เมื่อผิดพลาด แล้วแปลงให้เป็นสำเร็จได้แบบนั้นระดับบนสุดจะยังคงเป็น
validate and-then try-update-db and-then try-send-email or-then notify-userดูเหมือนว่าได้ย้ายand/or/thenไปให้เข้ากับmap/bindได้ดีเว็บไซต์นี้เป็นหนึ่งในเว็บสอนเขียนโปรแกรมที่ดีที่สุดเท่าที่เคยเจอมา ในแง่ที่สอนแนวคิดที่เป็นรูปธรรมและใช้งานได้จริง โดยเฉพาะแนวคิด ทำให้สถานะที่ไม่ถูกต้องไม่สามารถถูกแทนค่าได้ ซึ่งผมชอบมาก และพยายามนำไปใช้ไม่ว่าจะเป็นภาษาใดก็ตาม แน่นอนว่าบางภาษาทำให้เรื่องนี้ง่ายกว่า
https://fsharpforfunandprofit.com/posts/designing-with-types...
หากใช้ การไหลของข้อมูล แทนการเรียก/คืนค่า ปัญหานี้จะแก้ได้ดีกว่า และส่วนใหญ่จะหายไปเอง
ในแบบเรียก/คืนค่า เราต้องคืนค่าอะไรสักอย่าง ดังนั้นถ้าไม่มีค่าจะคืนเพราะเกิดข้อผิดพลาด ก็ต้องคืนข้อผิดพลาด หรือคืนทั้งข้อผิดพลาดกับค่าปกติพร้อมกันเหมือน Go สิ่งนี้ทำให้เส้นทางความสำเร็จปนเปื้อน การร้อยส่งการประมวลผลที่เหลือผ่านคอนเทนเนอร์แบบ polymorphic ช่วยให้ดีขึ้นเล็กน้อย แต่ปัญหายังอยู่
ในการไหลของข้อมูล แค่ไม่ต้องส่งอะไรไปยังขั้นตอนฟิลเตอร์ถัดไป เส้นทางความสำเร็จจึงไม่ถูกกระทบเลย ส่วนข้อผิดพลาดก็ส่งไปที่อย่างเช่น standard error output และสามารถรวมศูนย์ในระดับแอปพลิเคชันได้
อาจฟังดูดีเกินไป แต่ที่ Wunderlist เคยใช้จริงและทำงานได้ดี เทคนิคเดียวกันที่ใช้ตอน “ไม่มีค่า” ยังนำไปใช้ได้ตรง ๆ กับตอนที่ “ยังไม่มีค่า” นั่นคือ การประมวลผลแบบอะซิงโครนัส
ถ้าเกิดข้อผิดพลาดใน data stream แบบง่าย ๆ แล้วไม่มีข้อมูลส่งไปยังขั้นต่อไปจึงหยุด ก็ถือว่าโอเค แต่ถ้าที่
sqrt(-1)แล้ว “แครช” ทันทีและข้ามไปยัง error output หรือ logging ในบาง use case จะลำบากเมื่ออยากขยายโดเมนให้ค่าที่แตกต่างสำหรับจำนวนลบตรงนี้ ชนิดข้อผิดพลาดแบบชัดเจน อย่าง
Maybeมีประโยชน์ เราเขียนเส้นทางความสำเร็จเหมือนไม่มีข้อผิดพลาด แต่ใช้ตัวดำเนินการอีกแบบในการ compose ฟังก์ชัน ถ้าอยากกู้คืนจากข้อผิดพลาด ก็จัดการทั้งสองเส้นทางตรงจุดนั้น และหลังจากนั้นอาจไม่อนุญาตให้มีข้อผิดพลาดอีกก็ได้ จุดที่ดีเป็นพิเศษคือ เมื่อต้องดึงค่าจริงออกจาก “ค่าที่อาจมีหรือไม่มีก็ได้” แบบนามธรรม เราต้องจัดการทั้งสองเส้นทางอย่างสง่างามการใช้ชนิดข้อผิดพลาดแบบชัดเจนยังทำให้หลุดจากทวิภาวะสำเร็จ/ผิดพลาดได้ด้วย สามารถแสดงสถานะอย่าง “ไม่มีค่าแต่...”, “มีค่าแต่...” ได้ และยังสะสมข้อผิดพลาดจากงานหลายงานที่ประมวลผลแบบขนานแล้วรวบรวมเป็นผลลัพธ์ได้ด้วย ยิ่งไปกว่านั้น ยัง encode ความไม่กำหนดแน่นอนเพื่อนำไปใช้กับงานอย่างการท่องกราฟได้
สามารถพกพา state ในแบบ pure เหมือนที่โปรแกรมเมอร์ Haskell ทำกันได้ และ
IOmonad อันเลื่องชื่อของ Haskell นั้นไม่ได้แทนเส้นทางจริงเท่าไรนัก แต่ใกล้เคียงกับกลไกที่จำกัดจุดสัมผัสกับโลกภายนอกไว้ที่mainมากกว่า ทำเพื่อความบริสุทธิ์ แต่ผมมองว่าความบริสุทธิ์ไม่ใช่คุณค่าที่ต้องไล่ตามอย่างสัมบูรณ์เสมอไปเทคนิคนี้หลากหลายกว่ารางรถไฟธรรมดามาก และให้วิธีที่สะอาดในการเขียนเฉพาะเส้นทางความสำเร็จพร้อมรักษาความบริสุทธิ์ไว้ ยังเป็นประโยชน์ต่อการวิเคราะห์สถิติกแบบอิงสมการและ unit test แบบไม่ต้อง mock ด้วย อยากให้ภาษา mainstream มากกว่านี้มี กล่องเครื่องมือ monad แบบนี้ให้ดีกว่านี้
ลิงก์ที่เกี่ยวข้อง: Railway Oriented Programming - https://news.ycombinator.com/item?id=31404643 - พฤษภาคม 2022, Railway-Oriented Programming (2015) - https://news.ycombinator.com/item?id=17337155 - มิถุนายน 2018, Railway oriented programming - https://news.ycombinator.com/item?id=11955917 - มิถุนายน 2016, Railway Oriented Programming - https://news.ycombinator.com/item?id=9166943 - มีนาคม 2015, Railway-oriented programming - https://news.ycombinator.com/item?id=7887134 - มิถุนายน 2014
เพิ่มเติม: What is railway oriented programming? (2020) - https://news.ycombinator.com/item?id=34245639 - มกราคม 2023, Railway Oriented Programming in Elixir (2015) - https://news.ycombinator.com/item?id=11958578 - มิถุนายน 2016
สไตล์นี้แพร่กระจายเหมือน monad อื่น ๆ ทำให้ business logic ทั้งหมดมีหน้าตาแบบนี้ ถ้ารับได้ก็อ่านต่อได้
exception ก็คือข้อผิดพลาดที่ไม่คาดคิดตามชื่อ และจะคลาย stack กลับไปจนถึงจุดที่จับ พร้อมจัดการ cleanup ที่จำเป็นโดยอัตโนมัติ เหมาะกับข้อผิดพลาดที่เกิดนอกขอบเขตของฟังก์ชันธุรกิจ เช่น I/O error หรือหน่วยความจำไม่พอ
ถ้าต้องการตรวจสอบความถูกต้องของข้อมูล ก็ไม่จำเป็นต้องพึ่ง exception handler หรือ monad แค่ทำให้ผลลัพธ์ของ functional pipeline เป็นข้อมูลก็พอ ถ้ามี validation error ก็รวบรวมเป็นชุด แล้วค่อยตรวจหลัง pipeline จบ โดยทั่วไปมีแนวโน้มสูงว่าเราไม่อยากให้ล้มเหลวตั้งแต่ข้อผิดพลาดแรก
ถ้าใน pipeline ต้องมี side effect ก็อย่าทำ ให้บรรยาย effect นั้นเป็นข้อมูล แล้วค่อย execute ภายหลัง หากต้องออกก่อนกลางทาง ให้แบ่ง pipeline ณ จุดนั้น จัดการผลลัพธ์ แล้วใส่เข้าไปใน pipeline อื่น
หรือจะใช้ interceptor chain เพื่อให้ตัดสินใจว่าจะดำเนินเส้นทางความสำเร็จต่อหรือไม่ก็ได้ ถ้าภาษารองรับก็ใช้ pattern matching ได้ สิ่งสำคัญคือ การตัดสินใจว่าจะข้ามบางส่วนของ pipeline หรือไม่นั้นเกิดขึ้นนอกฟังก์ชันธุรกิจ และเมื่อเป็นเช่นนั้น ฟังก์ชันธุรกิจก็สนใจแค่ การแปลงข้อมูล แบบ pure เท่านั้น จึงมีโอกาสนำกลับมาใช้ซ้ำได้สูงขึ้น
แค่หัวข้อนี้ถูกหยิบขึ้นมาพูดคุยกันอีกครั้งก็ถือว่าดีแล้ว ผมอ่านเว็บนี้อยู่ 1 ปีและทำตามตัวอย่างไปด้วย และมันเป็นหนึ่งใน แหล่งข้อมูล F# ที่ดีที่สุด
แค่แนวคิดที่เว็บนี้พูดถึงก็ช่วยให้คิดแบบ functional ในทุกภาษาได้แล้ว
เป็นเว็บเก่า แต่ถ้าตอนนี้กลับมาได้รับความสนใจ ก็สงสัยว่าแปลว่า F# กำลังได้แรงส่งหรือเปล่า และก็สงสัยด้วยว่าโปรแกรมเมอร์ภาษาอื่น ๆ ใช้การจัดการข้อผิดพลาดแบบทางรถไฟเหมือน Rust กันไหม
แต่เหตุผลที่ยังย้ายไป F# ไม่ได้คือ เวลาส่วนใหญ่หมดไปกับการปรับแต่งเดสก์ท็อปฟรอนต์เอนด์ ในส่วนนี้ดูเหมือน F# จะไม่ได้ให้ข้อได้เปรียบมากนัก เดสก์ท็อปฟรอนต์เอนด์ที่พร้อมใช้จริงส่วนใหญ่ไม่ใช่ F# แบบเนทีฟ แต่เป็นโค้ดเชิงวัตถุที่มีฐานเป็น C# และเชื่อมกับ F# ด้วยโค้ดกาวบาง ๆ
พอจำเป็นต้องปรับแต่ง UI ที่ซับซ้อนขึ้นแม้แต่นิดเดียว ก็ต้องกลับไปสู่โลกเชิงวัตถุอีก และถ้าเป็นแบบนั้น ผมมองว่าทำใน C# ไปเลยดีกว่า โดยส่วนตัว ถ้า F# จะได้แรงส่ง ก็จำเป็นต้องมี เฟรมเวิร์ก UI แบบเนทีฟ
Tและ enum เฉพาะที่นิยามเงื่อนไขของผลลัพธ์ได้ถ้ารีบ ๆ จะใช้
std::pairก็ได้ หรือจะส่งข้อความข้อผิดพลาดกลับมาในสตริง แล้วถ้าสตริงไม่ว่างก็จบก่อนกำหนดก็ได้ ถ้าทุกฟังก์ชันส่งกลับชนิดแบบนั้น ก็สามารถ bubble up ข้อความข้อผิดพลาดขึ้นไปถึงระดับบนสุดได้ชอบที่บทความนี้กลับมาโผล่อีกทุก ๆ ไม่กี่ปี เป็นบทความและการนำเสนอที่ยอดเยี่ยมจริง ๆ
ที่น่าสนใจคือทุกครั้งที่กลับมาอ่าน ผมอยู่ในจุดที่ต่างออกไปในเส้นทางสายเทคนิคของตัวเอง ทั้งในแง่ฝีมือหรือปรัชญา ทำให้เห็นมันต่างออกไปทุกครั้ง