เขียนโค้ดที่มีประสิทธิภาพโดยไม่ลดทอนความสามารถในการอ่าน
การสร้างสมดุลระหว่างประสิทธิภาพของโค้ดและความสามารถในการอ่านเป็นทักษะที่สําคัญสําหรับนักพัฒนาซอฟต์แวร์ แม้ว่าประสิทธิภาพจะมีความสําคัญ แต่ก็ไม่ควรแลกกับความชัดเจนของโค้ดและความสามารถในการบํารุงรักษา
ความชัดเจนเป็นอันดับแรก ปรับให้เหมาะสมเมื่อจําเป็น
หลักการชี้นําในวิศวกรรมซอฟต์แวร์คือการทําให้โค้ดทํางานได้อย่างถูกต้องและชัดเจนก่อน จากนั้นจึงปรับให้เหมาะสมหากจําเป็น อย่าเริ่มเพิ่มประสิทธิภาพก่อนที่คุณจะรู้ว่าอะไรต้องการจริงๆ
เมื่อคุณพยายามปรับส่วนของโค้ดที่เฉพาะเจาะจงให้เหมาะสมโดยไม่มีหลักฐานว่าโค้ดช้า คุณก็เสี่ยงต่อปัญหาต่อไปนี้:
ทําให้โค้ดเข้าใจและบํารุงรักษายากขึ้น: การเพิ่มประสิทธิภาพที่ซับซ้อน (โดยเฉพาะการเพิ่มประสิทธิภาพระดับไมโคร) สามารถแนะนําตรรกะที่ซับซ้อน การแฮ็กที่คลุมเครือ หรือโค้ดกรณีพิเศษ ผู้ดูแลในอนาคตอาจประสบปัญหาในการทําความเข้าใจ หรือแย่กว่านั้นคือแนะนําข้อบกพร่องเมื่อแก้ไข
เสียเวลา: คุณอาจใช้เวลาหลายชั่วโมงในการปรับแต่งสิ่งที่มีผลเล็กน้อยต่อประสิทธิภาพโดยรวมในขณะที่ละเลยปัญหาที่ใหญ่กว่าที่อื่น
ลดความน่าเชื่อถือหรือความยืดหยุ่น: บางครั้งการปรับแต่งประสิทธิภาพสูงสุดจะลบเลเยอร์นามธรรมหรือการตรวจสอบข้อผิดพลาด ตัวอย่างเช่น การใช้เลขคณิตตัวชี้สําหรับความเร็วใน C# (รหัสที่ไม่ปลอดภัย) อาจได้รับประสิทธิภาพเพียงเล็กน้อย แต่มีความเสี่ยงสูงต่อความปลอดภัยและสูญเสียการพกพา การเปลี่ยนแปลงประเภทนี้ยากที่จะพิสูจน์ในแอปพลิเคชันทางธุรกิจ
เริ่มต้นด้วยวิธีแก้ปัญหาที่ชัดเจน โปรดจําไว้ว่านักพัฒนาอ่านโค้ดบ่อยกว่าที่เขียน เน้นการตั้งชื่อ โครงสร้าง และความเรียบง่ายที่อ่านได้ นอกเหนือจากตัวเลือกอัลกอริทึมระดับสูงแล้ว การเพิ่มประสิทธิภาพขนาดเล็กจํานวนมาก (เช่น การแคชการคํานวณเล็กน้อยหรือการบันทึกรอบ CPU สองสามรอบ) ไม่คุ้มกับการสูญเสียความชัดเจน คอมไพเลอร์และฮาร์ดแวร์สมัยใหม่สามารถเรียกใช้โค้ดที่ตรงไปตรงมาได้อย่างมีประสิทธิภาพ
การเพิ่มประสิทธิภาพจะสมเหตุสมผลเมื่อใด
หลังจากเขียนโค้ดเวอร์ชันเริ่มต้นแล้ว ให้ระบุส่วนสําคัญที่ต้องเพิ่มประสิทธิภาพ ("%3 ที่สําคัญ") ต่อไปนี้คือสัญญาณและสถานการณ์ที่การเพิ่มประสิทธิภาพ (แม้ว่าจะทําให้โค้ดซับซ้อนเล็กน้อยก็ตาม)
ฮอตสปอตที่ได้รับการยืนยัน: การทําโพรไฟล์แสดงให้เห็นว่าวิธีการหรือลูปเฉพาะใช้เวลาในการดําเนินการเป็นเปอร์เซ็นต์ที่สําคัญ ตัวอย่าง: คุณทําโปรไฟล์และค้นหาฟังก์ชันหนึ่งใช้เวลา 60% ของรันไทม์ของโปรแกรม หากการเพิ่มประสิทธิภาพสามารถลดเวลาของฟังก์ชันนั้นลงครึ่งหนึ่ง ก็จะให้ชัยชนะโดยรวมอย่างมีนัยสําคัญ
ความไร้ประสิทธิภาพของอัลกอริทึมที่เห็นได้ชัด: บางครั้งคุณ รู้ว่า วิธีการที่ง่ายกว่านั้นมีประสิทธิภาพน้อยกว่าอย่างมากในแง่ของบิ๊กโอ ตัวอย่างเช่น การใช้ลูปซ้อนกันสองครั้งเพื่อเปรียบเทียบองค์ประกอบของรายการขนาดใหญ่สองรายการคือ O(n*m); หากคุณใช้ชุดแฮชสําหรับรายการเดียวแทน คุณอาจลดลงเป็น O(n+m) ถ้า
nและสามารถmมีขนาดใหญ่ความแตกต่างก็ใหญ่มาก ในกรณีเช่นนี้ นักพัฒนาที่มีประสบการณ์อาจใช้แนวทางที่มีประสิทธิภาพมากขึ้นตั้งแต่เริ่มต้น ซึ่งไม่ใช่ "เร็วเกินไป" หากมีความจําเป็นที่ชัดเจน สิ่งสําคัญคือการปรับปรุงอัลกอริทึมจํานวนมาก ไม่ได้ทําให้โค้ดอ่านได้น้อยลง หากทําได้ดี (โดยใช้ชื่อวิธีการที่สื่อความหมาย ความคิดเห็น ฯลฯ)การดําเนินการซ้ํา: หากโค้ดทํางานเป็นครั้งคราว ความไร้ประสิทธิภาพเล็กน้อยก็ใช้ได้ แต่ถ้ามันทํางานหลายพันครั้งต่อวินาที (เช่น ภายในลูปที่คับแคบหรือการโทรใช้บริการความถี่สูง) คุณจะตรวจสอบอย่างละเอียดมากขึ้น ตัวอย่างเช่น การสร้างวัตถุใหม่อาจใช้ได้ แต่การทําแบบวนซ้ํา 100,000 ครั้งต่อวินาที (เมื่อคุณสามารถใช้วัตถุซ้ําได้) อาจรับประกันการเปลี่ยนแปลง
โดเมนที่มีความสําคัญต่อประสิทธิภาพ: ในบางโดเมน (เช่น การพัฒนาเกม ระบบเรียลไทม์ หรือระบบฝังตัว) ข้อกําหนดด้านประสิทธิภาพจะเข้มงวด ที่นี่นักพัฒนามักคิดถึงประสิทธิภาพตั้งแต่เริ่มต้นเนื่องจากแนวทางที่ไร้เดียงสาอาจไม่ตรงตามข้อกําหนด ถึงกระนั้น พวกเขาก็พึ่งพารูปแบบที่รู้จักและแนวทางปฏิบัติที่ดีที่สุดมากกว่าการปรับแต่งระดับต่ําที่คาดเดาไม่ได้
เป้าหมายคือการเพิ่มประสิทธิภาพ เมื่อข้อมูลรองรับหรือบริบทของโดเมนต้องการ และแม้กระทั่งต้องทําในลักษณะที่บํารุงรักษาได้
ความสามารถในการอ่านเทียบกับการเพิ่มประสิทธิภาพ
มักจะมีการแลกเปลี่ยนระหว่างการเขียนโค้ดที่อ่านง่ายและโค้ดที่ปรับให้เหมาะสมที่สุด อย่างไรก็ตาม การเพิ่มประสิทธิภาพหลายอย่างสามารถทําได้โดยไม่ต้องสูญเสียความชัดเจน ลองดูตัวอย่างบางส่วนที่แสดงให้เห็นถึงความสมดุลระหว่างความสามารถในการอ่านและประสิทธิภาพ
ใช้โครงสร้างข้อมูลที่เหมาะสม
สมมติว่าคุณมีคอลเลกชันและคุณจําเป็นต้องตรวจสอบซ้ําๆ ว่าคอลเลกชันมีค่าที่แน่นอนหรือไม่ คุณมีหลายตัวเลือก:
อ่านได้แต่มีประสิทธิภาพน้อยกว่า: ทําซ้ํา a
List<T>ในแต่ละครั้งเพื่อค้นหาค่า วิธีการนี้มีความซับซ้อน O(n) สําหรับการตรวจสอบแต่ละครั้ง และโค้ดยังคงชัดเจนและเรียบง่าย (ไม่ว่าจะผ่านลูปพื้นฐานหรือโดยใช้List.Containsซึ่งทําการวนซ้ําภายใน)มีประสิทธิภาพและยังคงอ่านได้: ใช้ a
HashSet<T>หรือ aDictionary<TKey, TValue>สําหรับการค้นหา โดยให้เวลาเฉลี่ยต่อการตรวจสอบ O(1) มีโค้ดอีกเล็กน้อย (คุณเติม HashSet และใช้เมธอดของมันContains) แต่ก็ยังชัดเจน ในความเป็นจริงการใช้ HashSet อาจแสดงออกได้มากกว่า: มันบอกผู้อ่านว่า "เราต้องการการค้นหาอย่างรวดเร็ว" นี่เป็นกรณีที่โซลูชันที่มีประสิทธิภาพมากกว่านั้นสะอาดเช่นกันปรับให้เหมาะสมเกินไปและอ่านได้น้อยลง: ทางเลือกที่ประดิษฐ์ขึ้นอาจเกี่ยวข้องกับการจัดการบิตระดับต่ําหรืออัลกอริธึมการแฮชแบบกําหนดเองที่ปรับให้เหมาะกับชุดข้อมูลเฉพาะนี้ วิธีนี้อาจทําให้ผู้ดูแลโค้ดสับสนและให้ประสิทธิภาพที่เพิ่มขึ้นเพียงเล็กน้อยเหนือมาตรฐาน
HashSet(หากมีการปรับปรุงเลย) คุณควรหลีกเลี่ยงกลยุทธ์นี้ เว้นแต่การทําโพรไฟล์จะแสดงให้เห็นว่าโครงสร้างข้อมูลที่มีอยู่แล้วภายในสร้างคอขวดด้านประสิทธิภาพ และการใช้งานแบบกําหนดเองเป็นสิ่งจําเป็นอย่างแท้จริง (ซึ่งไม่ค่อยเกิดขึ้น)
การขยายลูปหรือการฝังแบบแมนนวล
บางครั้งนักพัฒนาพยายาม "เพิ่มประสิทธิภาพ" ลูปโดยคลี่ออกหรืออินไลน์โค้ดด้วยตนเองเพื่อประหยัดค่าใช้จ่ายของลูป พิจารณาลูปที่ประมวลผลอาร์เรย์:
อ่านได้: เขียนลูปที่ประมวลผลอาร์เรย์ 100 องค์ประกอบ รหัสมีความกระชับและชัดเจน คอมไพเลอร์สามารถเพิ่มประสิทธิภาพได้ดี และ CPU ที่ทันสมัยสามารถจัดการการทําซ้ํา 100 ครั้งได้อย่างง่ายดาย
ปรับให้เหมาะสมเกินไป: "คลี่" ลูปโดยเขียนข้อความซ้ํา 100 รายการเพื่อหลีกเลี่ยงค่าใช้จ่ายของลูป วิธีนี้อาจช่วยประหยัดรอบการควบคุมลูปของ CPU ได้สองสามรอบ แต่ตอนนี้โค้ดของคุณมีคําสั่งซ้ําๆ 100 บรรทัด ซึ่งเห็นได้ชัดว่าไม่คุ้ม ฝันร้ายในการบํารุงรักษาหากคุณเคยเปลี่ยนเป็น 101 องค์ประกอบ!
เมื่อสําคัญ: สําหรับลูปภายในที่ไวต่อประสิทธิภาพ (พบในคอมพิวเตอร์ประสิทธิภาพสูงหรือไลบรารีอัลกอริทึม) นักพัฒนาจะใช้การคลี่ลูปบางส่วนเพื่อเพิ่มประสิทธิภาพเป็นครั้งคราว อย่างไรก็ตาม คอมไพเลอร์มักจะจัดการสถานการณ์นี้โดยอัตโนมัติหรือผ่านกลไกการเพิ่มประสิทธิภาพอื่นๆ แทนที่จะกําหนดให้มีการใช้งานด้วยตนเองในโค้ดแอปพลิเคชัน ในฐานะนักพัฒนาแอปพลิเคชัน ให้พึ่งพาความสามารถในการเพิ่มประสิทธิภาพของคอมไพเลอร์และรักษาโค้ดที่เรียบง่ายและชัดเจน
การต่อสตริงใน C#
การต่อสตริงเป็นสถานการณ์ทั่วไปที่ประสิทธิภาพและความสามารถในการอ่านอาจขัดแย้งกัน เมื่อทําการต่อสตริงซ้ําๆ การเลือกวิธีการอาจส่งผลต่อประสิทธิภาพการทํางานอย่างมาก
แนวทางไร้เดียงสา: ใช้
string += stringเป็นลูป ตัวอย่าง: การสร้างแบบสอบถาม SQL แบบยาวหรือ CSV โดยการผนวกบรรทัดในลูป เทคนิคนี้อ่านง่าย แต่ใน .NET แต่ละ+=สตริงจะสร้างสตริงใหม่ (เนื่องจากสตริงไม่สามารถเปลี่ยนแปลงได้) หากคุณผนวก 1,000 ครั้ง คุณจะสร้างออบเจ็กต์สตริงระดับกลางจํานวนมาก – โค้ดนี้ไม่มีประสิทธิภาพทั้งในด้านเวลาและหน่วยความจําแนวทางที่ดีกว่า: ใช้ a
StringBuilderสําหรับการรวมหลายครั้ง คลาสนี้ออกแบบมาสําหรับสถานการณ์นั้น มันสร้างสตริงในบัฟเฟอร์และสร้างสตริงสุดท้ายในตอนท้าย รหัสมีรายละเอียดมากกว่าเล็กน้อย (คุณต้องโทรAppendแทน+=) แต่ก็ยังเข้าใจง่าย มันส่งสัญญาณอย่างชัดเจนว่า "เรากําลังสร้างสตริงอย่างมีประสิทธิภาพ" ในความเป็นจริงคู่มือแนวทางปฏิบัติที่ดีที่สุดสําหรับ .NET แนะนําให้StringBuilderเชื่อมต่อลูปภายใน การใช้StringBuilderสําหรับการต่อสตริงซ้ําๆ นั้นอ่านง่ายกว่า (สําหรับนักพัฒนาที่มีประสบการณ์) และมีประสิทธิภาพมากกว่า
ตัวอย่างนี้แสดงให้เห็นว่าบางครั้งการเปลี่ยนแปลงเล็กน้อย (โดยใช้ API อื่น) ให้ประสิทธิภาพที่เพิ่มขึ้นอย่างมากโดยมีผลต่อความสามารถในการอ่านน้อยที่สุด วิธีการเริ่มต้นอาจใช้ได้กับสตริงขนาดเล็ก แต่ถ้าคุณเคยใช้อินพุตขนาดใหญ่ความแตกต่างด้านประสิทธิภาพจะมีนัยสําคัญ
ผลลัพธ์ของแคช
การแคชเป็นเทคนิคการเพิ่มประสิทธิภาพทั่วไปที่สามารถปรับปรุงประสิทธิภาพโดยการจัดเก็บผลลัพธ์ของการเรียกฟังก์ชันที่มีราคาแพงและนํากลับมาใช้ใหม่เมื่ออินพุตเดิมเกิดขึ้นอีกครั้ง
โดยไม่ต้องแคช: ลองนึกภาพฟังก์ชัน
GetExchangeRate(currency)ที่ดึงอัตราแลกเปลี่ยนปัจจุบันผ่านการเรียก HTTP หากคุณเรียกซ้ําๆ สําหรับสกุลเงินเดียวกัน และไม่แคช แสดงว่าคุณกําลังทํางานซ้ําซ้อน (และ I/O เครือข่าย) ตรงไปตรงมาแต่ไม่มีประสิทธิภาพด้วยการแคช: คุณเพิ่มพจนานุกรมเพื่อจัดเก็บผลลัพธ์หลังจากการดึงข้อมูล ดังนั้นการเรียกที่ตามมาจะกลับมาทันทีจากหน่วยความจํา เทคนิคนี้เพิ่มความซับซ้อน (คุณต้องจัดการแคช อาจทําให้เป็นโมฆะได้หากอัตราเปลี่ยนแปลง) แต่สําหรับข้อมูลที่ร้องขอบ่อย จะสามารถปรับปรุงประสิทธิภาพได้อย่างมากโดยหลีกเลี่ยงการเรียกที่ไม่จําเป็น
การตัดสินใจแคชมักจะขึ้นอยู่กับรูปแบบการใช้งาน โค้ดจะซับซ้อนขึ้นเล็กน้อย (คุณต้องจัดการกับตรรกะแคช) และคุณต้องแน่ใจว่าโค้ดนั้นถูกต้อง (ข้อมูลเก่า ความปลอดภัยของเธรดหากเข้าถึงจากหลายเธรด ฯลฯ) การแคชเป็นสถานการณ์คลาสสิกของการซื้อขายความซับซ้อนเพื่อประสิทธิภาพ การแคชช่วยปรับปรุงประสิทธิภาพอย่างมากเมื่อมีการเข้าถึงข้อมูลซ้ําๆ
แนวทางปฏิบัติที่ดีที่สุดสําหรับการสร้างสมดุลระหว่างประสิทธิภาพและความสามารถในการอ่าน
ต่อไปนี้เป็นแนวทางปฏิบัติที่ดีที่สุดบางส่วนที่จะช่วยให้คุณสร้างสมดุลระหว่างประสิทธิภาพและความสามารถในการอ่านในโค้ดของคุณ:
ต้องการความชัดเจนของอัลกอริทึม: เมื่อเลือกวิธีใช้บางสิ่ง ให้นึกถึงความซับซ้อนของอัลกอริทึมก่อน (เป็นเส้นตรง กําลังสอง ฯลฯ หรือไม่) เลือกการออกแบบที่ให้ความซับซ้อนที่ดีโดยไม่บิดเบือนโค้ดของคุณ บ่อยครั้งที่โซลูชันที่หรูหราที่สุดในอัลกอริทึมก็คือโค้ดที่สะอาดเช่นกัน
ใช้เครื่องมือที่เหมาะสมสําหรับงาน: ภาษาและไลบรารีระดับสูงมีฟังก์ชันการทํางานที่ปรับให้เหมาะสมซึ่งคุณควรใช้ ตัวอย่างเช่น Language Integrated Query (LINQ) ใน C# สามารถแสดงการดําเนินการข้อมูลบางอย่างได้อย่างชัดเจน และได้รับการปรับให้เหมาะสมภายในอย่างสมเหตุสมผล ในทํานองเดียวกัน ไลบรารีการประมวลผลแบบขนาน (
Parallel.ForEach, PLINQ) ช่วยให้สามารถดําเนินการพร้อมกันได้ในขณะที่ยังคงรักษาโครงสร้างโค้ดที่ค่อนข้างเรียบง่าย อย่าประดิษฐ์วงล้อใหม่เว้นแต่คุณจะจําเป็นแสดงความคิดเห็นเกี่ยวกับการเพิ่มประสิทธิภาพที่ไม่ชัดเจน: หากคุณทําบางสิ่งบางอย่างในลักษณะที่ไม่เข้าใจง่ายด้วยเหตุผลด้านประสิทธิภาพ ให้เพิ่มความคิดเห็นที่อธิบายว่าทําไม ตัวอย่าง: "การใช้พูลอ็อบเจ็กต์แบบแมนนวลที่นี่เพื่อลดแรงกดดันในการเก็บขยะ เนื่องจากวิธีนี้ถูกเรียกในวงที่แน่นหนา และเราไม่สามารถจัดสรรได้บ่อยครั้ง" การเพิ่มความคิดเห็นช่วยให้ผู้อ่านในอนาคต (และตัวคุณเองในอีกหกเดือน) จําได้ว่าเหตุใดโค้ดจึงเป็นเช่นนั้น
การปรับปรุงที่เพิ่มขึ้น: คุณมักจะเริ่มต้นด้วยการออกแบบที่เรียบง่าย จากนั้นค่อยๆ ปรับปรุงชิ้นส่วนที่ต้องการ เปรียบเทียบโค้ดเวอร์ชันที่ปรับให้เหมาะสมกับต้นฉบับเสมอเพื่อให้แน่ใจว่าลักษณะการทํางานของโค้ดไม่เปลี่ยนแปลง การควบคุมเวอร์ชันสามารถช่วยคุณย้อนกลับการเปลี่ยนแปลงได้หากจําเป็น
อย่าประนีประนอมความปลอดภัย/ความปลอดภัยเพื่อความเร็ว: ตัวอย่างเช่น การข้ามการตรวจสอบอินพุตหรือการจัดการข้อผิดพลาดอาจทําให้โค้ดทํางานเร็วขึ้นเล็กน้อย แต่แทบจะไม่คุ้มกับการแลกเปลี่ยนเลย ความทนทานมีความสําคัญมากกว่า มุ่งเป้าไปที่การเพิ่มประสิทธิภาพที่ไม่บ่อนทําลายความถูกต้องหรือความปลอดภัยของโค้ด
หลีกเลี่ยงการเพิ่มประสิทธิภาพก่อนเวลาอันควร
การเร่งรีบเพื่อเพิ่มประสิทธิภาพโค้ดก่อนที่คุณจะรู้ว่าปัญหาคอขวดที่แท้จริงอยู่ที่ไหนเป็นหลุมพรางทั่วไป
ในทางปฏิบัติ:
- เขียนโค้ดของคุณอย่างหมดจดด้วยโครงสร้างที่ดี
- ระบุว่าส่วนใดเป็นคอขวดหรือไม่
- เพิ่มประสิทธิภาพส่วนนั้นด้วยวิธีที่บํารุงรักษาได้ และตรวจสอบการปรับปรุง
วิธีนี้ช่วยให้คุณใช้เวลากับสิ่งที่สําคัญและทําให้ฐานโค้ดของคุณมีประสิทธิภาพและแข็งแรง
Summary
การเขียนโค้ดที่มีประสิทธิภาพไม่จําเป็นต้องแลกกับความสามารถในการอ่าน การจัดลําดับความสําคัญของความชัดเจนเป็นอันดับแรกและปรับให้เหมาะสมตามหลักฐาน คุณจะสามารถบรรลุความสมดุลที่ตอบสนองทั้งประสิทธิภาพและการบํารุงรักษา ใช้โครงสร้างข้อมูลที่เหมาะสม ใช้ไลบรารีในตัว และใช้การเพิ่มประสิทธิภาพอย่างรอบคอบ บันทึกตัวเลือกที่ไม่ชัดเจนเสมอและหลีกเลี่ยงการเพิ่มประสิทธิภาพก่อนเวลาอันควร แนวทางที่สมดุลนี้นําไปสู่โค้ดที่แข็งแกร่ง มีประสิทธิภาพ และเข้าใจได้ซึ่งยืนหยัดในการทดสอบของเวลา