Skip to content

Scalametaワークショップ

Taisuke Oe edited this page Jul 27, 2020 · 5 revisions

Scalametaとは?

Scalametaは、Scalaソースコードを解析したり、解析結果をもとに一部改変したり、もしくはイチからコード生成するためのtoolkitとして、MetalsやScalafix、そして様々なScalaのコード生成ツールの内部で使われています。

このワークショップ形式では、手を動かしながらソースコードの解析・改変(抽象構文木(AST)とその操作)、簡単なコード生成、ソースコードの改変方法について学びます。

Scala標準ライブラリの知識は前提としますが、ScalametaやASTに関する予備知識は必要ありません。

また、ソースコードの改変を適用するためのツールとしてScalafixを使っていますが、Scalafixの詳細には立ち入りません。

本ワークショップの題材

このワークショップでは、Scalametaを使ったコードベースの改善を体験します。

今回の題材は「ハードコーディング」です。

ここでは、なんらかの前提条件や環境に応じて変わるべき値を、環境変数や設定を介さず、直接ソースコードに埋め込んでいる状態を「ハードコーディング」と定義します。

このワークショップでは簡単のため、まずは「メソッドもしくはコンストラクタの引数として渡されているリテラル」を「ハードコーディング」であると推論することとします。

    1. これらのリテラルが使われている場所の検出。偽陽性を排除するロジックの検討。
    1. これらのリテラルを定数メンバとしてもつ Constants オブジェクトのコードの生成
    1. 1で検出したコードを、2で生成した Constants オブジェクトのメンバを参照するコードへの書き換え

という、ハードコードされた値のリファクタリングの初期ステップを自動化することを考えます。

実務では以下のように、更にリファクタリングをする必要があるでしょう。

  • なんらかのロジックで算出する
  • HOCONなどの設定ファイルから取得する
  • 実行時に環境変数から取得する
  • ビルド時に値を埋め込む
  • 一部のリテラルは、引数に直接渡すことを許容する。(環境変数や設定ファルのkeyなど)

リテラルがバラバラに埋め込まれている状態よりも、オブジェクトのフィールドを参照している状態の方が、IDEのサポートが得やすくリファクタリングをやりやすくなります。

プロジェクト構成

  • src/main/scala/ ... みなさんが編集するソースコードです。
  • src/test/scala/stepN/ ... それぞれのステップ(N=1..4) 用のテストです。編集する必要はないはずですが、必要に応じて参照してください。
  • input/ および output/ ... step5で実行するScalafix ruleの書き換えテストで利用するソースです。編集する必要はないはずですが、必要に応じて参照してください。

Scalameta の基本

Scalametaは、Scalaのソースコードを解析し木構造に変換にしたり、また逆に木構造を逆にソースコードに変換し直すこともできます。

ソースコード <-> 木構造

例えば、 hello("taro") 文の木構造は、Scalametaによって以下のように表現されます。

import scala.meta._
"""hello("taro")""".parse[Stat].get.structure

Term.Apply(Term.Name("hello"), List(Lit.String("taro")))

  • Term.Apply ... 第一引数の項に、第二引数のList(引数に相当)を適用する構文木
  • Term.Name ... 項の名前を表す構文木
  • Lit ... リテラルを表す構文木 (Lit.StringはStringリテラルを表す構文木)

これらの構文木の型は、抽象構文木(AST, Abstract Syntax Tree)と呼ばれるものの一部です。

ScalametaがサポートしているASTは、公式ドキュメントのtree/examplesを参照するか、IDEやGitHubからScalametaのソースコードを参照するのが簡単です。

Scalametaにおいて、ASTはcase classで表現されています。すなわち、木構造の探索にパターンマッチ、そして変換にcopyを使うことができます。

そしてTree型には、自身を含む全ての子ノードに部分関数を適用するcollectメソッドが用意されています。これらを組み合わせると、任意のレベルの木構造にマッチして、変換するような式を簡単に記述することができます。

  def printNames(tree: Tree): Unit = tree.collect {case Term.Name(name) => println(name)}

また、ASTを便利に扱うためのツールとして、quosiquotesと呼ばれる記法がScalametaに用意されています。quosiquotesを使うと、Scalaの構文ライクに木構造を定義することができます。

scala> q"""hello("taro")"""
res0: meta.Term.Apply = hello("taro")

quosiquotesの詳細は、tree/quosiquotesを参照してください。

では今回は明示的な木構造case classの操作と、quosiquotesの操作、どちらが推奨されるのでしょうか?

好みは別れますが、quosiquotesをパターンマッチのcase句に書くと漏れが発生しやすい点には注意してください。

たとえば、以下のようにClass定義の文(Defn.Class)にマッチするようcase句を書いたとしましょう。 ( ...$paramss はパラメーターリストのリスト(Seq[Seq[Term.Param]])にマッチ、 $template はTemplateと呼ばれる、スーパー型の呼び出し(inits: List[Init])や本文(stats: List[Stat])などを保持したデータ構造にマッチします。

def classDefn(tree:Tree): List[Defn.Class] = tree.collect {
  case defn@ q"class A(...$paramss) extends $template" => defn.asInstanceOf[Defn.Class]
}

このcase句では、いくつかのClass定義にはマッチしません。クラスの修飾子(List[Mod])、引数リストの修飾子、型引数[List[Type]]などがあるクラスにはマッチしないのです。

scala> classDefn(q"private class A()")
res0: List[scala.meta.Defn.Class] = List()

こういう場合は、以下のようにDefn.Classの抽出子を使ったcase句を書くほうが漏れにくくておすすめです。

def classDefn2(tree:Tree): List[Defn.Class] = tree.collect {
    case defn@ Defn.Class(mods, name, typeParams, ctor, template) => defn
}
scala> classDefn2(q"private class A()")
res1: List[scala.meta.Defn.Class] = List(private class A())

進め方

ここからは、READMEのガイドに従って、step 1 ~ 6を進めていってください。

全てのステップにおいて、編集するソースコードは src/main/scala/ 以下だけで済むはずです。

例えばstep1の実装をした後に、step1のテストが通るかどうか確かめるには、以下のコマンドをsbtシェルで実行してください。

sbt> testOnly step1.*