Visitor Pattern: Double Dispatch, ExpressionVisitor ve Pattern Matching ile Karşılaştırma
Visitor, C#'ın tek boyutlu virtual dispatch'ini Accept ve Visit çağrılarıyla ikiye katlayarak sabit bir tip hiyerarşisine element'lere dokunmadan yeni işlemler eklemeyi sağlar; bedeli, yeni bir tipin bütün visitor'ları kırmasıdır. Overload seçiminin static tip tuzağı, expression problem, ExpressionVisitor ve Roslyn CSharpSyntaxWalker, ve exhaustiveness kontrolü olmayan switch expression ile karşılaştırma.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un fatura ve sipariş özeti belgeleri birkaç element tipinden oluşuyor: paragraf, tablo, görsel. Belgeyi HTML'e (HyperText Markup Language) çeviren class her tip için ayrı bir overload yazmış, bir de "tanımadığım element" için genel bir overload:
DocumentElement[] invoice =
[
new Paragraph("Order #1042"),
new Table(["Title", "Qty"], [["Dune", "2"]]),
new Image("logo.png", 120),
];
HtmlRenderer renderer = new();
foreach (DocumentElement element in invoice)
{
Console.WriteLine(renderer.Render(element));
}
Console.WriteLine(renderer.Render(new Paragraph("Thank you")));
public abstract record DocumentElement;
public sealed record Paragraph(string Text) : DocumentElement;
public sealed record Table(string[] Header, string[][] Rows) : DocumentElement;
public sealed record Image(string Path, int WidthPx) : DocumentElement;
public sealed class HtmlRenderer
{
public string Render(DocumentElement element) => "<!-- unknown element -->";
public string Render(Paragraph paragraph) => $"<p>{paragraph.Text}</p>";
public string Render(Table table) => $"<table>{table.Rows.Length} rows</table>";
public string Render(Image image) => $"<img src=\"{image.Path}\">";
}
Döngü üç element'i tek tek render ediyor, son satır ise doğrudan bir Paragraph gönderiyor. Ekranda dört satır görünecek; hangileri <p>, hangileri <!-- unknown element --> olur?
Single Dispatch ve Overload'un Sınırı
Çıktı:
<!-- unknown element -->
<!-- unknown element -->
<!-- unknown element -->
<p>Thank you</p>
Döngüdeki üç element'in üçü de "unknown" çıktı; döngünün dışındaki Paragraph ise doğru render edildi. Sebep, C#'ta overload seçiminin derleme zamanında, argümanın static tipine göre yapılması. Döngü değişkeni DocumentElement tipinde; derleyici o satır için Render(DocumentElement)'ı seçiyor ve runtime'da element'in gerçekte bir Table olması bu kararı değiştirmiyor. Son satırda ise static tip zaten Paragraph.
Runtime tipine göre seçim yapan tek mekanizma virtual method çağrısı ve o da yalnızca bir boyutta çalışır: element.Something() dediğinde hangi implementation'ın çalışacağına element'in runtime tipi karar verir. Buna single dispatch denir. Bizim istediğimiz ise iki boyutlu bir seçim: hem element'in tipi (paragraf, tablo, görsel) hem de yapılacak işlem (HTML, düz metin, kelime sayımı). Bunu dilin kendisi vermiyor; Visitor pattern iki tane single dispatch'i art arda bağlayarak bu double dispatch'i kuruyor.
İlk akla gelen çözüm, genel overload'un içinde if (element is Table t) return Render(t); zinciri yazmak. Çalışır, ama yeni bir işlem eklediğin her yerde aynı zinciri tekrar yazarsın ve unuttuğun bir tip sessizce "unknown" olur.
Double Dispatch: Accept ve Visit
GoF (Gang of Four: Gamma, Helm, Johnson ve Vlissides'in 1994 tarihli Design Patterns kitabı) Visitor'ın amacını şöyle anlatır: bir object yapısının element'leri üzerinde yapılacak bir işlemi temsil et; Visitor, element'lerin class'larını değiştirmeden yeni işlemler tanımlamanı sağlar. Yapının iki parçası var. Her element tipi bir Accept method'u taşır ve visitor'ın kendisine ait Visit method'unu çağırır:
namespace Bookstore.Documents;
public interface IDocumentVisitor<out TResult>
{
TResult VisitParagraph(Paragraph paragraph);
TResult VisitTable(Table table);
TResult VisitImage(Image image);
}
public abstract record DocumentElement
{
public abstract TResult Accept<TResult>(IDocumentVisitor<TResult> visitor);
}
public sealed record Paragraph(string Text) : DocumentElement
{
// İkinci dispatch: this'in tipi burada derleme zamanında belli
public override TResult Accept<TResult>(IDocumentVisitor<TResult> visitor) => visitor.VisitParagraph(this);
}
public sealed record Table(string[] Header, string[][] Rows) : DocumentElement
{
public override TResult Accept<TResult>(IDocumentVisitor<TResult> visitor) => visitor.VisitTable(this);
}
public sealed record Image(string Path, int WidthPx) : DocumentElement
{
public override TResult Accept<TResult>(IDocumentVisitor<TResult> visitor) => visitor.VisitImage(this);
}
Her işlem de bir visitor class'ı olur:
namespace Bookstore.Documents;
public sealed class HtmlVisitor : IDocumentVisitor<string>
{
public string VisitParagraph(Paragraph paragraph) => $"<p>{paragraph.Text}</p>";
public string VisitTable(Table table) =>
$"<table><tr>{string.Concat(table.Header.Select(h => $"<th>{h}</th>"))}</tr>" +
string.Concat(table.Rows.Select(r => $"<tr>{string.Concat(r.Select(c => $"<td>{c}</td>"))}</tr>")) +
"</table>";
public string VisitImage(Image image) => $"<img src=\"{image.Path}\" width=\"{image.WidthPx}\">";
}
public sealed class PlainTextVisitor : IDocumentVisitor<string>
{
public string VisitParagraph(Paragraph paragraph) => paragraph.Text;
public string VisitTable(Table table) =>
string.Join(Environment.NewLine, new[] { table.Header }.Concat(table.Rows).Select(r => string.Join(" | ", r)));
public string VisitImage(Image image) => $"[image: {image.Path}]";
}
public sealed class WordCountVisitor : IDocumentVisitor<int>
{
public int VisitParagraph(Paragraph paragraph) => Count(paragraph.Text);
public int VisitTable(Table table) => table.Header.Concat(table.Rows.SelectMany(r => r)).Sum(Count);
public int VisitImage(Image image) => 0;
private static int Count(string text) => text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}
using Bookstore.Documents;
DocumentElement[] invoice =
[
new Paragraph("Order #1042 for Ada Lovelace"),
new Table(["Title", "Qty"], [["Dune", "2"], ["Emma", "1"]]),
new Image("logo.png", 120),
];
HtmlVisitor html = new();
PlainTextVisitor text = new();
WordCountVisitor words = new();
foreach (DocumentElement element in invoice)
{
Console.WriteLine(element.Accept(html));
}
Console.WriteLine();
Console.WriteLine(string.Join(Environment.NewLine, invoice.Select(e => e.Accept(text))));
Console.WriteLine();
Console.WriteLine($"Words: {invoice.Sum(e => e.Accept(words))}");
<p>Order #1042 for Ada Lovelace</p>
<table><tr><th>Title</th><th>Qty</th></tr><tr><td>Dune</td><td>2</td></tr><tr><td>Emma</td><td>1</td></tr></table>
<img src="logo.png" width="120">
Order #1042 for Ada Lovelace
Title | Qty
Dune | 2
Emma | 1
[image: logo.png]
Words: 11
element.Accept(html) çağrısında iki seçim art arda yapılıyor. Birincisi virtual dispatch: Accept abstract olduğu için element'in runtime tipi (Table) kendi implementation'ını çalıştırıyor. İkincisi o implementation'ın içinde oluyor: visitor.VisitTable(this) satırında this'in tipi derleme zamanında kesin olarak Table, ve visitor'ın runtime tipi (HtmlVisitor) interface üzerinden yine virtual dispatch ile seçiliyor. Önce Tahmin Et'te kaybolan bilgi, element'in kendi içine bir kez yazılan Accept satırıyla geri geliyor.
Generic TResult işlemlerin farklı tipte sonuç dönmesini sağlıyor: HTML ve düz metin visitor'ları string, kelime sayacı int döndürüyor. GoF'un orijinal örneklerinde Visit method'ları void döner ve sonucu visitor'ın içinde biriktirir; C#'ta generic'ler bu biriktirmeyi çoğu zaman gereksiz kılıyor. Gerçek bir uygulamada aynı yapıda bir PdfVisitor da olurdu; bir PDF library'si örneği gereksiz büyüteceği için burada onun yerini düz metin visitor'ı tutuyor. Gördüğün gibi yeni bir çıktı formatı eklemek, element'lere dokunmadan yeni bir class yazmak demek.
Expression Problem: Yeni Tip mi, Yeni İşlem mi?
Şimdi tersini deneyelim. Belgelere bir alıntı element'i eklemek istiyoruz. Interface'e TResult VisitQuote(Quote quote); satırını ekleyip yeni tipi yazıyoruz:
using Bookstore.Documents;
Console.WriteLine(new Quote("Dune is a masterpiece.", "Grace").Accept(new HtmlVisitor()));
// Yeni element tipi: interface'e VisitQuote eklendi, Accept onu çağırıyor
public sealed record Quote(string Text, string Author) : DocumentElement
{
public override TResult Accept<TResult>(IDocumentVisitor<TResult> visitor) => visitor.VisitQuote(this);
}
Visitors.cs(15,40): error CS0535: 'PlainTextVisitor' does not implement interface member 'IDocumentVisitor<string>.VisitQuote(Quote)'
Visitors.cs(25,40): error CS0535: 'WordCountVisitor' does not implement interface member 'IDocumentVisitor<int>.VisitQuote(Quote)'
Visitors.cs(3,35): error CS0535: 'HtmlVisitor' does not implement interface member 'IDocumentVisitor<string>.VisitQuote(Quote)'
Derleme üç hatayla duruyor: her visitor, yeni element'i bilmediği için kırıldı. Bu, Visitor'ın bilinçli olarak ödediği bedel. Philip Wadler 1998'de bir e-posta listesinde bu ikilemi "expression problem" diye adlandırdı: bir veri tipi hiyerarşisinde hem yeni tipler hem yeni işlemler eklemek isteriz, ama klasik tasarımların çoğu bunlardan yalnızca birini ucuz yapar.
| Tasarım | Yeni işlem eklemek | Yeni tip eklemek |
|---|---|---|
| Virtual method (her işlem element'in içinde) | Her element class'ına yeni method | Tek yeni class |
| Visitor | Tek yeni visitor class'ı | Interface'e yeni method ve her visitor'a implementation |
switch expression ve type pattern | Tek yeni fonksiyon | Her switch'e yeni bir kol |
Visitor'ın hatası bir bakıma iyi haber: derleyici, yeni tipin işlenmediği her yeri sana tek tek gösteriyor. Hangi tasarımı seçeceğin, hiyerarşinde neyin daha sık değiştiğine bağlı. Belge element'leri yılda bir değişip yeni çıktı formatları sık geliyorsa Visitor; element tipleri sık eklenip işlemler sabitse virtual method daha ucuz.
.NET'te Visitor: ExpressionVisitor
.NET'in en çok kullanılan visitor'ı System.Linq.Expressions.ExpressionVisitor. Bir Expression<Func<...>>, LINQ (Language Integrated Query) provider'larının SQL'e (Structured Query Language) çevirdiği bir ağaç; EF (Entity Framework) Core'un sorgu çevirisi baştan sona bu ağacı gezen ve yeniden yazan visitor'lardan oluşur. Bir filtrenin hangi property'lere dokunduğunu bulan küçük bir visitor:
using System.Linq.Expressions;
Expression<Func<Book, bool>> filter = b => b.Price > 20m && b.Author.Name == "Ada" || b.Stock == 0;
PropertyCollector collector = new();
collector.Visit(filter);
Console.WriteLine(filter.Body);
Console.WriteLine(string.Join(", ", collector.Properties));
public sealed record Author(string Name);
public sealed record Book(string Title, decimal Price, int Stock, Author Author);
public sealed class PropertyCollector : ExpressionVisitor
{
public SortedSet<string> Properties { get; } = [];
protected override Expression VisitMember(MemberExpression node)
{
Properties.Add($"{node.Member.DeclaringType!.Name}.{node.Member.Name}");
// Alt düğümleri gezmeye devam etmek için base'i çağır
return base.VisitMember(node);
}
}
(((b.Price > 20) AndAlso (b.Author.Name == "Ada")) OrElse (b.Stock == 0))
Author.Name, Book.Author, Book.Price, Book.Stock
Yalnızca VisitMember'ı override ettik; ağacın geri kalanını (AndAlso, OrElse, karşılaştırmalar, sabitler) base class gezdi. b.Author.Name iki member erişimi olduğu için iki kayıt üretti. dotnet/runtime kaynağına bakınca double dispatch'in birebir aynısını görürsün: ExpressionVisitor.Visit(node) yalnızca node.Accept(this) çağırır, BinaryExpression.Accept da visitor.VisitBinary(this) döndürür. Burada visitor yalnızca okumak için değil, yeniden yazmak için de kullanılıyor: Visit method'ları bir Expression döndürüyor ve farklı bir düğüm döndürdüğünde ağacın o parçası değişiyor. Serinin iki yazı sonraki bölümünde, Specification pattern'de iki filtreyi birleştirirken bu yeteneğe ihtiyaç duyacağız. C# İleri serisindeki "C#'ta Expression Tree: Lambda'yı Veri Olarak Tutmak ve EF Core'a Köprü" yazısı ağacın kendisini anlatıyor.
Roslyn: CSharpSyntaxWalker
C# derleyicisi Roslyn de syntax tree'yi gezmek için aynı pattern'i sunuyor. CSharpSyntaxWalker, her syntax node tipi için bir Visit... method'u olan bir visitor; ilgilendiğin tipi override edip gerisini base class'a bırakıyorsun. Clean Code serisindeki "Code Smell Kataloğu: C# Kodunda Kokuyu Tanımak" yazısındaki Long Parameter List smell'ini yakalayan bir walker:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
const string source = """
public sealed class OrderService
{
public void Place(Order order, Customer customer) { }
public decimal Total(Order order) => 0m;
private static void Log(string message, int level, bool urgent, string tag, object data) { }
}
""";
SyntaxTree tree = CSharpSyntaxTree.ParseText(source);
LongParameterListWalker walker = new(maxParameters: 3);
walker.Visit(tree.GetRoot());
public sealed class LongParameterListWalker(int maxParameters) : CSharpSyntaxWalker
{
public override void VisitMethodDeclaration(MethodDeclarationSyntax node)
{
int count = node.ParameterList.Parameters.Count;
string flag = count > maxParameters ? " <-- too many" : "";
Console.WriteLine($"{node.Identifier.Text}({count}){flag}");
base.VisitMethodDeclaration(node);
}
}
Place(2)
Total(1)
Log(5) <-- too many
Analyzer'lar ve source generator'lar bu walker'ların biraz daha gelişmiş hâlleri. Roslyn'in syntax node tipi sayısı yüzlerle ölçülüyor; bunun için switch yazmak pratik değil, visitor ise "yalnızca method declaration'larla ilgileniyorum" demeyi tek bir override'a indiriyor. Visitor'ın en iyi çalıştığı yer de tam burası: tip hiyerarşisi büyük, sabit ve başkasının kontrolünde, işlemler ise senin.
Modern Alternatif: switch Expression ve Type Pattern
C# 8 ile gelen switch expression ve type pattern'ler, Visitor'ın çözdüğü iki boyutlu seçimi tek bir fonksiyona sığdırıyor. Element'lerde Accept yok, interface yok:
DocumentElement[] invoice =
[
new Paragraph("Order #1042"),
new Image("logo.png", 120),
new Quote("Dune is a masterpiece.", "Grace"),
];
foreach (DocumentElement element in invoice)
{
Console.WriteLine(Html.Render(element));
}
public abstract record DocumentElement;
public sealed record Paragraph(string Text) : DocumentElement;
public sealed record Table(string[] Header, string[][] Rows) : DocumentElement;
public sealed record Image(string Path, int WidthPx) : DocumentElement;
public sealed record Quote(string Text, string Author) : DocumentElement;
public static class Html
{
public static string Render(DocumentElement element) => element switch
{
Paragraph p => $"<p>{p.Text}</p>",
Table t => $"<table>{t.Rows.Length} rows</table>",
Image { WidthPx: > 600 } i => $"<img src=\"{i.Path}\" class=\"wide\">",
Image i => $"<img src=\"{i.Path}\" width=\"{i.WidthPx}\">",
};
}
Program.cs(21,69): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '_' is not covered.
<p>Order #1042</p>
<img src="logo.png" width="120">
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was Quote { Text = Dune is a masterpiece., Author = Grace }.
Kısalık açık: dört kol, property pattern ile geniş görseller için ayrı bir kural, sıfır tören. Visitor'da aynı kuralı yazmak için VisitImage'in içine bir if koyman gerekirdi. Ama bedel çıktıda: Quote hiyerarşiye eklendi, switch'e eklenmedi ve hata derleme zamanında değil, runtime'da SwitchExpressionException olarak geldi. Derleyici CS8509 ile uyardı, ama söylediği şey "Quote eksik" değil, "_ kalıbı eksik". Çünkü DocumentElement abstract bir record ve derleyici başka bir assembly'nin ondan türemeyeceğini bilemez; hiyerarşinin kapalı olduğunu ifade edemediğin için exhaustiveness kontrolü de yok.
Bu yüzden pratikte iki yol var. Hiyerarşi senin kontrolündeyse ve işlemler az ise switch expression daha okunur; uyarıyı bastırmak için _ => throw new UnreachableException() gibi açık bir kol eklemek en azından hatanın nerede olduğunu söyler. Hiyerarşiye yeni tip eklendiğinde derleme zamanında her işlemin kırılmasını istiyorsan Visitor bu garantiyi bugün veren tek yapı. C# 15 ile gelen union ve closed hiyerarşiler, derleyiciye hiyerarşinin kapalı olduğunu söyleyip switch'e exhaustiveness kontrolü getirmeyi hedefliyor; serinin son yazısında konuşacağız. C# İleri serisindeki "C#'ta Pattern Matching Derinlemesine: Property, Positional ve Tuple Pattern'ler" yazısı pattern'lerin söz dizimini anlatıyor.
Sık Düşülen Tuzaklar
Açık hiyerarşide Visitor. Element tipleri sık ekleniyorsa ya da başka ekipler/plugin'ler yeni tip ekleyebiliyorsa, her ekleme bütün visitor'ları kırar. Üstelik başkasının yazdığı bir visitor'ı sen güncelleyemezsin. Visitor, tip hiyerarşisinin sabit olduğu varsayımı üzerine kurulu; bu varsayım yoksa pattern'in bedeli faydasını geçer.
Visitor'da state biriktirip tekrar kullanmak. GoF tarzı void dönen bir visitor sonucu bir field'da biriktiriyorsa, aynı instance'ı ikinci bir belge için kullanmak birinci belgenin sonucunu da taşır. Sonucu TResult olarak döndürmek ya da her kullanımda yeni instance oluşturmak bu hatayı önler.
Encapsulation'ı visitor için delmek. Visitor element'in iç verisine ihtiyaç duyar; bu yüzden element'lere yalnız visitor'lar için public property'ler eklenir ve yavaş yavaş her şey dışarıya açılır. Element'in davranışı olan kısmı element'te kalmalı; visitor'a yalnızca dışarıya göstermekte sakınca olmayan veri açılmalı.
base.Visit... çağrısını unutmak. ExpressionVisitor ve CSharpSyntaxWalker'da override ettiğin method base'i çağırmazsa o düğümün altındaki ağaç hiç gezilmez. PropertyCollector'da base.VisitMember'ı silersen b.Author.Name için yalnızca Author.Name görürsün, Book.Author kaybolur.
Ne Zaman Kullanma
Hiyerarşi küçük ve tek bir işlem varsa. Üç element tipi ve yalnızca HTML çıktısı için Visitor yazmak, bir interface, üç Accept ve bir visitor class'ı demek. Bir virtual method ya da tek bir switch expression aynı işi yapar.
Yeni tipler yeni işlemlerden daha sık geliyorsa. Expression problem tablosunun ilk satırı bu durum için: davranışı element'in kendisine koymak, yeni tip eklemeyi tek bir class'a indirir.
Tip hiyerarşisi senin değilse ve Accept ekleyemiyorsan. Visitor element'lerin işbirliğini ister. Third-party bir library'nin tiplerini gezmen gerekiyorsa ve library bir visitor sunmuyorsa, switch expression ve type pattern tek gerçekçi seçenek.
Küçük Bir Deneme
★☆☆ Önce Tahmin Et'teki HtmlRenderer'ı, döngü değişkenini dynamic'e cast ederek (renderer.Render((dynamic)element)) çalıştır. Çıktı nasıl değişti? Bu çözüm neden overload seçimini runtime'a taşıyor ve bedeli ne?
★★☆ Quote element'ini Visitor tasarımına tamamla: üç visitor'a VisitQuote ekle, HTML'de <blockquote> kullan. Sonra aynı element'i switch expression'lı sürüme ekle ve iki sürümde de kaç dosyaya dokunduğunu say.
★★★ ExpressionVisitor'dan türeyen ve bir filtredeki decimal sabitlerini yüzde 10 artıran bir PriceInflationVisitor yaz (VisitConstant'ı override et). Yeni ağacı Compile() edip birkaç kitap üzerinde çalıştır ve orijinal filtreyle sonuçları karşılaştır.
Visitor ile Behavioral pattern'lerin sonuna geldik ve GoF kataloğunun 23 pattern'inin hepsini gördük. Bir sonraki yazı bu kataloğun karanlık tarafına bakıyor: God Object, Service Locator, Singleton abuse, Anemic Domain Model ve üç satırlık işe beş pattern koyan pattern hastalığı. Anti-pattern'lerin belirtileri, neden ortaya çıktıkları ve nasıl dönüştürüldükleri.