4 คะแนน โดย GN⁺ 2023-08-20 | 2 ความคิดเห็น | แชร์ทาง WhatsApp
  • 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 นิยามทุกฟังก์ชันขึ้นมาเองตั้งแต่ต้น
  • การแยกแบบนี้มีข้อดีคือไม่มีการพึ่งพาภายนอกเลย

อ่านเพิ่มเติม

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

 
GN⁺ 2023-08-20
ความคิดเห็นบน 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 เองหากต้องการ

    • ถ้า “return ก่อนกำหนดสำหรับสิ่งที่ไม่ match” แล้วเมื่อค่าเหล่านั้นเป็นคนละ type กันทั้งหมดจะเป็นอย่างไร ก็สงสัยอยู่ ถ้าเป็น dynamic typing ก็อาจต้องดูว่าเป็น type อะไรแล้วเดาว่าล้มเหลวตรงไหนไม่ใช่หรือ
      รู้สึกเหมือนเป็นการประดิษฐ์ exception handling ขึ้นมาใหม่ครึ่งหนึ่ง และถ้าตั้งชื่อว่า else แทน catch ก็แค่ทำให้ดูเหมือน exception handling น้อยลงเท่านั้น
    • เท่าที่รู้ รูปแบบนี้ได้รับอิทธิพลมาจาก where ของ Haskell และ let ของ Lisp
      มันเหมือนเป็นการตั้ง precondition ของฟังก์ชัน และใน Elixir ยังสามารถใช้บล็อก else เพิ่มได้ด้วย
    • สงสัยว่าสิ่งนี้ดีกว่า exception อย่างไร และถ้า 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 ได้อีกครั้ง

    • สิ่งที่น่าเสียดายใน exception ของภาษากระแสหลักคือการจัดการนี้เกิดขึ้นแบบ มองไม่เห็น ไม่ใช่เรื่องง่ายที่จะรู้ตอนคอมไพล์ว่าในฟังก์ชันหนึ่ง ๆ อาจเกิด exception อะไรได้บ้าง
      ผลตอบแทนใหญ่ของ Railway Oriented Programming คือแค่ดู signature ของฟังก์ชันก็รู้ได้ทันทีว่าอาจเกิดข้อผิดพลาดอะไรบ้าง
      boilerplate สามารถบรรเทาได้ขึ้นอยู่กับภาษา Haskell มี do notation และใน 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...
    • ผมมองว่าวิธี implement exception แบบดั้งเดิมค่อนข้างแย่
      อย่างแรก หลายภาษาบังคับให้ต้องใช้ scope ที่ฝืนธรรมชาติและไม่จำเป็นเพื่อจับ exception เช่น ถ้าจะประกาศและ initialize ตัวแปรจากผลลัพธ์ของฟังก์ชันที่อาจ throw exception แล้วอยากใส่ค่าอื่นเมื่อ fail ก็ต้องแยกการประกาศกับการ initialize ออกจากกัน
      ปัญหาที่ใหญ่กว่าคือ ควรจัดการเงื่อนไขข้อผิดพลาดใน ระดับต่ำที่สุด ที่มีบริบทเพียงพอจะจัดการได้อย่างถูกต้อง แต่ค่าเริ่มต้นของ exception คือ “โยนขึ้นไปจนถึงบนสุด” abstraction จึงรั่วได้ง่าย เช่น แค่เพิ่ม file cache ตัวหนึ่ง ฟังก์ชัน HandleRequest ระดับบนก็จู่ ๆ อาจ throw I/O exception ได้
      ผมมองว่าทุกอย่างที่ฟังก์ชันสามารถ return ได้ควรเป็นส่วนหนึ่งของ signature อย่างชัดเจน และผู้เรียกควรต้องจัดการอย่างชัดเจน หรืออย่างน้อยก็แสดงว่าโยนต่อขึ้นไป ภาษาไม่จำเป็นต้องบังคับ boilerplate จำนวนมากเพื่อสิ่งนี้
    • ผมคิดว่า Rust แสดงให้เห็นได้ดีว่า Railway Oriented Programming ที่มี syntax support ระดับ first-class สามารถ ลด boilerplate ได้แทบหมด โดยส่วนตัวแล้ว type/trait Result ของ Rust ทำ error handling ได้ถูกทาง
    • ความต่างระหว่าง error-as-value กับ exception มีประโยชน์ที่สุดตอนอธิบาย ข้อผิดพลาดเฉพาะโดเมน ข้อผิดพลาดของโดเมนควรแสดงเป็นค่า ส่วนปัญหาที่ไม่เกี่ยวกับโดเมน เช่น ฐานข้อมูลเชื่อมต่อไม่ได้ ใช้ exception จะเป็นธรรมชาติกว่า
      แม้แต่กรณีไฟล์หรือ record ไม่พบ ก็ขึ้นกับว่าคาดหมายไว้หรือไม่ ถ้าเป็น request ที่ค้นหาด้วย ID ที่ผู้ใช้ให้มา การค้นหานั้นเองก็เป็นการตรวจสอบ input จากผู้ใช้ แต่ถ้าเอาชื่อ blob ที่เก็บอยู่ใน database record ไปค้น storage แล้วไม่มี blob นั่นเป็นสถานการณ์ที่เหมาะจะจัดการเป็น exception
      เส้นแบ่งระหว่าง error กับ exception ค่อนข้างพร่าเลือน และขึ้นกับทีมกับพื้นที่ปัญหา แต่ผมมองว่านี่เป็น rule of thumb ที่ใช้ได้
    • นอกจากข้อผิดพลาดระดับระบบแล้ว อยากรู้ตัวอย่างของสถานะข้อผิดพลาดที่ไม่ใช่ “ข้อผิดพลาดที่คาดหมายไว้ ซึ่งเฉพาะเจาะจงและนิยามชัดเจน และควรจัดการ ณ จุดที่เกิด” อยากเข้าใจว่าเหตุใด exception จึงดีกว่าในกรณีนั้น เป็นเพราะลักษณะของปัญหาเอง หรือเพราะข้อจำกัดของ runtime
      ในโค้ด C++ อาจจัดการได้โดยตรวจข้อมูลตั้งแต่ต้น ถ้าตรวจแล้วไม่ผ่านก็ทำให้แอปพลิเคชัน crash แล้วใช้สถานะข้อผิดพลาดที่พบมา debug และแก้โค้ดตรวจสอบช่วงต้น
      เนื่องจากข้อมูลที่จัดการเป็นข้อมูลที่ค่อนข้างชัดเจนตาม รูปแบบข้อมูล CAD หรือ topology ทางเรขาคณิตที่รู้จัก จึงค่อนข้าง “ง่าย” ในการจัดการเงื่อนไขข้อผิดพลาด เพราะสามารถทำความเข้าใจได้ก่อนว่าข้อมูลที่ถูกต้องคืออะไร
  • พูดตรง ๆ ทั้งไซต์นี้เหมือนคลังสมบัติ ถึงไม่ได้คิดจะใช้ภาษาเชิงฟังก์ชัน มันก็ให้มุมมองอีกแบบ และส่วนตัวแล้วช่วยผมได้มาก แนะนำให้อ่านบทความอื่น ๆ ด้วย

    • ซีรีส์ที่สร้าง parser combinator ตั้งแต่ศูนย์เป็นหนึ่งในแหล่งข้อมูลที่มีค่าที่สุดเท่าที่เคยอ่านและลองทำตาม แนวคิดและกลไกจำนวนมากจากตรงนั้นมีประโยชน์อย่างมหาศาลเวลาจัดการภาษาที่มี type ในสไตล์ functional
      ถึงอย่างนั้นผมก็คงไม่มีวันรู้ว่า 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 แล้วถือว่าสำเร็จ บางทีแบบนั้นก็อาจโอเคก็ได้

    • การจัดการข้อผิดพลาดแบบ monad เป็นแนวทางที่ได้ข้อดีจากทั้งสองฝั่ง
      คนที่ไม่ชอบ Go มักบ่นว่าต้องตรวจเงื่อนไขข้อผิดพลาดบ่อยเกินไป และ checked exception ของ Java ก็คล้ายกัน ถ้าใช้ Either จะไม่ถูกบังคับให้ตรวจ แต่จะตรวจเองหรือส่งต่อให้ผู้เรียกก็ได้
      คนที่ไม่ชอบ unchecked exception ของ Java มักบ่นว่าไม่รู้ว่าอะไรจะถูก throw เมื่อไร ถ้าใช้ Either สิ่งนี้จะถูกทำให้ชัดเจน
      ประเด็นหลักคือเมื่อเกิดข้อผิดพลาด เราไม่รู้ว่าควรทำอะไร และ Either ก็เข้ากับความไม่รู้นั้นได้ดี ใน codebase Java ปัจจุบัน ถ้า Limit getUserLimit(User) ไม่พบข้อมูล มันอาจ throw exception NotFound, อาจคืนค่า null หรืออาจคืนค่า default ก็ได้ ซึ่งก่อนจะขุดเข้าไปอ่านโค้ด เราไม่อาจรู้ได้
      ถ้าเป็น Either ผู้เรียกจะเชื่อถือได้โดยไม่ต้องอ่านโค้ดภายใน และสามารถแปลงเป็นเงื่อนไขล้มเหลวแบบอื่นได้ง่ายด้วยเครื่องมือในตัว เช่น orElse(null) หรือ orElseThrow(...) นอกจากนี้ Either ยังเป็น expression จึงเหมาะกับการทำ abstraction ทำให้เขียน retry logic แบบทั่วไปได้ แทนที่จะมีโค้ด retry เฉพาะทางอยู่ภายใน DbUpdater
    • ลองนำตรรกะนี้ไปเขียนในสไตล์ try-catch ของ Java แล้ว แต่ try ซ้อนกัน, catch(DBError), sleep(5), การ retry ผ่าน remote, การ requeue EmailFailedError, และการแจ้ง 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 ไว้ด้วย
    • มันอาจจบลงเหมือนตัวอย่างที่ยกมาได้ แต่ก็สามารถจัดองค์ประกอบต่างออกไปโดยยังคง pipeline ระดับบนสุดแบบเดิมไว้
      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 เคยใช้จริงและทำงานได้ดี เทคนิคเดียวกันที่ใช้ตอน “ไม่มีค่า” ยังนำไปใช้ได้ตรง ๆ กับตอนที่ “ยังไม่มีค่า” นั่นคือ การประมวลผลแบบอะซิงโครนัส

    • สงสัยว่านี่แตกต่างโดยพื้นฐานจากการคืนข้อมูลหรือโยน exception เพื่อให้ไปถูกจับที่อื่นหรือไม่
    • ถ้ามีตัวอย่างโค้ดช่วยอธิบายก็น่าจะดี
    • วิธีนั้นก็มีข้อเสีย และโดยเฉพาะรู้สึกว่ามีข้อจำกัด การจัดการ exception โดยเฉพาะ exception แบบอะซิงโครนัส อาจทำให้การกู้คืนจากข้อผิดพลาดยากขึ้น
      ถ้าเกิดข้อผิดพลาดใน data stream แบบง่าย ๆ แล้วไม่มีข้อมูลส่งไปยังขั้นต่อไปจึงหยุด ก็ถือว่าโอเค แต่ถ้าที่ sqrt(-1) แล้ว “แครช” ทันทีและข้ามไปยัง error output หรือ logging ในบาง use case จะลำบากเมื่ออยากขยายโดเมนให้ค่าที่แตกต่างสำหรับจำนวนลบ
      ตรงนี้ ชนิดข้อผิดพลาดแบบชัดเจน อย่าง Maybe มีประโยชน์ เราเขียนเส้นทางความสำเร็จเหมือนไม่มีข้อผิดพลาด แต่ใช้ตัวดำเนินการอีกแบบในการ compose ฟังก์ชัน ถ้าอยากกู้คืนจากข้อผิดพลาด ก็จัดการทั้งสองเส้นทางตรงจุดนั้น และหลังจากนั้นอาจไม่อนุญาตให้มีข้อผิดพลาดอีกก็ได้ จุดที่ดีเป็นพิเศษคือ เมื่อต้องดึงค่าจริงออกจาก “ค่าที่อาจมีหรือไม่มีก็ได้” แบบนามธรรม เราต้องจัดการทั้งสองเส้นทางอย่างสง่างาม
      การใช้ชนิดข้อผิดพลาดแบบชัดเจนยังทำให้หลุดจากทวิภาวะสำเร็จ/ผิดพลาดได้ด้วย สามารถแสดงสถานะอย่าง “ไม่มีค่าแต่...”, “มีค่าแต่...” ได้ และยังสะสมข้อผิดพลาดจากงานหลายงานที่ประมวลผลแบบขนานแล้วรวบรวมเป็นผลลัพธ์ได้ด้วย ยิ่งไปกว่านั้น ยัง encode ความไม่กำหนดแน่นอนเพื่อนำไปใช้กับงานอย่างการท่องกราฟได้
      สามารถพกพา state ในแบบ pure เหมือนที่โปรแกรมเมอร์ Haskell ทำกันได้ และ IO monad อันเลื่องชื่อของ 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 กันไหม

    • ผมมีพื้นฐาน C# สำหรับเดสก์ท็อปแอปพลิเคชันค่อนข้างมาก และยิ่งเวลาผ่านไป สไตล์การเขียน C# ของผมก็ยิ่งเป็น functional มากขึ้น ใช้ Linq เยอะ ใช้คลาสที่เปลี่ยนแปลงไม่ได้ และใช้ชนิดค่าที่ส่งกลับซึ่งใส่ข้อมูลข้อผิดพลาดแบบละเอียดได้
      แต่เหตุผลที่ยังย้ายไป F# ไม่ได้คือ เวลาส่วนใหญ่หมดไปกับการปรับแต่งเดสก์ท็อปฟรอนต์เอนด์ ในส่วนนี้ดูเหมือน F# จะไม่ได้ให้ข้อได้เปรียบมากนัก เดสก์ท็อปฟรอนต์เอนด์ที่พร้อมใช้จริงส่วนใหญ่ไม่ใช่ F# แบบเนทีฟ แต่เป็นโค้ดเชิงวัตถุที่มีฐานเป็น C# และเชื่อมกับ F# ด้วยโค้ดกาวบาง ๆ
      พอจำเป็นต้องปรับแต่ง UI ที่ซับซ้อนขึ้นแม้แต่นิดเดียว ก็ต้องกลับไปสู่โลกเชิงวัตถุอีก และถ้าเป็นแบบนั้น ผมมองว่าทำใน C# ไปเลยดีกว่า โดยส่วนตัว ถ้า F# จะได้แรงส่ง ก็จำเป็นต้องมี เฟรมเวิร์ก UI แบบเนทีฟ
    • ใน C++ ก็ใช้แนวทางแบบนั้นเหมือนกัน กำหนด ชนิดค่าที่ส่งกลับแบบเทมเพลต ที่สามารถ parameterize ด้วยชนิดค่าที่ส่งกลับ T และ enum เฉพาะที่นิยามเงื่อนไขของผลลัพธ์ได้
      ถ้ารีบ ๆ จะใช้ std::pair ก็ได้ หรือจะส่งข้อความข้อผิดพลาดกลับมาในสตริง แล้วถ้าสตริงไม่ว่างก็จบก่อนกำหนดก็ได้ ถ้าทุกฟังก์ชันส่งกลับชนิดแบบนั้น ก็สามารถ bubble up ข้อความข้อผิดพลาดขึ้นไปถึงระดับบนสุดได้
  • ชอบที่บทความนี้กลับมาโผล่อีกทุก ๆ ไม่กี่ปี เป็นบทความและการนำเสนอที่ยอดเยี่ยมจริง ๆ
    ที่น่าสนใจคือทุกครั้งที่กลับมาอ่าน ผมอยู่ในจุดที่ต่างออกไปในเส้นทางสายเทคนิคของตัวเอง ทั้งในแง่ฝีมือหรือปรัชญา ทำให้เห็นมันต่างออกไปทุกครั้ง

    • ชอบเนื้อหาแบบนี้มาก สมัยแรก ๆ ผมเคยดูงานพูด Python ชื่อ “so you want to be a python expert” ทุก 6 เดือนอยู่หลายปี และทุกครั้งที่ดูก็เข้าใจได้มากขึ้น ถ้ากลับไปดูตอนนี้ก็คงยังมีอะไรใหม่ให้ได้เรียนรู้อีก