Complexidade ciclomática, análise estática e refatoração

Complexidade ciclomática é uma métrica do campo da engenharia de software, desenvolvida por Thomas J. McCabe em 1976, e serve para mensurar a complexidade de um determinado módulo (uma classe, um método, uma função etc), a partir da contagem do número de caminhos independentes que ele pode executar até o seu fim. Um caminho independente é aquele que apresenta pelo menos uma nova condição (possibilidade de desvio de fluxo) ou um novo conjunto de comandos a serem executados.

As métricas de software podem ser definidas como métodos de determinar quantitativamente a extensão em que o projeto, o processo e o produto de software têm certos atributos. Isto inclui a fórmula para determinar o valor da métrica como também sua forma de apresentação e as diretrizes de utilização e interpretação dos resultados obtidos no contexto do ambiente de desenvolvimento de software. FERNANDES, A. A. (1995) – Gerência de Software através de métricas, São Paulo, Editora Atlas.

O resultado da complexidade ciclomática indica quantos testes (pelo menos) precisam ser executados para que se verifique todos os fluxos possíveis que o código pode tomar, a fim de garantir uma completa cobertura de testes.

Calculando a complexidade ciclomática

Existem diferentes formas de se calcular:

  • Usando a notação de um grafo de fluxo;
  • Usando fluxograma;
  • Com análise estática do código, usando uma ferramenta que automatize essa tarefa.

Tendo um grafo de fluxo ou um fluxograma, temos três fórmulas equivalentes para se mensurar a complexidade ciclomática:

  1. V(G) = R – onde ==R== é o número de regiões do grafo de fluxo.
  2. V(G) = E – N + 2 – onde ==E== é o número de arestas (setas) e ==N== é o número de nós do grafo G.
  3. V(G) = P + 1 – onde ==P== é o número de nós-predicados contidos no grafo G (só funciona se os nós-predicado tiverem no máximo duas arestas saindo.)

Ps: Nós-predicado são àqueles que podem desviar o fluxo da execução: if, while, switch etc.

Exemplo usando a notação de grafo de fluxo:

alt

Exemplo retirado do trabalho da professora Bianca Zadrozny (do Instituto de Computação da UFF), que pode ser consultado aqui como excelente referência.

A complexidade ciclomática desse código é ==6==. Esse resultado pode ser obtido usando uma das três formas acima descritas:

1) V(G) = R – onde ==R== é o número de regiões do grafo de fluxo.

alt

Temos 6 regiões.

2) V(G) = E – N + 2 – onde ==E== é o número de arestas (setas) e ==N== é o número de nós do grafo G.

V(G) = 17 arestas/setas – 13 nós + 2 = 6

3) V(G) = P + 1 – onde ==P== é o número de nós-predicados contidos no grafo G.

V(G) = 5 nós-predicados + 1 = 6

No final temos, então, 6 caminhos independentes. Com isso, sabemos que precisamos ter uma gama de pelo menos 6 testes para garantir uma boa cobertura para esse código.

Os seis caminhos independentes são:

  • 1) 1-2-10-12-13
  • 2) 1-2-10-11-13
  • 3) 1-2-3-10-11-13
  • 4) 1-2-3-4-5-8-9-2-[…]
  • 5) 1-2-3-4-5-6-8-9-2-[…]
  • 6) 1-2-3-4-5-6-7-8-9-2-[…]

Você pode traçar mentalmente um a um. Por exemplo, o primeiro caminho:

  • 1-2-10-12-13

alt

Com os caminhos definidos, você pode planejar os casos de teste para cada um deles.

Ferramentas para análise estática

Essa é, claro, a mais produtiva forma: usar um analisador estático. Dentre as várias vantagens, temos:

  • Precisão;
  • Possibilidade de incluir a análise na integração contínua;
  • Geração de relatórios da evolução das métricas;

Existem várias ferramentas, você pode pesquisar sobre “análise estática” na linguagem que você utiliza em seus projetos, certamente encontrará várias opções (com focos variados).

Uma ferramenta bastante utilizada é a Sonar. Ela suporta mais de 25 linguagens. Tem a premissa de avaliar “débito de código” (o “preço” que se paga no futuro por algo que foi implementado no presente sem se preocupar tanto com a qualidade).

Se você desenvolve em PHP, escrevi um artigo sobre Ferramenta para avaliar a complexidade de código escrito em PHP.

Se você desenvolve em .NET temos:

  • ReSharper (Oferece muito mais que só análise estática)
  • ndepend (Tem um foco maior nas relações entre os objetos, acoplamento etc)

Quais os parâmetros aceitáveis para a complexidade dos meus métodos?

De acordo com o trabalho de McCabe, os valores de referência são:

Complexidade Avaliação
1-10 Método simples. Baixo risco.
11-20 Método razoavelmente complexo. Moderado risco.
21-50 Método muito complexo. Elevado risco.
51-N Método de ==altíssimo risco== e bastante instável.

Esses são apenas valores de referência. O fato de um método ter baixa complexidade não quer dizer que ele não pode ser melhorado ou até mesmo refatorado. Essa é a parte relativa da “coisa”. Caberá a você e a sua equipe identificar esses pontos.

Como posso melhorar os meus códigos?

Um código com baixa complexidade ciclomática não necessariamente já esgotou todas as possibilidades de melhorias. Há um conjunto de fatores envolvidos. Mas, em termos gerais, refatorar é a melhor “ferramenta” para se ter um código melhor autodocumentado e com menos complexidade.

Nem sempre conseguimos de “prima” acertar em cheio naquilo que desenvolvemos. Para preencher essa lacuna, existe a refatoração: melhorar a compreensão do código existente (sem alterar o seu comportamento).

Martin Fowler criou um catálogo com algumas técnicas de refatoração. Elas são focadas em se ter um código autodocumentado (expressivo, de fácil leitura, coeso etc).

Algumas das principais técnicas descritas:

Substituir recursividade por iteração: O código recursivo é difícil de entender e na maioria dos casos pode ser substituído por iteração. A não ser que se tenha um objetivo muito bem definido para a existência da recursão (para se ter algum expressivo ganho em performance; ou se você está trabalhando com uma operação muito complexa com árvores binárias etc).

Exemplo (baseado no do catálogo):

public void countDown(int n) {
    if(n == 0) return;

    System.out.println(n + "...");
    waitASecond();
    countDown(n-1);
}

Poderia ser escrito assim:

public void countDown(int n) {
    while(n > 0) {
        System.out.println(n + "...");
        waitASecond();
        n -= 1;
    }
}

Decompor condicional: Quando se têm uma estrutura condicional muito complicada com diversos if-elseif-else, pode-se extrair métodos dos resultados dessas condições e invocá-los.

Exemplo (baseado no do catálogo):

if (date.before(SUMMER_START) || date.after(SUMMER_END)) {
    charge = quantity * _winterRate + _winterServiceCharge;
} else {
    charge = quantity * _summerRate;
}

Poderia ser decomposto para:

if (notSummer(date)) {
  charge = winterCharge(quantity);
} else {
  charge = summerCharge(quantity);
}

Substituir ninhos de condicionais por cláusulas de proteção: Para tirar a complexidade do entendimento do caminho de execução.

Exemplo (baseado no do catálogo):

double getPayAmount() {
  double result;
  if (_isDead) result = deadAmount();
  else {
    if (_isSeparated) result = separatedAmount();
    else {
      if (_isRetired) result = retiredAmount();
      else result = normalPayAmount();
    };
  }
  return result;
};

Poderia ser escrito assim:

double getPayAmount() {
  if (_isDead) return deadAmount();
  if (_isSeparated) return separatedAmount();
  if (_isRetired) return retiredAmount();
  return normalPayAmount();
};

Substituir condicional por polimorfismo: Tendo um comando condicional que a partir do tipo de objeto escolhe diferentes comportamentos, você pode tornar o método original abstrato e mover essas condicionais para suas próprias subclasses.

Exemplo retirado do catálogo:

class Bird {
  //...
  double getSpeed() {
    switch (type) {
      case EUROPEAN:
        return getBaseSpeed();
      case AFRICAN:
        return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
      case NORWEGIAN_BLUE:
        return (isNailed) ? 0 : getBaseSpeed(voltage);
    }
    throw new RuntimeException("Should be unreachable");
  }
}

Poderia ser descomposto para:

abstract class Bird {
  //...
  abstract double getSpeed();
}

class European extends Bird {
  double getSpeed() {
    return getBaseSpeed();
  }
}

class African extends Bird {
  double getSpeed() {
    return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
  }
}

class NorwegianBlue extends Bird {
  double getSpeed() {
    return (isNailed) ? 0 : getBaseSpeed(voltage);
  }
}

// Somewhere in client code
speed = bird.getSpeed();

Outras técnicas com exemplos aplicados, você pode consultar aqui.

Focar na qualidade do meu software ou na entrega?

Ideologicamente falando, o processo de refatorar é contínuo. O software vai crescer, precisará ser “tocado” e a refatoração andará lado a lado a tudo isso. Não existe código perfeito, não existe código “à prova de bala”, mas é possível ter código coeso, fácil de ser lido e o principal: fácil de ser testado.

Aliar entrega (o resultado final, o que interessa para a empresa) com qualidade de software é uma questão ampla e complexa. Sabemos que, na maioria das vezes, a realidade é essa: pressão para que as entregas aconteçam o mais rápido possível. Quase nunca entra na análise de tempo e custo o que será gasto para cuidar da qualidade/análise do código. O foco é quase sempre nas entregas e, pensando no lado “corporativo”, elas são extremamente importantes para manter a competitividade da empresa e isso não pode ser ignorado, mas também tem o lado negativo, que é o débito de código (o custo e o tempo que será gasto no futuro para implementar algo novo ou corrigir uma implementação).

Não tem “receita de bolo”, não tem manual, a equipe precisa aprender a mensurar todas essas nuances e achar um “meio termo”. Não dá pra julgar essa guerra de interesses, todos têm seus motivos e suas justificativas.

Outro ponto interessante de reflexão: Não, ninguém usa todas as “melhores técnicas” de codificação (e isso é muito relativo). “Código impecável” e que usa as “melhores técnicas” etc, quase sempre é apenas case para palestrantes, professores e artigos (como este aqui). No entanto, isso não tira e nem invalida o fato de que, como desenvolvedores, podemos evoluir, podemos encontrar novas formas e novos conceitos de pensar no “todo” que envolve o desenvolvimento de software. E essa responsabilidade é nossa, não sendo possível delegá-la.

Concluindo

Os temas aqui introduzidos são vastos. A ideia não foi a de esgotar os assuntos (e isso nem seria possível), o objetivo principal foi o de acender àquela “chama” que carregamos em nós: àquela que alimenta o nosso amor e interesse pelo desenvolvimento de software. Temos de mantê-la acesa, estudando coisas novas e buscando um conhecimento multimodal.

Até a próxima!