👀

継承としっかり向き合う

に公開

頭の中で蘇るあの問題…💭

世の中、大勢の意見に乗っかていれば煩わしいことに巻き込まれず、仕事以外の時間を、ある人は家族に、またある人は趣味に割くことができます。なので、それらを犠牲にしてまで世に逆らうなんてナンセンスなのです。私もそういった類の人間なんです、おそらく。ただ、時々違和感を覚える時があって、でも抗う気力はないから見て見ぬふりをしてきました。。。

私はフリーランスでエンジニアをしていますが、先日お客さんから2つの別々のお仕事を同時に頂きそうになり、どちらか一方を断る訳にもいかず、「どうしよう…」と焦っていたのですが、後日、そのいずれのお仕事も流れてしまうという"奇跡"が起きました。いや、凄いことです!これはきっと何らかの神の思し召しに違いない、と思い、では急にできた時間で自分は何をすべきなのか?と考えるようになりました。すると、頭の片隅に追いやっていたハズのあの問題がみるみる内に頭の大部分を占めるようになったのです。

私の頭で騒ぎ出したその問題とは、「クラスの継承問題」でした。時に"悪"だとも形容されるこの機能です。確かに基底(親)クラスに様々な機能が追加される内に潜在バグが蓄積されていく、という現象はよくある話です。ネットでもこの問題を例に挙げ、「継承は使うな!」という主張をよく見かけます。そして、必ずと言っていいほど、その解決策として紹介されているのが "委譲" なのです。しかし、私はこの継承の問題点と、委譲を使ったその解決策の提案に疑問を抱いてきました。果たして、本当にそうなのだろうか、と…

一般的に言われる継承の問題点

  1. 基底クラスが多くの責務を持つようになる(単一責任原則の違反)。
  2. 基底クラスの変更が派生クラスに思わぬ影響を及ぼすため、変更に弱い。
  3. 多段階継承した場合、構造が複雑になり、全体像の把握も難しくなる。
  4. 派生クラスを作成する際、基底クラス内部を理解する必要がある。

他にもあるかもしれませんが、上記で考えてみましょう。

  1. 基底クラスが多くの責務を持つようになる(単一責任原則の違反)。
    後からドンドコ機能を追加していって、気付いたらカオスになってた、ってヤツですね。それはそうです。共感します。しかし委譲を使えば解決する問題でしょうか?そもそも単一責任の原則の考え方がないプログラマが委譲にしたとたん、単一責任の原則に従うようになるのでしょうか?きっと委譲した先のクラスで原則から外れたコードを書いてるでしょう。そうなれば、その委譲先のクラスが肥大化しているハズ。つまり、これは継承の問題、というより 1つのクラスに様々な機能を混在させてしまう別の問題を、継承の問題にしてしまっている ように私には見えます。

  2. 基底クラスの変更が派生クラスに思わぬ影響を及ぼすため、変更に弱い。
    基底クラスの変更が派生クラスに影響を及ぼすのは、その通りです。基底クラスの変更を一気に派生クラスに反映させるのはメリットである一方、派生クラスにとって、その変更が予想外であれば悪影響を与える可能性はあります。しかし、委譲であれば、そのような問題は起きないのでしょうか?これは後で具体的なソースを例に確かめてみようと思います。

  3. 多段階継承[1]した場合、構造が複雑になり、全体像の把握も難しくなる。
    多段階継承すると、階層が深くなり分かりづらいのは分かります。では委譲を使って同じように段々と拡張していった場合、果たして分かりやすいのか?という疑問が沸くのです。

BufferedReader reader = new BufferedReader
(
    new InputStreamReader
    (
        new FileInputStream(file), "UTF-8"
    )
);

昔、Javaの勉強をした人なら馴染みのある例のコードですよ。何も知識がない状態でこれを見ると、「えっ、ナニ!?」となりましたよねっ、そこのアナタ!デザインパターンを学べばやりたいことのイメージは見えてきますが、ではこの例ほど有名ではない、見慣れないコードで同様に委譲による機能拡張を見かけた場合、その内部構造が果たして複雑ではないと思えるでしょうか?

  1. 派生クラスを作成する際、基底クラス内部を理解する必要がある。
    実はこれが継承の真の問題かもしれません。問題なのは基底クラスのフィールド変数を派生クラスからもアクセス可能(代入可)にした場合です。
    一般的に変数のスコープはできるだけ狭くすべきです。なぜなら、変数変更時の影響範囲を局所化したいためです。しかし、この原則を破るのが、この派生クラスへもアクセス可能とするケースです。
    クラスを作成する時、ローカル変数であれば、そのメソッド(関数)内で気を付ければ良いのですが、フィールド変数の場合、クラス内の各メソッドで変更される可能性があるため、その扱いには慎重になります(これはフィールド変数を多く作りたくない動機となり、結果的に単一責任の原則にも繋がります)。それが複数クラスに渡って変数変更の可能性が生じるとなると、これはもうストレスでしかありません。

ではやっぱり継承は避けるべきなのか?

結論を出す前に、まず継承と委譲を比較するサンプル・ソースを見てみましょう。自動車で指定した距離を走らせ、到着前までに燃料が尽きるかどうか、をガソリンエンジンの場合と電気モーターの場合とで比較するサンプルです。この2つの動力源を継承と委譲でそれぞれ実装してみます。

自動車のクラスです。コンストラクタで動力源MotivePowerを受け取り、これを基に走行します。ちなみに空調を入れるかどうか、で燃費が変わり走行距離に影響する、というイメージです。
https://github.com/spacehijackle/article01-car_inheritance/blob/main/app/src/main/java/inheritance/car/Car.java

まず、継承の例です。ガソリンエンジンと電気モーターの共通の基底クラスです。なお、フィールド変数はprivateにし、各々の参照、変更はメソッドを通じて行うようにしています。
https://github.com/spacehijackle/article01-car_inheritance/blob/main/app/src/main/java/inheritance/car/MotivePower.java
ちなみにinjectEnergyIfPossible()メソッドはハイブリッドエンジン向けに用意したものです(ガソリンエンジンで走行中に充電)。この記事では直接触れませんので悪しからず。。。

それではガソリンエンジンと電気モーターの実装です。
https://github.com/spacehijackle/article01-car_inheritance/blob/main/app/src/main/java/inheritance/car/power/GasolineEngine.java
https://github.com/spacehijackle/article01-car_inheritance/blob/main/app/src/main/java/inheritance/car/power/ElectricMotor.java
基底クラスでほとんど実装されているので、電気モーターがヒーターを入れた時に "電費" が落ちる、くらいの実装(オーバーライド)です。ちなみに switch 式を見てもらって分かるよう、空調モードは OFF, HEATING の2種類のみです。

ではもう一方の委譲の例です。動力源MotivePowerをインターフェース定義とし、それを共通的に実装するクラスMotivePowerCoreを作成します。
https://github.com/spacehijackle/article01-car_delegator/blob/main/app/src/main/java/delegator/car/MotivePower.java
https://github.com/spacehijackle/article01-car_delegator/blob/main/app/src/main/java/delegator/car/power/MotivePowerCore.java
共通実装クラスを用意したのは、継承のケースで基底クラスが共通実装をしていたので、これと条件を合わせるためです。
もちろん、ガソリンエンジンと電気モーターのクラスがそれぞれ別々にMotivePowerを実装しても良いのですが、委譲が継承の問題を解決するとの主張が正しいかどうかを検証するため、このような形を取りました。
※共通実装がないと、エンジンとモーターで同じ実装が重複してしまう問題もありますしね(同じ、または似たロジックを分散させるのはバグの素!)。

それではガソリンエンジンと電気モーターの実装です。
https://github.com/spacehijackle/article01-car_delegator/blob/main/app/src/main/java/delegator/car/power/GasolineEngine.java
https://github.com/spacehijackle/article01-car_delegator/blob/main/app/src/main/java/delegator/car/power/ElectricMotor.java
特徴としてはMotivePowerCoreに委譲し、不足している実装をそれぞれのクラスが自ら実装している、そんなイメージですね。

以上、継承と委譲のそれぞれのケースを比べて、どうでしょうか? 委譲ではガソリンエンジン、電気モーターがインターフェース実装のため、全ての公開メソッドを記述する必要があります。これは差分プログラミングの特徴を持つ継承の方が楽です(ただ、AIが補完してくれるので、それほどのアドバンテージじゃないかも…)。

重要なことは、最初の方で挙げた継承の問題点の一つである "基底クラスの変更が派生クラスに思わぬ影響を及ぼすため、変更に弱い。" が継承のみに当てはまる問題点かどうかです。例えば、基底クラスを変更する際にバグが混入すると派生クラスも影響を受けます。では委譲のケースではどうでしょう?MotivePowerCoreにバグが混入すると、委譲しているガソリンエンジンや電気モーターも影響を受けるのです。継承から委譲にすれば、変更の影響を受けないかのように印象付ける主張を目にすることもありますが、そうではないことは分かると思います。それはそうです。何らかのクラスに依存しているのですから、当然、依存するクラスの影響を受けます。

MotivePowerCoreを設けずに、ガソリンエンジン、電気モーターが直接MotivePowerを実装していれば、それぞれは別のクラスに依存していないので(実装の重複という問題はあるものの)、他のクラスからの影響はありません。これは疎結合である証拠でもあります。しかし、委譲は他のクラスに依存します。依存している以上、継承で指摘されている問題点を解決できる訳ではありません、よね?

つまり私の結論としては、継承はフィールド変数を派生クラスにまで可視しなければ有力な共通化の手段である、ということです(いわゆる is-a 関係が認められる場合)。もちろん、単一責任の原則はしっかりと踏まえた上で、です。基底クラスが単一責任の原則から外れそうになれば、その基底クラスが委譲するなり、またその委譲先を派生クラスが別の委譲クラスに入れ替え可な構造にするのも良いでしょう。つまり継承と委譲はどちらが優れている、という対立の構図で語るべきではなく、それぞれの特性を踏まえ適切に使い分ける協力の関係として捉えるべきではないでしょうか?

ちなみに上記のエンジンとモーターの委譲のソースを見てると、フィールド変数のdelegatorsuperに読み替えると継承にクリソツですね。このことから、基底クラスのフィールド変数を派生クラスから不可視にすれば、基本的に継承と委譲の両者の間に大きな差はない、と私は考えています。となると 継承が劣っていて、委譲が優るとは言えないんじゃないの(条件付き)? ということなんです。

実装に依存しちゃダメ!- 基底クラスの脆弱性問題

"基底クラスの脆弱性問題" とは先にも挙げた "基底クラスの変更が派生クラスに思わぬ影響を及ぼすため、変更に弱い。" の事なのですが、もう少し深堀ってみたいと思います。実はこの手の説明であまり納得いくサンプル・ソースを見たことがなかったのですが、最近見つけたので自分なりにアレンジしてみました。ちなみに動作確認してないので、イメージを汲み取ってください、と予めお断りしておきます。

public class 刑務所
{
    private List<囚人> prisoners = new ArrayList<>();

    public void 収容する(囚人 prisoner)
    {
        prisoners.add(prisoner);
    }

    public 囚人 脱獄される()
    {
        if(prisoners.isEmpty()) return null;

        return prisoners.remove(0);
    }

    public List<囚人> 集団脱獄される()
    {
        List<囚人> jailbreakers = new ArrayList<>();
        int count = prisoners.size();
        for(int i=0; i<count; i++)
        {
            jailbreakers.add(脱獄される());
        }
        return jailbreakers;
    }
}

public class 脱獄不可刑務所 extends 刑務所
{
    @Override
    public 囚人 脱獄される()
    {
        var prisoner = super.脱獄される();
        if(prisoner != null)
        {
            収容する(prisoner);
        }
        return prisoner;
    }
}

刑務所を疑似的に表したクラスです。派生クラスは囚人に逃げられても必ず捕まえる脱獄不可能な刑務所なんですが、オーバーライドしているのは脱獄される()のみで、集団脱獄される()は対象外です。それでもこの派生クラスが問題なく動作するのは、基底クラスの集団脱獄される()の実装が囚人の人数分、脱獄される()を呼び出しているからです。

さて、ソース・レビューを受けた時、刑務所集団脱獄される()は「リストのコピーを使えば、もっと簡素に実装できますね」と言われ、ナルホドとなり、以下のように書き換えました。

public class 刑務所
{
    // ---------- <中略> ---------- //

    public List<囚人> 集団脱獄される()
    {
        List<囚人> jailbreakers = new ArrayList<>(prisoners);
        prisoners.clear();  // 全員脱獄なので、全クリア
        return jailbreakers;
    }
}

この書き換えにより、脱獄不可能なハズの派生クラスは集団脱獄可能になってしまいました。つまり、集団脱獄する()の実装で脱獄する()を呼び出さなくなったため、拡張クラスで集団脱獄される()が呼び出されると、逃げられたい放題となったワケですね。。。

私が見つけた記事では、これと近い形で "基底クラスの脆弱性問題" が論じられていたんですが、一瞬「あっ、そっか」と思いつつも、すぐに気付きました。この問題の本質は、派生クラスが基底クラスのメソッドの中身を見ながら作成した事 なんですね。基底クラスの集団脱獄される()脱獄される()を呼び出しているから、脱獄される()だけオーバーライドすれば良い、と。しかし継承と言えども各々は別クラスです。派生クラスは基底クラスをオーバーライドする際、メソッドのシグネチャのみに注目し、その中身の実装に立ち入ってはいけません。一般的にも別クラスのメソッドを呼び出すときに、その中身の実装を前提とした記述をしますか?って話です。

ちなみに、この問題を委譲で回避するには刑務所をインターフェース定義し、そのデフォルト実装を作成した上で、脱獄不可刑務所からデフォルト実装を呼び出します。イメージとしては以下です。

public interface 刑務所IF
{
    void 収容する(囚人 prisoner);
    囚人 脱獄される();
    List<囚人> 集団脱獄される();
}

public class 刑務所 implements 刑務所IF
{
    private List<囚人> prisoners = new ArrayList<>();

    @Override
    public void 収容する(囚人 prisoner)
    {
        prisoners.add(prisoner);
    }

    @Override
    public 囚人 脱獄される()
    {
        if(prisoners.isEmpty()) return null;

        return prisoners.remove(0);
    }

    public List<囚人> 集団脱獄される()
    {
        List<囚人> jailbreakers = new ArrayList<>(prisoners);
        prisoners.clear();  // 全員脱獄なので、全クリア

        return jailbreakers;
    }
}

public class 脱獄不可刑務所 implements 刑務所IF
{
    private 刑務所 prison = new 刑務所();

    @Override
    public void 収容する(囚人 prisoner)
    {
        prison.収容する(prisoner); 
    }

    @Override
    public 囚人 脱獄される()
    {
        var prisoner = prison.脱獄される();
        if(prisoner != null)
        {
            prison.収容する(prisoner);
        }
        return prisoner;
    }

    @Override
    public List<囚人> 集団脱獄される()
    {
        var jailbreakers = prison.集団脱獄される();
        jailbreakers.forEach(jailbreaker -> 収容する(jailbreaker));
        return jailbreakers;
    }
}

少し長くなりましたが、脱獄不可刑務所もインターフェースの実装が必須になっていることがポイントですね。集団脱獄される()も必ず実装が必要になるので、前出のような問題は起きません(逆に言うと、委譲であってもインターフェースが無いと同じ問題が生じ得ます)。継承の場合と比べ、どちらが良いか、の判断を私はしませんが、ただこれをもって基底クラスの脆弱性と言われてもなぁ、って思います。なぜなら、これは 別クラスのメソッドの実装を前提としたコーディングをしている、という問題であって、そもそも継承の問題ではないからです。

継承の問題が語られる際、その多くは継承固有の問題ではないにも関わらず、そのように決めつけている場合が多いのでは?というのが私の印象です。

インターフェース定義を伴わない委譲こそ問題!?

特に気になるのは、継承の代わりに委譲! の主張の中に、インターフェース定義を伴わない委譲が散見されることです。この手段を取った場合、例えば以下のような、元々継承の問題点として挙げられていそうな問題があったりします。。。

public class 警備員
{
    public void 見回る(天候 weather, 建物 building)
    {
        if(weather == 天候.RAINY) { カッパを着る(); }

        立ち入る(building);
    }
}

public class しっかり警備員
{
    private 警備員 guard = new 警備員();

    private 報告書 report;

    public しっかり警備員(報告書 report)
    {
        this.report = report;
    }

    public void 見回る(天候 weather, 建物 building)
    {
        guard.見回る(weather, building);
        report.見回り完了報告する(weather, building);
    }
}

警備員のお仕事を超単純化した例ですが、見回りを行う際に雨の時だけカッパを着て、指定された建物に入る、という処理です。これを委譲によって拡張し、見回り後に完了報告をするしっかり警備員を作成したとします(報告を含めて見回りな気もしますが… ツッコミ無用)。

ある時、人手不足や高齢化の影響でしょうか、雨の日の見回りはしなくて良いルールになりました(特に真冬の雨は辛いからね…)。そこで警備員を以下のように書き換えました。ポイントは、新たにメソッドの返却値として実際に建物に入って見回ったかどうか、の真偽値を返すよう変更したことです。

public class 警備員
{
    public boolean 見回る(天候 weather, 建物 building)
    {
        var isPatrolled = switch(weather)
        {
            case SUNNY  -> { 立ち入る(building); yield true; }
            case CLOUDY -> { 立ち入る(building); yield true; }
            case RAINY  -> false;
        };

        return isPatrolled;
    }
}

ここで、漏れなくしっかり警備員も見回りしたかどうかの返却値を受け取り、その上で報告書に記載すべきかどうか、を判定する必要があったのですが、対応を忘れてしまいました。無理もありません。そのままでもコンパイルは通ってしまうのですから…[2]
後日、事件が起こり、その際に見回ってないのに見回ったと記録されていたことが発覚します。結局しっかり警備員うっかり警備員と揶揄され、泣く泣くフェードアウトしましたとさ。。。
※ただどっちみち、雨の日の犯行は見逃されたのですけどね( ^ω^)・・・

このケースの問題点はインターフェース定義をしていなかったことです。もし定義をしていたなら、警備員のシグネチャ変更でしっかり警備員はそのままでは返却値がないのでコンパイル・エラーです。この時点でしっかり警備員も対応が必要なことに気付いたでしょう。そして重要なのは、これは 継承でも回避できた点 です。基底クラスの脆弱性問題は "基底クラスの変更が派生クラスに思わぬ影響を及ぼすため、変更に弱い" でしたが、インターフェース定義を伴わない委譲はもっと弱いのかもしれません。

更に加えると、警備員しっかり警備員も同じ警備員という括りである以上、同じものとして扱う保障が必要でしょう。その保障となるのが、継承やインターフェースの役目です(同じものとして特徴付けるのはメソッド)。それら定義に変更がある可能性を考えれば、関係するクラスにもその定義に従わせるようにするのが肝要です。クラスの機能の拡張を委譲によって実現するならば、インターフェース定義を介すのが良いのでしょう(手間ですが、論理的にはそういうこと🤗)。

あちゃ~🫣

実は継承の問題点が他にもあることを後から知りました。地味ですが非常に悩ましい問題です。
例えば、こんなケース。派生クラスに存在しているメソッドと同じシグネチャのメソッド(抽象メソッドではない)を基底クラスに後から追加した場合。これはJavaの場合だと、コンパイル・オプションやIDEの機能でも派生クラス側のメソッドで@Overrideアノテーションが無い旨の警告を出すことができず、派生クラスは結果的にオーバーライドしてしまってることに気付けないのです。つまり、基底クラスに追加されたメソッドは無視されて実行されてしまうのです。これを避けるにはCheckStyle等、静的解析ツールを使うしかないようです。オーバーライドのキーワードを強制する言語なら問題はないんですけどね。。。

まとめます🧐

考慮すべきことが多く、当初の予想より遥かに長くなってしまいました。まだ足りてないのかも。。。

  • 継承はフィールド変数のアクセス権を派生クラスにまで広げなければ、クラス機能拡張の有力な方法
  • 委譲は結局のところ依存するのだから、委譲先クラスの変更の影響を受ける(本質的な問題は継承と同様)
  • 委譲で拡張する場合は、インターフェース定義を介するのが吉
  • 委譲を使わずインターフェースとその実装のみで実現するのは、疎結合という観点では良いが、各々の実装に同じ処理がダブる(可能性がある)という点では問題
    ※そもそもインターフェースと言えども、メソッドの引数が結合度を強める可能性があることに留意
  • 継承でも委譲でもどちらを使ってもイイと思うが、オブジェクト指向である限り、単一責任の原則は守られるべき

この問題を語る時、ギンタマ(銀の弾)はおそらく存在しないでしょう。ただ変更の影響を軽減する努力は必要だと思います。

脚注
  1. 多重継承ではない。派生した子クラスから更に継承する構造。 ↩︎

  2. 言語やIDEの設定によっては警告が出ると思います(警告をあまり気にしない人は、すぐには気付かないかも)。 ↩︎

GitHubで編集を提案

Discussion