Conception d'interface où les fonctions doivent être appelées dans une séquence spécifique

24

La tâche consiste à configurer un élément matériel au sein de l'appareil, selon certaines spécifications d'entrée. Ceci devrait être réalisé comme suit:

1) Collectez les informations de configuration. Cela peut se produire à différents moments et à différents endroits. Par exemple, le module A et le module B peuvent tous deux demander (à des moments différents) des ressources à mon module. Ces «ressources» sont en fait ce qu'est la configuration.

2) Une fois qu'il est clair qu'aucune autre requête ne sera réalisée, une commande de démarrage, donnant un résumé des ressources demandées, doit être envoyée au matériel.

3) Ce n'est qu'après cela que la configuration détaillée desdites ressources peut (et doit) être effectuée.

4) Aussi, seulement après 2), le routage des ressources sélectionnées vers les appelants déclarés peut (et doit) être effectué.


Une cause fréquente de bugs, même pour moi, qui ai écrit la chose, est de confondre cet ordre. Quelles conventions de dénomination, conceptions ou mécanismes puis-je utiliser pour rendre l'interface utilisable par quelqu'un qui voit le code pour la première fois?

Vorac
la source
L'étape 1 est mieux appelée discoveryou handshake?
rwong
1
Le couplage temporel est un anti-motif et doit être évité.
1
Le titre de la question me fait penser que vous pourriez être intéressé par le modèle de générateur d'étapes .
Joshua Taylor

Réponses:

45

C'est une refonte, mais vous pouvez empêcher l'utilisation abusive de nombreuses API mais ne pas avoir de méthode à ne pas appeler.

Par exemple, au lieu de first you init, then you start, then you stop

Votre constructeur initest un objet qui peut être démarré et startcrée une session qui peut être arrêtée.

Bien sûr, si vous avez une restriction à une session à la fois, vous devez gérer le cas où quelqu'un essaie d'en créer une avec une déjà active.

Appliquez maintenant cette technique à votre propre cas.

Vache à lait
la source
zlibet jpeglibsont deux exemples qui suivent ce modèle pour l'initialisation. Pourtant, de nombreuses documentations sont nécessaires pour enseigner le concept aux développeurs.
rwong
5
C'est exactement la bonne réponse: si l'ordre est important, chaque fonction renvoie un résultat qui peut ensuite être appelé pour effectuer l'étape suivante. Le compilateur lui-même est capable d'appliquer les contraintes de conception.
2
Ceci est similaire au modèle de générateur d'étape ; présenter uniquement l'interface qui a du sens à une phase donnée.
Joshua Taylor
@JoshuaTaylor ma réponse est une implémentation de modèle de générateur d'étape :)
Silviu Burcea
@SilviuBurcea Votre réponse n'est pas une implémentation de générateur d'étapes, mais je vais la commenter plutôt qu'ici.
Joshua Taylor
19

Vous pouvez demander à la méthode de démarrage de renvoyer un objet qui est un paramètre obligatoire à la configuration:

Resource * MyModule :: GetResource ();
MySession * MyModule :: Startup ();
void Resource :: Configure (session MySession *);

Même si votre MySessionest juste une structure vide, cela renforcera par la sécurité de type qu'aucune Configure()méthode ne peut être appelée avant le démarrage.

jpa
la source
Qu'est-ce qui empêche quelqu'un de faire module->GetResource()->Configure(nullptr)?
svick
@svick: Rien, mais vous devez le faire explicitement. Cette approche vous dit ce qu'elle attend et contourner cette expecation est une décision consciente. Comme avec la plupart des langages de programmation, personne ne vous empêche de vous tirer une balle dans le pied. Mais il est toujours bon par une API d'indiquer clairement que vous le faites;)
Michael Klement
+1 a l'air génial et simple. Cependant, je peux voir un problème. Si j'ai des objets a, b, c, d, je peux commencer aet l'utiliser MySessionpour essayer de l'utiliser bcomme un objet déjà commencé, alors qu'en réalité ce n'est pas le cas.
Vorac
8

S'appuyant sur la réponse de Cashcow - pourquoi devez-vous présenter un nouvel objet à l'appelant, alors que vous pouvez simplement présenter une nouvelle interface? Rebrand-Pattern:

class IStartable     { public: virtual IRunnable      start()     = 0; };
class IRunnable      { public: virtual ITerminateable run()       = 0; };
class ITerminateable { public: virtual void           terminate() = 0; };

Vous pouvez également laisser ITerminateable implémenter IRunnable, si une session peut être exécutée plusieurs fois.

Votre objet:

class Service : IStartable, IRunnable, ITerminateable
{
  public:
    IRunnable      start()     { ...; return this; }
    ITerminateable run()       { ...; return this; }
    void           terminate() { ...; }
}

// And use it like this:
IStartable myService = Service();

// Now you can only call start() via the interface
IRunnable configuredService = myService.start();

// Now you can also call run(), because it is wrapped in the new interface...

De cette façon, vous ne pouvez appeler que les bonnes méthodes, car vous ne disposez que de l'interface IStartable au début et la méthode run () ne sera accessible que lorsque vous aurez appelé start (); De l'extérieur, il ressemble à un modèle avec plusieurs classes et objets, mais la classe sous-jacente reste une classe, qui est toujours référencée.

Falco
la source
1
Quel est l'avantage d'avoir une seule classe sous-jacente au lieu de plusieurs? Comme c'est la seule différence avec la solution que j'ai proposée, je serais intéressé par ce point particulier.
Michael Le Barbier Grünewald
1
@ MichaelGrünewald Il n'est pas nécessaire d'implémenter toutes les interfaces avec une seule classe, mais pour un objet de type configuration, cela peut être la technique d'implémentation la plus simple pour partager les données entre les instances des interfaces (c'est-à-dire parce qu'elles sont partagées du fait qu'elles sont identiques) objet).
Joshua Taylor
1
Il s'agit essentiellement du modèle de générateur d'étapes .
Joshua Taylor
@JoshuaTaylor Le partage de données entre les instances de l'interface est double: bien que cela puisse être plus facile à implémenter, nous devons faire attention à ne pas accéder à «l'état non défini» (comme accéder à l'adresse client d'un serveur non connecté). Comme l'OP met l'accent sur la convivialité de l'interface, nous pouvons juger les deux approches égales. Merci de citer le BTW «pattern builder pattern».
Michael Le Barbier Grünewald
1
@ MichaelGrünewald Si vous interagissez uniquement avec l'objet via l'interface particulière spécifiée à un moment donné, il ne devrait pas y avoir de moyen (sans casting, etc.) d'accéder à cet état.
Joshua Taylor
2

Il existe de nombreuses approches valides pour résoudre votre problème. Basile Starynkevitch a proposé une approche «zéro bureaucratie» qui vous laisse avec une interface simple et repose sur le programmeur utilisant correctement l'interface. Bien que j'apprécie cette approche, je vais en présenter une autre qui a plus d'ingénierie mais permet au compilateur de détecter certaines erreurs.

  1. Identifier les différents états de votre appareil peut être, comme Uninitialised, Started, Configuredet ainsi de suite. La liste doit être finie.¹

  2. Pour chaque état, définissez une structréserve contenant les informations supplémentaires nécessaires concernant cet état, par exemple DeviceUninitialised, DeviceStartedetc.

  3. Emballez tous les traitements dans un seul objet DeviceStrategyoù les méthodes utilisent des structures définies en 2. comme entrées et sorties. Ainsi, vous pouvez avoir une DeviceStarted DeviceStrategy::start (DeviceUninitalised dev)méthode (ou quel que soit l'équivalent selon les conventions de votre projet).

Avec cette approche, un programme valide doit appeler certaines méthodes dans la séquence imposée par les prototypes de méthodes.

Les différents états sont des objets indépendants, c'est à cause du principe de substitution. S'il vous est utile que ces structures partagent un ancêtre commun, rappelez-vous que le modèle de visiteur peut être utilisé pour récupérer le type concret de l'instance d'une classe abstraite.

Alors que j'ai décrit dans 3. une DeviceStrategyclasse unique , il y a des situations où vous voudrez peut-être diviser la fonctionnalité qu'il fournit en plusieurs classes.

Pour les résumer, les points clés de la conception que j'ai décrite sont:

  1. En raison du principe de substitution, les objets représentant des états de périphérique doivent être distincts et ne pas avoir de relations d'héritage spéciales.

  2. Emballez les traitements de périphérique dans des objets de startegy plutôt que dans les objets représentant des périphériques eux-mêmes, de sorte que chaque périphérique ou état de périphérique ne se voit que lui-même, et la stratégie les voit tous et exprime les transitions possibles entre eux.

Je jurerais avoir vu une fois une description d'une implémentation de client Telnet suivant ces lignes, mais je n'ai pas pu la retrouver. Cela aurait été une référence très utile!

¹: Pour cela, suivez votre intuition ou trouvez les classes d'équivalence des méthodes dans votre implémentation réelle pour la relation «méthode₁ ~ méthode₂ ssi. il est valable de les utiliser sur le même objet »- en supposant que vous avez un gros objet encapsulant tous les traitements sur votre appareil. Les deux méthodes de listage des états donnent des résultats fantastiques.

Michael Le Barbier Grünewald
la source
1
Plutôt que de définir des structures distinctes, il peut suffire de définir les interfaces nécessaires qu'un objet à chaque phase doit présenter. Ensuite, c'est le modèle de générateur d'étapes .
Joshua Taylor
2

Utilisez un modèle de générateur.

Avoir un objet qui a des méthodes pour toutes les opérations que vous avez mentionnées ci-dessus. Cependant, il n'effectue pas ces opérations tout de suite. Il se souvient simplement de chaque opération pour plus tard. Étant donné que les opérations ne sont pas exécutées immédiatement, l'ordre dans lequel vous les transmettez au générateur n'a pas d'importance.

Après avoir défini toutes les opérations sur le générateur, vous appelez une executeméthode -met. Lorsque cette méthode est appelée, elle exécute toutes les étapes répertoriées ci-dessus dans le bon ordre avec les opérations que vous avez enregistrées ci-dessus. Cette méthode est également un bon endroit pour effectuer des contrôles d'intégrité couvrant plusieurs opérations (comme essayer de configurer une ressource qui n'a pas encore été configurée) avant de les écrire sur le matériel. Cela pourrait vous éviter d'endommager le matériel avec une configuration absurde (au cas où votre matériel serait sensible à cela).

Philipp
la source
1

Il vous suffit de documenter correctement la façon dont l'interface est utilisée et de donner un exemple de didacticiel.

Vous pouvez également avoir une variante de bibliothèque de débogage qui effectue des vérifications d'exécution.

Peut-être définir et documenter correctement certaines conventions de nommage (par exemple preconfigure*, startup*, postconfigure*, run*....)

BTW, beaucoup d'interfaces existantes suivent un modèle similaire (par exemple les kits d'outils X11).

Basile Starynkevitch
la source
Un diagramme de transition d'état, similaire au cycle de vie d'activité de l'application Android , peut être nécessaire pour transmettre les informations.
rwong
1

Il s'agit en effet d'une erreur courante et insidieuse, car les compilateurs ne peuvent appliquer que des conditions de syntaxe, alors que vous avez besoin que vos programmes clients soient "grammaticalement" corrects.

Malheureusement, les conventions de dénomination sont presque entièrement inefficaces contre ce type d'erreur. Si vous voulez vraiment encourager les gens à ne pas faire de choses non grammaticales, vous devez distribuer un objet de commande d'une sorte qui doit être initialisé avec des valeurs pour les conditions préalables, afin qu'ils ne puissent pas effectuer les étapes dans le désordre.

Kilian Foth
la source
Voulez-vous dire quelque chose comme ça ?
Vorac
1
public class Executor {

private Executor() {} // helper class

  public void execute(MyStepsRunnable r) {
    r.step1();
    r.step2();
    r.step3();
  }
}

interface MyStepsRunnable {

  void step1();
  void step2();
  void step3();
}

En utilisant ce modèle, vous êtes sûr que tout implémenteur s'exécutera dans cet ordre exact. Vous pouvez aller plus loin et créer un ExecutorFactory qui construira des exécuteurs avec des chemins d'exécution personnalisés.

Silviu Burcea
la source
Dans un autre commentaire, vous avez appelé cela une implémentation du générateur d'étapes, mais ce n'est pas le cas. Si vous avez une instance de MyStepsRunnable, vous pouvez appeler l'étape3 avant l'étape1. Une implémentation du générateur d'étapes serait plus du type ideone.com/UDECgY . L'idée est d'obtenir ce quelque chose avec une étape 2 en exécutant l'étape 1. Vous êtes donc obligé d'appeler des méthodes dans le bon ordre. Par exemple, voir stackoverflow.com/q/17256627/1281433 .
Joshua Taylor
Vous pouvez le convertir en une classe abstraite avec des méthodes protégées (ou même par défaut) pour restreindre la façon dont il peut être utilisé. Vous serez obligé d'utiliser l'exécuteur testamentaire, mais je pense qu'il pourrait y avoir une faille ou deux avec l'implémentation actuelle.
Silviu Burcea
Cela n'en fait toujours pas un constructeur de pas. Dans votre code, il n'y a rien qu'un utilisateur puisse faire pour exécuter du code entre les différentes étapes. L'idée n'est pas seulement de séquencer le code (qu'il soit public ou privé, ou autrement encapsulé). Comme le montre votre code, c'est assez facile à faire avec simplement step1(); step2(); step3();. Le but du générateur d'étapes est de fournir une API qui expose certaines étapes et d'appliquer la séquence dans laquelle elles sont appelées. Cela ne devrait pas empêcher un programmeur de faire d'autres choses entre les étapes.
Joshua Taylor