Universidade Federal de Minas Gerais
Instituto de Ciências Exatas
Departamento de Ciência da Computação

Algoritmos e Estruturas de Dados I

O pré-processador C

A linguagem de programação C prevê que o texto de um programa C seja primeiramente submetido a um componente denominado “pré-processador”. O pré-processador permite que certas sequências de caracteres sejam tratadas de forma a criar, alterar e remover outras seqüências segundo as regras discutidas a seguir.

Inclusão de arquivos:

Nos locais onde aparecem #include “nome” ou #include <nome>, estas seqüências são removidas e substituidas pelo conteúdo dos arquivos identificados entre aspas ou entre “menor” e “maior”. Arquivos cujos nomes estejam entre aspas são procurados a partir do diretório do fonte onde apareceu e se ele não for encontrado, ou se o nome estiver entre menor e maior, a busca será feita segundo regras ad-hoc (os arquivos incluidos também podem ter #include). A ocorrência de #include texto onde o texto não corresponde às duas formas anteriores é interpretada com sendo uma macro (veja à frente) e após a expansão da macro o texto resultante deve ser uma das duas formas anteriores.

“macros”

A palavra macro foi escolhida em função do significado “proeminente, largo, grande”, a idéia aqui é, tipicamente, substituir um nome por um texto. Existe o que é denominado “definição” cuja forma geral é #define nome texto_a_ser_colocado_no_lugar_do_nome, e partir desta definição podem ocorrer as “invocações” ou chamadas da macro que correspondem às ocorrências de nome, estas ocorrências serão substituidas pelo texto que o segue na definição. Dizemos também que nas ocorrências de nome haverá uma “expansão de macro”. Se o texto for realmente “macro” as linhas deverão terminar com o caractere “\”(barra invertida” e poderá consistir de várias linhas de texto. O escopo do nome será do ponto de definição até fim do arquivo. Uma definição pode usar outras definições. As substituições são baseadas em símbolos sintáticos (tokens).

É possível definir macros com argumentos, por exemplo
#define max(A, B) ((A)>(B)? (A):(B))
cada ocorrência de um parâmetro formal será substituida pelo parâmetro real correspondente a linha de texto
x=max(p+q,r+s);
será substituida pela linha de texto
x=((p+q)>(r+s)?(p+q):(r+s));

Observe esta macro e as armadilhas que o programador pode enfrentar, por exemplo, acostumado com funções ele pensa que trá um resultado e terá outro no caso de :
max(i++, j++)
Veja esta outra armadilha:
#define square(x) x*x /*armadilha, perigo */
um pouco menos perigosa é a definição
#define square(x) (x)*(x)

Um identificador X definido via #define X <texto> pode ter sua definição removida (e pode aparecer sem ser substituido) utilizando a diretiva #undef (por exemplo: #undef X )

Se um parâmetro formal for precedido do caractere # então haverá a expansão do texto corresponde a este parâmetro entre aspas.Lembrando que na linguagem C a ocorrência contígüa de duas seqüências de caracteres entre aspas corresponde à concatenação de strings( ou seja “abc” “def” equivale a “abcdef”) temos uma capacidade de expansão bastante expressiva.

Consider a macro
#define escreve(expr) printf(#expr “=%g\n”, expr)
quando houver uma invocação do tipo
escreve(x/y);
a macro será expandida da seguinte forma
printf(“x/y” “%g\n”, x/y);
com a concatenação dos strings o efeito corresponde a
printf(“x/y=%g\n”, x/y);
no argumento real cada aspas é trocada por barra aspas e cada barra é trocada por barra barra na tentativa de que o string seja correto.

O pré-processador permite o uso do operador ## que prove concatenação de argumentos reais durante a expansão e o resultado é verificado para novas expansões. Em outras palavras logo após serem feitas expansões, envolvendo ou não parâmetros, ocorrências de ## são removidas juntamente com os espaços adjacentes de forma a concatenar os símbolos adjacentes que poderão assim forma novos símbolos. As regras relacionadas ao uso aninhado do operador ## são bastante esotéricas. Para a definição
#define concatena(x,y) x##y
a chamada concatena(var,123) resulta em var123, entretanto a chamada concatena(concatena(1,2),3) é indefinida.

Processamento condicional de texto

É possível controlar o preprocessamento com comandos denominados “condicionais” e que são avaliados durante o pré-processamento. Pode ser feita então inclusão condicional de texto. A linha #if avalia uma expressão do tipo int se a expressão for diferente de zero as linhas subsequentes são incluidas no texto até que apareça uma linha #endif ou #elif ou #else . A expressão defined (nome) em uma linha #if corresponde a 1 se o nome foi definido e 0 caso contrário. Para ter certeza de que um certo cabecalho seja incluido somente uma vez podemos escrever
#if !defined(MASTERMIND)
#define  MASTERMIND
/* conteudo do arquivo*/
#endif

ou ainda

#ifndef MASTERMIND
#define  MASTERMIND
/* conteudo do arquivo*/
#endif

#iifdef nome e #ifndef nome são linhas especializadas que testam a definição de nome

 

 

Typedef

A linguagem C permite o uso de cláusulas ou declarações denominadas “typedef”, para criar novos nomes de tipos de dados. A declaração

typedef int Tamanho;

faz de Tamanho um sinônimo de int e podemos usa-lo de maneira a tornar mais legível um programa:

Tamanho tam, tamMax;
Tamanho *tamanho[];

De forma similar a declaração:

typedef char *String;

faz de String um sinônimo de char * (ponteiro para caracteres) e podemos usá-lo em declarações e elencamentos:

String p, aptLinha[LINHAMAX], alloc(int);

int strcmp(String, String);

p=(String) malloc(100);

Para entender a sintaxe do “typedef”: considere qualquer declaração válida, por exemplo int ii, jj; quando uma declaração é prefixada com “typedef” os identificadores não correspondem mais às variáveis mas sim aos nomes de tipos. O seguinte trecho:
typedef int ii, jj;
ii v1;
jj v2;

corresponde a definir dois sinônimos para o tipo int (ii~int, jj~int) e usar estes sinônimos para definir as variáveis v1 e v2. A convenção usual é usar nomes com letras maiúsculas ou iniciados com letras maiúsculas.

Uma declaração “typedef” não cria um novo tipo nem acrescenta nenhuma nova semântica , uma variável declarada via um identificador definido via typedef tem as mesmas propriedades das variáveis declaradas sem o uso do mecanismo provido pelo typedef.  Ou seja estes dois trechos são equivalentes:

Com typedef

Sem typedef

typedef int Nome1, Nome2;
Nome1 v1;
Nome2 v2;

int v1;
int v2;

 

O mecanismo associado ao typedef é similar ao mecanismo do #define, mas o typedef é interpretado pelo compilador, isto possibilita substituições que estão além da capacidade do pré-processador que é o elemento responsável pelo #define. Por exemplo
typedef int (*PFI)(char *, char *);
cria um identificador, PFI, que é sinônimo do tipo “ponteiro para função que tem dois parâmetros, ambos do tipo char *, e que retorna um valor do tipo int. Uma vez definido este identificador podemos de uma maneira bem legível declarar ou definir identificadores:
PFI strcmp, numcmp;

Além das questões estéticas existem duas razões principais para o uso de “typedef”s: (i) parametrização de um programa com relação a questões de portabilidade. Ao ser usado o typedef para tipos de dados que têm dependências de máquinas somente os typedefs são alterados nas mudanças de máquinas (este é o caso de size_t). O segundo propósito dos typedefs é prover melhor legibilidade e entendimento em um programa.

A definição em C de uma estrutura utilizando typedefs poderia ser:
typedef struct nodol *Nodoptr;
typedef struct nodol {
  char *info;
  Nodoptr anterior;
  Nodoptr proximo;
} NodoDeLista;

O trecho acima define nodol como uma etiqueta de estrutura, Nodoptr como identificador de tipo, e NodoDeLista como um identificador de uma instância de struct nodol. Uma função que aloca um nodo de lista e retorna seu endereço pode ser definida da seguinte forma:
Nodoptr aloca_nodolista(void){
  return (Nodoptr) malloc(sizeof(NodoDeLista));
}

Unions

Uma união ou “union” denota o tipo de uma variável que pode manter uma certa configuração de bits que pode ser “percebida” como sendo de diferentes tipos e tamanhos. Os elementos componentes de uma união ocupam o mesmo espaço da memória e só é reservado espaço sufiente para o maior componente. O conceito de “union” tenta disciplinar a visão e uso de uma mesma região da memória a partir de diferentes tipos e envolve a cooperação do compilador e do sistema de execução. Na linguagem Pascal o conceito correspondente é “variant records”. outra maneira de entender o conceito de união é: quando uma variável é do tipo união então a região de memória reservada para esta variável  (o tamanho corresponde ao tamanho do maior componente) pode ser “tipada” de mais de uma forma:

union identificador{ int IDint; char *IDchar;} var;

Outra forma de definir var é:
union { int IDint; char *IDchar;} var;

Similarmente às estruturas o identificador que segue a palavra reservada union é uma etiqueta opcional.

é comum a definição de uma variável que rastreia qual o tipo de valor foi atribuido a var:
int temINT=0;


então podemos escrever código da seguinte forma:

if(temINT)
  printf(“%d\n”, var.IDint);
else
  printf(“%s\n”, var.IDchar);

A forma de acessar os membros de uma união é idêntica à forma usada para estruturas. E podemos usar ponteiros para uniões:

union identificador *pu;
pu=&var;
printf(“%d\n”, pu->IDint);

A inicialização de uma instância de união pode ser feita e o tipo do “inicializador” deve corresponder ao tipo do primeiro componente.

Campos de bits

A linguagem C foi originalmente projetada pensando não só em programação em “alto nível” mas também na chamada “programação de baixo nível”.Programação de baixo nível é a programação voltada para aspectos de máquina!

Em alguns tipos de plataforma o espaço de memória é um bem muito precioso e pode ser necessário colocar ou compartilhar vários valores em uma “palavra de memória” (a memória principal além de ser organizada e endereçada byte a byte é comumente organizada e eficientemente endereçada palavra a palavra, onde uma palavra correponde a um certo grupo de bytes, usualmente a 2, 4 ou 8 bytes). Além deste tipo de requisito também é comum quer os registradores (por exemplo no caso de dispositivos de entrada e saída) exijam a manipulação de grupos de bits em palavras ou bytes. Neste tipo de contexto podemos usar o pré-processador C para definir “máscaras de bits” para certos grupos:

#define BIT0            01
#define BIT1            02
#define BIT2e3    0xC

ou ainda

enum {BIT0=01, BIT1=02, BIT2e3=0xC};

Agora podemos operar com deslocamentos, operar com máscaras, complementos etc.

int flags=0;
flags|=BIT0; /* liga o bit menos significativo */
flags|=BIT2e3; /*liga os bits 2 e 3 sem alterar os demais!*/

flags &=~(BIT0 | BIT2e3); /* desliga os bits 0 2 e 3 sem alterar os demais */

Além destes “estilos” a linguagem C oferece a capacidade de definição e acesso de bits de forma direta ao invés do uso de operadores lógicos. Um campo de bits (bit-field) ou campo é um conjunto de bits adjacentes em uma abstração conhecida como palavra (uma sequencia de bits). A metáfora a ser seguida é uma estrutura:

struct {unsigned int BIT0:1; unsigned int BIT1:1; unsigned int BIT2e3:2} flags;

A sentence acima define uma variável flags que contém dois campos de 1 bit e um campo de 2 bits. a expressão unsigned int é um açucar sintático com significado <campo de bits>. Os campos são acessados de forma similar a expressões de acesso a campos de estruturas. Os campos funcionam como “pequenos ints” e podem participar de expressões em geral:

flags.BIT2e3=3; /* 3 corresponde aos dois bits menos significativos ligados */
flags.BIT1=0;
flags.BIT0=0;
printf(“%d\n”, flags); /*>>pode<< imprimir 12 */

Os campos podem não ter nome (ou seja constituem-se apenas de dois pontos e largura): campos sem nome (p.ex. unsigned int antesDoSemNome:2; :3; unsigned int depoisDoSemNome:4;) são úteis para regiões de bits que não têm utilidade.

Quase tudo sobre campos de bits é dependente de implementação: um campo pode sobrepassar palavras? os campos devem ser alocados da direita para esquerda ou da esquerda para direita? A largura 0 alinha o campo na próxima palavra?

Um exemplo utilizando a arquitetura do Computador Simplificado

Considere o trecho de programa C abaixo:

typedef struct {unsigned int CodOp15:15;} Inst15;
typedef struct {unsigned int op1:4; unsigned int codOp11:11;} Inst11;
typedef struct {unsigned int op1:4; unsigned int op2:4; unsigned int codOp7:7;} Inst7;
typedef struct {unsigned int op1:4; unsigned int op2:4; unsigned int op3:4; unsigned int codOp3:3;} Inst3;

typedef union{int i; Inst15 inst15; Inst11 inst11; Inst7 inst7; Inst3 inst3;} Inst;

Inst memoriaCS[16];

Neste contexto:

memoriaCS é um aranjo de 16 uniões, as uniões são do tipo Inst. O elemento de maior comprimento da união é o i (int i), porque os demais elementos (inst15, inst11, inst7 e inst3) são campos de 15 bits.
memoriaCS[k] corresponde à união de índice k.
memoriaCS[k].inst11 corresponde à um elemento da união onde temos dois campos de bits: um campo dos 4 bits menos significativos e um campo dos 11 bits seguintes aos 4 bits menos significativos.
memoriaCS[5].inst11.op1=15; atribui o valor 15 ao campo de operando da sexta posição de memória (que provavelmente deverá conter uma instrução de um só operando...).

Exercicio: Considere que houve uma atribuição à posição índice k do arranjo de uniões memoriaCS: memoriaCS[k].i=valor; Escreva um trecho de programa C que determina se este valor corresponde a uma das sete instruções do computador simplificado. Por exemplo:

if(memoriaCS[k].inst15.codOp15==0x7030)
  printf(“instrução é [Pare]\n”);

...

if(memoriaCS[k].inst11.codOp11==0x700)
  printf(“instrução é [Leia Cartão e guarde em E%d]\n”, memoriaCS[k].inst11.op1);

...

if(memoriaCS[k].inst7.codOp7==0x71)
  printf(“instrução é [Copie E%d em E%d]\n”, memoriaCS[k].inst7.op2, memoriaCS[k].inst7.op1);

...

if(memoriaCS[k].inst3.codOp3==0)
  printf(“instrução é [Some E%d e E%d e guarde em E%d]\n”,
                       memoriaCS[k].inst3.op3, memoriaCS[k].inst3.op2, memoriaCS[k].inst3.op1);

 

Números pseudoaleatórios (rand() )e time.h

O programa abaixo simula 20 lançamentos de um dado de 6 faces
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(int argc, char *argv[])
{ int i;
  time_t timer;
  timer=time(NULL);
  srand(timer);
  for(i=0;i<20 ; i++)printf("%d\n",rand()%6+1);
  system("PAUSE");     
  return 0;
}

A função rand() retorna um valor pseudo aleatório a partir de uma certa “semente” geradora. A inicialização da semente é sempre a mesma e para que seja possível resultados diferentes a cada execução temos que modificar a semente. A iniciação da semente é feita utilizando a função srand(). No caso do programa acima a variável timer do tipo time_t (definido em time.h) é ajustada com o valor retornado pela função time() (definida em time.h). O valor retornado por time() varia potencialmente para cada chamada e portanto a cada execução o dado será lançado de forma diferente.