30
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【C#】 拡張メソッドで型によるswitchやif判定をできるだけ消す

Posted at

今回の話

以前、Visitorパターンで型によるswitchやif判定を消すという記事を書きました。前回のやり方に従えばVisitorパターンを使えばswitch文を消せて型安全にすることができました。

ただ、「じゃあ実際使いやすいのか?」というと正直なところ微妙でした。

そういうわけで今回は「妥協をしてできるだけ安全にしながら使い勝手を保つ」方法を紹介します。

前回のおさらい

問題としたコード

Visitorパターンで型によるswitchやif判定を消すで紹介したコードのおさらいです。

何を重視していたかというと「型の網羅性を保証したい」でした。あるデータ構造群を他のデータ構造に変換するときなど、switchを使ったパータンマッチングで記述することができます。

問題としたコード
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,
            };
        }
    }
}
定義した構造体など
/// キャラクターの抽象クラス
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()
    {
        // 省略…
    }
}
[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; }
}

この方法は愚直でわかりやすいのですが、「型の網羅性」が担保できないという欠点があります。この方法だとあとからデータ構造を追加実装したときに存在するすべてのswitchを洗い出し、そこに新しく追加したデータ構造に対する処理を追加する必要があります。switchの数が1個や2個程度ならいいのですが、大量に使っていた場合はすべての処理を網羅してチェックするのはかなり手間がかかります。また、もし対応に漏れがあったとしてもコンパイルエラーにはなりません。そのためミスってることに実行時までそれに気づくことができないというツラミがあります。

とこれを「Visitorパターンを使ってなんとかしてみた」というのが前回のお話でした。

Visitorパターンの何がよくないのか

単純にVisitorパターンがわかりにくいです。ダブルディスパッチの挙動が追いづらく、一見して何が起きているのかが把握できないので可読性が悪くなります。

また、用途に応じて毎回IVisitorを実装する必要があり、手間がかかりました。

妥協案

switchによるパターンマッチングを許してみる

パターンマッチングで書くことの欠点は「型の網羅性が保証できない」ことでしたが、これにはひとつ前提があります。それは「パターンマッチングを多用すると型の網羅性が保証できなくなる」です。

ということは、パターンマッチングで判定する部分を一箇所に閉じ込めてしまえばいいのでは?という発想で対応してみます。

拡張メソッドでパターンマッチングを一箇所に抑える

Dispatch()という拡張メソッドを定義して、ここにパターンマッチングを書いておきます。
そしてこのメソッドにデリゲートを渡して使うようにすることで、パターンマッチングをここ一箇所に抑えつつ汎用性をもたせることができるようになります。

拡張メソッド
public static class CharacterDispatcher
{
    public static T Dispatch<T>(
        this Character character,
        Func<Hero, T> heroFunc,
        Func<MagicCaster, T> magicCasterFunc,
        Func<Thief, T> thiefFunc,
        Func<Priest, T> priestFunc,
        Func<Warrior, T> warriorFunc)
    {
        return character switch
        {
            Hero hero => heroFunc(hero),
            MagicCaster magicCaster => magicCasterFunc(magicCaster),
            Thief thief => thiefFunc(thief),
            Priest priest => priestFunc(priest),
            Warrior warrior => warriorFunc(warrior),
            _ => throw new NotImplementedException()
        };
    }
}
使用例

// Dispatchを内部的に呼び出すメソッド
private static CharacterDto ToDto(Character character)
{
    return character.Dispatch<CharacterDto>(
        heroFunc: hero => 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,
        },
        magicCasterFunc: magicCaster => 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,
        },
        thiefFunc: thief => 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,
        },
        priestFunc: priest => 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,
        },
        warriorFunc: warrior => 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,
        }
    );
}


private static async ValueTask SampleMethodAsync(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(ToDto).ToArray();

    var saveDataRepository = new JsonSaveDataRepository();

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

引数をとる場合

public static class CharacterDispatcher
{
    // 引数なし
    public static T Dispatch<T>(this Character character,
        Func<Hero, T> heroFunc,
        Func<MagicCaster, T> magicCasterFunc,
        Func<Thief, T> thiefFunc,
        Func<Priest, T> priestFunc,
        Func<Warrior, T> warriorFunc)
    {
        return character switch
        {
            Hero hero => heroFunc(hero),
            MagicCaster magicCaster => magicCasterFunc(magicCaster),
            Thief thief => thiefFunc(thief),
            Priest priest => priestFunc(priest),
            Warrior warrior => warriorFunc(warrior),
            _ => throw new NotImplementedException()
        };
    }

    // 引数あり
    public static T Dispatch<Tin, T>(this Character character,
        Tin input,
        Func<Hero, Tin, T> heroFunc,
        Func<MagicCaster, Tin, T> magicCasterFunc,
        Func<Thief, Tin, T> thiefFunc,
        Func<Priest, Tin, T> priestFunc,
        Func<Warrior, Tin, T> warriorFunc)
    {
        return character switch
        {
            Hero hero => heroFunc(hero, input),
            MagicCaster magicCaster => magicCasterFunc(magicCaster, input),
            Thief thief => thiefFunc(thief, input),
            Priest priest => priestFunc(priest, input),
            Warrior warrior => warriorFunc(warrior, input),
            _ => throw new NotImplementedException()
        };
    }
}
使用例
// Character が SwordItem を装備可能であるか調べる
public bool IsEquipableCharacterSword(Character character, SwordItem item)
{
    return character.Dispatch<SwordItem, bool>(
        item,
        // 勇者は常に装備可能
        (hero, input) => true,
        // 魔法使いは常に装備不可
        (magicCaster, input) => false,
        // 盗賊は重さが10以下なら装備可能
        (thief, input) => input.Weight < 10,
        // 僧侶は常に装備不可
        (priest, input) => false,
        // 戦士は常に装備可能
        (warrior, input) => true
    );
}

メリットとデメリット

メリット

  • パターンマッチングを使う場所を限定できるので型の網羅性を保証しやすくなる
  • Visitorパターンと比較するとまだ読みやすい(都度IVisitorを実装する手間もない)

デメリット

  • 「こういう拡張メソッドが存在する」ということをチーム内に周知しないと普通にswitchでパターンマッチングで書かれてしまう可能性がある
    • うっかり普通にswitchを使ったコードを混ぜられてしまうとこの方法は破綻する
      • (ただ、Visitorパターン使った場合であってもswitchの併記はできてしまっていた)
  • パターンマッチングをベタで使うより読みにくいし書きにくい(ただVisitorパターンよりはマシ)
30
18
0

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
30
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?