search term:

僕が Scala 3 が好きな 10 の理由

何で Scala 3 をそんなに推すのかと聞かれることがあるので、リスト形式で書き出してみた。順は特に無し。これは僕が Scala 3 をどう書いているかとか、将来どう書きたいのかみたいな個人的な好みに基づいているので、それは注意してほしい。

1. enum (と GADT)

Scala 3 で enum が追加され、これは case class のアップグレード版だと考えることができる。ボーナスとして、これは GADT も扱うことができる。

case に関する備考:
一旦 Scala 2 に戻るが、case classcase がどこから来たのか考察した人はいるのだろうか? 一つの説としては、case は C 言語とか Java の switch 文の流れをくんでいて、既に予約語なのでそれを使ったと考えることもできる。

もう1つの由来として僕が提案したいのは 1998 年に Philip Wadler さんが java-genericity@galileo.East.Sun.COModersky@cis.unisa.edu.au、その他宛に送った The expression problem というメールだ。(Wadler さんは Haskell の作者として有名だが、当時は Odersky 先生らと Java のジェネリックス機能の追加に関わっていた):

「式問題」Expression Problem は古くからある問題への新しい名前だ。データ型を cases (場合分け) によって定義して、既存のコードを再コンパイルしたり静的型安全性を損なうことなく (例、キャストは禁止)、新しい case を追加したり、そのデータ型に関する新しい関数を定義できるようにすることが課題だ。

これは、1998年当時から「case」という名詞が、あるデータ型の型レベルの場合分けを表すのに使われていたことの証左だ。この観点から見ると、case class は型レベルの case を定義する方法を提供していると考えることができる。しかし、Scala 2.x においては case が型へと漏れてしまっているのが、よろしくない。

Some(...)None などの Scala 2.x の case class では、項が Some[A]None と型付けされてしまう。これは、項を Option[A] として取り扱い場合、不便だ。

Scala 3 の列挙型 enum においても、case は再び型レベルの case を定義するのに使われるが、項は NewOption[A] と型付けされる:

scala> enum NewOption[+A]:
         case Some(value: A) extends NewOption[A]
         case None extends NewOption[Nothing]

// defined class NewOption

scala> NewOption.Some(1)
val res0: NewOption[Int] = Some(1)

scala> NewOption.Some(1) == NewOption.Some(1)
val res1: Boolean = true

scala> NewOption.Some(1) == NewOption.None
val res2: Boolean = false

scala> NewOption.None
val res3: NewOption[Nothing] = None

scala> List(NewOption.Some(1))
val res4: List[NewOption[Int]] = List(Some(1))

僕が sbt 2.x で enum をどう使うかという例は酢鶏、パート1を参照。

GADT というのは、case が具象型を捕捉できるという意味だ。例えば、Option[A] は、A に何が入っているかが分かっていない。2つの整数を足すことができるミニ言語を例に GADT を定義する:

scala> enum MiniExpr[A]:
         case Bool(b: Boolean) extends MiniExpr[Boolean]
         case I32(i: Int) extends MiniExpr[Int]
         case Sum(a: MiniExpr[Int], b: MiniExpr[Int]) extends MiniExpr[Int]

// defined class MiniExpr

scala> MiniExpr.I32(1)
val res0: MiniExpr[Int] = I32(1)

scala> MiniExpr.Sum(MiniExpr.I32(1), MiniExpr.I32(1))
val res1: MiniExpr[Int] = Sum(I32(1),I32(1))

scala> MiniExpr.Sum(MiniExpr.I32(1), MiniExpr.Bool(false))
-- [E007] Type Mismatch Error: -------------------------------------------------
1 |MiniExpr.Sum(MiniExpr.I32(1), MiniExpr.Bool(false))
  |                              ^^^^^^^^^^^^^^^^^^^^
  |                              Found:    MiniExpr.Bool
  |                              Required: MiniExpr[Int]
  |
  | longer explanation available when compiling with `-explain`
1 error found

2. opaque 型

Scala 3 は (通常 opaque 型と呼ばれる) opaque 型エイリアスを導入し、これはオーバーヘッド無く新しい型を定義することができる。恐らく数値型を包むのが想定される用法だが、僕は String とか普通の型も包むのが好きだ。

例えば、sbt 2.x リモートキャッシュではハッシュ・ダイジェストを表す opaque 型を定義した:

opaque type Digest = String

object Digest:
  def apply(s: String): Digest =
    validateString(s)
    s

  def validateString(s: String): Unit = ()
end Digest

これは、内部構造を隠蔽した new type 的なものを手軽に作ることができる。

scala> def something(d: Digest): Unit = ()
def something(d: X.Digest): Unit

scala> something("foo")
-- [E007] Type Mismatch Error: -------------------------------------------------
1 |something("foo")
  |          ^^^^^
  |          Found:    ("foo" : String)
  |          Required: X.Digest
  |
  | longer explanation available when compiling with `-explain`
1 error found

注意: 吉田さんが以前言っていたのは値クラス同様 Scala 3 の opaque 型は JVM レベルでは隠蔽されていない。そのため、バイナリ互換性の維持が必要になる場合はその点を注意しなければならない。

3. inline

Scala 2.x でも @inline というのがあったが、これは言語概念というよりはオプティマイザのための指示みたいなもので使うのが難しかった。一方 Scala 3 は、インライン化が保証される正規の inline を導入した。

inline は以下の文脈で現れることができる:

以下は sbt 2.x のコードで inline def を使った実例:

inline def ~=(inline f: A1 => A1): Setting[Task[A1]] =
  transform(f)

def transform(f: A1 => A1): Setting[Task[A1]] =
  set(scopedKey(_ map f))

上の例では、transform というメソッドに対して ~= というシンボリックなエイリアスを提供した。ユーザが ~= を呼び出すとき、これはコンパイル時に transform(...) に完全に置き換えられるという仕組みだ。

inline をパラメータに付けた場合:

これは、inline def の本文内においてパラメータ変数が実際の引数によってインライン化されることを意味する。inline パラメータは名前呼び出しパラメータと同じ呼び出し意味論を持つが、引数にコードの重複を許す。

inline 条件は定数式をコンパイル時に評価するなんてことをまでやっている。inline がコンパイル時に処理されることが分かっているため、Scala 2.x ではマクロが必要だったような検査やトリックは inline と普通の関数によって書けるものも結構あるはずだ。

4. マクロ

Scala 2.x でのマクロシステムは、後付けで作られた実験的機能でコンパイラの内部をライブラリ作者に晒すものだった。Scala 3 になってマクロは正規の機能として認められ、メンテナンス性や機能のパワーの調整を入念に考察したことが伺われる仕上がりになっている。手前味噌になるが、Scala 3 マクロ入門も書いたので、オフィシャルのドキュメンテーションの補足になればいいかなと思う。

Scala 3 はハイジェニック (hygienic、健全) なマクロで、これはシステムが意図しない名前衝突を避けるようになっていることを意味する。以下に、コンパイル時に 1 を足すマクロを例に解説する:

import scala.quoted.*

inline def addOneX(inline x: Int): Int = ${addOneXImpl('{x})}

def addOneXImpl(x: Expr[Int])(using Quotes): Expr[Int] =
  val rhs = Expr(x.valueOrError + 1)
  '{
    val x = $rhs
    x
  }

上では、クォートされたコードが x という名前の変数を導入している。コンパイル時に、マクロシステムはこの変数を適当な名前に置換して、addOneX(1) が呼ばれた先で x という別の名前の変数を間違えて捕捉しないようになっている。

マクロシステムのもう 1つの用法は、型システムと話すことだ。「型システムと話す」ことは「リフレクション」と呼ばれるが、「リフレクション」と聞いただけで、実行時に文字列によってメソッドをアクセスすると言った Java のリフレクションを思い浮かべる人が多い。Scala 3 のリフレクションはコンパイル時に行われ、コードを使って型システムにクエリしたり、コード生成を行う。

以下は TypeRepr と型シンボルを使って、ある型が case class かどうかを判定する例だ:

import scala.quoted.*

inline def isCaseClass[A]: Boolean = ${ isCaseClassImpl[A] }
private def isCaseClassImpl[A: Type](using qctx: Quotes) : Expr[Boolean] =
  import qctx.reflect.*
  val sym = TypeRepr.of[A].typeSymbol
  Expr(sym.isClassDef && sym.flags.is(Flags.Case))

マクロの用法の多くは、大抵何らかのコード生成だが、クォートシステムは Scala のコードを使ってそれを行うことができる。以下は、sbt 2.x が条件タスク (if 式から構成されるタスクは Selective functor として扱われる) を処理する例だ:

inline def task[A1](inline a1: A1): Def.Initialize[Task[A1]] =
  ${ TaskMacro.taskMacroImpl[A1]('a1) }

def taskMacroImpl[A1: Type](a: Expr[A1])(using
    qctx: Quotes
): Expr[Initialize[Task[A1]]] =
  a match
    case '{ if $cond then $thenp else $elsep } => taskIfImpl[A1](a)
    case _ =>
      val convert1 = new FullConvert(qctx, 0)
      convert1.contMapN[A1, F, Id](a, convert1.appExpr, None)

def taskIfImpl[A1: Type](expr: Expr[A1])(using
    qctx: Quotes
): Expr[Initialize[Task[A1]]] =
  import qctx.reflect.*
  val convert1 = new FullConvert(qctx, 1000)
  expr match
    case '{ if $cond then $thenp else $elsep } =>
      '{
        Def.ifS[A1](Def.task($cond))(Def.task[A1]($thenp))(Def.task[A1]($elsep))
      }
    case _ =>
      report.errorAndAbort(s"Def.taskIf(...) must contain if expression but found ${expr.asTerm}")

ユーザは if 式かその他の式を渡すが、それがどちらなのかのチェックはパターンマッチで行われている。Scala 2.x のマクロでは、これらの作業は往々にして抽象構文木の走査が必要だった。

5. then

少し軽めな話題として、シンタックスの話をしよう。Object Pascal をしばらくやっていたので、Scala 3 の if 式が括弧無しで書けるようになったのは嬉しく思う。恐らく 1972年頃、C言語あたりが if 条件を括弧に入れ始めて、C++ と Java がそれに倣ったが、歴史的にはこれが亜流だと思う。

Algol 60、Pascal、ML 系列の言語 (SML, F#)、Haskell などは全て:

if condition then expression1 else expression2

という構文を使う。

Scala 3 は新しい制御構文を導入し、これは then を使った if 式も含まれる。僕が 2018 に書いた The state of then も参照。

Python、Rust、Swift といった今どきの言語も条件に括弧を必要としないが、then も採用していない。if の括弧を then で置き換えるたびに、これが Pascal/ML 系言語としての Scala の本来の姿だと笑みがこぼれてしまう。

1つ不満を言わせてもらうならば、以下が許されることだ:

scala> val x = 1
val x: Int = 1

scala> if x > 1 then 2

これを val に代入することすらできてしまう:

scala> val y = if x > 1 then 2

scala> y == (())
val res0: Boolean = true

if は文ではなく式のはずなので、else () と書く必要があっても else 節は省略不可能にしてほしい。いや、then 節と else 節の型が一致することも強制してほしい。

6. ポリモーフィック関数

Scala 2.x でもパラメトリック関数 makeListhead は以下のように実装できる:

scala> def makeList[A1](a: A1): List[A1] = List(a)
def makeList[A1](a: A1): List[A1]

scala> def head[A1](xs: List[A1]): A1 = xs.head
def head[A1](xs: List[A1]): A1

scala> head(makeList(1))
val res1: Int = 1

しかし、これを val に保持するにはどうすればいいだろうか? どのような型を持つだろう? この概念は rank-n ポリモーフィズムとも呼ばれ、関数型プログラミングでたびたび登場する。例えば、FunctionK 参照。Scala 3 はポリモーフィック関数とそれに対応したポリモーフィック関数型を導入したため、ポリ関数を渡して回ることができる。

scala> val makeList = [a] => (a: a) => List(a)
val makeList: [a] => (a: a) => List[a] = Lambda$7541/473366824@60b21dcd

scala> val head = [a] => (xs: List[a]) => xs.head
val head: [a] => (xs: List[a]) => a = Lambda$7542/1517955069@5ecdd9b

scala> head(makeList(1))
val res0: Int = 1

Miles Sabin さんのような人たちが 10年ぐらいこういうことをやり続けてきてたという背景がある。例えば、Dave Gurnell さんが書いた「The Type Astronaut’s Guide to Shapeless」というガイドの Functional operations on HLists セクション参照。Scala 3 によって「型宇宙飛行士」的アイディアが一次創作に入ってきたのは嬉しい。

7. タプル

アリティーと言えば (Shapeless はアリティーを抽象化する)、Scala 3 ではタプルがアリティー・ジェネリックな能力を獲得した。詳細は Tuple.scala とか Vincenzo Bazzucchi さんの Tuples bring generic programming to Scala 3 が参考になる:

Scala 3 ではタプルは、新しい演算、型安全性の向上、より少ない制約と、ジェネリックプログラミングの基礎となるデータ構造の Heterogeneous Lists (HLists) への方向性を示す能力を得た。

内部構造的には Scala 3 のタプルは 2.13 のタプルと変わらない (scala.runtime.Tuples.cons 参照) が、コンパイル時には型システムはタプルは A1 *: A2 *: A3 *: Tuple.EmptyTuple といった形になっているフリをさせてくれる。

マッチ型とポリモーフィック関数を使うことで、タプリ型を走査したり合成したりできる。

Functional operations on HLists での例を実装できるか試してみよう:

scala> (10, "hello", true).size
val res0: Int = 3

scala> type R = Tuple.Size[Int *: String *: Boolean *: EmptyTuple]
// defined alias type R = 3

より実用的なのは、個々の型を IO[a] みたいなエフェクト型で包むことだ。ここでは Option[a] で代用する:

scala> val makeOption: [a] => a => Option[a] = [a] => (a: a) => Option(a)
val makeOption: [a] => (x$1: a) => Option[a] = Lambda$7806/239970257@2435a640

scala> (10, "hello", true).map(makeOption)
val res0: (Option[Int], Option[String], Option[Boolean]) = (Some(10),Some(hello),Some(true))

scala> type R = Tuple.Map[Int *: String *: Boolean *: EmptyTuple, Option]
// defined alias type R = (Option[Int], Option[String], Option[Boolean])

これは結構良いのではと思う。sbt 2.x の内部構造とこれがどう関連するかは sudori part 3 参照。

8. 拡張メソッド

Scala 3 は拡張メソッドを導入して、これは既に型が定義された後で型にメソッドを追加することができる。これは Scala 2.x における implicit class に似ているが、拡張メソッドになってからか気のせいか思ったよりよく使うようななった。

例えば、sbt 1.x のコードでは、Initialize[A] とか Task[A] が別のレイヤーで定義されているために、.value メソッドは implicit で注入されるマクロで実装される。sbt 2.x も同様の制約があるが、拡張メソッドと inline を使うことで実装はかなり簡単になった:

extension [A1](inline in: Initialize[A1])
  inline def value: A1 = InputWrapper.`wrapInit_\u2603\u2603`[A1](in)

9. Scala 2.13 相互運用

Scala 3.x の面白い側面の 1つに 3.x 系はずっと後方互換であることと、Scala 2.13 系のエコシステムと相互運用しているという点がある。実際、標準ライブラリは Scala 2.13 のものを流用している。言い方を変えると、コレクションライブラリの書き換えなどランタイムの変更点は Scala 2.13 として前倒しでリリースしたと考えることもできる。

Scala 3 に飛び乗ってしまえばこれらはあんまり考えなくてもいいが、Scala 3 への移行を検討している会社などがあると、Scala 3 での実行時の振る舞いがほぼ同じであるというのは安心感があることだと思う。相互運用があることで、アプリ開発者の人たちは Scala 3.x に徐々に移行するということができる。

10. 中括弧省略構文

Scala 3 は中括弧省略構文を導入して、多くの {}: と文字の字下げで置き換えられるようになった。これは、完全に自由にオプトインできる構文の変更にも関わらず恐らく最も物議を醸した変更点だ。

個人的には、この新しい構文は気に入っている。定義の構文は好きだし、match その他が中括弧が要らなくなるのも好きだし、関数の本文に中括弧が要らなくなるもの好きだし、“fewer braces” と呼ばれる新しいラムダ構文も好きだ。可能な場合は、基本的に中括弧省略構文を選んでいる:

def toActionResult(ar: XActionResult): ActionResult =
  val outs = ar.getOutputFilesList.asScala.toVector.map: out =>
    val d = toDigest(out.getDigest())
    HashedVirtualFileRef.of(out.getPath(), d.contentHashStr, d.sizeBytes)
  ActionResult(outs, storeName, ar.getExitCode())

僭越ながら助言をするならば、もっと押して半角スペース2文字でインデントすることをほとんどの場所で強制するのと、オフサイド・ルールを強制してほしい。例えば、以下のような関数定義は Python 3 ではエラーとなる:

>>> def foo():
      x = 1
        y = 2
  File "<stdin>", line 3
    y = 2
IndentationError: unexpected indent

Scala 3 ではこれが許されている:

scala> def foo: Int =
         val x = 1
           val y = 2
         x + y

こういう書き方が禁止されればより良いが、前述の通り、僕は Scala 3 インデント構文のファンだ。

選外佳作: ユニオン型

Scala 3 はユニオン型を追加した。ユニオン型が入るのを楽しみにしていたが、実際の所はまだ実用していない。

scala> type OSIOI = Option[String] | Int | Option[Int]
// defined alias type OSIOI = Option[String] | Int | Option[Int]

scala> def foo(o: OSIOI): Option[String] =
         o match
           case None            => None
           case i: Int          => Some(i.toString)
           case Some(i: Int)    => Some(i.toString)
           case Some(s: String) => Some(s)

def foo(o: OSIOI): Option[String]

scala> foo(1)
val res1: Option[String] = Some(1)

scala> foo(Some("foo"))
val res2: Option[String] = Some(foo)

いい感じじゃないかなと思う。しかし、以下のようなコードはどうかなと思う:

scala> val hmmm = if true then "bar" else Option("baz")
val hmmm: String | Option[String] = bar

LUB を作らなくなったのはいいが、StringOption[String] の間に意味のある親が無いのだからコンパイラ・エラーにした方が分かりやすいと思うのだが。

選外佳作: 多元的等価性

素の状態の Scala 2.x は等価性がザルで、コンパイルを失敗するべき比較不能な状況でも 2つの値を比較してしまうことで悪名高い。Scala 3 ではその対策として多元的等価性が導入されたが、デフォルトでは使われていない。

scala> Option(1) == Option("foo")
val res0: Boolean = false

多元的等価性は以下のようにして有効化できる:

Compile / scalacOptions += "-language:strictEquality"
scala> given optionEq[A1, A2](using ev: CanEqual[A1, A2]): CanEqual[Option[A1], Option[A2]] = CanEqual.derived
def optionEq
  [A1, A2](using ev: CanEqual[A1, A2]): CanEqual[Option[A1], Option[A2]]

scala> Option(1) == Option("foo")
-- [E172] Type Error: ----------------------------------------------------------
1 |Option(1) == Option("foo")
  |^^^^^^^^^^^^^^^^^^^^^^^^^^
  |Values of types Option[Int] and Option[String] cannot be compared with == or !=.
  |I found:
  |
  |    optionEq[A1, A2](/* missing */summon[CanEqual[A1, A2]])
  |
  |But no implicit values were found that match type CanEqual[A1, A2].
1 error found

scala> Option(1) == Option(2)
val res0: Boolean = false

多少言いたいこともあるが、全般的により厳格な等価性は Scala にとって大きな一歩だ。この話題に関しては自由、平等、ボックス化されたプリミティブ型も参照。

選外佳作: 型クラスの自動導出

Scala 3 は、面白いことに型クラスの自動導出を導入した。これは詳しいことをちゃんと見てないので、努力目標的な「いいね」だ。

上のコードの例で、使ったばっかりではある:

scala> given optionEq[A1, A2](using ev: CanEqual[A1, A2]): CanEqual[Option[A1], Option[A2]] = CanEqual.derived

導出の部分も実装すると面白いと思う。

選外佳作: ユーザーランドでのコンパイル・エラー

マイグレーションの警告やエラーを表示するために、僕は前からユーザランドでのコンパイラ・エラーがあるといいと言ってきた。Scala 2.x だと、エラーメッセージを表示するためにエラーを投げるだけのシンプルなマクロを書いてたりする。Scala 3 は scala.compiletime モジュールを追加したので、比較的簡単にこれが実装できる:

scala> class SomeDSL[A1](a: A1):
         inline def <<=[A2](inline a2: A2): Option[A2] =
           compiletime.error("<<= is removed; migrate to := instead")
       end SomeDSL
// defined class SomeDSL

scala> SomeDSL(1) <<= 2
-- Error: ----------------------------------------------------------------------
1 |SomeDSL(1) <<= 2
  |^^^^^^^^^^^^^^^^
  |<<= is removed; migrate to := instead
1 error found

より多くの人がこれを使って「不正な状態を表現不可能」とできればいいと思っている。

まとめ

Scala 3.x は、2021年以来 Scala 言語の現行バージョンだ。Scala 2.x を使ったことがある人は、慣れ親しんだ概念は全部 Scala 3 にあることが分かると思う。次の段階へと進もうと思うと、Scala 3.x はより安全で表現力の高いコードを書くための道具がそろっている。