OE_uia Tech Blog

ScalaMatsuri / Scala / Android / Bioinfomatics

ScalaのREPLを拡張するには

| Comments

こちらはScala AdventCalendar 2014の7日目の記事です。 今日はScalaのカスタムREPLの作り方についての話。なお今回は(Scala REPL同様)StandaloneなREPLアプリの作成を目的としているので、:powerモードは主眼ではありません。

モチベーション

ScalaのREPLは手元のローカルマシンでAPIを試してみたいときや、ちょっとした計算をしたいときにはとても便利なのですが、やや凝ったことをしたいときなど、そのまま使うには不便さを感じることがあります。

具体的にはAndroidのAPIをScalaのREPLから叩けるようにしたかったのですが、Android環境をJVMでエミュレートするためにはカスタムClassLoaderを使ってAndroid APIのClassを書き換える必要があって。。。という感じ。

これはちょっと特殊なモチベーションかもしれませんが、クラスター上などの特定の環境で実行させたいとき(e.g.spark-shell)、自作ライブラリのsandbox環境を提供するにあたって特定の場面でよく使うコマンドを追加したい、など思われる方はいるかもしれません。

そんなとき、意外とScala REPLを拡張する記事を書いている人が少なかったので、今回はScalaのソースコードを読みながらカスタムClassLoaderを使用する方法、コマンドを追加する方法について書くことにしました。

参考:Create your custom Scala REPL … 数少ないREPL拡張方法に関する記事。

成果物

taisukeoe/MyCLRepl

MyCLRepl DEMO
1
2
3
4
5
6
7
8
9
10
11
scala> val hello = "hello"

MyClassLoader loads classOf <root>.$line3
<<中略>>
MyClassLoader loads classOf scala.collection.mutable.StringBuilder
MyClassLoader loads classOf scala.runtime.ScalaRunTime$
hello: String = hello

scala> :myCommand hello

This is a custom command example. You can do something from value:"hello" with custom Scala interpreter.

ClassLoaderの差し替え(Classのロード時にクラス名をprint)と、myCommandというコマンドの追加をしています。

REPLとは

Read–Eval–Print Loopの略で、対話型の開発環境。ユーザーの入力したコードを一行から評価する。 ScalaのREPLはscalaコマンドから開始できる。

ScalaのREPLの構成

scala.tools.nsc.interpreterパッケージがREPLに相当。インタプリタのソースコードがそれなりの量あるので一見大変そうだが、構成自体はシンプル。REPLをカスタマイズする上で最低限見る必要がある場所に絞って、以下では解説します。

エントリポイント

scalaのスクリプトの中身を見ると、以下の通りscala.tools.nsc.MainGenericRunnermain関数をエントリポイントとしてREPLを起動していることがわかる。

/usr/local/bin/scala#L202-212
1
2
3
4
5
6
7
8
9
10
execCommand \
  "${JAVACMD:=java}" \
  $JAVA_OPTS \
  "${java_args[@]}" \
  $(classpathArgs) \
  -Dscala.home="$SCALA_HOME" \
  $OVERRIDE_USEJAVACP \
  "$EMACS_OPT" \
  $WINDOWS_OPT \
  scala.tools.nsc.MainGenericRunner  "$@"

Main関数の中でLoopを呼び出し

scala.tools.nsc.MainGenericRunnermain関数の処理は、同コンパニオンクラスのprocess関数に移譲している。この関数の中で、ILoopprocess関数を呼んでおり、このクラスがREPLのLoopに相当。

scala.tools.nsc.MainGenericRunnerlink
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MainGenericRunner {
...
  def process(args: Array[String]): Boolean = {
    ...
    def run(): Boolean = {
      ....

      def runTarget(): Either[Throwable, Boolean] = howToRun match {
        ...
        case _  =>
          // We start the repl when no arguments are given.
          Right(new interpreter.ILoop process settings)
      }
     ...
    }
    ...
      run()
  }
}

object MainGenericRunner extends MainGenericRunner {
  def main(args: Array[String]): Unit = if (!process(args)) sys.exit(1)
}

参考:scala.tools.nsc.MainGenericRunner.run

Loopの中でインタプリタを呼び出し

scala.tools.nsc.interpreter.ILoopはREPLのLoopに相当するクラス。コマンドの判定や、入力したコードのインタプリタへの移譲などを行っている。

REPLのコマンド(e.g.:help)の定義はstandardCommands変数。overrideしたcommands関数において追加することでカスタムCommandを実装できる。

また、ILoopcreateInterpreter関数内で、インタプリタのメンバ変数var intp:IMainを初期化している。

なおインタプリタ intpの実装クラスはIMainを継承したILoopInterpreter

scala.tools.nsc.interpreter.ILooplink
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class ILoop(in0: Option[BufferedReader], protected val out: JPrintWriter)
                extends AnyRef
                   with LoopCommands
{
...
var intp: IMain = _
...

   lazy val standardCommands = List(
    cmd("edit", "<id>|<line>", "edit history", editCommand),
    cmd("help", "[command]", "print this summary or command-specific help", helpCommand),
    historyCommand,
    cmd("h?", "<string>", "search the history", searchHistory),
    cmd("imports", "[name name ...]", "show import history, identifying sources of names", importsCommand),
    cmd("implicits", "[-v]", "show the implicits in scope", intp.implicitsCommand),
    cmd("javap", "<path|class>", "disassemble a file or class name", javapCommand),
    cmd("line", "<id>|<line>", "place line(s) at the end of history", lineCommand),
    cmd("load", "<path>", "interpret lines in a file", loadCommand),
    cmd("paste", "[-raw] [path]", "enter paste mode or paste a file", pasteCommand),
    nullary("power", "enable power user mode", powerCmd),
    nullary("quit", "exit the interpreter", () => Result(keepRunning = false, None)),
    cmd("replay", "[options]", "reset the repl and replay all previous commands", replayCommand),
    cmd("require", "<path>", "add a jar to the classpath", require),
    cmd("reset", "[options]", "reset the repl to its initial state, forgetting all session entries", resetCommand),
    cmd("save", "<path>", "save replayable session to a file", saveCommand),
    shCommand,
    cmd("settings", "<options>", "update compiler options, if possible; see reset", changeSettings),
    nullary("silent", "disable/enable automatic printing of results", verbosity),
    cmd("type", "[-v] <expr>", "display the type of an expression without evaluating it", typeCommand),
    cmd("kind", "[-v] <expr>", "display the kind of expression's type", kindCommand),
    nullary("warnings", "show the suppressed warnings from the most recent line which had any", warningsCommand)
  )

  ...

    /** Available commands */
  def commands: List[LoopCommand] = standardCommands ++ (
    if (isReplPower) powerCommands else Nil
  )

  ...

  class ILoopInterpreter extends IMain(settings, out) {
    outer =>

    override lazy val formatting = new Formatting {
      def prompt = ILoop.this.prompt
    }
    override protected def parentClassLoader =
      settings.explicitParentLoader.getOrElse( classOf[ILoop].getClassLoader )
  }
  /** Create a new interpreter. */
  def createInterpreter() {
    if (addedClasspath != "")
      settings.classpath append addedClasspath

    intp = new ILoopInterpreter
  }

  ...

  // start an interpreter with the given settings
  def process(settings: Settings): Boolean = savingContextLoader {
    this.settings = settings
    createInterpreter()
     ...
    printWelcome()
     ...
    try loop() match {
      case LineResults.EOF => out print Properties.shellInterruptedString
      case _               =>
    }
    catch AbstractOrMissingHandler()
    finally closeInterpreter()

    true
  }
  ...

インタプリタの中でClassLoaderを生成

scala.tools.nsc.interpreter.IMainがインタプリタの主たるコード。

private var _classLoader:ClassLoaderがClassLoaderのメンバ変数だが、privateなのでclassLoader関数をoverrideしてカスタムClassLoaderを生成する。

ただし、IMainclassLoader関数の戻り値の型はscala.reflect.internal.util.AbstractFileClassLoaderになので、カスタムClassLoaderもこれを継承させる必要がある点に注意。

scala.tools.nsc.interpreter.IMainlink
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class IMain(@BeanProperty val factory: ScriptEngineFactory, initialSettings: Settings, protected val out: JPrintWriter) extends AbstractScriptEngine with Compilable with Imports {
  imain =>

  ...

  private var _classLoader: util.AbstractFileClassLoader = null                              // active classloader
  ....

  /** Parent classloader.  Overridable. */
  protected def parentClassLoader: ClassLoader =
    settings.explicitParentLoader.getOrElse( this.getClass.getClassLoader() )

  def resetClassLoader() = {
    repldbg("Setting new classloader: was " + _classLoader)
    _classLoader = null
    ensureClassLoader()
  }
    final def ensureClassLoader() {
    if (_classLoader == null)
      _classLoader = makeClassLoader()
  }
  def classLoader: util.AbstractFileClassLoader = {
    ensureClassLoader()
    _classLoader
  }

カスタムREPLを作る

基本的には解説した以下のクラスに相当するものを継承なり自前で作るなりすれば、カスタムREPLが作れます。

先ほども記載しましたが、完成物は以下の通り。試し方はREADME参照のこと。

taisukeoe/MyCLRepl

REPLを拡張している実例

Apache Sparkのspark-shellは、REPL上で入力したコマンドをcluster上で実行させるために、REPLを拡張している。以下のファイル群がカスタムREPLに相当する。

ScalaMatsuriで発表されたScaliveもREPLを拡張してJVM環境を触れるようにしている。

余談:Scalaの:power モード

Scala REPLに備わっている:powerモードを使用すると、Scala REPLコードのpublicな変数・関数へのアクセスが可能で、varなら置き換えも可能。

例えば上記のカスタムClassLoader挿入は、:powerモードを利用してこんな風にも書けます。(ClassLoaderのサイズが膨らんでくるとつらさはありますが)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
scala> :power

** Power User mode enabled - BEEP WHIR GYVE **
** :phase has been set to 'typer'.          **
** scala.tools.nsc._ has been imported      **
** global._, definitions._ also imported    **
** Try  :help, :vals, power.<tab>           **

scala> :paste
// Entering paste mode (ctrl-D to finish)

import scala.reflect.internal.util.ScalaClassLoader
import scala.tools.nsc.interpreter.JPrintWriter
import scala.tools.nsc.io.AbstractFile
import scala.tools.nsc.util
repl.intp = new repl.ILoopInterpreter {
   override def classLoader = new util.AbstractFileClassLoader(root:AbstractFile,parent:ClassLoader)  with ScalaClassLoader{
  override def loadClass(name: String) = {
    println(s"MyClassLoader loads classOf ${name}")
    super.loadClass(name)
  }
 }
}

scala> val hoge = "hoge"

MyClassLoader loads classOf <root>.$line2
MyClassLoader loads classOf $line2
<<中略>>
MyClassLoader loads classOf scala.runtime.ScalaRunTime$
MyClassLoader loads classOf scala.runtime.BoxedUnit

hoge: String = hoge

ただその一方でREPLのコマンドの判別はILoopクラスで行わており、MainGenericRunnerの内部関数中で直接呼び出されているのでPowerModeからのカスタムCommandの追加は不可能なはず(とはいいつつ、何がしかのhackもありそうな気がするのでもしあればコメント欄で教えてください。)

余談

肝心の、オレオレClassLoaderを使ってAndroid APIを叩けるScala REPLアプリは間に合わず。現状でも、未解決のwarningが出る問題とか、タブ補完が効かない問題など、色々と雑な感じにはなっています。。。

そのあたりのフォローはまた後日に(‘・ω・`)

Comments