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

Bridge

แยก abstraction ออก​จาก implementation ให้​ทั้ง​สอง​เปลี่ยนแปลง​อิสระ

ลอง​นึก​ภาพ​ว่า​ต้อง​ออกแบบ class รูปทรง (Shape) ที่​มี​ทั้ง Circle และ Square แล้ว​แต่ละ​รูป​ต้อง​วาด​ได้​บน​หลาย renderer เช่น Raster กับ Vector ถ้า​ใช้ inheritance ตรง ๆ จะ​ได้ RasterCircle, VectorCircle, RasterSquare, VectorSquare — เพิ่ม​รูปทรง​ใหม่ 1 แบบ หรือ renderer ใหม่ 1 แบบ จำนวน class จะ​โต​แบบ m×n ทันที นี่​คือ​ปัญหา​ที่​เรียก​ว่า class explosion ซึ่ง​เกิด​เมื่อ class หนึ่ง​ต้อง​แปรผัน​ไป​ตาม​มิติ (dimension) ของ​การ​เปลี่ยนแปลง​มากกว่า​หนึ่ง​แกน​พร้อม​กัน

Bridge แก้​ปัญหา​นี้​ด้วย​แนวคิด​หลัก​ของ Gang of Four (GoF) คือ “decouple an abstraction from its implementation so that the two can vary independently” — แยก​ลำดับ​ชั้น (hierarchy) ออก​เป็น​สอง​ฝั่ง​ที่​เป็น​อิสระ​ต่อ​กัน:

  • Abstraction — สิ่ง​ที่ client มอง​เห็น​และ​เรียก​ใช้ ถือ control logic ระดับ​สูง
  • Implementor — interface ของ​รายละเอียด​การ​ทำงาน​จริง ที่​มี​ได้​หลาย​แบบ (concrete implementor)

Abstraction ไม่​ได้ inherit จาก implementation แต่ ถือ reference ไป​ยัง implementor แล้ว delegate งาน​ที่​เจาะจง​ลง​ไป — ความ​สัมพันธ์​แบบ composition (“has-a”) นี้​เอง​ที่​เป็น “สะพาน” เชื่อม​สอง​ฝั่ง​เข้า​ด้วย​กัน ทำให้​เพิ่ม abstraction ใหม่​หรือ implementor ใหม่​ได้​อิสระ โดย​จำนวน class โต​แบบ m+n แทนที่​จะ​เป็น m×n และ​ยัง​สลับ implementor ได้​ตอน runtime ด้วย (ไม่​ต้อง compile ใหม่)

Bridge สนับสนุน Dependency Inversion Principle (Abstraction พึ่งพา interface ของ Implementor ไม่​พึ่ง concrete class) และ Open-Closed Principle (เพิ่ม abstraction หรือ implementor ใหม่​ได้​โดย​ไม่​แก้ code เดิม) เหมาะ​เมื่อ​มี​มิติ​ของ​การ​เปลี่ยนแปลง​หลาย​แกน​พร้อม​กัน เช่น ชนิด​ข้อความ × ช่อง​ทาง​ส่ง, ชนิด​รายงาน × รูปแบบ file หรือ business logic × platform

classDiagram
    class Notification {
      <<abstract>>
      #IChannel channel
      +Notify(text) void
    }
    class Alert {
      +Notify(text) void
    }
    class Reminder {
      +Notify(text) void
    }
    class IChannel {
      <<interface>>
      +Send(msg) void
    }
    class EmailChannel {
      +Send(msg) void
    }
    class SmsChannel {
      +Send(msg) void
    }
    class PushChannel {
      +Send(msg) void
    }

    Notification o-- IChannel
    Notification <|-- Alert
    Notification <|-- Reminder
    IChannel <|.. EmailChannel
    IChannel <|.. SmsChannel
    IChannel <|.. PushChannel

สังเกต​ว่า​มี​ลำดับ​ชั้น​สอง​ฝั่ง​ที่​แยก​กัน​เด็ดขาด:

  • ฝั่ง Abstraction (NotificationAlert / Reminder) แตก​แขนง​ตาม​แกน “ชนิด​ของ​การ​แจ้ง​เตือน”
  • ฝั่ง Implementor (IChannelEmailChannel / SmsChannel / PushChannel) แตก​แขนง​ตาม​แกน “ช่อง​ทาง​ส่ง”

เส้น o-- คือ​ความ​สัมพันธ์​แบบ aggregation ที่ Notification ถือ IChannel ไว้​เป็น​สมาชิก — นี่​คือ “สะพาน” ที่​ชื่อ pattern สื่อ​ถึง เพิ่ม​ชนิด​การ​แจ้ง​เตือน​ใหม่ (เช่น Digest) หรือ​ช่อง​ทาง​ใหม่ (เช่น SlackChannel) ก็​ทำได้​อิสระ โดย​ไม่​ต้อง​แตะ code อีก​ฝั่ง​เลย

เมื่อ client เรียก Notify() บน Alert (ซึ่ง​เป็น Abstraction) มัน​จะ​ไม่​ลงมือ​ส่ง​ข้อความ​เอง แต่ delegate งาน​ต่อ​ไป​ให้ IChannel ที่​ตัวเอง​ถือ​อยู่ ผ่าน Channel.Send(...) — ตัว Alert จึง​ไม่​จำเป็น​ต้อง​รู้​เลย​ว่า​ปลายทาง​เป็น​อีเมล, SMS หรือ push notification

sequenceDiagram
    participant Client
    participant Alert as Alert : Notification
    participant Channel as EmailChannel : IChannel

    Client->>Alert: Notify("server down")
    Alert->>Alert: format message
    Alert->>Channel: Send(formatted message)
    Channel-->>Alert: ok
    Alert-->>Client: return

ขั้นตอน​สำคัญ:

  1. Client สร้าง concrete implementor (เช่น EmailChannel) แล้ว inject เข้าไป​ใน constructor ของ Abstraction — นี่​คือ​จุด​ที่​ผูก “สะพาน” เข้า​ด้วย​กัน มัก​ทำ​ผ่าน Dependency Injection
  2. Client เรียก method ระดับ​สูง​บน Abstraction เช่น Notify(text)
  3. Abstraction จัดการ logic ของ​ตัวเอง (เช่น จัด​รูปแบบ​ข้อความ, ตัดสิน​ใจ​ว่า​จะแจ้ง​ไหม) แล้ว​ค่อย delegate งาน​เจาะจง platform ไป​ให้ Implementor ผ่าน interface ที่​ตกลง​กัน​ไว้
  4. Concrete Implementor ทำงาน​จริง (ยิง SMTP, เรียก SMS gateway ฯลฯ) — รายละเอียด​นี้ Abstraction ไม่​รับรู้​และ​ไม่​สนใจ

เพราะ Abstraction คุย​กับ Implementor ผ่าน interface เท่านั้น เรา​จึง​สลับ implementor ได้​ตอน runtime (เช่น อ่าน​ค่า​จาก config ว่า​จะ​ส่ง​อีเมล​หรือ SMS) โดย​ไม่​ต้อง​แก้​หรือ compile ฝั่ง Abstraction ใหม่​เลย

// Implementor — สัญญาที่ concrete channel ทุกตัวต้องทำตาม
public interface IChannel
{
void Send(string message);
}
// Concrete Implementor แต่ละแบบ แยกอิสระจากฝั่ง Abstraction โดยสิ้นเชิง
public class EmailChannel : IChannel
{
private readonly string _smtpHost;
public EmailChannel(string smtpHost) => _smtpHost = smtpHost;
public void Send(string message)
{
// ในของจริงจะยิงผ่าน SMTP client ไปที่ _smtpHost
Console.WriteLine($"[Email via {_smtpHost}] {message}");
}
}
public class SmsChannel : IChannel
{
private readonly string _gatewayKey;
public SmsChannel(string gatewayKey) => _gatewayKey = gatewayKey;
public void Send(string message) =>
Console.WriteLine($"[SMS] {message}");
}
public class PushChannel : IChannel
{
public void Send(string message) =>
Console.WriteLine($"[Push] {message}");
}
// Abstraction — ถือ implementor ไว้ แล้ว delegate งานเจาะจงไปให้
public abstract class Notification
{
protected readonly IChannel Channel;
protected Notification(IChannel channel) => Channel = channel;
// control logic ระดับสูง แต่ละ subclass กำหนดรูปแบบข้อความของตัวเอง
public abstract void Notify(string text);
}
// RefinedAbstraction: แปรผันตาม "ชนิด" ของการแจ้งเตือน — อิสระจากช่องทาง
public class Alert : Notification
{
public Alert(IChannel channel) : base(channel) { }
public override void Notify(string text) =>
Channel.Send($"[ALERT] {text}");
}
public class Reminder : Notification
{
public Reminder(IChannel channel) : base(channel) { }
public override void Notify(string text) =>
Channel.Send($"[Reminder] {text}");
}
// การใช้งาน: ผสม abstraction ใดก็ได้ เข้ากับ implementor ใดก็ได้ ตอน runtime
public class Program
{
public static void Main()
{
IChannel channel = LoadChannelFromConfig(); // เลือกช่องทางตอนรัน
Notification alert = new Alert(channel);
alert.Notify("disk usage เกิน 90%");
Notification reminder = new Reminder(new PushChannel());
reminder.Notify("ประชุม 15 นาทีข้างหน้า");
}
private static IChannel LoadChannelFromConfig() => new EmailChannel("smtp.internal");
}

เพิ่ม​ชนิด​การ​แจ้ง​เตือน​ใหม่ (เช่น DigestNotification) หรือ​ช่อง​ทาง​ใหม่ (เช่น SlackChannel) ก็​แค่​เพิ่ม class ใหม่1 class ใน​ฝั่ง​ของ​มัน — ไม่​ต้อง​แตะ code เดิม​เลย​แม้แต่​บรรทัด​เดียว ตรง​ตาม Open-Closed Principle

  • เมื่อ class มี​มิติ​ของ​การ​เปลี่ยนแปลง​มากกว่า​หนึ่ง​แกน และ inheritance ตรง ๆ จะ​ทำให้​เกิด class explosion (m×n class)
  • เมื่อ​ต้องการ​สลับ implementation ตอน runtime เช่น เปลี่ยน storage backend, renderer, หรือ​ช่อง​ทาง​ส่ง​ข้อความ​ตาม config
  • เมื่อ​ต้อง share implementation เดียวกัน​ระหว่าง abstraction หลาย​ตัว โดย​ไม่​อยาก​ผูก​ด้วย inheritance
  • เมื่อ​ทั้ง abstraction และ implementation ควร​ขยาย​ได้​ด้วย subclass ของ​แต่ละ​ฝั่ง​อย่าง​อิสระ
  • เมื่อ​ต้อง​ซ่อน​รายละเอียด platform-specific จาก client ทั้งหมด (cross-platform GUI, driver, protocol adapter)
  • เมื่อ​มี​มิติ​ของ​การ​เปลี่ยนแปลง​เพียง​แกน​เดียว — ใช้ inheritance ธรรมดา​หรือ Strategy ก็​เพียงพอแล้ว
  • เมื่อ class มี​ความ cohesive สูง​และ​ไม่มี​ทีท่า​ว่า​จะ​แตก​แขนง​เพิ่ม — การ​แยก hierarchy ล่วงหน้า​เป็นการ​เพิ่ม abstraction โดย​ไม่​จำเป็น ขัด​กับ YAGNI
  • เมื่อ​ทีม​ยัง​ไม่​เจอ​ความ​จำเป็น​ต้อง​สลับ implementation ตอน runtime หรือ​ยัง​ไม่มี​สัญญาณ​ของ​การ​ขยาย​หลาย​แกน​จริง ๆ — อย่า​รีบ generalize ก่อน​มี​หลักฐาน
  • เมื่อ​ความ​ซับซ้อน​ที่​เพิ่ม​ขึ้น (2 hierarchy, indirection เพิ่ม​ขึ้น) ไม่​คุ้ม​กับ​ความ​ยืดหยุ่น​ที่​ได้ ใน​ระบบ​เล็ก​ที่​ไม่​ค่อย​เปลี่ยนแปลง
ด้านรายละเอียด
ข้อดีเพิ่ม abstraction หรือ implementor ใหม่​ได้​อิสระ โดย​ไม่​กระทบ​อีก​ฝั่ง (m+n แทน m×n)
ข้อดีสลับ implementation ได้​ตอน runtime ผ่าน composition
ข้อดีซ่อน​รายละเอียด platform-specific จาก client — สอดคล้อง DIP
ข้อดีแต่ละ hierarchy ทดสอบ​และ​พัฒนา​แยก​กัน​ได้ (Single Responsibility ต่อ​ฝั่ง)
ข้อ​เสียเพิ่ม​ความ​ซับซ้อน​ของ code — มี2 hierarchy และ indirection มาก​ขึ้น​สำหรับ use case ที่​ไม่​ซับซ้อน​พอ
ข้อ​เสียออกแบบ​ยาก​ตั้งแต่​ต้น เพราะ​ต้อง​มอง​เห็น​แกน​ของ​การ​เปลี่ยนแปลง​ล่วงหน้า​ให้​ถูก
ข้อ​เสียถ้า​ใช้​กับ class ที่ cohesive สูง​และ​ไม่​ค่อย​เปลี่ยน จะ​เป็นการ​เพิ่ม abstraction ที่​ไม่​คุ้ม​ค่า
  • Dependency Inversion Principle — หลักการ​ที่ Bridge นำ​ไป​ใช้​โดยตรง (พึ่ง interface ของ Implementor)
  • Open-Closed Principle — เพิ่ม abstraction/implementor ใหม่​ได้​โดย​ไม่​แก้ code เดิม
  • Strategy — โครงสร้าง​คล้าย​กัน​มาก (composition + delegate) แต่ Strategy เน้น​สลับ “algorithm” หนึ่ง​ตัวแปร ส่วน Bridge เน้น​แยก2 hierarchy คู่​ขนาน
  • Adapter — ทั้ง​คู่​ห่อ object อื่น​ไว้ แต่ Adapter ใช้​แก้ interface ที่​เข้า​กัน​ไม่​ได้​ของ code ที่​มี​อยู่​แล้ว ส่วน Bridge ออกแบบ​แยก2 hierarchy ไว้​ตั้งแต่​ต้น
  • Abstract Factory — มัก​ใช้​คู่​กับ Bridge เพื่อ​สร้าง concrete implementor ที่​เข้า​กัน​ได้​โดยที่ client ไม่​ต้อง​รู้​ชนิด​จริง
  • Dependency Injection — วิธี​ปฏิบัติ​ที่​ใช้​ผูก Abstraction เข้า​กับ Implementor ที่​เลือก​ไว้