16
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Why extends is evil:extendsはなぜ邪悪か - 具象基底クラスをインターフェイスに変えることでコードを改善する

Last updated at Posted at 2022-02-15

翻訳にあたり

この記事はアレン・ホルブ(Allen Holub)1さんのWhy extends is evilの翻訳です。古い記事ですが日本語訳が存在しないため翻訳いたしました。アレンさんにはTwitterメッセージ越しに快諾いただきました。

Javaに総称型が導入される前の記事ですが、この記事の要点は現在でも通用する内容で、いい意味での古典だと思います。C#など、Java同様にインターフェイス(もしくはプロトコル、トレイト)を持つ言語にも応用できる知見です。なお、訳者は主にC#を使っていますが、以前Javaの経験も若干ながらあり、コードを読むのは苦手ではありません。

高校が語学系だったため英語には自信がありますが、翻訳記事の投稿は初めてのため、脚注に言葉選びの経緯を記載したり、重要なキーワードや意訳・翻訳ニュアンスに迷った言葉は英語を併記しています。訳注は少々本文とずれる部分がありますが、理解を深めると思われる情報を載せました。

序文

extends キーワードは邪悪だ。オウム真理教2レベルではないが、できる限り避けるべきである。GoF本ではページ数を割いて実装の継承(extends)をインタフェイスの継承(implements)に置き換える方法を議論している。

いいプログラマは大半のコードを具象基底クラス3ではなくインタフェイスで書く。この記事ではなぜ設計者がそのような奇習をするのか、またインターフェイスを使ったプログラミングの基本について数点述べる。

インタフェイス対クラス

あるとき参加したJavaのユーザーグループ会議で、ゲストスピーカーとしてJavaの発明者ジェイムズ・ゴスリングが登壇した。記憶に残るのはQ&Aセッションで、誰かが「Javaを作り直すのなら何を変えたいですか?」と質問したときのことだ。その質問に彼は「クラスを取り除きたい」と答えた。笑いが引いた後、彼は問題がクラスそのものにあるのではなく、実装の継承(extendsの関係)にあるのだと続けた。ゴスリングさんのおっしゃる通り4、インタフェイスの継承の方が好ましい。できるなら実装の継承を避けるべきだ5 6

柔軟性の喪失

なぜ実装の継承をすべきではないか? 最初の問題として、明示的に具象クラスの名前を使うのは特定の実装に捉われてしまうことになり、将来的にゼロから7書き換えるのがやたら難しくなることが挙げられる。

現代的なアジャイル開発手法の核となるのは並列設計8と開発という2つのコンセプトだ。プログラムの詳細を決める前からプログラミングを始める。この技術は既存の叡智―すなわち設計はプログラミング前に完了する―に反するが、様々な成功を収めたプロジェクトが今までのやり方よりも、ハイクオリティなコードをより迅速に(そしてコスパ良く)作れることを証明している。並列開発のカギを握るのは柔軟性への理解だ。新しく出てきた必要条件を既存のコードにできるだけ楽に取り入れられるようにコードを書かなければならない。

必要かもしれない機能よりも、変更を許容するやり方で絶対必要になる機能を実装する。このような柔軟性が無ければ、並行開発は不可能だ。

インターフェイスでプログラミングするのは柔軟な構造の肝だ。それを確認するために、もしインターフェイスを使わなかった場合どうなるかを見てみよう。以下のコードを考える。

f()
{
    LinkedList list = new LinkedList();
    //...
    g( list );
}
g( LinkedList list )
{
    list.add( ... );
    g2( list )
}

高速なデータ検索が必要になったと仮定しよう。するとLinkedListは力不足なので、HashSetに変える必要がある。現状のコードでは、f()だけでなくg() (LinkedListを引数に取るため)及びg()のリストを受け取る部分ももれなく修正する必要があるため、変更は局所的ではない。

そこでコードを以下の様に変えてみよう。

f()
{
    Collection list = new LinkedList();
    //...
    g( list );
}
g( Collection list )
{
    list.add( ... );
    g2( list )
}

これで、LinkedList()HashSet()にすれば、結合リストをハッシュテーブルに簡単に変えることができる。以上だ。他の変更は不要である。

次に、以下のコードを比較しよう。

f()
{
    Collection c = new HashSet();
    //...
    g( c );
}
g( Collection c )
{
    for( Iterator i = c.iterator(); i.hasNext() ;)
        do_something_with( i.next() );
}
f2()
{
    Collection c = new HashSet();
    //...
    g2( c.iterator() );
}
g2( Iterator i )
{
    while( i.hasNext() ;)
        do_something_with( i.next() );
}

g2()メソッドはコレクションの派生物だけではなく、Mapから取得できるキーと値のリストも横断できるようになった。実際、コレクションを横断するのではなく、データを生成するイテレータを書くこともできる。テストの土台やプログラムのファイルから情報を仕入れるイテレーターすら書くことができる、非常に柔軟なコードになった。

結合

実装の継承にまつわるさらに重大な問題は結合9だ―つまり、プログラムのある部分と別の部分が望まざる依存関係になることだ。グローバル変数は強い結合がトラブルを起こす古典的な例だ。例えば、グローバル変数の型を変えた場合、その変数を使う(すなわち変数に結合している)関数が影響を受けうるので、コードを検証し、変更し、再テストしなければならない。さらに、その変数を使うすべての関数は変数を通してつながっている。ということは、ある関数が変なタイミングで変数の値を変更するのなら、不適切に他の関数の振る舞いに影響しているかもしれない。マルチスレッドプログラムではこの問題はことさらおぞましい。

設計者として、結合関係を最小限にとどめるよう努めるべきだ。あるクラスのオブジェクトからのメソッド読み出しはゆるい結合であるので、結合は完全に排除できない。つまり、結合の無いプログラムを書くことはできない。それでも、オブジェクト指向の原理(その中で最も大事なのは、オブジェクトの実装はそれを使うオブジェクトから完全に秘匿されるということだ)を頑なに守ることにより、結合をかなり抑えることができる。例えば、オブジェクトのインスタンス変数(定数でないメンバーフィールド)は常にprivateにすべきだ。例外は一切認めない(時にはprotectedメソッドを効果的に使えることもあるが、protectedインスタンス変数は禁忌だ)。同様の理由でアクセサーも使ってはいけない―フィールドを無駄に複雑な方法で公開しているに過ぎないからだ(基本型ではなくオブジェクトを丸ごと返すアクセス関数は、戻り値のオブジェクトのクラスが設計における大事な抽象化であるなら有用なのだが)。

何も教条主義という訳ではない。自分が関わった仕事の中でオブジェクト指向のアプローチの厳密性とコード開発の迅速性、コードメンテナンスの簡便性の間に明らかな相関性があったからだ。実装の隠蔽のようなオブジェクト指向の原理を破った時は例外なく、コードを書き直さなければならなかった。大抵の場合、コードがデバッグ不可能だからだ。筆者はプログラムを書き換える時間が惜しいので、ルールを守ることにしている。問題なのは完全に実用的であることだ。純粋であろうとするために純粋さを追及することに興味はない。

基底クラスの脆弱性問題

さて、結合の考え方を継承に適用してみよう。実装の継承システムはextendsを使い、派生クラスは基底クラスと強固な結合を持つが、この親密なつながりは望ましくない。設計者はこの振る舞いを「基底クラスの脆弱性問題」10と呼ぶ。基底クラスの修正はさも安全そうだが、この新しい振る舞いが派生クラスに継承された場合、派生クラスで誤動作が起こってしまいかねないため、基底クラスは脆弱と考えられる。基底クラスのメソッドだけを調べるだけでは、その変更が安全かどうかは分からないので、すべての派生クラスを見(て、テストし)なければならない。さらに言えば、新しい動作でコードが壊れる可能性があるので、基底・派生両方のクラスのオブジェクトをすべてチェックしなければならない。基底クラスをちょっと変えただけで全プログラムが動作不能に陥ることだってある。

脆弱な基底クラスと、基底クラスの結合問題をまとめて見てみよう。以下のクラスはJavaのArrayListクラスをスタックのように振る舞わせるものだ11

class Stack extends ArrayList
{
    private int stack_pointer = 0;
    public void push( Object article )
    {
        add( stack_pointer++, article );
    }
    public Object pop()
    {
        return remove( --stack_pointer );
    }
    public void push_many( Object[] articles )
    {
        for( int i = 0; i < articles.length; ++i )
            push( articles[i] );
    }
}

こんな簡単なクラスでさえ問題がある。ユーザが継承を誤用12してArrayListclear()メソッドでスタックを空にしてしまった時を考えよう。

Stack a_stack = new Stack();
a_stack.push("1");
a_stack.push("2");
a_stack.clear();

このコードは普通にコンパイルされるが、基底クラスはスタックポインタのことを知らないので、Stackオブジェクトは不定状態になる。次にpush()を読みだした際はstack_pointerの今の値である2番目に新しいアイテムを入れるため、スタックには3つのアイテムがあることになる―底の2つはゴミなのだが(JavaのStackクラスはまさにこの問題があるので使わないこと)。

望ましくないメソッド継承問題を解決するには配列の状態を変えるすべてのArrayListのメソッドをオーバーライドして、スタックポインタを正しく操作するようにしたり、例外を投げたり(removeRange()メソッドとか)することだ。

この手法には2つのデメリットがある、まず、すべてをオーバーライドするなら、基底クラスはもはやクラスでなくインターフェイスであるということだ。メソッドを継承しない限り、実装を継承することに意味は無い。2つ目に、より重要なことだが、スタックでArrayListのメソッドを全て継承したくないはずだ。例えば、面倒なremoveRange()は不要だ。使えないメソッドを実装する合理的な方法は例外を投げることだ。呼ばれないわけだから。この手法は事実上コンパイル時のエラーを実行時にたらいまわししている。もしメソッドがあって例外を投げるなら、プログラムが実際に走るまで関数が呼ばれるかどうかは分からない。

基底クラス問題に対するベターな案として、データ構造を継承するのではなくカプセル化する方法がある。新しいStackの改善版は以下のとおりである。

class Stack
{
    private int stack_pointer = 0;
    private ArrayList the_data = new ArrayList();
    public void push( Object article )
    {
        the_data.add( stack_pointer++, article );
    }
    public Object pop()
    {
        return the_data.remove( --stack_pointer );
    }
    public void push_many( Object[] articles )
    {
        for( int i = 0; i < o.length; ++i )
            push( articles[i] );
    }
}

パッと見は良さげだが、ここで基底クラスの脆弱性問題を考えてみよう。ある期間の最大スタックサイズを管理できるStackの派生を作りたいとする。実装は例えばこんな感じである。

class Monitorable_stack extends Stack
{
    private int high_water_mark = 0;
    private int current_size;
    public void push( Object article )
    {
        if( ++current_size > high_water_mark )
            high_water_mark = current_size;
        super.push(article);
    }
    
    public Object pop()
    {
        --current_size;
        return super.pop();
    }
    public int maximum_size_so_far()
    {
        return high_water_mark;
    }
}

とりあえずはこの新しいクラスは機能する。しかしpush_many()push()を呼び出すということを悪用13していることがのちに響いてくる。とはいえ、それが悪いことだとは思いづらい。コードが単純化しているし、Monitorable_stackStack経由で参照されていてもpush()の派生版を使えるから、最大スタック数(high_water_mark)は正しく更新されるしね。

やがてある日のこと、プロファイラを走らせた誰かさんがStackがたくさん使われているにもかかわらず遅いことに気づいた。ArrayListを使わないようにStackを書き換えることでパフォーマンスが上がる。ということで、大慌てで14作ったバージョンがこちらだ。

class Stack
{
    private int stack_pointer = -1;
    private Object[] stack = new Object[1000];
    public void push( Object article )
    {
        assert stack_pointer < stack.length;
        stack[ ++stack_pointer ] = article;
    }
    public Object pop()
    {
        assert stack_pointer >= 0;
        return stack[ stack_pointer-- ];
    }
    public void push_many( Object[] articles )
    {
        assert (stack_pointer + articles.length) < stack.length;
        System.arraycopy(articles, 0, stack, stack_pointer+1,
                                             articles.length);
        stack_pointer += articles.length;
    }
}

push_many()push()を何回も読みださなくなったことに注目してほしい―ブロック転送に変わっている。Stackの新しいバージョンは前のものよりもきびきびと動く。だが残念ながら、push_many()から派生版のpush()が呼ばれないので、high_water_markが更新されない。そのためpush_many()を読みだした際に使用量が正しく測れなくなってしまい、Monitorable_stack派生クラスは機能しなくなってしまった。Stackは脆弱な基底クラスだ。ただ注意深くなるだけではこのような問題は実質排除できないことがお分かりいただけたと思う。

しかし、このように悪さを働く機能の継承が無いインターフェイスの継承を使えばこんな問題は発生しない。Stackがインターフェイスで、Simple_stackMonitorable_stackに実装が分かれていたならば、コードはさらに堅牢だ。

例0.1に、インターフェイスを使用した解法を載せる。この解法は実装の継承版と同じ柔軟性がある。どのスタック実装を操作しているのにかかわらずStackの抽象性に則ってコードを書くことができる。2つの実装はすべてのパブリックなインターフェイスを実装しているので、間違えることは難しい。派生ではなくカプセル化を使ったことで、基底クラスのコードを1回だけ書くのと同等の利点がある。カプセル化されたクラスの些細なアクセサメソッドを使ってデフォルト実装にアクセスしなければならないのが欠点だ(例えば、例のコード上では、Monitorable_Stack.push(...)はSimple_stackの相当のメソッドを読みださなければならない)。プログラマーはこう言ったワンライナーを書くことを嫌がるが、明らかなバグの温床を取り除けるなら安いものだ。

例0.1:インターフェイスで基底クラスの脆弱性を排除したバージョン

import java.util.*;

interface Stack
{
    void push( Object o );
    Object pop();
    void push_many( Object[] source );
}

class Simple_stack implements Stack
{
    private int stack_pointer = -1;
    private Object[] stack = new Object[1000];

    public void push( Object o )
    {
        assert stack_pointer < stack.length;

        stack[ ++stack_pointer ] = o;
    }

    public Object pop()
    {
        assert stack_pointer >= 0;

        return stack[ stack_pointer-- ];
    }

    public void push_many( Object[] source )
    {
        assert (stack_pointer + source.length) < stack.length;

        System.arraycopy(source,0,stack,stack_pointer+1,source.length);
        stack_pointer += source.length;
    }
}

class Monitorable_Stack implements Stack
{
    private int high_water_mark = 0;
    private int current_size;
    Simple_stack stack = new Simple_stack();

    public void push( Object o )
    {
        if( ++current_size > high_water_mark )
            high_water_mark = current_size;
        stack.push(o);
    }

    public Object pop()
    {
        --current_size;
        return stack.pop();
    }

    public void push_many( Object[] source )
    {
        if( current_size + source.length > high_water_mark )
            high_water_mark = current_size + source.length;

        stack.push_many( source );
    }

    public int maximum_size()
    {
        return high_water_mark;
    }
}

(訳注:さらなる柔軟性を求めるのであれば、stackの初期化をデフォルトで与えるのではなく、依存性の注入の考え方を用いて、Stackインターフェイスを引数に持つMonitorable_Stackコンストラクタを定義すると良い。これがDecorator Patternである。デザインパターンを取り扱った書籍からの抜粋なので、以降のページで取り扱う内容だったのかもしれない。)

フレームワーク

フレームワークベースプログラミングについて語らなければ、基底クラスの脆弱性問題の議論は不完全だろう。クラスライブラリをこしらえるのにMicrosoft Foundation Class (MFC)などのフレームワークは良く使われる。MFC自体はありがたいことに15消えつつあるのだが、MFCの構造はあまたのマイクロソフト商品16に入り込んでいる。プログラマが自分たちのやり方が最高だと思っているためだ。

フレームワークベースシステムは必要なことをすべてやるわけではなく、派生クラスで欠如した機能を補う半分出来合いのクラス17から始まる。Javaにおける分かりやすい例はComponentpaint()だ。これは実際のところ仮初めのものにすぎず、派生クラスで実際の処理を実装しなければならない。

このようなものは何の問題もなく18避けられるが、全体のクラスフレームワークが派生ベースのカスタマイズに頼るのは極端にもろく19、基底クラスの段階で脆弱が過ぎる。MFCをプログラミングしたとき、マイクロソフトが新バージョンをリリースするたびにすべてのアプリケーションを書き換えなければならなかったし、コードはコンパイルされても、基底クラスのメソッドが変わったために動かないことがままあった。

その点、全てのJavaパッケージは即戦力だ。動作させるのに拡張する必要は無い。この即戦力的構造は派生ベースのフレームワークよりも良い。使用・保守が簡単で、お上20がクラスの実装を変えたとしてもコードの安全性は保たれる21

まとめ

一般的には具象基底クラスとextends関係を避けて、インターフェイスとimplements関係を選択すると良い。筆者の鉄則はコードの最低でも80%はインターフェイスで書くことだ。例えば、HashMapを参照したりしない。使うのはMapインターフェイスの方だ(ここでいう「インターフェイス」はかなりルーズで、InputStreamは抽象クラスとして実装されているものの、実質的にはインターフェイスとして使える)。

抽象性22を上げれば上げるほど、柔軟性は増す。今日日のビジネス環境では、要求がプログラム開発が進むほどにころころ変わるので、柔軟性は必要不可欠だ。もっと言えば、アジャイル開発手法のほとんど(Crystalやエクストリーム・プログラミングなど)はコードを抽象的にしないと成り立たない。

GoFパターンをよく見ると、実装の継承の代わりにインターフェイスの継承をする方法が多数挙げられていることに気づくはずだ。それが大多数のパターンの特徴でもある。パターンは発明されたのではなく、発見されたものだというのは、原点というべき明らかな事実だ。パターンはよく書かれた、簡単にメンテナンスできる実用的なコードの中に現れる。このよく書かれた、簡単にメンテナンスできるコードが実装の継承を極力避けていることが伝わってくるはずだ。

終わりに

(この部分の内容は原文の最後をもとに、適宜情報をアップデートしたり、内容を追加しています。)

この記事はAPressより刊行された「Holub on Patterns: Learning Design Patterns by Looking at Code」(邦訳は残念ながら無し。だからこの記事を翻訳したわけではあるのだけれど)の37ページに所収である。「翻訳にあたり」で述べた通り古い本であるが、英語に明るい読者は購入して読んでみてはいかがだろうか。

著者について

アレン・ホルブは1979年からコンピュータ業界で働いている。コンサルタントとして、ソフトウェアに投資しない会社の役員にアドバイスしたり、トレーニング、デザインとプログラミングサービスを提供している。10冊の本を上梓しており、代表作に「Cの宝箱」(工学社、1989年)23 「Taming Java Threads」(Apress、2000年)「Compiler Design in C」(Pearson Higher Education、1990年)などがある。カリフォルニア大学バークレー校エクステンションで教壇に立っていたこともある。詳しい情報はholub.comをご覧のこと。

  1. 日本語圏における情報が少なかったため、氏が講師を務めるアメリカO'reilly社の講座で発音を確認したところ、「u」がほとんど発音されておらず(聴感上は「Holb」)、それに倣った。早口なせいか書き起こしが「Alan Holt(日本語字幕も当然アランホルト)」になっていたのはご愛敬。

  2. 原文ではCharles Manson. こういうちょっとした(Penn and Tellerをナポレオンズと言い換えるような)ローカライズは必要だと訳者は思うのだが、余計なお世話だろうか。原文からしてブラックジョークなわけだし…

  3. キーワード:concrete base class

  4. 訳注:これは訳者の勝手な追加。もしかしたら「インタフェイスの継承の方が…」というのも実際ゴスリングさんがQ&Aで答えた内容なのかもしれない。そこまで原文からは読み取れなかったので、この内容が筆者の伝えたいことであることを鑑み、意味合いを補完する形での追記をさせていただいた。

  5. 訳注:インターフェイスの継承のみを許した言語にRustが挙げられる。実装はimpl、トレイト(実装は持たないため実質インターフェイス)はtraitと区別され、implを拡張することはできない。

  6. 訳注:逆に実装の継承しか認めないのがC++である。この辺りはC言語のヘッダ形式(関数の型や引数の宣言部。インターフェイスと役割が重複すると言える)を受け継いだのが原因か。登場するメソッドを全て純粋仮想関数にすればインターフェイスっぽいことはできなくない(いわゆる抽象基底クラス)が、それは単なる中身のないクラスであり、メモリのオーバーヘッドが発生する。どう考えてもC++の設計思想に反している(が背に腹は代えられず濫用されているようだ)。脚注4のRustとは好対照である。

  7. 原文:down-the-line. 「将来的に」「徹底的に」と2つの意味があるが、文脈から両方の意味で取れるため併記した。

  8. キーワード:parallel design. これといった和訳は無いし、Googleにも情報が見つからなかったが、言葉通りの意味でとらえて問題ないと思われる。

  9. キーワード:coupling

  10. キーワード:Fragile base class problem. 英語版Wikipediaに記事があるほど有名な問題だが和訳が無いため直訳した。流行れ。

  11. 訳注:ご存知かもしれないが、Stackクラス自体はjava.utilモジュールに存在する。ここでは学習のために独自のモジュールで実装したと考えてほしい。

  12. 原文:leverages。「使う」程度の言葉であるが、やってはいけないことなのでこう訳した。

  13. 原文:exploits. 搾取するという意味で知られるが、プログラミング関連では「バグ」「不正動作」という意味でも使われる。

  14. 原文:lean and mean

  15. 原文:blessedly

  16. 原文:Microsoft shops. ここでいう「shops」は「店」のことではなく「工房(つまりMS社内)」と取るのが妥当だろう。ここでは文脈に合うように意訳した。

  17. 訳注:抽象クラスのこと。なぜ著者が「abstract class」という一般的な呼び方を避けたのかは不明だが、本文中の「抽象性」と意味合いが違っているし、抽象クラスはこの記事の論旨から言えば新規に設計するのは避けるべきことなので、婉曲的な呼び方をしているのかもしれない。

  18. 原文:in moderation. 「適度に」「ほどほどに」という意味だが日本語としてはしっくりこないので、ニュアンスを取って和訳した。

  19. 原文:brittle。fragile(脆弱性)とほぼ同義である。

  20. 原文:Sun Microsystems. Oracleに買収されたのち、仕様策定元がJava Community Process(JCP)に変わり、さらに言えば実装もオープン化して複数あるという現状から断定を避ける言い方に変更した。

  21. 訳注:あくまでも仮定の話であり、影響を可能な限り抑えた範囲で末端のパフォーマンス向上をする場合(C#の文字列補間のコード展開の改善など)はあっても、既存のコードを危険にさらす抜本的修正ないし破壊的変更は避ける傾向にある。今までコンパイルできていたものが通らなくなったら顧客離れを誘発するからだ。そんなことをするくらいなら、既存の機能を置き換える新しいクラス・インターフェイスや構文を追加し、ユーザーにそれを使うよう呼び掛けるはずだ。前出のjava.util.Stackクラス自体もジェネリック化の末に、まさにこの記事で述べられた方法論でDequeインターフェイスおよびその実装クラスに置き換わっている。MFCの問題点を見るに、元記事が執筆された時代はまだそういう配慮が欠けていたと考えられる。

  22. 訳注:抽象クラスを使えということではない。「派生ベースのカスタマイズに頼るのは究極にもろ」いからだ。

  23. ホルブ氏の書籍の中で唯一日本語版が確認された書籍(アレン・ホラブ名義)のため、原文にはなかったが追加した。

16
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?