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

日々常々

ふつうのプログラマがあたりまえにしたいこと。

バージョンアップ時に互換性を損ねてしまう変更

Java

あるいは、利用者にバージョンアップをためらわせる変更の手引き。

前置き

うっかり互換性を損なってしまう変更について書いてみます。

知ってたらやらかさないし、なんてことのない事なのだけれど、 知らなかったら何が駄目なのかわかりづらいし、 原因を突き止めるのとかも難しいのかもしれない。と思ったので。

互換性の話なので、ライブラリやフレームワークなどの提供側としての変更になります。

動かない、けど再コンパイルすれば動く

こんなコードがあって、

interface Hoge<T extends java.io.Serializable> {
    T method();
}

これを使っている、こんなコードがある。

class Hogera {

  public static void main(String... args) {
    Hoge hoge = new Hoge<String>() {
      @Override
      public String method() { return "HOGE"; }
    };

    System.out.println(hoge.method());
  }
}

コンパイルして実行すれば、まあ普通に動くよね。

% javac Hoge.java Hogera.java
% java Hogera
HOGE

ここで、元のコードをこう変更しちゃう。

interface Hoge<T> {
    T method();
}

Hoge.java だけ再コンパイルして実行すると、動かない。

% javac Hoge.java
% java Hogera
Exception in thread "main" java.lang.NoSuchMethodError: Hoge.method()Ljava/io/Serializable;
  at Hogera.main(Hogera.java:9)

使う方のコードも再コンパイルするだけで動く。

% javac Hoge.java Hogera.java
% java Hogera
HOGE

説明

Javaは戻り値の型までを含めてメソッドは識別されます。 で、ジェネリクスで境界を使用していると、境界の型で扱われます。何もしていしてないと java.lang.Object ですね。 こういう時に型引数は考慮されません。つまり、 method()LT とかにはならないの。

分かりづらくするためにジェネリクス使ってるけど、単に戻り型を拡げるだけでも十分だったりする。

String method(); // 変更前
Object method(); // 変更後(違うメソッドになる)

昔、似たような事を書いた(しかしタイトル微妙だな……)のだけれど、 正直なところ当時の私の見通しは甘くて、単にビルドツールを使えば解決するって問題ではありません。 今回もjavaファイルを直接扱っているから分かりづらいのだけれど、たとえば Hoge がjarで、 知らない誰かが作っているものだったら、一緒にコンパイルーなんてしませんよね。

こういう変更が入っているかも、と思うとバージョンアップできなくなります。 たまに、依存ライブラリ間でこういうことが起こったりします。 その瞬間のソースを眺めて脳内コンパイルしてると普通に動いちゃうので、知らないと原因特定が難しかったりする困った話。 クラスファイルを読んでたらわかるだろうけど、普通は添付されてるソース読むよね?

動く、けど再コンパイルできない

こんなコードがあって、

class Fuga {

  static void method(String str) {
    System.out.println("FUGA");
  }
}

これを使っている、こんなコードがある。

class Fugera {

  public static void main(String... args) {
    Fuga.method(null);
  }
}

コンパイルして実行すれば、まあ普通に動くよね。

% javac Fuga.java Fugera.java
% java Fugera
FUGA

ここで、元のコードをこう変更しちゃう。

class Fuga {

  static void method(String str) {
    System.out.println("FUGA");
  }

  static void method(Number str) {
    System.out.println("FUGAGA");
  }
}

Fuga.java だけ再コンパイルして実行すると、これは普通に動く。

% javac Fuga.java
% java Fugera
FUGA

使う方のコードも再コンパイル……できない。

% javac Fugera.java
Fugera.java:4: エラー: methodの参照はあいまいです
    Fuga.method(null);
        ^
  Fugaのメソッド method(String)とFugaのメソッド method(Number)の両方が一致します
エラー1個

仕方ないので修正する。

class Fugera {

  public static void main(String... args) {
    Fuga.method((String)null);
  }
}

ダサい。。。 けれど、これでコンパイルできるようになるし、コンパイル後は元通り動作する。うっかりNumberにキャストしたりすると動作が変わるから、「とにかくコンパイルが通るようにすればいい」みたいな対応をすると二次災害になります。 バグ修正時のバグ混入率は通常よりも高いって言いますし、慌てずに落ち着いて対応しましょう。

説明

nullなんて嫌いだ! 最初のと同じ感じだったりします。Javaコンパイル時に呼び出す先のメソッドをコードよりも厳密に決定します。 メソッドオーバーロードしたとしても別のメソッドとして扱うため、コンパイルできないようなオーバーロードを行っても、 コンパイル後のclassが実行される際は問題となりません。

既存のメソッドを修正するときは気をつけられるけれど、 メソッドを追加して今までコンパイルできていたものができなくなるってのは、油断しがちかもしれない。 そしてまだ同じクラスなら話は簡単なのだけれど、たとえばインタフェースに同じ名前のdefaultメソッドが足されたら同じことが起こる。 メソッド足すときも、やっぱり注意しなきゃです。

動作するかと、コンパイルできるかについて書いた。 2軸があるなら、4パターン出るから、あと2パターンも当然あります。 同じように書こう……と思ったのだけれど……

動かないし、再コンパイルもできない

何の互換も残ってないような。変更というより、破壊?

動くし、再コンパイルしても動く

普通に互換性保ててるから問題ないよね?

互換性、互換性、、、

現実で見たことのあるものから二つを挙げてみたけれど、もちろん互換性を損ねる変更なんてのはこれだけではありません。 というか、変更は必ず何かしらの互換性を損ねるでしょう。 先に挙げた通り、具象メソッドの追加も安全ではありません。 可視性を緩めることで損なわれる互換性もあります。

こういう変更をしてしまうと、一番最初に書いたように、利用者はバージョンアップをしたくなくなります。 すなわち、動いているものを変更したくなくなります。 なので提供側ならば全力で避けねばなりません。

だからといって、ありとあらゆる互換性を維持するなんて愚かな目標を立てるべきだと言いたいわけではありません。 それは変更を、つまり成長を阻害することとイコールになります。

そんな役に立たない妄想よりも、 変更がどのような互換性を損ねるかを把握し、 問題があった時にどう対応すれば良いか、その対応にどのくらいかかりそうか とかを 考えといた方がいいんじゃないかな、と思うわけです。