Rosetta Lisp in Scala
yubrot/scalisp - GitHub
Rosetta LispのScala実装について。
Scala 2で書かれたものから数年経って、今回(2022年) Scala 3にmigrateしたので所感。周辺環境もメモっておく。
ビルドツール。過去、sbtにあまり良いイメージが無く、millというマイナーなビルドツールを使っていたコミットが残っているが、数年経っているしということで改めてsbtを使ってみた。公式ドキュメントが大きく改善されたのか、謎演算子が減ったのか (記憶が無い) わからないが、今回は公式ドキュメントを読んでとりあえず使うことは出来たかなあという感じに。昔は本当にわからなかった記憶がある。 Def.task { .. }.taskValue とか、 TaskKey[T] と SettingsKey[T] が並列に存在する理由とか、依存関係解決をScala的にどう実装してるのか、等わかってないことは色々あるが... build.sbt のデバッグは依然しんどいように思う。
開発環境。VSCodeにMetalsというプラグインを入れたが、まだ型情報がうまく出なかったりコードジャンプが突拍子もないところに飛んだり大変ですね...Metalsの裏のBloopを通して、Build Server Protocolというものがあるのを初めて知った。名前からLSPが想起されるだろうが、実際BSPはLSP-inspiredとのこと。
Scalafmtも悪くはないが、rustfmtに慣れるとrustfmtでコードの「ぶれ」が無くなる感覚のほうが好みだなあとか実感した。rustfmtは、スペースの数や単一行/複数行などの様々なコードの「ぶれ」について、文字数やASTの複雑さに基づいて常に一貫したフォーマットを強制的に行う。そのため、空行を二行から一行にした、みたいなdiffの発生がかなり抑えられるようになっている。
言語の基本的な構文については、おおよそ書きやすいようにまとまっている。後置するmatchなどを始めとして、式指向で無駄がない。ただ、Scala 3で入ったインデント構文はあんまり好きではないかもしれない。Scala 2との互換性を保つ必要があったり、適切なASCII文字が残ってないので仕方ないが、 extension (...) class ...(...): のように : が要る場所と要らない場所がある、 {..} が必要な場所も残ってる、など。一方で、Scala 3 で入った enum などはとても書きやすくて良い。
code:Sexp.scala
enum Sexp+A:
case Num(data: Double) extends SexpNothing
case Sym(data: String) extends SexpNothing
case Str(data: ArrayByte) extends SexpNothing
case True extends SexpNothing
case False extends SexpNothing
case Cons(car: SexpA, cdr: SexpA) extends SexpA
case Nil extends SexpNothing
case Pure(data: A) extends SexpA
これでオブジェクト指向言語らしく基底クラスの Sexp と派生クラスの Sexp.Num, Sexp.Sym, .. になるのも気持ち良い。
普段は関数型言語寄りのプログラミングをしているため、scalispもSubtypingをうまく駆使したプログラムにはなってないかと思うが、 case Num(data: Double) などが extends Sexp[Nothing] とできることについて、なるほどSubtypingはこういう表現ができるのだなあとScala 2版を書いたときに思っていた気がする。 List のようなオブジェクトを設け、 apply unapply を定義する ことでコンストラクタのように構築や分解に使える、なども嬉しい。
型クラス。Scalaにおいて、型クラスのインスタンスは単にオブジェクトであり、これが暗黙に解決されるという形で型クラスが実現されている。Scala 2では implicit という予約語一つが様々な手法に用いられていたが、Scala 3でこれが言語機能として分解されたので、非常にわかりやすくなった。まず、型クラスは単に trait として定義する。
code:Match.scala
type Value = <上述の SexpA を SexpNative と特殊化したもの>
trait MatchArgT: // class MatchArg t と同等
def signature: String
def matchArg(arg: Value): OptionT
trait MatchArgsT:
def signature: SeqString
def matchArgs(args: SeqValue): OptionT
この型クラスのインスタンスは given で定義する。
code:Match.scala
given MatchArgValue with // instance MatchArg Value と同等
def signature: String = "value"
def matchArg(arg: Value): OptionValue = Some(arg)
given MatchArgDouble with
def signature: String = "num"
def matchArg(arg: Value): OptionValue = arg match
case Sexp.Num(num) => Some(num)
case _ => None
依存側は using を用いる...のだが、 [T](...)(using MatchArg[T]) を [T: MatchArg](...) と記述できる構文糖衣があり、今回のscalispでは型パラメタが1つの型クラスしか用いなかったため、この構文糖衣で全て済み、 using は一切登場しなかった。
code:Builtins.scala
trait CommonBuiltinImpl extends BuiltinImpl:
...
def takeT: MatchArgs(args: SeqValue): T =
val matcher = summon[MatchArgsT]
matcher.matchArgs(args) match
case Some(r) => r
case None => throw EvaluationError(s"Expected (${(name +: matcher.signature).mkString(" ")})")
暗黙に型クラス (この例では MatchArgs[T] 型) のインスタンスオブジェクトがこのメソッド呼び出しに渡っており (変数名が割り当たっていないだけで単に引数として渡っている)、 summon[T] のような標準のメソッドで渡ってきたオブジェクトを扱える。
ということで、例えばRosetta Lispの (substr str n bytesize) なら、この take メソッドを用いてこのように実装できる:
code:Builtins.scala
object BuiltinSubstr extends CommonBuiltinImpl:
def name = "substr"
def run(vm: VM, args: SeqValue) =
// MatchArgs(Sexp.Str, Int, Int) が暗黙に解決され、takeに渡る
val (s, index, size) = take(Sexp.Str, Int, Int)(args)
if index < 0 || s.data.length < index + size then throw EvaluationError("Index out of range")
vm.push(Sexp.Str(s.data.slice(index, index + size)))
型クラスのインスタンスが (グローバルな辞書でなく) givenで定義されるローカルなオブジェクトであると何が嬉しいか (嬉しくないか) というと、インスタンスの重複が許される (許されてしまう) とか、importでスコープを制御できる (制御できてしまう) とか考えられる。とりあえずGHCの ApplyingVia 拡張みたいなのが型レベルの特別な構文とか必要とせず書けるようだ: Inferring Complex Arguments
※この辺は結構適当言ってる気がする。さらに適当っぽいことを言うと、タプルの型クラスのインスタンスが *: を用いてへテロリストっぽく書けていてスマート (タプル自体がへテロリストになってるのかはわからない):
code:Match.scala
// 空のタプルのインスタンス (ベースケース)
given MatchArgsEmptyTuple with
def signature: SeqString =
Seq()
def matchArgs(args: SeqValue): OptionEmptyTuple = args match
case Seq() => Some(EmptyTuple)
case _ => None
// N要素のタプルのインスタンスを、先頭(H)とN-1要素のタプル(T)のインスタンスから実装
given H: MatchArg, T <: Tuple: MatchArgs: MatchArgsH *: T with
def signature: SeqString =
summon[MatchArgH].signature +: summon[MatchArgsT].signature
def matchArgs(args: SeqValue): OptionH *: T = args match
case h +: t =>
for
h1 <- summon[MatchArgH].matchArg(h)
t1 <- summon[MatchArgsT].matchArgs(t)
yield h1 *: t1
case _ => None
その他細かいところ雑記。 PartialFunction が地味に嬉しい。PartialFunctionのおかげで1行で書けるみたいな例が結構ある。 Option[T] とインテグレートされた match? みたいのを各プログラミング言語も取り入れてくれないかな...あと、Rust, Haskell, TypeScriptあたりのファイル単位のスコープが強い言語に慣れていてprivateの範囲が扱いづらく感じるなあとか、Type erasureがたまにしんどいとか。
あと試せてないのはマクロ。ScalaのマクロはScala 3で experimental が外れた。さわりだけ調べたツイート