Dartで1日の献立からカロリーを集計してみよう(総合演習)
ここまでの Dart レッスンで学んだことを全部つないで、動くものを1つ完成させます。新しい構文は出てきません。クラス・名前付き引数・リスト・マップ・null安全・拡張メソッド・switch——すでに練習したものだけで組み立てます。
作るのは1日の献立のカロリー集計です。食事の時間帯ごとにまとめ、目安と比べ、たんぱく質を合計して、一覧にします。
この演習の主題は2つです。——名前付き引数とnull安全。どちらもDartの中心にある仕組みで、Flutterのコードを読むときに必ず出会います。
そして「調べていない栄養値」をわざと混ぜてあります。nullを0で埋めてしまうと、「0gだった」と「調べていない」が区別できなくなります——その違いをどう守るかを、実際に書いて確かめます。
このページではコードを実行していません。かわりにお手本と同じコードを書けたかを、その場で判定します(判定はあなたのブラウザの中だけで行われ、どこにも送信されません)。
表示している実行結果は本物です。このページを作るときに、実際のDartで走らせて得た出力をそのまま載せています。
完成イメージ
最後まで進むと、このコードが完成します。下の実行結果は、このページを作るときに開発機で実際にコードを走らせて得たものです(このページ上では実行していません)。
===== 今日の献立 ===== 朝食 407 kcal(目安500) ごはん 234 kcal 3.5g 味噌汁 40 kcal 2.2g 焼き鮭 133 kcal 22.0g 昼食 628 kcal(目安700) 幕の内弁当 620 kcal —— コーヒー 8 kcal 0.2g 夕食 679 kcal(目安700) 豚の生姜焼き 380 kcal 24.5g サラダ 65 kcal 1.8g ごはん 234 kcal 3.5g 間食 210 kcal(目安なし) どら焼き 210 kcal —— ------------------------------------------ 品数 : 9品 合計 : 1924 kcal(目安 2000 kcal) たんぱく質 : 57.7g(未調査2品を除く) 目安まであと76 kcal です。
STEP 1料理をクラスにして、名前付き引数で作る
🎯 このステップのゴール: requiredとthis.を使い、呼ぶ側で意味が読める書き方にする
まずデータの形を決めます。料理1品が持つのは「名前・時間帯・カロリー・たんぱく質」の4つです。
コンストラクタの書き方に注目してください。
Dish({required this.name, required this.time, required this.kcal, this.protein});
波括弧で囲むと名前付き引数になります。呼ぶときはDish(name: 'ごはん', time: ..., kcal: 234)——どの値が何なのか、呼ぶ側のコードだけで分かります。
普通の引数だとこうなります。Dish('ごはん', MealTime.breakfast, 234, 3.5)——最後の3.5が何なのか、クラスの定義を見に行かないと分かりません。しかも順番を入れ替えても、型が同じなら気づけません。
Flutterのコードが名前付き引数だらけなのは、この読みやすさのためです。
requiredは「省略できない」という指定です。付けないと、呼ぶ側が書き忘れてもコンパイルが通ってしまいます。必ず要るものには必ず付けてください。
this.nameと書くだけで「受け取った値をそのままフィールドに入れる」という意味になります。Dish(String name) { this.name = name; }のような代入を書く必要がありません。
finalは「作ったあと変わらない」——献立の記録なので、あとから書き換わっては困ります。
そしてdouble? proteinの?。「調べていない料理がある」ことを型に書いています。requiredを付けていないので省略できます。
品数: 9品 1品目: ごはん 234kcal 幕の内弁当のたんぱく質: null
STEP 2nullを「無い」まま扱う
🎯 このステップのゴール: ??で計算し、nullのままの件数も数える
たんぱく質を合計します。ところがdouble?なので、そのままでは足せません。
sum + dish.proteinと書くと、The argument type 'double?' can't be assigned to the parameter type 'num'と怒られます。
これがDartのnull安全です。?の付いた型は、中身があるか確かめるまで使えません。他の多くの言語なら実行時まで気づけないところを、書いた瞬間に止めます。
解決は??です。dish.protein ?? 0.0——「nullなら0.0を使う」という意味になります。
ただし、ここで何が起きたかを意識してください。?? 0.0と書いた瞬間、「調べていない」が「0gだった」に化けます。合計だけ見れば同じですが、「未調査が何品あるか」は永遠に分からなくなります。
だからこの演習では、合計とは別に「未調査の件数」も数えています。menu.where((dish) => dish.protein == null).length——失われた情報を、別の形で残しておくのです。
「合計3g」とだけ出すのと、「合計3g(未調査2品を除く)」と出すのとでは、読む人に伝わることがまったく違います。
!という記号もあります。dish.protein!と書けば「nullではないと私が保証する」——外れると実行時に落ちます。Dartがせっかく用意した仕組みを自分から捨てることになるので、まず使わないでください。
たんぱく質: 57.7g 未調査: 2品 この合計に未調査の2品は入っていません
STEP 3switchで時間帯の名前を決める
🎯 このステップのゴール: enumを網羅し、defaultを書かずに済ませる
時間帯に日本語の名前を付けます。switch (time)で振り分けます。
ここでdefault:を書いていないのに注目してください。普通なら「書き漏らしたら危ない」と思うところです。
ですがこの場合は、書かないほうが安全です。——MealTimeは4つしか値を持たないenumで、その4つ全部にcaseを書いてあります。Dartは「全部書いてある」と分かっているので、文句を言いません。
効いてくるのは、あとで時間帯を1つ足したときです。MealTimeにlateNightを追加すると——このswitchで警告が出ます。「新しい値が扱われていない」と教えてくれるのです。
もしdefault: return 'その他';と書いていたら、この警告は出ません。新しい時間帯が黙って「その他」になり、誰も気づきません。
これが「defaultを書かないほうが安全」な理由です。enumを扱うswitchでは、あえて全部書き出して、defaultを省く——覚えておく価値のある書き方です。
逆に整数や文字列を扱うswitchではdefaultが必須です。取りうる値が無限にあるので、書き出しきれません。enumのときだけの話だと区別してください。
各caseですぐreturnしているので、breakを書き忘れて次に流れ込む事故も起きません。
朝食: ごはん 朝食: 味噌汁 朝食: 焼き鮭 昼食: 幕の内弁当 昼食: コーヒー 間食: どら焼き 夕食: 豚の生姜焼き 夕食: サラダ 夕食: ごはん
STEP 4Mapで目安を引く——キーが無い場合ごと
🎯 このステップのゴール: マップの取り出しがnullを返すことを、型で受け止める
時間帯ごとの目安カロリーをマップで持ちます。Map<MealTime, int>——時間帯をキーに、カロリーを値に。
ただし間食には目安を決めていません。マップに入れていないので、timeGoal[MealTime.snack]はnullを返します。
ここがDartのマップの大事な性質です。——キーが無いとエラーになるのではなく、nullが返ります。そして取り出した値の型は必ずint?になります。
だからMap<MealTime, int>から取り出しても、intではなくint?です。「キーがあるとは限らない」ことが、型に現れているのです。
final goal = timeGoal[time];と受けたあと、goal == nullで分けています。
goal ?? 0としないのが要点です。目安0kcalということはありえません。「目安を決めていない」を0にしてしまうと、必ず超過と判定されます——意味のない警告が毎回出ることになります。
「無い」と「0」は違う——ステップ2と同じ話が、別の形でまた出てきました。これはDartを書くうえで何度も出会う判断です。
表示する順番はtimeOrderという別のリストで持っています。マップの順序に頼らないためです。Dartのマップは入れた順を保ちますが、「朝・昼・夕・間食」という意味のある順に並べたいなら、自分で持つほうが確かです。
MealTime.breakfast: 407 kcal / 目安500 kcal → 範囲内 MealTime.lunch: 628 kcal / 目安700 kcal → 範囲内 MealTime.dinner: 679 kcal / 目安700 kcal → 範囲内 MealTime.snack: 210 kcal(目安なし)
STEP 5拡張メソッドでリストに機能を足す
🎯 このステップのゴール: 既存の型に、自分の都合のよいメソッドを後から付ける
合計を出すたびにforを書くのは面倒です。menu.totalKcalと書けたら楽です。
ところがListはDartが用意した型で、中身を書き換えることはできません。
そこで拡張メソッドです。extension MenuSummary on List<Dish> { ... }——「Dishのリストに、この機能を足す」という宣言です。
これでmenu.totalKcalと、元からあったかのように書けます。
on List<Dish>と型を絞っているのが要点です。on Listにすると、数値のリストにも文字列のリストにもtotalKcalが生えてしまいます——意味のない場所に候補が出て、かえって邪魔になります。
中身はfoldです。fold(0, (sum, dish) => sum + dish.kcal)——「0から始めて、1つずつ足していく」という意味です。
最初の0が大事です。これが無いと、空のリストのときに返す値が決まりません。foldは初期値を必ず渡させることで、空でも落ちないようにしています。
knownProteinではfold(0.0, ...)と小数の0から始めています。0と書くと整数のリストとみなされて型が合いません。
int get totalKcal => ...のgetは「括弧を書かずに使えるようにする」指定です。menu.totalKcal()ではなくmenu.totalKcal——計算というより「その値」だと感じられるものに向きます。
合計: 1924 kcal たんぱく質: 57.7g 朝食: 3品 407 kcal 空のリスト: 0 kcal
STEP 6ひとつの関数にまとめて仕上げる
🎯 このステップのゴール: reportにまとめ、日本語の幅を揃えて表にする
最後の仕上げです。ここまでの部品をreportという1つの関数にまとめます。
桁揃えにpadRightを自分で書いています。DartにもString.padRightはありますが、日本語では揃いません。
理由は文字の幅です。「ごはん」は3文字ですが、等幅フォントでは英数字6文字ぶんの幅で表示されます。padRight(14)は文字数で数えるので、日本語の行だけ長くなります。
s.runesで1文字ずつ見て、0x80より小さければ1、そうでなければ2として数え直しています。
runesを使っているのに理由があります。DartのStringを添字で見るとUTF-16の単位が返り、絵文字などが2つに割れます。runesなら文字として正しく1つずつ取り出せます。
数値の桁揃えはdish.kcal.toString().padLeft(4)です。数字は半角なので、こちらは標準のpadLeftで揃います。
toStringAsFixed(1)で小数第1位までに固定します。そのまま出すと57.699999999999996のような値が見えることがあります。
締めはdiffの正負で分けます。「あと何kcal」と「何kcal超過」を出し分ける——数字だけ出して終わりにせず、それで、どうなのかに答えるのが道具としての親切さです。
完成です。dailyGoalを1800に変えてみてください。締めの一言が「超えています」に変わります。
献立に一品足してみるのもよい確認になります。protein:を省いて追加すれば、未調査の件数が増えます——ステップ2で「別に数えて残しておいた」ものが、ちゃんと働くのが見られます。
===== 今日の献立 ===== 朝食 407 kcal(目安500) ごはん 234 kcal 3.5g 味噌汁 40 kcal 2.2g 焼き鮭 133 kcal 22.0g 昼食 628 kcal(目安700) 幕の内弁当 620 kcal —— コーヒー 8 kcal 0.2g 夕食 679 kcal(目安700) 豚の生姜焼き 380 kcal 24.5g サラダ 65 kcal 1.8g ごはん 234 kcal 3.5g 間食 210 kcal(目安なし) どら焼き 210 kcal —— ------------------------------------------ 品数 : 9品 合計 : 1924 kcal(目安 2000 kcal) たんぱく質 : 57.7g(未調査2品を除く) 目安まであと76 kcal です。
よくある質問
このページではコードを実行しないのですか?
していません。Dartはブラウザの中では走らせられないためです。このページではお手本と同じコードを書けたかどうかを判定する形にしています(判定はあなたのブラウザの中だけで行われ、コードはどこにも送信されません)。
ただし表示している実行結果は本物です。このページを作るときに実際のDartで走らせた出力を、そのまま載せています。
?? と ! は、どちらを使えばいいですか
!は使わないでください、というのが基本の答えです。
!は「nullではないと私が保証する」という意味で、外れるとその場で落ちます。Dartがせっかく用意した仕組みを、自分から捨てていることになります。
??で代わりの値を決めるか、if (x != null)で早めに分けるか、たいていはこのどちらかで書けます。
どうしても!を書きたくなったら、その値がなぜ?付きの型なのかを見直すほうが早いことが多いです。
null を 0 に置き換えてはいけないのですか
合計を出すだけなら構いません。問題はそのあと区別できなくなることです。
この演習でいえば、たんぱく質を?? 0.0で足した瞬間に、「0gだった料理」と「調べていない料理」が同じになります。合計だけ見れば同じでも、「この数字はどこまで信用できるか」が答えられなくなります。
だからこの演習では、合計とは別に未調査の件数も数えています。?? 0と書くときは、失われる情報を別の形で残せないかを一度考えてみてください。
enum の switch で default を書かなくていいのですか
enumのときは、書かないほうが安全です。
すべての値にcaseを書いてあれば、Dartは「全部扱われている」と分かるので文句を言いません。そしてあとでenumに値を足したとき、警告で教えてくれます。
defaultを書いてしまうと、新しい値が黙ってそこへ落ちて、誰も気づきません。
ただし整数や文字列を扱うswitchではdefaultが必要です。取りうる値が無限にあるので、書き出しきれないからです。
🚩 次はこのカテゴリへ
Dartのnull安全と、Kotlinのnull安全はほとんど同じ考え方です。?で「無いかもしれない」を型に書き、??で代わりを決める——Kotlinでは?:という記号になるだけです。片方を知っていると、もう片方はすぐ読めます。Kotlinカテゴリの最終演習では、記録し忘れのある歩数データを集計します。