ย้าย If ขึ้นบน ย้าย For ลงล่าง
(matklad.github.io)- การย้าย คำสั่ง if ภายในฟังก์ชันขึ้นไปไว้ที่จุดเรียกใช้ ช่วยลด ความซับซ้อน ของโค้ดได้
- การรวม การตรวจสอบเงื่อนไข และการจัดการทางแยกไว้ในที่เดียว ทำให้มองเห็นความซ้ำซ้อนและการตรวจสอบทางแยกที่ไม่จำเป็นได้ง่าย
- สามารถใช้ การรีแฟกเตอร์แบบแยก enum (Dissolving enum Refactor) เพื่อป้องกันปัญหาที่เงื่อนไขเดียวกันกระจายอยู่ทั่วโค้ด
- ลูป for ที่อิงกับ การประมวลผลแบบแบตช์ มีประสิทธิภาพในการเพิ่มสมรรถนะและปรับงานที่ทำซ้ำให้เหมาะสม
- การผสานแพตเทิร์น if ขึ้นบน, for ลงล่าง ช่วยเพิ่มทั้งความอ่านง่ายและประสิทธิภาพของโค้ดไปพร้อมกัน
บันทึกสั้น ๆ เกี่ยวกับกฎที่เกี่ยวข้องกันสองข้อ
- หากมี คำสั่ง if อยู่ภายในฟังก์ชัน แนะนำให้พิจารณาว่าสามารถย้ายมันไปไว้ที่จุดเรียกใช้ฟังก์ชันได้หรือไม่
- ดังเช่นในตัวอย่าง การให้จุดเรียกใช้เป็นผู้ตรวจสอบ precondition แทนการตรวจภายในฟังก์ชัน หรือทำให้ชนิดข้อมูล (หรือ
assert) รับประกัน precondition นั้นไว้ จะเหมาะสมกว่า - วิธีการย้ายการตรวจสอบ precondition ขึ้นบน (Push up) ส่งผลต่อโค้ดทั้งระบบ และช่วย ลดจำนวนการตรวจสอบเงื่อนไขที่ไม่จำเป็น โดยรวม
การรวมศูนย์ของ control flow และคำสั่งเงื่อนไข
- control flow และ คำสั่ง if เป็นสาเหตุหลักของความซับซ้อนและบั๊กในโค้ด
- การรวมคำสั่งเงื่อนไขขึ้นไปไว้ในระดับบน เช่น จุดเรียกใช้ แล้วให้มีเพียงฟังก์ชันเดียวที่รับผิดชอบการแตกแขนง ขณะที่งานจริงปล่อยให้เป็นซับรูทีนแบบเส้นตรง (straight-line) เป็นแพตเทิร์นที่มีประโยชน์
- เมื่อการแตกแขนงและ control flow ถูกรวมไว้ในจุดเดียว จะมองเห็น ทางแยกที่ซ้ำซ้อน และ เงื่อนไขที่ไม่จำเป็น ได้ง่าย
ตัวอย่าง:
- เมื่อมี if ซ้อนอยู่ในฟังก์ชัน f จะสังเกตเห็น dead code (Dead Branch) ได้ง่าย
- แต่ถ้าการแตกแขนงกระจายไปตามหลายฟังก์ชัน (g, h) การมองเห็นสิ่งเหล่านี้จะยากขึ้น
การรีแฟกเตอร์แบบแยก enum (Dissolving enum Refactor)
- หากโค้ดบรรจุการแตกแขนงจากคำสั่งเงื่อนไขเดียวกันไว้ผ่าน enum เป็นต้น การดึงเงื่อนไขขึ้นไประดับบนจะช่วยแยก “การแตกแขนง” ออกจาก “งานที่ทำ” ให้ชัดเจนขึ้น
- เมื่อใช้แนวทางนี้ จะป้องกันปัญหาที่เงื่อนไขเดียวกันปรากฏ ซ้ำหลายครั้ง ในโค้ดได้
ตัวอย่าง:
- สถานการณ์ที่เงื่อนไขการแตกแขนงเดียวกันถูกแสดงอยู่ทั้งในฟังก์ชัน f, g และใน enum E
- สามารถทำให้โค้ดทั้งระบบเรียบง่ายขึ้นได้ด้วยการรวมให้เหลือการแตกแขนงระดับบนเพียงจุดเดียว
แนวคิดแบบ data-oriented (Data Oriented Thinking) และการประมวลผลแบบแบตช์
- โปรแกรมส่วนใหญ่ทำงานกับออบเจ็กต์ (เอนทิตี) จำนวนมาก โดยประสิทธิภาพในเส้นทางวิกฤต (Hot Path) มักถูกกำหนดจากการประมวลผลออบเจ็กต์จำนวนมาก
- จึงเหมาะที่จะนำแนวคิดแบตช์ (batch) มาใช้ โดยทำให้การประมวลผลกับชุดของออบเจ็กต์เป็นกรณีหลัก และให้การประมวลผลออบเจ็กต์เดี่ยวเป็นกรณีพิเศษ
ตัวอย่าง:
-
ใช้ ฟังก์ชันประมวลผลแบบแบตช์ อย่าง
frobnicate_batch(walruses)เป็นค่าเริ่มต้น -
แล้วค่อยแปลงการทำงานกับออบเจ็กต์เดี่ยวให้เป็นกรณีพิเศษที่ประมวลผลผ่านลูป for ได้
-
แนวทางนี้มีบทบาทสำคัญในด้านการปรับประสิทธิภาพ โดยช่วยลดต้นทุนเริ่มต้นของงานขนาดใหญ่และเพิ่มความยืดหยุ่นด้านลำดับการประมวลผล
-
ยังสามารถใช้ SIMD (
struct-of-arrayเป็นต้น) ได้ด้วย ทำให้ประมวลผลเฉพาะบางฟิลด์พร้อมกันก่อน แล้วจึงทำงานทั้งหมดต่อได้
กรณีใช้งานจริงและแพตเทิร์นที่แนะนำ
- เช่นเดียวกับการคูณพหุนามด้วย FFT ที่เปิดให้คำนวณหลายจุดพร้อมกันได้ จึงสามารถดึงประสิทธิภาพออกมาได้สูงสุด
- กฎการย้ายคำสั่งเงื่อนไขขึ้นบน และย้ายลูปลงล่าง สามารถนำมาใช้ร่วมกันได้
ตัวอย่าง:
- แทนที่จะตรวจสอบเงื่อนไขเดียวกันซ้ำ ๆ ภายในลูป การดึงคำสั่งเงื่อนไขออกไปไว้นอกลูปจะช่วยลดการแตกแขนงภายในลูป และทำให้ การเพิ่มประสิทธิภาพและการเวกเตอร์ไรซ์ ทำได้ง่ายขึ้น
- แนวทางนี้รับประกันประสิทธิภาพสูงได้แม้ใน data plane ของระบบขนาดใหญ่ เช่น การออกแบบของ TigerBeetle
บทสรุป
- การผสานแพตเทิร์นที่ให้ if (คำสั่งเงื่อนไข) อยู่ ด้านบน (จุดเรียกใช้, จุดควบคุม) และให้ for (ลูปทำซ้ำ) อยู่ ด้านล่าง (ส่วนคำนวณ, ส่วนประมวลผลข้อมูล) จะช่วยยกระดับทั้ง ความอ่านง่าย ประสิทธิภาพ และ สมรรถนะ ของโค้ด
- การคิดในมุมของปริภูมิเวกเตอร์เชิงนามธรรม (การดำเนินการกับชุดข้อมูลเป็นหน่วย) เป็นเครื่องมือแก้ปัญหาที่ดีกว่าการแตกแขนงซ้ำ ๆ
- สรุปสั้น ๆ คือ if ขึ้นบน, for ลงล่าง!
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
getaddrinfoหรือEnterCriticalSectionได้จริงหรือ? ฉันคิดว่าการแปลงแบบนี้ควรพิจารณาเฉพาะกรณีที่มีการเรียกแค่ราว ๆ สองที่ และการตัดสินใจนั้นอยู่นอกขอบเขตความรับผิดชอบของฟังก์ชัน วิธีหนึ่งคือเขียนฟังก์ชันที่ทำแต่เงื่อนไข แล้วมอบหมายให้ helper function ไปทำงานจริง และถ้าจำเป็นต้องย้ายเงื่อนไขออกไปนอกลูป ก็อาจเปิดให้ caller ใช้ helper เงื่อนไขระดับล่างได้โดยตรง แต่หัวใจของการถกเถียงนี้คือเรื่อง “optimization” และ optimization ก็มักขัดกับการออกแบบโปรแกรมที่ดีกว่าอยู่บ่อย ๆ การที่ caller ไม่จำเป็นต้องรู้เรื่องเงื่อนไขอาจเป็นการออกแบบที่ดีกว่าก็ได้ ภาวะแบบนี้เจอบ่อยใน OOP เช่นกัน โดย “if” ที่ว่านี้ในความจริงอาจถูกแทนด้วย method dispatch การดึง dispatch แบบนี้ออกไปนอกลูปก็อาจไปเสียดสีกับหลักการออกแบบได้ เช่นเวลาวาดภาพลงบน canvas การใช้เมธอดอย่างblitจะเป็นแนวทางที่เหมาะกว่า เรียกputpixelซ้ำ ๆ ทุกครั้งDRYจะฟังดูเหมือนคำตอบที่ถูก แต่ต้องดูตัวอย่างโค้ดจริงก่อนตัดสินใจ ถ้าเป็น library มันอยู่ตรงขอบเขตความเป็นเจ้าของ ดังนั้นแต่ละฝั่งต้องจัดการข้อมูลและความรับผิดชอบของตัวเอง ฟังก์ชันอย่างEnterCriticalSectionควรมีการตรวจสอบที่เข้มงวดตรงจุดเข้าใช้งาน (รวมถึงเงื่อนไขด้วย) แต่ใน application code การย้าย if ไปฝั่ง caller ก็อาจไม่เป็นปัญหา สำหรับ library หรือโค้ดแกนกลาง การผลัก control flow ไปไว้ที่ขอบเป็นเรื่องเหมาะสม ภายในโดเมนที่เราดูแลเอง ก็ควรวาง control flow ไว้ที่ขอบเช่นกัน แต่แน่นอนว่ากฎพวกนี้เป็นเพียงแนวทางปฏิบัติเท่านั้น คนที่ตัดสินได้อย่างมีเหตุผลตามสถานการณ์ต้องเป็นคนที่เข้าใจบริบทmatchด้วยการเรียกเมธอดแบบ polymorphic ได้ จุดประสงค์ของแนวทางนี้คือแยกช่วงเวลาที่ตัดสินใจเลือกการแตกแขนงตั้งต้น ออกจากช่วงเวลาที่พฤติกรรมจริงถูกเรียกใช้ การจำแนกเคสถูกเก็บไว้ใน object (ในที่นี้คือค่า enum) หรือ closure ดังนั้นจึงไม่ต้องทำซ้ำทุกครั้งตอนเรียกใช้ ถ้าการจำแนกเคสเปลี่ยน ก็แก้แค่จุดแตกแขนง โดยไม่ต้องไปแก้ที่ตำแหน่งที่พฤติกรรมจริงเกิดขึ้น ข้อเสียคือมี trade-off ระหว่างความสะดวกในการมองเห็นการแตกแขนงของแต่ละเคสโดยตรง กับการที่โค้ดเกิดการพึ่งพารายชื่อเคสในระดับโค้ดifลงล่างไปเรื่อย ๆ แต่บทความนี้กลับแนะนำในทางตรงกันข้าม คือยกifขึ้นบนไปยังฟังก์ชันระดับสูงกว่า วิธีนั้นทำให้รวมศูนย์ลอจิกการแตกแขนงที่ซับซ้อนไว้ในฟังก์ชันเดียว และมอบหมายงานเชิงรูปธรรมจริง ๆ ให้ subroutine จัดการif (weShouldDoThis()) { doThis(); }ประมาณนี้ และถ้าแยกการตรวจสอบแต่ละอย่างเป็นฟังก์ชันต่างหาก ก็จะช่วยให้ทดสอบและจัดการความซับซ้อนได้ง่ายขึ้นsonarqubeและเครื่องมือแนวนี้ชอบรายงานแม้แต่ “code smell” ที่ไม่ใช่บั๊กจริง การพยายามแก้ “โค้ดที่ไม่ใช่ปัญหา” แบบนี้เสี่ยงจะสร้างบั๊กใหม่ และทำให้เสียเวลาที่ควรเอาไปจัดการปัญหาสำคัญจริง ๆifภายในforอาจจัดการง่ายกว่าifนอกforและยังให้ประสิทธิภาพการเข้าถึงหน่วยความจำที่ดีกว่า ยกตัวอย่างแบบเป็นรูปธรรม ถ้ามีการคำนวณว่า ถ้าเป็นเลขคี่ให้+1ถ้าเป็นเลขคู่ให้-2ปกติแล้วแต่ละรอบลูปต้องวิ่งผ่าน branch แต่ถ้าทำแบบ SIMD จะประมวลผลintได้พร้อมกันทีละ 16 ค่าและไม่มี branch ด้วย ถ้า compiler ทำ vectorization ได้ดี ก็จะเปลี่ยนโค้ดเดิมให้เป็นเวอร์ชัน optimize แบบไม่มี branch ได้ifภายในforเป็นการแตกแขนงที่ขึ้นกับข้อมูล จึงดึงขึ้นไปข้างบนได้ยาก ถ้าอัลกอริทึมมีโครงสร้างเป็นif (length % 2 == 1) { ... } else { ... }แบบนี้คือใช้เงื่อนไขนอกลูปอยู่แล้ว แน่นอนว่าควรย้ายเงื่อนไขประเภทนี้ขึ้นไปไว้เหนือforในเวอร์ชัน SIMD นั้นifหายไปเลย ซึ่งเป็นแพตเทิร์นโค้ดในอุดมคติ และน่าจะเป็นแนวที่ผู้เขียนบทความชอบด้วยforทันที มีใครพอรู้ไหมว่าการที่ compiler จะทำ auto-vectorization ให้โค้ดแบบนี้มันยากแค่ไหน? ฉันอยากรู้ว่าขอบเขตมันอยู่ตรงไหนforคือการสร้าง abstraction แบบ “for คือกฎ ส่วนเงื่อนไขคือพฤติกรรม” แต่พอมี requirement ใหม่ abstraction แบบนี้ก็พัง แล้วเราจะเริ่มยัดพารามิเตอร์เพิ่มหรือใส่กรณียกเว้นมากขึ้นจนโค้ดเข้าใจยาก ถ้าเริ่มต้นโดยไม่สร้าง abstraction ตั้งแต่แรก ผลลัพธ์อาจชัดเจนกว่าและดูแลรักษาง่ายกว่า https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction