🎯 最終演習

Scalaで各地の気温データを集計してみよう(総合演習)

ここまでの Scala レッスンで学んだことを全部つないで、動くものを1つ完成させます。新しい構文は出てきません。ケースクラス・Option・パターンマッチ・高階関数・for内包表記・文字列補間——すでに練習したものだけで組み立てます。

作るのは3都市5日ぶんの気温の集計です。平均と最高を出し、氷点下の日を数え、欠測を数えて、表にまとめます。

この演習では、観測データに欠測が混じっています。——実際の気象データがそうだからです。機械が止まっていた日、記録し忘れた日は必ずあります。

だからOptionが「例題のための例題」になりません。欠測を0.0で埋めれば、「0℃だった日」と「測れなかった日」が同じになります。平均は下がり、氷点下の件数は増える——数字が静かに狂います

もう1つ。この演習ではvarを一度も使いません。合計も最大値も、値を書き換えずに新しい値を作る書き方で通します。それがScalaで最初に身につける形だからです。

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

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

完成イメージ

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

===== 5日間の気温 =====
札幌  平均  -1.3℃  最高   1.2℃  欠測1件
  1日 -2.4  2日 -0.8  3日   ——  4日  1.2  5日 -3.1
東京  平均  10.5℃  最高  12.8℃  欠測1件
  1日  8.6  2日 11.2  3日  9.4  4日   ——  5日 12.8
那覇  平均  20.8℃  最高  22.6℃  欠測1件
  1日 19.5  2日 21.0  3日 20.2  4日 22.6  5日   ——
----------------------------------------------
観測数    : 15件(有効12件)
全体の平均:   10.0℃
氷点下    : 3件
最高気温  :   22.6℃(暖かい)

STEP 1観測をケースクラスで表す

🎯 このステップのゴール: 1行で値クラスを作り、欠測をOptionで持つ

まずデータの形を決めます。観測1件が持つのは「都市・日付・気温」の3つです。

case class Reading(city: String, day: Int, celsius: Option[Double])——これだけで完成です

caseを付けると、Scalaがいくつものものを自動で用意します。中身を並べたtoString中身が同じなら等しいと判定するequals、一部だけ変えた複製を作るcopy、そしてパターンマッチで分解できる能力

newも要りません。Reading("札幌", 1, Some(-2.4))そのまま書けます

そしてcelsius: Option[Double]が、この演習の要です。

Scalaにはnullもありますが、使いません。「値が無いかもしれない」はOptionで表します。Some(値)Noneのどちらかという型です。

この差は大きいです。nullだと、どの変数がnullになりうるのか型からは分かりませんOption[Double]と書いてあれば、「無いことがある」と一目で分かり、しかも中身を取り出すまで数値として使えません

「うっかり忘れる」ができなくなる——それがOptionの値打ちです。

フィールドはすべてval——作ったあと書き換えられません。観測の記録なので、それが正しい形です。

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
観測数: 15件
1件目: Reading(札幌,1,Some(-2.4))
欠測の例: Reading(札幌,3,None)
同じ内容は等しい: true

STEP 2flatMapで欠測を落とす

🎯 このステップのゴール: Optionのリストから、中身のあるものだけを取り出す

集計するには、まず欠測を除いた気温だけの一覧が要ります。

rs.flatMap(_.celsius)——これだけです

なぜこれで欠測が落ちるのか。——ScalaではOptionを「0個か1個の入れ物」として扱えますSome(8.6)は1個入り、Noneは空っぽ。

flatMap「それぞれを入れ物に変えて、全部つなげる」という働きです。だからSomeの中身は並び、Noneは何も足さずに消えます

結果の型がList[Double]になっているのが要点です。List[Option[Double]]ではありません。もうOptionが外れているので、そのまま足せます

_.celsius_r => r.celsiusの省略形です。引数を1回しか使わないときに使えます

手で書くとどうなるか、比べてみてください。——forで回して、ifNoneを除いて、可変のリストに足していく。5行以上かかりますし、途中で状態を書き換えることになります

欠測の件数は引き算で出せますrs.size - values.size——元の件数から有効な件数を引くだけです。

この一手間を省かないでください。「有効12件」とだけ出すのと「15件中12件有効(欠測3件)」と出すのとでは、読む人が数字をどこまで信用してよいかの判断が変わります

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
札幌の観測: 5件
欠測を除くと: 4件
気温: List(-2.4, -0.8, 1.2, -3.1)
欠測: 1件

STEP 3foldLeftで合計と最大を出す——varを使わずに

🎯 このステップのゴール: 書き換えずに畳み込み、空のときはNoneを返す

平均を出します。varを使わずに合計する方法がfoldLeftです。

values.foldLeft(0.0)(_ + _)——「0.0から始めて、順に足していく」という意味です。

var sum = 0.0と書いてforで足すのと、結果は同じです。違うのは途中で書き換わる変数が1つも無いこと。

なぜそれが良いのか。書き換わる変数があると、「いまこの変数はどんな値か」を追いながら読むことになります。foldLeftなら「0から始めて足していったもの」——それだけです。

そして戻り値をOption[Double]にしているのが要点です。

1件も観測が無い都市の平均は、0℃ではありません。「計算できない」のです。0.0を返すと、「平均0℃の寒い街」として集計に混ざります

if (values.isEmpty) None else Some(...)——正直に「無い」と返します。受け取る側はmatchで場合分けさせられるので、0と混同しようがありません

最大値のmaxOfは、もう少し込み入っていますfoldLeft(None: Option[Double])——「まだ何も見ていない」状態から始めます

中ではbest matchで分けています。すでに見つけた最大値があって、それが今の値以上ならそのまま。そうでなければ今の値に入れ替えるNoneのとき(1件目)もcase _で拾われるので、自然に1件目が採用されます

values.maxという短い書き方もありますが、空のリストで例外が飛びます。ここではそれを避けています。

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
東京の平均: Some(10.5)
東京の最高: Some(12.8)
空のときの平均: None
空のときの最高: None

STEP 4パターンマッチで気温を分類する

🎯 このステップのゴール: ガード付きのcaseを順に並べ、matchが式であることを使う

気温から一言のコメントを決めます。使うのはパターンマッチです。

celsius match { case c if c < freezing => "氷点下" ... }

case c if 条件ifがガードです。「この形に当たったうえで、さらに条件を見る」——値の形だけでなく、値そのものでも分けられます

順番が結果を決めます。上から順に見て、最初に当たったものが採用されます。だからc < 0.0c < 10.0より先に書かないと、氷点下も「寒い」に吸い込まれます

細かい条件から先に書く——パターンマッチを書くときの基本です。

最後のcase _ => "暖かい"_は「それ以外すべて」これが無いと、当てはまらない値が来たときに実行時エラーになります。数値のように取りうる値が無限にある場合は、必ず書いてください。

そしてmatchは式です。——値を返します。だからdef comment(celsius: Double): String = celsius match { ... }と、=のあとにそのまま書けますreturnも波括弧も要りません。

Javaのswitchのように「変数を用意して、各分岐で代入する」必要がありません。これがScalaのコードが短くなる理由の1つです。

Optionmatchで開くのも同じ仕組みです。case Some(a) => ...case None => ...——2つとも書かないと警告が出ます「中身があるとき」だけ書いて済ませられないようになっています。

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
札幌 1日: -2.4℃ 氷点下
札幌 2日: -0.8℃ 氷点下
札幌 3日: 欠測
札幌 4日: 1.2℃ 寒い
札幌 5日: -3.1℃ 氷点下
東京 1日: 8.6℃ 寒い

STEP 5for内包表記と文字列補間で1行にまとめる

🎯 このステップのゴール: yieldで新しいリストを作り、s補間とf補間を使い分ける

1都市ぶんの気温を1行に並べます。使うのはfor内包表記です。

for { r <- rs; text = ... } yield s"${r.day}日$text"

yieldが付くと、forは「回すだけ」ではなく「新しいリストを作る」ものになります。結果はList[String]です。

yieldの無いforとは別物だと考えてください。yield無しは表示するなど、何かをするだけyield付きは元のリストから新しいリストを作る——mapと同じ働きです。

中のtext = ...途中の値に名前を付けているだけです。<-ではなく=——回すのではなく、その回の値を計算しています

そして文字列補間です。Scalaには2種類あります。

s"..."は値をそのまま埋め込みます。s"${r.day}日"1日になります。

f"..."は書式を指定できます。f"$c%5.1f"——%5.1fが「5文字ぶんの幅で、小数第1位まで」printfと同じ書式です。

数字を揃えたいときは必ずfのほうです。s"$c"では8.611.2で桁がずれますし、10.5のつもりが10.499999999999998と出ることもあります。

mkString(" ")リストを区切り文字でつなげますList("1日 -2.4", "2日 -0.8")1日 -2.4 2日 -0.8という1つの文字列になります。

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
札幌: 1日 -2.4  2日 -0.8  3日   ——  4日  1.2  5日 -3.1
東京: 1日  8.6  2日 11.2  3日  9.4  4日   ——  5日 12.8
那覇: 1日 19.5  2日 21.0  3日 20.2  4日 22.6  5日   ——
s補間: 10.5
f補間:   10.5

STEP 6シングルトンオブジェクトにまとめて仕上げる

🎯 このステップのゴール: objectに集約し、Optionを開いて表示する

最後の仕上げです。ここまでの部品をobject WeatherReportにまとめます

objectは「そのものが1つだけ存在するクラス」です。newする必要がなく、WeatherReport.print(readings)とそのまま呼べます。

Scalaにはstaticがありません。Javaでstaticを付けていたものは、Scalaではobjectに入れます。「クラスに属するが、実体は1つ」というものを、言語の仕組みとして持っているのです。

ここでOptionの扱いをもう一度見てください。

averageOf(values) match { case Some(a) => s"平均${fmt(a)}℃"; case None => "平均——" }

表示の直前までOptionのまま運んでいます。途中でgetOrElse(0.0)としてしまうと、そこから先は「0℃だった」と区別できません

「無い」を最後まで持ち歩き、表示の段階で初めて——という形にする——これがOptionの正しい使い方です。

padRightは日本語の幅を揃えるために自分で書いています。「札幌」は2文字ですが、等幅フォントでは英数字4文字ぶんの幅——標準のpadToは文字数で数えるので揃いません。

s.map(c => if (c < 0x80) 1 else 2).sum——1文字ずつ幅に変換して合計するという、Scalaらしい素直な書き方です。

完成です。ここまででvarを一度も使っていません。合計も最大値も、書き換えではなく「元の値から新しい値を作る」で通しました。

観測データのSome(...)をいくつかNoneに変えてみてください。欠測の件数が増え、平均が変わります。1都市を全部Noneにすれば、「平均——」と表示されます——ステップ3でOptionを返すようにしておいた分岐が、実際に働くところが見られます。

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
===== 5日間の気温 =====
札幌  平均  -1.3℃  最高   1.2℃  欠測1件
  1日 -2.4  2日 -0.8  3日   ——  4日  1.2  5日 -3.1
東京  平均  10.5℃  最高  12.8℃  欠測1件
  1日  8.6  2日 11.2  3日  9.4  4日   ——  5日 12.8
那覇  平均  20.8℃  最高  22.6℃  欠測1件
  1日 19.5  2日 21.0  3日 20.2  4日 22.6  5日   ——
----------------------------------------------
観測数    : 15件(有効12件)
全体の平均:   10.0℃
氷点下    : 3件
最高気温  :   22.6℃(暖かい)

よくある質問

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

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

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

Option の中身を取り出す簡単な方法はありませんか

getOrElseがあります。averageOf(values).getOrElse(0.0)と書けば1行です。

ですが、書く前に一度考えてください。そこで0.0にした瞬間、「平均0℃だった」と「計算できなかった」が区別できなくなります

getOrElseが向くのは、代わりの値に意味があるときです。「設定が無ければ既定値を使う」ならぴったりですが、集計結果に代わりの値はありません

この演習では表示の直前までOptionのまま運び、そこで——という形にしています

var を使ってはいけないのですか

禁止ではありませんが、まずvalで書けないか考えてください。

この演習では合計も最大値もfoldLeftで書いていて、途中で書き換わる変数が1つもありません。読むときに「いまこの変数はどうなっているか」を追う必要が無くなります。

また書き換わらない値は、複数の処理から同時に触られても壊れません。Scalaが並行処理に強いとされるのは、この性質が土台にあるからです。

どうしてもvarが要る場面はあります。そのときは使う範囲をできるだけ狭くする——関数の中だけに閉じ込めてください。

flatMap で欠測が消えるのはなぜですか

ScalaではOptionを「0個か1個の入れ物」として扱えるからです。

Some(8.6)は1個入りの入れ物、Noneは空っぽの入れ物と考えてください。

flatMap「それぞれを入れ物に変えて、全部つなげる」働きです。つなげるとき、空っぽの入れ物は何も足しません——だからNoneが自然に消えます。

結果の型もList[Option[Double]]ではなくList[Double]になります。もうOptionが外れているので、そのまま計算できます。

🚩 次はこのカテゴリへ

ScalaはJavaと同じ土台の上で動く言語です。この演習でOptionとパターンマッチで書いたことを、Javaではどう書くか——比べると、Scalaが何を短くしているのかがはっきり分かります。Javaカテゴリの最終演習では、図書館の貸出管理を作ります。

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