- ความยืดเยื้อของการจัดการข้อผิดพลาด ในภาษา Go เป็นหนึ่งในข้อร้องเรียนหลักของผู้ใช้มาอย่างยาวนาน
- มีการหารือและทดลอง ข้อเสนอปรับปรุงไวยากรณ์หลายแบบ (เช่น check/handle, try, ตัวดำเนินการ
? เป็นต้น) แต่ทั้งหมดถูกปัดตกเพราะยังไม่มีฉันทามติจากชุมชนมากเพียงพอ
- สิ่งที่ถูกพิจารณาอย่างสำคัญคือ ผลกระทบในวงกว้างต่อโค้ด เครื่องมือ เอกสาร ฯลฯ จากการเปลี่ยนภาษา และหลักการรักษาความเรียบง่ายอันเป็นเอกลักษณ์ของ Go
- ด้วย ความชัดเจน ความสะดวกในการดีบัก ของวิธีปัจจุบัน รวมถึงความชอบของผู้ใช้บางส่วน จึงมีเหตุผลไม่มากพอที่จะต้องนำการเปลี่ยนแปลงทางไวยากรณ์เข้ามา
- ในอนาคตที่มองเห็นได้ยังไม่มีแผนเปลี่ยนไวยากรณ์การจัดการข้อผิดพลาด และข้อเสนอที่เกี่ยวข้องทั้งหมดจะถูกปิดโดยไม่มีการศึกษาต่อเพิ่มเติม
การตั้งประเด็นเรื่องความยืดเยื้อของการจัดการข้อผิดพลาดใน Go
- หนึ่งในข้อร้องเรียนเก่าแก่ของ Go คือ ไวยากรณ์การจัดการข้อผิดพลาดยืดเยื้อเกินไป
- ตัวอย่างที่เด่นชัดคือแพตเทิร์นอย่าง
if err != nil ที่ปรากฏซ้ำๆ ในโค้ด
- ยิ่งเป็นโปรแกรมที่ต้องเรียก API หลายครั้ง ไวยากรณ์นี้ก็ยิ่งเด่นชัด และเกิดปรากฏการณ์ที่โค้ดจัดการข้อผิดพลาดมีมากกว่าลอจิกหลัก
- ในแบบสำรวจผู้ใช้ประจำปี ข้อร้องเรียนนี้ยังคงถูกกล่าวถึงในอันดับต้นๆ อย่างต่อเนื่อง
การหารือกับชุมชนและข้อเสนอระยะแรก
- ทีม Go ให้ความสำคัญกับฟีดแบ็กจากชุมชน จึงศึกษาข้อเสนอปรับปรุงการจัดการข้อผิดพลาดมาอย่างต่อเนื่อง
- ในการหารือโครงการ Go 2 ปี 2018 Russ Cox ได้สรุปแก่นของปัญหาการจัดการข้อผิดพลาดอย่างเป็นทางการ
- มีข้อเสนอ กลไก
check และ handle ของ Marcel van Lohuizen ปรากฏขึ้น
- รวมถึงการวิเคราะห์เปรียบเทียบกับภาษาอื่นที่คล้ายกันและการพิจารณาทางเลือกหลายแบบ
- แม้ว่าวิธีนี้จะช่วยให้โค้ดกระชับขึ้นจริง แต่ก็ไม่ได้ถูกรับมาใช้เพราะ เพิ่มความซับซ้อน
ข้อเสนอ try และสิ่งที่ตามมา
- ในปี 2019 มีข้อเสนอ ฟังก์ชันในตัว
try ที่เรียบง่ายกว่ามาก
- นำเฉพาะความสามารถของ
check มาใส่เป็นโค้ด และตัด handle ออก
- ข้อเสนอนี้ถูกวิจารณ์ว่า ซ่อนการไหลของการควบคุม และสุดท้ายก็ถูกยกเลิกท่ามกลางแรงต้านจากชุมชน
- ประสบการณ์นี้ทำให้เห็นถึง ความเสี่ยงของข้อเสนอที่ดูสมบูรณ์โดยยังไม่ได้รับฟีดแบ็กเพียงพอ
- ยืนยันว่าข้อเสนอการเปลี่ยนแปลงขนาดใหญ่ควรเปิดรับความคิดเห็นที่กว้างขวางกว่านี้ตั้งแต่ช่วงออกแบบแรกเริ่ม
ความพยายามเพิ่มเติมและข้อเสนอที่หลากหลาย
- มีข้อเสนอรูปแบบแปรผันและแนวทางจัดการข้อผิดพลาดทางเลือกอีกจำนวนมากเกิดขึ้นในชุมชนอย่างต่อเนื่อง
- Ian Lance Taylor ได้สรุปสถานะไว้ใน umbrella issue และยังมีการรวบรวมกรณีตัวอย่างต่อเนื่องใน Go Wiki และบล็อก
- ในปี 2024 มีข้อเสนอให้นำ ตัวดำเนินการ
? ที่ยืมมาจาก Rust มาใช้
- แม้การทดสอบการใช้งานขนาดเล็กจะมีฟีดแบ็กว่าเข้าใจได้ง่าย แต่ก็ยังไม่สามารถสร้างฉันทามติได้ท่ามกลางความคิดเห็นที่หลากหลาย
ภาวะชะงักงันของการถกเถียงและข้อสรุป
- แม้จะมีข้อเสนออย่างเป็นทางการและไม่เป็นทางการเกิน 3 รายการ และข้อเสนอจากชุมชนอีกหลายร้อยรายการ แต่ทั้งหมดก็ถูกปัดตกเพราะ ขาดความเห็นพ้องหรือฉันทามติที่เพียงพอ
- แม้แต่ กลุ่มสถาปนิกภายในของ Go เองก็ยังไม่มีความเห็นตรงกันเรื่องทิศทาง
- จึงมีการตัดสินใจว่าจะหยุดความพยายามเปลี่ยนไวยากรณ์การจัดการข้อผิดพลาดไปก่อน จนกว่าจะมีการเปลี่ยนแปลงของสถานการณ์หรือเกิดฉันทามติพิเศษขึ้น
เหตุผลหลักที่สนับสนุนการคงวิธีปัจจุบันไว้
- หากใส่ syntactic sugar มาตั้งแต่การออกแบบภาษาในช่วงแรกอาจไม่เกิดข้อถกเถียง แต่ปัจจุบันระบบนิเวศได้คุ้นชินกับ แนวทางที่ใช้มานาน 15 ปี แล้ว
- หากเพิ่มไวยากรณ์ใหม่ ก็ย่อมมีความกังวลเรื่อง ช่องว่างด้านสไตล์โค้ดระหว่างผู้ใช้เดิมกับผู้ใช้ใหม่ และการสูญเสียความสม่ำเสมอ
- เรื่องนี้ยังสอดคล้องกับปรัชญาการออกแบบของ Go ที่ไม่ทำสิ่งเดียวกันได้หลายวิธี และยึดหลัก ความกระชับ/ความสม่ำเสมอ
- แม้แต่การอนุญาตให้ประกาศซ้ำในรูปแบบประกาศตัวแปรสั้น (
:=) ก็เป็นการเปลี่ยนแปลงรองที่เกิดจากการจัดการข้อผิดพลาด
- ไวยากรณ์การจัดการข้อผิดพลาดที่ชัดเจน (ผ่าน
if) มีข้อดีเชิงสัญชาตญาณต่อการอ่านโค้ด การดีบัก และการตั้ง breakpoint
- การเปลี่ยนภาษาเองก็เป็นภาระใหญ่ทั้งในแง่ขอบเขตของการเปลี่ยนจริง (โค้ด เอกสาร เครื่องมือ ฯลฯ) และต้นทุน
การปรับปรุงทางเลือกและทิศทางในอนาคต
- การ เพิ่มความสามารถของไลบรารีมาตรฐาน (เช่น การเพิ่ม
cmp.Or) อาจช่วยลดโค้ดซ้ำบางส่วนได้
- ด้วย การพับโค้ดใน IDE การเติมโค้ดอัตโนมัติ การใช้ LLM ฯลฯ เครื่องมือพัฒนาจึงช่วยบรรเทาความยืดเยื้อในงานจริงได้ระดับหนึ่ง
- ในกลุ่มผู้ใช้ Go หลักๆ (เช่น ผู้เข้าร่วมงาน Google Cloud Next) ความเห็นเชิงลบต่อความจำเป็นในการเปลี่ยนภาษามีมากกว่า
- ยิ่งใช้ Go มากขึ้น ปัญหาเรื่องความยืดเยื้อก็ยิ่งรู้สึกน้อยลงในทางปฏิบัติ
เหตุผลที่สนับสนุนความจำเป็นของการปรับปรุงไวยากรณ์
- จากฟีดแบ็กของผู้ใช้ ยังมี ความต้องการให้ปรับปรุงไวยากรณ์การจัดการข้อผิดพลาดอยู่จริง
- ไวยากรณ์การจัดการข้อผิดพลาดที่ ไม่ใช่แค่ลดจำนวนตัวอักษร แต่ช่วยเพิ่มความชัดเจน อาจมีส่วนยกระดับคุณภาพและความปลอดภัยของโค้ดได้
- ยังจำเป็นต้องมีการศึกษาเชิงลึกมากขึ้นเกี่ยวกับการจัดการข้อผิดพลาดที่ทำหน้าที่จริง ไม่ใช่เพียงการตรวจสอบข้อผิดพลาดแบบง่ายๆ
ข้อสรุปสุดท้ายและนโยบายต่อจากนี้
- จากการยอมรับว่ายังไม่มีฉันทามติหรือความเปลี่ยนแปลงที่เป็นรูปธรรม จึงประกาศยุติการหารือและข้อเสนอเรื่องการเปลี่ยนภาษาเชิงไวยากรณ์เพื่อการจัดการข้อผิดพลาดทั้งหมดในอนาคตอันใกล้
- กระบวนการหารือและการศึกษาที่ผ่านมามีส่วนช่วยต่อระบบนิเวศและการปรับปรุงกระบวนการของ Go ทางอ้อม
- หากในอนาคตมีการนิยามปัญหาที่ชัดเจนขึ้นและเกิดฉันทามติ ก็อาจกลับมาเปิดการหารือได้อีก
- ในช่วงนี้จะมุ่งเน้นที่ การรักษาความแข็งแกร่งและความเรียบง่ายของ Go เอง มากกว่าการลองแนวทางใหม่ๆ
1 ความคิดเห็น
ความเห็นจาก Hacker News
ถ้าอยากเสนออย่างง่าย ๆ ว่าทีม Go น่าจะเลือกทางอื่นได้ อยากให้ลองดู วิกิ Go2ErrorHandlingFeedback หรือ ค้นหา issue ใน GitHub ก่อน ไอเดียแทบทั้งหมดที่ถูกเสนอไปนั้นถูกพูดคุยกันอย่างจริงจังมาแล้ว และในฐานะผู้ใช้ที่ซาบซึ้งกับแนวทางที่โปร่งใสของทีม Go ก็รู้สึกสนุกกับการใช้ Go ทุกวันมาก
เอกสารร่างการออกแบบพูดถึง C++, Rust, Swift แต่แทบไม่เห็นสิ่งที่ผมหาอยู่ เช่น do-notation/for-comprehensions/monadic-let ของภาษาสายฟังก์ชันอย่าง Haskell/Scala/OCaml เลย ทีม Go ดูราวกับเป็นปรมาจารย์ด้านการออกแบบภาษา แต่สุดท้ายกลับติดข้อจำกัดของ static type ที่ไม่มี parametric polymorphism แบบ Java จนหาคำตอบเรื่องการจัดการข้อผิดพลาดไม่ได้ ผมมองว่านี่เป็นปัญหาที่มาจากการออกแบบภาษาตั้งแต่รากฐาน
แม้จะเป็นเอกสารที่เขียนโดยคนเก่งและมีประสบการณ์มาก ก็ยังน่าแปลกมากที่ไม่มีการกล่าวถึงทางออกอย่าง Maybe/Either monad ของ Haskell และตัวดำเนินการ bind (do-notation) เลย ทั้งที่จริง ๆ มันไม่ได้ยากหรือวิชาการเกินไป และเป็นวิธีที่สวยงามและผ่านการพิสูจน์มาแล้วสำหรับการส่งต่อข้อผิดพลาดอย่างปลอดภัย จึงไม่เข้าใจว่าทำไมชุมชน Go ถึงไม่เอาแนวคิดนี้มาใช้ ขอบคุณที่มีหน้านี้อยู่ แต่การมองข้ามวิธีแก้ที่โด่งดังขนาดนี้ก็ยังเข้าใจได้ยาก
แทบทุกภาษามีแนวทางที่ดีกว่าหลายแบบ เลยสงสัยว่าทำไมใน Go ปัญหานี้ถึงเด่นชัดเป็นพิเศษ เป็นเพราะยังหาฉันทามติไม่ได้ หรือมีคุณลักษณะบางอย่างของ Go ที่ทำให้วิธีแก้ของภาษาอื่นใช้ไม่เข้ากันกันแน่
สิ่งที่เห็นบ่อยในการวิจารณ์ Go คือคนที่ค่อนข้างไม่เชี่ยวชาญมักตั้งต้นว่าคนพัฒนา Go รู้เรื่องภาษาน้อยกว่าตัวเอง ทั้งที่จริงแล้วส่วนใหญ่คนพัฒนา Go มีประสบการณ์มากกว่าและรู้มากกว่ามาก คนที่ไม่เชี่ยวชาญมักคิดว่าภาษาที่มีฟีเจอร์เยอะกว่าย่อมดีกว่าโดยอัตโนมัติ แต่กลับมองข้ามว่าความสำคัญจริง ๆ คือการรักษาสมดุลของภาพรวม
คิดว่าผู้ใช้ได้ประโยชน์จากความอนุรักษนิยมของ Go ที่ระวังมากในการเพิ่มฟีเจอร์ใหม่ให้ภาษา ฝั่ง Swift มีการเปลี่ยนฟีเจอร์เยอะจนเรียนรู้ยาก และแม้บน Mac รุ่นใหม่ก็ยังเจอกรณีที่แค่โปรเจกต์ง่าย ๆ โปรเจกต์เดียวก็บิลด์ไม่ผ่านอยู่บ่อย ๆ เพราะคีย์เวิร์ดเพิ่มและเปลี่ยนอยู่ตลอด ทำให้ Swift ใช้งานต่อเนื่องได้ไม่ดีเท่าไร ขณะที่จุดแข็งของ Go คือความสม่ำเสมอ
ครั้งหนึ่งเคยมีสถานการณ์พิเศษที่ฟังก์ชัน Go ต้องคาดหวังให้ฟังก์ชันภายในเกิด error และถ้าฟังก์ชันภายในไม่ error กลับต้องถือว่าฟังก์ชันนั้นผิดพลาดแทน ในโครงสร้างที่ไม่ค่อยเจอแบบนี้จึงต้องแตกแขนงด้วย
if err == nilแต่ด้วยความเคยชินเลยเผลอเขียนif err != nilตามแพตเทิร์นเดิม ทำให้ใช้เวลานานมากกว่าจะหาความผิดพลาดเจอ เลยคิดว่าถ้าภาษาช่วยแยกเชิงไวยากรณ์ระหว่างif err != nilที่ใช้บ่อยกับif err == nilที่ใช้ไม่บ่อย ก็น่าจะช่วยลดความผิดพลาดได้if err == nilผมจะใส่คอมเมนต์// invertedเพื่อเน้นแพตเทิร์น ถ้าภาษาจัดการให้อัตโนมัติได้ก็คงดี แต่ตอนนี้อย่างน้อยวิธีนี้ก็ช่วยให้แยกความต่างได้ชัดขึ้นif err == nil { return ... }ที่ใช้บ่อยอาจยิ่งดูแปลกตาในโค้ดก็ได้ หลายคนชอบวิธีจัดการ error ของ Go ในปัจจุบันเพราะมันชัดเจนและอ่านง่ายif fruit != "Apple"ดังนั้นโดยแก่นแล้วมันไม่ใช่ปัญหาเฉพาะของการจัดการ error แต่เป็นปัญหาทั่วไปของการแตกแขนงตามสถานะ Error เองก็สุดท้ายเป็นเพียงค่าสถานะอีกแบบหนึ่งif err != nilให้เหมือนสัญลักษณ์พิเศษจนกลืนไปกับพื้นหลังอย่างเป็นธรรมชาติและเด่นน้อยลง แล้วทำให้if err == nilที่เขียนต่างออกไปโดดเด่นขึ้น จึงอาจช่วยป้องกันความผิดพลาดได้ในระดับเอดิเตอร์if err … {ตอนแสดงผลผมชอบวิธีจัดการ error แบบ explicit ของ Go มันทำให้เข้าใจง่ายว่าฟังก์ชันจะสำเร็จเสมอ (minimal error) หรืออาจล้มเหลวได้ ฟังก์ชันที่มีโอกาสล้มเหลวต้องถูกจัดการก่อนเสมอจึงจะไปขั้นถัดไปได้ หลายภาษาพอใช้ exception พอเกิดข้อผิดพลาดก็จะโยนไล่ขึ้นไปตาม stack จนกว่าจะมี
catchซึ่งมักบอกได้แค่ว่า error เกิดที่ไหนแต่ให้คำใบ้เชิงปฏิบัติไม่มากนัก ใน Go เรามีตัวเลือกที่ชัดเจนดังนี้: 1) ไม่สนใจ error 2) เจอ error แล้ว return ทันที 3) wrap error เพื่อเพิ่มข้อมูลที่มีประโยชน์ 4) ตีความ error เฉพาะแล้วแตกแขนงจัดการ (เช่น แปลงเป็น 404) ใน Go2 อยากลองเพิ่มชนิดResult<Value, Failure>หรือประเภท error ที่เฉพาะเจาะจงและแจกแจงได้มากกว่านี้ คิดว่าการนำเข้าใน Go 2 จะเหมาะกว่าเพื่อรักษาความเข้ากันได้กับ Go 1ตอนแรกไม่ค่อยชอบวิธีจัดการข้อผิดพลาดของ Go แต่พออ่าน บล็อกโพสต์ errors-are-values แล้วเริ่มใช้
panic(err)ในจุดที่เหมาะสม กลับรู้สึกพอใจมากขึ้นมาก สำหรับสภาวะผิดปกติที่โค้ดระดับบนไม่ควรจัดการเอง การใช้ panic ช่วยลดแขนง error จุกจิกในโค้ดลงได้มาก วิธีบริหารข้อผิดพลาดแบบนี้ช่วยงานจริงได้มากทีเดียว@ได้ และ bash ก็มีเทคนิคจัดการ error อย่าง-eมีข้อโต้ตอบเชิงขำ ๆ ว่า เมื่อบอกว่าถ้าจัดการ error จริง ๆ แล้วความเยิ่นเย้อจะถูกกลบไปไว การสร้าง manual stack trace นับว่าเป็น “การจัดการ” จริงหรือ? ถ้าตามนิยามของ Go งั้น exception ก็ถือว่าเป็นการจัดการเหมือนกันไม่ใช่หรือ?
ผมไม่ชอบที่บทความนี้ทำเหมือนปัญหาของการจัดการ error ใน Go เป็นแค่เรื่อง “ไวยากรณ์มันเยิ่นเย้อ” ปัญหาจริงในมุมมองผมคือ 1) error หลุดหายเงียบ ๆ หรือถูกมองข้ามโดยไม่ตั้งใจได้ง่าย 2) ส่งต่อหรือเก็บผลลัพธ์ของฟังก์ชันไว้เหมือนค่าอย่างสะดวกไม่ได้ 3) error ซ้อนอย่าง
errors.Isเข้ากับ type system ได้อย่างกระอักกระอ่วน 4) สลับกรณีตาม error ได้ยาก 5) มาตรฐานไลบรารีใช้ sentinel value เยอะ 6) เข้ากับ generics ไม่ดีจนต้องพึ่งแพ็กเกจ เป็นต้นใน Elixir (และ Erlang) โดยทั่วไปฟังก์ชันจะคืนค่าเป็นทูเพิล
{:ok, result}หรือ{:error, description}ด้วยไวยากรณ์withของ Elixir จึงสามารถรวบการจัดการ error ไว้ด้านล่างของบล็อก ทำให้อ่านง่ายขึ้นมาก ถ้า Go มีอะไรคล้ายwithเข้ามา ก็อาจทำให้อ่านง่ายขึ้นได้ โดยให้รันต่อเนื่องเฉพาะตอนที่ error เป็น nil แล้วค่อยมีบล็อก handler อยู่ล่างสุดมีมุมมองว่าไม่เข้าใจว่าทำไมถึงไม่เดินตามสไตล์ Rust ไปเลย โดยเฉพาะตอนนี้ที่มี generics แล้ว การทำอะไรคล้ายกันก็น่าจะเป็นไปได้ไม่ยาก แม้ตัวดำเนินการ
?ของ Rust จะสะดวก แต่ไม่ค่อยเห็นด้วยกับเหตุผลที่ว่ามันส่งเสริมให้มองข้ามข้อผิดพลาด เพราะในความเป็นจริง Go เองก็มีกรณีมากมายที่มองข้ามค่าที่คืนเป็น error ได้โดยไม่เกิด compile error ถ้าอยากป้องกันความผิดพลาดจริง ๆ ก็ควรบังคับให้คืนResultแบบสไตล์ Rust ไปเลย ถ้าจะเถียงเรื่องความสะดวกสบาย งั้นก็ควรห้าม panic ไปด้วยไม่ใช่หรือ เป็นความเห็นที่แรงพอสมควรResultเข้ามาไม่ได้ เป็นเพราะไม่มี sum type และมีการออกแบบแปลกเฉพาะตัวที่กำหนดให้ทุก type ต้องมี zero value?” จะทำให้ “เลิกใช้ wrapped error” นั้น ก็มีข้อโต้แย้งว่าสามารถออกแบบให้ฟีเจอร์แบบนั้นสนับสนุนการ wrapping ได้เหมือนกันผมคิดว่าภาษาไม่ควรถูกถกกันแบบติ๊กเช็กบ็อกซ์รับฟีเจอร์เหมือน Rust แต่ควรถูกออกแบบภายใต้ความสอดคล้องของภาพรวม การติ๊กครบทุกฟีเจอร์ไม่ได้แปลว่าควรเอาเข้ามาทันที เพราะมันอาจไม่เข้ากับแก่นแท้ของภาษานั้นจริง ๆ