Nella progettazione delle API, quando utilizzare / evitare il polimorfismo ad hoc?


14

Sue sta progettando una libreria JavaScript, Magician.js. La sua chiave di volta è una funzione che estrae Rabbitl'argomento passato.

Sa che i suoi utenti potrebbero voler estrarre un coniglio da a String, a Number, a Function, forse anche a HTMLElement. Con questo in mente, potrebbe progettare la sua API in questo modo:

L'interfaccia rigorosa

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

Ogni funzione nell'esempio sopra saprebbe come gestire l'argomento del tipo specificato nel nome della funzione / nome del parametro.

Oppure, potrebbe progettarlo in questo modo:

L'interfaccia "ad hoc"

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

pullRabbitdovrebbe tenere conto della varietà di diversi tipi previsti che l' anythingargomento potrebbe essere, così come (ovviamente) un tipo inaspettato:

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

Il primo (rigoroso) sembra più esplicito, forse più sicuro e forse più performante, poiché ci sono spese generali minime o nulle per il controllo del tipo o la conversione del tipo. Ma quest'ultimo (ad hoc) sembra più semplice guardarlo dall'esterno, in quanto "funziona" con qualsiasi argomento il consumatore dell'API ritenga conveniente passargli.

Per la risposta a questa domanda , vorrei vedere pro e contro specifici per entrambi gli approcci (o per un approccio completamente diverso, se nessuno dei due è l'ideale), che Sue dovrebbe sapere quale approccio adottare durante la progettazione dell'API della sua libreria.


Risposte:


7

Alcuni pro e contro

Pro per polimorfico:

  • Un'interfaccia polimorfica più piccola è più facile da leggere. Devo solo ricordare un metodo.
  • Si accompagna al modo in cui la lingua è destinata a essere utilizzata: la digitazione Duck.
  • Se è chiaro da quali oggetti voglio estrarre un coniglio, non dovrebbe esserci comunque ambiguità.
  • Fare un sacco di controllo del tipo è considerato negativo anche in linguaggi statici come Java, dove avere un sacco di controlli del tipo per il tipo di oggetto rende brutto codice, nel caso in cui il mago abbia davvero bisogno di distinguere tra il tipo di oggetti da cui sta tirando fuori un coniglio ?

Pro per ad-hoc:

  • È meno esplicito, posso estrarre una stringa da Catun'istanza? Funzionerebbe semplicemente? in caso contrario, qual è il comportamento? Se non limito il tipo qui, devo farlo nella documentazione o nei test che potrebbero peggiorare il contratto.
  • Hai tutta la maneggevolezza di tirare un coniglio in un posto, il Mago (alcuni potrebbero considerarlo un imbroglione)
  • I moderni ottimizzatori JS differenziano tra le funzioni monomorfe (funziona solo su un tipo) e polimorfiche. Sanno come ottimizzare quelli monomorfi molto meglio, quindi pullRabbitOutOfStringè probabile che la versione sia molto più veloce in motori come V8. Guarda questo video per ulteriori informazioni. Modifica: ho scritto un perf da solo, si scopre che in pratica, non è sempre così .

Alcune soluzioni alternative:

Secondo me, questo tipo di design non è molto "Java-Scripty" per cominciare. JavaScript è una lingua diversa con modi diversi da linguaggi come C #, Java o Python. Questi idiomi nascono da anni di sviluppatori che cercano di capire le parti deboli e forti del linguaggio, quello che farei è cercare di attenermi a questi idiomi.

Ci sono due belle soluzioni a cui riesco a pensare:

  • Elevare gli oggetti, renderli "pulibili", renderli conformi a un'interfaccia in fase di esecuzione, quindi far lavorare il Mago su oggetti pulibili.
  • Usando il modello strategico, insegnando al Mago in modo dinamico come gestire diversi tipi di oggetti.

Soluzione 1: elevare oggetti

Una soluzione comune a questo problema è quella di "elevare" gli oggetti con la capacità di farli estrarre dai conigli.

Cioè, avere una funzione che prende un qualche tipo di oggetto e aggiunge per estrarre un cappello. Qualcosa di simile a:

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

Posso fare tali makePullablefunzioni per altri oggetti, potrei creare un makePullableString, ecc. Sto definendo la conversione su ogni tipo. Tuttavia, dopo aver elevato i miei oggetti, non ho alcun tipo per usarli in modo generico. Un'interfaccia in JavaScript è determinata dalla digitazione di un'anatra, se ha un pullOfHatmetodo posso estrarla con il metodo del Mago.

Quindi Magician potrebbe fare:

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

Elevare gli oggetti, usando una sorta di pattern mixin sembra la cosa più JS da fare. (Si noti che ciò è problematico con i tipi di valore nella lingua che sono stringa, numero, null, indefinito e booleano, ma sono tutti in grado di boxare)

Ecco un esempio di come potrebbe apparire tale codice

Soluzione 2: modello di strategia

Quando ho discusso di questa domanda nella chat room di JS in StackOverflow, il mio amico phenomnomnominal ha suggerito l'uso del modello strategico .

Ciò ti consentirebbe di aggiungere le capacità di estrarre i conigli da vari oggetti in fase di esecuzione e di creare codice molto JavaScript. Un mago può imparare a estrarre oggetti di diversi tipi dai cappelli e li tira in base a quella conoscenza.

Ecco come potrebbe apparire in 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

Puoi vedere il codice JS equivalente qui .

In questo modo, beneficiate di entrambi i mondi, l'azione di come tirare non è strettamente accoppiata né agli oggetti, né al Mago e penso che questo rappresenti una soluzione molto bella.

L'utilizzo sarebbe qualcosa del tipo:

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


2
Lo farei +10 per una risposta molto approfondita da cui ho imparato molto, ma, secondo le regole SE, dovrai accontentarti di +1 ... :-)
Marjan Venema

@MarjanVenema Anche le altre risposte sono buone, assicurati di leggerle anche tu. Sono contento che ti sia piaciuto questo. Sentiti libero di fermarti e di porre più domande sul design.
Benjamin Gruenbaum,

4

Il problema è che stai cercando di implementare un tipo di polimorfismo che non esiste in JavaScript: JavaScript è quasi universalmente meglio trattato come un linguaggio tipizzato da anatra, anche se supporta alcune facoltà di tipo.

Per creare l'API migliore, la risposta è che è necessario implementare entrambi. È un po 'più di digitazione, ma risparmierà molto lavoro a lungo termine per gli utenti della tua API.

pullRabbitdovrebbe essere solo un metodo arbitro che controlla i tipi e chiama la funzione appropriata associata a quel tipo di oggetto (ad es pullRabbitOutOfHtmlElement.).

In questo modo, mentre gli utenti della prototipazione possono utilizzare pullRabbit, ma se notano un rallentamento possono implementare il controllo del tipo alla loro estremità (probabilmente in un modo più veloce) e semplicemente chiamare pullRabbitOutOfHtmlElementdirettamente.


2

Questo è JavaScript. Man mano che migliorerai, troverai spesso una via di mezzo che aiuta a negare dilemmi come questo. Inoltre, non importa se un 'tipo' non supportato viene catturato da qualcosa o si interrompe quando qualcuno cerca di usarlo perché non c'è compilazione vs. runtime. Se lo usi male si rompe. Cercare di nascondere che si è rotto o farlo funzionare a metà strada quando si è rotto non cambia il fatto che qualcosa è rotto.

Quindi prendi la tua torta e mangiala anche tu e impara a evitare confusione di tipo e rotture inutili mantenendo tutto davvero, davvero ovvio, come nel nome ben noto e con tutti i dettagli giusti in tutti i posti giusti.

Prima di tutto, incoraggio vivamente a prendere l'abitudine di mettere le tue anatre di fila prima di dover controllare i tipi. La cosa più magra ed efficiente (ma non sempre migliore per quanto riguarda i costruttori nativi) sarebbe quella di colpire prima i prototipi in modo che il tuo metodo non debba nemmeno preoccuparsi di quale tipo di supporto è in gioco.

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

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

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

Nota: è ampiamente considerato come una cattiva forma fare questo all'Object poiché tutto eredita dall'Object. Personalmente eviterei anche la funzione. Alcuni potrebbero sentirsi ansiosi di toccare il prototipo di qualsiasi costruttore nativo che potrebbe non essere una cattiva politica, ma l'esempio potrebbe ancora servire quando si lavora con i propri costruttori di oggetti.

Non mi preoccuperei di questo approccio per un metodo così specifico che non è probabile che ostruisca qualcosa da un'altra libreria in un'app meno complicata, ma è un buon istinto evitare di affermare qualcosa di eccessivamente generale attraverso metodi nativi in ​​JavaScript se non lo fai è necessario a meno che non si stiano normalizzando metodi più recenti in browser non aggiornati.

Fortunatamente, puoi sempre pre-mappare i tipi o i nomi dei costruttori sui metodi (fai attenzione a IE <= 8 che non ha <object> .constructor.name che richiede di analizzarlo dai risultati toString dalla proprietà del costruttore). Stai ancora effettuando il controllo del nome del costruttore (typeof è in qualche modo inutile in JS quando si confrontano gli oggetti) ma almeno legge molto meglio di un'istruzione switch gigante o catena if / else in ogni chiamata del metodo a ciò che potrebbe essere ampio varietà di oggetti.

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

O usando lo stesso approccio cartografico, se si desidera accedere al componente 'this' dei diversi tipi di oggetti per usarli come se fossero metodi senza toccare i loro prototipi ereditati:

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 buon principio generale in qualsiasi lingua IMO, è quello di cercare di risolvere i dettagli di ramificazione come questo prima di arrivare al codice che effettivamente preme il trigger. In questo modo è facile vedere tutti i giocatori coinvolti a quel livello API superiore per una bella panoramica, ma è anche molto più facile capire dove saranno probabilmente trovati i dettagli a cui qualcuno potrebbe interessare.

Nota: tutto questo non è testato, perché presumo che nessuno abbia effettivamente un uso RL per questo. Sono sicuro che ci sono errori di battitura / bug.


1

Questa (per me) è una domanda interessante e complicata a cui rispondere. In realtà mi piace questa domanda, quindi farò del mio meglio per rispondere. Se fai qualche ricerca sugli standard per la programmazione javascript, troverai tanti modi "giusti" per farlo come ci sono persone che propagandano il modo "giusto" per farlo.

Ma dal momento che stai cercando un parere su quale sia il modo migliore. Qui non va niente.

Personalmente preferirei l'approccio progettuale "ad hoc". Proveniente da uno sfondo c ++ / C #, questo è più il mio stile di sviluppo. È possibile creare una richiesta pullRabbit e fare in modo che un tipo di richiesta controlli l'argomento passato e faccia qualcosa. Ciò significa che non devi preoccuparti di quale tipo di argomento viene trasmesso in qualsiasi momento. Se si utilizza l'approccio rigoroso, è comunque necessario verificare il tipo di variabile, ma è necessario farlo prima di effettuare la chiamata del metodo. Quindi alla fine la domanda è: vuoi controllare il tipo prima di effettuare la chiamata o dopo.

Spero che questo ti aiuti, non esitare a fare più domande in merito a questa risposta, farò del mio meglio per chiarire la mia posizione.


0

Quando scrivi, Magician.pullRabbitOutOfInt, documenta cosa hai pensato quando hai scritto il metodo. Il chiamante si aspetterà che funzioni se viene passato un numero intero. Quando scrivi Magician.pullRabbitOutOfAnything, il chiamante non sa cosa pensare e deve scavare nel tuo codice e sperimentare. Potrebbe funzionare per un Int, ma funzionerà per un lungo? Un galleggiante? Un doppio? Se stai scrivendo questo codice, quanto sei disposto ad andare? Che tipo di argomenti sei disposto a sostenere?

  • Stringhe?
  • Array?
  • Mappe?
  • I flussi?
  • Funzioni?
  • Banche dati?

L'ambiguità richiede tempo per capire. Non sono nemmeno convinto che sia più veloce scrivere:

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

vs:

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

OK, quindi ho aggiunto un'eccezione al tuo codice (che consiglio vivamente) per dire al chiamante che non avresti mai immaginato che ti avrebbero passato quello che hanno fatto. Ma scrivere metodi specifici (non so se JavaScript ti consente di farlo) non è più un codice e molto più facile da capire come chiamante. Stabilisce ipotesi realistiche su ciò che l'autore di questo codice ha pensato e rende il codice facile da usare.


Ti sto solo facendo sapere che JavaScript ti consente di farlo :)
Benjamin Gruenbaum,

Se l'hyper-esplicito fosse più facile da leggere / comprendere, i libri di istruzioni sarebbero leggibili come legali. Inoltre, i metodi per tipo che fanno tutti all'incirca la stessa cosa sono un grave fallo secco per il tuo tipico sviluppatore JS. Nome per intento, non per tipo. Ciò che sostiene dovrebbe essere ovvio o molto facile da cercare controllando in un punto del codice o in un elenco di argomenti accettati con un nome di metodo in un documento.
Erik Reppen,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.