Introdução à Programação B (2017/2018)

Aulas teóricas

Artur Miguel Dias



Teórica 09 (20/nov/2017)

Técnicas básicas de depuração de programas.

Ficheiros e suas operações básicas.
Processamento sequencial de ficheiros, linha a linha.
Texto formatado em ficheiros e strings.
Duas técnicas de processamento de ficheiros: com uma única passagem pelos dados e com carregamento em vetor.



Técnicas básicas de depuração de programas

O facto de um programa já ter sido compilado com sucesso, e portanto já não gerar mensagens de erro nem mensagens de warning, ainda não significa que esteja correto. O programa já corre, mas será que faz tudo o que pretendemos, em todas as situações?

Só devemos dar o nosso trabalho por concluído quando nos convencermos de que eliminámos todos os erros de lógica do programa.

Testes para detetar erros de lógica

Para detetar possíveis erros de lógica, devemos conceber uma boa coleção de testes para validar o programa. Este tema já foi discutido no início da teórica 4, no tema "A importância dos testes na programação".

Se os nossos testes forem completos e bem concebidos e se o programa passar todos os testes, então ótimo. Temos o trabalho concluído pois ficámos com uma razoável confiança de que o programa está correto.

Mas, o que fazer se o programa produzir resultados errados para alguns dos testes? Este é um tema novo, que ainda não foi discutido nas aulas teóricas. A questão é: sabendo nós que existem erros de lógica, como descobrir quais são esses erros?

Leitura cuidada do código

Para encontrar erros de lógica, o primeiro passo será reler o programa cuidadosamente, à procura de falhas. Muitas vezes, o facto de sabermos que o programa falhou um determinado teste faz-nos ter a suspeita de que o erro está numa determinada função.

Corrige-se a função errada, voltam a correr-se os testes, e esperemos que, desta vez, o programe já passe todos os testes.

Técnica das mensagens de depuração

Contudo, podemos chegar à seguinte situação: já lemos o código meia dúzia de vezes e ainda não conseguimos detetar as falhas de lógica. O programa não funciona corretamente, o que significa que há erros de lógica presentes, mas por alguma razão não estamos a conseguir ver essas falhas!

A manobra seguinte será adicionar alguns printfs para confirmar se o que acontece durante a execução do programa corresponde ao que estamos a pensar.

Vamos ver um exemplo. O seguinte programa pretende somar os primeiros 10 números naturais a começar em 0, ou seja fazer a conta 0+1+2+3+4+5+6+7+8+9.

O resultado esperado é 45. Mas o nosso programa está a produzir 10, claramente errado! Vamos inserir um printf dentro do ciclo para ver se o ciclo está a funcionar bem. O ciclo deveria mostrar os valores de i a variar, desde 0 até 9. Mas o programa mostra um único valor para i, o valor 9. Descobrimos então que há um ";" errado no final do for. Retirando esse ";" e correndo novamente o programa já aparecem valores sucessivos para i. A situação melhorou!

Contudo, os valores de i não alcançam o 9, como queríamos, pois param no 8! Descobrimos então que a condição do for está errada. É preciso trocar "i < n-1" por "i < n". Correndo o programa corrigido, vemos que o ciclo já efetua todas as iterações pretendidas, com o i a variar de 0 até 9.

Mesmo assim, o resultado final ainda não está certo. Esperávamos o resultado 45 e o programa está a produzir 46. Bem, olhando para o código e considerando o desvio de apenas uma unidade, se calhar o erro está na inicialização da variável "soma"...

Está mesmo! Trocando o 1 por 0, já fica tudo bem. Podemos agora apagar o printf que colocámos para nos ajudar a descobrir os erros.

O programa final, com os três erros de lógica corrigidos, fica assim:

"assert" e programação preventiva

Quem tem muita experiência de programação já aprendeu há muito tempo a ser modesto. Acontece tantas vezes estarmos convencidos de que o nosso programa está certo, que já não tem erros de lógica, e passadas algumas semanas, ao voltar a usar o programa, descobrimos uma nova situação em que o programa dá o resultado errado. Afinal ainda havia erros de lógica escondidos, que não conseguimos apanhar na primeira versão do programa! Infelizmente, esta é uma situação comum nos programas médios e grandes. Pense, por exemplo, nos "updates" do Windows, que é um programa com milhões de linhas de código: estão constantemente a ser descobertos novos erros no Windows, que têm de ser corrigidos através de "updates" mundiais.

Outro problema é o seguinte. Muito programas têm um tempo de vida longo e vão ganhado novas funcionalidades ao longo da sua existência. Se uma versão do programa não tem erros de lógica, a versão seguinte já os pode ter. Imagine, por exemplo, que o programa contém uma função que só deve ser chamada com um argumento positivo; mas devido a alguma confusão na cabeça do programador, a nova funcionalidade usa a função com um argumento negativo.

Devemos sempre suspeitar dos nossos programas, mesmo que pareçam estar corretos. E num programa grande, por mais testes que criemos, costuma haver sempre situações que ficam por testar.

Será que existem técnicas que ajudem a tornar os erros de lógica mais óbvios, por forma a não ficarem disfarçados e a manifestarem-se mais depressa? Existem sim, e a técnica mais básica consiste no aproveitamento da função assert, que nos é oferecida pela biblioteca do C. Veja este exemplo de utilização:

Cada chamada de assert contém uma condição booleana que precisa de ser verdadeira no ponto onde aparece. Podemos inserir algumas chamadas de assert nos pontos que consideramos mais críticos do nosso programa, ou seja naqueles pontos mais complicados onde receamos que os erros de lógica se possam instalar. Em tempo de execução, as condições do assert são validadas, e no caso de algumas delas ser falsa, a execução do programa para imediatamente sendo escrita uma mensagem de erro. Nós, programadores, ficamos satisfeitos porque detetámos mais um erro de lógica para corrigir e assim evitamos a situação horrível que seria o programa produzir um resultado errado em que o utilizador iria confiar.

Recomenda-se o uso de alguns asserts em programas médios e grandes. Mas convém não abusar do seu uso porque um excesso de asserts prejudicam a legibilidade do código, o que por sua vez confunde a cabeça do programador e convida ao aparecimento de mais erros.

Outras técnicas de depuração

Chama-se depuração de programas à atividade de deteção e correção erros de lógica nos programas. Em Inglês, usa-se o termo debugging.

Atrás, foram explicadas as três técnicas mais básicas de depuração de programas: escrita de testes, escrita de mensagens de depuração, uso de asserts. São técnicas que funcionam bem e muitos programadores não sentem a necessidade de usar mais nada. Em geral, tratam-se de programadores que se preocupam em gastar mais tempo a tentar escrever código inicial correto do que posteriormente a fazer depuração de programas.

Contudo existem outras técnicas mais avançadas que muitos programadores apreciam e também não há nada de mal nisso. Por exemplo, o CodeBlocks possui um depurador de código sofisticado que poderá ser útil em algumas situações. Contudo, na nossa disciplina não desenvolvemos a aplicação dessas técnicas porque o nosso tempo é limitado e existem outras questões mais prioritárias.



Ficheiros

Esta secção constitui uma introdução ao tema da manipulação de ficheiros de texto usando a linguagem C.

Os ficheiros servem para guardar dados de forma permanente, geralmente no disco do computador. O computador pode ser desligado e mais tarde ligado sem que os ficheiros desapareçam.

Existem duas grandes categorias de ficheiros:

Em IP-B só vamos trabalhar com ficheiros de texto, que vamos processar linha a linha. Não trabalharemos com ficheiros binários.

Para manipular ficheiros num programa, é necessário colocar no início o seguinte include, já nosso conhecido:

Para representar ficheiros em C usamos o seguinte tipo:

Repare que o tipo se escreve todo em maiúsculas e que inclui um '*'. Inicialmente você vai estranhar o "*", até porque não daremos nenhuma explicação profunda da sua razão de ser, mas depois vai habituar-se.


Abertura de ficheiros

Antes de começar a usar um ficheiro, primeiro é necessário abrir esse ficheiro usando a função de biblioteca Quando se chama esta função, recolhemos o resultado numa variável de tipo FILE * para depois continuarmos a trabalhar com o ficheiro.

Eis um exemplo de abertura dum ficheiro para leitura através da função fopen. Usa-se a string "r" no segundo argumento para indicar que o ficheiro vai ser lido ("r" significa read). No caso de não ser possível abrir o ficheiro para leitura (por exemplo, o ficheiro poderá não existir...), a função fopen produz o valor especial NULL.

Eis um exemplo de abertura dum ficheiro para escrita. Usa-se a string "w", no segundo argumento, para indicar que o ficheiro vai ser escrito ("w" significa write). Cuidado com a abertura de ficheiros em modo de escrita: qualquer conteúdo existente no ficheiro perde-se, pois a abertura para escrita coloca um ficheiro no estado de vazio.


Fecho de ficheiros

Após o tratamento dum ficheiro, quando o programa já não tiver de lidar mais com ele, devemos fechar o ficheiro usando a função de biblioteca A mesma função serve para ficheiros abertos para leitura e abertos para escrita:

Processamento sequencial de ficheiros

Os ficheiros são processados sequencialmente, ou seja por ordem, começando-se no início e terminando no fim. Esse processamento pode ser efetuado linha a linha, ou caráter a caráter. O processamento linha a linha é o mais habitual e nós vamos especializar-nos neste tipo de processamento.

Processamento sequencial de ficheiros, linha a linha

Os processamentos linha a linha são baseadas nas duas seguintes funções de biblioteca:

Primeiro exemplo: O seguinte programa conta o número de linhas dum ficheiro. A contagem é efetuada no ciclo while da função contar_linhas. Outro exemplo: O seguinte programa copia um ficheiro para outro, linha a linha. A cópia é efetuada no ciclo while da função copiar.

Processamento sequencial de ficheiros, caráter a caráter

Este tipo de processamento não será treinado nas aulas práticas nem aparecerá nos testes e exame.

Os processamentos caráter a caráter são baseadas nas funções de biblioteca:

O seguinte programa copia um ficheiro para outro, caráter a caráter:

Leituras e escritas de texto formatado em ficheiro

Para além da leitura e escrita de linhas de texto completas, os processamentos de ficheiros também podem envolver a manipulação de texto formatado usando as duas funções de biblioteca: O primeiro argumento destas funções é um ficheiro. O segundo argumento é uma string que se chama string de formatação.

Nas strings de formatação podem ocorrer diversos designadores de formato, por exemplo %d, %lf, que indicam o tipo dos valores que se pretender ler ou escrever. O seguinte exemplo mostra a escrita, num ficheiro f, dum valor real r e duma string str:

O seguinte exemplo mostra a leitura, a partir dum ficheiro f, dum real r e duma string str. Note que os argumentos que aparecem depois da string de formatação são argumentos de saída, o que implica que sejam usados com o operador &. Só não se usa o operador & no caso das strings (a razão técnica é que as strings são vetores).

O seguinte exemplo mostra a leitura, a partir dum ficheiro f, duma data no formato dia/mes/ano:

A função de biblioteca fscanf produz um resultado inteiro que, por vezes, convém não ignorar. Esse resultado é o número de valores lidos com sucesso.

Exemplo:


Leituras e escritas de texto formatado em strings

Esta parte é muito interessante! Para além se ser possível ler e escrever texto formatado em ficheiros, também é possível fazer o mesmo em strings.

As funções de biblioteca que permitem fazer isso são as duas seguintes:

É preciso cautela no uso da função sprintf. A escrita é efetuada na string str e é importante que ela tenha suficiente capacidade para lá caber tudo o que é escrito.

Exemplos:


Os ficheiros predefinidos stdin, stdout, stderr

Muito importante: Quando um programa em C arranca, há sempre três ficheiros que são automaticamente abertos: Lidar com o teclado e com o ecrã do computador como se fossem ficheiros é uma ideia prática e inteligente. Faz sentido... trata-se de fazer leituras a partir do teclado e de fazer escritas no ecrã do computador.

Para ler/escrever carateres em stdio/stdout podem usar-se as duas funções de biblioteca:

Para ler/escrever texto formatado em stdio/stdout podem usar-se as, já bem conhecidas, funções de biblioteca: O ficheiro stderr deve ser usado para emitir mensagens de erro, como na seguinte função, muito útil:

Leitura de linhas de texto

A função fscanf não permite ler linhas de texto completas. A verdade é que o designador "%s", que aparece em scanf("%s", ...) só serve para ler palavras e não linhas completas.

Para ler linhas completas, precisamos de usar a função fgets, já apresentada atrás. Mas esta função tem um detalhe que importa conhecer: a linha vem sempre com um '\n' no final.

Em muitos problemas de programação, esse '\n' final não causa transtorno e podemos usar a função fgets diretamente.

Mas noutras situações é importante livrar-nos desse '\n' final. Fazer isso é simples: basta escrever um '\0' na posição em que se encontra o '\n'.

Estão aqui duas funções, prontas a usar, que leem uma linha de texto completa e já tratam de eliminar o '\n'.



Técnicas de processamento de ficheiros

Nesta secção vamos discutir a seguinte questão: A resposta a esta pergunta é clara em dois tipos de situações: O que determina a orientação anterior é o facto do acesso a dados em ficheiro (no disco), ser muito mais lento do que o acesso a dados guardados num vetor (na memória central). Não nos interessa abrir o ficheiro por duas vezes para o percorrer duas vezes.

As duas situações, bem distintas, são apresentadas de seguida usando exemplos.

Processamento de ficheiros fazendo uma única passagem pelo ficheiro

Considere o seguinte problema:

Enunciado

Assuma que a Seleção Nacional vai participar no Mundial de Futebol de 2018, na Rússia. Desenvolva um programa que permita manipular a seguinte informação referente a cada um dos jogadores: O programa lê os dados a partir dum ficheiro. Assuma que tanto o nome de cada jogador e o nome de cada clube é constituído por uma única palavra, para facilitar a leitura usando fscanf(f, "%s", nome).

No final da sua execução, o programa deve apresentar os seguintes resultados:

Procure fazer um programa bem organizado. Em particular, programe tarefas distintas dentro de funções distintas.

O ficheiro "jogadores.txt" tem um conteúdo semelhante ao seguinte:

Discussão e solução

Vamos resolver este problema progressivamente, ao longo de várias versões até o programa ficar completo. Tentaremos programar tudo com apenas uma passagem pelo ficheiro, sem carregar os dados num vetor.

Vamos começar por programar apenas esta parte:

Eis o código. Repare como o ciclo contido na função processar_ficheiro produz os resultados pretendidos, sem ter sido necessário carregar o ficheiro num vetor.

Agora vamos acrescentar mais um elemento ao enunciado do problema. O que pretende agora é:

Esta alteração é simples de tratar. Basta modificar um pouco a função processar_ficheiro:

Finalmente, vamos atacar o problema completo:

Esta alteração também é simples de tratar. Modifica-se novamente a função processar_ficheiro:

Processamento de ficheiros com carregamento dos dados num vetor

Já reparou que na solução anterior não respeitámos a ordem do que se pede no enunciado? Afinal o problema original ainda não foi resolvido a 100%. Vamos fazê-lo agora.

Para recordar, o que se pede no enunciado original é:

A lista dos jogadores que marcaram, pelo menos, dois golos aparece no final. Se insistirmos em programar tudo usando um percurso direto do ficheiro, seriamos obrigados a ir guardando, durante o percurso, os jogadores que interessam, para os poder mostrar no final. Essa solução, apesar de ser possível, não é a ideal pois compromete a simplicidade e a clareza.

Para obter uma solução simples e clara, o melhor é começar por carregar todos os dados em vetor e depois trabalhar com esse vetor. O resto do programa fica muito diferente! Agora há diversas funções que examinam o vetor para extrair a informação necessária. O vetor é percorrido diversas vezes, mas isso não faz mal porque os percursos em memória central são muito rápidos, ao contrário do que se passa nos ficheiros.

Exercício: Aumente este programa para obter agora:

#70