なんとなく使えるからの脱却

前回は簡単なforループについてScalaの数多くのルールでどのように処理されているかを追いかけました。for文はシンタックスシュガー(糖衣構文)であるということは前回書きましたが、今回はさらに一歩踏み込みます。より一般的なfor文について全てメソッドの表記に変換できるということを言語仕様書から追いかけていきます。

しばらくScalaを使っていると、なんとなく動くfor文を書くことは出来るようになります。使ったことのあるイディオムなら書けるという状態です。しかし、イディオムを使うだけではどうしても応用力に限界があります。一度仕様書の定義に立ち返り、for文の基本をおさえることで、なんとなく書けるからの脱却を目指します。

注意:文中の scala> で書き始めているコードはREPL環境で実行しています。

文法を読む

Scalaの言語仕様は公式ドキュメントサイトで公開されています。現在最新版の2.11の仕様書では6.19節に文法が定義されています。とりあえず引用してきましょう。

ほとんど呪文のような内容が書いてあります。これがさらりと理解できた人はおそらく今回の記事を読む必要などなく自力でどうにかできる人だと思います。プログラミング言語の仕様でよく見かけるこの記法はBNFという記法です。BNFとはバッカス・ナウア記法の略称で、Scalaの仕様書では拡張したEBNF(拡張バッカス・ナウア記法)を使用しています。ScalaのEBNFはBNFに繰り返しのルールを加えたもののようです。詳しく勉強したい人はグーグルで調べるか、wikipediaの該当ページをみてください。

EBNFのルールのうち、Scalaのfor文に影響しそうな事柄をざっくり解説します。

A BはAとBを続けて書くことを意味します。空白を間に入れるかは状況によってことなります。(明記がないのでよく分からない。Scalaの場合は入れなくていいパターンが多い模様)
A ::= BはAをBと定義を行います。 Enumerators ::= ~とEnumeratorsは~と書き現わすという意味になります。
A | BはAまたはBを記述するという意味です。
(~)は中の記述をカッコでくくるとグルーピングします。多くの場合 |とセットで使い、 |の影響を外に出しません。
[~]は中の記述を1回または0回存在することを示します。記述が文法上任意な部分の表記なります。記述の有無で意味が異なる場合があります。
{~}は中の記述を0回以上繰り返します。繰り返す回数の上限は制限がありません。

for文の構成要素

Scalaのfor文は大きく2種類あります。文法上は省略可能な [yield]によって変化します。
yieldのない for(enum) eという形式のfor文をforループ(For Loop)と呼びます。
yieldのある for(enum) yield eという形式をfor内包表記(For Comprehension)と呼びます。
本記事ではまとめてfor文と読むことにします。

ここからfor文について説明する上で、各要素の名前を押さえておきましょう。 Pattern1pExpreとしています。
p <- eという記述をジェネレーター(generator)と呼びます。私は eが値を作って pに入れていくイメージのためジェネレーターなのだと思っています。
ジェネレーターの後ろに記述できる if <条件>は英語でGuardと呼ばれますが、日本語での定訳はフィルターです。
フィルター同様にジェネレータの後ろに記述できる p = eは値の定義(value definition)または単純に定義と呼ばれます。これらの名前が決まりやっとfor文を語るに足りるようになります。

補足として、 Pattern1というのは(おそらく)全ての変数名宣言です。 Exprは(おそらく)全ての式の記述です。本記事ではざっくり変数名と式だと思っておけばOKです(ここに関しては筆者自身が仕様書に潜りきれてません)。さらに、6.19節の引用に定義されていないものとして seminlがあります。それぞれ、 semi ::= `;' | nl {nl}と定義されておりセミコロンまたは nl一個以上を意味します。 nlはNew Lineの頭文字で、改行文字を意味します。

変換ロジックを読む

for文の文法を見る限り、ジェネレーターとフィルターと値の定義を際限なく繰り返して記述することが可能です。かなり記述のバリエーションの多い文法です。Scalaでは状況に合わせて内部的には foreachをはじめとしたメソッドの呼び出しに変換を行っています。for文は最終的に foreachmap, flatMap, withFilterの4種類のメソッドに展開されます。どのような変換が行われるかをパターン分けして順番にみていきましょう。

1. ジェネレーター1つの場合

最初に確認として前回も紹介した簡単なforループの場合の変換です。forループは foreachに変換されます。

一般化すると for(p <- e) e' という記述は e.foreach(p => e')に変換されます( ee'は両方文法上 Exprなのですが、区別のために別の記号を当てています)。

同様に簡単なfor内包表記を考えます。
yieldの付いたfor内包表記は foreachの代わりに mapに変換されます。

一般化すると for(p <- e) yield e' という記述は e.map(p => e')に変換されます。

2. ジェネレーターが複数の場合

ジェネレータが1つの場合からの拡張でジェネレータが複数の場合の変換です。例えば下のような例があったとします。

ジェネレーターが複数あるforループの場合の変換ルールは、一番先頭のジェネレータから順番に変換を行います。先頭部分にだけ注目して for(p <- e; <残りのジェネレーター>) e'e.foreach(p => for(<残りのジェネレーター>) e')に変換します。 <残りのジェネレーター>が1つになるまで繰り返し、1つになったら、上で紹介したジェネレータが1つの場合の変換が行われます。

変換を行うと以下のようになります。

foreachが入れ子になった形に変換されます。

for内包表記の場合はforループと少々異なります。forループの場合は foreachでしたが、同様の変換を flatMapを用いて行います(ジェネレーターが1つの場合のメソッド mapではないことに注意)。

このfor内包表記の例を最初のジェネレーターのみ変換を行うと以下のようになります。

途中経過の残りのforはジェネレータ1つなので mapで変換を行います。

このように変換されます。

3. ジェネレーター1つにフィルターが付いている場合

ジェネレーターにフィルターが付随している場合の変換です。この変換はforループとfor内包表記に差がないので、forループを例にします。

これを変換すると下のようになります。

for文全体の構造は変えずに、フィルターを withFilterに変換してジェネレータ内に吸収します。

一般化を行うと for(p <- e if e') [yield] e''for(p <- e.withFilter(p => e'))) [yield] e''に変換されます。

文法上、このフィルターを複数つけれるのですが、複数フィルターが付いた場合を考えます。下のような例に変換を行います。

この例の場合前から順に1つづつ処理され最終的に下のようになります。

しかし複数のフィルターを使う場合、すべての条件を論理演算子 &&で接続しても同じ結果が得られるので、あまり意味のある利用シーンはありません(ifを並べた方が読みやすい場合はあります)。

4. ジェネレーター1つに値の定義が付いている場合

ジェネレーターに値の定義が付随している場合の変換です。この変換もフィルターに続いてforループとfor内包表記に差がないので、forループを例にします。

値の定義は一度for文に変換されます。for文はさらに変換されますので、下のように変換されます。

このように、タプルを使うことによって値の定義をジェネレーターに吸収します。

上の二つの変換は最終的に同じですが、読みやすさのためにfor文を用いて一般化を行います。 for(p <- e; q = e') [yield] e''for((p, q) <- for(p <- e) yield {val q = e'; (p, q)})) [yield] e''に変換されます(実際どっちが読みやすいかは甲乙つけがたいです)。

値の定義もフィルター同様、文法上は複数つけれるのですが、複数の値の定義が付いている場合を考えます。下のような例に変換を行います。

この例の場合ジェネレーターと値の定義すべてがまとめてすべて1つのfor文に変換されます。

(タプルを利用するということはTuple23以上が存在しない問題が発生しますが、その話題は次回に持ち越します。)

5. ジェネレーター1つにフィルターと値の定義が複数付いた場合

ここまで書いてきた「3. ジェネレーター1つにフィルターが付いている場合」「4. ジェネレーター1つに値の定義が付いている場合」を合わせて、両方あったらどうなるのかという問題です。答えは単純に「前から順に処理される」だけです。実際に例をとって見てみましょう。

前から、値の定義、フィルター、値の定義の順に並んでいるので、変換も値の定義、フィルター、値の定義の順に行われます。

かなり複雑ではありますが、1つのジェネレーターに変換できたことがわかります。

6. ジェネレーターが複数にフィルターと値の定義が複数付いた場合

例示しても複雑になりすぎるので、変換手順を説明しておきます。
まず、すべてのフィルターと値の定義をジェネレーターにまとめます。「5. ジェネレーター1つにフィルターと値の定義が複数付いた場合」の変換が理解できていれば、すべてのフィルターと値の定義の表現をジェネレーターにまとめてしまうことが可能だと理解できると思います。
変換の結果ジェネレータのみにできてしまえば「2. ジェネレーターが複数の場合」のルールに従ってすべてをメソッドに置き換えることができます。
実際に手を動かして変換してみるのは難しいかもしれませんが、ロジック上はすべてをメッソドに変換できているはずということが理解できれば十分です。

型が指定された場合のフィルター処理

ジェネレータの変数に型が指定されていた場合、指定していない場合に追加して処理が行われます。順番としては一番最初に行われるのですが、解説は一番最後になってしまいました。
下の二つの例は、違う処理に変換されます。

Scalaには型推論があるから、同じものとして処理されるはずという予想もあるとは思いますが、Scalaのコンパイラの型推論はforの展開の後に行われます。そのため、forの展開には型推論とは別に型を安全にするための処置が行われます。実際にその処理を施すと下のようになります。

一般化を行うと for(p <- e) [yield] e'for(p <- e.withFilter{case p => true; case _ => false}) [yield] e'に変換されます。これは型推論とは別に行われるので、 peの型がマッチしていたとしても型がマッチしない可能性のある「書き方」がされていると必ず変換されます。 i:Anyと書いても変換されます。 withFilterの内容は少ないので高速な処理ではありますが、for文では型指定をしない方が処理の量が減ります。

型を指定することで withFilterが働くのであれば以下のような記述が可能に思えますが、エラーが出て動きません。

withFilterを経たとしても Seq[Any]という型自体に変化はないので Intのみの引数を持つ関数を受け付けません。

型チェックでエラーが出るのであればどのような場面で役立つ仕様なのかわかりづらいですが、ちゃんと役立ちます。型チェックのフィルターが有効に機能するパターンがあります。ジェネレーターの pに抽出子(extractor)を利用する場合です。

for文での抽出子(extractor)

下に示すようなfor文を使ったことがある人は多いのではないでしょうか。

Noneを取り除いて処理ができます。ごく当たり前の処理に見えますが、下のように書き換えると動きません。 Seq(None, Some(1), Some(2))Seq[Option]であって Seq[Some]ではないからです。

上の例であれば Some(i)のことを抽出子(extractor)と呼びます。抽出子を利用したfor文を変換すると以下のような2ステップを経ます。

この変換で注目して欲しいのは2点あります。1つ目は withFilterが有効に機能していることです。型指定をしただけの場合では特に意味を見出せませんでしたが、抽出子をジェネレーターで使った場合はフィルターを入れることで目的のものだけを抽出できているのが見て取れます。
注目して欲しいことの2つ目は foreachの引数です。関数ではなく、matchパターンになっています。抽出子は関数の引数にできないので p => eという関数表記ではなく {case p => e}というmatchパターンになります。

抽出子による変換は「5. ジェネレーター1つにフィルターと値の定義が複数付いた場合」の変換例でも実は使われています。 for((i, q2, q3) <- ...という表記がそうです。これは、型名がありませんが、タプルの抽出子(正確には scala.Tuple3の抽出子)です。抽出子を利用する場合フィルターも caseを伴ったmatchパターンに書き換わります。

抽出子は unapplyというメソッドを定義することで利用できるようになります。 unapplyはcase classには自動で定義されているので、知っているとfor文の活用の幅が広がります。

for文の変換ルールの全て

今回でジェネレータが1つの場合の変換から始まり、複数のジェネレーターの変換やフィルターや値の定義の変換、最後に抽出子の変換について押さえました。どんなfor文であろうと「必ず」 foreachmap, flatMap, withFilterの4種類のメソッドに展開される。そしてそれが可能だということが理解できたのではないかと思います。一つひとつの変換自体はそこまで難しくないとは思うのですが実際のfor文を目の前にして、変換後の形を理解する、理解した上でfor文を書くというのは少々慣れが必要なようです。

まとめ

  • for文は最終的に foreachmap, flatMap, withFilterの4種類のメソッドに展開される
  • for文には大きく2種類あり yieldあるものをfor内包表記、ないものをforループという
  • for文の中で p <- eと表記される部分をジェネレーターという
  • for文の中で if ~と表記される部分をフィルター(英語だとguard)という
  • for文の中で p = eと表記される部分を(値の)定義という
  • フィルターと値の定義は直前のジェネレーターにまとめる変換をする
  • ジェネレーターが1つのfor文は foreachmapに変換を行う
  • 複数のジェネレーターがあるfor文は前から順に foreachflatMapに変換しfor文の外に括り出す
  • for文のジェネレーターでは抽出子(extractor)を利用することができる

次回予告

次回はここまで触れられなかった実際問題としてfor文をどう使うかを考えます。