Visitor Pattern trong C#: switch Expression và Double Dispatch
Visitor pattern trong C# / .NET 10: khi switch expression với pattern matching thắng hierarchy double-dispatch GoF, và khi Visitor cổ điển còn thắng.
Mục lục
- Visitor pattern giải quyết bài toán gì trong C#?
- Visitor giáo khoa với double dispatch trông thế nào?
- Pattern matching thay Visitor trong C# 9+ thế nào?
- Khi nào Visitor cổ điển còn là câu trả lời đúng?
- So sánh Visitor với Iterator và Composite thế nào?
- Khi nào Visitor pattern bắn trật?
- Một ví dụ thật trong .NET 10 trông thế nào?
- Đọc tiếp gì trong series?
Cart từ chương Composite là cây
node LineItem, Bundle, và FreeGift. Business hỏi ba thao
tác mới: tính thuế theo jurisdiction, audit-log mỗi line, và
quyết định mỗi line có đủ điều kiện gói quà free không. Mỗi
thao tác là đi đệ quy; mỗi cái cần state khác.
Bản năng sai là thêm ba method vào ICartNode — ComputeTax(),
AuditDump(), IsGiftWrapEligible(). Hai vấn đề. Một, node
không nên biết về thuế; đó là logic domain không thuộc value
type. Hai, thêm thao tác thứ tư mai ép thêm method khác trên
mọi class node.
Visitor pattern là câu trả lời GoF cho việc này. Mỗi thao
tác thành class riêng đi cây. Class node giữ đóng. Trong C#
hiện đại pattern matching đã hấp thụ phần lớn trách nhiệm này,
nhưng pattern vẫn là tool đúng khi thao tác nặng. Bài này chỉ
cả phiên bản double-dispatch giáo khoa và phiên bản
switch-expression hiện đại, với đánh đổi thật thà.
Visitor pattern giải quyết bài toán gì trong C#?
Pattern xứng chỗ khi tập node typed đóng phải hỗ trợ tập thao tác mọc, và bạn không muốn cứ sửa class node. Ba hình dạng cụ thể:
- Đi AST. Cây cú pháp compiler có node type cố định nhưng thao tác tiến hoá: type-check, optimise, lower, pretty-print, generate code.
- Cây domain. Cây cart, cây document, sơ đồ tổ chức. Tính thuế, audit, format đều là đi riêng.
- Điểm plug-in. Tool đi hierarchy type bên thứ ba (Roslyn
SyntaxNode, EF CoreIEntityType) mà không sửa nó.
Cái không phải bài toán Visitor: "Tôi muốn một thao tác qua cây" — chỉ viết method đệ quy. "Tôi muốn đi theo thứ tự" — đó là Iterator. Visitor đặc biệt nói về nhiều thao tác trên một hierarchy node đóng.
Visitor giáo khoa với double dispatch trông thế nào?
Pattern cổ điển: một method Visit* mỗi type node trên
visitor, và một method Accept(visitor) trên mỗi node:
public interface ICartVisitor<TResult>
{
TResult VisitLine (LineItem line);
TResult VisitBundle (Bundle bundle);
TResult VisitFreeGift(FreeGift gift);
}
public abstract record CartNode
{
public abstract TResult Accept<TResult>(ICartVisitor<TResult> visitor);
}
public sealed record LineItem(string Sku, decimal UnitPrice, int Quantity) : CartNode
{
public override TResult Accept<TResult>(ICartVisitor<TResult> visitor)
=> visitor.VisitLine(this);
}
public sealed record Bundle(string Name, decimal DiscountFactor, IReadOnlyList<CartNode> Children) : CartNode
{
public override TResult Accept<TResult>(ICartVisitor<TResult> visitor)
=> visitor.VisitBundle(this);
}
public sealed record FreeGift(string Sku) : CartNode
{
public override TResult Accept<TResult>(ICartVisitor<TResult> visitor)
=> visitor.VisitFreeGift(this);
}
public sealed class TaxVisitor : ICartVisitor<decimal>
{
private readonly decimal _rate;
public TaxVisitor(decimal rate) => _rate = rate;
public decimal VisitLine(LineItem l) => l.UnitPrice * l.Quantity * _rate;
public decimal VisitFreeGift(FreeGift g) => 0m;
public decimal VisitBundle(Bundle b)
=> b.Children.Sum(c => c.Accept(this)) * b.DiscountFactor * _rate;
}
"Double dispatch" sống trong Accept: mỗi node gọi method
type-specific của visitor, chọn theo type tĩnh của this.
Caller viết:
decimal tax = root.Accept(new TaxVisitor(0.08m));
Thêm thao tác thứ tư — AuditVisitor — là một class mới,
zero edit type node.
Bức tranh cấu trúc:
classDiagram
class CartNode {
<<abstract>>
+Accept(visitor)
}
class LineItem
class Bundle
class FreeGift
CartNode <|-- LineItem
CartNode <|-- Bundle
CartNode <|-- FreeGift
class ICartVisitor {
<<interface>>
+VisitLine(line)
+VisitBundle(b)
+VisitFreeGift(g)
}
class TaxVisitor
class AuditVisitor
ICartVisitor <|.. TaxVisitor
ICartVisitor <|.. AuditVisitor
Đánh đổi: mọi node biết về ICartVisitor. Thêm class node
(Coupon?) giờ là breaking change cho mọi visitor.
Pattern matching thay Visitor trong C# 9+ thế nào?
C# 9 giới thiệu type sealed và switch exhaustive với type
pattern. C# 11 làm required và primary constructor tốt hơn.
Cùng nhau cho phép viết thao tác kiểu Visitor không cần
plumbing Accept:
// Node - sealed; không method Accept.
public abstract record CartNode;
public sealed record LineItem(string Sku, decimal UnitPrice, int Quantity) : CartNode;
public sealed record Bundle(string Name, decimal DiscountFactor, IReadOnlyList<CartNode> Children) : CartNode;
public sealed record FreeGift(string Sku) : CartNode;
// Thao tác 1 - thuế
public static decimal ComputeTax(CartNode node, decimal rate) => node switch
{
LineItem l => l.UnitPrice * l.Quantity * rate,
FreeGift => 0m,
Bundle b => b.Children.Sum(c => ComputeTax(c, rate)) * b.DiscountFactor * rate,
_ => throw new InvalidOperationException("Unknown CartNode")
};
// Thao tác 2 - audit (file mới, không sửa node)
public static IEnumerable<string> AuditLines(CartNode node, int indent = 0) => node switch
{
LineItem l => new[] { $"{new string(' ', indent)}{l.Sku} x{l.Quantity}" },
FreeGift g => new[] { $"{new string(' ', indent)}🎁 {g.Sku}" },
Bundle b => new[] { $"{new string(' ', indent)}{b.Name}" }
.Concat(b.Children.SelectMany(c => AuditLines(c, indent + 2))),
_ => Array.Empty<string>()
};
Cái bạn được:
- Không method
Accepttrên node. Data model là data thuần. - Exhaustiveness compiler-warn. Với node sealed, compiler
có thể flag case thiếu (mức cảnh báo khác theo phiên bản C#;
Roslyn analyzer như
IDE0072giúp). - Thao tác là function. Test dễ hơn, compose dễ hơn với LINQ.
Đánh đổi: abstraction "một thao tác trên cây cart" không còn
type — chỉ là method. Tooling muốn enumerate thao tác có sẵn
không làm được bằng reflection trên implementation
ICartVisitor.
Khi nào Visitor cổ điển còn là câu trả lời đúng?
Ba tình huống bản GoF còn thắng:
- Thao tác có nhiều method, không phải một.
PrintVisitorvớiIndent,EmitToken,Wrap, cộng state per-visitor. Function không share state sạch qua call đệ quy; class thì có. - Tooling enumerate thao tác. Hệ plug-in load
implementation visitor từ assembly ngoài; reflection tìm type
ICartVisitor.switchkhông reflect được. - Cây node thuộc bên thứ ba. Roslyn
CSharpSyntaxWalker,CSharpSyntaxVisitor<TResult>— Roslyn tự là Visitor pattern, và code bạn cắm vào bằng cách subclass visitor đó. Bạn không thay được bằngswitchvì không sở hữu hierarchy node.
Cho code app với cây domain đóng, pattern matching thắng. Cho hệ extensible sâu, Visitor còn xứng chỗ.
So sánh Visitor với Iterator và Composite thế nào?
| Pattern | Mục đích | Hướng |
|---|---|---|
| Visitor | Thêm thao tác mới cho cây đóng | Thao tác pull; cây push qua Accept |
| Iterator | Đi collection từng element một | Caller pull một element |
| Composite | Xử leaf và group đồng nhất | Chỉ hình dạng cây |
Tách rõ: Composite cho hình dạng cây; Iterator yield một element; Visitor thực hiện thao tác qua cả cấu trúc. App .NET điển hình dùng cả ba: Composite cart, Iterator flatten nó, và Visitor tính thuế đệ quy.
Khi nào Visitor pattern bắn trật?
Ba bẫy:
- Hierarchy node mở. Nếu class node mới có thể được thêm
bởi caller, mọi visitor vỡ ở lần thêm kế. Dùng
switchvới default_cộng warning log, hoặc đừng dùng Visitor. - Visitor có state rò qua call.
TaxVisitortích vào field private chạy được cho một đi và vỡ ở đi thứ hai. Làm visitor stateless hoặc truyền state qua đệ quy tường minh. - Visitor async viết sync lúng túng. Interface là
TResult Visit*; biến nó async lúng túng. Hoặc định nghĩa interface trảTask<TResult>từ đầu, hoặc dùng pattern matching nơi async tự nhiên.
Một ví dụ thật trong .NET 10 trông thế nào?
Hình dạng thực dụng: pattern matching cho code app, với Visitor cổ điển nhỏ nơi thật cần (kiến trúc plug-in). Cart từ chương Composite có hai thao tác mới:
public abstract record CartNode;
public sealed record LineItem(string Sku, decimal UnitPrice, int Quantity) : CartNode;
public sealed record Bundle(string Name, decimal DiscountFactor, IReadOnlyList<CartNode> Children) : CartNode;
public sealed record FreeGift(string Sku) : CartNode;
public static class CartOperations
{
public static decimal ComputeTax(CartNode node, decimal rate) => node switch
{
LineItem l => l.UnitPrice * l.Quantity * rate,
FreeGift => 0m,
Bundle b => b.Children.Sum(c => ComputeTax(c, rate)) * b.DiscountFactor * rate,
_ => throw new InvalidOperationException(nameof(node)),
};
public static IEnumerable<string> Audit(CartNode node, int indent = 0) => node switch
{
LineItem l => new[] { $"{new string(' ', indent)}{l.Sku} x{l.Quantity}" },
FreeGift g => new[] { $"{new string(' ', indent)}🎁 {g.Sku}" },
Bundle b => new[] { $"{new string(' ', indent)}{b.Name}" }
.Concat(b.Children.SelectMany(c => Audit(c, indent + 2))),
_ => Array.Empty<string>(),
};
public static bool IsGiftWrapEligible(CartNode node) => node switch
{
LineItem l => l.UnitPrice >= 50m,
FreeGift => true,
Bundle b => b.Children.All(IsGiftWrapEligible),
_ => false,
};
}
// Caller
var cart = new Bundle("Cart", 1.0m, new CartNode[]
{
new LineItem("BOOK", 9.99m, 2),
new Bundle("Gift Box", 0.9m, new CartNode[]
{
new LineItem("MUG", 12m, 1),
new FreeGift("CARD"),
}),
});
var tax = CartOperations.ComputeTax(cart, 0.08m);
var lines = CartOperations.Audit(cart).ToList();
var canWrap = CartOperations.IsGiftWrapEligible(cart);
[Fact]
public void ComputeTax_excludes_free_gifts()
{
var tree = new Bundle("X", 1m, new CartNode[]
{
new LineItem("A", 10m, 1),
new FreeGift("B"),
});
Assert.Equal(0.8m, CartOperations.ComputeTax(tree, 0.08m));
}
Cái bạn không thấy: method Accept trên node nào. Thêm thao
tác thứ tư (SerializeToJson) là một method mới, zero edit
node. Thêm node type mới (Coupon) đòi cập nhật switch mỗi
thao tác — cùng chi phí maintain, nhưng compiler chỉ vào mọi
chỗ bạn bỏ sót.
Đọc tiếp gì trong series?
- Bài trước: Template Method — hook thừa kế cho khung share.
- Bài kế: Cách chọn design pattern phù hợp — cây quyết định buộc cả 23 pattern lại với nhau.
- Tham chiếu chéo: Composite — cây visitor đi.
- Tham chiếu chéo: Iterator — pull một element thay vì thực hiện thao tác qua cả cây.
- Bản đồ series: Giới thiệu.
Một kết thúc thực dụng cho nhóm behavioral: C# hiện đại đã
làm vài pattern behavioral gần như vô hình — Strategy thành
Func<>, Iterator thành yield, Memento thành record with. Visitor là ví dụ nổi bật nhất, vì pattern matching
cho ta tool tốt hơn cho hầu hết case. Bài học xuyên cả
series là cùng: pattern sống vì ý đồ bền; implementation
đổi theo ngôn ngữ.
Câu hỏi thường gặp
Khi nào nên dùng switch expression thay vì class Visitor?
switch expression với type pattern khi node type đóng (bạn kiểm soát mọi biến thể), thao tác ngắn, và bạn muốn mỗi thao tác ở một chỗ. Compiler có thể cảnh báo case thiếu khi node sealed, cho exhaustiveness của Visitor không boilerplate. Với Visitor cổ điển khi thao tác to, có state, hoặc nhiều.Double dispatch là gì và sao Visitor cần nó?
visitor.Visit(this), và overload resolution chọn overload đúng vì this là type concrete tĩnh. Pattern matching C# giải bài cùng kiểu khác.Sao Visitor gây tranh cãi trong code C# hiện đại?
Accept(visitor)), coupling data model với thao tác. Và switch với type pattern đạt cùng tách thao tác khỏi node mà không có coupling đó, cộng compiler báo bạn case thiếu. Visitor đôi khi đúng tool, nhưng default đổi trong C# 9–11.Visitor còn thắng pattern matching ở đâu?
IEvaluator.VisitAdd, VisitMul, VisitConst cộng state per-visitor share. Khi cây node cho phép extension bên thứ ba và visitor phải mở cho chúng. Khi bạn muốn thao tác (PrintVisitor, OptimiseVisitor) là đơn vị test thay vì mỗi nhánh case.