หนึ่งในคำถามพื้นฐานที่สุดในการออกแบบซอฟต์แวร์คือ เมื่อมีฟังก์ชันการทำงานสองอย่าง เราควรนำไปไว้ที่เดียวกัน หรือแยกการ Implement ออกจากกันดี? คำถามนี้ใช้ได้กับทุกระดับในระบบ ไม่ว่าจะเป็นฟังก์ชัน เมธอด คลาส หรือบริการ ตัวอย่างเช่น ควรใส่ Buffering ไว้ในคลาสที่ให้บริการ I/O แบบ Stream สำหรับไฟล์ หรือควรแยกไว้ในอีกคลาสหนึ่ง? ควรแยกการ Parse คำขอ HTTP ไว้ในเมธอดเดียวทั้งหมด หรือควรแบ่งออกเป็นหลายเมธอด (หรือหลายคลาส)? บทนี้จะพูดถึงปัจจัยต่าง ๆ ที่ต้องพิจารณาเมื่อต้องตัดสินใจแบบนี้ ปัจจัยบางอย่างถูกพูดถึงไปแล้วในบทก่อนหน้านี้ แต่จะนำมากล่าวถึงอีกครั้งเพื่อให้ครบถ้วน
เมื่อต้องตัดสินใจว่าจะรวมหรือแยก เป้าหมายคือการลดความซับซ้อนของระบบโดยรวมและปรับปรุง Modularity อาจดูเหมือนว่าวิธีที่ดีที่สุดคือการแบ่งระบบออกเป็น Component เล็ก ๆ จำนวนมาก ยิ่ง Component เล็กเท่าไหร่ Component แต่ละตัวก็ยิ่งเรียบง่ายมากขึ้นเท่านั้น อย่างไรก็ตาม การแบ่งย่อยก็สร้างความซับซ้อนเพิ่มเติมที่ไม่มีมาก่อนการแบ่งเช่นกัน:
- ความซับซ้อนบางส่วนมาจากจำนวนของ Component ยิ่งมี Component มากเท่าไหร่ ก็ยิ่งยากที่จะจดจำทั้งหมดและค้นหา Component ที่ต้องการท่ามกลางคอลเล็กชันขนาดใหญ่ การแบ่งย่อยมักส่งผลให้มี Interface มากขึ้น และทุก Interface ใหม่ก็เพิ่มความซับซ้อน
- การแบ่งย่อยอาจทำให้เกิดโค้ดเพิ่มเติมสำหรับการจัดการ Component เช่น โค้ดที่เคยใช้อ็อบเจกต์เดียวก่อนการแบ่งย่อยอาจต้องจัดการหลายอ็อบเจกต์แทน
- การแบ่งย่อยสร้างความแยกจากกัน: Component ที่ถูกแบ่งย่อยจะอยู่ห่างกัน มากกว่าก่อนการแบ่ง เช่น เมธอดที่เคยอยู่ในคลาสเดียวกันอาจอยู่คนละคลาสหลังการแบ่ง และอาจอยู่ในคนละไฟล์ด้วย ความแยกจากกันทำให้ Developer มองเห็น Component พร้อมกันได้ยากขึ้น หรือแม้กระทั่งรับรู้ถึงการมีอยู่ของมัน ถ้า Component เป็นอิสระต่อกันจริง ๆ การแยกจากกันก็เป็นสิ่งดี เพราะช่วยให้ Developer โฟกัสที่ Component ครั้งละตัวโดยไม่ต้องถูกรบกวนจาก Component อื่น ในทางกลับกัน ถ้ามี Dependency ระหว่าง Component การแยกจากกันก็เป็นสิ่งไม่ดี เพราะ Developer จะต้องสลับไปมาระหว่าง Component ที่แยกกันอยู่ และที่แย่กว่านั้น พวกเขาอาจไม่รู้ถึง Dependency เหล่านั้น ซึ่งอาจนำไปสู่ Bug ได้
- การแบ่งย่อยอาจทำให้เกิด Duplication: โค้ดที่เคยมีสำเนาเดียวก่อนการแบ่งย่อยอาจต้องมีอยู่ในทุก Component ที่ถูกแบ่งย่อย
การนำโค้ดส่วนต่าง ๆ มารวมกันจะได้ประโยชน์มากที่สุดถ้าส่วนเหล่านั้นมีความเกี่ยวข้องกันอย่างใกล้ชิด ถ้าส่วนต่าง ๆ ไม่เกี่ยวข้องกัน ก็ควรแยกออกจากกันจะดีกว่า ต่อไปนี้คือสัญญาณบางอย่างที่บ่งบอกว่าโค้ดสองส่วนมีความเกี่ยวข้องกัน:
- โค้ดทั้งสองส่วนแชร์ข้อมูลร่วมกัน เช่น โค้ดทั้งสองส่วนอาจขึ้นอยู่กับ Syntax ของเอกสารประเภทใดประเภทหนึ่ง
- โค้ดทั้งสองส่วนถูกใช้ร่วมกัน: ใครก็ตามที่ใช้โค้ดส่วนหนึ่ง ก็มักจะใช้อีกส่วนหนึ่งด้วย ความสัมพันธ์รูปแบบนี้จะมีน้ำหนักก็ต่อเมื่อเป็นแบบสองทิศทาง ในทางกลับกัน: Cache ของ Disk Block มักจะใช้ Hash Table เสมอ แต่ Hash Table สามารถใช้ได้ในหลายสถานการณ์ที่ไม่เกี่ยวกับ Block Cache ดังนั้น Module ทั้งสองนี้ควรแยกออกจากกัน
- โค้ดทั้งสองส่วนทับซ้อนกันในเชิงแนวคิด กล่าวคือมีหมวดหมู่ระดับสูงที่เรียบง่ายซึ่งครอบคลุมโค้ดทั้งสองส่วน เช่น การค้นหาสตริงย่อยและการแปลงตัวพิมพ์เล็กใหญ่ต่างก็อยู่ในหมวดหมู่ของการจัดการสตริง ในขณะที่การควบคุม Flow และการส่งข้อมูลแบบเชื่อถือได้ต่างก็อยู่ในหมวดหมู่ของการสื่อสารผ่านเครือข่าย
- เป็นการยากที่จะเข้าใจโค้ดส่วนหนึ่งโดยไม่ต้องดูอีกส่วนหนึ่ง
ส่วนที่เหลือของบทนี้จะใช้กฎที่เฉพาะเจาะจงมากขึ้น รวมถึงตัวอย่างต่าง ๆ เพื่อแสดงให้เห็นว่าเมื่อใดที่ควรนำโค้ดส่วนต่าง ๆ มารวมกัน และเมื่อใดที่ควรแยกออกจากกัน
9.1 Bring together if information is shared (รวมกันถ้ามีการแชร์ข้อมูลร่วมกัน)
Section 5.4 ได้นำเสนอหลักการนี้ในบริบทของโปรเจกต์ที่ Implement HTTP Server ในการ Implement ครั้งแรก โปรเจกต์ใช้สองเมธอดที่แตกต่างกันในสองคลาสที่แตกต่างกันเพื่ออ่านและ Parse คำขอ HTTP เมธอดแรกอ่านข้อความของ คำขอที่เข้ามาจาก Network Socket และวางไว้ใน String Object ส่วนเมธอดที่สอง Parse สตริงเพื่อแยก Component ต่าง ๆ ของคำขอ ด้วยการแบ่งแบบนี้ ทั้งสองเมธอดต่างก็ต้องมีความรู้มากมายเกี่ยวกับรูปแบบของคำขอ HTTP: เมธอดแรกพยายามจะอ่านคำขอเท่านั้น ไม่ใช่ Parse แต่มันไม่สามารถรู้ได้ว่าคำขอสิ้นสุดลงตรงไหนโดยไม่ต้องทำงานส่วนใหญ่ของการ Parse (เช่น มันต้อง Parse Header Line เพื่อหา Header ที่ระบุความยาวโดยรวมของคำขอ) เนื่องจากการแชร์ข้อมูลร่วมกันนี้ การอ่านและ Parse คำขอในที่เดียวกันจึงดีกว่า เมื่อรวมทั้งสองคลาสเข้าด้วยกัน โค้ดก็สั้นลงและเรียบง่ายขึ้น
9.2 Bring together if it will simplify the interface (รวมกันถ้าจะทำให้ Interface ง่ายขึ้น)
เมื่อนำสองโมดูลขึ้นไปมารวมกันเป็นโมดูลเดียว ก็อาจเป็นไปได้ที่จะกำหนด Interface สำหรับโมดูลใหม่ที่เรียบง่ายหรือง่ายต่อการใช้งานกว่า Interface ดั้งเดิม สิ่งนี้มักเกิดขึ้นเมื่อโมดูลดั้งเดิมแต่ละตัว Implement เพียงส่วนหนึ่งของคำตอบของปัญหา ในตัวอย่าง HTTP Server จากหัวข้อที่แล้ว เมธอดดั้งเดิมต้องมี Interface เพื่อคืนค่า String คำขอ HTTP จากเมธอดแรกและส่งต่อไปยังเมธอดที่สอง เมื่อรวมเมธอดเข้าด้วยกัน Interface เหล่านี้ก็ถูกกำจัดออกไป
นอกจากนี้ เมื่อรวมฟังก์ชันการทำงานของสองคลาสขึ้นไปเข้าด้วยกัน ก็อาจเป็นไปได้ที่จะทำบางฟังก์ชันโดยอัตโนมัติ เพื่อให้ผู้ใช้ส่วนใหญ่ไม่จำเป็นต้องรู้เกี่ยวกับฟังก์ชันเหล่านั้นด้วยซ้ำ Java I/O Library แสดงให้เห็นโอกาสนี้ ถ้า FileInputStream และ BufferedInputStream ถูกรวมเข้าด้วยกัน และ Buffering มีให้โดยค่าเริ่มต้น ผู้ใช้ส่วนใหญ่ก็จะไม่จำเป็นต้องรู้ถึงการมีอยู่ของ Buffering เลย คลาส FileInputStream ที่รวมกันแล้วอาจมี Method สำหรับปิดการใช้งานหรือแทนที่กลไก Buffering เริ่มต้น แต่ผู้ใช้ส่วนใหญ่ก็ไม่จำเป็นต้องเรียนรู้เกี่ยวกับมัน
9.3 Bring together to eliminate duplication (รวมกันเพื่อกำจัด Duplication)
ถ้าคุณพบว่าโค้ดรูปแบบเดียวกันถูกเขียนซ้ำแล้วซ้ำเล่า ให้ลองดูว่าคุณสามารถจัดระเบียบโค้ดใหม่เพื่อกำจัดการทำซ้ำนั้นได้หรือไม่ วิธีหนึ่งคือแยกโค้ดที่ซ้ำกันออกมาเป็น Method แยกต่างหาก แล้วแทนที่โค้ดที่ซ้ำกันด้วยการเรียกใช้ Method นั้น วิธีนี้จะได้ผลดีที่สุดถ้าโค้ดที่ซ้ำกันนั้นยาว และ Method ที่มาแทนที่มี Signature ที่เรียบง่าย ถ้าโค้ดที่ซ้ำกันมีความยาวแค่หนึ่งหรือสองบรรทัด ก็อาจ ไม่คุ้มที่จะแทนที่ด้วย Method Call ถ้าโค้ดที่ซ้ำกันมีปฏิสัมพันธ์ที่ซับซ้อนกับ Environment ของมัน (เช่น การเข้าถึง Local Variable จำนวนมาก) Method ที่มาแทนที่ก็อาจต้องมี Signature ที่ซับซ้อน (เช่น Argument แบบ Pass-by-Reference จำนวนมาก) ซึ่งจะลดคุณค่าของมันลง
อีกวิธีหนึ่งในการกำจัด Duplication คือการ Refactor โค้ดเพื่อให้โค้ดที่ต้องการทำงานแค่ในที่เดียวเท่านั้น สมมติว่าคุณกำลังเขียน Method ที่ต้องคืนค่า Error ณ จุดต่าง ๆ หลายจุด และต้องดำเนินการ Cleanup แบบเดียวกันในแต่ละจุดเหล่านี้ก่อนที่จะ Return (ดู Figure 9.1 สำหรับตัวอย่าง) ถ้า Programming Language รองรับ goto คุณสามารถย้ายโค้ด Cleanup ไปไว้ที่ส่วนท้ายสุดของ Method แล้ว goto ไปที่โค้ดนั้นในแต่ละจุดที่ต้องการ Return Error ดังใน Figure 9.2 . Goto โดยทั่วไปถือว่าเป็นสิ่งที่ไม่ดี และอาจทำให้โค้ดอ่านไม่รู้เรื่องถ้าใช้อย่างไร้ขีดจำกัด แต่มันก็มีประโยชน์ในสถานการณ์แบบนี้ที่ใช้เพื่อหลุดออกจากโค้ดที่ซ้อนกัน
9.4 Separate general-purpose and special-purpose code (แยกโค้ด General-Purpose ออกจาก Special-Purpose)
ถ้า Module ใดมีกลไกที่สามารถใช้เพื่อวัตถุประสงค์ที่แตกต่างกันหลายอย่าง Module นั้นควรให้เฉพาะกลไก General-Purpose นั้นเท่านั้น ไม่ควรรวมโค้ดที่ปรับแต่งกลไกสำหรับการใช้งานเฉพาะอย่าง และไม่ควรมีกลไก General-Purpose อื่น ๆ ด้วย โค้ด Special-Purpose ที่เกี่ยวข้องกับกลไก General-Purpose ควรอยู่ในอีก Module หนึ่ง (โดยทั่วไปคือ Module ที่เกี่ยวข้องกับวัตถุประสงค์เฉพาะนั้น) การอภิปรายเกี่ยวกับ GUI Editor ใน Chapter 6 แสดงให้เห็นหลักการนี้: การออกแบบที่ดีที่สุดคือการให้ Text Class จัดการ Operation ทั่วไปเกี่ยวกับข้อความ ในขณะที่ Operation เฉพาะของ User Interface (เช่น การลบ Selection) ถูก Implement ใน User Interface Module วิธีนี้ช่วยขจัด Information Leakage และ Interface เพิ่มเติมที่มีอยู่ในการออกแบบก่อนหน้านี้ที่ Operation เฉพาะของ User Interface ถูก Implement ใน Text Class
Red Flag: Repetition (ธงแดง: การทำซ้ำ)
ถ้าโค้ดชิ้นเดียวกัน (หรือโค้ดที่เกือบจะเหมือนกัน) ปรากฏขึ้นซ้ำแล้วซ้ำเล่า นั่นคือธงแดงที่บ่งบอกว่าคุณยังไม่พบ Abstractions ที่ถูกต้อง
Figure 9.1 (รูปที่ 9.1): โค้ดนี้ประมวลผลแพ็กเก็ตเครือข่ายขาเข้าหลายประเภท สำหรับแต่ละประเภท ถ้าแพ็กเก็ตสั้นเกินไปสำหรับประเภทนั้น ข้อความจะถูกบันทึกลง Log ในโค้ดเวอร์ชันนี้ คำสั่ง LOG ถูกทำซ้ำสำหรับแพ็กเก็ตหลายประเภท
Figure 9.2 (รูปที่ 9.2): การจัดระเบียบโค้ดจาก Figure 9.1 ใหม่ เพื่อให้มีคำสั่ง LOG เพียงชุดเดียว
โดยทั่วไปแล้ว Layer ล่างของระบบมักจะเป็น General-Purpose มากกว่า และ Layer บนจะเป็น Special-Purpose มากกว่า ตัวอย่างเช่น Layer บนสุดของแอปพลิเคชันประกอบด้วยฟีเจอร์ที่เฉพาะเจาะจงกับแอปพลิเคชันนั้นโดยสิ้นเชิง วิธีแยกโค้ด Special-Purpose ออกจากโค้ด General-Purpose คือการดึงโค้ด Special-Purpose ขึ้นไปไว้ใน Layer ที่สูงกว่า โดยปล่อยให้ Layer ล่างเป็น General-Purpose เมื่อคุณเจอคลาสที่มีทั้งฟีเจอร์ General-Purpose และ Special-Purpose สำหรับ Abstraction เดียวกัน ให้ลองดูว่าคลาสนั้นสามารถแยกออกเป็นสองคลาสได้หรือไม่ โดยคลาสหนึ่งมีฟีเจอร์ General-Purpose และอีกคลาสหนึ่งวางซ้อนอยู่ด้านบนเพื่อให้ฟีเจอร์ Special-Purpose
9.5 Example: insertion cursor and selection (ตัวอย่าง: Cursor สำหรับแทรกข้อความและการเลือก)
หัวข้อถัด ๆ ไปจะอธิบายตัวอย่างสามตัวอย่างที่แสดงให้เห็นหลักการที่พูดถึงข้างต้น ในสองตัวอย่าง วิธีที่ดีที่สุดคือการแยกส่วนของโค้ดที่เกี่ยวข้องออกจากกัน ในตัวอย่างที่สาม การรวมเข้าด้วยกันเป็นวิธีที่ดีกว่า
ตัวอย่างแรกประกอบด้วย Cursor สำหรับแทรกข้อความ (Insertion Cursor) และการเลือก (Selection) ในโปรเจกต์ GUI Editor จาก Chapter 6 . Editor จะแสดงเส้นแนวตั้งกระพริบที่ระบุตำแหน่งที่ข้อความที่ผู้ใช้พิมพ์จะปรากฏในเอกสาร Editor ยังแสดงช่วงของตัวอักษรที่ถูกไฮไลต์เรียกว่า selection ซึ่งใช้สำหรับคัดลอกหรือลบข้อความ Cursor สำหรับแทรกข้อความจะแสดงอยู่เสมอ แต่อาจมีบางครั้งที่ไม่มีข้อความถูกเลือก ถ้ามี Selection Cursor จะอยู่ที่ปลายด้านหนึ่งของ Selection เสมอ
Selection และ Cursor สำหรับแทรกข้อความมีความเกี่ยวข้องกันในบางแง่ ตัวอย่างเช่น Cursor จะอยู่ที่ปลายด้านหนึ่งของ Selection เสมอ และ Cursor กับ Selection มักถูกปรับแต่งร่วมกัน การคลิกและลากเมาส์จะตั้งค่าทั้งสองอย่าง และการแทรกข้อความจะลบข้อความที่ถูกเลือกก่อน (ถ้ามี) จากนั้นจึงแทรกข้อความใหม่ที่ตำแหน่ง Cursor ดังนั้นจึงอาจดูสมเหตุสมผลที่จะใช้อ็อบเจกต์เดียวในการจัดการทั้ง Selection และ Cursor และทีมโปรเจกต์หนึ่งก็ใช้แนวทางนี้ อ็อบเจกต์ดังกล่าวเก็บตำแหน่งสองตำแหน่งในไฟล์ พร้อมกับค่า Boolean ที่ระบุว่าปลายด้านใดคือ Cursor และว่ามี Selection อยู่หรือไม่
อย่างไรก็ตาม อ็อบเจกต์ที่รวมกันนั้นใช้งานได้ยาก มันไม่ได้ให้ประโยชน์ใด ๆ แก่โค้ดระดับสูงกว่า เนื่องจากโค้ดระดับสูงกว่ายังคงต้องรับรู้ว่า Selection และ Cursor เป็นเอนทิตีที่แยกจากกันและจัดการแยกกัน (ในระหว่างการแทรกข้อความ มันเรียก Method บนอ็อบเจกต์ที่รวมกันก่อนเพื่อลบข้อความที่ถูกเลือก จากนั้นก็เรียกอีก Method เพื่อดึงตำแหน่ง Cursor เพื่อแทรกข้อความใหม่) อ็อบเจกต์ที่รวมกันนั้น Implement ได้ซับซ้อนกว่าอ็อบเจกต์ที่แยกกันด้วยซ้ำ มันหลีกเลี่ยงการเก็บตำแหน่ง Cursor เป็นเอนทิตีแยกต่างหาก แต่ต้องเก็บค่า Boolean ที่ระบุว่าปลายด้านใดของ Selection คือ Cursor แทน เพื่อดึงตำแหน่ง Cursor อ็อบเจกต์ที่รวมกันต้องตรวจสอบ Boolean ก่อนแล้วจึงเลือกปลายด้านที่เหมาะสมของ Selection
Red Flag: Special-General Mixture (ธงแดง: การผสม General-Purpose และ Special-Purpose)
ธงแดงนี้เกิดขึ้นเมื่อกลไก General-Purpose มีโค้ดเฉพาะสำหรับการใช้งานเฉพาะอย่างของกลไกนั้นด้วย สิ่งนี้ทำให้กลไกซับซ้อนขึ้นและสร้าง Information Leakage ระหว่างกลไกและกรณีการใช้งานเฉพาะ: การแก้ไขในกรณีการใช้งานในอนาคตมักจะต้องเปลี่ยนแปลงกลไกพื้นฐานด้วย
ในกรณีนี้ Selection และ Cursor ไม่ได้มีความเกี่ยวข้องกันมากพอที่จะรวมเข้าด้วยกัน เมื่อโค้ดถูกปรับปรุงเพื่อแยก Selection และ Cursor ออกจากกัน ทั้งการใช้งานและการ Implement ก็เรียบง่ายขึ้น อ็อบเจกต์ที่แยกจากกันให้ Interface ที่เรียบง่ายกว่าอ็อบเจกต์ที่รวมกันซึ่งต้องสกัดข้อมูล Selection และ Cursor ออกมา การ Implement Cursor ก็เรียบง่ายขึ้นเช่นกันเพราะตำแหน่ง Cursor ถูกแทนโดยตรง ไม่ใช่โดยอ้อมผ่าน Selection และ Boolean จริง ๆ แล้ว ในเวอร์ชันที่ปรับปรุงแล้ว ไม่ได้ใช้คลาสพิเศษสำหรับ Selection หรือ Cursor เลย แต่ใช้คลาส Position ใหม่เพื่อแทนตำแหน่งในไฟล์ (หมายเลขบรรทัดและตัวอักษรในบรรทัด) Selection ถูกแทนด้วยสอง Positions และ Cursor ถูกแทนด้วยหนึ่ง Position . Positions ยังพบการใช้งานอื่น ๆ ในโปรเจกต์ด้วย ตัวอย่างนี้ยังแสดงให้เห็นถึงประโยชน์ของ Interface ระดับล่างแต่ General-Purpose มากกว่า ซึ่งได้พูดถึงไปแล้วใน Chapter 6 .
9.6 Example: separate class for logging (ตัวอย่าง: คลาสแยกสำหรับการทำ Log)
ตัวอย่างที่สองเกี่ยวข้องกับการบันทึก Error ในโปรเจกต์ของนักศึกษา คลาสหนึ่งมีลำดับโค้ดหลายจุดดังนี้:
try {
rpcConn = connectionPool.getConnection(dest);
} catch (IOException e) {
NetworkErrorLogger.logRpcOpenError(req, dest, e);
return null;
}
แทนที่จะบันทึก Error ณ จุดที่ตรวจพบ กลับเรียก Method ในคลาสพิเศษสำหรับการบันทึก Error คลาสสำหรับบันทึก Error ถูกกำหนดไว้ที่ท้ายไฟล์ต้นฉบับเดียวกัน:
private static class NetworkErrorLogger {
/**
* Output information relevant to an error that occurs when trying
* to open a connection to send an RPC.
*
* @param req
* The RPC request that would have been sent through the connection
* @param dest
* The destination of the RPC
* @param e
* The caught error
*/
public static void logRpcOpenError(RpcRequest req, AddrPortTuple dest, Exception e) {
logger.log(Level.WARNING, "Cannot send message: " + req + ". \n" + "Unable to find or open connection to " + dest + " :" + e);
}
...
}
คลาส NetworkErrorLogger มีหลาย Method เช่น logRpcSendError และ logRpcReceiveError โดยแต่ละ Method จะบันทึก Error คนละประเภทกัน
การแยกแบบนี้เพิ่มความซับซ้อนโดยไม่มีประโยชน์ใด ๆ Method การบันทึก Error เป็น Shallow Method: ส่วนใหญ่ประกอบด้วยโค้ดเพียงบรรทัดเดียว แต่ต้องใช้เอกสารค่อนข้างมาก แต่ละ Method ถูกเรียกใช้ในที่เดียวเท่านั้น Method การบันทึก Error พึ่งพาจุดที่เรียกใช้อย่างมาก: คนที่อ่านการเรียกใช้ Method มักจะต้องพลิกไปดู Method การบันทึก Error เพื่อให้แน่ใจว่ามีการบันทึกข้อมูลที่ถูกต้อง ในทำนองเดียวกัน คนที่อ่าน Method การบันทึก Error ก็มักจะพลิกไปดูจุดที่เรียกใช้เพื่อทำความเข้าใจวัตถุประสงค์ของ Method
ในตัวอย่างนี้ ควรกำจัด Method การบันทึก Error และวางคำสั่ง Logging ไว้ที่จุดที่ตรวจพบ Error จะดีกว่า ซึ่งจะทำให้โค้ดอ่านง่ายขึ้นและกำจัด Interface ที่จำเป็นสำหรับ Method การบันทึก Error เหล่านั้น
9.7 Example: editor undo mechanism (ตัวอย่าง: กลไก Undo ของ Editor)
ในโปรเจกต์ GUI Editor จาก Section 6.2 หนึ่งในข้อกำหนดคือการรองรับ Undo/Redo แบบหลายระดับ ไม่ใช่แค่สำหรับการเปลี่ยนแปลงข้อความเท่านั้น แต่รวมถึงการเปลี่ยนแปลงของ Selection, Cursor สำหรับแทรกข้อความ และมุมมองด้วย ตัวอย่างเช่น ถ้าผู้ใช้เลือกข้อความบางส่วน ลบมัน เลื่อนไปยังตำแหน่งอื่นในไฟล์ แล้วเรียก Undo Editor จะต้องคืนสถานะกลับไปเป็นก่อนการลบ ซึ่งรวมถึงการคืนข้อความที่ถูกลบ การเลือกข้อความนั้นอีกครั้ง และการทำให้ข้อความที่ถูกเลือกปรากฏในหน้าต่าง
โปรเจกต์ของนักศึกษาบางโปรเจกต์ Implement กลไก Undo ทั้งหมดไว้ใน Text Class โดย Text Class จะเก็บ List ของการเปลี่ยนแปลงที่สามารถ Undo ได้ทั้งหมด โดยจะเพิ่มรายการใน List นี้โดยอัตโนมัติเมื่อมีการเปลี่ยนแปลงข้อความ สำหรับการเปลี่ยนแปลงของ Selection, Cursor สำหรับแทรกข้อความ และมุมมอง โค้ด User Interface จะเรียก Method เพิ่มเติมใน Text Class ซึ่งจะเพิ่มรายการสำหรับการเปลี่ยนแปลงเหล่านั้นลงใน Undo List เมื่อผู้ใช้ขอให้ Undo หรือ Redo โค้ด User Interface จะเรียก Method ใน Text Class ซึ่งจะประมวลผลรายการใน Undo List สำหรับรายการที่เกี่ยวข้องกับข้อความ มันจะอัปเดตภายในของ Text Class สำหรับรายการที่เกี่ยวข้องกับสิ่งอื่น เช่น Selection Text Class จะเรียกกลับไปยังโค้ด User Interface เพื่อดำเนินการ Undo หรือ Redo
วิธีนี้ส่งผลให้เกิดชุดฟีเจอร์ที่ดูผิดปกติใน Text Class แกนหลักของ Undo/Redo ประกอบด้วยกลไก General-Purpose สำหรับจัดการ List ของ Action ที่ถูกดำเนินการและเดินผ่าน List เหล่านั้นระหว่างการ Undo และ Redo แกนหลักนี้อยู่ใน Text Class พร้อมกับ Handler แบบ Special-Purpose ที่ Implement การ Undo และ Redo สำหรับสิ่งเฉพาะ เช่น ข้อความและ Selection Handler การ Undo แบบ Special-Purpose สำหรับ Selection และ Cursor ไม่มีความเกี่ยวข้องกับสิ่งอื่นใดใน Text Class พวกมันทำให้เกิด Information Leakage ระหว่าง Text Class และ User Interface รวมถึง Method พิเศษในแต่ละ Module เพื่อส่งข้อมูล Undo ไปกลับมา ถ้าในอนาคตมีการเพิ่มเอนทิตีที่สามารถ Undo ได้ชนิดใหม่เข้าสู่ระบบ ก็จะต้องเปลี่ยนแปลง Text Class รวมถึง Method ใหม่ที่เฉพาะเจาะจงกับเอนทิตีนั้น นอกจากนี้ แกนหลัก Undo แบบ General-Purpose ก็แทบไม่มีความเกี่ยวข้องกับสิ่งอำนวยความสะดวกด้านข้อความแบบ General-Purpose ในคลาสเลย
ปัญหาเหล่านี้สามารถแก้ไขได้โดยการแยกแกนหลัก General-Purpose ของกลไก Undo/Redo ออกมาใส่ในคลาสแยกต่างหาก:
public class History {
public interface Action {
public void redo();
public void undo();
}
History() {...}
void addAction(Action action) {...}
void addFence() {...}
void undo() {...}
void redo() {...}
}
ในการออกแบบนี้ คลาส History จัดการ Collection ของอ็อบเจกต์ที่ Implement Interface History.Action แต่ละ History.Action อธิบายการดำเนินการเดียว เช่น การแทรกข้อความหรือการเปลี่ยนแปลงตำแหน่ง Cursor และมี Method ที่ สามารถ Undo หรือ Redo การดำเนินการนั้น คลาส History ไม่รู้ข้อมูลที่เก็บใน Action หรือว่า Method undo และ redo ของพวกมัน Implement อย่างไร History เก็บ List ประวัติที่อธิบาย Action ทั้งหมดที่ถูกดำเนินการตลอดอายุของแอปพลิเคชัน และมี Method undo และ redo ที่เดินย้อนกลับและไปข้างหน้าผ่าน List เพื่อตอบสนองต่อการ Undo และ Redo ที่ผู้ใช้ร้องขอ โดยเรียก Method undo และ redo ใน History.Actions .
History.Actions เป็นอ็อบเจกต์แบบ Special-Purpose: แต่ละตัวเข้าใจการดำเนินการที่สามารถ Undo ได้ชนิดใดชนิดหนึ่งโดยเฉพาะ พวกมันถูก Implement ภายนอกคลาส History ใน Module ที่เข้าใจ Action ที่สามารถ Undo ได้ชนิดพิเศษต่าง ๆ Text Class อาจ Implement UndoableInsert และ UndoableDelete เพื่ออธิบายการแทรกและการลบข้อความ เมื่อใดก็ตามที่มันแทรกข้อความ Text Class จะสร้าง UndoableInsert ใหม่ที่อธิบายการแทรกและเรียก History.addAction เพื่อเพิ่มลงใน List ประวัติ โค้ด User Interface ของ Editor อาจสร้าง UndoableSelection และ UndoableCursor ที่อธิบายการเปลี่ยนแปลงของ Selection และ Cursor สำหรับแทรกข้อความ
คลาส History ยังอนุญาตให้จัดกลุ่ม Action เพื่อให้ ตัวอย่างเช่น การขอ Undo หนึ่งครั้งจากผู้ใช้สามารถคืนข้อความที่ถูกลบ เลือกข้อความที่ถูกลบอีกครั้ง และ reposition Cursor สำหรับแทรกข้อความได้ มีหลายวิธีในการจัดกลุ่ม Action คลาส History ใช้ fences ซึ่งเป็นเครื่องหมายที่วางใน List ประวัติเพื่อแยกกลุ่มของ Action ที่เกี่ยวข้องกัน แต่ละ Call ไปยัง History.redo จะเดินย้อนกลับผ่าน List ประวัติ โดยทำการ Undo Action ไปเรื่อย ๆ จนกว่าจะถึง Fence ถัดไป ตำแหน่งของ Fence ถูกกำหนดโดยโค้ดระดับสูงกว่าผ่านการเรียก History.addFence .
วิธีนี้แบ่งฟังก์ชันการทำงานของ Undo ออกเป็นสามหมวดหมู่ ซึ่งแต่ละหมวดหมู่ถูก Implement ในที่ที่แตกต่างกัน:
- กลไก General-Purpose สำหรับการจัดการและจัดกลุ่ม Action และการเรียกใช้การ Undo/Redo (Implement โดยคลาส History )
- รายละเอียดเฉพาะของ Action แต่ละประเภท (Implement โดยคลาสต่าง ๆ มากมาย ซึ่งแต่ละคลาสเข้าใจ Action เพียงไม่กี่ประเภท)
- นโยบายสำหรับการจัดกลุ่ม Action (Implement โดยโค้ด User Interface ระดับสูงเพื่อให้พฤติกรรมโดยรวมของแอปพลิเคชันที่ถูกต้อง)
แต่ละหมวดหมู่เหล่านี้สามารถ Implement ได้โดยไม่ต้องเข้าใจหมวดหมู่อื่น คลาส History ไม่รู้ว่า Action ประเภทไหนกำลังถูก Undo มันสามารถใช้ได้ในแอปพลิเคชันที่หลากหลาย แต่ละคลาส Action เข้าใจ Action เพียงชนิดเดียว และทั้งคลาส History และคลาส Action ไม่จำเป็นต้องรับรู้นโยบายสำหรับการจัดกลุ่ม Action
การตัดสินใจออกแบบที่สำคัญคือการแยกส่วน General-Purpose ของกลไก Undo ออกจากส่วน Special-Purpose และใส่ส่วน General-Purpose ไว้ใน คลาสของมันเอง เมื่อทำเช่นนั้นแล้ว ส่วนที่เหลือของการออกแบบก็ดำเนินไปอย่างเป็นธรรมชาติ
หมายเหตุ: ข้อเสนอแนะให้แยกโค้ด General-Purpose ออกจากโค้ด Special-Purpose หมายถึงโค้ดที่เกี่ยวข้องกับกลไกใดกลไกหนึ่งโดยเฉพาะ ตัวอย่างเช่น โค้ด Undo แบบ Special-Purpose (เช่น โค้ดสำหรับ Undo การแทรกข้อความ) ควรแยกจากโค้ด Undo แบบ General-Purpose (เช่น โค้ดสำหรับจัดการ List ประวัติ) อย่างไรก็ตาม การรวมโค้ด Special-Purpose สำหรับกลไกหนึ่งเข้ากับโค้ด General-Purpose สำหรับอีกกลไกหนึ่งมักจะสมเหตุสมผล Text Class เป็นตัวอย่างของสิ่งนี้: มัน Implement กลไก General-Purpose สำหรับการจัดการข้อความ แต่รวมโค้ด Special-Purpose ที่เกี่ยวข้องกับการ Undo เข้าไปด้วย โค้ด Undo เป็น Special-Purpose เพราะมันจัดการเฉพาะการดำเนินการ Undo สำหรับการแก้ไขข้อความเท่านั้น การรวมโค้ดนี้เข้ากับ Infrastructure Undo แบบ General-Purpose ในคลาส History ไม่สมเหตุสมผล แต่มันสมเหตุสมผลที่จะใส่ไว้ใน Text Class เพราะมันเกี่ยวข้องอย่างใกล้ชิดกับฟังก์ชันข้อความอื่น ๆ
9.8 Splitting and joining methods (การแบ่งแยกและรวมเมธอด)
ปัญหาเรื่องการแบ่งย่อยไม่ได้ใช้แค่กับคลาสเท่านั้น แต่ยังรวมถึง Method ด้วย: มีบางครั้งไหมที่การแบ่ง Method ที่มีอยู่ออกเป็น Method เล็ก ๆ หลายอันจะดีกว่า? หรือควรรวม Method เล็กสองอันเป็น Method ใหญ่หนึ่งอัน? Method ที่ยาวมักจะเข้าใจยากกว่า Method ที่สั้น ดังนั้นหลายคนจึงแย้งว่าความยาวเพียงอย่างเดียวก็เป็นเหตุผลที่ดีพอที่จะแบ่ง Method ได้ นักเรียนในชั้นเรียนมักจะได้รับเกณฑ์ที่ตายตัว เช่น "ให้แบ่ง Method ใดก็ตามที่ยาวเกิน 20 บรรทัด!"
อย่างไรก็ตาม ความยาวเพียงอย่างเดียวมักไม่ใช่เหตุผลที่ดีในการแบ่ง Method โดยทั่วไปแล้ว Developer มักจะแบ่ง Method มากเกินไป การแบ่ง Method จะเพิ่ม Interface ใหม่ ซึ่งเพิ่มความซับซ้อน มันยังแยกส่วนต่าง ๆ ของ Method เดิมออกจากกัน ซึ่งทำให้โค้ดอ่านยากขึ้นถ้าส่วนต่าง ๆ นั้นเกี่ยวข้องกันจริง ๆ คุณไม่ควรแบ่ง Method นอกจากจะทำให้ระบบโดยรวมเรียบง่ายขึ้น ผมจะพูดถึงว่าสิ่งนี้เกิดขึ้นได้อย่างไรด้านล่าง
Method ยาวไม่ได้แย่เสมอไป ตัวอย่างเช่น สมมติว่า Method หนึ่งมีโค้ด 5 ชุด ชุดละ 20 บรรทัด ที่ถูกดำเนินการตามลำดับ ถ้าชุดโค้ดเหล่านั้นค่อนข้างเป็นอิสระต่อกัน Method ก็สามารถอ่านและเข้าใจทีละชุดได้ ไม่มีประโยชน์มากมายในการย้ายแต่ละชุดไปไว้ใน Method แยกต่างหาก ถ้าชุดโค้ดมี Interaction ที่ซับซ้อน การเก็บไว้ด้วยกันยิ่งสำคัญ เพื่อให้ผู้อ่านเห็นโค้ดทั้งหมดพร้อมกัน ถ้าแต่ละชุดอยู่ใน Method แยกกัน ผู้อ่านจะต้องสลับไปมาระหว่าง Method ที่กระจายกันเหล่านี้เพื่อทำความเข้าใจว่ามันทำงานร่วมกันอย่างไร Method ที่มีโค้ดหลายร้อยบรรทัดก็ใช้ได้ถ้ามันมี Signature ที่เรียบง่ายและอ่านง่าย Method เหล่านี้คือ Method ที่ Deep (ฟังก์ชันการทำงานมากมาย Interface เรียบง่าย) ซึ่งเป็นสิ่งที่ดี
Figure 9.3 (รูปที่ 9.3): Method (a) สามารถแบ่งได้โดยการแยก Subtask ออก (b) หรือโดยการแบ่งฟังก์ชันการทำงานออกเป็นสอง Method แยกกัน (c) ไม่ควรแบ่ง Method ถ้ามันส่งผลให้เกิด Method ที่ Shallow ดังใน (d)
เมื่อออกแบบ Method เป้าหมายที่สำคัญที่สุดคือการให้ Abstractions ที่สะอาดและเรียบง่าย แต่ละ Method ควรทำสิ่งเดียวและทำให้เสร็จสมบูรณ์ Method ควรมี Interface ที่สะอาดและเรียบง่าย เพื่อให้ผู้ใช้ไม่ต้องมีข้อมูลมากมายในหัวเพื่อใช้งานอย่างถูกต้อง Method ควรเป็น Deep: Interface ควรเรียบง่ายกว่า Implementation มาก ถ้า Method มีคุณสมบัติทั้งหมดนี้ ความยาวของ Method ก็คงไม่สำคัญ
การแบ่ง Method จะสมเหตุสมผลก็ต่อเมื่อมันส่งผลให้ Abstractions สะอาดขึ้นโดยรวม มีสองวิธีในการทำเช่นนี้ ซึ่งแสดงใน Figure 9.3 . วิธีที่ดีที่สุดคือการแยก Subtask ออกเป็น Method แยกต่างหาก ดังใน Figure 9.3(b) . การแบ่งย่อยนี้จะได้ Method ลูก (Child Method) ที่มี Subtask และ Method แม่ (Parent Method) ที่มีส่วนที่เหลือของ Method เดิม โดย Method แม่จะเรียก Method ลูก Interface ของ Method แม่ใหม่จะเหมือนกับ Method เดิม การแบ่งในรูปแบบนี้จะสมเหตุสมผลถ้ามี Subtask ที่สามารถแยกออกจากส่วนที่เหลือของ Method เดิมได้อย่างชัดเจน ซึ่งหมายถึง (a) คนที่อ่าน Method ลูกไม่จำเป็นต้องรู้อะไรเกี่ยวกับ Method แม่ และ (b) คนที่อ่าน Method แม่ไม่จำเป็นต้องเข้าใจ Implementation ของ Method ลูก โดยทั่วไปหมายความว่า Method ลูกค่อนข้างเป็น General-Purpose: มันสามารถใช้ได้โดย Method อื่นนอกจาก Method แม่ ถ้าคุณแบ่งในรูปแบบนี้แล้วพบว่าตัวเองต้องสลับไปมาระหว่าง Method แม่และลูกเพื่อทำความเข้าใจว่าพวกมันทำงานร่วมกันอย่างไร นั่นคือธงแดง ("Conjoined Methods") ที่บ่งบอกว่าการแบ่งอาจเป็นความคิดที่ไม่ดี
วิธีที่สองในการแบ่ง Method คือการแยกออกเป็นสอง Method แยกกัน ซึ่งแต่ละ Method มองเห็นได้โดยผู้เรียกใช้ Method เดิม ดังใน Figure 9.3(c) . วิธีนี้สมเหตุสมผลถ้า Method เดิมมี Interface ที่ซับซ้อนเกินไปเพราะมันพยายามทำหลายสิ่งที่ไม่เกี่ยวข้องกันอย่างใกล้ชิด ถ้าเป็นเช่นนั้น ก็อาจเป็นไปได้ที่จะแบ่งฟังก์ชันการทำงานของ Method ออกเป็น Method เล็ก ๆ สองอันขึ้นไป โดยแต่ละอันมีเพียงส่วนหนึ่งของฟังก์ชันการทำงานของ Method เดิม ถ้าคุณแบ่งแบบนี้ Interface ของ Method ใหม่แต่ละอันควรเรียบง่ายกว่า Interface ของ Method เดิม ตามอุดมคติ ผู้เรียกใช้ส่วนใหญ่ควรเรียกใช้ Method ใหม่เพียงอันเดียว ถ้าผู้เรียกใช้ต้องเรียกใช้ Method ใหม่ทั้งสองอัน นั่นจะเพิ่มความซับซ้อน ซึ่งทำให้การแบ่งมีโอกาสน้อยที่จะเป็นความคิดที่ดี Method ใหม่จะโฟกัสกับสิ่งที่ทำมากขึ้น เป็นสัญญาณที่ดีถ้า Method ใหม่เป็น General-Purpose มากกว่า Method เดิม (กล่าวคือ คุณสามารถจินตนาการถึงการใช้มันแยกกันในสถานการณ์อื่น ๆ)
การแบ่งในรูปแบบที่แสดงใน Figure 9.3(c) มักไม่ค่อยสมเหตุสมผล เพราะมันทำให้ผู้เรียกใช้ต้องจัดการหลาย Method แทนที่จะเป็น Method เดียว เมื่อคุณแบ่งแบบนี้ คุณเสี่ยงที่จะได้ Method ที่ Shallow หลายอัน ดังใน Figure 9.3(d) . ถ้าผู้เรียกใช้ต้องเรียกใช้ Method ที่แยกกันแต่ละอัน โดยส่ง State ไปกลับมาระหว่างพวกมัน การแบ่งก็ไม่ใช่ความคิดที่ดี ถ้าคุณกำลังพิจารณาการแบ่งแบบใน Figure 9.3(c) คุณควรตัดสินโดยดูว่ามันทำให้สิ่งต่าง ๆ ง่ายขึ้นสำหรับผู้เรียกใช้หรือไม่
นอกจากนี้ยังมีสถานการณ์ที่ระบบสามารถทำให้เรียบง่ายขึ้นโดยการรวม Method เข้าด้วยกัน ตัวอย่างเช่น การรวม Method อาจแทนที่ Method ที่ Shallow สองอันด้วย Method ที่ Deep หนึ่งอัน มันอาจกำจัด Duplication ของโค้ด มันอาจกำจัด Dependency ระหว่าง Method เดิมหรือ Data Structure ระดับกลาง มันอาจส่งผลให้ Encapsulation ดีขึ้น เพื่อให้ความรู้ที่เคยอยู่ในหลายที่ถูกแยกไว้ในที่เดียว หรือมันอาจส่งผลให้ Interface เรียบง่ายขึ้น ดังที่ได้พูดถึงใน Section 9.2 .
Red Flag: Conjoined Methods (ธงแดง: เมธอดที่เชื่อมติดกัน)
ควรเป็นไปได้ที่จะเข้าใจแต่ละ Method ได้อย่างอิสระ ถ้าคุณไม่สามารถเข้าใจ Implementation ของ Method หนึ่งได้โดยไม่ต้องเข้าใจ Implementation ของอีก Method หนึ่ง นั่นคือธงแดง ธงแดงนี้สามารถเกิดขึ้นในบริบทอื่นได้เช่นกัน: ถ้าโค้ดสองส่วนถูกแยกจากกันทางกายภาพ แต่แต่ละส่วนสามารถเข้าใจได้โดยการดูอีกส่วนเท่านั้น นั่นคือธงแดง
9.9 Conclusion (บทสรุป)
การตัดสินใจว่าจะแบ่งหรือรวม Module ควรอยู่บนพื้นฐานของความซับซ้อน ให้เลือกโครงสร้างที่ให้ Information Hiding ที่ดีที่สุด, Dependency น้อยที่สุด, และ Interface ที่ลึกที่สุด