Introdução à Programação B (2017/2018)
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.
int somar(int n) {   // funcao errada
int i, soma=1;
for( i = 0 ; i < n-1 ; i++ ); {
printf("%d\n", somar(10));
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.
printf("%d\n", i);   // mensagem de depuração
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:
assert(n >= 0);    // assinala que argumento deve ser maior ou igual a zero
return n == 0 ? 1 : n * fatorial(n-1)
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.
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:
Ficheiros de texto: cada ficheiro de texto é constituído por uma sequência de linhas, sendo cada linha constituída por uma sequência de carateres. Cada linha é terminada pelo caráter '\n'. Geralmente, um ficheiro de texto é compreendido por seres humanos, pois envolve texto normal.
Ficheiros binários: cada ficheiro binário contém dados que não representam texto, como por exemplo: músicas, filmes, fotos, dados comprimidos, etc. Geralmente, um ficheiro binário não é diretamente compreendido por seres humanos, sendo necessário usar programas para interpretar os dados binários.
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.
Antes de começar a usar um ficheiro, primeiro é necessário abrir esse ficheiro usando a função de biblioteca
FILE *fopen(char [], char []);
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.
f = fopen("myfile.txt", "r");
Erro("Nao foi possivel abrir o ficheiro para leitura");
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.
g = fopen("myfile2.txt", "w");
Erro("Nao foi possivel abrir o ficheiro para escrita");
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:
char *fgets(char [], int, FILE *);    // Lê uma linha. A linha lida fica com um '\n' no final
int fputs(char [], FILE *);           // Escreve uma linha
Primeiro exemplo: O seguinte programa conta o número de linhas dum ficheiro. A contagem é efetuada no ciclo while da função contar_linhas.
#define MAX_STRING   256
typedef char String[MAX_STRING];
int contar_linhas(String fname) {
int soma=0;                        // Inicializa o contador
f = fopen(fname, "r");             // Tenta abrir o ficheiro para leitura
if( f == NULL )                    // Testa insucesso
while( fgets(s, MAX_STRING, f) != NULL ) // Percorre o ficheiro contando as linhas
fclose(f);                         // Fecha o ficheiro
return soma;                       // Retorna o número de linhas contadas
printf("Nome do ficheiro? ");
int res = contar_linhas(fn);
if( res == -1 )
printf("Nao foi possivel abrir o ficheiro\n");
printf("O ficheiro tem %d linhas\n", res);
Outro exemplo: O seguinte programa copia um ficheiro para outro, linha a linha. A cópia é efetuada no ciclo while da função copiar.
bool copiar(String dest, String orig) {
f = fopen(orig, "r");           // Tenta abrir o ficheiro para leitura
if( f == NULL )                 // Testa insucesso
g = fopen(dest, "w");           // Tenta abrir o ficheiro para escrita
if( g == NULL ) {               // Testa insucesso
while( fgets(s, MAX_STRING, f) != NULL ) // Lê e copia uma linha de cada vez
fputs(s, g);
fclose(f);                      // Fecha os ficheiros
printf("Nome do ficheiro de entrada? "); scanf("%s", in);
printf("Nome do ficheiro de saida? "); scanf("%s", out);
if( copiar(out, in) )
printf("Copia efetuada com sucesso!\n");
fprintf(stderr, "Copia falhou!\n");
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:
int fgetc(FILE *);        // lê um caráter
int fputc(int, FILE *);   // escreve um caráter
O seguinte programa copia um ficheiro para outro, caráter a caráter:
bool copia(String dest, String orig) {
int c;  // Tem de ser int; char não serve porque fgetc retorna 257 valores diferentes
if( g == NULL )                 // Testa insucesso
while( (c = fgetc(g)) != EOF )  // Lê e copia um char de cada vez
fputc(c, f);                // O valor da constante EOF é -1
if( copia(out, in) )
fprintf(stderr, "Copia efetuada com sucesso!\n");
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:
int fprintf(FILE *fich, char formato[], ...);    // escreve texto formatado
int fscanf(FILE *fich, char formato[], ...);     // lê texto formatado
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:
fprintf(f, "%lf %s\n", r, 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).
fscanf(f, "%lf %s", &r, str);
O seguinte exemplo mostra a leitura, a partir dum ficheiro f, duma data no formato dia/mes/ano:
fscanf(f, "%d/%d/%d", &d.dia, &d.mes, &d.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.
if( fscanf(f, "%d %s %s %lf", &i, s1, s2, &r) != 4 )
erro("O ficheiro não contém os dados esperados");
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:
int sprintf(char str[], char formato[], ...);    // escrever texto formatado
int sscanf(char str[], char formato[], ...);     // ler texto formatado
É 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.
Data d = {11, 12, 2009};
sprintf(str, "%d/%d/%d", d.dia, d.mes, d.ano);       // escrever uma data numa string
sscanf(str, "%d/%d/%d", &d.dia, &d.mes, &d.ano);     // ler uma data a partir duma string
Os ficheiros predefinidos stdin, stdout, stderr
Muito importante: Quando um programa em C arranca, há sempre três ficheiros que são automaticamente abertos:
stdin - Ficheiro de entrada padrão, associado ao teclado.
stdout - Ficheiro de saída padrão, associado à consola.
stderr - Ficheiro de saída dos erros, associado à consola.
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:
int getchar(void);   // igual a fgetc(stdin);
int putchar(int);    // igual a fputc(int, stdout);
Para ler/escrever texto formatado em stdio/stdout podem usar-se as, já bem conhecidas, funções de biblioteca:
int printf(char [], ...);    // igual a fprintf(stdout, char [], ...);
int scanf(char [], ...);     // igual a fscanf(stdin, char [], ...);
O ficheiro stderr deve ser usado para emitir mensagens de erro, como na seguinte função, muito útil:
#include <stdlib.h>     // define a função exit
void erro(String mensagem)
fprintf(stderr, "%s.\n", mensagem);
exit(1);   // Termina imediatamente a execução do programa
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'.
void ler_linha(String l)    // le^ um linha a partir do teclado
fgets(l, MAX_STRING, stdin);
l[strlen(l) - 1] = '\0';   // elimina o '\n'
bool fler_linha(String l, FILE *f)    // le^ uma linha a partir do ficheiro f
char *resultado = fgets(l, MAX_STRING, f);
if( resultado != NULL )
return resultado != NULL;    // indica se a linha foi lida com sucesso, ou se o ficheiro chegou ao fim
Técnicas de processamento de ficheiros
Nesta secção vamos discutir a seguinte questão:
Quando se processa um ficheiro, será devemos primeiro carregar o seu conteúdo num vetor e depois processar esse vetor? Ou será que podemos tratar o ficheiro diretamente, assim evitando o passo de carregar o ficheiro num vetor?
A resposta a esta pergunta é clara em dois tipos de situações:
Para problemas simples, em que se pode obter a resposta fazendo uma única passagem pelos dados, não devemos carregar o ficheiro num vetor.
Para problemas em que a obtenção da resposta obriga de examinar os dados pelo menos duas vezes, devemos de carregar o ficheiro num vetor.
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
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:
Número total de golos marcados pelos jogadores do Leixões.
Número total de golos marcados pelos jogadores do Benfica.
Nome e clube dos jogadores que marcaram, pelo menos, dois golos.
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:
Joao 5 Leixoes
Tarquineo 6 Benfica
Pedro 3 Sporting
Luis 8 Belenenses
Miguel 2 Leixoes
Joao 1 Sporting
Manuel 5 Academica
Camoes 5 Porto
Fernando 10 Benfica
Joaquim 7 Boavista
Gervasio 7 Belenenses
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.
int golos;
String clube;
} Jogador;
Jogador fazer_jogador(String linha)
Jogador j;
sscanf(linha, "%s %d %s", j.nome, &j.golos, j.clube);
void escrever_jogador(Jogador j) {
printf("%s %d %s\n", j.nome, j.golos, j.clube) ;
void processar_ficheiro(String nome_ficheiro)
int leixoes = 0;
f = fopen(nome_ficheiro, "r");
erro("Impossivel abrir ficheiro");
while( fgets(linha, MAX_STRING, f) != NULL ) {
Jogador j = fazer_jogador(linha);
if (strcmp(j.clube,  "Leixoes") == 0)
leixoes += j.golos;
printf("Numero total de golos marcados por jogadores do Leixoes = %d\n",
leixoes);
processar_ficheiro("jogadores.txt");
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:
int leixoes = 0, benfica=0;
else if (strcmp(j.clube,  "Benfica") == 0)
benfica += j.golos;
printf("Numero total de golos marcados por jogadores do Benfica = %d\n",
benfica);
Finalmente, vamos atacar o problema completo:
Esta alteração também é simples de tratar. Modifica-se novamente a função processar_ficheiro:
if (j.golos >= 2)
escrever_jogador(j);     // Escreve os dados do jogador corrente
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.