23
11

More than 3 years have passed since last update.

Scala3に入るかもしれないContextual Abstractionsを味見してみた(更新・追記あり)

Last updated at Posted at 2019-02-24

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インスタンスがどこから来たのかを明確にするために導入されたようです
  • 拡張メソッド(Extension Methods)
    • Dottyの新機能です
    • 型が定義された後にメソッドを追加することができます
  • 型クラスの実装(Implementing Typeclasses)
    • givenインスタンス」、「using節」、「拡張メソッド」でよりシンプルに型クラスが実装可能になりました

味見の方法

ここからdotty-0.22.0-RC1.zipをダウンロードして解凍します。解凍後のフォルダのbinにパスを通せば利用できるようになります。
dotcがコンパイラです。dotrはクラス名を指定するとコンパイル済みのバイナリを実行します。単独で起動した場合にはREPLになります。

$ dotc <ソースファイル名> # ソースコードのコンパイル
$ dotr <メインクラス名> # コンパイル済みのコードを実行

もしくはサンプルコードをGitHubで公開したので、sbtをすでにインストールされている方はそちらの方が早いと思います。使い方はREADME.mdをご覧ください

hinastory/scala3_dotty_examples - GitHub

味見の結果

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)

以下の例ではプログラミングで有用な型クラスとして半群(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のみのようです
    • メタプログラミングを使えば自分で導出可能な型クラスの定義もできます
  • 暗黙の型変換(implicit conversion)
    • 元々のImplicitsのトラブルメーカーです。新たにConversionという型が定義されてその暗黙のインスタンスを定義することで利用できます
  • コンテキスト関数(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 conversionsconditional 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つです。

簡単に言うとdelegategivenに置き換えられてgiven節がgivenパラメータになってsummon大復活です。0.19.0-RC1より前のものも含まれていますが、今回合わせて修正しました。

2020年2月8日の更新内容

Dottyは0.21.0でめでたくfeature-completeしました。つまりこれ以降は大きな機能追加はないはずです。 ・・・と安心していたら0.22.0でusingキーワードが追加されました。文法の調整はまだ続くようです・・・
今回は以下の3つのリリースで行われた修正を記事本文に反映しています。

大きな変更はusingonasextensionなどのキーワードが登場してより読みやすくなったことだと思います。実際の使い方は記事本文をご覧ください。
あと、今まで拡張メソッドはこの記事では説明がなかったので追加しました。

2021年3月7日の更新内容

ようやく、Scala3のRC1(3.0.0-RC1)になったので、さすがにここに書かれている内容でこれ以上の変更はないと思います。

変更点は全体的にインデントベースのシンタックスで書き直しています。またContextual Abstractionsにも細かく変更が入ったのでそこそこ書き直しています。


  1. Pythonの教訓を活かして、なるべく言語の世代間の断絶を起こさないように配慮して開発が進められているようです。もちろん配慮が足りていない可能性もありますが・・・ 

  2. ScalafixはScalaにおける汎用的はリファクタリング、リンティングツールです。Scala3専用ではありません。 

  3. コンパイラの理論的な基盤にDependent Object Types (DOT)を用いているのが名前の由来です。 

  4. Towards Scala 3 | The Scala Programming Language 

  5. 現行のImplicitsの混乱するポイントについてはこちらの記事で詳しく取り上げられています。 

  6. 暗黙のインスタンスと推論可能パラメータが追加された経緯を知りたい方は#5458#5852をご確認ください・・・#5458の方は長すぎてまともに追っていませんが元々はwitnessというキーワードで提案されて途中でinstanceに変わって#5825でimpliedに変わったようです。本当に大激論で互換性に対する懸念が何回も強く出ています。とりあえずこの機能はSIPを通さないとScala3に入ることはないという念押しでマージされました。それ以外のContextual Abstractionsの機能(拡張メソッドや型クラスの導出等)はここまでもめた様子はなかったです。さらに#6649delegateに変更されました。そしてさらに、#6773#7210で大幅に文法チェンジ!!delegateが排除されてgiven一色になりました・・・ 本当に何回変わるんだろう・・・ツライ・・・ 

  7. このドキュメントは最新版のスナップショットなので、どんどん書き換えられています。今の所過去のバージョンは参照できないみたいです・・・ 

  8. 機能の日本語訳は自分がしました。間違っていたら教えてください。 

  9. 一部で実行しやすいように手を加えたり、コメントで説明を加えたり、例が間違っている箇所を修正したりしているのでドキュメントそのままというわけではないです。 

  10. もともとsummonという名前で提案されていましたが、0.13.0-RC-1ではinferに変わり、#5893ではtheに変更されています。しかし、#7205でまさかのsummonの復活!! 追うのも楽じゃない・・・ 

23
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
11