Magnolia Tech

いつもコードのことばかり考えている人のために。

『Scala関数型デザイン&プログラミング』の演習問題をScala3で解く その9

blog.magnolia.tech

今回はFP in Scalaの2nd edition用のリポジトリを見ていて気づいたScala3の構文の使い方について、調べたことのまとめです。

extensionについて

Scala3のextensionは、既存のクラスに後からメソッドが追加できる構文です。

Extension Methods | Scala 3 Language Reference | Scala Documentation

つまり、Scala2におけるenrich my libraryパターンの新しい書き方、というわけです。

https://scala-text..io/scala_text/implicit.html

同じimplicitというキーワードが色々な意味で使われるようになってしまったので、Scala3を契機に再整理されました。

リポジトリの例では、型パラメータを取るクラスに対して特定の型を指定したメソッドだけをコンパニオンオブジェクト内にextensionで用意しておくことで、その型の時だけメソッド呼び出しのコンパイルが成功する、という使われ方でした。

実際のコードは以下のような内容です(実際のコードはTree型に対するものでしたが)。

package example

enum List[+A]:
  case Cons(h: A, t: List[A])
  case Nil

  def foldRight[B](z: B, f: (A, B) => B): B =
    this match
      case Nil => z
      case Cons(x, xs) => f(x, xs.foldRight(z, f))

end List

object List:
  extension (l: List[Int])
    def sum: Int = l.foldRight(0, (x, y) => x + y)

end List

IntのListに対して呼び出すと、当たり前ですが、コンパイルが通って「3」という結果が表示されます。

import List.*

@main def main() =
  val list = Cons(1, Cons(2, Nil))
  println(list.sum)

List[String]に対して呼び出そうとしても型が合わないので、コンパイルエラーになります。

import List.*

@main def main() =
  val list = Cons("one", Cons("two", Nil))
  println(list.sum)
-- [E008] Not Found Error: /xxx/ListExtension.scala:25:15 ------
25 |  println(list.sum)
   |          ^^^^^^^^
   |      value sum is not a member of example.List[String].
   |      An extension method was tried, but could not be fully constructed:
   |
   |          example.List.sum(list)    failed with
   |
   |              Found:    (list : example.List[String])
   |              Required: example.List[Int]
1 error found
Error: Errors encountered during compilation

コンパニオンオブジェクトの中にまとめておけば、明示的なインポート無しに使える点が便利です。

もちろんScala2においても従来のimplicit構文を使って、下記のように書けます。

package example

sealed trait List[+A] {
  def foldRight[B](z: B, f: (A, B) => B): B =
    this match {
      case Nil => z
      case Cons(x, xs) => f(x, xs.foldRight(z, f))
    }
}

case class Cons[+A](h: A, t: List[A]) extends List[A]
case object Nil extends List[Nothing]

implicit class IntListOps(l: List[Int]) {
  def sum: Int = l.foldRight(0, (x, y) => x + y)
}

しかし、コードのまとまりとしての分かりやすさ、余計な記述量が減っている点でextensionを使った書き方の方が良いですね。あと、どうしてもIntListOpsのような、定義だけしてどこにも出てこない名称があるのが微妙でした。

また、型が違う時のエラーメッセージも不親切です。

-- [E008] Not Found Error: /xxx/ListExtension2.scala:20:15 -----
20 |  println(list.sum)
   |          ^^^^^^^^
   |          value sum is not a member of example.Cons[String]
1 error found
Error: Errors encountered during compilation

特定の型の時だけに適用されるメソッドを定義する方法としては、extensionを使ったやり方の方がスマートですね。言われてみればそうか、という使い方ですが、公式ドキュメントにはそのものずばりの書き方がされていなかったので、取り上げてみました。

メソッドをどこに配置するか、その汎用性や、限定性によって、必ずしもすべて一つのクラスの中に書かなくても済む点がScalaの良いところです。ただ、それで記述量が増えてしまうのは嬉しくないので、シンプルにまとまって書けるScala3のextensionはいいですね!

opaque typeについて

opaque typeはScala3から導入されたtype aliasの新しい仕組みです。

導入された経緯などは、公式ドキュメントを読んでください。

Opaque Type Aliases | Scala 3 Language Reference | Scala Documentation

先ほどと同じように第2版向けのリポジトリで、この機能が使われています。

第1版では、状態を示すStateの定義は、以下のようになっていました。

case class State[S,+A](run: S => (A, S))

しかし、実際には単にS => (A, S)をラップしたいだけなので、できればcase classは作りたくありません。一方で単なるtype aliasでは独自のメソッドは定義できません。

そこで、opaque typeを使います。

object State:
  extension [S, A](underlying: State[S, A])
    def run(s: S): (A, S) = underlying(s)

    def map[B](f: A => B): State[S, B] = ???

これでStateに独自のメソッドであるmapを保たせられるようになりました。余計なクラス階層も無くなり、オーバーヘッドも無くなりました。

型のエイリアスを作りたくなるのは、ラッパークラスを作ることで階層構造が複雑化したり、余計なオブジェクト生成に関するオーバーヘッドを避けたい時、あとは型定義が複雑でシンプルに見せたい時(FP in Scalaの例はまさにその例ですね)だと思いますが、最後の例が公式ドキュメントでは触れられていないので、リポジトリのコードを読むことで理解できました。