Book cover

Página Principal | Modo Dark

Fundamentos de Manutenção de Software

Marco Tulio Valente

6 Depuração

Even though we wish it were otherwise, a majority of programming time is spent testing and debugging. ― Brian W. Kernighan and Rob Pike (The Practice of Programming)

Este capítulo começa definindo o que é depuração (Seção 6.1). Depois, na Seção 6.2, apresentamos os quatro passos principais do processo de depuração: reproduzir, localizar, identificar a causa raiz e, finalmente, corrigir o bug. Por outro lado, depuração também possui um aspecto psicológico, que precisa ser conhecido e administrado, conforme iremos ver na Seção 6.3. Em seguida, iremos comentar sobre depuradores (Seção 6.4), que, apesar de não serem ferramentas mandatórias, podem desempenhar um papel relevante no processo de depuração. Para terminar o capítulo, vamos comentar sobre duas outras técnicas de depuração: o uso de comandos assert também no código de produção (Seção 6.5) e, não menos importante, o uso de logging (Seção 6.6). No caso de logging, vamos abordar temas como níveis de logging e também boas práticas de logging.

6.1 Introdução

Como já afirmamos na Introdução do capítulo anterior, infelizmente, não é razoável admitir que nossos sistemas vão estar livres de bugs ou mesmo que eles vão ter poucos bugs. Portanto, precisamos nos preparar para corrigir os bugs que inevitavelmente serão reportados em nossos sistemas. Em Engenharia de Software, chamamos de depuração as atividades realizadas com o objetivo de reproduzir, localizar, identificar as causas e corrigir bugs. Conforme sugere a citação de Brian Kernighan e Rob Pike que abre este capítulo, nós gostaríamos de dedicar mais tempo a tarefas de concepção e implementação de novas funcionalidades, mas a realidade é que sempre gastamos uma parcela importante do nosso tempo em atividades de depuração. Portanto, temos que nos preparar e conhecer as metodologias, técnicas e ferramentas de depuração, conforme iremos apresentar neste capítulo.

6.2 Passos para Depuração

O processo de depuração possui quatro passos principais: reproduzir, localizar, identificar a causa raiz e, finalmente, corrigir o bug. A próxima figura mostra um resumo das principais atividades de cada um desses passos. Essas atividades serão explicadas nas próximas subseções.

Passos e atividades de depuração

6.2.1 Reproduzir o Bug

O primeiro passo é tentar reproduzir o bug. Para isso, é fundamental começar pela leitura do formulário do bug, esperando que ele inclua uma descrição de como reproduzi-lo, conforme já recomendamos na Seção 5.4. Se essa recomendação tiver sido seguida, esse primeiro passo será concluído de modo rápido. Caso contrário, o desenvolvedor terá que assumir o ônus de descobrir como o bug pode ser reproduzido, se possível, com o auxílio do Product Owner (PO), de um profissional de qualidade (QA, Quality Assurance) ou mesmo de um usuário final.

Após reproduzir o bug, o desenvolvedor deve verificar se é possível simplificar os passos ou entradas necessários para essa reprodução. Por exemplo, suponha um bug no carrinho de compras de um sistema de comércio eletrônico. Você conseguiu reproduzir o bug colocando cinco produtos no carrinho, tal como descrito no formulário do bug. Porém, investigando um pouco mais, descobriu que bastaria ter inserido um único produto, exatamente aquele que é vendido por terceiros. Como outro exemplo, suponha um sistema com um bug no processamento de um arquivo de entrada, no formato texto, com 1000 linhas. Em um processo semelhante a uma busca binária, você pode remover parte dessas linhas (talvez, a metade final) e verificar se o bug ainda ocorre. Na verdade, vale a pena continuar com esse processo até chegar a um arquivo bem menor, com poucas linhas e cujo processamento ainda resulta em um bug.

Em seguida, o ideal é implementar um teste de unidade ou de integração que reproduza o bug de forma automatizada. Neste momento, esse teste ainda vai falhar. Porém, ele formaliza a tarefa final que cabe ao desenvolvedor de agora em diante, isto é, para corrigir o bug basta fazer o teste passar.

Aprofundamento: A reprodução de certos bugs pode ser bastante desafiadora. Por exemplo, bugs em programas concorrentes, bugs devido a um hardware com um defeito intermitente, bugs que somente ocorrem em determinadas condições da rede (por exemplo, com muita carga ou mesmo com pouca carga), bugs que dependem de datas e horários, dentre outros. Esses bugs são mais comuns em sistemas de nível mais básico, como sistemas operacionais, bancos de dados e servidores web. No limite, pode acontecer de não conseguirmos reproduzir um determinado bug. Neste caso, a solução será corrigir o bug de forma mais formal e lógica. Isto é, entender a solução que foi implementada, bem como as propriedades que ela deve garantir (por exemplo, ausência de condições de corrida, no caso de sistemas concorrentes). E, então, deduzir que a implementação atual, em casos pontuais, não atende a tais propriedades. A partir dessa dedução, deve-se corrigir a implementação. Apenas para comparar, o processo usual de correção de bugs — que vamos apresentar neste capítulo — é observacional e empírico. Ou seja, parte-se de entradas conhecidas que estão causando um comportamento incorreto do sistema, entendem-se as causas desse comportamento e, finalmente, elas são corrigidas.

Aprofundamento: Delta debugging é um algoritmo proposto por Andreas Zeller e Ralf Hildebrandt para automatizar a tarefa de simplificar as entradas de um programa que causam um bug (link). Em linhas gerais, o algoritmo segue um princípio semelhante ao de busca binária, comentado anteriormente, mas aplicado à redução de entradas. Enquanto o bug continuar presente, o algoritmo descarta sucessivamente partes da entrada, tipicamente metade dela a cada iteração, até encontrar a menor entrada capaz de reproduzir o problema.

6.2.2 Localizar o Bug

Após reproduzir o bug, devemos localizar o módulo defeituoso do programa, ou seja, o módulo responsável pelo bug. Se tivermos implementado um teste que reproduz o bug, essa tarefa fica mais fácil, pois já temos ideia do código que está sendo executado pelo teste.

Pode ser interessante também analisar os commits mais recentes do projeto, principalmente quando uma funcionalidade estava funcionando e deixou de funcionar. Por isso, é importante que os commits sejam pequenos, pois assim fica mais fácil identificar eventuais bugs neles.

Bugs em Tempo de Execução: Se o bug for em tempo de execução, isto é, se ele terminar o programa (crash), devemos, antes de mais nada, entender com calma as mensagens de erro do sistema operacional e o conteúdo da pilha de chamadas de funções (stack trace). Essa última é impressa quando um programa termina inesperadamente.

Por exemplo, suponha que um programa terminou com a seguinte pilha de chamadas:

Exception in thread "main" java.lang.NumberFormatException: For input
string: "123ABC"

at java.base/java.lang.NumberFormatException.forInputString(
NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:662)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at App.f4(App.java:17)
at App.f3(App.java:13)
at App.f2(App.java:9)
at App.f1(App.java:4)
at App.main(App.java:22)

A primeira linha informa que o programa terminou devido a uma exceção do tipo NumberFormatException. As próximas três linhas informam que essa exceção ocorreu em métodos internos da biblioteca da linguagem, pois elas começam com at java.base. Na verdade, o que ocorreu no código dessas bibliotecas não nos interessa muito. Por outro lado, é importante identificar a última linha do programa que foi executada. No caso, a linha 17 do arquivo App.java, a qual pertence a uma função de nome f4. Nesta linha, chamou-se o método parseInt da classe Integer, que é usado para converter uma string para inteiro. Especificamente, tentou-se converter para um número inteiro a string 123ABC (veja na primeira linha), o que evidentemente não foi possível. Por isso, parseInt lançou a exceção NumberFormatException. E, como ela não foi tratada, o programa terminou.

Logo, a pilha de chamadas de funções é uma espécie de filme que mostra, em retrospectiva, o que aconteceu no programa desde o problema (exceção) até o seu término. Se preferir, você pode analisar a pilha da última para a primeira linha, ou seja, do início da execução do programa (main) até o ponto do código no qual o problema ocorreu, conforme descrito na primeira linha. Porém, independentemente do sentido da análise, a pilha de chamadas de funções fornece informações valiosas sobre as funções envolvidas em um crash.

Não podemos esquecer de comentar que, muitas vezes, a pilha de chamadas de funções possui dezenas ou mesmo centenas de linhas, o que pode impressionar o desenvolvedor e fazer com que ele desista de analisá-la. Isso acontece principalmente em sistemas que dependem de frameworks mais complexos, como ocorre com sistemas Web. No entanto, nesses casos, é possível descartar boa parte das entradas da pilha de forma relativamente fácil. Ou seja, a pilha pode ser grande, mas conseguimos inferir onde estão as entradas que são, de fato, do nosso interesse.

Bugs que Não Terminam o Programa: Por outro lado, um programa pode produzir um resultado incorreto sem que isso implique em seu término. Logo, não vai existir stack trace. Para esses casos, também existem alternativas que são sempre recomendadas, tais como inserir comandos assert em partes específicas do código, como veremos na Seção 6.5. Outra alternativa consiste em analisar os logs de execução, conforme também vamos comentar a seguir, na Seção 6.6.

Literatura Científica: Dois pesquisadores conhecidos na área de Engenharia de Software, Barry Boehm e Victor Basili, destacaram uma característica importante de bugs: eles tendem a se concentrar em determinadas partes de um sistema. Segundo os autores, cerca de 80% dos defeitos vêm de 20% dos módulos, e aproximadamente metade dos módulos não apresenta defeitos (link). Portanto, pode ser interessante monitorar os bugs de um sistema e mapeá-los para módulos ou arquivos. Dessa forma, pode-se construir um mapa das partes quentes do sistema, em número de bugs, o que pode ajudar na localização de futuros bugs reportados.

6.2.3 Identificar a Causa Raiz do Bug

A boa notícia é que este terceiro passo pode ser mais fácil do que os anteriores, pois já conseguimos isolar o bug. De todo modo, é importante voltar a ler as mensagens de erro e as informações da pilha de chamadas, agora para entender a causa do bug. Pode ser interessante também pesquisar as mensagens de erro na Web ou em fóruns de programação, pois outros desenvolvedores podem ter tido o mesmo problema e, mais importante, já terem descoberto a sua causa. Também pode-se usar uma LLM para explicar melhor a mensagem de erro.

No caso de crashes, é importante lembrar que o bug não necessariamente é causado pelas primeiras linhas mencionadas na pilha de chamadas (de cima para baixo). No exemplo que usamos, essas linhas informam que a última função chamada foi parseInt("123ABC"). Mas a causa do bug não está nessa chamada e sim no código que criou a string 123ABC. Por exemplo, essa string pode ter sido criada em funções mais abaixo na pilha, isto é, f4, f3, f2, f1 ou main.

Se em uma primeira inspeção não conseguirmos achar as causas do bug, pode ser interessante também executar uma ferramenta de análise estática (ou linter), embora com a ressalva de que elas costumam gerar diversos alertas, conforme já estudamos na Seção 5.3.

Pode-se implementar também pequenos programas ou testes que verifiquem, de forma isolada, o comportamento do programa ou de alguma biblioteca usada por ele. Por exemplo, você pode suspeitar que o bug seja causado pelo uso incorreto da função substring. Então, você pode implementar um pequeno programa, isolado, que apenas chama substring com algumas strings conhecidas, para confirmar sua hipótese em um contexto controlado.

Nesta mesma linha, pode valer a pena isolar alguma funcionalidade do sistema. Ou seja, mover apenas o código dessa funcionalidade para uma aplicação separada, para fins exclusivos de depuração.

Bugs Pontuais: Grande parte dos bugs são causados por erros mínimos, restritos a uma ou poucas linhas de código. Como exemplo, podemos mencionar os bugs do tipo off-by-one, discutidos na Seção 5.2.5. Esses bugs são causados pela inicialização incorreta de variáveis (com um valor a mais ou a menos) ou por condições de terminação de loops incorretas. Por exemplo, uma condição exclusiva(<), quando deveria ser inclusiva (<=), ou vice-versa. Outras vezes, o bug pode ser causado por uma única linha a mais no código, como no caso do bug denominado goto fail, que afetou sistemas da Apple e que também comentamos em um dos exercícios do Capítulo 5.

Segue um exemplo de bug simples, causado por um ponto e vírgula no lugar errado, mas que pode demorar um tempo para ser localizado em sistemas grandes e complexos:

for (int i = 0; i < 10; i++); {  // bug: ponto e vírgula termina o for
  System.out.println(i);
}

Considere agora o seguinte programa, implementado em C:

if (status = 1) {     // bug: atribuição (=) em vez de comparação (==)
   printf("Sucesso");
}

Provavelmente, o usuário gostaria de testar se o valor de status é igual a 1 (==) e não de atribuir o valor 1 a essa variável (=). No entanto, no código mostrado, a condição do if sempre será verdadeira, pois a atribuição retorna 1, que é interpretado como verdadeiro. Consequentemente, o comando printf sempre será executado.

Literatura Científica: Existem trabalhos na literatura que reforçam essa característica localizada e pontual de diversos bugs. Por exemplo, em 2018, o Prof. Marcelo Maia e colegas da Universidade de Uberlândia publicaram um estudo que analisou um dataset conhecido de bugs para Java (link). Esse dataset continha 395 bugs, bem como as suas correções (patches). Os autores concluíram que metade dos patches modificava, no máximo, uma linha de código. Além disso, 92% dos patches alteravam um único arquivo.

6.2.4 Corrigir o Bug

Por último, mas não menos importante, temos que terminar o serviço com a correção do bug. Esse passo também pode ser simples, principalmente se o bug estiver restrito a uma única linha, comando ou expressão, como discutimos acima.

Por outro lado, existem bugs relacionados com o entendimento de requisitos (tal como estudamos na Seção 5.2.4) que podem ter uma correção mais complexa. Esses bugs ocorrem quando um desenvolvedor não entendeu um requisito e, então, realizou uma implementação de forma incorreta ou, no limite, não implementou um certo requisito. Por exemplo, uma regra de negócio pode variar de acordo com o estado do país. Porém, o desenvolvedor achou que a regra seria a mesma para todos os estados. Logo, para corrigir esse bug relacionado com o entendimento de um requisito, ele terá que mudar de forma significativa sua implementação para considerar as especificidades de cada um dos estados brasileiros. Isso pode demandar um bom esforço e, inclusive, conversas e interações com o Product Owner (PO).

Para terminar, uma última recomendação: após corrigir um bug, devemos verificar se ele ocorre em outras partes do código (o que é relativamente comum). Então, devemos aproveitar que a correção está fresca na cabeça e já aplicá-la nessas outras partes.

6.3 Psicologia da Depuração

Corrigir bugs envolve um modelo mental diferente. Quando temos que implementar uma nova funcionalidade, estamos em um contexto de criação, tentando entregar um resultado importante para os usuários do sistema. Por outro lado, quando estamos corrigindo um bug, o contexto e o modelo mental mudam radicalmente. No limite, podemos até nos sentir culpados, pois os usuários estão perdendo algo ao ficarem impossibilitados de usar uma parte do sistema (ou mesmo o sistema inteiro). Além disso, pode haver uma pressão externa para que o bug seja rapidamente corrigido, pois os prejuízos estão se acumulando. Por isso, uma recomendação frequente ao corrigir bugs é focar no problema e evitar entrar em pânico. Também não devemos terceirizar a causa do bug; isto é, não devemos culpar outros desenvolvedores nem as bibliotecas, compiladores ou ferramentas usadas no projeto.

Complementando, corrigir um bug assemelha-se a procurar uma agulha em um palheiro. Como já explicamos antes, um pequeno detalhe pode ser a causa de um bug. Porém, como estamos com milhares de suspeitas na mente, esse detalhe pode passar despercebido. Por isso, é comum observar casos de desenvolvedores que tentam por horas corrigir um bug e não conseguem. Então, fazem uma pausa para um café e, quando voltam, em poucos minutos, conseguem resolver o problema. Ou ainda, decidem ir para casa e, no dia seguinte, também conseguem rapidamente corrigir o bug.

Anedota do Pato de Borracha: No livro The Pragmatic Programmer, Andrew Hunt e David Thomas citam o caso de um desenvolvedor que mantinha em sua mesa um pato de borracha amarelo. Quando tinha que resolver um bug complicado, ele começava a explicar o bug para o pato. Assim, o ato de verbalizar e organizar seus pensamentos o ajudava a entender melhor o problema que estava enfrentando. Muitas vezes, quase que milagrosamente, ele conseguia não só localizar, mas também corrigir o bug. Talvez você não precise ter um pato de borracha ou um brinquedo na sua mesa, mas pode ser igualmente eficaz chamar um colega para ajudá-lo a entender o bug e os caminhos que já tentou para resolvê-lo.

6.4 Depuradores

Um depurador é uma ferramenta que auxilia um desenvolvedor a encontrar bugs em um programa, atuando principalmente nos passos de localização e identificação das causas de um bug. Existem depuradores para praticamente todas as linguagens de programação, os quais podem ter uma interface de linha de comando ou então uma interface gráfica integrada a uma IDE. Navegadores, como o Chrome, também possuem um depurador integrado, que é útil para depurar aplicações web.

Depuradores permitem uma execução controlada do programa que estamos depurando. Pode-se, por exemplo, executar o programa comando a comando, bem como inspecionar os valores de variáveis, expressões e da pilha de chamadas de funções. Também é possível alterar os valores de variáveis, durante a depuração, para testar alguma hipótese. Por fim, pode-se definir pontos de parada, chamados de breakpoints. Nesses pontos, a execução do programa será pausada e pode-se inspecionar o seu estado (incluindo valores de variáveis, expressões, etc.). Assim, evita-se ter que executar o programa passo a passo desde o início até chegar no ponto do código que queremos depurar.

Suponha que temos que corrigir um bug na função mostrada a seguir. Para isso, usamos a IDE e definimos um breakpoint na última linha da função (comando return).

public double calcularMedia(int[] vetor) {
  int soma = 0;
  for (int i = 0; i < vetor.length; i++) {
    soma += vetor[i];
  }
  return soma / vetor.length;
}

Em seguida, rodamos o programa no modo de depuração e ele pausou na linha do return. Então, podemos inspecionar o valor das variáveis locais neste momento, tal como mostrado na seguinte figura:

De forma interessante, o valor de soma está correto, considerando que o vetor tem dois elementos apenas (10 e 15). Então, imediatamente suspeitamos da expressão soma / vetor.length. Para verificar o seu resultado, digitamos a expressão na console de depuração da IDE e obtivemos o resultado 12, conforme mostrado a seguir

Ao ver essa resposta, nos veio à mente que soma e vetor.length são inteiros, logo a divisão será também de inteiros. Para confirmar, ainda na console de depuração, testamos o valor da seguinte expressão: (double) soma / vetor.length. E confirmamos que ela retorna a média correta (12.5). Logo, interrompemos a depuração e corrigimos o código da função, para retornar essa última expressão. Antes de terminar, fizemos alguns testes, confirmando que agora a função está funcionando como esperado. Assim, consideramos o bug como corrigido.

Provavelmente, o principal concorrente de um depurador é a inserção manual de comandos print ou console.log ao longo do código, como abaixo:

print("passei aqui");
...
print("Valor de soma " + soma);
...
print("Antes: total= " + total);
...  // atualiza "total"
print("Depois: total= " + total);

No entanto, as funcionalidades de um depurador substituem tais comandos. Com a vantagem de que depois da depuração não temos que voltar ao código para remover os vários print que foram adicionados.

Por outro lado, existe uma desvantagem relacionada ao uso de depuradores: alguns bugs simplesmente desaparecem quando tentamos reproduzi-los usando um depurador. Isso é mais comum em programas concorrentes ou que possuem uma interface próxima com dispositivos de hardware (como drivers). De forma simplificada, esse fenômeno ocorre porque o depurador muda o código compilado do programa para implementar os breakpoints e capturar o estado da execução. Essa mudança de comportamento pode mascarar certos bugs mais específicos. Na verdade, no caso de programas concorrentes, a simples pausa da execução já pode mudar a ordem de execução das threads. O código acrescentado pelo depurador também pode mudar a ocupação da memória, por exemplo, fazendo com que variáveis com valores lixo agora tenham valores bem definidos. Inclusive, existe um nome para bugs que desaparecem quando tentamos observá-los: heisenbugs. O prefixo heisen é uma referência ao físico alemão Werner Heisenberg, autor do princípio da incerteza na mecânica quântica. Segundo esse princípio, o simples ato de observar certos sistemas já é capaz de alterar o comportamento dos mesmos.

6.5 Comandos assert

Na verdade, você já pode conhecer comandos assert de testes de unidade. Mas a novidade é que eles também podem ser usados no código de produção, como mostrado abaixo:

private static double calcularDesconto(double preco, double desconto) {
  assert preco >= 0;
  assert desconto >= 0 && desconto <= 100;

  return preco * (1 - desconto / 100.0);
}

Nesse exemplo, usamos dois comandos assert para verificar se os parâmetros do método calcularDesconto são válidos, pois não faz sentido executar o método com um preço negativo ou com um desconto que não está entre 0 e 100.

Especificamente, um assert(condicao) funciona assim: se a condição for verdadeira, nada acontece e a execução prossegue normalmente. Porém, se a condição for falsa, levanta-se uma exceção que vai terminar o programa.

Você deve estar se perguntando então: qual a vantagem de usar um assert, se eu posso implementar um if que testa uma condição e, se ela for falsa, levanta uma exceção? Resposta: a vantagem é que podemos desligar os assert em tempo de execução. Por exemplo, em Java, esses comandos estão, por padrão, desativados em produção. Ou seja, eles não implicam em nenhum custo em tempo de execução.

Mas se, por padrão, os assert ficam desligados, qual o sentido de usá-los no código? Esta pergunta é muito interessante porque sua resposta tem a ver com o objetivo deste capítulo: devemos ligar os assert quando estamos em modo de depuração, tentando descobrir um bug no programa. E, para isso, temos que usar um flag específico do compilador ou da máquina virtual da linguagem (em Java, este flag é -ea, ou enable asserts).

Mas, continuando com as nossas perguntas, qual a vantagem de ligar os comandos assert durante a depuração? Basicamente, eles são usados para evitar que um programa continue executando em um estado inválido. Por exemplo, se um desenvolvedor se confundir e chamar calcularDesconto com um preço negativo, o próprio método vai sinalizar que não consegue calcular o desconto, pois o resultado não fará sentido. Logo, em vez de continuar a execução, terminamos o programa, porque alguém cometeu um erro antes.

Fazendo uma analogia, podemos pensar nos comandos assert como sendo semelhantes aos fusíveis de um equipamento eletrônico. Se você ligar o equipamento em uma voltagem errada, o fusível vai queimar e assim evitar que a corrente elétrica chegue no equipamento principal, o que causaria uma falha ainda maior.

Porém, temos que fazer uma ressalva importante: como afirmamos, os assert costumam ser desligados em produção, logo eles não podem fazer parte da lógica obrigatória do seu código.

Por exemplo, se uma validação é de fato importante e mandatória, devemos optar por um comando if, em vez de um assert, como a seguir:

if (preco < 0.0) {
   throw new IllegalArgumentException("preço negativo: " + preco);
}

Resumindo: um if deve ser usado para proteger um programa de entradas inválidas, fornecidas por usuários do sistema. Já um assert é um comando opcional, que colocamos no código para nos proteger de outros desenvolvedores que podem vir a chamar nossas funções de forma inválida. Logo, uma vez terminados todos os testes do programa, os comandos assert podem ser desligados. E somente devemos ligá-los de novo quando um bug for detectado no sistema.

Mundo Real: O SQLite é um servidor de banco de dados relacional, de código aberto, que precisa de requisitos mínimos para sua execução e que, por isso mesmo, é usado por diversos tipos de aplicações, nos mais diversos tipos de hardware e sistemas operacionais. Além de uma suíte de testes extremamente completa, o sistema usa diversas chamadas de assert no seu código. Por exemplo, a versão 3.42.0 do SQlite possui 155.8 KSLOC (milhares de linhas de código fonte, excluindo linhas em branco e comentários). Dentre essas linhas, 6754 são comandos assert, ou seja, cerca de 4%. Pelo menos na nossa opinião, esse percentual é expressivo, principalmente para um sistema minimalista. Na documentação do sistema, afirma-se também o seguinte: no SQLite, os asserts são tão numerosos e estão em pontos tão críticos para o desempenho que o banco de dados executa cerca de três vezes mais lentamente quando eles estão habilitados. Por isso, a build padrão (de produção) do SQLite desabilita os asserts. Os comandos assert somente são habilitados quando o SQLite é compilado com a macro de pré-processador SQLITE_DEBUG definida.

Aprofundamento: Comandos assert remetem ao conceito de programação por contrato (também conhecido por design by contract), proposto pelo Prof. Bertrand Meyer, em 1988. Basicamente, programação por contrato defende que a interface de um módulo não é formada apenas por assinaturas de métodos, mas também pelas pré-condições e pós-condições de tais métodos. Pré-condições, como o próprio nome indica, são as condições que o método precisa que sejam satisfeitas para executar com sucesso. Já as pós-condições são as condições que o método garante que serão válidas ao término da sua execução. Portanto, em programação por contrato — pelo menos como implementado em linguagens de programação mais antigas, como Eiffel — pré e pós-condições fazem parte do contrato dos métodos e, portanto, são sempre verificadas. Ou seja, não é possível desabilitá-las, como ocorre com as verificações implementadas por meio dos comandos assert que estudamos nesta seção.

Aprofundamento: Depuração reversa (reverse debugging) é uma técnica que permite executar um programa “para trás”, voltando no tempo durante a depuração para observar estados anteriores da execução. Em vez de apenas avançar passo a passo, como na depuração tradicional, o desenvolvedor pode retroceder a partir de um breakpoint e inspecionar valores de variáveis e de pilha de chamadas em estados anteriores. Para isso, depuradores que oferecem essa funcionalidade precisam registrar todo histórico da execução, o que costuma consumir bastante memória. O objetivo é facilitar a localização da causa raiz de um bug, a qual pode estar distante da linha de código em que ele foi detectado. Exemplos de ferramentas de depuração reversa incluem o rr (Linux) e o recurso de mesmo nome do GDB.

6.6 Logging

Logging é o ato de registrar informações sobre a execução de um sistema, incluindo eventos que ocorreram e estados pelos quais o sistema passou. Essas informações, que podem ser salvas em arquivos ou outros meios, são úteis para entender o que aconteceu durante a execução e, portanto, para depurar bugs. Porém, logging também pode ser usado para outros fins, incluindo auditoria, análise de desempenho (por exemplo, para descobrir gargalos de execução), geração de estatísticas de uso (por exemplo, funcionalidades mais acessadas) e geração de alertas. Em resumo, podemos entender logging como sendo mais uma forma de documentação de um sistema, porém uma documentação viva, que descreve os eventos e estados importantes que ocorreram durante a execução de um sistema.

O recomendável é registrar mensagens de log usando uma biblioteca criada especificamente para esse fim, em vez de usar comandos print ou funções para salvar em um arquivo. Na verdade, toda linguagem de programação possui pelo menos uma biblioteca de logging, que oferece recursos extras, além de um simples print. Dentre eles, podemos mencionar:

Para usar uma biblioteca de logging, você primeiro precisa obter um objeto que implementa os métodos de logging. No caso da biblioteca Log4j, muito popular e tradicional em Java, cuja primeira versão foi lançada em 2001, basta usar um código como o seguinte para obter uma instância desse objeto:

Logger logger = LogManager.getLogger();

A interface Logger é a mais importante de uma biblioteca de logging, pois a partir dela teremos acesso aos métodos de logging, isto é, aos métodos que vão registrar os eventos e estados que serão úteis para depurar futuros bugs, conforme veremos a seguir.

6.6.1 Níveis de Logging

Bibliotecas de logging, via objeto logger, oferecem diversos métodos para registro de logs, que devem ser selecionados com base na severidade e finalidade da mensagem que está sendo registrada. A próxima figura ilustra os principais níveis de logging oferecidos por essas bibliotecas.

Níveis de logging

Como podemos ver, existem pelo menos seis níveis de logging, sendo que os dois primeiros costumam ficar ativos apenas durante o desenvolvimento do sistema. Já os quatro níveis restantes estão sempre ativos, isto é, são executados inclusive quando o sistema está em produção. A seguir, vamos descrever melhor cada um desses níveis.

Trace: usado para registrar informações detalhadas sobre a execução do sistema, que vão ajudar a identificar todas as funções que foram executadas. A seguir, damos exemplos de mensagens de trace:

logger.trace("Entrando em getCustomerDetails com ID: {}",
customerId);
logger.trace("Saindo de getCustomerDetails com resultado: {}",
customerDetails);
for (int i = 0; i < items.size(); i++) {
  logger.trace("Processando item {}: {}", i, items.get(i));
  ...
}

Como mostrado, podemos logar não apenas strings literais, mas também incluir parâmetros nessas strings, por exemplo, o ID do cliente que é alvo de um método getCustomerDetails.

Quando executado, o primeiro trace grava a seguinte linha no arquivo de log:

2026-02-09 10:15:32 TRACE Entrando em getCustomerDetails com ID: 425

Além da string presente na chamada de trace e do valor do ID do cliente, que estamos assumindo que é 425, a biblioteca de logging automaticamente adiciona a data, hora e o nível do log. Essas informações são úteis para você conseguir filtrar apenas as mensagens que são do seu interesse e que estão relacionadas com o bug que está corrigindo. Portanto, mensagens de logging devem possuir uma estrutura padrão, principalmente para facilitar consultas e seu processamento por outras ferramentas.

Debug: usado para registrar mensagens que serão úteis para corrigir bugs futuros ou atuais, incluindo mensagens com valores de variáveis importantes. Seguem exemplos:

logger.debug("Cache inicializado com tamanho: {}", cacheSize);
logger.debug("Opção do menu selecionada: {}", selectedOption);

Info: usado para registrar informações sobre eventos que ocorreram durante a execução do sistema.

logger.info("Aplicação iniciada com sucesso.");
logger.info("Pedido {} processado com sucesso.", orderId);

Warn: usado para registrar informações sobre eventos que podem se tornar críticos no futuro. Logo, essas mensagens são úteis para gerar alertas e se antecipar a problemas.

logger.warn("Espaço em disco acabando: {} MB restantes", freeSpace);
logger.warn("Uso de memória alto: {}%", memoryUsage);

Error: usado para registrar problemas que impactam o funcionamento de um sistema, mas que não causam um crash. Essas mensagens ajudam a depurar bugs quando, por exemplo, não temos acesso à console do sistema.

try {
  db.connect();
} 
catch (SQLException e) {
  logger.error("Falha na conexão com o banco de dados", e);
}
logger.error("Arquivo não encontrado: {}", filePath);
logger.error("Chamada de API falhou com status: {}", statusCode);

Fatal: usado para registrar problemas que vão causar, logo em seguida, o encerramento da execução do sistema.

try {
  startPaymentService();
} 
catch (Exception e) {
  logger.fatal("Falha ao iniciar o serviço de pagamentos", e);
  System.exit(1);   // encerra aplicação
}
logger.fatal("Erro fatal na inicialização da aplicação", e);

6.6.2 Usando Logging para Depuração

Logging é particularmente útil quando não conseguimos reproduzir um bug na nossa própria máquina. Nesses casos, passamos a operar praticamente no escuro, pois não sabemos exatamente o que ocorreu em tempo de execução e o que levou à falha. Essa situação é muito comum quando o bug acontece apenas no ambiente do cliente, em condições de uso e configurações ou hardware diferentes das disponíveis para a equipe de desenvolvimento. Considere, por exemplo, um sistema de caixa de supermercado. Esse sistema costuma rodar em computadores mais simples, com recursos limitados e periféricos específicos. Assim, pode ser difícil para um desenvolvedor reproduzir o bug no mesmo ambiente do supermercado. Pequenas diferenças de hardware ou entre os bancos de dados de teste e de produção já podem impedir a reprodução do bug. Pode ser também que o bug só ocorre quando os clientes compram determinados produtos, ou seja, uma informação que pode ser perfeitamente registrada em mensagens de log.

No entanto, se o sistema estiver instrumentado com logging, em diferentes níveis, o cenário muda. Passamos a ter um rastro do que aconteceu em tempo de execução, com registros valiosos sobre operações realizadas, valores de variáveis, avisos que foram dados, exceções que foram levantadas etc. Com essas informações, fica mais fácil localizar e corrigir o bug com calma, mesmo sem ter sido possível reproduzi-lo.

Por outro lado, mesmo quando conseguimos reproduzir um bug, uma análise do logging também pode ser interessante e agregar valor. Nesses casos, logging pode substituir a técnica de inserir comandos print no código, como comentamos na seção sobre depuradores. Em outras palavras, logging é uma maneira planejada, mais organizada e flexível de depuração do que usar comandos print.

6.6.3 Perguntas Frequentes

Quando eu devo inserir as mensagens de log no código? Basicamente, quando estiver implementando uma determinada funcionalidade. Ou seja, ao implementar pela primeira vez uma função, você já deve acrescentar chamadas de logging, conforme os níveis que mencionamos antes. Evidentemente, futuramente, você ou outro desenvolvedor podem decidir por acrescentar ou remover algumas dessas chamadas. Por exemplo, ao depurar um bug, você pode sentir falta de uma informação importante, tal como o valor de uma variável. Assim, você pode acrescentar, neste momento, uma chamada para logger.debug no código.

Quantas mensagens de log eu preciso acrescentar no meu código? Essa é uma resposta difícil, pois depende do domínio do sistema e da sua importância. No entanto, para dar um exemplo, vamos nos basear em um estudo publicado em 2014 por pesquisadores da Microsoft Research e da Universidade Carnegie Mellon (link). Os pesquisadores analisaram o uso de logging em dois sistemas importantes, implementados em C# e usados pela Microsoft para gerenciar seus datacenters. A próxima tabela mostra dados básicos sobre o uso de logging nesses sistemas.

Sistema MLOC # comandos de logging % comandos de logging
System-A 2.5 23.5K 0.94
System-B 10.4 95.3K 0.92

Como podemos observar, a densidade de chamadas de logging, em ambos sistemas, fica próxima de 1%. Ou seja, de cada 100 linhas de código, uma linha é uma chamada de logging. Evidentemente, conforme comentamos, esse é apenas um exemplo de um domínio particular. Logo, não podemos generalizar seus resultados, embora eles, pelo menos, ofereçam uma primeira referência quantitativa sobre a frequência de chamadas de logging.

Ainda segundo os pesquisadores, metade das chamadas de log são para logar situações inesperadas (portanto, error e fatal, se pensarmos em termos dos níveis de logging usados pelo Log4j). A outra metade é usada para logar eventos normais, mas que ocorrem em pontos importantes e críticos da execução do sistema.

Onde as mensagens de log são gravadas? Esta é uma das vantagens de usar uma biblioteca de logging, pois elas podem ser configuradas para direcionar as mensagens para arquivos de texto (para ter um histórico persistente), console (para uma visualização rápida), para bancos de dados (de forma a facilitar consultas e geração de relatórios) ou mesmo para um servidor centralizado. Nesse último caso, os logs de várias máquinas são enviados para um mesmo servidor, que cuida de agregá-los. Isso é particularmente útil em sistemas distribuídos, como é o caso de sistemas baseados em microsserviços.

Podemos registrar mensagens de log de apenas certos níveis? Sim, bibliotecas de logging permitem configurar um nível mínimo de logging (por exemplo, fatal ou error). Logo, mensagens de um nível mais baixo não serão registradas. Ou seja, é como se elas não estivessem presentes no código.

Como fazemos para limpar o arquivo de logging? Esse é um outro serviço implementado por bibliotecas de logging, com o nome de rotação de arquivos (log rotation). Por exemplo, o Log4j pode rodar o log quando o arquivo atingir um certo tamanho ou após um certo intervalo de tempo (como diariamente). O termo rodar significa fechar o arquivo atual e criar um novo arquivo de log, arquivando o anterior. Assim, os arquivos antigos podem ser mantidos por um período ou quantidade pré-definida, sendo automaticamente removidos após isso. Na verdade, algumas indústrias, como bancos, sites de comércio eletrônico (que usam serviços de operadoras de cartão de crédito) ou instituições do setor de saúde, devem operar de acordo com certos regulamentos, que também definem regras para a retenção de logs para fins de auditoria.

6.6.4 Boas Práticas de Logging

A seguir apresentamos algumas boas práticas para implementação de mensagens de log.

Incluir informações de contexto. Por exemplo, a seguinte chamada não é recomendada:

// Ruim
logger.debug("Pedido realizado com sucesso");

O motivo é que devemos logar também alguma informação sobre o pedido. Por exemplo, o seu identificador, como a seguir:

// Bom
logger.debug("Pedido {} realizado com sucesso", orderId);

Por outro lado, é importante tomar cuidado para não logar dados sensíveis, como números de cartão de crédito, senhas ou mesmo CPFs.

Evitar concatenações. O motivo é que concatenações são computadas antes de chamar os métodos de logging, o que pode comprometer o desempenho. Portanto, a seguinte chamada não é recomendada:

// Ruim
logger.debug("Encontrados " + count + " itens para o pedido " +
orderId);

O ideal é usar marcadores (placeholders, como {}) para indicar onde os parâmetros devem entrar na string, como em:

// Bom
logger.debug("Encontrados {} itens para o pedido {}", count, orderId);

Resumindo: na primeira chamada, a concatenação sempre será realizada, mesmo que o nível de debug esteja desativado. Já na segunda chamada, se debug estiver desativado, a substituição dos marcadores pelos parâmetros não vai ocorrer.

Não usar toString(). O motivo é que toString() já é chamado automaticamente pela biblioteca de logging, como explicado neste exemplo:

// Bom
// se orderID não for null, chama-se automaticamente toString()
// caso contrário, loga "null"
logger.debug("orderId: {}", orderId);

Por outro lado, se o objeto for null e uma chamada toString () for executada, ocorrerá uma NullPointerException, como no seguinte exemplo:

// Ruim
// se orderId for null, levanta-se NullPointerException
logger.debug("orderId: {}", orderId.toString());

Logar exceções completas. Não devemos logar apenas o nome da exceção, como no seguinte exemplo:

try {
  processTransaction();
} 
catch (Exception exception) {
  // Ruim
  logger.error("Falha ao processar transação: " + exception.getMessage());
}

Em vez disso, devemos logar todo o objeto que representa a exceção.

try {
  processTransaction();
} 
catch (Exception exception) {
  // Bom
  logger.error("Falha ao processar transação", exception);
}

O motivo é que o objeto exception, dentre outras informações, armazena a pilha completa de chamadas de funções no momento da exceção. A análise dessa pilha é sempre útil para localizar o bug, como vimos na Seção 6.2.2.

6.6.5 Observabilidade

Como explicamos antes na seção, logging é o ato de gerar e armazenar registros sobre eventos e estados que ocorreram durante a execução de um sistema. Já observabilidade, como o próprio nome indica, é o ato de observar, analisar e entender tais registros. O objetivo não é depurar um bug, mas sim monitorar a execução do sistema, gerando métricas e alertas (por exemplo, quando um sistema fica indisponível ou quando uma mesma exceção é gerada diversas vezes). Normalmente, essas métricas e alertas são mostrados em dashboards, para facilitar a visualização. Portanto, observabilidade é uma atividade que fica mais sob a responsabilidade de profissionais da área de DevOps, como administradores de sistemas e SREs (Site Reliability Engineers).

Bibliografia

Brian W. Kernighan, Rob Pike. The Practice of Programming. Addison-Wesley, 1999.

Diomidis Spinellis. Effective Debugging: 66 Specific Ways to Debug Software and Systems. Addison-Wesley, 2016.

Andreas Zeller. Why Programs Fail: A Guide to Systematic Debugging, 2nd Edition. Morgan Kaufmann, 2009.

Andrew Hunt, David Thomas. The Pragmatic Programmer: Your Journey to Mastery (20th Anniversary Edition). Addison-Wesley, 2019.

Bertrand Meyer. Object-Oriented Software Construction. Prentice Hall, 1988.

Exercícios

1. Desenvolvedores estão sempre se deparando com bugs e tendo que corrigi-los. Então, da próxima vez que tiver que corrigir um bug, mesmo que seja simples e em um trabalho acadêmico, tente seguir os passos descritos na Seção 6.2 e que estão resumidos na tabela que aparece logo no início dessa seção. Reflita também sobre as principais atividades que realizou em cada um dos passos, bem como sobre a importância delas.

2. Na seção 6.2.2, mostramos a pilha de chamadas de funções que foi gerada após um crash que resultou no levantamento de uma exceção NumberFormatException. No entanto, nós apenas mostramos a pilha. Escreva agora um programa que termine e imprima a mesma pilha de chamadas de funções analisada na referida seção.

3. Usando a linguagem de programação e a IDE de sua preferência, simule os mesmos passos que usamos na seção 6.4 para corrigir um bug na função calcularMedia. Você pode reusar o código dessa função ou reimplementá-la em uma outra linguagem. Experimente e pratique a criação de breakpoints, bem como inspecione os valores de variáveis, expressões e da pilha de chamadas de funções.

4. Na Seção 6.2, ao comentar sobre técnicas para localização de bugs, mencionamos que pode ser interessante analisar os commits mais recentes do repositório de versões do sistema. No entanto, dois outros comandos git também podem ajudar nesses casos: git blame e git bisect.

  1. Pesquise e entenda o funcionamento de cada um desses comandos e descreva como eles podem ser usados na localização de bugs.

  2. Faça um pequeno teste com cada comando em algum repositório.

5. Além do breakpoint básico explicado neste capítulo, depuradores costumam oferecer variações desse instrumento de depuração. Pesquise por pelo menos outros três tipos de breakpoints e explique o funcionamento deles de forma resumida.

6. Quando comentamos sobre comandos assert, na Seção 6.5, fizemos uma analogia entre esses comandos e os fusíveis de um equipamento eletrônico. Apesar de interessante para fins didáticos, essa analogia não é perfeita. Qual a diferença principal entre comandos assert e fusíveis, tendo em vista o papel deles em seus respectivos contextos.

7. Em Java, comandos assert estão desligados por default e devem ser ligados por meio de um flag (-ea) da máquina virtual, conforme mencionamos na Seção 6.5. Pesquise então como asserts funcionam em Python e também em C. Qual o comportamento padrão dos comandos assert nessas linguagens? Como fazemos para ligá-los ou desligá-los?

8. Em 2021, foi descoberta uma vulnerabilidade crítica de segurança na biblioteca Log4j, conhecida como Log4Shell (CVE-2021-44228). Nessa época, o Log4j permitia escrever expressões que seriam avaliadas na máquina na qual o logging estava sendo registrado, como no seguinte exemplo:

String versao = "${java:version}";   
logger.info("Versão do Java em execução: {}", versao);
  1. Pesquise como essa funcionalidade foi explorada por atacantes para injetar código malicioso em aplicações que utilizavam Log4j.

  2. Qual prática de programação defensiva, tal como estudamos no Capítulo 5, poderia ter sido adotada para evitar essa vulnerabilidade?