Scala3のリサーチコンパイラであるDottyにImplicitsに代わる「Contextual Abstractions」と呼ばれる一連の機能が実装されていたので一部を味見してみました。
(本記事は自分のブログからの転載記事になります。)
(記事の本文は2021年3月7日時点のScala3(Dotty)最新版と整合するように更新されています。更新の詳細はここを見てください。)
TL;DR
- この記事はDottyに実装されたImplicitsに代わる「Contextual Abstractions」と呼ばれる一連の機能を味見してみたものです
利用したDottyのバージョンは2019年2月時点で最新の0.13.0-RC-1です。Dottyの開発は非常に活発なので異なるバージョンでは本記事の内容とは異なる場合があります2019年6月時点で最新の0.16.0-RC3で変更があった文法の更新を反映しました。Dottyの開発は非常に活発なので異なるバージョンでは本記事の内容とは異なる場合があります2019年9月時点で最新の0.18.1-RC1に更新しました。Dottyの開発は非常に活発なので異なるバージョンでは本記事の内容とは異なる場合があります2019年9月時点で最新の0.19.0-RC1に更新しました。Dottyの開発は非常に活発なので異なるバージョンでは本記事の内容とは異なる場合があります2020年2月時点で最新の0.22.0-RC1に更新しました。Dottyの開発は非常に活発なので異なるバージョンでは本記事の内容とは異なる場合があります- 2021年3月時点で最新の3.0.0-RC1に更新しました。本記載内容についてはここから大きな変更はさすがにないはずです
- 「Contextual Abstractions」は従来のImplicitsで初学者が躓きそうな機能を整理して使いやすくしています
- 「Contextual Abstractions」には従来のImplicitsでは実現できなかった機能(givenインポート、型クラス導出、コンテキスト関数等)も含まれています
- 「Contextual Abstractions」の機能はまだ提案段階でありScala3の正式な仕様に決定したわけではありません
- 今後機能が変化したり、機能が採用されなかったりする可能性も十分あります
- 「Contextual Abstractions」がScala3に正式採用された場合、古いImplicitsは段階的に廃止される予定です
- 「Contextual Abstractions」への移行はScalafixでサポートされる予定です
- この記事で紹介したサンプルコードは以下のリポジトリにあります。
Scala3とは
2021年前半に出ることが予定されているScalaの次世代版です。コンパイラの高速化と大幅な機能強化が行われる予定です。基本的には現行のScala(Scala2)とのソースコードレベルの後方互換性を意識して機能強化が行われていますが1、非互換が生まれるところや推奨の書き方が変わる所はScalfix2で対応することが予定されています。
Dottyとは
Dotty3はScala3の研究用コンパイラで、Scala3の仕様や実装を研究するためのものです。DottyがそのままScala3になることがアナウンスされています4。
Contextual Abstractionsとは
現行のScalaには俗にImplicitsと呼ばれる機能がありますが、初学者を非常に混乱させる機能として悪名高いものでした5。そこでDottyには、この混乱に決着を着けるべくImplicitsの機能を包含しつつより整理された「Contextual Abstractions」と呼ばれる一連の機能が実装されました6。Implicitsの代替という面でみるとこれらの機能は「implicit」というキーワードをなるべく使わずに別の用語(given
等)で置き換えて、型クラスをより書きやすいようにチューニングしたような内容になっている印象です。本記事ではDottyドキュメント7を参考にしながら、「Contextual Abstractions」の機能の一部を味見してみました。以下が味見した機能の一覧です8。
-
given
インスタンス(Given Instances)- 従来の
implicit
で定義されていたインスタンスと同等です
- 従来の
-
using
節(Using Clauses)- 従来の
implicit
で定義されていたパラメータリストと同等です
- 従来の
-
given
インポート(Given Imports)- 通常のimportでは
given
で定義されたgiven
インスタンスはインポートされず、別途import A.given
でインポートする必要があります -
import A.{given, _}
でパッケージAのgiven
インスタンスも含めた全てをインポートできます -
given
インスタンスがどこから来たのかを明確にするために導入されたようです
- 通常のimportでは
- 拡張メソッド(Extension Methods)
- Dottyの新機能です
- 型が定義された後にメソッドを追加することができます
- 型クラスの実装(Implementing Typeclasses)
- 「
given
インスタンス」、「using
節」、「拡張メソッド」でよりシンプルに型クラスが実装可能になりました
- 「
味見の方法
ここからdotty-0.22.0-RC1.zip
をダウンロードして解凍します。解凍後のフォルダのbin
にパスを通せば利用できるようになります。
dotc
がコンパイラです。dotr
はクラス名を指定するとコンパイル済みのバイナリを実行します。単独で起動した場合にはREPLになります。
$ dotc <ソースファイル名> # ソースコードのコンパイル
$ dotr <メインクラス名> # コンパイル済みのコードを実行
もしくはサンプルコードをGitHubで公開したので、sbt
をすでにインストールされている方はそちらの方が早いと思います。使い方はREADME.md
をご覧ください
味見の結果
Scala3ドキュメントに記載されている例をベースに味見をしてみました9。
基本的な例(拡張メソッド)
拡張メソッドは既存の型にメソッドを注入できる機能です。ポイントは「継承」を用いずにアドホックにメソッド拡張を行える点です。
拡張の仕方は3通りあって、直接、型にメソッドを拡張する方法と、トレイトを用いて拡張メソッドを定義したあとに、given
インスタンスで注入する方法とextension
キーワードを使って拡張メソッドの定義と注入を一緒に済ませてしまう方法です。
/** 拡張メソッドのサンプル */
object ExtensionMethodExampleDefs:
// Circle型にcircumferenceメソッドを拡張する
case class Circle(x: Double, y: Double, radius: Double)
extension (c: Circle)
def circumference: Double = c.radius * math.Pi * 2
// 演算子の拡張メソッド
extension (x: String)
def << (y: String) = s"???? $x $y ????"
// 中置演算子の拡張メソッド
extension (x: Int)
infix def min(y: Int) = if x < y then x else y
// ジェネリクスを用いた`extension`も可能
import math.Numeric.Implicits.infixNumericOps
extension [T](xs: List[T])
def second = xs.tail.head
def third: T = xs.tail.tail.head
def sumBy[U: Numeric](f: T => U) = xs.foldLeft(implicitly[Numeric[U]].zero)((acc, x) => acc + f(x))
以下のように利用します。
object ExtensionMethodExample:
import ExtensionMethodExampleDefs.{_, given}
def use(): Unit =
println("\n--- start ExtensionMethodExample ---")
val circle = Circle(0, 0, 1)
println( circle.circumference ) // 6.283185307179586
println( "abc" << "def" ) // ???? abc def ????
println( List(1, 2, 3, 4, 5).third ) // 3
println( List(1, 2, 3, 4, 5).sumBy(identity) ) // 15
少し高度な例(given
インスタンス、using
節)
前述のとおりgiven
インスタンスは型を拡張するときに用いることができますが、その応用として型パラメータを持つ型を「共通の型」として他の型を拡張できます。この機能は「型クラス」とも呼ばれますが詳細は次節で説明します。
using
節ではスコープに存在するgiven
インスタンスを受け取ることができます。仮にusing
節で指定された変数の型にマッチするgiven
インスタンスがスコープに存在すれば、呼び出し時にusing
節を省略することができます。
以下の例ではInt
型とList
型をOrd
型で拡張しています。また、using
節を用いてmax
関数やmaximam
関数等を定義しています。
object GivenExampleDefs:
/** 順序型の定義 */
trait Ord[T]:
def compare(x: T, y: T): Int
extension (x: T) def < (y: T) = compare(x, y) < 0 // 拡張メソッド記法を使って定義してあります
extension (x: T) def > (y: T) = compare(x, y) > 0 // 上記と同様
/** 順序型のIntの`given`インスタンスの定義 */
given intOrd: Ord[Int] with
def compare(x: Int, y: Int) =
if (x < y) -1 else if (x > y) +1 else 0
/** 順序型のListの`given`インスタンスの定義 */
given listOrd[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) // 先頭の大きさがLists全体の大きさ
if (fst != 0) fst else compare(xs1, ys1) // 同じだったら次の要素を再帰的に繰り返す
/** `using`節 */
def max[T](x: T, y: T)(using ord: Ord[T]): T =
if (ord.compare(x, y) < 1) y else x
/** 無名`using`節 */
def maximum[T](xs: List[T])(using Ord[T]): T = xs.reduceLeft(max)
/** コンテキスト境界使った書き換え(Scala2と同様) */
def maximum2[T: Ord](xs: List[T]): T = xs.reduceLeft(max)
/** `using`節を使って新しい逆順序型クラスインスタンスを作る関数 */
def descending[T](using asc: Ord[T]): Ord[T] = new Ord[T]:
def compare(x: T, y: T) = asc.compare(y, x)
/** より複雑な推論 */
def minimum[T](xs: List[T])(using Ord[T]) = maximum(xs)(using descending)
given
インスタンスの利用例が以下になります。
object GivenExample:
import GivenExampleDefs.{_, given} // givenは `Ord`の`<`演算子を利用するのに必要
def use(): Unit =
println("\n--- start GivenExample ---")
println( max(2,3) ) // 3
println( max(List(1, 2, 3), Nil) ) // List(1, 2, 3)
println(List(1, 2, 3) < List(1, 2, 3, 4)) // true
println(List(9, 2, 3) < List(1, 2, 3, 4)) // false
val numList = List(1,10,2)
println( maximum(numList) ) // 10
println( maximum2(numList) ) // 10
println( minimum(numList) ) // 1
高度な例(型クラス)
型クラスは単一の型を拡張するわけではなく「共通の型」を用意して「共通の型」を後から既存の型にアドホックに注入して拡張できる点が異なります。すでにコードで紹介した例を用いて型クラスとそうでない拡張を区別すると以下のようになります。
- 単一の型を拡張(型クラスではない)
-
String
型を拡張してlongestStrings
メソッドを追加
-
- 共通の型で複数の型を拡張(型クラス)
- Ord型クラスを定義
- 実態は型パラメータを持つトレイトで複数の型で共通で使えるメソッド
compare
と演算子<
、>
を持っている
- 実態は型パラメータを持つトレイトで複数の型で共通で使えるメソッド
-
given
インスタンスを用いてInt
型をOrd
型クラスで拡張(intOrd
) -
given
インスタンスを用いてList
型をOrd
型クラスで拡張(listOrd
)
- Ord型クラスを定義
以下の例ではプログラミングで有用な型クラスとして半群(Semigroup
)、モノイド(Monoid
)、関手(Functor
)、モナド(Monad
)を定義しています。また、型クラスのgiven
インスタンスも定義しています。
object TypeClassExampleDefs:
/** 半群の型クラス */
trait SemiGroup[T]:
extension (x: T) def combine (y: T): T
/** モノイドの型クラス */
trait Monoid[T] extends SemiGroup[T]:
def unit: T
/** applyでモノイドを召喚 */
object Monoid with
def apply[T](using m: Monoid[T]) = m
/** `String`モノイド */
given Monoid[String] with
extension (x: String) def combine (y: String): String = x.concat(y)
def unit: String = ""
/** `Int`モノイド */
given Monoid[Int] with
extension (x: Int) def combine (y: Int): Int = x + y
def unit: Int = 0
/** モノイドの和を求める */
def sum[T: Monoid](xs: List[T]): T =
xs.foldLeft(Monoid[T].unit)(_ combine _)
/** 関手の型クラス */
trait Functor[F[_]]:
extension [A](x: F[A])
def map[B](f: A => B): F[B]
/** モナドの型クラス */
trait Monad[F[_]] extends Functor[F]:
extension [A](x: F[A])
def flatMap[B](f: A => F[B]): F[B]
def map[B](f: A => B) = x.flatMap(f `andThen` pure)
def pure[A](x: A): F[A]
/** リストモナドのインスタンスを定義 */
given listMonad: Monad[List] with
extension [A](xs: List[A])
def flatMap[B](f: A => List[B]): List[B] = xs.flatMap(f)
def pure[A](x: A): List[A] = List(x)
/** リーダモナドのインスタンスを定義 */
given readerMonad[Ctx]: Monad[[X] =>> Ctx => X] with
extension [A](x: Ctx => A)
def flatMap[B](f: A => Ctx => B): Ctx => B = ctx => f(x(ctx))(ctx)
def pure[A](x: A): Ctx => A = ctx => x
/** 関手の利用 */
def transform[F[_], A, B](src: F[A], func: A => B)(using Functor[F]): F[B] = src.map(func)
/** コンテキスト境界を使った書き換え */
def transform2[F[_]: Functor, A, B](src: F[A], func: A => B): F[B] = src.map(func)
型クラスは以下のように利用できます。
object TypeClassExample:
import TypeClassExampleDefs.{given, _}
def use(): Unit =
println("\n--- start TypeClassExample ---")
println( sum(List("abc", "def", "gh")) ) // abcdefgh
println( sum(List(1, 2, 3)) ) // 6
println( summon[Monad[List]].pure(12) ) // List(12)
println( transform(List(1, 2, 3), (_:Int) * 2) ) // List(2, 4, 6)
// Reader Monad Example
val calc: Int => Int = for {
x <- (e:Int) => e + 1
y <- (e:Int) => e * 10
} yield x + y
println( calc(3) ) // 34
Contextual Abstractionsのその他の機能
Contextual Abstractionsの機能で味見できなかった機能を簡単に紹介します。
- マルチバーサル等価性(Multiversal Equality)
- Scala2では文字列と数値が比較可能でしたが、この機能により厳密に型が合っていないとコンパイルエラーにすることもできるようになりました
- 型クラスの導出(Typeclass Derivation)
- Haskellの
deriving
と同等の機能です。現在のDottyで導出可能な型クラスはマルチバーサル等価性を表すEql
のみのようです - メタプログラミングを使えば自分で導出可能な型クラスの定義もできます
- Haskellの
- 暗黙の型変換(implicit conversion)
- 元々のImplicitsのトラブルメーカーです。新たに
Conversion
という型が定義されてその暗黙のインスタンスを定義することで利用できます
- 元々のImplicitsのトラブルメーカーです。新たに
- コンテキスト関数(Context Functions)
- 以前はImplicit Function Typeと呼ばれていた機能で0.13.0-RC-1で構文が変更されました
- ビルダーパターンを簡単に実装できます
味見してみた感想
Implicitsが大分飼いならされたような印象でした。特に従来はimplicitをパラメータリストで受け取っていたのをgiven
という専用構文で受け取るようになったのが非常に分かりやすかったです。ただ、従来のimplicitly
の名称はまだかなり揺れているみたいです10。
もともとは「A Snippet of Dotty」を読んで、あまりにも自分が知っているScalaと違っていたので調べ始めたのがこの記事を書こうと思ったきっかけです。この記事がScala3がどういう方向を目指しているのか知りたい人の参考になれば幸いです。
おまけ
Zennに関連記事を書きました。
2019年3月10日の更新内容
本家のブログが公開されたようです。0.13.0-RC-1
のタグが打たれてから10日以上経ってからの公開なのでかなり遅い方だと思いますが、それだけ今回のリリースが盛りだくさんだったと言うことだと思います。本家のブログには従来のimplicit
がなぜダメだったのか丁寧に説明されていました。
The implicit keyword is used for both implicit conversions and conditional implicit values and we identified that their semantic differences must be communicated more clearly syntactically. Furthermore, the implicit keyword is ascribed too many overloaded meanings in the language (implicit vals, defs, objects, parameters). For instance, a newcomer can easily confuse the two examples above, although they demonstrate completely different things, a typeclass instance is an implicit object or val if unconditional and an implicit def with implicit parameters if conditional; arguably all of them are surprisingly similar (syntactically). Another consideration is that the implicit keyword annotates a whole parameter section instead of a single parameter, and passing an argument to an implicit parameter looks like a regular application. This is problematic because it can create confusion regarding what parameter gets passed in a call. Last but not least, sometimes implicit parameters are merely propagated in nested function calls and not used at all, so giving names to implicit parameters is often redundant and only adds noise to a function signature.
Dotty Blogより
意訳すると従来のimplicit
にはimplicit conversions
とconditional implicit values
の2つの用途があったけど、意味が違うし初学者は混同しやすいので構文的に別にするという話です。というかconditional implicit values
という言い方は自分は初めて目にしました。単純なimplicit values
よりもわかりやすいですね。
この本家のブログを受けてというわけではないですが、前回の記事でサンプルが大分雑だったのでいろいろと見直して、サンプルコードもGitHubに公開しました。興味のある方は味見をして頂けると幸いです。
追記・更新内容
2019年6月22日の更新内容
先日発表されたDotty 0.16.0-RC3で本記事に関する大きな文法変更が行われました。具体的には以下の通りです。
-
implied
からdelegate
にキーワードを変更 (#6649) - 型ラムダに=>>を使用 (#6558)
- サンプルコードで使用していた
-
given
節を最後にする (#6513)- 0.15.0-RC1で変更
上記の変更に伴い本文の該当箇所を修正しました。また、GitHubに公開したサンプルコードも最新にしてあります。あと何回キーワードが変更されるんだろう・・・
2019年9月15日の更新内容
先日発表されたDotty 0.18.1-RC1でリーダーモナドの例がコンパイルできるようになっていました。また、0.18.1-RC1で追加されたインデントベースの構文についても記事を書いたので興味があればご一読ください。
2019年9月28日の更新内容
先日発表されたDotty 0.19.0-RC1で本記事に関する文法が大きく変更されました。関連するプルリクは主に以下の4つです。
- Trial: given as instead of delegate for by odersky · Pull Request #6773 · lampepfl/dotty
- Change to new given syntax by odersky · Pull Request #7210 · lampepfl/dotty
- Drop old syntax styles for givens by odersky · Pull Request #7245 · lampepfl/dotty
- Replace the[...] by summon[...] by odersky · Pull Request #7205 · lampepfl/dotty
簡単に言うとdelegate
がgiven
に置き換えられてgiven
節がgiven
パラメータになってsummon
大復活です。0.19.0-RC1より前のものも含まれていますが、今回合わせて修正しました。
2020年2月8日の更新内容
Dottyは0.21.0でめでたくfeature-complete
しました。つまりこれ以降は大きな機能追加はないはずです。 ・・・と安心していたら0.22.0でusing
キーワードが追加されました。文法の調整はまだ続くようです・・・
今回は以下の3つのリリースで行われた修正を記事本文に反映しています。
- Announcing Dotty 0.20.0-RC1 –
with
starting indentation blocks, inline given specializations and more - Announcing Dotty 0.21.0-RC1 - explicit nulls, new syntax for
match
and conditional givens, and more - Announcing Dotty 0.22.0-RC1 - syntactic enhancements, type-level arithmetic and more
大きな変更はusing
、on
、as
、extension
などのキーワードが登場してより読みやすくなったことだと思います。実際の使い方は記事本文をご覧ください。
あと、今まで拡張メソッドはこの記事では説明がなかったので追加しました。
2021年3月7日の更新内容
ようやく、Scala3のRC1(3.0.0-RC1)になったので、さすがにここに書かれている内容でこれ以上の変更はないと思います。
変更点は全体的にインデントベースのシンタックスで書き直しています。またContextual Abstractionsにも細かく変更が入ったのでそこそこ書き直しています。
-
Pythonの教訓を活かして、なるべく言語の世代間の断絶を起こさないように配慮して開発が進められているようです。もちろん配慮が足りていない可能性もありますが・・・ ↩
-
ScalafixはScalaにおける汎用的はリファクタリング、リンティングツールです。Scala3専用ではありません。 ↩
-
コンパイラの理論的な基盤にDependent Object Types (DOT)を用いているのが名前の由来です。 ↩
-
暗黙のインスタンスと推論可能パラメータが追加された経緯を知りたい方は#5458と #5852をご確認ください・・・#5458の方は長すぎてまともに追っていませんが元々は
witness
というキーワードで提案されて途中でinstance
に変わって#5825でimplied
に変わったようです。本当に大激論で互換性に対する懸念が何回も強く出ています。とりあえずこの機能はSIPを通さないとScala3に入ることはないという念押しでマージされました。それ以外のContextual Abstractionsの機能(拡張メソッドや型クラスの導出等)はここまでもめた様子はなかったです。さらに#6649でdelegate
に変更されました。そしてさらに、#6773と#7210で大幅に文法チェンジ!!delegate
が排除されてgiven
一色になりました・・・ 本当に何回変わるんだろう・・・ツライ・・・ ↩ -
このドキュメントは最新版のスナップショットなので、どんどん書き換えられています。今の所過去のバージョンは参照できないみたいです・・・ ↩
-
機能の日本語訳は自分がしました。間違っていたら教えてください。 ↩
-
一部で実行しやすいように手を加えたり、コメントで説明を加えたり、例が間違っている箇所を修正したりしているのでドキュメントそのままというわけではないです。 ↩
-
もともと
summon
という名前で提案されていましたが、0.13.0-RC-1
ではinfer
に変わり、#5893ではthe
に変更されています。しかし、#7205でまさかのsummon
の復活!! 追うのも楽じゃない・・・ ↩