Scala3の新機能紹介

こんにちは、おかむです。

開発開始から8年。28,000件のコミット、7400件のPR、4100件のClosed issue…
5/14に、とうとうScala3.0.0がリリースされました。
Scala3では、最新の型理論とScala2での経験が反映されています。

Easy to use, learn, and scale

移行や学習のためのドキュメント、Scala3の新しいマクロの解説など、各種ドキュメントの充実化も行われています。

今回はScala3の新しい要素について紹介していきます。
ちなみにすべては紹介しきれないので大きめの機能に絞っています。

新しい文法

New Control Syntax (新しい制御構文)

https://docs.scala-lang.org/scala3/reference/other-new-features/control-syntax.html

一定条件の元、ifforの括弧を省略できるようになりました。

if x < 0 then {
  "negative"
} else if x == 0 then {
  "zero"
} else {
  "positive"
}
  • ifの条件式は、thenが後に続く場合丸括弧を省略することができる
  • whileループの条件式は、doが後に続く場合丸括弧を省略できる
  • for式の各エントリは、yielddoが後に続く場合は、囲む波括弧を省略することができる forの中のdoは、forループであることを示します
  • catchが単一のcaseしかない場合、caseは同じ行に書ける (複数のcaseがある場合、波括弧で囲うか後述のindent blockにする必要がある

Optional Braces (括弧の省略)

https://docs.scala-lang.org/scala3/reference/other-new-features/indentation.html

Scala3ではインデンテーションに関して一定のルールを強要し、その代わりに一部の波括弧を省略できるようになりました。
Pythonのようにインデントベースで書けます。

現在でも賛否両論あるのですが、公式ドキュメントや最新のコップ本(Programming in Scala Fifth Edition)・Oreilly本(こちらはScala2での書き方も説明されています)が新しいsyntaxで書かれているので、長期的には移行していくことになるのではないかと思っています。

enum IndentWidth:
  case Run(ch: Char, n: Int)
  case Conc(l: IndentWidth, r: Run)

  def <= (that: IndentWidth): Boolean = this match
    case Run(ch1, n1) =>
      that match
        case Run(ch2, n2) => n1 <= n2 && (ch1 == ch2 || n1 == 0)
        case Conc(l, r)   => this <= l
    case Conc(l1, r1) =>
      that match
        case Conc(l2, r2) => l1 == l2 && r1 <= r2
        case _            => false

  def < (that: IndentWidth): Boolean =
    this <= that && !(that <= this)

  override def toString: String =
    this match
      case Run(ch, n) =>
        val kind = ch match
          case ' '  => "space"
          case '\t' => "tab"
          case _    => s"'$ch'-character"
        val suffix = if n == 1 then "" else "s"
        s"$n $kind$suffix"
      case Conc(l, r) =>
        s"$l, $r"

object IndentWidth:
  private inline val MaxCached = 40

  private val spaces = IArray.tabulate(MaxCached + 1)(new Run(' ', _))
  private val tabs = IArray.tabulate(MaxCached + 1)(new Run('\t', _))

  def Run(ch: Char, n: Int): Run =
    if n <= MaxCached && ch == ' ' then
      spaces(n)
    else if n <= MaxCached && ch == '\t' then
      tabs(n)
    else
      new Run(ch, n)
  end Run

  val Zero = Run(' ', 0)
end IndentWidth

型システム

Intersection Type (交差型)

https://docs.scala-lang.org/scala3/reference/new-types/intersection-types.html

一言で言えば、AでありかつBである型が定義できます。

trait HasCar {
  def drive(): Unit
}
trait HasSwitch {
  def play(): Unit
}

def today(x: HasCar & HasSwitch) =
  x.play()
  x.drive()

また、AとBが同じメンバーを持っている場合、そのA & Bのそのメンバーの型もintersection typeになります。
例を挙げると

trait A:
  def children: List[A]

trait B:
  def children: List[B]

val x: A & B = new C
val ys: List[A & B] = x.children

ここで少し興味深いのは、List[A] & List[B]がさらにList[A & B]と簡略化されていることです。
この変換が可能なのは、Listがcovariant(共変)だからです。

Union Type (共用体型)

https://docs.scala-lang.org/scala3/reference/new-types/union-types.html

Intersectionがあるなら、その双対(Duals)たるUnionもありますよね?
あります。

import scala.language.postfixOps

case class UserName(name: String)
case class Password(hash: String)

def help(id: UserName | Password) =
  val user = id match
    case UserName(name) => Predef.???
    case Password(hash) => Predef.???

見ての通りA | BはAかBのどちらかの型、ということを示す型になります。

まぁ便利なんですが、一点注意しなければならないことがあります。
残念ながらScala3のコンパイラには間が抜けているところがあるので、プログラマーが明示的に型を与えてあげないと正しく推論してくれません…

val password = Password("123")
// password: Password = Password("123")
val name = UserName("Eve")
// name: UserName = UserName("Eve")

val u = if true then name else password
// u: Object = UserName("Eve")
val u2: Password | UserName = if true then name else password
// u2: Password | UserName = UserName("Eve")

Type Lambda (型ラムダ)

https://docs.scala-lang.org/scala3/reference/new-types/type-lambdas.html

これまでkind-projectorというコンパイラプラグインを使って実現されていたtype lambdaがScala3だけで実現できるようになりました。

type MAB = [A, B] =>> Map[A, B]

例として上に書いたのはABを受け取ってMap[A, B]という型を返す型です。
正直型システムに慣れていない方にとっては何を言っているのかわからないですよね。
これはどういった時に使うものなのでしょうか。

trait Functor[F[_]] {
  def fmap[A, B](fa: F[A])(f: A => B): F[B]

  extension [A](fa: F[A]) def map[B](f: A => B): F[B] = fmap(fa)(f)
}

こんな定義のtraitがあるとします。F[_]は1つの型引数を取る型、ということを示しています。
この場合、FとしてEitherを渡したくてもEitherは2つの型引数を取る型なので、そのままではF[_]に合致せず渡せません。
そのため、Either[E, ?]とEitherの左辺型を固定してあげることで型のkindを合わせて渡せるようにする、というような場合があります。

Scala2までは

type E = String
type EE[A] = Either[E, A]

implicit object EEFunctor extends Functor[EE] {
  def fmap[A, B](ma: EE[A])(f: A => B): EE[B] = ???
}

と一旦型引数が1つのtypeとして定義してあげる必要がありました。
これがScala3では

implicit object EF extends Functor[[A] =>> Either[E, A]]:
  def fmap[A, B](ma: Either[E, A])(f: A => B): Either[E, B] = ???

と書けるようになります。

Match Types

https://docs.scala-lang.org/scala3/reference/new-types/match-types.html

Q: なにそれ?
A: タイプのパターンマッチ

type Elem[A] = A match
  case String => Char
  case Array[t] => t
  case Iterable[t] => t

Q: 何が嬉しいの?
A: Dependent typeを返す関数

def lastElementOf[A](a: A): Elem[A] = a match
  case s: String => s.last
  case seq: Array[_] => seq.last
  case it: Iterable[_] => it.last

val lst = 3 :: 6 :: Nil
// lst: List[Int] = List(3, 6)
lastElementOf(lst)
// res0: Int = 6

注意点として、lastElementOf()で実装している値のパターンマッチの型の構造がElem[A]のMatch typeの構造と一致していないとコンパイルエラーになります。

Dependent Function Types

https://docs.scala-lang.org/scala3/reference/new-types/dependent-function-types.html

Scala2の頃から、返り値の型が引数に依存するメソッドを定義することはできましたが、それを関数として型に束縛することはできませんでした。

trait Entry { type Key; val key: Key }

def extractKey(e: Entry): e.Key = e.key

Scala3では、これを型で表現して値に束縛することができます。

// Scala2ではコンパイルが通らない
val extractor: (e: Entry) => e.Key = extractKey
// extractor: Function1[Entry, Key] {
//   def apply(e: Entry): Key
// } = repl.MdocSession$App$$Lambda$131880/0x0000000803be9840@40491898

Polymorphic Function Types

https://docs.scala-lang.org/scala3/reference/new-types/polymorphic-function-types.html

Scalaのメソッドは型引数を取れるため、多相になれます。

def wrapA[A](a: A): List[A] = a :: Nil

しかし、Scala2ではそれを関数として型に束縛することはできませんでした。(関数の値は多相になれませんでした)
Scala3では、多相な関数の型をサポートします

val wrapA: [A] => A => List[A] = [A] => (a: A) => a :: Nil
// wrapA: PolyFunction {
//   def apply[A >: Nothing <: Any](x$1: A): List[A]
// } = <function1>

Enums

https://docs.scala-lang.org/scala3/reference/enums/enums.html

Scala3では、Enumerationつまり「列挙型」を定義するための糖衣構文(シンタックスシュガー)を提供します。

enum Color:
  case Red, Green, Blue

この定義は、Colorというsealed classと、Color.Red, Color.Green, Color.Blueという3つの値を定義します。
Red, Green, BlueColorのコンパニオンオブジェクトのメンバーとなります。

Enumはパラメータを受け取ることができます。

enum Color(val rgb: Int):
  case Red extends Color(0xff0000)
  case Green extends Color(0x00ff00)
  case Blue extends Color(0x0000ff)

Enumは、いくつかのメソッドをデフォルトで提供します。

val red = Color.Red;
// red: Color = Red;
red.ordinal
// res2: Int = 0

Color.values
// res3: Array[Color] = Array(Red, Green, Blue)

val blue = Color.valueOf("Blue")
// blue: Color = Blue
val green = Color.fromOrdinal(1)
// green: Color = Green
Color.valueOf("Black")
// java.lang.IllegalArgumentException: enum case not found: Black
//     at repl.MdocSession$App1$Color$.valueOf(index.md:139)
//     at repl.MdocSession$App1.$init$$$anonfun$1(index.md:169)

Enumやそのコンパニオンオブジェクトに任意のメソッドを追加することもできます。

enum Planet(mass: Double, radius: Double):
  private[this] final val G = 6.67300E-11

  def surfaceGravity = G * mass / (radius * radius)
  def surfaceWeight(otherMass: Double) = otherMass * surfaceGravity

  case Mercury extends Planet(3.303e+23, 2.4397e6)
  case Venus   extends Planet(4.869e+24, 6.0518e6)
  case Earth   extends Planet(5.976e+24, 6.37814e6)
  case Mars    extends Planet(6.421e+23, 3.3972e6)
  case Jupiter extends Planet(1.9e+27,   7.1492e7)
  case Saturn  extends Planet(5.688e+26, 6.0268e7)
  case Uranus  extends Planet(8.686e+25, 2.5559e7)
  case Neptune extends Planet(1.024e+26, 2.4746e7)
end Planet

object Planet:
  def main(args: Array[String]) =
    val earthWeight = args(0).toDouble
    val mass = earthWeight / Earth.surfaceGravity
    for p <- values do
      println(s"Your weight on $p is ${p.surfaceWeight(mass)}")
end Planet

ここまでで紹介したように、enum構文はADT(代数的データ型:Alebraic Data Types)を定義するのに十分な機能があります。

enum Option[+T]:
  case Some(x: T)
  case None

  def isDefined: Boolean = this match
    case None => false
    case _    => true

object Option:
  def apply[T >: Null](x: T): Option[T] =
    if x == null then None else Some(x)
end Option

Contextual Abstractions (コンテキストの抽象化)

https://docs.scala-lang.org/scala3/reference/contextual.html

Scala3ではimplicitsを「Contextual Abstractions」として再設計します。

implicitという単一のキーワードは様々な使われ方・意味を持ち、非常に複雑です。
コンテキストの引き回しなど便利に利用できる反面、安易に使うと様々な問題の原因となると言われていました。
implicitは、とても強力で特徴的なScalaの機能ですが、原則としてScala3では忘れてしまって結構です。

この再設計は重要ないくつかの変更に分けられます。

  • Given instances
  • Using clause
  • Given imports
  • Implicit conversions
  • Extension methods

しっかり説明するとこれだけでいくつもの記事が書けてしまうような内容なのですが、ここでは簡単に説明していきます。

Given instances

https://docs.scala-lang.org/scala3/reference/contextual/givens.html

コンテキストの値を定義する方法です。

trait Ord[T]:
  def compare(x: T, y: T): Int

given intOrd: Ord[Int] with
  def compare(x: Int, y: Int) =
    if x < y then -1 else if x > y then +1 else 0

上記のコードは「順序を持つ」ことを表す型クラスのtrait Ordと、Ord[Int]型のgivenを定義しています。

givenは名前をつけなくても作成できます。

given [T](using ord: Ord[T]): Ord[List[T]] with
  def compare(xs: List[T], ys: List[T]): Int = (xs, ys) match
    case (Nil, Nil) => 0
    case (Nil, _) => -1
    case (_, Nil) => +1
    case (x :: xs1, y :: ys1) =>
      val fst = ord.compare(x, y)
      if fst != 0 then fst else compare(xs1, ys1)

全てのOrd[T]を与えられた(後で説明するusingが使われています)T型に対して、Ord[List[T]]型のgivenを定義しています。

Using

https://docs.scala-lang.org/scala3/reference/contextual/using-clauses.html

一般的に、ある関数への入力は単純に引数として受け渡されます。これはほとんどの場合簡潔で優れている方法なのですが、DBのコネクションや設定オブジェクトなど、同じ変数がいろんな関数に順繰りに引き回されて使われるようなケースがあります。
これをプログラマが手で明示的に渡してまわるのは冗長ですので、Scalaでは「コンテキストパラメータ」としてコンパイラが暗黙的にパラメータを渡してくれる機能を提供します。

例えば上で例に出した、ある型に対するOrdgiven定義がスコープに含まれている場合、下記のようなmaxメソッドを呼ぶことができます。

def maximum[A](x: A, y: A)(using ord: Ord[A]): A =
  if ord.compare(x, y) < 0 then y else x

maximum(1, 2)
// res5: Int = 2
maximum(10 :: 20 :: Nil, Nil)
// res6: List[Int] = List(10, 20)

この時、定義されたgivenのインスタンスがusing経由で渡されています。

Given imports

https://docs.scala-lang.org/scala3/reference/contextual/given-imports.html

前述した通り、コンテキストパラメータを使うにはgiven定義がスコープに存在している必要があります。
Scalaではimport文を使って外部で定義された関数や変数をインポートするのですが、その中でもgivenは特別な扱いとなります。

trait NameOf[A]:
  def name: String

def printNameOf[A](using n: NameOf[A]): String = n.name

object G:
  given str: NameOf[String] with
    val name = "String"

上記のような定義があったとして、下記のコードはコンパイルを通りません。

import G._

printNameOf[String]
// error:
// no implicit argument of type App1.this.NameOf[String] was found for parameter n of method printNameOf in class App1
// printNameOf[String]
//                   ^

理由はgiven_*のワイルドカードインポートではインポートされないからです。
givenには専用のワイルドカードが用意されています。

import G.{ given, _ }

printNameOf[String]
// res7: String = "String"

もちろん、無名givenでなければ直接インポートしても構いません。

import G.str

givenインポートではインポートしたい型を指定することもできます。

import G.given NameOf[String]

Scala3でgiven専用のインポートが用意されていることには、2つの利点があります。

  • givenに対するインポートを明確に他と区別できる
  • 他のメソッドやクラスを除外し、全てのもしくは必要なgivenだけをインポートすることができる

これまでのScalaでimplicitとワイルドカードインポートが生み出していた「どこでどのimplicitがインポートされているのかよくわからない」という混乱した状態が改善されるはずです。

Implicit conversions

https://docs.scala-lang.org/scala3/reference/contextual/conversions.html

これまでimplicit defで定義していたimplicit conversionは、Scala3ではscala.Conversionのgivenを定義することで作成します。

abstract class Conversion[-T, +U] extends (T => U):
  def apply (x: T): U
enum Token:
  case Empty
  case Text(s: String)
  case Num(n: Int)

given Conversion[String, Token] with
  def apply(str: String): Token = Token.Text(str)

// or using given alias
given Conversion[Int, Token] = Token.Num(_)

Extension methods

https://docs.scala-lang.org/scala3/reference/contextual/extension-methods.html

すでに定義された型に対して、メソッドが追加できます。(Scala2までは、implicit classなどを使って定義していました)

case class Circle(x: Double, y: Double, radius: Double)

extension (c: Circle)
  def circumference: Double = c.radius * math.Pi * 2

Extension methodは、通常のメソッドと同様レシーバーに対して.(dot)で呼び出せます。

val circle = Circle(0, 2.0, 2.5)
// circle: Circle = Circle(0.0, 2.0, 2.5)

circle.circumference
// res9: Double = 15.707963267948966

演算子もメソッドなので、既存の型に対して追加することができます。

extension (c: Circle)
  def *(n: Double): Circle = c.copy(radius = c.radius * n)

  def move(x: Double, y: Double): Circle = c.copy(x = c.x + x, y = c.y + y)

circle * 2
// res10: Circle = Circle(0.0, 2.0, 5.0)
circle.move(2.5, -3)
// res11: Circle = Circle(2.5, -1.0, 2.5)

givenで定義した型クラスと合わせて、Extension methodsを利用することができます。

trait SemiGroup[T]:
  extension (x: T) def combine (y: T): T

trait Monoid[T] extends SemiGroup[T]:
  def unit: T

given Monoid[String] with
  extension (x: String) def combine (y: String): String = x.concat(y)
  def unit: String = ""

def combineAll[T: Monoid](xs: List[T]): T =
  xs.foldLeft(summon[Monoid[T]].unit)(_.combine(_))
"some prefix".combine("some suffix")
// res13: String = "some prefixsome suffix"

combineAll(List("A", "B", "C"))
// res14: String = "ABC"

まとめ

Scala3の新機能の中でも特に目立ったものを挙げてきました。
ここまでで挙げた以外にも、Scala3にはメタプログラミングなど多くの変更が含まれています。
気になった方は是非公式ドキュメントを眺めてみてください。

まだScala3対応が完了していないライブラリ・フレームワークも多いですし、Scala3自体もまだ安定したとは言い切れない状況ではありますが、 今後発生していくであろう移行の流れにアドウェイズとして乗り遅れることがないように、今後もウォッチしていきます!