- แม้การเขียน
if err!= nil ซ้ำ ๆ ใน Go จะเป็นความไม่พอใจใหญ่ในแบบสำรวจผู้ใช้มาหลายปี แต่ทีม Go ตัดสินใจว่าจะยังไม่ผลักดัน การเปลี่ยนแปลงไวยากรณ์การจัดการข้อผิดพลาด ในตอนนี้
- ข้อเสนอ
check/handle ในปี 2018, try ในปี 2019 และ ? ในปี 2024 ต่างก็ไม่ได้รับฉันทามติเพียงพอ โดยเฉพาะ try ที่ถูกคัดค้านอย่างหนักเพราะมี control flow ที่ซ่อนอยู่
- ตามกระบวนการข้อเสนอของ Go หากไม่มีฉันทามติทั่วไป ข้อเสนอมักจะถูกปฏิเสธ และแม้แต่ในหมู่สมาชิกอาวุโสของทีม Go ที่ Google เองก็ยังไม่มีความเห็นเป็นเอกฉันท์เกี่ยวกับ ทิศทางที่ดีที่สุด ในปัจจุบัน
- ฝ่ายที่ต้องการคงสภาพเดิมเห็นว่า Go มีวิธีจัดการข้อผิดพลาดที่ใช้งานได้อยู่แล้ว และไวยากรณ์ใหม่จะสร้างต้นทุนจำนวนมากต่อสไตล์โค้ด การดีบัก เอกสาร เครื่องมือ และโค้ดเดิม
- ทีม Go จะปิด ข้อเสนอที่เปิดอยู่และข้อเสนอใหม่ ที่มีเป้าหมายหลักเป็นไวยากรณ์การจัดการข้อผิดพลาดโดยไม่ตรวจสอบเพิ่มเติม และจะมุ่งไปที่โอกาสปรับปรุงด้านอื่นจนกว่าจะเกิดความเข้าใจปัญหาที่ชัดเจนขึ้น
ความไม่พอใจเรื้อรังที่เกิดจาก if err != nil
- หนึ่งในความไม่พอใจที่ยืดเยื้อที่สุดใน Go คือความ ยืดยาว ของโค้ดจัดการข้อผิดพลาด
- รูปแบบที่พบได้ทั่วไปเป็นดังนี้
x, err := call()
if err != nil {
// handle err
}
- ในโปรแกรมที่มีการเรียก API จำนวนมากและเพียงแค่คืนค่าข้อผิดพลาดต่อไป
if err != nil อาจกินพื้นที่เด่นกว่าโค้ดส่วนที่เหลือ
- ในฟังก์ชันตัวอย่าง
printSum จากเนื้อหาฟังก์ชัน 10 บรรทัด มีเพียง 4 บรรทัดที่ดูเหมือนเป็นงานจริง รวมถึงการเรียกฟังก์ชัน การพิมพ์ผล และการคืนค่า ส่วนอีก 6 บรรทัดดูเหมือน noise
- ในแบบสำรวจผู้ใช้ประจำปีของ Go การจัดการข้อผิดพลาดเป็นความไม่พอใจอันดับต้น ๆ มาหลายปี ช่วงหนึ่งการไม่มี generics เคยแซงขึ้นไป แต่หลังจาก Go รองรับ generics แล้ว การจัดการข้อผิดพลาดก็กลับมาเป็นความไม่พอใจอันดับต้น ๆ อีกครั้ง
ข้อเสนอไวยากรณ์หลักสามครั้ง
- ความพยายามอย่างชัดเจนครั้งแรกของทีม Go เริ่มขึ้นในปี 2018 ในฐานะส่วนหนึ่งของความพยายาม Go 2 เมื่อ Russ Cox สรุปปัญหาอย่างเป็นทางการ
- ร่างการออกแบบ ของ Marcel van Lohuizen อิงกับกลไก
check และ handle และยังรวมการวิเคราะห์แนวทางของภาษาอื่นและทางเลือกต่าง ๆ ด้วย
func printSum(a, b string) error {
handle err { return err }
x := check strconv.Atoi(a)
y := check strconv.Atoi(b)
fmt.Println("result:", x + y)
return nil
}
- แนวทาง
check/handle ถูกมองว่าซับซ้อนเกินไป และในปี 2019 ก็มี try proposal ที่เรียบง่ายกว่าออกมา
- คีย์เวิร์ดที่คล้าย
check กลายเป็น built-in function ชื่อ try
- ส่วน
handle ถูกตัดออก
- มีการสร้างเครื่องมือ tryhard เพื่อแปลงโค้ดจัดการข้อผิดพลาดเดิมให้เป็นแบบ
try
- GitHub issue ที่เกี่ยวข้องถูกถกเถียงอย่างดุเดือดจนมีคอมเมนต์เกือบ 900 รายการ
func printSum(a, b string) error {
// use a defer statement to augment errors before returning
x := try(strconv.Atoi(a))
y := try(strconv.Atoi(b))
fmt.Println("result:", x + y)
return nil
}
try ส่งผลต่อ control flow ด้วยการคืนค่าจากฟังก์ชันที่ครอบอยู่เมื่อเกิดข้อผิดพลาด และการคืนค่าสามารถเกิดขึ้นได้แม้ในนิพจน์ที่ซ้อนลึก ทำให้ผู้ใช้จำนวนมากยอมรับได้ยาก
- ในเวลานั้น การเพิ่มคีย์เวิร์ดใหม่อาจเป็นทางเลือกที่ดีกว่า และปัจจุบันสามารถควบคุมเวอร์ชันภาษาได้ละเอียดขึ้นผ่านไฟล์
go.mod และ directive รายไฟล์
- ข้อเสนอล่าสุด ของ Jimmy Frasche ย้อนกลับไปยังการออกแบบ
check/handle ดั้งเดิม พร้อมจัดการข้อเสียบางส่วน
การทบทวนกระบวนการหลัง try และข้อเสนอ ?
- หลังข้อเสนอ
try Russ Cox ได้ทบทวนกระบวนการข้อเสนอผ่านชุดบทความ “Thinking about the Go Proposal Process”
- “Go Proposal Process: Large Changes” ประเมินว่า
try ควรเป็นร่างการออกแบบฉบับที่สอง ไม่ใช่ข้อเสนอที่มีตารางการ implement ติดมาด้วย
- หลายปีต่อมา ทีม Go ไม่ได้ผลักดันการเปลี่ยนแปลงไวยากรณ์การจัดการข้อผิดพลาด และในชุมชนก็ยังมีข้อเสนอที่คล้ายกัน น่าสนใจ เข้าใจยาก หรือเป็นไปไม่ได้ส่งเข้ามาเรื่อย ๆ
- Ian Lance Taylor สร้าง umbrella issue เพื่อสรุปสถานะของข้อเสนอปรับปรุงการจัดการข้อผิดพลาด และยังมี Go Wiki สำหรับรวบรวม feedback และการถกเถียงที่เกี่ยวข้อง
- “go error handling proposals” ของ Sean K. H. Liao ติดตามข้อเสนอเกี่ยวกับการจัดการข้อผิดพลาดจำนวนมากตลอดหลายปี
- เมื่อความไม่พอใจยังคงอยู่ Ian Lance Taylor จึงเผยแพร่ ข้อเสนอในปี 2024 ที่ใช้
? เพื่อลด boilerplate การจัดการข้อผิดพลาด
- เป็นสัญลักษณ์ที่ยืมมาจาก
? operator ของ Rust
- ในการศึกษาผู้ใช้แบบไม่เป็นทางการขนาดเล็ก ผู้เข้าร่วมส่วนใหญ่เดาความหมายของโค้ด Go ที่ใช้
? ได้ถูกต้อง
- ยังมีการสร้างเครื่องมือสำหรับแปลงโค้ด Go ปกติเป็นไวยากรณ์ใหม่ และ prototype ของ compiler ด้วย
func printSum(a, b string) error {
x := strconv.Atoi(a) ?
y := strconv.Atoi(b) ?
fmt.Println("result:", x + y)
return nil
}
- ข้อเสนอนี้ก็เต็มไปด้วยคอมเมนต์จำนวนมากและข้อเสนอปรับรายละเอียดตามความชอบส่วนตัวอย่างรวดเร็ว Ian จึงปิดข้อเสนอแล้วบรรจุเนื้อหาไปไว้ใน discussion
- เวอร์ชันที่ปรับเล็กน้อยได้รับ เสียงตอบรับเชิงบวกมากขึ้นเล็กน้อย แต่ก็ยังไม่ได้รับการสนับสนุนอย่างกว้างขวาง
ทำไมตอนนี้จึงต้องการหยุด
- ทีม Go เห็นว่าควรหยุดความพยายามแก้ ปัญหาเชิงไวยากรณ์ ของการจัดการข้อผิดพลาดในอนาคตที่คาดการณ์ได้
- กระบวนการข้อเสนอ สนับสนุนการตัดสินใจนี้
- เป้าหมายของกระบวนการข้อเสนอคือการบรรลุฉันทามติทั่วไปต่อผลลัพธ์อย่างทันท่วงที
- หากในการถกเถียงใน issue tracker ไม่พบฉันทามติทั่วไป ข้อเสนอมักจะถูกปฏิเสธ
- หากไม่พบทั้งฉันทามติและขั้นตอนถัดไป Go architects จะตรวจสอบการถกเถียงและพยายามหาฉันทามติภายใน
- ไม่มีข้อเสนอด้านการจัดการข้อผิดพลาดใดได้รับการสนับสนุนที่ใกล้เคียงฉันทามติ และทั้งหมดถูกปฏิเสธ
- แม้แต่สมาชิกอาวุโสของทีม Go ที่ Google ก็ยังไม่มีความเห็นเป็นเอกฉันท์เกี่ยวกับ ทิศทางการเดินหน้าที่ดีที่สุด ในปัจจุบัน และหากไม่มีฉันทามติที่แข็งแรงก็ไม่สามารถเดินหน้าได้อย่างสมเหตุสมผล
เหตุผลของการคงสภาพเดิมและการเปลี่ยนแปลง
- ฝั่งคงสภาพเดิมมีเหตุผลเชิงปฏิบัติเรื่องความเติบโตเต็มที่ของ Go และต้นทุนต่อ ecosystem
- หาก Go เพิ่ม syntactic sugar เฉพาะสำหรับการจัดการข้อผิดพลาดตั้งแต่ช่วงแรก ตอนนี้คงมีข้อถกเถียงน้อยกว่า แต่ Go มีอายุ 15 ปีแล้วและมีวิธีจัดการข้อผิดพลาดที่ใช้งานได้อยู่แล้ว
- แม้ตอนนี้จะพบทางออกที่สมบูรณ์แบบ สถานการณ์ก็อาจเปลี่ยนเป็นฝ่ายที่ชอบคงสภาพเดิมเป็นผู้ไม่พอใจแทนฝ่ายที่สนับสนุนการเปลี่ยนแปลง
- Generics เป็นสิ่งที่ผู้ใช้ไม่จำเป็นต้องเขียนเองโดยตรงเสมอไป แต่ไวยากรณ์ใหม่สำหรับการจัดการข้อผิดพลาด หากไม่ใช้ โค้ดอาจดูไม่เป็น idiomatic ทำให้ในทางปฏิบัติคนส่วนใหญ่ต้องใช้
- การเพิ่มไวยากรณ์ใหม่อาจขัดกับกฎการออกแบบของ Go ที่ไม่เสนอหลายวิธีในการทำสิ่งเดียวกัน
- ความสามารถ redeclare ของ short variable declaration
:= ถูกนำเข้ามาเพื่อแก้ปัญหาที่เกิดจากการจัดการข้อผิดพลาด
- หากไม่มี redeclaration การตรวจข้อผิดพลาดต่อเนื่องแต่ละครั้งต้องใช้ชื่อ
err ที่ต่างกันหรือประกาศตัวแปรแยกต่างหาก
- หากตอนนั้นมีการรองรับไวยากรณ์สำหรับการจัดการข้อผิดพลาดที่ดีกว่า กฎ redeclaration และความซับซ้อนที่เกี่ยวข้องก็อาจไม่จำเป็น
- หากเสริมข้อมูลให้ข้อผิดพลาดอย่างเหมาะสม สัดส่วนของการเขียนซ้ำแบบง่าย ๆ จะลดลง
- ในแบบสำรวจผู้ใช้ มีความเห็นซ้ำ ๆ ว่าข้อผิดพลาดไม่มี stack trace
- สามารถใช้ helper function สร้างและคืนค่าข้อผิดพลาดที่เสริมข้อมูลแล้วได้
- หากแนบข้อมูล input เช่น
fmt.Errorf("invalid integer: %q", a) สัดส่วนสัมพัทธ์ของ boilerplate ก็จะเล็กลง
- ฟีเจอร์ใน standard library ก็ช่วยลด boilerplate ในการจัดการข้อผิดพลาดได้
- เป็นแนวทางเดียวกับ “Errors are values” ของ Rob Pike
- ในบางกรณีสามารถใช้
cmp.Or เพื่อจัดการหลายข้อผิดพลาดพร้อมกันได้
- การเขียน การอ่าน และการดีบักเป็นกิจกรรมที่แตกต่างกัน
- การเขียนการตรวจข้อผิดพลาดซ้ำ ๆ เป็นเรื่องน่าเบื่อ แต่ IDE และการเติมโค้ดด้วยความช่วยเหลือจาก LLM สามารถสร้างการตรวจข้อผิดพลาดพื้นฐานได้ง่าย
- ตอนอ่าน ความยืดยาวจะเด่นชัดกว่า และ IDE อาจมี toggle สำหรับซ่อนโค้ดจัดการข้อผิดพลาด
- ตอนดีบัก หากมี statement
if แยกอยู่แล้ว การเพิ่ม println หรือการตั้ง breakpoint ก็ทำได้ง่าย
- หากการจัดการข้อผิดพลาดถูกซ่อนไว้หลัง
check, try, ? ก็มักอาจต้องแปลงกลับเป็น statement if และขั้นตอนนี้อาจทำให้การดีบักซับซ้อนขึ้นหรือก่อบั๊กละเอียดอ่อนได้
- การเปลี่ยนแปลงภาษาไม่ได้มีต้นทุนแค่การออกแบบและ implement แต่ยังรวมถึงการเปลี่ยนโค้ดเดิม การอัปเดตเอกสาร และการปรับเครื่องมือด้วย
- ทีม Go มีขนาดค่อนข้างเล็ก และยังมีลำดับความสำคัญอื่นอีกมากที่ต้องจัดการ
- ลำดับความสำคัญและขนาดทีมอาจเปลี่ยนแปลงได้
- ผู้ใช้ Go บางส่วนที่ทีม Go พบในงาน Google Cloud Next 2025 กล่าวอย่างหนักแน่นว่าไม่ควรเปลี่ยนภาษาเพื่อให้การจัดการข้อผิดพลาดดีขึ้น
- พวกเขาบอกว่าเมื่อเพิ่งย้ายมาจากภาษาอื่น การไม่มีไวยากรณ์เฉพาะสำหรับการจัดการข้อผิดพลาดใน Go จะสะดุดตาที่สุด แต่เมื่อเริ่มเขียนโค้ด Go ที่ idiomatic มากขึ้น เรื่องนี้จะสำคัญน้อยลง
- กลุ่มตัวอย่างนี้ไม่ได้ใหญ่พอจะมีความเป็นตัวแทน แต่ก็อาจเป็นกลุ่มคนละแบบกับที่เห็นบน GitHub
- เหตุผลของฝ่ายสนับสนุนการเปลี่ยนแปลงก็ยังคงมีน้ำหนัก
- การขาดการรองรับการจัดการข้อผิดพลาดที่ดีกว่ายังคงเป็นความไม่พอใจอันดับต้น ๆ ในแบบสำรวจผู้ใช้
- แนวทางที่มุ่งลดจำนวนอักขระเพียงอย่างเดียวอาจเป็นทิศทางที่ผิด
- หากทำให้การจัดการข้อผิดพลาดพื้นฐานมองเห็นได้ชัดด้วยคีย์เวิร์ด พร้อมกำจัด boilerplate
err != nil ก็อาจช่วยให้ตรวจสอบใน code review ได้ง่ายขึ้นว่ามีการจัดการข้อผิดพลาดหรือไม่
- ยังไม่ทราบเพียงพอว่าแก่นของปัญหาคือความยืดยาวของไวยากรณ์ง่าย ๆ หรือความยืดยาวของการจัดการข้อผิดพลาดที่ดี ซึ่งต้องประกอบข้อผิดพลาดให้มีความหมายต่อ API นักพัฒนา และผู้ใช้ปลายทาง
การตัดสินใจของทีม Go
- จนถึงตอนนี้ ไม่มีความพยายามใดในการจัดการข้อผิดพลาดที่ได้รับแรงส่งเพียงพอ
- ทีม Go เห็นว่ายังขาดความเข้าใจปัญหาร่วมกัน และทุกคนก็ไม่ได้เห็นตรงกันตั้งแต่แรกด้วยซ้ำว่ามีปัญหาอยู่หรือไม่
- ในอนาคตที่คาดการณ์ได้ จะไม่ผลักดัน การเปลี่ยนแปลงภาษาเชิงไวยากรณ์ สำหรับการจัดการข้อผิดพลาด
- ข้อเสนอที่เปิดอยู่และข้อเสนอที่จะเข้ามาในอนาคต ซึ่งมีเป้าหมายหลักเป็นไวยากรณ์การจัดการข้อผิดพลาด จะถูกปิดโดยไม่มีการตรวจสอบเพิ่มเติม
- การสำรวจและถกเถียงของชุมชนแม้ไม่ได้นำไปสู่การเปลี่ยนแปลงไวยากรณ์การจัดการข้อผิดพลาด แต่ก็นำไปสู่การปรับปรุงหลายด้านของภาษา Go และกระบวนการของ Go
1 ความคิดเห็น
ความคิดเห็นใน Hacker News
ถ้าอยากโยนข้อเสนอแบบเบา ๆ ว่าทีม Go “น่าจะทำแบบนี้ก็จบแล้ว” ก็อยากให้ดูก่อนที่หน้า Wiki ที่ลิงก์ไว้ในบทความ https://go.dev/wiki/Go2ErrorHandlingFeedback และการค้นหา issue บน GitHub https://github.com/golang/go/issues?q=+is%3Aissue+label%3Aerror-handling
สิ่งที่กำลังจะเสนอแทบจะแน่นอนว่าไม่ใช่ของใหม่ และหลายอย่างน่าจะถูกพิจารณาอย่างลึกซึ้งไปแล้ว
ผมมองแนวทางที่ตรงไปตรงมาของทีม Go นี้ในแง่ดี และยังใช้ Go ทำงานทุกวันอย่างมีความสุข
ใน GitHub issue ที่มีคอมเมนต์มากที่สุดหลายหน้าก็ไม่เห็นอะไรคล้ายกัน และคงฝืนเกินไปที่จะคิดว่าทีม Go เป็นพ่อมดด้านการออกแบบภาษา จึงต้องเคยพิจารณาคำตอบที่คนแถวนี้โยนกันแบบเบา ๆ ไปแล้วแน่นอน
ทีม Go เคยทำผิดแบบเดียวกับ Java คือเป็น static type แต่ไม่มี parametric polymorphism และรากของปัญหาการจัดการ error นี้ก็อยู่ตรงนั้น แต่ดูเหมือนพวกเขายอมแพ้และไม่แก้
มันอาจฟังดูใหญ่โตและน่ากลัว แต่เป็นวิธีที่สง่างามและบริสุทธิ์ในเชิงฟังก์ชัน สำหรับส่งต่อ error ไปจนถึงจุดที่จัดการได้ โดยไม่ปล่อยให้ถูกลืม
สำหรับคนที่เขียนโค้ด Haskell วิธีนี้ฝังลึกมาก จึงเข้าใจยากว่าในชุมชน Go จะไม่มีใครรู้จักและชอบมันเลย
ขอบคุณสำหรับตัวหน้าเว็บและลิงก์ แต่ก็น่าสับสนที่คนซึ่งใส่ใจภาษาของตัวเองขนาดนั้นกลับข้ามคำตอบที่ตั้งหลักได้ดีแบบนี้ไป
แทบทุกภาษามีวิธีที่ดีกว่าของตัวเอง เลยอยากรู้ว่าเป็นแค่เพราะตัดสินใจไม่ได้หรือทำให้ทุกคนพอใจไม่ได้ หรือมีเหตุผลเฉพาะของตัวภาษา Go เองที่ทำให้คำตอบของภาษาอื่นไม่เหมาะ
ในความเป็นจริง แทบทุกกรณีพวกเขารู้มากกว่ามาก
มือสมัครเล่นมักคิดอย่างใสซื่อว่าภาษาที่อัดฟีเจอร์เข้าไปมากที่สุดคือภาษาที่ดีที่สุด โดยเฉพาะถ้ามีฟีเจอร์ที่ตัวเองชอบอยู่ด้วย
คล้ายกับคนที่เพิ่งเริ่มหัดทำมีด เห็นมีดเชฟญี่ปุ่นแล้วรู้สึกว่ามันยังขาดอะไรไป จากนั้นคิดว่าถ้าใส่ร่องนิ้ว ช่องลับ ไฟแช็ก และด้ามพิมพ์ 3D ที่มีลำโพง Bluetooth เข้าไปน่าจะดีกว่า
ทำรายการเช็กบ็อกซ์ขึ้นมา ถกแต่ละข้อแล้วเติมให้ครบ จากนั้นอย่าเอาออกอีก เว้นแต่จะพบข้อผิดพลาดเชิง semantics ร้ายแรงหรือช่องโหว่ด้าน soundness
พอเติมครบก็ implement ไป แล้วคนที่เคยเถียงกันว่าจะใช้เป็น
.awaitหรือ/awaitหรือ.await!()ก็จะหายกลับไปเองRust ทำงานแบบนี้ บาง issue อาจล่าช้าเกิน 10 ปี แต่สุดท้ายรายการก็ถูกเติมครบและถูก stabilize จาก nightly ล่าสุด
ถ้า Go แก้ปัญหาเดี่ยวที่ทุกคนชนทันทีไม่ได้ ทั้งที่มีข้อเสนอที่สมบูรณ์สูงอยู่หลายแบบ แต่เลือกไม่ถูกสักอันและรอให้การ bikeshedding หยุด ขั้นตอนนั้นก็เป็นเรื่องตลก
ไม่ใช่ชุดฟีเจอร์ที่พอผ่านข้อกำหนดแบบเช็กบ็อกซ์แล้วก็แค่เพิ่มเข้าไปได้
Rust จะกลายเป็นความยุ่งเหยิงเหมือน C++ ไหม? Go จะยังเป็น ภาษาที่ไม่ล้าสมัยตามกาลเวลา เหมือนตอนเปิดตัวไหม?
ในแบบสำรวจ มีสัดส่วนที่พูดถึง การจัดการ error อยู่ 13% และก็มีคนที่ชอบวิธีปัจจุบันแบบเดิมอยู่ด้วย
https://go.dev/blog/survey2024-h1-results
ก่อนหน้านี้เคยเขียนฟังก์ชัน Go แปลก ๆ ที่คาดหวังให้ฟังก์ชันภายในคืนข้อผิดพลาด
ดังนั้นถ้าฟังก์ชันภายในไม่คืนข้อผิดพลาด ฟังก์ชันภายนอกก็ต้องคืนข้อผิดพลาดและจัดการอย่างอื่น และถ้าฟังก์ชันภายในคืนข้อผิดพลาด ก็ต้องคืน nil
สรุปคือไม่ใช่
if err != nil { ... }แต่ต้องเขียนif err == nil { // return an error }ทว่าเผลอเขียนแบบแรกตามความเคยชิน และใช้เวลาดีบักอยู่นานพอสมควรเพราะชินชากับ
if err != nilมากเกินไป จนสมองไม่คิดถึงความเป็นไปได้เลยว่าประโยคนั้นไม่ควรอยู่ตรงนั้นดังนั้นจึงคิดว่าสำนวนที่พบบ่อยควรมี syntactic sugar หากความแตกต่างระหว่าง
if err != nilที่พบบ่อยมาก กับif err == nilที่พบไม่บ่อย เด่นชัดกว่านี้ ก็คงช่วยได้จริงif err == nilจะใส่คอมเมนต์// invertedไว้ให้สะดุดตาถ้าจัดการได้ในระดับภาษาก็คงดี แต่ขอแชร์ไว้เป็นอย่างน้อยว่าเป็นวิธีทำให้มองเห็นได้ชัดขึ้น
if fruit != "Apple" { ... }ก็สร้างสถานการณ์แบบเดียวกันได้เลยสงสัยว่ามีวิธีแก้ทั่วไปเพื่อปรับปรุงเรื่องนี้ไหม และการมองว่านี่เป็นปัญหาเฉพาะของการจัดการข้อผิดพลาดก็ดูจะคลาดเคลื่อนไปหน่อย
ข้อผิดพลาดไม่ได้มีอะไรพิเศษหรือเฉพาะตัว เป็นเพียงสถานะหนึ่งเหมือนอย่างอื่นเท่านั้น
เพราะถ้าเกิดแพตเทิร์น
if err == nil { return ... }ที่ใช้กันบ่อยขึ้น คราวนี้มันก็จะกระจายอยู่เต็มโค้ดแทนวิธีแก้ปัจจุบันก็ใช้ได้ดี และดูเหมือนว่าคนที่ไม่ชอบมักเป็นคนที่เพิ่งเริ่มใช้ Go หรือยังอยู่ระดับต้น
คนรอบตัวชอบการจัดการข้อผิดพลาดที่ “ยืดยาว” แต่ชัดเจน ตรงไปตรงมา และอ่านง่าย
if err != nilถูกไฮไลต์หรือเรนเดอร์เป็นพื้นหลังแบบจาง ๆ เหมือน ligature ขนาดเล็กตัวเดียวได้แบบนั้นรูปแบบที่ต่างจากสตริงนี้เป๊ะ ๆ เช่น
if err == nilจะเด่นขึ้นมาแทนif err … {ชอบการจัดการข้อผิดพลาดแบบชัดเจนของ Go
ฟังก์ชันอาจสำเร็จเสมอ หรืออาจสำเร็จหรือล้มเหลวก็ได้ ฟังก์ชันที่สำเร็จเสมอนั้นเรียบง่าย ส่วนฟังก์ชันที่ล้มเหลวได้ หากล้มเหลว โค้ดภายนอกก็ไม่สามารถเดินหน้าต่อในสถานะล้มเหลวได้ จึงต้องจัดการ
ตรงนี้เองที่ภาษาต่าง ๆ แยกแนวทางกัน หลายภาษาจะโยน exception ขึ้นไปจนกว่าจะมีใครจับอย่างชัดเจน และให้ stack trace บางรูปแบบ
ใน Go สิ่งที่ชอบคือเวลาเขียนโค้ดจะมีตัวเลือกที่ต้องเลือกเสมอ: เพิกเฉยข้อผิดพลาดแล้วไปต่อ (
foo, _ := doSomething()), คืนค่าก่อนโดยไม่มีข้อมูลที่มีความหมาย (return nil, err), คืนค่าก่อนพร้อมแนบบริบทที่มีประโยชน์, หรือตีความข้อผิดพลาดที่ได้รับแล้วแตกแขนงเช่น หากฐานข้อมูลไม่พบแถวที่ต้องแก้ไข ชั้นบริการอาจคืนข้อผิดพลาด not found แล้ว API แปลงเป็น 404 หรือฟังก์ชันลบแบบ idempotent อาจตีความ not found ว่าเป็นความสำเร็จก็ได้
ถ้าเป็น Go 2 หรือภาษาอื่น ก็อยากให้มี Result type แบบ Rust/Swift แทนทูเพิลที่อาจเป็น nil ได้ และมีชนิดข้อผิดพลาดที่พิมพ์ชนิดได้ดีกว่าและ enumerate ได้ แทนการใช้
errorโดยตรงเสมอแต่ถ้าเพิ่ม Result เข้าไปบนการคืนค่าทูเพิลตาม idiom ของ Go 1 ก็จะเกิดหลายวิธีในการทำสิ่งเดียวกัน ทำให้สับสนและแตกแยก จึงเหมาะกับ Go 2 หรือภาษาใหม่มากกว่า
ชั้นล่าง ๆ ของสแตกโดยทั่วไปไม่รู้ว่าควรทำอะไร จึงไม่ควรจัดการข้อผิดพลาด
นโยบายที่เรียกว่าการจัดการข้อผิดพลาด สุดท้ายมักกลายเป็นการห่อข้อผิดพลาดแล้วส่งกลับขึ้นไปบนสแตกอีกที และกลายเป็นงานจุกจิกไม่น้อย
Go ปล่อยให้เพิกเฉยข้อผิดพลาดได้โดยสมบูรณ์ และผลลัพธ์อาจนำไปสู่การแครชได้
ทั้งที่ชี้ข้อกำหนดสำหรับการสร้างซอฟต์แวร์ที่แข็งแรงได้ตรงจุด แต่กลับชอบวิธีจัดการข้อผิดพลาดของ Go นั้นออกจะเข้าใจยาก
[1]: https://borgo-lang.github.io/ | https://github.com/borgo-lang/borgo
ภาษา functional ทั้งหมด, ภาษาสมัยใหม่จำนวนมากอย่าง Rust, แม้แต่ Java ที่มี checked exception ก็ให้สิ่งนี้ได้
ถ้าเป็นภาษาที่มี generics ก็มักจะจำลอง “การจัดการข้อผิดพลาด” แบบ Go ได้ และอาจได้โค้ดที่ดีกว่าด้วย
ถ้าคำตอบคือ JavaScript หรือ Python นั่นก็เป็นแพตเทิร์นการเปรียบเทียบที่พบบ่อยอยู่
สำหรับ Go นี่เป็นการตัดสินใจที่ถูกต้อง ตอนเริ่มใช้ Go แรก ๆ ไม่ชอบการจัดการข้อผิดพลาด แต่ตอนนี้ชอบมากจริง ๆ
จุดเปลี่ยนมีสองอย่าง คือได้อ่านบทความ https://go.dev/blog/errors-are-values แล้วรับมุมมอง “ข้อผิดพลาดคือค่า” อย่างจริงจัง และจากฐานคิดนั้นก็ได้สร้างแพ็กเกจที่ค่อนข้างได้รับความนิยมอย่าง https://github.com/stytchauth/sqx ด้วย
อีกอย่างคือเริ่มคุ้นเคยกับการใช้
panic(err)ทีละน้อยในสถานะผิดพลาดที่ไม่สมเหตุสมผลจริง ๆไม่มีเหตุผลที่จะบังคับให้โค้ดแม่ต้องจัดการสถานะแปลก ๆ ทั้งหมดที่มันไม่มีทางรู้สึกได้ว่าควรจัดการอย่างไร และ panic ที่วางดี ๆ สักหนึ่งสองจุดก็สามารถตัดการตรวจข้อผิดพลาดหลายร้อยจุดออกจาก codebase ได้
ตัวอย่างเช่น ลองคิดถึงปัญหาอย่างการมี logger เริ่มต้นอยู่ใน ctx หรือไม่
กลับมีโอกาสจะดีขึ้นด้วยซ้ำ
bash ก็มี
-eสมัยเริ่มใช้ C# ใหม่ ๆ เคยคิดว่าการเข้าใจ flow ของ try/catch/finally, using, การซ้อนกัน, ถ้าเกิดข้อผิดพลาดใน catch จะเป็นอย่างไร, ถ้าเกิดใน finally จะเป็นอย่างไร เป็นเรื่องที่ฉลาด
ตอนนี้คิดว่าไม่ต้องคิดเรื่องพวกนั้นดีกว่า
ผมไม่ชอบวิธีที่บทความนี้พูดเหมือนว่าปัญหาหลักของการจัดการข้อผิดพลาดใน Go คือไวยากรณ์ที่เยิ่นเย้อเกินไป เรื่องนั้นผมไม่ได้ใส่ใจเท่าไร
สิ่งที่สำคัญกว่าคือข้อผิดพลาดอาจถูกทิ้งไปเงียบ ๆ หรือถูกละเลยโดยไม่ตั้งใจได้, ผลลัพธ์จากการเรียกฟังก์ชันไม่ใช่ค่า จึงเก็บหรือส่งต่อได้ไม่ง่าย, ต้องใช้
errors.Isและข้อผิดพลาดแบบ “ซ้อนกัน” ทั้งชุดเป็นกลไก runtime แปลก ๆ ที่ไม่ค่อยเข้ากับ type systemการ switch กับข้อผิดพลาดก็ทำได้ยาก, standard library ใช้ค่า sentinel และการทำงานร่วมกับ generics ก็ไม่ดี จนต้องมีแพ็กเกจอย่าง errgroup
ยังมีอะไรที่ผมตกหล่นไปอีกไหม?
ถ้าเป็นภาษาที่มี exception คงไม่มีใครทำแบบนั้น
พวกเขาตัดสินใจว่าจะไม่พยายามเปลี่ยนไวยากรณ์การจัดการข้อผิดพลาดอีกในอนาคตอันคาดการณ์ได้ และนั่นทำให้มีพื้นที่ความสนใจไปดูปัญหาอื่น ๆ ไม่ว่าจะเป็นเรื่องข้อผิดพลาดหรือหัวข้ออื่น
วิวัฒนาการของ Go ช้าเหมือนธารน้ำแข็ง และสำหรับหลายคน นั่นไม่ใช่บั๊กแต่เป็นฟีเจอร์
คำอธิบายทำนองว่า “ความเห็นในแบบสำรวจที่บอกว่าข้อผิดพลาดไม่มี stack trace แก้ได้โดยให้ฟังก์ชันช่วยสร้างและคืนข้อผิดพลาดที่เสริมข้อมูลแล้ว” นั้นตลกดี
การ ใส่ stack trace ด้วยมือ อย่าง
if err != nil { return fmt.Errorf("invalid integer: %q", a) }แล้วเรียกว่า “การจัดการข้อผิดพลาด” นี่น่าสนุกดีตามนิยามของทีม Go exception ก็คงเป็นการจัดการข้อผิดพลาดให้อัตโนมัติแล้ว แน่นอนว่ายกเว้นภาษา C++
มันอาจเป็นอย่างนั้นก็ได้ แต่จำเป็นต้องมีทั้งหมดนั้นจริงหรือ? แล้วค่าใช้จ่ายของ log ล่ะ?
ผมว่า error ที่ wrap มาเป็นบรรทัดเดียวและตัด noise จาก framework กับ runtime ออกไปนั้นดีกว่ามาก
ถ้า wrap ดี ๆ ก็ค้นหาได้ง่ายมาก และโดยปกติตามรอยได้มีประสิทธิภาพกว่า stack trace
ผมใช้ Go แบบเต็มเวลามากว่า 10 ปี และไม่เคยมีสักครั้งที่ต้องการ noise ยืดยาวจากฟังก์ชัน runtime หรือ call stack
จากมุมมองของนักพัฒนา Elixir นี่ดูเหมือนเรื่องบ้าบอ
ใน Erlang/Elixir ปกติแก้ด้วยการให้ฟังก์ชันคืน tuple แบบ
{:ok, result}หรือ{:error, description_or_struct}พอใช้คำสั่ง
withของ Elixir ร่วมด้วย ก็สามารถรวบการจัดการข้อผิดพลาดไว้ด้านล่าง ทำให้อ่านง่ายขึ้นมากGo ก็น่าจะเพิ่มสิ่งที่เทียบเท่ากับ
withclause ให้ฟังก์ชันต่าง ๆ ดำเนินต่อไปตราบใดที่ข้อผิดพลาดเป็น nil แล้วมี clause จัดการข้อผิดพลาดอยู่ด้านล่างwithมาใช้เลยน่าสนใจที่ Go เลื่อนโครงสร้างพื้นฐานและมีคุณค่าอย่างชัดเจน เช่น generics, error handling, package management ออกไปนานมากเพราะ ขาดฉันทามติในชุมชน
generics ใช้เวลา 13 ปีหลังเปิดเป็นโอเพนซอร์ส, ผ่านมา 16 ปีแล้วตอนนี้ก็ยังไม่มี error handling และ package management ใช้เวลาราว 9 ปี
การไตร่ตรองมีคุณค่า แต่การปล่อยของก็มีคุณค่าเช่นกัน คนที่เขียนคอมเมนต์ GitHub 900 ความเห็นสุดท้ายก็ยังจะใช้ Go ต่อไป และมีความเป็นไปได้ว่าสิ่งที่ถูกใส่เข้าไปในภาษาจะดีกว่าการผัดผ่อนไปเรื่อย ๆ
ฟังก์ชันที่มี return type หลายตัว แทบทำอะไรไม่ได้นอกจาก assign ให้ตัวแปร
แต่ใน Erlang และ Elixir มันเป็นแนวทางที่เป็น idiomatic อย่างสมบูรณ์โดยไม่มีภาระใด ๆ
ที่จริงยังทรงพลังกว่าตระกูล ML มากด้วยซ้ำ เพราะ sum type ของฝั่งนั้นเป็นแบบเปิด
ผมไม่ได้ตามการถกเถียงนี้อย่างละเอียด แต่ไม่เข้าใจว่าทำไมไม่เอาวิธีแบบ Rust มาใช้ตรง ๆ
หลังจาก Go มี generics วิธีที่ผมเพิ่มเข้าไปทันทีเองก็เป็นแบบนั้น
ในบทความที่ลิงก์ไว้ เห็นมีแค่คำอธิบายว่า “Rust ไม่มีสิ่งที่เทียบเท่า handle และความสะดวกของ operator
?อาจทำให้ละเว้นการจัดการที่เหมาะสมได้”แต่ผมไม่เข้าใจว่าความสะดวกหมายถึงการเมิน error ได้อย่างไร
ปัญหาครึ่งหนึ่งของวิธีแบบ Go คือมันไม่บังคับอะไรกับผลลัพธ์เลย และบังคับตรวจ error แค่ขั้นต่ำเท่านั้น
x, err := strconv.Atoi("123"); fmt.Println("result:", x)จะเจอdeclared and not used: errแต่หลังจากแปลงค่าครั้งที่สอง ต่อให้ไม่ตรวจerrก็อาจรันผ่านได้โดยไม่รู้ว่ามีปัญหา เพราะค่า default ของyเป็น 0ถ้าเขียนว่างไว้แบบ
if err != nil { }ก็ยัง compile และรันได้ โดยไม่รู้ว่ามีอะไรผิดพลาดถ้าทำให้ค่าที่คืนกลับมาเป็น Result ก็จะถูกบังคับให้ต้องตัดสินใจ ต่อให้บางคนใช้
!มั่ว ๆ หรือใช้?โยนขึ้นไปแบบง่าย ๆ โดยไม่จัดการเคส error แล้วอย่างนั้นจะห้ามpanicด้วยไหม?และเพราะความยึดติดแปลก ๆ ว่าทุก type ต้องมี zero value ที่กำหนดไว้ เลยเพิ่ม sum type ไม่ได้ด้วย
?ใช้ง่าย ก็หมายความว่าจะไม่มีใคร wrap error อีกต่อไปเป็นตรรกะที่น่าสงสัยมาก
ตั้งแต่แรกก็ออกแบบให้
?ส่งเสริมการ wrap error ได้อยู่แล้วตัวอย่างเช่น สิ่งที่เทียบกับ
Fromของ Rust ใน Go ควรมีหน้าตาอย่างไร??มองเห็นได้ไม่เด่น และซ่อนการแตกแขนงของ control flow ไว้ใน statement หรือ expression เดียวนั่นก็เป็นเหตุผลหนึ่งที่ Go ไม่มี ternary operator และเลือกใช้ statement
ifที่แต่ละ branch อยู่คนละบรรทัดตั้ง breakpoint ก็ไม่ง่าย และทำให้เอนเอียงไปทางส่ง error ขึ้นไปตรง ๆ มากกว่าจะเสริมหรือจัดการ error
:=คือการประกาศและกำหนดค่าใน statement เดียว แต่ในบรรทัดที่ 5 ของตัวอย่าง มีการประกาศerrอีกครั้ง แล้วerrตัวใหม่ไม่ได้บังerrเดิมอยู่หรือ?ถ้าอย่างนั้นก็น่าจะ fail ด้วย
declared and not used: errเพราะตัวแปรerrใหม่ไม่ได้ถูกใช้หรือถ้ามีตัวแปรอยู่แล้ว
:=จะทำตัวเหมือนการ assign ปกติไปเลย?คำพูดที่ว่า “การที่ error ไม่มี stack trace แก้ได้ด้วยการให้ helper function สร้างและคืน error ที่เสริมข้อมูลแล้ว” มองโลกจริงในแง่ดีเกินไป
ภาษาที่มี stack trace ให้สิ่งนี้มาฟรี ๆ แต่ใน Go ต้อง implement เองทุกครั้ง
ตัวคุณเองอาจเป็นนักพัฒนาที่มีวินัยและใส่รายละเอียดเพิ่มเสมอ แต่ไม่ได้แปลว่าสมาชิกทีมทุกคนจะมีวินัยแบบเดียวกัน
สิ่งที่ดีที่สุดของ stack trace คือมันให้ call path ไปจนถึง error
ถ้าเกิด error ใน method ที่ถูกเรียกจากหลายที่ stack trace จะบอกได้ทันทีว่าเข้ามาทางเส้นทางไหน
ผมทำงานแนว sysadmin/SRE มาหลายปีและแก้ปัญหามามากมาย เวลามี stack trace ปัญหาง่าย ๆ ที่ต้นเหตุชัดเจนมักจบใน 1–2 นาที
แต่ใน Go ถ้ามีใครไม่เสริมข้อมูลให้ error หรือ reuse ข้อความ error เดิม ปัญหาง่าย ๆ ก็กลายเป็นงานสืบสวนและใช้เวลานานขึ้น