DEV Community

Chakrit Likitkhajorn
Chakrit Likitkhajorn

Posted on

Composition over Inheritance: จงขี้เกียจจัดกลุ่ม

หลายๆ ครั้งที่ผมอ่านโค้ดเบสที่เขียนด้วยภาษาที่มีแต่ Object-oriented paradigm ให้ใช้ผมมักจะเจอปัญหาแบบนี้เสมอ

"ทำไม Class นี้ถึง Inherit จากสิ่งนี้ละ มันไม่ดูจะคนละคอนเซปต์เลยนะ"
"อ้อ ตอนแรกมันก็คล้ายๆ กันแหละ พอทำไปทำมามันไม่เหมือนกัน"

ปัญหาที่มักจะเจอบ่อยของการวางโครงสร้างในแนวคิด OOP


สมมติคุณทำระบบบริหารงานในองค์กรขึ้นมาตัวหนึ่ง โดยเริ่มจากระบบบัญชี, ระบบจัดซื้อ และระบบงานขาย

คุณมีเอกสารชื่อ Invoice (ใบแจ้งหนี้), Purchase Order (ใบสั่งซื้อ), Quotation (ใบเสนอราคา)

ทั้งหมดนี้ลูกค้าบอกว่า ต้องให้ผู้บริหาร Approve ได้

ในเวลานี้ทั้งระบบคุณมีเอกสารแค่เพียงสามอันนี้

คุณเขียนโค้ดประมาณนี้

class Invoice: Document
{
    public void Approve(int approverId)
    { }
    // Other methods
}

class PurchaseOrder: Document
{
    public void Approve(int approverId)
    { }
    // Other methods
}

public class Quotation: Document
{
    public void Approve(int approverId)
    { }
    // Other methods
}

หลังจากนี้ Requirement เพิ่มถัดมาเป็นระบบใบเบิกเงินสำรองจ่าย Travel expense ซึ่งก็ต้องอนุมัติอีก

คุณรอจนมันมี 3 กรณีแล้ว คุณรอจนมีกรณีที่ 4 มั่นใจมากว่ามันมีโค้ดซ้ำ จนคุณกำหนดว่า ทั้งหมดนี้เป็นเอกสาร และเอกสารต้องสามารถอนุมัติได้

public abstract class Document
{
    public void Approve(int approverId)
    {
       // Approve logic
       // 30 Lines 
    }
}
class Invoice: Document
{
    // Other methods
}

class PurchaseOrder: Document
{
    // Other methods
}

public class Quotation: Document
{
    // Other methods
}

class TravelExpense: Document
{
    // Other methods
}

แต่ทว่า เราระบบถัดมาที่ต้องพัฒนาคือใบเสร็จรับเงิน ซึ่ง ใบเสร็จรับเงินจะออกทันทีเมื่อได้รับเงิน ไม่ต้องให้ใครอนุมัติ

อ้าว เวรกรรม!!! แต่ Class Document ของเรามีความสามารถในการอนุมัติไปแล้วนี่หว่า ทำไงดีเนี่ย

ตอนนี้เรามีสองทางคือ

  1. บอกว่าใบเสร็จรับเงินไม่ใช่ เอกสาร ไม่ใช่ Document ไม่ต้อง Inherit ซึ่งถ้าทำแบบนี้พอมีคนใหม่เข้าทีมมา ก็จะงงว่าอ้าว ทำไมใบเสร็จรับเงินไม่ใช่ Document ล่ะ มันก็เป็นเอกสารนี่หว่า เราได้แต่ตอบว่า "อ้อ เพราะมันไม่ต้อง Approve" ซึ่งก็ชวนงงน่าดู
  2. บอกว่าใบเสร็จรับเงินเป็น Document ที่สามารถ Approve ได้ แต่พอสั่ง Approve แล้วจะไม่ทำอะไรทั้งนั้น เป็น no-op ซึ่งก็ชวนงงไปอีกแบบ มันมี Method Approve แต่ไม่สามารถ Approve ได้ในทางปฏิบัติ...

ซึ่งทั้งสองอย่างพอมองจากมุมการออกแบบ มันดูแปร่งๆ ทั้งคู่


ผมชอบบล็อกหนึ่งที่เขียนถึงปัญหาที่มักเจอในโค้ดใน OOP

The real world doesn’t always break down into neat categories with well-defined properties. For example, suppose you create a class hierarchy that represents the animal kingdom. Over here, you’ve got your reptiles — cold blooded, scaly, egg-laying and so on. Over there you’ve got your mammals — warm blooded, furry, bearing live young. And so on with birds, amphibians, invertebrates, etc.
And then along comes a platypus, which doesn’t seem to fit into any of your categories. What do you do? Do you create a brand new category, or do you rethink your entire classification scheme? Either approach can have significant costs in terms of effort and program complexity.

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

ผมเป็นคนนึงที่ตบตีกับ Object-oriented paradigm มาเยอะมากจนเห็นปัญหาเรื่องนี้มาไม่รู้กี่ครั้ง เพราะ Object-oriented โดยเฉพาะเวลาเราพยายามจะใช้ Inheritance ในการจัดกลุ่มของ Object ต่างๆ

หลายๆ ครั้งผมจะเจอ

"ทำไม Class นี้ถึงต้อง Subclass อันนี้มา มันดูคนละเรื่องกันเลยนะ แปร่งมากๆ"
"อ้อ มันเคยคล้ายๆ กัน แต่ Requirement change ทำให้มันกลายเป็นคนละเรื่องกัน แล้วรื้อก็รื้อไม่ไหวแล้ว พี่ช่วยคุยกับ Stakeholder หน่อยสิว่าจะรื้อใหม่เนี่ย หาเวลาให้ผมหน่อย"

ผมเชื่อว่าหลายๆ คนเคยเจอสถานการณ์แบบนี้แน่ๆ ครับ

ความเชื่อผิดๆ เกี่ยวกับปัญหานี้

ผมพบว่าเวลาเจอปัญหานี้ มักจะมีความเชื่อที่ผิดๆ สองอย่าง

ข้อแรก หลายคนเชื่อว่าปัญหานี้สามารถโดยการคิดให้ดีคิดให้เยอะตอนดีไซน์ตอนออกแบบ คิดให้ดีจะได้ไม่จัดกลุ่มผิด

ความเข้าใจผิดของความเชื่อนี้คือ เราสามารถคาดเดาได้หรือสามารถสร้าง Category สร้าง Class structure ที่รองรับอนาคตได้

แต่ถามว่าเราสามารถทำได้จริงเหรอ ยกตัวอย่างในโลกความจริงเลย ตัว Platypus ถึงทุกวันนี้ก็ยังเป็นที่ถกเถียงว่ามันควรจะอยู่ในกลุ่มไหน ต่อให้คิดจนหัวแตก เราก็ไม่สามารถบอกได้ว่า Platypus ควรจะซับคลาสจากสัตว์เลี้ยงลูกด้วยนมหรือสัตว์เลื้อยคลาน โปรแกรมเมอร์จะคิดออกได้ยังไง นักชีววิทยายังถกเถียงกันอยู่เลย

เช่นกันในหลายๆ Business domain ก็จะมีเหตุการณ์ที่แม้แต่ผู้เชี่ยวชาญใน Domain นั้นๆ ยังไม่สามารถจัดกลุ่มได้

แล้วคุณจะไปมโนว่าถ้าฉันคิดดีๆ คิดละเอียดตอนออกแบบ ฉันจะจัดกลุ่มได้อย่างถูกต้องครอบคลุมถึงอนาคตได้ไม่มีทางต้องรื้อ ในความเห็นผม ผมว่ามันยากเกินไปและทำไม่ได้จริง

ความเชื่อผิดๆ อีกอย่างคือ ปัญหานี้เป็นปัญหาคู่กับ Object-oriented paradigm ที่ยังไงก็เจอ หลีกเลี่ยงไม่ได้

จริงๆ ปัญหานี้เราสามารถเลี่ยงได้นะครับ

Composition over Inheritance

มันมีหลักการหนึ่งที่น่าสนใจชื่อ Composition over inheritance

หลักการนี้ ถ้าพูดสั้นๆ คือ "เวลาไม่แน่ใจ ให้ใช้ Composition ไว้ก่อน"

เวลาที่เราใช้หลักนี้ แทนที่เราจะคิดว่า Invoice, PurchaseOrder, Quotation มันคือเอกสาร และเอกสาร สามารถ Approve ได้ เราจะคิดว่า

"Invoice, PurchaseOrder, และ Quotation คืออะไรซักอย่างหนึ่งที่สามารถ Approve ได้"

จะไม่มีคำว่า "เอกสาร" โผล่ขึ้นมาในระบบเลยในตอนนี้

หากทั้งสามอย่างนี้มี Logic การ Approve เหมือนกัน แล้วเราจะหลีกเลี่ยงโค้ดซ้ำ แทนที่เราจะสร้าง Base class ทำให้เราต้องตั้งชื่อ "กลุ่ม" ของสามสิ่งนี้

เราจะเขียนแบบนี้แทน เพื่อหลีกเลี่ยงการจัดกลุ่ม

class Invoice : IApprovable
{
    public void Approve(int approverId) 
    { 
        new StandardApprover().Approve(approverId, this)
    }

    // Other methods
}

class PurchaseOrder : IApprovable
{
    public void Approve(int approverId) 
    { 
        new StandardApprover().Approve(approverId, this)
    }

    // Other methods
}

public class Quotation : IApprovable
{
    public void Approve(int approverId) 
    { 
        new StandardApprover().Approve(approverId, this)
    }

    // Other methods
}

public class StandardApprover
{
    public void Approve(int approverId, IApprovable approveItem)
    {
        // Approve logic
    }
}

สิ่งที่ต่างกันอย่างมากในเชิงคอนเซปต์คือ แทนที่เราจะจัดกลุ่มว่ามันเป็นอะไร เราแค่บอกว่ามันมีความสามารถอะไร แต่ยังไม่รีบจัดกลุ่ม

การจัดกลุ่มแบบ Composition คือเราแค่บอกว่า Object พวกนี้มีความสามารถ A,B,C,D นะ แต่ไม่ได้บอกว่ามันเป็นของประเภทเดียวกันมี Base class เดียวกัน ไม่ยอมบอก ไม่ยอมจัดกลุ่ม จัดกลุ่มให้ช้าที่สุดเท่าที่จะช้าได้ ขี้เกียจจัดกลุ่มให้มากที่สุดเท่าที่จะมากได้

เราจะจัดกลุ่มต่อเมื่อมั่นใจแล้วจริงๆ มั่นใจแล้วสุดๆ เท่านั้น ถึงจะย้ายมาเป็น Inheritance และใช้ Base class, Subclass เพื่อจัดกลุ่มของมัน

นี่คือหลักการ Composition over inheritance ครับ

พูดง่ายๆ คือ ถ้าไม่มั่นใจอย่างสุดๆๆๆๆๆๆ จริงๆ เราจะใช้ Composition ไว้ก่อน จะไม่สร้าง Base class และ Subclass เด็ดขาด จนกว่าจะมั่นใจอย่างสุดๆๆๆๆๆๆ แล้วเท่านั้น

ถามว่าการไม่มีกลุ่มดียังไง?

ต่อมาถ้าเราดันมี Receipt หรือใบเสร็จรับเงิน ที่เป็นเอกสารเหมือนกับตัวอื่นๆ แต่ไม่ต้อง Approve

เราก็แค่ไม่ต้องแปะ IApprovable จบ

ถ้าเทียบกับอันเก่าที่จะต้องรื้อโค้ดเลยว่าตกลงจะเอายังไงกับ Document ดี เอายังไงกับ Approval ใน Receipt ดี จะให้เป็น no-op มั้ย หรือจะรื้อใหม่ว่า Document จะไม่ต้องมี Method Approve แล้ว

จะเห็นว่าการออกแบบยังงี้มันเปิดทางให้มีทางหนีทีไล่ได้มากกว่าเยอะมาก

สิ่งที่เราต้องรู้คือการเขียนโปรแกรมแบบ Object-oriented มันมีเรื่องของวัตถุในชีวิตจริง

เวลาที่คุณจะจัดกลุ่มของสิ่งของ คุณต้องหาวัตถุที่มีรูปธรรมมาตั้งชื่อ

ในตัวอย่าง คุณมี Invoice, PurchaseOrder

ถ้าคุณจะสร้าง Base class ให้สองสิ่งนี้ คุณต้องหาวัตถุจริงมาตั้งชื่อ คุณคงจะตั้งชื่อว่า class SomethingSomething แล้วก็บอกว่า class Invoice: SomethingSomething ไม่ได้กระมัง คนคงด่าแย่

แต่ถ้าคุณรีบจัดกลุ่มให้มัน คุณก็ต้องตั้งชื่อว่ามันคือเอกสาร ต้องกำหนดว่าเอกสารทำอะไรได้บ้าง มี Method อะไรบ้าง

ซึ่งสิ่งที่มันต้องมีในเวลานี้ อาจจะไม่ใช่สิ่งที่มันต้องมีในอนาคต

ในอนาคตเมื่อมีของที่ในเชิงรูปธรรม มันดูเป็น "เอกสาร" แต่มันไม่ต้องการความสามารถบางอย่างที่เรา Implement ไปแล้วในคลาส "เอกสาร" มันก็จะวุ่นวาย

ดังนั้นทางแก้ที่ผมพบว่าเวิร์คที่สุดคือ เราจะรีบเอาคำว่า "เอกสาร" เข้ามาในระบบทำไม

ไปคิดทีหลัง คิดให้ช้าที่สุด ตอนที่มีข้อมูลมากที่สุด ค่อยเอาคำนี้เข้ามาในระบบดีกว่า

แต่หลายคนไม่รู้จะแก้โค้ดซ้ำโดยไม่ต้องเอาคำว่า "เอกสาร" เข้ามาในระบบได้อย่างไร

นั่นแหละคือตัวอย่างที่ผมแสดง

เราประกอบของโค้ดด้วยคอนเซปต์ว่า "อะไรไม่รู้ซักอย่างที่ Approve ได้"

ก็จะสามารถแก้โค้ดได้โดยไม่ต้องเอาคำว่า "เอกสาร" เข้ามาในระบบ

ซึ่งมันเปิดทางหนีทีไล่ให้เราได้ดีกว่าการพยายามรีบจัดกลุ่มเยอะเลย

ถ้าจัดกลุ่มผิดไปแล้วทำยังไง

นอกจากข้อดีที่ผ่านมาที่เล่าไปแล้ว การยึดหลัก Composition over inheritance ทำให้ต่อให้เราจัดกลุ่มผิดก็มีทางหนีทีไล่ที่ง่ายขึ้นด้วย

สมมติเราเกิดมั่นใจละว่า Document มันต้องมี Approve แน่ๆ เป็นความมั่นใจผิดๆ แหละ แต่มั่นใจไปละ จัดกลุ่มสร้าง Base class, Sub class ดีกว่า

เวลาเราเริ่มต้นจาก Composition over inheritance เราจะได้โค้ดประมาณนี้

public abstract class Document: IApprovable
{
    public void Approve(int approverId) 
    { 
        new StandardApprover().Approve(approverId, this)
    }
}

interface IApprovable
{
    void Approve(int approverId);
}

class Invoice : Document
{
    // Other methods
}

class PurchaseOrder : Document
{
    // Other methods
}

public class Quotation : Document
{
    // Other methods
}

public class StandardApprover
{
    public void Approve(int approverId, IApprovable approveItem) 
    {
        // Approve logic
        // 30 Lines
    }    
}

สุดท้ายโลกความจริงบอกเรามั่นใจผิด เราก็ไปเจอ Receipt ที่เป็นเอกสารที่ไม่ต้อง Approve

เราก็แค่ต้องก็อปปี้โค้ดเพียง 1 บรรทัดจาก Base class ที่เขียนว่า new StandardApprover().Approve(approverId, this) ไปแปะไว้ทุกคลาสเก่า

และ Logic ต่างๆ ในแต่ละคลาส ก็ยังไม่ซ้ำ ยังรวมที StandardApprover อยู่

จะเห็นว่าเวลาเราเริ่มจาก Composition แล้วพอมั่นใจมากๆ ค่อยไป Inheritance การถอยหลังกลับเวลาคิดผิด ก็ง่ายกว่าด้วยนะครับ

ซึ่งถ้าเทียบกับเราไป Inheritance เลย แบบตัวอย่างแรกสุดที่ผมทำให้ดูว่าทุกอัน Subclass document เราต้องก็อปโค้ด 30 บรรทัดไปแปะในแต่ละ Class ที่เกี่ยวข้อง!!!

มันทำใจยากน่าดูเลยนะ แถมโค้ดซ้ำกันอย่างโหดร้ายมากๆ

Lesson learned

ผมขอสรุปสิ่งที่ผมอยากสื่อดังนี้

  1. เจอโค้ดซ้ำแล้ว คิดอะไรไม่ออกให้ใช้ Composition ไว้ก่อน อย่าเอะอะสร้าง Base class, สร้าง Sub class ถ้าคุณใช้ Base กับ Subclass แล้วเนี่ย ถอยยากมาก ในขณะที่ถ้าคุณใช้ Composition ถอยง่ายกว่ามาก
  2. การใช้ Composition คือการจัดกลุ่มคลาสตามความสามารถของมัน มากกว่าการที่มันเป็นอะไร แทนที่เราจะบอกว่า Platypus คือสัตว์เลี้ยงลูกด้วยนม เราบอกว่า Platypus คืออะไรซักอย่างที่เลือดอุ่น แทนที่เราจะบอกว่า Invoice คือเอกสาร เราบอกว่า Invoice คืออะไรซักอย่างที่สามารถ Approve ได้
  3. เราไปจัดกลุ่มของ Base class, Sub class ก็ต่อเมื่อเรามั่นใจสุดๆๆๆๆๆๆๆๆๆ แล้วว่ามันเป็นของประเภทเดียวกัน จงขี้เกียจจัดกลุ่มให้มากที่สุดเท่าที่จะมากได้

แล้วคุณจะเขียนโค้ด OOP ที่ไม่ตันเกินไปจนต้องรื้อบ่อยๆ ได้ง่ายขึ้นครับ

ปล. ผมอยากเมาท์ต่อนิดนึงว่าเรื่องนี้ภาษามีผลมาก เพราะบางภาษาทำให้การ Composition มันยากเหลือเกิน อย่างโค้ดแบบเดียวกันผมเขียนใน Ruby มีแค่นี้

class Invoice
  include Approvable
end

class PurchaseOrder
  include Approvable
end

module Approvable
  def approve(approver_id)
  end
end

ผมพูดเรื่องนี้ทำไมกันนะ ผมขอทิ้งคำถามนี้ให้ผู้อ่านคิดดีกว่า

Top comments (0)