Scalaメタプログラミング今昔物語 - 導入編

お久しぶりです。これはscala advent calendar 2017の2日目の記事です(ということにします。空いていたので穴埋め)。 前日はpoad1010さんのJupyterでScala - Qiita、翌日はokapiesさんのAkka HTTP クライアントを使う - Okapies' Archiveでした。

さて、この記事は本来23日目に予定していた私のアドベントカレンダーがあまりにも大長編になりそうなことがわかったので前後編に分割した前半部分です。 Scalaメタプログラミング周辺の話をするに当たって最低限必要になる用語を整理します。 ひとつのまとまりのある記事というよりは、辞典としてお使いください。 23日の記事からも適宜参照として使います。

その前に宣伝

ScalaMatsuri2018に「Scalaメタプログラミング今昔物語」というタイトルでCFPを提出しました。ぜひ皆さん投票をお願いいたします。

では始めましょう。

リフレクション

リフレクション (reflection) とは、プログラムが自身を形式的に解釈(注:実行とは異なる)し、または変更を加えることができる能力*1です。 つまり、「プログラムを変更できるプログラムの記述」=「メタプログラミング」に必要な言語機能がリフレクションです。 リフレクションには実行時リフレクションとコンパイル時リフレクションが存在し、様々なプログラミング言語がそれぞれの哲学に基づいてこれらの一方または両方を実装しています。

実行時リフレクションはプログラム実行時にランタイムに存在する変数や関数を操作するものです。主にインタプリタ型の言語で(狭義の)メタプログラミングと呼ばれるものがこれに当たります。JVMも歴史的にはインタプリタとして開発されており、したがってJVM言語であるscalaも実行時リフレクションの機能を持ちます(※やや語弊があり、23日の記事で詳述します)。

一方、コンパイル時リフレクションは、静的言語においてコンパイラがプログラムの文字列そのものあるいは抽象構文木を操作するもので、一般にマクロ (macro) と呼ばれます。 C系の言語で#defineを用いて定義されるものは単なる文字列置換*2ですが、scalaのマクロでは抽象構文木を操作します。

抽象構文木

抽象構文木 (abstract syntax tree; AST) は、テキストとして記述されたプログラムを、より意味の取りやすい構造をもったとして表現したものです。 ほぼあらゆるプログラミング言語の処理系が最初に行う処理は、単なるテキストであるプログラムを解析 (parse) し、ASTに変換する作業であると言っても過言ではありません。

例えば、

if (str.startsWith("abc")) 1 else 0

のようなコードは、下図のような抽象構文木に変換できます。

f:id:iTakeshi:20171217124459p:plain

scalaのリフレクションではASTをcase classを使って表記するため、上記の例は

case class Expr(...)
case class IfExpr(condExpr: Expr, thenExpr: Expr, elseExpr: Option[Expr])
val sample = IfExpr(<str.startsWith("abc")>, <1>, Some(<0>))

となります。else節はあってもなくても合法な構文なので、case classのフィールドとしてもOption型で表しています。

このcase classはあくまで説明のための例であり、実際にscalaのリフレクションで使われているものとは異なります。また<>は「<>で囲まれた中身のコードのAST」を表すための省略記法として用います。

さて、ここで重要なことは、等価なコードを表すためのASTは一通りに定まらないということです。 例えば、上記の例は以下のようにも表すことができます。 f:id:iTakeshi:20171217124517p:plain

case class ElseClause(expr: Expr)
case class IfExpr(condExpr: Expr, thenExpr: Expr, elseClause: Option[ElseClause])
val sample = IfExpr(<str.startsWith("abc")>, <1>, Some(ElseClause(<0>)))

この例ではElseClauseを独立した文法単位として切り出し、IfExprOption[ElseClause]を持つという構造になっています。 コンパイラの実装に際してelseを独立して扱うほうが都合が良いという事情があればこのような表現も可能である、ということがわかります。

この「異なるASTが同一のコードと対応し得る」という事実は23日の記事で大変重要になりますので、頭の片隅に入れておいてください。

レイフィケーション

レイフィケーション (reification) とは、テキストとして記述されたプログラムを解析し、ASTを構築する手続きを指します。動詞はreifyです。

準クオート

準クオート (quasiquote)とは、抽象構文木を操作するプログラムの記述を簡便にするために導入された記法で、一種の構文糖衣です*3。 具体的にはq"..."という形式のStringContext*4として実装されており、 文字列として記述したコードをreifyしたものを返します。

以下の説明ではscala 2.10で導入されたscala.reflectで実装された準クオートに基づいて話を進めますが、後発世代のメタプログラミング環境でも同等の準クオートが実装されています。 なお、以下のコードサンプルはscalaのREPLで実行可能です。

つまり、

import scala.reflect.runtime.universe._
val expr = q"1 + 1" // 1 + 1を表すAST

と記述できるため、大変便利です。実際、同等のexprを準クオートを用いずに記述しようとすると

import scala.reflect.runtime.universe._
val expr = Apply(Select(Literal(Constant(1)), TermName("+")), List(Literal(Constant(1))))

となります。この程度ならまだ我慢できますが、長くなるとやってられません。

さらに、準クオートはパターンマッチのためにも用いることができます。

import scala.reflect.runtime.universe._
val expr = q"10 + 1"
val q"$numA + 1" = expr
println(numA) // => 10 実際には Literal(Constant(10))

これは画期的なことで、マクロなどを作る際、ASTから必要な情報を手早く抜き出してくるために極めて便利です。

引数など、文法上可変長であることが許されている構造もList[Tree]として抜き出すことが可能です。

import scala.reflect.runtime.universe._

val expr1 = q"List(1, 2, 3)"
val q"List(..$args)" = expr1 // dot2つ
println(args) // => List(1, 2, 3): List[Literal]

val expr2 = q"hoge(1, 2, 3)(curried)"
val q"hoge(...$argss)" = expr2 // dot3つでカリー化
println(argss) // => List(List(1, 2, 3), List("curried")): List[List[Tree]]

名前とシンボル

名前 (name) とは、ASTの中に存在する特定のノードを表すための識別子として用いられる文字列です。 単なる文字列であり、それ以上の情報は持ちません。

シンボル (symbol) とは、名前とその名前が参照するクラスやメソッドのような実体 (entity) を関連付けるために用いられる*5概念です。 つまり、AST内で何らかの名前を持つ変数や関数に対して、型をはじめとする各種の情報を付加するために必要なブリッジと言えます。

例えば

val str = "abc"

というコードを書いたとします。 このコードをASTに変換すると、strという「名前」が取り出されます。この時点では名前だけですので、コンパイラstrがどのような性質を持つのか知りません。 しかし、コンパイラ型推論能力を持つため、右辺の"abc"によりstrString型であると決定することができます。 したがって、コンパイラは名前strに対応するシンボルを作成し、ここにStringという型情報を注記するわけです。


さて、以上で導入編は終了です。もちろんこの記事の内容が全てというわけではありませんが、scalaメタプログラミングに関する議論の多くを理解するのに必要十分な内容は抑えていると思います。 それでは23日、本編でお目にかかります。