Как вы делаете GUI для полиморфного класса?

17

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

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

Я хотел бы избежать двух вещей:

  1. Проверка типа или приведение типа
  2. Все, что связано с GUI в моем коде данных.

В моей первой попытке я получаю следующие классы:

class Test{
    List<Question> questions;
}
interface Question { }
class MultipleChoice implements Question {}
class TextBox implements Question {}

Однако, когда я иду показывать тест, я неизбежно получаю такой код:

for (Question question: questions){
    if (question instanceof MultipleChoice){
        display.add(new MultipleChoiceViewer());
    } 
    //etc
}

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

Натан Меррилл
источник
6
Это не плохая идея спросить о вещах, с которыми у вас проблемы, но для меня этот вопрос имеет тенденцию быть слишком широким / неясным, и, наконец, вы
задаете
1
В общем, я стараюсь избегать проверок типов / приведение типов, так как это обычно приводит к меньшему количеству проверок во время компиляции и в основном «обходит» полиморфизм, а не использует его. Я не принципиально против них, но стараюсь искать решения без них.
Натан Меррилл
1
То, что вы ищете, это в основном DSL для описания простых шаблонов, а не иерархическая объектная модель.
user1643723
2
@NathanMerrill "Я определенно хочу полимофизм", разве это не должно быть наоборот? Вы бы предпочли достичь своей реальной цели или «использовать полимофизм»? ИМО, полимофизм хорошо подходит для построения сложных API и моделирования поведения. Он менее подходит для моделирования данных (чем вы сейчас и занимаетесь).
user1643723
1
@NathanMerrill «каждый таймблок выполняет действие, или содержит другие таймблоки и выполняет их, или запрашивает приглашение пользователя», - я полагаю, что эта информация очень важна, чтобы добавить ее к вопросу.
user1643723

Ответы:

15

Вы можете использовать шаблон посетителя:

interface QuestionVisitor {
    void multipleChoice(MultipleChoice);
    void textBox(TextBox);
    ...
}

interface Question {
    void visit(QuestionVisitor);
}

class MultipleChoice implements Question {

    void visit(QuestionVisitor visitor) {
        visitor.multipleChoice(this);
    }
}

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

Уинстон Эверт
источник
2
Хм ... это не страшный вариант, однако интерфейс QuestionVisitor должен будет добавлять метод каждый раз, когда возникает вопрос другого типа, который не является супермасштабируемым.
Натан Меррилл
3
@NathanMerrill, я не думаю, что это на самом деле сильно меняет вашу масштабируемость. Да, вы должны реализовать новый метод в каждом экземпляре QuestionVisitor. Но это код, который вам придется написать в любом случае для обработки графического интерфейса для нового типа вопроса. Я не думаю, что это действительно добавляет много кода, который вы бы иначе не исправили, но превращает отсутствующий код в ошибку компиляции.
Уинстон Эверт
4
Правда. Однако, если бы я когда-нибудь захотел позволить кому-то создать свой собственный тип вопроса + рендер (чего я не делаю), я не думаю, что это было бы возможно.
Натан Меррилл
2
@NathanMerrill, это правда. Этот подход предполагает, что только одна кодовая база определяет типы вопросов.
Уинстон Эверт
4
@WinstonEwert это хорошее использование шаблона посетителя. Но ваша реализация не совсем соответствует шаблону. Обычно методы в посетителе не именуются в соответствии с типами, они обычно имеют одинаковые имена и отличаются только типами параметров (перегрузка параметров); общее имя visit(посетитель посещает). Также обычно вызывается метод в посещаемых объектах accept(Visitor)(объект принимает посетителя). См. Oodesign.com/visitor-pattern.html
Виктор Зайферт
2

В C # / WPF (и, я полагаю, в других языках проектирования, ориентированных на пользовательский интерфейс) у нас есть DataTemplates . Определяя шаблоны данных, вы создаете связь между одним типом «объекта данных» и специализированным «шаблоном пользовательского интерфейса», созданным специально для отображения этого объекта.

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

BTownTKD
источник
Кажется, это переносит проблему в XML, где вы теряете всю строгую типизацию в первую очередь.
Натан Меррилл
Я не уверен, говорите ли вы, что это хорошо или плохо. С одной стороны, мы решаем проблему. С другой стороны, это звучит как спичка, заключенная на небесах.
BTownTKD
2

Если каждый ответ может быть закодирован как строка, вы можете сделать это:

interface Question {
    int score(String answer);
    void display(String answer);
    void displayGraded(String answer);
}

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

class MultipleChoice implements Question {
    MultipleChoiceView mcv;
    String question;
    String answerKey;
    String[] choices;

    MultipleChoice(
            MultipleChoiceView mcv, 
            String question, 
            String answerKey, 
            String... choices
    ) {
        this.mcv = mcv;
        this.question = question;
        this.answerKey = answerKey;
        this.choices = choices;
    }

    int score(String answer) {
        return answer.equals(answerKey); //Or whatever scoring logic
    }

    void display(String answer) {
        mcv.display(question, choices, answer);            
    }

    void displayGraded(String answer) {
        mcv.displayGraded(
            question, 
            answerKey, 
            choices, 
            answer, 
            score(answer)
        );            
    }
}

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

Разделив вывод на display()и displayGraded()представление не нужно менять местами и не нужно выполнять разветвление для параметров. Тем не менее, каждый вид может свободно использовать как можно больше логики при отображении. Какая бы схема не была разработана, она не должна просачиваться в этот код.

Однако, если вы хотите более динамично контролировать отображение вопроса, вы можете сделать это:

interface Question {
    int score(String answer);
    void display(MultipleChoiceView mcv, String answer);
}

и это

class MultipleChoice implements Question {
    String question;
    String answerKey;
    String[] choices;

    MultipleChoice(
            String question, 
            String answerKey, 
            String... choices
    ) {
        this.question = question;
        this.answerKey = answerKey;
        this.choices = choices;
    }

    int score(String answer) {
        return answer.equals(answerKey); //Or whatever scoring logic
    }

    void display(MultipleChoiceView mcv, String answer) {
        mcv.display(
            question, 
            answerKey, 
            choices, 
            answer, 
            score(answer)
        );            
    }
}

Это имеет тот недостаток, что требует представления, которые не предназначены для отображения score()или answerKeyзависят от них, когда они не нужны. Но это означает, что вам не нужно перестраивать тестовые вопросы для каждого типа представления, которое вы хотите использовать.

candied_orange
источник
Так что это ставит код GUI в вопрос. Ваши «display» и «displayGraded» показательны: для каждого типа «display» мне нужна другая функция.
Натан Меррилл
Не совсем, это ставит ссылку на представление, которое является полиморфным. Это может быть графический интерфейс, веб-страница, PDF, что угодно. Это выходной порт, который отправляется без макета содержимого.
candied_orange
@NathanMerrill пожалуйста, обратите внимание, редактировать
candied_orange
Новый интерфейс не работает: вы помещаете «MultipleChoiceView» внутри интерфейса «Вопрос». Вы можете поместить средство просмотра в конструктор, но большую часть времени вы не знаете (или не заботитесь), каким будет средство просмотра при создании объекта. (Это можно решить, используя ленивую функцию / фабрику, но логика внедрения в эту фабрику может стать грязной)
Натан Меррилл
@NathanMerrill Что-то, где-то должно знать, где это должно отображаться. Единственное, что делает конструктор, это позволяет вам решить это во время сборки и затем забыть об этом. Если вы не хотите решать это во время строительства, вы должны принять решение позже и каким-то образом запомнить это решение, пока не вызовете display. Использование фабрик в этих методах не изменит эти факты. Это просто скрывает, как вы приняли решение. Обычно не в хорошем смысле.
candied_orange
1

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

///Questions package

class Test {
  IList<Question> questions;
}

class Question {
  String Type;   //example; could be another type
  IList<QuestionInfo> Info;  //Simple array of key/value information
}

Затем для части рендеринга я удалил проверку типа, реализовав простую проверку данных в объекте вопроса. Приведенный ниже код пытается выполнить две вещи: (i) избежать проверки типов и избежать нарушения принципа «L» (подстановка Лискова в SOLID), удалив подтип класса Question; и (ii) сделать код расширяемым, никогда не меняя основной код рендеринга ниже, просто добавляя больше реализаций QuestionView и его экземпляров в массив (это на самом деле принцип «O» в SOLID - открытый для расширения и закрытый для модификации).

///GUI package

interface QuestionView {
  Boolean SupportsQuestion(Question question);
  View CreateView(Question question);
}

class MultipleChoiceQuestionView : QuestionView {
  Boolean SupportsQuestion(Question question){
    return question.Type == "multiple_coice";
  }

  //...more implementation
}
class TextBoxQuestionView : QuestionView { ... }
//...more views

//Assuming you have an array of QuestionView pre-configured
//with all currently available types of questions
for (Question question : questions) {
  for (QuestionView view : questionViews) {
    if (view.SupportsQuestion(question)) {
        display.add(view.CreateView(question));
    }
  }
}
Эмерсон Кардосо
источник
Что происходит, когда MultipleChoiceQuestionView пытается получить доступ к полю MultipleChoice.choices? Требуется приведение. Конечно, если мы предположим, что этот вопрос.
Натан Меррилл
Если вы заметите, в моем примере, нет такого типа MultipleChoice. Существует только один тип Вопроса, который я попытался определить в общем, со списком информации (вы можете сохранить несколько вариантов в этом списке, вы можете определить его, как вы хотите). Таким образом, нет никакого приведения, у вас есть только один тип Вопроса и несколько объектов, которые проверяют, могут ли они отобразить этот вопрос, если объект поддерживает его, тогда вы можете безопасно вызвать метод рендеринга.
Эмерсон Кардосо
В моем примере я решил уменьшить связь между вашим графическим интерфейсом и строго типизированными свойствами в конкретном классе вопросов; вместо этого я заменяю эти свойства общими свойствами, к которым GUI должен был бы получить доступ с помощью строкового ключа или чего-то еще (слабая связь). Это компромисс, возможно, эта слабая связь нежелательна в вашем сценарии.
Эмерсон Кардосо
1

Фабрика должна быть в состоянии сделать это. Карта заменяет оператор switch, который необходим исключительно для сопряжения Вопроса (который ничего не знает о представлении) с QuestionView.

interface QuestionView<T : Question>
{
    view();
}

class MultipleChoiceView implements QuestionView<MultipleChoiceQuestion>
{
    MultipleChoiceQuestion question;
    view();
}
...

class QuestionViewFactory
{
    Map<K : Question, V : QuestionView<K>> map;

    register<K : Question, V : QuestionView<K>>();
    getView(Question)
}

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

Завод может быть заполнен с помощью отражения или вручную при запуске приложения.

Xtros
источник
Если бы вы были в системе, где кэширование представления было важным (например, игра), фабрика могла бы включать пул QuestionViews.
Xtros
Это похоже на ответ Калет: вам все еще нужно будет сыграть Questionв, MultipleChoiceQuestionкогда вы создадитеMultipleChoiceView
Натан Меррилл
В C # по крайней мере мне удалось сделать это без приведения. В методе getView, когда он создает экземпляр представления (вызывая Activator.CreateInstance (questionViewType, question)), вторым параметром CreateInstance является параметр, отправляемый конструктору. Мой конструктор MultipleChoiceView принимает только MultipleChoiceQuestion. Возможно, он просто перемещает приведение внутрь функции CreateInstance.
Xtros
0

Я не уверен, что это считается «избеганием проверок типов», в зависимости от того, как вы относитесь к рефлексии .

// Either statically associate or have a register(Class, Supplier) method
Dictionary<Class<? extends Question>, Supplier<? extends QuestionViewer>> 
viewerFactory = // MultipleChoice => MultipleChoiceViewer::new etc ...

// ... elsewhere

for (Question question: questions){
    display.add(viewerFactory[question.getClass()]());
}
Caleth
источник
Это в основном проверка типа, но переход от ifпроверки dictionaryтипа к проверке типа. Например, как Python использует словари вместо операторов switch. Тем не менее, мне нравится этот способ больше, чем список операторов if.
Натан Меррилл
1
@NathanMerrill Да. У Java нет хорошего способа держать две иерархии классов параллельно. В C ++ я бы порекомендовал template <typename Q> struct question_traits;с соответствующими специализациями
Caleth
@Caleth, вы можете получить доступ к этой информации динамически? Я думаю, что вам нужно для того, чтобы создать правильный тип с учетом экземпляра.
Уинстон Эверт
Кроме того, фабрика, вероятно, нуждается в передаче экземпляра вопроса. Это делает эту модель, к сожалению, грязной, так как она обычно требует некрасивого приведения.
Уинстон Эверт