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
ขั้นตอนโดยละเอียด:
- Client สร้าง concrete visitor ขึ้นมาหนึ่งตัว (เช่น
AreaVisitor) แล้วเดิน (traverse) object structure ทีละ element - สำหรับแต่ละ element client เรียก
element.Accept(visitor)— นี่คือ dispatch ครั้งแรก ซึ่งใช้ virtual dispatch ปกติเพื่อเข้าไปยังAcceptของ concrete type ที่ถูกต้อง (Circle.AcceptหรือSquare.Accept) - ภายใน
Acceptแต่ละ class เรียกvisitor.Visit(this)โดยที่thisมีชนิดคอนกรีตชัดเจน (เช่นCircle) — นี่คือ dispatch ครั้งที่2 compiler เลือก overloadVisit(Circle)แทนที่จะเป็นVisit(Square)เพราะ static type ของthisที่จุดเรียกคือCircleไม่ใช่IShape - visitor ทำงานตาม logic ของ operation นั้น อาจอ่านค่าจาก element (ต้องเป็น public/internal เพราะ visitor อยู่นอก class) แล้วเก็บผลสะสมไว้ใน state ภายในตัวมันเอง (เช่นผลรวมพื้นที่)
- เมื่อ traverse ครบทุก element client อ่านผลลัพธ์จาก visitor ได้
ถ้าอยากเพิ่ม operation ใหม่ — เช่น “หาปริมณฑล” — เพียงเขียน PerimeterVisitor : IShapeVisitor ใหม่1 class โดยไม่ต้องแก้ Circle หรือ Square เลย แต่ถ้าอยากเพิ่มชนิด element ใหม่ — เช่น Triangle — ต้องไปเพิ่ม Visit(Triangle t) ในทุก visitor interface/implementation ที่มีอยู่ นี่คือข้อแลกเปลี่ยนหลักของ pattern นี้
ตัวอย่าง code
หัวข้อที่มีชื่อว่า “ตัวอย่าง code”// --- 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 ของเราเอง — บางครั้ง
switchexpression ให้ผลลัพธ์เดียวกันโดยไม่ต้องมีโครง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 เดิม