Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Scala 3.8が出たので紹介します

Scala 3.8.1がリリースされた。

scala-lang.org

このリリースのお知らせが出た時点で3.8.1が出ているのだが、なぜ3.8.0のときにリリースノートが出なかったかというと、3.8.0にはバグがあったため、それが修正されてからリリースノートが出たためだ。

www.scala-lang.org

Scala 3.8.1はScala CLIでは以下のようにして起動できる:

% scala-cli -S 3.8.1

ではさっそく主要な変更を紹介していこう。

JDKの要求バージョンが8から17に上がった

これまでのScalaはJDK 8で動作することができる、つまりJDK 8互換のバイトコードを出力できていたのだが、Scala 3.8からはJDK 17が必要になった。

これは主に、Lazy Valを実装する上での都合によるものだ。

これについては id:xuwei さんの記事が詳しい:

xuwei-k.hatenablog.com

標準ライブラリがScala 3化された

Scala 3はおおまかに2つの部品からなりたっている。すなわち、コンパイラと標準ライブラリだ。前者は昔はDottyと呼ばれていた。そして実は、標準ライブラリ自体はずっとScala 2で書かれていた。

なぜこれで問題ないかというと、けっきょく標準ライブラリもJARファイルにコンパイルされてJVMバイトコードになっているからであり、Scala 3からでも呼び出すのに(いろいろな工夫のおかげで)あまり不都合がなかったからである。

ではなぜ今回Scala 3で書くことにしたのかというと、おそらくCapture Checkingなどの新機能との兼ね合いだ。Capture CheckingはScala 3で最近導入されたexperimentalな機能であり、これが標準ライブラリでもうまく動作するには、つまるところ標準ライブラリもScala 3で書かれていなければならないのだ。 こういった事情から、Scala 3で書くモチベーションが高まったのだろう。

ライブラリをScala 3で書けるようになったことで、互換性の制約から解き放たれることになる。

REPLがコンパイラと分離された

一般ユーザには馴染みがない話かもしれないが、これまでScala 3コンパイラはREPLがいっしょにくっついていた。しかしREPLを必ず使うわけではないので、これが別々のアーティファクトとして切り離され、コンパイラ自体のアーティファクトサイズが小さくなった。

SIP-62: Better fors が安定化し、デフォルトで有効化した

SIP-62は、for式の構文、および内部表現を改善するものだ。前者はユーザにとって、後者はコンパイラエンジニアやライブラリ作者にとって助かる改善だ。

docs.scala-lang.org

ユーザ目線で見たこのSIPの変更点は、for式の先頭で代入ができるようになったことだ。

val y = for
  someValue = 42
  x <- Some(someValue).filter(_ % 2 == 0)
yield  x

これまではfor式の先頭では代入できなかったため、valを外側に置く必要があった。これの巻き添えになる形で、場合によってはブロックを形成する必要があった:

val y = {
  val someValue = 42
  for
    x <- Some(someValue).filter(_ % 2 == 0)
  yield x
}

この面倒がなくなるのはとても嬉しい。

また内部的に場合によって生じていた無駄なmapが作られなくなる改善も入っているので、パフォーマンスも有利になる。

ただしまだ細かい部分でリグレッションがあるようなので、気にする人は3.8.2を待ったほうがよいだろう。

SIP-57: @uncheckedの削除

こんな関数fがあるとする:

def f(x: Int): Option[Int] = Some(x).filter(_ % 2 == 0)

fを呼ぶとき、なんとSomeで変数を受けることができる:

val Some(xx) = f(42) // => xx = 42

しかしコンパイラからすればこれは危険なコードだ。fの返り値の型はOption[Int]だ。Noneになるかもしれないのに、Someで決め打ちしているからだ。だから、コンパイラは職責を全うするために必死に警告してくる:

val Some(xx) = f(42)
-- Warning: --------------------------------------------------------------------
1 |val Some(xx) = f(42)
  |               ^^^^^
  |pattern's type Some[Int] is more specialized than the right hand side expression's type Option[Int]
  |
  |If the narrowing is intentional, this can be communicated by adding `.runtimeChecked` after the expression,
  |which may result in a MatchError at runtime.

昔はこれを抑制する目的で: @uncheckedと書いていた:

val Some(xx) = f(42): @unchecked

しかしこれは使いにくいので、表記がメソッドチェーンスタイルに改められて再登場した:

// Scala 3.8+
val Some(xx) = f(42).runtimeChecked

これはOptionに限らず、unapplyできるものなら何でも使える:

val List(head, tail*) = List.fill(4)(1).runtimeChecked

メソッドチェーンスタイルなので、他の処理との食い合わせが良くなった、というわけ。

matchはメソッドチェーンできる

閑話休題。ところでmatchはScala 3.0.0から.matchというふうにも書けるということを、君は知っているか:

f(42).map(_ * 2).runtimeChecked.match { case Some(x)  => s"value is $x" }

ちなみに自分は全く知らなかった。非常に便利じゃないか。だって今までは

val x = (fooBar.map(...).filter(...)) match { ... }

みたいに不恰好な括弧をつけるしかなかったじゃないか。これは嬉しい。

ちなみにmatchした後も、もちろんチェーンできる:

f(42)
  .map(_ * 2)
  .runtimeChecked
  .match { case Some(x)  => s"value is $x" }
  .*(2)

ところで、超古代のScalaではmatchは実際にメソッドだったんだよね、という話をid:susisuがしてくれた。

https://stuff.mit.edu/afs/sipb/project/scala/old/share/doc/scala-1.4.0.3/ScalaReference.pdf

どうやら、matchが中置なのもその名残りらしい。

プレビュー機能

プレビューは、experimentalを脱して安定した機能が次に入る場所だ。-previewをコンパイラオプションにつけることで有効化される。

(翻訳)

プレビュー版の言語機能やAPIは、次のマイナーリリース版のScalaで正式に標準化されることが保証されていますが、コンパイラチームはコミュニティからのフィードバックに基づいて、場合によってはバイナリ互換性に影響を与えない範囲で小規模な変更を加えることができます。 https://nightly.scala-lang.org/docs/reference/other-new-features/preview-defs.html

SIP-71: intoによる完全暗黙変換の導入

docs.scala-lang.org

昔話をしよう。Scala 2にはimplicitを使った暗黙変換があった。

implicit def intToString(i: Int): String = s"$i"

これは便利なのだが、特定のスコープに入ってさえすれば自動的に発動するので困ったヤツだった(ため、Scala 2のうちから色々制約をかけられて今に至る)。あまりにも何でもできてしまうのでオダースキー神の怒りに触れ、Scala 3ではgivenusingとに整理された。

それはそうと暗黙の変換が欲しいことはあるもので、Scala 3.7までは、scala.Conversiongivenで提供することで暗黙の変換を利用できるようになっていた:

import scala.language.implicitConversions

object IntUtil {
  given Conversion[Int, String] with // ライブラリ作者はConversionを定義する
    def apply(i: Int): String = s"$i"
}

// ...

import IntUtil.given // ユーザがgivenをimportする

val str: String = 42

しかしこれにも問題があった。暗黙の変換を利用したいときは、利用したい場所でscala.language.implicitConversionsimportする必要があった。 これが問題になるのはライブラリを作るときだ。ライブラリ作者はおもてなしのために柔軟に型を受け取りたいが、そのためにはユーザがscala.language.implicitConversionsimportしなければならないのだ。 特に、標準ライブラリではこの手間は許容できない。

そこでScala 3.8で導入されるintoでは、以下のような挙動を提供するようになった:

  • 基本形: scala.Conversion.into[T]という形で修飾した型を引数に要求したとき、Tに変換するようなConversiongivenがスコープから検索され、それが利用されるようになった
    • その型への暗黙的な変換が許可されていることを示すマーカーとして機能する
    • Conversionは基本的にコンパニオンオブジェクトに置く慣例がある
  • よりパワフル: 型定義にintoというソフト修飾子を導入し、その型に向けて変換できることを宣言できるようにした

それぞれ見ていこう。

into[T]

加算と減算ができるASTを考えてみよう。enumを使えば簡単だ:

enum Expr:
  case Add(x: Expr, y: Expr)
  case Sub(x: Expr, y: Expr)
  case Const(n: Int)

加算と減算を表すコンストラクタのほかに、定数項を表現するためのコンストラクタも用意した。

ところがこれだとConstを毎回書くのが大変だ:

import Expr.*
Add(Add(Const(5), Const(3)), Sub(Const(1), Const(2)))

簡単のために、Intは勝手にConstに変換してほしい。そこで、Conversionintoを使ってみよう:

//> using scala 3.8.1
//> using scalacOption -preview

import scala.Conversion.into

enum Expr:
  case Add(x: into[Expr], y: into[Expr])
  case Sub(x: into[Expr], y: into[Expr])
  case Const(n: Int)

object Expr:
  given Conversion[Int, Const] = Const.apply

@main
def main(): Unit =
  import Expr.*
  println(Add(Add(5, 3), Sub(1, 2)))

これをexpr.scalaとして保存して実行すると、以下のように出力される:

% scala-cli expr.scala
Add(Add(Const(5),Const(3)),Sub(Const(1),Const(2)))

into[Expr]型を見付けたコンパイラが自動的にConversion[Int, Const]を検出し、内部的に自動的に変換を行ったことがわかる。

into[T]は、変換したい場所を型レベルでマークすることで、Scala 2のimplicitみたいに野放図にならないように制約をかけつつ、柔軟な暗黙的変換の機能を提供してくれることがわかった。

into soft modifier

前掲のinto[T]呼び出す場所で「変換して〜」と宣言していたのに対して、もう一つのintoソフト修飾子は型を定義する場所で「変換できるよ〜」と宣言する役割を持つ。

例えば、IDを表現するID型があるとする:

opaque type ID = String

object ID:
  def apply(s: String): ID = s

ふつうID型の値を組み立てるには、applyを呼び出す必要がある:

val id = ID("windymelt")

ここで、opaque type IDintoソフト修飾子を付けて、コンパニオンオブジェクトにConversionを定義してみよう:

//> using scala 3.8.1
//> using scalacOption -preview

into opaque type ID = String

object ID:
  def apply(s: String): ID = s
  given Conversion[String, ID] = apply

@main
def main(): Unit =
  val id: ID = "windymelt"
  println(id)

すると、ID型を受け取るべき場所にString型である"windymelt"を渡しても、自動的に型が変換されてID型になる。

これは、into opaque type IDとして定義したことで、「もしConversionがあったら、ID型に自動的に変換しても良いですよ」と宣言したことになり、コンパイラが自動的にConversion[String, ID]をあてはめたのだ。

ただしこの用法は非常にスコープが広くなりうる(IDを受け取る場所すべてで起動する)ため、可能な限り使わず、使うとしても注意が必要だ。

実験的機能

Scala 3.8ではいくつかの実験的(experimental)機能が追加された。実験的機能はプロダクションで利用することは推奨されておらず、機能も安定していない。

SIP-67: 厳密な等価性に基づくパターンマッチング

Multiversal Equality

もともとScalaは、あらゆる値を比較できていた:

// Scala 2

42 == "foobar2000" // => false

これをUniversal Equalityと呼ぶ。

しかし、そもそも型が違うことが分かっているのに比較できるのもおかしいし、そういう状況になっているのは大抵なにかがおかしくなっている時だ。そこで、Scala 3では型安全性のためにこの挙動を制限できるようになった:

// Scala 3

42 == "foobar2000"
-- [E172] Type Error: ----------------------------------------------------------
1 |42 == "foobar2000"
  |^^^^^^^^^^^^^^^^^^
  |Values of types Int and String cannot be compared with == or !=
1 error found

この仕組みは、内部的にはCanEqual型クラスを用意することで「この型同士は比較可能である」ことをコンパイラに教え、CanEqualがあるのにマッチしない場合はコンパイルに失敗する、という仕組みになっている:

42 == "foobar2000" // 失敗する

標準ライブラリは基本的な型のCanEqualを既に用意してくれているので、明らかにおかしい比較は自動的に拒否するという、ちょうどよい塩梅の挙動になっている。

また後方互換性のために、必要に応じてCanEqual.derivedを呼び付けることでCanEqual[Any, Any]がもらえることになっている:

given CanEqual[Int, String] = CanEqual.derived

42 == "foobar2000" // false

CanEqual[Any, Any]は「全ての型は全ての型と比較できる」と言っているのと同じなので、Scala 2のUniversal Equalityを部分的に適用するのと同じだ。

この仕組みは、互換性を保ちつつも安全な比較を提供する。Scala 3では、この仕組みをMultiversal Equalityと呼ぶ。

strictEquality

しかしながら、この手法は既にCanEqualが定義されている型同士でしか使えない。先程、CanEqualがあるのにマッチしない場合はコンパイルに失敗すると言ったのを覚えているだろうか。IntにもStringにも標準ライブラリが既にCanEqualを提供してくれているのでこの制約が成り立つのだが、例えば手で定義したcase classにはそもそもCanEqualが定義されていないので、Multiversal Equalityはこれを見逃す:

case class Foo()
case class Bar()

Foo == Bar() // => false

「ちょうどよい塩梅に型を厳しくする」という目的からするとこの挙動は適切なのだが、もっと厳しく、つまりCanEqualが明示的に定義されているならば、そのときに限り、比較できるようにしたい、ということもあるかもしれない。

そこで、strictEqualityが登場する。-language:strictEqualityを設定すると、等価性の比較は非常に厳格になり、明示的にCanEqualを用意しない限り、同一の型ですら比較できなくなる:

λ scala-cli -O -language:strictEquality

scala> case class Foo()
// defined case class Foo

scala> Foo() == Foo()
-- [E172] Type Error: ----------------------------------------------------------
1 |Foo() == Foo()
  |^^^^^^^^^^^^^^
  |Values of types Foo and Foo cannot be compared with == or !=
1 error found

scala> "foo" == "foo"
val res0: Boolean = true // 標準ライブラリで既にCanEqualが定義されているのでこれは問題ない

scala> case class Bar() derives CanEqual // derivesを利用するとCanEqualを同時に定義できる
// defined case class Bar

scala> Bar() == Bar()
val res1: Boolean = true

scala> case class Buzz()
// defined case class Buzz

// CanEqual.derivedを利用することもできる
scala> given CanEqual[Buzz, Buzz] = CanEqual.derived
lazy val given_CanEqual_Buzz_Buzz: CanEqual[Buzz, Buzz]

scala> Buzz() == Buzz()
val res2: Boolean = true

strictEqualityPatternMatching

ようやくここまで来た。experimentalで入ったのはここだ。

先程までの挙動だと困ってしまうことがある。次のようなコードがあるとする:

enum L:
  case N
  case C(head: Int, tail: L)

import L.*

val lis: L = C(1, C(2, C(3, N)))

def printL(l: L): Unit = l match
  case N => print("[NIL]")
  case C(h, t) =>
    print(h)
    print(" :: ")
    printL(t)

printL(lis)

L同士にCanEqualが定義されていない上、Nに対するCanEqualは自動導出できないので、これはコンパイルできない:

-- [E172] Type Error: ----------------------------------------------------------
10 |  case N => print("[NIL]")
   |       ^
   |       Values of types L and L cannot be compared with == or !=

わざわざenum L derives CanEqualとして定義しなければならないのだが、enumをパターンマッチするのはあまりにも頻出操作なので、要するにenumに対するパターンマッチであればそのcase objectに対するCanEqualの強制はやめる、という挙動にするのがstrictEqualityPatternMatchingだ。

この挙動はimport scala.language.experimental.strictEqualityPatternMatchingすることで有効化できる。

SIP-70: 柔軟なvarargs

varargsを受けるような関数で、複数回spreadingできるようになった。

import scala.language.experimental.multiSpreads

def sum(xs: Int*): Int = xs.sum

val a: Seq[Int] = Seq(1, 2)
val b: Array[Int] = Array(3, 4)
      
val total = sum(0, a*, b*, 5)   // 15

SIP-75: :記号の後で単一行ラムダ式を書けるようにした

Scala 3はインデント記法を利用できる。つまりこれを

def f(x: Int) = {
  val y = x + 1
  y * 2
}

こういうふうに書くことができる:

def f(x: Int) =
  val y = x + 1
  y * 2

さらに、fewerBraces(SIP-44)により、メソッド引数が特定のパターンになるときはコロンを使えるようになっている:

val xs = List(1,2,3,4,5)

xs.map: x =>
  x * 2 // 改行が必要

blog.3qe.us

しかし、bodyに1行しか必要ない場合でも必ず改行してからbodyを始める必要があった。

SIP-75はこの制限を緩め、単一行でもbodyを書けるようにする:

import scala.language.experimental.relaxedLambdaSyntax

xs.map: x => x * 2

するとチェーンするときに見た目が良くなる:

xs
  .map: x => x * 2
  .filter: x => x > 5
  .flatMap: x => List(x, x + 1)

サブマッチ

match式が強化され、match中にさらにmatchできるようになった:

//> using option -experimental

import scala.language.experimental.subCases

enum Email:
  case ValidEmail(email: String)
  case Invalid

case class User(name: String, email: Email)

def validate(u: User) = u match {
  case User(name, email) if email match {
    case Email.ValidEmail(e) if e match {
      case s if s.startsWith("admin@") => s"Valid admin: $e"
    }
  }
  case otherwise => s"Invalid admin"
}

val x = User("admin", Email.ValidEmail("admin@example.com"))
val y = User("admin", Email.ValidEmail("fake@example.com"))

println(validate(x))
println(validate(y))

match clause中のifで、さらにmatchと書くことで、サブマッチを開始できる。

サブマッチは以下の特徴を持つ:

  • サブマッチに失敗した場合はエラーにせず、上位のマッチにバックトラックする
    • このため、サブマッチは網羅しなくてもよい
  • サブマッチは外側のマッチの変数を参照できる
  • サブマッチはネストできる

個人的には、サブマッチはけっこう便利な機能だと思う。

まとめ

Scala 3.8の主な変更点を解説した。より詳細な解説は公式ドキュメントなどを参考にしてほしい。

scala-lang.org

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?