FizzBuzzしてみよう
FizzBuzzしてみよう
for式・if式といった基本的な制御構文
ここで扱うのは,みんな大好き“FizzBuzz”です.そう,「1からnまでの値を取り,3の倍数のときは Fizz を,5の倍数のときは Buzz を,それ以外のときは数値をそのまま出力する」というよくある問題です.書き方は色々有りますが,例えばJavaで書くとこんな感じでしょうか.
void fizzBuzz(int n) {
for (int i = 1; i <= n; i++) {
if (i % 15 == 0) {
System.out.println("FizzBuzz");
} else if (i % 3 == 0) {
System.out.println("Fizz");
} else if (i % 5 == 0) {
System.out.println("Buzz");
} else {
System.out.println(i);
}
}
}
これをScalaに置き換えていきましょう.
FizzBuzzに必要な道具を揃える① ─for式
とりあえずループと条件分岐が必要ですね.Scalaにはループに使うことができる構文としてwhile式とfor式がありますが,ここではwhile式のことは一旦忘れて,for式を使って進めていきたいと思います.
JavaやCでよく行われるwhile文 + break・continueをScalaで実現することも可能ですがあまり推奨されていません.より賢く読みやすい方法で記述できる場合がほとんどですのでそちらを模索しましょう.
JavaやCのfor文と似ていますが,Scalaでは式になっています.つまり値を返すことができます.また,“ジェネレータ” という構文を使うことで,for(int i = 0; i < 10; i++)のようにループカウンタを宣言して明示的にインクリメントしていくようなことはせず,JavaやC++で言う拡張for文(≒foreach文 ≒for-in文)に近い挙動をします.では1からnまでカウントアップしながら標準出力を行う簡単なサンプルを見てみましょう.
1: val n = 3
2: for { i <- (1 to n) }{
3: println(i)
4: }
5: /* 出力
6: 1
7: 2
8: 3
9: */
重 要なのはi <- (1 to n)の部分です.これを“ジェネレータ”と呼びます.<-はジェネレータを表す専用のキーワードとなっており,右辺がデータ源,左辺が右辺から順番に取り出された値になります.先ほどのサンプルで見ると,右辺の(1 to n)は1からnの値を,つまり1,2,3という値を生成し,左辺のiで1,2,3を順番に利用できます.結果的にこのサンプルでは1,2,3が改行区切りで出力されるはずです.JavaやCでfor(int i = 1; i <= 3; i++)としたときと同じですね1.
ループの範囲を変えるにはジェネレータの右辺を変えればいいことがわかりました.次はもう少し高度なループを見ていきたいと思います.ターゲットにするJavaのサンプルは以下の通りです.
for (int i = 0; i <= 12; i += 3) {
if (i % 2 == 0) {
for (int j = 1; j <= 3; j++) {
System.out.println(i * j);
}
}
}
2重ループになっており,iが3ずつインクリメントされたりifが挟まっていたりとやや複雑になっています.Scalaでは次のようになります.
1: for {
2: i <- (0 to 12 by 3) if i % 2 == 0
3: j <- (1 to 3)
4: } {
5: println(i * j)
6: }
「3ずつインクリメント」という文脈はby 3を追加するだけで実現できます.例えばby 2に変えると2ずつインクリメントされるようになります.次に見るべきはその後ろに付いているifです.これは生成される値から取り出すときの条件を指定できます.この例では偶数だけがiに渡されることになります.
ただし,このifはあくまで生成される値から取り出すときの条件であり,値を生成するための条件ではありません.つまり,一旦0,3,6,9,12という値が作られてからiに0,6,12だけが渡されます.
次は2つ目のjに関するジェネレータに注目しましょう.Scalaのfor式ではこのようにジェネレータを改行して複数並べることで多重ループを実現できます.今回は2つなので2重ループ相当ですが,何個でも並べることができます.先にあるジェネレータが外側のループに相当します.見た目の順序はJavaやCでfor文をネストさせた場合と同じですね.
ここまで見てきたfor式をまとめると,以下のようになります.
for {
順番に取り出される値のための変数 <- データ源
...
} 式
また現れました,for式の本体部分(後半部分)も式ですね.ジェネレータで定義した変数を利用してなんらかの計算を行うことになります2.
さて,ここで1つ気になる点はないでしょうか?for式なのに返り値がないように見えませんか?実は本体の式にどんな式を入れようともUnit型の()を返してきます.
1: val result = for {
2: i <- (1 to 3)
3: } {
4: i
5: } // >> ()
6: // 単純に i を返すだけの式にしたい
7: // つまり,`1`,`2`,`3` と3つの値が返ってきて欲しい
8: // が,`result` は `Unit` 型の `()` になってしまう
以前解説したようにUnit型は返り値がないことを意味するものです.困りましたね,このままではfor式が式である意味が感じられません.そこで登場するのがyieldというキーワードです.C#やPythonといった一部の言語では,Scalaのyieldとほぼ同じ意味のキーワードとして使われています.このキーワードをfor式に挿入することで,本体の式の値を返すことができるようになります.
1: val result = for {
2: i <- (1 to 3)
3: } yield {
4: i
5: } // >> `1`,`2`,`3` の3つの値からなるコレクション
ここではresultはVectorというコレクションになっていますが詳細は6章で説明します.現時点では配列のように順序ある値の集まりと考えてください.これを一般的な形で見てみると以下のようになります.
for {
順番に取り出される値のための変数 <- データ源
...
} yield 式
本当にただyieldというキーワードが増えただけです.単純ですね.
FizzBuzzに必要な道具を揃える② ─if式
さて次は条件分岐を見ていきましょう.まずはif式です.これも式になっており,JavaやCの3項演算子に近い,より汎用的な挙動をします.一般的な形にすると以下のようになります.
if (`Boolean` 型の値を返す式) 式 else 式
条件式に応じてどちらかの式の結果がif式の結果として返されます.もちろんelse ifを利用した複数条件も記述できます.
さて,手札が揃ったところでScalaでFizzBuzzを書いていきましょう.
1: def fizzBuzz(n: Int): Unit = for { i <- 1 to n } {
2: if (i % 15 == 0) {
3: println("FizzBuzz")
4: } else if (i % 3 == 0) {
5: println("Fizz")
6: } else if (i % 5 == 0) {
7: println("Buzz")
8: } else {
9: println(i)
10: }
11: }
概形は先に示したJavaのものと同じですね.
FizzBuzzの趣向を変えてみる① ─match式
少し違う形でも書いてみましょう.match 式という構文を使ってみたいと思います.JavaやCで言うswitch文のお化けのようなものです.先ほどのFizzBuzzを書き直すと以下のようになります.
1: def fizzBuzz(n: Int): Unit = for { i <- 1 to n } {
2: i match {
3: case x if x % 15 == 0 =>
4: println("FizzBuzz")
5: case x if x % 3 == 0 =>
6: println("Fizz")
7: case x if x % 5 == 0 =>
8: println("Buzz")
9: case x =>
10: println(x)
11: }
12: }
一般的なswitch文と形はほぼ同じですが,より多くの条件を記述できるようになっています.上記の例ではx if x % 3 == 0やxといった条件が使われています.caseの直後に現れるxは条件判定と判定後の処理のみで利用できる変数の宣言です3.この変数宣言のみの場合は全ての条件にマッチしますが,更に条件を加えることができます.x if x % 3 == 0は「xが3で割り切れる場合に限定する」という条件,xは「全てにマッチする」という条件になります.列挙された条件は上から順番に評価され,最初にマッチした条件のみがその後の式の評価に進みます(JavaやCのような条件ごとのbreakはなく,複数の条件にマッチすることもありません).「15で割り切れる」場合はprintln("FizzBuzz")のみが実行され,そうでなく「3で割り切れる」場合はprintln("Fizz")のみが実行され,「5で割り切れる」場合はprintln("Buzz")のみが実行されます.そして,残りの全ての場合はprintln(x)のみが実行されるという寸法です.
match式の一般的な形も見てみましょう.
対象の式 match {
case 1つ目の条件 => 式
case 2つ目の条件 => 式
...
}
対象の式(変数単体も式であることを忘れないでください)が評価された結果に基づいてパターンマッチが行われます.多くの場合,必要なパターンを上から順に記述し,最後にcase x => 式で残り全ての条件をキャッチすると良いでしょう4.
また,match式であるため,値を返します.
1: val data = 10
2: val result = data match {
3: // 定数による条件
4: case 0 =>
5: "0です"
6:
7: // `|` によるOR条件
8: case 1 | 2 =>
9: "1か2です"
10:
11: case x if x % 3 == 0 =>
12: // `toString` を呼び出すことで文字列に変換できます
13: "0でも1でも2でもなく3で割り切れる値である" + x.toString + "です"
14:
15: case x =>
16: // 文字列補完
17: s"0でも1でも2でもなく3で割り切れない値である${x}です"
18: }
19:
20: println(result)
21: /* 出力
22: "0でも1でも2でもなく3で割り切れない値である10です"
23: */
文字列補完
複数の文字列と変数を組み合わせて文字列を作成するための文字列補完と呼ばれる機能があります.文字列を括るクォーテーション(")の前にsを付けることで,文字列内で${式}として変数を参照して式を記述できるようになります.なお,文字列補完やprintlnにString型でない値が渡された時は暗黙的にtoStringが呼び出されます.
定数条件の場合は定数をそのまま条件として利用します.この際|で区切ることでOR条件にできます.match式はここまで扱ってきた以外にも様々な条件に対応しています.本書でももう少し出てきます.
FizzBuzzの趣向を変えてみる② ─再帰
ここで趣向を変えてループを使わずにFizzBuzzを書き直してみましょう.ループの代わりといえば再帰ですね.
1: def fizzBuzz(n: Int,i: Int = 1): Unit = {
2: // 値に応じて出力
3: i match {
4: case x if x % 15 == 0 =>
5: println("FizzBuzz")
6: case x if x % 3 == 0 =>
7: println("Fizz")
8: case x if x % 5 == 0 =>
9: println("Buzz")
10: case x =>
11: println(x)
12: }
13:
14: // `i` が `n` になるまで再帰呼び出し
15: if (i < n) fizzBuzz(n,i + 1)
16: }
17:
18: fizzBuzz(15) // 最初の呼び出し
iをカウンタとして,nまで再帰呼び出しを行います.最初の呼び出しではi: Int = 1のデフォルト引数を利用しています(再帰呼び出しの際はi + 1を利用).
Scalaは関数型プログラミングのアプローチを汲んでいる言語のため,そうでない言語に比べてループより再帰の方が記述しやすいという機会が自然と多くなります.慣れておくと良いでしょう.ただしScalaで再帰を書く際には末尾再帰最適化が注意点として挙げられます.末尾再帰最適化というのは,「再帰呼び出しが関数内で最後に評価される箇所でしか発生しない場合に適用される最適化」のことです.この最適化が行われない再帰関数は,容易にスタックオーバーフローを引き起こしてしまうため書くべきではありません.フィボナッチ数列(0,1,1,2,3,5,...と続く,第n項が第n-1項と第n-2項の和で表される数値列)を例にすると以下のようになります.
1: def fib(n: Int): Int =
2: if (n < 2) n else fib(n - 1) + fib(n - 2)
まず1つ目のコードは末尾再帰最適化が行われないサンプルです.見てみると,if式前半はnを返すだけなので良いものの,後半部分(fib(n - 1) + fib(n - 2))で最後に評価されるのは+になっています(fib(n - 1)→fib(n - 2)→ それらの結果の加算を行う+という順で評価される).つまり末尾以外で再帰呼び出しが発生しているため最適化が行われません.
1: def fib(n: Int): Int = {
2: // 慣習的に再帰のために切り出された内部メソッドには
3: // `go` や `loop` という名前が用いられることが多い
4: @scala.annotation.tailrec
5: def go(n: Int,prev: Int,curr: Int): Int =
6: if(n == 0) prev
7: else go(n - 1,curr,prev + curr)
8: go(n,0,1)
9: }
一方2つ目のコードは,全ての再帰呼び出しが最後に評価される箇所(go(n - 1,curr,prev + curr))でしか発生していません.そのためgoメソッドは末尾再帰最適化が行われます.末尾再帰最適化が行われる形への変形は困難な場合もあるため,できないのであればfor式など他の方法を模索しましょう.
なお,再帰関数を書く場合は@scala.annotation.tailrecというアノテーションを付けておくことで,末尾再帰最適化ができない形になっていたときにコンパイルエラーで教えてくれます.実行時のスタックオーバーフローを防ぐためにも必ず付けておきましょう.
(この項、了)