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で回して、ifでNoneを除いて、可変のリストに足していく。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.0をc < 10.0より先に書かないと、氷点下も「寒い」に吸い込まれます。
細かい条件から先に書く——パターンマッチを書くときの基本です。
最後のcase _ => "暖かい"の_は「それ以外すべて」。これが無いと、当てはまらない値が来たときに実行時エラーになります。数値のように取りうる値が無限にある場合は、必ず書いてください。
そしてmatchは式です。——値を返します。だからdef comment(celsius: Double): String = celsius match { ... }と、=のあとにそのまま書けます。returnも波括弧も要りません。
Javaのswitchのように「変数を用意して、各分岐で代入する」必要がありません。これがScalaのコードが短くなる理由の1つです。
Optionをmatchで開くのも同じ仕組みです。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.6と11.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カテゴリの最終演習では、図書館の貸出管理を作ります。