LoginSignup
83
62

Visitorパターンで型によるswitchやif判定を消す

Last updated at Posted at 2023-12-29

追記

続きを書きました

今回の話

Visitorパターン」を使ってswitch文やif文での型分岐を置き換えてみよう、という話です。C#で書いてます。

(サンプルコードはだいぶ端折ってるのであまり突っつかないで下さい)

switchやif文のどこが良くないのか

「型の判定によって処理を分岐する」という実装をswitchやifで作ってしまうと、後から構造を変更した際などに問題が起きる可能性があります。
(そもそもオープンクローズド原則違反になってしまう)

たとえば「型をみてデータ構造を変換する」といった場合はこの問題を踏みやすいです。

例:データ構造を1:1変換する

「RPGのキャラクター(ジョブ)」という概念があったとしましょう。
これは基底クラスであり、派生先にいくつかのジョブがあってそれぞれで独自のパラメータを保持しています。

/// キャラクターの抽象クラス
public abstract class Character
{
    // 名前
    public string Name { get; }

    // レベル
    public int Level { get; }

    // 最大体力
    public int MaxHp { get; }

    // 最大魔力
    public int MaxMp { get; }

    // 攻撃力
    public int Atk { get; }

    // 防御力
    public int Def { get; }

    // 素早さ
    public int Spd { get; }

    // 運
    public int Luck { get; }

    // 現在のパラメータなど
    protected int CurrentHp;
    protected int CurrentMp;

    public abstract void Attack();

    public void OnDamaged(int damage)
    {
        CurrentHp -= damage;
    }

    // 以下いろいろ処理が続く
    // コンストラクタとかも存在するが一旦省略
}



// 「勇者」
public sealed class Hero : Character
{
    // 勇者は特別に「剣の威力」のパラメータを個別に持つ
    public int SwordPower { get; }

    public override void Attack()
    {
        // 省略…
    }
}

// 「魔法使い」
public sealed class MagicCaster : Character
{
    // 魔法使いは「覚えた魔法」のパラメータを個別に持つ
    public int[] UnlockedMagicIds { get; }

    public override void Attack()
    {
        // 省略…
    }
}

// 「盗賊」
public sealed class Thief : Character
{
    // 盗賊は特別に「盗む確率」「今まで盗んだ回数」のパラメータを個別に持つ
    public int StealRate { get; }
    public int StealCount { get; }

    public override void Attack()
    {
        // 省略…
    }
}

// 「僧侶」
public sealed class Priest : Character
{
    // 僧侶は特別に「回復魔法の威力」のパラメータを個別に持つ
    public int HealPower { get; }

    public override void Attack()
    {
        // 省略…
    }
}

さて、これらCharacterのデータ構造はRPGゲームの実行中はメモリ上に載って扱われる値です。
ゲームを中断するとなったとき、これらの値を「セーブデータに書き出す」という処理が必要なります。

セーブデータの保存方法はいろいろありますが、今回はシンプルに「JSONとして書き出す」ということにしてみます。
そのためにも、それぞれの型に対応したDTO(Data Transfer Object)へ格納する必要があるため、そのDTOを定義します。

[Serializable]
public class CharacterDto
{
    public string Name { get; set; }
    public int Level { get; set; }
    public int MaxHp { get; set; }
    public int MaxMp { get; set; }
    public int Atk { get; set; }
    public int Def { get; set; }
    public int Spd { get; set; }
    public int Luck { get; set; }
}

[Serializable]
public class HeroDto : CharacterDto
{
    public int SwordPower { get; set; }
}

[Serializable]
public class MagicCasterDto : CharacterDto
{
    public int[] UnlockedMagicIds { get; set; }
}

[Serializable]
public class ThiefDto : CharacterDto
{
    public int StealRate { get; set; }
    public int StealCount { get; set; }
}

[Serializable]
public class PriestDto : CharacterDto
{
    public int HealPower { get; set; }
}

DTOが定義できたのなら、セーブデータの保存機構はこんな形で実装できるはずです。

using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;

namespace Visitors.Samples
{
    public sealed class JsonSaveDataRepository
    {
        public async ValueTask SaveAsync(IList<CharacterDto> characters, string filePath, CancellationToken ct)
        {
            try
            {
                var jsonString = JsonConvert.SerializeObject(characters);
                await File.WriteAllTextAsync(filePath, jsonString, ct);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"An error occurred while saving the data: {ex.Message}");
            }
        }
    }
}

Character -> CharacterDtoをどうするか

ここで問題となるのが「Character」というデータ構造を「CharacterDto」に変換するときです。

ここでシンプルによく使われる方法がswtichでの型判定です。
その場合はこのように書けます。

using System;

namespace Visitors.Samples
{
    public static class CharacterConverter
    {
        /// <summary>
        /// Character型をそれぞれに対応したCharacterDtoに変換する
        /// </summary>
        public static CharacterDto ToDto(Character character)
        {
            switch (character)
            {
                case Hero hero:
                    return ToDto(hero);
                case MagicCaster magicCaster:
                    return ToDto(magicCaster);
                case Priest priest:
                    return ToDto(priest);
                case Thief thief:
                    return ToDto(thief);
                default:
                    throw new ArgumentException($"Unknown character type: {character.GetType()}");
            }
        }

        private static HeroDto ToDto(Hero hero)
        {
            return new HeroDto
            {
                Name = hero.Name,
                Level = hero.Level,
                MaxHp = hero.MaxHp,
                MaxMp = hero.MaxMp,
                Atk = hero.Atk,
                Def = hero.Def,
                Spd = hero.Spd,
                Luck = hero.Luck,
                SwordPower = hero.SwordPower,
            };
        }

        private static MagicCasterDto ToDto(MagicCaster magicCaster)
        {
            return new MagicCasterDto
            {
                Name = magicCaster.Name,
                Level = magicCaster.Level,
                MaxHp = magicCaster.MaxHp,
                MaxMp = magicCaster.MaxMp,
                Atk = magicCaster.Atk,
                Def = magicCaster.Def,
                Spd = magicCaster.Spd,
                Luck = magicCaster.Luck,
                UnlockedMagicIds = magicCaster.UnlockedMagicIds,
            };
        }

        private static PriestDto ToDto(Priest priest)
        {
            return new PriestDto
            {
                Name = priest.Name,
                Level = priest.Level,
                MaxHp = priest.MaxHp,
                MaxMp = priest.MaxMp,
                Atk = priest.Atk,
                Def = priest.Def,
                Spd = priest.Spd,
                Luck = priest.Luck,
                HealPower = priest.HealPower,
            };
        }

        private static ThiefDto ToDto(Thief thief)
        {
            return new ThiefDto
            {
                Name = thief.Name,
                Level = thief.Level,
                MaxHp = thief.MaxHp,
                MaxMp = thief.MaxMp,
                Atk = thief.Atk,
                Def = thief.Def,
                Spd = thief.Spd,
                Luck = thief.Luck,
                StealRate = thief.StealRate,
                StealCount = thief.StealCount,
            };
        }
    }
}
使用例

private static async ValueTask SampleMethod(CancellationToken ct)
{
    var characters = new List<Character>
    {
        // 「Character」型があったとして
        new Hero("勇者", level: 5, maxHp: 100, maxMp: 10, atk: 10, def: 10, spd: 10, luck: 10,
            swordPower: 10),
        new MagicCaster("魔法使い", level: 5, maxHp: 100, maxMp: 10, atk: 10, def: 10, spd: 10, luck: 10,
            unlockedMagicIds: new[] { 1, 2, 3 }),
        new Priest("僧侶", level: 5, maxHp: 100, maxMp: 10, atk: 10, def: 10, spd: 10, luck: 10,
            healPower: 10),
        new Thief("盗賊", level: 5, maxHp: 100, maxMp: 10, atk: 10, def: 10, spd: 10, luck: 10,
            stealRate: 10, stealCount: 10)
    };

    // DTOにまとめて変換
    var dtos = characters.Select(CharacterConverter.ToDto).ToArray();

    var saveDataRepository = new JsonSaveDataRepository();

    // 保存
    await saveDataRepository.SaveAsync(dtos, "saveData.json", ct);
}

[
	{
		"SwordPower": 10,
		"Name": "勇者",
		"Level": 5,
		"MaxHp": 100,
		"MaxMp": 10,
		"Atk": 10,
		"Def": 10,
		"Spd": 10,
		"Luck": 10
	},
	{
		"UnlockedMagicIds": [
			1,
			2,
			3
		],
		"Name": "魔法使い",
		"Level": 5,
		"MaxHp": 100,
		"MaxMp": 10,
		"Atk": 10,
		"Def": 10,
		"Spd": 10,
		"Luck": 10
	},
	{
		"HealPower": 10,
		"Name": "僧侶",
		"Level": 5,
		"MaxHp": 100,
		"MaxMp": 10,
		"Atk": 10,
		"Def": 10,
		"Spd": 10,
		"Luck": 10
	},
	{
		"StealRate": 10,
		"StealCount": 10,
		"Name": "盗賊",
		"Level": 5,
		"MaxHp": 100,
		"MaxMp": 10,
		"Atk": 10,
		"Def": 10,
		"Spd": 10,
		"Luck": 10
	}
]

問題点

さて、今回のコードでの問題点はここです。

public static CharacterDto ToDto(Character character)
{
    switch (character)
    {
        case Hero hero:
            return ToDto(hero);
        case MagicCaster magicCaster:
            return ToDto(magicCaster);
        case Priest priest:
            return ToDto(priest);
        case Thief thief:
            return ToDto(thief);
        default:
            throw new ArgumentException($"Unknown character type: {character.GetType()}");
    }
}

switchで型を判定して処理を分岐しています。
このコード自体は問題なく動きますし、現時点では問題はありません。

しかし、CharacterおよびCharacterDtoの派生先が増えたときにちょっと問題がでてきます。
たとえば「Warrior(戦士)」というジョブが増えたときを考えてみます。

// 「戦士」
public sealed class Warrior : Character
{
    // 戦士は特別に「スタミナ」のパラメータを個別に持つ
    public int Stamina { get; }

    public override void Attack()
    {
        // 省略…
    }
}

[Serializable]
public class WarriorDto : CharacterDto
{
    public int Stamina { get; set; }
}

そのとき、このswitch文はこのように修正しなくてはいけません。

public static CharacterDto ToDto(Character character)
{
    switch (character)
    {
        case Hero hero:
            return ToDto(hero);
        case MagicCaster magicCaster:
            return ToDto(magicCaster);
        case Priest priest:
            return ToDto(priest);
        case Thief thief:
            return ToDto(thief);

        // ↓ これをここに追加する必要がある
        case Warrior warrior:
            return ToDto(warrior);
        // ↑ 

        default:
            throw new ArgumentException($"Unknown character type: {character.GetType()}");
    }
}

だがしかし、ここでうっかりswitch文の修正を忘れ、Warriorの項目を追加し忘れたときにどうなるでしょうか。

この場合、コンパイルエラーになりません。ビルドが通った上で動いてしまいます。
しかし実際にゲームを動かしていき、いざ戦士をメンバーに加えてセーブしようとしたタイミングでArgumentExceptionが起きるという状況になってしまいます。

つまり「型安全性」が失われており、実際に動かしてみるまでそのミスに気付くことができないというかなり厄介なバグを作りやすい状況になっています。

「型の判定によって処理を分岐する」の問題点です。型安全性が失われてしまい、データ構造を変更した際にコード中のすべてのif文やswitch文を洗い出して修正する必要があり、対応に漏れがあっても実行時までそれに気づけ無いという状況になってしまうのです。

Visitorパターンで回避する

ここまではお膳立てで、ここからが本題です。

ではswitchifを使わずに今回のような場合にどう対応するかですが、デザインパターンの「Visitorパターン」を使います。

今回のCharacterを次のように拡張します。


// Visitorパターン用のインタフェース
public interface ICharacterVisitor<out T>
{
    T Visit(Hero hero);
    T Visit(MagicCaster magicCaster);
    T Visit(Priest priest);
    T Visit(Thief thief);
}

/// キャラクターの抽象クラス
public abstract class Character
{

    // Visitorパターンの受け入れメソッド
    public abstract T Accept<T>(ICharacterVisitor<T> visitor);


    // 名前
    public string Name { get; }

    // レベル
    public int Level { get; }

    // 最大体力
    public int MaxHp { get; }

    // 最大魔力
    public int MaxMp { get; }

    // 攻撃力
    public int Atk { get; }

    // 防御力
    public int Def { get; }

    // 素早さ
    public int Spd { get; }

    // 運
    public int Luck { get; }


    // 現在のパラメータなど
    protected int CurrentHp;
    protected int CurrentMp;

    public abstract void Attack();

    public void OnDamaged(int damage)
    {
        CurrentHp -= damage;
    }

    // コンストラクタ省略
}

// 「勇者」
public sealed class Hero : Character
{
    // 勇者は特別に「剣の威力」のパラメータを個別に持つ
    public int SwordPower { get; }
    
    // Visitorパターンの受け入れメソッド
    public override T Accept<T>(ICharacterVisitor<T> visitor)
    {
        return visitor.Visit(this);
    }

    public override void Attack()
    {
        // 省略…
    }
    
    // コンストラクタ省略
}

// 「魔法使い」
public sealed class MagicCaster : Character
{
    // 魔法使いは「覚えた魔法」のパラメータを個別に持つ
    public int[] UnlockedMagicIds { get; }

    // Visitorパターンの受け入れメソッド
    public override T Accept<T>(ICharacterVisitor<T> visitor)
    {
        return visitor.Visit(this);
    }

    public override void Attack()
    {
        // 省略…
    }

    // コンストラクタ省略
}

// 「盗賊」
public sealed class Thief : Character
{
    // 盗賊は特別に「盗む確率」「今まで盗んだ回数」のパラメータを個別に持つ
    public int StealRate { get; }
    public int StealCount { get; }

    // Visitorパターンの受け入れメソッド
    public override T Accept<T>(ICharacterVisitor<T> visitor)
    {
        return visitor.Visit(this);
    }

    public override void Attack()
    {
        // 省略…
    }

    // コンストラクタ省略

}

// 「僧侶」
public sealed class Priest : Character
{
    // 僧侶は特別に「回復魔法の威力」のパラメータを個別に持つ
    public int HealPower { get; }

    // Visitorパターンの受け入れメソッド
    public override T Accept<T>(ICharacterVisitor<T> visitor)
    {
        return visitor.Visit(this);
    }

    public override void Attack()
    {
        // 省略…
    }

    // コンストラクタ省略
}

ICharacterVisitor<T>が定義され、それを受け入れるCharacter.Accept()が定義されました。

これを用いてCharacterConverterを次のように書き換えることができます。

namespace Visitors.Samples
{
    public sealed class CharacterConverter : ICharacterVisitor<CharacterDto>
    {
        public static readonly CharacterConverter Default = new CharacterConverter();
        
        public CharacterDto Visit(Hero hero)
        {
            return new HeroDto
            {
                Name = hero.Name,
                Level = hero.Level,
                MaxHp = hero.MaxHp,
                MaxMp = hero.MaxMp,
                Atk = hero.Atk,
                Def = hero.Def,
                Spd = hero.Spd,
                Luck = hero.Luck,
                SwordPower = hero.SwordPower,
            };
        }

        public CharacterDto Visit(MagicCaster magicCaster)
        {
            return new MagicCasterDto
            {
                Name = magicCaster.Name,
                Level = magicCaster.Level,
                MaxHp = magicCaster.MaxHp,
                MaxMp = magicCaster.MaxMp,
                Atk = magicCaster.Atk,
                Def = magicCaster.Def,
                Spd = magicCaster.Spd,
                Luck = magicCaster.Luck,
                UnlockedMagicIds = magicCaster.UnlockedMagicIds,
            };
        }

        public CharacterDto Visit(Priest priest)
        {
            return new PriestDto
            {
                Name = priest.Name,
                Level = priest.Level,
                MaxHp = priest.MaxHp,
                MaxMp = priest.MaxMp,
                Atk = priest.Atk,
                Def = priest.Def,
                Spd = priest.Spd,
                Luck = priest.Luck,
                HealPower = priest.HealPower,
            };
        }

        public CharacterDto Visit(Thief thief)
        {
            return new ThiefDto
            {
                Name = thief.Name,
                Level = thief.Level,
                MaxHp = thief.MaxHp,
                MaxMp = thief.MaxMp,
                Atk = thief.Atk,
                Def = thief.Def,
                Spd = thief.Spd,
                Luck = thief.Luck,
                StealRate = thief.StealRate,
                StealCount = thief.StealCount,
            };
        }
    }
}

(すべてCharacterDtoにアップキャストされてしまってるが、少なくともこの例では問題はない)

使用例
private static async ValueTask SampleMethod(CancellationToken ct)
{
    var characters = new List<Character>
    {
        // 「Character」型があったとして
        new Hero("勇者", level: 5, maxHp: 100, maxMp: 10, atk: 10, def: 10, spd: 10, luck: 10,
            swordPower: 10),
        new MagicCaster("魔法使い", level: 5, maxHp: 100, maxMp: 10, atk: 10, def: 10, spd: 10, luck: 10,
            unlockedMagicIds: new[] { 1, 2, 3 }),
        new Priest("僧侶", level: 5, maxHp: 100, maxMp: 10, atk: 10, def: 10, spd: 10, luck: 10,
            healPower: 10),
        new Thief("盗賊", level: 5, maxHp: 100, maxMp: 10, atk: 10, def: 10, spd: 10, luck: 10,
            stealRate: 10, stealCount: 10)
    };

    // DTOにまとめて変換
    var dtos = characters.Select(x =>
    {
        // Visitorパターンを使って型変換
        return x.Accept(CharacterConverter.Default);
    }).ToArray();

    var saveDataRepository = new JsonSaveDataRepository();

    // 保存
    await saveDataRepository.SaveAsync(dtos, "saveData.json", ct);
}

このようにVisitorパターンを使うことで、switchを使わずに型の判定を行って変換する処理を作ることができました。
しかもこの方法は「型安全」です。

Visitorパターンで実装したとき、新しい型が増えるとどうなるか

ではWarrior型を追加してみましょう。

// 「戦士」
public sealed class Warrior : Character
{
    // 戦士は特別に「スタミナ」のパラメータを個別に持つ
    public int Stamina { get; }

    public override T Accept<T>(ICharacterVisitor<T> visitor)
    {
        return visitor.Visit(this);
    }

    public override void Attack()
    {
        // 省略…
    }

    // コンストラクタ省略
}

このとき、ただ追加しただけではこのようなコンパイルエラーが起きます

error1.jpg

ICharacterVisitor<T>Warriorが定義されていない、と怒られている)

これを修正するために、ICharacterVisitor<T>Warriorの定義を追加します。

public interface ICharacterVisitor<out T>
{
    T Visit(Hero hero);
    T Visit(MagicCaster magicCaster);
    T Visit(Priest priest);
    T Visit(Thief thief);

    // これを追加する
    T Visit(Warrior warrior);
}

すると今度はCharacterConverter側でコンパイルエラーが起きました。

error2.png

(「Interface member 'CharacterDto Visitors.Samples.ICharacterVisitor<out CharacterDto>.Visit(Warrior)' is not implemented」、なのでインタフェースのWarrior版のメソッド実装が抜けていると怒られている)

なのでこのコンパイルエラーを修正するためにWarrior版の実装を追加します。

namespace Visitors.Samples
{
    public sealed class CharacterConverter : ICharacterVisitor<CharacterDto>
    {
        public static readonly CharacterConverter Default = new CharacterConverter();
        
        public CharacterDto Visit(Hero hero)
        {
            return new HeroDto
            {
                Name = hero.Name,
                Level = hero.Level,
                MaxHp = hero.MaxHp,
                MaxMp = hero.MaxMp,
                Atk = hero.Atk,
                Def = hero.Def,
                Spd = hero.Spd,
                Luck = hero.Luck,
                SwordPower = hero.SwordPower,
            };
        }

        public CharacterDto Visit(MagicCaster magicCaster)
        {
            return new MagicCasterDto
            {
                Name = magicCaster.Name,
                Level = magicCaster.Level,
                MaxHp = magicCaster.MaxHp,
                MaxMp = magicCaster.MaxMp,
                Atk = magicCaster.Atk,
                Def = magicCaster.Def,
                Spd = magicCaster.Spd,
                Luck = magicCaster.Luck,
                UnlockedMagicIds = magicCaster.UnlockedMagicIds,
            };
        }

        public CharacterDto Visit(Priest priest)
        {
            return new PriestDto
            {
                Name = priest.Name,
                Level = priest.Level,
                MaxHp = priest.MaxHp,
                MaxMp = priest.MaxMp,
                Atk = priest.Atk,
                Def = priest.Def,
                Spd = priest.Spd,
                Luck = priest.Luck,
                HealPower = priest.HealPower,
            };
        }

        public CharacterDto Visit(Thief thief)
        {
            return new ThiefDto
            {
                Name = thief.Name,
                Level = thief.Level,
                MaxHp = thief.MaxHp,
                MaxMp = thief.MaxMp,
                Atk = thief.Atk,
                Def = thief.Def,
                Spd = thief.Spd,
                Luck = thief.Luck,
                StealRate = thief.StealRate,
                StealCount = thief.StealCount,
            };
        }

        // 追加
        public CharacterDto Visit(Warrior warrior)
        {
            return new WarriorDto
            {
                Name = warrior.Name,
                Level = warrior.Level,
                MaxHp = warrior.MaxHp,
                MaxMp = warrior.MaxMp,
                Atk = warrior.Atk,
                Def = warrior.Def,
                Spd = warrior.Spd,
                Luck = warrior.Luck,
                Stamina = warrior.Stamina,
            };
        }
    }
}

なんと、実はこれでWarrior型のConverter実装が終わってしまいました。
このようにWarriorを追加したことで発生したコンパイルエラーを順番に直していったら自然と実装が終わっていました。

もしswitch文で実装をしていた場合はこうスムーズにことは運びません。
(コード中のすべてのCharacterを使ったswitchやifを探す必要があり、「100%対応が終わった」と言い切ることができなくなりずっと「これで対応は終わったのか…?」という不安を抱えることになる)

このようにVisitorパターンを使うことで、「データ構造に変化があったときにそれをコンパイルエラーで気づくことができる」「コンパイルエラーを修正すれば対応が100%完了したと言い切れる」という状況にすることができます。

まとめ

  • Visitorパターンを使えば「型の判定にifswitchを使う」を避けることができる
  • とくに複数人開発するような大規模なプロジェクトほどその恩恵は大きい

ただし、開発規模が小さかったり、型の判定処理が局所的ならifswitchでそのまま書いても良いとは思います。ある型の判定処理がコード中のあちこちに書いてある、みたいな状況下ではVisitorパターンに差し替えることによるメリットが大きくなります。

おまけ

Visit時にデータを受け取れるようにする

IVisitor<in Tin, out T>みたいなのを定義してそれも含めてしまえばOK。

public interface ICharacterVisitor<out T>
{
    T Visit(Hero hero);
    T Visit(MagicCaster magicCaster);
    T Visit(Priest priest);
    T Visit(Thief thief);
    T Visit(Warrior warrior);
}

public interface ICharacterVisitor<in Tin, out T>
{
    T Visit(Hero hero, Tin input);
    T Visit(MagicCaster magicCaster, Tin input);
    T Visit(Priest priest, Tin input);
    T Visit(Thief thief, Tin input);
    T Visit(Warrior warrior, Tin input);
}


/// キャラクターの抽象クラス
public abstract class Character
{
    public abstract T Accept<T>(ICharacterVisitor<T> visitor);
    public abstract T Accept<Tin, T>(ICharacterVisitor<Tin, T> visitor, Tin input);

    // 以下略
}
// SwordItemを装備できるかの判定機
public sealed class CharacterSwordEquipChecker : ICharacterVisitor<SwordItem, bool>
{
    public bool Visit(Hero hero, SwordItem input)
    {
        // 勇者は常に装備可能
        return true;
    }

    public bool Visit(MagicCaster magicCaster, SwordItem input)
    {
        // 魔法使いは装備不可
        return false;
    }

    public bool Visit(Priest priest, SwordItem input)
    {
        // 僧侶は装備不可
        return false;
    }

    public bool Visit(Thief thief, SwordItem input)
    {
        // 盗賊は重さが10未満なら装備可能
        return input.Weight < 10;
    }

    public bool Visit(Warrior warrior, SwordItem input)
    {
        // 戦士は常に装備可能
        return true;
    }
}
83
62
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
83
62