Rosetta Lisp in 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
case Num(data: Double) extends SexpNothing case Sym(data: String) extends SexpNothing case Cons(car: SexpA, cdr: SexpA) extends SexpA 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 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) 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:
...
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) = 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
// 空のタプルのインスタンス (ベースケース)
Seq()
case Seq() => Some(EmptyTuple)
case _ => None
// N要素のタプルのインスタンスを、先頭(H)とN-1要素のタプル(T)のインスタンスから実装
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 が外れた。さわりだけ調べたツイート