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

DEPRECATED WARNING

2018/12/05 この記事の内容は、すでに開発が放棄されたプロジェクトに基づいています。ScalaにおけるメタプログラミングはDotty / Scala3世代で大きく変わることが予定されており、すでにコンパイラ本体と統合された開発が進行しています。この記事はすでに「歴史的文書」としてのもの以上の価値を持たないものですので、ご覧になる場合はそのことを念頭に置いて読み進めていただくようお願いします。

なお、この記事の前編として執筆した基本単語の解説記事は一般的なものですので、そちらの内容は現在でも役に立つものと思います。


今日が天皇誕生日なのも来年までということになりましたが、これはscala advent calendar 2017の23日目の記事です。昨日はnobkz さんのScalaJSの話、明日はmoc-yutoさんのAkkaStreamの話です。

さて、この記事では、scalaメタプログラミング機能について、投稿時点で最新、かつ日本語で最も詳しい解説を試みます。 最新版はGeneration 3なので、歴史的経緯に興味がなければGeneration 0/1/2は適宜飛ばしてください。 なお、本稿で使う単語を説明するための別記事を用意してありますので、わからない用語が出てきた場合には適宜ご参照ください。

12/26追記 Twitterでいただいたいくつかのご指摘を元に、一部加筆修正を行いました。

宣伝

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

最初に結論

いつscalaメタプログラミング機能を使い始めるべきか? → 「全くもって今じゃないです」

いつscalaメタプログラミング機能に貢献すべきか? → 「過去最大のチャンスです」

ネタ元

本稿のネタ元は以下の論文およびブログ記事、そして5つのレポジトリです。

Generation 0: java.lang.reflect

scala 2.9まで、scalaは独自のメタプログラミング機能を持たず、メタプロを実現するためにはJavaのリフレクション機能を用いる必要がありました。 しかし、(当然ながら)JavaのリフレクションはscalaコンパイラJVMのclassファイルを吐き出した後にしか使えませんので、scalaのASTを知ることができません。 さらに、コンパイラによる型消去が完了した後なので、高カインド型の型引数情報なども利用できません(List[Int]List[String]はどちらもListとしてしか扱えない)。

これではさすがに貧弱だろうということで、scala独自のリフレクション機能としてGeneration 1が開発されます。

12/26追記 @xuwei_kさんから以下のようなご指摘をいただきました。

これは私は全く知らなかったのですが、まずScalaSignatureというscala独自のメタ情報をjavaのclassファイルに埋め込むための企画があって、そのScalaSignatureをscalacで書き込むのに使われるのがscalapということのようです。ですが、このscalapをランタイムにScalaSignature用のパーサとして利用することもできるらしく、これを使ってリフレクションができるんだそうです。詳しくは上記ツイート内のリンクを参照ください。

なお、次のscala.reflectはこのScalaSignatureを扱うためのユーザーフレンドリーなAPIとして開発され始めたという側面もあるということです。

また、これに関連して@phenanさんからも以下のご指摘をいただきました。

その後Twitterで少し議論させていただいたのですが、結論として「『JVMが型消去を行う』ということを知っているscalacは型消去されたjavaバイトコードを出力するが、そこで欠失する情報を補うためにScalaSignatureを書き込む」ということのようです(まだ私も完全には理解しきれていません)。

ご指摘いただいたお二人に感謝いたします。

追記ここまで

Generation 1: scala.reflect

scalaにおいて実行時・コンパイル時両方のリフレクション機能を実現した初のライブラリです。 scala 2.10-2.13で使えます。 2.14でdeprecated警告が出るようになるとのことです。Dottyには含まれません。

なお、Generation 1については公式のドキュメントをSBT開発チームのメンバーとして有名なEugene Yokotaさんが翻訳してくださっていますので、より詳しい解説はそちらを参照してください。

実行時リフレクション: ユニバースとミラー

scala.reflectで重要になる用語として「ユニバース (universe)」と「ミラー (mirror)」があります*1

ユニバースはリフレクションが実行される環境が持つ情報の集合です。 実行時リフレクションであれば、JVMが現在実行中のスコープに存在する変数・関数やそれらの型情報などの集合がユニバースです。 scala.reflect.runtime.universe(uは小文字)でアクセスできます。 一方でコンパイル時リフレクションであればコンパイラが持つ全ての情報の集合がユニバースとなります。 scala.reflect.macros.Universe(Uは大文字)でアクセスできます(ただしこれを明示的に使うことはありません。後述)。

ミラーは文字通り鏡です。 つまり、ユニバースに含まれる情報を鏡(ミラー)で反射(reflect)させて映し出し、その情報をもとに処理を行うのがリフレクションというわけですね。

実行時リフレクションを例で見てみましょう(REPLでそのまま実行できます)。

class C { val x = List(0) }
// universeを取得
val ru = scala.reflect.runtime.universe
// universeに対応するmirrorを取得
val m = ru.runtimeMirror(getClass.getClassLoader)
// mirrorでuniverseの中の情報をreflectすると、像 (image) が得られる
val image = m.reflect(new C)
// class Cのフィールドxを取得; これはuniverseに存在する型定義を直接参照しており、mirrorは不要
val fieldX = ru.typeOf[C].decl(ru.TermName("x")).asTerm.accessed.asTerm
// Cのインスタンス全体の像から、フィールドxの部分だけを取り出す
val fmX = image.reflectField(fieldX)

println(fmX.symbol.info.toString) // => List[scala.Int] 高カインド型の情報もそのまま扱える
println(fmX.get) // => List(0)

また、java.lang.reflectでは使えなかった高カインド型の型情報を実行時環境に持ち込むための専用オブジェクトである型タグなどの機能もあります。

コンパイル時リフレクション: コンテキストとmacroキーワード

ここまでユニバースとミラーに関して解説してきました。これは本質的には実行時リフレクションでもコンパイル時リフレクションでも同等なものですが、コンパイル時リフレクションではscala.reflect.macros.Universeをさらにラップしたコンテキスト (context)というものを用います。繰り返しになりますが、ユニバースとコンテキスト本質的に同じもので、マクロ、つまりコンパイル時リフレクションの記述のための便利機能を付加したものがコンテキストです。

*2をもとに見ていきましょう(REPLで実行できます)。

import scala.reflect.macros.Context
import scala.language.experimental.macros // macroキーワードのために必要
def assertImpl(c: Context)(cond: c.Expr[Boolean], msg: c.Expr[Any]) : c.Expr[Unit] = {
  import c.universe._ // 準クオートのために必要
  c.Expr[Unit](q"if ($cond) () else throw new AssertionError($msg)")
}
def myAssert(cond: Boolean, msg: Any): Unit = macro assertImpl

val x = 0
myAssert(x == 1, "x must be 1!!") // => "x must be 1!!"

def assertはアプリケーションコードで利用されるマクロのAPIです。 scalaコンパイラmacroキーワードを見つけると、このキーワードに引き続く名前を以下の規則でメソッド呼び出しに変換します。

def m(arg: T): U = macro impl // original
def m(arg: T): U = impl(c)(arg) // converted

最終的にアプリケーションコードが変換されるまでには以下のような段階があります。

  1. (マクロコンパイル時)impl(c: Context) => c.Expr[T] => c.Expr[U]という型を持つことを検査
  2. (マクロコンパイル時)macro implimpl(c)(arg)に変換
  3. (アプリケーションコードコンパイル時)現在のコンテキストをcとして埋め込み
  4. (アプリケーションコードコンパイル時)impl(c)(arg)を実行し、展開されたASTをもとのアプリケーションコードの位置に挿入

得られる結果はC系言語の#defineと似たものですが、マクロコンパイル時にimplの型を検査するためマクロAPIの型T => Uが崩れないことを保証できたり、AST変換が型安全であることにより変換後のASTがvalidであることを保証できるたりするため、マクロにより不適切なコードが生成されてしまってデバッグに苦労するというケースは減らせると考えます。

なお、この形式のマクロはdefにより関数として定義するためdefマクロと呼ばれます。

問題点

scala.reflectは「実験的機能」という位置づけでしたが、scalaメタプログラミングをしたかった人々により広く使われるようになりました。一方でその過程で多くの問題点が指摘されました。 マクロの定義を宣言とimplにわけなければならないというような小さな不満も含みますが、最大の問題点はコンパイラの内部実装、具体的にはASTの定義に強く依存してしまうことです。

一般的な事実として、「異なるAST定義が等価なコードを生成しうる」というものがあります(詳しくは23日の記事)が、実際にコンパイラのバージョンが変わるとASTの定義は変更されます。 scala2.x系とDottyでは全くことなるASTが採用されており、scala.reflectで記述したメタプログラムはDottyでは確実に動きません。

さらに、IntelliJのようなIDEは様々な言語に対して独自のASTを定義している場合が多いです。 これは定義元ジャンプや静的解析による警告表示などの「IDEらしい」機能を実現するためのサポートをASTレベルで実装したいという動機によるものですが、 この独自ASTの帰結として、IDE上ではscala.reflectによるリフレクションがコードにどのように作用するか知ることができません。 したがって適切なIDEサポートが得られず、プログラミング体験を大きく損なうということが指摘されています。

Generation 2: scala.metaとマクロアノテーション

scala.meta

コンパイラごとにAST定義が違っていて困るのであれば「標準AST」を定義すればいいではないか、ということで開発が始まったのがscala.metaプロジェクトです。 scala.metaで定義された標準ASTはこのファイルで一覧することができます。 この標準ASTを使ってマクロコードを記述することで、コンパイラが変わったとしても同じマクロが動くことを保証できます。

注意すべき点として、scala.metaはリフレクションのためのライブラリではありません。あくまでも標準ASTを定義し、このASTに関するレイフィケーションの機能を提供するものです。 scala.meta単体で何ができるかという解説は公式のチュートリアルが大変わかりやすくておすすめです。 scalaのlintツールであるscalafixscalafmtなどがscala.metaを利用していますが、 プログラム自身を操作するプログラムという意味での「メタプログラム」の定義からは少し外れます。

paradiseとマクロアノテーション

Generation 2のメタプログラミング機能は、paradiseという名前のコンパイラプラグインによって実現されるコンパイル時リフレクション「マクロアノテーション (macro annotation)」です。 ちなみに、私は一時期このparadiseプラグインの3番手コントリビュータでした(最終的には4番手)。

paradiseプラグイン、およびマクロアノテーションはGeneration 1にも存在しますが、前節で紹介したマクロ(defマクロと呼ばれます)に比べて明らかに影が薄いです。 大枠はGeneration1でも2でもあまり変わらず、Generation 1でscala.reflectだった部分をscala.metaで置き換えただけと言ってしまうこともできますが、 細かい「書きやすさ」のようなものの向上は感じられます。

マクロアノテーション@annotationの構文を使って既存のクラス定義などを拡張するのに用いられます。 例を見てみましょう(レポジトリ)。 このコードは、objectの中に裸で入れられた処理を、プログラムのエントリーポイントであるmainメソッドに作り変えるサンプルです。

// macro.scala
import scala.meta._
class main extends scala.annotation.StaticAnnotation {
  inline def apply(defn: Any): Any = meta {
    // 準クオートを使ってobjectの名前nameとコンストラクタの手続きstatsを取得
    val q"object $name { ..$stats }" = defn
    // statsを使って新しいメソッドmainを作成
    val main = q"def main(args: Array[String]): Unit = { ..$stats }"
    // nameとmainメソッド(のAST)を使って新しいobject(のAST)を作成し、返す
    q"object $name { $main }"
  }
}
// core.scala
@main object Hello {
  println("Hello world!") // この行がstatsとなる
}

このコードを動かすためにはparadiseプラグインなどが必要なため、上記のレポジトリをクローンしてお試しください。 レポジトリのREADMEの通りに動かすと、まずマクロアノテーションコンパイルされ、次いでparadiseプラグインアノテーションを使ってアプリケーションコードを

object Hello {
  def main(args: Array[String]): Unit = {
    println("Hello world!")
  }
}

のように展開し、それからこの生成されたコードがコンパイルされて、おなじみのHello worldを出力します。

動作としては、まずStaticAnnotationを継承したmainクラスをコンパイルします。 マクロアノテーションのためのクラスはinline def apply(defn: Any): Anyというシグネチャを持つメソッドを持つ必要があり、本体はmeta { ... }で囲みます。 なお、inlinemetaはscalametaのv1.0-1.8で提供される新しいキーワードです(scalameta v2からは削除、後述) このメソッドは、アプリケーションコード内でアノテーションが付けられたコードのまとまり(Annotteeといいます)をAST(defn)として受け取り、新しいASTを返します。 細かい動作はコード中のコメントを読んでいただければだいたいイメージができると思いますが、大雑把には

  • 準クオートでannotteeの中の構文要素(上の例だとnameとかstatsとか)を取得
  • それらの構文要素を使って新しいASTを作成

これだけです。

実際、マクロアノテーションは大変書きやすい上に柔軟性が高く、便利な存在でした(※アノテーションに引数を付けられるなど、いろいろニッチな需要もカバーされていて本当に使いやすいのですが、本稿では割愛)。 私が使った主な用途はボイラープレートの生成で、例えばplay frameworkのjson formatterを自動生成させるアノテーションなどを作って今も使っています。

問題点

登場当初からコミュニティにおいて高い評価を得て、一時は次の公式マクロシステムの座を射止めたかに見えたマクロアノテーションですが、現在ではロードマップから外され、deprecatedの扱いとなってしまいました。

Generation 1と異なり、2では標準ASTを採用することによって「コンパイラのバージョンが変わった時に、マクロコードを書き換える必要性」を無くしました。 一方で、「コンパイラのAST」から「標準AST」への変換器は、やはりコンパイラごとに必要になってしまいます。 そしてこの変換器を正しく作るのは恐ろしく骨の折れる作業でした。 実際私も初期の開発プロセス(scala2.11.3からの変換だったと思います)に参加し、苦労した思い出があります。 これをscalacのみならずDottyやIntelliJコンパイラにも対応させ続けようと思うと、持続性が問題になってくるのは明らかでした。

Liu・Burmako両氏はこの問題を2017年の論文で詳しく論じ、次のGeneration 3への開発へと移行することになりました。 この論文では、AST変換に伴うデメリットとして以下の4つを挙げています。

  • コンパイラASTに付随する型情報を標準ASTに持ち込むのが難しい
  • コンパイラASTのメタ情報(コードの行番号など)も標準ASTに持ち込みにくい
  • 変換にかかる計算コストが大きい(遅い)
  • 変換器の作成コストが大きい(前述)

また、AST変換による問題の他にもいくつか細かい制約がありました。 例えば、scala本体の文法規約として、アノテーションは「定義」にしかつけることができません。 つまり、trait, class, object, def, val, varにしかマクロアノテーションを利用できず、普通の式(1+1など)を元にして展開することはできません。 package, package objectは「定義」ではなく「参照」なので、これらに対してもマクロアノテーションを利用できません。 また、何もないところにコードを生成することもできません。必ず@macro Hoge {}のように空のオブジェクトにアノテーションを付けて、その中に展開する必要があります。

Generation 3: scala.macrosscalagen

さてここからが本題なのですが、実際にはまだまだ未定な部分が多い段階です。以降全ての文に「2017年12月現在では」や「おそらく」「多分」という接頭辞を付けるつもりで読んでください。

Generation 3ではメタプログラミング機能が2つに分裂します。 Generation 1のような式ベースの記述macro(1+1)は新開発のscala.macrosに引き継がれます。 Generation 2のような定義の拡張やコード生成はscala.metaをベースとしたscalagenが担うことになりそうな雰囲気です。 順にみていきます。

scala.macros

コンパイルごとに異なるASTを直接扱いたくない→標準ASTへの変換はコストが大きすぎる、という流れを経てたどり着いた考え方が「標準構文(standard abstract syntax)」という考え方です。 「標準AST(standard abstract syntax tree)」からtreeが抜けただけのようにも見えますがこれらは本質的に異なります。 以下、F. Liu & E. Burmako (2017)の解説を通して、両者の違いを見ていきます。

標準ASTの場合

まず標準ASTを用いる場合ですが、これは前節で述べたように「コンパイラASTから標準ASTへの変換」が必要になります。 マクロによるコード展開の流れを擬似コードで見てみましょう。

// 標準ASTであるscala.metaを使用
import scala.meta
// マクロは標準ASTから標準ASTへの変換として定義される
type Macro = meta.Tree => meta.Tree
// コンパイラASTから標準ASTへの変換
def toMeta(cTree: <compiler>.Tree): meta.Tree
// 標準ASTからコンパイラASTへの逆変換
def fromMeta(mTree: meta.Tree): <compiler>.Tree
// マクロの適用
def expand(macro: Macro, tree: <compiler>.Tree): <compiler>.Tree =
    fromMeta(macro(toMeta(tree)))

つまり、meta.Tree<compiler>.Treeと同等のコードを表すASTですが、コンパイラASTとは全く別のオブジェクトです。

標準構文の場合

次に標準構文を用いる場合を見てみましょう。 これは、コンパイラASTのオブジェクトはそのまま利用しつつ、標準コンストラクタと標準抽出子を利用して見かけだけ標準化する手法です。

// scala.macrosライブラリ

// 標準構文のパッケージ
package macros {
  // 標準構文で用いるTreeは単なるコンパイラASTのエイリアス
  type Tree = <compiler>.Tree
  // Term(Treeのうち式として扱えるもの全体)もコンパイラのTermのエイリアス
  type Term = <compiler>.Term
  // If式の標準構文
  def TermIf: TermIfImpl
  // 標準コンストラクタと標準抽出子の定義
  trait TermIfImpl {
    // 標準If構文を書くと、内部ではコンパイラのIf構文のオブジェクトを作る
    def apply(condTerm: Term, thenTerm: Term, elseTerm: Term): Term =
      <compiler>.If(condTerm, thenTerm, elseTerm)
    // treeがコンパイラIf構文として解釈可能であれば、標準If構文の引数を返す
    def unapply(tree: Tree): Option[(Term, Term, Term)] = {
      case <compiler>.If(condTerm, thenTerm, elseTerm) => Some((condTerm, thenTerm, elseTerm))
      case _ => None
    }
  }
}
// my_macro.scala

// 標準構文パッケージのインポート
import scala.macros
// マクロはあくまで標準構文から標準構文への変換として定義
type Macro = macros.Tree => macros.Tree
// マクロ定義
val macro: Macro = tree => {
  // 標準抽出子を使ってIf構文の要素を取り出し、thenとelseをひっくり返す
  case macros.TermIf(c, t, e) => macros.TermIf(c, e, t)
}
// マクロの適用
def expand(macro: Macro, tree: <compiler>.Tree): <compiler>.Tree =
  // 戻り値としてmacros.Treeを返すが、この実態はコンパイラASTなのでそのままコンパイラに返して良い
  macro(tree)

初見だと型エイリアスが飛び回るので激しく混乱すると思いますが、雰囲気は掴んでいただけたでしょうか。 最も大事な点は(繰り返しになりますが)ASTを構成するオブジェクトは、コンパイラのASTで使っているものをそのまま使いまわすということです。 macroUniverse.TermIfなどの標準コンストラクタ・標準抽出子はコンパイラASTの中から所望の文法構造を探し出すための道具に過ぎません。

こうすることで、マクロを記述する際には「見た目は標準構文」「実際に扱っているのはコンパイラAST」という、一件矛盾する要求を解決できます。 Scalaのunapplyがいかに強力かということを示す例でもありますね。 また、新しいオブジェクトを作らないので省メモリだったり、高速になったりという副効果も期待できます。

(想定)FAQ

「結局コンストラクタ・抽出子はコンパイラごとに作る必要があるのでは?」 → まったくもってそのとおりです。コンパイラが変わればコンストラクタと抽出子は別のものになります。

ですが、変換器を作るのとはコストが雲泥の差です。 一番の理由は、そもそも同一なオブジェクトを利用することで型情報やメタ情報について心配する必要がなくなることです。 形式的な構文にのみ注意を払ってコンストラクタと抽出子を作ればその他の情報は自然と付いてくる、という意味で「標準構文」という名前になっています。

開発状況

scalacについて言えば、標準コンストラクタについては実装済みです。標準抽出子はどうやら全く手付かずです。 Dottyは一部抽出子も実装されているっぽいですが見た感じ不完全と思われます。

標準抽出子がないということはすなわち既存のコードをタネにしてマクロで展開することができないという意味です。 コードをタネとしない、つまり無から有を生み出すようなマクロはすでに書くことができます。 ただし、対応する準クオートが実装されていないため標準コンストラクタを全て手で書くことになりますが…(

scalagen

scala.macrosがGeneration 1のdefマクロと似た機能を提供しようとするのに対し、scalagenはGeneration 2のマクロアノテーションを置き換えるものになる予定です。

12/26追記 これは言い過ぎでした。Scala centerのÓlafur Páll Geirssonさんから直々にDMをいただきました(本人の許可を得てDMのスクリーンショットを転載します)。

f:id:iTakeshi:20171226161957p:plain

この後の会話の中で、「scalagenはあくまでもDavid Dudsonさんの個人プロジェクトであり、「マクロアノテーションを置き換える」ことが正式に決まったものではないということを強調されました。 これについては私の認識の誤りでしたので、お詫びして訂正いたします。

ただし、scalagen自体の開発は進行中ですので、個人的にはGeneration 2からの良い乗り換え先となることを強く期待しています。

Thanks Olafur for your kind review!

追記ここまで

コードを展開するロジックはscala.metaを用いてGeneration 2とほぼ全く同じ形式で記述します。

大きく異なる点として、scalagen静的コードジェネレータです。つまりコンパイル時にマクロとしてコードを展開するのではなく、静的なファイルとして生成したコードを吐き出し、それをコンパイラに渡します。 したがって、これはコンパイラプラグインとしてではなくビルドマネージャのプラグインとして提供されます(まずはSBTプラグイン)。 コンパイラから独立させることで、変換前のASTは単にscala.metaに付属のパーサで解析したものを用いることができ、出力もscala.metaに付属のprettyprint機能が使えます。 よって、SBTプラグインとの入出力を管理するインターフェースだけ適切に定義してやればよいため、scalagen自体はscala.metaのごく薄いラッパーとして機能するようになるはずです。

機構が単純なため、特にこれ以上コメントすべき点はありません。

開発状況

始まったばかりで、コード生成器が実装すべきtrait Generatorと、それを使ってコードを展開するclass ExtensionRunnerのプロトタイプがだいたいできあがったところです。 機能要件もまだ流動的ですので、何かしら提案してみると議論がもりあがったりして楽しいです()。

結論

冒頭の「最初に結論」に戻ります。

いつscalaメタプログラミング機能を使い始めるべきか?

「全くもって今じゃないです」

今動くシステム、つまりGeneration 1と2はすでにdeprecatedで将来的に使えなくなることが確定しており、開発中のGeneration 3は未だ実用に程遠い状況(しかもscala.macrosは停滞気味、scalagenは始まったばかり)です。 どうしても今すぐメタプログラミングしたくてたまらない場合にどうすればよいかと言うと、これも迷わしいところです。 ひとつの提言としては、Generation 2のマクロアノテーションで書いたコードはごく僅かな修正でscalagenに使える可能性が高いです(今の所)ので、どうしてもという場合は検討しても良いでしょう。

いつscalaメタプログラミング機能に貢献すべきか?

「過去最大のチャンスです」

前述の通り、scala.macrosの開発は未完了かつ停滞気味で、scalagenは始まったばかりです。 各々のニーズに従い、scala.macrosをプッシュするでもよし、scalagenに便利機能を提案するでもよし、できることはいくらでもあります。 特にscalagenはまだ1000行くらいしかありませんので追いつくのも簡単です。 私もscalagenの開発には積極的に関わっていくつもりですが、みなさんもご一緒にいかがですか?


ここまでお読みいただいた方、ありがとうございます。scalaメタプログラミングについて、現状私が把握していることの概略はお伝えできていると期待しますが、わかりにくい点・間違っている点などありましたらぜひご指摘をお願いします。 それでは、Merry Metaprogramming!!