เพื่อเป็นตัวอย่างของหลักการที่กล่าวถึงในหนังสือเล่มนี้ บทนี้จะพิจารณาแนวโน้มและรูปแบบต่างๆ ที่กลายเป็นที่นิยมในการพัฒนาซอฟต์แวร์ในช่วงสองสามทศวรรษที่ผ่านมา สำหรับแต่ละแนวโน้ม ผมจะอธิบายว่าแนวโน้มนั้นเกี่ยวข้องกับหลักการในหนังสือเล่มนี้อย่างไร และใช้หลักการเหล่านี้เพื่อประเมินว่าแนวโน้มนั้นช่วยลดความซับซ้อนของซอฟต์แวร์ได้หรือไม่
19.1 Object-oriented programming and inheritance (การเขียนโปรแกรมเชิงวัตถุและการสืบทอด)
Object-oriented programming เป็นหนึ่งในแนวคิดใหม่ที่สำคัญที่สุดในการพัฒนาซอฟต์แวร์ในช่วง 30–40 ปีที่ผ่านมา มันนำเสนอแนวคิดต่างๆ เช่น classes, inheritance, private methods, และ instance variables หากใช้อย่างระมัดระวัง กลไกเหล่านี้สามารถช่วยให้การออกแบบซอฟต์แวร์ดีขึ้นได้ ตัวอย่างเช่น private methods และ variables สามารถใช้เพื่อรับประกัน information hiding นั่นคือไม่มีโค้ดภายนอก class ใดสามารถเรียกใช้ private methods หรือเข้าถึง private variables ได้ ดังนั้นจึงไม่มี dependencies ภายนอกใดๆ เกิดขึ้นกับสิ่งเหล่านั้น
หนึ่งในองค์ประกอบสำคัญของ object-oriented programming คือ inheritance (การสืบทอดคุณสมบัติ) Inheritance มีสองรูปแบบ ซึ่งส่งผลต่อความซับซ้อนของซอฟต์แวร์ต่างกัน รูปแบบแรกคือ interface inheritance โดยที่ parent class จะกำหนด signatures ของ method หนึ่งตัวหรือมากกว่า แต่ไม่ได้ implement method เหล่านั้น แต่ละ subclass จะต้อง implement signatures ดังกล่าว แต่ subclass ที่แตกต่างกันสามารถ implement method เดียวกันในรูปแบบที่แตกต่างกันได้ ตัวอย่างเช่น interface อาจกำหนด method สำหรับการทำ I/O โดย subclass หนึ่งอาจ implement การดำเนินการ I/O สำหรับไฟล์บนดิสก์ และอีก subclass หนึ่งอาจ implement การดำเนินการเดียวกันสำหรับ network sockets
Interface inheritance ช่วยลดความซับซ้อนด้วยการ reuse interface เดียวกันสำหรับหลายวัตถุประสงค์ มันช่วยให้ความรู้ที่ได้จากการแก้ปัญหาหนึ่ง (เช่น วิธีใช้ I/O interface เพื่ออ่านและเขียนไฟล์บนดิสก์) สามารถนำไปใช้แก้ปัญหาอื่นๆ ได้ (เช่น การสื่อสารผ่าน network socket) อีกวิธีในการทำความเข้าใจคือการมองในแง่ของ depth ยิ่งมี implementations ที่แตกต่างกันของ interface มากเท่าไหร่ interface ก็ยิ่งลึกมากขึ้นเท่านั้น เพื่อให้ interface มีหลาย implementations ได้นั้น มันต้องรวบรวมคุณสมบัติที่จำเป็นของ implementations ทั้งหมดที่อยู่เบื้องหลัง ในขณะที่หลีกเลี่ยงรายละเอียดที่แตกต่างกันระหว่าง implementations เหล่านั้น แนวคิดนี้คือหัวใจของ abstraction
รูปแบบที่สองของ inheritance คือ implementation inheritance ในรูปแบบนี้ parent class จะกำหนดไม่เพียงแต่ signatures ของ method หนึ่งตัวหรือมากกว่า แต่ยังรวมถึง default implementations ด้วย Subclasses สามารถเลือกที่จะสืบทอด implementation ของ method จาก parent หรือ override โดยการกำหนด method ใหม่ที่มี signature เดียวกัน หากไม่มี implementation inheritance การ implement method เดียวกันอาจต้องถูกทำซ้ำในหลาย subclass ซึ่งจะสร้าง dependencies ระหว่าง subclass เหล่านั้น (การแก้ไขจะต้องถูกทำซ้ำในทุกๆ สำเนาของ method) ดังนั้น implementation inheritance จึงช่วยลดปริมาณโค้ดที่ต้องแก้ไขเมื่อระบบพัฒนาไปเรื่อยๆ หรืออีกนัยหนึ่งคือ มันช่วยลดปัญหา change amplification ที่อธิบายไว้ใน บทที่ 2 .
อย่างไรก็ตาม implementation inheritance สร้าง dependencies ระหว่าง parent class และ subclass แต่ละตัว Class instance variables ใน parent class มักถูกเข้าถึงทั้งโดย parent และ child classes ส่งผลให้เกิด information leakage ระหว่าง classes ในลำดับชั้น inheritance และทำให้ยากที่จะแก้ไข class ใด class หนึ่งในลำดับชั้นโดยไม่ต้องดู class อื่นๆ ตัวอย่างเช่น นักพัฒนาที่กำลังแก้ไข parent class อาจต้องตรวจสอบ subclass ทั้งหมดเพื่อให้แน่ใจว่าการเปลี่ยนแปลงนั้นไม่ทำให้อะไรพัง ในทำนองเดียวกัน หาก subclass override method ใน parent class นักพัฒนาของ subclass อาจต้องตรวจสอบ implementation ใน parent class ด้วย ในกรณีที่เลวร้ายที่สุด โปรแกรมเมอร์จะต้องมีความรู้ครบถ้วนเกี่ยวกับ class hierarchy ทั้งหมดภายใต้ parent class เพื่อที่จะแก้ไข class ใดๆ ก็ตาม Class hierarchies ที่ใช้ implementation inheritance อย่างแพร่หลายมักจะมีความซับซ้อนสูง
ดังนั้น implementation inheritance ควรถูกใช้ด้วยความระมัดระวัง ก่อนใช้ implementation inheritance ให้พิจารณาว่าวิธีการที่ใช้ composition สามารถให้ประโยชน์แบบเดียวกันได้หรือไม่ ตัวอย่างเช่น อาจเป็นไปได้ที่จะใช้ helper classes ขนาดเล็กเพื่อ implement ฟังก์ชันการทำงานที่ใช้ร่วมกัน แทนที่จะสืบทอด functions จาก parent, classes ดั้งเดิมสามารถสร้างต่อจาก features ของ helper classes ได้
หากไม่มีทางเลือกอื่นที่ใช้ได้จริงนอกจากการใช้ implementation inheritance ให้ลองแยก state ที่ถูกจัดการโดย parent class ออกจาก state ที่ถูกจัดการโดย subclasses วิธีหนึ่งในการทำเช่นนี้ คือให้ instance variables บางตัวถูกจัดการโดย methods ใน parent class ทั้งหมด โดยที่ subclasses ใช้งานมันในแบบ read-only หรือผ่าน methods อื่นๆ ใน parent class เท่านั้น วิธีนี้เป็นการนำแนวคิดของ information hiding ไปใช้ภายใน class hierarchy เพื่อลด dependencies
แม้ว่ากลไกต่างๆ ที่ object-oriented programming มีให้จะช่วยในการ implement การออกแบบที่สะอาดได้ แต่กลไกเหล่านั้นไม่ได้รับประกันการออกแบบที่ดีด้วยตัวของมันเอง ตัวอย่างเช่น หาก classes มีความตื้น (shallow) หรือมี interfaces ที่ซับซ้อน หรืออนุญาตให้เข้าถึง internal state จากภายนอกได้ ก็จะยังคงส่งผลให้เกิดความซับซ้อนสูง
19.2 Agile development (การพัฒนาแบบ Agile)
Agile development เป็นแนวทางการพัฒนาซอฟต์แวร์ที่เกิดขึ้นในช่วงปลายทศวรรษ 1990 จากชุดแนวคิดเกี่ยวกับวิธีทำให้การพัฒนาซอฟต์แวร์มีน้ำหนักเบา ยืดหยุ่น และเป็น incremental มากขึ้น มันถูกกำหนดขึ้นอย่างเป็นทางการในระหว่างการประชุมของผู้ปฏิบัติงานในปี 2001 Agile development ส่วนใหญ่เกี่ยวข้องกับกระบวนการพัฒนาซอฟต์แวร์ (การจัดทีม การจัดการตารางเวลา บทบาทของ unit testing การโต้ตอบกับลูกค้า ฯลฯ) ซึ่งต่างจากการออกแบบซอฟต์แวร์ อย่างไรก็ตาม มันเกี่ยวข้องกับหลักการออกแบบบางอย่างในหนังสือเล่มนี้
หนึ่งในองค์ประกอบที่สำคัญที่สุดของ agile development คือแนวคิดที่ว่าการพัฒนาควรเป็นแบบ incremental และ iterative ในแนวทาง agile ระบบซอฟต์แวร์จะถูกพัฒนาผ่านชุดของ iterations ซึ่งแต่ละ iteration จะเพิ่มและประเมิน features ใหม่สองสามอย่าง แต่ละ iteration รวมถึงการออกแบบ การทดสอบ และ input จากลูกค้า โดยทั่วไปแล้วสิ่งนี้คล้ายกับแนวทาง incremental ที่สนับสนุนในหนังสือเล่มนี้ ดังที่กล่าวไว้ใน บทที่ 1 , เป็นไปไม่ได้ที่จะมองเห็นภาพรวมของระบบที่ซับซ้อนได้ดีพอตั้งแต่เริ่มต้นโครงการเพื่อกำหนดการออกแบบที่ดีที่สุด วิธีที่ดีที่สุดในการลงเอยด้วยการออกแบบที่ดีคือการพัฒนาระบบแบบ increment โดยที่แต่ละ increment จะเพิ่ม abstractions ใหม่สองสามอย่างและ refactor abstractions ที่มีอยู่ตามประสบการณ์ที่ได้รับ ซึ่งคล้ายกับแนวทาง agile development
หนึ่งในความเสี่ยงของ agile development คือมันสามารถนำไปสู่ tactical programming (การเขียนโปรแกรมแบบเน้นงานเฉพาะหน้า) Agile development มักจะ focus นักพัฒนาไปที่ features ไม่ใช่ abstractions และมันกระตุ้นให้นักพัฒนาเลื่อนการตัดสินใจด้านการออกแบบเพื่อที่จะผลิตซอฟต์แวร์ที่ใช้งานได้โดยเร็วที่สุด ตัวอย่างเช่น ผู้ปฏิบัติงาน agile บางคนโต้แย้งว่าคุณไม่ควร implement general-purpose mechanisms ทันที ให้ implement minimal special-purpose mechanism ก่อน แล้วค่อย refactor เป็นสิ่งที่ generic มากขึ้นทีหลัง เมื่อคุณรู้ว่ามันจำเป็นจริงๆ แม้ว่าข้อโต้แย้งเหล่านี้จะฟังดูมีเหตุผลในระดับหนึ่ง แต่มันขัดกับแนวทางแบบ investment approach และส่งเสริมรูปแบบการเขียนโปรแกรมแบบ tactical มากขึ้น ซึ่งอาจส่งผลให้เกิดการสะสมของความซับซ้อนอย่างรวดเร็ว
การพัฒนาแบบ incremental โดยทั่วไปเป็นความคิดที่ดี แต่ increments ของการพัฒนาควรเป็น abstractions ไม่ใช่ features ไม่เป็นไรที่จะเลื่อนความคิดทั้งหมดเกี่ยวกับ abstraction ใด abstraction หนึ่งออกไปจนกว่ามันจะถูกต้องการโดย feature เมื่อคุณต้องการ abstraction นั้นแล้ว ให้ลงทุนเวลาออกแบบมันอย่างสะอาด ปฏิบัติตามคำแนะนำใน บทที่ 6 และทำให้มันค่อนข้างเป็น general-purpose
19.3 Unit tests (การทดสอบระดับหน่วย)
ในอดีตนักพัฒนาไม่ค่อยเขียน tests เท่าไหร่ ถ้ามีการเขียน tests จริงๆ ก็มักจะเขียนโดยทีม QA ที่แยกต่างหาก อย่างไรก็ตาม หนึ่งในหลักการของ agile development คือการทดสอบควรถูกผสานรวมอย่างใกล้ชิดกับการพัฒนา และโปรแกรมเมอร์ควรเขียน tests สำหรับโค้ดของตัวเอง ปฏิบัติการนี้ได้แพร่หลายในปัจจุบันแล้ว Tests โดยทั่วไปแบ่งออกเป็นสองประเภท: unit tests และ system tests Unit tests เป็นประเภทที่นักพัฒนามักเขียนบ่อยที่สุด พวกมันมีขนาดเล็กและ focus: แต่ละ test มักจะตรวจสอบโค้ดส่วนเล็กๆ ใน method เดียว Unit tests สามารถรันแยกต่างหากได้ โดยไม่ต้องตั้งค่า production environment สำหรับระบบ Unit tests มักถูกรันร่วมกับเครื่องมือ test coverage เพื่อให้แน่ใจว่าทุกบรรทัดของโค้ดในแอปพลิเคชันถูกทดสอบ เมื่อใดก็ตามที่นักพัฒนาเขียนโค้ดใหม่หรือแก้ไขโค้ดที่มีอยู่ พวกเขามีหน้าที่รับผิดชอบในการอัปเดต unit tests เพื่อรักษา test coverage ที่เหมาะสม
test ประเภทที่สองคือ system tests (บางครั้งเรียกว่า integration tests) ซึ่งช่วยให้แน่ใจว่าส่วนต่างๆ ของแอปพลิเคชันทำงานร่วมกันอย่างถูกต้อง โดยทั่วไปแล้วมันเกี่ยวข้องกับการรันแอปพลิเคชันทั้งหมดใน production environment System tests มักจะถูกเขียนโดยทีม QA หรือ testing ทีมที่แยกต่างหาก
Tests โดยเฉพาะ unit tests มีบทบาทสำคัญในการออกแบบซอฟต์แวร์เพราะมันช่วยให้ refactoring เป็นไปได้ง่ายขึ้น หากไม่มี test suite การเปลี่ยนแปลงโครงสร้างครั้งใหญ่ในระบบเป็นเรื่องอันตราย ไม่มีวิธีง่ายๆ ในการหา bugs โอกาสที่ bugs จะไม่ถูกตรวจพบจนกว่าโค้ดใหม่จะถูก deploy ซึ่งการหาและแก้ไขในตอนนั้นมีต้นทุนสูงกว่ามาก ผลลัพธ์ก็คือนักพัฒนาหลีกเลี่ยงการ refactoring ในระบบที่ไม่มี test suite ที่ดี พวกเขาพยายามลดจำนวนการเปลี่ยนแปลงโค้ดสำหรับแต่ละ feature ใหม่หรือ bug fix ซึ่งหมายความว่าความซับซ้อนจะสะสมและข้อผิดพลาดในการออกแบบไม่ได้รับการแก้ไข
เมื่อมีชุด tests ที่ดี นักพัฒนาสามารถมั่นใจมากขึ้นเมื่อ refactoring เพราะ test suite จะพบ bugs ส่วนใหญ่ที่ถูกนำเข้ามา ซึ่งส่งเสริมให้นักพัฒนาปรับปรุงโครงสร้างของระบบ ส่งผลให้การออกแบบดีขึ้น Unit tests มีคุณค่าอย่างยิ่ง: พวกมันให้ code coverage ในระดับที่สูงกว่า system tests ดังนั้นจึงมีแนวโน้มที่จะเปิดเผย bugs ได้มากกว่า
ตัวอย่างเช่น ระหว่างการพัฒนาภาษา scripting Tcl เราตัดสินใจ ปรับปรุงประสิทธิภาพโดยการแทนที่ interpreter ของ Tcl ด้วย byte-code compiler นี่เป็นการเปลี่ยนแปลงครั้งใหญ่ที่ส่งผลกระทบเกือบทุกส่วนของ core Tcl engine โชคดีที่ Tcl มีชุด unit tests ที่ยอดเยี่ยม ซึ่งเราได้รันบน byte-code engine ใหม่ tests ที่มีอยู่มีประสิทธิภาพในการเปิดเผย bugs ใน engine ใหม่มากจนมี bug เพียงตัวเดียวที่ปรากฏขึ้นหลังจากการ alpha release ของ byte-code compiler
19.4 Test-driven development (การพัฒนาแบบ Test-driven)
Test-driven development (TDD) เป็นแนวทางการพัฒนาซอฟต์แวร์ที่โปรแกรมเมอร์เขียน unit tests ก่อนที่จะเขียนโค้ด เมื่อสร้าง class ใหม่ นักพัฒนาจะเขียน unit tests สำหรับ class นั้นก่อน โดยอิงจาก behavior ที่คาดหวัง tests ทั้งหมดจะไม่ผ่าน เนื่องจากยังไม่มีโค้ดสำหรับ class นั้น จากนั้นนักพัฒนาจะทำงานผ่าน tests ทีละตัว โดยเขียนโค้ดให้เพียงพอเพื่อให้ test นั้นผ่าน เมื่อ tests ทั้งหมดผ่าน ถือว่า class นั้นเสร็จสมบูรณ์
แม้ว่าผมจะสนับสนุน unit testing อย่างแข็งขัน แต่ผมไม่ใช่แฟนของ test-driven development ปัญหาของ test-driven development คือมัน focus ความสนใจไปที่การทำให้ features เฉพาะเจาะจงทำงานได้ มากกว่าการค้นหาการออกแบบที่ดีที่สุด นี่คือ tactical programming อย่างแท้จริง พร้อมกับข้อเสียทั้งหมดของมัน Test-driven development เป็น incremental เกินไป: ในทุกช่วงเวลา มันน่าดึงดูดที่จะแฮก feature ถัดไปเพื่อให้ test ถัดไปผ่าน ไม่มีเวลาที่ชัดเจนสำหรับการออกแบบ ดังนั้นจึงง่ายที่จะลงเอยด้วยความยุ่งเหยิง
ดังที่กล่าวไว้ใน หัวข้อ 19.2 , หน่วยของการพัฒนาควรเป็น abstractions ไม่ใช่ features เมื่อคุณค้นพบความจำเป็นสำหรับ abstraction อย่าสร้าง abstraction เป็นชิ้นเล็กชิ้นน้อยเมื่อเวลาผ่านไป ให้ออกแบบมันทั้งหมดในครั้งเดียว (หรืออย่างน้อยให้เพียงพอที่จะมีชุด core functions ที่ครอบคลุมพอสมควร) วิธีนี้มีแนวโน้มที่จะสร้างการออกแบบที่สะอาดซึ่งแต่ละส่วนประกอบกันได้ดี
จุดหนึ่งที่การเขียน tests ก่อนนั้นสมเหตุสมผลคือเมื่อแก้ไข bugs ก่อนที่จะแก้ bug ให้เขียน unit test ที่ล้มเหลวเพราะ bug นั้น จากนั้นแก้ไข bug และตรวจสอบให้แน่ใจว่า unit test ผ่านแล้ว นี่คือวิธีที่ดีที่สุดเพื่อให้แน่ใจว่าคุณได้แก้ bug จริงๆ หากคุณแก้ bug ก่อนเขียน test เป็นไปได้ว่า unit test ใหม่จะไม่ trigger bug จริงๆ ซึ่งในกรณีนั้นมันจะไม่บอกคุณว่าคุณแก้ปัญหาจริงหรือไม่
19.5 Design patterns (รูปแบบการออกแบบ)
Design pattern คือแนวทางที่ใช้กันทั่วไปในการแก้ปัญหาชนิดใดชนิดหนึ่ง เช่น iterator หรือ observer แนวคิดของ design patterns ถูกทำให้เป็นที่นิยมโดยหนังสือ Design Patterns: Elements of Reusable Object-Oriented Software โดย Gamma, Helm, Johnson, และ Vlissides และ design patterns ปัจจุบันถูกใช้อย่างแพร่หลายในการพัฒนา object-oriented software
Design patterns เป็นทางเลือกหนึ่งในการออกแบบ: แทนที่จะออกแบบกลไกใหม่จากศูนย์ เพียงแค่ใช้ design pattern ที่เป็นที่รู้จักกันดี โดยส่วนใหญ่แล้วสิ่งนี้เป็นสิ่งที่ดี: design patterns เกิดขึ้นเพราะพวกมันแก้ปัญหาที่พบบ่อย และเป็นที่ยอมรับกันทั่วไปว่ามันให้ solutions ที่สะอาด ถ้า design pattern ทำงานได้ดีในสถานการณ์เฉพาะ คุณคงจะคิดหาแนวทางอื่นที่ดีกว่าได้ยาก
ความเสี่ยงที่ใหญ่ที่สุดของ design patterns คือการใช้งานมากเกินไป (over-application) ไม่ใช่ทุกปัญหาจะสามารถแก้ไขได้อย่างสะอาดด้วย design pattern ที่มีอยู่ อย่าพยายามบังคับปัญหาให้เข้ากับ design pattern เมื่อวิธีแบบ custom จะสะอาดกว่า การใช้ design patterns ไม่ได้ช่วยปรับปรุงระบบซอฟต์แวร์โดยอัตโนมัติ มันจะช่วยก็ต่อเมื่อ design patterns นั้นเหมาะสมเท่านั้น เช่นเดียวกับแนวคิดหลายอย่างในการออกแบบซอฟต์แวร์ แนวคิดที่ว่า design patterns เป็นสิ่งที่ดีไม่ได้หมายความว่าการมี design patterns มากขึ้นจะดีกว่าเสมอไป
19.6 Getters and setters (Getter และ Setter)
ในชุมชนการเขียนโปรแกรม Java getter และ setter methods เป็น design pattern ที่ได้รับความนิยม Getter และ setter จะสัมพันธ์กับ instance variable ของ class พวกมันมีชื่อเช่น getFoo และ setFoo โดยที่ Foo คือชื่อของตัวแปร getter method จะคืนค่าปัจจุบันของตัวแปร และ setter method จะเปลี่ยนแปลงค่านั้น
Getters และ setters ไม่ได้จำเป็นอย่างแท้จริง เนื่องจาก instance variables สามารถทำให้เป็น public ได้ ข้อโต้แย้งสำหรับ getters และ setters คือมันทำให้สามารถดำเนินการฟังก์ชันเพิ่มเติมในขณะที่ getting และ setting ได้ เช่น การอัปเดตค่าที่เกี่ยวข้องเมื่อตัวแปรเปลี่ยนแปลง การแจ้ง listeners ถึงการเปลี่ยนแปลง หรือการบังคับใช้ constraints กับค่า แม้ว่า features เหล่านี้อาจไม่จำเป็นในตอนแรก มันสามารถถูกเพิ่มทีหลังได้โดยไม่ต้องเปลี่ยน interface
แม้ว่าการใช้ getters และ setters อาจสมเหตุสมผลหากคุณต้อง expose instance variables แต่จะดีกว่าถ้าไม่ expose instance variables ตั้งแต่แรก การ expose instance variables หมายความว่าส่วนหนึ่งของ implementation ของ class มองเห็นได้จากภายนอก ซึ่งละเมิด แนวคิดของ information hiding และเพิ่มความซับซ้อนของ interface ของ class Getters และ setters เป็น methods ที่ตื้น (shallow methods) (โดยทั่วไปมีเพียงบรรทัดเดียว) ดังนั้นมันเพิ่มความรก (clutter) ให้กับ interface ของ class โดยไม่ได้ให้ฟังก์ชันการทำงานมากมาย ควรหลีกเลี่ยง getters และ setters (หรือการ expose ข้อมูล implementation ใดๆ) เท่าที่จะทำได้
หนึ่งในความเสี่ยงของการสร้าง design pattern คือนักพัฒนาจะ assume ว่า pattern นั้นดีและพยายามใช้มันให้มากที่สุด ซึ่งได้นำไปสู่การใช้งาน getters และ setters ที่มากเกินไป (overusage) ใน Java
19.7 Conclusion (บทสรุป)
เมื่อใดก็ตามที่คุณพบกับข้อเสนอสำหรับกระบวนทัศน์ (paradigm) การพัฒนาซอฟต์แวร์ใหม่ จงท้าทายมันจากมุมมองของความซับซ้อน: ข้อเสนอนั้นช่วยลดความซับซ้อนในระบบซอฟต์แวร์ขนาดใหญ่ได้จริงหรือไม่? หลายข้อเสนอฟังดูดีบนพื้นผิว แต่ถ้าคุณมองลึกลงไป คุณจะเห็นว่าบางข้อเสนอทำให้ความซับซ้อนแย่ลง ไม่ใช่ดีขึ้น