非常に面白い題材をTwitterで発見したのでメモ。
このようなツイートを見た。
scalaって関数にも部分型関係があって、コンパイル通ると思ってたけどダメみたい。なんでですかね?😢
— 泥舟から抜け出したいPHPer (@liveinwood0422) 2023年3月21日
val f : Double => Boolean = (d:Double) => true
val g : Int => Boolean = fhttps://t.co/VgXJgnla8X
以下のようなコードがコンパイルしないという。
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
型の変数に代入できないのか?というのが疑問の骨子だ。
これを理解するにはいくつかの前提知識が必要だ。
サブタイピング
型には大小関係がある。例えばAA
がA
を継承しているとき、AA
はA
のサブタイプである、といった言い方をする。イメージとしては、その型が取りうる値が、別の型によってすっぽり覆われてしまう感じだ。
ある型がある型のサブタイプかどうかは、Scalaだとimplicitly
と<:<
を使って即座に確認できる:
class Total class Partial extends Total // Partial is-a Total implicitly[Partial <:< Total]
この例では、Partial
がTotal
のサブタイプであるからコンパイルが通る。順序を入れ替えるとコンパイルしない。
関数引数の反変性
ある型にある値が代入できる(つまり、代入をコンパイラが許す)とき、当然ながら型が合致している必要がある。だが完全に一致していなければならないのでは不便なので、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
この型パラメータに付いている+
や-
といった符号が、「どのような関係をサブタイプとして許すか」を表現している。
また、「型が型パラメータを含むとき、それらがどのような関係であれば全体をサブタイプとして許すか」という特徴を変性(variance)と呼ぶ。
そして、Function1
では引数について反変であり、返り値については共変なのである。
共変・反変
共変・反変についておさらいしておこう。
この変性が使われている例として、簡単なものではList[+A]
が挙げられる。リストList[A]
があるとき、AA
がA
のサブタイプなら、List[AA]
はList[A]
のサブタイプになると考えるのが普通だし、実用上そのほうがありがたい。例えば具体的には、List[Any]
にList[Int]
が代入できてほしい(つまりList[Int]
はList[Any]
のサブタイプになるはず)。このような特性をScalaの型システムに指示するために、「型パラメータA
の親子関係がそのまま型に反映されますよ」という宣言を+
の記号で表現する。このような関係を共変(covariant)と呼ぶ。
AA
がA
のサブタイプなら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 => Boolean
をInt => 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の型ソルバが壊れているとは信じたくないから、おそらく前提が間違っている。Int
はDouble
のサブタイプではないのだ。
このことは<:<
を使っても示すことができる:
implicitly[Int <:< Double] // コンパイルしない
しかし!!Double
型の変数にInt
を代入することができるのだ。
// (再掲) val n: Int = 42 val d: Double = n
この謎は標準で導入されるimplicitにあった。
Numeric conversionsという機能がそれで、必要な場合Int
などをDouble
などの型に自動的に変換してよいことになっているのだ。かくしてInt
型は自動的にDouble
に変換され、いかにもDouble
がInt
のサブタイプであるかのように見えているだけだったのだ。
まとめ
- IntとDoubleとの間にサブタイプ関係はない
- サブタイプ関係はないが、自動的に変換する機能がある
- 関数の引数は反変だが、そこには自動変換機能は及ばないので、IntとDoubleとの間にサブタイプ関係があるように見えても関数同士は代入できない
- サブタイプ関係は
implicitly[AA <:<A]
で確認できる
参考資料
*1:いずれにせよ、IntをDoubleに向ける方向の代入はできない