C#でストラテジーパターンを用いてメソッドの複雑性を下げる

現実は紹介する例より圧倒的に複雑かと思いますが、以下のような状況でC#で利用できるデザインパターンの一つである、ストラテジーパターンを使ったリファクタリングの方法を紹介したいと思います。

  • あるメソッドに長大な処理が書かれている
  • メソッド内である変数による定型的な動作分岐がある

基本的にStrategy パターンを使うので、Strategy パターンって何?という人はこちらを確認してください。

コード例

例えば、とても長い実装がされた以下のようなメソッドあったとします。メソッドの中身に変数によって定型的な処理の分岐があったとします。

public class Hoge
{
    public string Func(int mode, string str)
    {
        if(mode == 1)
        {
            // 長い処理
        }
        else if(mode == 2)
        {
            // 長い処理
        }
        else if(mode == 3)
        {
            // 長い処理
        }
        
        throw new NotSupportedException($"This mode is not suported. mode={mode}");
    }
}

対応方針

上記のコードの複雑性を下げるために、C#の言語機能も考慮して以下の作戦を立てます。

  • 作用を確認するためのテストメソッド(MSTest)の作成(オプション)
  • 対策(1):関数テーブル化
  • 対策(2):Modeごとの処理をクラス化
  • 対策(3):ストラテジーパターンを適用する

作用を確認するためのテストメソッドの作成

普段確認テストを書く習慣が無いとなかなか作成する気にならないですが始める前にテストコードを作成しましょう。

(ソリューションに単体テストを作成する方法化こちら)http://takachan.hatenablog.com/entry/2017/11/10/021842

テストケースは完全なものがあれば、もちろん最高ですが、どうせそんなことは無理なのでわかってる範囲で書きましょう。まだ前準備なので頑張りすぎないようにしましょう。

[TestClass]
public class UnitTest1
{
    [TestMethod]
    public void TestMethod1()
    {
        var h = new Hoge();
        string retStr = h.Func(1, "a");
        Assert.AreEqual("ok", retStr); // 実行結果の確認
        
        retStr = h.Func(1, "b");
        Assert.AreEqual("ng", retStr); // 実行結果の確認
        
        // 以下略
    }
}

関数テーブル化

DictionaryのTValueにデリゲートが設定できることを利用したリファクタリングになります。この方法の特徴は以下の通りです。

  • 長所
    • クラスを増やさない
    • メソッドの複雑度を下げることができる
    • 個々の処理に分割されるので可読性が上がる
  • 短所
    • コードが少し長くなる
public class Hoge
{
    public string Func(int mode, string str)
    {
        //if (mode == 1)
        //{
        //    // 長い処理
        //}
        //else if (mode == 2)
        //{
        //    // 長い処理
        //}
        //else if (mode == 3)
        //{
        //    // 長い処理
        //}

        //throw new NotSupportedException($"This mode is not suported. mode={mode}");
        
        // ↑上記処理を以下の通り置き換える↓
        IDictionary<int, Func<string, string>> funcTable = this.Table;

        if (!funcTable.ContainsKey(mode))
        {
            throw new NotSupportedException($"This mode is not suported. mode={mode}");
        }

        return funcTable[mode](str);
    }
    
    // ★関数テーブルを作成
    public IDictionary<int, Func<string, string>> Table => new Dictionary<int, Func<string, string>>()
    {
        { 1, this.FuncMode1 },
        { 2, this.FuncMode2 },
        { 3, this.FuncMode3 },
    };
    
    // ★Modeに対応した個別の処理を追加
    public string FuncMode1(string str)
    {
        return "";
    }

    public string FuncMode2(string str)
    {
        return "";
    }

    public string FuncMode3(string str)
    {
        return "";
    }
}

分岐ごとの処理をクラス化

先ほどメソッド化したものをインターフェースを継承した処理クラスに分割する手法です。ストラテジーパターンとC#固有機能を組み合わせて実装します。

  • 長所
    • もともとの呼び出し箇所がシンプル化する
    • クラス・メソッドの複雑度を更に下げることができる
    • 個々の処理に分割されるので可読性が上がる
    • 変更の影響の局所化の説明がしやすい
  • 短所
    • クラスが増える
    • コードが少し長くなる
    • コード追跡時に若干追いにくくなる

コードは、(1)共通のシグネチャを表すためのインターフェース、(2)クラスを作成するためのファクトリ、(3)実際に処理を実行するところ、の3つの部分からなります。

// メイン処理
public string Func(int mode, string str)
{
    return SimpleIHogeFuncFactory.Create(mode).InvokeMode(str); // これだけ
}
// Modeごとの処理を表すインターフェース
public interface IHogeFunc
{
    string InvokeMode(string str);
}

// Modeごとの処理クラスを取得するためのファクトリ
public static class SimpleIHogeFuncFactory
{
    private static readonly IDictionary<int, Func<IHogeFunc>> table =  
        new Dictionary<int, Func<IHogeFunc>>()
    {
        { 1, () => new Func1() },
        { 1, () => new Func2() },
        { 1, () => new Func3() },
    };

    public static IHogeFunc Create(int mode)
    {
        if (!table.ContainsKey(mode))
        {
            throw new NotSupportedException($"This mode is not suported. mode={mode}");
        }

        return table[mode]();
    }
}
// Modeごとの処理クラス
public class Func1 : IHogeFunc
{
    public string InvokeMode(string str) { return ""; }
}

public class Func2 : IHogeFunc
{
    public string InvokeMode(string str) { return ""; }
}

public class Func3 : IHogeFunc
{
    public string InvokeMode(string str) { return ""; }
}

ストラテジーパターンを適用する

上記の処理クラス化とほぼ同じですが、今度はHoge自体をクラス化します。今までは呼び出し元のコードには影響が無いようにしていましたが、こちらは呼び出し元に、ファクトリ経由でクラスを生成・取得するように依頼し変更する必要があります。

呼び出し元の変更が可能であれば余計な階層が減るため全体で少々コードの削減効果があります。

  • 長所
    • 世間で一般的とされるリファクタリングが実施できる
    • リリース後にモードが増えても呼び出し元には影響が出ない
  • 短所
    • 呼び出し側コードを変更する必要がある

コード自体は処理クラス化とほぼ同じです。

// Modeごとの処理を表すインターフェース
public interface IHoge
{
    string Func(string str);
}

// Modeごとの処理クラスを取得するためのファクトリ
public static class SimpleIHogeFactory
{
    private static readonly IDictionary<int, Func<IHoge>> table = new Dictionary<int, Func<IHoge>>()
    {
        { 1, () => new Hoge1() },
        { 1, () => new Hoge2() },
        { 1, () => new Hoge3() },
    };

    public static IHoge Create(int mode)
    {
        if (!table.ContainsKey(mode))
        {
            throw new NotSupportedException($"This mode is not suported. mode={mode}");
        }

        return table[mode]();
    }
}
// Modeごとの処理クラス
internal class Hoge1 : IHoge
{
    public string Func(string str) { return ""; }
}

internal class Hoge2 : IHoge
{
    public string Func(string str) { return ""; }
}

internal class Hoge3 : IHoge
{
    public string Func(string str) { return ""; }
}

基本的に、最後のパターンは影響範囲が大きくなるので使用できないと思いますが、前2つは利用側のシグネチャを維持したままの内部実装の改善(変更)になるため実行しやすいと思います。