Home RecentChanges

Lesson15

Organização de programas

Os programas são em geral divididos em vários módulos, em que cada módulo providencia um conjunto de funcionalidades bem definido e coerente, incluindo a especificação, a documentação e os testes de validação necessários. No caso da programação em C, cada um desses módulos é em geral implementado em ficheiros diferentes e, de facto, cada módulo pode ser subdividido também por vários ficheiros.

A título de exemplo, sugere-se aos alunos a consulta da documentação do projecto VLC, uma aplicação desenvolvida em C, onde é possível ver os módulos e a documentação dos mesmos, assim como os vários ficheiros que compõem o VLC.

Num programa em C existem em geral dois tipos de ficheiros:

Vejamos o ficheiro de cabeçalhos complexos.h:

#ifndef COMPLEXOS_H
#define COMPLEXOS_H

typedef struct {
  float real, img;
} complexo;

complexo soma(complexo a, complexo b);
complexo le_complexo();
void escreve_complexo(complexo a);

#endif

Neste ficheiro temos declarados o tipo complexo e as funções que queremos disponibilizar. As instruções de preprocessamento #ifndef, #define e #endif servem apenas para garantir que este ficheiro é apenas incluído uma vez.

O ficheiro de código corresponde complexos.c pode ser o seguinte:

#include <stdio.h>

#include "complexos.h"

complexo
soma(complexo a, complexo b)
{
  a.real += b.real;
  a.img += b.img;

  return a;
}

complexo
le_complexo()
{
  complexo a;
  char sign;

  scanf("%f%c%fi", &a.real, &sign, &a.img);

  if (sign == '-')
    a.img *= -1;

  return a;
}

void
escreve_complexo(complexo a)
{
  if (a.img >= 0)
    printf("%f+%fi", a.real, a.img);
  else
    printf("%f%fi", a.real, a.img);
}

Notar a inclusão do ficheiro de cabeçalhos complexos.h.

Podemos utilizar este módulo da seguinte forma:

#include <stdio.h>
#include <stdlib.h>

#include "complexos.h"

int
main()
{
  complexo x, y, z;

  x = le_complexo();
  y = le_complexo();

  z = soma(x, y);

  escreve_complexo(x);
  printf(" + ");
  escreve_complexo(y);
  printf(" = ");
  escreve_complexo(z);
  printf("\n");

  return EXIT_SUCCESS;
}

Para compilar e executar basta fazer o seguinte:

[aplf@darkstar ~]$ gcc -g -Wall -ansi -pedantic -o complexos main.c complexos.c 
[aplf@darkstar ~]$ ./complexos 
3+4i
5+6i
3.000000+4.000000i + 5.000000+6.000000i = 8.000000+10.000000i
[aplf@darkstar ~]$

Notar que apenas passamos como argumentos ao compilador os ficheiros com código fonte, os ficheiros de cabeçalhos são incluídos automaticamente pelo preprocessador seguindo as instruções de preprocessamento, nomeadamente os #include.

É fácil de imaginar que, em programas complexos com inúmeros ficheiros, a compilação pode tornar-se complexa, sendo necessário compilar inúmeros ficheiros de código. Em particular a divisão em módulos e por vários ficheiros permite que tenhamos apenas de recompilar os ficheiros que foram modificados e os que dependem destes, o que implica gerir as dependências entre ficheiros de código e ficheiros de cabeçalhos. Por esta razão existem várias ferramentas para ajudar neste processo, sendo uma das mais utilizadas o make. Esta ferramenta permite compilar os programas de forma automática a partir das regras definidas em ficheiros Makefile, onde em particular são definidas as dependências entre módulos e ficheiros.


Regras de scope

Em C todas as variáveis têm um scope, i.e., um âmbito de acessibilidade e visibilidade, que podem tomar um de quatro estados (ou na realidade apenas três):

  1. bloco (variáveis locais);
  2. função (idêntico ao bloco, variáveis locais);
  3. ficheiro (variáveis globais apenas visíveis dentro do ficheiro onde são declaradas);
  4. programa (variáveis globais visíveis em múltiplos ficheiros).

No caso do bloco ou função temos as variáveis automáticas, por exemplo i e soma:

int soma(int v[], int n) {
  int i,
  static int soma=0;
  for (i=0 ; i<n; i++)
    soma+=v[i];
  return soma;
} 

Recordar que a utilização do qualificador static permite manter o valor da variável entre chamadas à função.

No caso genérico do bloco temos também, por exemplo a variável t:

void bubble(int a[], int l, int r){ 
  int i, j;
  for (i = l; i < r; i++)
    for (j = r; j > i; j--)
      if (a[j-1]>a[j]){
        int t=a[j-1]; 
        a[j-1]=a[j]; 
        a[j]=t;
      };
}

Notar que este tipo de declaração não é no entanto aceite em ANSI C.

As variáveis globais pode ter scope de ficheiro ou de programa. Um programa C pode ser composto por conjunto de objectos externos, que podem ser variáveis ou funções, que podem ser utilizadas por qualquer função, ao contrário de variáveis internas/locais, que apenas podem ser utilizadas dentro da uma função ou bloco. Exemplo:

int acumulador;

void soma(int valor) {
  acumulador += valor;
}

Neste caso a variável acumulador pode ser utilizada ao nível do programa, em qualquer ficheiro. Podemos no entanto limitar o scope ao ficheiro: as variáveis globais definidas como estáticas permitem limitar o seu scope ao ficheiro em que são definidas. De facto as funções também podem ser definidas como estáticas, limitando o scope da função entre ponto da definição e fim do ficheiro onde a definição ocorre. Exemplo de uma variável com scope de ficheiro:

static int acumulador;

void soma(int valor) {
  acumulador += valor;
} 

No caso de querermos utilizar uma variável global com scope de programa num ficheiro diferente daquele em que foi declarada, temos declarar a mesma neste novo ficheiro como externa. Uma variável externa é definida quando são indicadas as propriedades da variável, e quando são especificados os seus requisitos em termos de memória:

int a;

Uma variável externa é declarada quando apenas são indicadas as suas propriedades:

extern int a;

Uma variável apenas pode ter uma definição, embora possa ser declarada várias vezes. Notar que a dimensão de um vector é obrigatória na definição do mesmo, mas opcional na declaração. A inicialização de uma variável externa apenas pode ter lugar na definição da mesma.


Documentação, testes e asserções

Tal como mencionado acima, cada módulo deve ser acompanha de documentação e de um conjunto de testes. Não sendo o foco desta disciplina, vamos apenas ver algumas orientações nesse sentido, salientando que existem ferramentas específicas e muito completas em engenharia de software para auxiliar na documentação, no desenvolvimento, e em testes de software.

Em C, com vista a produzir testes a serem validados durante o desenvolvimento e sempre que for necessário confirmar a concordância com uma dada especificação, podemos utilizar instruções assert. Existe quem prefira incluir estas instruções junto com a implementação e existe quem prefira incluir estas instruções em ficheiros de teste específicos. Em qualquer dos casos, o importante é definir os testes e testar a implementação. A utilização da instrução assert pode ser vista, por exemplo, na implementação dos algoritmos de ordenação facultada nesta disciplina. Notar que as asserções deixam de estar activas quando a macro NDEBUG é definida, por exemplo adicionando a opção -DDEBUG ao gcc, o que acontece quando o programa está terminado e queremos obter um executável para distribuir pelos utilizadores finais. Portanto, devemos ter em conta que as instruções realizadas nas asserções não podem ter influência no comportamento do programa.

No que diz respeito à documentação, esta deve em geral acompanhar o código e, utilizando ferramentas específicas para o efeito, podemos extrair essa documentação directamente do código. No caso de programas em C, uma das ferramentas mais utilizadas é o doxygen. A título de exemplo para ilustrar de que forma devemos documentar o código com vista ao uso do doxygen, vejamos a documentação da implementação dos complexos acima:

/**
 * @file complexos.h
 * @brief Módulo para números complexos.
 */
#ifndef COMPLEXOS_H
#define COMPLEXOS_H


typedef struct {
  float real, img;
} complexo;

/**
 * @brief Operação de soma de dois complexos.
 *
 * Esta função permite somar dois complexos passados por valor. O
 * resultado é também passado por valor.
 *
 * @param 'a' Um número complexo.
 * @param 'b' Um número complexo.
 * @return A soma dos números complexos 'a' e 'b'.
 */
complexo soma(complexo a, complexo b);

/**
 * @brief Lê um número complexo do stdin.
 *
 * Lê um número complexo do stdin no formato 'x+yi'.
 *
 * @return O número complexo lido do stdin.
 */
complexo le_complexo();

/**
 * @brief Escreve um número compelxo no stdout.
 *
 * escreve um número complexo no stdout no formato 'x+yi'.
 *
 * @param 'a' Um número complexo.
 */
void escreve_complexo(complexo a);

#endif

Para produzir a documentação basta executar o comando doxygen -g uma vez, editar o ficheiro Doxyfile criado pelo último comando se for necessário, e executar o comando doxygen Doxyfile sempre que for necessário actualizar a documentação. O documentação resultante estará na directoria html. Notar que esta é uma utilização muito básica desta ferramente, que tem inúmeras potencialidades e muitas outras funcionalidades.


Operações sobre ficheiros

Até este momento fizemos sempre leituras do stdin e escrevemos sempre para o stdout. Vamos ver agora como realizar estas operações sobre ficheiros. Para tal precisamos de um ponteiro para ficheiro FILE * e de algumas funções, definidos em stdio.h.

Para abrir um ficheiro podemos utilizar a função fopen:

FILE *fp;
fp=fopen("tests.txt", "r");

Em que o primeiro argumento é o ficheiro a abrir, neste caso assumimos o ficheiro teste.txt está no local em que executámos o programa, caso contrário seria necessário colocar o caminho completo para o mesmo. O segundo argumento é o modo com que abrimos o ficheiro. Podemos abrir um ficheiro nos seguintes modos:

r  - open for reading
w  - open for writing (file need not exist)
a  - open for appending (file need not exist)
r+ - open for reading and writing, start at beginning
w+ - open for reading and writing (overwrite file)
a+ - open for reading and writing (append if file exists)

Ao abrir um ficheiro pode ocorrer um erro e o ponteiro retornado pode ser NULL. Podemos detectar e tratar o erro da seguinte forma:

#include <stdio.h>
#include <stdlib.h>

int main()
{
  FILE *fp;

  fp = fopen("teste.txt", "r");
  if (fp == NULL) {
    perror("teste.txt");
    exit(1);
  }

  return EXIT_SUCCESS;
}

Em que, quando o ficheiro teste.txt não existe, obtemos o seguinte:

[aplf@darkstar ~]$ gcc -g -Wall -ansi -pedantic file.c 
[aplf@darkstar ~]$ ./a.out 
teste.txt: No such file or directory
[aplf@darkstar ~]$

Neste caso utilizámos a função perror que permite tratar erros de sistema que ocorrem em várias situações, como por exemplo no caso de operações sobre ficheiros.

Para fechar um ficheiro podemos utilizar a função fclose:

fclose(fp);

A leitura e a escrita pode ser realizada através das funções fscanf, fprintf, fgetc, e fputc, que são em tudo idênticas às que utilizámos até agora tendo como única diferença o facto de receberem um ponteiro para o ficheiro. Exemplo:

#include <stdio.h>
#include <stdlib.h>

int main()
{
  FILE *fp;

  fp = fopen("teste.txt", "w");
  if (fp == NULL) {
    perror("teste.txt");
    exit(1);
  }

  fprintf(fp, "Hi file!\n");

  fclose(fp);

  return EXIT_SUCCESS;
}

Neste caso obtemos:

[aplf@darkstar ~]$ gcc -g -Wall -ansi -pedantic file.c 
[aplf@darkstar ~]$ ./a.out 
[aplf@darkstar ~]$ cat teste.txt 
Hi file!
[aplf@darkstar ~]$

Existem outras funções que podem ser úteis quando trabalhamos com ficheiros, como por exemplo a função fflush.

Notar que no que vimos o acesso aos ficheiros é em modo de text stream. Podemos também aceder aos ficheiros em modo binário, caso em que teremos de utilizar outras funções como open, close, read, e write. Neste caso é necessário ter em mente a noção de file descriptor e a enumeração dos mesmos. A título de exemplo, o stdin tem associado o file descriptor 0, o stdout tem associado o 1, e o stderr tem associado o 2. Todos os ficheiros sobre os quais um programa opera terão associados file descriptors numerados em sequência.