1 คะแนน โดย GN⁺ 2023-09-20 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • Go 1.22 เปลี่ยนตัวแปรของลูป for จากขอบเขตที่ครอบคลุมทั้งลูปเป็น ขอบเขตต่อรอบการวนซ้ำ เพื่อลดความผิดพลาดคลาสสิกของ Go ที่ closure จับตัวแปรเดียวกันผิดพลาด
  • ในความหมายเดิม แม้ไม่มี goroutine ฟังก์ชันที่ทำงานหลังจบการวนซ้ำก็อาจอ้างถึง v หรือ i ตัวเดิม ทำให้เห็นแค่ค่าท้ายสุดหรือทำให้การทดสอบ ผ่านแบบผิดๆ
  • ตัววิเคราะห์ loopclosure ของ go vet และ gopls จับได้เฉพาะกรณีที่แน่ชัด จึงเกิด false negative ได้ ส่วนตัวตรวจที่รุกมากกว่าจะเพิ่มโค้ด x:= x ที่ไม่จำเป็นเพราะ false positive
  • ความหมายใหม่จะใช้กับโมดูลที่ประกาศ go 1.22 ขึ้นไปใน go.mod เท่านั้น และใน Go 1.21 สามารถลอง พรีวิว ได้ด้วย GOEXPERIMENT=loopvar
  • Google บังคับใช้โหมดนี้กับทุกบิลด์ใน toolchain ภายในตั้งแต่ต้นเดือนพฤษภาคม 2023 และตลอด 4 เดือนก็ไม่มีรายงานปัญหาในโปรดักชัน แต่พบการทดสอบที่เขียนผิด

กับดักการจับตัวแปรของลูป for แบบเดิม

  • ตัวแปรลูป for แบบเดิมของ Go มี ขอบเขตครอบคลุมทั้งลูป ดังนั้นโค้ดที่อ้างถึงตัวแปรนั้นหลังจบการวนซ้ำอาจเห็นค่าที่ต่างจากที่ตั้งใจ
  • หากวน values := []string{"a", "b", "c"} แล้วสร้าง goroutine 3 ตัว แต่ละ goroutine จะพิมพ์ตัวแปร v ตัวเดียวกัน ไม่ใช่ v ของแต่ละรอบ
  • ปัญหาเดียวกันเกิดขึ้นได้ แม้ไม่มีงานพร้อมกัน
    • ถ้าเก็บ func() { fmt.Println(i) } ลงใน slice ภายในลูปแล้วค่อยนำไปรันทีหลัง แต่ละฟังก์ชันก็จะอ้างถึง i ตัวเดียวกัน ไม่ใช่ค่าของแต่ละรอบ

ปัญหาในโปรดักชันและข้อจำกัดของตัววิเคราะห์

  • ความผิดพลาดลักษณะนี้นำไปสู่ปัญหาในโปรดักชันของหลายบริษัท และ issue สาธารณะของ Let’s Encrypt ก็เป็นหนึ่งในนั้น
  • ในกรณีของ Let’s Encrypt ระหว่างการวน map ได้คัดลอก k ด้วย kCopy := k แล้ว แต่ modelToAuthzPB(&v) ใช้ pointer ไปยังฟิลด์ของ v ระหว่างสร้างผลลัพธ์ จึงจำเป็นต้องคัดลอก v แยกด้วย
    • การจับตัวแปรกินข้ามหลายฟังก์ชัน ทำให้สังเกตปัญหาได้ยาก
  • เครื่องมือวิเคราะห์แบบ static ตัดสินได้ยากว่าตัวแปรจะมีชีวิตอยู่ต่อหลังการวนซ้ำหรือไม่ จึงต้องประนีประนอมระหว่าง false positive และ false negative
    • ตัววิเคราะห์ loopclosure ของ go vet และ gopls รายงานเฉพาะปัญหาที่แน่ชัด จึงยอมรับ false negative
    • ตัวตรวจที่รุกมากกว่าอาจชี้โค้ดที่ถูกต้องว่าเป็นโค้ดผิด
  • เมื่อดู commit ในโค้ด Go แบบโอเพนซอร์สที่เพิ่มบรรทัด x := x จะพบว่ามีทั้งการแก้บั๊กจริงและการเปลี่ยนแปลงที่ไม่จำเป็นปะปนกันมาก
    • มีสถานการณ์ที่นักพัฒนาต้องเพิ่มโค้ดที่ไม่จำเป็นเพื่อให้ผ่านตัวตรวจ
    • ใน diff สองแบบอย่าง informer := informer และ a := a มีเพียงอันเดียวที่เป็นการแก้บั๊ก ส่วนอีกอันเป็นการเปลี่ยนแปลงที่ไม่จำเป็น แต่ถ้าไม่รู้ข้อมูลชนิดและฟังก์ชันก็แยกได้ยาก

ความหมายใหม่ของลูปใน Go 1.22

  • ใน Go 1.22 ตัวแปรของลูป for จะถูกเปลี่ยนให้มี ขอบเขตแยกสำหรับแต่ละรอบการวนซ้ำ
  • ตัวอย่างก่อนหน้านี้จะไม่ถือเป็นโปรแกรม Go ที่มีบั๊กอีกต่อไป และทั้งปัญหาในโปรดักชันจากความผิดพลาดลักษณะนี้รวมถึงความจำเป็นของเครื่องมือตรวจที่ไม่แม่นยำก็จะลดลง
  • เพื่อคงความเข้ากันได้แบบย้อนหลัง ความหมายใหม่นี้จะใช้เฉพาะกับแพ็กเกจในโมดูลที่ประกาศ go 1.22 ขึ้นไปใน go.mod
    • จึงสามารถย้ายแบบค่อยเป็นค่อยไปได้โดยไม่ต้องเปลี่ยนทั้งโค้ดเบสในครั้งเดียว
    • และยังควบคุมระดับไฟล์ได้ด้วยบรรทัด //go:build
  • โค้ดเดิมจะยังคงความหมายปัจจุบันไว้เหมือนเดิม
    • การเปลี่ยนแปลงจะมีผลเฉพาะกับโค้ดใหม่หรือโค้ดที่อัปเดตแล้ว
    • นักพัฒนาสามารถควบคุมได้ว่าแพ็กเกจใดจะเปลี่ยนไปใช้ความหมายใหม่นี้เมื่อใด

กลไกความปลอดภัยใน Go เวอร์ชันก่อนหน้า

  • ตามงานด้าน forward compatibility ของ Go นั้น Go 1.21 จะไม่คอมไพล์โค้ดที่ประกาศ go 1.22 ขึ้นไป
  • ใน point release ของ Go 1.20.8 และ Go 1.19.13 ก็มีการใส่การจัดการพิเศษเพื่อให้ได้ผลแบบเดียวกัน
  • หลังจาก Go 1.22 ออกแล้ว โค้ดที่เขียนโดยอาศัยความหมายใหม่จะไม่ถูกคอมไพล์ด้วยความหมายเดิมอีก เว้นแต่จะใช้ Go เวอร์ชันที่หมดการสนับสนุนแล้ว ที่เก่ามาก

ทดลองพรีวิวใน Go 1.21

  • Go 1.21 มี พรีวิว ของการเปลี่ยนขอบเขตลูปนี้มาให้แล้ว
  • หากคอมไพล์โดยตั้งค่า GOEXPERIMENT=loopvar จะละเลยบรรทัด go ใน go.mod และใช้ความหมายใหม่กับทุกลูป
  • หากต้องการตรวจสอบว่าแพ็กเกจและ dependency ทั้งหมดผ่านการทดสอบภายใต้ความหมายใหม่ของลูปหรือไม่ ให้รันดังนี้
GOEXPERIMENT=loopvar go test
  • ใน Go Playground สามารถทดลองความหมายใหม่ได้โดยใส่คอมเมนต์ // GOEXPERIMENT=loopvar ไว้บนสุดของโปรแกรม
    • โปรแกรมตัวอย่าง: ตัวอย่าง Go Playground
    • คอมเมนต์นี้มีผลเฉพาะใน Go Playground เท่านั้น
  • toolchain Go ภายในของ Google ถูกแพตช์ให้บังคับใช้โหมดนี้กับทุกบิลด์ตั้งแต่ต้นเดือนพฤษภาคม 2023 และหลังจากนั้นตลอด 4 เดือนก็ไม่มีรายงานปัญหาในโค้ดโปรดักชัน

บั๊กในเทสต์ที่ความหมายใหม่เปิดเผยออกมา

  • ความหมายใหม่ของลูปไม่ได้ทำให้เกิดปัญหาในโค้ดโปรดักชัน แต่ได้เปิดเผย การทดสอบที่ผ่านแบบผิดๆ
  • ในตัวอย่าง subtest ที่ใช้ t.Parallel นั้น Go 1.21 จะกันแต่ละ subtest ไว้จนกว่าทั้งลูปจะจบก่อน แล้วจึงค่อยรันแบบขนาน
    • เมื่อจบลูปแล้ว v จะเป็น 6 เสมอ ดังนั้นทุก subtest จึงตรวจว่า 6 เป็นเลขคู่และผ่าน
    • แต่ในเคสทดสอบจริงมีค่า 1 อยู่ด้วย ดังนั้นการทดสอบควรล้มเหลว
  • ใน Go 1.21 มีการปรับปรุงความแม่นยำของตัววิเคราะห์ loopclosure จึงสามารถระบุและรายงานปัญหานี้ได้
    • ตัวอย่างรายงานใน Go Playground: ตัวอย่างโปรแกรม
    • หาก go vet รายงานปัญหาลักษณะนี้ในเทสต์ การแก้ไขจะช่วยเตรียมพร้อมสำหรับ Go 1.22
  • เครื่องมือและตัวอย่างสำหรับค้นหาว่าลูปใดทำให้เทสต์บางตัวล้มเหลวเมื่อใช้ความหมายใหม่ ได้ถูกรวบรวมไว้ใน FAQ

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

  • รายละเอียดเพิ่มเติมของการเปลี่ยนแปลงดูได้จาก design document และ FAQ

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

 
GN⁺ 2023-09-20
ความคิดเห็นจาก Hacker News
  • อาจมีตัวอย่างที่เก่ากว่านี้มากก็ได้ แต่คำเตือนที่เก่าแก่ที่สุดเกี่ยวกับพฤติกรรมนี้ที่หาเจอจากการค้นหา 60 วินาทีคือ comp.lang.lisp FAQ ที่โพสต์ไว้เมื่อปี 1992 หรือกว่า 30 ปีก่อน
    มีการอธิบายว่า DOTIMES, DOLIST, DO ใช้การกำหนดค่าแทนการ bind ตอนอัปเดตตัวแปรที่ใช้วนซ้ำ ดังนั้นถ้า lambda จับ n เหมือนในตัวอย่าง closure ทั้ง 10 ตัวจะถูกสร้างขึ้นบนค่าของตัวแปร N ตัวเดียวกัน

    • D ก็มีปัญหาเดียวกัน: https://issues.dlang.org/show_bug.cgi?id=2043
      ถ้าจับแบบอ้างอิง จริง ๆ แล้วก็เป็นพฤติกรรมที่คาดไว้
    • ในมาตรฐานไม่ได้ระบุไว้ว่าลูปแบบนี้เปลี่ยนค่าหรือ rebind ค่า ดังนั้นถ้าจะจับตัวแปร ก็ควรสันนิษฐานว่าไม่ได้ rebind
      ถึงอย่างนั้น พอเรียนรู้วิธีทำงานสักครั้งแล้วก็ไม่ใช่ปัญหา และถ้าจำเป็นก็เลือกฟอร์มแล้ว macro expand เพื่อตรวจสอบวิธี implement ได้
  • ทีมภาษา C# ก็เจอปัญหาเดียวกันหลังจากนำ lightweight closure เข้ามาใน C# 4.0 และไม่นานก็เห็นชัดว่านี่เป็นกับดัก
    ผู้ใช้แทบจะใช้ตัวแปรลูปผิดอยู่เสมอ และใน C# 5.0 ก็ใส่การเปลี่ยนแปลงที่ทำให้เสีย compatibility เข้าไป
    Eric Lippert เขียนบทความที่อธิบาย “ทำไม” จากมุมนั้นได้ดี: https://ericlippert.com/2009/11/12/closing-over-the-loop-var...
    บทความประกาศ C# 5 เดิมหายากแล้ว หวังว่าจะไม่สูญหายไประหว่างการย้ายบล็อกหลายรอบบนโดเมนของ Microsoft หลังปี 2012

    • Python ก็ได้รับ feature request แบบเดียวกันหลายครั้งตลอดหลายปี แต่คำตอบมักเป็นว่า “ได้ประโยชน์ไม่มาก และทำให้โค้ดเดิมพัง”: https://discuss.python.org/t/make-lambdas-proper-closures/10...
      ถ้านึกถึงความวุ่นวายตอนเปลี่ยนจาก Python 2 เป็น 3 แค่เรื่องการเปลี่ยนชนิดสตริง ก็ไม่คิดว่าการเปลี่ยนแปลงนี้จะเข้าได้ก่อน Python 4.0
      แล้วก็คงมีใครสักคนบ่นว่า Python แย่เพราะไม่แก้เรื่องแบบนี้ แล้วก็หันมาด่า Python อีกทีเพราะสคริปต์ที่เขียนไว้เมื่อปี 2003 รันไม่ได้
    • jaredpar จากทีม C# เป็นคนคอมเมนต์แรกใน GitHub discussion ของข้อเสนอ Go นี้: https://github.com/golang/go/discussions/56010
      ผมคิดว่าเขามีบทบาทสำคัญในการช่วยให้ข้อเสนอเปลี่ยนภาษาแบบนี้ข้ามกำแพง “ปฏิเสธไว้ก่อน” ที่ควรมีโดยพื้นฐานได้
      อีกจุดที่โน้มน้าวได้มากคือผลจากการสแกนโค้ดเบสโอเพนซอร์ส เพื่อดูสมดุลระหว่างบั๊กที่จะถูกแก้กับบั๊กใหม่ที่จะเกิดขึ้น
    • Java ก็เคยมีปัญหานี้กับ anonymous class และโดยทั่วไปแก้ด้วยการนำ function object เข้ามาใช้
      เพราะเป็นการส่งค่า จึงจับสถานะของตัวแปร ณ เวลาที่เรียก ช่วยลดความกำกวมของโค้ด
      ถ้าพยายามจับตัวแปรแบบแปลก ๆ เช่น collection ที่ใช้สะสมเพื่อแปลง array เป็น map กับตัวแปรที่ประกาศไว้จะทำงานต่างกัน
      Go ดูเหมือนพยายามสร้างสมดุลโดยใช้พฤติกรรมนี้เฉพาะกับตัวนับลูป แต่ตัวแปรบางตัวยังคงทำงานแปลกอยู่
      โดยเฉพาะกรณีที่นิยามตัวแปรลูปหลายตัวเพื่อสแกนอินพุตโดยตรง ผมสงสัยว่าจะเกิดอะไรขึ้น
    • JavaScript ก็มีปัญหาเดียวกัน และได้เพิ่มลูป for(let) เข้ามา
    • สมกับเป็น Go คือไม่เรียนรู้จากภาษาก่อน ๆ แล้วเมินพฤติกรรมนี้ไป ก่อนจะกลับมาแก้ทีหลัง
  • https://eli.thegreenplace.net/2019/go-internals-capturing-lo... ดูเหมือนจะอธิบายปัญหานี้ละเอียดกว่า

    • น่าสนใจที่เหตุผลว่า trick i := i แบบเก่าทำงานได้นั้นต่างจากที่ผมคิดไว้โดยสิ้นเชิง
      ตอนแรกผมคิดว่า i ตัวใหม่ถูกส่งเข้า goroutine ดังนั้น escape analysis จะทำเครื่องหมายว่ามัน escape ออกนอก lexical scope จึงถูกจัดสรรบน heap และแต่ละรอบจะมีการจัดสรร heap หนึ่งครั้ง ทำให้ goroutine แต่ละตัวอ้างถึงตำแหน่งหน่วยความจำของตัวเอง
      จริง ๆ แล้วคอมไพเลอร์ Go มี heuristic สำหรับเลือก capture by reference หรือ capture by value และมีเงื่อนไขว่าค่าที่ไม่ถูกอัปเดตหลัง initialization จะถูก capture by value
      i ตัวใหม่อยู่ใน scope ของ body ของ for และตัวลูปเองไม่ได้อัปเดตมัน จึงถูกตัดสินว่าเป็นค่าที่ไม่ถูกอัปเดตหลัง initialization และสร้างโค้ดที่ capture by value โดยไม่ต้องจัดสรร heap
      ผมรู้ว่าแบบหลังดีกว่า แต่ก็อยากฟังจากคนที่รู้ Go ลึก ๆ ว่าทำไมแบบแรกถึงไม่เกิดขึ้นพร้อมกัน
  • การเปลี่ยนแปลงนี้จะไม่ทำให้โปรแกรมที่พึ่งพาพฤติกรรมปัจจุบันพังหรือ?

    • เพื่อรับประกัน ความเข้ากันได้ย้อนหลัง กับโค้ดเดิม semantic ใหม่จะถูกใช้เฉพาะกับแพ็กเกจในโมดูลที่ประกาศ go 1.22 ขึ้นไปใน go.mod เท่านั้น
      ในระดับไฟล์ก็สามารถตัดสินใจได้โดยใช้บรรทัด //go:build
    • ไม่รู้ว่าทำไมถึงโดน downvote แต่ในความเป็นจริง นี่เป็นการเปลี่ยนแปลงที่ถือว่าละเมิด คำมั่นเรื่องความเข้ากันได้ของ Go 1 จริง ๆ
      คำมั่นนั้นบอกว่าโปรแกรมที่เขียนตามสเปก Go 1 จะต้องคอมไพล์และทำงานได้อย่างถูกต้องต่อไปโดยไม่ต้องเปลี่ยนแปลงตลอดอายุของสเปก และถึงแม้สักวันอาจมีสเปก Go 2 ออกมา แต่ก่อนถึงตอนนั้น โปรแกรม Go ที่ทำงานได้ในวันนี้ก็ควรทำงานต่อไปได้แม้ใน point release อย่าง Go 1.1, Go 1.2
    • ในช่วงเตรียม Go 1.21 ได้วิเคราะห์คลังโค้ด Go ขนาดใหญ่มากเพื่อดูว่าอะไรจะได้รับผลกระทบบ้าง และบอกว่าจำนวนนั้นน้อยมาก ๆ
      พวกเขาคิดว่าจำนวนคนที่เจอบั๊กโดยไม่ตั้งใจจากดีไซน์นี้ น่าจะมากกว่าจำนวนคนที่จะได้รับผลกระทบจากการแก้ไขนี้มาก
    • ข้อเสนอเดิมพูดถึงการสำรวจกรณีใช้งานเดิมของไวยากรณ์นี้ไว้ค่อนข้างละเอียด
      เท่าที่จำได้ เขาบอกว่าแทบไม่มีกรณีใน codebase ของ Google หรือโค้ดบน GitHub ที่การเปลี่ยนแปลงนี้ทำลายพฤติกรรมที่คาดหวัง
      หลังจากตรวจสอบแล้วว่า codebase ที่ได้รับผลกระทบมีน้อยแค่ไหน และสร้างกลไกให้ต้องแก้โค้ดเชิงรุกเพื่อใช้พฤติกรรมใหม่ผ่านการระบุเวอร์ชันใน go.mod แล้ว จึงตัดสินใจทำลายความเข้ากันได้ย้อนหลัง
    • มีค่อนข้างเยอะ
      https://twitter.com/go100and1/status/1690412229135601664
      https://twitter.com/go100and1/status/1690587305806057472
      https://twitter.com/go100and1/status/1690589791686119424
      https://twitter.com/go100and1/status/1690591234715492352
      https://twitter.com/go100and1/status/1690593184857145344
      https://twitter.com/go100and1/status/1691456732151889920
      ส่วนใหญ่ไม่ได้ถูกกล่าวถึงในเอกสารข้อเสนอเลย
  • ใน Python ก็เคยเจอปัญหานี้เหมือนกัน แต่ไม่ใช่ช่วงหลัง ๆ
    ไม่แน่ใจว่า Python เปลี่ยนไปแล้ว หรือเป็นเพราะผมเริ่มสังเกตเห็นปัญหาเอง
    แค่โค้ดนี้ก็เพียงพอจะเห็นว่ายังอาจเป็นปัญหาใน Python ได้: funcs = [(lambda: x) for x in range(3)]; funcs[0]() จะพิมพ์ 2

    • เป็นพฤติกรรมที่ถูกต้องแล้ว
      เมื่อก่อน Python แย่กว่านี้อีก และแชร์ scope ไปถึงนอก list comprehension ด้วย
    • พฤติกรรมนี้เกิดจาก late binding ของ closure ใน Python
      เมื่อใช้ lambda ใน list comprehension หรือในลูป มันจะจับ reference ไปยังตัวแปร x ไม่ใช่ค่าปัจจุบันของ x
      ตอนเรียก funcs[0]() นั้น x ถูกตั้งค่าเป็นค่าตัวสุดท้ายของ range คือ 2 ไปแล้ว
      ถ้าต้องการพฤติกรรมที่ต้องการ ให้ส่ง x เป็นอาร์กิวเมนต์ค่าเริ่มต้นของ lambda: funcs = [(lambda x=x: x) for x in range(3)]
  • ผมเคยใช้ Go แค่นิดหน่อย และรู้ปัญหาทั่วไปที่การเปลี่ยนแปลงนี้แก้ แต่ยังไม่ค่อยเข้าใจตัวอย่างที่ละเอียดกว่าอย่างกรณี letsencrypt หรือ "range c.informerMap" เทียบกับ "range alarms"
    ใน for k, v := range someMap นั้น v เป็นชนิดค่าของ map และมี binding เดียวตลอดทั้งลูปที่ถูกคัดลอกในแต่ละรอบใช่ไหม? ถ้าเป็นแบบนั้นก็อธิบายปัญหาได้ แต่ผมคาดว่า v น่าจะเป็น reference ที่ชี้เข้าไปใน map
    ลองไล่อ่าน “For statements with range clause” ในสเปกอย่างเร็ว ๆ แล้วก็ไม่เจอคำตอบ แต่ผมแทบไม่ได้จับ Go เลย น่าจะดูผิดที่: https://go.dev/ref/spec#For_statements
    แก้ไข: คำตอบอยู่ในตารางรูปแบบ code block นั่นเอง เหมือนผมมองข้ามไปแบบเป็น banner เลย น่าประหลาดใจที่ v เป็น ค่าที่คัดลอกมา ไม่ใช่ reference

    • Go ไม่รองรับ pointer ไปยัง key หรือ value ของ map
      รองรับ pointer ไปยัง slot ของ array แต่ for range จะคัดลอกแทนที่จะให้ pointer ที่ชี้ไปยังแต่ละ slot
    • ถ้ามี map จาก string ไป int ชนิดของ v คือ int
      เป็นค่า ไม่ใช่ pointer ไปยัง int
    • เจอต้นฉบับของชิ้นโค้ดเหล่านี้แล้ว
      ถ้าสนใจก็ไปดูได้: https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
      https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
      โดยพื้นฐานแล้ว compiler กำลังเปลี่ยน go a.Monitor(b) เป็น (&a).Monitor(b) เพราะมีการ dereference อัตโนมัติ
  • ส่วนที่ว่า “จากผลของงานด้านความเข้ากันได้ไปข้างหน้า Go 1.21 จะไม่พยายามคอมไพล์โค้ดที่ประกาศ go 1.22 ขึ้นไป และใน point release อย่าง Go 1.20.8 กับ Go 1.19.13 ก็ใส่การจัดการพิเศษที่ให้ผลแบบเดียวกันไว้ด้วย ดังนั้นเมื่อ Go 1.22 ออก โค้ดที่เขียนโดยพึ่งพา semantics ใหม่จะไม่มีทางถูกคอมไพล์ด้วย semantics เดิมเลย เว้นแต่จะใช้ Go เวอร์ชันเก่ามากที่ไม่รองรับแล้ว” นี่ทำงานยังไงกันแน่
    ถ้าแพ็กเกจบางตัวล็อกไว้ที่ 1.22 แล้วผมคอมไพล์ด้วย 1.18 มันจะคอมไพล์ได้ไหม หรือจะ error ว่าต้องใช้คอมไพเลอร์ 1.22?

    • ใช้วิธีที่ค่อนข้างแยบยลนิดหน่อย
      เพราะใน Go 1.21 มีการเปลี่ยน รูปแบบหมายเลขเวอร์ชัน ในไฟล์ go.mod ถ้าพยายาม build ด้วย Go 1.18 ก็จะเจอ error อย่าง go.mod:3: invalid go version '1.21.0': must match format 1.23
      แต่กรณีนี้เป็นแบบนั้นเฉพาะตอนสร้างโมดูลด้วย go mod init เท่านั้น ถ้าเขียน go 1.21 ใน go.mod เอง ก็ build ได้โดยไม่บ่น
    • ที่น่าสนใจคือ ใน Go 1.21 ถ้าโมดูลประกาศ Go เวอร์ชันที่สูงกว่า พฤติกรรมเริ่มต้นคือไปดึง toolchain ที่ใหม่กว่ามาใช้แทน: https://go.dev/blog/toolchain
      เป็นฟีเจอร์ที่เจ๋งทีเดียว แต่ก็เป็นพฤติกรรมที่น่าประหลาดใจ และทำให้ลังเลนิดหน่อยเพราะมันจะไปเชื่อมต่อเซิร์ฟเวอร์ที่ Google ควบคุมเพื่อดาวน์โหลดไบนารี
      มันเป็นหนึ่งในฟีเจอร์ของ Go ที่ให้ความรู้สึกสองทางมากที่สุด ควบคู่กับ module proxy และผมน่าจะสบายใจกว่านี้มากถ้า Go ถูกดูแลโดยมูลนิธิที่ Google แค่มีส่วนถือหุ้นเท่านั้น
      แก้ไข: พอมาคิดดู นี่เป็นกรณีที่โมดูลปัจจุบันประกาศเวอร์ชัน ไม่ใช่ตอน dependency ประกาศเวอร์ชันอื่น จึงไม่ตรงกับคำถามเดิม
    • ตามที่ผมเข้าใจ ใน Go 1.18 ถึงมีโมดูล 1.22 เข้ามาเป็น dependency ก็ยังคอมไพล์ได้ และถ้าพึ่งพาฟีเจอร์นี้ก็อาจทำให้เกิด ลอจิกที่ผิด ได้
      ดังนั้นการใช้ Go 1.18 จึงกลายเป็นเรื่องอันตรายอย่างจริงจัง
      ใน Go 1.19 น่าจะเกิด compiler error
      ยังไง Go ก็ไม่ได้ backport การแก้ security bug ให้ release เก่า ๆ กับ standard library อยู่แล้ว ผมเลยมองว่าการใช้เวอร์ชันพวกนั้นเองก็อันตราย
    • ควรจะเกิด compile error
      แต่ถึงจะคอมไพล์ด้วย Go 1.22 โค้ดของคุณก็ยังมี semantics แบบ Go 1.18 อยู่ดี
  • Go เป็นภาษาที่แปลกมากในบางแง่
    เป็นภาษาที่มี ความคิดเห็นแรงมาก แต่ในขณะเดียวกันก็ดูเหมือนเป็นภาษาที่แทบไม่มีความคิดเห็นเลย

  • ผมไม่แน่ใจว่าความต่างระหว่างโค้ดที่วนผ่าน c.informerMap กับโค้ดที่วนผ่าน alarms คืออะไร แต่ถ้าให้เดา ตัวแปรลูปฝั่งหนึ่งอาจเป็น pointer ส่วนอีกฝั่งเป็น value
    เพราะการเรียกเมธอดใช้ pointer receiver กรณีที่เป็น value คอมไพเลอร์อาจใส่ reference ไปยัง receiver ให้โดยอัตโนมัติหรือเปล่า?

    • ผมหาต้นฉบับที่มีโค้ดชิ้นนี้เจอด้วย GitHub code search
      https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
      https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
      ความต่างคือในฝั่งหนึ่ง informer เป็น interface การเรียกเมธอดจึงถูกตีความเป็น informer.Run ทันทีและไม่มีปัญหา
      ส่วนอีกฝั่ง a เป็น struct Alarm และถูกคัดลอกเป็น value ขณะที่เมธอด Monitor รับ pointer receiver
      ดังนั้นคอมไพเลอร์จึงแปลง go a.Monitor(b) ให้เป็น go (&a).Monitor(b) โดยพฤตินัย และนั่นสร้าง reference ไปยังตัวแปรลูปจนทำให้เกิดปัญหา
    • ใน Go ถ้าวนผ่าน map ค่า value จะถูก copy เสมอ ดังนั้นโค้ดแรกดูเหมือนจะทำงานตามที่คาด
      ส่วนอันที่สอง ผมเดาว่า a สุดท้ายจะมีแค่ค่าของ element สุดท้ายใน alarms จึงเกิดปัญหาเดิมตามที่บทความอธิบาย
    • ดูจากชื่อแล้ว ข้างบนเป็น map ส่วนข้างล่างเป็น slice
      ความรู้วงในของผมมีแค่นั้น แต่ slice มี backing array บน heap จึงมี pointer หรือ reference เข้าไปเกี่ยวข้องอยู่บ้าง
    • ดูเหมือนว่ามันต้องมีอะไรบางอย่างในเชิงที่คอมไพเลอร์รู้ว่าจะจับค่าเข้ามา
  • อ่านแล้วรู้สึกโล่งใจมาก
    นี่คือการแก้ ข้อบกพร่อง ใหญ่ที่สุดอย่างหนึ่งของ Go

    • ไม่ใช่ ข้อบกพร่องใหญ่ที่สุดคือ การจัดการ error
      ถ้าเขียน foo, err := getFoo(); if err != nil ... แล้วต่อด้วย bar, err := getBar(); fmt.Println(bar) ก็จะพลาดการตรวจ error ของ getBar
      ด้วยกฎ scope ทำให้ pattern if foo, err := getFoo(); err != nil จัดการยากมากเมื่อ nesting ลึกขึ้นแค่นิดเดียว
      นอกจากนี้ยังนำสถานะที่ไม่ถูกต้องเข้ามาอีกด้วย ถ้า getFoo คืน error แล้วควรคืนอะไร? ทำให้ต้องคิดว่าจะเปลี่ยน API ให้คืน pointer เพื่อคืน nil หรือจะวางอ็อบเจกต์ที่สร้างมาบางส่วนซึ่งอยู่ในสถานะไม่ valid ไว้ดี
    • รอบหน้าก็แก้ การตรวจ nil ของ interface ต่อได้เลย