Saltar para o conteúdo
Topos Uranos

Resumo

Um programa que dá um resultado errado não se corrige mudando linhas ao acaso, mas investigando. Esta aula apresenta a depuração como um método experimental: reproduzir a falha, formular uma hipótese sobre sua causa, projetar um experimento que a confirme ou a descarte, e delimitar o lugar do defeito; a bissecção o encontra em um número de passos que cresce como o logaritmo do tamanho do programa. Desenvolve o instrumento (a compilação com -g e sem otimização, a configuração do Visual Studio Code nos três sistemas e uma sessão real do GDB que mostra o que o editor faz por baixo), os pontos de interrupção simples e condicionais, os passos, a pilha de chamadas e a inspeção do estado, com um exemplo central: a mediana que falha apenas com um número par de dados. Fecha com as estratégias que valem mais que a ferramenta (reduzir o caso, buscar o último ponto correto, verificar invariantes com assert, ler o relatório do detector) e com três casos: um erro de um a mais, uma recursão que não termina e uma referência invalidada.

Objetivos de aprendizagem

  1. Configurar a compilação com informação de depuração (-g, sem otimização) e iniciar o depurador a partir do Visual Studio Code no próprio sistema.
  2. Usar pontos de interrupção simples e condicionais, a execução passo a passo (intervir, contornar, sair) e a inspeção de variáveis, de expressões e da pilha de chamadas.
  3. Aplicar uma estratégia sistemática (reproduzir, reduzir, bissectar, formular e pôr à prova hipóteses) para localizar um defeito dado, documentando cada hipótese.
  4. Combinar o depurador com os detectores e com os testes para localizar um erro de memória a partir de seu relatório.

Depurar é investigar

O defeito e a falha

A falha é o que se observa: um resultado errado, um programa que não termina, um relatório do detector (sanitizer). O defeito (ou erro) é a causa: uma linha, uma condição ou uma decisão de projeto que não corresponde à especificação. A falha se vê; o defeito é preciso encontrá-lo, e pode estar longe dela. Segue-se daí que corrigir onde se vê a falha, por exemplo subtraindo 2 do resultado porque “sempre sai 2 a mais”, corrige o sintoma e deixa a causa, que voltará com outros dados.

Se isso é assim, depurar é uma investigação sobre a causa, e seu método é o das ciências experimentais:

  1. Observar e reproduzir. Descrever com precisão a falha (com que entrada, o que se esperava, o que se obteve) e conseguir que ocorra sempre que se queira. Um defeito que não se reproduz não pode ser investigado, nem se pode verificar que foi corrigido.
  2. Formular uma hipótese. Uma afirmação sobre a causa que possa resultar falsa: “a ordenação não ordena”, “o índice do segundo dado central tem um a mais”.
  3. Projetar um experimento. Escolher um ponto do programa e prever que estado deveria ser visto ali se a hipótese for verdadeira e qual se for falsa.
  4. Concluir e delimitar. Descartar a hipótese ou confirmá-la; em ambos os casos, o intervalo suspeito se reduz. Repete-se até que o intervalo seja uma linha.

Ora, o método exige disciplina: mudar uma só coisa de cada vez, anotar cada hipótese com seu resultado, e não tocar no código enquanto não se souber o que se procura. Mudar linhas ao acaso até que a saída pareça boa pode ocultar a falha sem remover o defeito.

ExemploA mediana com um número par de dados

A mediana de uma lista de dados é o dado central da lista ordenada se a quantidade é ímpar, e a média dos dois centrais se é par. A aula Testar mostrou que os testes descobrem quando uma função falha, mas não onde está seu defeito. A função seguinte passa no teste com cinco dados e não com quatro.

Programa em C++
#include <algorithm>#include <cstddef>#include <print>#include <vector>​// Mediana de uma lista de dados.// Pré-condição: values não está vazio.// Pós-condição: devolve o dado central da lista ordenada, ou a média dos dois centrais.double median(std::vector<double> values){    std::ranges::sort(values);    const std::size_t n{values.size()};    const std::size_t middle{n / 2};    if (n % 2 == 1) {        return values[middle];    }    return (values[middle] + values[middle + 1]) / 2.0;}​int main(){    const std::vector<double> odd{7.0, 1.0, 5.0, 3.0, 9.0};    const std::vector<double> even{7.0, 1.0, 5.0, 3.0};    std::println("Mediana de {}: {}", odd, median(odd));    std::println("Mediana de {}: {}", even, median(even));}
Saída
Mediana de [7, 1, 5, 3, 9]: 5Mediana de [7, 1, 5, 3]: 6

A observação é precisa: com {7,1,5,3}\{7, 1, 5, 3\}, ordenados {1,3,5,7}\{1, 3, 5, 7\}, esperava-se (3+5)/2=4(3 + 5)/2 = 4 e obteve-se 6; com cinco dados, o resultado é correto. Cabem pelo menos três hipóteses: (H1) a ordenação não ordena; (H2) os índices dos dois dados centrais estão errados; (H3) a soma ou a divisão estão mal escritas. Cada uma prevê algo distinto na linha 17, antes de executá-la: se H1 é verdadeira, values não está ordenado; se H2 o é, values[middle] e values[middle + 1] não são 3 e 5; se H3 o é, os dois dados são 3 e 5 e ainda assim a conta dá 6. Que o caso ímpar funcione torna H1 improvável, mas “improvável” não é “descartada”; o experimento, com o depurador, é deter o programa na linha 17 e olhar.

A bissecção

Quando não há uma hipótese clara, resta um método que não falha: a bissecção. Supõe-se que o estado do programa é correto no começo e errado no final, e que em cada ponto intermediário se pode decidir se é correto (comparando-o com um valor calculado à mão, ou verificando um invariante). Examina-se o estado na metade do intervalo suspeito: se é correto, o defeito está na segunda metade; se não, na primeira. É a busca binária da aula Buscar e ordenar, aplicada às linhas de um programa (ou às iterações de um laço, ou às versões de um arquivo).

A figura acompanha a bissecção de dezesseis linhas com o defeito na 11; cada traço é um experimento sobre o estado depois dessa linha: correto (✓) ou errado (✗).

Demonstração

  1. No começo, as dezesseis linhas são suspeitas: sabe-se que o estado é correto antes da linha 1 e errado depois da 16.

  2. Depois da linha 8, o estado é correto: as linhas 1 a 8 ficam inocentadas.

  3. Depois da 12, o estado já é incorreto: o defeito está entre a 9 e a 12.

  4. Depois da 10, correto: restam a 11 e a 12.

  5. Depois da 11, incorreto: o último ponto correto e o primeiro incorreto são vizinhos, e o defeito está na linha 11.

A contagem é a da busca binária: cada experimento divide o intervalo por dois, de modo que kk experimentos bastam para 2k2^k linhas. Para um programa de mil linhas bastam dez, porque 210=10242^{10} = 1024. Donde a vantagem do método: seu custo cresce como o logaritmo do tamanho, e não exige compreender todo o programa, mas saber decidir, em um ponto, se o estado é o devido. Ora, essa decisão é a parte difícil: as especificações e os invariantes são o critério com que se julga cada estado intermediário.