Fundamentos de Manutenção de Software
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.
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:
Possibilidade de definir níveis de logging, de acordo com sua severidade e finalidade, conforme iremos explicar na próxima subseção.
Possibilidade de direcionar as mensagens para diferentes destinos, como console, arquivos e bancos de dados. Normalmente, isso pode ser feito mudando um único parâmetro no arquivo de configuração da biblioteca de logging.
Possibilidade de formatar e padronizar as mensagens, incluindo campos como data, hora e nível do log. Ou seja, desenvolvedores não precisam se preocupar com esses metadados, pois eles serão automaticamente gerados pela biblioteca de logging.
Melhor desempenho do que o uso de comandos
print, devido, por exemplo, ao uso de caches e de estratégias para evitar concatenações desnecessárias de strings.
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.
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.
Pesquise e entenda o funcionamento de cada um desses comandos e descreva como eles podem ser usados na localização de bugs.
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);
Pesquise como essa funcionalidade foi explorada por atacantes para injetar código malicioso em aplicações que utilizavam Log4j.
Qual prática de programação defensiva, tal como estudamos no Capítulo 5, poderia ter sido adotada para evitar essa vulnerabilidade?