読者です 読者をやめる 読者になる 読者になる

Scala の implicit parameter は型クラスの一種とはどういうことなのか

なんか型クラスとか言うと型の怖いひとたちが怖い話をワイワイしてるイメージがありますよね。わたしはあります。「で、それって何がうれしいのよ」とか、そういう話はあまりされていないような印象がありますね(あくまで印象です)。その上 "Scala の implicit parameter は型クラスの一種" とか言われると「暗黙的な引数がなんで型クラスの一種なんや!!!意味がわからん!!!!」となります。わたしはなりました。

というわけでそのへんについて勉強したので書きます。

そもそも型クラスってなんや

Haskellとかにあるやつですね。アドホック多相を実現するもの、らしいです。すごい、いきなり意味がわからない。

というわけで、まずは「アドホック多相ってなんなの」という話からして行きます。

さて、まずは「多相」から行きましょう。この文脈で言う多相とは、簡単に言えば「引数にいろんな型を取れる」ということです。

よく例に出される id 関数を見てみましょう。

def id[T](x: T) = x

引数をそのまま返すだけの関数です。この関数は、どんな型の引数でも受け取ることができますね。こんなふうに、「いろんな型を引数に取れる」ってのがこの文脈で言うところの「多相」だと思ってください。

ちなみに、引数に型パラメータを指定することで「どんな型でも受け入れるぜ」を実現するようなのを「パラメータ多相」と呼ぶそうです。

では、アドホック多相というのは?アドホックって、「その場で」とか「場当たり的に」とかそんなニュアンスの言葉ですよね。で、この文脈でアドホック多相関数ってのはどういう意味かっていうと、「この型のばあいはこういう計算をするよ」ってのを定義すると、その型を引数にとることができるようになる関数くらいの意味だと思ってください。なので、アドホック多相の場合、「ありとあらゆる型に対応」ではなくて、「振る舞いが定義されている型にのみ対応」することになります。例えば Int と String に対する振る舞いは定義されてるけど Boolean に対する振る舞いは定義されてないので Boolean は受け取れない、みたいな多相関数がありうるわけです。

具体的な例として、たとえば、「引数をひっくり返す」という振る舞いをする、IntとStringに対応したアドホック多相関数を考えましょう。

flipFlap(1) // => -1
flipFlap("string") // => gnirts
flipFlap(true) // => コンパイルエラー

こんな感じです。こういう動きをする関数を「アドホック多相関数」と言うみたいですね。

さて、では型クラスというのはなんなのでしょう?それは、「アドホック多相な関数がどんな型に対応してるか」を制限するための「インターフェイス」のようなもののようです。あるいは、「アドホック多相関数が受け取れる型の集合」みたいなものです。ちょっと具体的に見てみましょう。今、flipFlap は Int と String に対応していますね。このとき、便宜的にflipFlap関数の引数の型クラスを「flipFlap型クラス」と呼ぶと、Int と String は「flipFlap型クラスのインスタンスである」と言います。flipFlapが受け取れる型の集合を「flipFlap型クラス」と呼ぶとき、その要素ひとつひとつを「flipFlap型クラスのインスタンス」と呼ぶ訳です。

逆の言い方をすると、「flipFlap型クラスのインスタンス」である型でありさえすれば、flipFlap関数の引数として渡して上げることができる、ってことでもありますね。

インスタンスとかクラスとか言ってるけど、オブジェクト指向のいわゆる「クラス」とか「インスタンス」とは関係がないことに注意してください。型クラスと型インスタンスの関係はむしろ、型ができることを定義した抽象的なものであるインターフェイスと、その具体的な型であるクラス、という関係に近いかもしれませんね。

さて、話は変わって、たとえば、さらにあとからflipFlap 関数に Boolean に対する振る舞いを定義してあげたとします。すると、

flipFlap(1) // => -1
flipFlap("string") // => gnirts
flipFlap(true) // => false

となりますね。これは言って見れば、あとから Boolean を「flipFlap型クラス」に属するインスタンスにしてあげたことになるわけです。

実際にやってみる

では実際に Scalaアドホック多相を実現してみましょう。型パラメータ使ったらうまく実現できないかな〜、という発想がまず出てきそうです。

def flipFlap[T](x: T) = ...

で、パターンマッチで x の型に応じた処理を書く…?でもそうすると「対応してない型の場合はコンパイルエラー」にできないですよね。詰まってしまった……。しょうがないので、そこで、いきなり最終形をめざすのではなくて、いくつか段階を踏んでやっていきましょう。まずは、「型によって振る舞いが異なるなら、"ひっくり返す"という操作の実装を flipFlap の外に出しちゃえばいいんじゃないの?」という発想でこういうのはどうでしょう。

trait FlipFlapper[T] {
  def doFlipFlap(x:T):T
}
object IntFlipFlapper extends FlipFlapper[Int] {
  def doFlipFlap(x:Int) = - x
}
object StringFlipFlapper extends FlipFlapper[String] {
  def doFlipFlap(x:String) = x.reverse
}

def flipFlap[T](x:T, flipFlapper: FlipFlapper[T]) = flipFlapper.doFlipFlap(x) // ...(1)

flipFlap(1, IntFlipFlapper) // => -1
flipFlap("string", StringFlipFlapper) // => "gnirts"

(1)の行に注目してください。flipFlap 関数に、 FlipFlapper 型の引数を追加してみました。そして、その与えられたflipFlapper の doFlipFlap に、実際に値をひっくり返す処理を委譲しています。伝統的なオブジェクト指向ポリモーフィズムを活用した感じですね。ちょっと引数が増えちゃったけど、Tが変われば振る舞いが変わる、ということは実現できています。さらに、ここで例えばflipFlap(true, StringFlipFlapper)みたいなことをしても、StringFlipFlapper は FlipFlapper[Boolean] ではないのでコンパイルエラーとなります。

さて、それでは、flipFlap を Boolean に対応させてみましょう。

object BooleanFlipFlapper extends FlipFlapper[Boolean] {
  def doFlipFlap(x:Boolean) = ! x
}

flipFlap(true, BooleanFlipFlapper)

まあ、一応これでも動くっちゃ動くし、安全っちゃ安全ですね。うーん、でもこれ、やっぱり第二引数が邪魔ですね。第一引数の値の型はわかってるんだし型推論だってあるんだから、なんとかこの第二引数は暗黙的にこううまく解釈してやってくれないかなぁ……、となります。今「暗黙的に」という言葉が出てきましたね。implicit parameter を使えばよさそうです。

trait FlipFlapper[T] {
  def doFlipFlap(x:T):T
}
implicit object IntFlipFlapper extends FlipFlapper[Int] { // ...(1)
  def doFlipFlap(x:Int) = - x
}
implicit object StringFlipFlapper extends FlipFlapper[String] { // ...(2)
  def doFlipFlap(x:String) = x.reverse
}

def flipFlap[T](x:T)(implicit flipFlapper: FlipFlapper[T]) = flipFlapper.doFlipFlap(x) // ...(3)

flipFlap(1) // => -1
flipFlap("string") // => "gnirts"

さて、まずは(3)から見て行きましょう。

第二引数は implicit parameter として暗黙に取るように変更しました。この状態で例えば flipFlap(1) と渡したらどうなるでしょうか?

まず、T が 型推論によって Int に確定しますね。となると、自動的に flipFlapper の型も FlipFlapper[Int]に確定します。

さて、このスコープにおいて implicit で定義されている FlipFlapper[Int] 型の値は、(1)で定義されている IntFlipFlapper だけですので、これで無事 flipFlapper には IntFlipFlapper が暗黙に束縛されます。

もし flipFlap("string") とした場合はどうなるでしょう。同じ理屈で T が String に確定し、(2)で定義されている FlipFlapper[String] 型の implicit な値であるStringFlipFlapper が flipFlapper に束縛されます。

ではこのflipFlap 関数に Boolean を追加したい場合はどうすれば良いでしょう?

implicit object BooleanFlipFlapper extends FlipFlapper[Boolean] {
   def doFlipFlap(x: Boolena) = ! x
}

を追加するだけでいいですね。同じ理屈で、これさえ定義してあれば flipFlap(true) と渡すと T が Boolean に確定して、flipFlapper には BooleanFlipFlapper が暗黙的に束縛されます。

じゃあ、flipFlap('c')としたら?対応する FlipFlapper[Char] が implicit に定義されていないので、コンパイルエラーになりますね!やった!

さて、このとき、型クラスとの対応はどうなっているでしょうか。FlipFlapper[T] が型クラス、FlipFlapper[Int] や FlipFlapper[String] が型クラスのインスタンスの役割を果たしています。すこし丁寧に説明しましょう。def flipFlap[T](x:T) なら、Tにはどんな型も入って来れてしまいます。しかし、(implicit FlipFlapper[T])があると、FlipFlapper[具体的な型] が定義されている型以外を引数に渡したらコンパイルエラーになりますね。そして、例えば FlipFlapper[Int] を定義してやれば、flipFlap は Int を受け取ることができます。つまり、 「FlipFlapper[具体的な型]」を継承したオブジェクトがある場合、その具体的な型が「flipFlapが計算可能な型」になるわけですね。

そんな感じで、FlipFlapper[T] が型クラス、FlipFlapper[Int] や FlipFlapper[String] が型クラスのインスタンスの役割を果たしている、と言えるわけです。

これで、 implicit parameter が型クラスの一種である、ということが確認できました。

まとめ

  • アドホック多相な関数ってのは、「この型に対してはこういう振る舞いをするよ」と定義しておくことでいろんな型を受け取ることができるようになる関数。
  • アドホック多相が実現されてると、型によって柔軟な振る舞いができてうれしいし、コンパイル時に型チェックで守られてうれしい。
  • アドホック多相関数が受け入れることが可能な具体的な型(IntとかStringとか)」のことを「型クラスのインスタンス」と呼び、その集合を「型クラス」と呼ぶ。
  • 言い換えれば、型クラスは「そのアドホック多相関数がどんな具体的な型を受け入れることができるのか」を縛る役割をするものでもある。
  • Scalaにおいては implicit parameter で渡した値を多態させることでアドホック多相が実現できる。
  • 多態させるためのインターフェイスであるところの trait が型クラスに、その実装であるところの implicit な object が型インスタンスに対応する。
  • すなわち、implicit parameter は型クラスの一種である。

その他

単純に「引数の型によって振る舞いが変わる」という意味ではメソッドオーバーロードで実現できるじゃないか、という疑問は残るので、「こんな複雑なことをしてまで何がうれしいのか」という疑問は残る。ただ、オーバーロードだと「計算する側の型」つまりレシーバの側の型に処理が実装されるけど、型クラスと型インスタンスの関係だと「計算される側」、つまり「パラメータ」の側の型に処理が実装される。これは言い方を変えると、「新しい型に対応させたくなったときに、その新しい型の側に実装が書ける」ということであり、このほうがより変更に対して開かれているし、柔軟だし、責務の上でも適切かもしれないと感じる。

ただ、そのうれしみを享受するためにここまでのことをしなければならないことを考えると、「そういうことしたいなら言語仕様で型クラスがサポートされてる Haskell 使えばいいんじゃないの……」という気持ちがわき上がらないわけでもない……。「なにがうれしいのか」という部分に対してはまだそういう程度の理解度なので、もう少し考えてみたいと思う。

追記:

うれしいパターン一個あった。こういうのはどうでしょうか? https://gist.github.com/Shinpeim/76c687b3c9a8f8126da0

さらに:

2014.10.9 1:11 追記

ブコメより

id:matarillo (続く記事も読みました)C#/Java的に言うと、型クラスは型制約なんですよね。とある型をとある型クラスのインスタンスに「後から」できるかどうかは本質ではないという理解。

型制約というだけならば trait でいいじゃないかという話になってしまい、わざわざ型クラスを導入するメリットがないので、やはり既存の型に対する拡張が可能というのは型クラスの本質(の一部)ではないかなぁという理解をわたしはしています。実際型クラスの存在しない C#/Java でも、サブタイピングでの型制約は実現できているわけですし、「なぜわざわざ型クラスを導入する必要があるのか」という視点から見ると、サブタイピングでは実現できない柔軟さを実現する必要があるからかな、と思います。「そうじゃないんだ」「それはこういう理由で本質的には型制約と同等なのだ」という意見があれば是非参考にしたいので教えてください。