โค้ดแบบเชิงเส้นอ่านง่ายกว่า
(blog.separateconcerns.com)- แทนที่จะใช้วิธีแยกเป็นฟังก์ชันเล็ก ๆ เพื่อแยก ระดับของการนามธรรม โค้ดแบบเชิงเส้นที่ไล่จากบนลงล่างทำให้ตามภาพรวมของการทำงานได้ง่ายกว่า
- หากแยกฟังก์ชันเพื่อสร้างโครงสร้างแบบบนลงล่าง (top-down) อาจต้อง สลับไปมาระหว่างฟังก์ชัน ที่ชื่อคล้ายกันอย่าง
bakeและbakePizzaเพื่อตรวจสอบ - รายละเอียดอย่างตำแหน่งที่วอร์มเตาอบ หรือผลลัพธ์เมื่อส่งพิซซ่าเข้าเตาสองครั้ง แสดงให้เห็นว่าฟังก์ชันเล็ก ๆ อาจซ่อน พฤติกรรมที่เกิดขึ้นจริง ไว้ แทนที่จะสื่อเจตนา
- หากใส่คอมเมนต์เป็นขั้นตอนไว้ในโค้ดแบบเชิงเส้น ก็สามารถอธิบายเจตนาของงานได้โดยไม่เพิ่มการอ้างอิงทางอ้อม จึงอาจอ่านง่ายกว่าการเพิ่มชั้นนามธรรม
- การแยกฟังก์ชันเล็ก ๆ ที่ถูกใช้เพียงครั้งเดียวออกมา ทำให้ ความเป็นเชิงเส้นหายไป และในโค้ดจริงก็อาจเผยให้เห็นปัญหาด้านประสิทธิภาพได้ เช่น วิธีสร้างเตาอบในตัวอย่าง
กรณีที่ความเป็นเชิงเส้นสำคัญกว่าการแยกฟังก์ชัน
- ตัวอย่างจาก Google Testing Blog เปรียบเทียบการทำ
createPizzaสองแบบ และมองว่าฝั่งขวาอ่านง่ายกว่าเพราะไม่ปะปนระดับของการนามธรรม และเป็นแบบ บนลงล่าง (top-down) - แต่อีกมุมหนึ่งมองว่าสิ่งที่สำคัญกว่าคือ ฝั่งซ้ายเป็น โค้ดที่อ่านแบบเชิงเส้น ได้จากบนลงล่างในหน้าจอเดียว
- หากต้องการเข้าใจการทำงานทั้งหมดของโค้ดฝั่งขวา จำเป็นต้องย้ายไปดูฟังก์ชันเล็ก ๆ หลายตัว
- ในการนำเสนอ โค้ดฝั่งขวาบางส่วนยังถูกตัดออก ทำให้ดูเหมือนมีขนาดใกล้กัน แต่จริง ๆ แล้วฝั่งขวายาวกว่า
- การแยกฟังก์ชันอาจทำให้เข้าใจพฤติกรรมได้ไม่เพียงพอจากชื่อเพียงอย่างเดียว
- เมื่อมีทั้ง
bakeและbakePizzaก็ยากจะรู้ได้ทันทีว่าฟังก์ชันไหนเป็นตัวอุ่นเตาอบ - หากส่งพิซซ่าชิ้นเดิมเข้าไปสองครั้ง จะยังให้ผลลัพธ์เดิมหรือทำให้ผลลัพธ์เสีย ต้องเปิดดู implementation ภายในจึงจะรู้
- เมื่อมีทั้ง
โค้ดเชิงเส้นพร้อมคอมเมนต์ และตัวอย่างเตาอบ
- เวอร์ชันที่นำชื่อฟังก์ชันจากฝั่งขวามาใส่เป็นคอมเมนต์ในโค้ดเชิงเส้นฝั่งซ้าย ถูกมองว่าเป็นรูปแบบที่อ่านง่ายที่สุด
- คอมเมนต์อย่าง
Prepare pizza,Add toppings,Heat oven,Bake pizza,Box and sliceช่วยสื่อเจตนาของแต่ละขั้นตอน - ความอ่านง่ายไม่ได้มาจาก การเพิ่มชั้นนามธรรม และการอ้างอิงทางอ้อม แต่เกิดจากการอธิบายสิ่งที่กำลังทำอยู่ให้ชัดเจน
- คอมเมนต์อย่าง
- ข้อสรุปจึงค่อนข้างเอนเอียงไปทางไม่ควรแยกฟังก์ชันเล็ก ๆ ที่ใช้เพียงครั้งเดียวออกจากโค้ดเชิงเส้น
- มองว่าประโยชน์ของการแยกฟังก์ชันเล็ก ๆ ไม่คุ้มกับการสูญเสียความเป็นเชิงเส้น
- การจัดการเตาอบในตัวอย่างเองก็ยังดูแปลกในเชิงโครงสร้าง
- การอุ่นเตาอบเป็นการทำงานที่สมบูรณ์ในตัวเอง จึงเหมาะจะเป็นเมธอดของเตาอบมากกว่า
- การสร้างเตาอบใหม่และอุ่นใหม่ทุกครั้งที่ทำพิซซ่าหนึ่งถาด ไม่สอดคล้องกับการใช้งานที่สมจริง
- ในโค้ดจริงก็พบโครงสร้างแบบนี้ได้ และบางครั้งอาจทำให้เกิดปัญหาด้านประสิทธิภาพ
- แทนที่จะสร้างเตาอบใหม่ภายใน
createPizzaมีแนวโน้มว่าควร รับเตาอบผ่านพารามิเตอร์- การจัดหาเตาอบใกล้เคียงกับความรับผิดชอบของฝั่งผู้เรียกใช้มากกว่า
- หากลำดับงานจบลงที่การใส่พิซซ่าลงกล่อง อินเทอร์เฟซที่คืนค่ากล่องแทนพิซซ่าก็อาจเป็นธรรมชาติกว่า
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
เป็นเรื่องของสไตล์ เหมือนการทำอาหารที่ใส่เกลือมากไปหรือน้อยไปก็ทำให้อาหารเสียได้
หวังว่าคงไม่มีใครในนี้เสนอให้มี ฟังก์ชันเทพเจ้า 1000 บรรทัด เพียงฟังก์ชันเดียว และการจำกัดไว้ไม่เกิน 5 บรรทัดต่อฟังก์ชันก็ไม่ได้อ่านง่ายเช่นกัน จะตัดแบ่งตรงไหนต้องอาศัยวิจารณญาณ เซนส์ที่ดี และการทำซ้ำ ๆ การที่ abstraction ที่ลองครั้งแรกไม่ดี ไม่ได้แปลว่าควรเลิกใช้ abstraction และหลังจาก refactor ไปสองสามรอบ ก็อาจได้ class และ API ที่เข้ากับโดเมนธุรกิจได้ดี
ขณะเดียวกันก็ไม่ควรรีบทำ abstraction เกินไป หรือทำเหมือนโดนบาดเจ็บสาหัสเพราะมีโค้ดซ้ำกันไม่กี่บรรทัด abstraction ที่รีบร้อนเกินไป มักเอาโค้ดที่ไม่จำเป็นต้องวิวัฒน์ไปด้วยกันมาผูกไว้ด้วยกัน การดึงฟังก์ชันที่ถูกเรียกจากที่เดียวออกมาเพื่อซ่อนหน่วยงานหนึ่ง ๆ อาจทำให้อัลกอริทึมดูสะอาดขึ้นได้ และมีประโยชน์เป็นพิเศษเมื่อต้องซ่อนการปะปนของ boilerplate หรือ concern ด้าน infrastructure เช่น การเชื่อมต่อ DB กับ business logic แต่ต้องใช้อย่างระมัดระวัง และควรหลีกเลี่ยงการแตกขั้นตอนที่ควรอยู่ในระดับ abstraction เดียวกันออกจากกัน
คนหนึ่งที่เคยทำงานด้วยดึงเงื่อนไข boolean ทุกตัวออกเป็นฟังก์ชันด้วยเหตุผลว่า “อ่านง่าย” และไม่เขียนคอมเมนต์เลยด้วยเหตุผลว่า “คอมเมนต์ไม่ดี” ผมไม่ชอบหนังสือเล่มนั้น เพราะมันสร้างพวกคลั่งไคล้ที่ทำตามคำแนะนำแย่ ๆ แบบตาบอด
บางครั้งโดเมนอาจต้องการฟังก์ชันเทพเจ้า 1000 บรรทัด และเมื่อ logic กับงานรวมอยู่ที่เดียว ก็อาจอ่านง่ายกว่าฟังก์ชัน 50 บรรทัดจำนวน 20 ตัวมาก ๆ อยู่ดี หากต้องการเข้าใจทั้งหมดก็ต้องอ่านทั้ง 20 ตัวอยู่แล้ว และอาจมีคนพยายาม reuse บางตัวในนั้น แล้วปรับให้เข้ากับข้อกำหนด 2–3 อย่างที่งานเดิมไม่มี ทำให้ logic บางอย่างถูกผูกเข้ากับ use case ที่ไม่เกี่ยวกัน
ถ้าฟังก์ชันนั้นเป็น pure function จะ 1000 บรรทัดหรือ 10000 บรรทัดก็ไม่สำคัญ และผมยังมองว่าโอเค
สูตรอาหารเองก็ถูก abstract ไว้มากแล้ว เมื่อบอกว่า “ผัดหัวหอมพอสลด” ก็ถือว่ารู้วิธีหั่นหัวหอมและอัลกอริทึมการผัดพอสลดอยู่แล้ว ถ้าเขียนทุกอย่างแบบ inline จะอ่านไม่ออก
โค้ดก็คล้ายกัน หากตัด abstraction ออกอย่างเคร่งครัด ก็จะลงไปถึงระดับต่ำสุดที่ภาษายอมให้ทำได้ และนั่นไม่ใช่โค้ดที่อ่านง่ายแน่นอน ตัวอย่างเช่น ถ้าพยายามทำ Unicode decoding เองแทนที่จะใช้เมธอด
decodeของ Python จะเข้าใจได้ยากมากว่าโปรแกรมทำอะไรอยู่จริง ๆ เพียงแต่ไม่มีใครทำแบบนั้น เพราะภาษามี abstraction ที่เรียบง่ายและผ่านการตรวจสอบมาอย่างดีให้ใช้ แล้วมันต่างอะไรกับการที่เราสร้าง abstraction ที่เรียบง่ายและผ่านการตรวจสอบมาอย่างดีเอง แล้วใช้ทั่ว business logicส่วนที่ยากคือการสร้าง abstraction ที่เลือกมาได้ดีจนไม่มีใครต้องกลับมาแตะอีก
แค่บรรทัดโค้ดตอนนี้ดูคล้ายกัน ไม่ได้แปลว่าอนาคตจะต้องเหมือนกันหรือควรถูกทำให้เหมือนกันต่อไป หากฝืนรวม use case สองอย่างที่ต่างกันเพียงเพราะ “โค้ดแทบจะซ้ำกัน” เมื่อเวลาผ่านไป มันมักกลายเป็น abstraction ที่ไม่สามารถ abstract อะไรได้เลย
หาก use case แตกต่างกันมากเกินไป implementation ก็จะผลัก logic จำนวนมากขึ้นไปให้ฝั่ง caller หรือไม่ก็เปิดเผยความต่างผ่าน flag แล้วมี implementation สองแบบที่ต่างกันวางคู่กันอยู่ภายใน แบบแรกเป็น abstraction ตื้น ๆ จึงมีคุณค่าน้อย ส่วนแบบหลังชัดเจนน้อยกว่าการมี implementation อิสระสองชุด
โค้ดตัวอย่างเรียบง่ายเกินไป ดังนั้นแน่นอนว่าโค้ดแบบเชิงเส้นอ่านง่ายกว่า แต่แนวคิดนั้นไม่ได้ขยายผลได้ดีนัก
ยังต้องคำนึงถึงการนำกลับมาใช้ซ้ำและ ความง่ายในการทำ unit test ด้วย และถ้าใส่โค้ดทั้งหมดไว้ในฟังก์ชันเดียว ตัวแปรโลคัลทั้งหมดจะอยู่ในสโคปเดียวกัน ไม่ว่าจะเกี่ยวข้องกับบล็อกโค้ดที่กำลังอ่านอยู่หรือไม่ก็ตาม ทำให้การให้เหตุผลยากขึ้น
อย่างไรก็ตาม เมื่อมองย้อนกลับไปในช่วงที่ยังมีประสบการณ์น้อย ผมเคยทำให้โค้ดเชิงเส้นที่ดีอยู่แล้วถูกแบ่งเป็นโมดูลมากเกินไป จนกลายเป็นโค้ดที่ดูแลรักษายากกว่าเพราะต้องกระโดดไปมาหลายที่บ่อยมาก รูปแบบที่เขียนครั้งแรกมีข้อดีตรงที่ใกล้เคียงกับลำดับความคิดในหัวตอนนั้นมากกว่า และผู้อ่านก็น่าจะตีความไปในแนวเดียวกันได้ง่ายกว่า การ refactor มากเกินไปอาจทำให้ข้อดีนั้นหายไป
สุดท้ายแล้วการเขียนโปรแกรมใกล้เคียงกับงานฝีมือ ประสบการณ์จึงช่วยให้เลือกได้เหมาะกับสถานการณ์
จุดประสงค์มีเพียงอย่างเดียว คือแปลงหน้า HTML แยก ๆ ที่ใช้ในมุมหนึ่งของแอปบนแพลตฟอร์มหนึ่ง ให้กลายเป็น carousel ที่เลียนแบบความรู้สึกแบบ native ของอีกแพลตฟอร์มหนึ่ง และมันถูกทำมาเฉพาะเจาะจงอย่างยิ่งกับแพลตฟอร์มนั้นและส่วนนั้นของแอป
จะทำให้สโคปทั้ง 9 เป็นฟังก์ชันแยกกันก็ได้ แต่ถ้าทำแบบนั้นนักพัฒนาคงอยากนำไปใช้ซ้ำ แต่ละขั้นมีสมมติฐานเล็ก ๆ น้อย ๆ เกี่ยวกับสิ่งที่เกิดขึ้นในขั้นก่อนหน้า และถ้าจะทำเป็นฟังก์ชันแยก ก็ต้องกลับมาตรวจทานสมมติฐานเหล่านั้น ทำให้ทั่วไปขึ้น และตรวจสอบว่าแต่ละเมธอดทำงานได้อย่างอิสระ ไม่มีเหตุผลต้องจ่ายต้นทุนแบบนั้นกับโค้ดที่แทบไม่มีที่อื่นต้องใช้
การดีบักก็ไม่ได้ยากขึ้น มีการทดสอบแบบ end-to-end และสถานะของขั้นตอนกลางก็ไม่รั่วออกนอกฟังก์ชัน จริง ๆ แล้วมีนักพัฒนาอีก 2 คนเข้ามาช่วยแก้ไขเมื่อเวลาผ่านไป และมันก็ทำงานได้ดี แถมเขียนได้เร็วด้วย
โค้ดเชิงเส้นขยายผลได้ดีและแก้ปัญหาได้ มันไม่ใช่รูปแบบที่ต้องการเสมอไป แต่ในหลายสถานการณ์กว่าที่คิด มันทำให้ชีวิตง่ายขึ้นมาก
ตอนเห็นสัตว์ประหลาด 2000 บรรทัดครั้งแรก ปฏิกิริยาไม่ดีนัก แต่พอดูแค่ 5 นาที ก็แทบหาข้อบกพร่องจริง ๆ ไม่เจอ และถ้ามีเทสต์ไม่กี่ชุด สิ่งที่เหลือก็มีแต่ความกลัวที่ไม่เกิดขึ้นจริง
ถึงจุดหนึ่งคุณจะตระหนักว่าฟังก์ชันหลายสิบตัวเหล่านั้น ต้องถูกเรียกตามลำดับเฉพาะเท่านั้น และแต่ละตัวถูกใช้เพียงครั้งเดียว สุดท้ายก็เหมือนบังคับให้ใครก็ตามที่อยากใช้ฟังก์ชันเหล่านั้นให้เป็นประโยชน์ ต้องรู้ลำดับการประกอบแบบเวทมนตร์เสียก่อน
เหตุผลหลักที่ฟังก์ชันเชิงเส้นขนาดใหญ่มักอ่านง่ายกว่าและน่าพึงประสงค์กว่า คือมันช่วยให้เก็บแนวคิดและความสัมพันธ์หลายอย่างไว้ เป็นก้อนเดียว พร้อมกันได้โดยไม่ต้องสลับบริบท จึงทำให้เข้าใจได้ง่ายขึ้น ผู้สนับสนุนแบบสุดขั้วคือ Arthur Whitney ผู้สร้างภาษา K ซึ่งเขียนโค้ดที่กระชับมากเพื่อให้ใส่ได้มากที่สุดในหนึ่งหน้าจอ และในสายตาคนอื่นแทบจะเข้าใจไม่ได้
จากตัวอย่างส่วนตัว การอ่าน ทำความเข้าใจ และดีบักฟังก์ชันประมวลผลข้อความ Windows ขนาดใหญ่ที่มี business logic อยู่ในคำสั่ง
switchใหญ่ ๆ หรือก็คือWndProcนั้นง่ายกว่าเวอร์ชัน Visual C++ ที่แยก message handler ออกเป็นฟังก์ชันต่างหากมากอีกตัวอย่างคือโค้ดตัวอย่างไมโครคอนโทรลเลอร์สำหรับการใช้ ADC มีเวอร์ชันที่ทุกอย่างอยู่ในไฟล์เดียว และเวอร์ชันที่แบ่งเป็นหลายไฟล์อย่าง
main.c,config.c,interrupts.c,timer.cเป็นต้น แม้จะไม่ถึง 200 บรรทัด แต่เวอร์ชันที่สองเข้าใจยากเพราะต้องสลับบริบทชิ้นโค้ดเหล่านี้มักกลายเป็นฟังก์ชัน
privateของคลาส และมีสถานะของตัวเอง พอเป็นฟังก์ชันprivateก็ทดสอบจริง ๆ ได้ยากตอนนี้จึงมีฟังก์ชัน
privateจำนวนมากที่ถูกเรียกเพียงครั้งเดียว และโดยปกติแก้ไขสถานะผ่าน side effect ถ้ามันอยู่ติดกับผู้เรียก ในกรณีง่าย ๆ ก็ยังพออ่านได้ แต่เมื่อเวลาผ่านไป ใครสักคนจะเพิ่มฟังก์ชันอื่นคั่นระหว่างฟังก์ชันที่เรียกกับฟังก์ชันที่ถูกดึงออกมาจากนั้น ชิ้นโค้ดที่คุณไม่รู้ว่าถูกเรียกจากที่ไหน เว้นแต่จะดู call graph หรือค้นหาในไฟล์คลาส ก็จะไปแก้ไขสถานะผ่าน side effect คนละแบบกัน
ถ้าจะทำให้โค้ดไม่เป็นเชิงเส้น อย่างน้อยก็อยากให้พิจารณาทำฟังก์ชัน
privateที่ดึงออกมาให้เป็น ฟังก์ชันภายใน ของฟังก์ชันที่เรียก หากภาษารองรับ แบบนั้นจะชัดเจนว่ามันไม่ได้ถูกเรียกจากที่อื่นในโค้ดเบสจริง เรื่องนี้ก็ไม่ใช่การเลือกอย่างใดอย่างหนึ่ง แต่ใกล้เคียงกับศิลปะของการผสมทั้งสองแบบให้เป็นรูปแบบที่อ่านง่ายและดูแลรักษาได้
คนจะทดสอบ branch ทั้งหมดนั้นจริงไหม? หรือจะเขียนแค่เทสต์ที่ใส่พิซซ่าหนึ่งอันแล้วดูคร่าว ๆ ว่าทำงานได้ไหม? การทดสอบหลาย branch จากภายนอกมักยุ่งยาก และน่ารำคาญกว่าการทดสอบฟังก์ชันเล็ก ๆ ที่เฉพาะเจาะจง ดังนั้นดูเหมือนอย่างหลังจะเป็นไปได้มากกว่า
คำกล่าวที่ว่า “โค้ดแบบเชิงเส้นขยายต่อไม่ได้” จริง ๆ แล้วกลับตรงกันข้าม สิ่งที่กลายเป็นฝันร้ายจริง ๆ ใน codebase ขนาดใหญ่คือฟังก์ชันเล็ก ๆ กระชับ ๆ ที่มี call stack ซ้อนลึก
มันไม่ชัดเจนว่าควรเพิ่มโค้ดใหม่ไว้ตรงไหน และต้องไล่ตามทุกเส้นทางที่โค้ดอาจถูกเรียก ทำให้ความยากในการประเมินผลกระทบของการเปลี่ยนแปลงเพิ่มขึ้นแบบทวีคูณ แถมยังเกิด subroutine ซ้ำ ๆ ด้วย
99% ของกรณีไม่ได้สร้าง abstraction ที่ดีขึ้นมาหรอก ดังนั้นใช้โค้ดแบบเชิงเส้นไปเลยดีกว่า ผมชอบ copy/paste มากกว่า semantics ของฟังก์ชันที่น่าสงสัย
print_table()เข้าไป ก็จะมีคนไปเจอมันแล้วเอาไปใช้ในโค้ดของตัวเอง จากนั้นก็ใส่ flag เล็ก ๆ เพื่อปรับ output ให้เข้ากับ use case ของตัวเองผ่านไป 12 เดือนมันจะหน้าตาแบบนี้:
print_table(rows,headers = None,is_unicode = False,left_align = False,align = [],remove_emoji = None,max_width = 80,potato_mode = 7,_debug_frontend = not FLAGS.dont_debug,ellipsis_for = 0,no_print = False,)ถ้ามองสองแนวคิดนี้ให้เป็นอิสระต่อกัน ยกเว้นข้อเท็จจริงที่ว่าความอ่านง่ายอาจส่งผลต่อความสามารถในการขยาย โค้ดแบบเชิงเส้นก็ขยายได้ไม่ดีเท่าโค้ดแบบโมดูลาร์ การแบ่งเป็นสองขั้วแบบนี้ควรรู้ไว้ และควรนำมาพิจารณาตามสถานการณ์
แต่ถึงอย่างนั้นก็ยังไม่เห็นด้วยอยู่ดี ถ้าฟังก์ชันเล็ก ๆ เป็น pure function ก็จะไม่ก่อปัญหาเรื่องความอ่านง่าย หมายความว่ามันไม่แตะ state ไม่ยัด logic เข้าไปในโค้ด และควรลด dependency injection กับการส่งฟังก์ชันไปให้ฟังก์ชันอื่นให้เหลือน้อยที่สุดอย่างชัดเจน
ถ้าสร้าง pipeline ของ pure function ที่ส่งต่อกันแค่ data ก็จะทั้งอ่านง่ายและขยายได้ โอกาสที่ต้องเขียน logic ใหม่เพราะข้อบกพร่องในการออกแบบจะน้อยลงมาก และเมื่อประกอบ pure function เข้าด้วยกัน โค้ดจะเหมือนเลโก้ การ refactor ก็ใกล้เคียงกับการจัดองค์ประกอบพื้นฐานเดิมใหม่และประกอบใหม่
โค้ดตัวอย่างน่าจะทำให้วอกแวกน้อยกว่านี้ ถ้าอย่างน้อยพยายามรักษาอุปมาเรื่องพิซซ่าให้มีความหมาย หรือไม่ก็ไม่ใช่ โค้ด Go ระดับต่ำแบบนี้
prepareเป็นชื่อฟังก์ชันที่แย่มาก ถ้าเป็น Gopher ที่ชำนาญคงตั้งชื่อประมาณNewPizzaFromOrderไม่เห็นเหตุผลที่จะทำ
addToppingsเป็นฟังก์ชันแยก ถ้าจำเป็นจริง ๆ ส่วนตัวคงทำเป็นเมธอดของPizzaแบบfunc (p *Pizza) WithToppings(topping ...Topping) *Pizza { /* ... */ }พิซซ่าจริง ๆ เปลี่ยนแปลงได้อยู่แล้ว ดังนั้นเมธอดจึงแก้ไข receiverก็ไม่เข้าใจเหมือนกันว่าทำไมต้อง instantiate เตาอบใหม่ทุกครั้งที่อบพิซซ่า ควรเริ่มจากเตาอบที่มีอยู่แล้ว เรียก
oven.Preheat()แล้วเรียกoven.Bake(pizza)มากกว่า ยิ่งไปกว่านั้น อาจให้oven.Preheat()คืน type ใหม่ของOvenที่ expose.Bake()เพื่อกันข้อผิดพลาดการอบโดยไม่ preheat ได้ตั้งแต่ขั้น compile ก็ได้ ที่อื่นอาจมี interfaceBakerและอาจมี implementation แบบToasterOvenที่ไม่จำเป็นต้อง preheat เพราะการ preheat ไม่ได้สำคัญนักต่อให้ไม่เปลี่ยนโค้ด ผมก็จะจัดลำดับ declaration ใหม่ให้ตรงกับ flow ที่คาดเดาได้ เพื่อที่เวลาสแกนฟังก์ชันที่เรียกกันจะได้ไม่ต้องกระโดดหน้าขึ้นลง
ไม่มีเวลาเลยหยุดไว้แค่นี้ แต่โค้ดนี้เป็นตัวอย่างที่แย่เกินไปแล้ว แม้แต่จะใช้เริ่มถกเถียงว่า “แบบไหนอ่านง่ายกว่า” ก็ตาม
John Carmack ก็พูดเกือบเหมือนกัน และผมก็ทำตามมาตลอดตั้งแต่นั้นมา โค้ดแบบเชิงเส้นอ่านง่ายโดยธรรมชาติ เพราะมันเดินตามลำดับการทำงาน และลด การกระโดดของสายตา ให้เหลือน้อยที่สุด
โค้ดบางอย่างจำเป็นต้องไม่เป็นเชิงเส้นเพื่อการ reuse และเมื่อเป็นแบบนั้น execution ก็กลายเป็นกราฟ ถ้าโค้ดไม่ได้ใช้ประโยชน์จากการ reuse ในโครงสร้างแบบกราฟ ก็ไม่จำเป็นต้องเพิ่ม vertex ในจุดที่ edge เส้นเดียวก็พอ
http://number-none.com/blow/blog/programming/2014/09/26/carm...
ในกรณีนี้ โค้ดฝั่งซ้ายน่าจะดีกว่านี้ถ้าทำเป็น
pizza.Toppings = get_pizza_toppings(order.kind)เพราะจะทำให้ การเปลี่ยนแปลงพิซซ่า ยังคงเป็นแกนหลักอยู่ในฟังก์ชันหลักเห็นด้วยในระดับหนึ่งว่าโค้ดแบบเชิงเส้นอ่านง่ายกว่า แต่แค่นั้นไม่ได้ทำให้มันเป็นแนวปฏิบัติการเขียนโค้ดที่ดี
ผมคิดว่าโค้ดเชิงเส้นที่ดีอ่านง่ายกว่า แต่ด้านการบำรุงรักษาและความง่ายในการทดสอบแย่ลงมาก ผมมีประสบการณ์หลายสิบปีและยังเป็นผู้ประเมินภายนอกให้นักศึกษา CS ด้วย ตลอดหลายปีที่ผ่านมา แนวปฏิบัติที่ดีในโลกจริงที่ผมเห็นว่าแน่นอนมีแค่ รักษาฟังก์ชันให้เล็ก เท่านั้น
ผมไม่ได้ชอบ abstraction เป็นพิเศษ และก็ไม่ได้คิดว่าต้องหลีกเลี่ยงการทำโค้ดซ้ำเสมอไป แต่ถ้าทำฟังก์ชันให้ใกล้เคียงกับวัตถุประสงค์เดียวเท่าที่เป็นไปได้ ตัวคุณในอนาคตจะขอบคุณคุณ
ถ้าโค้ดแบบในตัวอย่างรันใน production นาน 10 ปี แต่ละส่วนจะเปลี่ยนไป ถ้าโชคดี comment ก็จะถูกอัปเดตด้วย แต่ส่วนใหญ่จะไม่เป็นเช่นนั้น unit test ก็จะใหญ่และจัดการยากขึ้นจนค่อย ๆ เละเทะ และอาจมีใครบางคนลืมแก้ส่วนของ test ที่ดูไม่เกี่ยวข้องโดยตรงกับการเปลี่ยนแปลง โค้ดเองก็มีแนวโน้มจะอ่านยากลงตามเวลา ไม่ใช่เพราะเจตนาหรือความไร้ความสามารถ แต่เพราะเหตุผลแบบมนุษย์ เช่นแรงกดดันด้านเวลา
ถ้าเป็นโลกที่สมบูรณ์แบบ เราคงไม่จำเป็นต้องแยก concern ออกจากกัน แต่เราอยู่ในโลกที่ไม่สมบูรณ์ และยิ่งฟังก์ชันเล็กและมีความรับผิดชอบน้อยเท่าไร ก็ยิ่งรับมือกับความไม่สมบูรณ์นั้นได้ง่ายขึ้นเมื่อเวลาผ่านไป
ถ้าคุณกำลังส่ง object ให้ไหลผ่านลำดับของสถานะบางอย่าง ผมคิดว่าควรจะแยกมันออก แล้วใช้ type เพื่อแสดง transition หรือไม่ก็เขียนเป็นฟังก์ชันใหญ่ฟังก์ชันเดียวไปเลย ตัวอย่างเช่น ถ้า
bakePizzaรับRawPizzaแล้วคืนBakedPizzaก็สามารถบังคับลำดับการเรียกได้ตั้งแต่ compile timeเพราะอ่านง่าย ความถูกต้อง และความง่ายในการทดสอบ ผมชอบแบบแรกมากกว่า แต่ในภาษาโปรแกรมส่วนใหญ่ หากต้องการเปลี่ยน type ของ object ก็ต้องสร้าง object ใหม่ ซึ่งมีต้นทุน runtime ถ้าเป็น hot code path การแก้ไข in-place ก็สมเหตุสมผล และในกรณีนั้นการวางไว้ในฟังก์ชันเชิงเส้นฟังก์ชันเดียวดีกว่า
https://mitpress.mit.edu/9780262045490/
อีเมลที่เกี่ยวข้องของ John Carmack: http://number-none.com/blow/blog/programming/2014/09/26/carm...
การถกเถียง: https://news.ycombinator.com/item?id=12120752
เห็นด้วยอย่างมาก เมื่อก่อนผมเคยอยู่ฝั่งตรงข้าม
ความตึงเครียดพื้นฐานตรงนี้อยู่ระหว่าง locality of behaviour ของฝั่งหนึ่ง กับความต้องการของอีกฝั่งที่อยากให้มุมมอง “สารบัญ” ระดับสูงชัดเจน ในโค้ดที่อ่านง่าย locality สำคัญกว่า ตามที่บทความบอก มุมมองแบบสารบัญทำให้ชัดได้พอด้วย comment แบ่ง section
ยังมีเหตุผลที่สำคัญกว่านั้นที่ควรชอบโค้ดเชิงเส้น เมื่อสำรวจ codebase ทั้งหมด จะง่ายกว่ามากถ้า “ก้อน” ต่าง ๆ เช่นฟังก์ชัน คลาส หรือหน่วยที่ภาษาบังคับไว้ สอดคล้องคร่าว ๆ กับ use case ทางธุรกิจ ไม่อย่างนั้นพื้นที่ในการสำรวจจะใหญ่เกินไป และคุณต้องประกอบภาพรวมขึ้นเองจากชิ้นส่วนต่าง ๆ โครงสร้างโค้ดควรทำงานนั้นแทนคุณ
ถ้า “สิ่ง” หลายอย่างล้วนเกี่ยวข้องกับงานเดียวกัน เช่นการสมัครสมาชิกหรือการซื้อ ก็ควรวางไว้เป็นหนึ่งเดียวในโค้ดด้วย จะหาและแก้ได้ง่ายกว่ามาก ควรแยกเป็นฟังก์ชันย่อยเฉพาะเมื่อจำเป็นต้อง reuse เท่านั้น ไม่ใช่แยกเพียงเพื่อจัดระเบียบ
[0] https://htmx.org/essays/locality-of-behaviour/
เหตุผลใหญ่ที่สุดคือ state ยิ่งฟังก์ชันยาว scope ของ local variable ก็ยิ่งกว้าง ตัวแปรอะไรก็ถูกเปลี่ยนได้จากที่ไหนก็ได้ในฟังก์ชัน และ data flow ก็ไม่ชัดเจนทันที ถ้ามีฟังก์ชันมากขึ้น scope จะถูกทำให้เล็ก และ data flow ก็ชัดเจนมากขึ้น
ผลข้างเคียงคือ indentation ก็ลดลงด้วย
พร้อมกันนั้น ผมก็ไม่ชอบฟังก์ชันที่เล็กเกินไป เพราะจะหายากว่างานจริงเกิดขึ้นตรงไหน
ลองนึกถึง การประมวลผลปิดสิ้นวัน ที่มี 10 ขั้นตอนที่ reuse ไม่ได้และต้องรันตามลำดับ โดยแต่ละขั้นตอนยาว 100 บรรทัด แต่ละขั้นใช้ข้อมูลที่คล้ายกับขั้นก่อนหน้าแต่ไม่เหมือนกัน คุณจะเลือกฟังก์ชันเดียวยาว 1000 บรรทัดจริง ๆ หรือ?
ทั้งสองแบบอ่านเป็นเชิงเส้นได้เหมือนกัน เวอร์ชันที่แยกฟังก์ชันเล็กออกมามี สารบัญ อยู่ด้านบนของหน้า และสรุป data flow ระหว่างขั้นตอน ถ้าจะอ่านทั้งหมด ก็ดูเป็นลำดับการอ่านที่น่าดึงดูด
แต่เพื่อรักษาความอ่านง่ายนี้ไว้ เมื่อมีการเปลี่ยนลำดับขั้นตอน ก็ต้องย้ายตำแหน่งฟังก์ชันด้วย ถ้าเป็นฟังก์ชัน
privateและถูกเรียกแค่จากสารบัญก็โอเค แต่ไม่มีอะไรบังคับให้คงลำดับไว้ และก็ไม่ได้บังคับให้คิดถึง flow การอ่านทั้งหมดด้วยเมื่อฟังก์ชันเริ่มถูก reuse บ่อยครั้งก็จะไม่สามารถทำให้เป็นเชิงเส้นได้อีก บางครั้งผู้คนก็ยอมแพ้แล้วเรียงตามลำดับตัวอักษร หรือไม่ก็กลายเป็นสุ่มไปเลย
จากประสบการณ์ ยิ่งใครคุ้นกับโค้ดมากเท่าไร ก็ยิ่งคิดว่าการยัดโค้ดเข้าไปในฟังก์ชันเล็ก ๆ เป็นหนทางที่ถูกต้อง
เพราะคนคนนั้นได้สร้าง mental model ของโค้ดนั้นไว้แล้ว สำหรับเขา การ implement ที่สะอาดที่สุดคือแบบที่มีจำนวนบรรทัดน้อยมาก
แต่เมื่อคนถัดไปเข้ามา เขาต้องวิ่งไปมาหลายที่เพื่อสร้าง mental model แบบเดียวกันโดยไม่มีบริบทเดิม และต้อง push/pop stack ในหัวไปด้วย ซึ่งยากกว่ามาก
เช่น คุณอ่าน source code ของ standard library ของภาษาที่ใช้อยู่บ่อยแค่ไหน? แทบไม่อ่านเลย ปกติดูแค่ method signature และถ้าซับซ้อนหรือตัวใหม่หน่อยก็อ่านเอกสาร
แก่นของ interface คือทำให้เราสนใจแค่ว่า method ทำอะไร ไม่ใช่ว่ามัน implement อย่างไร สิ่งนั้นอธิบายได้ด้วยการผสมกันของบริบท ชื่อ และเอกสาร แต่ developer จำนวนมากไม่เข้าใจหรือไม่ใส่ใจเรื่องนี้ จึงเขียนโค้ดที่ไม่สมเหตุสมผล ไม่ว่าจะเป็นแบบเชิงเส้นหรือแบบโมดูลาร์ก็ตาม
เช่น ถ้าใน service class ต้องเรียก method หนึ่งเพื่อเอาข้อมูลบางอย่าง เรียกอีก method เพื่อเอาข้อมูลอีกอย่าง แล้วต้องเรียก method ที่สามเพื่อเอาข้อมูลที่จะนำไปรวมกับสองอย่างก่อนหน้า service นั้นมีความหมายอะไรกันแน่? เท่ากับเผย complexity ภายในออกมาข้างนอกทั้งหมด
ไม่ได้บอกให้บังคับใช้ method เล็ก ๆ ฟังก์ชัน 5 บรรทัดจำนวน 20 ฟังก์ชันที่ถูกเรียกแค่ครั้งเดียว ทำงานเฉพาะเจาะจงมาก และต้องถูกเรียกตามลำดับที่ถูกต้องนั้นไม่มีความหมาย นั่นไม่ใช่ clean code แต่ใกล้เคียงกับ cargo cult programming มากกว่า
สิ่งสำคัญคือการ abstract อย่างเหมาะสมให้ทั้งสมาชิกทีมใหม่และสมาชิกที่ชำนาญแล้วเข้าใจได้ง่าย reasoning ได้ง่าย และซ่อน complexity ไว้ในตำแหน่งที่เหมาะสม ไม่ง่ายนัก แต่ทำได้
ลูกชายผมฉลาดพอสมควรแต่ลำบากที่โรงเรียน และหนึ่งในผู้เชี่ยวชาญหลายคนอธิบายว่า โรงเรียนโดยทั่วไปสอนแบบ bottom-up แต่ลูกชายผมเป็นผู้เรียนแบบ top-down มาก เขาต้องเห็นภาพรวมก่อนค่อยลงรายละเอียด ขณะที่คนอื่น ๆ ต้องจับรายละเอียดก่อนแล้วค่อยประกอบเป็นภาพรวม โรงเรียนมักสอนให้เหมาะกับกลุ่มที่สอง
ในหมู่ programmer ก็อาจมีความแตกต่างคล้ายกัน
ถ้า developer ก่อนหน้าเขียนฟังก์ชัน
BakePizzaไว้ ก็สมมติว่าพิซซ่าจะถูกอบอย่างถูกต้อง แล้วข้ามไปบรรทัดถัดไปได้เลย ถ้าพยายามทำความเข้าใจวิธีดำเนินงานของร้านอาหารแล้วไปจมกับรายละเอียดอย่างอุณหภูมิเตาอบ สุดท้ายจะไม่เข้าใจด้วยซ้ำว่าร้านอาหารทำงานอย่างไร และยังลืมอุณหภูมิเตาอบที่ถูกต้องอีกด้วยeditor ควรมี toggle สำหรับ inline ฟังก์ชันชั่วคราว จะได้ไม่ต้องสลับไปมาอีกต่อไป