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

Дан следующий класс:

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

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

Ответы [ 8 ]

0 голосов
/ 16 января 2019

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

Ниже показаны итеративные изменения в решении, соответствующие принципам 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 ... Чтобы вызвать их, требуется приведение, и для обеспечения безопасности также следует выполнить проверку экземпляра:

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

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

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

0 голосов
/ 14 января 2019

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

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

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

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

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

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

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

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

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

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

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

0 голосов
/ 15 января 2019

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

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

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, а не Superman.

Вы можете сказать «но я знаю, что это Superman объект, который реализует интерфейс IShovel!». Но тогда вы забудете, что вы только сказали компилятору, что это переменная 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.

0 голосов
/ 14 января 2019

Обычно многие клиентские интерфейсы хороши и являются частью принципа разделения интерфейса («I» в 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 (sic!), Но в зависимости от сложности реализации 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());

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

0 голосов
/ 14 января 2019

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

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

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

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

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

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

0 голосов
/ 14 января 2019

Ваш пример не нарушает 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
    }
}
0 голосов
/ 14 января 2019

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

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

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

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

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


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

Example example = new Example();

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

Interface1 example = new Example();
if (example instanceof Interface2) {
    ((Interface2) example).someInterface2Method();
}
0 голосов
/ 14 января 2019

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

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

interface Interface3 extends Interface1, Interface2 {}

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

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

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

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

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

...