C#で1か月の勤怠を集計してみよう(総合演習)
ここまでの C# レッスンで学んだことを全部つないで、動くものを1つ完成させます。新しい構文は出てきません。クラス・List・LINQ・switch式・文字列補間——すでに練習したものだけで組み立てます。
作るのは1か月の勤怠集計です。実働時間を合計し、残業を計算し、区分ごとに数えて、レポートにまとめます。
この演習の主役はLINQです。foreachで書けば10行かかる集計が、WhereとSumで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.0や10.25のような小数です。intにすると10.25が10になり、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.0は8、8.89は8.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 { "出勤" => "○", ... }——caseもbreakもreturnも書きません。
昔の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)で分けます。数字を並べて終わりにせず、「それで、どうなの?」に答える——道具として使えるかどうかは、たいていここで決まります。
StandardHoursとOvertimeLimitを定数にしてあるのが効いてきます。所定労働時間が7.5時間の職場なら1か所、残業の目安を変えたければもう1か所——直す場所が探さなくても分かります。
完成です。StandardHoursを7.5に変えてみてください。残業時間が増え、締めの判定も変わります。1つの数字を変えるだけで、表示も判定も全部連動する——定数に切り出しておいた効果が確かめられます。
次に進むなら、この勤怠をCSVから読むところです。ReportはRecordsを見ているだけなので、読み込みの部分を足せば、集計はそのまま使えます。
===== 今月の勤怠 ===== 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が得意なのは「元のデータから、別のデータを作る」ことです。合計する、絞り込む、並べ替える、グループにまとめる——これらはSum・Where・OrderBy・GroupByでやりたいことがそのまま書けます。
逆に画面に出す、ファイルに書く、外部に送るといった処理は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カテゴリの最終演習では、売上伝票の集計を作ります。