kmizuの日記

プログラミングや形式言語に関係のあることを書いたり書かなかったり。

プロジェクトのバイナリ互換性をうっかり壊してしまわないように、最初に気を付けるべきこと(主にScala)

先日、MiMaの紹介のために、

kmizu.hatenablog.com

を書きましたが、それの続編みたいな何か。基本的なことだと思うのですが、色々なScalaプロジェクトがバイナリ非互換な変更の元になる行為を意図せず行っている気がするので、啓蒙のために書いてみることにしました(意図している場合も多々あると思います)。主にScalaについて書いていますが、他のJVM系言語でも当てはまる場合は結構あると思います。

なお、自分の手元で、どのようにするとバイナリ非互換が起きるかを試すためのリポジトリとして、

GitHub - kmizu/how_to_use_mima: A repository to explain how to use MiMa

を用意してみました。このプロジェクトのmasterブランチをcloneして、用意されているソースコードに何かの変更を加えて

> sbt mimaReportBinaryIssues

をたたくことでバイナリ非互換な変更についてより深く知ることができるようになってるので、興味があれば試してみてください。

公開メンバの型は明記する

返り値の型推論、便利ですよね。使いたくなるのはわかります。しかし、推論される型はメンバ本体の式の型に依存して変わります。つまり、メンバ本体をいじると、意図せず返り値の型が変わってしまう可能性があります。

privateなメソッドは子クラスを含む外部から参照されないので良いのですが、protected以上のアクセス権を持つメンバの型を型推論におまかせにしてしまうのはやめましょう。

how_to_use_mima/ReturnTypeInferenceInPublishedMethod.scala at b85e77a6524780d25fb949887d8f7368a379f10b · kmizu/how_to_use_mima · GitHub

たとえば、上のような変更をすると、MiMaは

[error]  * method makeBuffer()scala.collection.mutable.ArrayBuffer in class com.github.kmizu.how_to_use_mima.ReturnTypeInferenceInPublishedMethod has a different result type in current version, where it is scala.collection.mutable.Buffer rather than scala.collection.mutable.ArrayBuffer
method makeBuffer()scala.collection.mutable.ArrayBuffer

という報告をしてくれます。このように、型推論に頼っていた部分で、より一般的な型を明示するようにしたくなることがありますが、それはバイナリ互換性を壊すことになります。scalacのソースコードでも、しばしばpublicなメソッドの返り値型を型推論にたよっていることがあったりするので、にほんと気を付けましょう。

また、メソッド本体全体が無名クラスのインスタンス生成になっているようなケースも気を付けましょう。無名クラスは、その継承元のクラスとは実際には別の型になっているので、単に

def foo = new Foo {
  ...
}

def foo: Foo = new Foo {
  ...
}

に変えただけでもNGです。なお、このアドバイスは、同様にメンバに対する型推論が効くKotlinのような言語でも当てはまります。

公開される型名/メソッドの名前は事前によく考える

当たり前の話ですが、いったんバイナリを公開した後に、その名前を変えてしまうと、そのバイナリの型名やメソッド名に依存しているライブラリが、依存ライブラリのバージョンを上げたときに壊れます。

といっても、現実的には事前に完璧な名前付けをするのは難しいので、新しい名前のものが古い名前のものを参照するようにして、古い名前のものをdeprecatedにして移行を促してから、あとのバージョンで削除するといった方法をとるべきでしょう。

how_to_use_mima/BadMethod.scala at b85e77a6524780d25fb949887d8f7368a379f10b · kmizu/how_to_use_mima · GitHub

[error]  * method badNamedMethod()scala.runtime.Nothing# in class com.github.kmizu.how_to_use_mima.BadMethod does not have a correspondent in current version

implicitな値やメソッドの名前をうっかり変更しない

これはややScala特有な話になりますが、ソースコードレベルでimplicitなメソッドを直接使わなかったとしても、バイナリレベルではそれらが使われる可能性があります。ソースコードレベルでは見えない隠れた依存性というべきものができるので、気を付けましょう。

how_to_use_mima/RenamingImplicitIsDanger.scala at b85e77a6524780d25fb949887d8f7368a379f10b · kmizu/how_to_use_mima · GitHub

[error]  * method IntToString(Int)java.lang.String in class com.github.kmizu.how_to_use_mima.RenamingImplicitIsDanger does not have a correspondent in current version

implicitとついていても、それらは通常の名前で参照することができるので、名前変更をしたときと同じエラーになります。

non-final(デフォルト)で良いかよく考える

non-finalなメンバはユーザにとって自由に拡張できるので、利点もありますが(Template Methodパターン等)、いったんnon-finalなメンバとして公開してしまうと、あとからfinalにするとバイナリ互換性を壊すことになります。また、ユーザにとっても、あるメソッドがfinalであることは、挙動を推測するヒントになります。

Kotlinはクラスもメソッドもデフォルトでfinalで、オーバーライド可能なものにはopenキーワードを付ける必要がありますが、これは意図しない挙動を防ぐためにも、意図しないバイナリ非互換を導入しないためにも良い言語設計だと思います(openを明示するということは、その危険性についてもわかった上で選択したということになるので)

how_to_use_mima/NonFinal.scala at b85e77a6524780d25fb949887d8f7368a379f10b · kmizu/how_to_use_mima · GitHub

[error]  * method nonFinalMethod()scala.runtime.Nothing# in class com.github.kmizu.how_to_use_mima.NonFinal is declared final in current version

他にもあるのですが、眠くなってきたので、今回はここまで。参考になれば幸いです。