รูปแบบความซับซ้อนทางสายตาที่ทำให้โค้ดอ่านยากขึ้น (2023)
(seeinglogic.com)- แม้โค้ดจะยอดเยี่ยมในเชิงตรรกะ ก็อาจอ่านนาน ๆ ได้ยาก บทความนี้มองหาความเหนื่อยล้านั้นจาก ความซับซ้อนที่มองเห็นได้ด้วยตา
- Halstead Complexity Metrics นับจำนวนตัวดำเนินการและตัวถูกดำเนินการเพื่อคำนวณ Volume และ Difficulty และมองว่าการเขียนที่มีตัวแปรกับตัวดำเนินการมากกว่า แม้ทำงานเหมือนกัน ก็ยากกว่า
- Cognitive Complexity ของ SonarSource ประเมินภาระจากเงื่อนไข ลูป การจัดการข้อยกเว้น การผสมตัวดำเนินการเชิงตรรกะ รีเคอร์ชัน และ
gotoโดยเน้นที่ไวยากรณ์ย่อ การขัดจังหวะโฟลว์เชิงเส้น และโฟลว์ควบคุมที่ซ้อนกัน - ในแง่ตัวแปร variable shadowing, ชื่อที่คล้ายกัน, ช่วงอายุของตัวแปรที่ยาว และรูปแบบการใช้งานที่ไม่คุ้นเคย ทำให้ผู้อ่านต้องเสียต้นทุนมากขึ้นในการติดตามการไหลของข้อมูล
- ฟังก์ชันเล็ก ๆ, รูปแบบที่คุ้นเคย, การจัดกลุ่มเชนยาว ๆ, เงื่อนไขที่เรียบง่าย, การใช้
gotoอย่างจำกัด, การซ้อนที่ตื้น, ชื่อตัวแปรที่แยกแยะได้ และช่วงอายุของตัวแปรที่สั้น กลายเป็นเกณฑ์ด้าน readability ที่ใช้ได้ข้ามภาษาและรูปแบบการจัดฟอร์แมต
ข้อจำกัดและเป้าหมายของตัวชี้วัด readability ของโค้ด
- readability ของโค้ดไม่มี ตัวชี้วัดเดี่ยวที่ใช้กันแพร่หลายและเป็นที่เห็นพ้องกัน
- แหล่งอ้างอิงที่พอใช้ได้มักเป็นงานวิชาการที่ไม่ได้ใช้กันแพร่หลายในโลกจริง หรือใกล้เคียงกับความคิดเห็น จึงจำเป็นต้องมีเครื่องมืออภิปรายที่เป็นรูปธรรมกว่าเพื่อใช้ได้ทันทีในการ code review
- เป้าหมายไม่ใช่การประดิษฐ์ตัวชี้วัดใหม่ แต่เป็นการรวบรวม รูปแบบทางสายตา ที่ใครก็ใช้ได้เมื่อพูดว่าโค้ดอ่านง่ายหรือไม่
- สิ่งที่นำมาพิจารณาคือการวัดหรือแนวคิดที่เข้าเงื่อนไขต่อไปนี้
- ใช้กับชิ้นส่วนซอร์สโค้ดหรือฟังก์ชันเดี่ยวได้
- ไม่ได้โฟกัสเฉพาะ ความซับซ้อนหลัก ที่แยกออกจากอัลกอริทึมของการ implement เองได้ยาก เช่น Cyclomatic Complexity
- ไม่ได้หยุดอยู่แค่องค์ประกอบสไตล์ผิวเผิน เช่น ความยาวชื่อตัวแปร ช่องว่าง การเยื้อง หรือการวางวงเล็บ
Halstead Complexity Metrics
- Maurice Halstead เสนอ Halstead Complexity Metrics ในช่วงปลายทศวรรษ 1970 เพื่อสร้างค่าการวัดเชิงประจักษ์ของซอร์สโค้ด
- ตัวชี้วัดนี้ใช้ได้ข้ามภาษาและแพลตฟอร์ม และโฟกัสที่รูปแบบการเขียนโค้ดมากกว่าอัลกอริทึมที่ถูก implement เอง
- ค่าการวัดหลักคือจำนวน 4 ค่า โดยอิงจากตัวดำเนินการและตัวถูกดำเนินการ
- จำนวนตัวดำเนินการที่ไม่ซ้ำ
n1 - จำนวนตัวถูกดำเนินการที่ไม่ซ้ำ
n2 - จำนวนตัวดำเนินการทั้งหมด
N1 - จำนวนตัวถูกดำเนินการทั้งหมด
N2
- จำนวนตัวดำเนินการที่ไม่ซ้ำ
- จากสิ่งเหล่านี้ Halstead สร้างตัวชี้วัดที่เกี่ยวข้อง เช่น length, volume, difficulty และยังพยายามอนุมานตัวเลขสำหรับคาดการณ์จำนวนบั๊กใน implementation ด้วย
- โดยสัญชาตญาณ ยิ่งมีตัวดำเนินการมาก ก็ยิ่งต้องพิจารณาปฏิสัมพันธ์ที่เป็นไปได้มากขึ้น และยิ่งมีตัวถูกดำเนินการมาก ก็ยิ่งเข้าใจความเป็นไปได้ของการไหลของข้อมูลได้ยากขึ้น
-
ตัวอย่าง JavaScript
- แม้จะเป็นฟังก์ชันตรวจเลขคู่คี่แบบเดียวกัน implementation แบบง่ายที่ใช้
ifและreturnมีตัวดำเนินการและตัวถูกดำเนินการน้อยกว่า - ตัวดำเนินการที่ไม่ซ้ำ 4 ตัว, ตัวดำเนินการทั้งหมด 7 ตัว
- ตัวถูกดำเนินการที่ไม่ซ้ำ 5 ตัว, ตัวถูกดำเนินการทั้งหมด 6 ตัว
Volume33.30,Difficulty2.50- implementation ที่ใช้อาร์เรย์,
Number, นิพจน์เปรียบเทียบ และดัชนี มีตัวดำเนินการและตัวถูกดำเนินการมากกว่า - ตัวดำเนินการที่ไม่ซ้ำ 7 ตัว, ตัวดำเนินการทั้งหมด 10 ตัว
- ตัวถูกดำเนินการที่ไม่ซ้ำ 9 ตัว, ตัวถูกดำเนินการทั้งหมด 12 ตัว
Volume71.35,Difficulty3.75- implementation แรกดูด้วยตาก็ง่ายกว่า และค่า
VolumeกับDifficultyของ Halstead ก็สนับสนุนเรื่องนี้ - ข้อเสียคือ ในทุกภาษาไม่ได้ชัดเจนเสมอไปว่าอะไรควรถูกมองเป็น operator และ operand ดังนั้นในการวัดควรกำหนดเครื่องมือหรือ implementation เฉพาะแล้วใช้ให้สม่ำเสมอ
- แม้จะเป็นฟังก์ชันตรวจเลขคู่คี่แบบเดียวกัน implementation แบบง่ายที่ใช้
-
รูปแบบเชิงปฏิบัติที่ได้จาก Halstead
- โดยทั่วไป ฟังก์ชันยิ่งเล็กและมีตัวแปรยิ่งน้อย ก็ยิ่งอ่านง่าย
- ตัวดำเนินการเฉพาะภาษา หรือ syntactic sugar เพิ่มภาระให้ผู้อ่าน จึงควรหลีกเลี่ยงการใช้มากเกินไป
- หากนำองค์ประกอบเชิง functional เช่น
map,reduce,filter, lambda, iterator, comprehension มาเชื่อมเป็นเชนยาว ๆ แม้จะกระชับก็อาจทำให้ readability ลดลง - เชนยาวลักษณะนี้อาจพบได้บ่อยกว่าใน JavaScript และ Rust หรือในโค้ด Python ที่ใช้
itertoolsอย่างลึกซึ้ง
ความยากในการอ่านตาม Cognitive Complexity
- Cognitive Complexity ที่ SonarSource สร้างขึ้น เป็นตัวชี้วัดเพื่อจับความอ่านยากให้แม่นยำขึ้น
- แนวคิดหลักของตัวชี้วัดนี้มี 3 ข้อ
- ไวยากรณ์ย่อที่รวมประโยคลดความยากลง
- ทุกครั้งที่ออกนอกโฟลว์เชิงเส้น ความยากจะเพิ่มขึ้น
- โฟลว์ควบคุมที่ซ้อนกันเพิ่มความยาก
- มีคำวิจารณ์ว่าชื่อฟังดูเหมือนเป็นตัวชี้วัดเชิงวิทยาศาสตร์หรือเชิงวัตถุวิสัย แต่ในทางปฏิบัติอาจมองได้ว่าเป็น heuristic ที่ได้ผล
-
ปัญหาความหนาแน่นของไวยากรณ์ย่อ
- ไวยากรณ์ย่ออย่าง
MyObj myObj = a?.myObj;สั้นกว่าและใช้เวลาอ่านน้อยกว่าแบบif (a != null) { myObj = a.myObj; } - แต่โค้ดสองแบบอาจไม่ได้เหมือนกันทุกประการจริง ๆ
- แบบแรกทำให้
myObjเป็นa.myObjหรือnull - แบบที่สองทำให้
myObjเป็นa.myObjหรือundefined - แม้ภาษาที่ตรวจชนิดเข้มงวดอย่าง TypeScript หรือ Rust จะลดความเป็นไปได้ที่จะพลาดได้ แต่ก็ไม่ได้รับประกันว่าจะจัดการทุกกรณีได้ถูกต้อง
- หากการรองรับการตรวจชนิดอ่อนกว่า เช่น JavaScript ทั่วไป โอกาสที่ corner case แบบนี้จะไม่ถูกจัดการก็สูงขึ้น
- ไวยากรณ์ย่ออาจเขียนง่ายและอ่านง่าย แต่มี trade-off ระหว่าง ความกระชับกับความหนาแน่น
- ไวยากรณ์ย่ออย่าง
-
องค์ประกอบที่ทำลายโฟลว์เชิงเส้น
- โค้ดเชิงเส้นที่ไม่มีเงื่อนไขอ่านแบบกวาดตาได้ง่ายกว่าโค้ดที่มีเงื่อนไข
- Cognitive Complexity มองว่าไม่ใช่แค่เงื่อนไข ลูป และ
gotoแต่รวมถึง conditional macro,try/except, ลำดับตัวดำเนินการเชิงตรรกะ และรีเคอร์ชัน ล้วนเป็นองค์ประกอบที่เพิ่มความยาก switchถูกคำนวณเป็นกลุ่มเดียว แต่เชนelse-ifถูกมองว่ายากขึ้นทุกครั้งที่มีelse-ifเพิ่ม- เพราะ
else-ifสามารถมีการเปรียบเทียบตั้งแต่สองอย่างขึ้นไปในแต่ละ branch - อย่างไรก็ตาม fall-through ของ
switchและbreakที่ขาดหายไปก็เพิ่มความยากในการอ่านได้เช่นกัน - กรณีที่ต่อกันด้วยตัวดำเนินการเชิงตรรกะตัวเดิมในนิพจน์เงื่อนไข กับกรณีที่ผสม
&&,||,!จะให้ความยากต่างกัน debug || verbose || consoleModeเป็นนิพจน์เงื่อนไขที่เรียบง่ายdebug || (verbose && consoleMode)อ่านยากขึ้นเพราะมีการผสมตัวดำเนินการdebug || !(verbose && consoleMode)ซับซ้อนขึ้นอีกเพราะรวมการปฏิเสธด้วย
-
การจัดการข้อยกเว้นและ goto
try/catchเพิ่มความยากใน Cognitive Complexity แต่การมีหลายบล็อกcatchไม่ได้ถูกมองว่ายากกว่าการมีcatchเดียว และtryกับfinallyจะถูกละเลย- การ throw exception เองก็สร้างต้นทุนในการอ่านได้
- เมื่อการจัดการ exception ข้ามขอบเขตฟังก์ชัน ความซับซ้อนของฟังก์ชันที่เกี่ยวข้องจะพันกัน
- ผู้อ่านต้องค้นหาว่า exception นั้นถูกจับที่ไหน
- โดยทั่วไป
gotoถูกนับเป็นองค์ประกอบที่เพิ่มความยาก - แต่รูปแบบ
goto outหรือgoto doneที่ปล่อยทรัพยากรแล้วออกจากฟังก์ชันในเงื่อนไขข้อผิดพลาด ผู้เชี่ยวชาญบางส่วนมองว่าอาจมีประโยชน์ - ในทางกลับกัน
gotoที่ข้ามขอบเขตลูปในแบบที่ใช้continueหรือbreakแทนไม่ได้ สร้างภาระในการอ่านสูง เพราะผู้อ่านต้องประกอบโฟลว์ควบคุมใหม่
การซ้อนและรูปทรงของฟังก์ชัน
- หากเงื่อนไขเองก็อ่านยาก เงื่อนไขที่ซ้อนกันก็ยิ่งอ่านยากกว่า
- Cognitive Complexity ให้ความยากเพิ่มตามระดับการซ้อน นอกเหนือจากคะแนนของเงื่อนไขและลูปเอง
- แนวคิดนี้ยังถูกเรียกด้วยชื่ออย่าง “Level of Indentation” หรือ “Bumpy Road”
- เมื่อการซ้อนเกิน 2 ระดับ จะอ่านยากเป็นพิเศษ และโค้ดที่ลดการซ้อนด้วย early return จะอ่านได้ราบกว่า
- ตัวชี้วัดนี้ไม่ได้สะท้อนความยาวฟังก์ชันโดยตรง แต่ถ้าเงื่อนไขอื่นเท่ากัน ฟังก์ชันยาวย่อมใช้แรงอ่านมากกว่าฟังก์ชันสั้น
ชื่อตัวแปร ช่วงอายุ และรูปแบบที่คุ้นเคย
-
ชื่อที่แยกแยะได้และสื่อความหมาย
- ชื่อที่สื่อความหมายสำคัญต่อการเข้าใจว่าโค้ดพยายามทำอะไร ส่วนชื่อที่ซ้ำซ้อนหรือเหมือนรหัสลับให้ผลตรงข้าม
- variable shadowing เป็นเรื่องอันตราย
- ควรหลีกเลี่ยงสถานการณ์ที่ผู้อ่านต้องไล่กฎ scope เพื่อแยกแยะว่าตัวแปรใดถูกใช้
- ควรหลีกเลี่ยง identifier ที่ดูคล้ายกันด้วย
- ชื่อที่สับสนได้ง่ายด้วยสายตา เช่น
iกับj,itemกับitemsอาจนำไปสู่ความผิดพลาด - โค้ดที่ใช้รูปแปรหลายแบบของชื่อตัวแปรเดียวกันในฟังก์ชันเดียว เช่น
node,_node,thisNodeเป็นรูปแบบที่ทำให้เกิดบั๊กได้ง่าย
-
ช่วงอายุของตัวแปรที่สั้น
- live variable analysis ดูช่วงตั้งแต่จุดที่ตัวแปรถูกใช้ครั้งแรก ไปจนถึงจุดสุดท้ายที่อาจถูกใช้ได้
- หากช่วงอายุตัวแปรยาว ผู้อ่านต้องเก็บตัวแปรและค่าที่เป็นไปได้ไว้ในหัวมากขึ้น
- การประกาศตัวแปรก่อนใช้จริงทันทีช่วยลดช่วงอายุได้ดีกว่าวิธีประกาศตัวแปรทั้งหมดไว้บนสุดของฟังก์ชัน
- กรณีเลวร้ายที่สุดคือตัวแปรมีชีวิตอยู่ข้ามหลายฟังก์ชันและถูกใช้หลายแห่ง
- หากช่วงอายุของตัวแปรหลายตัวแทบจะเหมือนกัน การใช้อ็อบเจ็กต์อาจเหมาะกว่า
- ถ้าอ็อบเจ็กต์ไม่เหมาะ ก็ควรลดจำนวนฟังก์ชันและจำนวนบรรทัดที่ผู้อ่านต้องอ่านเพื่อเข้าใจค่าให้น้อยที่สุด
-
เชนยาวและตัวแปรกลาง
- สไตล์ functional programming ทำให้ช่วงอายุตัวแปรสั้น แต่เชนที่ยาวเกินไปหรือ callback ที่ซ้อนกันอาจสร้างภาระในการอ่านได้
- การแบ่งเชนฟังก์ชันยาว ๆ เป็นกลุ่มเล็ก ๆ และใช้ตัวแปรกลางหรือ helper function ที่ตั้งชื่อดี จะช่วยลดภาระทางปัญญาของผู้อ่านได้
- เวอร์ชันที่ใช้ตัวแปรกลางอาจมีประสิทธิภาพน้อยลงเล็กน้อย
- ตราบใดที่เครื่องมือด้าน performance ไม่ได้บอกว่าบรรทัดนั้นเป็นคอขวดจริง ความต่างด้านประสิทธิภาพเล็กน้อยแบบนี้ไม่สำคัญ
-
การใช้รูปแบบโค้ดที่คุ้นเคยซ้ำ
- การใช้โค้ดและรูปแบบตัวแปรที่คุ้นเคยซ้ำ ทำให้ผู้อ่านจำ pattern ที่รู้อยู่แล้วได้ จึงอ่านได้เหนื่อยน้อยลง
- เรื่องนี้สอดคล้องกับ Principle of Least Surprise
- ภายใน codebase ควรรักษารูปแบบที่เกิดซ้ำ เช่น วิธีเขียนเงื่อนไข ให้สม่ำเสมอ
- เมื่อต้องออกนอก pattern อาจใช้ชื่อตัวแปรหรือคอมเมนต์เพื่อแสดงความแตกต่างได้
- หากผลักแนวคิดนี้ไปจนสุด ก็จะนำไปสู่การใช้ template function หรือ generic function เพื่อให้ผู้อ่านไม่ต้องจำ pattern ที่ซ้ำกันใหม่อีกครั้ง
8 รูปแบบทางสายตาที่ช่วยเพิ่ม readability
- Line/Operator/Operand count: ฟังก์ชันที่เล็กกว่า และตัวแปรกับตัวดำเนินการที่น้อยกว่า อ่านง่ายกว่า
- Novelty: หลีกเลี่ยงความแปลกใหม่ในรูปทรงฟังก์ชัน ตัวดำเนินการ และ syntactic sugar แล้วใช้ pattern ร่วมของ codebase ซ้ำ
- Grouping: เชนฟังก์ชันยาว ๆ, iterator, comprehension ควรแบ่งเป็นกลุ่มเชิงตรรกะด้วย helper function หรือตัวแปรกลาง
- Conditional simplicity: รักษานิพจน์เงื่อนไขให้สั้นที่สุดเท่าที่ทำได้ และในเงื่อนไขเดียวให้เลือกใช้ลำดับของตัวดำเนินการชนิดเดียวกันมากกว่าผสมตัวดำเนินการเชิงตรรกะต่างชนิด
- Gotos: อย่าใช้
gotoเว้นแต่จะทำตาม pattern การจัดการข้อผิดพลาดเฉพาะ และทางเลือกอื่นแย่กว่า - Nesting: ลดโลจิกที่ซ้อนกันและการเปลี่ยนระดับเยื้องมาก ๆ ให้น้อยที่สุด และถ้าจำเป็นต้องซ้อนลึก ให้แยกเป็นฟังก์ชันต่างหากแทนที่จะฝังไว้ในฟังก์ชันใหญ่
- Variable distinction: ใช้ชื่อตัวแปรที่สื่อความหมายและแยกแยะได้ด้วยสายตา และหลีกเลี่ยง variable shadowing
- Variable liveness: รักษาช่วงอายุตัวแปรให้สั้น และระวังเป็นพิเศษกับช่วงอายุยาวที่ข้ามขอบเขตฟังก์ชัน
ปัญหาที่ปรากฏใน codebase จริง
- codebase ที่ทำให้เกิดความเหนื่อยล้าทางใจอย่างมาก มี anti-pattern หลายอย่างพร้อมกัน
- โดยเฉพาะฟังก์ชันยาว การผสมองค์ประกอบของภาษาหลายแบบ และเชนฟังก์ชันจำนวนมากที่ควรแยกออกเป็น helper function
- ผลคือในฟังก์ชันใหญ่ ความซับซ้อนจากการซ้อน และช่วงอายุตัวแปรที่ยาวเพิ่มขึ้นพร้อมกัน
- แม้คุณภาพของโค้ดและผู้เขียนจะสูง ก็ยังพบ critical bug อย่างน้อยหนึ่งรายการ
- หนึ่งในนั้นเป็นบั๊กที่ดูง่าย แต่มีแนวโน้มว่าจะพลาดไปเพราะมันอยู่กลางฟังก์ชันที่ยาวและซับซ้อน จึงยากต่อการให้เหตุผล
- คนที่มีแนวโน้มจะอ่านโค้ดของตัวเองมากที่สุดในอีกหนึ่งเดือนข้างหน้าคือตัวเราเอง ดังนั้นการเขียนให้อ่านง่ายจึงเชื่อมโยงกับต้นทุนการทำงานจริงโดยตรง
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
บทความบอกว่า สาย
map/reduce/filterที่ยาวหรือมีหลายชุดทำให้ความอ่านง่ายลดลง แต่ประเด็นนี้แทบไม่ได้มีที่มาจากเนื้อหาส่วนอื่นเลยให้ความรู้สึกเหมือนแอบสอดคำบ่นแบบคลาสสิกว่าอะไรที่ไม่คุ้นเคยก็แย่ ทั้งที่พอคุ้นขึ้นมาสักนิด หลายครั้งมันทั้งอ่านและเขียนง่ายกว่าทางเลือกอื่นมาก
เช่น ผมว่าแทบยากจะยกตัวอย่างโค้ดที่อ่านง่ายกว่า
books.filter(book => book.pageCount > 1000).map(book => book.author).distinct()ถ้ามองด้วยตัวชี้วัดด้านความซับซ้อน โค้ดแบบนี้ก็ดีกว่าแทบทุกวิธีอื่น และอย่างน้อยก็ควรเรียนรู้ พื้นฐานของการเขียนโปรแกรมเชิงฟังก์ชัน ไว้บ้าง ไม่ถึงขั้นต้องอธิบาย monad แต่ก็ควรคุ้นกับ
mapและfilterมากพอที่จะไม่ด่ามันแบบเหมารวมผมมองว่าพอเป็น chain ที่ต่อกันราว 5 ครั้ง ก็เริ่มอ่านยากแล้ว และตัวอย่างในบทความก็ยาวประมาณนั้น
คำว่า “multiple” หมายถึงกรณีที่มี chain ซ้อนกัน หรือชนิดข้อมูลที่กำลังจัดการเปลี่ยนไประหว่างทาง ซึ่งกรณีแบบนี้ทำให้อ่านช้าลง
องค์ประกอบเชิงฟังก์ชันอาจดูสง่างามได้ แต่ก็ใช้มากเกินไปได้เหมือนกัน
พอ chain ยาวเกินไป เงื่อนไขซับซ้อนขึ้น และมีหลายรายการ/หลายตัวแปร ก็จะทำความเข้าใจทั้งหมดในครั้งเดียวยากขึ้น
ในลูปแบบกระบวนวิธี เราสามารถ ตั้งชื่อให้ผลลัพธ์ระหว่างทาง ได้ และชื่อนั้นช่วยให้เราลืมรายละเอียดของขั้นก่อนหน้าไปชั่วคราวเพื่อโฟกัสกับขั้นถัดไปได้
SELECT DISTINCT author FROM books WHERE pageCount > 1000;ส่วนตัวผมมักแยก chain ออกเพื่อจะได้ตั้งชื่อให้ผลลัพธ์ระหว่างทาง และใช้ชื่อตัวแปรเหมือนเป็นคอมเมนต์
var longBooks = books.filter(book => book.pageCount > 1000)var authorsOfLongBooks = longBooks.map(book => book.author).distinct()?- setof(Author, Book^Pages^(book_author(Book, Author), book_pages(Book, Pages), Pages > 1000), Authors).ขึ้นอยู่กับโครงสร้างฐานข้อมูลของ Prolog อาจเขียนให้สั้นกว่านี้ได้อีก
?- setof(Author, Pages^(book(_, Author, Pages), Pages > 1000), Authors).ผมคิดว่าโค้ดที่ดีโดยพื้นฐานแล้วมี มิติที่เป็นเชิงคุณภาพและเชิงวรรณศิลป์ อยู่มากพอสมควร
โปรแกรมเมอร์หรือคนในแวดวงวิชาการที่คุ้นกับการคิดแบบคณิตศาสตร์และอยากได้คำตอบเชิงปริมาณ มักจะอึดอัดกับเรื่องนี้
ผมชอบทั้ง Dostoyevsky และ Wodehouse ซึ่งทั้งคู่ยอดเยี่ยมแต่คนละแบบ
ถึงการเขียนโค้ดจะไม่ได้เปิดกว้างเท่านั้น แต่ผมก็เคยทำงานกับโค้ดเบสที่ดีหลายชุดซึ่งให้ความรู้สึกต่างกันอย่างสิ้นเชิงในเชิงคุณภาพ และบ่อยครั้งก็ต้องใช้เวลาเหมือนเวลาที่เราค่อย ๆ คุ้นกับสำนวนของนักเขียนคนใหม่ กว่าจะจับ “อารมณ์” ของสไตล์ในโค้ดเบสนั้นได้
มันหมายความว่าถ้าอ่านไฟล์จากบนลงล่าง ลำดับของฟังก์ชันจะทำให้เรื่องราวไหลไปอย่างเป็นธรรมชาติ และการเขียนจะเป็นเชิงประกาศราวกับกำลังพูดกับผู้อ่าน
ผมยึดตามกระบวนทัศน์การเขียนโปรแกรมเชิงฟังก์ชันล้วน และคิดว่าวิธีนี้เข้ากับสไตล์ที่เป็นเรื่องเล่ามากกว่า
การพึ่งพา/อินพุตของฟังก์ชันถูกจำกัดอยู่แค่อาร์กิวเมนต์หรือฟังก์ชันบริสุทธิ์อื่น ๆ และเอาต์พุตก็อยู่ในชนิดค่าที่คืนกลับเท่านั้น จึงพาผู้อ่านผ่านความซับซ้อนทีละขั้นได้ง่าย
ต่างจากกระบวนทัศน์อื่นที่มีความซับซ้อนอย่างสถานะที่ซ่อนอยู่ ผมกลับมองว่ากระบวนทัศน์ที่แม่นตรงทางคณิตศาสตร์ที่สุดนี่แหละ กลับเหมาะกับสไตล์แบบเรื่องเล่ามากที่สุด
หน้าตาเป็นแบบไหนไม่สำคัญ ถ้าไม่มีประสบการณ์พัฒนายาวนานแล้วคนยังไม่สามารถเข้าใจได้ภายในเวลาที่สมเหตุสมผลว่าฟังก์ชันนั้นทำอะไร มันก็คือโค้ดที่ไม่ดี
ตัวอย่างเช่น ternary operator ในบทความ
return n % 2 === 0 ? 'Even' : 'Odd';สำหรับสมองมนุษย์มันเหมือนต้องอ่านย้อนทาง และเหมาะกับคอมไพเลอร์ที่ประมวลผล parse tree มากกว่ามนุษย์ถ้าเป็นนักคณิตศาสตร์ที่เป็นมนุษย์จริง ๆ ก็คงเขียนในรูป ฟังก์ชันแบบแยกกรณี ประมาณว่า ถ้า
n mod 2 = 0ก็เป็น'Even'ไม่อย่างนั้นเป็น'Odd'ซึ่งชัดเจนกว่ามากมันช่วยให้สมาชิกใหม่ของทีมเรียนรู้สไตล์ที่สม่ำเสมอ และช่วยให้สไตล์ของทีมโดยรวมคงความสอดคล้องในระดับที่เหมาะสม
คอมเมนต์เรื่อง
.editorconfigก็อ่านประกอบได้: https://news.ycombinator.com/item?id=43333011ช่วยลดการเถียงกันเรื่องรายละเอียดด้านสไตล์ใน pull request
บทความนี้ดี แต่ผมคิดว่ามันพลาดประเด็น ความเปลี่ยนแปลงได้ ซึ่งเป็นสิ่งที่ทำให้เหนื่อยล้าทางความคิดมากที่สุดเวลาอ่านโค้ด
การที่ระหว่างอ่านเมธอด เราสามารถ “ตรึง” ความหมายของตัวแปรไว้ได้เพียงครั้งเดียว แล้วคงมันไว้อย่างนั้นระหว่างอนุมานส่วนที่เหลือ ถือเป็นของขวัญชิ้นใหญ่
ความเข้าใจต่อเมธอดควรเพิ่มจาก 0% ไป 100% อย่างต่อเนื่อง ไม่ใช่ต้องเริ่มทำความเข้าใจเมธอดใหม่ในหัวอีกครั้ง เพราะเกิดสับสนว่าในรอบใดรอบหนึ่งลูปบอดีได้เปลี่ยน accumulator อย่างไร
นี่ก็เป็นเหตุผลที่แท้จริงว่าทำไม GOTO ถึงเป็นอันตราย ปัญหาไม่ใช่ว่าการย้าย instruction pointer ในหัวภายในเมธอดนั้นยาก แต่คือเมื่อมี GOTO แล้ว การรู้ สถานะของตัวแปรที่เปลี่ยนแปลงได้ กลายเป็นเรื่องยาก
ทั้งตัวแปรแบบเปลี่ยนแปลงได้และเปลี่ยนแปลงไม่ได้ อาจช่วยหรือขัดขวางการเคลื่อนที่นั้นก็ได้ ขึ้นอยู่กับว่าโค้ดสอดคล้องกับพื้นที่นั้นอย่างเรียบร้อยแค่ไหน
ตัวแปร immutable มีข้อได้เปรียบเชิงยุทธวิธีเล็กน้อยตรงที่ไม่ต้องกังวลว่าค่าจะเปลี่ยน หรือเปลี่ยนในแบบที่ทำให้เข้าใจผิด แต่จากประสบการณ์ ข้อดีนี้ยังไม่มากพอจะยกระดับเป็นกฎว่า “ต้องใช้ immutable เสมอ”
บางครั้งความเปลี่ยนแปลงได้กลับทำให้ถ่ายทอดพื้นที่ข้อมูลนั้นได้สะอาดกว่ามาก
หากมองจากฝั่งที่ถูกเรียกแทนที่จะเป็นจุดเรียก เมื่อใครบางคนกระโดดมาที่บรรทัดใดบรรทัดหนึ่งได้ คุณจะย้อนดูไม่ได้ว่าเกิดอะไรขึ้นก่อนหน้านั้น เพราะมันอาจมาจากที่ไหนก็ได้ จึงไม่ใช่การวิเคราะห์เชิงเฉพาะที่อีกต่อไป แต่ต้องเป็น การวิเคราะห์โปรแกรมทั้งระบบ
ถ้าความเปลี่ยนแปลงได้คือแหล่งที่มาที่แท้จริงของความซับซ้อนแบบ GOTO งั้น
ifและลูปforก็ควรมีปัญหาแบบเดียวกันผมเห็นด้วยว่าความเปลี่ยนแปลงได้และสถานะสร้างความซับซ้อนโดยตรง แต่ GOTO เป็นอีกหมวดหนึ่งที่อันตรายกว่ามากโดยสิ้นเชิง
แพตเทิร์นที่ผมไม่ชอบเป็นการส่วนตัวคือการ
returnทันทีในifแล้วปล่อยที่เหลือเป็นเส้นทางปริยายเริ่มต้นif (n % 2 === 0) return "Even"; return "Odd";สั้นกว่าจริง แต่ผมชอบif ... return "Even"; else return "Odd";มากกว่าเยอะเพราะแบบแรกให้ความรู้สึก ไม่สมมาตร ขณะที่
"Even"กับ"Odd"เป็นทางเลือกที่สมมาตรกัน ดังนั้นแบบที่มีelseจึงตรงสัญชาตญาณมากกว่าreturn (n % 2 === 0) ? "Even" : "Odd";เพราะมี boilerplate น้อยที่สุด เลยอ่านง่ายที่สุดถ้าเป็นภาษาที่มี ternary operator ก็ควรจะอ่านรูปแบบนี้ออกได้ไม่ยาก
จากประสบการณ์
elseสร้างการซ้อนเพิ่ม และทำให้ต้องคอยประเมินเงื่อนไขขอบเขตหรือตัวแปรที่อยู่นอกขอบเขตทันทีของเส้นทางปกติต่อไปง่ายขึ้น คุณต้องแบกบริบททั้งหมดนั้นไว้เรามองเห็นการคืนค่าได้ในระดับการเยื้องหนึ่งชั้นถัดจากชื่อฟังก์ชัน และถ้าไม่มีการจบก่อนกำหนด ก็ให้ความรู้สึกว่ามีผลลัพธ์ที่รับประกันไว้
ถ้าการคืนค่าถูกฝังไว้ลึกลงไปกว่านั้น มันจะให้ความรู้สึกแปลก ๆ
return "Odd";ดูแยกออกจากifและถ้าภาษาอนุญาตก็จะใส่วงเล็บปีกกาให้บอดีของifด้วยมีบางกรณีที่ยอมรับ
elseได้ แต่ส่วนใหญ่คือเมื่อมี side effect และโดยทั่วไปถ้ารีแฟกเตอร์จนเอาออกได้ก็มักจะชัดเจนขึ้นบ่อยครั้งโค้ดที่ซับซ้อนสามารถเปลี่ยนเป็น ลำดับของ guard ที่ค่อย ๆ ออกไปตามลำดับความสำคัญหรือค่าใช้จ่ายในการรัน และช่วยแยกตรรกะจริงของฟังก์ชัน/เมธอดออกจากเงื่อนไขยุติ
แบบแรกจะไหลลงด้านล่างแล้วรันชุดคำสั่งชุดที่สอง
returnเป็นโอเปอเรเตอร์พิเศษที่ตัดการไหลของการควบคุม ดังนั้นการไหลของการควบคุมทั่วไปของแบบแรกจึงไม่ได้ถ่ายทอด ความครบถ้วน ของสองกรณีได้อย่างเหมาะสมใน Rust แบบ idiomatic มักไม่ใช้
returnเว้นแต่จะเป็นกรณีพิเศษที่ขัดจังหวะการไหลของการควบคุมของเมธอด และตัวอย่างที่สองก็มักพบได้บ่อยกว่าในรูปที่ไม่มีคำสั่งคืนค่าPython ก็เช่นกัน โดยทั่วไปจะคืนค่าก่อนตั้งแต่ต้นเมื่ออาร์กิวเมนต์หรือสถานะไม่ถูกต้อง และใช้การคืนค่าในตำแหน่งท้ายเป็นค่าที่คืนจริง
ด้วยธรรมเนียมแบบนี้ เมื่อทำลายโครงสร้าง
if-elseที่สมบูรณ์returnที่มีการเยื้องจึงดูเหมือนกรณียกเว้น หากยึดตามธรรมเนียมนี้อย่างสม่ำเสมอ คำสั่งreturnจะดูเหมือนส่วนเกินโดยธรรมชาติ ยกเว้นเมื่อใช้เพื่อตัดการไหลของการควบคุม และทำให้เข้าใจธรรมเนียมของ Rust ได้ ในทุกภาษาreturnคือคำสั่งที่เทียบได้กับbreakอาจจะมีแค่ผมคนเดียวก็ได้ แต่บางครั้ง TypeScript ทำให้โค้ดอ่านยาก
ถ้าคุณคง data model ให้ “เป็นอะตอม” ในระดับหนึ่ง และนักพัฒนาขยันประกาศกับทำเอกสารของ type จริง ๆ มันก็โอเค
แต่พอ type เริ่มถูกอนุมานมาจาก type อื่นผ่าน utility type และละการระบุ type แบบชัดเจนไปพึ่งการอนุมานของ type เรื่องก็เริ่มพังอย่างรวดเร็ว
มันยากมากที่จะไล่ตามว่าฟิลด์หนึ่งมาจากไหน เมื่ออยู่ในสแตกการอ้างอิงอ้อมของ type ที่ลึก 4-5 ชั้น บางส่วนเป็นการอนุมาน บางส่วนประกาศชัด บางส่วนเป็น type ที่สร้างต่อ และยังมี alias ของฟิลด์ปนอยู่
ใน data model ขนาดใหญ่กับ call stack ที่ลึก รูปแบบอย่าง
function checkDogs(dogs: Dog[]) { ... }ที่ละ return type ไปนั้นใช้ไม่ได้เลยและน่าหงุดหงิดมากเหตุผลหลักคือเพื่อบังคับให้ทุกเส้นทางที่คืนค่าจากฟังก์ชันนั้นสอดคล้องกับชนิดดังกล่าว
ผมเคยเห็นหลายครั้งที่มีการเพิ่มเงื่อนไขใหม่ แล้วคืน type ที่ต่างจากกิ่งอื่นเล็กน้อยจนเกิด regression
แต่ผมไม่คิดว่าการใส่ type ให้ตัวแปรประกาศจะมีคุณค่ามากนัก
ในตัวอย่าง
const checkedDoggos = checkDogs([])นั้นดีแล้ว และปล่อยให้checkedDoggosสืบทอด type จากฟังก์ชันก็พอผมกำลังทำงานกับ codebase ที่ linter บังคับให้เขียน
const checkedDoggos: DogBreedAndSize[] = checkDogs([])ซึ่งค่อนข้างตลกและแทบไม่มีค่าอะไรใน JavaScript เรามั่นใจไม่ได้ จึงต้องไล่ขึ้นไล่ลงในสแตกตลอดและคอยเก็บทุกอย่างไว้ในหัว
ควรระวังกับคำกล่าวที่ว่า “ฟังก์ชันเล็กที่มีตัวแปรน้อยมักอ่านง่ายกว่า”
ฉันไม่ชอบเวลาการถกเรื่องความอ่านง่ายไปโฟกัสแค่ ความอ่านง่ายระดับจุลภาค เพราะมันทำให้โค้ดถูกซอยย่อยมากเกินไปภายใต้สมมติฐานที่ผิดว่าความอ่านง่ายระดับจุลภาคสำคัญกว่าความอ่านง่ายระดับมหภาค
การยึดถือแบบนี้ทำให้เกิดโปรแกรมเมอร์ที่เห็นแต่ต้นไม้ไม่เห็นป่า และนำไปสู่โค้ดที่ไม่มีประสิทธิภาพเกินจำเป็นหรือดีบักได้ยาก
ภาษาตระกูล APL อยู่คนละสุดขั้ว แต่จุดที่เหมาะจริงน่าจะอยู่ตรงกลางสักแห่ง และน่าจะแตกต่างกันมากในแต่ละคน
แค่กระโดดไปดู definition ในโค้ดที่ไม่คุ้น 3-4 ครั้งก็เริ่มหนักแล้ว อาจเป็นปัญหาของฉันเอง แต่ก็ยากจะนึกภาพว่าคนส่วนใหญ่จะเก่งกว่าฉันมากนัก
ในวัฒนธรรม .NET โดยเฉพาะ “clean architecture” ปัญหานี้หนักจนน่าตกใจ เวลาแก้ฟีเจอร์หรือไล่ปัญหา มันกระจายอยู่ใน 4 ชั้นกับ 15 ไฟล์ และบางไฟล์มีแต่คีย์เวิร์ดเกิน 60%
ฉันไม่รู้ว่าควรขีดเส้นตรงไหน แต่โดยรวมแล้วฉันมักชอบฟังก์ชันยาวฟังก์ชันเดียวที่ยังอ่านต่อเนื่องได้และทำตามคำแนะนำอื่น ๆ มากกว่าโค้ดที่แตกเป็นชิ้นเล็กเกินไปจนต้องเลื่อนขึ้นลงทุก 5 บรรทัด
เรื่อง type/class ก็เหมือนกัน จึงไม่จำเป็นต้องเอา enum 4 ค่าที่ใช้เฉพาะใน DTO นี้ไปไว้อีกไฟล์หนึ่ง
เป็นบทความที่น่าสนใจ แต่ยังไม่ค่อยน่าพอใจนัก
มันรีบกระโดดไปหาข้อสรุปเร็วเกินไป แล้วสุดท้ายก็ย้อนกลับไปที่ รสนิยมส่วนบุคคล อีกครั้ง ฉันเห็นด้วยกับรสนิยมหลายแบบ แต่ตัวบทความเองตั้งใจเขียนเพื่อพยายามก้าวข้ามเรื่องรสนิยมอย่างชัดเจน
คำพูดที่ว่า “ควรหลีกเลี่ยงโอเปอเรเตอร์หรือ syntax sugar เฉพาะภาษา เพราะเป็นภาระต่อผู้อ่าน” ไม่ได้ตามมาจากตัวชี้วัดนั้น ถ้าในฟังก์ชันมีโอเปอเรเตอร์ต่างกัน 3 ตัว และโอเปอเรเตอร์เฉพาะภาษาตัวเดียวแทนทั้งสามได้ในครั้งเดียว “ความพยายาม” ของฟังก์ชันก็ลดลง
องค์ประกอบอย่าง
map/reduce/filterถ้าใช้ดี ๆ ก็สามารถแทนโอเปอเรเตอร์อื่นและลด “ปริมาตร” ได้ จึงไปได้ทั้งสองทางตัวอย่าง
?.ดูเป็นข้อวินิจฉัยที่เฉพาะกับภาษามาก โดยชี้ไปที่การออกแบบภาษาที่อ่านยากของ JavaScript ในหลายภาษานั้นnullกับundefinedไม่ได้แยกจากกัน จึงมักเรียกมันว่า null-safe operatorข้อความว่า “variable shadowing แย่มาก” กับ “อายุการมีชีวิตที่ยาวขึ้นทำให้ต้องเก็บตัวแปรไว้ในหัวมากขึ้น” อาจขัดกันเอง
ในบางบริบทฉันชอบ variable shadowing มาก เพราะมันช่วยเอาอินสแตนซ์ก่อนหน้าออกจากสโคป แทนที่จะปล่อยให้ยังเข้าถึงได้ตลอด
มีปลั๊กอินเจ๋ง ๆ สำหรับ VS Code ชื่อ Highlight
มันทำให้ใส่สีต่างกันในโค้ดด้วย regex แบบกำหนดเองได้ และการใช้งานที่พบบ่อยคงเป็นทำ
//TODOให้เป็นสีเหลืองฉันใช้มันทำให้ log ดูจางลง เพราะพอแทรก log ไว้ทั่ว ๆ แล้วมันกลายเป็น noise ทางสายตาเยอะมาก
ไลบรารีที่ฉันดูแลใช้ log แบบ
this.logger?.info('Some logs here');และฉันตั้ง opacity 0.4 ให้มันถอยไปอยู่ด้านหลังมันยังมองเห็นได้อยู่ แต่สิ่งที่เด่นขึ้นมาในแวบแรกจะเป็น business logic จริง ๆ
สามารถตั้งค่าแบบนี้ได้:
"highlight.regexes": { "((?:this\\.)?(?:_)?logger(?:\\?)?.(debug|error|info|warn)[^\\)]*\\)\\;)": { "regexFlags": "gmi", "decorations": [{ "opacity": "0.4" }] } }https://marketplace.visualstudio.com/items?itemName=fabiospa...
ฉันไม่เห็นด้วยกับคำพูดที่ว่าการแบ่งฟังก์ชันเชนยาว ๆ หรือ callback ออกเป็นกลุ่มเล็ก ๆ แล้วใช้ตัวแปรที่ตั้งชื่อดีนั้น มีประสิทธิภาพน้อยกว่านิดหน่อย
สองเวอร์ชันอาจมีประสิทธิภาพเท่ากันเป๊ะก็ได้
ทั้งสองกรณีมีการจัดสรรอ็อบเจ็กต์เหมือนกัน เก็บลง heap เหมือนกัน และถูก garbage collection เหมือนกัน ความต่างด้านประสิทธิภาพขึ้นอยู่กับคอมไพเลอร์
ในเวอร์ชันที่สอง คอมไพเลอร์ควรมองออกว่าตัวแปรแต่ละตัวถูกใช้แค่ทันทีหลังประกาศ และจึงควรจัดการอ็อบเจ็กต์เหล่านั้นให้พ้นสโคปได้เหมือนกับการเรียกแบบเชน
แน่นอนว่านี่ตั้งอยู่บนสมมติฐานว่าคุณปล่อยให้มันอนุมานชนิดของตัวแปร
ต้นทุนที่เห็นจริงมักเกิดตอนคุณ materialize ค่ากลางอย่างชัดเจนเพื่อจะดูมันในดีบักเกอร์ เช่น แปลงเป็นลิสต์ ซึ่งทำให้เกิดการจัดสรรที่เลี่ยงได้และมีต้นทุนตามมา
ความพยายามจะทำให้ “ความอ่านง่าย” วัดเชิงปริมาณได้เป็นเรื่องดี เราต้องการแนวทางแบบนี้มากกว่านี้มาก
ตอนนี้นิยามความอ่านง่ายที่พบบ่อยที่สุดให้ความรู้สึกใกล้เคียงกับ “สิ่งที่ฉันอ่านง่าย”
ถ้าเอาโค้ดไปให้คนจำนวนมากดู แล้วจับเวลาขณะให้พวกเขาเลือกประโยคที่อธิบายว่าโค้ดนั้นทำอะไร เราอาจค้นพบมิติที่แท้จริงของความอ่านง่ายได้
โจทย์ที่มีคนตอบถูกมากที่สุดด้วยเวลาเฉลี่ยสั้นที่สุด ก็จะกลายเป็นตัวอย่างของ โค้ดที่อ่านง่ายในโลกจริง และที่สำคัญกว่านั้นคืออาจช่วยระบุแนวปฏิบัติที่อ่านยากจริง ๆ ได้
ผู้ตอบน่าจะถูกจัดกลุ่มตามแกนอย่าง “ประสบการณ์เขียนโปรแกรม” หรือ “เข้าใจพาราไดม์ X หรือไม่” และผลลัพธ์ก็อาจเลื่อนไปตามกาลเวลาเมื่อกระแสนิยมเปลี่ยน
สิ่งที่เราเคยเรียนมาให้อ่านและเขียน มีผลต่อสิ่งที่เรารู้สึกว่าอ่านง่าย
ปัจจัยมากมายมีผล ไม่ว่าจะเป็นคุณกำลังพยายามทำอะไร ทำงานกับใคร ก่อนเขียนโค้ดคุณทำอะไรเป็นมาก่อน หรือรู้ภาษาอื่นอะไรบ้าง
เมื่อเก็บผลไม้ที่อยู่ต่ำแล้ว เช่น เลยระดับคำแนะนำอย่างอย่าตั้งชื่อตัวแปรแบบสุ่ม ไม่เกี่ยวข้อง หรือชวนเข้าใจผิดไปแล้ว ปัญหาเรื่อง “ความอ่านง่าย” จำนวนมากอาจกลายเป็นเรื่องของการสร้างฉันทามติในท้ายที่สุด
อาจไม่มีคำตอบที่ถูกต้องแบบครอบจักรวาลซึ่งใช้ได้เหนือกว่ากลุ่มโปรแกรมเมอร์เฉพาะที่คุณจะทำงานด้วยกัน
ความอ่านง่ายของโค้ดคล้ายกับความอ่านง่ายของภาษา คือมักเป็นปัญหาสำหรับคนที่ยังไม่คุ้นกับภาษานั้น และแก้ได้ด้วยเวลา
ปัญหาที่แท้จริงของการเขียนโปรแกรมคือ ความซับซ้อนของโค้ด ซึ่งตัดสินจากตัวชี้วัดของโค้ดชิ้นเดี่ยว ๆ ไม่ได้
ปัญหาอยู่ที่ความสัมพันธ์ระหว่างฟังก์ชัน มากกว่าทางเลือกการ implement ภายในตัวฟังก์ชัน
การรู้ว่าโค้ดทำอะไรนั้นส่วนใหญ่ไม่ยาก สิ่งที่ยากคือการแก้ไขมันหรือเพิ่มฟีเจอร์เข้าไป
ความยากนี้เกิดขึ้นเพราะหลายชั้นของ abstraction ซ่อนวิธีที่มันเชื่อมต่อกันไว้