Scalaでoverrideした際の共変戻り値型と型推論

ScalaでもJavaでも、overrideする際に、sub typeでoverrideすることが可能です。 (すごく古い1.4以前のJavaでは不可能だったはずだが)

Javaの仕様書で英語だと covariant return type というはず?の機能です。

https://docs.oracle.com/javase/specs/jls/se11/html/jls-8.html#d5e14373

$ jshell
|  Welcome to JShell -- Version 17.0.6
|  For an introduction type: /help intro

jshell> interface A { Object a(); }
|  created interface A

jshell> class B implements A { @Override public String a() { return "aaa";} }
|  created class B

jshell> String x = new B().a()
x ==> "aaa"
Welcome to Scala 3.3.1 (17.0.6, Java OpenJDK 64-Bit Server VM).
Type in expressions for evaluation. Or try :help.
                                                                                                                                             
scala> trait A { def a: Object }
clas// defined trait A
                                                                                                                                             
scala> class B extends A { override def a: String = "aaa" }
// defined class B
                                                                                                                                             
scala> (new B).a
val res0: String = aaa

さて、ScalaにはJavaと違って、ローカル変数以外でのもっと強い?広い?型推論機能があるため、慣習的にはそれほど推奨されませんが、publicなmethodの戻り値型でも、推論に任せて明示的な型の記述を省略出来てしまいます。

Welcome to Scala 3.3.1 (11.0.20, Java OpenJDK 64-Bit Server VM).
Type in expressions for evaluation. Or try :help.
                                                                                                                                             
scala> class A {
     |   def x = 2
     |   def y = "abc"
     | }
// defined class A
                                                                                                                                             
scala> (new A).x
val res0: Int = 2
                                                                                                                                             
scala> (new A).y
val res1: String = abc

ここまでが大前提として、ここからタイトルの「overrideした際の共変戻り値」と「型推論」の関係の説明をするわけですが、

overrideする際に共変の戻り値(親で返していたものよりも、より具体的なsub type)を返しつつも、型を明示せずに型推論に任せた場合は、何に推論されるべきでしょうか?

何と、これがScalaのversionやcompile optionによって異なります。

Welcome to Scala 3.3.1 (11.0.20, Java OpenJDK 64-Bit Server VM).
Type in expressions for evaluation. Or try :help.
                                                                                                                                             
scala> trait A { def a: Object }
// defined trait A
                                                                                                                                             
scala> class B extends A { override def a = "aaa" }
// defined class B
                                                                                                                                             
scala> (new B).a
val res0: Object = aaa
Welcome to Scala 2.13.12 (OpenJDK 64-Bit Server VM, Java 11.0.20).
Type in expressions for evaluation. Or try :help.

scala> trait A { def a: Object }
trait A

scala> class B extends A { override def a = "aaa" }
class B

scala> (new B).a
val res0: String = aaa

compile optionなしの場合は

  • Scala 3では親の型のまま
  • Scala 2ではoverrideしたときのsub type

となります。このScala 3での変更は、意図した仕様変更です。

さて、Scala 2.12や2.13には -Xsource:3 という、出来るだけ色々をScala 3に似せて、警告を出したり、Scala 3の文法が一部だけ使えたり、そもそもcompile時の挙動というかcompile後の出力自体を変えるオプションがあります。 それを指定すると、かなり色々と変わって、そもそも自分も全部完璧に把握できてないので、全てを説明しませんが、 少なくともこれを指定すると、今回の主題である「overrideした際の共変戻り値」について「型推論」の挙動が変わります。

Welcome to Scala 2.13.11 (OpenJDK 64-Bit Server VM, Java 11.0.20).
Type in expressions for evaluation. Or try :help.

scala> trait A { def a: Object }
trait A

scala> class B extends A { override def a = "aaa" }
class B

scala> (new B).a
val res0: Object = aaa

Scala 2.13の途中のどこか(詳細を後で調べたら追記するかも)、から、Scala 3と同じように、型の記述を省略すると、親の型になります。 しかし、これはこれで、出力されるJVMでのbytecodeというかclassfileが変わって、ライブラリ作者からすると、意図せずバイナリ互換が維持しにくくなる元となって、 単に警告が増えるような変更とは違い、おそらく、色々と驚きというか、反響があったと思われます。 (というか自分がtwitterで騒いでたりしましたが)

よって、つい最近リリースされたScala 2.13.12から、なんと、これは -Xsource:3 だけを指定した場合にデフォルトではcompile errorになるようになりました!!!

Welcome to Scala 2.13.12 (OpenJDK 64-Bit Server VM, Java 11.0.20).
Type in expressions for evaluation. Or try :help.
    
scala> trait A { def a: Object }
trait A

scala> class B extends A { override def a = "aaa" }
                                        ^
       error: under -Xsource:3, inferred Object instead of String [quickfixable]
       Scala 3 migration messages are errors under -Xsource:3. Use -Wconf / @nowarn to filter them or add -Xmigration to demote them to warnings.
       Applicable -Wconf / @nowarn filters for this fatal warning: msg=<part of the message>, cat=scala3-migration, site=B.a

エラーメッセージにあるように、他のオプションを同時に指定するなどで回避は可能です。 これ以外にもいくつか同様に、急に(?)compile errorに変わったものがあります。(case classのコンストラクタに対するprivate付与など)

ただ、個人的には、理由を知らないとびっくりするだろうけど、安全側に倒すというか、ユーザーに明示的に何をしてるか選ばせる(型を書かせるか、明示的に警告などにレベルを変えさせる)のは、とても良い変更だと思いました。

親の型のままの方がいいか?sub typeでさらに具体的な型になって欲しいか?は、完全に場合によると思うので、考えて付与しましょう。 sub typeは実装詳細で隠蔽したい場合は親の型のままの方がいいだろうし、あえて具体的な型を露出させていきたい(露出させないとそもそもcompileできない)場合は、そちらを書くようにしましょう。

https://github.com/scala/scala/pull/10439

よって、各種OSSや仕事でも、基本的に抑制はせずに全部、型を書いていく方針で直してます。 ただ、無名クラスでそのシグネチャが表に出ない場合は、だるい気はしますが、まぁ現実的に書ける量だったので、全部書きました。

ちなみに、こういうのを半自動で修正するquickfixという機能がScala 2本体に入ったらしいですが、バグっていてあまり使えないので、使いませんでした。

以下、OSSで修正した例などを貼っておきます