🥰

Scala 3 と比較して理解する Scala の implicit

2022/12/22に公開約7,400字

この記事は Scala Advent Calandar 2022 の記事です.

Scala 2.x の implicit はさまざまな使い道があるため少々ユーザーの混乱を招いていました. Scala 3 では implicit が機能・目的に応じて整理されました. 一見複雑でわかりにくい Scala の implicit を Scala 3 の機能と比較しながら理解してみましょう.

implicit 引数: グローバルなデータや Context のもちまわり

もっともわかりやすい(当社比) implicit の使い方は暗黙の引数です. 暗黙の引数はスコープにその引数の型の値が存在すればそれを自動で引数に取ってくれる機能です.

implicit 引数は設定、コネクションプールやスレッドプールのように、アプリケーション内部に何個も存在しない、さまざまなところで共有されうる値を楽に受け渡すために使えます. フロントエンドやモバイルのエンジニアにとってわかりやすい例でいうと React の props や Flutter の BuildContext でしょうか.

Scala の中で最も身近な暗黙の引数の利用例として ExecutionContext が挙げられます. Scala では、Future を使うときに implicit ec: ExecutionContext が必要です. ExecutionContext は非同期処理を効率的にスケジューリングするためのスレッドプールのようなものです.
開発者が積極的に ExecutionContext のインスタンスを触ることは少ないですが、Future を使うメソッドは非同期処理のスケジューリングを裏側で ec に移譲しているのでこの引数が常に必要になります.

def myAsyncFunction(arg:Int)(implicit ec: ExecutionContext):Future[Int] = ???

このようにメソッドのシグニチャに implicit ec: ExecutionContext が現れますが、この ec は便宜上つけた名前であまり意味を持ちません.

Scala 3 ではこの implicitusing に書き換えられます. また、using 節では変数の名前を省略できます.

def myAsyncFunction(arg:Int)(using ExecutionContext): Future[Int]= ???

さらに次のように書くこともできます. これは Contextual Function と呼ばれていて、 その名の通り文脈(下の例では ExecutionContext)を持った関数になります.

def myAsyncFunction(arg:Int): ExecutionContext ?=> Future[Int] = ???

import some.namespace.implicit(s).some.modules

Future やそのほかのライブラリを使っていると次のような import を目にすることがあると思います.

import cats.implicits._
import cats.effect.unsafe.implicit.global
import scala.concurrent.ExecutionContext.Implicits.global

これは、デフォルトのクラスや(この後説明する)型クラスのインスタンス、暗黙の型変換などを利用できるようにするためのコードです. 言語によっては prelude という名前が使われることもあります.
便利なメソッドを生やしたり、いい感じのデフォルト値を与えたりして開発者体験を改善するためのもので、初心者はあまり深入りせずこれを使うといい感じになるんやな〜くらいの温度感で見ておくといいと思います.

implicit class: 既存の型へメソッドを追加する(A.K.A 拡張メソッド)

やりたいこと

IntInt から KiloMeter クラスへ変換する km メソッドを生やしたい.

val ikm = 1.km

Scala 2.x: 暗黙の変換 ・ enrich mylib パターン

Scala 2.x で既存のクラスにメソッドを足すには次のように書く必要がありました. これは enrich my library パターンなどと呼ばれています.

implicit class MyInt(i: Int) {
  def km: KiloMeter = new KiloMeter(i)
}
case class KiloMeter(i:Int)
def program() = {
  val i = 1
  // implicit class MyInt が import されていたら
  // ここで、Int => MyInt に変換されて MyInt#km が呼ばれるイメージ
  val ikm = i.km // new MyInt(i).km
}

なお、上の implicit class は以下の implicit def のショートハンドです.
Int から MyInt へ暗黙の型変換が起こっています.

class MyInt(i: Int) {
  def km: KiloMeter = new KiloMeter(i:Int)
}
implicit def int2myint(i:Int):MyInt = new MyInt(i)

Scala 3: extension method

Scala 3 では既存のクラスにメソッドを生やす専用の構文 extension が用意されてより直感的に書けるようになりました.

extension (i:Int)
  def twice(): Int = i * 2

もちろん Generics を使うこともできます.

extension [T] (t:T)
  def show():Unit =
    println(t)
@main def run() = 1.show() // 1

型クラス(あるいはアドホック多相)のインスタンスの定義

Rust や Haskell を使ったことがある人なら Rust の trait や Haskell の型クラスをイメージしてください.

Rust では fn f<T: ATrait>(a:T) というふうに型パラメターにトレイト境界を指定することで、継承によらない形で多相、つまり異なる型について一般的に定義された関数を表現できます. また、これはコンパイル時に解決されるので不正な型を渡した場合はコンパイルエラーになります. trait の実装は(trait のインスタンスを implicit で与えることで)、ある型が何らかの制約を満たしていることをコンパイラに伝える役割を果たしています.

Ruby や Python のような動的型付け言語の Duck Typing のように柔軟ですが Duck Typing と違って、不正な入力をコンパイル時に検出できる嬉しさがあります.

まず説明のためにRust の trait をみてみましょう. Rust では関数の型パラメターに T : Trait1 : Trait2 : ... とトレイト境界を指定することで、Trait1,Trait2, ... を impl したものはなんでも 受け取る関数が表現できます.

trait Additive {
  fn add(&self,other: Self);
}

impl Additive for /*Specific Type*/ {
  // ???
}

// program は Additive trait を impl したあらゆる型を受け入れる.
fn program<T:Additive>(a:T,b:T) {
  let it = a.add(b);
  // do something with `it`
}

Scala 2.x: implicit def/val

Scala 2.x では以下のように trait を定義して、implicit def でインスタンスを作ることで trait を実装できます. fn f(&self) のような構文がないので trait は型パラメターを取ります. また、implicit def は Rust の impl ATrait for AType に相当します.

なお、ここでいう trait は Java の abstract classinterface よりも Rust の trait や Haskell の型クラスに近いことに注意しましょう.

trait Additive[T] {
  def add(a:T,b:T):T
}
// Rust の trait の impl に相当する.
implicit def additiveForKM = new Additive[KiloMeter] {
  def add (a:KiloMeter, b: KiloMeter): KiloMeter = {
    (a,b) match {
      case (KiloMeter(a),KiloMeter(b)) => KiloMeter(a + b)
    }
  }
}
// program は Additive trait を impl したあらゆる型を受け入れるので additiveForKM 
// がある KiloMeter も受けつける.
// trait 境界の表現. `T <: Additive` で表記するサブタイピングとは別であることに注意!
def program[T:Additive](a:T,b:T) = {
  val it = implicitly[Additive[T]].add(a,b)
  // do something with `it`
}

implicitly は名前をつけないで型クラス(trait) のインスタンスにアクセスするためのキーワードで implicit で受け取る引数と対応しています.

つまり上の program は以下のようにも書けます. いずれにしても implicit 引数の機能を利用して、 T について Additive[T] が実装されていることを要求しています.

def program[T](a:T,b:T)(implicit evidence: Additive[T]) = {
  val it = evidence.add(a,b)
  // do something with `it`
}

Scala 3: given/summon/using

Scala 3 では、型クラス(trait)のインスタンスを定義するのに使える given というキーワードが入りました. 便宜上つけていた additiveForKM という変数名も省略できるようになりました. また、implicitlysummon に置き換わってより直感的になっています.

trait Additive[T]:
  def add(a:T,b:T): T

given Additive[KiloMeter] with
  def add(a:KiloMeter,b:KiloMeter): KiloMeter =
    (a,b) match
      case (KiloMeter(a),KiloMeter(b)) => KiloMeter(a + b)

def program[T:Additive](a:T,b:T) =
  val it = summon[Additive[T]].add(a,b)
  // do something with `it`

最初に説明した extention を以下のように定義するとよりそれっぽく、メソッドのように書けます.

extension[T:Addtive] (t0:T)
  def add(t1:T):T = summon[Additive[T]].add(t0,t1)
def program[T:Additive](a:T,b:T) =
  val it = a.add(b)
  // do something with `it`

余談ですが、さらに contextual function, polymorphic function を使うと def program は関数としても書けます. Haskell の =>?=> に、->=> に置き換えたものとしてみるといいかもしれないです.

val program1 = [T] => (_:Additive[T]) ?=> (a:T) => (b:T) => a.add(b)

Scala 2.x では型引数や暗黙の引数を取る関数は Function 型で表現しにくかったですが、それが Scala 3 では解消されています.

いまいちアドホック多相(型クラス/trait)の嬉しさが分かりにくいかもしれないので別の例も出してみよう.

たとえば、以下のようにデータをシリアライズする関数を作ったとしましょう.

trait MySerializable:
  def serialize = ???

trait MySerializableTC[T]:
  def serialize(t:T) = ???

def mySerializerA[T<: MySerializable](t:T) = ???
def mySerializerB[T: MySerializableTC](t:T) = ???

mySerializerAIntDouble などのプリミティブ型、外部パッケージの型などに対応できませんが、mySerializerB はプリミティブ型にも外部のパッケージの型にも対応できます.
(Int はプリミティブ型で class Int extends MySerializable とはできないことを思い出しましょう)
継承よりも緩い制約で多相性を表現できるということです. 嬉しいですね.

暗黙の変換

extension や型クラスの説明が終わったところで暗黙の変換についてみていきましょう.

Scala 2.x では、implicit def で定義された、暗黙の変換をするメソッドがスコープにあるときには、型が合わないが暗黙の変換をすれば合うケースではその関数を呼び出して整合性を取ってくれました.

implicit def s2i(s:String) = Integer.parseInt(s)
def f(arg:Int): Unit = ???
def program() = {
  f("1") // compile
} 

Scala 3 では、この変換は implicit ではなく、Conversion 型クラスの責務になりました.

String から Int への変換(Conversion) のインスタンスを与えてやることでコンパイラに暗黙の変換が可能であることを伝えているイメージです.

given Conversion[String, Int] with
  def apply(s: String): Int = Integer.parseInt(s)
def f(arg:Int): Unit = ???
def program() = {
  f("1") // compile
} 

まとめ

以上のように Scala の implicit には 文脈の伝播・enrich my lib・型クラスの定義/呼び出し・暗黙の変換が混ざっていましたが、 Scala 3 ではそれぞれ Contextual Function, extension method, given/summon/using, Conversion に整理されました.
暗黙の変換の使い所は難しいですが、Contextual Function や extension method で以前よりすっきりとしたメソッド・関数の定義ができるようになっていたり、型クラスの概念も Scala 2.x と比べて扱いやすくなっていたりします.

それぞれに名前がついて機能が増えた/複雑になったようにも見えますが、コンパイル時に解決される暗黙の引数/変換という概念からさまざまな派生的な機能が提供されていて、それぞれにわかりやすさのためのエイリアスが貼られていると考えるととてもシンプルではないでしょうか.

Discussion

ログインするとコメントできます