Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

ScalaではIntをDoubleに代入できるがサブタイプではない(関数引数の変性が反変になる話と絡めて)

非常に面白い題材をTwitterで発見したのでメモ。

このようなツイートを見た。

以下のようなコードがコンパイルしないという。

val f : Double => Boolean = (d:Double) => true
val g : Int => Boolean = f

しかし、以下のコードはちゃんと「動く」。

val x: Int = 42
val y: Double = x

Intの値をDouble型の変数に代入できるのに、Double => Boolean型の値をInt => Boolean型の変数に代入できないのか?というのが疑問の骨子だ。

これを理解するにはいくつかの前提知識が必要だ。

サブタイピング

型には大小関係がある。例えばAAAを継承しているとき、AAAのサブタイプである、といった言い方をする。イメージとしては、その型が取りうる値が、別の型によってすっぽり覆われてしまう感じだ。

ある型がある型のサブタイプかどうかは、Scalaだとimplicitly<:<を使って即座に確認できる:

class Total
class Partial extends Total // Partial is-a Total

implicitly[Partial <:< Total]

この例では、PartialTotalのサブタイプであるからコンパイルが通る。順序を入れ替えるとコンパイルしない。

関数引数の反変性

ある型にある値が代入できる(つまり、代入をコンパイラが許す)とき、当然ながら型が合致している必要がある。だが完全に一致していなければならないのでは不便なので、Scalaでは値が変数の型のサブタイプであれば代入してよいことになっている。例えば、以下のコードはコンパイルする:

class Total
class Partial extends Total

val p: Partial = new Partial
val t: Total = p

これだけだと簡単だが、Scalaのような関数型言語では関数も値として扱うことができるので話が複雑になる。関数同士のサブタイプ関係はどうやって判断するのか?

それは、Scalaの場合引数は反変(contravariant)に、返り値は共変(covariant)に、という方法でサブタイプ関係が計算される。

例えば冒頭に登場したDouble => Booleanのような1引数関数を表現する型、Function1のシグネチャを見てみると、以下のようになっている:

trait Function1[-T1, +R] extends AnyRef

www.scala-lang.org

この型パラメータに付いている+-といった符号が、「どのような関係をサブタイプとして許すか」を表現している。 また、「型が型パラメータを含むとき、それらがどのような関係であれば全体をサブタイプとして許すか」という特徴を変性(variance)と呼ぶ。 そして、Function1では引数について反変であり、返り値については共変なのである。

共変・反変

共変・反変についておさらいしておこう。

この変性が使われている例として、簡単なものではList[+A]が挙げられる。リストList[A]があるとき、AAAのサブタイプなら、List[AA]List[A]のサブタイプになると考えるのが普通だし、実用上そのほうがありがたい。例えば具体的には、List[Any]List[Int]が代入できてほしい(つまりList[Int]List[Any]のサブタイプになるはず)。このような特性をScalaの型システムに指示するために、「型パラメータAの親子関係がそのまま型に反映されますよ」という宣言を+の記号で表現する。このような関係を共変(covariant)と呼ぶ。

  • AAAのサブタイプなら
  • List[AA]List[A] のサブタイプになってほしいとき
  • List[+A]と書き、このことをAについて共変であると呼ぶ

逆に「型パラメータAの親子関係を反転させて型に反映しますよ」という宣言もあって、これを-の記号で表現し、反変(contravariant)と呼ぶ。反変の具体例がこのFunctionシリーズだ。

関数の変性をどう定義する?

関数の変性について考えるとき、つまりピッタリ型を合わせていなくても引数や返り値の型がある程度合っていればサブタイプとみなしてあげたいとき、どのような要件を課すだろうか?

おそらく次のような制約が実用上妥当だろう:

  • 関数ffが、元々の関数fで可能だったことが全て可能な場合、ffをfのサブタイプにしてもよい。

では「可能」という表現をより具体的にしていこう。

まずは返り値について考えてみると、例えば元々の型の範囲を逸脱した返り値を返してしまうような関数は、元の関数の代わりに使うことができないから、サブタイプとは言いがたい。 これを整理すると以下のようになる:

  • 関数ffがfのサブタイプであるとき、ffの返り値の型はfの返り値の型の範囲を逸脱してはならない。

これはよりシンプルに次のように書き換えられるだろう:

  • 関数ffがfのサブタイプであるとき、ffの返り値の型はfの返り値の型のサブタイプでなければならない。

つまり、Function1は返り値については共変だ。

次に引数について考えてみると、例えば元々の型よりも狭い範囲でしか引数を受け取れない関数は、元の関数の代わりに使うことができないから、サブタイプとは言いがたい。 これを整理すると以下のようになる:

  • 関数ffがfのサブタイプであるとき、ffの引数の型はfの引数の型よりも狭くなってはならない。

これもシンプルに書き換えて、以下のように言い直すことができる:

  • 関数ffがfのサブタイプであるとき、fの引数の型はffの引数の型のサブタイプでなければならない。

よく見てみると、ffとfとの関係が、引数の型では反転している。これこそが反変だ。

今までの定義を総合して、これらだけでサブタイプ関係を定義すると、以下のような表現になる。

  • ffの返り値の型がfの返り値の型のサブタイプであり、fの引数の型がffの引数の型のサブタイプであるとき、ffはfのサブタイプである。

これをそのままScalaのシグネチャに落とし込むと、Function1[-T1, +R]になるというわけ。

IntとDouble

ちょっと長いこと脇道に逸れてしまったけれど、とにかく、IntがDoubleのサブタイプなら、Double => BooleanInt => Booleanに代入できるはずだ*1:

  • Int が Doubleのサブタイプである
  • Function1の変性はFunction1[-T1, +R]である
  • Function1[Double, Boolean]Function[Int, Boolean]のサブタイプである

ところがどっこい、冒頭で示した通りこれはコンパイルしない。

// (再掲)
val f : Double => Boolean = (d:Double) => true
val g : Int => Boolean = f

Scalaの型ソルバが壊れているとは信じたくないから、おそらく前提が間違っている。IntDoubleのサブタイプではないのだ。

このことは<:<を使っても示すことができる:

implicitly[Int <:< Double] // コンパイルしない

しかし!!Double型の変数にIntを代入することができるのだ。

// (再掲)
val n: Int = 42
val d: Double = n

この謎は標準で導入されるimplicitにあった。

www.scala-lang.org

Numeric conversionsという機能がそれで、必要な場合IntなどをDoubleなどの型に自動的に変換してよいことになっているのだ。かくしてInt型は自動的にDoubleに変換され、いかにもDoubleIntのサブタイプであるかのように見えているだけだったのだ。

まとめ

  • IntとDoubleとの間にサブタイプ関係はない
  • サブタイプ関係はないが、自動的に変換する機能がある
  • 関数の引数は反変だが、そこには自動変換機能は及ばないので、IntとDoubleとの間にサブタイプ関係があるように見えても関数同士は代入できない
  • サブタイプ関係はimplicitly[AA <:<A]で確認できる

参考資料

tarao.hatenablog.com

waman.hatenablog.com

*1:いずれにせよ、IntをDoubleに向ける方向の代入はできない

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?