プレースホルダ構文と文字列補間を組み合わせた時の挙動についての考察
久しぶりの日記の更新です。今回のテーマは、Scalaにおける、Placeholder syntax for anonymous function(以後、プレースホルダ構文)とString Interpolation(文字列補間)を組み合わせた時にどのような挙動であるべきか、また、現状の挙動が妥当かについて考察する記事です。
きっかけは、本日のきの子さんのツイートでした。
Scalaのstring interpolation、プレースホルダー(_)使えないのなんでなんだろう
— SAMMY(きの子) (@aa7th) 2017年8月28日
文法上はできるはずだけど、どういうケースなのだろう、と疑問に思ったのですが、吉田さんのツイート
こういうのですかね?https://t.co/1OzitqpAWs
— Kenji Yoshida (@xuwei_k) 2017年8月28日
"pull requests welcome"
と言ってるので、きの子さんが実装してpull req送れば解決ですね(?)
のリンク先をみて、補間文字列の中に現れるプレースホルダが、その外側と結びつかないことが問題ということのようでした。
ただ、リンク先では
It can probably be made to work; pull requests welcome.
と軽く言っちゃってますが、これ、プレースホルダ構文について以前詳細に調べたことのある身としては、なんか許しちゃ駄目なケースではないかと瞬間的に思いました。ただ、文字列補間の詳細な仕様を参照したことがなく、自信があったわけではないので、両方の仕様を突き合わせたり、サンプルの入力を考えた時に、プレースホルダが補間文字列の外側と結びついて大丈夫なのか考えてみることにしました。
まず、プレースホルダ構文についてですが、Scala Language Specificationの6.23.5に仕様が規定されています。ただ、これが意味するところは結構ややこしいので、自分が以前書いた解説記事をベースに話を勧めます(当該記事も、仕様書を元に説明したものなので、改めて解説する手間をはしょっただけです)。
文字列補間については、SIP-11 - String Interpolationに仕様が定義されているので、これを元にして考察します。
解説記事にも書いているのですが、プレースホルダ構文は構文解析時に作用する要素であるという点が非常に特異な代物で、文法定義上のカテゴリという概念を参照して定義されています。一方、文字列補間も構文解析時に主に処理が行われるもので、両者を組み合わせた場合のことをテキトーに考えると、おかしな結果になることは割とすぐに想像がつきます。
たとえば、GitHubのIssueに上がっている例では、
val f : Int => String = s"The there are ${_} apples in the bowl"
が
val f: Int => String = n => s"The there are $n apples in the bowl"
と解釈されて欲しい、という事が書いてあります。ぱっと見で、文字列リテラルっぽいものの型が関数の型になるという結果で、かなり直観的ではないのですが、さて、これは許されるべきなのでしょうか。まあ、想像だけで考えても仕方ないので、String Interpolationの方の仕様を見てみます。
関係する部分を抜粋すると、
A processed string literal of either of the forms
id ”text0${ expr1 }text1 … ${ exprn }textn” id ”””text0${ expr1 }text1 … ${ exprn }textn”””
where each texti is a possibly empty string not containing dollar sign escapes, is equivalent to:
StringContext(”””text0”””, …, ”””textn”””).id(expr1, …, exprn)
と書かれています。これは、processed string literal(fとかsとかがプレフィックスについた文字列リテラルのこと。補間文字列リテラルと呼びます)が、StringContext
オブジェクトのメソッド呼び出しに変換され、式の部分は引数として渡されるということを言っています。このような仕組みになっているのは、文字列補間をユーザ定義可能にするためなのですが、それはさておき、この説明を読む限り、補間文字列リテラルの型は、プレフィックスと同じ名前のメソッド呼び出しによって決定されるのであって、中にプレースホルダがあったら、 Int => String
に型が変わるというのは、現状の文字列補間の仕様と衝突します。要求を通すなら、少なくとも、現状の仕様を、StringContext
を使わない形に解釈され得るという風に更新する必要がありそうです。
じゃあ、文字列補間の仕様を更新することを前提にするなら、OKか。駄目とは言い切れないものの、結構微妙なところがあります。まず、補間文字列の構文カテゴリは SimpleExpr1
だと定められており、補間文字列中の ${...}
は BlockExpr
であると定められています。プレースホルダ構文の仕様としては、構文カテゴリ Expr
が _
を含み得るという形で定義されており、かつ、SimpleExpr1
も BlockExpr
も Expr
に昇格可能なので、補間文字列中の式が外側の式と結びつくのは、構文カテゴリの点で言えば、既存の仕様と矛盾していないとは言えそうです。
ただ、矛盾していないからいいかというと…次の例を考えてみます。
List("A", "B", "C").map(s"alphabet = ${_}")
これが、
List("A", "B", "C").map(a => StringContext("alphabet = ", "").s(a))
と変換されて欲しいとします(し、実際の要求としても、こういうのがありそうです)。この場合、プレースホルダ構文が処理されて、その結果として、補間文字列の式が a => StringContext(...).s(a)
になる、という解釈になるでしょう。
別の例として、
(List(1, 2), List(3, 4)).zipped.map(s"${_ + _}")
がどう変換されるべきかを考えてみます。プレースホルダ構文の仕様の方はいじらないなら、これは
(List(1, 2), List(3, 4)).zipped.map((x, y) => StringContext("", "").s(x + y))
に変換されるべきです…ほんとに?実は、ちょっとごまかしが入っていて、文字列補間による式変形と、プレースホルダ構文による式変形がなんとなくうまくはまったかのように書いていますが、十分に挙動を説明しきれていません。
正確に仕様を書こうとするなら、文字列補間による式変形の順番とプレースホルダ構文による式変形の順番を規定する必要があります。しかし、困ったことに、文字列補間は式の(構文カテゴリ上の)形を変えてしまうので、どちらを先に適用するかで最終結果が変わる可能性があります。
上記の例で言えば、文字列補間→プレースホルダ構文という順番で処理されると考えるのなら、
(List(1, 2), List(3, 4)).zipped.map(StringContext("", "").s(_ + _))
その後に
(List(1, 2), List(3, 4)).zipped.map(StringContext("", "").s((x, y) => x + y))
となります。逆の順番なら、
(List(1, 2), List(3, 4)).zipped.map((x, y) => s"${x + y}")
その後に
(List(1, 2), List(3, 4)).zipped.map((x, y) => StringContext("", "").s(x + y))
となります。このケースでは、後者、つまり、プレースホルダ構文→文字列補間、の順番に変形してくれた方が嬉しいでしょう。しかし、順番を考えないとうまく動くかどうか判断できないのは仕様としてはあんまりだと思うのです。
プレースホルダ構文が補間文字列リテラルの外に影響を与えない、という現状の仕様であれば、(おそらく)処理順序による影響は出ません。自分としては、構文上の仕様に(プレースホルダ構文だけでもめんどうなのに)これ以上ややこしいものを放り込んでほしくないので、これはちょっと…と思うところです。
長くなりましたが、結論としては、
- 現状のプレースホルダ構文と文字列補間の仕様の整合性は取れている。要求に従うと現状の仕様に不整合が出る
- 仕様変更が必要
- プレースホルダ構文が補間文字列リテラルの外側に影響を与える仕様変更を行うには、両者の処理順序を明記する必要がある
- 複雑であり、わかりにくい挙動
- 結論としては、現状の仕様のままが妥当そう
といったところになります。
結構ややこしい問題で、自分の考察になんか穴がある可能性はあるので、もしあれば指摘していただければと思います。