【Effective Java】項目18:抽象クラスよりインタフェースを選ぶ

Java では抽象クラスよりも、インタフェースを利用するべき。

抽象クラスとインタフェース

抽象クラスとインタフェースには以下の違いがある。

  • 抽象クラスはメソッドの実装を含むことが許されるが、インタフェースは許されない
  • 抽象クラスを実装するためには抽象クラスを継承(拡張)する必要がある
  • インタフェースはクラス階層のどこでも実装できる

Java では単一継承のみが許されているため、一般的に抽象クラスの利用は不自由である。 なぜなら、すでにそのクラスが拡張されていた場合、追加で抽象クラスを実装することは不可能である。

一方、新たなインタフェースを実装するように既存のクラスに変更を加えることは容易である。クラス宣言に implements 節を追加して、必要なメソッドを実装するだけである。

ミックスイン

ミックスインとは本来の型に加え、任意の振る舞いを提供するために、新たに実装型を追加することだ。 インタフェースはミックスインに最適である。

たとえば、Comparable はミックスインインタフェースであり、本来の機能に対して「順序づけ」という機能を追加することができる。

インタフェースは階層を持たない型フレームワークを構築することを可能にする。 以下のように、シンガーを表す Singer とソングライターを表す Songwriter インタフェースがあれば、SingerSongwriter インタフェースを作成するのは容易である。

public interface Singer {
    AudioClip sing(Song s);
}

public interface Songwriter {
    Song compose(boolean hit);
}

public interface SingerSongwriter extends Singer, Songwrite {
    AudioClip sing(Song s);
    Song compose(boolean hit);
}

これを抽象クラスで行うと、サポートされている組み合わせごとに別々のクラスを含む階層になる。

抽象骨格実装

ラッパークラスイディオムを利用することで、インタフェースは安全で強力な機能エンハンスを可能にする。

インタフェースは実装を含むことはできないが、付随する抽象骨格実装クラスを提供することで、インタフェースと抽象クラスの長所を組み合わせることができる。 インタフェースでは型を定義し、骨格実装はそのインタフェースのデフォルト実装を行っている。

慣例として、骨格実装は AbstractInterface と呼ばれ、Interface はそれが実装しているインタフェースの名前が入る。

Java のコレクションフレームワークは、主要な骨格実装を提供している。 AbstractCollection、AbstractSet、AbstractList、AbstractMap などである。 SkeltonInterface とするのが妥当であったが、慣例として AbstractInterface が用いられている。

骨格実装を利用すると、独自の実装を提供するのがかなり簡単になります。

static List<Integer> intArrayAsList(final int[] a) {
        if (a == null) {
            throw new NullPointerException();
        }

        return new AbstractList<Integer>() {
            @Override
            public int size() {
                return a.length;
            }

            public Integer get(int i) {
                return a[i]; // 自動ボクシング
            }

            @Override
            public Integer set(int i, Integer val) {
                int oldVal = a[i];
                a[i] = val;
                return oldVal;
            }
        };
    }

この List は、ボクシング/アンボクシングが行われるため、それほどよいパフォーマンスは発揮しないが、簡単に新たな機能をもった List を実装できていることを示している。 なお、この AbstractList を拡張したクラスは無名クラスである(項目22)。

インタフェースを実装したクラス内に、その骨格実装を拡張したクラスの private メンバーを持つことで、インタフェースの呼び出しをそのクラスに転送する手法が利用できる。 これは疑似多重継承と呼ばれる手法である。

骨格実装の作成

骨格実装の作成では、インタフェースを調べ、基本操作にあたるメソッドがどれかを調べて、それらを抽象メソッドとする。 そのうえで、インタフェースの他のメソッドのすべての実装を提供する。

public abstract class AbstractMapEntry<K, V> implements Map.Entry<K, V> {
    // 基本操作に該当するメソッド
    public abstract K getKey();
    public abstract V getValue();

    // 変更可能なマップはこのメソッドをオーバーライドする
    public V setValue(V value) {
        throw new UnsupportedOperationException();
    }

    @Override
    public boolean equals(Object o) {
        if (o == this) {
            return true;
        }

        if (!(o instanceof Map.Entry)) {
            return false;
        }
        Map.Entry<?, ?> arg = (Map.Entry) o;
        return equals(getKey(), arg.getKey())
                && equals(getValue(), arg.getValue());
    }

    private static boolean equals(Object o1, Object o2) {
        return o1 == null ? o2 == null : o1.equals(o2);
    }

    @Override
    public int hashCode() {
        return hashCode(getKey()) ^ hashCode(getValue());
    }

    private static int hashCode(Object obj) {
        return obj == null ? 0 : obj.hashCode();
    }
}

骨格実装は、継承されるために設計されているため、項目17で言及された項目に従うべきである。

インタフェースの変更

一般に、public インタフェースに新たなメソッドを追加することは既存のプログラムのコンパイル性を破壊する。 そのインタフェースを実装していたクラスに新たなメソッドの実装がないからである。

そのため、インタフェースがリリースされて実装されたらインタフェースを変更することはほぼ不可能です。 最初から正しい設計が必要であり、欠陥がある場合にはその API は捨てられます。

一方、抽象クラスの場合には適切なデフォルト実装を含んでいれば既存のプログラムのコンパイル性は破壊しない。

新たなインタフェースをリリースする際、インタフェースの決定の前に多くのプログラマにできるだけそのインタフェースを実装してもらうべきである。

感想

本文中にあった、以下の箇所がよく分からなかったので、ちゃんと考えてみる。

抽象クラスで行うと、サポートされている属性の組み合わせごとに、別々のクラスを含む膨れ上がったクラス階層になります。 もし、型システムに n 個の属性があったとしたら、サポートしなければならない可能性のある組み合わせは 2n あります。 これは、組み合わせ爆発として知られている事柄です。膨れ上がったクラス階層では、そのクラス階層の中に共通の振る舞いを表す型がないので、引数の型だけが異なる多くのメソッドを持つ膨れ上がったクラスを生み出します。

以下、自分なりの考え。

型システムに a, b, c というプロパティを持つ A、B、C という3つの型があるとして、これらの組み合わせ総数は 23 - 1 = 7(1を引くのは何もないクラスは除去するため)になる。

すなわち、A, B, C, AB, AC, BC, ABC だ。ここで、AB は A と B の型を合わせた型を意味し、AB はプロパティ a と b を保持している。

この組み合わせをインタフェースで実現すると以下のようになる。

public class InterfaceVersion {
    interface A {
        int a();
    }
    interface B {
        int b();
    }
    interface C {
        int c();
    }
    interface AB extends A, B {
    }
    interface AC extends A, C {
    }
    interface BC extends B, C {
    }
    interface ABC extends A, B, C {
    }
}

この場合、ABC を実装したクラス(e.g. ABCImpl)は、A を継承しているので、A としても扱うことができる。 そのため、例えば、a の値を出力するメソッドがほしければ、A を引数にとるメソッドを一つだけ用意すれば、A を実装したすべてのインスタンスに対応できる。

static class InterfaceVersionLogger {
    // 一つのメソッドで A を実装したインスタンスにすべて対応できる
    static void outputA(A a) {
        System.out.println(a.a());
    }
}

public class Main {
    public static void main(String[] args) {
        InterfaceVersion.A a = new InterfaceVersion.A() {
            @Override
            public String a() {
                return "a";
            }
        };
        InterfaceVersion.ABC abc = new InterfaceVersion.ABC() {
            @Override
            public String a() {
                return "a";
            }

            @Override
            public String b() {
                return "b";
            }

            @Override
            public String c() {
                return "c";
            }
        };

        InterfaceVersionLogger.outputA(a);
        InterfaceVersionLogger.outputA(abc);
    }
}

一方、抽象クラスで実現する場合には、以下のようにすべての抽象クラスで独自に a, b, c のプロパティを定義する必要がある。 そして、A や AB や ABC は「共通の振る舞いを表す型がない」状態になる。

public class AbstractClassVersion {
    static abstract class A {
        abstract void a();
    }
    static abstract class B {
        abstract void b();
    }
    static abstract class C {
        abstract void c();
    }
    static abstract class AB {
        abstract void a();
        abstract void b();
    }
    static abstract class AC  {
        abstract void a();
        abstract void c();
    }
    static abstract class BC {
        abstract void b();
        abstract void c();
    }
    static abstract class ABC {
        abstract void a();
        abstract void b();
        abstract void c();
    }
}

これに対して、インタフェースの場合と同じように a の値を出力する Logger を作成してみる。 すると、継承関係がないので、メソッドオーバーロードを利用するしかなく、同じような outputA がいくつもできてしまう。

static class AbstractVersionLogger {
    // 引数の型だけ異なるいくつものメソッド
    static void outputA(A a) {
        System.out.println(a.a());
    }
    static void outputA(AB ab) {
        System.out.println(ab.a());
    }
    static void outputA(AC ac) {
        System.out.println(ac.a());
    }
    static void outputA(ABC abc) {
        System.out.println(abc.a());
    }
}

public class Main {
    public static void main(String[] args) {
        AbstractClassVersion.A a = new AbstractClassVersion.A() {
            @Override
            public String a() {
                return "a";
            }
        };
        AbstractClassVersion.ABC abc = new AbstractClassVersion.ABC() {
            @Override
            public String a() {
                return "a";
            }

            @Override
            public String b() {
                return "b";
            }

            @Override
            public String c() {
                return "c";
            }
        };

        AbstractVersionLogger.outputA(a);
        AbstractVersionLogger.outputA(abc);
    }
}

まさに「引数の型だけが異なる多くのメソッドを持つ膨れ上がったクラスを生み出す」の状態になる。

まとめると、引用した箇所の言いたいことは、以下のようになる(と思う)。

  • 型システムに n 個の属性があると、サポートしなければならない組み合わせは 2n (組み合わせ爆発)になる。これは抽象クラスを使っても、インタフェースを使っても変わらない。
    • 最初にこの記事を書いているときは「抽象クラスを使うと組み合わせ爆発が起きる。インタフェースでは起きない。」という風に読み取ってしまったが、大きな間違い。
    • ネットで検索しても上記のような結論を書いている記事がほとんどだが、たぶん間違っている。
  • ただし、抽象クラスを使ってこの組み合わせを実現すると、継承関係のない別々のクラスを新たに定義する必要がある。
  • 継承関係がないため「共通の振る舞いを表す型がない」状態になり、「引数の型だけが異なる多くのメソッドを持つ膨れ上がったクラス(例:AbstractVersionLogger)」ができる。

感想の感想

とまぁ、ここまで長々と書いてきたが、改めて引用した箇所を読み直すと、正直あってるかどうか自信がなくなってきた。 詳しい人、間違ってたら教えてください。

しかし、前にも書いたような気がするけど、こうやって細かく調べるといくら時間があっても足りないなぁ、、、。 まだ項目18。いつ終りがくるんだろう( ^ω^)