メインコンテンツまでスキップ

第79章:Visitor ①:構造と操作を分離する🧳

ねらい 🎯✨

Visitorパターン:データ構造と操作の分離(ダブルディスパッチ)

  • データ構造(ツリー/Composite) はあまり変えたくないけど、やりたい処理(操作)がどんどん増える」問題をスッキリさせる 🧹🌳
  • Visitorの“本質”=構造と操作を分ける、を言葉で説明できるようにする 🗣️💡
  • C#だと何が代替案になるか(switch式のパターンマッチ等)も一緒に整理する 🧠🔀

到達目標 🏁😊

  • 「増えるのは 操作?それとも 要素(型)?」を見分けて、Visitorを採用する/しないを判断できる ✅
  • Visitorの登場人物(Element / Visitor / ConcreteVisitor)を、責務つきで説明できる 👀📌
  • 最小のVisitor実装(double dispatch)を読んで、「何が嬉しいか」を説明できる 🎁✨

手順 🧭🧩

1) まずは“増えるのはどっち?”チェック ✅🔍

Visitorはざっくりこういう時に効きます👇

  • 要素(構造のノード型)は固定(増えにくい)
  • 操作が増える(合計計算・表示・検証・エクスポート・ログ…みたいに)

逆に、こういう時はつらいです👇

  • 要素(ノード型)が増えやすい(新しいノードが頻繁に追加) → Visitorは“全Visitor修正”になりがち💦

2) 「switch式でやる」素朴な書き方を見て、痛みを確認 😵‍💫🔁

C#はswitch式が強いので、最初はこう書きがち👇(読みやすい!…けど、操作が増えると同じswitchが増殖しやすい)

// ノード(例:注文の内訳がツリーになってる想定)
public interface IOrderNode { }

public sealed record LineItem(string Name, decimal UnitPrice, int Quantity) : IOrderNode;
public sealed record ShippingFee(decimal Amount) : IOrderNode;
public sealed record PercentDiscount(decimal Rate) : IOrderNode; // 例: 0.10m = 10%
public sealed record OrderGroup(IReadOnlyList<IOrderNode> Children) : IOrderNode;

// 操作① 合計を出す
public static decimal CalcTotal(IOrderNode node) => node switch
{
LineItem li => li.UnitPrice * li.Quantity,
ShippingFee sf => sf.Amount,
PercentDiscount d => 0m, // ここでは割引は「別処理」にしてるとする(例)
OrderGroup g => g.Children.Sum(CalcTotal),
_ => throw new NotSupportedException()
};

// 操作② レシート文字列を作る(別のswitchが必要になりがち)
public static string ToReceipt(IOrderNode node) => node switch
{
LineItem li => $"{li.Name} x{li.Quantity} = {li.UnitPrice * li.Quantity}",
ShippingFee sf => $"送料 = {sf.Amount}",
PercentDiscount d => $"割引 = {d.Rate:P0}",
OrderGroup g => string.Join(Environment.NewLine, g.Children.Select(ToReceipt)),
_ => throw new NotSupportedException()
};

switch式自体はとても良いです(C#の言語機能としても推奨される書き方の一つ)(Microsoft Learn) ただ、操作が増えるたびに switch が増えると、だんだん「どこを直せば?」になりやすいんですね😵‍💫🌀


3) Visitorの登場人物を“1行で”覚える 🧳🧠

  • Element(要素):訪問される側。Accept(visitor) を持つ 🏠
  • Visitor(訪問者):要素ごとの処理口。Visit(LineItem) みたいに型別メソッドを持つ 🧑‍💼
  • ConcreteVisitor(具体訪問者):実際の操作(合計計算/レシート生成など)を実装する ✍️🧾

ポイントはこれ👇 要素側が visitor を呼ぶことで、型ごとの処理が自然に分岐する(= double dispatch)🔁✨


4) 最小のVisitor実装に置き換える(操作を増やしやすくする)🧩✨

同じ「注文ノード構造」で、操作(Visitor)だけ追加できる形にしてみます👇

public interface IOrderNode
{
void Accept(IOrderNodeVisitor visitor);
}

public interface IOrderNodeVisitor
{
void Visit(LineItem node);
void Visit(ShippingFee node);
void Visit(PercentDiscount node);
void Visit(OrderGroup node);
}

public sealed class LineItem : IOrderNode
{
public string Name { get; }
public decimal UnitPrice { get; }
public int Quantity { get; }

public LineItem(string name, decimal unitPrice, int quantity)
=> (Name, UnitPrice, Quantity) = (name, unitPrice, quantity);

public void Accept(IOrderNodeVisitor visitor) => visitor.Visit(this);
}

public sealed class ShippingFee : IOrderNode
{
public decimal Amount { get; }
public ShippingFee(decimal amount) => Amount = amount;
public void Accept(IOrderNodeVisitor visitor) => visitor.Visit(this);
}

public sealed class PercentDiscount : IOrderNode
{
public decimal Rate { get; } // 0.10m = 10%
public PercentDiscount(decimal rate) => Rate = rate;
public void Accept(IOrderNodeVisitor visitor) => visitor.Visit(this);
}

public sealed class OrderGroup : IOrderNode
{
public IReadOnlyList<IOrderNode> Children { get; }
public OrderGroup(IReadOnlyList<IOrderNode> children) => Children = children;
public void Accept(IOrderNodeVisitor visitor) => visitor.Visit(this);
}

ここからが本番!「操作」をVisitorで増やします👇

操作①:合計(割引は最後にまとめて適用する簡易例)💰

public sealed class TotalPriceVisitor : IOrderNodeVisitor
{
public decimal Subtotal { get; private set; }
public decimal DiscountRateSum { get; private set; } // 簡易に“率を足す”例
public decimal Shipping { get; private set; }

public decimal Total => (Subtotal * (1m - DiscountRateSum)) + Shipping;

public void Visit(LineItem node) => Subtotal += node.UnitPrice * node.Quantity;
public void Visit(ShippingFee node) => Shipping += node.Amount;
public void Visit(PercentDiscount node) => DiscountRateSum += node.Rate;

public void Visit(OrderGroup node)
{
foreach (var child in node.Children)
child.Accept(this);
}
}

操作②:レシート文字列 🧾✨

using System.Text;

public sealed class ReceiptVisitor : IOrderNodeVisitor
{
private readonly StringBuilder _sb = new();
public string Text => _sb.ToString();

public void Visit(LineItem node)
=> _sb.AppendLine($"{node.Name} x{node.Quantity} = {node.UnitPrice * node.Quantity}");

public void Visit(ShippingFee node)
=> _sb.AppendLine($"送料 = {node.Amount}");

public void Visit(PercentDiscount node)
=> _sb.AppendLine($"割引 = {node.Rate:P0}");

public void Visit(OrderGroup node)
{
foreach (var child in node.Children)
child.Accept(this);
}
}

使う側(呼び出し側)はこうなる 🧠👍

var order = new OrderGroup(new IOrderNode[]
{
new LineItem("りんご", 120m, 2),
new LineItem("みかん", 80m, 5),
new PercentDiscount(0.10m),
new ShippingFee(500m),
});

var totalV = new TotalPriceVisitor();
order.Accept(totalV);

var receiptV = new ReceiptVisitor();
order.Accept(receiptV);

Console.WriteLine($"合計: {totalV.Total}");
Console.WriteLine(receiptV.Text);

操作(Visitor)を増やすだけなら、ノード側(構造)をほぼ触らないのが嬉しい!🎉 (例:監査ログVisitor、JSON出力Visitor、検証Visitor…を追加しやすい)


5) 「C#だとVisitorってどこで現役なの?」を知って安心する 🛟😊

Visitorは“教科書の昔話”じゃなくて、C#の標準・定番APIで今も普通に出ます👇

  • 式ツリーを歩き回るための ExpressionVisitor(標準) → 式ツリーを「走査・調査・コピー」する用途で継承して使う設計です(Microsoft Learn)
  • **Roslyn(C#コンパイラAPI)**の構文木を歩く CSharpSyntaxWalker(定番) → 構文木を深さ優先で訪問して、ノード種類ごとのメソッドをoverrideする形です(Microsoft Learn)

「うわ、Visitorむずそう…😇」ってなっても大丈夫! 次章では ExpressionVisitor で“標準のVisitor”を実際に触って、感覚を掴みます🧠✨


6) AI補助の使い方(雛形はAI、判断は人間)🤖🧑‍🏫

Visitorは雛形が多いのでAIが得意です✨ ただし“過剰抽象化”しやすいので、プロンプトに釘を刺すのがコツ🔨😤

例(そのまま使ってOK)👇

  • 「C#でVisitorパターンの最小例を作って。IOrderNodeIOrderNodeVisitor で、ノードは LineItem/ShippingFee/PercentDiscount/OrderGroup の4つ。汎用フレームワーク化しないAcceptVisit 以外の仕組みは足さない。MSTestで簡単なテストも1本。」

よくある落とし穴 ⚠️😵

  • 要素(ノード型)追加が多い案件で採用して地獄 → 新ノード追加のたびに 全Visitorを修正…💥
  • Visitorが状態を持ちすぎて“神クラス”化 → 「合計計算」だけのはずが、検証も出力も…って混ぜない!🧹
  • “Visitorという名前を付けること”が目的化 → 大事なのは「操作が増えた時に、どこを足すかが明確」ってこと🌟
  • C#のswitch式で十分な場面までVisitorにする → 操作が2〜3個で増える予定も薄いなら、switch式のほうが読みやすいことも多いよ🙂(Microsoft Learn)

演習(10〜30分)🧪🌸

  1. “操作が増える痛み”を体験する😵‍💫
  • さっきの素朴版(switch式)で、操作をもう1個追加してみてね 例:Validate()(数量が0以上か、割引率が0〜1か など)
  1. Visitor版に移植する🧳✨
  • ValidationVisitor を1つ追加して、エラーを List<string> に貯める

  • ルール例:

    • LineItem.Quantity <= 0 はNG🙅‍♀️
    • PercentDiscount.Rate < 0 || Rate > 1 はNG🙅‍♀️
  1. 比較メモを書く📝
  • 「操作を1個増やす」時、どっちが楽だった?(switch式 vs Visitor)
  • 「ノード型を1個増やす」時、どっちが楽そう?(switch式 vs Visitor)

自己チェック ✅😊

  • 「増えるのは操作?要素?」をまず確認した?🔍
  • Visitorで増やすべき“操作”が具体的に言える?(例:出力、検証、監査…)🧾
  • Accept(visitor)visitor.Visit(this) の流れを説明できる?🔁
  • 「今回はswitch式で十分」って判断もできる?🙂✨