Comment créer une interface graphique pour une classe polymorphe?

17

Disons que j'ai un générateur de tests, pour que les enseignants puissent créer un tas de questions pour un test.

Cependant, toutes les questions ne sont pas identiques: vous avez plusieurs choix, zone de texte, correspondance, etc. Chacun de ces types de questions doit stocker différents types de données et nécessite une interface graphique différente pour le créateur et pour le candidat.

Je voudrais éviter deux choses:

  1. Contrôles de type ou transtypage
  2. Tout ce qui concerne l'interface graphique dans mon code de données.

Dans ma première tentative, je me retrouve avec les classes suivantes:

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

Cependant, lorsque je vais afficher le test, je me retrouverais inévitablement avec du code comme:

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

Cela ressemble à un problème très courant. Y a-t-il un modèle de conception qui me permet d'avoir des questions polymorphes tout en évitant les éléments énumérés ci-dessus? Ou le polymorphisme est-il une mauvaise idée en premier lieu?

Nathan Merrill
la source
6
Ce n'est pas une mauvaise idée de poser des questions sur des choses avec
lesquelles
1
En général, j'essaie d'éviter les vérifications de type / la conversion de type car cela conduit généralement à moins de vérification au moment de la compilation et "contourne" le polymorphisme plutôt que de l'utiliser. Je ne suis pas fondamentalement contre eux, mais essayez de chercher des solutions sans eux.
Nathan Merrill
1
Ce que vous recherchez est essentiellement une DSL pour décrire des modèles simples, pas un modèle d'objet hiérarchique.
user1643723
2
@NathanMerrill "Je veux vraiment du polymophisme", - cela ne devrait-il pas être l'inverse? Préférez-vous atteindre votre objectif réel ou "utiliser le polymophisme"? OMI, le polymophisme est bien adapté à la construction d'API complexes et à la modélisation du comportement. Il est moins adapté à la modélisation de données (ce que vous faites actuellement).
user1643723
1
@NathanMerrill "chaque timeblock exécute une action, ou contient d'autres timeblocks et les exécute, ou demande une invite de l'utilisateur", - ces informations sont très précieuses, je suggère, que vous les ajoutiez à la question.
user1643723

Réponses:

15

Vous pouvez utiliser un modèle de visiteur:

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

interface Question {
    void visit(QuestionVisitor);
}

class MultipleChoice implements Question {

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

Une autre option est une union discriminée. Cela dépendra beaucoup de votre langue. C'est beaucoup mieux si votre langue le prend en charge, mais pas beaucoup de langues populaires.

Winston Ewert
la source
2
Hmm .... ce n'est pas une option terrible, mais l'interface QuestionVisitor devrait ajouter une méthode chaque fois qu'il y a un type de question différent, qui n'est pas super évolutif.
Nathan Merrill
3
@NathanMerrill, je ne pense pas que cela change réellement votre évolutivité. Oui, vous devez implémenter la nouvelle méthode dans chaque instance de QuestionVisitor. Mais c'est du code que vous devrez de toute façon écrire pour gérer l'interface graphique du nouveau type de question. Je ne pense pas que cela ajoute vraiment beaucoup de code que vous n'auriez pas dû redresser autrement, mais cela transforme le code manquant en une erreur de compilation.
Winston Ewert
4
Vrai. Cependant, si jamais je voulais permettre à quelqu'un de créer son propre type de question + rendu (ce que je ne fais pas), je ne pense pas que ce serait possible.
Nathan Merrill
2
@NathanMerrill, c'est vrai. Cette approche suppose qu'une seule base de code définit les types de questions.
Winston Ewert
4
@WinstonEwert c'est une bonne utilisation du modèle de visiteur. Mais votre implémentation n'est pas tout à fait conforme au modèle. Habituellement, les méthodes dans le visiteur ne sont pas nommées d'après les types, elles ont généralement le même nom et ne diffèrent que par les types de paramètres (surcharge de paramètres); le nom commun est visit(le visiteur visite). De plus, la méthode dans les objets visités est généralement appelée accept(Visitor)(l'objet accepte un visiteur). Voir oodesign.com/visitor-pattern.html
Viktor Seifert
2

En C # / WPF (et, j'imagine, dans d'autres langages de conception axés sur l'interface utilisateur), nous avons des DataTemplates . En définissant des modèles de données, vous créez une association entre un type «d'objet de données» et un «modèle d'interface utilisateur» spécialisé créé spécifiquement pour afficher cet objet.

Une fois que vous avez fourni des instructions pour que l'interface utilisateur charge un type spécifique d'objet, il verra s'il existe des modèles de données définis pour l'objet.

BTownTKD
la source
Cela semble déplacer le problème vers XML où vous perdez tout typage strict en premier lieu.
Nathan Merrill
Je ne sais pas si vous dites que c'est une bonne ou une mauvaise chose. D'une part, nous déplaçons le problème. D'un autre côté, cela ressemble à une allumette faite au paradis.
BTownTKD
2

Si chaque réponse peut être codée sous forme de chaîne, vous pouvez le faire:

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

Où la chaîne vide signifie une question sans réponse pour le moment. Cela permet de séparer les questions, les réponses et l'interface graphique tout en permettant le polymorphisme.

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

La zone de texte, la correspondance, etc. pourraient avoir des conceptions similaires, implémentant toutes l'interface de question. La construction de la chaîne de réponse se produit dans la vue. Les chaînes de réponse représentent l'état du test. Ils doivent être conservés au fur et à mesure que l'élève progresse. Les appliquer aux questions permet d'afficher le test et son état à la fois gradué et non gradué.

En séparant la sortie display()et displayGraded()la vue n'a pas besoin d'être échangée et aucune ramification ne doit être effectuée sur les paramètres. Cependant, chaque vue est libre de réutiliser autant de logique d'affichage que possible lors de l'affichage. Quel que soit le schéma conçu pour le faire, il n'a pas besoin de fuir dans ce code.

Si, cependant, vous souhaitez avoir un contrôle plus dynamique de l'affichage d'une question, vous pouvez le faire:

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

et ça

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

Cela présente l'inconvénient de nécessiter des vues qui n'ont pas l'intention d'afficher score()ou answerKeyde dépendre d'eux lorsqu'elles n'en ont pas besoin. Mais cela signifie que vous n'avez pas à reconstruire les questions de test pour chaque type de vue que vous souhaitez utiliser.

candied_orange
la source
Cela met donc le code GUI dans la question. Votre "affichage" et "displayGraded" est révélateur: pour chaque type "d'affichage", je devrais avoir une autre fonction.
Nathan Merrill
Pas tout à fait, cela fait référence à une vue polymorphe. Ce pourrait être une interface graphique, une page Web, un PDF, peu importe. Il s'agit d'un port de sortie auquel est envoyé du contenu sans mise en page.
candied_orange
@NathanMerrill please note edit
candied_orange
La nouvelle interface ne fonctionne pas: vous mettez "MultipleChoiceView" à l'intérieur de l'interface "Question". Vous pouvez placer la visionneuse dans le constructeur, mais la plupart du temps vous ne savez pas (ou ne vous souciez pas) de la visionneuse qui sera lorsque vous créerez l'objet. (Cela pourrait être résolu en utilisant une fonction / usine paresseuse mais la logique derrière l'injection dans cette usine pourrait devenir désordonnée)
Nathan Merrill
@NathanMerrill Quelque chose, quelque part doit savoir où cela doit être affiché. La seule chose que le constructeur fait est de vous laisser décider au moment de la construction, puis de l'oublier. Si vous ne voulez pas décider de cela lors de la construction, vous devez décider plus tard et vous souvenir de cette décision jusqu'à ce que vous appeliez display. L'utilisation d'usines dans ces méthodes ne changerait pas ces faits. Cela cache simplement comment vous avez pris la décision. Généralement pas dans le bon sens.
candied_orange
1

À mon avis, si vous avez besoin d'une telle fonctionnalité générique, je diminuerais le couplage entre les éléments du code. J'essaierais de définir le type de question le plus générique possible, puis je créerais différentes classes pour les objets de rendu. Veuillez consulter les exemples ci-dessous:

///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
}

Ensuite, pour la partie de rendu, j'ai supprimé la vérification de type en implémentant une simple vérification des données dans l'objet question. Le code ci-dessous essaie d'accomplir deux choses: (i) éviter la vérification de type et éviter la violation du principe "L" (substitution Liskov dans SOLID) en supprimant le sous-typage de la classe Question; et (ii) rendre le code extensible, en ne modifiant jamais le code de rendu principal ci-dessous, en ajoutant simplement plus d'implémentations QuestionView et ses instances au tableau (c'est en fait le principe "O" dans SOLID - ouvert pour extension et fermé pour modification).

///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));
    }
  }
}
Emerson Cardoso
la source
Que se passe-t-il lorsque MultipleChoiceQuestionView essaie d'accéder au champ MultipleChoice.choices? Il nécessite un casting. Bien sûr, si nous supposons cette question.Le type est unique et le code est sain, c'est un casting assez sûr, mais c'est toujours un casting: P
Nathan Merrill
Si vous remarquez dans mon exemple, il n'y a pas un tel type MultipleChoice. Il n'y a qu'un seul type de question, que j'ai essayé de définir de manière générique, avec une liste d'informations (vous pouvez stocker plusieurs choix dans cette liste, vous pouvez la définir comme vous le souhaitez). Par conséquent, il n'y a pas de distribution, vous n'avez qu'une seule question de type et plusieurs objets qui vérifient s'ils peuvent rendre cette question, si l'objet la prend en charge, vous pouvez alors appeler la méthode de rendu en toute sécurité.
Emerson Cardoso
Dans mon exemple, j'ai choisi de diminuer le couplage entre votre interface graphique et les propriétés typées fortes dans une classe Question spécifique; au lieu de cela, je remplace ces propriétés par des propriétés génériques, auxquelles l'interface graphique aurait besoin d'accéder par une clé de chaîne ou autre chose (couplage lâche). Il s'agit d'un compromis, ce couplage lâche n'est peut-être pas souhaité dans votre scénario.
Emerson Cardoso
1

Une usine devrait pouvoir le faire. La carte remplace l'instruction switch, qui est uniquement nécessaire pour associer la question (qui ne sait rien de la vue) avec la 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)
}

Avec cela, la vue utilise le type spécifique de question qu'elle est capable d'afficher, et le modèle reste déconnecté de la vue.

L'usine peut être remplie par réflexion ou manuellement au démarrage de l'application.

Xtros
la source
Si vous étiez dans un système où la mise en cache de la vue était importante (comme un jeu), la fabrique pourrait inclure un pool des vues de questions.
Xtros
Cela semble assez similaire à la réponse de Caleth: Vous allez encore besoin de casting Questiondans un MultipleChoiceQuestionlorsque vous créez leMultipleChoiceView
Nathan Merrill
En C # au moins, j'ai réussi à le faire sans cast. Dans la méthode getView, lorsqu'il crée l'instance de vue (en appelant Activator.CreateInstance (questionViewType, question)), le deuxième paramètre de CreateInstance est le paramètre envoyé au constructeur. Mon constructeur MultipleChoiceView accepte uniquement une MultipleChoiceQuestion. Peut-être que cela déplace simplement le cast à l'intérieur de la fonction CreateInstance.
Xtros
0

Je ne suis pas sûr que cela compte comme "éviter les vérifications de type", selon ce que vous pensez de la réflexion .

// 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
la source
Il s'agit essentiellement d'une vérification de type, mais le passage d'une ifvérification de type à une dictionaryvérification de type. Comme la façon dont Python utilise les dictionnaires au lieu des instructions switch. Cela dit, j'aime cette façon plus d'une liste des cas relevés.
Nathan Merrill du
1
@NathanMerrill Oui. Java n'a pas une bonne façon de garder deux hiérarchies de classes en parallèle. En c ++, je recommanderais un template <typename Q> struct question_traits;avec les spécialisations appropriées
Caleth
@Caleth, pouvez-vous accéder à ces informations de manière dynamique? Je pense que vous auriez à le faire pour construire le bon type étant donné une instance.
Winston Ewert
De plus, l'usine a probablement besoin que l'instance de question lui soit passée. Cela rend ce modèle malheureusement désordonné, car il nécessite généralement un casting laid.
Winston Ewert