Different Layer, Different Abstraction (ชั้นต่างๆ หนึ่งความหลากหลายในการสรุป)

ระบบซอฟต์แวร์ประกอบด้วยหลายชั้น ซึ่งชั้นที่สูงกว่าใช้สิ่งอำนวยความสะดวกที่ชั้นต่ำกว่าให้ไว้ ในระบบที่ออกแบบได้ดี แต่ละชั้นจะให้การสรุป (abstraction) ที่ต่างจากชั้นข้างบนและข้างล่าง ถ้าคุณติดตามการดำเนินการ (operation) ครั้งหนึ่งขณะที่เคลื่อนไปมาผ่านชั้นต่างๆ ด้วยการเรียกเมธอด การสรุปจะเปลี่ยนแปลงไปในแต่ละการเรียกเมธอด ตัวอย่างเช่น:

  • ในระบบไฟล์ ชั้นบนสุดใช้ abstraction ของไฟล์ ไฟล์ประกอบด้วย Array ความยาวแปรผันของ byte ซึ่งสามารถอัปเดตได้โดยการอ่านและเขียนช่วง byte ที่มีความยาวแปรผัน ชั้นต่ำกว่าในระบบไฟล์ใช้ Cache ในหน่วยความจำ สำหรับ disk block ที่มีขนาดคงที่ ผู้เรียก (caller) สามารถสันนิษฐานได้ว่า block ที่ใช้บ่อยจะอยู่ในหน่วยความจำ ซึ่งจะเข้าถึงได้อย่างรวดเร็ว ชั้นต่ำสุดประกอบด้วย device driver ซึ่ง move block ระหว่าง secondary storage device และหน่วยความจำ
  • ในโปรโตคอล Transport ของเครือข่าย เช่น TCP abstraction ที่ชั้นบนสุดให้ไว้คือ stream ของ byte ที่ส่งมาอย่างน่าเชื่อถือจากเครื่องหนึ่งไปยังเครื่องอื่น ระดับนี้สร้างขึ้นจาก level ต่ำกว่าที่ transmit packet ขนาดที่จำกัดระหว่างเครื่องต่างๆ บนพื้นฐาน best-effort: packet ส่วนใหญ่จะส่งถึงสำเร็จ แต่บาง packet อาจหายหรือส่งในลำดับที่ผิดก็ได้

ถ้าระบบมีชั้นที่อยู่ติดกันและมี abstraction ที่คล้ายกัน นี่คือสัญญาณเตือน (red flag) ที่บ่งชี้ปัญหาในการแยกชั้น (class decomposition) บทนี้พูดถึงสถานการณ์ที่เกิดเหตุนี้ปัญหาที่เกิดขึ้น และวิธีการ refactor เพื่อกำจัดปัญหา

7.1 Pass-through methods (เมธอดผ่านสิ่งเร้า)

เมื่อชั้นที่อยู่ติดกันมี abstraction ที่คล้ายกัน ปัญหามักจะปรากฏออกมาในรูปแบบของ pass-through method Pass-through method คือเมธอดที่ทำอะไรไม่มากนอกจากการเรียกเมธอดอื่น signature ของเมธอดที่เรียกจะคล้ายหรือเหมือนกับ signature ของเมธอดที่เรียก (calling method) ตัวอย่างเช่น โปรเจกต์นักเรียนที่ implement GUI text editor มีคลาสที่ประกอบด้วย pass-through method เกือบทั้งหมด นี่คือส่วนจากคลาสนั้น:

public class TextDocument ... {
        private TextArea textArea;
        private TextDocumentListener listener;
        ...
        public Character getLastTypedCharacter() {
                return textArea.getLastTypedCharacter();
        }
        public int getCursorOffset() {
                return textArea.getCursorOffset();
        }
        public void insertString(String textToInsert,
int offset) {
                textArea.insertString(textToInsert, offset);
        }
        public void willInsertString(String stringToInsert, int offset) {
                if (listener != null) {
                     listener.willInsertString(this, stringToInsert, offset);
                }
        }
        ...
}

13 จาก 15 public method ในคลาสนั้นเป็น pass-through method

img Red Flag: Pass-Through Method img

Pass-through method คือเมธอดที่ไม่ทำอะไรเลยนอกจากการส่ง argument ไปให้เมธอดอื่น มักจะมี API ที่เหมือนกับ pass-through method นี่มักบ่งบอกว่า ไม่มีการแบ่งความรับผิดชอบอย่างชัดเจน (clean division of responsibility) ระหว่างคลาส

Pass-through method ทำให้คลาสตื้น (shallow): พวกมันเพิ่มความซับซ้อนของ interface ของคลาส ซึ่งเพิ่มความซับซ้อน แต่ไม่เพิ่ม functionality รวมของระบบ จาก 4 method ข้างบน มีเพียง method สุดท้ายเท่านั้นที่มี functionality แม้ที่นั่นก็ trivial เท่านั้น: เมธอดตรวจสอบความถูกต้อง (validity) ของตัวแปรหนึ่ง Pass-through method ยังสร้าง dependency ระหว่างคลาส: ถ้า signature เปลี่ยนแปลงสำหรับเมธอด insertString ใน TextArea แล้ว เมธอด insertString ใน TextDocument จะต้องเปลี่ยนตามด้วย

Pass-through method บ่งบอกว่า มีความสับสนเกี่ยวกับการแบ่งความรับผิดชอบระหว่างคลาส ในตัวอย่างข้างบน คลาส TextDocument มี method insertString แต่ functionality ของการแทรกข้อความ implement อยู่ใน TextArea ทั้งหมด โดยปกติสิ่งนี้ไม่ใช่ความคิดที่ดี: interface ของ piece of functionality ควรจะอยู่ในคลาสที่ implement functionality นั้น เมื่อคุณเห็น pass-through method จากคลาสหนึ่งไปยังอีกคลาส ให้พิจารณาคลาสทั้งสอง และถามตัวเองว่า "ฟีเจอร์และ abstraction ไหนที่แต่ละคลาสนี้ควรรับผิดชอบ?" คุณอาจจะสังเกตว่า มี overlap ในความรับผิดชอบระหว่างคลาส

วิธีแก้ปัญหาคือ refactor คลาสเพื่อให้แต่ละคลาสมี distinct และ coherent set ของความรับผิดชอบ Figure 7.1 แสดงวิธีหลายๆ วิธีในการทำสิ่งนี้ วิธีหนึ่ง แสดงใน Figure 7.1(b) คือการ expose lower level class โดยตรงให้กับผู้เรียกของ higher level class ลบออกทั้งหมด responsibility สำหรับฟีเจอร์จาก higher level class วิธีอื่นคือการ redistribute functionality ระหว่างคลาส ดังใน Figure 7.1(c) สุดท้าย ถ้าไม่สามารถแยก (disentangle) คลาส วิธีแก้ปัญหาที่ดีที่สุดอาจจะเป็นการรวมกัน (merge) ดังใน Figure 7.1(d)

ในตัวอย่างข้างบน มีสามคลาสที่มี intertwined responsibility: TextDocument TextArea และ TextDocumentListener นักเรียนกำจัด pass-through method โดยการ move method ระหว่างคลาส และยุบ (collapse) สามคลาสลงเหลือเพียงสองคลาส ซึ่งมี responsibility ที่แยกความแตกต่างได้อย่างชัดเจนมากขึ้น

7.2 When is interface duplication OK? (เมื่อไรการจำลอง Interface ถือว่าโอเค)

การมี method ที่มี signature เดียวกันนั้นไม่ใช่สิ่งที่ไม่ดีเสมอไป สิ่งที่สำคัญคือแต่ละ method ใหม่ควรมี significant functionality สำหรับ Pass-through method นั้นไม่ดีเพราะว่าพวกมันไม่มี functionality ใหม่เลย

ตัวอย่างหนึ่งที่มีประโยชน์สำหรับเมธอดในการเรียกเมธอดอื่นที่มี signature เดียวกันคือ dispatcher Dispatcher คือเมธอดที่ใช้ argument ของมัน เพื่อเลือกหนึ่งในหลายๆ เมธอดอื่นเพื่อเรียก จากนั้นมันจะส่ง argument ส่วนใหญ่หรือทั้งหมดไปให้เมธอดที่เลือก signature สำหรับ dispatcher มักจะเหมือนกับ signature สำหรับเมธอดที่เรียก ถึงแม้อย่างนั้น dispatcher ก็ให้ functionality ที่มีประโยชน์: มันเลือกว่าเมธอดอื่นไหนที่ควรจะทำงานสำหรับแต่ละงาน

img

Figure 7.1: Pass-through method ใน (a) คลาส C1 มี pass-through method สามตัว ซึ่งไม่ทำอะไรนอกจากการเรียก method ที่มี signature เดียวกันใน C2 (แต่ละสัญลักษณ์แทน particular method signature) Pass-through method สามารถขจัดได้โดยการให้ caller ของ C1 เรียก C2 โดยตรง ดังใน (b) โดยการ redistribute functionality ระหว่าง C1 และ C2 เพื่อหลีกเลี่ยงการเรียกระหว่างคลาส ดังใน (c) หรือโดยการรวม (combine) คลาส ดังใน (d)

ตัวอย่างเช่น เมื่อ Web server ได้รับ HTTP request ที่เข้ามา (incoming) จาก Web browser มันเรียก dispatcher ที่ตรวจสอบ URL ในการร้องขอ (incoming request) และเลือก method เฉพาะเพื่อจัดการกับการร้องขอ บาง URL อาจจัดการโดยการส่งเนื้อหา (contents) ของไฟล์บนดิสก์ อื่นๆ อาจจัดการโดยการเรียก procedure ในภาษาเช่น PHP หรือ JavaScript กระบวนการส่ง (dispatch process) สามารถค่อนข้างซับซ้อนได้ และมักจะขับเคลื่อนโดยชุดกฎ (set of rules) ที่ match กับ incoming URL

เป็นเรื่องถูกต้อง (fine) สำหรับเมธอดหลายตัวที่มี signature เดียวกัน ตราบใดที่แต่ละตัวให้ useful และ distinct functionality เมธอดที่เรียกโดย dispatcher มีคุณสมบัตินี้ ตัวอย่างอื่นคือ interface ที่มี implementation หลายตัว เช่น disk driver ในระบบปฏิบัติการ driver แต่ละตัวให้ support สำหรับ disk type ที่ต่างกัน แต่พวกมันทั้งหมดมี interface เดียวกัน เมื่อเมธอดหลายตัวให้ implementation ต่างๆ ของ interface เดียวกัน มันจะลดภาระ (cognitive load) ครั้งหนึ่งที่คุณ work กับ method หนึ่งใน method เหล่านี้ มันจะง่ายขึ้นในการ work กับอื่นๆ เนื่องจากคุณไม่ต้อง learn interface ใหม่ Method เหล่านี้มักจะอยู่ในชั้นเดียวกัน และพวกมัน ไม่เรียก method อื่นๆ

7.3 Decorators (ตัวแต่งแต้ม)

Decorator design pattern (เรียกอีกอย่างว่า "wrapper") คือ pattern ที่สนับสนุน API duplication ระหว่างชั้น Decorator object ใช้ object ที่มีอยู่แล้ว และยืดออกไป (extends) functionality ของมัน มันให้ API ที่คล้ายหรือเหมือนกับ object อยู่ใต้ (underlying object) และ method ของมันเรียก method ของ object อยู่ใต้ ในตัวอย่าง Java I/O จาก Chapter 4 คลาส BufferedInputStream คือ decorator: ได้รับ InputStream object มันให้ API เดียวกัน แต่นำเสนอ buffering ตัวอย่างเช่น เมื่อ method read ของมันเรียกเพื่ออ่านอักขระตัวเดียว มันเรียก read บน InputStream ที่อยู่ใต้ เพื่ออ่านบล็อกที่ใหญ่มาก และ save อักขระพิเศษเพื่อตอบสนอง (satisfy) การเรียก read ในอนาคต ตัวอย่างอื่นเกิดขึ้นในระบบ windowing: Window class implement รูปแบบง่ายๆ ของ window ที่ไม่เลื่อนได้ (not scrollable) และ ScrollableWindow class decorate Window class โดยเพิ่มแถบเลื่อน (scrollbars) แนวนอนและแนวตั้ง

แรงจูงใจสำหรับ decorator คือการแยก special-purpose extensions ของคลาส จาก generic core ที่เป็นมากขึ้น อย่างไรก็ตาม decorator class มีแนวโน้มที่จะตื้น: พวกมันนำเสนอจำนวนมากของ boilerplate สำหรับจำนวนเล็กน้อยของ new functionality Decorator class มักมี pass-through method จำนวนมาก มันเป็นเรื่องง่ายในการใช้ pattern decorator มากเกินไป สร้าง class ใหม่สำหรับทุก feature ใหม่เล็กน้อย นี่ส่งผลให้เกิด explosion ของ shallow class เช่นตัวอย่าง Java I/O

ก่อน create decorator class ให้พิจารณา alternative เช่นต่อไปนี้:

  • คุณสามารถเพิ่ม new functionality โดยตรงไปยัง underlying class หลีกเลี่ยงการ create decorator class? สิ่งนี้มีความหมายถ้า new functionality ค่อนข้าง general-purpose หรือถ้ามันมีความเกี่ยวข้องทางตรรกะ (logically related) กับ underlying class หรือถ้าส่วนใหญ่ของการใช้ underlying class จะ use new functionality ด้วย ตัวอย่างเช่น เกือบทุกคน (virtually everyone) ที่สร้าง Java InputStream จะ create BufferedInputStream ด้วย และ buffering เป็น natural part ของ I/O ดังนั้นคลาสเหล่านี้ควรรวมกัน
  • ถ้า new functionality เพื่อใช้เฉพาะ (specialized) สำหรับ use case ที่เฉพาะเจาะจง มันจะมีความหมายในการ merge มันกับ use case หรือไม่ แทนที่จะ create separate class?
  • คุณสามารถ merge new functionality กับ existing decorator แล้ว ไม่ create decorator ใหม่? สิ่งนี้จะส่งผลให้เกิด single deeper decorator class แทนที่จะ multiple shallow one
  • สุดท้าย ถามตัวเองว่า new functionality ต้องการ wrap existing functionality จริงๆ: คุณสามารถ implement มันเป็น stand-alone class ที่เป็น independent จากคลาส base? ในตัวอย่าง windowing แถบเลื่อน (scrollbar) อาจจะ implement แยกออกจาก main window ได้ โดยไม่ wrap functionality ที่มีอยู่แล้วทั้งหมดของมัน

บ่อยครั้งที่ decorator มีความสำคัญ แต่โดยปกติจะมี alternative ที่ดีกว่า

7.4 Interface versus implementation (Interface เทียบกับ Implementation)

การใช้งานอื่นของกฎ "ชั้นต่างๆ ต่างๆ abstraction" คือว่า interface ของคลาสควรจะต่างจาก implementation: การแสดงแทน (representations) ที่ใช้ภายในควรจะต่างจาก abstraction ที่ปรากฏใน interface ถ้าทั้งสองมี abstraction ที่คล้ายกัน แล้วคลาสอาจจะไม่ลึก (deep) มากนัก ตัวอย่างเช่น ในโปรเจกต์ text editor ที่พูดถึงใน Chapter 6 ทีมส่วนใหญ่ implement text module ในเงื่อนไขของ line ของข้อความ ด้วยแต่ละ line เก็บแยกกัน บาง team นอกจากนี้ยังได้ออกแบบ API สำหรับ text class รอบ ๆ line ด้วย method เช่น getLine และ putLine อย่างไรก็ตาม สิ่งนี้ทำให้ text class ตื้นและ awkward ในการใช้ ในโค้ด user interface ระดับที่สูงกว่า มันเป็นเรื่องธรรมชาติในการแทรก text ตรงกลางของ line (เช่นเมื่อผู้ใช้พิมพ์) หรือลบช่วง text ที่ขยายไปถึง line ด้วย line-oriented API สำหรับ text class caller ถูกบังคับให้แยก (split) และเข้าร่วม (join) line เพื่อ implement user-interface operation โค้ดนี้ไม่ใช่เรื่องเล็กน้อย และมันถูกซ้ำกัน (duplicated) และกระจายไป (scattered) ตัวอักษร (across) ของ implementation ของ user interface

Text class ได้ง่ายขึ้นมากในการใช้ เมื่อพวกมันให้ character-oriented interface เช่น insert method ที่แทรก (insert) arbitrary string ของ text (ซึ่งอาจรวม newline) ที่ arbitrary position ใน text และ delete method ที่ลบ text ระหว่าง arbitrary position สองตำแหน่งใน text ภายใน text ยังคงแสดงแทนในเงื่อนไขของ line ซึ่ง Character-oriented interface encapsulate ความซับซ้อนของ line splitting และ joining ข้างใน text class ซึ่งทำให้ text class ลึกขึ้นและทำให้โค้ด higher level ที่ใช้คลาส ง่ายขึ้น ด้วยวิธีการนี้ text API ค่อนข้างต่างจาก line-oriented storage mechanism; ความแตกต่างนี้แทน valuable functionality ให้โดยคลาส

7.5 Pass-through variables (ตัวแปรผ่านสิ่งเร้า)

อีกรูปแบบหนึ่งของ API duplication ระหว่างชั้นคือ pass-through variable ซึ่งเป็นตัวแปรที่ผ่านลงมา (passed down) ผ่านโซ่ยาวของ method Figure 7.2(a) แสดง example จาก datacenter service Command-line argument อธิบาย certificate เพื่อใช้สำหรับ secure communication ข้อมูลนี้เท่านี้เท่านั้นที่จำเป็นโดย low-level method m3 ซึ่ง call library method เพื่อ open socket แต่มันถูก pass ลงมาผ่าน method ทั้งหมดบน path ระหว่าง main และ m3 ตัวแปร cert ปรากฏใน signature ของแต่ละ intermediate method

Pass-through variable เพิ่มความซับซ้อน เพราะว่า พวกมันบังคับ intermediate method ทั้งหมดให้ตระหนักถึงการมีตัว แม้ว่า method ไม่มี use สำหรับตัวแปร นอกจากนี้ ถ้า variable ใหม่มาหา (comes into existence) (ตัวอย่างเช่น ระบบได้สร้างตั้งแต่แรกโดยไม่มี support สำหรับ certificate แต่คุณต่อมา decide ที่จะเพิ่ม support) คุณอาจต้อง modify interface และ method จำนวนมากเพื่อ pass variable ผ่านทั้งหมดของ relevant path

กำจัด pass-through variable สามารถเป็นความท้าทาย (challenging) ได้ วิธีการหนึ่งคือการ see ถ้ามี object ที่มีอยู่แล้วที่ share ระหว่าง topmost และ bottommost method เก่าแก่ที่สุด ใน datacenter service example ของ Figure 7.2 อาจจะมี object ที่มีข้อมูลอื่นๆ เกี่ยวกับ network communication ซึ่ง available ให้ทั้ง main และ m3 ถ้าเป็นเช่นนั้น main สามารถ store ข้อมูล certificate ใน object นั้น ดังนั้นมันไม่จำเป็นต้อง pass ผ่าน method intervening ทั้งหมด บน path ไปถึง m3 (ดู Figure 7.2(b)) อย่างไรก็ตาม ถ้า object เช่นนั้นมีอยู่ แล้ว มันอาจจะตัว pass-through variable (วิธีอื่นใด ที่ m3 ได้รับ access ไปถึงมัน?)

วิธีการอื่นคือการ store ข้อมูล global variable ดังใน Figure 7.2(c) สิ่งนี้หลีกเลี่ยงความจำเป็นในการ pass ข้อมูลจาก method ไปยัง method แต่ global variable เกือบจะสร้างปัญหาอื่นๆ เสมอ ตัวอย่างเช่น global variable ทำให้เป็นไปไม่ได้ที่จะสร้าง instance ใหม่เอี่ยมของระบบเดียวกันในกระบวนการเดียว เนื่องจาก access ถึง global variable จะขัดแย้ง มันอาจดูเหมือน unlikely ว่า คุณจะต้อง instance หลายตัวในการผลิต (production) แต่พวกมัน มักจะมีประโยชน์ใน testing

สารละลายที่ผมใช้บ่อยที่สุดคือการ introduce context object ดังใน Figure 7.2(d) Context store ทั้งหมดของ application ของ global state (สิ่งใดที่มิฉะนั้นจะเป็น pass-through variable หรือ global variable) ส่วนใหญ่ของ application มี variable หลายตัวใน global state ของพวกมัน แทนสิ่งต่างๆ เช่น option การกำหนดค่า (configuration) shared subsystem และ performance counter มี context object หนึ่งต่อ instance ของระบบ Context allow instance หลายตัวของระบบ coexist ในกระบวนการเดียว แต่ละตัวมี context ของตัวเอง

น่าเสียดายที่ context อาจจะจำเป็นในสถานที่จำนวนมาก ดังนั้นมันสามารถที่จะสร้าง pass-through variable เป็นไปได้ เพื่อ reduce จำนวน method ที่ต้องตระหนักถึง reference ไป context สามารถ save ใน object ส่วนใหญ่ของระบบ ใน example ของ Figure 7.2(d) คลาส ที่มี m3 store reference ไปถึง context เป็น instance variable ใน object ของ มัน เมื่อ object ใหม่ถูกสร้าง creating method retrieve reference ของ context จาก object ของมัน และ pass มันไปถึง constructor สำหรับ object ใหม่ ด้วยวิธีการนี้ context ใช้ได้ทุกที่ แต่มันเพียงแต่ปรากฏเป็นชัดแจ้ง argument ใน constructor

img

Figure 7.2: เทคนิคที่เป็นไปได้สำหรับการจัดการกับ pass-through variable ใน (a) cert ผ่านไป method m1 และ m2 แม้ว่าพวกมันไม่ใช้มัน ใน (b) main และ m3 มี shared access ไปยัง object ดังนั้นตัวแปรสามารถ store ที่นั่นแทนการ passing ผ่าน m1 และ m2 ใน (c) cert ถูก store เป็น global variable ใน (d) cert ถูก store ใน context object พร้อมกับข้อมูลระบบกว้าง (system-wide information) อื่นๆ เช่น timeout value และ performance counter; reference ไปถึง context ถูก store ใน object ทั้งหมดที่มี method ที่ต้อง access มัน

Context object unify handling ของข้อมูล system-global ทั้งหมดและ eliminate ความจำเป็นสำหรับ pass-through variable ถ้า variable ใหม่ต้อง add ได้มันสามารถ add ไปที่ context object; code ที่มีอยู่ไม่ได้รับผลกระทบ ยกเว้น constructor และ destructor สำหรับ context Context ทำให้มันง่ายในการ identify และ manage global state ของระบบ เนื่องจากมันทั้งหมด store ไว้ในสถานที่เดียว Context ยังเป็นสะดวก (convenient) สำหรับ testing: test code สามารถ change global configuration ของ application โดยการ modify field ใน context มันจะเป็น much harder ในการ implement การเปลี่ยน (change) เช่นนั้นถ้าระบบ used pass-through variable

Context อยู่ห่างจาก ideal solution เดิม ตัวแปร store ใน context มี disadvantage มากที่สุด (most) ของ global variable; ตัวอย่างเช่น มันอาจจะไม่ชัดเจนว่า ทำไม particular variable ที่มี หรือที่ไหน มันถูก used ไม่มี discipline context สามารถเปลี่ยนเป็น huge grab-bag ของข้อมูล (data) ที่สร้าง nonobvious dependency ตลอดทั้ง system Context ยังอาจสร้าง thread-safety issue; วิธี best ในการหลีกเลี่ยง problem คือสำหรับ variable ใน context จะเป็น immutable น่าเสียดาย ผม haven't พบ better solution กว่า context

7.6 Conclusion (บทสรุป)

แต่ละ piece ของ design infrastructure ที่เพิ่ม (add) ไปยัง system เช่น interface argument function class หรือ definition เพิ่ม complexity เนื่องจาก developer ต้อง learn เกี่ยวกับ element นี้ เพื่อให้ element นี้ provide net gain เทียบกับ complexity มันต้อง eliminate complexity บาง ที่จะ present ในการ absence ของ design element มิฉะนั้น คุณมี better off implement system ไม่มี particular element นั้น ตัวอย่างเช่น class สามารถลด complexity โดยการ encapsulate functionality ดังนั้น user ของ class needn't aware ของมัน

กฎ "ชั้นต่างๆ ต่างๆ abstraction" เป็นเพียง application ของ idea นี้: ถ้า different layer มี abstraction เดียวกัน เช่น pass-through method หรือ decorator แล้ว มี good chance ที่พวกมันหา't provided enough benefit เพื่อ compensate สำหรับ additional infrastructure พวกมัน represent ในทำนอง เดียวกัน pass-through argument require ของ method หลายตัวในการมี awareness ของการมีตัว (which add ไปยัง complexity) โดยไม่มี contribute additional functionality