1 คะแนน โดย GN⁺ 4 시간 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • การตรวจสอบ nil ใน Go ช่วยป้องกัน panic ได้ แต่ถ้าวางซ้ำในตำแหน่งที่ผิด โค้ดจะไม่สามารถอธิบายได้เองว่า “อะไรบ้างที่อาจเป็น nil”
  • หากไปตรวจสอบ dependency ที่จำเป็น เช่น Redis client ภายในเมธอดภายใน จะเท่ากับมองความล้มเหลวในการสร้างให้เป็นเหมือนเส้นทางการทำงานปกติ
  • การกรอง nil ใน constructor อย่างเดียวไม่เพียงพอ แต่ควรจัดการความล้มเหลวทันทีที่ จุดเริ่มต้นการ initialize อย่าง NewRedisClient(addr)
  • ค่าที่เข้ามาจากภายนอกอย่าง request object ควรถูกตรวจสอบที่ ชั้นขอบเขต เช่น HTTP handler, RPC dispatcher, queue consumer และให้โลจิกภายในเชื่อถือการรับประกันนั้น
  • หากปล่อยให้สถานะที่ไม่ควรเป็นไปได้ผ่านไปแบบเงียบ ๆ ความล้มเหลวจะ เงียบ·ล่าช้า·คลุมเครือ และภายหลังต้องเสียค่าใช้จ่ายในการกู้คืนสัญญาณที่หายไปผ่าน metric, dashboard และ alert

การตรวจสอบ nil ไม่ได้เป็น defensive programming เสมอไป

  • หากต้องการป้องกัน panic ใน production จำเป็นต้องมี defensive programming ที่ตรวจสอบ input, ช่วงค่า และ pointer ก่อน deferred recover
  • การตรวจสอบ nil ในตำแหน่งที่ถูกต้องช่วยให้โค้ดปลอดภัยขึ้น แต่ถ้าตรวจในตำแหน่งที่ผิด มันจะเป็นสัญญาณว่าเราไม่สามารถตามรอยได้ว่าค่าใดบ้างที่อาจเป็น nil
  • มักพบแพตเทิร์นนี้บ่อยในโค้ดที่เกี่ยวกับการสร้างอ็อบเจ็กต์ แต่ก็ไม่ใช่เรื่องใหม่ และไม่ได้จำกัดอยู่แค่ AI
  • การตรวจสอบ nil ดูเหมือนต้นทุนต่ำและปลอดภัย แต่จะทิ้งข้อความไว้ให้ผู้อ่านคนถัดไปว่า “ค่านี้อาจเป็น nil ได้” ซึ่งมักสื่อความหมายผิด

ปัญหาของการตรวจสอบ dependency ที่เป็น nil

  • โค้ดที่ RateLimiter มีฟิลด์เป็น *redis.Client และตรวจ r.redis != nil ภายใน Allow ดูเผิน ๆ เหมือนปลอดภัย
  • แต่ถ้า Redis client เป็น nil ปัญหานั้นเกิดขึ้นไปแล้วตั้งแต่ ตอนสร้าง ไม่ใช่ตอนที่ Allow ทำงาน
  • การตรวจ nil ภายในเมธอดภายในทำให้สถานะที่สร้างไม่สำเร็จแต่ยังทำงานต่อ ถูกปฏิบัติเหมือนเป็นสถานะที่ยอมรับได้
  • การตรวจแบบนี้เป็นสัญญาณว่าโค้ดสูญเสียความชัดเจนเรื่องที่มาของอ็อบเจ็กต์ ความรับผิดชอบในการ initialize และ invariant ที่ nil ไม่ควรเกิดขึ้นได้

การตรวจ nil ใน constructor อย่างเดียวไม่พอ

  • การให้ NewRateLimiter(client *redis.Client) คืน error เมื่อ client == nil นั้นดีกว่า แต่ก็ยังไม่ใช่คำตอบทั้งหมด
  • แค่การที่ nil pointer ถูกส่งมาถึงฟังก์ชันนี้ได้ ก็หมายความว่าสถานะที่ผิดพลาดได้เข้ามาในระบบแล้ว
  • ความผิดพลาดจริงควรถูกจัดการที่จุด initialize ซึ่งเป็นจุดที่สร้าง Redis client
    • ถ้า redisClient, err := NewRedisClient(addr) เกิด error ต้องคืนกลับทันที
    • หลังจากนั้น NewRateLimiter(redisClient) ควรได้รับเฉพาะ client ที่ใช้งานได้เท่านั้น
  • เมื่อทำแบบนี้ constructor ของ RateLimiter ก็ไม่จำเป็นต้องคืน error ด้วย
  • หากจำเป็นต้องยอมให้มีสถานะที่ storage ใช้งานไม่ได้ชั่วคราว ก็ไม่ควรส่งต่อ nil แต่ควรห่อด้วย external type ที่เป็น non-nil เสมอ เพื่อ encapsulate การ retry หรือการทำงานแบบ degraded ไว้ภายใน
  • แนวคิดนี้คล้ายกับ NOT NULL หรือ foreign key constraint ในฐานข้อมูล
    • ถ้าแถวที่ไม่ถูกต้องไม่สามารถมีอยู่ได้ตั้งแต่แรก ทุก query ก็ไม่ต้องคอยตรวจสอบข้อมูลซ้ำ
    • ค่าขณะรันไทม์ก็เช่นกัน เมื่อสร้าง invariant ได้ครั้งเดียว โค้ดส่วนที่เหลือก็หลีกเลี่ยงการตรวจซ้ำได้

ต้นทุนของความล้มเหลวแบบเงียบ

  • การใช้การตรวจ nil หรือแค่เขียน log เพราะไม่อยากให้โปรแกรมหยุดจากการเปลี่ยนแปลงเล็กน้อย อาจให้ความรู้สึกว่ามั่นคง
  • แต่ในความเป็นจริง ตัวเลือกไม่ใช่ “แครชหรือทำงานต่อ” เท่านั้น แต่ใกล้เคียงกับ ล้มเหลวแบบชัดเจน กับ ล้มเหลวแบบเงียบ มากกว่า
  • error ที่ถูกคืนกลับอย่างชัดเจนมีคุณสมบัติ 3 อย่าง
    • ความชัดเจน: รู้ได้ว่าความล้มเหลวเกิดขึ้นแล้ว
    • ความทันทีทันใด: รู้ว่าล้มเหลวตรงใกล้กับต้นเหตุ
    • ความโยงกลับได้: ผู้เรียกสามารถเชื่อมความล้มเหลวนั้นกับงานที่กำลังทำอยู่ได้
  • ส่วน error ที่ถูกกลืนจะทำงานตรงกันข้าม
    • ความล้มเหลวหายไปแบบเงียบ ๆ
    • โค้ดอีกมากถูกเรียกต่อก่อนอาการจะค่อย ๆ ปรากฏ
    • พอเห็นอาการแล้วก็ยากที่จะระบุสาเหตุ
  • ยิ่งมีการเรียกที่ปล่อยให้โปรแกรมอยู่รอดในสถานะที่ผิดมากขึ้นเท่าไร ช่องว่างระหว่างสาเหตุกับอาการก็ยิ่งกว้างขึ้น
  • การแก้ที่ถูกต้องไม่ใช่ซ่อนความล้มเหลวไว้ในจุดเล็ก ๆ แต่คือการทำความเข้าใจว่า error ควรถูก propagate ไปที่ไหน และถูกเปลี่ยนเป็นการปฏิเสธ request, ทำให้งานล้มเหลว, retry, alert หรือหยุดระบบที่จุดไหน
  • หากการคืน error ทำให้ระบบหยุดมากเกินความจำเป็น ปัญหาไม่ได้อยู่ที่ฟังก์ชันนั้น แต่อยู่ที่ ขอบเขตการจัดการ error

ต้นทุนชั้นที่สองของการสร้างสัญญาณที่หายไปขึ้นมาใหม่

  • เมื่อความล้มเหลวเงียบลง บั๊กก็สามารถซ่อนอยู่ได้ เพราะเราไม่รู้ว่าเกิดอะไรขึ้นจริง
  • สุดท้ายจึงต้องสร้างโครงสร้างสำหรับสังเกตการณ์ เช่น metric, dashboard และ alert เพื่อจับการไม่เกิดขึ้นของพฤติกรรมที่ควรเกิด
  • ทุกครั้งที่เรายอมให้สถานะที่เป็นไปไม่ได้หรือไม่ได้รับการจัดการอยู่ในระบบ เรากำลังจ่าย ต้นทุนทางวิศวกรรม เพื่อฟื้นสัญญาณที่ทิ้งไปกลับมาทีหลังผ่านการสังเกตการณ์

บทบาทของชั้นภายนอกและชั้นภายใน

  • จุดที่การทำงานเริ่มต้นและมีข้อมูลจากภายนอกไหลเข้ามาคือ ชั้นภายนอก ส่วนโค้ดที่อยู่ลึกลงไปซึ่งการเรียกนั้นวิ่งไปถึงคือชั้นภายใน
  • ในช่วงเริ่มต้นของการทำงาน ยังไม่มีอะไรได้รับการรับประกัน แต่ในขณะเดียวกันก็ยังไม่มีงานใดถูกทำไป
  • ระหว่างขั้นตอน initialize ต้องตั้งค่าองค์ประกอบที่โปรแกรมพึ่งพา และตัดสินว่าองค์ประกอบแต่ละอย่างจำเป็นต้องมีเสมอ หรืออาจหายไปชั่วคราวได้
  • การออกแบบควรเอนเอียงไปทาง dependency ที่พร้อมใช้งานเสมอ และลด dependency ที่อาจหายไประหว่างทางให้น้อยที่สุด

ข้อมูลระดับ request ควรถูกตรวจสอบที่ขอบเขต

  • request object, ฟิลด์ของ request และค่าที่ derive มาจาก request แตกต่างจาก dependency แบบคงที่
  • request จะเข้ามาจากภายนอกทุกครั้งที่มีการเรียก ไม่ว่าจะผ่าน HTTP handler, RPC, queue, test helper หรือแพ็กเกจอื่น
  • การตรวจ req == nil ภายใน RateLimiter.Allow(ctx, req) ก็เป็นความผิดพลาดแบบเดียวกับการตรวจ dependency ที่เป็น nil
  • request ไม่ได้เพิ่งเข้ามาที่ Allow แต่เข้ามาตั้งแต่ขอบเขตการรับส่งที่อยู่ก่อนหน้านั้น และเคลื่อนผ่านโค้ดภายในมาแล้ว
  • ถ้าฟังก์ชันภายในอย่าง Allow ยังต้องมาตรวจซ้ำ ก็เท่ากับให้ฟังก์ชันลึกลงไปตรวจซ้ำสิ่งที่ชั้นภายนอกควรรับประกัน และทำให้ความไม่แน่นอนแพร่กระจาย

หลังตรวจที่ขอบเขตแล้ว โลจิกภายในควรเชื่อถือ invariant

  • การตรวจ nil ควรอยู่ที่ จุดขอบเขต ซึ่ง byte ที่ยังเชื่อถือไม่ได้ถูกแปลงเป็น internal type อย่าง *Request
  • ในตัวอย่าง HTTP handler ถ้า DecodeRequest(r) ล้มเหลว ก็ควรตอบกลับด้วย http.StatusBadRequest แล้ว return
  • หลังจากตรวจสอบเสร็จ req ก็เป็นค่าที่ถูกต้อง และหลังจากนั้น h.limiter.Allow(r.Context(), req) ก็ควรเชื่อถือค่านั้นได้
  • เพราะข้อมูลจากภายนอกเป็นสิ่งที่ควบคุมไม่ได้ การตรวจ nil และข้อจำกัดที่จำเป็นที่ขอบเขตจึงสมเหตุสมผล
  • เมื่อข้อมูลผ่านขอบเขตแล้ว มันจะถูก map เป็น internal type และ business logic และหลังจากนั้นก็กลายเป็น invariant ของระบบ
  • Allow ขั้นสุดท้ายจึงโฟกัสที่โลจิกจริงโดยไม่ต้องมีการตรวจ nil
    • userID := GetUserID(req)
    • ถ้า userID == "" ให้คืน false, nil
    • ไม่เช่นนั้นให้เรียก r.checkLimit(ctx, userID)
  • การตรวจ userID ว่างก็อาจย้ายไปไว้ที่ชั้น HTTP ได้เช่นกัน แต่ในตัวอย่างนี้ปล่อยให้ rate limiter เป็นเจ้าของนโยบายนั้น

การตรวจ nil ซ้ำ ๆ สร้าง branch ใหม่และพฤติกรรมใหม่

  • ระบบที่มีโครงสร้างแบบนี้จะให้เหตุผลได้ง่ายและแก้ไขเปลี่ยนแปลงได้ง่าย
  • ในทางกลับกัน ระบบที่ไม่มี invariant จะลงเอยด้วยการเติมการตรวจไว้ทั่วทุกที่ แล้วต้องตัดสินใจทุกครั้งว่าตรวจแล้วควรทำอะไรต่อ
  • การตรวจ nil แต่ละครั้งคือ branch ใหม่ และแต่ละ branch กำลังกำหนดพฤติกรรมใหม่ให้กับสถานะที่ไม่ควรมีอยู่
  • การตรวจ nil มีประโยชน์เมื่อใช้บังคับขอบเขตที่มีการระบุไว้ชัดเจน หรือใช้จำลองสถานะที่เป็น optional อย่างตั้งใจ
  • แต่ควรตั้งข้อสงสัยกับการตรวจ nil ที่จัดการสถานะซึ่งโปรแกรมถือว่าเป็นไปไม่ได้แบบเงียบ ๆ
  • ถ้าเห็นการตรวจ nil กระจายอยู่ทั่วไป มักเป็นหนึ่งในสองกรณี
    • เป็นโค้ดปกติที่ป้องกัน input จากขอบเขตที่ไม่น่าเชื่อถือ
    • เป็นปัญหาด้านการออกแบบที่ codebase ไม่สามารถสร้าง invariant ได้
  • ในระบบที่ยังไม่สามารถเชื่อถือพารามิเตอร์ใดได้เลย อาจจำเป็นต้องเพิ่มการตรวจไว้ก่อนทันที แต่สิ่งที่ต้องทำจริงคือสร้าง invariant ที่การตรวจเหล่านั้นกำลังทำหน้าที่แทนอยู่ และเปลี่ยนมันให้เป็นการรับประกันที่เชื่อถือได้

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

 
GN⁺ 4 시간 전
ความคิดเห็นจาก Lobste.rs
  • ขอฝากถึงโปรแกรมเมอร์ Go คนอื่น ๆ อีกครั้งว่า อยากให้ช่วย wrap error กันด้วย

    redisClient, err := NewRedisClient(addr)  
    if err != nil {  
      return nil, fmt.Errorf("Couldn't obtain new RedisClient: %w", err)  
    }  
    

    เมื่อ call stack คลายตัวลงมา ควรสะสมบริบทของ error ไปด้วย

    • ตัวอย่างที่เป็น idiomatic กว่าน่าจะหน้าตาแบบนี้
      redisClient, err := NewRedisClient(addr)  
      if err != nil {  
        return nil, fmt.Errorf("NewRedisClient: %w", err)  
      }  
      
      จากนั้นแต่ละเลเยอร์แค่เติมว่า error เกิดขึ้น ที่ไหน และให้ err ชั้นในสุดบอกว่าเกิด อะไร ขึ้น จะเป็นโครงสร้างที่ดี
    • น่าเสียดายที่ไม่มี stack trace สำหรับ error ที่เป็นเอกภาพและกลายเป็นมาตรฐานโดยพฤตินัย
      ในทางปฏิบัติ “การ wrap” มักกลายเป็นการ grep สตริงของ error แล้วหวังว่าสตริงนั้นจะไม่ซ้ำ และต้องพยายามคิดข้อความแบบฝืน ๆ เพื่อทำให้สตริงนั้นไม่ซ้ำ
    • บางคนบ่นว่า error stack ยาวเกินไป แต่คนส่วนใหญ่มองว่าข้อความแบบนี้ นำไปแก้ไขได้และมีประโยชน์
      เมื่อก่อนในผลิตภัณฑ์เน็ตเวิร์ก มีวิศวกรคนหนึ่งใช้เวลาหนึ่งเดือนแก้ข้อความ error หลายร้อยรายการ เพราะการมี “What the f-ck?” โผล่ใน log ไม่ได้ช่วยผู้ใช้ปลายทางเลย
      ต้องเปลี่ยนข้อความเหล่านั้นให้มีประโยชน์ และเพิ่ม error stack ด้วยเหตุผลเดียวกับข้างต้น
    • วิธีสมัยนี้เท่าที่จำได้คือหันไปใช้ errors.Join
  • ผมมองว่า Go สร้างปัญหาสองอย่างตรงนี้

    1. ถ้า Go มี nullability แบบชัดเจน ปัญหานี้แทบจะหายไปเอง
    2. ดูเหมือนจะไม่มีวิธีป้องกัน zero initialization ของ type ที่ตั้งชื่อได้ ดังนั้นความผิดพลาดจึงแทรกเข้ามาได้เสมอ
    • รู้สึกว่าประโยคนี้ในบทความชี้ให้เห็นปัญหาพื้นฐานได้ดี
      คือส่วนที่ว่า “เพราะควบคุมไม่ได้ว่าจะได้รับอะไรเข้ามา การตรวจว่าเป็น nil ที่ขอบเขตนั้นจึงสมเหตุสมผล”
      สำหรับ input จากภายนอกถือว่าถูกต้อง แต่ถ้า pointer ทุกตัวเป็น nil ได้ การติดตามขอบเขตที่ปลอดภัยภายใน codebase ก็ต้องอาศัยการอนุมาน
      ปัญหาของ Go คือมันบังคับให้การอนุมานนี้เกิดขึ้นในหัวของโปรแกรมเมอร์ทุกคน แทนที่จะให้ compiler ทำ
  • Rust มี Option<T> และ C# มี nullable type
    ผมคิดว่าในปี 2026 เราไม่ควรยังต้องเจอปัญหาแบบนี้แล้ว

    • ถ้ามองจากอีกฝั่ง ความสามารถในการแสดง “ไม่มี” หรือ “ขาดหาย” ได้อย่างกระชับมีประโยชน์มาก โดยเฉพาะเมื่อจัดการกับโครงสร้างข้อมูลตามอำเภอใจอย่าง JSON
      โดยทั่วไป syntax ในภาษาอาจไม่ใช่ส่วนที่น่าสนใจนัก แต่การเขียน foo.bar.baz ในภาษา scripting ที่ชอบนั้นง่ายกว่า foo.unwrap().bar.unwrap().baz ของ Rust มาก
      แม้ในฐานะคนที่ชอบ Rust ก็ยังมองแบบนั้น และถึง Go กับ Rust มักถูกจัดอยู่ในกลุ่มเดียวกัน แต่ Go ใกล้เคียงกับภาษา scripting ที่โปรแกรมเมอร์ C สร้างใหม่มากกว่า
      ถึงอย่างนั้น ถ้าภาษาใช้ null ค่า default ก็ควรเป็น ไม่เป็น null มากกว่า โดยเฉพาะถ้ามี syntax สั้น ๆ อย่าง ? หรือ .? ภาระด้าน syntax ในโปรเจกต์ใหญ่ก็ถือว่ายอมรับได้
    • ถ้าไม่ใช้ pointer ก็ไม่มี null ไชโย… 😭
  • ผมเข้าใจว่า Go ไม่ใช่ภาษาที่ model ออบเจกต์ที่ไม่เป็น null ได้ดีนัก
    ในจุดนี้คล้าย C และ Option<T> อาจแทนได้ด้วย T* แต่ T* ไม่ได้แปลว่า Option<T> เสมอไป
    โดยรวมเห็นด้วยกับบทความ ตอนทำงานที่บริษัทเฟิร์มแวร์ embedded ก็เคยพยายามโน้มน้าวให้เลิกใส่ null check กระจัดกระจายในโค้ด C++ แล้วใช้ assert แทน
    assert debug ง่าย ไม่ถูกนับเป็น branch ในแง่ coverage และสื่อสารเงื่อนไขที่คาดหวังให้ผู้อ่านชัดเจน ใน release build ก็ถูกตัดออกไป จึงมีประสิทธิภาพกว่าด้วย
    แต่ใน Go ผมเข้าใจว่า nil dereference ให้ข้อมูลสำหรับ debug ที่ดีอยู่แล้ว ประโยชน์ของ assert จึงไม่ได้มากเท่ากับใน C++

    • nil dereference ของ Go ดีกว่า null pointer dereference ของ C ตรงที่มัน panic อย่างแน่นอน แต่ก็ยังไม่ได้ยอดเยี่ยมขนาดนั้น เพราะ error จะเกิดก็ต่อเมื่อ pointer จริง ๆ ถูก dereference แล้วเท่านั้น
      ถ้าเป็นตัวอย่างในบทความ มันคงไปแตกอยู่ลึก ๆ ใน checkLimit แล้วต้องย้อนรอยว่า nil มาจากไหน ซึ่งอาจซับซ้อนพอสมควรขึ้นอยู่กับระบบหรือสถาปัตยกรรม
      ดังนั้นการ assert ทันทีภายใน NewRateLimiter ย่อมมีประโยชน์แน่ ๆ ในโค้ดตัวอย่างก็เท่ากับเปลี่ยนจาก
      if client == nil {  
          return nil, errors.New("redis client is nil")  
      }  
      
      เป็น
      if client == nil {  
          panic("redis client is nil")  
      }  
      
      อย่างไรก็ตาม ทีม Go คัดค้าน assertion อย่างหนัก และ panic ก็ไม่ใช่ทางที่เหมาะนัก เพราะถ้าไม่ถูกจับไว้จะทำให้ runtime ทั้งหมด crash
    • null check กับ assert ต่างกันโดยสิ้นเชิงในความเห็นผม
      assert หมายถึง “สถานะนี้ไม่ถูกต้อง” และ assert macro สามารถทำให้ null check นั้นกลายเป็นไม่ทำอะไรใน release build ได้
      ขึ้นอยู่กับวิธีกำหนด assert macro อาจเกิด optimization ที่เกี่ยวกับ undefined behavior ทำให้ check ภายหลังถูกลบออก และนำไปสู่ crash ที่ชวนสับสน
      เช่น เคยเห็นการนิยาม assert แบบที่ทำให้ใน assert(p); if (!p) { ... } check ข้างหลังถูกลบออก
      การพูดแบบเหมารวมว่า “อย่า null check ให้ใช้ assert” อาจถูกสำหรับ state invariant แต่ไม่เหมาะกับการตรวจ error
  • ในส่วนสรุปมีคำแนะนำที่ดีอยู่
    ถ้าเห็นการเช็ก nil โผล่เต็มไปหมด มันเป็นได้หนึ่งในสองอย่าง: ไม่ใช่โค้ดปกติที่ป้องกันอินพุตจากขอบเขตที่เชื่อถือไม่ได้ ก็เป็นปัญหาด้านการออกแบบที่ codebase ไม่สามารถตั้ง invariant ขึ้นมาได้
    ในระบบที่เชื่อถือพารามิเตอร์ใด ๆ ไม่ได้ ทางออกไม่ใช่การเพิ่มเช็กเข้าไปอีก ตอนนี้อาจจำเป็นต้องทำแบบนั้น แต่เงินจริง ๆ คือการสร้าง invariant ที่เช็กเหล่านั้นกำลังทำหน้าที่แทน และค่อย ๆ เปลี่ยน noise ที่เกิดจากความกลัวให้กลายเป็นการรับประกันที่ระบบพึ่งพาได้
    ผมมองว่านี่ไปไกลกว่า nil check การเพิ่มเช็กหรือโค้ดป้องกันในส่วน “ใบ” ของระบบ มักเป็นวิธีรับมือกับอาการที่ invariant ไม่พอหรือไม่ได้ถูกบังคับใช้อย่างถูกต้อง
    “เพิ่มเช็กอีกหนึ่งตัว” ทำเป็นค่าเริ่มต้นได้ง่าย แต่มีเพดานการขยายตัวอยู่ ถึงจุดหนึ่งตรรกะการเช็กจะมากกว่าตรรกะของฟีเจอร์ และความซับซ้อนรวมจะโตจนควบคุมไม่ได้
    เช็กเพิ่มเติมเพื่อกันบั๊กสักหนึ่งสองตัวมักไม่เป็นอันตราย แต่เมื่อรู้สึกว่าจำนวนและความซับซ้อนของเช็กเพิ่มมากเกินไป ระยะยาวแล้วการถอยออกมาหา ต้นตอของปัญหา แทนที่จะคอยแก้แต่ส่วนใบอย่างเดียว จะดีกว่าต่อทั้งระบบและชีวิตของผู้ดูแลรักษา

    • การ assert invariant นั้นยอดเยี่ยมเมื่อเริ่มทำแบบนั้นตั้งแต่แรกและรักษาไว้ต่อเนื่อง
      แต่ประเด็นที่ยากกว่าคือการฝึกให้นักพัฒนาหยุดทำ defensive programming
  • invariant แบบนี้ เช่นในที่นี้คือ ความไม่เป็น null สามารถ model ได้ดีกว่ามากใน type system ที่มีพลังในการสื่อความหมายมากกว่า Go
    บทความที่ผมชอบที่สุดในหัวข้อนี้คือบทความปี 2019 ของ Alexis King เรื่อง Parse, don't validate
    หลักการนี้ใช้ได้ทุกที่ แต่ใน type system ของ Haskell ดูเหมือนจะง่ายจริง ๆ ผมพยายามทำตามคำแนะนำของ Alexis ใน TypeScript มาหลายปี แต่ไม่ง่ายเลย

  • สรุปคือปัญหาไม่ใช่การมีเช็กมากเกินไป แต่คือ การห่อ nil ไว้เป็นค่า

  • ปัญหานี้ถูกพูดถึงซ้ำแล้วซ้ำเล่า และผมมองว่าเป็นผลจาก ภาษาที่ error handling ไม่ใช่ฟีเจอร์ระดับ first-class
    เท่าที่จำได้ ในเธรดอื่นก็เคยพูดถึงว่า linter มาตรฐานโดยพฤตินัยบังคับให้ใช้โครงสร้างแบบนี้
    ผมไม่แน่ใจว่า nil check เหล่านี้แย่ในเชิงตรรกะหรือไม่ หลายภาษามี error handling ในตัว และความต่างก็อยู่ประมาณความสม่ำเสมอและความเรียบง่ายของการ propagate
    ทางเลือกเมื่อต้องรับมือกับ interface ที่อาจเกิด error มีคร่าว ๆ สี่อย่าง: จัดการและกู้คืน, เพิกเฉย, propagate error, หรือทิ้ง error เดิมแล้ว propagate error ของตัวเอง ซึ่งอย่างหลังอาจ wrap error เดิมไว้ด้วยก็ได้
    ภาษาที่มี error handling เป็น first-class มักทำให้ข้อ 2 และ 3 ง่ายขึ้น และยิ่งเป็นภาษาสมัยใหม่ก็ยิ่งเป็นแบบนั้น ดังนั้นข้อ 4 ก็อาจทำได้ค่อนข้างสะอาด ขึ้นกับภาษา
    ข้อ 1 นั้นแม้จะมีการสนับสนุนแบบ first-class ก็ช่วยได้ไม่มาก นอกจากทำให้ชัดเจนขึ้นว่าจำเป็นต้องมีการจัดการแบบนั้น
    โดยพื้นฐานแล้ว ถ้าฟังก์ชันสามารถให้ error ได้ ทุกภาษาก็เหมือนทำ {error,result} = functioncall() แล้วตามด้วย if (error) { ... } ไม่ว่าจะ implement อย่างไรหรือไม่ก็ตาม
    Go ไม่มี error handling เป็น first-class จึงมีฟังก์ชันจำนวนมากที่คืน tuple (result, err) ล่วงหน้า และเมื่อ linter บังคับเช็ก err != nil โดยพฤตินัย โค้ดจึงดูเหมือนเต็มไปด้วยแพตเทิร์นนี้
    ผมมองว่าการที่ภาษาไม่จัดการ error handling ที่ถูกต้องโดยตรงเป็นข้อบกพร่องของการออกแบบภาษา แต่เมื่ออยู่ในตำแหน่งนั้นแล้ว model นี้ก็น่าจะใกล้เคียงกับทางเลือกที่ดีที่สุด
    ผมไม่แน่ใจว่าโค้ด Go ใช้ optional return type ตาม idiom เพื่อแยก error ที่มองข้ามได้ในเชิงฟังก์ชัน ออกจาก error ที่ “ต้องใส่ใจ” หรือไม่ ถ้าแม้แต่กรณีนั้นก็ยังนิยมคืน error type เสมอ linter ก็คงบังคับแพตเทิร์นนี้ตลอด
    ผมไม่ได้เกลียด Go แค่ไม่เห็นด้วยกับการตัดสินใจออกแบบอย่างหนึ่งเท่านั้น แทบทุกภาษาก็มีการตัดสินใจด้านการออกแบบให้บ่นได้ทั้งนั้น
    ผมมองว่าความผิดพลาดใหญ่ที่สุดของ Go คือการที่แทบทุกที่ต้องเช็ก err != nil อย่างชัดเจนในเชิงฟังก์ชัน และ linter ก็เลยต้องเรียกร้องสิ่งนี้ตามไปด้วย

  • ตอน Go ออกมาใหม่ ๆ ก็มีคนเป็นร้อย ๆ ชี้ให้เห็นว่าโครงสร้างทั้งหมดนี้น่าขันแค่ไหน
    แต่ภาษากลับได้รับความนิยมอย่างมาก และคำวิจารณ์ก็ถูกปัดตกไปท่ามกลางบรรยากาศว่า Rob Pike รู้ดีกว่า
    ตอนนี้ดีที่ได้เห็นผู้คนถกเถียงกันตามปกติด้วยเหตุผลเชิงตรรกะ
    ไม่ใช่ว่าเรื่องนี้ไม่เคยเป็นที่รู้กันมาตั้งแต่หลายสิบปีก่อนว่าเป็นไอเดียที่แย่ แต่ถ้า Google ทำก็คงดีแหละ… ใช่ไหม?

    • ผมไม่ใช่แฟน Go แต่ framing แบบนี้ทำให้ขัดใจ
      เพราะการเรียกว่า “เรื่องไร้สาระน่าขัน” มักไปกดทับ การคิดเชิงตรรกะ ที่บอกว่าอยากเห็นมากขึ้นนั่นเอง
      ผมจำไม่ได้ว่าเป็นพอดแคสต์ Oxide ตอนไหน แต่ Bryan Cantrill เคยพูดประมาณว่า “ผมอยากศึกษาสิ่งนี้ เพื่อจะได้เกลียดมันให้ดียิ่งขึ้น”
      ในความหมายนั้น ผมอยากเข้าใจว่าทำไมผู้คนในยุค 2010 ถึงตื่นเต้นกับ Go กันมาก บางส่วนเป็น hype แน่นอน และผมเคยเห็นกับตาในที่ทำงานตอนนั้นว่านักพัฒนาตื่นเต้นกันทั้งที่อธิบายไม่ได้ว่ามันดียังไง
      แต่มันคงไม่ใช่แค่ hype ล้วน ๆ ผมสงสัยว่า steel-man argument ที่แข็งแรงที่สุดในยุคนั้นสำหรับการใช้ Go คืออะไร