Как избежать нарушения принципа подстановки Лискова с помощью класса, который реализует несколько интерфейсов?

Учитывая следующий класс:

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, когда я опрашиваю экземпляр среды выполнения, чтобы определить его реализации?

Какую бы реализацию я не использовал, у нее, похоже, есть побочный эффект - либо плохой дизайн, либо использование.

Ответы

Ответ 1

Я знаю, что у меня мог бы быть интерфейс-обертка, который расширяет оба интерфейса, но тогда я мог бы получить несколько интерфейсов для обслуживания всех возможных перестановок интерфейсов, которые могут быть реализованы одним и тем же классом.

Я подозреваю, что если вы обнаружите, что многие ваши классы реализуют различные комбинации интерфейсов, то либо: ваши конкретные классы делают слишком много; или (менее вероятно) ваши интерфейсы слишком малы и слишком специализированы, чтобы быть бесполезными по отдельности.

Если у вас есть веские основания для того, чтобы какой-то код требовал чего-то, что является одновременно Interface1 и Interface2 тогда обязательно сделайте комбинированную версию, которая расширяет оба варианта. Если вы изо всех сил FooAndBar придумать подходящее название для этого (нет, не FooAndBar), то это показатель того, что ваш дизайн неверен.

Абсолютно не полагайтесь на кастинг. Он должен использоваться только в качестве крайней меры и обычно только для очень специфических проблем (например, сериализация).

Мой любимый и наиболее используемый шаблон дизайна - шаблон декоратора. Поэтому большинство моих классов будут реализовывать только один интерфейс (за исключением более общих интерфейсов, таких как Comparable). Я бы сказал, что если ваши классы часто/всегда реализуют более одного интерфейса, то это пахнет кодом.


Если вы создаете экземпляр объекта и используете его в одной и той же области видимости, тогда вам просто нужно написать

Example example = new Example();

Просто чтобы было ясно (я не уверен, что это то, что вы предлагали), ни при каких обстоятельствах вы никогда не должны писать что-то вроде этого:

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

Ответ 2

Ваш класс может прекрасно реализовать несколько интерфейсов, и это не нарушает никаких принципов ООП. Напротив, он следует принципу разделения интерфейса.

Это сбивает с толку, почему у вас возникла ситуация, когда что-то типа Interface1 должно предоставлять someInterface2Method(). Вот где ваш дизайн не так.

Подумайте об этом немного по-другому: представьте, что у вас есть другой метод, void method1(Interface1 interface1). Он не может ожидать, что interface1 также будет экземпляром Interface2. Если бы это было так, тип аргумента должен был быть другим. Пример, который вы показали, именно такой, имеет переменную типа Interface1 но ожидает, что она также будет иметь тип Interface2.

Если вы хотите иметь возможность вызывать оба метода, вы должны иметь тип вашей переменной example установленный на Example. Таким образом вы полностью избегаете instanceof и приведение типов.

Если ваши два интерфейса Interface1 и Interface2 не так слабо связаны, и вам часто придется вызывать методы из обоих, возможно, разделение интерфейсов было не очень хорошей идеей, или вам нужен другой интерфейс, который расширяет оба.

В целом (хотя и не всегда), instanceof проверок и приведение типов часто указывают на некоторые недостатки в дизайне ОО. Иногда дизайн подходил бы для остальной части программы, но у вас был бы небольшой случай, когда проще набирать приведение, чем реорганизовывать все. Но если возможно, вы всегда должны сначала избегать этого, как часть вашего дизайна.

Ответ 3

У вас есть два разных варианта (держу пари, их намного больше).

Первый - создать свой собственный interface который расширяет два других:

interface Interface3 extends Interface1, Interface2 {}

И затем используйте это во всем вашем коде:

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

Другой способ (и, на мой взгляд, лучший) заключается в использовании обобщений для каждого метода:

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

Последний вариант на самом деле менее ограничен, чем первый, потому что обобщенный тип T динамически выводится и, следовательно, приводит к меньшему сцеплению (класс не должен реализовывать определенный интерфейс группировки, как в первом примере).

Ответ 4

Основная проблема

Немного подправив ваш пример, чтобы я мог решить основную проблему:

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

Итак, вы определили метод DoTheThing(Interface1 example). Это в основном говорит: "Для этого мне нужен объект Interface1 ".

Но затем, в вашем теле метода, кажется, что вам действительно нужен объект Interface2. Тогда почему вы не попросили один в параметрах вашего метода? Совершенно очевидно, что вы должны были просить Interface2

Здесь вы предполагаете, что любой объект Interface1 вы получите, будет также объектом Interface2. Это не то, на что вы можете положиться. У вас могут быть некоторые классы, которые реализуют оба интерфейса, но вы также можете иметь некоторые классы, которые реализуют только один, а не другой.

Не существует неотъемлемого требования, согласно которому Interface1 и Interface2 должны быть реализованы на одном и том же объекте. Вы не можете знать (и не полагаться на предположение), что это так.

Если вы не определите неотъемлемое требование и не примените его.

interface InterfaceBoth extends Interface1, Interface2 {}

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

В этом случае вам необходим объект InterfaceBoth для реализации Interface1 и Interface2. Таким образом, всякий раз, когда вы запрашиваете объект InterfaceBoth, вы можете быть уверены, что получите объект, который реализует как Interface1 и Interface2, и, таким образом, вы можете использовать методы из любого интерфейса, даже не требуя приведения или проверки типа.

Вы (и компилятор) знаете, что этот метод всегда будет доступен, и нет никаких шансов, что это не сработает.

Примечание: вы могли бы использовать Example вместо создания InterfaceBoth, но тогда вы могли бы использовать только объекты типа Example а не любой другой класс, который бы реализовывал оба интерфейса. Я предполагаю, что вы заинтересованы в обработке любого класса, который реализует оба интерфейса, а не только Example.

Деконструируем проблему дальше.

Посмотрите на этот код:

ICarrot myObject = new Superman();

Если вы предполагаете, что этот код компилируется, что вы можете рассказать мне о классе Superman? Что он четко реализует интерфейс ICarrot. Это все, что вы можете сказать мне. Вы не представляете, реализует ли Superman интерфейс IShovel или нет.

Так что, если я попытаюсь сделать это:

myObject.SomeMethodThatIsFromSupermanButNotFromICarrot();

или это:

myObject.SomeMethodThatIsFromIShovelButNotFromICarrot();

Стоит ли удивляться, если я скажу, что этот код компилируется? Вы должны, потому что этот код не компилируется.

Вы можете сказать "но я знаю, что это объект Superman который имеет этот метод!". Но тогда вы ICarrot , что сказали компилятору, что это переменная ICarrot, а не переменная Superman.

Вы можете сказать "но я знаю, что это объект Superman который реализует интерфейс IShovel !". Но тогда вы ICarrot , что сказали компилятору, что это переменная ICarrot, а не переменная Superman или IShovel.

Зная это, давайте оглянемся на ваш код.

Interface1 example = new Example();

Все, что вы сказали, это то, что у вас есть переменная Interface1.

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

Нет смысла предполагать, что этот объект Interface1 также реализует второй не связанный интерфейс. Даже если этот код работает на техническом уровне, это признак плохого дизайна, разработчик ожидает некоторой внутренней корреляции между двумя интерфейсами, фактически не создав эту корреляцию.

Вы можете сказать: "Но я знаю, что я помещаю объект Example, компилятор тоже должен это знать!" но вы бы упустили момент, что если бы это был параметр метода, у вас не было бы никакой возможности узнать, что отправляют вызывающие вашего метода.

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

Когда другие вызывающие вызовут этот метод, компилятор остановит их, только если переданный объект не реализует Interface1. Компилятор не собирается мешать кому-либо передавать объект класса, который реализует Interface1 но не реализует Interface2.

Ответ 5

Ваш пример не нарушает LSP, но, кажется, нарушает SRP. Если вы сталкиваетесь с тем случаем, когда вам нужно привести объект ко второму интерфейсу, метод, содержащий такой код, можно считать занятым.

Реализация 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
    }
}

Ответ 6

Обычно многие клиентские интерфейсы хороши и являются частью принципа сегрегации интерфейса ("Я" в SOLID). Некоторые технические аспекты уже упоминались в других ответах.

В частности, что вы можете зайти слишком далеко с этим разделением, имея такой класс, как

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

Или, наоборот, что у вас есть реализующий класс, который является слишком мощным, как в

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

Я думаю, что основной момент в принципе разделения интерфейсов состоит в том, что интерфейсы должны быть специфичными для клиента. Они должны в основном "суммировать" функции, которые требуются определенному клиенту для определенной задачи.

Говоря так: проблема в том, чтобы найти правильный баланс между крайностями, которые я набросал выше. Когда я пытаюсь выяснить, интерфейсы и их отношения (взаимно, и в терминах классов, которые реализуют их), я всегда стараюсь, чтобы сделать шаг назад и спросить себя, в намеренно наивном образе: кто собирается получить, что и что он собирается с этим делать?

Что касается вашего примера: когда все ваши клиенты всегда нуждаются в функциональности Interface1 и Interface2 одновременно, тогда вам следует рассмотреть возможность определения

interface Combined extends Interface1, Interface2 { }

или не иметь разные интерфейсы в первую очередь. С другой стороны, когда функциональные возможности полностью различны и не связаны друг с другом и никогда не используются вместе, вы должны задаться вопросом, почему один класс реализует их одновременно.

На этом этапе можно сослаться на другой принцип, а именно на композицию по наследованию. Хотя это классически не связано с реализацией нескольких интерфейсов, в этом случае состав также может быть благоприятным. Например, вы можете изменить свой класс, чтобы не реализовывать интерфейсы напрямую, а только предоставлять экземпляры, которые их реализуют:

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

В этом Example это выглядит немного странно (так!), Но в зависимости от сложности реализации Interface1 и Interface2 может иметь смысл разделять их.


Отредактировано в ответ на комментарий:

Цель здесь не в том, чтобы передать конкретный Example класса методам, которым нужны оба интерфейса. Случай, когда это может иметь смысл, скорее всего, когда класс объединяет функциональные возможности обоих интерфейсов, но не делает это, напрямую реализуя их одновременно. Трудно создать пример, который не выглядит слишком надуманным, но что-то вроде этого может донести идею:

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());

соответственно.

Ответ 7

С помощью различных постов и комментариев на этой странице было найдено решение, которое я считаю правильным для моего сценария.

Ниже показаны итеративные изменения в решении, отвечающие принципам SOLID.

требование

Для создания ответа для веб-службы пары ключ + объект добавляются к объекту ответа. Необходимо добавить множество пар "ключ + объект", каждая из которых может иметь уникальную обработку, необходимую для преобразования данных из источника в формат, требуемый в ответе.

Из этого ясно, что, хотя разные пары ключ/значение могут иметь разные требования к обработке для преобразования исходных данных в целевой объект ответа, все они имеют общую цель добавления объекта в объект ответа.

Поэтому в итерации 1 был создан следующий интерфейс:

Решение Итерации 1

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

Любой разработчик, которому нужно добавить объект в ответ, теперь может сделать это, используя существующую реализацию, соответствующую их требованию, или добавить новую реализацию с учетом нового сценария.

Это замечательно, так как у нас есть общий интерфейс, который действует как контракт для этой обычной практики добавления объектов ответа

Однако один сценарий требует, чтобы целевой объект был взят из исходного объекта с определенным ключом, "идентификатором".

Здесь есть варианты, во-первых, добавить реализацию существующего интерфейса следующим образом:

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
    }

}

Решение Итерация 3

Чтобы исправить проблему разделения интерфейса, методы интерфейса 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));
  }
}

Это решает проблему разделения интерфейса, но вызывает еще две проблемы... Если вызывающий клиент хочет запрограммировать интерфейс, то есть:

ResponseObjectProvider responseObjectProvider = new  GetObjectBySourceKeyResponseObjectProvider<>();

Тогда метод addObject будет доступен для экземпляра, но НЕ для методов getParams и setParams интерфейса ParametersProvider... Чтобы вызвать их, требуется приведение, и для безопасности также должна быть выполнена проверка instanceof:

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

Это не только нежелательно, но и нарушает принцип подстановки Лискова - "если S является подтипом T, то объекты типа T в программе могут быть заменены объектами типа S без изменения каких-либо желательных свойств этой программы".

т.е. если мы заменили реализацию ResponseObjectProvider, которая также реализует ParametersProvider, реализацией, которая не реализует ParametersProvider, то это может изменить некоторые из желательных свойств программы... Кроме того, клиент должен знать, какая реализация находится в использовать для вызова правильных методов

Дополнительной проблемой является использование для вызова клиентов. Если вызывающий клиент хотел использовать экземпляр, который реализует оба интерфейса, для выполнения addObject несколько раз, метод setParams должен был бы быть вызван перед addObject... Это может привести к ошибкам, которых можно избежать, если при вызове не соблюдать осторожность.

Решение Итерация 4 - Окончательное решение

Интерфейсы, созданные в Solution Iteration 3, решают все известные на данный момент требования к использованию с некоторой гибкостью, обеспечиваемой обобщениями для реализации с использованием различных типов. Однако это решение нарушает принцип подстановки Лискова и имеет неочевидное использование setParams для вызывающего клиента.

Решение состоит в том, чтобы иметь два отдельных интерфейса, ParameterisedResponseObjectProvider и ResponseObjectProvider.

Это позволяет клиенту программировать на интерфейсе и выбирать соответствующий интерфейс в зависимости от того, требуют ли дополнительные параметры добавляемые в ответ объекты или нет.

Новый интерфейс был впервые реализован как расширение ResponseObjectProvider:

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

Однако это все еще имело проблему с использованием, когда вызывающему клиенту сначала нужно было бы вызвать setParams перед вызовом addObject, а также сделать код менее читабельным.

Таким образом, окончательное решение имеет два отдельных интерфейса, определенных следующим образом:

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);
}

Это решение устраняет нарушения принципов сегрегации интерфейса и подстановки Лискова, а также улучшает использование для вызова клиентов и улучшает читабельность кода.

Это означает, что клиент должен знать о различных интерфейсах, но поскольку контракты различаются, это кажется оправданным решением, особенно при рассмотрении всех проблем, которых решение избежало.

Ответ 8

Проблема, которую вы описываете, часто возникает из-за чрезмерного усердия в применении принципа сегрегации интерфейса, поощряемого неспособностью языков указать, что члены одного интерфейса по умолчанию должны быть связаны со статическими методами, которые могут реализовать разумное поведение.

Рассмотрим, например, базовый интерфейс последовательности/перечисления и следующие варианты поведения:

  1. Создайте перечислитель, который может считывать объекты, если другой итератор еще не создан.

  2. Создайте перечислитель, который может считывать объекты, даже если другой итератор уже создан и использован.

  3. Сообщите, сколько элементов в последовательности

  4. Сообщите значение N-го элемента в последовательности

  5. Скопируйте диапазон элементов из объекта в массив этого типа.

  6. Получите ссылку на неизменный объект, который может эффективно разместить вышеуказанные операции с содержимым, которое гарантированно никогда не изменится.

Я хотел бы предположить, что такие способности должны быть частью базового интерфейса последовательности/перечисления, наряду с методом/свойством, чтобы указать, какие из вышеперечисленных операций поддерживаются. Некоторые типы одноразовых перечислителей по требованию (например, бесконечный генератор действительно случайных последовательностей) могут не поддерживать ни одну из этих функций, но разделение таких функций на отдельные интерфейсы значительно усложнит создание эффективных оболочек для многих типов. операций.

Можно создать класс-оболочку, который бы вмещал все вышеперечисленные операции, хотя и не обязательно эффективно, в любой конечной последовательности, которая поддерживает первую способность. Однако, если класс используется для обертывания объекта, который уже поддерживает некоторые из этих способностей (например, доступ к N-му элементу), использование обертки с использованием базовых поведений может быть гораздо более эффективным, чем выполнение всего этого с помощью второй функции, описанной выше. (например, создание нового перечислителя и использование его для итеративного чтения и игнорирования элементов из последовательности, пока не будет достигнут желаемый).

Наличие всех объектов, которые производят любые виды последовательностей, поддерживает интерфейс, который включает в себя все вышеперечисленное, вместе с указанием того, какие возможности поддерживаются, было бы чище, чем пытаться иметь разные интерфейсы для разных подмножеств способностей, и требовать, чтобы классы-оболочки создавали явное предоставление любых комбинаций, которые они хотят представить своим клиентам.