小ネタ: 指定日からn営業日後の日付を計算したい

今日は大雪で外に出るのが億劫なので、ひとり C# クイズをやっていました。

もんだい

2014年2月7日(金)の7営業日後の日付を求めよ。ただし、以下の日付は休業日とする(営業日としてはならない)。

  • 2014年2月8日(土)
  • 2014年2月9日(日)
  • 2014年2月11日(火)
  • 2014年2月15日(土)
  • 2014年2月16日(日)

基礎中の基礎「数え上げ」の問題ですが、解を3通りほど用意してあります(最終的には if 文も for 文も使わないコードになります)。

こたえ

そのいち

まずは10年くらい前の常識で書いた愚直なコードです。

僕も昔はよくこういう書き方をしていました。

    class Program
    {
        static void Main(string[] args)
        {
            // 起算日
            DateTime startDay = new DateTime(2014, 2, 7);

            // 休業日
            DateTime[] holidays = 
            {
                new DateTime(2014, 2, 8),
                new DateTime(2014, 2, 9),
                new DateTime(2014, 2, 11),
                new DateTime(2014, 2, 15),
                new DateTime(2014, 2, 16)
            };

            // 日数
            int dayCount = 7;

            // 結果出力 
            Console.WriteLine("{0}の{1}営業日後は{2}です",
                startDay.ToString("yyyy/MM/dd"),
                dayCount,
                Hoge(startDay, dayCount, holidays).ToString("yyyy/MM/dd"));
        }


        static DateTime Hoge(DateTime 起算日, int 日数, IEnumerable<DateTime> 非営業日群)
        {
            if (日数 < 0) throw new ArgumentOutOfRangeException("ばーかばーか!");

            DateTime date = 起算日.Date;    // 起算日から時刻情報を除いた値で初期化
            int c = 0;                      // カウンタ変数。0で初期化

            while(true) {
                // [1] date が営業日かどうかを判断するよ
                // -------------------------------------
                bool isHoliday = false;     // フラグ: 非営業日なら true
                foreach (DateTime 非営業日 in 非営業日群)
                {
                    // dateが非営業日だったら、isHolidayフラグをtrueにして foreach ループを抜ける
                    if (date.Date.Equals(非営業日.Date))
                    {
                        isHoliday = true;
                        break;
                    }
                }

                // [2] 営業日だったら、カウンタ c を1進めるよ 
                // ------------------------------------------
                if (!isHoliday)
                {
                    c++;

                    // [3] もしカウンタ c が指定日数を超えたら、[3] には進まず処理を終了するよ
                    // -----------------------------------------------------------------------
                    if (c > 日数) break;
                }

                // [3] date を更新するよ!明日は営業日かな?休日かな?
                // ---------------------------------------------------
                date = date.AddDays(1);
            }
            return date;
        }
    }

実際に問題を解いているのは Hoge() メソッドですね。引数名に日本語が使われているのはスルーしてください。ご愛嬌です。

ちなみに 2014年2月7日の7営業日後は、2014年2月19日です。

さてさて。解「そのいち」は一応正解が出力されているものの、コードにはいくつか問題があります。

一番の問題点は、余分な制御構文を使いすぎていることです。また、フラグ変数 isHoliday の存在も、読み手によっては快く思わないかもしれません。

そのに

解「そのいち」の反省点を踏まえ、Hoge() メソッドを書き直してみました。C# 3.0以降で動きます。

        static DateTime Hoge(DateTime 起算日, int 日数, IEnumerable<DateTime> 非営業日群)
        {
            if (日数 < 0) throw new ArgumentOutOfRangeException("ばーかばーか!");

            // 引数から時刻情報を捨てる
            DateTime date = 起算日.Date;
            var holidays = from holiday in 非営業日群 select holiday.Date;

            // for文で回す(変数 i がダサい)
            for (int i = 0;;)
            {
                if (!holidays.Contains(date) && ++i > 日数) return date;
                date = date.AddDays(1);
            }
        }

いかがでしょう。 for 文のネストが解消し、さらにフラグ変数もなくなりました。

解「そのに」は、だいぶスッキリしたものの、まだ一時変数 i の存在がいまいちダサい。if 文からの i の更新からの return というめくる処理の流れがこのメソッドの関所であり、バグを作りやすいポイントになっています。

さて、解「そのいち」では、date オブジェクトが営業日かどうかの判定を foreach 文で行い、結果をフラグ変数に格納していました。今回はこの判定を「非営業日群の中に date が含まれているかどうか」という問題に置き換えています。

ただし、単純に非営業日群の Contains() メソッドを実行した場合、非営業日群の要素と date が日付だけでなく時刻情報も一致していなければ等値とはみなされず、意図した結果にはなりません。今回は日付情報だけを比較対象にしたいので、処理の最初に DateTime の時刻情報を破棄するコードを挿入しています。

非営業日群から時刻情報を除去する箇所については多少改良の余地がありそうですね。

そのさん

ここからは少々変態的なコードになります。

if 文も for 文も foreach 文も、カウンタ変数も制御フラグも排除したある意味究極的なコードです。

        static DateTime Hoge(DateTime 起算日, int 日数, IEnumerable<DateTime> 非営業日群)
        {
            if (日数 < 0) throw new ArgumentOutOfRangeException("ばーかばーか!");

            // 即解決
            return new DateSequence(起算日).Except(非営業日群, new DateComparer()).ElementAt(日数);
        }

うん。

超すっきりしました。

めでたし。

というのはウソで、このあと、2つほど新たなクラスを作る必要があります。ひとつは、指定日以降の日付を1日ずつ返却する DateSequence というクラスです。そしてもうひとつは、DateTimeオブジェクト同士を日付情報のみで比較する DateComparer というクラスです。

        class DateSequence : IEnumerable<DateTime>
        {
            private DateTime date;
            
            // コンストラクタ(引数は起算日)
            public DateSequence(DateTime from) { date = from.Date; }

            // 翌日の日付を順次返していく
            public IEnumerator<DateTime> GetEnumerator()
            {
                while (true)
                {
                    yield return date;
                    date = date.AddDays(1);
                }
            }

            IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); }
        }

        class DateComparer : IEqualityComparer<DateTime>
        {
            // DateTimeオブジェクトの時刻部分を無視して比較
            public bool Equals(DateTime x, DateTime y) { return x.Date.Equals(y.Date);  }
            public int  GetHashCode(DateTime obj)      { return obj.Date.GetHashCode(); }
        }

このうち DateSequence は列挙オブジェクトと呼ばれている比較的ナウい代物です。

以下に使い方の一部をご紹介しましょう。ざっくり言ってしまえば、既にデータが格納されたリストのような使い心地です。

            // DateSequenceクラスのつかいかた(誕生編)
            var dateSeq = new DateSequence(DateTime.Today);

            // 1. foreachの中で使うことができる
            foreach (DateTime element in dateSeq)
            {
                if (element.Year != DateTime.Today.Year) break;
                Console.WriteLine(element.ToString("yyyy/MM/dd"));
            }
            
            // 2. 任意の要素にアクセスできる
            DateTime date = dateSeq.ElementAt(10);
            Console.WriteLine(date.ToString("yyyy/MM/dd"));

ただし、 DateSequence は配列のようにデータそのものの集まりを表すオブジェクトではなく、必要に応じて要素を計算して返す(前もってデータの集合を保持しない・必要になるまで何もしない)性質を持っています。つまり、必要なときに必要なだけ働く賢い仕掛けになっているのです*1

DateSequence オブジェクトは指定日以降の日付のシーケンスです。そこから休業日を Except() メソッドで除外し、その要素の n 番目を ElementAt() メソッドで取得した結果を返しています。

めでたし

というわけで、3通りの解をご紹介しました。

解「そのいち」ではあんなにごちゃごちゃしていた条件分岐や反復処理が「そのさん」では一掃されています。ただ、その場限りのクラスが2つも増えてしまったので、かえってわかりづらく感じる人もいるかもしれません。

おたよりコーナー

*1:そのため、遥か遠くの要素を ElementAt() で取得しようとすると相応の時間がかかります。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.