Kotlinで歩数の記録から週のふりかえりを作ってみよう(総合演習)
ここまでの Kotlin レッスンで学んだことを全部つないで、動くものを1つ完成させます。新しい構文は出てきません。data class・null安全・when・リスト・filter——すでに練習したものだけで組み立てます。
作るのは1週間の歩数記録のふりかえりです。合計と平均を出し、目標を達成した日を数え、体重の増減を計算して、表にまとめます。
この演習には仕掛けがあります。——記録し忘れた日が入っています。水曜は何も記録せず、土曜は体重だけ量りました。
ここがKotlinを学ぶ意味のいちばん出るところです。「値が無いかもしれない」をnullで表し、型の上でそれを扱わせる——だから?.や?:が、覚えるべき記法ではなく必要だから使う道具として出てきます。
このページではコードを実行していません。かわりにお手本と同じコードを書けたかを、その場で判定します(判定はあなたのブラウザの中だけで行われ、どこにも送信されません)。
表示している実行結果は本物です。このページを作るときに、実際のKotlinでコンパイルして走らせた出力をそのまま載せています。
完成イメージ
最後まで進むと、このコードが完成します。下の実行結果は、このページを作るときに開発機で実際にコードを走らせて得たものです(このページ上では実行していません)。
===== 今週のふりかえり ===== 月 8,420歩 62.4kg あと少し 火 11,350歩 62.1kg 達成 水 —— —— 記録なし 木 6,980歩 62.6kg 不足 金 13,240歩 62.0kg 達成 土 —— 61.8kg 記録なし 日 9,510歩 61.9kg あと少し -------------------------------------- 記録できた日: 5日 / 7日 合計歩数 : 49,500歩 1日平均 : 9,900歩 目標達成 : 2日(火・金) 体重の変化 : -0.5kg
STEP 11日の記録をdata classで表す
🎯 このステップのゴール: 値を持つだけのクラスを1行で作り、null許容の型を使い分ける
まずデータの形を決めます。1日が持つのは「日付・歩数・体重」の3つです。
data class DayLog(val date: String, val steps: Int?, val weightKg: Double?)——これだけで完成です。
dataを付けると、Kotlinが3つのものを自動で用意します。中身を並べたtoString()、中身が同じなら等しいと判定するequals()、そして一部だけ変えた複製を作るcopy()。Javaで同じことをすると、数十行の決まりきったコードを書くことになります。
ここからが本題です。型に付いている?を見てください。
date: Stringには?がありません——日付は必ずあります。steps: Int?には付いています——記録し忘れた日があるからです。
この?が Kotlin の中心にある考え方です。?の付かない型にはnullを入れられません。試しにDayLog("月", null, 62.4)の第1引数をnullにしてみてください——コンパイルが通りません。
多くの言語では、どの変数にもnullが入りえます。だから「ここはnullにならないはず」という思い込みが、実行時に崩れます。Kotlinはその思い込みを型に書かせて、コンパイラに見張らせます。
記録した日数: 7日 1日目: DayLog(date=月, steps=8420, weightKg=62.4) 水曜日: DayLog(date=水, steps=null, weightKg=null)
STEP 2記録のない日を飛ばして合計する
🎯 このステップのゴール: エルビス演算子で「無いときの代わり」を決める
歩数を合計します。ところがday.stepsはInt?——そのままでは足せません。
total += day.stepsと書くとコンパイルエラーになります。nullかもしれないものを足そうとしているからです。
ここが Kotlin の親切なところです。他の言語なら、たいてい実行時まで気づきません。夜中にサーバーが落ちてから「あの日だけデータが無かった」と分かる——Kotlinはそれを書いた瞬間に止めます。
解決するのがエルビス演算子?:です。day.steps ?: 0と書くと、「stepsがnullなら0を使う」という意味になります。
名前の由来は見た目です。横に倒すと歌手のエルビス・プレスリーの前髪に見える、というところから来ています。覚えやすい名前ですが、やっていることは「無かったときの代わりを決める」——それだけです。
ここで大事なのは「0でよいのか」を考えることです。歩数の合計なら、記録の無い日を0扱いしても合計は変わりません。ですが平均を出すときは違います——記録の無い日を0として割ると、平均が実際より低く出ます。
だからステップ6の平均は記録のある日数で割ります。?: 0と書いた瞬間、「無い」が「0」に化けます。それでよいかは、毎回考えてください。
合計歩数: 49500歩 記録のない日も0として足しています
STEP 3whenで評価を振り分ける
🎯 このステップのゴール: 条件を並べたwhenを式として使い、returnを省く
その日の歩数から「達成」「あと少し」「不足」を決めます。
使うのはwhenです。when { 条件 -> 値 }と書くと、上から順に条件を見て、最初に当たったものの値になります。
順番が結果を決めます。steps == nullを最初に置いているのは、nullを先に片付けないと、その先の比較ができないからです。null >= GOALは書けません。
そしてwhenより前に、この関数の形を見てください。
fun rank(steps: Int?): String = when { ... }——波括弧もreturnもありません。=のあとに式を1つ書くだけの単一式関数という書き方です。
これが成り立つのは、Kotlinのwhenが「文」ではなく「式」だからです。値を返すので、そのまま関数の戻り値にできます。Javaのswitchのように、変数を用意して各分岐で代入する必要がありません。
elseを書いているのにも意味があります。戻り値の型をStringと決めた以上、どの道を通っても必ず文字列を返さなければなりません。elseが無いとコンパイラが「返らない場合がある」と止めます。
GOAL * 3 / 4は7500です。掛け算を先に書いています——GOAL / 4 * 3でも同じ値になりますが、割り算を先にすると端数が落ちる場面があるので、掛けてから割るのを習慣にしてください。
月: あと少し 火: 達成 水: 記録なし 木: 不足 金: 達成 土: 記録なし 日: あと少し
STEP 4letで「値があるときだけ」整形する
🎯 このステップのゴール: ?.let を使い、nullのときの表示を別に用意する
歩数を8,420歩のように整形して表示します。ただし記録の無い日は——と出したい。
ここでString.format("%,6d歩", day.steps)とは書けません。day.stepsはInt?だからです。
使うのが?.letです。
day.steps?.let { String.format("%,6d歩", it) } ?: " ——"——長く見えますが、読み方は素直です。
?.は「nullでなければ、その先へ進む」。nullならそこで止まって、全体がnullになります。
let { }は「受け取った値をブロックに渡して、結果を返す」。ブロックの中ではitという名前でその値が使えます。しかもitの型はInt——?が外れています。nullのときはletまで来ないからです。
そして最後の?: " ——"が、nullだったときの表示です。
この3つが組み合わさると「値があるときだけ加工し、無ければ別のものを出す」が1行で書けます。if (day.steps != null) { ... } else { ... }と書いても同じですが、変数を増やさずに済むぶん、読む負担が軽くなります。
%,6dの,は3桁区切り、6は6文字ぶんの幅で右揃えという意味です。幅を揃えるので、表の列がきれいに並びます。
月 8,420歩 62.4kg 火 11,350歩 62.1kg 水 —— —— 木 6,980歩 62.6kg 金 13,240歩 62.0kg 土 —— 61.8kg 日 9,510歩 61.9kg
STEP 5filterで絞り込み、体重の増減を出す
🎯 このステップのゴール: 条件に合う要素を集め、両端が揃わないときはnullを返す
目標を達成した日を数えます。week.filter { (it.steps ?: 0) >= GOAL }——条件に合う要素だけを集めた新しいリストが返ります。
中で?: 0を使っているのは、記録の無い日を「達成していない」扱いにするためです。nullのまま>=で比べることはできません。
日付を並べるのがjoinToString("・") { it.date }です。区切り文字と、各要素から何を取り出すかを渡すだけで火・金という文字列になります。
次が体重の増減です。ここは少し込み入っています。
week.firstOrNull { it.weightKg != null }?.weightKg——体重を記録した最初の日を探し、その体重を取り出すという意味です。
firstOrNullは「見つからなければnull」を返します。firstだと見つからないときに例外で落ちるので、「無いかもしれない」場面ではこちらを使います。
そのあとの?.weightKgが効いています。見つからなければnullのまま先へ進まない——nullに対してプロパティを取ろうとして落ちる、ということが起きません。
そして戻り値をDouble?にしているのが、この関数の要点です。体重を1日も記録していなければ、増減は「0」ではなく「計算できない」。0と「不明」を混ぜないでください——混ぜた瞬間、「体重が変わらなかった週」と「量らなかった週」が区別できなくなります。
if (first == null || last == null) return nullと早めに返しておくと、そこから先はnullを気にせず計算できます。Kotlinのコンパイラも、この行より下ではfirstをDoubleとして扱ってくれます。
目標達成: 2日(火・金) 体重の変化: -0.5kg 記録なしの週: null
STEP 6ひとつの関数にまとめて仕上げる
🎯 このステップのゴール: reportにまとめ、平均を「記録のある日数」で割る
最後の仕上げです。ここまでの部品をreportという1つの関数にまとめます。
ここで、ステップ2で保留にした話に決着をつけます。平均歩数を何で割るかです。
total / week.size——7で割ると、記録の無い2日を「0歩歩いた日」として数えることになります。実際より低い平均が出ます。
total / recorded.size——記録のある5日で割れば、「歩いた日の平均」になります。こちらが知りたい数字のはずです。
どちらが正しいかは、何を知りたいかで決まります。「毎日どれだけ歩いたか」なら7で割る、「歩いた日はどれだけ歩いたか」なら5で割る。プログラムは黙って計算するだけなので、決めるのは書く人です。
if (recorded.isNotEmpty())で囲んでいるのは、0で割らないためです。1日も記録していない週を渡されたら、ここで落ちてしまいます。割る前に0を確かめる——習慣にしてください。
体重の増減はif (change == null)で分けます。「記録が足りません」と正直に出す——0kgと表示して「変わらなかった」と誤解させるより、ずっと親切です。
増えたときに+を付けるのはif (change < 0) "" else "+"です。マイナスは自動で付きますが、プラスは付きません。増減を見せるときは、この一手間で読みやすさが変わります。
完成です。weekの水曜に歩数を入れてみてください。合計も平均も達成日数も、全部連動して変わります。
逆に全部のweightKgをnullにしてみてください。体重の欄が「記録が足りません」に変わり、ステップ5でDouble?を返すようにしておいた意味が確かめられます。
===== 今週のふりかえり ===== 月 8,420歩 62.4kg あと少し 火 11,350歩 62.1kg 達成 水 —— —— 記録なし 木 6,980歩 62.6kg 不足 金 13,240歩 62.0kg 達成 土 —— 61.8kg 記録なし 日 9,510歩 61.9kg あと少し -------------------------------------- 記録できた日: 5日 / 7日 合計歩数 : 49,500歩 1日平均 : 9,900歩 目標達成 : 2日(火・金) 体重の変化 : -0.5kg
よくある質問
このページではコードを実行しないのですか?
していません。Kotlinはコンパイルが必要な言語で、ブラウザの中では走らせられないためです。このページではお手本と同じコードを書けたかどうかを判定する形にしています(判定はあなたのブラウザの中だけで行われ、コードはどこにも送信されません)。
ただし表示している実行結果は本物です。このページを作るときに実際のKotlinでコンパイルして走らせた出力を、そのまま載せています。
?. と !! は、どう使い分けますか
!!は使わないでください、というのが基本の答えです。
!!は「nullではないと私が保証する」という意味で、外れるとその場で例外が飛びます。つまりKotlinがせっかく用意した仕組みを、自分から捨てていることになります。
?.で先へ進めないようにするか、?:で代わりの値を決めるか、if (x != null)で早めに分けるか——たいていはこの3つのどれかで書けます。
どうしても!!を書きたくなったら、その値がなぜnullになりうる型なのかを見直すほうが早いことが多いです。
平均を出すとき、記録のない日はどう扱えばいいですか
何を知りたいかで変わります。プログラムは決めてくれません。
「毎日どれだけ歩いたか」を知りたいなら、記録のない日を0として7日で割ります。
「歩いた日はどれだけ歩いたか」を知りたいなら、記録のある日数で割ります。この演習ではこちらを採りました。
大事なのはどちらにしたかをコードから読み取れるようにしておくことです。total / recorded.sizeと書いてあれば、意図が伝わります。
data class は普通の class と何が違いますか
「値を持つのが仕事」のクラス向けに、決まりきったコードを自動で用意してくれます。
toString()——中身を並べた文字列。ステップ1でDayLog(date=月, steps=8420, weightKg=62.4)と表示できたのはこれのおかげです。
equals()——中身が同じなら等しいと判定します。普通のクラスは「同じ実物かどうか」で比べるので、中身が同じでも別物と判定されます。
copy()——一部だけ変えた複製を作ります。day.copy(steps = 9000)のように書けます。
逆に振る舞いが主役のクラスには向きません。継承させたいクラスや、内部の状態を隠したいクラスは普通のclassで書きます。
🚩 次はこのカテゴリへ
KotlinはJavaと同じ土台の上で動きます。この演習で1行で書いたdata classを、Javaで書くとどうなるか——比べてみると、Kotlinが何を自動でやってくれているかがはっきりします。Javaカテゴリの最終演習では、図書館の貸出管理を作ります。null安全が無い世界でどう気をつけるかも、あわせて見えてきます。