No design da API, quando usar / evitar polimorfismo ad hoc?

14

Sue está projetando uma biblioteca JavaScript Magician.js,. Seu pino de linch é uma função que retira Rabbito argumento passado.

Ela sabe que seus usuários podem querer tirar um coelho de um String, um Number, um Function, talvez até um HTMLElement. Com isso em mente, ela poderia criar sua API da seguinte maneira:

A interface estrita

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

Cada função no exemplo acima saberia como lidar com o argumento do tipo especificado no nome da função / nome do parâmetro.

Ou ela poderia projetá-lo assim:

A interface "ad hoc"

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

pullRabbitprecisaria considerar a variedade de tipos esperados diferentes que o anythingargumento poderia ser, bem como (é claro) um 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.
};

O primeiro (estrito) parece mais explícito, talvez mais seguro e talvez com melhor desempenho - pois há pouca ou nenhuma sobrecarga para verificação ou conversão de tipo. Mas o último (ad hoc) parece mais simples olhando de fora, na medida em que "simplesmente funciona" com qualquer argumento que o consumidor da API achar conveniente passar para ele.

Para a resposta a essa pergunta , eu gostaria de ver prós e contras específicos de qualquer uma das abordagens (ou de uma abordagem diferente, se nenhuma delas é ideal), para que Sue saiba qual abordagem deve ser adotada ao criar a API de sua biblioteca.

GladstoneKeep
fonte
2
Tipo de

Respostas:

7

Alguns prós e contras

Prós para polimórficos:

  • Uma interface polimórfica menor é mais fácil de ler. Eu só tenho que lembrar de um método.
  • Isso combina com a maneira como o idioma deve ser usado - digitação de pato.
  • Se estiver claro de quais objetos eu quero puxar um coelho, não deve haver ambiguidade de qualquer maneira.
  • Realizar muitas verificações de tipo é considerado ruim, mesmo em linguagens estáticas como Java, onde ter muitas verificações de tipo para o tipo de objeto gera códigos feios, caso o mago precise realmente diferenciar o tipo de objeto que está puxando um coelho ?

Prós para ad-hoc:

  • É menos explícito, posso puxar uma corda de um Cat instância? Isso apenas funcionaria? se não, qual é o comportamento? Se eu não limitar o tipo aqui, tenho que fazê-lo na documentação ou nos testes que podem fazer um contrato pior.
  • Você tem todo o cuidado de puxar um coelho em um só lugar, o Mágico (alguns podem considerar isso um golpe)
  • Os otimizadores de JS modernos diferem entre funções monomórficas (funciona em apenas um tipo) e funções polimórficas. Eles sabem como otimizar os monomórficos muito melhor, de modo que a pullRabbitOutOfStringversão provavelmente será muito mais rápida em motores como o V8. Veja este vídeo para mais informações. Edit: Eu mesmo escrevi um perfume, acontece que, na prática, esse nem sempre é o caso .

Algumas soluções alternativas:

Na minha opinião, esse tipo de design não é muito 'Java-Scripty' para começar. JavaScript é uma linguagem diferente, com idiomas diferentes de linguagens como C #, Java ou Python. Esses idiomas se originam em anos de desenvolvedores tentando entender as partes fracas e fortes da linguagem, o que eu faria é tentar ficar com esses idiomas.

Existem duas soluções legais que posso pensar:

  • Elevar objetos, tornar objetos "puxáveis", adaptando-os a uma interface em tempo de execução, e depois fazer com que o Mágico trabalhe em objetos puxáveis.
  • Usando o padrão de estratégia, ensinando ao Mago dinamicamente como lidar com diferentes tipos de objetos.

Solução 1: elevando objetos

Uma solução comum para esse problema é "elevar" objetos com a capacidade de obter coelhos deles.

Ou seja, tem uma função que pega algum tipo de objeto e acrescenta puxar um chapéu para ele. Algo como:

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

Posso criar essas makePullablefunções para outros objetos, criar um makePullableStringetc. Estou definindo a conversão em cada tipo. No entanto, depois de elevar meus objetos, não tenho tipo de usá-los de maneira genérica. Uma interface em JavaScript é determinada por uma digitação de pato, se houver um pullOfHatmétodo, eu posso puxá-la com o método do Magician.

Então Magician poderia fazer:

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

Elevar objetos, usar algum tipo de padrão mixin parece ser a coisa mais JS a fazer. (Observe que isso é problemático com os tipos de valor no idioma, que são string, número, nulo, indefinido e booleano, mas todos podem ser usados ​​em caixas)

Aqui está um exemplo de como esse código pode parecer

Solução 2: Padrão de Estratégia

Ao discutir esta questão na sala de bate-papo JS no StackOverflow, meu amigo phenomnomnominal sugeriu o uso do padrão Strategy .

Isso permitiria adicionar as habilidades para extrair coelhos de vários objetos em tempo de execução e criaria muito código JavaScript. Um mágico pode aprender a tirar objetos de diferentes tipos de cartolas, e os puxa com base nesse conhecimento.

Aqui está como isso pode parecer no 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

Você pode ver o código JS equivalente aqui .

Dessa forma, você se beneficia dos dois mundos, a ação de como puxar não está fortemente acoplada aos objetos ou ao Mágico, e acho que isso cria uma solução muito agradável.

O uso seria 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
fonte
1
Também recomendo vivamente este livro gratuito de padrões de design JavaScript
Benjamin Gruenbaum
2
Eu tinha +10 isso para uma resposta muito completa a partir do qual eu aprendi muito, mas, conforme as regras SE, você vai ter que se contentar com um ... :-)
Marjan Venema
@MarjanVenema As outras respostas também são boas, leia-as também. Estou feliz que você tenha gostado disso. Sinta-se à vontade para parar e fazer mais perguntas sobre o design.
Benjamin Gruenbaum
4

O problema é que você está tentando implementar um tipo de polimorfismo que não existe no JavaScript - o JavaScript é quase universalmente melhor tratado como uma linguagem tipada por pato, mesmo que ele suporte algumas faculdades de tipo.

Para criar a melhor API, a resposta é que você deve implementar as duas. É um pouco mais digitado, mas economizará muito trabalho a longo prazo para os usuários da sua API.

pullRabbitdeve ser apenas um método de árbitro que verifica os tipos e chama a função apropriada associada a esse tipo de objeto (por exemplo pullRabbitOutOfHtmlElement).

Dessa forma, enquanto os usuários de prototipagem podem usar pullRabbit, mas se perceberem uma desaceleração, poderão implementar a verificação de tipo no final (provavelmente de maneira mais rápida) e apenas ligar pullRabbitOutOfHtmlElementdiretamente.

Jonathan Rich
fonte
2

Este é o JavaScript. À medida que você melhora, descobre que muitas vezes há um caminho intermediário que ajuda a negar dilemas como esse. Além disso, realmente não importa se um 'tipo' não suportado é capturado por algo ou se quebra quando alguém tenta usá-lo, porque não há compilação versus tempo de execução. Se você usar errado, ele quebra. Tentar esconder que ele quebrou ou fazê-lo funcionar a meio caminho quando quebrou não muda o fato de que algo está quebrado.

Portanto, coma seu bolo e coma-o também, e aprenda a evitar confusão de tipo e quebra desnecessária, mantendo tudo muito, muito óbvio, como bem nomeado e com todos os detalhes certos, nos lugares certos.

Primeiro de tudo, eu recomendo que você tenha o hábito de colocar seus patos em uma fila antes de precisar verificar os tipos. A coisa mais enxuta e eficiente (mas nem sempre melhor no que diz respeito aos construtores nativos) seria acertar os protótipos primeiro, para que seu método nem precise se preocupar com o tipo suportado em jogo.

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

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

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

Nota: É amplamente considerado como uma forma incorreta fazer isso com o Object, pois tudo herda do Object. Eu pessoalmente evitaria a função também. Alguns podem se sentir impacientes ao tocar no protótipo de qualquer construtor nativo, o que pode não ser uma política ruim, mas o exemplo ainda pode servir ao trabalhar com seus próprios construtores de objetos.

Eu não me preocuparia com essa abordagem para um método de uso específico que provavelmente não perturba algo de outra biblioteca em um aplicativo menos complicado, mas é um bom instinto evitar evitar algo excessivamente geral nos métodos nativos em JavaScript, se você não o fizer. a menos que você esteja normalizando métodos mais recentes em navegadores desatualizados.

Felizmente, você sempre pode pré-mapear tipos ou nomes de construtores para métodos (cuidado com o IE <= 8, que não possui <object> .constructor.name, exigindo que você os analise dos resultados de toString da propriedade construtor). Você ainda está checando o nome do construtor (typeof é meio inútil em JS ao comparar objetos), mas pelo menos parece muito melhor do que uma declaração de switch gigante ou encadeia se / else em todas as chamadas do método para o que poderia ser amplo variedade 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]();
}

Ou usando a mesma abordagem de mapa, se você quiser acessar o componente 'this' dos diferentes tipos de objetos para usá-los como se fossem métodos sem tocar em seus protótipos herdados:

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

Um bom princípio geral em qualquer idioma da IMO é tentar resolver detalhes de ramificação como este antes de chegar ao código que realmente aciona o gatilho. Dessa forma, é fácil ver todos os jogadores envolvidos no nível superior da API para obter uma boa visão geral, mas também é muito mais fácil descobrir onde os detalhes com os quais alguém pode se importar provavelmente serão encontrados.

Nota: tudo isso não foi testado, porque presumo que ninguém realmente tenha um uso de RL para isso. Tenho certeza de que há erros de digitação / erros.

Erik Reppen
fonte
1

Esta (para mim) é uma pergunta interessante e complicada de responder. Na verdade, eu gosto dessa pergunta e farei o possível para responder. Se você pesquisar alguma coisa sobre os padrões da programação javascript, encontrará tantas maneiras "certas" de fazê-lo quantas pessoas estão divulgando a maneira "certa" de fazê-lo.

Mas como você está procurando uma opinião sobre qual caminho é melhor. Aqui vai nada.

Eu, pessoalmente, preferiria a abordagem de design "adhoc". Vindo de um background c ++ / C #, esse é mais o meu estilo de desenvolvimento. Você pode criar uma solicitação pullRabbit e fazer com que esse tipo de solicitação verifique o argumento passado e faça alguma coisa. Isso significa que você não precisa se preocupar com o tipo de argumento que está sendo passado a qualquer momento. Se você usar a abordagem estrita, ainda precisará verificar qual é o tipo da variável, mas faria isso antes de fazer a chamada do método. Portanto, no final, a questão é: você deseja verificar o tipo antes de fazer a ligação ou depois.

Espero que isso ajude, sinta-se à vontade para fazer mais perguntas em relação a esta resposta, farei o possível para esclarecer minha posição.

Kenneth Garza
fonte
0

Quando você escreve, Magician.pullRabbitOutOfInt, ele documenta o que você pensou quando escreveu o método. O chamador espera que isso funcione se for passado um número inteiro. Quando você escreve Magician.pullRabbitOutOfAnything, o interlocutor não sabe o que pensar e precisa se aprofundar no seu código e experimentar. Pode funcionar para um Int, mas funcionará para um Long? Um flutuador? O dobro? Se você está escrevendo este código, até onde está disposto a ir? Que tipos de argumentos você deseja apoiar?

  • Cordas?
  • Matrizes?
  • Maps?
  • Streams?
  • Funções?
  • Bases de dados?

A ambiguidade leva tempo para entender. Não estou nem convencido de que seja mais rápido escrever:

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, então eu adicionei uma exceção ao seu código (o que eu recomendo) para informar ao chamador que você nunca imaginou que eles passariam o que fizeram. Mas escrever métodos específicos (não sei se o JavaScript permite fazer isso) não é mais código e é muito mais fácil entender como o chamador. Ele define suposições realistas sobre o que o autor deste código pensou e facilita o uso do código.

GlenPeterson
fonte
Apenas deixá-lo saber que o JavaScript permite que você faça isso :)
Benjamin Gruenbaum
Se fosse mais fácil ler / entender hiper-explícito, os livros de instruções seriam lidos como juridiquês. Além disso, métodos por tipo que praticamente fazem a mesma coisa são uma grande falta DRY para o seu típico JS dev. Nome para intenção, não para tipo. O que é necessário args deve ser óbvio ou muito fácil de procurar, verificando em um local do código ou em uma lista de argumentos aceitos em um nome de método em um documento.
amigos estão