1 คะแนน โดย GN⁺ 2023-11-16 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • หัวใจสำคัญคือ กฎการจัดโครงสร้างโค้ด สองข้อ: ดึงการแยกสาขาตามเงื่อนไขขึ้นไปฝั่งผู้เรียก และผลักการประมวลผลแบบวนซ้ำลงไปยังฝั่งปฏิบัติการแบบแบตช์
  • เมื่อดัน if ขึ้นด้านบน จะบังคับ precondition ด้วย type หรือ assert ได้ง่ายขึ้น และรวม control flow ที่ซับซ้อนไว้ในที่เดียว ทำให้หาชุดเงื่อนไขที่ซ้ำซ้อนและ branch ที่ไม่มีวันถูกเรียกได้ง่ายขึ้น
  • โครงสร้างที่สร้าง enum แล้ว match กลับทันที เป็นการทำซ้ำเงื่อนไขเดิมในรูปของการแยกสาขา โครงสร้างข้อมูล และการแยกสาขาอีกครั้ง จึงมีช่องให้ทำให้ง่ายลงเป็นการเรียก foo(x) และ bar(y)
  • เมื่อดัน for ลงด้านล่าง batch operation จะกลายเป็นหน่วยพื้นฐานแทนการวนซ้ำบนอ็อบเจ็กต์ทีละตัว ทำให้ amortize ต้นทุนการเริ่มต้น เปลี่ยนลำดับการประมวลผล และทำ optimization แบบ vectorization กับ struct-of-array ได้
  • เมื่อนำเงื่อนไขออกนอกลูป จะลด branch ใน hot loop ได้ และยังนำไปสู่โครงสร้างแบบ TigerBeetle ที่ amortize ต้นทุนการตัดสินใจของ control plane ด้วยการประมวลผลแบบแบตช์ใน data plane

ดัน if ขึ้นด้านบน

  • เงื่อนไข if ภายในฟังก์ชันเป็นสิ่งที่ควรพิจารณาก่อนว่าย้ายไปยัง ผู้เรียก ได้หรือไม่
    • ตัวอย่างมองว่าฟังก์ชันที่รับ Walrus โดยตรงเป็นรูปแบบที่ดีกว่าฟังก์ชันที่รับ Option<Walrus> แล้วคืนค่ากลับเมื่อเป็น None
    • แทนที่จะตรวจ precondition ภายในฟังก์ชันแล้ว “ไม่ทำอะไรเลย” การให้ผู้เรียกตรวจสอบและบังคับให้เงื่อนไขเป็นจริงด้วย type หรือ assert จะชัดเจนกว่า
  • การรีแฟกเตอร์ที่ดึงการตรวจ precondition ขึ้นด้านบนอาจแพร่ต่อไปตามเส้นทางการเรียก และท้ายที่สุดอาจลด จำนวนครั้งของการตรวจสอบ ทั้งระบบได้
  • control flow และ if เพิ่มความซับซ้อน และมักเป็นสาเหตุของบั๊กได้ง่าย
    • เมื่อดัน if ขึ้นด้านบน ลอจิกการแยกสาขาที่ซับซ้อนจะไปรวมอยู่ในฟังก์ชันเดียว ส่วนงานจริงจะถูกลดลงเป็น subroutine ที่เดินตรงไปตรงมา
    • control flow ที่ซับซ้อนจะพบเงื่อนไขซ้ำซ้อนและเงื่อนไขที่ตายแล้วได้ง่ายกว่าเมื่ออยู่ในฟังก์ชันเดียวบนหน้าจอเดียว มากกว่ากระจัดกระจายไปทั่วไฟล์
  • การรีแฟกเตอร์ “dissolving enum” คือวิธีกำจัดแพตเทิร์นที่เปลี่ยน branch เป็นโครงสร้างข้อมูล แล้วกลับมา branch อีกครั้ง
    • ถ้า f() สร้าง E::Foo(x) หรือ E::Bar(y) ตามเงื่อนไข และ g(e) ใช้ match อีกครั้งเพื่อเรียก foo(x) หรือ bar(y) ก็เท่ากับเงื่อนไขเดียวกันถูกทำซ้ำในหลายรูปแบบ
    • เมื่อลากเงื่อนไขขึ้นไปที่ main() จะทำให้ง่ายลงเป็นรูป if condition { foo(x) } else { bar(y) }

ดัน for ลงด้านล่าง

  • จากมุมมอง data-oriented โปรแกรมและ hot path มักจัดการอ็อบเจ็กต์จำนวนมาก จึงนำ แบตช์ ของอ็อบเจ็กต์เข้ามา และถือให้ batch operation เป็นกรณีพื้นฐาน
    • เวอร์ชัน scalar สำหรับอ็อบเจ็กต์เดี่ยวจะกลายเป็นกรณีพิเศษของ batch operation
    • frobnicate_batch(walruses) เป็นรูปแบบที่ดีกว่า for walrus in walruses { frobnicate(walrus) }
  • ประโยชน์หลักคือ ประสิทธิภาพ
    • เมื่อจัดการทั้งแบตช์ในครั้งเดียว จะ amortize ต้นทุนการเริ่มต้นได้
    • สามารถเปลี่ยนลำดับการประมวลผลได้ยืดหยุ่นขึ้น และเปิดทางให้ใช้ vectorization หรือแนวทาง struct-of-array เช่นประมวลผลฟิลด์หนึ่งของทุก entity ก่อน
    • ตัวอย่างสุดขั้วเชื่อมไปยัง Vectorized Interpreters Talk
  • ตัวอย่างที่น่าสนใจคือ การคูณพหุนามด้วย FFT
    • วิธีประเมินพหุนามที่หลายจุดพร้อมกันอาจเร็วกว่าเมื่อเทียบกับการประเมินทีละจุดหลายครั้ง
  • กฎ if และ for สามารถใช้ร่วมกันได้
    • รูปแบบที่ดีคือโครงสร้างที่แยก branch ตาม if condition จากภายนอก แล้วค่อยทำ for walrus in walruses ในแต่ละ branch
    • รูปแบบที่ไม่ดีคือโครงสร้างที่ประเมิน if condition ทุกครั้งภายในลูป for
    • วิธีนี้ช่วยหลีกเลี่ยงการประเมินเงื่อนไขซ้ำ ลด branch ใน hot loop และอาจทำให้ vectorization เป็นไปได้
  • แพตเทิร์นนี้ใช้ได้ทั้งระดับไมโครและแมโคร
    • สถาปัตยกรรมของ TigerBeetle เป็นโครงสร้างที่ประมวลผลแบตช์ของอ็อบเจ็กต์ใน data plane พร้อมกัน เพื่อ amortize ต้นทุนการตัดสินใจของ control plane
    • แรงจูงใจหลักของคำแนะนำเรื่อง for คือประสิทธิภาพ แต่แนวทางที่ทำงานเป็นหน่วยคอลเลกชันเหมือน jQuery ก็อาจช่วยด้าน expressive power ได้เช่นกัน

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

 
GN⁺ 2023-11-16
ความคิดเห็นจาก Hacker News
  • ตอนแรกผมแปลกใจที่บทความนี้ถูกต่อต้านมากขนาดนี้ แต่พอนึกขึ้นได้ว่านี่เป็นคำแนะนำด้าน การออกแบบแบบเน้นข้อมูล ก็เข้าใจได้
    เหมือนกับหลายคนในฟอรัมนี้ ผมเองก็ทำเว็บแอปสำหรับงานธุรกิจเป็นส่วนใหญ่ และในบริบทแบบนั้นคำแนะนำนี้อาจดูเหมือนพูดเหลวไหลได้
    ถ้าในงานประจำวันคุณไม่ต้องสนใจ instruction cache คำแนะนำนี้ก็ดูเหมือนจะเป็น heuristic ที่ถูกต้องว่ามองข้ามไปได้เป็นส่วนใหญ่
    ถ้าอยากจับความรู้สึกให้ได้ว่าคำแนะนำนี้สำคัญเมื่อไร ลองค้นหา “Typical C++ Bullshit” ของ Mike Acton ดูก็ดี และบทความนี้ให้ความรู้สึกเหมือนเป็นการเรียบเรียงเนื้อหานั้นให้อ่านเข้าใจง่ายขึ้น
    ผมค่อนข้างเห็นด้วยกับประเด็นที่ Casey Muratori กังวล แต่ซอฟต์แวร์ธุรกิจส่วนใหญ่ควรปรับให้เหมาะกับความเปลี่ยนแปลงได้และความถูกต้องมากกว่าประสิทธิภาพ หรือก็คือ “การเขียนโปรแกรมในช่วงเวลาที่ยาวนาน”

    • ผมว่าคำแนะนำนี้ยังใช้ได้ค่อนข้างมากกับ การเขียนโปรแกรมเชิงวัตถุ ด้วย
      นักพัฒนามักพยายามแยก business logic ที่ซับซ้อนออกเป็นเมธอด private เล็ก ๆ ภายในคลาสเพื่อให้เป็น DRY แต่ “ยก if ขึ้นด้านบน” ช่วยไม่ให้ตรรกะการแตกกิ่งกระจัดกระจายไปตามหลายเมธอด
      “เลื่อน for ลงด้านล่าง” ก็สำคัญเช่นกัน โฟลว์การเรียกจำนวนมากสุดท้ายไปจบที่คิวรีฐานข้อมูลราคาแพง และมักเกิดกรณีที่ลูปชั้นบนทำให้ชั้นล่างเรียก DB หลายครั้ง
      การวนซ้ำนั้นมักแทนได้ด้วยเงื่อนไข where หรือ join ใน SQL และการ aggregate หรือ filtering ก็ควรผลักให้ไปอยู่ ใกล้ DAO มากกว่าจะดึงอ็อบเจ็กต์จำนวนมากออกมาวนลูป เพราะจะปรับให้เหมาะสมใกล้กับ DB ได้ง่ายกว่า
      อย่างไรก็ดี เช่นเดียวกับหลักการออกแบบทั้งหมด อย่านำไปใช้เหมือนหลักศาสนา แต่ควรตัดสินใจอย่างมีสติ
    • การออกแบบแบบเน้นข้อมูล ดูเหมือนจะกระตุ้นคนได้เสมอ น่าจะเพราะมันสื่อเป็นนัยว่าหลายส่วนของแนวทางเชิงวัตถุที่ครอบงำอยู่เดิมนั้นผิด
      ผมเสียดายที่มองความเปลี่ยนแปลงได้กับประสิทธิภาพเป็นสิ่งตรงข้ามกัน จริง ๆ แล้วผมยังไม่เห็นหลักฐานที่น่าเชื่อว่าทั้งสองอย่างขัดแย้งกัน
    • ความเปลี่ยนแปลงได้ ความถูกต้อง และประสิทธิภาพไม่ได้แยกกันโดยสิ้นเชิง แต่กลับมักไปด้วยกันบ่อย ๆ
      แง่มุมหนึ่งที่สำคัญที่สุดของประสิทธิภาพคือการทำให้เล็กลง โค้ดขนาดเล็ก โครงสร้างข้อมูลขนาดเล็ก จำนวนคำสั่งที่รันน้อยลงคือหัวใจสำคัญ และการ “คิดถึง instruction cache” โดยแก่นแล้วก็คือการเขียน โค้ดขนาดเล็ก
      ยิ่งโค้ดเล็ก พื้นที่ให้บั๊กแทรกตัวก็ยิ่งน้อย เข้าใจได้ง่ายกว่าในคราวเดียว และทำ test coverage ได้ง่ายกว่า จึงเป็นผลดีต่อความถูกต้อง ความเปลี่ยนแปลงได้ก็เช่นกัน ยิ่งโค้ดเล็ก การเปลี่ยนแปลงก็ยิ่งเล็ก
      แน่นอนว่ามีการปรับแต่งอย่าง parallelization, caching และ low-level optimization ที่ทำให้โค้ดซับซ้อนขึ้น แต่นั่นเป็นเพียงส่วนหนึ่งของการปรับประสิทธิภาพเท่านั้น และโปรแกรมเมอร์ที่ใส่ใจประสิทธิภาพอย่างจริงจังจะไม่ทำเรื่องพวกนั้นโดยไม่มีการ profiling และวิเคราะห์
      if/for กับ for/if อาจไม่ต่างกันมากในเชิงฟังก์ชัน แต่แบบหนึ่งอาจเร็วกว่าได้ ดังนั้นถ้าเขียนให้ถูกทางได้โดยไม่มีต้นทุน ก็ไม่มีเหตุผลต้องเลือกฝั่งที่ช้ากว่า
    • ถ้าฟังคำพูดของ John Ousterhout ในงานจริง ความเปลี่ยนแปลงได้·ความถูกต้อง ไม่ได้ขัดกับประสิทธิภาพมากเท่าที่คิดในตอนแรก
      โปรแกรมที่เรียบง่ายมักมี call stack สั้น หลีกเลี่ยง abstraction ที่ซับซ้อนและมีค่าใช้จ่ายสูง จึงมีแนวโน้มใช้หน่วยความจำน้อยกว่าและทำงานเร็วกว่า
      ถ้าไล่ตามประสิทธิภาพระดับสูงสุด โปรแกรมอาจซับซ้อนขึ้นได้ แต่ความเรียบง่ายที่แท้จริงส่วนใหญ่จะนำมาซึ่งประสิทธิภาพที่สมเหตุสมผล
      “เลื่อน for ลงด้านล่าง” เป็นคำแนะนำเชิงเน้นข้อมูลเป็นหลัก แต่ “ยก if ขึ้นด้านบน” ใกล้เคียงกับการทำให้โปรแกรมเรียบง่ายขึ้นมากกว่า พูดให้แม่นคือเป็นเรื่องการเพิ่ม locality ของซอร์สโค้ด และหัวใจคือการรวมตรรกะการแตกกิ่งไว้ในที่เดียว: https://loup-vaillant.fr/articles/source-of-readability
    • ผมคิดว่าคำแนะนำทั้งสองอย่างใช้ได้ค่อนข้างมากกับแอปธุรกิจด้วย
      โดยเฉพาะ “เลื่อนลูปลงด้านล่าง” นั้นทรงพลังในแอป CRUD การทำ create และ update แบบ batch ให้มากที่สุดเท่าที่เป็นไปได้ มักประหยัดเวลาได้มากกว่าคอขวด CPU หลายเท่า
      ความแตกต่างระหว่าง items.map(insertToDb/postToServer) กับ insertToDb/postToServer(items) แทบทุกกรณีต่างกันหลายหลัก
      ผมเคยเห็นการปรับแบบนี้ลดเวลางานจากระดับวินาที·นาทีลงมาเป็นระดับมิลลิวินาที และหลายครั้ง API ก็สะอาดขึ้น แถม log ก็อ่านง่ายขึ้นด้วย
  • ยิ่งมีประสบการณ์มากขึ้น ก็ยิ่งรู้สึกว่าโปรแกรมเมอร์จำนวนมากเกินไปใส่ใจ “โค้ดสวย” ในหน่วยเล็ก ๆ แต่ไม่ได้ใส่ใจ การออกแบบโค้ดเบสโดยรวม มากพอ
    ฟังก์ชันที่กระชับและขัดเกลาดีนั้นเป็นเรื่องดี แต่บทความแบบนี้อาจลุกลามใน PR หรือการถกเถียงเป็นการเถียงเรื่องโรงจอดจักรยานที่ไม่ก่อให้เกิดผลิตภาพได้
    ผมไม่ค่อยสนใจมากนักว่าฟังก์ชันจะเลอะเทอะนิดหน่อยหรือไม่, จะวาง if กับ for ไว้ตรงไหน, จะใช้ map กับ filter หรือไม่ ตราบใดที่ชื่อฟังก์ชันเหมาะสม อินเทอร์เฟซและไทป์สื่อความหมาย จุดประสงค์ชัดเจน มีเอกสารกำกับ และไม่ได้ใช้ side effect มากเกินไป

    • “ยก if ขึ้นไปไว้ข้างบน” ไม่ใช่การเถียงเรื่องโรงจอดจักรยาน แต่ใกล้เคียงกับ ตัวเลือกเชิงสถาปัตยกรรม ที่ต้องตัดสินใจอย่างที่พูดไว้มากกว่า
      ถ้าลองคิดว่าจะตรวจสอบความถูกต้องของอินพุตที่ไหน ตาม heuristic นี้ก็ควรตรวจตั้งแต่จุดบนสุดที่รับอินพุตเข้ามา
      สิ่งนี้ช่วยให้เข้าใจโค้ดได้ และในมุมมองของการพิสูจน์ก็จำเป็นในเชิงเทคนิคเช่นกัน เพราะควรทำให้เงื่อนไขตั้งต้นถูกส่งต่อขึ้นไปด้านบน
      คำแนะนำข้อแรกจึงไม่ใช่ข้อถกเถียงเล็กน้อยแน่นอน ส่วนข้อที่สองคลุมเครือกว่าเล็กน้อย
    • จากที่ทำงานด้านความปลอดภัยและได้พบ technical lead กับ architect ระดับประมาณ L7~L9 ตามมาตรฐาน FAANG มามาก คนที่หมกมุ่นกับ “การออกแบบที่ถูกต้อง” มากที่สุดมักเป็นคนที่มีประสบการณ์ประมาณ 5~10 ปี
      พวกเขาพยายามไม่ให้ junior ทำพลาดแบบยิงเท้าตัวเอง แต่บ่อยครั้งยังไม่มีสัญชาตญาณเพียงพอเกี่ยวกับต้นทุนของความซับซ้อนและโค้ดจะเปลี่ยนไปอย่างไรในระยะยาว
      ในทางกลับกัน เมื่อทำมานานกว่า 20 ปี ส่วนใหญ่จะให้คุณค่ากับ ความเรียบง่าย สูงกว่าองค์ประกอบทางเทคนิคส่วนมาก
      คำถามอย่าง “ควรแยก branch ให้เร็วแค่ไหน” หรือ “โค้ดนี้จริง ๆ ต้องทำอะไร” มักให้คำตอบที่มีคุณค่ามากที่สุดในระยะยาว
      คำถามเกี่ยวกับ abstraction และ encapsulation กลับมีแนวโน้มจะนำไปสู่ข้อถกเถียงแบบที่กล่าวไปข้างต้น และเหตุผลที่โค้ดของคนที่มองแต่ “ภาพใหญ่” มักมีปัญหาความปลอดภัยมากที่สุด ก็เพราะพวกเขาไม่เข้าใจดีพอว่าโค้ดเบสที่ออกแบบมาอย่างดีนั้นจริง ๆ ทำอะไร
    • หากหลักการที่ดูเหมือนเล็กน้อยเหล่านี้ถูกกำหนดไว้ล่วงหน้า ก็ช่วยลด การถกเถียงสิ้นเปลือง ประเภทเดิม ๆ ใน PR และการสนทนาได้
      ในซอฟต์แวร์ที่ไวต่อประสิทธิภาพและเหมาะกับ data-oriented design ต้องใส่ใจกับเรื่องระดับหน่วยเล็ก ๆ แบบนี้มากขึ้น เพราะ compiler optimization ทำงานในลักษณะนั้น
      เมื่อนั้นกติกาของเกมจะเปลี่ยนไป ความหมายของ statement จะสำคัญขึ้น โค้ดที่อธิบายตัวเองได้จะมีความหมายมากขึ้น คอมเมนต์ในโค้ดจะเหลือไว้เพื่อใช้ในการอนุมานเท่านั้น และ “เอกสาร” จะใกล้เคียงกับสเปกและคู่มือผู้ใช้มากขึ้น
    • ผมเห็นด้านตรงข้ามมามากเช่นกัน มีคนจำนวนมากที่ชอบออกแบบ มหาวิหารที่ over-engineered แต่ไม่คิดถึงประสิทธิภาพระดับต่ำของอัลกอริทึม
    • เหตุผลหนึ่งคือภาษาโปรแกรมยังขาดวิธีที่จะแสดง component ที่ใหญ่กว่าฟังก์ชันมาก ๆ หรือ component ที่เชื่อมต่อกันด้วยวิธีต่างจากฟังก์ชันได้อย่างมีความหมาย
      อย่างมากก็แค่จัดกลุ่มฟังก์ชันเข้าด้วยกันได้ แล้วจากนั้นก็ต้องจัดการกันเอง
  • “ยก if ขึ้นไปไว้ข้างบน” มีข้อเสียคือ precondition และ postcondition ไม่ปรากฏให้เห็นโดยตรงภายในนิยามฟังก์ชัน และต้องไปตรวจดูที่แต่ละจุดเรียกใช้
    ในโปรเจกต์ขนาดใหญ่ที่มีหลายคนร่วมทำ ฟังก์ชันแบบนี้อาจถูกนำกลับมาใช้ใหม่นอกบริบทที่ตั้งใจไว้และนำไปสู่บั๊กได้
    ใช้ contract framework ก็แก้ได้ แต่จะต้องเขียนเงื่อนไขซ้ำทั้งใน contract และในโค้ด และ dependent type ก็เช่นเดียวกัน
    วิธีแท็กพื้นที่ของโค้ดที่อยู่ในบริบทเฉพาะ แล้วกำหนดฟังก์ชันที่เรียกได้เฉพาะภายในบริบทนั้น เป็นแนวคิดที่น่าสนใจ
    ถ้าเป็น Python อาจใช้ decorator อย่าง @requires_context("VALIDATED_XY") และ context manager ชื่อ validated_xy เพื่อทำให้เรียกฟังก์ชันได้เฉพาะในพื้นที่ที่ผ่านการตรวจสอบความถูกต้องแล้ว
    runtime จะไม่รู้ความหมายของบริบทนั้น แต่ถ้ามีเครื่องมือและการทดสอบ ก็สามารถออกแบบให้ตั้งบริบทที่ต้องการเฉพาะเมื่อเงื่อนไขบางอย่างเป็นจริงเท่านั้นได้
    ในภาษาอย่าง Haskell สามารถบังคับในระดับไทป์ด้วยสิ่งอย่าง identity monad ได้ และแม้ไม่ใช่การบังคับระดับไทป์ ก็อาจเป็นวิธีที่น่าสนใจในการปกป้องพื้นที่โค้ด “unsafe”

    • ดูเหมือนว่าคุณพลาดประเด็นส่วนที่สองของผู้เขียนไป ประเด็นคือ “ผลักการตรวจ precondition ไปให้ caller และ บังคับด้วยไทป์ ได้”
      ดังนั้น precondition ยังมองเห็นได้โดยตรงในนิยามฟังก์ชัน เพียงแต่ปรากฏเป็นส่วนหนึ่งของ type signature ไม่ใช่คำสั่ง if
      นี่เป็น pattern ที่พบได้บ่อยใน Rust ที่ใช้ในบทความ และต่างจากการตรวจด้วย if ตรงที่เป็น precondition แบบเข้มงวดซึ่งตรวจตอน compile time ไม่ใช่ runtime หากไม่เป็นไปตามนั้นโปรแกรมจะคอมไพล์ไม่ผ่าน
    • ใน Python ก็สามารถแสดงสิ่งนี้ด้วยไทป์ได้เช่นกัน
      ตัวอย่างเช่น รับค่าแบบ do_something(position: ValidatedPosition) แล้วตรวจสอบ Position ปกติก่อน จากนั้นแปลงเป็น ValidatedPosition เพื่อส่งต่อ
      ในทางปฏิบัติคง encapsulate การตรวจไว้ใน constructor ของ ValidatedPosition แต่ประเด็นสำคัญคือถ้าพยายามส่ง Position เข้าไปตรง ๆ mypy จะบอกว่าคุณส่งไทป์ผิด
      การตรวจไทป์ของ Python ไม่ครอบคลุมเท่า Rust แต่เรากำลังใช้มันอย่างมีประโยชน์มากขึ้นเรื่อย ๆ เมื่อต้องการรับประกันว่าข้อมูลที่ถูกส่งต่อได้รับการจัดการอย่างเหมาะสมแล้ว
    • คำแนะนำ “ยก if ขึ้นไปไว้ข้างบน” ไม่ได้เกี่ยวกับการตรวจ precondition แต่เกี่ยวกับ การเลือกเส้นทางโค้ดที่ถูกต้อง
      ถ้าฟังก์ชันมี precondition ก็แน่นอนว่าสามารถ assert ที่จุดเริ่มต้นของฟังก์ชันได้ ตัวอย่างเช่น type system ของ Java อนุญาตให้เป็น null ดังนั้นฟังก์ชันที่ต้องการ object ควรโยน exception เมื่อมี null เข้ามา
      แต่ละจุดเรียกมีหน้าที่เรียกฟังก์ชันเฉพาะเมื่อ precondition เป็นจริง ซึ่งเป็นเรื่องชัดเจนตามนิยามของ precondition
      การเรียกฟังก์ชันโดยละเมิด precondition เป็นบั๊กของ caller อาจจำเป็นต้องมีโค้ดในฟังก์ชันเพื่อตรวจเรื่องนี้และป้องกัน undefined behavior แต่ต้องแยก assert แบบนี้ออกจาก control flow จริงของโปรแกรม และบทความพูดถึงอย่างหลัง
    • ในตัวอย่าง fn frobnicate(walrus: Walrus) นี้ หากส่งสิ่งที่ไม่ใช่ Walrus ที่ตนเป็นเจ้าของเข้าไป โปรแกรมจะคอมไพล์ไม่ผ่าน
      แม้จะเป็น generic อาร์กิวเมนต์ก็ต้องเป็นไปตาม trait bound และขึ้นอยู่กับว่าภายในฟังก์ชันใช้อาร์กิวเมนต์อย่างไร compiler จะเรียกร้อง bound ที่จำเป็นสำหรับนิยามฟังก์ชัน
    • ผมคิดว่าเจตนาของ public และ private อาจเดิมทีตั้งใจให้ทำหน้าที่แท็กโค้ดให้เข้ากับบริบทเฉพาะในระดับหนึ่ง
      หรือไม่ก็อาจต้องมี semantics ที่เฉพาะเจาะจงกว่านี้ซึ่งครอบคลุมข้ามขอบเขตที่ public, private และ protected ในระบบนิเวศ .NET จัดการอยู่
  • ตัวอย่างแรกไม่ดีด้วยเหตุผลอื่น ไม่ใช่เพราะ if กับ for
    โดยทั่วไป ถ้ามี “คอนเทนเนอร์” ของอะไรสักอย่างอยู่ การเขียนฟังก์ชันไว้กับ “ออบเจ็กต์” ระดับโดเมนที่อยู่ข้างใน แทนที่จะเขียนไว้กับคอนเทนเนอร์ จะดีกว่า
    เวลาใช้ agent ใน Clojure เราไม่ได้เขียนฟังก์ชันสำหรับ agent แต่เขียนฟังก์ชันสำหรับสิ่งที่ agent อาจบรรจุไว้
    ใน Elixir ก็เช่นกัน ฟังก์ชันโดเมนหลักจะทำงานกับโครงสร้างข้อมูลโดเมนภายใน ไม่ใช่ PID และถ้าจำเป็น การเรียก GenServer ก็จะ delegate ไปที่ตรงนั้น
    วิธีนี้ยืดหยุ่นกว่า และแยก “การ frobnicate Walrus” ซึ่งเป็นโดเมนหลัก ออกจาก “อาจมีหรือไม่มี Walrus อยู่ และถ้ามีก็ให้ frobnicate” ซึ่งเป็นข้อกังวลของแอปพลิเคชัน ได้สะอาดกว่า

    • คำแนะนำที่ให้อาจมีแนวโน้มยกตรรกะการตรวจสอบขึ้นไปสูงเกินไป
      การตรวจสอบตั้งแต่ต้นเป็นเรื่องดี แต่การทำให้เกิด ข้อผิดพลาดจากการตรวจสอบ อย่างชัดเจน แทนที่จะให้ฟังก์ชันระเบิดด้วยข้อผิดพลาดประหลาด ๆ อย่างกะทันหัน ก็สำคัญเช่นกัน
      Haskell แก้ปัญหานี้ด้วย newtype โดยให้ “คอนเทนเนอร์แบบโปร่งใสที่รับรองว่าได้ผ่านการตรวจสอบที่เหมาะสมแล้ว”
      คำแนะนำที่อยากเน้นกับผู้คนจริง ๆ คือให้ชอบ “sad if” มากกว่า แทนที่จะซ้อนเส้นทางปกติหลายชั้น โค้ดที่ตรวจเงื่อนไขผิด ๆ ทีละอย่างจากด้านบน แล้วแก้ไขหรือหยุดทันที แทบจะอ่านง่ายกว่าและบำรุงรักษาง่ายกว่าเสมอ
      มนุษย์มีธรรมชาติที่อยากโฟกัสกรณีที่คาดหวัง แต่โค้ดกลับอยากให้โฟกัสกรณียกเว้นก่อน if ทุกตัวสร้างภาระในหัว และถ้าต้องดึงข้อมูลจากระบบภายนอกหรือออกก่อนด้วยข้อผิดพลาด เมื่อการตรวจพบและการจัดการอยู่ติดกัน ก็วางภาระนั้นลงได้ทันที
    • ถ้าหมายความว่าตัวอย่างแรกควรเป็น walrus.frobnicate() มากกว่า ผมว่าเรื่องนั้นใกล้กับ รสนิยมด้านไวยากรณ์ มากกว่าประเด็นหลักที่ผู้เขียนอยากพูด
  • คงไม่ถึงกับบอกว่านี่เป็นคำแนะนำที่แย่ แต่ก็ไม่จำเป็นต้องเป็นคำแนะนำที่ดีเสมอไป
    การที่ภาษาที่เลือกเป็น Rust บอกอะไรได้ค่อนข้างมาก ระบบ type ที่แข็งแรงช่วยป้องกัน defensive programming จำนวนมากที่จำเป็นในภาษาอื่น
    ถ้าโปรแกรมเมอร์ C ไม่ตรวจสอบความถูกต้องของพอยน์เตอร์ที่ส่งเข้าไปในฟังก์ชันจนเกิด NULL dereference ผมคงไม่อยากทำงานทีมเดียวกับคนนั้น
    ดังนั้นอย่างน้อย if บางส่วนควรอยู่ด้านล่างแน่นอน และจะดีถ้าข้อผิดพลาดถูกส่งต่อขึ้นไปได้ดี
    สำหรับ for ผมรู้สึกไม่แรงเท่า แต่เพราะใน C อาร์กิวเมนต์ที่เป็นอาร์เรย์จะ decay เป็นพอยน์เตอร์ ผมจึงคิดว่าการวนซ้ำก็ควรอยู่ด้านบน ไม่ใช่ด้านล่าง ในฟังก์ชันที่สร้างอาร์เรย์ขึ้นมา เรายังเชื่อถือความยาวได้ แต่ในฟังก์ชันที่รับเป็นอาร์กิวเมนต์แล้วไม่ใช่แบบนั้น

    • อินเทอร์เฟซของ C ควรบันทึกความคาดหวังไว้อย่างรอบคอบ และตรวจสอบแค่เท่านั้น
      เอกสารควรทำหน้าที่แทนระบบ type ที่แข็งแรง ไม่ใช่ให้การตรวจสอบตอนรันไทม์มาทำแทน
      โค้ดที่เต็มไปด้วยการตรวจ NULL และมาตรการป้องกันอื่น ๆ อ่านยากกว่ามาก
      อาจบอกได้ว่าตรงขอบเขตของไลบรารีต้องมีการตรวจสอบเชิงป้องกันมากขึ้น และบทความนี้ก็กำลังบอกให้ดันการตรวจสอบแบบนั้นขึ้นไปพอดี
      โค้ดที่สำคัญด้านความปลอดภัยอาจต่างออกไป แต่ในกรณีส่วนใหญ่ NULL dereference ที่เกิดโดยไม่ตั้งใจ แค่ถูกจับได้ด้วยการทดสอบ, sanitizer และ fuzzing ก็เพียงพอแล้ว
    • ใน C ค่า NULL มักเป็นค่าพอยน์เตอร์ที่ถูกต้องแต่ไม่สามารถ dereference ได้
      ถ้าจะตรวจค่าพอยน์เตอร์ที่ไม่ถูกต้อง ก็ต้องตรวจค่าผิดที่เป็นไปได้จำนวนมหาศาลทั้งหมด และถ้าตรวจแค่ NULL จริง ๆ แล้วก็ไม่ได้ตรวจค่าที่ไม่ถูกต้องโดยทั่วไป
      ถ้า precondition ของฟังก์ชันคือ “พารามิเตอร์ p ต้องไม่เป็น NULL” ก็ตรวจได้ แต่ถ้า precondition คือ “p ต้องเป็นพอยน์เตอร์ที่ถูกต้อง” ก็ขอให้โชคดีกับการหา assertion ที่เหมาะสม
    • ฮิวริสติกของผมคือ ถ้าระบบ type ป้องกันค่าที่ผิดไม่ได้ ก็เป็นหน้าที่ของรันไทม์ที่จะป้องกัน
      ช่วงนี้ผมใช้ T-SQL เยอะ และไม่สามารถประกาศพารามิเตอร์หรือตัวแปรเป็น NOT NULL ได้
      ดังนั้นจึงควรตรวจ NULL ให้เร็วที่สุดเท่าที่สมเหตุสมผล โดยปกติคือด้านบนสุดของ stored procedure
      ไม่อย่างนั้น NULL อาจแพร่ต่อไปลึกในลำดับชั้นการเรียกอย่างไม่คาดคิด และสร้างปัญหาที่ไม่ชัดเจนกว่า
      โชคดีที่ข้อมูลในตารางสามารถประกาศเป็น NOT NULL ได้ บั๊กแบบนี้จึงมักไม่ทำให้ข้อมูลเสียหาย แต่การจับให้ได้ตั้งแต่ต้นนั้นง่ายกว่ามาก
      อย่างไรก็ตาม ถ้ามีตรรกะที่เขียนลง DB ตามค่าพารามิเตอร์บางค่า แล้วค่านั้นกลายเป็น NULL โดยไม่คาดคิด ก็อาจเขียนค่าผิด หรือไม่เขียนค่าที่จำเป็นเลย ซึ่งโดยเนื้อแท้แล้วทำให้ข้อมูลเสียหายได้
      ดังนั้น defensive programming จึงเป็นคำตอบ
  • ถ้าดูโดยไม่มีบริบทที่เหมาะสม คำแนะนำนี้ค่อนข้างแปลก และอาจถึงขั้นเป็นคำแนะนำที่แย่
    ทั้งลูปและคำสั่ง if ต่างก็เป็นการทำงานด้าน control flow ดังนั้นข้อโต้แย้งบางส่วนในบทความจึงไม่ค่อยสมเหตุสมผล
    เหตุผลที่หนักแน่นที่สุดดูเหมือนจะเป็นเรื่องประสิทธิภาพ แต่โดยปกติประสิทธิภาพควรเป็นเรื่องที่คิดทีหลังสุด และยิ่งถ้าเป็นคำแนะนำแบบฮิวริสติกก็ยิ่งเป็นเช่นนั้น
    น่าเสียดายที่ผู้เขียนทำให้มันกลายเป็น catchphrase ไปแล้ว และหวังว่าจะไม่แพร่ต่อ

    • ประสิทธิภาพเป็นเหตุผลได้ แต่ไม่ใช่เหตุผลที่หนักแน่น
      ถ้าเขียนโค้ดที่ปรับปรุงแล้วได้เหมือนในตัวอย่างของผู้เขียน เงื่อนไขนั้นก็เป็นค่าคงที่ตลอดการทำงานของลูป ถ้าไม่ได้ประเมินเงื่อนไขที่แพงทุกครั้ง branch prediction ก็จะจัดการให้เป็นส่วนใหญ่
      ถ้าเงื่อนไขเป็นนิพจน์บูลีนที่ประกอบจากค่า const คอมไพเลอร์ก็มีโอกาสรู้ได้เช่นกัน
  • ผมคิดว่าทั้งบทความจะดำเนินต่อด้วยตัวอย่างแบบนี้: แยก walrus ตามเงื่อนไข แล้วเรียก frobnicate_batch(fwalrus) กับ transmogrify_batch(twalrus)
    แต่จริง ๆ แล้วกลับไปเป็นตัวอย่างที่เอาเงื่อนไขหนึ่งอันไปครอบลูปทั้งหมดเป็นสองทาง

  • น่าแปลกที่โปรแกรมเมอร์ที่มาจากพื้นฐานวิศวกรรมซอฟต์แวร์มักทำเรื่องนี้ผิดบ่อย ๆ
    ผมเริ่มเขียนโปรแกรมในสายวิทยาศาสตร์ และที่นั่นการคิดเรื่องแบบนี้จำเป็นอย่างยิ่ง
    แค่จัดลำดับลูปผิดก็อาจทำให้การจำลองใช้เวลา 1 สัปดาห์แทนที่จะเป็น 1 ชั่วโมง
    พื้นฐานแบบนั้นทำให้ผมปรับแต่งเล็ก ๆ โดยจัดลำดับ for กับ if ให้เหมาะสมโดยสัญชาตญาณ และโค้ดที่ไม่ทำแบบนั้นก็ดูเหมือนผิดไปเลย

  • ไม่แน่ใจว่ากฎทั่วไปแบบนี้จะนำไปใช้กับโค้ดจริงได้จริงแค่ไหน
    กฎแบบนี้มักดูเหมือนหลักคำสอนที่วางผิดที่ และแม้บทความบล็อกจะเริ่มต้นว่าเป็น heuristic แต่โปรแกรมเมอร์รุ่นใหม่ก็ไม่ได้รับมันไปแบบนั้นเสมอไป
    เมื่อไม่กี่สัปดาห์ก่อน YouTube แนะนำวิดีโอชื่อ “I'm a never-nester” ให้ดูอยู่เรื่อย ๆ ซึ่งดูเหมือนเป็นข้ออ้างว่าอย่าซ้อน if เด็ดขาด และมันก็ดูชวนขำพอสมควร
    เหมือนตัวอย่างในบทความ ที่ทำเครื่องหมายโค้ดซึ่งมี if condition อยู่ใน for ว่า “แย่” แต่ในโค้ดส่วนใหญ่ที่เขียนแบบนั้นจริง ๆ แล้ว condition จะขึ้นกับ walrus จึงยก if ขึ้นไปข้างบนไม่ได้
    ถ้ายกขึ้นได้จริง การประเมินเงื่อนไขเดิมซ้ำทุกครั้งในลูปก็เป็นเรื่องชัดเจนเกินไปจนโปรแกรมเมอร์มักจะหลีกเลี่ยงเองโดยธรรมชาติ
    แต่ถ้า junior หรือนักศึกษาอ่านคำแนะนำที่ฟังดูเหมือนหลักคำสอน พวกเขาอาจพยายามทำตามอย่างเคร่งครัดจนทำให้โค้ดแย่ลงได้

    • ผมไม่ได้ยึดติดแบบเป็นลัทธิเป็นพิเศษกับเรื่อง “อย่าซ้อนเด็ดขาด” แต่จากประสบการณ์ ภายใต้สมมติฐานว่า syntax ทำได้ ผมยังไม่เคยเห็นกรณีที่การใช้ match (condition_a, condition_b) เพื่อแยกเป็นสี่กรณี จะแย่กว่า if แบบซ้อนกัน
    • ผมมองว่านี่กลับเป็นตัวอย่างที่ดีของการที่ควร “ยก if ขึ้นไปข้างบน”
      จุดประสงค์ของโค้ดคือทำ operation บางอย่างกับ walrus ตามเงื่อนไข แต่จริง ๆ แล้ว if กำลังถูกใช้แทน polymorphism และระบบ type
      ทำไม walrus ต้องมีสองฟังก์ชันที่ต้องเรียกในคนละ scenario ด้วย? มีฟังก์ชันเดียวกับ type สองแบบ แล้วส่ง type ที่ถูกต้องเข้าไปไม่ได้หรือ?
      แม้ในโครงสร้างปัจจุบัน ก็ยังสามารถเลือกฟังก์ชันประมวลผลตามเงื่อนไขไว้ด้านบน แล้วให้ลูปด้านล่างเรียกฟังก์ชันที่เลือกไว้ในทุกครั้งได้
      ถ้าตัดสินใจให้เร็วที่สุดเท่าที่เป็นไปได้ ก็ไม่จำเป็นต้องโปรยการตัดสินใจนั้นไปทั่วทั้งโค้ด ภายในโค้ดทำงานเดิมซ้ำทุกครั้งโดยไม่มี branch และผลลัพธ์จะแตกต่างกันผ่านกราฟการประกอบเท่านั้น
      แน่นอนว่านี่ไม่ใช่แนวคิดใหม่ และเมื่อ 15 ปีก่อนก็เป็นไอเดียเก่าอยู่แล้ว: https://www.youtube.com/watch?v=4F72VULWFvc
    • การ refactor แบบ “GOOD” ใช้ได้เฉพาะเมื่อเงื่อนไขไม่ได้ขึ้นกับ walrus และช่วยทำให้ข้อเท็จจริงนั้นปรากฏชัดขึ้น
      ถ้านำ “ดัน for ลงไปข้างล่าง” มาใช้ซ้ำตรงนี้ ก็จะกลายเป็นรูปแบบที่เรียก frobnicate_batch(walruses) หรือ transmogrify_batch(walruses) ภายใน if condition
    • ผมคิดว่าการมีบทความแบบนั้นอยู่เป็นเรื่องดี
      มีคนช่วยเรียบเรียงปัญหาที่ผมเจอมาหลายครั้งแต่ไม่เคยอธิบายได้ดีออกมาเป็นคำพูด และมันดูเหมือนเป็นโมเดลที่ดีที่ควรเก็บไว้ในหัว
      ในขณะเดียวกัน คำวิจารณ์แบบนี้ก็มีคุณค่าเช่นกัน หวังว่า junior developer ที่ยึดหลักคำสอนจะได้อ่านควบคู่กันและตัดสินใจได้อย่างละเอียดอ่อนขึ้นเล็กน้อย
    • แก่นที่ควรเรียนรู้จาก “heuristic” และ “best practice” ทุกอย่าง คือ เหตุผล ที่อยู่เบื้องหลังมัน
      โปรแกรมเมอร์โดยเฉพาะไม่ควรคัดลอกสิ่งเหล่านี้ไปใช้แบบกลไก
      การนำ heuristic และ best practice ไปใช้อย่างมืดบอดอาจไม่ใช่ความคิดที่ดี และท้ายที่สุดทุกอย่างก็ “ขึ้นอยู่กับบริบท”
  • heuristic แบบนี้มักมีเศษเสี้ยวของปัญญาอยู่ แต่โดยรวมแล้วมักกลายเป็นสิ่งที่ต้องค่อย ๆ ทำให้พ้นจากความเป็นหลักคำสอน อีกครั้งในหมู่โปรแกรมเมอร์มือใหม่
    มีหลายกรณีเสมอที่พอพยายามทำตามอย่างเข้มงวดเกินไปแล้วกลับแย่ลง และ “รู้ว่าเมื่อไรไม่ควรฟังคำแนะนำนี้” นั่นแหละคือความยากหลักจริง ๆ

    • ยังไงก็จำเป็นต้องทำให้พ้นจากความเป็นหลักคำสอนอยู่ดี
      ผมคิดว่ากฎแบบนี้มีประโยชน์ในฐานะของเล่นให้ทดลอง ลองรับมาใช้ ลองผลักมันไปให้สุด ลองกลับด้านมันสักวันหนึ่งหรือสักปีหนึ่ง แล้วดูว่ามันพาไปได้ไกลแค่ไหน
      การเรียนรู้ขีดจำกัดก็เป็นอีกหนึ่งวัตถุดิบที่ทับถมลงบนแผ่นหนังเก่าที่ถูกเขียนทับไปเรื่อย ๆ
    • บทความนี้อาจมีประโยชน์ในฐานะ koan ชิ้นหนึ่งในชุดรวมที่ใหญ่กว่า
      และ koan บางส่วนในนั้นก็ควรขัดแย้งกันเอง