こんにちは、おかむです。
開発開始から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
一定条件の元、if
や for
の括弧を省略できるようになりました。
if x < 0 then { "negative" } else if x == 0 then { "zero" } else { "positive" }
if
の条件式は、then
が後に続く場合丸括弧を省略することができるwhile
ループの条件式は、do
が後に続く場合丸括弧を省略できるfor
式の各エントリは、yield
かdo
が後に続く場合は、囲む波括弧を省略することができる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]
例として上に書いたのはA
とB
を受け取って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
, Blue
はColor
のコンパニオンオブジェクトのメンバーとなります。
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ではimplicit
sを「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では「コンテキストパラメータ」としてコンパイラが暗黙的にパラメータを渡してくれる機能を提供します。
例えば上で例に出した、ある型に対するOrd
given定義がスコープに含まれている場合、下記のような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自体もまだ安定したとは言い切れない状況ではありますが、 今後発生していくであろう移行の流れにアドウェイズとして乗り遅れることがないように、今後もウォッチしていきます!