web-dev-qa-db-ja.com

複数のインターフェイスを実装するクラスでLiskov置換の原則を破らないようにするにはどうすればよいですか?

次のクラスがあるとします。

class Example implements Interface1, Interface2 {
    ...
}

Interface1を使用してクラスをインスタンス化すると:

Interface1 example = new Example();

...次に、私がキャストしない限り、Interface1メソッドのみを呼び出すことができ、Interface2メソッドを呼び出すことはできません。

((Interface2) example).someInterface2Method();

もちろん、このランタイムを安全にするには、これをinstanceofチェックでラップする必要があります。

if (example instanceof Interface2) {
    ((Interface2) example).someInterface2Method();
}

両方のインターフェースを拡張するラッパーインターフェースを使用できることは承知していますが、同じクラスで実装できるインターフェースの可能なすべての順列に対応するために、複数のインターフェースを使用することになります。問題のインターフェースは自然にお互いを拡張しないので、継承も間違っているようです。

instanceof/castアプローチは、ランタイムインスタンスに問い合わせてその実装を決定しているときにLSPを壊しますか?

どちらの実装を使用しても、設計または使用方法が悪い場合に何らかの副作用があるようです。

44
jml

両方のインターフェースを拡張するラッパーインターフェースを使用できることは承知していますが、同じクラスで実装できるインターフェースの可能なすべての順列に対応するために、複数のインターフェースが必要になる可能性があります

多くのクラスがインターフェイスのさまざまな組み合わせを実装していることがわかった場合は、次のいずれかになると思います。具体的なクラスが多すぎます。または(可能性は低いですが)インターフェースが小さすぎ、特殊化しすぎて、個別に役に立たなくなるほどで​​す。

両方のコードがInterface1Interface2次に、絶対に先に進み、両方を拡張した結合バージョンを作成します。これに適切な名前を付けるのに苦労している場合(いいえ、FooAndBarではありません)、それは設計が間違っていることを示しています。

絶対にキャストに依存しないでください。これは最後の手段としてのみ使用し、通常は非常に特定の問題(シリアル化など)にのみ使用してください。

私のお気に入りで最も使用されているデザインパターンは、デコレータパターンです。そのため、私のクラスのほとんどは、1つのインターフェースしか実装しません(Comparableなどのより一般的なインターフェースを除く)。あなたのクラスが頻繁に/常に複数のインターフェースを実装しているなら、それはコードのにおいだと私は言うでしょう。


オブジェクトをインスタンス化して同じスコープ内で使用している場合は、

Example example = new Example();

状況下の下であなたがすべきでないことは明らかです(これがあなたが提案していたものかどうかわかりません) )everは次のように書きます:

Interface1 example = new Example();
if (example instanceof Interface2) {
    ((Interface2) example).someInterface2Method();
}
35
Michael

クラスは複数のインターフェースを適切に実装でき、OOPの原則に違反していません。逆に、 インターフェース分離の原則 に従っています。

_Interface1_型のものがsomeInterface2Method()を提供することが期待される状況があるのはなぜか混乱しています。それはあなたのデザインが間違っているところです。

少し異なる方法で考えてみましょう。別のメソッドvoid method1(Interface1 interface1)があるとします。 _interface1_が_Interface2_のインスタンスであることも期待できません。その場合、引数のタイプは異なっているはずです。あなたが示した例はまさにこれであり、_Interface1_型の変数を持っていますが、_Interface2_型でもあると想定しています。

両方のメソッドを呼び出すことができるようにするには、変数のタイプexampleExampleに設定する必要があります。そうすれば、instanceofを避けて型キャストを完全に行うことができます。

2つのインターフェース_Interface1_と_Interface2_がそれほど疎結合ではなく、多くの場合、両方からメソッドを呼び出す必要がある場合、おそらくインターフェースを分離するのはあまり良いアイデアではなかったでしょう。両方を拡張する別のインターフェース。

一般に(常にではありませんが)、instanceofチェックと型キャストは、いくつかのOO設計上の欠陥を示します。設計がプログラムの残りの部分に適合することもありますが、すべてをリファクタリングするよりもキャストをタイプする方が簡単な小さなケースですが、可能であれば、設計の一環として、最初は常にそれを回避するように努めるべきです。

25
jbx

あなたには2つの異なるオプションがあります(私はもっとたくさんあると思います)。

1つ目は、他の2つを拡張する独自のinterfaceを作成することです。

interface Interface3 extends Interface1, Interface2 {}

そして、それをコード全体で使用します。

public void doSomething(Interface3 interface3){
    ...
}

もう1つの方法(そして私の意見ではより良い方法)は、メソッドごとにジェネリックを使用することです。

public <T extends Interface1 & Interface2> void doSomething(T t){
    ...
}

ジェネリック型Tは動的に推論され、結合が少なくなるため、後者のオプションは実際には前者よりも制限が少なくなります(最初の例のように、クラスが特定のグループ化インターフェースを実装する必要がない) 。

11
Lino

中心的な問題

中心的な問題に対処できるように、例を少し調整します。

_public void DoTheThing(Interface1 example)
{
    if (example instanceof Interface2) 
    {
        ((Interface2) example).someInterface2Method();
    }
}
_

したがって、メソッドDoTheThing(Interface1 example)を定義しました。これは基本的に「そのためには、_Interface1_オブジェクトが必要です」と言っています。

しかし、メソッド本体では、実際には_Interface2_オブジェクトが必要なようです。では、なぜメソッドパラメータで要求しないのですか?明らかに、あなたは_Interface2_を求めてきたはずです

ここでしていることは仮定取得した_Interface1_オブジェクトが_Interface2_オブジェクトになることです。これは信頼できるものではありません。あなたmightは、両方のインターフェースを実装するクラスをいくつか持っていますが、一方だけを実装し、もう一方を実装しないクラスもあるかもしれません。

_Interface1_と_Interface2_の両方を同じオブジェクトに実装する必要があるという固有の要件はありません。あなたはこれが事実であることを知ることはできません(仮定に頼ることもできません)。

固有の要件を定義して適用しない限り

_interface InterfaceBoth extends Interface1, Interface2 {}

public void DoTheThing(InterfaceBoth example)
{
    example.someInterface2Method();
}
_

この場合、_Interface1_と_Interface2_の両方を実装するためにInterfaceBothオブジェクトが必要です。したがって、InterfaceBothオブジェクトを要求するときはいつでも、_Interface1_と_Interface2_の両方を実装するオブジェクトを確実に取得できるため、どちらのインターフェイスからでもメソッドを使用する必要はありません。タイプをキャストまたはチェックします。

あなた(そしてコンパイラ)は、このメソッドがalwaysを利用できることを知っており、これが機能しない可能性はありません。

注:Exampleインターフェースを作成する代わりにInterfaceBothを使用することもできますが、その場合はExampleであり、両方のインターフェースを実装する他のクラスはありません。 Exampleだけでなく、両方のインターフェイスを実装するクラスの処理に興味があると思います。

問題をさらに分解する

このコードを見てください:

_ICarrot myObject = new Superman();
_

このコードがコンパイルされると想定した場合、Supermanクラスについて何がわかりますか? ICarrotインターフェースを明確に実装していること。あなたが私に言うことができるすべてです。 SupermanIShovelインターフェースを実装しているかどうかはわかりません。

だから私がこれをやろうとすると:

_myObject.SomeMethodThatIsFromSupermanButNotFromICarrot();
_

またはこれ:

_myObject.SomeMethodThatIsFromIShovelButNotFromICarrot();
_

このコードがコンパイルされると言っても驚かないでしょうか?このコードはコンパイルしないなので、そうする必要があります。

「しかし、このメソッドを持つのはSupermanオブジェクトであることは知っています!」ただし、そのことは忘れてしまいますICarrot変数ではなく、Superman変数であるとコンパイラに伝えただけです。

「しかし、それはSupermanインターフェースを実装するIShovelオブジェクトであることを私は知っています!」と言うかもしれません。ただし、そのことは忘れてしまいますICarrotまたはSuperman変数ではなく、IShovel変数であるとコンパイラに伝えただけです。

これを知って、コードを振り返ってみましょう。

_Interface1 example = new Example();
_

あなたが言ったすべてはあなたが_Interface1_変数を持っているということです。

_if (example instanceof Interface2) {
    ((Interface2) example).someInterface2Method();
}
_

この_Interface1_オブジェクトが2番目の無関係なインターフェースも実装していると想定しても、意味がありません。このコードが技術レベルで機能する場合でもこれは悪い設計の兆候です、開発者は実際にこの相関を作成せずに2つのインターフェース間の固有の相関を期待しています。

Exampleオブジェクトを挿入していることはわかっていますが、コンパイラも知っているはずです!」しかし、これがメソッドパラメータである場合、メソッドの呼び出し元が何を送信しているかを知る方法がないという点を逃してしまいます。

_public void DoTheThing(Interface1 example)
{
    if (example instanceof Interface2) 
    {
        ((Interface2) example).someInterface2Method();
    }
}
_

他の呼び出し元がこのメソッドを呼び出す場合、渡されたオブジェクトが_Interface1_を実装していない場合にのみ、コンパイラーはそれらを停止します。コンパイラーは、誰かが_Interface1_を実装しているが_Interface2_を実装していないクラスのオブジェクトを渡すことを阻止しません。

8
Flater

あなたの例はLSPを壊しませんが、それはSRPを壊すようです。オブジェクトを2番目のインターフェースにキャストする必要があるような場合は、そのようなコードを含むメソッドがビジーであると見なすことができます。

クラスに2つ(またはそれ以上)のインターフェースを実装することは問題ありません。データ型として使用するインターフェイスの決定は、それを使用するコードのコンテキストに完全に依存します。

キャストは、特にコンテキストを変更する場合に問題ありません。

class Payment implements Expirable, Limited {
 /* ... */
}

class PaymentProcessor {
    // Using payment here because i'm working with payments.
    public void process(Payment payment) {
        boolean expired = expirationChecker.check(payment);
        boolean pastLimit = limitChecker.check(payment);

        if (!expired && !pastLimit) {
          acceptPayment(payment);
        }
    }
}

class ExpirationChecker {
    // This the `Expirable` world, so i'm  using Expirable here
    public boolean check(Expirable expirable) {
        // code
    }
}

class LimitChecker {
    // This class is about checking limits, thats why im using `Limited` here
    public boolean check(Limited limited) {
        // code
    }
}
7
sweet suman

通常、多くのクライアント固有のインターフェースは問題なく、 インターフェース分離原則[〜 #〜]固体[〜#〜] )。技術レベルでのいくつかのより具体的なポイントは、すでに他の回答で言及されています。

特にあなたがすることができますのようなクラスを持つことにより、この分離に行き過ぎます

class Person implements FirstNameProvider, LastNameProvider, AgeProvider ... {
    @Override String getFirstName() {...}
    @Override String getLastName() {...}
    @Override int getAge() {...}
    ...
}

または、逆に、次のように強力すぎる実装クラスがある場合

class Application implements DatabaseReader, DataProcessor, UserInteraction, Visualizer {
    ...
}

インターフェース分離の原則の要点は、インターフェースが client-specific であるべきだということです。彼らは基本的に、特定のタスクで特定のクライアントが必要とする機能を「要約」する必要があります。

言い換えると、問題は、上でスケッチした両極端の間の適切なバランスを取ることです。インターフェイスとそれらの関係を(相互に、およびそれらを実装するクラスの観点から)把握しようとするときは、意図的にナイーブな方法で常に一歩下がって自分自身に問いかけます。を受け取り、はそれで何をしますか?

あなたの例について:すべてのクライアント always Interface1Interface2の機能を同時に必要とする場合、

interface Combined extends Interface1, Interface2 { }

そもそも異なるインターフェースを持っていないか。一方、機能が完全に異なり、無関係であり、 never を一緒に使用する場合、単一のクラスがそれらを同時に実装しているのはなぜか不思議に思うはずです。

この時点で、別の原則、つまり 継承より継承 を参照できます。古典的には複数のインターフェースの実装とは関係ありませんが、この場合、合成も可能です。たとえば、インターフェイスを実装しないようにクラスを変更して直接できますが、それらを実装するインスタンスのみを提供します。

class Example {
    Interface1 getInterface1() { ... }
    Interface2 getInterface2() { ... }
}

このExample(sic!)は少し奇妙に見えますが、Interface1Interface2の実装の複雑さによっては、これらを分離しておくのが実際的です。


コメントに応じて編集:

ここでの意図は、両方のインターフェースを必要とするメソッドに具象クラスExampleを渡すことを not にすることです。これが理にかなっているのは、クラスが両方のインターフェースの機能を組み合わせる場合ではなく、直接それらを同時に実装することによってそうしない場合です。不自然に見えない例を作るのは難しいですが、このようなものはアイデアを全体にもたらすかもしれません:

interface DatabaseReader { String read(); }
interface DatabaseWriter { void write(String s); }

class Database {
    DatabaseConnection connection = create();
    DatabaseReader reader = createReader(connection);
    DatabaseReader writer = createWriter(connection);

    DatabaseReader getReader() { return reader; }
    DatabaseReader getWriter() { return writer; }
}

クライアントは引き続きインターフェースに依存します。のような方法

void create(DatabaseWriter writer) { ... }
void read  (DatabaseReader reader) { ... }
void update(DatabaseReader reader, DatabaseWriter writer) { ... }

次に呼び出すことができます

create(database.getWriter());
read  (database.getReader());
update(database.getReader(), database.getWriter());

それぞれ。

6
Marco13

このページのさまざまな投稿やコメントのおかげで、自分のシナリオに合ったと思う解決策が作成されました。

以下は、SOLIDの原則を満たすためのソリューションへの反復的な変更を示しています。

要件

Webサービスの応答を生成するために、キーとオブジェクトのペアが応答オブジェクトに追加されます。追加する必要のあるさまざまなキー+オブジェクトのペアがたくさんあります。それぞれに、ソースからのデータを応答で必要な形式に変換するために必要な一意の処理がある場合があります。

このことから、ソースデータをターゲットレスポンスオブジェクトに変換するために、異なるキー/値のペアには異なる処理要件がある可能性がありますが、それらはすべて、オブジェクトをレスポンスオブジェクトに追加するという共通の目標を持っています。

したがって、ソリューションの反復1で次のインターフェースが作成されました。

ソリューション反復1

ResponseObjectProvider<T, S> {
    void addObject(T targetObject, S sourceObject, String targetKey);
}

応答にオブジェクトを追加する必要がある開発者は、要件に一致する既存の実装を使用するか、新しいシナリオを指定して新しい実装を追加できます。

これは、応答オブジェクトを追加するというこの一般的な慣習のコントラクトとして機能する共通のインターフェースを持っているので、すばらしいです

ただし、1つのシナリオでは、特定のキー「識別子」を指定して、ソースオブジェクトからターゲットオブジェクトを取得する必要があります。

ここにはオプションがあります。最初の方法は、次のように既存のインターフェースの実装を追加することです。

public class GetIdentifierResponseObjectProvider<T extends Map, S extends Map> implements ResponseObjectProvider<T, S> {
  public void addObject(final T targetObject, final S sourceObject, final String targetKey) {
     targetObject.put(targetKey, sourceObject.get("identifier"));
  }
}

これは機能しますが、このシナリオは他のソースオブジェクトキー( "startDate"、 "endDate"など)に必要になる可能性があるため、この実装をより汎用的にして、このシナリオで再利用できるようにする必要があります。

さらに、他の実装では、addObject操作を実行するためにより多くのコンテキスト情報が必要になる場合があります。そのため、これに対応するために新しいジェネリック型を追加する必要があります

ソリューション反復2

ResponseObjectProvider<T, S, U> {
    void addObject(T targetObject, S sourceObject, String targetKey);
    void setParams(U params);
    U getParams();
}

このインターフェースは、両方の使用シナリオに対応しています。 addObject操作を実行するために追加のパラメーターを必要とする実装と、そうでない実装

ただし、後者の使用シナリオを考慮すると、追加のパラメーターを必要としない実装は、SOLIDインターフェース分離の原則を破ります。これらの実装はgetParamsおよびsetParamsメソッドをオーバーライドしますが、それらを実装しないためです。例:

public class GetObjectBySourceKeyResponseObjectProvider<T extends Map, S extends Map, U extends String> implements ResponseObjectProvider<T, S, U> {
    public void addObject(final T targetObject, final S sourceObject, final String targetKey) {
        targetObject.put(targetKey, sourceObject.get(U));
    }

    public void setParams(U params) {
        //unimplemented method
    }

    U getParams() {
        //unimplemented method
    }

}

ソリューション反復

インターフェース分離の問題を修正するために、getParamsおよびsetParamsインターフェースメソッドが新しいインターフェースに移動されました。

public interface ParametersProvider<T> {
    void setParams(T params);
    T getParams();
}

パラメータを必要とする実装で、ParametersProviderインターフェースを実装できるようになりました。

public class GetObjectBySourceKeyResponseObjectProvider<T extends Map, S extends Map, U extends String> implements ResponseObjectProvider<T, S>, ParametersProvider<U>

  private String params;
  public void setParams(U params) {
      this.params = params;
  }

  public U getParams() {
    return this.params;
  }

  public void addObject(final T targetObject, final S sourceObject, final String targetKey) {
     targetObject.put(targetKey, sourceObject.get(params));
  }
}

これにより、インターフェース分離の問題は解決しますが、さらに2つの問題が発生します...呼び出し元のクライアントがインターフェースにプログラムする場合は、次のようになります。

ResponseObjectProvider responseObjectProvider = new  GetObjectBySourceKeyResponseObjectProvider<>();

その後、インスタンスでaddObjectメソッドを使用できますが、ParametersProviderインターフェースのgetParamsおよびsetParamsメソッドは使用できません...これらを呼び出すにはキャストが必要であり、安全を確保するためにinstanceofチェックも実行する必要があります。

if(responseObjectProvider instanceof ParametersProvider) {
      ((ParametersProvider)responseObjectProvider).setParams("identifier");
}

これは望ましくないだけでなく、Liskov置換の原則にも違反します-"SがTのサブタイプである場合、プログラム内のタイプTのオブジェクトは、変更せずにタイプSのオブジェクトで置き換えることができます。そのプログラムの望ましい特性の "

つまり、ParametersProviderも実装するResponseObjectProviderの実装を、ParametersProviderを実装しない実装に置き換えた場合、プログラムの望ましいプロパティの一部が変更される可能性があります...さらに、クライアントは、どの実装が含まれているかを認識する必要があります。正しいメソッドを呼び出すために使用

追加の問題は、クライアントを呼び出すための使用法です。呼び出し側のクライアントが両方のインターフェースを実装するインスタンスを使用してaddObjectを複数回実行する場合、addObjectの前にsetParamsメソッドを呼び出す必要があります...呼び出し時に注意を払わないと、回避可能なバグが発生する可能性があります。

ソリューション反復4-最終ソリューション

Solution Iteration 3から生成されたインターフェースは、現在知られているすべての使用要件を解決し、ジェネリックによってさまざまなタイプを使用して実装するための柔軟性を提供します。ただし、このソリューションはLiskov置換の原則を破り、呼び出し側クライアントに対してsetParamsの明白でない使用法があります

解決策は、ParameterisedResponseObjectProviderとResponseObjectProviderの2つの別々のインターフェースを用意することです。

これにより、クライアントはインターフェースにプログラミングでき、応答に追加されるオブジェクトに追加のパラメーターが必要かどうかに応じて、適切なインターフェースを選択します

新しいインターフェイスは、最初にResponseObjectProviderの拡張として実装されました。

public interface ParameterisedResponseObjectProvider<T,S,U> extends ResponseObjectProvider<T, S> {
    void setParams(U params);   
    U getParams();
}

ただし、これにはまだ使用上の問題があり、呼び出し側のクライアントは最初にsetParamsを呼び出してからaddObjectを呼び出す必要があり、コードも読みにくくなります。

したがって、最終的なソリューションには、次のように定義された2つの別個のインターフェースがあります。

public interface ResponseObjectProvider<T, S> {
    void addObject(T targetObject, S sourceObject, String targetKey);   
}


public interface ParameterisedResponseObjectProvider<T,S,U> {
    void addObject(T targetObject, S sourceObject, String targetKey, U params);
}

このソリューションは、インターフェース分離とリスコフ置換の原則の違反を解決し、クライアントの呼び出しの使用法を改善し、コードの可読性を改善します。

これは、クライアントが異なるインターフェイスを認識する必要があることを意味しますが、コントラクトが異なるため、特にソリューションが回避したすべての問題を検討する場合、これは正当な決定であると思われます。

4
jml

あなたが説明する問題は、多くの場合、言語が1つのインターフェイスのメンバーをデフォルトで、適切な動作を実装できる静的メソッドにチェーンするように指定できないことを助長する、インターフェイス分離原則の熱狂的な適用によって発生します。

たとえば、基本的なシーケンス/列挙インターフェースと次の動作を考えてみます。

  1. 他のイテレータがまだ作成されていない場合にオブジェクトを読み取ることができる列挙子を作成します。

  2. 別のイテレータがすでに作成および使用されている場合でもオブジェクトを読み取ることができる列挙子を作成します。

  3. シーケンス内のアイテム数を報告する

  4. シーケンスのN番目の項目の値を報告します

  5. ある範囲のアイテムをオブジェクトからそのタイプの配列にコピーします。

  6. 変更されないことが保証されているコンテンツで上記の操作に効率的に対応できる不変オブジェクトへの参照を生成します。

そのような機能は、上記の操作のどれが意味のある形でサポートされているかを示すためのメソッド/プロパティとともに、基本的なシーケンス/列挙インターフェイスの一部であるべきだと私は提案します。一部の種類のシングルショットオンデマンド列挙子(たとえば、無限の真にランダムなシーケンスジェネレーター)は、これらの関数をサポートできない場合がありますが、そのような関数を個別のインターフェイスに分離すると、多くの種類の効率的なラッパーを生成することがはるかに困難になります。操作の。

最初の機能をサポートする有限のシーケンスで、必ずしも効率的ではないが、上記の操作のすべてに対応するラッパークラスを生成できます。ただし、クラスがこれらの機能の一部をすでにサポートしているオブジェクトをラップするために使用されている場合(たとえば、N番目の項目にアクセスする)、ラッパーに基本的な動作を使用させると、上記の2番目の関数ですべてを実行するよりもはるかに効率的です。 (たとえば、新しい列挙子を作成し、それを使用して、シーケンスからアイテムを繰り返し読み取り、目的のアイテムに到達するまで無視します)。

あらゆる種類のシーケンスを生成するすべてのオブジェクトが、上記のすべてを含むインターフェースをサポートし、サポートされている機能の指示を示すことは、機能の異なるサブセットに異なるインターフェースを用意することよりも、ラッパークラスがクライアントに公開したい組み合わせの明示的な規定。

1
supercat