Help us understand the problem. What is going on with this article?

C#(Unity)でのアダプティブコード入門(デザインパターンとSOLID原則)

More than 1 year has passed since last update.

この記事は Akatsuki Advent Calendar 2017 の 9 日目の記事です。
8日目: システム障害解析におけるログのあれこれ

概要

今年読んだ記事・本の中で、個人的に最もためになったのがマイクロソフト公式解説書の「C#実践開発手法」でした。

内容としては、変化に容易に適応できるコード(アダプティブコード)を実現するために、以下の手法をC#でどのように実践するかを解説する本です。

  • アジャイル開発
  • デザインパターン
  • SOLID原則

C#に携わって1~2年ぐらいの方には是非おすすめしたいのですが、440ページあったりしてサラッと読むには若干辛いです。
というわけで、宣伝・復習用にUnityのサンプルプロジェクトと共に基本の要点をまとめました。

※ あくまで自分にとって役に立った部分をまとめたもので、書籍より基礎的かつ部分的になります。
※ 興味のある方は本屋さんとかで手にとって読んで頂ければと思います。

この記事が役立つ可能性がある方

C#・Unityを書き始めて1~2年ぐらいで、SOLID原則などは普段使っていない方

※ デザインパターン・SOLID原則に慣れ親しんでいる方には全く役に立ちません

記事を書いている人

新卒でUnityのゲーム開発プロジェクトに入って1年5ヵ月目ぐらいの、2年目Lv.1のクライアントエンジニア
Unityでの開発とJenkinsのお世話がお仕事です。

Hello "Adaptive Code"

改めてまして、はじめまして、 @hareruyanosuke です。
本記事は「アダプティブコード」の入門編の記事になります。(書籍『C#実践開発手法』の導入です。)
アダプティブコードとは小さな変更量で仕様の変化に対応するコードです。

アダプティブコード=変化に強いコード

普段、おそらく実務で開発を行っている方も個人で開発を行っている方も、たびたび遭遇するのが仕様の変更です。
僕も1年ほど新規開発プロジェクトを通して実感したのですが、まぁとにかく仕様は変わります。
最終的なアウトプットよりも、仕様変更に対応した物量の方が多そうです。

そんなこんなで、アダプティブコードは身に付けるととても有用です。
仕様変更と日夜戦っている方が、この記事の手法を通して少しでも精神的ダメージを減らせてもらえるとうれしいです
(アダプティブコードを身に付けても物理的ダメージは変わらないですが。)

本章では、まずアダプティブコードの真逆、ノンアダプティブコード(不適応なコード)について説明します。

ノンアダプティブコード

アダプティブコードの説明の前に、まずノンアダプティブコード(適応力の低いコード)について見てみましょう。
ノンアダプティブコードには幾つかの特徴が見られることが多いです。

硬直性が高い

抽象化が欠如していたり責務が混在していることが多いと、コードの変更にリスクが伴い硬直性が高くなります。
抽象化が欠如されたコードは、クラスの実体や内部仕様に依存したコードをクライアント側が書かなければならなくなります。
また、責務が混在したコードでは、目的が1つではないモジュールが含まれることで、どこか1箇所の変更が意図しない他のコードへ波及されることが往々にあります。

テスタビリティの欠如

テストが難しかったり、テストに時間がかかる場合、テストは十分に行われません。
そして、テストが不十分なコードには不具合がありますし、変更した際のリスクが極端に高くなります。

スカイフックとクレーン

テスタビリティを確認する上で、スカイフックとクレーンの比喩が参考になります。
スカイフックとは、先行条件を参照せずに何かを説明する方法のことです。
これに対し、クレーンは説明可能な先行条件が存在するものを言います。

プログラミングにはテスタビリティを下げるスカイフックが幾つか存在しますが、それらを置き換えられるクレーンも存在します。

プログラミングにおけるスカイフック
  • 静的メソッド
  • 静的クラス(シングルトンを含む)
  • newを使用するオブジェクトの生成
  • 拡張メソッド
スカイフックを置き換えるクレーン
  • インターフェイス
  • 依存性の注入(DI)
  • 制御の反転
  • ファクトリ

メトリクス的に良くない

  • ユニットテストのカバレッジが低い
  • 循環的複雑度が高い

上記2つは統計的に不具合数と相関します。

ノンアダプティブコードの実例

ここまでノンアダプティブコードの特徴を説明しましたが、やけに文字ばっかりで分かりづらいですね。
そこで、具体的な例として、今回は昔の僕のコードを引っ張り出してきました。

サンプルプロジェクトの説明

この記事の説明で用いているコードのリポジトリは以下になります。
https://github.com/tsukiwo55216/UniDAO

今回ノンアダプティブコード・アダプティブコードのサンプルとして、Android・iOSのローカルストレージにデータを保存する機能を選択しました。
(小さいコードで書けるわりに工夫できるところが多かった。)

また、簡単のためシリアライズ・デシリアライズにはUnityのJsonUtilityを使用しており、
JsonUtilityが対応していないクラスは非対応にする方針を取りました。

昔の実装(ノンアダプティブコード)

去年の僕が同等の仕様で作成したコードを作りなおしたものになります。
エラー部分は省き、見せちゃダメな部分は消しました。

// Scripts/Old/DataAccessObject.cs

using UnityEngine;
using System.IO;

namespace UniDAO.Old
{
    // クライアントのデータ保存・読み込み・削除・存在確認
    public class DataAccessObject
    {
        private static readonly string BasePath = Application.persistentDataPath + "/DAO/";


        private const string Extention = ".json";


        // 存在確認
        public static bool Exists( string fileName )
        {
            return ExistsFile( fileName );
        }

        // カスタムクラスを読み込み
        public static T Read<T>( string fileName )
        {
            string text = ReadFile( fileName );

            if( string.IsNullOrEmpty( text ) )
                return default(T);

            return JsonUtility.FromJson<T>( text );
        }

        // カスタムクラスを保存
        public static void Save( object data, string fileName )
        {
            string text = JsonUtility.ToJson( data );
            OverWriteFile( fileName, text );
        }

        // 削除
        public static void Delete( string fileName )
        {
            DeleteFile( fileName );
        }


        // パスの取得
        private static string MakeFilePath( string fileName )
        {
            return BasePath + "/" + fileName + Extention;
        }

        // ファイルの存在確認
        private static bool ExistsFile( string fileName )
        {
            string path = MakeFilePath( fileName );
            return File.Exists( path );
        }

        // ファイルの読み込み
        private static string ReadFile( string fileName )
        {
            string path = MakeFilePath( fileName );

            if( !ExistsFile( fileName ) )
                return null;

            return File.ReadAllText( path );
        }

        // ファイルの上書き
        private static void OverWriteFile( string fileName, string text )
        {
            if( !Directory.Exists( BasePath ) )
                Directory.CreateDirectory( BasePath );

            string path = MakeFilePath( fileName );

            DeleteFile( fileName );
            File.WriteAllText( path, text );
        }

        // ファイルの削除
        private static void DeleteFile( string fileName )
        {
            string path = MakeFilePath( fileName );

            if( Exists( fileName ) )
                File.Delete( path );
        }
    }


    // Scripts/Old/Example/Example.cs
    // 使用例
    public class Example : MonoBehaviour
    {
        // データ保存用クラス
        [Serializable]
        private class TestData
        {
            public string testString;
            public int    testInt;


            public override string ToString()
            {
                return   "Test Data :: "
                       + "Test String : " + testString + ", "
                       + "Test Int : " + testInt; 
            }
        }


        private void Start()
        {
            var testData = new TestData();
            {
                testData.testString = "string";
                testData.testInt = 15;
            }


            // 保存
            DataAccessObject.Save( testData, "fileName" );

            // 読み込み
            Debug.Log( DataAccessObject.Read<TestData>( "fileName" ).ToString() );

            // 存在確認
            Debug.Log( DataAccessObject.Exists( "fileName" ) );

            // 削除
            DataAccessObject.Delete( "fileName" );
            Debug.Log( DataAccessObject.Exists( "fileName" ) );
        }
    }
}


どこがノンアダプティブか?

去年の僕はわりとしっかり実装したつもりだったのですが、変化に対応できるかという視点で見ると工夫できる余地はたくさんあります。
ノンアダプティブコードの特徴から考えてみます。

硬直性

一見するとコードが短いこともあり、硬直性は低そうですが、各メソッドの責務が多く(ReadメソッドではIO処理・デシリアライズなど)、変更に対する拡張ポイントが用意されていません。

そのため、暗号化・復号化などの処理もこのクラスに追加されていき、結果としてクラスの責務や依存度がどんどん大きくなることが予想されます。
(実際に、これらは追加された仕様です。)

テスタビリティ

低いです、問題点としては以下の点があげられます。

  • 全ての公開メソッドがIO処理を行っている
  • メソッドの責務が複数あるため、テストコードが複雑になりがち

また、スカイフックである静的メソッド・クラスにも引っかかっていますね。
(ただ、こちらはこのクラスの使用用途を考えるとそこまで問題では無さそうです。)

メトリクス

循環的複雑度は問題無さそう、というか循環度が問題になる規模じゃないですね。
ユニットテストのカバレッジは単体テストを行っていないので0%でした。

アダプティブコード

ようやくアダプティブコードの説明まで辿り着けましたが、実はアダプティブコードには「適応力が高い」以外にそんなに明確な定義が無さそうです。(書籍から探す限り)

ただ、これだけだとあまりにもふわっとしているので、個人的に以下の条件満たしているコードを「良いアダプティブコード」としています。

  • ほどよい拡張ポイントがある=将来発生しそうな仕様変更に対する拡張ポイントがある
  • ノンアダプティブコードでは無い

ほどよい拡張ポイントがある

適応力を考える上で大事な視点が、「実際に拡張されるかどうか」ということだと思います。
適応力が高くても実際に仕様変更で拡張ポイントが使われなければ意味がありませんし、
拡張ポイントを作るコストが変更コストより高くなっても意味がありません。

というわけであくまで個人的な意見ですが、拡張ポイントに関してはプロジェクト状況的に起こり得て、かつ現実的な工数で出来る程度に作っておくことが良いかと思います。
(ただ、プランナーやディレクターがどういう風に変更したくなるかは運ですが。)

ノンアダプティブコードでは無い

上の方でノンアダプティブコードとはアダプティプではないコードと言っているので「???」となりそうです。
しかし、ノンアダプティブコードはアダプティブコードよりも明確な特徴があり、アンチパターンを避けていくと良い感じになるという経験則もあるのでこの要件を採用してます。

Try "Adaptive Code"

本章では、前章で紹介したノンアダプティブコードをアダプティブコードに変更し、主要な手法の説明を行います。

アダプティプコードを実践するための武器

書籍内では様々な手法が紹介されていますが、大きく分けると次のようになります。

  • 依存関係の管理
  • インターフェイス
  • デザインパターン
  • TDD(テスト駆動開発)
  • SOLID原則
    • SRP : 単一責務の原則(Single Responsibility Principle)
    • OCP : 開放/閉鎖の原則(Open/Closed Principle)
    • LSP : リスコフの置換原則(Liskov Substitution Principle)
    • ISP : インターフェイス分離の原則(the Interface Segregation Principle)
    • DIP : 依存性反転の原則(Dependency Inversion Principle)

次節からは、サンプルプロジェクトで使用した中で特に効果のあった手法の説明を行っていきます。

武器の実例

サンプルプロジェクトのReadメソッドは、クライアントのJSONファイルを読み込み指定されたクラスとして返すメソッドです。
とてもシンプルなコードではありますが、今回サンプルプロジェクトで使ったアダプティブコードの武器が全て含まれています。
ですので、ここからはReadメソッドに着目して、変化への適応力を上がるための工夫を説明していきます。

まず、改めて昔のコードをのせます。

using UnityEngine;
using System.IO;

namespace UniDAO.Old
{
    // Scripts/Old/DataAccessObject.cs
       // クライアントのデータ読み込み
    public class DataAccessObject
    {
        private static readonly string BasePath = Application.persistentDataPath + "/DAO/";


        private const string Extention = ".json";



        public static T Read<T>( string fileName )
        {
            string text = ReadFile( fileName );

            if( string.IsNullOrEmpty( text ) )
                return default(T);

            return JsonUtility.FromJson<T>( text );
        }


        private static string MakeFilePath( string fileName )
        {
            return BasePath + "/" + fileName + Extention;
        }

        private static bool ExistsFile( string fileName )
        {
            string path = MakeFilePath( fileName );
            return File.Exists( path );
        }

        private static string ReadFile( string fileName )
        {
            string path = MakeFilePath( fileName );

            if( !ExistsFile( fileName ) )
                return null;

            return File.ReadAllText( path );
        }
    }
}

昔のReadメソッドでの問題点を再掲しておきます。
- IO処理があり、実行環境がテストに影響する。
- 責務が複数ある。(IO処理・デシリアライズ)
- 拡張ポイントが無く、仕様変更によりこのメソッドが変更され責務が増大する可能性が高い。

それでは、今回アダプティブコードを意識して作成したサンプルの旧Readメソッドに対応する部分を見てみましょう。

namespace UniDAO
{
    // Scripts/Main/IRead.cs
    // インターフェイス:読み込み
    public interface IRead<T>
    {
        T Read();
    }


    // Scripts/Main/Factory/DaoFactory.cs
    // IReadの作成
    public class DaoFactory
    {
        private readonly string basePath;
        private readonly string fileName;


        private DaoFactory( string fileName )
        {
            this.basePath = Application.persistentDataPath + "/DAO/";
            this.fileName = fileName;
        }


        public static IRead<T> CreateReader<T>( string fileName )
        {
            return new DaoFactory( fileName ).CreateReader<T>();
        }


        private IRead<T> CreateReader<T>( string fileName )
        {
            var textReader = new TextReader( basePath, fileName );
            return new DeserializeReader<T>( textReader );
        }
    }


    // Scripts/Main/Read/TextReader.cs
    // テキストの読み込み
    public class TextReader : IRead<string>
    {
        private readonly string basePath;
        private readonly string fileName;


        public TextReader( string basePath, string fileName )
        {
            this.basePath = basePath;
            this.fileName = fileName;
        }


        public string Read()
        {
            string path = basePath + fileName;

            if( !Directory.Exists( basePath ) || !File.Exists( path ) )
                return null;

            return File.ReadAllText( path );
        }
    }


    // Scripts/Main/Read/DeserializeReader.cs
    // Jsonを指定されたクラスとして読み込み
    public class DeserializeReader<T> : IRead<T>
    {
        private readonly IRead<string> stringReader;


        public DeserializeReader( IRead<string> stringReader )
        {
            this.stringReader = stringReader;
        }


        public T Read()
        {
            string text = stringReader.Read();

            if( string.IsNullOrEmpty( text ) )
                return default(T);

            return JsonUtility.FromJson<T>( text );
        }
    }


    // Scripts/Main/Example/Example.cs
    // 使用例
    public class Example : MonoBehaviour
    {
        // データ保存用クラス
        [Serializable]
        private class TestData
        {
            public string testString;
            public int    testInt;


            public override string ToString()
            {
                return   "Test Data :: "
                       + "Test String : " + testString + ", "
                       + "Test Int : " + testInt; 
            }
        }


        private void Start()
        {
            // Data Access Objectの取得
            var testDataDAO = DaoFactory.Create<TestData>( "fileName" );

            var testData = new TestData();
            {
                testData.testString = "test";
                testData.testInt = 99;
            }


            // 保存
            testDataDAO.Save( testData );

            // 読み込み
            Debug.Log( testDataDAO.Read().ToString() );

            // 存在確認
            Debug.Log( testDataDAO.Exists() );

            // 削除
            testDataDAO.Delete();
            Debug.Log( testDataDAO.Exists() );
        }
    }
}


一見するとクラス数もコード量も増えており、複雑になっただけの様な気がしますね。
ここは落ち着いて、武器を見ていきましょう。

武器その1:単一責務の原則(Single Responsibility Principle)

はじめに使っている武器は、単一責務の原則です。
単一責務の原則は、「クラスを変更する理由は複数存在してはならない」というものです。
この原則は、複数の責務が割り当てられたクラスをより小さなクラスへ分割します。
今回のサンプルプロジェクトでは、旧Readメソッドが持っていた「ファイルの読み込み」・「デシリアライズ」という責務が、TextReaderとDeserializeReaderへ分離されています。

実際に、クラスの責務が減少すると何が良いのでしょうか?
まず、1つ言えることは単体テストを行いやすくなります。
旧Readメソッドをテストする場合、「ファイルの読み込み」・「デシリアライズ」の処理を組み合わせたテストを書かなければなりません。
しかし、責務の分割によりそれぞれに特化した単体テストを書けるようになります。

また、責務を分割すると自然に処理の委譲が行われ、モックの使用が容易になります。
例えば、DeserializeReaderはテキストの読み込み処理を外部から注入されたオブジェクトに委譲しているので、
以下のMockStringReaderの様なモックを使用することが出来ます。

// Test/Scripts/MockStringReader.cs
// 簡単なモック

using UniDAO;

namespace UniDAO.Test
{
    public class MockStringReader : IRead<string>
    {
        private readonly string cache;


        public MockStringReader( string cache )
        {
            this.cache = cache;
        }


        public string Read()
        {
            return cache;
        }
    }
}

また、分離されたクラス毎に単体テストを用意しておくことで、デシリアライザをJsonUtilityから変更する場合や
読み込み先をDBに変更する場合にそれぞれのテストを変更するだけで対応できます。

そして、単一責務の原則の最大の利点はクラスが小さくなることかと思います。
小さく役割が明確なクラスは使いやすいですし、何より可読性が高いです。

武器その2:Decoratorパターン

単一責務の原則と相性が良いデザインパターンが、Decoratorパターンです。
Decoratorパターンを使用することで、責務の結びつきが強い部分も別クラスとして分割することが出来ます。

実際に、読み込み時に復号化とログ出力を行うよう変更する場合を考えてみましょう。
従来のコードでは、Readメソッドに追加の処理を行うことが予想されます。

// そのまま関数を拡張したパターン

namespace UniDAO.Old
{
    public class DataAccessObject
    {
        public static T Read<T>( string fileName )
        {
            // 読み込まれたテキストは暗号化されている想定
            string encryptedText = ReadFile( fileName );

            // 何らかの方法で復号化
            string text = Decrypt( encryptedText );

                                                // ログを出力
            Debug.Log( text );

            if( string.IsNullOrEmpty( text ) )
                return default(T);

            return JsonUtility.FromJson<T>( text );
        }
    }
}

Readメソッドの責務が増大し、テストもしづらくなっているかと思います。
一方、新しいコードで機能追加する場合を見てみましょう。

// デコレーターで拡張したパターン

namespace UniDAO
{
                // 復号化
    public class DecryptReader : IRead<string>
    {
        private readonly IRead<string> stringReader;


        public DecryptReader( IRead<string> stringReader )
        {
            this.stringReader = stringReader;
        }


        public string Read()
        {
            return Decrypt( stringReader.Read() );
        }
    }

    // ログ出力
    public class LogReader : IRead<string>
    {
        private readonly IRead<string> stringReader;


        public LogReader( IRead<string> stringReader )
        {
            this.stringReader = stringReader;
        }


        public string Read()
        {
            string str = stringReader.Read();
            Debug.Log( str );

            return str;
        }
    }


    public class DaoFactory
    {

        private IRead<T> CreateReader<T>( string fileName )
        {
            var textReader  = new TextReader( basePath, fileName );

            // 復号化機能をデコレーション
            var decryptReader = new DecryptReader( textReader );

                                                // ログ出力機能をデコレーション
            var logReader  = new LogReader( decryptReader );

            return new DeserializeReader<T>( logReader );
        }
    }
}

Decoratorパターンで拡張を行うと、クラス数が増加したりFactoryの組み立て処理が複雑になりますが、
各クラスのテスタビリティは下がりません。
これは、比較的安全に拡張が出来ていることを意味します。

武器その3:Poor Man's Dependency Injectionパターン

Poor Man's Dependency InjectionパターンはDIパターンの中で最も単純なものになります。
パターンの説明の前に、関連する依存性反転の原則(Dependency Inversion Principle)について少し記載します。

依存性反転の原則は、サンプルプロジェクトではDeserializeReaderに使われています。

namespace UniDAO
{
    // Scripts/Main/Read/DeserializeReader.cs
    // Jsonを指定されたクラスとして読み込み
    public class DeserializeReader<T> : IRead<T>
    {
        private readonly IRead<string> stringReader;


        public DeserializeReader( IRead<string> stringReader )
        {
            this.stringReader = stringReader;
        }


        public T Read()
        {
            string text = stringReader.Read();

            if( string.IsNullOrEmpty( text ) )
                return default(T);

            return JsonUtility.FromJson<T>( text );
        }
    }
}

DeserializeReaderは旧Readメソッドの「デシリアライズ」・「ファイルの読み込み」処理のうち、「ファイルの読み込み」処理を外部から注入するよう変更しています。
この変更により、「ファイルの読み込み」処理以外に、Fileクラスへの依存関係も外部から注入されるようになっております。
つまり、旧ReadメソッドではReadメソッドを使用するクライアントは暗黙的にFileクラスへの依存を行っていたのに対して、DeserializeReaderのクライアントはオブジェクト生成時に明示的にFileクラスへの依存を注入することになり、依存関係が反転します。

依存性の反転によるメリットは、上位レイヤーが下位レイヤーに依存することを防ぎ、クラス間を疎結合に保つことにあります。
例えば、DeserializeReaderはFileクラスへの依存が無くなっているため、読み込み先がデータベースに変わってもコードを変更する必要がありません。

さて、制御の反転を使うことでクラス間の依存度が減り開発者は幸せになりますが、コードを動かすにはどこかで実装を注入しなければならず、注入方法をパターン化したものが俗に言うDI(Dependency Injection)パターンです。
DIパターンの実装にはDIコンテナを使ったものなど様々なものがありますが、今回はオブジェクトの依存関係を自力で解決するPoor Man's Dependency Injectionパターンを用いています。
コードを見てみましょう。

namespace UniDAO
{
    // Scripts/Main/Factory/DaoFactory.cs
    // IReadの作成
    public class DaoFactory
    {
        private IRead<T> CreateReader<T>( string fileName )
        {
                                                // 自力で依存関係を解決
            var textReader        = new TextReader( basePath, fileName );
            var decryptReader     = new DecryptReader( textReader );
            var logReader         = new LogReader( decryptReader );
            var deserializeReader = new DeserializeReader<T>( logReader );

            return deserializeReader;
        }
    }
}

CreateReaderメソッドの中で、以下の様に依存関係が解決されていきます。

  • TextReader(Fileクラスへ依存した実装)をDecryptReaderに注入。
  • DecryptReader(復号化処理へ依存した実装)をLogReaderに注入。
  • LogReader(ログクラスへ依存した実装)をDeserializeReaderに注入。
  • 最終的にユーザーに渡されるIReadは、「Fileクラス」「復号化処理」「ログクラス」「デシリアライズ処理」に依存しています。

一見すると単純なパターンではありますが、Factoryの中で依存関係を解決することでクライアントには実装を隠蔽しつつ柔軟に機能を拡張できる強力なパターンです。

ただし、オブジェクトの依存関係グラフが複雑になるとこのパターンはとてつもなく労力を要します。
その際は、DIコンテナの利用などを検討してみて下さい。
(Poor Man's Dependency InjectionパターンはDIコンテナ等を一切持たないので、"Poor Man"だそうです・・・)

ちなみに、せっかく依存性反転の原則に触れたので、より依存性を減らしたDeserializeReaderも紹介しておきます。

namespace UniDAO
{
    public interface IDeserialize<TSource>
    {
        T Deserialize<T>( TSource source );
    }

    // JsonUtilityへの依存をなくしたDeserializeReader
    public class DeserializeReader<T> : IRead<T>
    {
        private readonly IRead<string> stringReader;
        private readonly IDeserialize<string> deserializer;


        public DeserializeReader( IRead<string> stringReader, IDeserialize<string> deserializer )
        {
            this.stringReader = stringReader;
            this.deserializer = deserializer;
        }


        public T Read()
        {
            string text = stringReader.Read();

            if( string.IsNullOrEmpty( text ) )
                return default(T);

            // IDeserializeにデシリアライズ処理を委譲
            return deserializer.Deserialize<T>( text );
        }
    }
}

このように、デシリアライズ処理に関しても外部から注入することが可能です。
その結果、DeserializeReaderはUnityEngine以外のC#環境でも使用可能になり、コードの再利用性が高まっています。
(今回のサンプルプロジェクトでは過度な抽象化と判断し、やめました。)

武器その4:インターフェイス分離の原則(the Interface Segregation Principle)

ここまでReadメソッドの中身について着目してきましたが、最後にIReadインターフェイスでの工夫を説明します。

namespace UniDAO
{
    // Scripts/Main/IRead.cs
    // インターフェイス:読み込み
    public interface IRead<T>
    {
        T Read();
    }
}

・・・・・・どんな工夫があるのかわからないコードですね。
まぁ、けど大丈夫です。ここでの工夫はインターフェイスを分離して小さくしていることです。

旧DataAccessObjectでもそうでしたが、データ保存の処理などは同じクラスに存在確認・読み込み・保存・削除をまとめて書くことが多いです。
そのため、DataAccessObject用のインターフェイスを作成すると以下の様になりがちです。

namespace UniDAO
{
    // インターフェイス:存在確認・読み込み・保存・削除
    public interface IDataAccessObject<T>
    {
        bool Exists();
        T Read();
        void Save( T data );
        void Delete();
    }
}

一見すると、問題無さそうなIDataAccessObjectですが、デコレーターとの相性が悪いです。
例えば、IDataAccessObjectを用いて、「ファイル処理」「シリアライズ」処理を分割した場合を見てみましょう。

using UnityEngine;
using System.IO;

namespace UniDAO
{
    // テキストの存在確認・読み込み・保存・削除
    public class TextDao : IDataAccessObject<string>
    {
        private readonly string basePath;
        private readonly string fileName;


        private string Path
        {
            get
            {
                return basePath + fileName;
            }
        }


        public TextDao( string basePath, string fileName )
        {
            this.basePath = basePath;
            this.fileName = fileName;
        }


        public bool Exists()
        {
            return File.Exists( Path );
        }

        public string Read()
        {
            if( !Exists() )
                return null;

            return File.ReadAllText( Path );
        }

        public void Save( string text )
        {
            if( !Directory.Exists( basePath ) )
                Directory.CreateDirectory( basePath );

            Delete();
            File.WriteAllText( Path, text );
        }

        public void Delete()
        {
            if( Exists() )
                File.Delete( Path );
        }
    }


                // シリアライズしたテキストを保存
    // テキストをデシリアライズして読み込み
    // テキストの存在確認・削除 ← 委譲しているだけ!
    public class SerializeDao<T> : IDataAccessObject<T>
    {
        private readonly IDataAccessObject<string> stringDao;


        public SerializeDao( IDataAccessObject<string> stringDao )
        {
            this.stringDao = stringDao;
        }


        public bool Exists()
        {
            // 委譲しているだけ
            return stringDao.Exists();
        }

        public T Read()
        {
            string text = stringDao.Read();

            if( string.IsNullOrEmpty( text ) )
                return default(T);

            return JsonUtility.FromJson<T>( text );
        }

        public void Save( T data )
        {
            string json = JsonUtility.ToJson( data );
            stringDao.Save( json );
        }

        public void Delete()
        {
            // 委譲しているだけ
            stringDao.Delete();
        }
    }
}

完成したコードはIReadで使っているものとほぼ同一ですが、SerializeDaoのExistsメソッドとDeleteメソッドは処理を委譲しているだけで何もしていません。
このように、責務が多いインターフェイスにDecoratorパターンを使用すると、ただ委譲するだけの処理を何回も書かなくてはなりません。
そのため、工数や可読性とのバランスではあるのですが、基本的には責務が混在したインターフェイスは分割することが推奨されます。

また、補足になりますが、シングルメソッドのインターフェイスは非常に柔軟性が高いので、色々試してみると面白いです。
少ない労力や制約で、様々なデコレーション、合成が可能になります。

まとめ

ここまで、アダプティブコードを実践するための武器について説明を行ってきました。
アダプティプコードというと、中々新しそうな感じはありますが、実際は今まで積み重ねられてきたオブジェクト指向のベストプラクティスの組み合わせと言った感じです。
(紹介した武器は全て10年以上前から使われている)

ただし、どの武器も本当に使いこなすまでは難しいです。
これだけ小さいプロジェクトでもたくさん工夫できる点はありますし、常に熟慮して判断しないと余計な抽象化を生む可能性もあります。

おわりに

本記事は「アダプティブコード」の入門として、書籍『C#実践開発手法』の導入的な内容をサンプルプロジェクトを通して紹介しました。
様々な、アダプティブコードを実現するための様々な武器を紹介してきましたが、書籍には以下の様なより実践的な内容がありますので、興味を持たれた方は是非読んでみて下さい。

  • 依存関係が少ないアセンブリ構成(Stairwayパターン)
  • アスペクト指向
  • 残りのSOLID原則
  • 発展的なDIパターン(DIコンテナ等)
  • 上記を実現するための、ライブラリなど

また、最後にですが一点だけ注意点を。
この記事の中では昔のコードをノンアダプティブコードとして載せましたが、決してあのコードは悪いコードというわけではありません。
基本的にアダプティブコードもコードを書く上での手法というか方針の1つでしかありませんので、絶対的に正ではありません。
(もし似た書き方をされていた方が不快に感じてしまったら、申し訳ありません。)

あとがき

はじめはアドベントカレンダーのネタがなく、取り敢えず要約するかーというテンションでした。
しかし、取り敢えずぐぐってみると、「夜は寝る」の「『C#実践開発手法』を読んだ」にとても良い要約がありました。(無念)

差別化をはかるために、本記事では具体的なサンプルコードと共に説明することを選択したので、
アダプティブコードに興味を持ってもらえた方は上記要約も参照して頂ければと思います。

また、実際の書籍は以下の構成になっておりますが、アダプティブコードに集中するために本記事は「第2部〜第3部のちょっと」の部分に焦点をあてております。

  • 第1部:アジャイルの基礎
  • 第2部:SOLIDコードの記述
  • 第3部:アダプティブサンプル

省略してしまいましたが、アジャイルの基礎もアダプティブコードを実現するために必要不可欠な手法でありますので、
そのあたりは誤解が無ければ嬉しいなと思います。

参考

メインの参考元

以下は記事内で使用したリンク先になります。

aktsk
株式会社アカツキは、スマートフォンゲームの企画開発を中心に事業を展開しております。創業以来全てのゲームを内製しているため、高い技術ノウハウが蓄積されています。今後は、新規事業の立ち上げも行ってまいります。
http://aktsk.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away