Por que usar uma abordagem OO em vez de uma declaração gigante de "switch"?

58

Estou trabalhando em uma loja de .Net, C # e tenho um colega de trabalho que continua insistindo que devemos usar declarações Switch gigantescas em nosso código com muitos "Casos" em vez de abordagens mais orientadas a objetos. Seu argumento consistentemente volta ao fato de que uma instrução Switch compila para uma "tabela de salto CPU" e é, portanto, a opção mais rápida (mesmo que em outras coisas nossa equipe saiba que não nos importamos com a velocidade).

Eu honestamente não tenho um argumento contra isso ... porque eu não sei do que diabos ele está falando.
Ele está certo?
Ele está apenas falando a bunda dele?
Apenas tentando aprender aqui.

    
por James P. Wright 25.05.2011 / 17:15
fonte

17 respostas

48

Ele é provavelmente um hacker C antigo e sim, ele fala de bunda. .Net não é C ++; o compilador .Net continua melhorando e os hacks mais inteligentes são contraproducentes, se não hoje, na próxima versão .Net. Pequenas funções são preferíveis porque .Net JIT-s todas as funções antes de serem usadas. Portanto, se alguns casos nunca forem atingidos durante um ciclo de vida de um programa, nenhum custo é incorrido na compilação JIT. De qualquer forma, se a velocidade não é um problema, não deve haver otimizações. Escreva para o programador primeiro, para o segundo compilador. Seu colega de trabalho não será facilmente convencido, então eu provaria empiricamente que um código melhor organizado é realmente mais rápido. Eu escolheria um dos seus piores exemplos, reescrevê-los de uma maneira melhor e, em seguida, garantir que seu código seja mais rápido. Cherry-pick se você deve. Em seguida, executá-lo alguns milhões de vezes, perfil e mostrar-lhe. Isso deve ensiná-lo bem.

EDITAR

Bill Wagner escreveu:

Item 11: Entenda a Atração de Pequenas Funções (C # Efetiva Segunda Edição) Lembre-se de que traduzir seu código C # em código executável por máquina é um processo de duas etapas. O compilador C # gera IL que é entregue em assemblies. O compilador JIT gera código de máquina para cada método (ou grupo de métodos, quando inlining está envolvido), conforme necessário. Pequenas funções tornam muito mais fácil para o compilador JIT amortizar esse custo. Pequenas funções também são mais propensas a serem candidatas a inlining. Não é apenas pequenez: o fluxo de controle mais simples é importante. Menos ramificações de controle dentro das funções facilitam o registro do JIT para o registro de variáveis. Não é apenas uma boa prática escrever códigos mais claros; é como você cria um código mais eficiente em tempo de execução.

EDIT2:

Então ... aparentemente, uma instrução switch é mais rápida e melhor que um monte de instruções if / else, porque uma comparação é logarítmica e outra é linear. link

Bem, minha abordagem favorita para substituir uma instrução switch enorme é por um dicionário (ou às vezes até uma matriz, se eu estiver ligando enums ou small ints) que está mapeando valores para funções que são chamadas em resposta a elas. Fazer isso obriga a pessoa a remover muitos spaghetti compartilhados, mas isso é bom. Uma declaração de switch grande geralmente é um pesadelo de manutenção. Então ... com arrays e dicionários, a pesquisa levará um tempo constante, e haverá pouca memória extra desperdiçada.

Ainda não estou convencido de que a declaração do switch seja melhor.

    
por 25.05.2011 / 17:24
fonte
38

A menos que seu colega possa fornecer prova de que essa alteração fornece um benefício real mensurável na escala de toda a aplicação, ela é inferior à sua abordagem (ou seja, polimorfismo), que realmente fornece tal benefício: manutenção.

A microotimização só deve ser feita, após os gargalos são fixados. A otimização prematura é a raiz de todo o mal .

A velocidade é quantificável. Há pouca informação útil na "abordagem A é mais rápida que a abordagem B". A questão é " Quanto mais rápido? ".

    
por 25.05.2011 / 17:33
fonte
27

Quem se importa se é mais rápido?

A menos que você esteja escrevendo software em tempo real, é improvável que a quantidade minúscula de aceleração que você pode obter fazendo algo de uma maneira completamente insana faça muita diferença para seu cliente. Eu nem sequer iria lutar contra este na frente de velocidade, esse cara claramente não vai ouvir nenhum argumento sobre o assunto.

Manter, no entanto, é o objetivo do jogo, e uma declaração de mudança gigante não é nem um pouco sustentável, como você explica os diferentes caminhos através do código para um novo pessoal? A documentação terá que ser tão longa quanto o próprio código!

Além disso, você tem a incapacidade completa de testar a unidade de forma eficaz (muitos caminhos possíveis, sem mencionar a provável falta de interfaces etc.), o que torna seu código ainda menos sustentável.

[Do lado interessado: o JITter funciona melhor em métodos menores, então declarações gigantes de switch (e seus métodos inerentemente grandes) irão prejudicar sua velocidade em grandes montagens, IIRC.]

    
por 25.05.2011 / 17:22
fonte
14

Afaste-se da instrução switch ...

Esse tipo de declaração de mudança deve ser evitada como uma praga porque viola o Princípio Aberto e Fechado . Isso força a equipe a fazer alterações no código existente quando novas funcionalidades precisam ser adicionadas, ao invés de apenas adicionar um novo código.

    
por 25.05.2011 / 17:24
fonte
8

Eu sobrevivi ao pesadelo conhecido como a massiva máquina de estados finitos manipulada por enormes declarações de troca. Pior ainda, no meu caso, o FSM abarcou três DLLs C ++ e ficou bastante claro que o código foi escrito por alguém versado em C.

As métricas com as quais você precisa se preocupar são:

  • Velocidade de fazer uma alteração
  • Velocidade de encontrar o problema quando isso acontece

Recebi a tarefa de adicionar um novo recurso a esse conjunto de DLLs e consegui convencer o gerenciamento de que levaria apenas o tempo necessário para reescrever as 3 DLLs como uma DLL orientada a objetos propriamente dita, como seria para mim a consertar o macaco e o júri manipular a solução para o que já estava lá. A reescrita foi um enorme sucesso, pois não apenas suportava a nova funcionalidade, mas era muito mais fácil de extender. Na verdade, uma tarefa que normalmente levaria uma semana para ter certeza de que você não quebraria nada acabaria levando algumas horas.

Então, como sobre os tempos de execução? Não houve aumento ou diminuição da velocidade. Para ser justo, nosso desempenho foi limitado pelos drivers do sistema, portanto, se a solução orientada a objetos fosse de fato mais lenta, não saberíamos.

O que há de errado com as declarações massivas de troca para uma linguagem OO?

  • O fluxo de controle do programa é retirado do objeto ao qual pertence e colocado fora do objeto
  • Muitos pontos de controle externo se traduzem em muitos lugares que você precisa rever
  • Não está claro onde o estado é armazenado, particularmente se o comutador estiver dentro de um loop
  • A comparação mais rápida não é nenhuma comparação (você pode evitar a necessidade de muitas comparações com um bom design orientado a objetos)
  • É mais eficiente fazer uma iteração em seus objetos e sempre chamar o mesmo método em todos os objetos do que alterar seu código com base no tipo de objeto ou enum que codifica o tipo.
por 25.05.2011 / 20:32
fonte
8

Eu não compro o argumento de desempenho; é tudo sobre manutenção do código.

MAS: algumas vezes , uma instrução switch gigante é mais fácil de manter (menos código) do que um monte de classes pequenas substituindo as funções virtuais de uma classe base abstrata. Por exemplo, se você fosse implementar um emulador de CPU, você não implementaria a funcionalidade de cada instrução em uma classe separada - você apenas o colocaria em um swtich gigante no opcode, possivelmente chamando funções auxiliares para instruções mais complexas. / p>

Regra geral: se o switch for executado de alguma forma no TYPE, você provavelmente deve usar as funções virtual e de herança. Se o switch for executado em um VALUE de um tipo fixo (por exemplo, o código de operação da instrução, como acima), não há problema em deixá-lo como está.

    
por 26.05.2011 / 09:36
fonte
5

Você não pode me convencer disso:

void action1()
{}

void action2()
{}

void action3()
{}

void action4()
{}

void doAction(int action)
{
    switch(action)
    {
        case 1: action1();break;
        case 2: action2();break;
        case 3: action3();break;
        case 4: action4();break;
    }
}

É significativamente mais rápido que:

struct IAction
{
    virtual ~IAction() {}
    virtual void action() = 0;
}

struct Action1: public IAction
{
    virtual void action()    { }
}

struct Action2: public IAction
{
    virtual void action()    { }
}

struct Action3: public IAction
{
    virtual void action()    { }
}

struct Action4: public IAction
{
    virtual void action()    { }
}

void doAction(IAction& actionObject)
{
    actionObject.action();
}

Além disso, a versão OO é mais fácil de manter.

    
por 25.05.2011 / 20:09
fonte
4

Ele está certo de que o código da máquina resultante será provavelmente mais eficiente. O compilador essencial transforma uma instrução switch em um conjunto de testes e ramificações, que serão relativamente poucas instruções. Há uma grande chance de que o código resultante de abordagens mais abstratas exija mais instruções.

CONTUDO : É quase certo que o seu aplicativo em particular não precisa se preocupar com esse tipo de micro-otimização, ou você não estaria usando o .net em primeiro lugar. Para qualquer coisa que precise de aplicativos incorporados muito restritos, ou trabalho intensivo de CPU, você deve sempre deixar o compilador lidar com a otimização. Concentre-se em escrever código limpo e sustentável. Isso é quase sempre de grande valor do que alguns décimos de um nano-segundo em tempo de execução.

    
por 25.05.2011 / 17:25
fonte
3

Um dos principais motivos para usar classes em vez de instruções switch é que as instruções switch tendem a levar a um arquivo enorme com muita lógica. Isso é tanto um pesadelo de manutenção quanto um problema com o gerenciamento de código-fonte, já que você precisa fazer o check-out e editar esse arquivo enorme em vez de arquivos de classe menores diferentes

    
por 25.05.2011 / 17:28
fonte
3

uma instrução switch no código OOP é um strong indicativo de classes ausentes

experimente nos dois sentidos e execute alguns testes simples de velocidade; As chances são de que a diferença não seja significativa. Se eles forem e o código for crítico , mantenha a instrução switch

    
por 25.05.2011 / 17:34
fonte
3

Normalmente eu odeio a palavra "otimização prematura", mas isso cheira a isso. Vale a pena notar que Knuth usou essa famosa frase no contexto de usar as instruções goto para acelerar o código nas áreas críticas . Essa é a chave: os caminhos críticos .

Ele estava sugerindo usar goto para acelerar o código, mas alertando contra os programadores que gostariam de fazer esses tipos de coisas com base em palpites e superstições para códigos que nem sequer são críticos.

Para favorecer switch declarações tanto quanto possível uniformemente ao longo de uma base de código (se ou não alguma carga pesada é tratada) é o exemplo clássico do que Knuth chama de "penny-wise and pound- "programador tolo que passa o dia todo lutando para manter seu código" otimizado "que se transformou em um pesadelo de depuração como resultado de tentar economizar centavos acima de libras. Esse código raramente é sustentável e muito menos eficiente em primeiro lugar.

Is he right?

Ele está correto desde a perspectiva da eficiência básica. Nenhum compilador que eu saiba pode otimizar o código polimórfico envolvendo objetos e despacho dinâmico melhor que uma instrução switch. Você nunca terminará com uma LUT ou uma tabela de salto para o código embutido do código polimórfico, já que esse código tende a servir como uma barreira de otimizador para o compilador (ele não saberá qual função chamar até o momento em que o despacho dinâmico ocorre).

É mais útil não pensar nesse custo em termos de tabelas de salto, mas mais em termos da barreira de otimização. Para o polimorfismo, chamar Base.method() não permite que o compilador saiba qual função realmente será chamada se method for virtual, não lacrada e puder ser substituída. Como ele não sabe qual função será chamada antecipadamente, ela não pode otimizar a chamada de função e utilizar mais informações na tomada de decisões de otimização, já que ela não sabe qual função será chamada no momento. a hora em que o código está sendo compilado.

Os otimizadores estão no seu melhor quando conseguem perscrutar uma chamada de função e fazer otimizações que achatam completamente o chamador e o chamador ou, pelo menos, otimizam o chamador para trabalhar mais eficientemente com o chamado. Eles não podem fazer isso se não souberem qual função realmente será chamada antecipadamente.

Is he just talking out his ass?

Usar esse custo, que muitas vezes equivale a centavos, para justificar a transformação desse padrão em um padrão de codificação aplicado uniformemente é geralmente muito tolo, especialmente para locais que têm uma necessidade de extensibilidade. Essa é a principal coisa que você quer observar com otimizadores prematuros genuínos: eles querem transformar questões menores de desempenho em padrões de codificação aplicados uniformemente ao longo de uma base de código sem nenhuma consideração pela capacidade de manutenção.

Eu tomo um pouco de ofensa à citação do "antigo hacker C" usada na resposta aceita, já que sou uma delas. Nem todo mundo que tem codificado há décadas a partir de hardware muito limitado se transformou em um otimizador prematuro. Ainda eu encontrei e trabalhei com eles também. Mas esses tipos nunca medem coisas como desvios de ramificação ou erros de cache, eles acham que sabem melhor e baseiam suas noções de ineficiência em uma base de código de produção complexa baseada em superstições que não são verdadeiras hoje e às vezes nunca são verdadeiras. As pessoas que trabalharam genuinamente em campos de desempenho crítico geralmente entendem que a otimização eficaz é uma priorização eficaz e tentar generalizar um padrão de codificação que reduz a manutenção para economizar centavos é uma priorização muito ineficaz.

Moedas de um centavo são importantes quando você tem uma função barata que não faz tanto trabalho, o que é chamado de um bilhão de vezes em um loop muito apertado, crítico para o desempenho. Nesse caso, acabamos economizando 10 milhões de dólares. Não vale a pena raspar tostões quando você tem uma função chamada duas vezes pela qual o corpo sozinho custa milhares de dólares. Não é aconselhável gastar seu tempo pechinchando durante a compra de um carro. Vale a pena pechinchar mais moedas se você está comprando um milhão de latas de refrigerante de um fabricante. A chave para uma otimização eficaz é entender esses custos em seu contexto adequado. Alguém que tenta economizar centavos em cada compra e sugere que todos os outros tentam pechinchar mais, não importa o que estejam comprando, não é um otimizador habilidoso.

    
por 05.01.2016 / 12:43
fonte
2

Parece que seu colega de trabalho está muito preocupado com o desempenho. Pode ser que, em alguns casos, uma grande estrutura de caso / switch tenha um desempenho mais rápido, mas esperamos que vocês façam um experimento fazendo testes de tempo na versão OO e na versão switch / case. Eu estou supondo que a versão OO tem menos código e é mais fácil de seguir, entender e manter. Eu diria que a versão OO primeiro (como manutenção / legibilidade deve ser inicialmente mais importante), e só considere a versão switch / case somente se a versão OO tiver sérios problemas de desempenho e puder ser mostrado que um switch / case fará um melhoria significativa.

    
por 25.05.2011 / 17:23
fonte
2

Uma vantagem de manutenibilidade do polimorfismo que ninguém mencionou é que você será capaz de estruturar seu código de forma muito mais agradável usando herança se estiver sempre alternando na mesma lista de casos, mas em alguns casos vários casos são tratados da mesma maneira e às vezes eles não são

Se você estiver alternando entre Dog , Cat e Elephant , e às vezes Dog e Cat tiverem o mesmo caso, você pode fazer com que ambos herdem de uma classe abstrata DomesticAnimal e colocar essas funções no classe abstrata.

Além disso, fiquei surpreso que várias pessoas usaram um analisador como um exemplo de onde você não usaria o polimorfismo. Para um analisador parecido com uma árvore, esta é definitivamente a abordagem errada, mas se você tem algo como assembly, onde cada linha é um tanto independente, e começa com um opcode que indica como o resto da linha deve ser interpretado, eu usaria o polimorfismo e uma fábrica. Cada classe pode implementar funções como ExtractConstants ou ExtractSymbols . Eu usei essa abordagem para um interpretador BASIC de brinquedo.

    
por 25.07.2013 / 08:37
fonte
0

"Devemos esquecer as pequenas eficiências, digamos 97% do tempo: a otimização prematura é a raiz de todo o mal"

Donald Knuth

    
por 25.05.2011 / 21:04
fonte
0

Mesmo que isso não seja ruim para a manutenção, não acredito que seja melhor para o desempenho. Uma chamada de função virtual é simplesmente uma indireta extra (o mesmo que o melhor caso para uma instrução switch), portanto, mesmo em C ++, o desempenho deve ser aproximadamente igual. Em C #, onde todas as chamadas de função são virtuais, a instrução switch deve ser pior, já que você tem a mesma sobrecarga de chamada de função virtual em ambas as versões.

    
por 28.08.2012 / 16:09
fonte
0

Seu colega não está falando de seu lado negativo, no que diz respeito ao comentário sobre as tabelas de salto. No entanto, usar isso para justificar a escrita de código ruim é onde ele erra.

O compilador C # converte instruções switch em apenas alguns casos em uma série de if / else, então não é mais rápido do que usar if / else. O compilador converte instruções de comutação maiores em um Dicionário (a tabela de salto à qual seu colega está se referindo). Consulte esta resposta a uma pergunta sobre estouro de pilha no tópico para obter mais detalhes .

Uma declaração de switch grande é difícil de ler e manter. Um dicionário de "casos" e funções é muito mais fácil de ler. Como é assim que o switch é transformado, você e seu colega seriam bem aconselhados a usar os dicionários diretamente.

    
por 05.01.2016 / 13:33
fonte
0

Ele não está necessariamente falando de sua bunda. Pelo menos em C e C ++ switch instruções podem ser otimizadas para pular tabelas, enquanto eu nunca vi isso acontecer com um despacho dinâmico em uma função que só tem acesso a um ponteiro base. No mínimo, o último requer um otimizador muito mais inteligente, observando muito mais código circundante para descobrir exatamente qual subtipo está sendo usado a partir de uma chamada de função virtual através de um ponteiro base / referência.

Além disso, o despacho dinâmico geralmente serve como uma "barreira de otimização", o que significa que o compilador muitas vezes não conseguirá codificar e alocar registradores de forma otimizada para minimizar os derramamentos de pilha e todas essas coisas sofisticadas, já que não pode descobrir qual função virtual será chamada através do ponteiro base para inline e fazer toda a sua mágica de otimização. Não tenho certeza se você deseja que o otimizador seja tão inteligente e tente otimizar as chamadas de função indireta, já que isso poderia levar a muitos ramos de código a serem gerados separadamente por uma determinada pilha de chamadas (uma função que chama foo->f() teria que gerar código de máquina totalmente diferente de um que chama bar->f() através de um ponteiro base, e a função que chama essa função teria então que gerar duas ou mais versões de código e assim por diante - a quantidade de código de máquina ser gerado seria explosivo - talvez não tão ruim com um JIT de rastreio que gera o código enquanto ele está rastreando caminhos de execução a quente).

No entanto, como muitas respostas ecoaram, essa é uma razão ruim para favorecer uma grande quantidade de declarações de switch , mesmo que o volume de mão seja mais rápido por algum valor marginal. Além disso, quando se trata de micro-eficiências, coisas como ramificação e inline são geralmente muito baixa prioridade em comparação com coisas como padrões de acesso à memória.

Dito isso, eu pulei aqui com uma resposta incomum. Eu quero fazer um caso para a manutenção de switch declarações sobre uma solução polimórfica quando, e somente quando, você sabe com certeza que só vai haver um lugar que precisa executar o switch .

Um bom exemplo é um manipulador de eventos central. Nesse caso, você geralmente não tem muitos lugares lidando com eventos, apenas um (porque é "central"). Para esses casos, você não se beneficia da extensibilidade que uma solução polimórfica fornece. Uma solução polimórfica é benéfica quando há muitos lugares que fazem a declaração switch analógica. Se você tiver certeza de que só haverá um, uma instrução switch com 15 casos pode ser muito mais simples do que projetar uma classe base herdada por 15 subtipos com funções substituídas e uma fábrica para instanciá-las, apenas para ser usada em uma função em todo o sistema. Nesses casos, adicionar um novo subtipo é muito mais entediante do que adicionar uma instrução case a uma função. Se qualquer coisa, eu diria para a manutenção, não o desempenho, de switch declarações neste caso peculiar, onde você não se beneficiará de extensibilidade.

    
por 10.12.2017 / 02:16
fonte