🎯 最終演習

C#で1か月の勤怠を集計してみよう(総合演習)

ここまでの C# レッスンで学んだことを全部つないで、動くものを1つ完成させます。新しい構文は出てきません。クラス・List・LINQ・switch式・文字列補間——すでに練習したものだけで組み立てます。

作るのは1か月の勤怠集計です。実働時間を合計し、残業を計算し、区分ごとに数えて、レポートにまとめます。

この演習の主役はLINQです。foreachで書けば10行かかる集計が、WhereSumで1行になります。ただ短いだけではありません——「何をしたいか」がそのまま式になるのが、LINQのいちばんの値打ちです。

だからこの演習では、まずforeachで書いてから、同じものをLINQに書き換えます。仕組みが分かったうえで短く書くのと、意味も分からず短く書くのとは、まったく違います。

このページではコードを実行していません。かわりにお手本と同じコードを書けたかを、その場で判定します(判定はあなたのブラウザの中だけで行われ、どこにも送信されません)。

表示している実行結果は本物です。このページを作るときに、実際にコンパイルして走らせた出力をそのまま載せています。

完成イメージ

最後まで進むと、このコードが完成します。下の実行結果は、このページを作るときに開発機で実際にコードを走らせて得たものです(このページ上では実行していません)。

===== 今月の勤怠 =====
 1日 ○ 出勤      8h
 2日 ○ 出勤    9.5h  残業1.5h
 3日 有 有休      0h
 4日 ○ 出勤      8h
 5日 ○ 出勤  10.25h  残業2.25h
 8日 × 欠勤      0h
 9日 ○ 出勤    7.5h
10日 ○ 出勤     11h  残業3h
11日 ○ 出勤      8h
12日 有 有休      0h
----------------------------------------
出勤日数  : 7日
実働合計  : 62.25時間
1日平均   : 8.89時間
残業合計  : 6.75時間
--- 区分別 ---
  出勤 : 7日
  有休 : 2日
  欠勤 : 1日
最長勤務  : 10日 11時間
残業が5時間を超えています。働き方を見直してください。

STEP 1勤怠1日ぶんをクラスで表す

🎯 このステップのゴール: 自動プロパティで、読み取り専用の値を短く書く

まずデータの形を決めます。1日が持つのは「日付・区分・実働時間」の3つです。

public int Day { get; }——これが自動プロパティです。

C#の歴史がここに現れています。昔はprivate int day;というフィールドと、public int GetDay()というメソッドを両方書いていました。自動プロパティはその2つを1行に畳んだもので、裏では今も同じことが起きています。

{ get; }setを書いていないのが要点です。これで読み取り専用になり、値を入れられるのはコンストラクタの中だけになります。

なぜ書き換えられないようにするのか。勤怠の記録は、あとから書き換わってはいけないものです。{ get; set; }にしておくと、プログラムのどこからでも実働時間を書き換えられます——おかしな値になったとき、探す場所が全部になります。

「変えられるようにしておく」は、便利ではなく危険です。必要になってからsetを足せば十分です。

static readonly List<Attendance>で一覧を持ちます。readonlyは「このリストを別のリストに差し替えない」という意味です。中身を足したり消したりはできるので、そこは混同しないでください。

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
記録件数: 10件
1件目: 1日 出勤 8時間

STEP 2まずforeachで実働時間を合計する

🎯 このステップのゴール: 手で回して集計し、次のステップとの比較の土台にする

実働時間を合計します。まずはforeachで、素直に書きます

foreach (var a in Records)——varは「型はコンパイラが決めてくれる」という書き方です。Attendance aと書いても同じですが、右辺から明らかなときはvarにするのが今のC#の作法です。

やっていることは3つです。出勤の日だけ数え、その実働時間を足し、平均を出す

if (a.Kind != "出勤") continue;——continueで「この回は飛ばす」と書いています。ifで囲んで中に処理を入れても同じですが、先に除外するほうが、そのあとの本筋が右に寄りません

平均を出すところで0で割らないよう気をつけますworkedDays == 0 ? 0 : total / workedDays——出勤が1日も無い月に落ちないようにしておきます。

ここでdoubleを使っているのに注目してください。実働時間は8.010.25のような小数です。intにすると10.2510になり、15分単位の記録が全部消えます

お金と時間は、扱い方を先に決めてください。あとから型を変えるのは、たいてい大ごとになります。

この10行を覚えておいてください。次のステップで、これが2行になります。

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
出勤日数: 7日
実働合計: 62.25時間
1日平均 : 8.89時間

STEP 3同じ集計をLINQで書き直す

🎯 このステップのゴール: WhereとSumで、10行を2行にする

ステップ2とまったく同じ計算を、LINQで書きます。

var worked = Records.Where(a => a.Kind == "出勤").ToList();

double totalHours = worked.Sum(a => a.Hours);

これで終わりです。ステップ2の10行が2行になりました。

ただ短いことより、読み方が変わったことが大事です。ステップ2は「変数を用意して、回して、条件を見て、足す」——やり方が書いてあります。こちらは「出勤のものを、実働時間で合計する」——やりたいことがそのまま書いてあります。

a => a.Kind == "出勤"ラムダ式です。aを受け取って、判定を返す」という小さな関数を、その場で書いています。

.ToList()を付けているのには理由があります。LINQは遅延実行——Whereを書いた時点では、まだ何も起きていません。結果を実際に使うときになって、はじめて回り始めます。

これが罠になる場面があります。Whereの結果を2回使うと、2回とも最初から数え直します。データが大きいと、そのぶん遅くなります。.ToList()で一度確定させておけば、結果が使い回されます

逆に、1回しか使わないなら.ToList()は要りません。途中の配列を作らないぶん、そのほうが速くなります。「何回使うか」で決めてください。

{totalHours:0.##}:0.##は書式指定です。#は「値があれば出す、無ければ出さない」——8.088.898.89と出ます。0.00にすると常に8.00になります。

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
出勤日数: 7日
実働合計: 62.25時間
1日平均 : 8.89時間
ステップ2と同じ結果が、2行の集計で出ています

STEP 4残業時間を出して、switch式で記号を付ける

🎯 このステップのゴール: 式形式のメソッドと switch 式で、短く読みやすく書く

残業時間を計算します。所定の8時間を超えたぶんです。

static double Overtime(Attendance a) => a.Hours > StandardHours ? a.Hours - StandardHours : 0.0;

=>で書くメソッド——式形式のメンバーと呼びます。{ return ...; }と書くのと同じですが、返す式が1つだけのときは、こちらのほうが素直です。

次がswitch式です。kind switch { "出勤" => "○", ... }——casebreakreturnも書きません

昔のswitch文との違いは大きいです。昔はbreakを書き忘れると、次のcaseに流れ込みました。switch式にはその事故がありません——値を返す式なので、流れ込みようがないのです。

_ => "?"_は「それ以外すべて」です。これを書かないとコンパイルエラーになります——stringを返すと宣言した以上、どの道を通っても必ず文字列を返さなければならないからです。

残業の合計はRecords.Sum(Overtime)です。ラムダ式ではなく、メソッド名をそのまま渡しています——a => Overtime(a)と同じ意味で、メソッドグループ変換と呼ばれる書き方です。短く済むうえ、意図がはっきりします。

ここで有休と欠勤もSumに含めているのに注目してください。実働0時間なので残業も0——除外する必要がありません。「そもそも0になるものは、除外しなくてよい」と気づくと、条件が1つ減ります。

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
 1日 ○ 8h
 2日 ○ 9.5h  残業1.5h
 3日 有 0h
 4日 ○ 8h
 5日 ○ 10.25h  残業2.25h
 8日 × 0h
 9日 ○ 7.5h
10日 ○ 11h  残業3h
11日 ○ 8h
12日 有 0h
残業合計: 6.75時間

STEP 5GroupByで区分ごとに数える

🎯 このステップのゴール: グループ化してDictionaryにし、TryGetValueで安全に引く

「出勤・有休・欠勤」がそれぞれ何日あったかを数えます。

Records.GroupBy(a => a.Kind).ToDictionary(g => g.Key, g => g.Count())

読み方は2段階です。GroupBy(a => a.Kind)で「区分ごとの束」に分けToDictionaryで「区分 → 件数」の対応表に変えます

g.Keyその束の区分名g.Count()その束の件数です。

foreachとDictionaryで書けば7〜8行かかります。「まだ無いキーなら0を入れて、それから足す」という前処理が要るからです。それが1行になります。

取り出すところにもう1つの要点があります。

byKind.TryGetValue(kind, out int n) ? n : 0——byKind[kind]と書いてはいけませんキーが無いと例外で落ちます

この演習では「欠勤」が1件あるので落ちませんが、欠勤が0日の月なら落ちます。——「たまたま動く」コードの典型です。

TryGetValue「あれば取り出してtrue、無ければfalseを返します。out int nという書き方で、宣言と受け取りを同時にやっています

表示する順番を自分で決めているのにも理由があります。new[] { "出勤", "有休", "欠勤" }を回しているのは、Dictionaryが順番を保証しないからです。データに現れた順でもなければ、五十音順でもありません。見せたい順があるなら、自分で持ってください。

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
出勤  : 7日
有休  : 2日
欠勤  : 1日
特別休暇: 0日
最長勤務: 10日 11時間

STEP 6ひとつのメソッドにまとめて仕上げる

🎯 このステップのゴール: Reportにまとめ、桁を揃えて締めの判定まで出す

最後の仕上げです。ここまでの部品をReportという1つのメソッドにまとめます

桁揃えに文字列補間の位置指定を使います。{a.Day,2},2が「2文字ぶんの幅で右揃え」{a.Kind,-3},-3が「3文字ぶんで左揃え」です。

マイナスが左揃えというのは、他の言語のprintfと同じ約束です。書式指定と組み合わせるときは{値,幅:書式}——幅が先、書式が後の順になります。

new string('-', 40)で区切り線を引きます。stringのコンストラクタで「同じ文字を40個」という意味です。

締めはif (totalOver > OvertimeLimit)で分けます。数字を並べて終わりにせず、「それで、どうなの?」に答える——道具として使えるかどうかは、たいていここで決まります。

StandardHoursOvertimeLimit定数にしてあるのが効いてきます。所定労働時間が7.5時間の職場なら1か所、残業の目安を変えたければもう1か所——直す場所が探さなくても分かります

完成です。StandardHours7.5に変えてみてください。残業時間が増え、締めの判定も変わります。1つの数字を変えるだけで、表示も判定も全部連動する——定数に切り出しておいた効果が確かめられます。

次に進むなら、この勤怠をCSVから読むところです。ReportRecordsを見ているだけなので、読み込みの部分を足せば、集計はそのまま使えます

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
===== 今月の勤怠 =====
 1日 ○ 出勤      8h
 2日 ○ 出勤    9.5h  残業1.5h
 3日 有 有休      0h
 4日 ○ 出勤      8h
 5日 ○ 出勤  10.25h  残業2.25h
 8日 × 欠勤      0h
 9日 ○ 出勤    7.5h
10日 ○ 出勤     11h  残業3h
11日 ○ 出勤      8h
12日 有 有休      0h
----------------------------------------
出勤日数  : 7日
実働合計  : 62.25時間
1日平均   : 8.89時間
残業合計  : 6.75時間
--- 区分別 ---
  出勤 : 7日
  有休 : 2日
  欠勤 : 1日
最長勤務  : 10日 11時間
残業が5時間を超えています。働き方を見直してください。

よくある質問

このページではコードを実行しないのですか?

していません。C#はコンパイルが必要な言語で、ブラウザの中では走らせられないためです。このページではお手本と同じコードを書けたかどうかを判定する形にしています(判定はあなたのブラウザの中だけで行われ、コードはどこにも送信されません)。

ただし表示している実行結果は本物です。このページを作るときに実際にコンパイルして走らせた出力を、そのまま載せています。

foreach と LINQ は、どちらで書くべきですか

集計や絞り込みならLINQ、副作用のある処理なら foreachが目安です。

LINQが得意なのは「元のデータから、別のデータを作る」ことです。合計する、絞り込む、並べ替える、グループにまとめる——これらはSumWhereOrderByGroupByやりたいことがそのまま書けます

逆に画面に出す、ファイルに書く、外部に送るといった処理はforeachのほうが素直です。LINQの中でそういう処理をすると、遅延実行のせいでいつ動くか読みにくくなります

ToList() は付けたほうがいいですか

結果を2回以上使うなら付けてください。

LINQは遅延実行なので、Whereの結果をそのまま持ち回すと、使うたびに最初から数え直します。件数を見て、そのあと中身も回す——それだけで2周します。

.ToList()で一度確定させれば、結果が使い回されます。

逆に1回しか使わないなら付けないほうが速いです。途中のリストを作らずに済むからです。

Dictionary から値を取るとき、[] と TryGetValue はどう違いますか

キーが無いときの振る舞いが違います。

dict[key]キーが無いと例外で落ちます。「必ずある」と分かっているときだけ使ってください。

TryGetValue無ければfalseを返すだけで落ちません。0件かもしれない集計結果を引くときは、必ずこちらです。

「今のデータでは落ちない」は理由になりません。欠勤が0日の月が来た瞬間に落ちる——この種の不具合は、たいてい忙しい時期に見つかります。

🚩 次はこのカテゴリへ

VB.NETはC#と同じ.NETの上で動く言語です。書き方は違いますが、List(Of T)Dictionary(Of K, V)もLINQもそっくり同じものが使えます。C#で覚えたことが、ほとんどそのまま通じます。VB.NETカテゴリの最終演習では、売上伝票の集計を作ります。

VB.NETのレッスンへ進む
広告スペース(バナー)
広告スペース(記事内)