Babel Coder

[Design Pattern#1] ลดความยุ่งเหยิงของโค๊ดด้วยหลักการ Law of Demeter

intermediate

 บทความนี้เป็นส่วนหนึ่งของชุดบทความ Principle และ Design Pattern สำหรับโลก OOP สมัยใหม่

สารบัญ

ณ ร้านอาหารแห่งหนึ่ง ลูกค้าหน้าเดิมเรียกบริกรมาเก็บเงินหลังจากสวาปามอาหารมื้อใหญ่ร่วมสองชั่วโมง ด้วยโค๊ดต่อไปนี้พนักงานแฮปปี้ที่ได้เก็บตังค์ ส่วนลูกค้าก็ยิ้มแฉ่งจากความสุขที่ตัวเบาเพราะจ่ายตังค์จนหมดตัว

// ตัวอย่างโปรแกรมใน TypeScript

// คลาสกระเป๋าตังค์
class Wallet {
  // กระเป๋าตังค์ก็ต้องมีตังค์ซิฮะ
  private money: number;
  
  constructor(money: number) {
    this.money = money;
  }
  
  getMoney() : number {
    return this.money
  }
  
  setMoney(money : number) {
    this.money = money;
  }
}

class Customer {
  // ลูกค้าที่เข้ามาในร้านต้องมีกระเป๋าตังค์
  // ไม่มีตังค์ก็ไปล้างจานตรงนู้่น~~
  private wallet: Wallet;
  
  constructor(wallet: Wallet) {
    this.wallet = wallet;
  }
  
  getWallet() : Wallet {
    return this.wallet;
  }
}

class Waiter {
  private name : string;
  
  constructor(name : string) {
    this.name = name;
  }
  
  // เมธอดเก็บตังค์จาก customer ด้วยจำนวนเงินเป็น money
  collectMoney(customer: Customer, money: number) {
    const wallet = customer.getWallet();
    const customerMoney = wallet.getMoney();
    
    // ถ้าตังค์ไม่พอก็ไปล้างจานเลยคร๊าบ
    if(customerMoney < money) {
      throw new Error('Not enough!');
    }
    
    // แต่ถ้าในกระเป๋าตังค์มีเงินพอก็หักเงินออกไป
    wallet.setMoney(customerMoney - money);
  }
}

// ทั้งเนื้อทั้งตัวมีอยู่ 1,000
const wallet = new Wallet(1000);
const customer = new Customer(wallet);
const waiter = new Waiter('Adam Lorem');

// พนักงงานเก็บค่าอาหารไป 750
waiter.collectMoney(customer, 750);
// ตัวเบาหวิวเหลือตังค์กลับบ้านแค่ 250
console.log(wallet.getMoney())

โค๊ดนี้ทำงานได้อย่างถูกต้องครับ แต่ด้วยดีไซน์ที่ผิดพลาด เราเจอปัญหาเข้าแล้ว

การออกแบบอ็อบเจ็กต์ให้รู้มากไปนั้นไม่ดี!

จากการประมวลผลโค๊ดข้างบนทั้งหมด เราลองมาแปลเป็นภาษามนุษย์เล่นๆกันครับ เริ่มจากลูกค้าที่เข้ามาในร้านมีกระเป๋าตังค์กันทุกคน แน่นอนว่าในนั้นก็ต้องมีเงินด้วยคงไม่มาหลอกกินฟรีเป็นแน่ เมื่อลูกค้าเรียกพนักงงานเพื่อมาเก็บเงิน พนักกงานจะเก็บเงินลูกค้าผ่านเมธอด collectMoney โดยทำขั้นตอนต่อไปนี้

  • บรรทัดที่44: บริกรล้วงกระเป๋าลูกค้าออกมาเองเลย
  • บรรทัดที่45: บริกรเปิดกระเป๋าตังค์ของลูกค้าพร้อมนับเงิน
  • บรรทัดที่48: ถ้าในกระเป๋าตังค์มีเงินไม่พอ ไล่ไปล้างจานท้ายครัวนู่น~~
  • บรรทัดที่53: แต่ถ้ามีเงินพอ ก็หยิบออกไปตามราคาอาหาร

เริ่มเห็นปัญหาแล้วใช่ไหมครับ? การออกแบบอ็อบเจ็กต์ของเรานั้นมีปัญหา กล่าวคือคงไม่มีลูกค้าที่ไหนชอบใจหรอกที่จะให้พนักงานมากระซวกกระเป๋าตังค์ไปนับเงินเองจริงไหม?

ปัญหาข้างต้นเกิดจากการที่เราออกแบบให้ waiter ของเรารู้มากเกินไป นั่นคือรู้แม้กระทั่งว่าลูกค้ามีกระเป๋าตังค์และในนั้นมีตังค์ด้วย! ถ้าสมมติวันหนึ่ง getMoney ในบรรทัดที่13คืนค่ากลับมาเป็น null เราก็ต้องมาแก้เมธอด collectMoney ของเราในบรรทัดที่48และ53ด้วยเพิ่อไม่ให้เกิดข้อผิดพลาดของโปรแกรมในการคำนวณตัวเลขกับค่า null ทั้งๆที่การจัดการข้อผิดพลาดของจำนวนเงินพนักงานไม่ควรรับรู้ด้วยซ้ำ ก็มันกระเป๋าตังค์ลูกค้านี่นา…

Law of Demeter

เพื่อลดการผูกติดกันแน่นหนึบของอ็อบเจ็กต์ที่ไม่สัมพันธ์กัน จึงเกิดหลักการในการออกแบบที่เรียกว่า Law of Demeter โดยหลักการที่ว่านี้บอกไว้ว่า

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

เมื่อประยุกต์หลักการข้างต้นเข้ากับโลกของ OOP โดยพิจารณาจากอ็อบเจ็กต์ waiter ของเรากับเมธอด collectMoney จะได้ว่า

  • collectMoney จะเรียกใช้ this ที่อ้างถึง waiter ได้เพราะมันคือความสัมพันธ์แบบผูกติด
  • collectMoney สามารถเรียกพารามิเตอร์ที่ส่งเข้ามาได้คือ customer และ money เพราะมันสัมพันธ์กัน
  • collectMoney สามารถเรียกอะไรก็ตามที่เป็นชิ้นส่วนของ waiter เช่น name
  • แต่ collectMoney ไม่สามารถเรียก customer.getWallet().getMoney() ได้ นั่นเป็นเพราะเราจะสนทนาแต่กับเพื่อนเรา เราจะไม่คุยกับเพื่อนของเพื่อนเรา พูดง่ายๆคือยิ่งพูดกับคนแปลกหน้าเยอะยิ่งเป็นการเอาเหามาใส่หัว

waiter จะเรียก customer.getWallet().getMoney() ไม่ได้นั่นเป็นเพราะบริกรไม่จำเป็นต้องรู้เลยว่าลูกค้ามีเงินเท่าไหร่ ขอเพียงเขามีปัญญาจ่ายตังค์ก็พอครับ

ถ้าเราสังเกตดูดีๆจะเริ่มค้นพบว่า ใช้ Law of Demeter ไปๆมาๆการเรียกเมธอดของเราจะเหลือแค่ . ตัวเดียว เช่น customer.getWallet().getMoney() มีสองจุดจึงผิดหลักการเพราะกำลังคุยอยู่กับเพื่อนของเพื่อนเราอีกที

Delegation และ one dot ไม่ใช่ Law of Demeter เสมอไป

ในหัวข้อที่แล้วผมบอกว่าการออกแบบโดยอิงหลักการ Law of Demeter นั้นมีแนวโน้มที่จะทำให้โค๊ดของเราเรียกเมธอดแบบใช้จุดเดียวหรือ one dot เพื่อนๆที่อ่านตรงนี้เลยอาจคิดไปว่า เห้ยงั้นเราทำยังไงก็ได้ให้การเรียกเมธอดของเราทำได้ด้วยการใช้แค่จุดเดียวซะซิ แบบที่เราทำกันในโลกของ Ruby on Rails ผ่าน delegate ดังนี้

# โค๊ดไม่ได้ผ่านการรันนะครับ อ่านเอาคอนเซ็ปต์พอครับ
class Wallet
  # set/get money ได้
  attr_accessor :money

  def initialize(money)
    @money = money
  end
end

class Customer
  attr_accessor :wallet_money
  
  # ลูกค้าของเรามีกระเป๋าตังค์ใบเดียว
  has_one :wallet

  # หัวใจอยู่ตรงนี้
  # ต่อไปนี้เราไม่ต้องเรียก customer.wallet.money เพื่อดูเงินในกระเป๋าแล้ว
  # ด้วยความสามารถของ delegate เราสามารถยุบเหลือเพียง
  # customer.wallet_money ได้
  # เย้เหลือแค่จุดเดียวแล้ว
  delegate :money, to: :wallet, prefix: true
end

class Waiter
  def collect_money(customer, money)
    # ตรงนี้ก็ใช้จุดเดียว (one dot)
    customer_money = customer.wallet_money
    
    if customer_money < money
      raise 'ไปล้างจานซะ!'
    end

	# ตรงนี้ก็ใช้จุดเดียว (one dot)
    customer.wallet_money = customer_money - money
  end
end

โอวตลอกทั้งโค๊ดเราเรียกเมธอดอื่นโดยใช้จุดเดียวแล้ว! คงมาถูกทางแล้ว นี่ซินะเดชานุภาพของ Delegation กับ Law of Demeter!

ช้าก่อนครับ แม้เราจะลดการเรียกเมธอดจนเหลือแค่จุดเดียวแล้ว แต่ความจริงยังไม่เปลี่ยน การเรียก customer.wallet_money แท้จริงแล้วพนักงงานก็ยังคงรับรู้รายละเอียดอยู่ดีว่า ลูกค้ามีกระเป๋าตังค์และในกระเป๋านั้นมีเงิน ซึ่งมันผิดหลักการ information hiding หรือการซ่อนข้อมูลที่อ็อบเจ็กต์อื่นไม่ควรทราบนั่นเอง

จึงสรุปได้ว่า delegation ไม่ได้ช่วยให้ปัญหาการผูกติดกันเช่นนี้หายไปเลย ทั้งยังไม่ช่วยซ่อนข้อมูลตังค์ในกระเป๋าของเราออกจากการรับรู้ของบริกรอีกด้วย ต้องระวังตรงจุดนี้ครับเพราะส่วนใหญ่ที่ใช้หลักการนี้กันชอบแปลงโค๊ดให้เหลือ one dot โดยความหมายไม่เปลี่ยนแปลงเลย

Law of Demeter คือการระงับการรับรู้ส่วนที่ไม่เกี่ยวข้อง

เราเปลี่ยนโค๊ดของเราเสียใหม่ให้บริกรไม่สามารถเข้าถึงกระเป๋าตังค์เราได้ โดยให้โค๊ดในการตรวจสอบเงินคงเหลือในกระเป๋าตังค์อยู่ที่ wallet เองเลย พร้อมทั้งให้ลูกค้าเป็นผู้หยิบเงินออกจากกระเป๋าตังค์ด้วยตนเองผ่านเมธอด pay ดังนี้

class Wallet {
  private money: number;
  
  constructor(money: number) {
    this.money = money;
  }
  
  // อนุญาตให้ลูกค้าเป็นผู้หยิบเงินออกจากกระเป๋าตังค์ได้ด้วยตนเอง
  withdraw(amount : number) {
    // ย้ายการตรวจสอบจำนวนเงินมาไว้ในกระเป๋าตังค์เอง
    // เป็นการปกปิดข้อมูลจำนวนเงินไม่ให้ภายนอกรับรู้
    if(this.money < amount) {
      throw new Error('Not enough!');
    }
    
    this.money -= amount;
  }
}

class Customer {
  private wallet: Wallet;
  
  constructor(wallet: Wallet) {
    this.wallet = wallet;
  }
  
  pay(amount : number) {
    // เพื่อนๆอาจสงสัยไหนผมบอกว่า Law of Demeter มีแนวโน้มทำให้โค๊ดเราเรียกเมธอดด้วย dot เดียว
    // แต่อันนี้มีตั้งสองจุด?
    // จริงๆแล้วมันมีแค่ dot เดียวครับคือ wallet.withdraw
    // แต่ในภาษา TypeScript เราจะอ้างถึง attribute ในอ็อบเจ็กต์ได้
    // ต้องเรียกผ่าน this
    // ถ้าเราเรียกในภาษาอื่นอาจเหลือ dot เดียว
    // เช่นใน Ruby จะเป็น @wallet.withdraw แทน
    return this.wallet.withdraw(amount);
  }
}

class Waiter {
  private name : string;
  
  constructor(name : string) {
    this.name = name;
  }
  
  collectMoney(customer: Customer, money: number) {	
    // บริกรไม่รู้เห็นเงินในกระเป๋าตังค์เราอีกต่อไป!
    customer.pay(money);
  }
}

const wallet = new Wallet(1000);
const customer = new Customer(wallet);
const waiter = new Waiter('Adam Lorem');

waiter.collectMoney(customer, 750);
console.log(wallet.getMoney())

ทบทวนกระบวนการของหลักการ Law of Demeter

ลองตรวจสอบโค๊ดใหม่ที่เราเขียนอีกรอบว่าเข้าหลักการของ Law of Demeter หรือไม่

  • บรรทัดที่9: ภายใน withdraw ของเราเข้าถึงแค่ money ที่เป็น attribute ของอ็อบเจ็กต์เอง และเข้าถึง amount ที่เป็นพารามิเตอร์ในระดับชั้นเดียว ไม่ได้เรียกซ้อนเข้าไปเรื่อยๆจึงถูกต้อง
  • บรรทัดที่27: pay ของเรามีความสัมพันธ์ใกล้ชิดกับ wallet ในระดับเดียวด้วยการรับรู้เพียงว่า wallet มี withdraw ให้เรียกใช้ โดยไม่รู้ถึงไส้ในว่า wallet เองเก็บ money เอาไว้ จึงเป็นการรับรู้เพียงของที่ควรรู้เข้ากับหลักการของ Law of Demeter
  • บรรทัดที่46: collectMoney ไม่ได้ตรวจสอบเงินในกระเป๋าเองอีกแล้ว เพียงแต่โบ้ยให้ลูกค้าเป็นผู้รับผิดชอบจ่ายเงินผ่านเมธอด pay จึงเป็นการแบ่งแยกหน้าที่ของใครของมันอย่างชัดเจน

Law of Demeter นั้นช่วยให้โค๊ดของเราซับซ้อนน้อยลงด้วยการทำ loose coupling หรือให้อ็อบเจ็กต์แต่ละตัวผูกติดกันน้อยลง ด้วยการให้อ็อบเจ็กต์รู้จักเพียง unit ที่เกี่ยวข้องพอ ไม่รับรู้ถึงการทำงานของผู้อื่นเช่นไม่ให้บริกรรู้ว่าเงินในกระเป๋าตังค์ของลูกค้ามีเท่าไหร่เป็นต้น Law of Demeter จึงเป็นหนึ่งในหลักการสำคัญที่จะช่วยให้โค๊ดของเพื่อนๆดีงามพระรามแปดครับ

เอกสารอ้างอิง

Dan Manges (2007). Misunderstanding the Law of Demeter. Retrieved June, 20, 2016, from http://www.dan-manges.com/blog/37

Law of Demeter. Retrieved June, 20, 2016, from https://en.wikipedia.org/wiki/Law_of_Demeter


แสดงความคิดเห็นของคุณ


Nuttavut Thongjorปีที่แล้ว

ความเห็นผมคือทั้งสองวิธีต่างกันที่มุมมองว่าเราเลือกมองปัญหาที่

  • จ่ายให้ใคร
  • หรือ เก็บจากใคร

ทั้งนี้ผมเห็นด้วยครับว่าลูกค้าเป็นคนจ่ายเงินให้พนักงานดูเหมาะสมกว่าครับ 👍


ไม่ระบุตัวตนปีที่แล้ว

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

class Wallet {
  private money: number;
  
  constructor(money: number) {
    this.money = money;
  }
  
  withdraw(amount : number) {
    if(this.money < amount) {
      throw new Error('Not enough!');
    }
    
    this.money -= amount;
  }

  deposit(amount: number) {
   if(amount < 0) {
      throw new Error('Not Valid!');
    }
    
    this.money += amount; 
  }
}

class Customer {
  private wallet: Wallet;
  
  constructor(wallet: Wallet) {
    this.wallet = wallet;
  }
  
  pay(waiter : Waiter, amount : number) {
    this.wallet.withdraw(amount);
    waiter.collectMoney(amount);
  }
}

class Waiter {
  private name : string;
  private wallet: Wallet;
  
  constructor(name : string, wallet: Wallet) {
    this.name = name;
    this.wallet = wallet;
  }
  
  collectMoney(money: number) {	
    this.wallet.deposit(money);
  }
}

const customerWallet = new Wallet(1000);
const customer = new Customer(customerWallet);
const waiterWallet = new Wallet(0);
const waiter = new Waiter(waiterWallet, 'Adam Lorem');

customer.pay(waiter, 750);

console.log(customerWallet.getMoney());
console.log(waiterWallet.getMoney());