今日は大雪で外に出るのが億劫なので、ひとり 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つも増えてしまったので、かえってわかりづらく感じる人もいるかもしれません。
おたよりコーナー
@tercel_s 営業日の計算はいつも部品に任せるので考えたことなかったです…。個人的には、一行にいっぱい意味が詰まったコードに慣れてないので、「そのいち」の愚直な感じが親しみやすいですね。
— とき (@toki_62) 2014, 2月 8
@toki_62 『そのいち』は、コードを書くときにはラクでも、後から直すときに非常に苦労します。
100万行の業務システムを修正する事になり、そのコードが『そのいち』のように書かれていて、if文やfor文がどこで閉じられているか一見しただけでは分からないという事もざらです。→
— たーせる (@tercel_s) 2014, 2月 8
@toki_62 『そのさん』のコードは、1行に処理をたくさん詰め込んでいるように見えるかも知れませんが、SQLのSELECT〜FROM〜WHEREと本質的に一緒です。
『今日以降の日付の一覧のうち、指定条件に当てはまる要素のn番目』を取得する処理を1行に書いています。→
— たーせる (@tercel_s) 2014, 2月 8
@toki_62 『そのさん』ではメソッドの本質ではない処理を外に出して、それぞれの部品ができるだけ単純な役割を持つようになっています。
これにより『変更されやすそうなところ』と『そうでないところ』が分かれています。
このため、保守性に関しては『そのさん』の方が有利なのです。
— たーせる (@tercel_s) 2014, 2月 8
*1:そのため、遥か遠くの要素を ElementAt() で取得しようとすると相応の時間がかかります。