Código Simples: Evitando o excesso de condicionais if/else e switch!

As estruturas condicionais fazem parte do cotidiano de todo o programador. Sabemos que elas nos ajudam bastante. O uso das condicionais é praticamente indispensável na maioria dos projetos, já que elas são capazes de realizar diferentes funções de forma prática. Elas permitem, por exemplo, controlar o conteúdo que será exibido, criar formulários dinâmicos, desenvolver mídias interativas e tornar páginas responsivas. Mas qualquer coisa em excesso não faz bem e podemos dizer o mesmo do if-else.

Neste artigo vamos entender como evitar em certos casos o uso de condicionais e como isso facilita a leitura e evita a introdução de bugs no software.

Quais os problemas de se utilizar em excesso?

Eles podem ser ruins nos seguintes casos:

  • Muitas estrutura condicionais levam a um grande número de ramificações de condição. Se por um descuido algum parâmetro for declarado errado e os desenvolvedores não trabalham com testes, isso pode levar a um bug que será detectado pelo QA ou em produção.

  • Aninhamento if-else ou de vários níveis ocasiona em uma leitura de código ruim para quem estiver desenvolvendo. O mesmo se aplica as estruturas de switch quando ficam extensas demais para se ler.

  • Nos testes terá que garantir que todas as possibilidades foram cobertas. Se alguma condição não foi coberta no teste, a probabilidade da condicional ocasionar em um bug será maior!

Os usos excessivos de if…else são cheiro de código (code smells). Isso torna sua base de código difícil de ler e manter. Existem muitas maneiras de superar esse problema, vamos ver algumas dicas de como superar esses obstáculos.

Funções com estruturas condicionais extensas!

Veja o código abaixo e suas condicionais:

function processTransactions(transactions) {
  if (transactions && transactions.length > 0) {
    for (const transaction of transactions) {
      if (transaction.type === 'PAYMENT') {
        if (transaction.status === 'OPEN') {
          if (transaction.method === 'CREDIT_CARD') {
            processCreditCardPayment(transaction);
          } else if (transaction.method === 'PAYPAL') {
            processPayPalPayment(transaction);
          } else if (transaction.method === 'PLAN') {
            processPlanPayment(transaction);
          }
        } else {
          console.log('Invalid transaction type!');
        }
      } else if (transaction.type === 'REFUND') {
        if (transaction.status === 'OPEN') {
          if (transaction.method === 'CREDIT_CARD') {
            processCreditCardRefund(transaction);
          } else if (transaction.method === 'PAYPAL') {
            processPayPalRefund(transaction);
          } else if (transaction.method === 'PLAN') {
            processPlanRefund(transaction);
          }
        } else {
          console.log('Invalid transaction type!', transaction);
        }
      } else {
        console.log('Invalid transaction type!', transaction);
      }
    }
  } else {
    console.log('No transactions provided!');
  }
}

function processCreditCardPayment(transaction) {
  console.log(
    'Processing credit card payment for amount: ' + transaction.amount
  );
}

function processCreditCardRefund(transaction) {
  console.log(
    'Processing credit card refund for amount: ' + transaction.amount
  );
}

function processPayPalPayment(transaction) {
  console.log('Processing PayPal payment for amount: ' + transaction.amount);
}

function processPayPalRefund(transaction) {
  console.log('Processing PayPal refund for amount: ' + transaction.amount);
}

function processPlanPayment(transaction) {
  console.log('Processing plan payment for amount: ' + transaction.amount);
}

function processPlanRefund(transaction) {
  console.log('Processing plan refund for amount: ' + transaction.amount);
}

É preciso um certo tempo para entender as condições do começo ao fim. Vemos claramente que essa estrutura de condicionais do método processTransaction() é um code smell. A leitura é difícil e qualquer desenvolvedor que precisar realizar uma manutenção fica um pouco apreensivo em trabalhar em cima desse método. Podemos até citar Martin Fowler que escreveu no livro Refatoração - Aperfeiçoando o Projeto de Código Existente (Refactoring):

"Qualquer função com mais de meia dúzia de linhas de código começa a exalar um cheiro..."

O que poderia ser feito neste código? Não alterar o comportamento, ou seja, o que a função realiza é essencial! Neste caso quem estiver desenvolvendo pode utilizar a estratégia de Extrair Função citada no livro de Martin Fowler. As várias verificações nas condições aninhadas são agrupadas em funções com nomes claros que expressam o objetivo daquela função. Depois podemos quebrar em várias funções curtas, que cuidam de responsabilidades claras para cada condicional. Mas isso deve ser feito pouco a pouco sem mover condicionais e loops demais, correndo o risco de introduzir novos bugs ao código. Como isso seria feito na prática? No código acima é possível notar que no método processTransactions() existe uma condicional que verifica se existe uma transaction ou não. Me refiro a esse trecho do código:

if (transactions && transactions.length > 0)

Podemos recortar esse código e adicionar em uma nova função abaixo de processTransactions(). Assim vamos iniciar um processo de refatoração pensando em cada parte desse método gigante como pequenas funções que tem objetivos bem claros:

function isEmpty(transactions) {
  return !transactions || transactions.length === 0;
}

Então agora a função isEmpty() retornará true se não tivermos nenhum objeto de transações ou se não tivermos elementos nas transações. Isso facilita a leitura para todos os desenvolvedores da equipe. Veja como ficou agora essa parte do código:

function processTransactions(transactions) {
  if (isEmpty(transactions)) {
    return 'No transactions provided!';
  } ...

// abaixo da função
function isEmpty(transactions) {
  return !transactions || transactions.length === 0;
}

Mas ainda podemos melhorar. Estamos retornando uma mensagem de erro, mas isso poderia ser movido para uma função que retornaria realmente um erro:

function showErrorMessage(message: string): Error {
   return new Error(message);
    }

Assim temos novamente funções objetivas. Além disso estamos conseguindo com pequenos passos refatorar um código sem introduzir bugs, o ideal seria iniciar os testes unitários para deixar o desenvolvimento mais seguro, isso é até citado no livro Refatoração - Aperfeiçoando o Projeto de Código Existente (Refactoring):

let transactions = "open";

function processTransactions(transactions: string): string | Error {
  if (isEmpty(transactions)) {
    return showErrorMessage('No transactions provided!');
  } else {
    return `Transaction is ${transactions.toUpperCase()}`
  }
} // lembrando que esse código ainda continua, apenas refatoramos uma parte dele!

// abaixo da função
function isEmpty(transactions: string ): boolean {
  return !transactions || transactions.length === 0;
}

function showErrorMessage(message: string): Error {
   return new Error(message);
    }

   console.log(processTransactions(transactions))

O código acima é apenas um exemplo. Você poderá testar o código e ver como funciona no Playground do Typescript, basta copiar, colar e executar para os dois casos de sucesso e erro.

Apenas lembrando que esse código ainda continua, apenas refatoramos uma parte dele, existem outras cascatas de condicionais que precisam de uma analise cuidadosa! Além disso ainda estamos utilizando uma estrutura condicional if-else, mas agora vemos que ela é realmente necessária para essa situação!

Existem outras técnicas para refatoração de funções gigantes, apenas vimos uma breve introdução de como refatorar. Mas além disso existe outra estratégia para utilizar quando nos deparamos com if-else extensos ou longas instruções switch.

Lookup table

No JavaScript podemos utilizar Literais de Objetos, esses encapsulam os dados, encerrando-os em um pacote organizado conforme no exemplo abaixo:

let album = {
  title: "Metallica (Black Album)",
  released: 1991,
  showInfo: function() {
    alert("Título do álbum: " + this.title + "Lançado em: " + this.released);
  }
};

Mas como podemos utilizar esse recurso para substituir condicionais switch ou if-else? Primeiro vamos ver um exemplo de um caso onde foi utilizado o switch:

const colorMapper = (color) => {
  switch (color) {
    case "yellow":
      return "amarelo";
    case "green":
      return "verde";
    case "blue":
      return "azul";
    default:
      return "Não se aplica";
  }
};

colorMapper("yellow"); //=> amarelo
colorMapper("pink"); //=> Não se aplica

Vemos que temos muitas instruções, você pode estar pensando; "o código funciona e esse switch será mais rápido para realizar a consulta". Sim e tem razão! Mas e se tivermos mais opções? A condicional poderia ter 9, 10 ou mais situações para verificar - e a legibilidade logo se tornará um problema. O ponto é deixar as coisas legíveis. Quanto mais fácil de entender, melhor será para outros desenvolvedores que precisarem ler aquele trecho de código.

O recurso de literais de objetos nos ajuda exatamente nisso, veja como ficaria esse código refatorado, lembrando que o código neste exemplo está em Typescript:

type ColorEnUSA = "yellow" | "green" | "blue";
type ColorPtBR = "amarelo" | "verde" | "azul";

const colors: Record<ColorEnUSA, ColorPtBR> = {
  yellow: "amarelo",
  green: "verde",
  blue: "azul",
};

const colorMapper = (color: ColorEnUSA): ColorPtBR | "Não aplicável " => colors[color] || "Não aplicável";

colorMapper("yellow"); //=> amarelo
colorMapper("pink"); //=> Não aplicável

A consulta por tabela pode ser mais lenta, mas temos que levar alguns pontos em consideração:

  • Existe a possibilidade de que mais casos sejam adicionados no futuro? Nesse caso, talvez seja melhor considerar outras alternativas do que apenas usar if-else. Pois por mais que seja simples no momento você terá que refatorá-lo mais tarde.

  • Existe a possibilidade de que algum dos casos compartilhe o mesmo comportamento/valor mapeado com o outro? Nesse caso, o literal de objeto pode não ser o ideal. O mais apropriado seria utilizar uma expressão switch ou a estrutura if.

  • Quantos casos haverá? Se notamos a tendência de muitas possibilidades, podemos utilizar um dicionário de dados.

  • if-else é definitivamente a melhor opção quando temos menos de 3 casos para verificar. Mais que isso podemos utilizar um switch, em outros casos podemos levar para um lookup table.

  • Introduzir expressões longas if-else deixa o código muito verboso, cansativo de se ler e entender. Tente isolar em funções pequenas e com nomes claros!

Conclusão

Escrever condicionais será sempre uma questão de entender e verificar o que faz sentido para o contexto do algoritmo que precisa ser escrito ou que já foi escrito. Certas situações exigirão uma abordagem diferente. No entanto, quando temos várias condições para verificar, os literais de objeto são a maneira mais legível e sustentável. Em outras situações realmente precisamos recorrer para outras estruturas condicionais.

Agradeço muito por ler até o final! Qualquer crítica construtiva por favor deixe nos comentários! Até o próximo artigo.