30億のデバイスで走るHonMarkHunt

JavaとJavaScriptと恐竜の絶滅について書いていきます。

Scala implicit デザインパターン

Scala implicit デザインパターン

implicit。書いてあるコードは読めるけど自分で実装する時に使いどころがワカン。」 みたいのがあって職場の人に聞いたらいい感じのリンクを教えて頂いたので翻訳しつつ勉強がてらメモ。

目次

  1. 最初に
  2. Implicit Contexts
  3. Type-class Implicits
  4. Derived Implicits
  5. Type-driving Implicits
  6. まとめ

最初に

しばしば貧弱Scalaエンジニア(俺)達から畏敬の念とともに語られるimplicit。実はそれ自体の機能はそんなに強力じゃないみたい。

  • implicit parameter : 明示的に引数のを渡す必要なく、その型とスコープ内の値に基づいて自動的に推論
  • implicit conversion function : 要求に応じて明示的に関数を呼び出す。

ただ単純に使用するのではなく、長い歴史と数多くのエンジニアたちの知恵を礎に最適な使用方法(=デザインパターン)が生まれた。4つほど紹介してみる。

Implicit Contexts

DB Connectionとか 何回も何回も使用する引数を、渡す度に定義してたらめんどくさいよね。値があるなら空気読んでそれ使ってよ。 という話。

ex) 非同期処理する全てのメソッドにExecutionContextを引き渡す

// 全てのメソッドが`ExecutionContext`を必要としている
def getEmployee(id: Int)(implicit e: ExecutionContext): Future[Employee] = ???
def getRole(employee :Employee)(implicit e: ExecutionContext): Future[Role] = ???

getEmployee(), getRole()の両方でカリー化された引数でExecutionContextを要求している。

// implicitではない雑魚
val ec: ExecutionContext = scala.concurrent.ExecutionContext.Implicits.global

val bigEmployee: Future[EmployeeWithRole] =
  getEmployee(100)(ec).flatMap { e =>
    getRole(e)(ec).map { r =>
      EmployeeWithRole(e.id, e.name,r) 
    }(ec)
  }(ec)

implicitを使わない場合全ての呼び出し箇所で明示的に(ec)を引き渡す必要がある。すごくめんどくさい。 4つならまだいいけどもっと数が増えてきたり、複数箇所で使用されていたらもうめんどくさすぎる。

// implicit parameterを使うぞ!
implicit val ec: ExecutionContext = scala.concurrent.ExecutionContext.Implicits.global

val bigEmployee: Future[EmployeeWithRole] =
  getEmployee(100).flatMap { e =>
    getRole(e).map { r =>
      EmployeeWithRole(e.id, e.name,r) 
    }
  }

implicitキーワードとともにExecutionContextを定義することで、全ての使用箇所で明示的な引数の引き渡し(ec)を全て削除できる。やったねたえちゃん!という話。

Implicit Contextsの定義

  1. 通常ジェネリックス型ではなく、型パラメーターも持たない。
  2. 異なるシグネチャを持つあらゆる関数に同じimplicitが渡される。
  3. implicitは呼び出すタイミングによってその都度値が評価される。
    • play frameworkではHTTPリクエストの度にRequestオブジェクトの値が変わる
  4. AkkaのActerSystemのような場合にはimplicitの値は変更可能な場合がある。(これは意味不明誰か教えてくれ)

Implicit Contextsパターンは "ボイラープレートな引数を渡す便利な方法" であり、implicit parameter の唯一使用方法である。それ以外は一般的な引数と違いはない。

Type-class Implicits

いろんな型からJsonに変換したい場合とかにパターンマッチやオーバーロードで実装もできるけど、全ての型を記述するのは不可能でなによりコンパイル時にわからないし、同名関数を定義しまくるのは汎用性がない。変換可能な型が事前にわかればコンパイルエラーで防げるし、呼ばれるべき関数が型でわかれば汎用性もあるよねという話。

ex) Jsonシリアライズ

// Jsonオブジェクトの定義
sealed trait Json
object Json{
  case class Str(s: String) extends Json
  case class Num(value: Double) extends Json
  // ... もっとたくさん
}

def convertToJson(x) // このメソッドで x をJsonオブジェクトに変換したい

このような場合 def convertToJson(x): Json の実装はどのようなパターンが考えられるだろうか。

パターンマッチ

まずは前述のパターンマッチで実装してみる。

// パターンマッチで convertToJson を実装してみる
def convertToJson(x: Any): Json = {
  x match{
    case s: String => Json.Str(s)
    case d: Double => Json.Num(d)
    case i: Int => Json.Num(i.toDouble)
    // float, short などさらに多くのケースが考えられる
  }
}

さあ。このconvertToJson()はどうだろうか...?勿論パターンマッチで実装済みの型からの変換は正しく動くだろう。しかし、未実装の型(Booleanjava.io.File)の場合はどうだろ? 実行時エラーで失敗してしまう。 現実的に考えて全ての型のパターンマッチを実装するのは不可能であるし、何より変換できない型が渡された場合はコンパイラーに止めてほしい。この問題を解決するのがType-class Implicitsパターンだ。

オーバーロード

オーバーロードを使用することで複数パターンのconvertToJson()を実装することができるかもしれない。

def convertToJson(t: String) = Json.Str(t)
def convertToJson(t: Double) = Json.Num(t)
def convertToJson(t: Int) = Json.Num(t.toDouble)

このように同名で引数の型が異なる関数を複数用意しておけば、前述の未実装の場合にコンパイルエラーが発生しない問題は回避できる。 しかし、convertToJson()のような関数を複数作成しようとした場合どうなるだろうか?

def convertToJson(t: String) = Json.Str(t)
def convertToJson(t: Double) = Json.Num(t)
def convertToJson(t: Int) = Json.Num(t.toDouble)

def convertToJsonAndPrint(t: String) = println(convertToJson(t))
def convertToJsonAndPrint(t: Double) = println(convertToJson(t))
def convertToJsonAndPrint(t: Int) = println(convertToJson(t))

def convertMultipleItemsToJson(t: Array[String]) = t.map(convertToJson)
def convertMultipleItemsToJson(t: Array[Double]) = t.map(convertToJson)
def convertMultipleItemsToJson(t: Array[Int]) = t.map(convertToJson)

こうなる。

オーバーロードは同名関数という規約があるので、少し役割を変えた関数を定義しようとした場合。その新しい関数でも全ての型を網羅するオーバーライドを記述する必要がある。これはもうさいあくである。

Type-class Implicits パターン

  • パターンマッチは全パターンを網羅することができない
  • オーバーロードは汎用性がない

ここでType-class Implicitsパターンを使用してみよう!

trait Jsonable[T]{
  def serialize(t: T): Json
}
object Jsonable{
  implicit object StringJsonable extends Jsonable[String]{
    def serialize(t: String) = Json.Str(t)
  }
  implicit object DoubleJsonable extends Jsonable[Double]{
    def serialize(t: Double) = Json.Num(t)
  }
  implicit object IntJsonable extends Jsonable[Int]{
    def serialize(t: Int) = Json.Num(t.toDouble)
  }
}

こん感じで書いておけば...

def convertToJson[T](x: T)(implicit converter: Jsonable[T]): Json = {
  converter.serialize(x)
}

このようにconvertToJson()を実装できる!!!! convertToJson()のカリー化された引数にimplicitJsonable[T]型を受け取っている。この[T]convertToJson[T]で束縛されている。

例えばxStringであった場合、このような挙動になる(っぽい)。

  1. xStringなのでJsonable[T]Jsonable[String]に束縛される
  2. Jsonable[String]implicitなのでscalaが使えるimplicitがないか捜索の度に出る
  3. まずスコープ内を探す、この場合は定義していないので見つからない
  4. 次はJsonableのコンパニオンオブジェクトを探しに行く、今回はStringJsonableを定義済みなのでここで見つける
  5. scala「お!定義済みだね!通れ!」となる

もし、Jsonable[File]のような未定義のクラスの場合。scala「だめだ!お前は!implicitが見つからないぞ!コンパイルエラーだ」となる。

さらに、前述の def convertToJsonAndPrint(t: String) = println(convertToJson(t)) のような関数を定義した場合も「お!StringからJsonに変換するimplicitがほしいんだね!StringJsonableを使いな!」となる。やったね!

Type-class Implicitsと型クラスimplicitの違い

  • Type-class Implicitsは評価する度に使用するimplicitを探しにいく、ライブラリで実装済のものを使う場合もあれば、自前で実装した物を使用する場合もある。
  • Type-class Implicitsは通常​​、データを保有しており、しばしば変更可能型クラスimplicitは純粋な関数である場合が多い。(まったく理解していない)

Derived Implicits

Type-class Implicitsでは深い階層の呼び出しも型安全に行えるよ!イエ〜イ! という話。

前述のJsonクラスでStrNumの他にJson Listも実装したいとしたらどうすれば良いだろうか?

sealed trait Json
object Json {
  case class Str(s: String) extends Json
  case class Num(value: Double) extends Json
  // ... and more
}

StrNum実装済みであるのでconvertToJson()は成功させたい。しかし、File型などの未実装の場合はconvertToJson()コンパイルエラーにして防ぎたい。

// これは実行できる
convertToJson(Seq(1, 2, 3))
convertToJson(Seq("baz", "bar", "foo"))
// これはコンパイルエラーにしたい
convertToJson(Seq(new java.io.File("."), new java.io.File("/")))

どうすればこの問題を解決できるだろうか? 答えは先ほど追加したJsonableにSeqをserializeするimplicit SeqJsonableを作成。このSeqJsonable内のSeqを[T]型に束縛してしまえばいいのだ。 きっと日本語でおkなのでコードを見てみよう!

trait Jsonable[T]{
  def serialize(t: T): Json
}
object Jsonable{
  implicit object StringJsonable extends Jsonable[String]{
    def serialize(t: String) = Json.Str(t)
  }
  implicit object DoubleJsonable extends Jsonable[Double]{
    def serialize(t: Double) = Json.Num(t)
  }
  implicit object IntJsonable extends Jsonable[Int]{
    def serialize(t: Int) = Json.Num(t.toDouble)
  }
  // このSeqJsonableを新しく追加
  implicit def SeqJsonable[T: Jsonable]: Jsonable[Seq[T]] = new Jsonable[Seq[T]]{
    def serialize(t: Seq[T]) = {
      Json.List(t.map(implicitly[Jsonable[T]].serialize):_*)
    }
  }
}

このdef serialize(t: Seq[T])[T]型の束縛のおかげで、implicit実装済みの型は呼び出せますが、未実装のjava.io.File型などは呼び出し時にコンパイルエラーになります!めでたし、めでたし。

実はDerived Implicitsパターンはこれだけではありません。どれだけネストした型でも型安全に呼び出すことができるのです! これも日本語でおkなのでコードを見ていきましょう!

// コンパイル可能
convertToJson(Seq(Seq(Seq(1, 2, 3))))
// コンパイルエラー
convertToJson(Seq(Seq(Seq(new java.io.File(".")))))

どれだけネストが深くても関係ありません。コンパイラ再帰的に型を判定し[T]型にマッチしなければコンパイルエラーに変換します。 この制約は一見面倒に感じるかもしれません。仮にJsonableが外部ライブラリだった場合、前述のようにjava.io.File型はコンパイルエラーなってしまうため実行できないからです。しかし、これはメリットでもあります。 存在しなければjava.io.File型のFileJsonableを実装すれば、Seq[File]も同様に扱うことができるのです。

// これを作ってあげる
implicit object FileJsonable extends Jsonable[java.io.File]{
    def serialize(t: java.io.File) = Json.Str(t.getAbsolutePath)
  }

// これも実行可能になる
convertToJson(Seq(Seq(Seq(new java.io.File(".")))))

このように型の束縛は実装を制限するのではなく、ユーザー側に拡張性という大きなメリットをもたらします。

Type-driving Implicits

より大きな型に変換しようとすると予想外の動きをすることがあるけど、一つの関数が引数の型に応じて戻りの型を自動で変換してくれた楽チンだよね! という話

ex) Byte -> Short -> Longに変換

// Byteで定義
val x: Byte = 123
x: Byte = 123

// Byte -> Shortに変換
val y: Short = x
y: Short = 123

// Short -> Longに変換
val z: Long = y
z: Long = 123L

これは結構便利。しかし、予想外の動きをする場合があります。

// Longの最大値
val bigLong = Long.MaxValue - 1
bigLong: Long = 9223372036854775806L

// Long -> Floatに変換
val bigFloat: Float = bigLong
bigFloat: Float = 9.223372E18F

// Float -> Longに戻す
val bigLong2: Long = bigFloat.toLong
bigLong2: Long = 9223372036854775807L

// 最初のLongと一度Floatを通したLongを比較
bigLong == bigLong2
> res40: Boolean = false

false!!!!!!!

このような事態を防ぐにはどうすれば良いでしょう?型の拡張を自動で行う関数を定義できます!

{
  def widen[T, V](x: T)(implicit widener: Widener[T, V]): V = widener.widen(x)
  class Widener[T, V](val widen: T => V)
  object Widener{
    implicit object FloatWiden extends Widener[Float, Double](_.toDouble)
    implicit object ByteWiden extends Widener[Byte, Short](_.toShort)
    implicit object ShortWiden extends Widener[Short, Int](_.toInt)
    implicit object IntWiden extends Widener[Int, Long](_.toLong)
  }
}

こんな感じで、与えられた型を自動で拡張するwiden()関数を作成します!さぁ呼び出してみましょう!

// Floatを渡すとDoubleが返ってくる
widen(1.23f: Float)
> res12: Double = 1.2300000190734863

val smallValue: Byte = 123

// Byteを渡すとShortが返ってくる
val shortValue = widen(smallValue)
> shortValue: Short = 123

// Shortを渡すとIntが返ってくる
val intValue = widen(shortValue)
> intValue: Int = 123

// Intを渡すとLongが返ってくる
val longValue = widen(intValue)
> longValue: Long = 123L

戻りの型は最初のようにscalaコンパイラが自動で判断するのではなく、Widenerに定義されているimplicitの実装で決定されます。それだけではなく、widen(longValue: Long)このように未実装な型を渡した場合はコンパイルエラーで防ぐことができます。

このDerived Implicitsは、一般的にTupleを大きなTupleに拡張する時にも用いられます。

{
  def extend[T, V, R](tuple: T, value: V)(implicit extender: Extender[T, V, R]): R = {
    extender.extend(tuple, value)
  }
}
case class Extender[T, V, R](val extend: (T, V) => R)
object Extender {
  implicit def tuple2[T1, T2, V, R] = Extender[(T1, T2), V, (T1, T2, V)] {
    case ((t1, t2), v) => (t1, t2, v)
  }
  implicit def tuple3[T1, T2, T3, V, R] = Extender[(T1, T2, T3), V, (T1, T2, T3, V)]{
    case ((t1, t2, t3), v) => (t1, t2, t3, v)
  }
  implicit def tuple4[T1, T2, T3, T4, V, R] = Extender[(T1, T2, T3, T4), V, (T1, T2, T3, T4, V)]{
    case ((t1, t2, t3, t4), v) => (t1, t2, t3, t4, v)
  }
  // ... tuple21 まで続くよ ...
}

widen()の時と同じで、戻り値の型はExtenderで定義したimplicitに依存します。結果、Tuple2Tuple3はクラス階層では互いに直接関係がありませんので、Tuple2extend()を使用することができ、scalaコンパイラは結果をTuple3であると自動的に判断します。

// Tuple2を宣言
val t = (1, "lol")
t: (Int, String) = (1, "lol")

// Tuple3に拡張
val bigger = extend(t, true)
bigger: (Int, String, Boolean) = (1, "lol", true)

// Tuple4に拡張
al evenBigger = extend(bigger, List())
evenBigger: (Int, String, Boolean, List[Nothing]) = (1, "lol", true, List())

Type-driving Implicitsパターンを使用することで、implicitパラメータのどのインスタンスが有効範囲にあるかに応じて、関数の戻り値の型をscalaコンパイラが推測する方法を制御できる。これは少しクレーバーなテクニックです。戻りの方を制御するのは気づかない可能性もありますし何でもかんでもType-driving Implicitsパターンを使用すれば良いというわけではありません。しかし、効果的に使用すれば非常に有効なパターンになります。

ex) fastparse ではこのExtenderに似た形で実装されています。

最後に

これでimplicitを使用したいくつかのデザインパターンの紹介は終わりになります。しかし、これで全てを説明しきったわけではありません。ここで説明しなかった使用方法を最後に少し言及します。

  • ShapelessAux PatternShapelessではimplicitを使用してできるほぼ全てのことが網羅されています。しかし、ここで全てに言及するのは不可能なので興味があれば除いて見てみてください。
  • implicit parametersの代わりにimplicit conversionsを使用するもの。 ex) implicit constructorsやその拡張クラスを実装するもの。(C#のものに似ている)
  • 前述のCollectionのようなものだけではなく、case classで動作するDerived Implicitsパターンも存在します。Shapelessでも実装されています。
  • Implicit Contextsはよりパワフルに、行番号やファイル名などコンパイラからさらなる情報を取得することもできます。ClassTagsourcecodeで実装されています。

以上です!!マサカリ待っています!

参考

http://www.lihaoyi.com/post/ImplicitDesignPatternsinScala.html