En el diseño de API, ¿cuándo usar / evitar el polimorfismo ad hoc?

14

Sue es el diseño de una biblioteca JavaScript, Magician.js. Su eje central es una función que extrae Rabbitel argumento pasado.

Ella sabe que sus usuarios pueden querer sacar un conejo de una String, una Number, una Function, tal vez incluso una HTMLElement. Con eso en mente, podría diseñar su API de esta manera:

La interfaz estricta

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

Cada función en el ejemplo anterior sabría cómo manejar el argumento del tipo especificado en el nombre de la función / nombre del parámetro.

O bien, podría diseñarlo así:

La interfaz "ad hoc"

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

pullRabbittendría que explicar la variedad de diferentes tipos esperados que anythingpodría ser el argumento, así como (por supuesto) un tipo inesperado:

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

El primero (estricto) parece más explícito, quizás más seguro y quizás más eficaz, ya que hay pocos o ningún gasto general para la verificación o conversión de tipos. Pero este último (ad hoc) se siente más simple al mirarlo desde afuera, ya que "simplemente funciona" con cualquier argumento que el consumidor de API considere conveniente pasarle.

Para la respuesta a esta pregunta , me gustaría ver los pros y los contras específicos de cada enfoque (o de un enfoque completamente diferente, si ninguno de los dos es ideal), que Sue debe saber qué enfoque tomar al diseñar la API de su biblioteca.

GladstoneMantener
fuente

Respuestas:

7

Algunos pros y contras

Pros para polimórficos:

  • Una interfaz polimórfica más pequeña es más fácil de leer. Solo tengo que recordar un método.
  • Va con la forma en que se debe usar el idioma: escribir en pato.
  • Si está claro de qué objetos quiero sacar un conejo, no debería haber ambigüedad de todos modos.
  • Hacer muchas verificaciones de tipos se considera malo incluso en lenguajes estáticos como Java, donde tener muchas verificaciones de tipos para el tipo de objeto crea un código feo, si el mago realmente necesita diferenciar entre el tipo de objetos de los que está sacando un conejo. ?

Pros para ad-hoc:

  • Es menos explícito, ¿puedo extraer una cadena de una Catinstancia? ¿Eso solo funcionaría? si no, ¿cuál es el comportamiento? Si no limito el tipo aquí, tengo que hacerlo en la documentación o en las pruebas que podrían hacer un contrato peor.
  • Tienes todo el manejo de tirar de un conejo en un solo lugar, el mago (algunos podrían considerar esto una estafa)
  • Los optimizadores JS modernos diferencian entre funciones monomórficas (funciona en un solo tipo) y polimórficas. Saben cómo optimizar los monomórficos mucho mejor, por lo que pullRabbitOutOfStringes probable que la versión sea mucho más rápida en motores como V8. Vea este video para más información. Editar: yo mismo escribí un perf, resulta que en la práctica, este no es siempre el caso .

Algunas soluciones alternativas:

En mi opinión, este tipo de diseño no es muy 'Java-Scripty' para empezar. JavaScript es un lenguaje diferente con idiomas diferentes de lenguajes como C #, Java o Python. Estos modismos se originan en años de desarrolladores que intentan comprender las partes débiles y fuertes del lenguaje, lo que haría es tratar de mantener estos modismos.

Hay dos buenas soluciones en las que puedo pensar:

  • Elevar objetos, hacer que los objetos sean "extraíbles", hacer que se ajusten a una interfaz en tiempo de ejecución, y luego hacer que el mago trabaje en objetos extraíbles.
  • Usando el patrón de estrategia, enseñando al mago dinámicamente cómo manejar diferentes tipos de objetos.

Solución 1: elevar objetos

Una solución común a este problema es 'elevar' los objetos con la capacidad de sacarles conejos.

Es decir, tener una función que tome algún tipo de objeto y agregue sacarlo de un sombrero para ello. Algo como:

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

Puedo hacer tales makePullablefunciones para otros objetos, podría crear un makePullableString, etc. Estoy definiendo la conversión en cada tipo. Sin embargo, después de elevar mis objetos, no tengo ningún tipo para usarlos de forma genérica. Una interfaz en JavaScript está determinada por un tipeo de pato, si tiene un pullOfHatmétodo, puedo extraerlo con el método del mago.

Entonces Magician podría hacer:

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

Elevar objetos, usando algún tipo de patrón de mezcla parece ser la cosa más JS que hacer. (Tenga en cuenta que esto es problemático con los tipos de valores en el lenguaje que son cadena, número, nulo, indefinido y booleano, pero todos pueden encuadrarse)

Aquí hay un ejemplo de cómo se vería ese código

Solución 2: Patrón de estrategia

Al discutir esta pregunta en la sala de chat de JS en StackOverflow, mi amigo phenomnomnominal sugirió el uso del patrón de Estrategia .

Esto le permitiría agregar las habilidades para extraer conejos de varios objetos en tiempo de ejecución y crearía un código muy JavaScript. Un mago puede aprender a sacar objetos de diferentes tipos de sombreros, y los saca en base a ese conocimiento.

Así es como podría verse esto en 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

Puede ver el código JS equivalente aquí .

De esta manera, te beneficias de ambos mundos, la acción de cómo tirar no está estrechamente acoplada ni a los objetos ni al Mago, y creo que esto es una solución muy buena.

El uso sería algo como:

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
fuente
1
También recomiendo este libro gratuito de patrones de diseño de JavaScript
Benjamin Gruenbaum
2
Haría +10 para obtener una respuesta muy completa de la que aprendí mucho, pero, según las reglas de SE, tendrás que conformarte con +1 ... :-)
Marjan Venema
@MarjanVenema Las otras respuestas también son buenas, asegúrese de leerlas también. Me alegra que hayas disfrutado esto. No dude en pasar y hacer más preguntas de diseño.
Benjamin Gruenbaum
4

El problema es que está tratando de implementar un tipo de polimorfismo que no existe en JavaScript: JavaScript es casi universalmente mejor tratado como un lenguaje de tipo pato, a pesar de que admite algunas facultades de tipo.

Para crear la mejor API, la respuesta es que debe implementar ambos. Es un poco más de tipeo, pero a la larga ahorrará mucho trabajo para los usuarios de su API.

pullRabbitdebería ser un método de árbitro que verifica los tipos y llama a la función apropiada asociada con ese tipo de objeto (por ejemplo pullRabbitOutOfHtmlElement).

De esa manera, mientras que los usuarios de prototipos pueden usar pullRabbit, pero si notan una desaceleración, pueden implementar la verificación de tipo en su extremo (probablemente de una manera más rápida) y simplemente llamar pullRabbitOutOfHtmlElementdirectamente.

Jonathan Rich
fuente
2

Esto es JavaScript A medida que lo mejore, encontrará que a menudo hay un camino intermedio que ayuda a negar dilemas como este. Además, realmente no importa si un 'tipo' no compatible es atrapado por algo o se rompe cuando alguien intenta usarlo porque no hay compilación frente al tiempo de ejecución. Si lo usas mal, se rompe. Intentar ocultar que se rompió o hacer que funcione a mitad de camino cuando se rompió no cambia el hecho de que algo está roto.

Así que tenga su pastel y cómelo también y aprenda a evitar la confusión de tipos y las roturas innecesarias manteniendo todo muy, muy obvio, como bien nombrado y con todos los detalles correctos en los lugares correctos.

En primer lugar, recomiendo adquirir el hábito de poner a sus patos en fila antes de que necesite verificar los tipos. Lo más ágil y eficiente (pero no siempre lo mejor en lo que respecta a los constructores nativos) sería golpear primero los prototipos para que su método ni siquiera tenga que preocuparse por el tipo compatible que está en juego.

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

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

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

Nota: Se considera una mala forma hacer esto a Object ya que todo hereda de Object. Yo personalmente evitaría la función también. Algunos pueden sentirse ansiosos por tocar el prototipo de cualquier constructor nativo, lo que podría no ser una mala política, pero el ejemplo aún podría servir cuando trabaje con sus propios constructores de objetos.

No me preocuparía este enfoque para un método de uso tan específico que no es probable que golpee algo de otra biblioteca en una aplicación menos complicada, pero es un buen instinto para evitar afirmar algo demasiado general en los métodos nativos en JavaScript si no lo hace tiene que hacerlo a menos que esté normalizando métodos más nuevos en navegadores desactualizados.

Afortunadamente, siempre puede preasignar tipos o nombres de constructores a métodos (tenga cuidado con IE <= 8 que no tiene <objeto> .constructor.name que requiere que lo analice fuera de los resultados de toString de la propiedad del constructor). Todavía está en efecto comprobando el nombre del constructor (typeof es un poco inútil en JS al comparar objetos) pero al menos se lee mucho mejor que una declaración de interruptor gigante o si / de lo contrario encadena en cada llamada del método a lo que podría ser un ancho variedad de objetos

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 el mismo enfoque de mapa, si desea acceder al componente 'this' de los diferentes tipos de objetos para usarlos como si fueran métodos sin tocar sus prototipos heredados:

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 buen principio general en cualquier idioma de la OMI es tratar de ordenar detalles de ramificación como este antes de llegar al código que realmente aprieta el gatillo. De esa manera, es fácil ver a todos los jugadores involucrados en ese nivel superior de API para una buena visión general, pero también es mucho más fácil determinar dónde se encontrarán los detalles que podrían interesarle a alguien.

Nota: todo esto no se ha probado, porque supongo que nadie realmente tiene un uso de RL. Estoy seguro de que hay errores tipográficos / errores.

Erik Reppen
fuente
1

Esta (para mí) es una pregunta interesante y complicada de responder. De hecho, me gusta esta pregunta, así que haré todo lo posible para responder. Si realiza alguna investigación sobre los estándares para la programación javascript, encontrará tantas formas "correctas" de hacerlo como personas que promocionan la forma "correcta" de hacerlo.

Pero como estás buscando una opinión sobre cuál es la mejor manera. Aquí va nada.

Personalmente, preferiría el enfoque de diseño "ad hoc". Viniendo de un fondo c ++ / C #, este es más mi estilo de desarrollo. Puede crear una solicitud pullRabbit y hacer que ese tipo de solicitud verifique el argumento pasado y haga algo. Esto significa que no tiene que preocuparse sobre qué tipo de argumento se pasa en ningún momento. Si usa el enfoque estricto, aún necesitaría verificar de qué tipo es la variable, pero lo haría antes de realizar la llamada al método. Entonces, al final, la pregunta es, ¿desea verificar el tipo antes de hacer la llamada o después?

Espero que esto ayude, no dude en hacer más preguntas en relación con esta respuesta, haré todo lo posible para aclarar mi posición.

Kenneth Garza
fuente
0

Cuando escribe, Magician.pullRabbitOutOfInt, documenta lo que pensaba cuando escribió el método. La persona que llama esperará que esto funcione si pasa cualquier número entero. Cuando escribes, Magician.pullRabbitOutOfAnything, la persona que llama no sabe qué pensar y tiene que buscar su código y experimentar. Puede funcionar para un Int, pero ¿funcionará para un Long? Un flotador? ¿Un doble? Si está escribiendo este código, ¿hasta dónde está dispuesto a llegar? ¿Qué tipo de argumentos estás dispuesto a apoyar?

  • ¿Instrumentos de cuerda?
  • Matrices?
  • Mapas?
  • Corrientes?
  • Funciones?
  • Bases de datos?

La ambigüedad lleva tiempo para comprender. Ni siquiera estoy convencido de que sea más rápido escribir:

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, así que agregué una excepción a su código (que recomiendo) para decirle a la persona que llama que nunca imaginó que le pasarían lo que hicieron. Pero escribir métodos específicos (no sé si JavaScript le permite hacer esto) no es más código y es mucho más fácil de entender como quien llama. Establece suposiciones realistas sobre lo que pensó el autor de este código, y hace que el código sea fácil de usar.

GlenPeterson
fuente
Solo para hacerle saber que JavaScript le permite hacer eso :)
Benjamin Gruenbaum
Si hiper-explícito fuera más fácil de leer / entender, los libros de instrucciones se leerían como jerga legal. Además, los métodos por tipo que hacen más o menos lo mismo son una falta SECA importante para su desarrollador típico de JS. Nombre para intención, no para tipo. Los argumentos que se necesitan deben ser obvios o muy fáciles de buscar al verificar en un lugar del código o en una lista de argumentos aceptados en el nombre de un método en un documento.
Erik Reppen 01 de