Notas sobre programação com linguagem C


Introdução

Nas aulas práticas apercebi-me que vocês cometem erros de sintaxe na programação com a linguagem C de ``bradar aos céus''.

Julgo que este problema resulta duma compreensão deficiente de alguns conceitos associados a linguagens de programação, obtida, talvez, da leitura de apontamentos resumidos que foram criados para suporte de aulas teóricas. Para corrigir estes problemas, sugiro que leiam com cuidado um livro como The C Programming Language de Kernighan e Ritchie, o qual faz uma apresentação sintética mas completa dos conceitos envolvidos.

Correndo o risco de mais uma vez apresentar conceitos duma forma resumida, este documento procura esclarecer alguns aspectos que me parecem fundamentais, mas dos quais muitos de vocês têm ideias nebulosas.

Tipos

Em C, cada variável tem um tipo, o qual deve ser indicado quando a variável é declarada ou definida. Por exemplo:

int main() {
   int n;
define a variável de nome n como sendo do tipo int, i.e. inteiro.

O tipo duma variável especifica:

1.
os valores que essa variável pode ter;
2.
as operações que lhe podem ser aplicadas;

Por exemplo:

   n = 3.14;
não é uma instrução correcta porque atribui um valor real a uma variável do tipo inteiro. (Embora seja possível que o compilador gere código que trunque a parte decimal do valor real, e não indique qualquer erro.)

De igual modo, em

   float x, pi = 3.14;
   x = pi % 3;
a instrução x = pi % 3; não é válida porque a operação % (módulo, ou resto da divisão inteira) requer que ambos os operandos sejam inteiros.

Tal como as variáveis, as constantes também têm tipos. Por exemplo, 3 é uma constante do tipo inteiro, enquanto que 3.0 é uma constante do tipo real e não pode ser usada como operando da operação %.

Mais, qualquer expressão da linguagem C tem um tipo. O tipo duma expressão depende do tipo dos seus operandos e das operações envolvidas na expressão. Por exemplo, se n fôr do tipo inteiro, a expressão:

   n/2
é do tipo inteiro. No entanto, a expressão:
   n/2.0
é do tipo real, mesmo assumindo que n é do tipo inteiro.

Uma regra fundamental é:

numa instrução de atribuição os tipos das expressões dos dois têm de ser idênticos, ou pelo menos terá ser possível converter o tipo do lado direito no tipo do lado esquerdo

Esta regra é análoga à regra da física:

Numa equação, as unidades do lado esquerdo têm ser idênticas às unidades do lado direito.

Violar esta regra dos tipos é um erro inaceitável e que permite classificar a pessoa como um ``programador do fundo da escala''.

Um erro semelhante à violação desta regra diz respeito à invocação duma função com argumentos cujos tipos não são os (ou não são convertíveis nos) especificados na definição da função.

Apontadores, endereços e vectores

A maioria dos erros devido à incompatibilidade dos tipos das expressões usadas surgem no contexto de apontadores.

Um apontador é uma variável cujo valor é um endereço duma variável de um determinado tipo. Por exemplo:

   int *p;
define a variável p como sendo do tipo endereço de inteiro, ou seja um apontador para inteiro.

Apontadores podem ser inicializados usando o operador &. Por exemplo:

   int n;
   int *p = &n;
inicializa p com o endereço da variável n, como ilustra a figura seguinte:

next

Por exemplo, o código seguinte:

   int n = 2;
   int *p = &n;
   printf(``n = %d\t *p = %d\t p = %p \t &n = %p\n'', 
          n, *p, p, &n);
imprimirá qualquer coisa como:
n = 2   *p = 2  p = 0xbffffc84  &n = 0xbffffc84
Isto é, o endereço da variável n é o valor da variável p e vale 0xbffffc84 (um endereço de 32 bits).

Para interpretar expressões usando apontadores ou endereços, pode ajudá-lo ser as seguintes expressões como indicado:

&n
o endereço de n;
*p
o valor da variável apontada por p;

Muitas vezes, o uso de apontadores aparece associado à alocação dinãmica de memória. De facto, a função malloc() da biblioteca de C retorna o endereço da primeira posiçào de memória do bloco reservado. Por exemplo:

 
   char *buf;
   buf = (char *)malloc(BUF_SIZE * sizeof(char));
reserva um buffer, i.e. um bloco de memória, de tamanho BUF_SIZE, em princípio para guardar carácteres.

Outro uso comum de endereços na linguagem C é como argumentos de funções. De facto, a passagem dum endereço como argumento duma função permite que essa função altere o valor da variável apontada pelo argumento, e deste modo tornar esta alteração visível fora dessa função. Por exemplo:

int inet_aton(char *cp, struct in_addr *inp);
a qual converte um endereço IP em formato dotted decimal no formato especificado na norma IP, usa 2 apontadores como argumentos:
cp
é o endereço da posição de memória com o primeiro carácter da string com o endereço em formato dotted decimal;
inp
é o endereço da struct in_addr a inicializar com o endereço IP;
permitindo que o valor convertido e escrito na variável apontada por inp seja visível fora de inet_aton(). (Uma alternativa era inet_aton() retornar o valor da conversão, o qual seria atribuído a uma variável de tipo apropriado.)

Vectores e strings

Na linguagem C, vectores e strings estão intimamente ligados a endereços e apontadores.

Vectores

De facto, o nome dum vector denota o endereço do primeiro elemento desse vector. Por exemplo, o código seguinte:

   int fib[10];
   int *p1 = fib;
   int *p2 = &(fib[0]);
   fib[0] = 1;
   printf(``fib = %p \t p1 = %p\t p2 = %p\n'',
          fib, p1, p2);
   printf(``fib[0] = %d \t*p1 = %d\t *p2 = %d\n'',
          fib[0], *p1, *p2);
imprime qualquer coisa como:
fib = 0xbffffc68         p1 = 0xbffffc68         p2 = 0xbffffc68
fib[0] = 1               *p1 = 1                 *p2 = 1

Atendendo a que o nome dum vector é um endereço, e não uma variável, a seguinte atribuição:

   int fib[10];
   int n;
   fib = &n;
não é legal, e o compilador assinala um erro de compilação. De facto, esta atribuição é análoga à seguinte:
   5 = 4;
que, espero, vocês reconheçam imediatamente como um erro.

O seguinte exemplo ilustra mais uma vez a relação entre vectores e endereços:

   char buf[BUF_SIZE]
   int s;
   ...
   recv(s, buf, BUF_SIZE, 0);
Note que o nome do vector buf[] é passado como segundo à função recv(), a qual espera como segundo argumento um endereço de void. Ora, buf é o endereço da primeira posição do vector buf[], i.e. o endereço dum carácter, há assim que usar um cast para indicar ao compilador que sabemos o que estamos a fazer, e não nos incomodar com mensagens de aviso.

Strings

Sendo uma string uma sequência de carácteres guardados em posições de memória consecutivas, ``não é preciso ser engenheiro'' para reconhecer que uma string pode ser acedida usando o endereço do seu primeiro carácter. Assim, por exemplo:

   char *msg = ``Hello World!!!\n'';
   printf(``%s'', msg);
gera a mensagem que seria de esperar.

De facto, conhecer o primeiro carácter duma string não é suficiente para especificar a string: qualquer prefixo dessa string tem o mesmo primeiro carácter. A convenção que se usa em C é a seguinte: o último carácter duma string é o carácter cujo código é 0x00 (lembre-se que o tipo char usa apenas um ``baite'') e que se costuma designar por end of string character.

Remover o carácter 0x00 do fim duma string pode conduzir a resultados inesperados, o menos inócuo dos quais será um core dump durante a fase de teste.

Strings e vectores de carácteres

A relação entre strings e vectores de carácteres parece também ser óbvia: ambos podem ser identificados usando os endereços dos seus primeiros elementos.

No entanto a relação entre strings e vectores de carácteres é bem mais subtil do que a observação acima deixa transparecer. Por exemplo:

   char buf[BUF_SIZE];
   buf = ``Hello World!\n''
não é legal, embora os tipos dos 2 lados da atribuição sejam compatíveis. O problema aqui foi já mencionado acima: buf é um endereço, ou melhor, uma constante, não uma variável, pelo que não pode ser modificado.

No entanto, o seguinte segmento já é válido:

   char buf[BUF_SIZE] = ``Hello World!\n'';
Neste caso, o que se passa é que os primeiros elementos do vector buf são inicializados com os carácteres da string ``Hello World!\\n'' (carácter end-of-string incluído).

Mas a única maneira de inicializar um vector de carácteres é quando da sua declaração? A resposta é não, como ilustra o seguinte exemplo:

   char buf[BUF_SIZE];
   strcpy(buf, ``Hello World!\n'');
Neste caso usa-se a função:
   char *strcpy(char *dest, const char *src)
para copiar a string ``Hello World!\n'' para o vector buf[]. Este exemplo ilustra dois outros aspectos já mencionados:
1.
que o nome dum vector de elementos dum dado tipo é de facto um endereço desse tipo;
2.
que o uma string é denotada pelo endereço do seu primeiro carácter: de facto, o compilador traduz a string ``Hello World!\n'' no endereço da posição de memória contendo o seu primeiro carácter.

Funções

Foi com surpresa que constatei a dificuldade de alguns de vocês em usar funções em C.

Antes de mais, o conceito de função na linguagem C é essencialmente o conceito matemático de função. Isto é, uma função associa um valor dum conjunto, ``domínio'', a um valor dum outro conjunto.

Por exemplo, a função int fact(int n) associa um inteiro ao seu factorial (i.e., a outro inteiro que é o valor da função matemática factorial).

Um exemplo mais complexo é a função int inet_aton(char *cp, struct in_addr *inp), a qual associa um inteiro a um par de endereços, o primeiro um endereço dum carácter, e o segundo um endereço duma struct in_addr. Além disso, a função inet_aton() tem um efeito lateral: inicializar a struct in_addr cujo endereço é o valor de inp, com a representação em formato de rede do endereço IP, cujo valor em formato dotted decimal é apontado por cp.

Note, que algumas funções, como fact() não têm efeitos laterais, enquanto que outras funções só têm efeitos laterais (nomeadamente, aquelas cujo tipo do valor retornado é void).

Na linguagem C, as funções estão associadas a diferentes tipos de construções, nomeadamente:

Nos parágrafos seguintes consideramos cada uma destas construções.

Definição duma função

Qualquer função usada num programa em C tem que ser definida. A definição duma função inclui as instruções que a função executa quando é invocada.

Por exemplo, a função int fact(int n) que calcula o factorial dum inteiro pode ser definida como se segue:

unsigned int fact(unsigned int n) {
   unsigned int f;
   for( f = 1; n > 0; n--) 
      f = f * n;
   return f;
}

n é o argumento (formal) da função fact(), a qual retorna um inteiro sem sinal: o factorial de n.

Note que os argumentos formais duma função são essencialmente variáveis usadas nas instruções que constituem a função propriamente dita. De facto, a função fact usa o argumento n de modo análogo à variável f. Os valores dos argumentos são inicializados quando a função é invocada.

Note que um programador não tem que definir todas as funções que invoca num programa. Aliás, esta é uma das vantagens do uso de funções: reutilizar código que executa tarefas frequentemente usadas. Por exemplo, as funções inet_aton() e printf() estão definidas na biblioteca da linguagem C, pelo que não é necessário, ou melhor, não devem, ser definidas de novo.

Invocação duma função

Como dito acima, as instruções especificadas na definição duma função são executadas quando essa função é invocada.

Na linguagem C, uma função é invocada especificando o seu nome e o valor de cada um dos seus argumentos. Por exemplo, a função fact() poderia ser invocada como se segue:

int main() {
  int i, f;

  for(i = 0; i < 10; i++) {
    f = fact(i);
    printf("%d! = %d\n", i, f);
  }
}
Neste exemplo, a função fact() é invocada 10 vezes, variando o valor do seu argumento de 0 a 9, inclusivé. Como indicado, pode atribuir-se o valor retornado por uma função a uma variável de tipo apropriado. Poder-se-ia ter usado o valor retornado directamente na função printf():
    printf("%d! = %d\n", i, fact(i));
De facto, o argumento duma função é o valor da expressão correspondente. Assim, neste caso, o 3o argumento de printf() é o valor retornado pela invocação fact(i).

Lembre-se que os tipos das expressões usadas nos argumentos na invocação duma função devem ser compatíveis com os tipos especificados na definição da função, devendo usar-se casts se necessário.

Por exemplo, em:

   char buf[BUF_SIZE];
   int s;
   ...
   if( send(s, buf, BUF_SIZE, 0) == -1 ) {
   ...
o segundo argumento de send() é buf, o nome dum vector de caracteres, ou seja, uma expressão cujo tipo é char *, o qual é compatível com o tipo do segundo argumento void * especificado na definição de send():
void * é um tipo especial: qualquer tipo endereço é compatível com o tipo void *, pelo que não é necessário usar casts

Declaração de funções

A declaração duma função consiste essencialmente no texto que precede o corpo duma função. Por exemplo, a função fact() pode ser declarada como se segue:

unsigned int fact(unsigned int);

A linguagem C requer que uma função seja declarada antes de ser invocada, sempre. Fazer a declaração duma função antes da sua invocação permite que o compilador verifique se a função é invocada correctamente: nomeadamente que é usada numa expressão com o tipo apropriado e que os tipos dos seus argumentos são compatíveis com os declarados.

Tipicamente, as declarações de funções são feitas em header files, as quais deverão ser incluídas nos ficheiros que invocam essas funções. Por exemplo, <stdio.h> inclui a declaração de printf().

Obs. Se definir a função num ficheiro, antes de invocar essa função, não precisa de a declarar.


About this document ...

This document was generated using the LaTeX2HTML translator Version 98.2 beta6 (August 14th, 1998)

Copyright © 1993, 1994, 1995, 1996, Nikos Drakos, Computer Based Learning Unit, University of Leeds.
Copyright © 1997, 1998, Ross Moore, Mathematics Department, Macquarie University, Sydney.

The command line arguments were:
latex2html -t INST-C -split 0 inst_C.tex

The translation was initiated by Pedro Souto on 2000-04-01


Pedro Souto
2000-04-01