ดัน if ขึ้นด้านบน และดัน for ลงด้านล่าง
(matklad.github.io)- หัวใจสำคัญคือ กฎการจัดโครงสร้างโค้ด สองข้อ: ดึงการแยกสาขาตามเงื่อนไขขึ้นไปฝั่งผู้เรียก และผลักการประมวลผลแบบวนซ้ำลงไปยังฝั่งปฏิบัติการแบบแบตช์
- เมื่อดัน
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 เป็นไปได้
- รูปแบบที่ดีคือโครงสร้างที่แยก branch ตาม
- แพตเทิร์นนี้ใช้ได้ทั้งระดับไมโครและแมโคร
- สถาปัตยกรรมของ TigerBeetle เป็นโครงสร้างที่ประมวลผลแบตช์ของอ็อบเจ็กต์ใน data plane พร้อมกัน เพื่อ amortize ต้นทุนการตัดสินใจของ control plane
- แรงจูงใจหลักของคำแนะนำเรื่อง
forคือประสิทธิภาพ แต่แนวทางที่ทำงานเป็นหน่วยคอลเลกชันเหมือน jQuery ก็อาจช่วยด้าน expressive power ได้เช่นกัน
1 ความคิดเห็น
ความคิดเห็นจาก 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อาจไม่ต่างกันมากในเชิงฟังก์ชัน แต่แบบหนึ่งอาจเร็วกว่าได้ ดังนั้นถ้าเขียนให้ถูกทางได้โดยไม่มีต้นทุน ก็ไม่มีเหตุผลต้องเลือกฝั่งที่ช้ากว่าโปรแกรมที่เรียบง่ายมักมี 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 มากเกินไปถ้าลองคิดว่าจะตรวจสอบความถูกต้องของอินพุตที่ไหน ตาม heuristic นี้ก็ควรตรวจตั้งแต่จุดบนสุดที่รับอินพุตเข้ามา
สิ่งนี้ช่วยให้เข้าใจโค้ดได้ และในมุมมองของการพิสูจน์ก็จำเป็นในเชิงเทคนิคเช่นกัน เพราะควรทำให้เงื่อนไขตั้งต้นถูกส่งต่อขึ้นไปด้านบน
คำแนะนำข้อแรกจึงไม่ใช่ข้อถกเถียงเล็กน้อยแน่นอน ส่วนข้อที่สองคลุมเครือกว่าเล็กน้อย
พวกเขาพยายามไม่ให้ junior ทำพลาดแบบยิงเท้าตัวเอง แต่บ่อยครั้งยังไม่มีสัญชาตญาณเพียงพอเกี่ยวกับต้นทุนของความซับซ้อนและโค้ดจะเปลี่ยนไปอย่างไรในระยะยาว
ในทางกลับกัน เมื่อทำมานานกว่า 20 ปี ส่วนใหญ่จะให้คุณค่ากับ ความเรียบง่าย สูงกว่าองค์ประกอบทางเทคนิคส่วนมาก
คำถามอย่าง “ควรแยก branch ให้เร็วแค่ไหน” หรือ “โค้ดนี้จริง ๆ ต้องทำอะไร” มักให้คำตอบที่มีคุณค่ามากที่สุดในระยะยาว
คำถามเกี่ยวกับ abstraction และ encapsulation กลับมีแนวโน้มจะนำไปสู่ข้อถกเถียงแบบที่กล่าวไปข้างต้น และเหตุผลที่โค้ดของคนที่มองแต่ “ภาพใหญ่” มักมีปัญหาความปลอดภัยมากที่สุด ก็เพราะพวกเขาไม่เข้าใจดีพอว่าโค้ดเบสที่ออกแบบมาอย่างดีนั้นจริง ๆ ทำอะไร
ในซอฟต์แวร์ที่ไวต่อประสิทธิภาพและเหมาะกับ data-oriented design ต้องใส่ใจกับเรื่องระดับหน่วยเล็ก ๆ แบบนี้มากขึ้น เพราะ compiler optimization ทำงานในลักษณะนั้น
เมื่อนั้นกติกาของเกมจะเปลี่ยนไป ความหมายของ statement จะสำคัญขึ้น โค้ดที่อธิบายตัวเองได้จะมีความหมายมากขึ้น คอมเมนต์ในโค้ดจะเหลือไว้เพื่อใช้ในการอนุมานเท่านั้น และ “เอกสาร” จะใกล้เคียงกับสเปกและคู่มือผู้ใช้มากขึ้น
อย่างมากก็แค่จัดกลุ่มฟังก์ชันเข้าด้วยกันได้ แล้วจากนั้นก็ต้องจัดการกันเอง
“ยก if ขึ้นไปไว้ข้างบน” มีข้อเสียคือ precondition และ postcondition ไม่ปรากฏให้เห็นโดยตรงภายในนิยามฟังก์ชัน และต้องไปตรวจดูที่แต่ละจุดเรียกใช้
ในโปรเจกต์ขนาดใหญ่ที่มีหลายคนร่วมทำ ฟังก์ชันแบบนี้อาจถูกนำกลับมาใช้ใหม่นอกบริบทที่ตั้งใจไว้และนำไปสู่บั๊กได้
ใช้ contract framework ก็แก้ได้ แต่จะต้องเขียนเงื่อนไขซ้ำทั้งใน contract และในโค้ด และ dependent type ก็เช่นเดียวกัน
วิธีแท็กพื้นที่ของโค้ดที่อยู่ในบริบทเฉพาะ แล้วกำหนดฟังก์ชันที่เรียกได้เฉพาะภายในบริบทนั้น เป็นแนวคิดที่น่าสนใจ
ถ้าเป็น Python อาจใช้ decorator อย่าง
@requires_context("VALIDATED_XY")และ context manager ชื่อvalidated_xyเพื่อทำให้เรียกฟังก์ชันได้เฉพาะในพื้นที่ที่ผ่านการตรวจสอบความถูกต้องแล้วruntime จะไม่รู้ความหมายของบริบทนั้น แต่ถ้ามีเครื่องมือและการทดสอบ ก็สามารถออกแบบให้ตั้งบริบทที่ต้องการเฉพาะเมื่อเงื่อนไขบางอย่างเป็นจริงเท่านั้นได้
ในภาษาอย่าง Haskell สามารถบังคับในระดับไทป์ด้วยสิ่งอย่าง identity monad ได้ และแม้ไม่ใช่การบังคับระดับไทป์ ก็อาจเป็นวิธีที่น่าสนใจในการปกป้องพื้นที่โค้ด “unsafe”
ดังนั้น precondition ยังมองเห็นได้โดยตรงในนิยามฟังก์ชัน เพียงแต่ปรากฏเป็นส่วนหนึ่งของ type signature ไม่ใช่คำสั่ง
ifนี่เป็น pattern ที่พบได้บ่อยใน Rust ที่ใช้ในบทความ และต่างจากการตรวจด้วย
ifตรงที่เป็น precondition แบบเข้มงวดซึ่งตรวจตอน compile time ไม่ใช่ runtime หากไม่เป็นไปตามนั้นโปรแกรมจะคอมไพล์ไม่ผ่านตัวอย่างเช่น รับค่าแบบ
do_something(position: ValidatedPosition)แล้วตรวจสอบPositionปกติก่อน จากนั้นแปลงเป็นValidatedPositionเพื่อส่งต่อในทางปฏิบัติคง encapsulate การตรวจไว้ใน constructor ของ
ValidatedPositionแต่ประเด็นสำคัญคือถ้าพยายามส่งPositionเข้าไปตรง ๆ mypy จะบอกว่าคุณส่งไทป์ผิดการตรวจไทป์ของ Python ไม่ครอบคลุมเท่า Rust แต่เรากำลังใช้มันอย่างมีประโยชน์มากขึ้นเรื่อย ๆ เมื่อต้องการรับประกันว่าข้อมูลที่ถูกส่งต่อได้รับการจัดการอย่างเหมาะสมแล้ว
ถ้าฟังก์ชันมี 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 เป็นพอยน์เตอร์ ผมจึงคิดว่าการวนซ้ำก็ควรอยู่ด้านบน ไม่ใช่ด้านล่าง ในฟังก์ชันที่สร้างอาร์เรย์ขึ้นมา เรายังเชื่อถือความยาวได้ แต่ในฟังก์ชันที่รับเป็นอาร์กิวเมนต์แล้วไม่ใช่แบบนั้นเอกสารควรทำหน้าที่แทนระบบ type ที่แข็งแรง ไม่ใช่ให้การตรวจสอบตอนรันไทม์มาทำแทน
โค้ดที่เต็มไปด้วยการตรวจ NULL และมาตรการป้องกันอื่น ๆ อ่านยากกว่ามาก
อาจบอกได้ว่าตรงขอบเขตของไลบรารีต้องมีการตรวจสอบเชิงป้องกันมากขึ้น และบทความนี้ก็กำลังบอกให้ดันการตรวจสอบแบบนั้นขึ้นไปพอดี
โค้ดที่สำคัญด้านความปลอดภัยอาจต่างออกไป แต่ในกรณีส่วนใหญ่ NULL dereference ที่เกิดโดยไม่ตั้งใจ แค่ถูกจับได้ด้วยการทดสอบ, sanitizer และ fuzzing ก็เพียงพอแล้ว
ถ้าจะตรวจค่าพอยน์เตอร์ที่ไม่ถูกต้อง ก็ต้องตรวจค่าผิดที่เป็นไปได้จำนวนมหาศาลทั้งหมด และถ้าตรวจแค่ NULL จริง ๆ แล้วก็ไม่ได้ตรวจค่าที่ไม่ถูกต้องโดยทั่วไป
ถ้า precondition ของฟังก์ชันคือ “พารามิเตอร์
pต้องไม่เป็น NULL” ก็ตรวจได้ แต่ถ้า precondition คือ “pต้องเป็นพอยน์เตอร์ที่ถูกต้อง” ก็ขอให้โชคดีกับการหา assertion ที่เหมาะสมช่วงนี้ผมใช้ 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 หรือนักศึกษาอ่านคำแนะนำที่ฟังดูเหมือนหลักคำสอน พวกเขาอาจพยายามทำตามอย่างเคร่งครัดจนทำให้โค้ดแย่ลงได้
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
walrusและช่วยทำให้ข้อเท็จจริงนั้นปรากฏชัดขึ้นถ้านำ “ดัน for ลงไปข้างล่าง” มาใช้ซ้ำตรงนี้ ก็จะกลายเป็นรูปแบบที่เรียก
frobnicate_batch(walruses)หรือtransmogrify_batch(walruses)ภายในif conditionมีคนช่วยเรียบเรียงปัญหาที่ผมเจอมาหลายครั้งแต่ไม่เคยอธิบายได้ดีออกมาเป็นคำพูด และมันดูเหมือนเป็นโมเดลที่ดีที่ควรเก็บไว้ในหัว
ในขณะเดียวกัน คำวิจารณ์แบบนี้ก็มีคุณค่าเช่นกัน หวังว่า junior developer ที่ยึดหลักคำสอนจะได้อ่านควบคู่กันและตัดสินใจได้อย่างละเอียดอ่อนขึ้นเล็กน้อย
โปรแกรมเมอร์โดยเฉพาะไม่ควรคัดลอกสิ่งเหล่านี้ไปใช้แบบกลไก
การนำ heuristic และ best practice ไปใช้อย่างมืดบอดอาจไม่ใช่ความคิดที่ดี และท้ายที่สุดทุกอย่างก็ “ขึ้นอยู่กับบริบท”
heuristic แบบนี้มักมีเศษเสี้ยวของปัญญาอยู่ แต่โดยรวมแล้วมักกลายเป็นสิ่งที่ต้องค่อย ๆ ทำให้พ้นจากความเป็นหลักคำสอน อีกครั้งในหมู่โปรแกรมเมอร์มือใหม่
มีหลายกรณีเสมอที่พอพยายามทำตามอย่างเข้มงวดเกินไปแล้วกลับแย่ลง และ “รู้ว่าเมื่อไรไม่ควรฟังคำแนะนำนี้” นั่นแหละคือความยากหลักจริง ๆ
ผมคิดว่ากฎแบบนี้มีประโยชน์ในฐานะของเล่นให้ทดลอง ลองรับมาใช้ ลองผลักมันไปให้สุด ลองกลับด้านมันสักวันหนึ่งหรือสักปีหนึ่ง แล้วดูว่ามันพาไปได้ไกลแค่ไหน
การเรียนรู้ขีดจำกัดก็เป็นอีกหนึ่งวัตถุดิบที่ทับถมลงบนแผ่นหนังเก่าที่ถูกเขียนทับไปเรื่อย ๆ
และ koan บางส่วนในนั้นก็ควรขัดแย้งกันเอง