🙌

オブジェクト指向講座

2022/10/18に公開約21,700字

Qiitaより転載
2作あったので併せて投稿


オブジェクト指向101:定石

2017/12/31

説明用に、手なりでオブジェクト指向っぽくコードを書けるようにするためのチュートリアルです。

最初は「まず作ってから、それをオブジェクト指向っぽく」します。しょっぱなからオブジェクト指向的に考える方法は次書きます。

まずは手続き的にコードを書いてみる

とりあえず「じゃんけんゲーム」を作ってみます。余計なことをせず、手続き的に全部やります。

    internal class Program
    {
        private static void Main(string[] args)
        {
            var random = new Random();
            var opponent = random.Next(3);

            Console.WriteLine("じゃんけんしましょ!");
            Console.WriteLine("グー[1] チョキ[2] パー[3]");

            int player;
            while (true)
            {
                var input = Console.ReadLine();
                if (!int.TryParse(input, out player))
                {
                    Console.WriteLine("半角数字を入力してね!");
                    continue;
                }

                if (player < 1 || player > 3)
                {
                    Console.WriteLine("1~3で入力してね!");
                    continue;
                }
                break;
            }

            player--;

            if (opponent == player)
            {
                Console.WriteLine("あいこでした!");
            }
            else if ((player + 1) % 3 == opponent)
            {
                Console.WriteLine("勝ち!");
            }
            else
            {
                Console.WriteLine("負け!");
            }
        }
    }

じゃんけんの部分がちょっとまどろっこしく書かれていますが、基本的には脳みそ停止で手続き的に書くとこんな感じになるんじゃないかなと思います。早速こいつを分解していきます。

関数を抜き出す

スコープを狭める

本当はその関数の責務とか、再利用性を考えて関数を切りますが、脳を停止して関数を切り出せるようになりたいので、「スコープ」だけを見て関数を切り抜いていきます。

まず上から。

            var random = new Random();
            var opponent = random.Next(3);

randomopponent の取得にしか使っていないにも関わらず、メソッドの最後までスコープが生きているのでこいつを分離します。

        ...
            var opponent = GetOpponent();
        ...

        private static int GetOpponent()
        {
            var random = new Random();
            return random.Next(3);
        }

次に

                var input = Console.ReadLine();

inputplayer の取得にしか使っていませんし、while の中にいるので安全そうに見えますが、while を抜いたりするとスコープが漏れ出てくる可能性があるのでこいつも隔離します。ついでに while 文自体と、player--;Main がその後知る必要が無いので隔離します。

        ...
            Console.WriteLine("じゃんけんしましょ!");
            var opponent = GetOpponent();
            int player = GetPlayer();
        ...

        private static int GetPlayer()
        {
            int player;
            while (true)
            {
                Console.WriteLine("グー[1] チョキ[2] パー[3]");

                var input = Console.ReadLine();

                if (!int.TryParse(input, out player))
                {
                    Console.WriteLine("半角数字を入力してね!");
                    continue;
                }

                if (player < 1 || player > 3)
                {
                    Console.WriteLine("1~3で入力してね!");
                    continue;
                }
                break;
            }

            player--;

            return player;
        }

選択肢の表示も関数の中に入っていたほうが都合が良さそうなので、それも移動しておきました。

こうなりました:

    internal class Program
    {
        private static void Main(string[] args)
        {
            Console.WriteLine("じゃんけんしましょ!");
            var opponent = GetOpponent();
            int player = GetPlayer();

            if (opponent == player)
            {
                Console.WriteLine("あいこでした!");
            }
            else if ((player + 1) % 3 == opponent)
            {
                Console.WriteLine("勝ち!");
            }
            else
            {
                Console.WriteLine("負け!");
            }
        }

        private static int GetOpponent()
        {
            var random = new Random();
            return random.Next(3);
        }

        private static int GetPlayer()
        {
            int player;
            while (true)
            {
                Console.WriteLine("グー[1] チョキ[2] パー[3]");
                var input = Console.ReadLine();
                if (!int.TryParse(input, out player))
                {
                    Console.WriteLine("半角数字を入力してね!");
                    continue;
                }

                if (player < 1 || player > 3)
                {
                    Console.WriteLine("1~3で入力してね!");
                    continue;
                }
                break;
            }

            player--;

            return player;
        }
    }

関心範囲を減らす

ちょっと input の時に混ざっちゃいましたが、「関心範囲を減らす」とはすなわち「知らなきゃいけないことを減らす」ということです。今 Main 関数が目指しているのは、いわゆる「サービス」と呼ばれるもので、「値を取得して、それを別の関数に流す」という役割です。現段階で Main が直接知っていることは、「Opponent の取得」「Player の取得」「勝ち負けの計算」です。この勝ち負けの計算も関数に抜き取ってしまえば、Main 関数は具体的な処理を知らない状態でじゃんけんゲームを遂行することができます。

ということで、勝ち負けの計算を抜き出しました。

        private static void Main(string[] args)
        {
            Console.WriteLine("じゃんけんしましょ!");
            var opponent = GetOpponent();
            int player = GetPlayer();

            DoJanken(player, opponent);
        }

        ...

        private static void DoJanken(int player, int opponent)
        {
            if (opponent == player)
            {
                Console.WriteLine("あいこでした!");
            }
            else if ((player + 1) % 3 == opponent)
            {
                Console.WriteLine("勝ち!");
            }
            else
            {
                Console.WriteLine("負け!");
            }
        }

関数を切り出した結果

    internal class Program
    {
        private static void Main(string[] args)
        {
            Console.WriteLine("じゃんけんしましょ!");
            var opponent = GetOpponent();
            int player = GetPlayer();

            DoJanken(player, opponent);
        }

        private static int GetOpponent()
        {
            var random = new Random();
            return random.Next(3);
        }

        private static int GetPlayer()
        {
            int player;
            while (true)
            {
                Console.WriteLine("グー[1] チョキ[2] パー[3]");
                var input = Console.ReadLine();
                if (!int.TryParse(input, out player))
                {
                    Console.WriteLine("半角数字を入力してね!");
                    continue;
                }

                if (player < 1 || player > 3)
                {
                    Console.WriteLine("1~3で入力してね!");
                    continue;
                }
                break;
            }

            player--;

            return player;
        }

        private static void DoJanken(int player, int opponent)
        {
            if (opponent == player)
            {
                Console.WriteLine("あいこでした!");
            }
            else if ((player + 1) % 3 == opponent)
            {
                Console.WriteLine("勝ち!");
            }
            else
            {
                Console.WriteLine("負け!");
            }
        }
    }

クラスを切り出す

クラスの切り出し方はシンプルで、この切り出した3つの関数をクラスにするだけです。その際、その関数はインターフェースを定義しておきましょう。この有用性については、また後で説明します。

    internal interface IOpponentGetter
    {
        int GetOpponent();
    }

    internal interface IPlayerGetter
    {
        int GetPlayer();
    }

    internal interface IJankenChecker
    {
        void DoJanken(int player, int opponent);
    }

    internal class OpponentGetter : IOpponentGetter
    {
        public int GetOpponent()
        {
            var random = new Random();
            return random.Next(3);
        }
    }

    internal class PlayerGetter : IPlayerGetter
    {
        public int GetPlayer()
        {
            int player;
            while (true)
            {
                Console.WriteLine("グー[1] チョキ[2] パー[3]");
                var input = Console.ReadLine();
                if (!int.TryParse(input, out player))
                {
                    Console.WriteLine("半角数字を入力してね!");
                    continue;
                }

                if (player < 1 || player > 3)
                {
                    Console.WriteLine("1~3で入力してね!");
                    continue;
                }
                break;
            }

            player--;

            return player;
        }
    }

    internal class JankenChecker : IJankenChecker
    {
        public void DoJanken(int player, int opponent)
        {
            if (opponent == player)
            {
                Console.WriteLine("あいこでした!");
            }
            else if ((player + 1) % 3 == opponent)
            {
                Console.WriteLine("勝ち!");
            }
            else
            {
                Console.WriteLine("負け!");
            }
        }
    }

    internal class Program
    {
        private static void Main(string[] args)
        {
            var opponentGetter = new OpponentGetter();
            var playerGetter = new PlayerGetter();
            var jankenChecker = new JankenChecker();

            Console.WriteLine("じゃんけんしましょ!");
            var opponent = opponentGetter.GetOpponent();
            int player = playerGetter.GetPlayer();

            jankenChecker.DoJanken(player, opponent);
        }
    }

Main でそのままインスタンス化したコードを使ってもなんの得もないので、処理をまかなう部分を JankenService として切り出します。

    internal class JankenService
    {
        private readonly IOpponentGetter _opponentGetter;
        private readonly IPlayerGetter _playerGetter;
        private readonly IJankenChecker _jankenChecker;

        public JankenService(
            IOpponentGetter opponentGetter, 
            IPlayerGetter playerGetter, 
            IJankenChecker jankenChecker)
        {
            _opponentGetter = opponentGetter;
            _playerGetter = playerGetter;
            _jankenChecker = jankenChecker;
        }


        internal void Execute()
        {
            Console.WriteLine("じゃんけんしましょ!");
            var opponent = _opponentGetter.GetOpponent();
            int player = _playerGetter.GetPlayer();

            _jankenChecker.DoJanken(player, opponent);
        }
    }

最後に、これはちょっと経験的に慣れなきゃわからないところかもしれませんが、Console.ReadLine とか、いわゆる環境依存なコードも分離したいところです。今回はこれ以上コードを増やしてもこんがらがってくるので省きますが、テストに必要なのでJankenCheckerConsole.WriteLine だけは分離します。

    internal class JankenChecker : IJankenChecker
    {
        private readonly IOutput _output;

        public JankenChecker(IOutput output)
        {
            _output = output;
        }

        public void DoJanken(int player, int opponent)
        {
            if (opponent == player)
            {
                _output.WriteLine("あいこでした!");
            }
            else if ((player + 1) % 3 == opponent)
            {
                _output.WriteLine("勝ち!");
            }
            else
            {
                _output.WriteLine("負け!");
            }
        }
    }

    internal interface IOutput
    {
        void WriteLine(string message);
    }

    internal class ConsoleOutput : IOutput
    {
        public void WriteLine(string message)
        {
            Console.WriteLine(message);
        }
    }

クラスを抜き出した結果

    internal interface IOpponentGetter
    {
        int GetOpponent();
    }

    internal interface IPlayerGetter
    {
        int GetPlayer();
    }

    internal interface IJankenChecker
    {
        void DoJanken(int player, int opponent);
    }

    internal class OpponentGetter : IOpponentGetter
    {
        public int GetOpponent()
        {
            var random = new Random();
            return random.Next(3);
        }
    }
    
    internal class PlayerGetter : IPlayerGetter
    {
        public int GetPlayer()
        {
            int player;
            while (true)
            {
                Console.WriteLine("グー[1] チョキ[2] パー[3]");
                var input = Console.ReadLine();
                if (!int.TryParse(input, out player))
                {
                    Console.WriteLine("半角数字を入力してね!");
                    continue;
                }

                if (player < 1 || player > 3)
                {
                    Console.WriteLine("1~3で入力してね!");
                    continue;
                }
                break;
            }

            player--;

            return player;
        }
    }

    internal class JankenChecker : IJankenChecker
    {
        private readonly IOutput _output;

        public JankenChecker(IOutput output)
        {
            _output = output;
        }

        public void DoJanken(int player, int opponent)
        {
            if (opponent == player)
            {
                _output.WriteLine("あいこでした!");
            }
            else if ((player + 1) % 3 == opponent)
            {
                _output.WriteLine("勝ち!");
            }
            else
            {
                _output.WriteLine("負け!");
            }
        }
    }

    internal interface IOutput
    {
        void WriteLine(string message);
    }

    internal class ConsoleOutput : IOutput
    {
        public void WriteLine(string message)
        {
            Console.WriteLine(message);
        }
    }

    internal class JankenService
    {
        private readonly IOpponentGetter _opponentGetter;
        private readonly IPlayerGetter _playerGetter;
        private readonly IJankenChecker _jankenChecker;

        public JankenService(
            IOpponentGetter opponentGetter,
            IPlayerGetter playerGetter,
            IJankenChecker jankenChecker)
        {
            _opponentGetter = opponentGetter;
            _playerGetter = playerGetter;
            _jankenChecker = jankenChecker;
        }


        internal void Execute()
        {
            Console.WriteLine("じゃんけんしましょ!");
            var opponent = _opponentGetter.GetOpponent();
            int player = _playerGetter.GetPlayer();

            _jankenChecker.DoJanken(player, opponent);
        }
    }

    internal class Program
    {
        private static void Main(string[] args)
        {
            var opponentGetter = new OpponentGetter();
            var playerGetter = new PlayerGetter();

            var output = new ConsoleOutput();
            var jankenChecker = new JankenChecker(output);

            var service = new JankenService(
                opponentGetter, 
                playerGetter, 
                jankenChecker
            );

            service.Execute();
        }
    }

今までやってきたことを有効に使う

さて、コードが40行強から140行弱になって、管理しなきゃいけないコードが増えただけに見えて、ここまでやって一体何の得があるねん? という話なわけですが、モジュール化の有用性を結構すぐに実感できるのがテストコードです。

例えば今回、JankenChecker をまどろっこしい方法で書いた訳ですが、これ実際動くんかいな? っていうのをテストしてみましょう。

まずはこんな感じで、さっき作ったインターフェースを背負ったクラスを作ります。

    internal class StubOpponentInput : IOpponentGetter
    {
        private readonly int _opponent;

        public StubOpponentInput(int opponent)
        {
            _opponent = opponent;
        }
        
        public int GetOpponent() => _opponent;
    }

    internal class StubPlayerInput : IPlayerGetter
    {
        private readonly int _player;

        public StubPlayerInput(int player)
        {
            _player = player;
        }

        public int GetPlayer() => _player;
    }

    internal class StubOutput : IOutput
    {
        internal string Output { get; private set; }

        public void WriteLine(string message) => Output = message;
    }

この時点で勘のいい読者なら気づくかと思いますが、テストコードを以下のように記述することができます。


    public class JankenTests
    {
        [Fact]
        public void じゃんけんできるか()
        {
            // 新しく作ったクラスを使う
            var opponentInput = new StubOpponentInput(0);
            var playerInput = new StubPlayerInput(0);
            var output = new StubOutput();

            // 実際にテストしたいクラス
            var jankenChecker = new JankenChecker(output);

            var jankenService = new JankenService(
                opponentInput,
                playerInput,
                jankenChecker
            );

            jankenService.Execute();

            // 結果を確認。第1引数が想定の、第2引数が実際の値
            Assert.Equal("あいこでした!", output.Output);
        }
    }

これを、xUnit の力でちょちょいと書いてあげると……

    public class JankenTests
    {
        [Theory]
        [InlineData(0, 1)]
        [InlineData(1, 2)]
        [InlineData(2, 0)]
        public void じゃんけんで勝てるか(int player, int opponent)
        {
            var opponentInput = new StubOpponentInput(opponent);
            var playerInput = new StubPlayerInput(player);

            var output = new StubOutput();
            var jankenChecker = new JankenChecker(output);

            var jankenService = new JankenService(
                opponentInput,
                playerInput,
                jankenChecker
            );

            jankenService.Execute();

            Assert.Equal("勝ち!", output.Output);
        }
    }

これで勝ちルートはすべてチェックするコードができました。関数を抜き出したりして、ほかのルートもすべて確認してみましょう。

        [Theory]
        [InlineData(0, 1)]
        [InlineData(1, 2)]
        [InlineData(2, 0)]
        public void じゃんけんで勝てるか(int player, int opponent)
        {
            TestExecuter(player, opponent, "勝ち!");
        }

        [Theory]
        [InlineData(0, 2)]
        [InlineData(1, 0)]
        [InlineData(2, 1)]
        public void じゃんけんで負けるか(int player, int opponent)
        {
            TestExecuter(player, opponent, "負け!");
        }

        [Theory]
        [InlineData(0, 0)]
        [InlineData(1, 1)]
        [InlineData(2, 2)]
        public void じゃんけんであいこできるか(int player, int opponent)
        {
            TestExecuter(player, opponent, "あいこでした!");
        }

        private void TestExecuter(int player, int opponent, string result)
        {
            var opponentInput = new StubOpponentInput(opponent);
            var playerInput = new StubPlayerInput(player);

            var output = new StubOutput();
            var jankenChecker = new JankenChecker(output);

            var jankenService = new JankenService(
                opponentInput,
                playerInput,
                jankenChecker
            );

            jankenService.Execute();

            Assert.Equal(result, output.Output);
        }

別にすべてを一個の関数でテストしちゃってもいいんですが、結果の見やすさを優先して関数で分けてみました。テスト結果は以下のように表示されます。

0004.png

大丈夫そうですね。

最後に

ここまでやってみて、オブジェクト指向の使い方というか、モジュール化することの有用性を体感して頂けたでしょうか。オブジェクト指向は難しい、とは言いますが、慣れちゃえば結構しょっぱなからインターフェイスを使ったコードを書くようになり、テストしやすいコードを書けるようになるかと思います(これをオブジェクト指向と呼ぶかは、皆さんの解釈にお任せします)。

ただもちろん途中で書いたように、コード数は増えるので、どこからどこまでを分離するかはその時々で変わってきます。例えば、入力が変わるのを想定してPlayerGetter のチェック処理を分離するかもしれません。ただ、その変更がない場合徒労に終わることが多いので、YAGNIの法則をお忘れなきよう。

皆さんもぜひ、オブジェクト指向を使ってエンバグしづらいコードを書いていきましょう。

次回は「しょっぱなからオブジェクト指向」をやってみたいと思います。


オブジェクト指向102:しょっぱなからオブジェクト指向

2017/12/31

さて、前回一度手続き的に書いたコードをオブジェクト指向に直すという組み方をしましたが、今回はしょっぱなからオブジェクト指向的に書いていきましょう。

流れを考える

まず、ちょこっとプログラムに慣れたであろう読者は、プログラムを書く前にある程度全貌の想像がつくかと思います。もちろん、まだそのような思考ができない人でも話を追えるように、一度作った「じゃんけんゲーム」で話を進めていきましょう。

じゃんけんゲームの流れは以下になります。
1)コンピュータの手を決める
2)プレイヤーの手を求める
3)じゃんけんの結果を表示する

だいたいこんな感じになります。前回作った JankenService そのままですね。

実装してみる

さて、これをインターフェイスベースで作ります。

    internal enum Hand
    {
        Rock = 0,
        Scissors = 1,
        Paper = 2,
    }

    internal enum Result
    {
        PlayerWin,
        PlayerLose,
        Tie,
    }

    internal interface IHandProvider
    {
        Hand GetHand();
    }

    internal interface IJankenProcessor
    {
        Result Process(Hand player, Hand opponent);
    }

    internal interface IOutput
    {
        void WriteLine(string message);
    }

    internal class JankenService
    {
        private readonly IHandProvider _playerHandProvider;
        private readonly IHandProvider _opponentHandProvider;
        private readonly IJankenProcessor _processor;
        private readonly IOutput _output;

        public JankenService(
            IHandProvider playerHandProvider, 
            IHandProvider opponentHandProvider, 
            IJankenProcessor processor,
            IOutput output)
        {
            _playerHandProvider = playerHandProvider;
            _opponentHandProvider = opponentHandProvider;
            _processor = processor;
            _output = output;
        }

        void Execute()
        {
            _output.WriteLine("じゃんけんしましょ!");

            var player = _playerHandProvider.GetHand();
            var opponent = _opponentHandProvider.GetHand();

            var result = _processor.Process(player, opponent);

            switch (result)
            {
                case Result.PlayerWin: _output.WriteLine("勝ち!"); break;
                case Result.PlayerLose: _output.WriteLine("負け!"); break;
                case Result.Tie: _output.WriteLine("あいこでした!"); break;
                default: throw new ArgumentOutOfRangeException();
            }
        }
    }

すでに知っちゃっているので Hand には数値を振っていますが、基本的に細かい処理はまだ確定していないので意味を確定させるために enum を使ってインターフェイスを構築します。

「3)じゃんけんの結果を表示する」は「じゃんけんの結果を得る」と「出力する」に分けました。これはやはり事前知識があるのでこのような分け方になりますが、実際に作ってみても最終的にはこのような分け方になるかと思います。

前回は「プレイヤー」と「コンピュータ」で「手を得る」処理を分けていましたが、今回はそれらの意味が同一であることを事前に知っている(というか違ったら困る)ので、同じインターフェイスを利用します。ただ、「コンピュータの手を得る」と「プレイヤーの手を得る」だと意味が違うからそこは分けるべき、という意見もあるかと思います。そこに関しては実際に作ってみ、運用してみて経験で学んでいくしかないかと思います(筆者もこれで正しいかどうかは答えかねるので)。

以上!

みじけぇ。と自分でも思うのですが、詳細の実装は前回と同じなので省略します。まあこれを書いた理由なんですが、前回のテストコードなりなんなりを書いてると「ああ、だいたいここら辺がテストの値として取得できたりモックorスタブ作れないと困るから、インターフェイスにしておこう」という発想になってきます。そうすると、前回の最後に到達した「値を受け取って、他に投げる」だけのクラスをまず作り、いやむしろそれをテストコードとして書き、それをもとに本実装していく、というようなワークフローができるかと思います。これができるとテストコードをベースとした開発ができるようになり、エンバグしづらいコードを書く習慣が作れます。

っていうか今どきのアプリケーションなんてほとんで「値を画面から受け取って、サーバに保存する」か「サーバからデータを受け取って、画面に表示する」だけなので、そこでエンバグするようじゃヤバいわけです。計算が少しでもあるならそこはすべてテストコードを書くことで、より堅牢なコードに仕上げることができます。

ということで、これがある種「テストを意識したコードの書き方」にもなります。皆さんもぜひテストを意識したコードを書いて、バグのないプログラマを目指しましょう。

次回があれば、本当にゼロからプログラムを書いていこうと思います。

Discussion

ログインするとコメントできます