É uma boa prática substituir a divisão por multiplicação quando possível?

68

Sempre que eu precisar de divisão, por exemplo, verificação de condição, eu gostaria de refatorar a expressão de divisão em multiplicação, por exemplo:

Versão original:

if(newValue / oldValue >= SOME_CONSTANT)

Nova versão:

if(newValue >= oldValue * SOME_CONSTANT)

Porque acho que pode evitar:

  1. Divisão por zero

  2. Estouro quando oldValue é muito pequeno

Isso é certo? Existe algum problema para este hábito?

    
por mmmaaa 03.01.2018 / 02:52
fonte

9 respostas

71

Dois casos comuns a serem considerados:

Aritmética inteira

Obviamente, se você estiver usando a aritmética inteira (que trunca), você obterá um resultado diferente. Aqui está um pequeno exemplo em C #:

public static void TestIntegerArithmetic()
{
    int newValue = 101;
    int oldValue = 10;
    int SOME_CONSTANT = 10;

    if(newValue / oldValue > SOME_CONSTANT)
    {
        Console.WriteLine("First comparison says it's bigger.");
    }
    else
    {
        Console.WriteLine("First comparison says it's not bigger.");
    }

    if(newValue > oldValue * SOME_CONSTANT)
    {
        Console.WriteLine("Second comparison says it's bigger.");
    }
    else
    {
        Console.WriteLine("Second comparison says it's not bigger.");
    }
}

Saída:

First comparison says it's not bigger.
Second comparison says it's bigger.

Aritmética de ponto flutuante

Além do fato de que a divisão pode produzir um resultado diferente quando divide por zero (gera um erro, enquanto a multiplicação não o faz), também pode resultar em erros de arredondamento ligeiramente diferentes e um resultado diferente. Exemplo simples em C #:

public static void TestFloatingPoint()
{
    double newValue = 1;
    double oldValue = 3;
    double SOME_CONSTANT = 0.33333333333333335;

    if(newValue / oldValue >= SOME_CONSTANT)
    {
        Console.WriteLine("First comparison says it's bigger.");
    }
    else
    {
        Console.WriteLine("First comparison says it's not bigger.");
    }

    if(newValue >= oldValue * SOME_CONSTANT)
    {
        Console.WriteLine("Second comparison says it's bigger.");
    }
    else
    {
        Console.WriteLine("Second comparison says it's not bigger.");
    }
}

Saída:

First comparison says it's not bigger.
Second comparison says it's bigger.

Caso você não acredite em mim, aqui é um Fiddle que você pode executar e ver por si mesmo.

Outros idiomas podem ser diferentes; lembre-se, no entanto, que o C #, como muitas linguagens, implementa uma biblioteca de ponto flutuante do padrão IEEE (IEEE 754) , você deve obter os mesmos resultados em outros tempos de execução padronizados.

Conclusão

Se você estiver trabalhando com greenfield , provavelmente está OK.

Se você estiver trabalhando em código legado e o aplicativo for financeiro ou outro aplicativo sensível que execute aritmética e seja necessário para fornecer resultados consistentes, tenha muito cuidado ao alterar as operações. Se você precisar, certifique-se de ter testes de unidade que detectarão quaisquer mudanças sutis na aritmética.

Se você está apenas fazendo coisas como contar elementos em uma matriz ou outras funções computacionais gerais, provavelmente estará OK. Eu não tenho certeza se o método de multiplicação torna seu código mais claro.

Se você estiver implementando um algoritmo para uma especificação, eu não mudaria nada, não apenas por causa do problema de erros de arredondamento, mas para que os desenvolvedores pudessem revisar o código e mapear cada expressão de volta à especificação para garantir há falhas de implementação.

    
por 03.01.2018 / 05:43
fonte
25

Gosto da sua pergunta, pois ela abrange potencialmente muitas ideias. No geral, suspeito que a resposta seja depende , provavelmente dos tipos envolvidos e do possível intervalo de valores em seu caso específico.

Meu instinto inicial é refletir sobre o estilo , isto é. sua nova versão é menos clara para o leitor do seu código. Eu imagino que teria que pensar por um segundo ou dois (ou talvez mais) para determinar a intenção de sua nova versão, enquanto sua versão antiga é imediatamente clara. A legibilidade é um atributo importante do código, portanto, há um custo em sua nova versão.

Você está certo de que a nova versão evita uma divisão por zero. Certamente você não precisa adicionar um guarda (nos moldes de if (oldValue != 0) ). Mas isso faz sentido? Sua versão antiga reflete uma proporção entre dois números. Se o divisor for zero, sua proporção será indefinida. Isso pode ser mais significativo em sua situação, por exemplo. você não deve produzir um resultado neste caso.

A proteção contra estouro é discutível. Se você sabe que newValue é sempre maior que oldValue , talvez você possa fazer esse argumento. No entanto, pode haver casos em que (oldValue * SOME_CONSTANT) também transbordará. Então não vejo muito ganho aqui.

Pode haver um argumento de que você obtém um melhor desempenho porque a multiplicação pode ser mais rápida que a divisão (em alguns processadores). No entanto, teria que haver muitos cálculos como esses para um ganho significativo, ie. Cuidado com a otimização prematura.

Refletindo sobre todos os itens acima, em geral, não acho que haja muito a ganhar com sua nova versão em comparação com a versão antiga, dada a redução da clareza. No entanto, pode haver casos específicos em que haja algum benefício.

    
por 03.01.2018 / 03:22
fonte
22

Não.

Eu provavelmente chamaria isso de otimização prematura , em um sentido amplo, independentemente de você estar otimizando para desempenho , como a frase geralmente se refere, ou qualquer outra coisa que possa ser otimizada, como contagem de borda , lines de código ou, ainda mais amplamente, coisas como "design".

Implementar esse tipo de otimização como um procedimento operacional padrão coloca em risco a semântica do código e, potencialmente, oculta as bordas. Os casos de borda que você considera adequados para eliminar silenciosamente podem precisar ser explicitamente endereçados de qualquer maneira . E é infinitamente mais fácil depurar problemas em torno de bordas barulhentas (aquelas que lançam exceções) em relação àquelas que falham silenciosamente.

E, em alguns casos, é até vantajoso "desotimizar" por questões de legibilidade, clareza ou explicitação. Na maioria dos casos, seus usuários não notarão que você salvou algumas linhas de código ou ciclos de CPU para evitar manuseio de caso extremo ou tratamento de exceção. Código inábil ou silenciosamente falhando, por outro lado, irá afetar as pessoas - seus colegas de trabalho, no mínimo. (E também, portanto, o custo para construir e manter o software.)

Padrão para o que for mais "natural" e legível em relação ao domínio do aplicativo e ao problema específico. Mantenha-o simples, explícito e idiomático. Otimize conforme necessário para ganhos significativos ou para atingir um limite de usabilidade legítimo.

Observe também: Compiladores freqüentemente otimizar a divisão para você de qualquer maneira - quando é seguro para fazê-lo.

    
por 03.01.2018 / 06:45
fonte
12

Use o que tiver menos bugs e faça mais sentido lógico.

Geralmente , a divisão por uma variável é uma má ideia, uma vez que, geralmente, o divisor pode ser zero.
A divisão por uma constante geralmente depende apenas do significado lógico.

Veja alguns exemplos para mostrar que isso depende da situação:

Divisão boa:

if ((ptr2 - ptr1) >= n / 3)  // good: check if length of subarray is at least n/3
    ...

Multiplicação inválida:

if ((ptr2 - ptr1) * 3 >= n)  // bad: confusing!! what is the intention of this code?
    ...

Multiplicação boa:

if (j - i >= 2 * min_length)  // good: obviously checking for a minimum length
    ...

Divisão ruim:

if ((j - i) / 2 >= min_length)  // bad: confusing!! what is the intention of this code?
    ...

Multiplicação boa:

if (new_length >= old_length * 1.5)  // good: is the new size at least 50% bigger?
    ...

Divisão ruim:

if (new_length / old_length >= 2)  // bad: BUGGY!! will fail if old_length = 0!
    ...
    
por 03.01.2018 / 10:28
fonte
3

Fazer qualquer coisa “sempre que possível” raramente é uma boa ideia.

Sua prioridade número um deve ser a correção, seguida pela legibilidade e manutenção. Substituir cegamente a divisão por multiplicação, sempre que possível, freqüentemente falhará no departamento de correção, às vezes apenas em casos raros e, portanto, difíceis de encontrar.

Faça o que é correto e mais legível. Se você tiver evidências sólidas de que escrever código da maneira mais legível causa um problema de desempenho, é possível considerar a possibilidade de alterá-lo. Cuidado, matemática e revisões de código são seus amigos.

    
por 03.01.2018 / 19:47
fonte
1

Em relação à legibilidade do código, acho que a multiplicação é realmente mais legível em alguns casos. Por exemplo, se houver algo que você deve verificar se newValue aumentou 5% ou mais acima de oldValue , então 1.05 * oldValue é um limite no qual testar newValue , e é natural escrever

    if (newValue >= 1.05 * oldValue)

Mas cuidado com números negativos quando refatora as coisas dessa maneira (substituindo a divisão por multiplicação ou substituindo multiplicação por divisão). As duas condições que você considerou são equivalentes se oldValue tiver garantia de não ser negativo; mas suponha que newValue seja realmente -13,5 e oldValue seja -10,1. Então

newValue/oldValue >= 1.05

é avaliado como true , mas

newValue >= 1.05 * oldValue

é avaliado como falso .

    
por 03.01.2018 / 13:01
fonte
1

Observe o famoso artigo Divisão por inteiros invariantes usando multiplicação .

O compilador está realmente fazendo multiplicação, se o inteiro for invariante! Não é uma divisão. Isso acontece mesmo para não poder de 2 valores. A potência de 2 divisões usa obviamente mudanças de bits e, portanto, é ainda mais rápida.

No entanto, para números inteiros não invariantes, é sua responsabilidade otimizar o código. Antes de otimizar, certifique-se de que você está realmente otimizando um gargalo genuíno e que a exatidão não é sacrificada. Cuidado com estouro de inteiro.

Eu me preocupo com a micro-otimização, então eu provavelmente daria uma olhada nas possibilidades de otimização.

Pense também nas arquiteturas nas quais seu código é executado. Especialmente o ARM tem uma divisão extremamente lenta; você precisa chamar uma função para dividir, não há instrução de divisão no ARM.

Além disso, nas arquiteturas de 32 bits, a divisão de 64 bits não é otimizada, já que eu descobriu .

    
por 03.01.2018 / 16:51
fonte
1

Pegando em seu ponto 2, ele realmente impedirá o estouro de um oldValue muito pequeno. No entanto, se SOME_CONSTANT também for muito pequeno, seu método alternativo terminará com underflow, em que o valor não poderá ser representado com precisão.

E, inversamente, o que acontece se oldValue for muito grande? Você tem os mesmos problemas, exatamente o oposto.

Se você quiser evitar (ou minimizar) o risco de estouro / estouro negativo, a melhor maneira é verificar se newValue está mais próximo em magnitude de oldValue ou SOME_CONSTANT . Você pode então escolher a operação de divisão apropriada,

    if(newValue / oldValue >= SOME_CONSTANT)

ou

    if(newValue / SOME_CONSTANT >= oldValue)

e o resultado será mais preciso.

Para dividir por zero, na minha experiência isso quase nunca é apropriado para ser "resolvido" na matemática. Se você tem uma divisão por zero em suas verificações contínuas, então quase certamente você tem uma situação que requer alguma análise e quaisquer cálculos baseados nesses dados não têm sentido. Uma verificação explícita de divisão por zero é quase sempre o movimento apropriado. (Note que eu digo "quase" aqui, porque eu não pretendo ser infalível. Vou apenas notar que não me lembro de ter visto uma boa razão para isso em 20 anos escrevendo software embutido, e seguir em frente. .)

No entanto, se você tiver um risco real de overflow / underflow em seu aplicativo, essa provavelmente não é a solução correta. Mais provavelmente, você deve geralmente verificar a estabilidade numérica do seu algoritmo, ou simplesmente simplesmente passar para uma representação de maior precisão.

E se você não tiver um risco comprovado de estouro / estouro negativo, você não se preocupará com nada. Isso significa que você literalmente precisa provar que precisa, com números, nos comentários ao lado do código, o que explica a um mantenedor por que é necessário. Como engenheiro principal, revisando o código de outras pessoas, se eu encontrasse alguém fazendo um esforço extra com relação a isso, eu pessoalmente não aceitaria nada menos. Isso é o oposto da otimização prematura, mas geralmente teria a mesma causa raiz - obsessão com detalhes que não faz diferença funcional.

    
por 05.01.2018 / 13:50
fonte
0

Encapsule a aritmética condicional em métodos e propriedades significativos. Não só a boa nomenclatura lhe dirá o que "A / B" significa , verificação de parâmetros & O tratamento de erros também pode se esconder lá também.

Importante, como esses métodos são compostos em lógica mais complexa, a complexidade extrínseca permanece muito gerenciável.

Eu diria que a substituição de multiplicação parece uma solução razoável porque o problema é mal definido.

    
por 04.01.2018 / 19:07
fonte