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

Visitor

แยก algorithm ออก​จาก​โครงสร้าง object ด้วย double dispatch

ลอง​นึก​ภาพ​ว่า​คุณ​มี object structure ที่​ค่อน​ข้าง​นิ่ง เช่น AST ของ​ภาษา​โปรแกรม, ต้นไม้​ของ shape ใน​โปรแกรม​วาด​รูป, หรือ node ของ​เอกสาร — แต่​ตลอด​อายุ​ของ​ระบบ คุณ​จะ​ถูก​ขอ​ให้​เพิ่ม operation ใหม่ กับ​โครงสร้าง​นั้น​เรื่อย ๆ (export เป็น XML, คำนวณ​ราคา, ตรวจสอบ​ความ​ถูกต้อง, พิมพ์​รายงาน ฯลฯ) ถ้า​เขียน​แต่ละ operation เป็น method ใน​ทุก class ของ element code ของ​แต่ละ class จะ​บวม​ขึ้น​เรื่อย ๆ ด้วย logic ที่​ไม่​เกี่ยวข้อง​กับ​หน้าที่​หลัก​ของ​มัน และ​ทุก​ครั้ง​ที่​เพิ่ม operation ใหม่​ต้อง​แก้ file กระจาย​ไป​ทั่ว​ทุก class

Visitor แก้​ปัญหา​นี้​ด้วย​การ​ย้าย operation ออก​จาก class element ไป​ไว้​ใน class “visitor” ต่างหาก แต่ละ operation ใหม่ = visitor class ใหม่​หนึ่ง​ตัว โดย​ไม่​ต้อง​แตะ code element เลย กลไก​ที่​ทำให้​สิ่ง​นี้​เป็น​ไป​ได้​คือ double dispatch: element เรียก Accept(visitor) ของ​ตัวเอง (dispatch ครั้ง​ที่ 1 — เลือก​ตาม concrete type ของ element) แล้ว​ภายใน Accept จะ​เรียก visitor.Visit(this) กลับ​ไป (dispatch ครั้ง​ที่ 2 — compiler เลือก overload ตาม concrete type ของ element ที่​ส่ง​เข้าไป) ผล​คือ code ที่​ทำงาน​จริง​ถูก​เลือก​จาก​ทั้ง “element ชนิด​ไหน” และ “visitor ชนิด​ไหน” พร้อม​กัน ซึ่ง​ภาษา​อย่าง C# ที่​รองรับ​แค่ single dispatch (virtual method ปกติ) ทำ​เอง​ตรง ๆ ไม่​ได้

นี่​คือ Open/Closed Principle ใน​ทาง​ปฏิบัติ: เปิด​ให้​ขยาย operation ใหม่​ได้​ตลอด แต่​ปิด​การ​แก้ไข class element เดิม ข้อ​แลกเปลี่ยน​คือ​กลับ​ด้าน​กับ polymorphism ทั่วไป — เพิ่ม operation ใหม่​ง่าย (แค่​เพิ่ม visitor class) แต่ เพิ่ม​ชนิด element ใหม่​ยาก (ต้อง​ไป​แก้​ทุก visitor ที่​มี​อยู่​ให้​รองรับ​ชนิด​ใหม่) จึง​เหมาะ​กับ​โครงสร้าง​ที่​ชุด​ชนิด element ค่อน​ข้าง​นิ่ง แต่​ชุด operation ขยาย​ตัว​เรื่อย ๆ

classDiagram
    class IShapeVisitor {
      <<interface>>
      +Visit(Circle c)
      +Visit(Square s)
    }
    class AreaVisitor {
      +Visit(Circle c)
      +Visit(Square s)
    }
    class ExportVisitor {
      +Visit(Circle c)
      +Visit(Square s)
    }
    class IShape {
      <<interface>>
      +Accept(IShapeVisitor v)
    }
    class Circle {
      +Radius
      +Accept(IShapeVisitor v)
    }
    class Square {
      +Side
      +Accept(IShapeVisitor v)
    }
    IShapeVisitor <|.. AreaVisitor
    IShapeVisitor <|.. ExportVisitor
    IShape <|.. Circle
    IShape <|.. Square
    Circle --> IShapeVisitor : Accept calls Visit
    Square --> IShapeVisitor : Accept calls Visit

องค์​ประกอบ​หลัก​ของ pattern มี 4 ส่วน:

  • Visitor interface — ประกาศ method Visit แบบ overload หนึ่ง​ตัว​ต่อ concrete element type หนึ่ง​ชนิด
  • ConcreteVisitor — implement operation จริง​สำหรับ​แต่ละ​ชนิด (เช่น AreaVisitor คำนวณ​พื้นที่, ExportVisitor แปลง​เป็น XML)
  • Element interface — ประกาศ method Accept(IVisitor v)
  • ConcreteElement — implement Accept โดย​เรียก v.Visit(this) เพื่อ​ให้ compiler เลือก overload ที่​ตรง​กับ​ชนิด​ของ​ตัวเอง

ลำดับ​การ​ทำงาน​เมื่อ client เดิน object structure แล้ว​ให้​แต่ละ element accept visitor:

sequenceDiagram
    participant Client
    participant Circle
    participant Visitor as AreaVisitor

    Client->>Circle: Accept(visitor)
    Circle->>Visitor: Visit(this)
    Note over Visitor: overload ที่ตรงกับ Circle ถูกเลือก ณ compile time
    Visitor-->>Circle: อ่าน Radius แล้วคำนวณพื้นที่
    Visitor-->>Client: เก็บผลสะสมไว้ใน visitor

ขั้นตอน​โดย​ละเอียด:

  1. Client สร้าง concrete visitor ขึ้น​มา​หนึ่ง​ตัว (เช่น AreaVisitor) แล้ว​เดิน (traverse) object structure ทีละ element
  2. สำหรับ​แต่ละ element client เรียก element.Accept(visitor) — นี่​คือ dispatch ครั้ง​แรก ซึ่ง​ใช้ virtual dispatch ปกติ​เพื่อ​เข้าไป​ยัง Accept ของ concrete type ที่​ถูกต้อง (Circle.Accept หรือ Square.Accept)
  3. ภายใน Accept แต่ละ class เรียก visitor.Visit(this) โดยที่ this มี​ชนิด​คอนกรีต​ชัดเจน (เช่น Circle) — นี่​คือ dispatch ครั้ง​ที่2 compiler เลือก overload Visit(Circle) แทนที่​จะ​เป็น Visit(Square) เพราะ static type ของ this ที่​จุด​เรียก​คือ Circle ไม่ใช่ IShape
  4. visitor ทำงาน​ตาม logic ของ operation นั้น อาจ​อ่าน​ค่า​จาก element (ต้อง​เป็น public/internal เพราะ visitor อยู่​นอก class) แล้ว​เก็บ​ผล​สะสม​ไว้​ใน state ภายใน​ตัว​มัน​เอง (เช่น​ผล​รวม​พื้นที่)
  5. เมื่อ traverse ครบ​ทุก element client อ่าน​ผลลัพธ์​จาก visitor ได้

ถ้า​อยาก​เพิ่ม operation ใหม่ — เช่น “หา​ปริมณฑล” — เพียง​เขียน PerimeterVisitor : IShapeVisitor ใหม่1 class โดย​ไม่​ต้อง​แก้ Circle หรือ Square เลย แต่​ถ้า​อยาก​เพิ่ม​ชนิด element ใหม่ — เช่น Triangle — ต้อง​ไป​เพิ่ม Visit(Triangle t) ใน​ทุก visitor interface/implementation ที่​มี​อยู่ นี่​คือ​ข้อ​แลกเปลี่ยน​หลัก​ของ pattern นี้

// --- Element hierarchy: ชนิดค่อนข้างนิ่ง ไม่ค่อยเพิ่มชนิดใหม่บ่อย ---
public interface IShapeVisitor
{
void Visit(Circle c);
void Visit(Square s);
}
public interface IShape
{
void Accept(IShapeVisitor visitor);
}
public class Circle : IShape
{
public double Radius { get; }
public Circle(double radius) => Radius = radius;
// dispatch ครั้งที่ 1 (virtual) → dispatch ครั้งที่ 2 (overload resolution)
public void Accept(IShapeVisitor visitor) => visitor.Visit(this);
}
public class Square : IShape
{
public double Side { get; }
public Square(double side) => Side = side;
public void Accept(IShapeVisitor visitor) => visitor.Visit(this);
}
// --- Concrete visitors: operation ใหม่แต่ละตัว = class ใหม่1 class ---
public class AreaVisitor : IShapeVisitor
{
public double TotalArea { get; private set; }
public void Visit(Circle c) => TotalArea += Math.PI * c.Radius * c.Radius;
public void Visit(Square s) => TotalArea += s.Side * s.Side;
}
public class SvgExportVisitor : IShapeVisitor
{
private readonly StringBuilder _svg = new();
public void Visit(Circle c) =>
_svg.AppendLine($"<circle r=\"{c.Radius}\" />");
public void Visit(Square s) =>
_svg.AppendLine($"<rect width=\"{s.Side}\" height=\"{s.Side}\" />");
public string ToSvg() => _svg.ToString();
}
// --- Client: เดินโครงสร้างแล้วส่ง visitor เข้าไป ---
public class Drawing
{
private readonly List<IShape> _shapes = new()
{
new Circle(5),
new Square(3),
new Circle(2),
};
public double CalculateTotalArea()
{
var visitor = new AreaVisitor();
foreach (var shape in _shapes)
shape.Accept(visitor); // element เลือกเองว่าจะเรียก Visit overload ไหน
return visitor.TotalArea;
}
public string ExportToSvg()
{
var visitor = new SvgExportVisitor();
foreach (var shape in _shapes)
shape.Accept(visitor);
return visitor.ToSvg();
}
}

ข้อสังเกต: ถ้า​ใช้ C# สมัย​ใหม่ (7.0+) ที่​มี pattern matching แบบ switch expression บน​ชนิด บาง​กรณี​ที่ element hierarchy อยู่​ใน code ของ​เรา​เอง​และ​ไม่​ต้อง​ข้าม assembly boundary ก็​สามารถ​เขียน shape switch { Circle c => ..., Square s => ... } แทน Visitor ได้​โดย​ไม่​ต้อง​มี Accept/IVisitor เลย — แต่​แนวทาง​นี้​แลก compile-time exhaustiveness check (ผ่าน exhaustive switch กับ analyzer) มา​แทน​ความ​ปลอดภัย​ที่ Visitor ให้​แบบ built-in ผ่าน interface

  • object structure มี​ชุด​ชนิด (element types) ที่​ค่อน​ข้าง นิ่ง แต่​ต้อง​รองรับ operation ใหม่ ๆ ที่​เพิ่ม​ขึ้น​เรื่อย ๆ ตลอด​อายุ​ระบบ
  • ต้องการ​ดำเนิน​การ​ที่ ไม่​เกี่ยวข้อง​กัน หลาย​แบบ​กับ​โครงสร้าง object เดียวกัน (เช่น validate, export, คำนวณ, พิมพ์​รายงาน) โดย​ไม่​อยาก​ให้ logic เหล่า​นั้น​กระจัดกระจาย​อยู่​ใน​ทุก class
  • ต้องการ​เดิน​ผ่าน (traverse) โครงสร้าง​ที่​ซับซ้อน เช่น AST, object graph หรือ composite tree แล้ว​สะสม​ผลลัพธ์​ระหว่าง​ทาง
  • ต้องการ​เพิ่ม​พฤติกรรม​ให้ class ที่ แก้ code ต้นทาง​ไม่​ได้ หรือ​ไม่​อยาก​เสี่ยง​แก้ code ที่​ทำงาน​อยู่​ใน production แล้ว (สอดคล้อง​กับ Open/Closed Principle)
  • ชุด​ชนิด​ของ element เปลี่ยน​บ่อย — ทุก​ครั้ง​ที่​เพิ่ม​ชนิด​ใหม่​ต้อง​ไล่​แก้​ทุก visitor ที่​มี​อยู่ ซึ่ง​ขัดแย้ง​กับ​จุด​ประสงค์​ของ Open/Closed
  • operation มี​เพียง​ไม่​กี่​ตัว และ​ค่อน​ข้าง​นิ่ง​เช่น​กัน — การ​เพิ่ม virtual method ตรง ๆ ใน​แต่ละ class นั้น​เรียบ​ง่าย​กว่า​มาก ไม่​คุ้ม​กับ​ความ​ซับซ้อน​ของ double dispatch
  • ภาษา​ที่​ใช้​มี pattern matching แบบ exhaustive switch ที่​แข็งแรง​อยู่​แล้ว (เช่น C#, F#, Kotlin) และ element hierarchy อยู่​ใน code ของ​เรา​เอง — บาง​ครั้ง switch expression ให้​ผลลัพธ์​เดียวกัน​โดย​ไม่​ต้อง​มี​โครง IVisitor/Accept
  • visitor ต้อง​เข้าถึง private state ของ element จำนวน​มาก — จะ​ถูก​บังคับ​ให้​เปิด public accessor เพิ่ม​เพื่อ visitor เข้าถึง​ได้ ซึ่ง​อาจ​ทำให้ encapsulation รั่ว
ด้านรายละเอียด
ข้อดีปฏิบัติ​ตาม Open/Closed Principle — เพิ่ม operation ใหม่​โดย​ไม่​แก้ class element เดิม
ข้อดีรวม logic ของ operation หนึ่ง​ตัว​ไว้​ใน​ที่​เดียว (visitor class เดียว) แทนที่​จะ​กระจาย​ใน​ทุก class — สอดคล้อง​กับ Single Responsibility
ข้อดีvisitor สามารถ​สะสม state ระหว่าง​เดิน​โครงสร้าง​ได้​ตาม​ธรรมชาติ (เช่น​นับ​จำนวน, รวม​ยอด)
ข้อ​เสียเพิ่ม element type ใหม่ = ต้อง​แก้​ทุก visitor ที่​มี​อยู่ — ยิ่ง​มี visitor เยอะ​ยิ่ง​เจ็บ
ข้อ​เสียvisitor มัก​ต้องการ public accessor ของ element เพิ่ม​ขึ้น อาจ​ทำให้ encapsulation อ่อน​ลง
ข้อ​เสียเพิ่ม indirection และ boilerplate (Accept, IVisitor, overload ต่อ​ชนิด) เทียบ​กับ switch/pattern matching ธรรมดา
  • Composite — Visitor มัก​ถูก​ใช้​คู่​กับ Composite เพื่อ​เดิน​ผ่าน​โครงสร้าง​ต้นไม้​ที่​มี node หลาย​ชนิด
  • Interpreter — AST ของ Interpreter เป็น​ตัวอย่าง​คลาสสิก​ของ object structure ที่ Visitor เดิน​ผ่าน​เพื่อ evaluate หรือ compile
  • Strategy — ทั้ง​คู่​แยก algorithm ออก​จาก​โครงสร้าง​ข้อมูล แต่ Strategy สลับ algorithm หนึ่ง​ตัว​ที่ context เลือก​เอง ส่วน Visitor กระจาย operation ไป​ตาม​หลาย​ชนิด​ของ element ผ่าน double dispatch
  • State — ใช้ single dispatch ผ่าน polymorphism ปกติ ต่าง​จาก Visitor ที่​ต้อง double dispatch เพื่อ​แยก operation ออก​จาก element
  • Open-Closed — หลักการ​ที่ Visitor นำ​มา​ปฏิบัติ​จริง​กับ “การ​เพิ่ม operation” โดย​ไม่​แก้ class element เดิม