ข้าม​ไป​ยัง​เนื้อหา

Open-Closed Principle

เปิด​รับ​การ​ขยาย แต่​ปิด​ต่อ​การ​แก้ไข

Open-Closed Principle (OCP) กล่าว​ว่า สิ่ง​ต่าง ๆ ใน​ซอฟต์แวร์ (class module method ฯลฯ) ควร เปิด​รับ​การ​ขยาย (open for extension) แต่​ปิด​ต่อ​การ​แก้ไข (closed for modification) นี่​คือ​ตัว “O” ตัว​ที่​สอง​ใน​อักษร​ย่อ SOLID

Bertrand Meyer เป็น​ผู้​บัญญัติ​คำ​ว่า open-closed principle ไว้​ใน​หนังสือ Object-Oriented Software Construction (1988) โดย​นิยาม​ว่า module หนึ่ง “เปิด” หาก​ยัง​ขยาย​เพิ่ม field หรือ function ได้ และ “ปิด” หาก​มัน​เสถียร​พอ​ให้ module อื่น​มา​เรียก​ใช้ได้​อย่าง​มั่นใจ วิธี​ที่ Meyer เสนอ​ใน​ตอน​นั้น​คือ​ใช้ implementation inheritance — class หนึ่ง​ถูก compile และ​เก็บ​ไว้​ใช้งาน​ได้ (ปิด) แต่​ก็​ยัง​เปิด​ให้ class ลูก​สืบทอด​แล้ว​เพิ่ม​พฤติกรรม​ใหม่​ได้​โดย​ไม่​ต้อง​แก้ class แม่​หรือ​รบกวน client ที่​ใช้​มัน​อยู่

ใน​ช่วง​ทศวรรษ 1990 Robert C. Martin ตีความ​หลักการ​นี้​ใหม่​ใน​บทความ “Design Principles and Design Patterns” (2000) จน​กลาย​เป็น​ที่​เรียก​กัน​ว่า Polymorphic Open-Closed Principle — แทนที่​จะ​สืบทอด​จาก class จริง เรา​ให้ client ขึ้น​กับ interface หรือ abstract class เท่านั้น ส่วน​พฤติกรรม​ที่​เปลี่ยนแปลง​ได้​ก็​ผลัก​ไป​ไว้​ใน implementation ที่​สลับ​เปลี่ยน​ได้​แบบ polymorphic นี่​คือ​มุมมอง​ที่​ใช้​กัน​แพร่หลาย​ที่สุด​ใน​ปัจจุบัน และ​เป็น​ฐาน​ให้​กับ design pattern เชิง​ประกอบ (compositional) อย่าง Strategy pattern

วิธี​ที่​ง่าย​ที่สุด​ใน​การ​อธิบาย​หลักการ​นี้ คือ​ลอง​พิจารณา method ที่​ทำงาน​อย่าง​หนึ่ง สมมติ​ว่า​มัน​เขียน​ข้อมูล​ลง file หนึ่ง โดย​ชื่อ file ถูก hard-code ไว้​ใน method หาก​ความ​ต้องการ​เปลี่ยน​ไป และ​ตอน​นี้​ชื่อ file ต้อง​ต่าง​ออก​ไป​ใน​บาง​สถานการณ์ เรา​ก็​ต้อง​เปิด method ขึ้น​มา​แก้​ชื่อ file แต่​ใน​ทาง​กลับ​กัน หาก​ชื่อ file ถูก​ส่ง​เข้า​มา​เป็น parameter เรา​จะ​สามารถ​เปลี่ยน​พฤติกรรม​ของ method นี้​ได้​โดย​ไม่​ต้อง​แตะ​ต้อง source ของ​มัน — คง​สภาพ “ปิด​ต่อ​การ​แก้ไข” ไว้​ได้

Craig Larman เชื่อม​โยง OCP เข้า​กับ pattern Protected Variations ของ Alistair Cockburn และ​แนวคิด information hiding ของ David Parnas ใน​บทความ “Protected Variation: The Importance of Being Closed” (IEEE Software, 2001) ใจความ​สำคัญ​ตรง​กัน คือ: มอง​หา​จุด​ที่​คาด​ว่า​จะ​แปรผัน​ใน​อนาคต แล้ว​ห่อ​หุ้ม​มัน​ไว้​หลัง interface ที่​มั่นคง

แผนภาพ​ด้าน​ล่าง​แสดง​ทิศทางการ​พึ่งพา (dependency direction) ที่ OCP ต้องการ — ทั้ง client และ implementation เดิม​ต่าง​ก็​ขึ้น​กับ abstraction ตัว​เดียวกัน เมื่อ​จะ​เพิ่ม​พฤติกรรม​ใหม่ เรา​เพิ่ม implementation ใหม่​เข้า​มา​ชี้​ที่ abstraction เดิม โดย​ไม่​ต้อง​แตะ​ทั้ง client และ implementation เก่า​เลย

flowchart LR
    Client[Client code]
    Abstraction[IDiscountStrategy interface]
    Existing[Existing implementation]
    New[New implementation added later]
    Client --> Abstraction
    Existing --> Abstraction
    New --> Abstraction

เกี่ยว​เนื่อง​กับ DRY: Open-Closed Principle จะ​ทำงาน​ได้​ก็​ต่อ​เมื่อ​ยึด​หลัก Don’t Repeat Yourself เพราะ​การ​ขยาย​พฤติกรรม​โดย​ไม่​แก้ code เดิม​ต้อง​อาศัย​การ​สร้าง abstraction ที่​ไม่​ซ้ำซ้อน หาก​ตรรกะ​เดิม​กระจัดกระจาย​ซ้ำซ้อน​อยู่​หลาย​ที่ การ​เพิ่ม​พฤติกรรม​ใหม่​ก็​จะ​บังคับ​ให้​ต้อง​ไล่​แก้​ทุก​จุด​อยู่ดี

code ที่​ผ่าน​การ​ทดสอบ​แล้ว​และ​ใช้งาน​จริง​อยู่​ใน​โปร​ดัก​ชัน​มี​ความ​เสี่ยง​ทุก​ครั้ง​ที่​ถูก​แก้ไข การ​แก้ code เดิม​อาจ​ทำให้​เกิด regression ที่​ไม่​คาด​คิด ต้อง compile ใหม่ ทดสอบ​ใหม่ และ deploy ใหม่​ทั้ง​ระบบ ใน​ขณะ​ที่​การ “เพิ่ม” code ใหม่​เข้าไป​โดย​ไม่​แตะ​ของ​เดิม​นั้น​ปลอดภัย​กว่า​มาก เพราะ code เดิม​ที่​ผ่าน​การ​พิสูจน์​แล้ว​ยัง​คง​เสถียร​อยู่​เหมือน​เดิม

Uncle Bob ยก​ตัวอย่าง​สถาปัตยกรรม plugin อย่าง Eclipse, IntelliJ, Vim หรือ Minecraft เป็น​บท​พิสูจน์​ว่า OCP ใช้งาน​ได้​จริง​ใน​ระดับ​สถาปัตยกรรม ไม่ใช่​แค่​ทฤษฎี — ระบบ​เหล่า​นี้​ทำให้ dependency ไหล​เข้าหา​แกน​กลาง​เสมอ (plugin ขึ้น​กับ core ไม่ใช่ core ขึ้น​กับ plugin) ทำให้​เพิ่ม​ความ​สามารถ​ใหม่​ได้​เรื่อย ๆ โดย​ไม่​ต้อง​แก้ core เลย

ประโยชน์​โดย​สรุป:

  • ลด​ความ​เสี่ยง regression เพราะ code ที่​ทดสอบ​ผ่าน​แล้ว​ไม่​ถูก​แตะ​ต้อง
  • หลีก​เลี่ยง shotgun surgery — การ​เปลี่ยนแปลง​หนึ่ง​ครั้ง​ไม่​ต้อง​กระจาย​ไป​แก้​หลาย file
  • รองรับ​การ​ขยาย​ทีม​และ feature คู่​ขนาน เพราะ​ทีม​ต่าง ๆ เพิ่ม implementation ใหม่​ได้​โดย​ไม่​ชน​กัน
  • สอดคล้อง​กับ deployment ที่​ปลอดภัย​กว่า เช่น การ​เพิ่ม assembly/DLL ใหม่​โดย​ไม่​ต้อง redeploy ของ​เดิม

ก่อน (ละเมิด OCP): ทุก​ครั้ง​ที่​มี​ลูกค้า​ประเภท​ใหม่ ต้อง​เปิด method นี้​ขึ้น​มา​แก้ไข และ​เสี่ยง​ทำ​เงื่อนไข​เดิม​พัง

public class DiscountCalculator
{
public decimal CalculateDiscount(Order order)
{
if (order.CustomerType == CustomerType.Regular)
{
return order.Total * 0.0m;
}
else if (order.CustomerType == CustomerType.Silver)
{
return order.Total * 0.05m;
}
else if (order.CustomerType == CustomerType.Gold)
{
return order.Total * 0.10m;
}
// ทุกครั้งที่เพิ่มลูกค้าประเภทใหม่ ต้องเข้ามาแก้ if-else นี้
throw new ArgumentException("Unknown customer type");
}
}

หลัง (ยึด OCP ด้วย Polymorphic OCP): ดึง​พฤติกรรม​ที่​แปรผัน​ออก​ไป​อยู่​หลัง interface แล้ว​ให้ DiscountCalculator ขึ้น​กับ abstraction เท่านั้น

public interface IDiscountStrategy
{
decimal Apply(decimal total);
}
public class RegularDiscount : IDiscountStrategy
{
public decimal Apply(decimal total) => total * 0.0m;
}
public class SilverDiscount : IDiscountStrategy
{
public decimal Apply(decimal total) => total * 0.05m;
}
public class GoldDiscount : IDiscountStrategy
{
public decimal Apply(decimal total) => total * 0.10m;
}
// เพิ่มลูกค้าประเภทใหม่ เช่น Platinum ได้โดยเพิ่ม class ใหม่เท่านั้น
// ไม่ต้องแก้ DiscountCalculator หรือ class ส่วนลดเดิมแม้แต่บรรทัดเดียว
public class PlatinumDiscount : IDiscountStrategy
{
public decimal Apply(decimal total) => total * 0.15m;
}
public class DiscountCalculator
{
private readonly IDiscountStrategy _strategy;
public DiscountCalculator(IDiscountStrategy strategy)
{
_strategy = strategy;
}
public decimal CalculateDiscount(Order order) => _strategy.Apply(order.Total);
}

โครงสร้าง​นี้​ตรง​กับ Strategy pattern พอดี — DiscountCalculator (ผู้​เรียก) และ class ส่วนลด​เดิม (ผู้​ถูก​เรียก) ต่าง​ขึ้น​กับ IDiscountStrategy ตัว​เดียวกัน เมื่อ​ต้อง​เพิ่ม​ส่วนลด​แบบ​ใหม่ ก็​แค่​เพิ่ม class ใหม่​ที่ implement interface นี้ ไม่​ต้อง​เปิด class เดิม​ขึ้น​มา​แก้​เลย

  • if-else หรือ switch chain ที่​ยาว​ขึ้น​เรื่อย ๆ ทุก​ครั้ง​ที่​มี requirement ใหม่ โดย​เฉพาะ​ที่​แตก​กิ่ง​ตาม type หรือ enum
  • การ​ตรวจสอบ​ชนิด​ข้อมูล​ด้วย typeof/is/GetType เพื่อ​ตัดสิน​ใจ​พฤติกรรม แทนที่​จะ​ใช้ polymorphism
  • ต้อง​แก้ไข class ที่​ผ่าน​การ​ทดสอบ​และ​ใช้งาน​จริง​แล้ว ทุก​ครั้ง​ที่​มี feature ใหม่​เข้า​มา ทั้ง​ที่​ตรรกะ​เดิม​ไม่​ได้​เปลี่ยน
  • shotgun surgery — การ​เปลี่ยนแปลง​เล็ก ๆ หนึ่ง​อย่าง​บังคับ​ให้​ต้อง​ไล่​แก้​หลาย file หลาย class พร้อม​กัน
  • ค่า​คงที่​หรือ​ชื่อ file/path ถูก hard-code ไว้​ใน method แทนที่​จะ​รับ​เข้า​มา​เป็น parameter หรือ configuration
  • ไม่มี seam หรือ extension point ใด ๆ ให้​ทีม​อื่น​เพิ่ม​พฤติกรรม​ได้​โดย​ไม่​ต้อง fork หรือ​แก้ source เดิม