Dans la conception d'API, quand utiliser / éviter le polymorphisme ad hoc?

14

Sue est la conception d' une bibliothèque JavaScript, Magician.js. Son pivot est une fonction qui extrait un Rabbitde l'argument passé.

Elle sait que ses utilisateurs peuvent vouloir retirer un lapin d'un String, d'un Number, d'un Function, peut-être même d'un HTMLElement. Dans cet esprit, elle pourrait concevoir son API comme suit:

L'interface stricte

Magician.pullRabbitOutOfString = function(str) //...
Magician.pullRabbitOutOfHTMLElement = function(htmlEl) //...

Chaque fonction de l'exemple ci-dessus saurait comment gérer l'argument du type spécifié dans le nom de fonction / nom de paramètre.

Ou, elle pourrait le concevoir comme ceci:

L'interface "ad hoc"

Magician.pullRabbit = function(anything) //...

pullRabbitdevrait tenir compte de la variété des différents types attendus que l' anythingargument pourrait être, ainsi que (bien sûr) d'un type inattendu:

Magician.pullRabbit = function(anything) {
  if (anything === undefined) {
    return new Rabbit(); // out of thin air
  } else if (isString(anything)) {
    // more
  } else if (isNumber(anything)) {
    // more
  }
  // etc.
};

Le premier (strict) semble plus explicite, peut-être plus sûr et peut-être plus performant - car il y a peu ou pas de frais généraux pour la vérification de type ou la conversion de type. Mais ce dernier (ad hoc) se sent plus simple à regarder de l'extérieur, en ce sens qu'il "fonctionne" avec n'importe quel argument que le consommateur d'API trouve commode de lui transmettre.

Pour répondre à cette question , je voudrais voir les pros de spécifiques et les inconvénients de ces deux approches (ou à une approche tout à fait différente, si ni est idéal), que Sue devrait savoir quelle approche adopter lors de la conception de l' API de sa bibliothèque.

GladstoneKeep
la source
2
Duck typing
Vorac

Réponses:

7

Quelques avantages et inconvénients

Avantages pour polymorphe:

  • Une interface polymorphe plus petite est plus facile à lire. Je n'ai qu'à me souvenir d'une méthode.
  • Cela va avec la façon dont la langue est censée être utilisée - Duck typing.
  • S'il est clair de quels objets je veux sortir un lapin, il ne devrait pas y avoir d'ambiguïté de toute façon.
  • Faire beaucoup de vérification de type est considéré comme mauvais même dans des langages statiques comme Java, où avoir beaucoup de vérifications de type pour le type d'objet rend le code laid, si le magicien a vraiment besoin de différencier le type d'objets dont il tire un lapin de ?

Avantages pour ad-hoc:

  • C'est moins explicite, puis-je extraire une chaîne d'une Catinstance? Est-ce que ça marcherait? sinon, quel est le comportement? Si je ne limite pas le type ici, je dois le faire dans la documentation ou dans les tests qui pourraient aggraver le contrat.
  • Vous avez toute la manipulation de tirer un lapin en un seul endroit, le magicien (certains pourraient considérer cela comme un con)
  • Les optimiseurs JS modernes différencient les fonctions monomorphes (ne fonctionnent que sur un seul type) et polymorphes. Ils savent comment optimiser les monomorphes beaucoup mieux, donc la pullRabbitOutOfStringversion est susceptible d'être beaucoup plus rapide dans des moteurs comme le V8. Voir cette vidéo pour plus d'informations. Edit: j'ai écrit moi-même un perf, il s'avère qu'en pratique, ce n'est pas toujours le cas .

Quelques solutions alternatives:

À mon avis, ce type de conception n'est pas très «Java-Scripty» pour commencer. JavaScript est un langage différent avec des idiomes différents de langages comme C #, Java ou Python. Ces idiomes trouvent leur origine dans des années de développeurs essayant de comprendre les parties faibles et fortes du langage, ce que je ferais, c'est essayer de m'en tenir à ces idiomes.

Il y a deux belles solutions auxquelles je peux penser:

  • Élever des objets, rendre des objets «extractibles», les rendre conformes à une interface lors de l'exécution, puis faire travailler le magicien sur des objets extractibles.
  • En utilisant le modèle de stratégie, apprendre au magicien de manière dynamique comment gérer différents types d'objets.

Solution 1: élévation d'objets

Une solution courante à ce problème consiste à «élever» des objets avec la possibilité d'en retirer des lapins.

Autrement dit, avoir une fonction qui prend un certain type d'objet, et ajoute le retrait d'un chapeau pour cela. Quelque chose comme:

function makePullable(obj){
   obj.pullOfHat = function(){
       return new Rabbit(obj.toString());
   }
}

Je peux faire de telles makePullablefonctions pour d'autres objets, je pourrais créer un makePullableString, etc. Je définis la conversion sur chaque type. Cependant, après avoir élevé mes objets, je n'ai plus de type pour les utiliser de manière générique. Une interface en JavaScript est déterminée par une saisie de canard, si elle a une pullOfHatméthode, je peux la tirer avec la méthode du magicien.

Le magicien pourrait alors faire:

Magician.pullRabbit = function(pullable) {
    var rabbit = obj.pullOfHat();
    return {rabbit:rabbit,text:"Tada, I pulled a rabbit out of "+pullable};
}

Élever des objets, en utilisant une sorte de modèle de mixage semble être la chose la plus JS à faire. (Notez que cela pose problème avec les types de valeurs dans la langue qui sont des chaînes, des nombres, des valeurs nulles, non définies et booléennes, mais ils sont tous compatibles avec les boîtes)

Voici un exemple de ce à quoi pourrait ressembler un tel code

Solution 2: modèle de stratégie

En discutant de cette question dans la salle de chat JS dans StackOverflow, mon ami phénoménominal a suggéré l'utilisation du modèle de stratégie .

Cela vous permettrait d'ajouter des capacités pour retirer des lapins de divers objets au moment de l'exécution, et créerait du code très JavaScript. Un magicien peut apprendre à retirer des objets de différents types de chapeaux, et il les tire en fonction de ces connaissances.

Voici à quoi cela pourrait ressembler dans CoffeeScript:

class Magician
  constructor: ()-> # A new Magician can't pull anything
     @pullFunctions = {}

  pullRabbit: (obj) -> # Pull a rabbit, handler based on type
    func = pullFunctions[obj.constructor.name]
    if func? then func(obj) else "Don't know how to pull that out of my hat!"

  learnToPull: (obj, handler) -> # Learns to pull a rabbit out of a type
    pullFunctions[obj.constructor.name] = handler

Vous pouvez voir le code JS équivalent ici .

De cette façon, vous bénéficiez des deux mondes, l'action de tirer n'est pas étroitement liée aux objets ou au magicien et je pense que cela constitue une très bonne solution.

L'utilisation serait quelque chose comme:

var m = new Magician();//create a new Magician
//Teach the Magician
m.learnToPull("",function(){
   return "Pulled a rabbit out of a string";
});
m.learnToPull({},function(){
   return "Pulled a rabbit out of a Object";
});

m.pullRabbit(" Str");
Benjamin Gruenbaum
la source
1
Je recommande également fortement ce livre gratuit de modèles de conception JavaScript
Benjamin Gruenbaum
2
J'aurais +10 pour une réponse très approfondie dont j'ai beaucoup appris, mais, selon les règles SE, vous devrez vous contenter de +1 ... :-)
Marjan Venema
@MarjanVenema Les autres réponses sont également bonnes, assurez-vous de les lire aussi. Je suis content que vous ayez apprécié cela. N'hésitez pas à vous arrêter et à poser plus de questions de conception.
Benjamin Gruenbaum
4

Le problème est que vous essayez d'implémenter un type de polymorphisme qui n'existe pas dans JavaScript - JavaScript est presque universellement mieux traité comme un langage de type canard, même s'il prend en charge certaines facultés de type.

Pour créer la meilleure API, la réponse est que vous devez implémenter les deux. C'est un peu plus de frappe, mais cela économisera beaucoup de travail à long terme pour les utilisateurs de votre API.

pullRabbitdevrait simplement être une méthode d'arbitre qui vérifie les types et appelle la fonction appropriée associée à ce type d'objet (par exemple pullRabbitOutOfHtmlElement).

De cette façon, alors que les utilisateurs de prototypage peut utiliser pullRabbit, mais s'ils remarquent un ralentissement , ils peuvent mettre en œuvre le type de vérification à leur fin (en probablement un moyen plus rapide) et il suffit d' appeler pullRabbitOutOfHtmlElementdirectement.

Jonathan Rich
la source
2

C'est JavaScript. Au fur et à mesure que vous l'améliorez, vous constaterez qu'il existe souvent une voie médiane qui aide à éliminer les dilemmes comme celui-ci. De plus, peu importe si un «type» non pris en charge est pris par quelque chose ou se casse lorsque quelqu'un essaie de l'utiliser parce qu'il n'y a pas de compilation par rapport à l'exécution. Si vous l'utilisez mal, il se casse. Essayer de cacher qu'il s'est cassé ou de le faire fonctionner à mi-chemin quand il s'est cassé ne change rien au fait que quelque chose est cassé.

Alors, prenez votre gâteau et mangez-le aussi et apprenez à éviter la confusion de type et la casse inutile en gardant tout vraiment, vraiment évident, comme bien nommé et avec tous les bons détails aux bons endroits.

Tout d'abord, j'encourage fortement à prendre l'habitude de mettre vos canards en rang avant de devoir vérifier les types. La chose la plus simple et la plus efficace (mais pas toujours la meilleure en ce qui concerne les constructeurs natifs) serait de frapper les prototypes en premier afin que votre méthode n'ait même pas à se soucier du type pris en charge en jeu.

String.prototype.pullRabbit = function(){
    //do something string-relevant
}

HTMLElement.prototype.pullRabbit = function(){
    //do something HTMLElement-relevant
}

Magician.pullRabbitFrom = function(someThingy){
    return someThingy.pullRabbit();
}

Remarque: Il est largement considéré comme une mauvaise forme de faire cela à Object car tout hérite d'Object. Personnellement, j'éviterais aussi Function. Certains peuvent se sentir inquiets de toucher un prototype de constructeur natif, ce qui n'est peut-être pas une mauvaise politique, mais l'exemple peut toujours servir lorsque vous travaillez avec vos propres constructeurs d'objets.

Je ne m'inquiéterais pas de cette approche pour une telle méthode d'utilisation spécifique qui ne risque pas de gâcher quelque chose d'une autre bibliothèque dans une application moins compliquée, mais c'est un bon instinct pour éviter d'affirmer quoi que ce soit trop généralement entre les méthodes natives de JavaScript si vous ne le faites pas. à moins que vous ne normalisiez de nouvelles méthodes dans des navigateurs obsolètes.

Heureusement, vous pouvez toujours simplement mapper des types ou des noms de constructeur aux méthodes (méfiez-vous IE <= 8 qui n'a pas <object> .constructor.name vous obligeant à l'analyser des résultats toString de la propriété constructeur). Vous êtes toujours en train de vérifier le nom du constructeur (typeof est un peu inutile dans JS lorsque vous comparez des objets) mais au moins, cela se lit beaucoup mieux qu'une déclaration de commutateur géant ou si / autre chaîne dans chaque appel de la méthode à ce qui pourrait être un large variété d'objets.

var rabbitPullMap = {
    String: ( function pullRabbitFromString(){
        //do stuff here
    } ),
    //parens so we can assign named functions if we want for helpful debug
    //yes, I've been inconsistent. It's just a nice unrelated trick
    //when you want a named inline function assignment

    HTMLElement: ( function pullRabitFromHTMLElement(){
        //do stuff here
    } )
}

Magician.pullRabbitFrom = function(someThingy){
    return rabbitPullMap[someThingy.constructor.name]();
}

Ou en utilisant la même approche de carte, si vous vouliez accéder au composant «this» des différents types d'objets pour les utiliser comme s'il s'agissait de méthodes sans toucher à leurs prototypes hérités:

var rabbitPullMap = {
    String: ( function(obj){

    //yes the anon wrapping funcs would make more sense in one spot elsewhere.

        return ( function pullRabbitFromString(obj){
            var rabbitReach = this.match(/rabbit/g);
            return rabbitReach.length;
        } ).call(obj);
    } ),

    HTMLElement: ( function(obj){
        return ( function pullRabitFromHTMLElement(obj){
            return this.querySelectorAll('.rabbit').length;
        } ).call(obj);
    } )
}

Magician.pullRabbitFrom = function(someThingy){

    var
        constructorName = someThingy.constructor.name,
        rabbitCnt = rabbitPullMap[constructorName](someThingy);

    console.log(
        [
            'The magician pulls ' + rabbitCnt,
            rabbitCnt === 1 ? 'rabbit' : 'rabbits',
            'out of her ' + constructorName + '.',
            rabbitCnt === 0 ? 'Boo!' : 'Yay!'
        ].join(' ');
    );
}

Un bon principe général dans n'importe quel langage IMO est d'essayer de trier les détails de branchement comme celui-ci avant d'arriver au code qui tire réellement le déclencheur. De cette façon, il est facile de voir tous les joueurs impliqués à ce niveau d'API le plus élevé pour une belle vue d'ensemble, mais aussi beaucoup plus facile de trier où les détails dont quelqu'un pourrait se soucier sont susceptibles d'être trouvés.

Remarque: tout cela n'est pas testé, car je suppose que personne n'a réellement d'utilisation de RL pour cela. Je suis sûr qu'il y a des fautes de frappe / bugs.

Erik Reppen
la source
1

C'est (pour moi) une question intéressante et compliquée à répondre. En fait, j'aime cette question, donc je ferai de mon mieux pour y répondre. Si vous faites des recherches sur les normes de programmation javascript, vous trouverez autant de "bonnes" façons de le faire qu'il y a de gens qui vantent la "bonne" façon de le faire.

Mais puisque vous cherchez une opinion sur la meilleure façon. Rien ne va ici.

Personnellement, je préférerais l'approche de conception "adhoc". Issu d'un background c ++ / C #, c'est plus mon style de développement. Vous pouvez créer la seule demande pullRabbit et demander à ce type de demande de vérifier l'argument transmis et de faire quelque chose. Cela signifie que vous n'avez pas à vous soucier du type d'argument transmis à un moment donné. Si vous utilisez l'approche stricte, vous devrez toujours vérifier de quel type est la variable, mais vous le ferez à la place avant de faire l'appel de méthode. Donc, à la fin, la question est de savoir si vous voulez vérifier le type avant de passer l'appel ou après.

J'espère que cela vous aide, n'hésitez pas à poser plus de questions par rapport à cette réponse, je ferai de mon mieux pour clarifier ma position.

Kenneth Garza
la source
0

Lorsque vous écrivez, Magician.pullRabbitOutOfInt, il documente ce que vous avez pensé lorsque vous avez écrit la méthode. L'appelant s'attendra à ce que cela fonctionne s'il passe un entier. Lorsque vous écrivez, Magician.pullRabbitOutOfAnything, l'appelant ne sait pas quoi penser et doit aller fouiller dans votre code et expérimenter. Cela pourrait fonctionner pour un Int, mais cela fonctionnera-t-il pendant longtemps? Un flotteur? Un double? Si vous écrivez ce code, jusqu'où êtes-vous prêt à aller? Quels types d'arguments êtes-vous prêt à soutenir?

  • Des cordes?
  • Des tableaux?
  • Plans?
  • Ruisseaux?
  • Les fonctions?
  • Bases de données?

L'ambiguïté prend du temps à comprendre. Je ne suis même pas convaincu qu'il soit plus rapide d'écrire:

Magician.pullRabbit = function(anything) {
  if (anything === undefined) {
    return new Rabbit(); // out of thin air
  } else if (isString(anything)) {
    // more
  } else if (isNumber(anything)) {
    // more
  } else {
      throw new Exception("You can't pull a rabbit out of that!");
  }
  // etc.
};

Contre:

Magician.pullRabbitFromAir = fromAir() {
    return new Rabbit(); // out of thin air
}
Magician.pullRabbitFromStr = fromString(str)) {
    // more
}
Magician.pullRabbitFromInt = fromInt(int)) {
    // more
};

OK, j'ai donc ajouté une exception à votre code (que je recommande fortement) pour dire à l'appelant que vous n'avez jamais imaginé qu'il vous passerait ce qu'il a fait. Mais écrire des méthodes spécifiques (je ne sais pas si JavaScript vous permet de le faire) n'est plus du code et beaucoup plus facile à comprendre en tant qu'appelant. Il établit des hypothèses réalistes sur ce que l'auteur de ce code a pensé et rend le code facile à utiliser.

GlenPeterson
la source
Juste pour vous faire savoir que JavaScript vous permet de le faire :)
Benjamin Gruenbaum
Si les hyper-explicites étaient plus faciles à lire / à comprendre, les livres pratiques se liraient comme des jargons. En outre, les méthodes par type qui font à peu près la même chose sont une faute DRY majeure pour votre développeur JS typique. Nom pour l'intention, pas pour le type. Les arguments qu'il faut doivent être évidents ou très faciles à rechercher en vérifiant à un endroit du code ou dans une liste d'arguments acceptés à un nom de méthode dans un document.
Erik Reppen