Apontadores e Estruturas de Dados Dinâmicas em C
Fernando Mira da Silva
Departamento de Engenharia Electrotécnica e de Computadores
Instituto Superior Técnico
Novembro de 2002
Resumo
O C é provavelmente a mais flexı́vel das linguagens de programação de alto-nı́vel, mas apresenta uma relativa complexidade sintáctica. Uma das maiores dificuldades na abordagem do
C numa disciplina de introdutória de programação é a necessidade de introduzir os conceitos de
endereço de memória, apontador e memória dinâmica.
Este texto foi preparado para apoio à disciplina de Introdução à Programação da Licenciatura
em Engenharia Electrotécnica e Computadores do Instituto Superior Técnico. Este texto tenta
focar de modo sistemático alguns dos tópicos que maiores dúvidas suscita nas abordagens iniciais
da linguagem: apontadores e estruturas de dados dinâmicas. Assim, embora se pressuponha o
conhecimentos dos elementos básicas da linguagem C por parte do leitor – nomeadamente, os
tipos de dados elementares e as estruturas de controlo – o texto é mantido ao nı́vel elementar de
uma disciplina introdutória de informática.
Na apresentação das estruturas de dados consideradas, que incluem pilhas, filas, listas e
anéis, introduz-se de forma natural a noção de abstracção de dados, e os princı́pios essenciais
de estruturação e modularidade baseados neste paradigma de programação.
Para o programador experiente em C, alguns dos exemplos de código poderão parecer pouco
optimizados. Trata-se de uma opção premeditada que tenta beneficiar a clareza e a simplicidade
algorı́tmica, ainda que em alguns casos esta opção possa sacrificar ligeiramente a eficiência do
código apresentado. Pensamos, no entanto, que esta é a opção correcta numa abordagem introdutória da programação.
Índice
1 Introdução
1
2 Apontadores
5
2.1
Motivação para os apontadores em C . . . . . . . . . . . . . . . . . . . . . . . .
5
2.2
Modelos de memória em C . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5
2.3
Apontadores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
7
2.4
Funções e passagem por referência . . . . . . . . . . . . . . . . . . . . . . . . .
11
2.4.1
Passagem por referência . . . . . . . . . . . . . . . . . . . . . . . . . .
11
2.4.2
Erros frequentes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
16
Vectores e apontadores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
18
2.5.1
Declaração de vectores . . . . . . . . . . . . . . . . . . . . . . . . . . .
18
2.5.2
Aritmética de apontadores . . . . . . . . . . . . . . . . . . . . . . . . .
22
2.5.3
Índices e apontadores . . . . . . . . . . . . . . . . . . . . . . . . . . . .
25
2.5.4
Vectores como argumentos de funções . . . . . . . . . . . . . . . . . . .
26
Matrizes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
28
2.6.1
28
2.5
2.6
Declaração . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
IV
ÍNDICE
2.7
3
Matrizes como argumento de funções . . . . . . . . . . . . . . . . . . .
29
2.6.3
Matrizes e vectores . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
31
2.6.4
Matrizes e apontadores . . . . . . . . . . . . . . . . . . . . . . . . . . .
34
Generalização para mais do que duas dimensões . . . . . . . . . . . . . . . . . .
38
Vectores e memória dinâmica
41
3.1
Introdução . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
41
3.2
Vectores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
43
3.3
“Vectores” dinâmicos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
46
3.4
Gestão da memória dinâmica . . . . . . . . . . . . . . . . . . . . . . . . . . . .
48
3.4.1
Verificação da reserva de memória . . . . . . . . . . . . . . . . . . . . .
48
3.4.2
Outras funções de gestão de memória dinâmica . . . . . . . . . . . . . .
50
3.4.3
Garbbage . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
52
Criação dinâmica de matrizes . . . . . . . . . . . . . . . . . . . . . . . . . . . .
55
3.5.1
Introdução . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
55
3.5.2
Matrizes estáticas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
55
3.5.3
Matrizes dinâmicas . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
56
3.5.4
Vectores de apontadores e matrizes . . . . . . . . . . . . . . . . . . . .
58
3.5
4
2.6.2
Listas dinâmicas
61
4.1
Introdução . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
61
4.2
Abstracção de dados . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
62
ÍNDICE
4.3
Listas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
63
4.4
Listas dinâmicas: listar elementos . . . . . . . . . . . . . . . . . . . . . . . . .
66
4.5
Listas: pilhas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
68
4.5.1
Introdução . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
68
4.5.2
Declaração . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
70
4.5.3
Inicialização . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
70
4.5.4
Sobreposição . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
71
4.5.5
Remoção . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
72
4.5.6
Teste . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
72
4.5.7
Exemplo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
73
Listas: filas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
80
4.6.1
Introdução . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
80
4.6.2
Declaração . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
80
4.6.3
Inicialização . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
81
4.6.4
Inserção . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
82
4.6.5
Remoção . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
83
4.6.6
Teste . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
83
4.6.7
Exemplo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
84
Listas ordenadas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
87
4.7.1
Introdução . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
87
4.7.2
Declaração e inicialização . . . . . . . . . . . . . . . . . . . . . . . . .
88
4.6
4.7
V
VI
ÍNDICE
4.8
4.9
4.7.3
Listagem ordenada . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
89
4.7.4
Procura . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
89
4.7.5
Abstracção de dados e métodos de teste . . . . . . . . . . . . . . . . . .
91
4.7.6
Inserção ordenada . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
92
4.7.7
Remoção . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
95
4.7.8
Exemplo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
96
Variantes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107
4.8.1
Introdução . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107
4.8.2
Listas com registo separado para a base . . . . . . . . . . . . . . . . . . 107
4.8.3
Listas duplamente ligadas . . . . . . . . . . . . . . . . . . . . . . . . . 108
4.8.4
Aneis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109
Anel duplo com registo separado para a base . . . . . . . . . . . . . . . . . . . . 109
4.9.1
Introdução . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109
4.9.2
Declaração . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
4.9.3
Inicialização . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
4.9.4
Listagem . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
4.9.5
Procura . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
4.9.6
Inserção . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 112
4.9.7
Remoção . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 113
4.9.8
Exemplo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 114
4.10 Listas de listas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 121
ÍNDICE
5
Conclusões
Bibliografia
125
127
VII
Capı́tulo 1
Introdução
Até ao aparecimento da linguagem C, as linguagens de alto-nı́vel tinham por objectivo distanciar o programador do hardware especı́fico de trabalho. Pretendia-se, deste modo, que o programador focasse a sua actividade na solução conceptual e algorı́tmica do problema e, simultaneamente, que o código final fosse independente do hardware e, como tal, facilmente portável entre
diferentes plataformas.
Este princı́pio teórico fundamental, ainda hoje correcto em muitas áreas de aplicação de software, conduzia no entanto a problemas diversos sempre que o programador, por qualquer motivo,
necessitava de explorar determinadas regularidades das estruturas de dados de modo a optimizar
zonas crı́ticas do código ou, em outros casos, por ser conveniente ou desejável explorar determinadas facilidades oferecidas pelas instruções do processador que não estavam directamente
disponı́veis na linguagem de alto-nı́vel. Nestas situações, a única alternativa era a programação
directa em linguagem máquina destes blocos de código, opção que implicava a revisão do software
em cada alteração ou evolução de hardware.
Por razões semelhantes, quer o sistema operativo quer ferramentas de sistema como compiladores, gestores de ficheiros ou monitores de sistema eram consideradas aplicações que, por
requisitos de eficiência do código, eram incompatı́veis com linguagens de alto-nı́vel. O mesmo
sucedia com todos os programas que necessitavam, de alguma forma, de controlar directamente
dispositivos de hardware. Como corolário, estas aplicações eram tradicionalmente escritas totalmente em linguagem máquina, implicando um enorme esforço de desenvolvimento e manutenção
em cada evolução do hardware.
Quando Ken Thompson iniciou a escrita do sistema operativo Unix (Ritchie e Thmompson,
1974), desenvolveu uma linguagem, designada B, que funcionava no hardware de um computador
2 I NTRODUÇ ÃO
Digital PDP-10. O B era uma linguagem próxima da linguagem máquina, mas que facilitava extraordinariamente a programação de baixo-nı́vel. O B adoptava algumas estruturas decisionais e
ciclos comuns a linguagens de alto-nı́vel e, simultaneamente, disponibilizava um conjunto de facilidades simples, geralmente só acessı́veis em linguagem máquina, como o acesso a endereços de
memória e a métodos de endereçamento indirecto. De facto, os mecanismos de acesso a variáveis
e estruturas de dados previstos no B cobriam a esmagadora maioria das necessidades dos programadores quando anteriormente eram obrigados a recorrer à linguagem máquina. Ora estes mecanismos, embora obviamente dependentes do hardware, obedeciam na sua generalidade ao modelo
de memória previsto na arquitectura de Von Neuman, o qual, nos seus princı́pios essenciais, está na
base da maioria das plataformas computacionais desde os anos 50 até aos nossos dias. Foi assim
surgiu a ideia da possibilidade de desenvolvimento de uma linguagem de alto nı́vel, independente
do hardware, que permitisse simultaneamente um acesso flexı́vel aos modos de endereçamento
e facilidades disponı́veis ao nı́vel da linguagem máquina da maioria dos processadores. É assim
que, em 1983, é inventada a linguagem C (Kernighan e Ritchie, 1978), a qual permitiu a re-escrita
de 90% do núcleo do sistema operativo Unix em alto-nı́vel.
Para além da manipulação directa de endereços de memória, uma das facilidades introduzida
pela linguagem C foi a incorporação de mecanismos para gestão de memória dinâmica. O conceito
de memória dinâmica permite que um programa ajuste de modo flexı́vel a dimensão da memória
que utiliza de acordo com as suas necessidades efectivas. Por exemplo, um mesmo programa de
processamento de texto pode ocupar pouca memória se estiver a tratar um documento de pequena
dimensão, ou ocupar um volume mais significativo no caso de um documento de maior número de
páginas.
A invenção da linguagem C (Kernighan e Ritchie, 1978) permitiu a re-escrita de 90% do
núcleo do sistema operativo Unix em linguagem de alto-nı́vel. A possibilidade de manipular
endereços de memória permitiu ainda a implementação eficiente em linguagem de alto nı́vel de
muitos algoritmos e estruturas de dados até então geralmente escritos em linguagem Assembler
(Knuth, 1973).
Neste texto apresenta-se uma introdução aos apontadores e memória dinâmica na linguagem
de programação C. Para exemplificar estes conceitos, são introduzidas algumas estruturas de dados
dinâmicas simples, como pilhas, filas, listas e aneis. Durante a exposição, introduz-se de forma
natural a noção de abstracção de dados, e os princı́pios essenciais de estruturação e modularidade
baseados neste paradigma de programação. Deste modo, a noção de abstracção de dados não é introduzida formalmente num capı́tulo autónomo, mas sim quando se instroduzem listas dinâmicas,
altura em que o conceito é fundamental para justificar a estrutura adoptada. Esta abordagem, embora pouco convencional, deriva da nossa experiência na docência da disciplina de Introdução à
Programação durante vários anos no IST, tendo beneficiado das crı́ticas e sugestões de diversas
3
gerações de alunos.
Tenta-se neste texto dar-se primazia à clareza algorı́tmica e legibilidade do código, ainda
que em alguns casos esta opção possa sacrificar pontualmente a eficiência do código produzido.
Considera-se, no entanto, que é esta a abordagem mais adequada numa disciplina de Introdução
à Programação. Por outro lado, a maioria dos compiladores recentes incluem processos de
optimização que dispensam a utilização explı́cita dos mecanismos de optimização previstos originalmente na linguagem C.
Capı́tulo 2
Apontadores
2.1
Motivação para os apontadores em C
2.2
Modelos de memória em C
Embora os mecanismos de endereçamento e acesso à memória tenham sofrido várias
evoluções nas últimas décadas e sejam evidentemente dependentes do hardware, o C requer apenas
hipóteses muito simples relativamente ao modelo de memória do processador.
A memória de um computador encontra-se organizada em células ou palavras de memória
individuais. Cada palavra de memória é referenciada por um endereço e armazena um dado
conteúdo. Designa-se por “número de bits da arquitectura” o número máximo de bits que um
processador é capaz de ler num único acesso à memória. Cada palavra de memória de referência
de um processador tem em geral um número de bits idêntico ao número de bits da arquitectura1 .
Admita-se que num dado programa em C declara, entre outras as variáveis i,j,k com o tipo
int, a variável f com o tipo float e uma variável d do tipo double, não necessariamente
por esta ordem. Admita-se que após a declaração destas variáveis são realizadas as seguintes
atribuições:
i = 2450; j = 11331; k = 113; f = 225.345; d = 22.5E+145;
1
A complexidade dos modos de endereçamento dos processadores actuais conduz a que a definição e os modelos
aqui apresentados pequem por uma simplicidade excessiva, mas são suficientes para os objectivos propostos.
6 A PONTADORES
Endereço
..
.
Conteúdo
Variável
..
.
..
.
1001
???
1002
2450
1003
???
1004
225.345
f
1005
11331
j
1006
113
k
1007
22.5E145
1008
(double, 64bits)
1009
???
..
.
i
d
..
.
Figura 2.1: Modelo de memória em C (exemplo).
Um exemplo esquemático de um modelo memória correspondente a esta configuração encontra-se
representado na figura 2.1. Admite-se aqui que a palavra de memória e o tipo inteiro são representados por 32 bits. De acordo com a figura, durante a compilação foram atribuı́dos às variáveis
i,j,k,f os endereços 1002,1005, 1006 e 1004, respectivamente, enquanto que à variável d foi
atribuı́do o endereço 10072 . Note-se que, com excepção do tipo double, cada variável tem uma
representação interna de 32 bits, ocupando por isso apenas um endereço de memória. A variável
d, de tipo double, tem uma representação interna de 64 bits, exigindo por isso duas palavras de
memória: os endereços 1007 e 1008. Na figura 2.1 encontram-se representados outras posições
de memória, provavelmente ocupadas por outras variáveis, e cujo conteúdo é desconhecido do
programador.
2
Estritamente falando, a cada variável local, é apenas atribuı́do um endereço relativo durante a compilação, sendo o
endereço final fixado durante a execução da função ou activação do bloco de instruções em que a declaração é realizada.
Este tema voltará a ser abordado na secção 3.2.
A PONTADORES 7
2.3
Apontadores
Um apontador em C é uma variável cujo conteúdo é um endereço de outra posição de
memória. A declaração de variáveis do tipo apontador pode ser construı́da a partir de qualquer
tipo definido anteriormente, e deve especificar o tipo de variável referenciada pelo apontador.
A declaração de uma variável do tipo apontador realiza-se colocando um “*” antes do nome
da variável. Assim, na declaração
double *pd;
int
i;
int
*pi;
float
f;
int
j,k;
double d;
int
m,*pi2;
double *pd2,d2;
as variáveis i,j,k,m são do tipo int, f é do tipo float, e d,d2 são do tipo double. Além
destas variáveis de tipos elementares, são declaradas as variáveis pi, pi2 do tipo apontador
para inteiro, enquanto que pd e pd2 são do tipo apontador para double.
Admita-se agora se realiza a seguinte sequência de atribuições:
i = 2450; f = 225.345; k = 113; d = 22.5E145; m = 9800;
Após estas instruções, o mapa de memória poderia ser o representado na figura 2.2, situação
A. Sublinhe-se que as variáveis de tipo apontador apenas contêm um endereço de memória, independentemente do tipo referenciado. Desta forma, todas as variáveis de tipo apontador têm
uma representação igual em memória, normalmente de dimensão idêntica ao do tipo inteiro (uma
palavra de memória). Note-se que o conteúdo dos apontadores, tal como o de algumas variáveis
elementares, ainda não foi inicializado, pelo que surgem representados com ???. É importante
compreender que cada uma destas variáveis tem de facto um conteúdo arbitrário, resultante da
operação anterior do computador. Claro que estes valores, não tido sido ainda inicializados pelo
programa, são desconhecidos do programador, mas esta situação não deve ser confundida com a
“ausência de conteúdo”, erro de raciocı́nio frequentemente cometido por programadores principiantes.
As variáveis de tipo elementar podem ser inicializadas pela atribuição directa de valores constantes. A mesma técnica pode ser utilizada para a inicialização de apontadores, embora este
8 A PONTADORES
Endereço
..
.
Conteúdo
Variável
..
.
..
.
Endereço
..
.
Conteúdo
Variável
..
.
..
.
Endereço
..
.
Conteúdo
Variável
..
.
..
.
pd
1001
1007
pd
1001
1007
pd
i
1002
2450
i
1002
2450
i
pi
1003
1006
pi
1003
1006
pi
225.345
f
1004
225.345
f
1004
225.345
f
1005
???
j
1005
???
j
1005
???
j
1006
113
k
1006
113
k
1006
113
k
d
1007
d
1007
1001
???
1002
2450
1003
???
1004
1007
22.5E145
1008
(double, 64bits)
1009
9800
1010
1011
1012
m
1009
9800
m
???
pi2
1010
1006
pi2
???
pd2
1011
1007
pd2
d2
1012
???
d2
m
1009
9800
???
pi2
1010
???
pd2
1011
d2
1012
(double, 64bits)
1013
..
.
..
.
..
.
A
..
.
1008
1008
???
Conteúdo
???
(double, 64bits)
1013
..
.
..
.
Variável
Endereço
..
.
Conteúdo
Variável
..
.
..
.
(double, 64bits)
..
.
C
B
..
.
..
.
d
22.5E145
(double, 64bits)
(double, 64bits)
1013
Endereço
22.5E145
Endereço
..
.
Conteúdo
Variável
..
.
..
.
1001
1007
pd
1001
1007
pd
5001
3
i1
1002
2450
i
1002
2450
i
5002
4
i2
1003
1006
pi
1003
1006
pi
5003
5001
pi1
5004
5003
pi2
1004
225.345
f
1005
113
j
1005
113
j
5005
5004
pi3
1006
113
k
1006
113
k
5006
10
k1
d
1007
d
5007
46
k2
5008
5006
pk1
5003
pk2
1007
22.5E145
1008
(double, 64bits)
1009
9800
1010
1011
1012
1013
..
.
1004
225.345
f
22.5E145
1008
(double, 64bits)
m
1009
24500
m
5009
1006
pi2
1010
1002
pi2
5010
1007
pd2
1011
1012
pd2
5011
d2
1012
d2
5012
22.5E145
(double, 64bits)
..
.
D
1013
..
.
22.5E144
(double, 64bits)
..
.
E
5013
..
.
..
.
F
Figura 2.2: Mapa de memória após diferentes sequências de atribuição (explicação no texto).
A PONTADORES 9
método raramente seja utilizado: em geral, o programador não sabe quais os endereços de memória
disponı́veis no sistema, e a manipulação directa de endereços absolutos tem reduzidas aplicações
práticas.3 Assim, A inicialização de apontadores é geralmente realizada por outros métodos, entre
os quais a utilização do endereço de outras variáveis já declaradas. Este endereço pode ser obtido
em C aplicando o operador & a uma variável. Na continuação do exemplo anterior, admita-se que
era realizada agora a seguinte sequência de atribuições:
pd = &d; pi = &k;
Após esta fase, o mapa de memória seria o representado na figura 2.2, situação B, onde as
variáveis pd e pi surgem agora preenchidas com os endereços de d e k. Como seria de esperar, a consistência da atribuição exige que a variável referenciada e o tipo do apontador sejam
compatı́veis. Por exemplo, a atribuição pd=&k é incorrecta, atendendo a que k é do tipo int e
pd está declarada como um apontador para double.
A partir do momento em que os apontadores são inicializados, o seu conteúdo pode ser
copiado e atribuı́do a outras variáveis, desde que os tipos ainda sejam compatı́veis. Assim, as
atribuições
pd2 = pd; pi2 = pi;
conduziriam apenas à cópia dos endereços guardados em pd e pi para pd2 e pi2. Após esta
sequência, a situação seria a representada na figura 2.2, caso C.
Uma vez estabelecido um mecanismo para preecher o conteúdo de um apontador, coloca-se a
questão de como utilizar este apontador de modo a aceder aos dados apontados. Este mecanismo é
suportado no chamado sistema de endereçamento indirecto, o qual é realizado em C pelo operador
*. Assim, a atribuição j = *pi; tem o significado “consultar o endereço guardado em pi (1006)
e, seguidamente, ler o inteiro cujo endereço é 1006 e colocar o resultado (113) na variável j. Deste
modo, após as atribuições
j = *pi; d2 = *pd;
a situação seria a representada na figura 2.2, caso D.
Referiu-se anteriomente que um apontador é apenas um endereço de memória. Assim, poderá
questionar-se a necessidade de distinguir um apontador para um inteiro de um apontador para um
3
Embora possa ser pontualmente utilizada em programas que lidam directamente com o hardware.
10 A PONTADORES
real, por exemplo. De facto, a dependência do apontador do tipo apontado não tem a ver com
a estrutura do apontador em si, mas sim com o facto de esta informação ser indispensável para
desreferenciar (aceder) correctamente o valor endereçado. Por exemplo, na expressão d2 = *pd,
é o facto de pd ser um apontador para double que permite ao compilador saber que o valor
referenciado ocupa não apenas uma, mas duas palavras de memória e qual a sua representação. Só
na posse desta informação é possı́vel efectuar a atribuição correcta a d2.
Outras sequências de atribuição seriam possı́veis. Por exemplo, após a sequência
pd2 = &d2; *pd2 = *pd1 / 10.0; pi2 = &i; m= *pi2 * 10;
a situação seria a representada no caso E. Note-se que aqui o operador * foi utilizado no lado
esquerdo da atribuição. Assim, por exemplo, a atribuição *pd2 = *pd1 é interpretada como
ler o real cujo endereço é especificado pelo conteúdo de pd1 e colocar o resultado no endereço
especificado por pd2.
Embora até aqui tenham sido considerados apontadores para tipos elementares, um apontador
pode também endereçar um outro apontador. Assim, na declaração
int i1,i2,*pi1,**pi2,***pi3;
int k1,k2,*pk1,**pk2;
enquanto que p1 é um apontador para um inteiro, p2 é um apontador para um apontador para um
inteiro e p3 é um apontador para um apontador para um apontador para um inteiro. Como seria de
esperar, a inicialização destes apontadores realiza-se segundo as mesmas regras seguidas para os
apontadores simples, sendo apenas necessário ter em atenção, o nı́vel correcto de endereçamento
indirecto múltiplo. Assim, se após a declaração anterior, fosse executada a sequência de instruções
i1 = 3; i2 = 4;
pi1 = &i1; pi2 = &pi1; pi3 = &pi2;
k1 = 10;
pk1 = &k1; pk2 = pi2;
k2 = i1 * ***pi3 + *pi1 + i2 + **pk2 * *pk1;
a situação final poderia ser a representada na figura 2.2, caso F.
Em apontadores mútilpos a possibilidade de desreferenciar um apontador continua a ser dependente do tipo apontado. Deste modo, um apontador para um inteiro e um apontador para um
apontador para um inteiro são tipos claramente distintos e cujos conteúdos não podem ser mutuamente trocados ou atribuı́dos. Por outro lado, os nı́veis de indirecção devem ser claramente
F UNÇ ÕES E PASSAGEM POR REFER ÊNCIA 11
respeitados. Assim, a atribuição i1 = *pk2; no exemplo precedente seria incorrecta, já que i1
é do tipo inteiro e, *pk2 é um apontador para inteiro (recorde-se que pk2 é um apontador para um
apontador para um inteiro).
Como é sabido, o valor de uma variável nunca deve ser utilizado antes de esta ser inicializada
explicitamente pelo programa. Com efeito, no inı́cio de um programa, as variáveis têm um valor
arbitrário desconhecido. Por maioria de razão, o mesmo princı́pio deve ser escrupulosamente
seguido na manipulação de apontadores. Suponha-se, por exemplo que, no inı́cio de um programa,
são incluı́das a declaração e as instruções seguintes:
int *p,k;
k = 4; *p = k*2;
Aqui, a segunda atribuição especifica que o dobro de k (valor 8) deve ser colocado no endereço
especificado pelo conteúdo da variável p. No entanto, dado que p não foi inicializada, o seu
conteúdo é arbitrário. Com elevada probabilidade, o seu valor corresponde a um endereço de
memória inexistente ou inválido. No entanto, o C não realiza qualquer juı́zo de valor sobre esta
situação e, tendo sido instruı́do para “colocar 8 no endereço indicado por p” tentará executar esta
operação. A tentativa de escrita numa posição de memória inválida ou protegida conduzirá ou ao
compromisso da integridade do sistema operativo se o espaço de memória não for convenientemente protegido, como é o caso do DOS. Se o sistema tiver um modo protegido, como o Unix ou o
Windows NT, esta situação pode originar um erro de execução, devido a uma violação de memória
detectada pelo sistema operativo. Os erros de execução conduzem à interrupção imediata, em erro,
do programa.
Sublinhe-se que nas figuras desta secção foram utilizados endereços especı́ficos nos apontadores de modo a melhor demonstrar e explicar o mecanismo de funcionamento dos mecanismos de
apontadores e indirecção em C. No entanto, na prática, o programador não necessita de conhecer o
valor absoluto dos apontadores, sendo suficiente a manipulação indirecta do seu conteúdo através
dos mecanismos de referenciação e desreferenciação descritos.
2.4
2.4.1
Funções e passagem por referência
Passagem por referência
Em C, na chamada de uma função, os parâmetros formais da função recebem sempre uma
cópia dos valores dos parâmetros actuais. Este mecanismo de passagem de argumentos é desig-
12 A PONTADORES
nado passagem por valor (Martins, 1989) e está subjacente a todas as chamadas de funções em
C. Esta situação é adequada se se pretender apenas que os argumentos transmitam informação do
módulo que chama para dentro da função. Dado que uma função pode também retornar um valor,
este mecanismo básico é também adequado quando a função recebe vários valores de entrada e
tem apenas um valor de saı́da. Por exemplo, uma função que determina o maior de três inteiros
pode ser escrita como
int maior_3(int i1,int i2, int i3){
/*
* Função que determina o maior de três inteiros
*
*/
if((i1 > i2) && (i1 > i3))
return i1;
else
return (i2 > i3 ? i2 : i3);
}
Existem no entanto situações em que se pretende que uma função devolva mais do que um
valor. Uma situação possı́vel seria uma variante da função maior_3 em que se pretendesse
determinar os dois maiores valores, e não apenas o maior. Outro caso tı́pico é o de uma função
para trocar o valor de duas variáveis entre si. Numa primeira tentativa, poderia haver a tentação de
escrever esta função como
#include <stdio.h>
#include <stdlib.h>
void trocaMal(int x,int y){
/*
* ERRADO
*/
int aux;
aux = x;
x
= y;
y
= aux;
}
int main(){
int a,b;
printf("Indique dois números: ");
scanf("%d %d",&a,&b);
trocaMal(&a,&b);
printf("a = %d, b= %d\n",a,b);
F UNÇ ÕES E PASSAGEM POR REFER ÊNCIA 13
exit(0);
}
No entanto, este programa não funciona: o mecanismo de passagem por valor implica que a
função troca opera correctamente sobre as variáveis locais x e y, trocando o seu valor, mas estas
variáveis não são mais do que cópias das variáveis a e b que, como tal, se mantêm inalteradas.
Na figura 2.3 representa-se a evolução do mapa de memória e do conteúdo das variáveis durante a
chamada à função trocaMal.
Este aparente problema pode ser resolvido pela utilização criteriosa de apontadores. A função
troca especificada anteriormente pode ser correctamente escrita como se segue:
#include <stdio.h>
#include <stdlib.h>
void troca(int *x,int *y){
/*
* Função que troca dois argumentos
*/
int aux;
aux = *x;
*x = *y;
*y = aux;
}
int main(){
int a,b;
printf("Indique dois números: ");
scanf("%d %d",&a,&b);
troca(&a,&b);
printf("a = %d, b= %d\n",a,b);
exit(0);
}
Tal como pode ser observado, a solução adoptada consiste em passar à função não o valor das
variáveis a e b, mas sim os seus endereços. Embora estes endereços sejam passados por valor (ou
seja, a função recebe uma cópia destes valores), o endereço permite à função o conhecimento da
posição das variáveis a b em memória e, deste modo, permite a manipulação do seu conteúdo por
meio de um endereçamento indirecto.
Um hipotético exemplo de um mapa memória associado ao funcionamento do programa
troca encontra-se representado na figura 2.4. Admita-se que a declaração de variáveis no pro-
14 A PONTADORES
Endereço
Conteúdo
..
.
..
.
..
.
..
.
..
.
..
.
..
.
..
.
..
.
Variáveis 2135
do bloco
main()
2136
..
.
Variável
8
a
2
b
..
.
..
.
Endereço
..
.
..
.
2131
Variáveis
da funçao 2132
troca()
2133
Variável
..
.
2
8
x
8
2
y
8
aux
..
.
..
.
..
.
..
.
..
.
..
.
Variáveis 2135
do bloco
main()
2136
..
.
..
.
C
Variável
..
.
8
2
x
y
aux
..
.
..
.
..
.
..
.
..
.
..
.
Variáveis 2135
do bloco
main()
2136
..
.
8
a
2
b
..
.
..
.
B
Conteúdo
..
.
..
.
2131
Variáveis
da funçao 2132
troca()
2133
A
Endereço
Conteúdo
8
a
2
b
..
.
Endereço
Conteúdo
Variável
..
.
..
.
..
.
..
.
..
.
..
.
..
.
..
.
..
.
Variáveis 2135
do bloco
main()
2136
..
.
..
.
8
a
2
b
..
.
D
Figura 2.3: Mapa de memória durante as diferentes fase de execução de um programa que utiliza
a função trocaMal. A - antes da chamada à função, B - no inı́cio de troca, C - no final de
troca, D - após o regresso ao programa principal. O mecanismo de passagem por valor conduz
a que os valores do programa principal não sejam alterados.
F UNÇ ÕES E PASSAGEM POR REFER ÊNCIA 15
Endereço
Conteúdo
..
.
..
.
..
.
..
.
..
.
..
.
..
.
..
.
..
.
Variáveis 2135
do bloco
main()
2136
..
.
Variável
8
a
2
b
..
.
..
.
Endereço
..
.
..
.
2131
Variáveis
da funçao 2132
troca()
2133
Variável
..
.
2135
2136
8
y
aux
..
.
..
.
..
.
..
.
..
.
..
.
2
8
a
8
2
b
..
.
C
..
.
2135
2136
x
y
aux
..
.
..
.
..
.
..
.
..
.
..
.
Variáveis 2135
do bloco
main()
2136
..
.
8
a
2
b
..
.
..
.
Endereço
Conteúdo
Variável
..
.
..
.
..
.
..
.
..
.
..
.
..
.
..
.
..
.
x
..
.
Variáveis 2135
do bloco
main()
2136
Variável
B
Conteúdo
..
.
..
.
2131
Variáveis
da funçao 2132
troca()
2133
A
Endereço
Conteúdo
..
.
Variáveis 2135
do bloco
main()
2136
..
.
..
.
2
a
3
b
..
.
D
Figura 2.4: Mapa de memória durante as diferentes fase de execução do programa que utiliza a
função troca. A - antes da chamada à função, B - no inı́cio de troca C - no final de troca, D após o regresso ao programa principal. A passagem de apontadores para as variáveis do programa
principal (passagem por referência) permite que a função altere as variáveis do programa principal.
16 A PONTADORES
grama principal atribuiu às variáveis A e B os endereços 2135 e 2136, e que estas foram inicializadas pelo utilizador com os valores 8 e 2, respectivamente. A situação imediatamente antes da
chamada da função troca encontra-se representada em A. Durante a chamada da função, realizase a activação das variáveis x, y e aux, locais à função, eventualmente numa zona de memória
afastada daquela onde se encontram as variáveis a e b, sendo as duas primeiras destas variáveis
inicializadas com os endereços de a e b (situação B). Através do endereçamento indirecto através
das variáveis x e y, são alterados os valores das variáveis a e b do programa principal, atingido-se
a situação C. Após o regresso ao programa principal, as variáveis da função troca são libertadas,
atingindo-se a situação representada em D, com as
Note-se que, estritamente falando, a passagem de argumento se deu por valor, atendendo a que
x e y são variáveis locais à função, tendo recebido apenas valores correspondentes ao endereço de
variáveis declaradas no programa principal. No entanto, neste tipo de mecanismo, diz-se também
que as variáveis a e b foram passadas por referência(Martins, 1989), atendendo a que o seu
endereço (e não o seu conteúdo) que foi passadas à função.
Mais genericamente, sempre que é necessário que uma função altere o valor de um ou mais
dos seus argumentos, este ou estes deverão ser passados por referência, de forma a ser possı́vel à
função modificar o valor das variáveis por um mecanismo de indirecção. É por este motivo que,
na chamada da função scanf(), todas variáveis a ler são passados por referência, de modo a ser
possı́vel a esta função poder ler e alterar os valores das variáveis do programa principal.
2.4.2
Erros frequentes
A tentativa de desdobramento do mecanismo de referenciação de uma variável quando uma
dado programa envolve vários nı́veis de funções é um erro frequente de programadores principiantes ou com reduzida experiência de C. Considere-se o seguinte troço de programa, em que se
pretende que a variável x do programa principal seja modificada na função func2. Neste exemplo, o programador adoptou desnecessariamente uma referenciação (cálculo do seu endereço) da
variável a modificar em cada um dos nı́veis de chamada da função:
/*
Utilização incorrecta (desnecessária)
de referências múltiplas.
*/
void func2(int **p2,int b2){
**p2 = -b2 * b2;
}
void func1(int *p1,int b1){
b1 = b1 + 1;
F UNÇ ÕES E PASSAGEM POR REFER ÊNCIA 17
func2(&p1,b1);
}
int main(){
int x;
func1(&x,5);
return 0;
}
Como pode ser facilmente entendido, a referenciação de uma variável uma única vez é suficiente
para que este mesmo endereço possa ser sucessivamente passado entre os vários nı́veis de funções
e ainda permitir a alteração da variável seja sempre possı́vel. Assim, embora o programa anterior
funcione, a referenciação de p1 na passagem de func1 para func2 é inútil: o mecanismo ali
adoptado só faria sentido caso se pretendesse que func2 alterasse o conteúdo da variável p1.
Como é evidente não é esse o caso, e o programa anterior correctamente escrito tomaria a seguinte
forma:
/*
Utilização correcta de uma passagem
por referência entre vários nı́veis
de funções.
*/
void func2(int *p2,int b2){
*p2 = -b2 * b2;
}
void func1(int *p1,int b1){
b1 = b1 + 1;
func2(p1,b1);
}
int main(){
int x;
func1(&x,5);
return 0;
}
Um outro erro frequente em programadores principiantes é passagem ao programa principal
ou função que chama a referência de uma variável local à função evocada. Este tipo de erro pode
ser esquematizado pelo seguinte programa:
int *func(int a){
int b,*c;
b = 2 * a;
c = &b;
return c;
18 A PONTADORES
}
int main(){
int x,*y;
y = func1(1);
x = 2 * *y;
return 0;
}
Aqui, a variável b é local à função func e, como tal, é criada quando func é activada e a
sua zona de memória libertada quando a função termina. Ora o resultado da função func é
passada ao programa principal sob a forma do endereço de b. Quando o valor desta variável é
lido no programa principal por meio de um endereçamento indirecto na expressão x = 2 * *y,
a variável b já não está activa, realizando-se por isso um acesso inválido à posição de memória
especificada pelo endereço em y. Com elevada probabilidade, o resultado final daquela expressão
será incorrecto.
2.5
2.5.1
Vectores e apontadores
Declaração de vectores
Um vector em C permite a criação de uma estrutura com ocorrências múltiplas de uma variável
de um mesmo tipo. Assim, a declaração
int
x[5] = {123,234,345,456,567};
double y[3] = {200.0,200.1,200.2};
declara um vector x de 5 inteiros, indexado entre 0 e 4, e um vector y de 3 reais de dupla precisão, indexado entre 0 e 2, inicializados na própria declaração. Um possı́vel mapa de memória
correspondente a esta declaração encontra-se representado na figura 2.5.1.
Cada elemento individual de um vector pode ser referenciado acrescentando à frente do nome
da variável o ı́ndice, ou posição que se pretende aceder, representado por um inteiro entre [].
Para um vector com N posições, o ı́ndice de acesso varia entre 0 (primeira posição) e N − 1
(última posição). Cada elemento de x e y corresponde a uma variável de tipo inteiro ou double,
respectivamente. Deste modo, se se escrever,
int
x[5] = {123,234,345,456,567};
V ECTORES E APONTADORES 19
Endereço
..
.
Conteúdo
Variável
..
.
..
.
1001
123
x[0]
1002
234
x[1]
1003
345
x[2]
1004
456
x[3]
1005
789
x[4]
1006
200.0
y[0]
1007
1008
y[1]
200.1
1009
1010
1011
..
.
y[2]
200.2
..
.
Figura 2.5: Mapa de memória correspondente à declaração de dois vectores
20 A PONTADORES
double y[3] = {200.0,200.1,200.2};
double a;
a = y[1] + x[3];
o conteúdo final de a será o resultado da soma da segunda posição de y com a quarta posição de
x, ou seja 656.1.
Dado que cada elemento de um vector é uma variável simples, é possı́vel determinar o seu
endereço. Assim, é possı́vel fazer
int
x[5] = {123,234,345,456,567};
double y[3] = {200.0,200.1,200.2};
int
*pi; double *pd;
pi = &(x[2]);
pd = &(y[1]);
conduzindo-se assim à situação representada na figura 2.5.1. Note-se que nas atribuições
pi = &(x[2]) e pd = &(y[1]) os parenteses poderiam ser omitidos, dado que o operador [] (ı́ndice) tem precedência sobre o operador & (endereço de). Assim, aquelas atribuições
poderiam ser escritas como pi = &x[2] e pd = &y[1].
Uma das vantagens da utilização de vectores é o ı́ndice de acesso poder ser uma variável.
Deste modo, inicialização de um vector de 10 inteiros a 0 pode ser feita pela sequência
#define NMAX 10
int main(){
int x[NMAX];
int k;
for(k = 0; k < NMAX ; k++)
x[k] = 0;
/* ...*/
Uma utilização comum dos vectores é a utilização de vectores de caracteres para guardar
texto. Por exemplo a declaração
char texto[18]="Duas palavras";
V ECTORES E APONTADORES 21
Endereço
..
.
Conteúdo
Variável
..
.
..
.
1001
123
x[0]
1002
234
x[1]
1003
345
x[2]
1004
456
x[3]
1005
789
x[4]
1006
200.0
y[0]
1007
1008
y[1]
200.1
1009
1010
y[2]
200.2
1011
1012
1003
pi
1013
1008
pd
..
.
..
.
Figura 2.6: Apontadores e vectores (explicação no texto).
22 A PONTADORES
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
texto[18] ’D’ ’u’ ’a’ ’s’ ’ ’ ’p’ ’a’ ’l’ ’a’ ’v’ ’r’ ’a’ ’s’’\0’
Figura 2.7: Utilização de vectores de caracteres para armazenar texto.
cria um vector de 18 caracteres, e inicia as suas primeiras posições com o texto Duas palavras.
Uma representação gráfica deste vector depois de inicializado é apresentada na figura 2.7.
Dado que o texto pode ocupar menos espaço que a totalidade do vector, como sucede neste
caso, a última posição ocupada deste vector é assinalada pela colocação na posição seguinte
à última do caracter com o código ASCII 0, geralmente representado pela constante inteira 0
ou pelo caracter ’\0’. Note-se que esta última representação não deve ser confundida com a
representação do algarismo zero ’0’, internamente representado por 48, o código ASCII de 0.
2.5.2
Aritmética de apontadores
É possı́vel adicionar ou subtrair uma constante inteira de uma variável de tipo apontador. Este
tipo de operação só faz sentido em vectores ou estruturas de dados regulares, em que o avanço ou
recuo de um apontador conduz a um outro elemento do vector (ou estrutura de dados regulares).
É da única e exclusiva responsabilidade do programador garantir que tais operações aritméticas
se mantêm dentro dos endereços válidos da estrutura de dados apontada. Assim, por exemplo, se
na sequência do exemplo e da situação representada na figura 2.5.1 (repetida por conveniência na
figura 2.5.2, A) fossem executadas as instruções
pi = pi + 2;
pd = pd - 1;
a situação resultante seria a representada na figura 2.5.2, B.
No caso do modelo de memória adoptado como exemplo, a operação aritmética sobre o apontador inteiro tem uma correspondência directa com a operação realizada sobre o endereço. No
entanto, o mesmo não sucede com o apontador para double, onde subtracção de uma unidade
ao apontador corresponde na realidade a uma redução de duas unidades no endereço fı́sico da
memória.
De facto, a aritmética de apontadores em C é realizada de forma a que o incremento ou decremento unitário corresponda a um avanço ou recuo de uma unidade num vector, independentemente
do tipo dos elementos do vector. O compilador de C garante o escalamento da constante adicionada
V ECTORES E APONTADORES 23
Endereço
..
.
Conteúdo
Variável
..
.
..
.
Endereço
..
.
Conteúdo
Variável
..
.
..
.
1001
123
x[0]
1001
123
x[0]
1002
234
x[1]
1002
234
x[1]
1003
345
x[2]
1003
345
x[2]
1004
456
x[3]
1004
456
x[3]
1005
789
x[4]
1005
789
x[4]
y[0]
1006
1006
200.0
1007
1008
y[0]
1007
y[1]
200.1
1009
1010
200.0
1008
y[1]
200.1
1009
y[2]
200.2
1011
1010
y[2]
200.2
1011
1012
1003
pi
1012
1005
pi
1013
1008
pd
1013
1006
pd
..
.
..
.
A
..
.
..
.
B
Figura 2.8: Aritmética de apontadores (explicação no texto).
24 A PONTADORES
ou subtraı́da de acordo com a dimensão do tipo apontado. Este modo de operação garante que,
na prática, o programador possa realizar operações sobre apontadores abstraindo-se do número
efectivo de bytes do elemento apontado.
Já anteriormente se mencionou que em C o tipo do apontador depende do objecto apontado, de
modo a ser possı́vel determinar, num endereçamento indirecto, qual o tipo da variável referenciada.
A aritmética de apontadores reforça esta necessidade, dado que o seu incremento ou decremento
exige o conhecimento da dimensão do objecto apontado.
A aritmética de apontadores define apenas operações relativas de endereços. Assim, embora
seja possı́vel adicionar ou subtrair constantes de apontadores, não faz sentido somar apontadores.
Numa analogia simples, considere-se que os endereços são os números da porta dos edifı́cios de
uma dada rua: faz sentido referir “dois números depois do prédio 174” ou “três números antes do
180”, mas não existe nenuma aplicação em que faça sentido adicionar dois números de porta (174
e 180, por exemplo). No entanto, faz sentido referir “o número de prédios entre o 174 e o 180”:
de modo equivalente, também em C a subtracção de apontadores é possı́vel, desde que sejam do
mesmo tipo. Como é evidente, tal como na adição, a subtracção de operadores é convenientemenet
escalada pela dimensão do objecto apontado:
int
x[5] = {123,234,345,456,567};
double y[3] = {200.0,200.1,200.2};
int
*pi1,*pi2; double *pd1,*pd2;
int
di,dd;
pi1 = &x[2]; pi2 = &x[0]; di = pi1 - pi2; /* di <- 2 */
pd1 = &y[1]; pd2 = &y[3]; dd = pd1 - pd2; /* dd <- -1 */
.
Uma última nota relativamente aos apontadores de tipo void. O C-ANSI permite a definição
de apontadores genéricos, cujo tipo é independente do objecto apontado. Um apontador pv deste
tipo manipula um endereço genérico e pode ser simplesmente declarado por
void *pv;
e encontra utilização em situações em que se pretende que uma mesma estrutura possa endereçar
entidades ou objectos de tipos diferentes. Sempre que possı́vel, este tipo de situações deve ser
preferencialmente abordado pela utilização de estruturas do tipo union. No entanto, existem
casos em que tal não é possı́vel, obrigando à utilização deste tipo de apontadores. É, por exemplo,
o caso das funções de gestão de memória, que trabalham com apontadores genéricos (V. secção
3.3). Note-se, no entanto, que o C não consegue desreferenciar automaticamente um apontador
V ECTORES E APONTADORES 25
para void (ou seja, aceder ao conteúdo apontado por). De igual modo, o desconhecimento da
dimensão do objecto apontado impede que a aritmética de apontadores seja aplicável a apontadores
deste tipo.
2.5.3
Índices e apontadores
Dos princı́pios gerais referidos anteriormente, resulta que atribuição do 3o elemento de um
vector x a uma variável a pode ser realizada directamente por
a = x[2];
ou, de forma equivalente, por
a = *(&x[0] + 2);
sendo o resultado idêntico. Enquanto que no primeiro caso se adopta um mecanismo de
indexação directa, no segundo caso determina-se um apontador para o primeiro elemento do
vector, incrementa-se o apontador de duas unidades para endereçar o 3o elemento e, finalmente,
aplica-se o operador “*” para realizar o endereçamento indirecto.
De facto, a segunda expressão pode ser simplificada. O C define que o endereço do primeiro
elemento de um vector pode ser obtido usando simplesmente o nome do vector, sem o operador
de indexação (“[0]”) à frente. Ou seja, no contexto de uma expressão em C, “ &x[0]” é equivalente a usar simplesmente “ x”. Por outras palavras, se x[] é um vector do tipo xpto, x é um
apontador para o tipo xpto. Assim, a expressão “ x[k]” é sempre equivalente a “ *(xk)+”. Este
facto conduz ao que designamos por regra geral de indexação em C, que pode ser enunciada pela
equivalência
x[k] <-> *(x + k)
Registe-se, como curiosidade, que a regra geral de indexação conduz a que, por exemplo,
x[3] seja equivalente a 3[x]. Com efeito,
x[3] <-> *(x+3) <-> *(3+x) <-> 3[x]
Claro que a utilização desta propriedade é geralmente proibido, não pelo C, mas pelas normas de
boa programação!
26 A PONTADORES
2.5.4
Vectores como argumentos de funções
Para usar um vector como argumento de uma função func() basta, no bloco que chama
func(), especifiar o nome da variável que se pretende como argumento. Assim, pode fazer-se,
por exemplo,
int x[10];
/* ... */
func(x);
/* ... */
No entanto, é necessário ter em conta que x, quando usado sem ı́ndice, representa apenas o
endereço da primeira posição do vector. Como deve então ser declarado o argumento formal
correspondente no cabeçalho de func?
Diversas opções existem para realizar esta declaração. No exemplo seguinte, a função set é
utilizada para inicializar os NMAX elementos do vector a do programa principal com os inteiros
entre 1 e NMAX. Numa primeira variante, o argumento formal da função é apenas a repetição da
declaração efectuada no programa principal. Assim,
#define NMAX 100
void set(int x[NMAX],int n){
int k;
for(k = 0; k < n; k++) x[k] = k+1;
}
int main(){
int a[NMAX];
set(a,NMAX);
/* ... */
}
Note-se que para uma função manipular um vector é suficiente conhecer o endereço do
primeiro elemento. Dentro da função, o modo de aceder ao k-ésimo elemento do vector é sempre
adicionar k ao endereço da base, independentemente da dimensão do vector. Ou seja, o parâmetro
formal int x[NMAX] apenas indica que x é um apontador para o primeiro elemento de um vector de inteiros. Deste modo, o C não usa a informação “[NMAX]” no parâmetro formal para aceder
à função. Assim, a indicação explı́cita da dimensão pode ser omitida, sendo válido escrever
void set(int x[],int n){
V ECTORES E APONTADORES 27
int k;
for(k = 0; k < n; k++) x[k] = k+1;
}
Note-se que a possibilidade de omitir a dimensão resulta também da função não necessitar de
reservar o espaço para o vector: a função limita-se a referenciar as células de memória reservadas
no programa principal.
Na passagem de vectores como argumento o C utiliza sempre o endereço da primeira posição,
não existindo nenhum mecanismo previsto que permita passar a totalidade dos elementos do vector por valor. Esta limitação, inerente à própria origem do C, tinha por base a justificação de
que a passagem por valor de estruturas de dados de dimensão elevada é pouco eficiente, dada a
necessidade de copiar todo o seu conteúdo4 .
Atendendo a que a, sem inclusão do ı́ndice, especifica um apontador para primeiro elemento
do vector, uma terceira forma de declarar o argumento formal é
void set(int *x,int n){
int k;
for(k = 0; k < n; k++) x[k] = k+1;
}
Esta última forma sugere uma forma alternativa de escrever o corpo da função set. Com
efeito, para percorrer um vector basta criar um apontador, inicializá-lo com o endereço da primeira
posição do vector e seguidamente incrementá-lo sucessivamente para aceder às posições seguintes.
Esta técnica pode ser ilustrada escrevendo a função
void set(int *x,int n){
int k;
for(k = 0; k < n; k++) *x++ = k+1;
}
Note-se que sendo x um ponteiro cujo valor resulta de uma passagem cópia do endereço de a no
programa principal, é possı́vel proceder ao seu incremento na função de modo a percorrer todos
os elementos do vector. Aqui, a expressão *x++ merece um comentário adicional. Em primeiro
lugar, o operador ++ tem precedência sobre o operador * e, deste modo, o incremento opera sobre
o endereço e não sobre a posição de memória em si. É este apenas o significado da precedência,
4
Na versão original do C, esta limitação estendia-se ao tratamento de estruturas de dados criadas com a directiva
struct, mas esta limitação foi levantada pelo C-Ansi
28 A PONTADORES
o qual não deve ser confundido com a forma de funcionamento do operador incremento enquanto
sufixo: o sufixo estabelece apenas que o conteúdo de x é utilizado antes da operação de incremento
se realizar. Ou seja, *x+=k;+ é equivalente a *x=k;x+;+.
Um outro exemplo de utilização desta técnica pode ser dado escrevendo uma função para
contabilizar o número de caracteres usados de uma string. Atendendo a que se sabe que o último
caracter está seguido do código ASCII 0, esta função pode escrever-se
int conta(char *s){
int n = 0;
while(*s++ != ’\0’) n++;
return n;
}
De facto, esta função é equivalente á função strlen, disponı́vel na biblioteca string.h.
2.6
2.6.1
Matrizes
Declaração
Na sua forma mais simples, a utilização de estruturas multidimensionais em C apenas envolve
a declaração de uma variável com vários ı́ndices, especificando cada um da dimensão pretendida
da estrutura. Por exemplo
float x[3][2];
declara uma estrutura bidimensional de três por dois reais, ocupando um total de seis palavras
de memória no modelo de memória que temos vindo a usar como referência. É frequente uma
estrutura bidimensional ser interpretada como uma matriz, neste exemplo de três linhas por duas
colunas.
Nos modos de utilização mais simples deste tipo de estruturas, o programadador pode abstrairse dos detalhes de implementação e usar a variável bidimensional como uma matriz. Assim, a
inicialização a zeros da estrutura x pode ser efectuada por
float x[3][2];
int k,j;
M ATRIZES 29
Endereço
..
.
Conteúdo
Variável
..
.
..
.
1001
1.0
x[0][0]
1002
2.0
x[0][1]
1003
3.0
x[1][0]
1004
4.0
x[1][1]
1005
5.0
x[2][0]
1006
6.0
x[2][1]
..
.
..
.
Figura 2.9: Mapa de memória correspondente à declaração de uma estrutura de três por dois reais
for(k = 0 ; k < 3 ; k++)
for(j = 0 ; j < 2 ; j++)
x[k][j] = 0.0;
Alternativamente, a inicialização pode ser feita listando os valores iniciais, sendo apenas
necessário agrupar hierarquicamente as constantes de inicialização de acordo com as dimensões
da estrutura:
float x[3][2] = {{1.0,2.0},{3.0,4.0},
{5.0,6.0}};
A disposição em memória desta estrutura é representada esquematicamente na figura 2.9.
2.6.2
Matrizes como argumento de funções
O facto de ser possı́vel omitir a dimensão de um vector na declaração do parâmetro formal de
uma função leva por vezes a pensar que o mesmo pode ser feito no caso de uma matriz. De facto,
a situação não é totalmente equivalente.
Comece-se por regressar à declaração int x[3][2] e observar a forma como é determinado o endereço de x[k][j]. Analisando a figura 2.9, é evidente que o endereço deste elemento é
obtido adicionando ao endereço do primeiro elemento k*2+j. Ou seja,
30 A PONTADORES
x[k][j]
<->
*(&(x[0][0]) + k * 2 +j)
No caso mais genérico da declaração ter a forma <tipo> x[N][M] ter-se-á ainda
x[k][j]
<->
*(&(x[0][0]) + k * M +j)
Ou seja, para aceder a um elemento genérico de uma matriz não basta conhecer o endereço da
primeira posição e o os dois ı́ndices de acesso: é necessário saber também o número de colunas da
matriz. Deste modo, quando uma matriz é passada como argumento de uma função, é necessário
que esta saiba a dimensão das colunas da matriz. Por este motivo, dada a chamada à função
/* ... */
#define NLIN 3
#define NCOL 2
/* ... */
int m[NLIN][NCOL];
int a;
/* ... */
a = soma(m);
/* ... */
o parâmetro formal da função pode repetir na totalidade a declaração da matriz, como em
float norma(float x[NLIN][NCOL]){
int s = 0,k,j;
for(k = 0; k < NLIN ; k++)
for(j = 0; j < NCOL ; j++)
s += x[k][j];
return s;
}
ou pode omitir o número de linhas (dado que, como se mostrou, este número não é indispensável
para localizar o endereço de um elemento genérico da matriz), como em
float norma(float x[][NCOL]){
int s = 0,k,j;
for(k = 0; k < NLIN ; k++)
for(j = 0; j < NCOL ; j++)
s += x[k][j];
return s;
}
M ATRIZES 31
No entanto, a omissão simultânea de ambos os ı́ndices como em
float norma(float x[][]){ /* ERRADO */
int s = 0,k,j;
for(k = 0; k < NLIN ; k++)
for(j = 0; j < NCOL ; j++)
s += x[k][j];
return s;
}
não é possı́vel, gerando um erro de compilação.
Viu-se anteriormente que se o vector int x[NMAX] for utilizado na chamada a uma função,
a declaração a adoptar nos parâmetros formais da função podia ser ou int a[]) ou int *a.
É frequente surgir a dúvida se é possı́vel adoptar uma notação de apontador equivalente no caso
de uma matriz. De facto sim, embora esta notação raramente seja utilizada na prática. No caso do
exemplo que tem vindo a ser utilizado a declaração possı́vel seria
float norma(float (*x)[NCOL]){
int s = 0,k,j;
for(k = 0; k < NLIN ; k++)
for(j = 0; j < NCOL ; j++)
s += x[k][j];
return s;
}
Nesta declaração, x é um apontador para um vector de NCOL floats. Uma explicação mais
detalhada do significado desta invulgar declaração pode ser encontrado na secção 2.6.4.
2.6.3
Matrizes e vectores
Em C, uma matriz não é mais do que um vector de vectores. Ou seja, a declaração
int x[3][2] pode ser interpretada como “ x é um vector de 3 posições, em que cada posição é
um vector de 2 posições”. Esta interpretação é também sugerida pela representação que se adoptou
na figura 2.9. Por outras palavras, x[0], x[1] e x[2] representam vectores de dois elementos
constituı́dos respectivamente por {1.0 , 2.0}, {2.0 , 3.0}, e {4.0 , 5.0}.
Na prática, este facto significa que cada uma das linhas de uma matriz pode ser tratada individualmente como um vector.
32 A PONTADORES
Suponha-se, por exemplo, que dada uma matriz a de dimensão NLIN × NCOL, se pretende
escrever uma função que determine o máximo de cada uma das linhas da matriz e coloque o
resultado num vector y. Esta função pode ser escrita como
void maxMat(float y[],float a[][NCOL]){
int k;
for(k = 0; k < NLIN ; k++)
y[k] = maxVec(a[k],NCOL);
}
onde cada linha foi tratada individualmente como um vector, cujo máximo é determinado pela
função vectorial
float maxVec(float v[],int n){
float m;
int k;
m = v[0];
for(k = 1; k < n; k++)
m = (v[k] > m ? v[k] : m);
return m;
}
De igual forma, considere-se que se pretende calcular o produto matricial
y = Ax
onde A é uma matriz de dimensão N × M e x e y são vectores de dimensão M e N , respectivamente.
Admita-se que o programa principal era escrito como
#define N 3
#define M 2
int main(){
float A[N][M] = {{1,2},{3,4},{5,6}};
float x[M] = {10,20};
float y[N];
int k;
matVecProd(y,A,x);
for(k = 0; k < N ; k++)
printf("%f\n",y[k]);
exit(0);
}
M ATRIZES 33
Atendendo a que o produto de uma matriz por um vector é um vector em que cada elemento
não é mais do que o produto interno de cada linha da matriz com o vector operando, a função
prodMatVec poderia ser escrita como
void matVecProd(float y[],float A[][M],float x[]){
int k;
for(k = 0; k < N ; k++)
y[k] = prodInt(A[k],x,M);
}
com o produto interno definido por
float prodInt(float a[],float b[],int n){
float s = 0.0;
int k;
for(k = 0; k < n; k++)
s += a[k] * b[k];
return s;
}
Uma última situação em que é possı́vel exemplificar a utilização de linhas como vectores é
o do armazenamento de várias linhas de texto. Admita-se, por exemplo, que se pretende ler uma
sequência de linhas de texto e imprimir as mesmas linhas por ordem inversas. Tal é possı́vel através
da utilização de uma matriz de caracteres, utilizando cada linha como uma string convencional:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define NUM_LINHAS 5
#define DIM_LINHA 40
int main(){
char texto[NUM_LINHAS][DIM_LINHA];
int k;
/* Leitura */
printf("Escreva uma sequência de %d linhas:\n",NUM_LINHAS);
for(k = 0;k < NUM_LINHAS; k++){
fgets(texto[k],DIM_LINHA,stdin);
if(texto[k][strlen(texto[k])-1] != ’\n’){
printf("Erro: linha demasiado comprida.\n");
exit(1);
34 A PONTADORES
}
}
/* Escrita */
printf("\nLinhas por ordem inversa:\n");
for(k = NUM_LINHAS-1;k >= 0; k--)
printf("%s",texto[k]);
exit(0);
}
2.6.4
Matrizes e apontadores
Se a utilização de estruturas multidimensionais é relativamente simples e intuitiva, já o mesmo
nem sempre sucede quando é necessário manipular apontadores relacionados com este tipo. 5 O
tratamento de matrizes no C é frequentemente fonte de alvo de dúvidas. Se se perguntar a qualquer
programador de C “dada a declaração int x[3], qual é o tipo de x quando usado isoladamente”,
nenhum terá dúvidas em afirmar que a resposta é “apontador para int”. Experimente-se, no
entanto, a colocar a pergunta semelhante “dada a declaração int x[3][2], qual é o tipo de x
quando usado isoladamente” e, com elevada probabilidade, não será obtida a mesma unanimidade
nas respostas.
Dado que, por consistência sintáctica, se tem sempre
x[k] <-> *(x + k)
então, no caso de uma estrutura bidimensional, terá que ser
x[k][j] <-> *(x[k] + j)
e, portanto, se x[k][j] é por exemplo do tipo float, x[k] é um apontador para float. Consultando novamente o exemplo da figura 2.9, significa isto que x[0] corresponde a um apontador
para float com o valor 1001 (e portanto o endereço do primeiro elemento do vector de floats
formado pelos reais 1.0 e 2.0, enquanto que x[2] corresponde também a um apontador, cujo
valor é 1003 (primeiro elemento do vector de floats formado pelos reais 3.0 e 4.0).
Considere-se agora, novamente, a questão de qual o tipo de x, quando considerado isoladamente. Como já se disse anteriormente em C, uma estrutura multidimensional representa sempre
5
A leitura desta secção pode ser omitida numa abordagem introdutória da linguagem C.
M ATRIZES 35
uma hierarquia de vectores simples. Ou seja, x[3][2] representa um vector de três elementos,
em que cada um é por sua vez um vector de dois elementos. Assim, x[k] representa sempre um
vector de dois floats. Com esta formulação, resulta claro que x representa um apontador para
um vector de dois floats. Isto não é mais do que a generalização da situação dos vectores, em que
dada a declaração int a[N], se sabe que a isoladamente é um apontador para inteiro.
Assim, é natural que x sem ı́ndices especifique o endereço do primeiro elemento de um vector
de três elementos, em que cada um é um vector de dois floats. Ou seja, no nosso exemplo, x
corresponde ao valor 1001.
Mas, afinal, qual a diferença entre um apontador para float e um apontador para um vector de floats? Por um lado, a forma de usar este tipo variável num endereçamento indirecto
é claramente diferente. Por exemplo, x e &x[0][0] correspondem ao mesmo valor (1001 no
nosso exemplo), mas são tipos diferentes: o primeiro é um apontador para um vector, pelo que *x
é um vector (a primeira linha de x, enquanto que *(&x[0][0]) é um float (o conteúdo de
x[0][0]. No entanto, é provavelmente mais importante reter que a diferença fundamental reside
na dimensão do objecto apontado. Considere-se novamente o exemplo que temos vindo a considerar. Uma variável do tipo apontador para float, cujo valor seja 1001, quando incrementada
passa a ter o valor 1002. Mas uma variável do tipo apontador para vector de dois float, cujo
valor seja 1001, quando incrementada passa a ter o valor 1003 (já que o incremento é escalado
pela dimensão do objecto apontado).
É interessante verificar que este modelo é o único que permite manter a consistência sintáctica
do C na equivalência entre vectores e apontadores. Já se disse que sendo sempre
x[k] <-> *(x + k)
então,
x[k][j] <-> *(x[k] + j)
O que não se disse antes, mas que também se verifica, é que a primeira destas regras também deve
ser aplicável à entidade x[k] que surge na última expressão. Ou seja, tem-se
x[k][j] <-> *(*(x+k) + j)
o que exige por outro lado estabelecer que
36 A PONTADORES
Expressão
Tipo
Valor
x
Apontador para vector
de dois floats
Apontador para vector
de dois floats
Apontador para float
Apontador para float
float
Apontador para float
float
float
Apontador para float
1001
Elemento da
estrutura
-
1003
-
1003
1006
6.0
1001
1.0
2.0
1003
x[2][1]
x[0][0]
x[0][1]
-
x+1
*(x+1)
*(x+2)+1
*(*(x+2)+1)
*x
**x
*(*x+1)
x[1]
Tabela 2.1: Exemplos de tipos e valores derivados do exemplo da figura 2.9.
• Se a é um apontador para um escalar, *a é desse tipo escalar, e o valor de *a é o conteúdo
do endereço especificado por a.
• Se a é um apontador para um vector de elementos de um dado tipo, *a é um apontador para
um elemento do tipo constituinte, e o seu valor é idêntico ao de a.
Um factor que contribui frequentemente para alguma confusão deriva do facto de que, ainda
que x não seja sintacticamente um duplo apontador para float, sendo
x[0][0] <-> *(*(x+0) + 0) <-> **x
verifica-se que **x é um float.
A consistência destas equivalências podem ser verificada considerando casos particulares do
exemplo que tem vindo a ser utilizado como referência, tal como listados na tabela 2.1. O código
de um pequeno programa que permite validar esta tabela está listado no apêndice A.
Como é natural, é possı́vel declarar um apontador para um vector de dois floats, sem ser
da forma implı́cita que resulta da declaração da matriz. A declaração de uma variável y deste tipo
pode ser feita por
float (*y)[2];
Por este motivo, que quando a matriz x é passada por argumento para uma função func, a
M ATRIZES 37
declaração do parâmetro formal poder ser feita repetindo a declaração total, omitindo a dimensão
do ı́ndice interior, ou então por
void func(float (*y)[2]);
tal como se referiu na secção 2.6.2.
Dado que este tipo de declarações é alvo frequente de confusão, é conveniente saber que existe
uma regra de leitura que ajuda a clarificar a semântica da declaração. Com efeito, é suficiente
“seguir” as regras de precedência, procedendo à leitura na seguinte sequência:
float (*y)[3]
yé
um apontador
para um vector de três
floats
Sublinhe-se que, face a tudo o que ficou dito anteriormente, não é possı́vel declarar um apontador para um vector sem especificar a dimensão do vector: como já foi dito por diversas vezes,
um apontador tem que conhecer a dimensão do objecto apontado. Isto não é possı́vel sem especificar a dimensão do vector. Como corolário, um apontador para um vector de três inteiros é de
tipo distinto de um apontador para um vector para seis inteiros, não podendo os seus conteúdos
ser mutuamente atribuı́dos.
Note-se que a declaração que se acabou de referir é claramente distinta de
float *y[2];
onde, devido à ausência de parenteses, é necessário ter em atenção a precedência de “[]” sobre o
“*”. Neste caso, y é um vector de dois apontadores para float, podendo a leitura da declaração
ser realizada pela sequência:
float *y[3]
yé
um vector de três
apontadores
para float
A utilização de vectores de apontadores é abordada em maior detalhe na secção 3.5.4.
Finalmente, refira-se que os apontadores para vectores podem ainda surgir noutros contextos:
dada a declaração int x[10], x é um apontador para inteiro, mas a expressão &x é do tipo
38 A PONTADORES
apontador para um vector de 10 inteiros.
2.7
Generalização para mais do que duas dimensões
A generalização do que ficou dito para mais do que duas dimensões é directa. Considere-se,
como referência, a declaração da estrutura
int x[M][N][L];
1. No cálculo do endereço de qualquer elemento da estrutura tem-se a igualdade:
&(x[m][n][l]) == (&x[0][0][0]) + m * (N*L) + n * L + l
2. x[k][j] é um apontador para inteiro.
3. x[k] é um apontador para um vector de L inteiros.
4. x é um apontador para uma matriz de N×L inteiros.
5. Em geral,
x[m][n][l] == *(*(*(x+m)+n)+l)
A passagem de uma estrutura multidimensional como argumento pode ser feita pela repetição
da declaração completa do tipo. Assim, uma declaração possı́vel é
#define M ...
#define N ...
#define L ...
void func(int x[M][N][L]){
/* ... */
}
int main(){
int x[M][N][L];
/*...*/
func(x);
/*...*/
return 0;
}
G ENERALIZAÇ ÃO PARA MAIS DO QUE DUAS DIMENS ÕES 39
Como se mostrou anteriormente, o cálculo do endereço de um elemento genérico de uma estrutura tridimensional exige o conhecimento das duas dimensões exteriores da estrutura (N e L no
exemplo). A generalização desta regra mostra que para calcular o endereço de um elemento de
uma estrutura n-dimensional é necessário conhecer com rigor os n − 1 ı́ndices exteriores da estrutura. Deste modo, nos argumentos formais de uma função é sempre possı́vel omitir a dimensão
do primeiro ı́ndice de uma estrutura multidimensional, mas não mais do que esse. No exemplo
anterior, pode então escrever-se
void func(int x[][N][L]){
/* ... */
}
Claro que todas as outras variantes em que exista consistência sintáctica entre os argumentos
formais e actuais do procedimento são válidas. Assim, pelas mesmas razões já detalhadas na
secção 2.6.4,
void func(int (*x)[N][L]){
/* ... */
}
é uma alternativa sintacticamente correcta neste caso.
Capı́tulo 3
Vectores e memória dinâmica
3.1
Introdução
Até ao aparecimento da linguagem C, a maioria das linguagens de alto nı́vel exigia um dimensionamento rı́gido das variáveis envolvidas. Por outras palavras, a quantidade máxima de memória
necessária durante a execução do programa deveria ser definida na altura de compilação do programa. Sempre que os requisitos de memória ultrapassavam o limite fixado durante a execução
do programa, este deveria gerar um erro. A solução nestes casos era recompilar o programa aumentando a dimensão das variáveis, solução só possı́vel se o utilizador tivesse acesso e dominasse
os detalhes do código fonte. Por exemplo, um programa destinado à simulação de um circuito
electrónico poderia ser obrigado a definir no código fonte o número máximo de componentes
do circuito. Caso este número fosse ultrapassado, o programa deveria gerar um erro, porque a
memória reservada durante a compilação tinha sido ultrapassada. A alternativa nestas situações
era a reserva à partida de uma dimensão de memória suficiente para acomodar circuitos de dimensão elevada, mas tal traduzia-se obviamente num consumo excessivo de memória sempre que
o programa fosse utilizado para simular circuitos de dimensão reduzida. Em alguns casos, os programas recorriam à utilização de ficheiros para guardar informação temporária, mas esta solução
implicava geralmente uma complexidade algorı́tmica acrescida.
Tal como o nome indica, os sistemas de memória dinâmica permitem gerir de forma dinâmica
os requisitos de memória de um dado programa durante a sua execução. Por exemplo, no caso
do sistema de simulação referido anteriormente, o programa pode, no inı́cio da execução, determinar a dimensão do circuito a simular e só nessa altura reservar a memória necessária. Com
esta metodologia, o programa pode minimizar a quantidade de memória reservada e, deste modo,
permitir que o sistema operativo optimize a distribuição de memória pelos vários programas que
42 V ECTORES E MEM ÓRIA DIN ÂMICA
se encontram simultaneamente em execução. No entanto, até ao aparecimento do C este tipo
de mecanismos, caso estivessem disponı́veis no sistema operativo, eram apenas acessı́veis em
linguagem máquina ou Assembler, mais uma vez pela necessidade de manipular directamente
endereços de memória.
Apesar da flexibilidade oferecida, a gestão directa da memória dinâmica exige algumas
precauções suplementares durante o desenvolvimento do código. Por este motivo, muitas linguagens de programação de alto-nı́vel mais recentes (Lisp, Java, Scheme, Python, Perl, Mathematica,
entre outras) efectuam a gestão automática da memória dinâmica, permitindo assim que o programador se concentre na implementação algorı́tmica e nos modelos de dados associados, sem se
preocupar explicitamente com os problemas de dimensionamento de variáveis ou os volumes de
memória necessários para armazenamento de dados.
Neste contexto, poderá perguntar-se quais as vantagens de programar em C ou porque é que
o C ainda mantém a popularidade em tantas áreas de aplicação. Há diversas respostas para esta
questão:
• A gestão directa da memória dinâmica permite normalmente a construção de programas
com maior eficiência e com menores recursos computacionais.
• Dada a evidente complexidade dos compiladores de linguagens mais elaboradas, é frequente
microprocessadores e controladores especializados (processadores digitais de sinal, microcontroladores, etc) apenas disporem de compiladores para a linguagem C, a qual se encontra
mais próximo da linguagem máquina do que linguagens conceptualmente mais elaboradas.
Por este motivo, o C ainda se reveste hoje de uma importância crucial em diversas áreas da
Engenharia Electrotécnica, nomeadamente em aplicações que implicam o recurso a microcontroladores especializados.
• Dada a sua proximidade com o hardware1 , a maioria dos sistemas operativos actualmente
existentes são ainda hoje programados na linguagem C.
• A maioria dos compiladores de linguagens de alto nı́vel, incluindo o próprio C, são actualmente escritos e desenvolvidos em C. Ou seja, nesta perspectiva, o C é hoje uma linguagem
indispensável à geração da maioria das outras linguagens, constituindo, neste sentido, a
“linguagem das linguagens”.
1
O C é frequentemente designado na gı́ria como um Assembler de alto nı́vel, apesar do evidente paradoxo contido
nesta designação.
V ECTORES 43
3.2
Vectores
Em C, um vector é uma colecção com um número bem definido de elementos do mesmo tipo.
Ao encontrar a declaração de um vector num programa, o compilador reserva automaticamente
espaço em memória para todos os seus elementos. Por razões de clareza e de boa prática de
programação, estas constantes são normalmente declaradas de forma simbólica através de uma
directiva define, mas a dimensão continua obviamente a ser uma constante:
#define DIM_MAX 100
#define DIM_MAX_STRING 200
int
char
x[DIM_MAX];
s[DIM_MAX_STRING];
Por outras palavras, a necessidade de saber quanta memória é necessária para o vector que se
pretende utilizar implica que a dimensão deste vector seja uma constante, cujo valor já conhecido
durante a compilação do programa.
Como é sabido, as variáveis locais a uma função têm um tempo de vida limitado à execução
da função2 . O espaço para estas variáveis é reservado na chamada zona da pilha (stack), uma
região de memória dedicada pelo programa para armazenar variáveis locais e que normalmente é
ocupada por ordem inversa. Por outras palavras, são usados primeiros os endereços mais elevados
e vão sendo sucessivamente ocupados os endereços inferiores à medida que são reservadas mais
variáveis locais. Dada a forma como se realiza esta ocupação de memória, o limite inferior da
região da pilha é geralmente designada por topo da pilha. Neste sentido, quando uma função
é chamada, o espaço para as variáveis locais é reservado no topo da pilha, que assim decresce.
Quando uma função termina, todas as variáveis locais são libertadas e o topo da pilha aumenta
novamente. Assim, por exemplo, dada a declaração
int function(int z){
int a;
int x[5];
int b;
/* ... */
a chamada da função func poderia dar origem a uma evolução do preenchimento da memória da
forma como se representa na figura 3.1.
2
Excepto se a sua declaração for precedido do atributo static, mas este só é usado em circunstâncias excepcionais.
44 V ECTORES E MEM ÓRIA DIN ÂMICA
Endereço
..
.
Conteúdo
Variável
..
.
..
.
Endereço
..
.
Conteúdo
Variável
..
.
..
.
1001
1001
topo da pilha
1002
1002
b
1003
1003
x[0]
1004
1004
x[1]
1005
1005
x[2]
1006
1006
x[3]
1007
1007
a
1008
1008
z
1009
1009
...
..
.
..
.
topo da pilha
A
..
.
..
.
B
Figura 3.1: Mapa de memória antes e após a chamada à função func (A) e mapa de memória
durante a execução da função (B).
A necessidade da dimensão dos vectores ser conhecida na altura da compilação conduz à
impossibilidade de declarações do tipo
int function(int n){
int x[n]; /* MAL: n é uma variável, e o seu valor é desconhecido
na altura da compilação */
/* ... */
Como é evidente, os elementos de um vector podem não ser escalares simples como no exemplo anterior. Os elementos de um vector podem ser estruturas de dados, como por exemplo
em
#define MAX_NOME
200
#define MAX_ALUNOS 500
typedef struct _tipoAluno {
int numero;
char nome[MAX_NOME];
} tipoAluno;
V ECTORES 45
int main(){
tipoAluno alunos[MAX_ALUNOS];
/* ... */
ou mesmo outro vector. Com efeito, na declaração
int mat[10][5]
a matriz mat, do ponto de vista interno do C, não é mais do que um vector de 10 elementos, em
que cada elemento é por sua vez um vector de 5 inteiro (V. secção 2.6.3).
A necessidade de definir de forma rı́gida a dimensão dos vectores na altura da compilação
obriga frequentemente ao estabelecimento de um compromisso entre a dimensão máxima e a
eficiência do programa em termos da memória utilizada. Uma solução possı́vel é utilizar um
majorante dos valores “tı́picos”. Considere-se novamente um sistema para simulação de circuitos
electrónicos. Se os sistemas que se pretende simular têm em média 1000 componentes, poderse-ia utilizar um valor de 2000 ou 5000 para dimensionar o vector de componentes. No entanto,
a utilização de um valor máximo elevado pode conduzir a situações frequentes de desperdı́cio
de memória, com o programa a reservar à partida volumes de memória muito superiores aos
necessários, enquanto que um valor reduzido deste parâmetro pode limitar seriamente a dimensão
dos problemas a abordar. Por outro lado, é por vezes difı́cil encontrar parâmetros razoáveis para
definir “valor médio”. Em determinadas aplicações, 50 componentes pode ser um valor razoável,
noutras 10,000 pode ser um valor reduzido. Para agravar a situação, a dimensão de memória que
é razoável reservar à partida depende da memória fı́sica disponı́vel no sistema em que se está a
trabalhar: sistemas com 8MB ou 8GB de memória conduzem obviamente a situações distintas.
Não se pretende com esta análise colocar de parte todas as utilizações de vectores convencionais com dimensão fixa. Frequentemente, esta é uma solução mais que razoável. Por exemplo,
uma linha de texto numa consola de texto tem em geral cerca de 80 caracteres, pelo que a definição
de um valor máximo para o comprimento de uma linha de 160 ou 200 caracteres é um valor que
pode ser razoável e que não é excessivo na maioria das aplicações. No entanto, em situações
em que o número de elementos pode variar significativamente, é desejável que a memória seja
reservada à medida das necessidades.
46 V ECTORES E MEM ÓRIA DIN ÂMICA
3.3
“Vectores” dinâmicos
Uma forma de ultrapassar a dificuldade criada pelo dimensionamento de vectores é a reserva
de blocos de memória só ser realizada durante a execução do programa. Considere-se novamente
o simulador de circuitos electrónicos. A ideia essencial é iniciar o programa com um volume de
memória mı́nimo, determinar qual o número de componentes do sistema a simular e só depois
deste passo reservar espaço para manipular o número de componentes pretendido.
Recorde-se que ao declarar um vector
int x[MAX_DIM]
a utilização do nome do vector sem especificar o ı́ndice é equivalente ao endereço do ı́ndice 0 do
vector. Por outras palavras, a utilização no programa de x é equivalente a &x[0].Decorre também
daqui a regra geral de indexação em C:
x[k] <-> *(x + k)
equivalência que é universal em C.
A utilização de um bloco de memória criado dinamicamente e que pode ser utilizado com
mecanismos de acesso idênticos aos de um vector pode ser realizado declarando uma variável
de tipo de apontador e solicitando ao sistema operativo a reserva de um bloco de memória da
dimensão pretendida. O pedido de reserva de memória ao sistema operativo é efectuado através
de um conjunto de funções declaradas no ficheiro stdlib.h. Uma das funções utilizáveis para
este efeito tem como protótipo
void *calloc(size_t nmemb, size_t size);
A função calloc reserva um bloco de memória contı́gua com espaço suficiente para armazenar
nmemb elementos de dimensão size cada, devolvendo o endereço (apontador) para a primeira
posição do bloco. size_t é o tipo utilizado para especificação de dimensões numéricas em várias
funções do C e a sua implementação pode ser dependente do sistema operativo, mas corresponde
geralmente a um inteiro sem sinal. O tipo de retorno, void*, corresponde a um endereço genérico
de memória. o que permite que a função seja utilizada independentemente do tipo especı́fico do
apontador que se pretende inicializar. A função retorna um apontador para o primeiro endereço de
“V ECTORES ” DIN ÂMICOS 47
uma região de memória livre da dimensão solicitada. Caso esta reserva não seja possı́vel, a função
retorna NULL, para indicar que a reserva não foi efectuada.
Considere-se, como exemplo, um programa para cálculo da média e variância de N valores
reais indicados pelo programador. Em vez de utilizar um vector de dimensão fixa, é possı́vel
utilizar apenas um apontador para um real e um programa com a seguinte estrutura:
/* Ficheiro: media.c
Conteúdo: Programa para cálculo da média e variânicia
Autor:
Fernando M. Silva, IST ([email protected])
História : 2001/06/01 - Criação
*/
#include <stdio.h>
#include <stdlib.h>
int main(){
int n;
/* Número de valores */
float *f; /* Apontador para o primeiro valor */
float soma,media,variancia;
int k;
printf("Indique quantos valores pretende utilizar: ");
scanf("%d",&n);
f = (float*) calloc(n,sizeof(float));
/* reserva de memória
para um bloco de n
reais */
if(f == NULL){
/* Teste da reserva de memória */
fprintf(stderr,"Erro na reserva de memória\n");
exit(1);
}
/* A partir deste ponto, f pode ser tratado como um
vector de n posições */
for( k = 0 ; k < n ; k++){
printf("Indique o valor ı́ndice %d : ",k);
scanf("%f",&f[k]);
}
soma = 0.0;
for( k = 0 ; k < n ; k++)
soma += f[k];
media = soma / n;
/* cálculo da média */
soma = 0.0;
48 V ECTORES E MEM ÓRIA DIN ÂMICA
for( k = 0 ; k < n ; k++)
soma += (f[k] - media) * (f[k] - media);
variancia = soma / n;
/* cálculo da var. */
printf("Média = %5.2f, var = %5.2f\n",
media, variancia);
free(f);
exit(0);
}
Na chamada à função calloc(), dois aspectos devem ser considerados. Em primeiro lugar o operador sizeof() é um operador intrı́nseco do C que devolve a dimensão (geralmente em bytes)
do tipo ou variável indicado no argumento. Por outro lado, à esquerda da função calloc() foi
acrescentada e expressão (float*). Esta expressão funciona como um operador de cast, obrigando à conversão do apontador genérico devolvido pela função calloc para um apontador para
real. Em geral, na operação de cast é indicado o tipo do apontador que se encontra do lado esquerdo da atribuição. Embora este cast não seja obrigatório, é geralmente utilizado na linguagem
C como uma garantia adicional da consistência das atribuições.
Após a reserva dinâmica de memória, o apontador f pode ser tratado como um vector convencional. Como é evidente, o ı́ndice não deve ultrapassar a posição n − 1, dado que para valores
superiores se estaria a aceder a posições de memória inválidas.
Enquanto que as variáveis locais são criadas na zona da pilha, toda a memória reservada
dinamicamente é geralmente criada numa região independente de memória designada por heap
(molhe). Admita-se, no caso do programa para cálculo das médias e variâncias, que o valor de n
especificado pelo utilizador era 4. Uma possı́vel representação do mapa de memória antes e após
a chamada da função calloc encontra-se representado na figura 3.2.
3.4
Gestão da memória dinâmica
3.4.1
Verificação da reserva de memória
Como já se referiu, ao efectuar uma reserva dinâmica de memória é essencial testar se os recursos solicitados foram ou não disponibilizados pelo sistema. Com efeito, a reserva de memória
pode ser mal sucedida por falta de recursos disponı́veis no sistema. Este teste teste e deve ser sempre efectuado testando o apontador devolvido pela função calloc(). Quando existe um erro,
a função devolve o endereço 0 de memória, o qual é representado simbolicamente pela constante
G EST ÃO DA MEM ÓRIA DIN ÂMICA 49
Zona da pilha (var. locais)
Endereço
..
.
Conteúdo
Zona do heap (var. dinamicas)
Variável
Endereço
..
.
..
.
..
.
...
1001
k
2102
1003
variancia
2103
1004
media
2104
1005
soma
2105
1006
f
2106
n
2107
...
2108
4
1008
1009
..
.
Variável
..
.
..
.
...
2101
1002
1007
Conteúdo
topo
...
2109
..
.
..
.
..
.
A
Zona da pilha (var. locais)
Endereço
..
.
Conteúdo
..
.
Zona do heap (var. dinamicas)
Variável
Endereço
..
.
..
.
...
1001
Conteúdo
Variável
..
.
..
.
...
2101
1002
k
2102
*f
1003
variancia
2103
*(f+1) <−> f[1]
1004
media
2104
*(f+2) <−> f[2]
1005
soma
2105
*(f+3) <−> f[3]
f
2106
n
2107
...
2108
1006
1007
2102
4
1008
1009
..
.
<−>f[0]
topo
...
2109
..
.
..
.
..
.
B
Figura 3.2: Mapa de memória antes da reserva de memória (A) e após a reserva de memória. (B).
50 V ECTORES E MEM ÓRIA DIN ÂMICA
NULL (definida em stdio.h). Se o endereço devolvido tiver qualquer outro valor, a reserva de
memória foi bem sucedida, e o programa pode continuar a sua execução normal.
3.4.2
Outras funções de gestão de memória dinâmica
Função free()
Enquanto que a função calloc() efectua uma reserva dinâmica de memória, a função
free(p) liberta o bloco de memória apontado por p. Como seria de esperar, esta função só
tem significado se o apontador p foi obtido por uma função prévia de reserva de memória, como
a função calloc() descrita anteriormente.
De um modo geral, é boa prática de programação libertar toda a memória reservada pelo
programa sempre que esta deixa de ser necessária. Isto sucede frequentemente pela necessidade
de libertar memória que só foi necessária temporariamente pelo programa.
Note-se que sempre que o programa termina toda a memória reservada é automaticamente libertada. Deste modo, a libertação explı́cita da memória reservada durante a execução do programa
não é estritamente obrigatória antes de sair do programa através da função exit() ou da directiva return no bloco main(). Apesar deste facto, alguns autores consideram que, por razões
de consistência e arrumação do código, o programa deve proceder à libertação de toda a memória
reservada antes de ser concluı́do. É este o procedimento adoptado no programa para cálculo da
variância, onde a função free() é chamada antes do programa terminar.
Função malloc()
A função malloc() tem por protótipo
void *malloc(size_t total_size);
onde total_size representa a dimensão total da memória a reservar, expressa em bytes. A
função malloc() é muito semelhante à função calloc() A forma calloc(n,d) pode ser
simplesmente substituı́da por malloc(n*d). A única diferença formal entre as duas funções é
que enquanto a função calloc() devolve um bloco de memória inicializado com zero em todas
as posições, a função malloc() não efectua explicitamente esta inicialização.
G EST ÃO DA MEM ÓRIA DIN ÂMICA 51
Função realloc()
A função realloc() pode ser utilizada sempre que é necessário modificar a dimensão de
um bloco de memória dinâmica reservado anteriormente. O protótipo da função é
void *realloc(void *old_ptr,size_t total_new_size);
onde old_ptr é o apontador para o bloco de memória reservado anteriormente, enquanto que
total_new_size é a dimensão total que se pretende agora para o mesmo bloco. A função
retorna um apontador para o bloco de memória redimensionado. Note-se que o segundo argumento
tem um significado semelhante ao da função malloc.
Suponha-se, por exemplo, que no inı́cio de um programa tinha sido reservado um bloco de
memória para a n inteiros:
int main(){
int *x,n;
/* Obtenção do valor de n ... */
x = (int*) calloc(n,sizeof(int));
mas que, mais tarde, se verificou a necessidade de acrescentar 1000 posições a este bloco de
memória. Este resultado pode ser obtido fazendo
x = (int*) realloc(x,(n+1000)*sizeof(int));
O funcionamento exacto da função realloc() depende das disponibilidades de memória
existentes. Suponha-se, para assentar ideias, que inicialmente se tinha n=2000, e que o valor de x
resultante da função calloc era 10000 (figura 3.3, A). Deste modo o bloco de memória reservado
inicialmente estendia-se do endereço 10000 ao endereço 11999. Quando mais tarde é chamada a
função realloc duas situações podem ocorrer. Se os endereços de memória entre 12000 e 12999
estiverem livres, o bloco de memória é prolongado, sem mudança de sı́tio, pelo que o valor de x
permanece inalterado (figura 3.3, B). No entanto, se algum endereço entre 12000 e 12999 estiver
ocupado (figura 3.3, C), é necessário deslocar todo o bloco de memória para uma nova região.
Neste caso, o novo bloco de memória pode ser mapeado, por exemplo, entre os endereços 12500
e 15499 (figura 3.3, D), retornando a função realloc o novo endereço da primeira posição de
memória (12500). Como complemento, a função realloc trata de copiar automaticamente todo
52 V ECTORES E MEM ÓRIA DIN ÂMICA
o conteúdo guardado nos endereços 10000 a 11999 para os endereços 12500 a 14499 e liberta as
posições de memória iniciais.
3.4.3
Garbbage
Como referido anteriormente, o espaço de memória para as variáveis locais é reservado na
zona de memória da pilha quando uma função é chamada, sendo automaticamente libertado (e
com ele as variáveis locais) quando a função termina.
Ao contrário das variáveis locais, a memória dinâmica é criada e libertada sob controlo do
programa, através das chamadas às funções calloc(), malloc() e free().
Em programas complexos, é frequente um programa reservar blocos de memória que, por
erro do programador ou pela própria estrutura do programa, não são libertados mas para os quais
se perdem todos os apontadores disponı́veis. Neste caso, o bloco de memória dinâmica é reservado, mas deixa de ser acessı́vel porque se perderam todos os apontadores que indicavam a sua
localização em memória. Estes blocos de memória reservados mas cuja localização se perdeu são
geralmente designados por “garbbage” (lixo).
Considere-se, por exemplo, o seguinte, programa:
void func(){
int *x,y;
y = 3;
x = (int*) calloc(y,sizeof(int));
/* ... Utilização de x, free()
não chamado...
*/
}
void main()
int a,b;
func();
/* O Bloco reservado em func deixou de ser
usado, mas deixou de estar inacessı́vel */
Um mapa de memória ilustrativo desta situação está representado na figura 3.4.
G EST ÃO DA MEM ÓRIA DIN ÂMICA 53
Zona da pilha (var. locais)
Zona da pilha (var. locais)
Endereço
Conteúdo
..
.
Variável
Endereço
..
.
..
.
2000
1003
10000
Conteúdo
..
.
...
1001
1002
10000
n
10001
11999
1006
12000
1007
12001
...
..
.
...
Endereço
..
.
Conteúdo
Variável
Endereço
..
.
..
.
1001
2000
..
.
1003
10000
1765
1005
n
1008
12003
1009
..
.
..
.
...
12999
..
.
Endereço
..
.
...
1001
1002
Conteúdo
..
.
2000
1003
10000
Conteúdo
..
.
10000
666
10001
−300
x
1004
2000
1003
12500
..
.
..
.
n
..
.
...
10001
−300
..
.
11999
1005
1009
1006
12000
1007
12001
11111111
12002
−555555
...
1009
..
.
C
12001
11111111
12002
−555555
...
Memoria
libertada
..
.
..
.
Memoria ocupada
por outras var. dinamicas
12500
666
12501
−300
..
.
14599
..
.
12003
..
.
1765
...
12000
1008
..
.
Variável
..
.
..
.
666
x
1004
1765
Conteúdo
10000
Variável
1007
11999
..
.
Endereço
...
1002
..
.
1005
1008
Zona do heap (var. dinamicas)
Variável
1006
n
..
.
B
Zona do heap (var. dinamicas)
..
.
1765
..
.
1001
..
.
...
12000
..
.
..
.
Variável
..
.
..
.
Zona da pilha (var. locais)
..
.
−300
1007
Endereço
Conteúdo
666
10001
11999
A
Endereço
10000
1006
12002
Variável
..
.
x
1004
Memoria livre
..
.
Zona da pilha (var. locais)
Conteúdo
..
.
...
1002
−300
1005
1009
..
.
666
x
1008
Variável
..
.
1004
..
.
Zona do heap (var. dinamicas)
Zona do heap (var. dinamicas)
..
.
Memoria
"realocada"
..
.
..
.
D
Figura 3.3: Mapa de memória após a chamada à função calloc() (A) e após a função
realloc() (B), se existir espaço disponı́vel nos endereços contı́guos. Em (C) e (D) representase a situação correspondente no caso em que a memória dinâmica imediatamente a seguir ao bloco
reservado está ocupada, sendo necessário deslocar todo o bloco para outra zona de memória. Neste
caso, o apontador retornado pela função é diferente do inicial.
54 V ECTORES E MEM ÓRIA DIN ÂMICA
Zona da pilha (var. locais)
Endereço
..
.
Conteúdo
Zona do heap (var. dinamicas)
Variável
Endereço
..
.
..
.
..
.
...
1001
Conteúdo
Zona da pilha (var. locais)
Variável
Endereço
..
.
..
.
..
.
...
2101
Conteúdo
Zona do heap (var. dinamicas)
Variável
..
.
..
.
..
.
...
1001
2102
1003
2103
1003
1004
2104
1004
2105
1005
1006
b
2106
1007
a
2107
topo
1006
b
2106
1007
a
2107
1008
2108
1009
2109
..
.
..
.
..
.
1002
...
2102
..
.
...
2102
x[0]
2103
x[1]
y
2104
x[2]
2105
2108
1009
2109
..
.
Variável
..
.
x
1008
..
.
Conteúdo
2101
topo
1002
1005
topo
Endereço
..
.
..
.
A
topo
...
..
.
B
Zona da pilha (var. locais)
Endereço
..
.
Conteúdo
Zona do heap (var. dinamicas)
Variável
Endereço
..
.
..
.
..
.
...
1001
Conteúdo
Variável
..
.
..
.
...
2101
1002
2102
x[0]
1003
2103
x[1]
1004
2104
x[2]
1005
topo
2105
1006
b
2106
1007
a
2107
1008
2108
1009
2109
..
.
..
.
..
.
topo
...
..
.
C
Figura 3.4: Criação de “garbbage” (lixo). Mapa de memória (exemplo do texto): (A) antes da
chamada à função func, (B) no final da função func e (C) após retorno ao programa principal. De notar que em (C) a memória dinâmica ficou reservada, mas que se perderam todas as
referências que permitiam o acesso a esta zona de memória.
C RIAÇ ÃO DIN ÂMICA DE MATRIZES 55
3.5
3.5.1
Criação dinâmica de matrizes
Introdução
A criação e utilização de estruturas dinâmicas em C que possam ter um acesso equivalente
a uma matriz é simples. No entanto, a compreensão detalhada de como funcionam este tipo de
estruturas nem sempre é claro.
De modo a simplificar a exposição, começar-se-á por apresentar na secção 3.5.2 um resumo do
modo de declaração e de acesso de matrizes estáticas. Seguidamente, na secção 3.5.3, descreve-se
uma metodologia simples para criar dinamicamente vectores de apontadores com um comportamento equivalente ao de uma matriz. Seguidamente, na secção 3.5.4 serão discutidas as diferenças
e semelhanças entre matrizes e vectores de apontadores, de modo a evidenciar as diferenças entre
as estruturas dinâmicas criadas e as matrizes nativas do C.
3.5.2
Matrizes estáticas
Conforme já se viu na secção 2.6.1, a utilização de estruturas multidimensionais em C apenas exige a declaração de uma variável com vários ı́ndices, especificando cada um da dimensão
pretendida da estrutura. Por exemplo
float x[3][2];
declara uma estrutura bidimensional de dois por três reais, ocupando um total de seis palavras
de memória no modelo de memória que temos vindo a assumir como referência. É frequente
uma estrutura bidimensional ser interpretada como uma matriz, neste exemplo de duas linhas por
três colunas. A inicialização de uma matriz pode ser efectuada durante a execução do programa
ou listando os valores iniciais, sendo apenas necessário fazer um agrupamento hierárquico das
constantes de inicialização de acordo com as dimensões da estrutura:
float x[3][2] = {{1.0,2.0},{3.0,4.0},
{5.0,6.0}};
Como se mostrou anteriormente, a arrumação em memória desta estrutura encontra-se representada esquematicamente na figura 2.9.
56 V ECTORES E MEM ÓRIA DIN ÂMICA
3.5.3
Matrizes dinâmicas
Admita-se que num dado programa se pretende substituir a sequência
#define N ...
#define M ...
...
int x[N][M];
por uma estrutura dinâmica com um mecanismo de acesso equivalente, mas em que os limites n e
m são variáveis cujo valor só é conhecido durante a execução do programa.
A solução para este problema é substituir a estrutura x por um vector dinâmico de apontadores
para inteiros, em que cada posição é por sua vez inicializada com um vector dinâmico. Considerese, por exemplo, que se pretende criar uma matriz de n por m. O código para este efeito é dado
por
int main{
int k;
int n,m;
int **x;
/* Inicialização de n e m */
x = (int**) calloc(n,sizeof(int*));
if(x == NULL){
printf("Erro na reserva de memória\n");
exit(1);
}
for(k = 0; k < n ; k++){
x[k] = (int*) calloc(m,sizeof(int));
if(x == NULL){
printf("Erro na reserva de memória\n");
exit(1);
}
}
Por exemplo, se se pretender criar uma matriz de 4 por 2 e inicializá-la com um valores inteiros
em que a classe das dezenas corresponde à linha e a classe dos algarismos às unidades, poder-se-ia
fazer
C RIAÇ ÃO DIN ÂMICA DE MATRIZES 57
int main{
int k,j;
int n,m;
int **x;
n = 4; m = 3;
x = (int**) calloc(n,sizeof(int*));
if(x == NULL){
printf("Erro na reserva de memória\n");
exit(1);
}
for(k = 0; k < n ; k++){
x[k] = (int*) calloc(m,sizeof(int));
if(x == NULL){
printf("Erro na reserva de memória\n");
exit(1);
}
}
for(k = 0; k < n ; k++)
for(j = 0; j < m ; j++)
x[k][j] = 10 * k + j;
O resultado da execução deste bloco de código seria o que se mostra na figura 3.5.
Este exemplo indica que a solução geral para simular a criação dinâmica de matrizes é declarar
um duplo apontador para o tipo pretendido dos elementos da matriz e, seguidamente, reservar
dinâmicamente um vector de apontadores e criar dinamicamente os vectores correspondentes a
cada linha.
Como é evidente, neste exemplo x é um duplo apontador para inteiro. Deste modo, se pretender usar esta “matriz dinâmica” como argumento de uma função fazer-se, no bloco que chama,
func(x,n,m,/* Outros argumentos */);
enquanto que o cabeçalho de func deverá ser
void func(int **x,int n,int m,/* Outros argumentos */);
58 V ECTORES E MEM ÓRIA DIN ÂMICA
Zona da pilha
Zona do heap
Endereço
Conteúdo
Variável
..
.
..
.
..
.
1543
2001
..
.
..
.
x
..
.
Endereço
..
.
Conteúdo
Variável
..
.
..
.
2001
2006
x[0]
2002
2009
x[1]
2003
2012
x[2]
2004
2015
x[3]
..
.
..
.
..
.
Endereço
Conteúdo
Variável
2006
0
x[0][0]
2007
1
x[0][1]
2009
10
x[1][0]
2010
11
x[1][1]
2012
20
x[2][0]
2013
21
x[2][1]
2015
30
x[3][0]
2016
31
x[3][1]
Figura 3.5: Criação dinâmica de matrizes por meio de um vector de apontadores. Os endereços
indicados são apenas ilustrativos.
Esta solução é tão frequente na prática que, por analogia, muitos programadores (mesmo
experientes) de C pensam que, dada a declaração tipo x[N][M], x sem qualquer ı́ndice corresponde a um duplo apontador para tipo. Como se mostrou na secção 2.6.4, esta suposição não
corresponde à realidade: nesta situação, x é um apontador para um vector de elementos de tipo.
3.5.4
Vectores de apontadores e matrizes
Considere-se a declaração
int *x[4];
Recordando que os [] têm precedência sobre o operador *, e seguindo a regra de interpretação
semântica da declaração apresentada na secção 2.6.4, resulta que x é um vector de 4 apontadores
para inteiros. Ou seja, em termos de modelo de memória, encontramos uma representação como a
indicada na figura 3.6, situação A. Um ponto importante a sublinhar é que esta declaração apenas
reserva memória para quatro apontadores. Esta situação é frequentemente mal interpretada pelo
facto de, sendo x[k] uma apontador para um inteiro, então *x[k] e *(x[k]+j) também são
do tipo inteiro. Mas, devido às equivalências sintácticas de endereçamento em C, tem-se
C RIAÇ ÃO DIN ÂMICA DE MATRIZES 59
Endereço
Conteúdo
Variável
..
.
..
.
..
.
1001
???
x[0]
1002
???
x[1]
1003
???
x[2]
1004
???
x[3]
..
.
..
.
..
.
A
Endereço
..
.
Conteúdo
Variável
Endereço
..
.
2001
1
x[0][0]
2002
2
x[0][1]
2005
3
x[1][0]
2006
4
x[1][1]
2010
5
x[2][0]
2011
6
x[2][1]
2013
7
x[3][0]
2014
8
x[3][1]
..
.
1001
2001
x[0]
1002
2005
x[1]
1003
2010
x[2]
1004
2013
x[3]
..
.
..
.
..
.
Conteúdo
Variável
B
Figura 3.6: Vectores de apontadores: A - Antes de inicializado, B- após a inicialização.
60 V ECTORES E MEM ÓRIA DIN ÂMICA
*x[k]
<-> x[k][0]
*(x[k]+j) <-> x[k][j]
e, portanto, x[k][j] é um inteiro. O que conduz, por vezes, ao raciocı́nio errado de que um
vector de apontadores pode ser utilizado indiscriminadamente como se de uma matriz se tratasse.
Ora, de facto, tal só é possı́vel se os elementos de x tiverem sido convenientemente inicializados
de modo a endereçarem a base de um vector de inteiros. Isto, só por si, não é realizado pela
declaração int *x[4], que se limita a declarar um vector de apontadores e a reservar 4 palavras
de memória para este efeito. Nesta declaração, não é reservada memória para qualquer inteiro.
Claro que, se o utilizador o pretender, é fácil inicializar as posições de memória do vector
de apontadores reservando memória dinâmica de modo a armazenar os inteiros pretendido. Admitindo que se pretende que o vector de apontadores anteriores sirva de base a uma estrutura
de endereçamento equivalente a uma matriz de 4 por 2, esta inicialização pode ser feita pela
sequência
int k,j;
int *x[4];
for(k = 0; k < 4; k++)
x[k] = (int*) calloc(2,sizeof(int));
for(k = 0; k < 4; k++)
for(j = 0; j < 2; j++)
x[k][j] = k*2 + j + 1;
onde, além da reserva das posições de memória para os vectores inteiros, se procedeu à sua
inicialização. A situação final pode ser representada pelo modelo da figura 3.6, situação B.
Em sı́ntese, é conveniente reter que, apesar da manipulação de matrizes e de vectores de
apontadores seja sintacticamente semelhante, os mecanismos de reserva de memória são claramente distintos. No caso da matriz int x[4][2], a própria declaração reserva automaticamente espaço para oito inteiros, os quais podem ser acedidos pelas regras habituais. No caso da
declaração de um vector de apontadores, como por exemplo int *x[4], apenas é efectuada a
reserva de quatro apontadores. A sua utilização requer a prévia inicialização de cada um dos seus
elementos com um endereço válido de um bloco de memória, o qual pode ser obtido a partir de
um vector já declarado ou, como no exemplo aqui usado, pela reserva de um bloco de memória
dinâmica através da função calloc().
Capı́tulo 4
Listas dinâmicas
4.1
Introdução
Como se viu anteriormente, a utilização de vectores em C apresenta a desvantagem de exigir o
conhecimento à partida da dimensão máxima do vector. Em muitas situações, a utilização de vectores dinâmicos, como descrito na secção 3.3, é uma solução eficiente. No entanto, em situações
em que a quantidade de memória dinâmica necessária varia frequentemente durante a execução do
programa, a utilização de vectores dinâmicos revela-se frequentemente pouco eficaz.
Considere-se, por exemplo, um gestor de uma central telefónica que necessita de reservar
dinamicamente memória para cada chamada estabelecida (onde é armazenada toda a informação
sobre a ligação, como por exemplo os números de origem e destino, tempo de ligação, etc,) e
libertar essa mesma memória quando a chamada é terminada. Dado que o número de ligações
activas varia frequentemente ao longo do dia, uma solução baseada num vector dinâmico exigiria o seu redimensionamento frequente. Em particular, sempre que não fosse possı́vel encontrar
memória livre imediatamente a seguir ao fim do vector, seria necessário deslocar todo o vector (v.
função realloc, secção 3.4.2) para uma nova zona de memória, e a cópia exaustiva de todo o
seu conteúdo para a nova posição de memória. Este mecanismo, além de pouco eficiente, poderia
inviabilizar o funcionamento em tempo real do sistema, dado que uma percentagem significativa
do tempo disponı́vel seria dispendido na gestão da memória usada pelo vector.
Quando se pretende armazenar entidades individuais de memória que possam ser criadas e
libertadas individualmente com uma certa frequência, é normalmente mais adequado utilizar estruturas de dados criadas dinâmicas e organizadas em listas.
62 L ISTAS DIN ÂMICAS
4.2
Abstracção de dados
Neste capı́tulo descrevem-se listas dinâmicas como uma forma de armazenar ou organizar
informação. Esta organização é, obviamente, independente do tipo de informação que se pretende
armazenar. Esta situação é semelhante à da construção de um armário com gavetas: a estrutura
do móvel é independente do conteúdo que se irá arrumar nas gavetas. Esta independência entre
móvel e conteúdo garante a generalidade do móvel, no sentido em que este será adaptável a várias
situações.
De modo semelhante, ao desenhar um programa é importante que as suas componentes sejam
o mais independentes possı́veis, de forma a manterem a generalidade. Tanto quanto possı́vel, é
desejável que um bloco de código desenvolvido para o programa A seja reutilizável no programa
B ou, pelo menos, que tal seja possı́vel sem alterações ou com alterações mı́nimas.
Uma forma de atingir este objectivo é, ao desenhar o programa, adoptar uma metodologia
designada por abstracção de dados(Martins, 1989). Esta metodologia baseia-se na definição e
distinção clara dos vários tipos de dados utilizados pelo programa (ou, seguindo o exemplo anterior, distinguir tanto quanto possı́vel o móvel do conteúdo das gavetas). Segundo esta metodologia,
cada tipo de dados deve ter manipulado apenas por um conjunto de funções especı́ficas, designadas
métodos, conhecedoras dos detalhes internos do tipo de dados associado. Todos os outros blocos
do programa têm apenas que conhecer as propriedades abstractas ou genéricas deste tipo. A
manipulação do tipo só são acessı́veis de outros blocos de programa através dos métodos disponibilizados pelo tipo de dados.
Esta metodologia de programação garante que, caso seja necessário alterar os detalhes internos de um determinado tipo, apenas é necessário alterar os métodos correspondentes a esse tipo.
Dado que todos os blocos de programas onde este tipo de dados é utilizado apenas acedem a ele
através dos métodos disponı́veis, requerendo apenas o conhecimento das suas propriedades gerais
(ou abstractas), é possı́vel minimizar ou eliminar totalmente as alterações necessárias aos outros blocos de programa. Deste modo, a metodologia de abstracção de dados contribui para uma
relativa estanquecidade e independência dos diversos módulos constituintes.
Neste capı́tulo, utilizar-se-á nos diversos exemplos uma metodologia estrita de abstracção de
dados, chamando-se a atenção caso a caso para aspectos especı́ficos da implementação.
L ISTAS 63
Zona da pilha (var. locais)
Variável
Conteúdo
..
.
base
Zona do heap (var. dinamicas)
Conteúdo
..
.
dados
(pos 1)
dados
(pos 2)
dados
(pos 3)
dados
(pos 4)
NULL
Figura 4.1: Lista dinâmica. Todos os elementos são criados dinamicamente na zona de heap,
sendo suficiente uma variável local de tipo apontador (variável base, nas figura) para poder aceder
a todos os elementos da lista.
4.3
Listas
Uma lista dinâmica não é mais do que uma colecção de estruturas de dados, criadas dinamicamente, em que cada elemento dispõe de um apontador para o elemento seguinte (figura 4.1). Cada
elemento da lista é constituı́do por uma zona de armazenamento de dados e de um apontador para
o próximo elemento. Para ser possı́vel identificar o fim da lista, no apontador do último elemento
é colocado o valor NULL.
Para criar uma lista é necessário definir um tipo de dados e criar uma variável local (normalmente designada base ou raiz. Admita-se, por exemplo, que se pretendia que cada elemento da
lista guardasse uma string com um nome e um número inteiro. A criação de uma lista de elementos
deste tipo exigiria uma declaração de tipos e de variáveis como se segue:
#define MAX_NOME 200
64 L ISTAS DIN ÂMICAS
typedef struct _tipoDados {
char nome[MAX_NOME];
int num;
} _tipoDados;
typedef struct _tipoLista{
tipoDados dados;
struct _tipoLista *seg;
} tipoLista;
int main(){
tipoLista *base;
/* ... */
Note-se que na definição do apontador seguinte, é necessário utilizar a construção
struct _tipoLista *seg e não tipoLista *seg. De facto, apesar de serem duas formas aparentemente semelhantes, a forma
typedef struct _tipoLista{
tipoDados dados;
tipoLista *seg; /* DECLARAÇÂO INVÁLIDA */
} tipoLista;
é inválida porque tipoLista só é conhecido no final da declaração, e não pode ser utilizado
antes de completamente especificado.
Uma das vantagens deste tipo de estruturas é que cada um dos seus elementos pode ser reservado e libertado individualmente, sem afectar os restantes elementos (exigindo apenas ajustar um
ou dois apontadores, de forma a garantir a consistência do conjunto). Adicionalmente, a ordem
dos elementos da lista é apenas definida pela organização dos apontadores. Deste modo, se for
necessário introduzir um elemento entre dois já existentes, não é necessário deslocar todos os elementos de uma posição, como sucederia num vector. Basta de facto reservar espaço para um
elemento adicional e deslocar todos os outros elementos de uma posição. Na figura 4.2 apresentase um exemplo em que se ilustra esta independência entre endereços de memória e sequência da
lista, e confere a esta uma flexibilidade superior à de um vector.
Dada esta independência entre endereços de memória e sequência, é frequente adoptar-se
representações gráficas simplificadas para as listas, como a se mostra na figura 4.3. A cruz no
último elemento corresponde a uma representação simbólica abreviada para o valor NULL.
L ISTAS 65
Zona da pilha (var. locais)
Variável
Conteúdo
..
.
base
Zona do heap (var. dinamicas)
Conteúdo
..
.
dados
(pos 2)
dados
(pos 1)
dados
(pos 4)
NULL
dados
(pos 3)
Figura 4.2: Lista dinâmica. A sequência dos elementos da lista é apenas definida pelos apontadores
de cada elemento, independentemente do endereço de memória ocupado.
base
Figura 4.3: Lista dinâmica. Representação simplificada
66 L ISTAS DIN ÂMICAS
Apesar das vantagens já referidas de uma lista, é necessário ter em atenção que o programa só
dispõe de uma variável para aceder a toda a lista, e que esta tem que ser percorrida elemento a elemento até se atingir a posição pretendida. Num vector, dado que todos as posições são adjacentes,
para aceder a qualquer posição basta saber o endereço do primeiro elemento e o número de ordem
(ı́ndice) do elemento que se pretende aceder para ser possı́vel calcular o endereço do elemento que
se pretende. Ou seja, a flexibilidade acrescida da lista em termos de organização de memória é
conseguida à custa do tempo de acesso aos seus elementos.
4.4
Listas dinâmicas: listar elementos
Uma operação particularmente simples sobre uma lista, mas ilustrativa do mecanismos de
acesso geralmente adoptados, é a listagem de todos os seus elementos. Considere-se, por exemplo,
que se pretende listar no terminal o conteúdo de todos os números e nomes da lista do exemplo da
secção 4.3. Uma solução para este efeito seria a sequência de código seguinte:
#define MAX_NOME 200
typedef struct _tipoDados {
char nome[MAX_NOME];
int num;
} tipoDados;
typedef struct _tipoLista{
tipoDados dados;
struct _tipoLista *seg;
} tipoLista;
void escreveDados(tipoDados dados){
printf("Num = %5d Nome = %s\n",dados.num, dados.nome);
}
void lista(tipoLista *base){
tipoLista *aux; /* Apontador que percorre a lista */
aux = base;
/* Incializa com o primeiro elemento
while(aux != NULL){ /* Enquanto não se atingir o final...
escreveDados(aux -> dados); /* Escreve o conteúdo
aux = aux -> seg; /* Avança para a próxima posição
}
}
int main(){
*/
*/
*/
*/
L ISTAS DIN ÂMICAS :
LISTAR ELEMENTOS
tipoLista *base;
/* Neste ponto, o apontador base e os restantes
elementos da lista são, de alguma forma,
inicializados */
/* Listagem do conteÚdo */
lista(base);
O princı́pio essencial da operação de listagem está incluı́da na função lista e é muito simples. Inicializa-se um apontador aux para o inı́cio da lista. Seguidamente, enquanto este apontador for diferente de NULL (ou seja, não atingir o fim da lista), o apontador é sucessivamente
avançado para o elemento seguinte.
Note-se como se adoptou neste bloco de código a metodologia de abstracção de dados. Existe neste exemplo uma separação funcional de tarefas entre as diversas entidades que participam o programa. A função lista manipula unicamente a estrutura de dados que suporta a
lista, percorrendo todos os seus elementos até ao fim da lista. No entanto, quando é necessário
escrever o conteúdo de cada nó no terminal, esta tarefa é delegada a uma função especı́fica (
escreveDados), responsável pela manipulação e processamento dos detalhes especı́ficos de
variáveis do tipo tipoDados. Neste sentido, tipoDados é, para a lista, um tipo abstracto
genérico, cuja representação interna ela desconhece, e da qual só tem que conhecer uma propriedade muito genérica: uma variável deste tipo pode de alguma forma ser escrita no terminal,
havendo um método competente para o fazer.
Uma forma alternativa da função listar frequentemente utilizada passa pela utilização de um
ciclo for em vez do ciclo while:
void lista(tipoLista *base){
tipoLista *aux; /* Apont. que percorre */
/* a lista.
*/
for(aux = base ; aux != NULL; aux = aux -> seg)
escreveDados(aux -> dados);
}
67
68 L ISTAS DIN ÂMICAS
4.5
4.5.1
Listas: pilhas
Introdução
As listas têm habitualmente uma estrutura e organização em memória semelhante à apresentada na figura 4.1. No entanto, os mecanismos de acesso à lista são variáveis e, no seu conjunto,
podem permitir que a lista realize apenas um subconjunto de tarefas bem determinadas.
Em teoria da computação, uma pilha (ou stack, na lietratura anglo-saxónica) é um sistema
que armazena dados de forma sequencial e que permite a sua leitura por ordem inversa da escrita.
A designação pilha vem da semelhança funcional com uma pilha comum. Admita-se que se
dispõe de um conjunto de palavras que se pretende armazenar. Considere-se ainda que o sistema
de armazenamento disponı́vel é um conjunto de pratos, sendo cada palavra manuscrita no fundo
do prato. À medida que as palavras vão sendo comunicadas ao sistema de armazenamento, são
escritas no fundo de um prato e este é colocado numa pilha. A leitura posterior das palavras
registadas é feita desempilhando pratos sucessivamente e lendo o valor registado em cada um.
Como é óbvio, a leitura ocorre por ordem inversa da escrita.
Uma pilha é frequentemente designada por uma estrutura LIFO, acrónimo derivado da expressão Last In First Out. A operação de armazenamento na pilha é geralmente designada de
operação de push, enquanto que a leitura é designada de pop.
As pilhas têm uma utilização frequente em informática sempre que se pretende inverter uma
sequência de dados. Embora a relação não seja óbvia, pode indicar-se, por exemplo, que o cálculo
de uma expressão aritmética em que vários operadores têm nı́veis de precedência diferentes é
realizada acumulando numa pilha resultados intermédios.
Considere-se, por exemplo, que se pretende inverter os caracteres da palavra Luı́s. A forma
como uma pilha pode contribuir para este resultado está graficamente sugerido na figura 4.4.
Uma pilha pode ser realizada em C através de uma lista dinâmica ligada. A colocação de
um novo elemento no topo da pilha corresponde a criar um novo elemento para a lista e inseri-lo
junto à base. De forma correspondente, a operação de remoção e leitura corresponde a retirar o
elemento da base, ficando esta a apontar para o elemento seguinte (v. figura 4.5)
L ISTAS :
L
L
u
í
s
s
s
s
í
í
í
í
u
u
u
u
u
u
L
L
L
L
L
L
Entrada (push)
(sobreposição)
í
u
PILHAS
L
L
Saida (pop)
(remoção)
Figura 4.4: Inversão de uma sequência de caracteres por meio de uma pilha.
base
’u’
’L’
’í’
Figura 4.5: Realização de uma pilha por uma lista ligada. No exemplo, considera-se uma pilha de
caracteres e a inserção do caracter ’ı́’ no topo da pilha. A tracejado indica-se a ligação existente
antes da inserção, a cheio após a inserção. A operação de remoção corresponde à realização
do mecanismo inverso (reposição da ligação a tracejado e libertação do elemento de memória
correspondente ao ’ı́’).
69
70 L ISTAS DIN ÂMICAS
4.5.2
Declaração
Tal como qualquer lista, a realização de uma pilha implica a declaração de um tipo de suporte
da lista e a utilização de uma variável para a base da pilha. A declaração da pilha pode ser feita,
por exemplo, pela declaração
typedef struct _tipoPilha {
tipoDados dados;
struct _tipoPilha *seg;
} tipoPilha;
onde se admite que tipoDados já foi definido e caracteriza o tipo de informação registada em
cada posição da pilha.
A utilização da pilha implica que no programa principal, ou no bloco de código onde se
pretende utilizar a pilha, seja declarada uma variável de tipo apontador para tipoPilha que
registe a base da pilha. Ou seja, será necessário dispor de uma variável declarada, por exemplo,
por
tipoPilha *pilha;
4.5.3
Inicialização
A primeira operação necessária para utilizar a pilha é inicializá-la ou criá-la. Uma pilha vazia
deve ter a sua base apontada para NULL. Embora seja possı́vel realizar directamente uma atribuição
à variável pilha, tratando-se de um detalhe interno de manipulação do tipo tipoPilha, esta
inicialização deverá ser delegada numa função especı́fica. Deste modo, para respeitar a metodologia de abstracção de dados, deve declarar-se uma pequena função
tipoPilha *inicializa(){
return NULL;
}
e utilizar-se esta função sempre que seja necessário inicializar a pilha:
pilha = inicializa();
L ISTAS :
4.5.4
PILHAS
Sobreposição
A operação de sobreposição exige a seguinte sequência de operações:
• Reserva de memória dinâmica para armazenar um novo elemento.
• Cópia dos dados para o elemento criado.
• Inserção do novo elemento na base da pilha.
Para a criação da memória dinâmica, é conveniente criar uma função novoNo() que cria
espaço para um novo nó, verificando a simultaneamente a existência de erros na reserva de
memória. Esta função pode ser definida como
tipoPilha *novoNo(tipoDados x){
tipoPilha *novo = calloc(1,sizeof(tipoPilha));
if(novo == NULL)
Erro("Erro na reserva de memória");
novo -> dados = x;
novo -> seg = NULL;
return novo;
}
onde Erro() é uma função genérica simples que imprime uma mensagem de erro e termina o
programa (ver secção 4.5.7).
A operação de inserção no topo da pilha exige a realização das operações representadas na
figura 4.5. Estas podem ser realizada pela seguinte função:
tipoPilha *sobrepoe(tipoPilha *pilha,tipoDados x){
tipoPilha *novo;
novo = novoNo(x);
novo -> seg = pilha;
return novo;
}
Esta função, após a criação do novo elemento, limita-se a colocar o apontador seg a apontar para o elemento que anteriormente se encontrava no topo, e indicar à base que o novo topo
corresponde ao novo elemento inserido.
71
72 L ISTAS DIN ÂMICAS
Note-se que esta função aceita como argumento o apontador original para o inı́cio da lista
(topo da pilha) e devolve a nova base alterada.
4.5.5
Remoção
A operação de remoção da pilha é acompanhada da leitura. Tal como anteriormente a função
recebe o apontador para a base da lista e devolve a base alterada. Dado que a remoção é acompanhada de leitura, a função recebe um segundo apontador, passado por referência, que deve ser
usado para guardar o valor do elemento removido. Uma função possı́vel para a realização desta
operação é
tipoPilha *retira(tipoPilha *pilha,tipoDados *x){
tipoPilha *aux;
if(pilhaVazia(pilha))
Erro("remoção de uma pilha vazia");
*x = pilha -> dados;
aux = pilha -> seg;
free(pilha);
return aux;
}
A operação de remoção é precedida de um teste para verificação de pilha vazia, para garantia
de integridade da memória (v. secção 4.5.6). Note-se que após a leitura da pilha, o bloco de
memória utilizado é libertado pela função free().
4.5.6
Teste
Antes de ser tentada a remoção de um elemento da pilha, é conveniente testar se a pilha se
encontra vazia. Numa primeira abordagem, poder-se-ia ser tentado a testar, sempre que necessário,
se o apontador da base se encontra com o valor NULL. Esta realização, ainda que correcta, violaria
o princı́pio essencial de abstracção de dados que temos vindo a respeitar: a caracterização como
vazia de uma pilha é um detalhe interno do tipoPilha, e como tal deve ser delegado numa
função especı́fica, de tipo booleano. Deste modo, poderia fazer-se
int pilhaVazia(tipoPilha *pilha){
return (pilha == NULL);
}
L ISTAS :
PILHAS
Esta função retorna 1 se a pilha estiver vazia, e 0 em caso contrário.
4.5.7
Exemplo
Suponha-se que se pretende implementar um sistema de inversão de caracteres baseado na
pilha definida anteriormente. Uma das vantagens de utilizar uma pilha para este efeito consiste
no facto de não ser necessário definir à partida a dimensão máxima da cadeia de caracteres: o
programa efectua a reserva e libertação de memória à medida das necessidades do programa.
Neste caso, tipoDados é realizado por um simples caracter. Para respeitar e exemplificar
o princı́pio de abstracção, iremos escrever os métodos especı́ficos de leitura e escrita deste tipo.
De modo a explorar de forma eficaz a independência dos diversos tipos de dados, os métodos
correspondentes a cada tipo devem ser declarados em ficheiros separados. Deste modo, os métodos
de acesso a tipoDados são agrupados num ficheiro dados.c. Os protótipos correspondentes
são declarados no ficheiro dados.h.
Ficheiro dados.h
/*
* Ficheiro: dados.h
* Autor:
Fernando M. Silva
* Data:
7/11/2002
* Conteúdo:
*
Ficheiro com declaração de tipos e
*
protótipos dos métodos para manipulação
*
de um tipoDados, concretizados aqui
*
por um tipo caracter simples.
*/
#ifndef _DADOS_H
#define _DADOS_H
#include <stdio.h>
#include <stdlib.h>
typedef char tipoDados;
/* Métodos de acesso a tipo dados */
int
leDados(tipoDados *x);
void
escreveDados(tipoDados x);
#endif /* _DADOS_H */
73
74 L ISTAS DIN ÂMICAS
Ficheiro dados.c
/*
*
*
*
*
*
*
*
*/
Ficheiro: dados.c
Autor:
Fernando M. Silva
Data:
12/11/2002
Conteúdo:
Métodos para manipulação de um
tipoDados, concretizado por um
caracter simples.
#include "dados.h"
int leDados(tipoDados *x){
/*
Lê um caracter. Devolve 1 se ler um ’\n’ */
*x = getchar();
if(*x == ’\n’)
return 1;
else
return 0;
}
void escreveDados(tipoDados x){
putchar(x);
}
De igual modo, a metodologia de abstracção de dados implementada sugere que todos
os métodos de acesso a tipoPilha sejam agrupados num ficheiro pilha.c, ficando as
declarações e protótipos correspondentes acessı́veis num ficheiro pilha.h.
Deste modo, ter-se-ia
Ficheiro pilha.h
/*
*
*
*
*
*
*
Ficheiro: pilha.h
Autor:
Fernando M. Silva
Data:
7/11/2000
Conteúdo:
Ficheiro com declaração de tipos e
protótipos dos métodos para manipulação
L ISTAS :
*
de uma pilha simples de elementos genéricos
*
de "tipoDados".
*/
#ifndef _PILHA_H
#define _PILHA_H
#include "dados.h"
typedef struct _tipoPilha {
tipoDados dados;
struct _tipoPilha *seg;
} tipoPilha;
/* Protótipos das funções */
tipoPilha *inicializa(void);
tipoPilha *sobrepoe(tipoPilha *pilha,tipoDados x);
tipoPilha *retira(tipoPilha *pilha,tipoDados *x);
int
pilhaVazia(tipoPilha *pilha);
#endif /* _PILHA_H */
Ficheiro pilha.c
/*
* Ficheiro: pilha.c
* Autor:
Fernando M. Silva
* Data:
1/12/2000
* Conteúdo:
*
Métodos para manipulação de uma pilha suportada
*
numa estrutura dinâmica ligada
*/
#include "pilha.h"
#include "util.h"
tipoPilha *novoNo(tipoDados x){
/*
* Cria um novo nó para a pilha
*/
tipoPilha *novo = calloc(1,sizeof(tipoPilha));
if(novo == NULL)
Erro("Erro na reserva de memória");
novo -> dados = x;
novo -> seg = NULL;
return novo;
PILHAS
75
76 L ISTAS DIN ÂMICAS
}
tipoPilha *inicializa(){
/*
* Cria uma nova pilha
* Retorna:
*
Pilha inicializada
*/
return NULL;
}
int pilhaVazia(tipoPilha *pilha){
/*
* Verifica o estado da pilha
* Argumentos:
*
pilha - Apontador para a base da pilha
* Retorna:
*
1 se a pilha estiver vazia
*
0 em caso contrário
*/
return (pilha == NULL);
}
tipoPilha *sobrepoe(tipoPilha *pilha,tipoDados x){
/*
* Adiciona um elemento à pilha
* Argumentos:
*
pilha - Apontador para a base da pilha
*
x
- dados a inserir
* Retorna:
*
Pilha modificada
*/
tipoPilha *novo;
novo = novoNo(x);
novo -> seg = pilha;
return novo;
}
tipoPilha *retira(tipoPilha *pilha,tipoDados *x){
/*
* Retira um elemento da pilha
* Argumentos:
*
pilha - Apontador para a base da pilha
*
x
- Apontador para variável que
*
retorna o valor lido da pilha
* Retorna:
*
Pilha modificada
L ISTAS :
PILHAS
*/
tipoPilha *aux;
if(pilhaVazia(pilha))
Erro("remoção de uma pilha vazia");
*x = pilha -> dados;
aux = pilha -> seg;
free(pilha);
return aux;
}
A função genérica de erro Erro() pode ser declarada num módulo genérico util.c, onde
são agrupadas funções utilitárias genéricas (neste exemplo, Erro() é única). O protótipos correspondente deve ser declarado num módulo util.h.
Ficheiro util.h
/*
* Ficheiro: util.h
* Autor:
Fernando M. Silva
* Data:
7/11/2002
* Conteúdo:
*
Ficheiro com declaração de funções e
*
protótipos genéricos
*/
#ifndef _UTIL_H
#define _UTIL_H
void Erro(char *msgErro);
#endif /* _UTIL_H */
Ficheiro util.c
/*
*
*
*
*
*
*/
Ficheiro:
Autor:
Data:
Conteúdo:
Funções
util.c
Fernando M. Silva
1/11/2002
genéricas
77
78 L ISTAS DIN ÂMICAS
#include <stdio.h>
#include <stdlib.h>
#include "util.h"
void Erro(char *msgErro){
/*
* Termina o programa, escrevendo uma mensagem
* de erro
*/
fprintf(stderr, "Erro: %s\n",msgErro);
exit(1);
}
Considere-se, por último, o programa principal. Este limita-se a inicializar a pilha e a efectuar
a leitura de um sequência de dados, acumulando-os sucessivamente na pilha. A leitura é efectuada
até que a função leDados() (método de tipoDados) identifique uma mudança de linha na
entrada. Seguidamente, os dados são sucessivamente removidos (desempilhados) e escritos no
dispositivo de saı́da, até que a pilha esteja vazia. Deste modo, ter-se-ia:
Ficheiro main.c
/*
* Ficheiro: main.c
* Autor:
Fernando M. Silva
* Data:
7/11/2000
* Conteúdo:
*
Programa principal simples para teste
*
de uma pilha, usada para inversão de
*
uma cadeia de caracteres
*/
#include <stdio.h>
#include <stdlib.h>
#include "pilha.h"
int main(){
tipoPilha *pilha;
tipoDados x;
pilha = inicializa();
printf("Introduza uma sequência de caracteres:\n");
while(!leDados(&x)){
pilha = sobrepoe(pilha,x);
}
L ISTAS :
PILHAS
printf("Sequência invertida:\n");
while(!pilhaVazia(pilha)){
pilha = retira(pilha,&x);
escreveDados(x);
}
printf("\n");
exit(0);
}
A compilação automática de todos os módulos pode ser realizada por meio do utilitário make.
A Makefile correspondente poderia ser escrita como
#
# Ficheiro: Makefile
# Autor:
Fernando M. Silva
# Data:
7/11/2000
# Conteúdo:
#
Makefile para teste de estruturas dinâmicas
#
#
A variável CFLAGS especifica as flags usadas
#
por omissão nas regras de compilação
#
#
A variável SOURCES, que define os ficheiro
#
fonte em C, só e usada para permitir a
#
evocação do utilitário "makedepend" (pelo
#
comando ’make depend’), de modo a actualizar
#
automaticamente as dependências dos ficheiros
#
.o nos ficheiros .h
#
#
A variável OBJECTS define o conjunto dos
#
ficheiros objectos
#
CFLAGS=-g -Wall -ansi -pedantic
SOURCES=main.c pilha.c dados.c util.c
OBJECTS=main.o pilha.o dados.o util.o
#
# Comando de linkagem dos executáveis
#
teste: $(OBJECTS)
gcc -o $@ $(OBJECTS)
#
# A regra ’make depend’ efectua uma actualização da
# makefile, actualizando as listas de dependências dos
79
80 L ISTAS DIN ÂMICAS
# ficheiros .o em .c
#
#
depend::
makedepend $(SOURCES)
#
# Regra clean: ’make clean’ apaga todos os ficheiros
# reconstruı́veis do disco
#
clean::
rm -f *.o a.out *˜ core teste *.bak
# DO NOT DELETE
4.6
4.6.1
Listas: filas
Introdução
Referiu-se anteriormente que uma pilha correspondia a uma estrutura LIFO (Last In, First
Out). De forma semelhante, podemos descrever uma fila como um sistema FIFO (First In, First
Out). O exemplo corrente de funcionamento de uma fila é a vulgar fila de espera. Cada novo
elemento armazenado é colocado no final da fila, enquanto que cada elemento retirado é obtido do
inı́cio da fila (figura 4.6, A).
Uma fila pode ser realizada por meio de uma lista ligada, relativamente à qual são mantidos
não um, mas dois apontadores: um para a base ou inı́cio, por onde são retirados os elementos, e
outro para o fim ou último elemento da lista, que facilita a inserção de novos elementos na lista.1
4.6.2
Declaração
De forma a que seja possı́vel manipular uma única variável do tipoFila, esta é realizada
por uma estrutura de dados que agrupa os dois apontadores, inı́cio e fim. Deste modo, a declaração
da fila pode ser realizada por
1
De facto, o apontador para a base seria suficiente para realizar a fila; no entanto, cada operação de inserção exigiria
percorrer a totalidade da fila para realizar a inserção no final, método que seria pouco eficiente.
L ISTAS :
fim
’L’
’u’
FILAS
’í’
inicio
A
fim
’L’
’u’
’í’
’s’
’í’
’s’
inicio
B
fim
’L’
’u’
inicio
C
Figura 4.6: Realização de uma fila de caracteres com uma lista ligada. A - Estrutura da fila, B Fila em A após a inserção do caracter ’s’ (a tracejado, as ligações eliminadas), C - Fila em B após
a leitura de um caracter (’L’).
typedef struct _tipoLista {
tipoDados dados;
struct _tipoLista *seg;
} tipoLista;
typedef struct _tipoFila {
tipoLista *fim;
tipoLista *inicio;
} tipoFila;
4.6.3
Inicialização
A inicialização da fila vazia corresponde simplesmente a colocar a NULL os dois apontadores
da fila. Deste modo, a inicialização da fila pode ser realizada simplesmente por
void inicializa(tipoFila *fila){
fila -> inicio = NULL;
fila -> fim
= NULL;
}
Note-se que na chamada à função o argumento deve ser efectuada por referência (passando o
endereço da variável tipoFila) de modo a que a variável seja alterável pela função.
81
82 L ISTAS DIN ÂMICAS
4.6.4
Inserção
A inserção de novos elementos corresponde à colocação de elementos no final da fila, tal como
representado graficamente na figura 4.6, caso B.
Tal como no caso da pilha, para a criação da memória dinâmica, é conveniente criar uma
função auxiliar novoNo().
tipoLista *novoNo(tipoDados x){
tipoLista *novo = calloc(1,sizeof(tipoFila));
if(novo == NULL)
Erro("Erro na reserva de memória");
novo -> dados = x;
novo -> seg = NULL;
return novo;
}
A função de inserção propriamente dita pode ser realizada por
void adiciona(tipoFila *fila,tipoDados x){
tipoLista *novo;
novo = novoNo(x);
novo -> dados = x;
if(fila -> fim != NULL){
fila -> fim -> seg = novo;
}
else{
fila -> inicio = novo;
}
fila -> fim = novo;
}
Nesta função é necessário considerar o caso particular em que a fila está vazia e em que,
como tal, o apontador fim está a NULL. Neste caso particular, ambos os apontadores ( inicio
e fim) devem ser colocados ser inicializados com o endereço do novo elemento inserido. No
caso habitual (lista não vazia), basta adicionar o novo elemento ao último da lista e modificar o
apontador fim.
L ISTAS :
4.6.5
FILAS
Remoção
A inserção de novos elementos corresponde à eliminação de elementos presentes no final da
fila, tal como representado graficamente na figura 4.6, caso C.
Esta função pode ser realizada por
void retira(tipoFila *fila,tipoDados *x){
tipoLista *aux;
if(filaVazia(fila))
Erro("Remoção de uma fila vazia");
*x = fila -> inicio -> dados;
aux = fila -> inicio;
fila -> inicio = fila -> inicio -> seg;
if(fila -> inicio == NULL)
fila -> fim = NULL;
free(aux);
}
A função de retira tem também uma estrutura simples. Tal como na função de inserção, é
necessário considerar o caso particular em que a lista tem apenas um elemento, situação em que o
apontador fim deve ser colocado a NULL depois da remoção.
Tal como no caso da pilha, é testada a condição de pilha vazia antes de efectuar a remoção
(ver secção 4.6.6).
4.6.6
Teste
Para manipulação da lista é indispensável dispor de uma função para testar se a fila se encontra
vazia. Para este teste basta verificar qualquer um dos apontadores se encontra a NULL. Este teste
pode ser efectuado pelo código
int filaVazia(tipoFila *fila){
return (fila -> inicio == NULL);
}
83
84 L ISTAS DIN ÂMICAS
4.6.7
Exemplo
Para uma maior semelhança com o exemplo da pilha, utiliza-se também neste exemplo o caso
de uma fila de caracteres. Como seria de esperar, a fila não realiza neste caso uma inversão, mas
apenas um armazenamento temporário da informação, permitindo a sua reprodução pela mesma
ordem de entrada.
Tal como no exemplo da pilha, o código foi distribuı́do por três ficheiros: main.c, fila.c
e dados.c, tendo para os dois últimos sido desenvolvido um ficheiro de protótipos associado.
Dada a semelhança com o exemplo da pilha, apresentam-se aqui apenas o conteúdo
dos vários ficheiros, sem outros comentários. Omite-se aqui o conteúdo dos ficheiros
dados.h, dados.c, util.h e util.c por serem obviamente idênticos aos anteriores.
Ficheiro fila.h
/*
* Ficheiro: fila.h
* Autor:
Fernando M. Silva
* Data:
7/11/2002
* Conteúdo:
*
Ficheiro com declaração de tipos e
*
protótipos dos métodos para manipulação
*
de uma fila simples de elementos genéricos
*
de "tipoDados".
*/
#ifndef _FILA_H
#define _FILA_H
#include "dados.h"
typedef struct _tipoLista {
tipoDados dados;
struct _tipoLista *seg;
} tipoLista;
typedef struct _tipoFila {
tipoLista *fim;
tipoLista *inicio;
} tipoFila;
/* Protótipos das funções */
L ISTAS :
void
void
void
int
inicializa(tipoFila *fila);
adiciona(tipoFila *fila,tipoDados x);
retira(tipoFila *fila,tipoDados *x);
filaVazia(tipoFila *fila);
#endif /* _FILA_H */
Ficheiro fila.c
/*
* Ficheiro: fila.c
* Autor:
Fernando M. Silva
* Data:
1/11/2002
* Conteúdo:
*
Métodos para manipulação de uma fila suportada
*
numa estrutura dinâmica ligada
*/
#include "fila.h"
#include "util.h"
tipoLista *novoNo(tipoDados x){
/*
* Cria um novo nó para a fila
*/
tipoLista *novo = calloc(1,sizeof(tipoFila));
if(novo == NULL)
Erro("Erro na reserva de memória");
novo -> dados = x;
novo -> seg = NULL;
return novo;
}
void inicializa(tipoFila *fila){
/*
* Inicializa uma nova fila
*/
fila -> inicio = NULL;
fila -> fim
= NULL;
}
int filaVazia(tipoFila *fila){
/*
* Verifica o estado da fila
* Argumentos:
FILAS
85
86 L ISTAS DIN ÂMICAS
*
fila - Apontador para a base da fila
* Retorna:
*
1 se a fila estiver vazia
*
0 em caso contrário
*/
return (fila -> inicio == NULL);
}
void adiciona(tipoFila *fila,tipoDados x){
/*
* Adiciona um elemento à fila
* Argumentos:
*
fila - Apontador para a fila
*
x
- dados a inserir
*/
tipoLista *novo;
novo = novoNo(x);
if(fila -> fim != NULL){
fila -> fim -> seg = novo;
}
else{
fila -> inicio = novo;
}
fila -> fim = novo;
}
void retira(tipoFila *fila,tipoDados *x){
/*
* Retira um elemento fila
* Argumentos:
*
fila - Apontador para a fila
*
x
- Apontador para variável que
*
retorna o valor lido da fila
*/
tipoLista *aux;
if(filaVazia(fila))
Erro("Remoção de uma fila vazia");
*x = fila -> inicio -> dados;
aux = fila -> inicio;
fila -> inicio = fila -> inicio -> seg;
if(fila -> inicio == NULL)
fila -> fim = NULL;
free(aux);
}
L ISTAS ORDENADAS 87
Ficheiro main.c
/*
* Ficheiro: main.c
* Autor:
Fernando M. Silva
* Data:
7/11/2000
* Conteúdo:
*
Programa principal simples para teste
*
de uma fila.
*/
#include <stdio.h>
#include <stdlib.h>
#include "fila.h"
int main(){
tipoFila fila;
tipoDados x;
inicializa(&fila);
printf("Introduza uma sequência de caracteres:\n");
while(!leDados(&x)){
adiciona(&fila,x);
}
printf("Sequência lida:\n");
while(!filaVazia(&fila)){
retira(&fila,&x);
escreveDados(x);
}
printf("\n");
exit(0);
}
4.7
4.7.1
Listas ordenadas
Introdução
Um vector pode ser ordenado segundo uma qualquer relação de ordem utilizando um algoritmo apropriado(Knuth, 1973) (selection sort, bubble-sort, quicksort ou qualquer outro). Embora
estes algoritmos estejam bem estudados e sejam frequentemente necessários, todos eles requerem
um esforço computacional significativo. É possı́vel provar que a quantidade de trabalho necessária
para esta tarefa não é apenas proporcional ao número de elementos a ordenar: a complexidade da
88 L ISTAS DIN ÂMICAS
tarefa aumenta significativamente mais que a dimensão do vector. Para compreender melhor a
quantidade de trabalho exigido, podemos comparar esta situação ao de arrumar alfabeticamente
uma biblioteca a partir de uma situação desorganizada. Se arrumar uma biblioteca com 5,000
livros demorar um dia, arrumar uma biblioteca com 10,000 livros não demorará apenas dois dias,
mas sim três ou quatro, já que cada tı́tulo individual terá que ser alfabeticamente comparado com
um número muito maior de outros livros.
No caso de uma biblioteca, se se pretender evitar a tarefa hercúlea de ordenar todos os livros,
o melhor é mantê-la sempre ordenada. Desta forma, cada novo livro adicionado terá apenas que
ser colocado no local certo, tarefa obviamente muito mais rápida.
Em C, quando os elementos a ordenar se encontram armazenados num vector, o método anterior corresponde basicamente a encontrar o local de inserção, deslocar todos os elementos posteriores de uma posição, e inserir o elemento na posição assim aberta. Embora o algoritmo seja
simples, a tarefa de deslocar todos os elementos de uma posição é computacionalmente pesada,
sobretudo quando a inserção se efectua nas posições iniciais. O paralelo que podemos encontrar
no exemplo da biblioteca é o de uma estante repleta de livros, excepto na última prateleira. A
introdução ordenada de um novo tı́tulo na primeira prateleira exige deslocar todos os tı́tulos de
uma posição. Dado que só existe espaço disponı́vel na última prateleira, este processo pode exigir
de facto movimentar livros em todas as prateleiras da estante.
As listas dinâmicas oferecem uma forma simples de criar e manter eficientemente uma
colecção de objectos ordenados. Ao contrário do vector, em que todas as posições se encontram em endereços de memória contı́guos, a ordem dos elementos da lista não depende do seu
endereço de memória, mas apenas da ordem definida pela sequência de apontadores, tal como já
se mostrou 4.2.
As operações necessárias para a manipulação e manutenção de uma lista ordenada são
generalizações relativamente simples dos casos da pilha e da fila. Algumas destas operações,
como criar a lista ou listar os seus elementos, já foram apresentadas anteriormente. Apenas algumas das operações descritas seguidamente, como a inserção, procura e remoção no meio da lista
são de facto totalmente novas.
4.7.2
Declaração e inicialização
A declaração e inicialização de uma lista segue os passos já vistos para o caso da pilha. A
declaração da lista pode ser realizada por
typedef struct _tipoLista {
L ISTAS ORDENADAS 89
tipoDados dados;
struct _tipoLista *seg;
} tipoLista;
onde, tal como nos exemplos anteriores, tipoDados é um tipo abstracto que define a informação
armazenada em cada elemento.
De igual modo, a inicialização da lista corresponde apenas à inicialização do apontador a
NULL, por meio da função de inicialização
tipoLista *inicializa(){
return NULL;
}
4.7.3
Listagem ordenada
A listagem ordenada corresponde apenas a uma listagem convencional do conteúdo. Deste
modo, a listagem pode ser feita por
void listar(tipoLista *base ){
tipoLista *aux = base;
while(aux){
printf(" -> ");
escreveDados(aux -> dados);
printf("\n");
aux = aux -> seg;
}
}
4.7.4
Procura
A procura é um procedimento relativamente simples. Basta percorrer a lista até encontrar o
elemento que se procura, e retornar um apontador para o respectivo elemento. Convenciona-se
que a função de procura deve retornar NULL caso o elemento a procurar não exista.
Para assentar ideias, admita-se temporariamente que se está a lidar com uma lista de inteiros.
Deste modo, presume-se que se definiu previamente
typedef int tipoDados;
90 L ISTAS DIN ÂMICAS
Neste caso, a função de procura pode ser realizada por
tipoLista *procura(tipoLista *base,tipoDados x){
tipoLista *aux;
aux = base;
while((aux!=NULL) &&
aux = aux -> seg;
return aux;
aux -> dados != x)
}
A função anterior, embora funcione, apresenta o inconveniente de não explorar o facto da lista
estar ordenada. Por exemplo, no caso de uma lista ordenada de inteiros onde estejam todos os
valores pares entre 10 e 10000, se se procurar o número 15 será necessário ir até ao fim da lista
para concluir que o número não está presente. Como é óbvio, esta conclusão poderia ter sido tirada
muito mais cedo, logo que fosse atingido o número 16 na lista.
Deste modo, uma versão mais optimizada da função de procura pode ser escrita como
tipoLista *procuraOrdenado(tipoLista *base,tipoDados x){
tipoLista *aux = base;
while((aux!=NULL) && (aux -> dados<x))
aux = aux -> seg;
if((aux != NULL) && (aux -> dados == x))
return aux;
else
return NULL;
}
Neste caso, a lista é percorrida enquanto o elemento a procurar for inferior à posição actual
da lista. Quando o ciclo é interrompido, é testado se se se atingiu o fim da lista ou se se encontrou
o elemento procurado.
Nesta função, vale a pena considerar a estrutura do teste
if((aux != NULL) &&
...
}
(aux -> dados
== x)){
e verificar a forma como se toma partido do modo como o C avalia expressões lógicas. Repare-se
que o segundo operando da disjunção ( aux -> dados == x) só pode ser avaliado se aux
L ISTAS ORDENADAS 91
for diferente de NULL, já que de outra forma se poderia estar a gerar uma violação de memória
(tentativa de acesso através do endereço 0, o que se encontra fora do controlo do programador).
No entanto, o C garante que realiza a avaliação de expressões lógicas da esquerda para a direita e
que interrompe a sua avaliação assim que for possı́vel determinar univocamente o resultado final.
Neste exemplo, se aux for NULL, o primeiro operando da conjunção lógica ’&&’ é falso, o que
implica que o resultado global da expressão também o é. Assim, não sendo necessário o cálculo
do segundo operando, não há o risco de se produzir a violação de memória decorrente do acesso
através de um apontador NULL.
Refira-se, por último, que num problema prático podem coexistir várias funções de procura,
consoante o que se pretende encontrar. Por exemplo, se os elementos de uma lista são estruturas
que incluem, por exemplo, um número e um nome, podem ser escritas duas funções de procura,
uma para o número e outra para o nome. Claro que se a lista estiver ordenada por números, a
busca pelo número poderá ser optimizada (procura só até encontrar um elemento de número igual
ou superior ao procurado), mas a busca pelo nome terá obviamente que ser exaustiva se o nome
não existir, já que só no final da lista será possı́vel ter a certeza de que determinado nome não faz
parte da lista.
4.7.5
Abstracção de dados e métodos de teste
Nas duas funções de procura anteriores admitiu-se que o tipoDados era um inteiro. Esta
hipótese permitiu utilizar os operadores relacionais == e < de uma forma intuitiva.
No caso mais geral em que tipoDados é um tipo abstracto genérico esta comparação
não pode ser realizada directamente pelos operadores relacionais. Para demonstrar este facto,
considere-se que tipoDados corresponde a uma estrutura com um número e um nome de um
aluno. Neste caso, não faz sentido usar o operador relacional < para comparar dois alunos a e
b. De facto, o C desconhece o que se pretende comparar: é o nome dos alunos a e b ou os seus
números?
Para contornar esta dificuldade, seria possı́vel utilizar o operador ’.’ (membro de estrutura)
e aceder directamente ao campo numérico, utilizando o operador < para a comparação, ou aceder
ao campo com o nome e utilizar a função strcmp() de forma adequada. No entanto, qualquer
destas soluções estaria a violar o princı́pio de abstracção de dados, já que os métodos de procura
da lista, para quem tipoDados deveria ser um tipo abstracto genérico, estariam a aceder directamente a detalhes internos do tipo.
A forma de resolver esta questão é as funções procura delegarem o processo de comparação
92 L ISTAS DIN ÂMICAS
em métodos (funções) especı́ficos internos de tipoDados, responsáveis pela implementação dos
detalhes práticos da comparação. Deste modo, admitindo que foram associados ao tipo abstracto
tipoDados um método com protótipo
int menor(tipoDados a,tipoDados b);
que devolve 1 se a for de algum modo anterior a b e 0 em caso contrário (as funções da lista não
precisam de conhecer os detalhes desta comparação) e um outro
int igual(tipoDados a,tipoDados b);
que devolve 1 se a for igual b segundo um dado critério e 0 em caso contrário.
Deste modo, uma solução mais geral da função de procura ordenada deveria ser escrita como
tipoLista *procuraOrdenado(tipoLista *base,tipoDados x){
tipoLista *aux = base;
while((aux!=NULL) && menor(aux -> dados,x))
aux = aux -> seg;
if((aux != NULL) && igual(aux -> dados ,x))
return aux;
else
return NULL;
}
Alternativamente, é frequente unificar todos os métodos de comparação numa única função
que devolve -1, 0 ou 1 consoante o primeiro elemento é anterior, igual ou posterior ao segundo. É
esta solução que é adoptada na função strcmp() para comparação de strings.
4.7.6
Inserção ordenada
Para realizar uma inserção ordenada é suficiente percorrer a lista até encontrar um elemento
que seja superior ao que se pretende inserir. O novo elemento deverá ser colocado antes deste.
Apesar desta metodologia simples, a inserção ordenada requer algumas considerações suplementares. Um dos pontos a ter em conta é que para posicionar o novo elemento antes do que foi
identificado é necessário alterar o apontador do elemento que se encontra antes deste. Ou seja, ao
L ISTAS ORDENADAS 93
antes
depois
5
novo
10
7
Figura 4.7: Inserção entre dois elementos da lista. O apontador antes não aponta para o elemento
anterior da lista, mas sim para o campo apontador seg desse elemento.
percorrer a lista, é necessário manter uma referência não apenas para o elemento da lista que está
a ser comparado (elemento actual) mas também para o seu predecessor (elemento anterior).
De modo a melhor compreender esta operação, é conveniente começar por desenvolver uma
função auxiliar de inserção em que se admite que a posição de inserção já foi determinada e, como
tal, que já existem referências para o apontador do elemento anterior e para o elemento actual
(v. figura 4.7). Designaremos estas variáveis de referência por antes e depois. Neste caso o
código desta função pode ser escrito
void insere(tipoLista **antes, tipoDados x, tipoLista *depois){
tipoLista *novo = novoNo(x);
*antes = novo;
novo -> seg = depois;
}
onde a função novoNo() é semelhantes às anteriores
tipoLista *novoNo(tipoDados x){
tipoLista *novo = calloc(1,sizeof(tipoLista));
if(novo == NULL)
Erro("Erro na reserva de memória");
novo -> dados = x;
novo -> seg = NULL;
return novo;
}
Uma vez definida esta função, a inserção ordenada apenas tem que ter em conta alguns casos
particulares, nomeadamente a inserção numa fila vazia ou a inserção antes do inı́cio. Nos outros
94 L ISTAS DIN ÂMICAS
casos, basta percorrer a lista com um apontador act, e manter presente que se deve manter um
apontador ant para o elemento anterior. A função insereOrdenado() pode então ser escrita
tipoLista *insereOrdenado(tipoLista *base,tipoDados x){
tipoLista *act,*ant;
if(base == NULL){
insere(&base,x,NULL); /* Lista vazia */
}
else if(menor(x,base -> dados)){
insere(&base,x,base); /* Insere *antes* da base */
}
else{
ant = base; act = base -> seg;
while((act != NULL) && (menor(act -> dados,x))){
ant = act; act = act -> seg;
}
insere(&(ant -> seg),x,act);
}
return base;
}
Esta função recebe como um dos argumentos o valor da base e devolve o valor desta depois
da inserção. A base retornada é idêntica à base inicial, excepto quando a inserção é feita no inı́cio
da lista.
O par de funções insere() e insereOrdenado() foi escrita de modo a sublinhar os
diversos aspectos necessários à inserção numa lista. No entanto, o C permite escrever uma função
de inserção ordenada de modo muito mais compacto:
tipoLista *insereOrdenado(tipoLista *base,tipoDados x){
tipoLista *aux = base, *novo = novoNo(x);
if(aux == NULL || menor(x,aux -> dados)) {
novo -> seg = base;
return novo;
}
aux = base;
while( aux -> seg!= NULL && menor(aux -> seg -> dados,x))
aux = aux -> seg;
novo -> seg = aux -> seg;
aux -> seg = novo;
return base;
L ISTAS ORDENADAS 95
reg
1
4
9
Figura 4.8: Remoção. O apontador reg referencia o campo seg do registo anterior da lista.
}
Aqui, o primeiro if abrange simultaneamente a inserção numa lista vazia e antes do primeiro
elemento. Seguidamente, o ciclo while percorre a lista, mantendo apenas um apontador (equivalente ao apontador ant do exemploanterior).
4.7.7
Remoção
Tal como a inserção, a remoção pode ser simplificada se for escrita uma função auxiliar
libertaReg que é executada quando já é conhecido o local de remoção. Esta função (v. figura
4.8) pode ser escrita
tipoLista *libertaReg(tipoLista *reg){
tipoLista *aux;
aux = reg;
reg = reg -> seg;
free(aux);
return reg;
}
Seguidamente, é necessário escrever uma função que procure o o elemento a apagar e chame
a função anterior. Esta função é dada por
tipoLista *apaga(tipoLista *base,tipoDados x){
tipoLista *aux;
aux = base;
if(base != NULL){
if(igual(base -> dados,x)){
base = libertaReg(base);
}
else{
96 L ISTAS DIN ÂMICAS
aux = base;
while((aux -> seg != NULL) &&
(menor(aux -> seg -> dados,x)))
aux = aux -> seg;
if((aux -> seg != NULL) &&
igual(aux -> seg -> dados,x))
aux -> seg = libertaReg(aux -> seg);
}
}
return base;
}
4.7.8
Exemplo
Como exemplo, apresenta-se aqui o código completo de um programa que manipula uma
lista ordenada de racionais. O tipo racional é representado por dois inteiros, que descrevem o seu
numerador e o denominador. O programa aceita uma sequência de racionais. Um racional positivo
é inserido na lista, enquanto que um racional negativo provoca uma tentativa de remoção do seu
simétrico, caso este exista (ou seja, a indicação de − 35 provoca a remoção do racional 53 ).
Omite-se aqui a listagem dos ficheiros util.h e util.c, idênticos ao introduzido no exemplo da pilha (v. secção 4.5.7).
Ficheiro dados.h
/*
*
*
*
*
*
*
*
*
*
*
*
#
*/
Ficheiro: lista.h
Autor:
Fernando M. Silva
Data:
7/11/2000
Conteúdo:
Definição do tipo genérico
"tipoDados" usado nos exemplos de
estruturas de dados dinâmicas.
Para fins de exemplo, "tipoDados"
é realizado por uma fracção inteira,
a qual e’ especificada por um numerador
e um denominador
#ifndef _DADOS_H
L ISTAS ORDENADAS 97
#define _DADOS_H
#include <stdio.h>
typedef struct{
int numerador;
int denominador;
} tipoDados;
void
tipoDados
float
int
int
int
int
tipoDados
#endif
escreveDados(tipoDados x);
leDados(char mensagem[]);
valor(tipoDados x);
igual(tipoDados x,tipoDados y);
menor(tipoDados x,tipoDados y);
numerador(tipoDados x);
denominador(tipoDados x);
simetrico(tipoDados x);
Ficheiro dados.c
/*
*
*
*
*
*
*
*
*
*
*
*/
Ficheiro: dados .c
Autor:
Fernando M. Silva
Data:
1/12/2000
Conteúdo:
Métodos de acesso exemplificativos
da definição de um tipo abstracto "tipoDados"
Neste exemplo, "tipoDados" implementa um numero
racional, especificado por um numerador e um
denominador
#include "dados.h"
#define DIM_LINE 100
tipoDados leDados(char mensagem[]){
/*
* Escreve a mensagem passada por argumento, lê um racional e
* valida a entrada
* Argumento:
*
mensagem - mensagem a escrever
* Retorna:
*
racional lido
98 L ISTAS DIN ÂMICAS
*/
char line[DIM_LINE];
tipoDados x;
int ni;
do{
printf("%s\n",mensagem);
fgets(line,DIM_LINE,stdin);
ni=sscanf(line,"%d %d",&x.numerador,&x.denominador);
if(ni !=2){
printf("Erro na leitura\n");
}
else{
if(x.denominador == 0)
printf("Racional inválido\n");
}
}while((ni != 2) || (x.denominador == 0));
return x;
}
void
escreveDados(tipoDados x){
/*
* Escreve x em stdout, no formato
* numerador / denominador
* Argumento:
*
x - valor a escrever
*/
printf("%d/%d",x.numerador,x.denominador);
}
float
valor(tipoDados x){
/*
* Retorna um real com o valor (aproximado) do racional x
* Argumento:
*
x - racional
* Retorna
*
valor aproximado de x
*/
return (float) x.numerador / (float) x.denominador;
}
int
menor(tipoDados x,tipoDados y){
/*
* Retorna 1 caso o racional x seja menor que y
* Argumentos:
*
x,y - racionais a comparar
* Retorna
L ISTAS ORDENADAS 99
*
1 se x < y
*
0 em caso contrário
*/
if(x.denominador * y.denominador > 0)
return x.numerador * y.denominador < y.numerador * x.denominador;
else
return x.numerador * y.denominador > y.numerador * x.denominador;
}
int
igual(tipoDados x,tipoDados y){
/*
* Retorna 1 caso o racional x seja igual a y
* Argumentos:
*
x,y - racionais a comparar
* Retorna
*
1 se x = y
*
0 em caso contrário
*/
return x.numerador * y.denominador == x.denominador * y.numerador;
}
int
numerador(tipoDados x){
/*
* Retorna o numerador do racional x
* Argumento:
*
x - racional
* Retorna
*
numerador de x
*/
return x.numerador;
}
int
denominador(tipoDados x){
/*
* Retorna o denominador do racional x
* Argumento:
*
x - racional
* Retorna
*
denominador de x
*/
return x.denominador;
}
tipoDados simetrico(tipoDados x){
/*
* Retorna o simétrico do racional x
* Argumento:
100 L ISTAS DIN ÂMICAS
*
x - racional
* Retorna
*
simétrico de x
*/
tipoDados aux;
aux.numerador
= -x.numerador;
aux.denominador = x.denominador;
return aux;
}
Ficheiro lista.h
/*
* Ficheiro: lista.h
* Autor:
Fernando M. Silva
* Data:
7/11/2000
* Conteúdo:
*
Ficheiro com declaração de tipos e
*
protótipos dos métodos para manipulação
*
de uma lista dinâmica simples
*
*/
#ifndef _LISTA_H
#define _LISTA_H
#include <stdio.h>
#include <stdlib.h>
/*
* Tipo dos dados da lista
*/
#include "dados.h"
/*
* Definição de tipoLista
*/
typedef struct _tipoLista {
tipoDados dados;
struct _tipoLista *seg;
} tipoLista;
/*
* Protótipos dos métodos de acesso
*/
L ISTAS ORDENADAS 101
/* Inicialização */
tipoLista *inicializa(void);
/*
* Procura x na lista iniciada por base, retornando um apontador
* para o registo que contem este valor (ou NULL se não existe)
*/
tipoLista *procura(tipoLista *base,tipoDados x);
tipoLista *procuraOrdenado(tipoLista *base,tipoDados x);
/*
* Insere x antes do registo "depois" e modificando o apontador "antes".
*/
void insere(tipoLista **antes, tipoDados x, tipoLista *depois);
/*
* Insere x na lista ordenada iniciada por base
* Devolve a base, eventualmente alterada
*/
tipoLista *insereOrdenado(tipoLista *base,tipoDados x);
/*
* Lista todos os elementos da estrutura.
*/
void listar(tipoLista *base);
/*
* Liberta da lista o elemento especificado por reg
*/
tipoLista *libertaReg(tipoLista *reg);
/*
* Apaga da lista o registo que contém x
* Retorna a base, eventualmente alterada
*/
tipoLista *apaga(tipoLista *base,tipoDados x);
#endif
Ficheiro lista.c
/*
*
*
*
Ficheiro: lista.c
Autor:
Fernando M. Silva
Data:
7/11/2000
102 L ISTAS DIN ÂMICAS
* Conteúdo:
*
Métodos para manipulação
*
de uma lista dinâmica
*
simples (ordenada)
*
*/
/*
* Inclui ficheiro com tipo e protótipos
*/
#include "lista.h"
#include "util.h"
tipoLista *novoNo(tipoDados x){
/*
* Cria um novo nó da lista
*/
tipoLista *novo = calloc(1,sizeof(tipoLista));
if(novo == NULL)
Erro("Erro na reserva de memória");
novo -> dados = x;
novo -> seg = NULL;
return novo;
}
tipoLista *inicializa(){
/*
* Cria uma nova lista
* Retorna:
*
lista inicializada
*/
return NULL;
}
tipoLista *procura(tipoLista *base,tipoDados x){
/*
* Procura um elemento na lista iniciada por base
* Argumentos:
*
base - apontador para a base da lista
*
x
- elemento a procurar
* Retorna
*
apontador para x (ou NULL caso nao encontre)
*/
tipoLista *aux = base;
while((aux!=NULL) && !igual(aux -> dados,x))
aux = aux -> seg;
return aux;
L ISTAS ORDENADAS 103
}
tipoLista *procuraOrdenado(tipoLista *base,tipoDados x){
/*
* Idêntico ao anterior, mas optimizado para listas
* ordenadas
* Argumentos:
*
base - apontador para a base da lista
*
x
- elemento a procurar
* Retorna
*
apontador para x (ou NULL caso nao encontre)
*/
tipoLista *aux = base;
while((aux!=NULL) && menor(aux -> dados,x))
aux = aux -> seg;
if((aux != NULL) && igual(aux -> dados ,x))
return aux;
else
return NULL;
}
void insere(tipoLista **antes, tipoDados x, tipoLista *depois){
/*
* Insere x entre dois registos
* Argumentos:
*
antes - predecessor na lista
*
x
- elemento a inserir
*
depois - elemento seguinte a x
*/
tipoLista *novo = novoNo(x);
*antes = novo;
novo -> seg = depois;
}
tipoLista *insereOrdenado(tipoLista *base,tipoDados x){
/*
* Insere x na lista ordenada
* Argumentos:
*
base - apontador para a base da lista
*
x
- elemento a procurar
* Retorna
*
base, eventualmente alterada
*/
tipoLista *act,*ant;
if(base == NULL){
insere(&base,x,NULL); /* Lista vazia */
104 L ISTAS DIN ÂMICAS
}
else if(menor(x,base -> dados)){
insere(&base,x,base); /* Insere *antes* da base */
}
else{
ant = base; act = base -> seg;
while((act != NULL) &&
(menor(act -> dados,x))){
/*
* Procura local de inserção
*/
ant = act; act = act -> seg;
}
insere(&(ant -> seg),x,act);
}
return base;
}
void listar(tipoLista *base ){
/*
* Lista todos os elementos
* Argumentos:
*
base - apontador para a base da lista
*/
while(base){
printf(" -> ");
escreveDados(base -> dados);
printf("\n");
base = base -> seg;
}
}
tipoLista *libertaReg(tipoLista *reg){
/*
* Remove da lista o elemento apontado por reg e
* liberta a memória associada.
* Argumentos:
*
reg - apontador para a variável da lista que
*
aponta para o registo a libertar
* Retorna - novo valor do apontandor
*/
tipoLista *aux;
aux = reg;
reg = reg -> seg;
free(aux);
return reg;
}
L ISTAS ORDENADAS 105
tipoLista *apaga(tipoLista *base,tipoDados x){
/*
* Apaga da lista o registo que contém x
* Argumentos:
*
base - apontador para a base da lista
*
x
- elemento a eliminar
* Retorna
*
base, eventualmente alterada
*/
tipoLista *aux;
aux = base;
if(base != NULL){
if(igual(base -> dados,x)){
base = libertaReg(base);
}
else{
aux = base;
while((aux -> seg != NULL) &&
(menor(aux -> seg -> dados,x)))
aux = aux -> seg;
if((aux -> seg != NULL) &&
igual(aux -> seg -> dados,x))
aux -> seg = libertaReg(aux -> seg);
}
}
return base;
}
Ficheiro main.c
/*
*
*
*
*
*
*
*
*
*
*
*
*
Ficheiro: main.c
Autor:
Fernando M. Silva
Data:
7/11/2000
Conteúdo:
Programa principal simples para teste
de estruturas dinâmicas ligadas.
Neste teste, os dados armazenados na lista
são fracções inteiras, implementadas
por um tipo abstracto "tipoDados".
Assim:
1. A especificação de um racional positivo,
106 L ISTAS DIN ÂMICAS
*
acrescenta o racional à lista
*
2. A introdução de um racional negativo,
*
especifica que deve ser apagada o seu
*
simétrico da lista
*
3. Com a entrada de um racional com valor 0,
*
termina o programa
*
*/
#include <stdio.h>
#include <stdlib.h>
#include "lista.h"
int main(){
tipoLista *base;
tipoDados x;
base = inicializa();
printf(" Programa para teste de uma lista "
"ordenada de racionais:\n");
printf("
- A introdução de um racional positivo "
"conduz à sua\n"
"
inserção ordenada na lista.\n");
printf("
- A introdução de um racional "
"negativo procura o seu\n"
"
simétrico na lista e, "
"caso exista, remove-o.\n");
x = leDados("\nIndique o numerador e denominador "
"de um racional:\n"
"(dois inteiros na mesma linha)");
while(numerador(x) != 0) {
if(valor(x) > 0){
base = insereOrdenado(base,x);
}
else{
x = simetrico(x);
if(procuraOrdenado(base,x) == NULL){
printf(" ************ Erro: elemento de "
"valor idêntico a ");
escreveDados(x);
printf(" não encontrado na lista\n");
}
else{
base = apaga(base,x);
}
}
printf("\nConteúdo actual da lista:\n");
VARIANTES 107
base
1
4
9
Figura 4.9: Lista de inteiros com registo separado para a base. Nesta figura, a lista tem apenas três
elementos efectivos (1, 4 e 9), sendo o primeiro registo utilizado apenas para o suporte da base da
lista.
listar(base);
x = leDados("\nIndique o numerador "
"e denominador de um racional:\n"
"(dois inteiros na mesma linha)");
}
printf("\n Racional com valor 0: fim do programa\n");
exit(0);
}
4.8
Variantes
4.8.1
Introdução
Embora a estrutura fundamental das listas dinâmicas seja no essencial a que se viu anteriormente, existem diversas variantes que têm como objectivo simplificar os mecanismos de acesso
ou ajustar a lista a objectivos especı́ficos.
4.8.2
Listas com registo separado para a base
Conforme se viu anteriormente, a manipulação de listas ordenadas exige que a base da lista
seja tratada como um caso particular. Uma forma de evitar estes testes adicionais é manter permanentemente um registo mudo no inı́cio da lista (registo separado para a base) que, embora não
seja utilizado de facto, permite simplificar o acesso aos restantes elementos. Adicionalmente, este
tipo de estrutura (v. figura 4.9 tem a vantagem do apontador para a base não ser modificado depois
da criação da lista.
As funções de manipulação da lista são no essencial semelhantes às da lista ordenada simples.
Embora sem listar aqui todas as funções de acesso, referem-se algumas das que são modificadas
pela existência de um registo permanente na base.
A inicialização da lista corresponde neste caso à criação do registo da base. Deste modo, a
108 L ISTAS DIN ÂMICAS
fim
1
5
8
9
inicio
Figura 4.10: Lista dupla de inteiros.
função de inicialização é dada por
Por outro lado, a função de inserção ordenada pode ser simplificada. Admitindo a utilização
da mesma função insere() já apresentado para a lista simples, a inserção pode ser feita por
onde se pode constatar a ausência do teste particular à base que se realiza na lista ordenada simples.
4.8.3
Listas duplamente ligadas
Um dos inconvenientes das listas simplesmente ligadas é serem unidireccionais. Por outras
palavras, ao aceder a um dado elemento da lista é fácil aceder ao elemento seguinte, mas o acesso
ao elemento anterior não é possı́vel.
Para evitar este inconveniente é possı́vel desenvolver uma lista duplamente ligada, em que
cada elemento dispõe de dois apontadores, um para o elemento seguinte e outro para o anterior.
Adicionalmente, tal como no caso da fila, o acesso à lista é geralmente mantido por dois apontadores, um para o inı́cio e outro para o fim da lista (v. figura 4.10). A declaração de uma lista
duplamente ligada pode ser realizada simplesmente por
typedef struct _tipoRegDupla{
tipoDados dados;
struct _tipoRegDupla *seg,*ant;
} tipoRegDupla;
typedef struct _tipoDupla{
tipoRegDupla inicio;
tipoRegDupla fim;
} tipoDupla;
A NEL DUPLO COM REGISTO SEPARADO PARA A BASE 109
base
1
4
4
9
Figura 4.11: Lista em anel de inteiros. O último elemento aponta para o primeiro
base
5
8
9
Figura 4.12: Anel duplo com registo separado para a base.
4.8.4
Aneis
Numa lista simplesmente ligada, o último da elemento da lista é identificado pelo facto do
apontador para o elemento seguinte ser NULL. Uma alternativa é colocar o último elemento da
lista a apontar para a base (v. figura 4.11). A manipulação de uma estrutura deste tipo é muito
semelhante à de uma lista simples, substituindo-se apenas parte das comparações com o valor
NULL por comparações com o apontador da base.
Uma lista em anel apresenta a vantagem do último elemento apontar para o primeiro, o que
pode ser conveniente em aplicações em que seja necessário percorrer os elementos da lista de uma
forma cı́clica.
4.9
4.9.1
Anel duplo com registo separado para a base
Introdução
Um anel duplo com registo separado para a base é uma estrutura dinâmica que combina as
diversas variantes abordadas anteriormente: registo separado para a base, ligação em anel e registos com apontadores bidireccionais (figura 4.12). Embora este tipo estrutura possa sugerir uma
manipulação à partida mais complexa, verifica-se na prática o inverso: a exploração correcta desta
estrutura origina, de modo geral, código mais simples.
110 L ISTAS DIN ÂMICAS
base
Figura 4.13: Anel vazio após a criação.
4.9.2
Declaração
A declaração de um anel é feita da forma habitual. Ou seja,
typedef struct _tipoAnel {
tipoDados dados;
struct _tipoAnel *seg,*ant;
} tipoAnel;
4.9.3
Inicialização
A inicialização de um anel corresponde à criação de um registo para a base e ao fecho em anel
das suas ligações (figura 4.13). Esta inicialização pode ser feita por
tipoAnel *inicializa(){
tipoAnel *aux;
tipoDados regMudo;
aux = novoNo(regMudo);
aux -> seg = aux -> ant = aux;
return aux;
}
A função novoNo(), semelhante às anteriores, pode ser definida como
tipoAnel *novoNo(tipoDados x){
tipoAnel *novo = calloc(1,sizeof(tipoAnel));
if(novo == NULL)
Erro("Erro na reserva de memória");
novo -> dados = x;
A NEL DUPLO COM REGISTO SEPARADO PARA A BASE 111
novo -> seg = novo -> ant = NULL;
return novo;
}
4.9.4
Listagem
A listagem de um anel pode ser feita por ordem directa ou inversa. Deste modo, podem ser
desenvolvidas duas funções para este efeito, de estrutura muito semelhante:
/*
Listagem directa
void listar(tipoAnel *base ){
tipoAnel *aux;
*/
aux = base -> seg;
while(aux != base){
printf(" -> ");
escreveDados(aux -> dados);
printf("\n");
aux = aux -> seg;
}
}
/*
Listagem inversa
*/
void listarInv(tipoAnel *base ){
tipoAnel *aux;
aux = base -> ant;
while(aux != base){
printf(" -> ");
escreveDados(aux -> dados);
printf("\n");
aux = aux -> ant;
}
}
Repare-se que em qualquer das duas funções o inı́cio da listagem se efectua no elemento a seguir
à base (de modo a saltar o registo da base), enquanto que na condição de manutenção no ciclo se
testa o regresso à base.
4.9.5
Procura
A função de procura é semelhante à de uma lista ordenada, omitindo o elemento inicial:
112 L ISTAS DIN ÂMICAS
tipoAnel *procuraOrdenado(tipoAnel *base,tipoDados x){
tipoAnel *aux;
base -> dados = x;
aux = base -> seg;
while(menor(aux -> dados,x))
aux = aux -> seg;
if((aux != base) && (igual(aux -> dados,x)))
return aux;
else
return NULL;
}
Repare-se que, para simplificar a condição do ciclo, se iniciou o registo da base com o próprio
valor do elemento a procurar. Este artifı́cio garante que, mesmo que o elemento de procura não
se encontre no anel, a procura é interrompida quando se atinge a base. Claro que, deste modo,
é preciso testar à saı́da do ciclo se a interrupção se verificou por ter encontrado o local real de
interrupção e o elemento de procura, ou por se ter encontrado o elemento artificialmente colocado
na base. Neste último caso deverá retornar-se o apontador NULL.
4.9.6
Inserção
No caso de inserção ordenada no anel, é particularmente útil utilizar uma função só para a
inserção do registo, admitindo que já se conhece o local de inserção, e desenvolver posteriormente
a função de procura o local de inserção.
A primeira destas funções pode ser definida como
void insere(tipoAnel *depois,tipoDados x){
tipoAnel *novo = novoNo(x);
novo -> seg = depois;
/* (1) */
novo -> ant = depois -> ant;
/* (2) */
depois -> ant = novo;
/* (3) */
novo -> ant -> seg = novo;
/* (4) */
}
Apesar da aparente complexidade da função, a sua realização corresponde apenas à realização
das ligações necessárias pela ordem correcta. Na figura 4.14 detalha-se este processo de inserção
no caso de um anel de inteiros, identificando-se a correspondência entre as ligação e as instruções
da listagem.
A NEL DUPLO COM REGISTO SEPARADO PARA A BASE 113
depois
1
8
(2)
(4)
novo
(1)
(3)
3
Figura 4.14: Inserção num anel. Os números entre () correspondem às atribuições da listagem
apresentada no texto.
Repare-se que, dado que a lista é dupla, é suficiente o apontador para o elemento seguinte para
estabelecer todas as ligações.
A função de inserção propriamente dita realiza apenas a pesquisa do local de inserção. De
forma semelhante ao já efectuado no caso da função de procura, é possı́vel colocar uma cópia
do elemento a inserir no registo da base para permitir que o caso particular de inserção no final
da lista não tem que ser tratado como um caso particular. Deste modo, o código da função fica
particularmente simples:
void insereOrdenado(tipoAnel *base,tipoDados x){
tipoAnel *act;
base -> dados = x;
act = base -> seg;
while(menor(act -> dados,x))
act = act -> seg;
insere(act,x);
}
4.9.7
Remoção
A operação de remoção pode ser realizada por duas funções complementares. A primeira
(removeReg()) é utilizada após a identificação do registo a eliminar e é responsável pela
libertação da memória ocupada e reconstrução das ligações. A segunda (apaga()) que corresponde à função a utilizar externamente, procura o registo a eliminar e, caso o identifique, chama
114 L ISTAS DIN ÂMICAS
a primeira.
void removeReg(tipoAnel *reg){
reg -> seg -> ant = reg -> ant;
reg -> ant -> seg = reg -> seg;
free(reg);
}
void apaga(tipoAnel *base,tipoDados x){
tipoAnel *aux;
base -> dados = x;
aux = base -> seg;
while(menor(aux -> dados,x))
aux = aux -> seg;
if((aux != base) && igual(aux -> dados,x))
removeReg(aux);
}
4.9.8
Exemplo
Utiliza-se neste caso o mesmo exemplo de listagem de racionais já apresentado anteriormente.
Neste caso, omite-se a listagem dos métodos do tipo tipoDados, e os ficheiros util.c e
util.h, obviamente idêntico ao anterior.
Ficheiro anel.h
/*
* Ficheiro: anel.h
* Autor:
Fernando M. Silva
* Data:
7/11/2000
* Conteúdo:
*
Ficheiro com declaração de tipos e
*
protótipos dos métodos para manipulação
*
de um anel com registo seoparado
*
para a base
*
*/
#ifndef _ANEL_H
#define _ANEL_H
#include <stdio.h>
A NEL DUPLO COM REGISTO SEPARADO PARA A BASE 115
#include <stdlib.h>
/*
* Tipo dos dados da lista
*/
#include "dados.h"
/*
* Definição de tipoAnel
*/
typedef struct _tipoAnel {
tipoDados dados;
struct _tipoAnel *seg,*ant;
} tipoAnel;
/*
* Protótipos dos métodos de acesso
*/
/* Inicialização */
tipoAnel *inicializa(void);
/*
* Procura x na lista iniciada por base, retornando um apontador
* para o registo que contem este valor (ou NULL se não existe)
*/
tipoAnel *procura(tipoAnel *base,tipoDados x);
tipoAnel *procuraOrdenado(tipoAnel *base,tipoDados x);
/*
* Insere x antes do registo "depois"
*/
void insere(tipoAnel *depois,tipoDados x);
/*
* Insere x na lista ordenada iniciada por base
*/
void insereOrdenado(tipoAnel *base,tipoDados x);
/*
* Lista todos os elementos da estrutura.
*/
void listar(tipoAnel *base);
void listarInv(tipoAnel *base);
/*
*
Remove da lista o elemento apontado por reg
116 L ISTAS DIN ÂMICAS
*/
void libertaReg(tipoAnel *reg);
/*
* Apaga da lista o registo que contém x
*/
void apaga(tipoAnel *base,tipoDados x);
#endif
Ficheiro anel.c
/*
*
*
*
*
*
*
*
*
*/
Ficheiro: anel.c
Autor:
Fernando M. Silva
Data:
7/11/2000
Conteúdo:
Métodos para manipulação
de um anel com registo separado
para a base.
/*
* Inclui ficheiro com tipo e protótipos
*/
#include "anel.h"
#include "util.h"
tipoAnel *novoNo(tipoDados x){
/*
* Cria um novo nó da lista
*/
tipoAnel *novo = calloc(1,sizeof(tipoAnel));
if(novo == NULL)
Erro("Erro na reserva de memória");
novo -> dados = x;
novo -> seg = novo -> ant = NULL;
return novo;
}
/* Inicialização. O anel vazio é constituı́da pelo
* registo da base.
*/
tipoAnel *inicializa(){
A NEL DUPLO COM REGISTO SEPARADO PARA A BASE 117
tipoAnel *aux;
tipoDados regMudo;
aux = novoNo(regMudo);
aux -> seg = aux -> ant = aux;
return aux;
}
/*
* Procura x no anel iniciada por base, retornando um apontador
* para o registo que contem este valor (ou NULL se não existe)
*
* Este procedimento realiza uma busca exaustiva em todo o anel
*/
tipoAnel *procura(tipoAnel *base,tipoDados x){
tipoAnel *aux;
base -> dados = x;
aux = base -> seg;
while(!igual(aux -> dados,x))
aux = aux -> seg;
return (aux == base ? NULL : aux);
}
/*
* Idêntico ao anterior, mas optimizado para aneis
* ordenadas (a procura para logo que seja atingido
* um elemento igual ou SUPERIOR a x).
*/
tipoAnel *procuraOrdenado(tipoAnel *base,tipoDados x){
tipoAnel *aux;
base -> dados = x;
aux = base -> seg;
while(menor(aux -> dados,x))
aux = aux -> seg;
if((aux != base) && (igual(aux -> dados,x)))
return aux;
else
return NULL;
}
/*
* Insere x antes do registo "depois"
*/
void insere(tipoAnel *depois,tipoDados x){
tipoAnel *novo = novoNo(x);
118 L ISTAS DIN ÂMICAS
novo -> seg = depois;
novo -> ant = depois -> ant;
depois -> ant = novo;
novo -> ant -> seg = novo;
}
/*
* Insere x no anel ordenada iniciada por base
*/
void insereOrdenado(tipoAnel *base,tipoDados x){
tipoAnel *act;
base -> dados = x;
act = base -> seg;
while(menor(act -> dados,x)){
/*
* Procura local de inserção
*/
act = act -> seg;
}
insere(act,x);
}
/*
* Lista todos os elementos da estrutura.
*/
void listar(tipoAnel *base ){
tipoAnel *aux;
aux = base -> seg;
while(aux != base){
printf(" -> ");
escreveDados(aux -> dados);
printf("\n");
aux = aux -> seg;
}
}
/*
* Lista todos os elementos da estrutura
* por ordem inversa.
*/
void listarInv(tipoAnel *base ){
tipoAnel *aux;
aux = base -> ant;
while(aux != base){
printf(" -> ");
escreveDados(aux -> dados);
A NEL DUPLO COM REGISTO SEPARADO PARA A BASE 119
printf("\n");
aux = aux -> ant;
}
}
/*
* Remove do anel o elemento apontado por reg
* e liberta a memória associada.
*/
void libertaReg(tipoAnel *reg){
reg -> seg -> ant = reg -> ant;
reg -> ant -> seg = reg -> seg;
free(reg);
}
/*
* Apaga do anel o registo que contém x
* Retorna a base, eventualmente alterada
*/
void apaga(tipoAnel *base,tipoDados x){
tipoAnel *aux;
base -> dados = x;
aux = base -> seg;
while(menor(aux -> dados,x))
aux = aux -> seg;
if((aux != base) && igual(aux -> dados,x))
libertaReg(aux);
}
Ficheiro main.c
/*
*
*
*
*
*
*
*
*
*
*
*
*
Ficheiro: main.c
Autor:
Fernando M. Silva
Data:
7/11/2000
Conteúdo:
Programa principal simples para teste
de estruturas dinâmicas ligadas.
Neste teste, os dados armazenados na lista
são fracções inteiras, implementadas
por um tipo abstracto "tipoDados".
Assim:
1. A especificação de um racional positivo,
120 L ISTAS DIN ÂMICAS
*
acrescenta o racional à lista
*
2. A introdução de um racional negativo,
*
especifica que deve ser apagada o seu
*
simétrico da lista
*
3. Com a entrada de um racional com valor 0,
*
termina o programa
*
*/
#include <stdio.h>
#include <stdlib.h>
#include "anel.h"
int main(){
tipoAnel *base;
tipoDados x;
base = inicializa();
printf(" Programa para teste de uma lista ordenada de racionais:\n");
printf("
- A introdução de um racional positivo conduz à sua\n"
"
inserção ordenada na lista.\n");
printf("
- A introdução de um racional negativo procura o seu\n"
"
simétrico na lista e, caso exista, remove-o.\n");
x = leDados("\nIndique o numerador e denominador de um racional:\n"
"(dois inteiros na mesma linha)");
while(numerador(x) != 0) {
if(valor(x) > 0){
insereOrdenado(base,x);
}
else{
x = simetrico(x);
if(procuraOrdenado(base,x) == NULL){
printf(" ************ Erro: elemento de valor idêntico a ");
escreveDados(x);
printf(" não encontrado na lista\n");
}
else{
apaga(base,x);
}
}
printf("\nConteúdo actual do anel (ordem crescente):\n");
listar(base);
printf("\nConteúdo por ordem inversa:\n");
listarInv(base);
x = leDados("\nIndique o numerador e denominador de um racional:\n"
"(dois inteiros na mesma linha)");
}
L ISTAS DE LISTAS 121
base
Figura 4.15: Lista de listas.
printf("\n Racional com valor 0: fim do programa\n");
exit(0);
}
4.10
Listas de listas
É frequente a implementação de uma dada aplicação requerer a utilização hierárquica de diversas listas. Por exemplo, um sistema de arquivo de documentos pode basear-se numa lista de
categorias, em que cada categoria tem associada, por sua vez, uma lista de documentos dessa categoria. Eventualmente, cada um destes documentos pode ainda ter associado uma lista especı́fica,
como uma lista das datas em que o documento foi alterado e as alterações fundamentais de cada
vez. Uma representação simbólica de uma lista de listas está representada na figura 4.15.
Uma lista de listas (ou um anel de anéis) nada tem de particular do ponto de vista de
programação. Cada lista por si só ú uma estrutura do tipo apontado anteriormente. No entanto,
é necessário ter em atenção que a lista e as suas sublistas têm tipos diferentes e, de acordo com
o modelo que temos vindo a desenvolver, cada uma delas precisará de um método especı́fico de
acesso. Por outras palavras, será necessário manter duas funções de listagem, duas funções de
inserção, etc, dado que cada tipo de lista exige um método especı́fico2 .
A declaração de uma lista de lista pode ser efectuada pelo código
typedef struct _tipoSubLista {
/*
2
De facto, esta duplicação de código pode ser evitada escrevendo código baseado em apontadores genéricos do tipo
void*. Esta possibilidade não será, por agora, abordada
122 L ISTAS DIN ÂMICAS
tipoDadosSubLista descreve todos os
atributos de cada elemento de uma
sublista
*/
tipoDadosSubLista dadosSubLista;
struct _tipoSubLista *seg;
} tipoSubLista;
typedef struct _tipoDadosLista{
/*
declaração dos campos necessários
a cada elemento da lista
principal
int ...
char ...
*/
tipoSubLista *baseSub;
} tipoDadosLista;
typedef struct _tipoListaDeListas{
tipoDadosLista dados;
struct _tipoListaDeListas *seg;
} tipoListaDeListas;
Note-se que se adoptou aqui quatro tipos abstractos distintos: o tipo tipoDadosSubLista,
que suporta os dados de cada sublista, o tipo tipoSubLista, que suporta as sublistas, o tipo
tipoDadosLista que suporta o tipo de dados da lista principal e, finalmente, o tipoListaDeListas, que suporta a lista principal.
A manipulação da lista faz-se pela combinação das funções (métodos) desenvolvidos para
cada tipo de objecto. Admita-se, por exemplo, que no programa principal foi declarada uma lista
de listas
tipoListaDeListas *base;
que foi de alguma forma inicializada. Suponha-se agora que é necessário inserir uma variável
x de tipoDados na sublista suportada no elemento da lista principal que tem o valor y. O código
para este efeito seria:
tipoListaDeListas *base,*aux;
tipoDadosSubLista x;
tipoDadosLista
y;
L ISTAS DE LISTAS 123
/*
inicialização da lista e dos valores x e y
...
*/
aux = procuraOrdenadoListaDeListas(base,y);
if(aux == NULL)
fprintf(stderr,"Erro: valor não encontrado na lista principal");
else
insereDados(aux -> dados,x);
/*
...
*/
A função insereDados é nova neste contexto, e deverá ser um método especı́fico de
tipoDadosLista. A sua estrutura é muito simples, e a sua existência deve-se apenas à necessidade de manter uma realização correcta da abstracção de dados. Esta função seria
void insereDados(tipoDadosLista dados,tipoDadosSubLista x){
insereDadosSubLista(dados.baseSub,x);
}
sendo a função insereDadosSubLista uma função convencional de inserção.
Capı́tulo 5
Conclusões
O C é provavelmente a mais flexı́vel das linguagens de programação de alto-nı́vel, mas apresenta uma relativa complexidade sintáctica. Uma das maiores dificuldades na abordagem do
C numa disciplina introdutória de programação é a necessidade de introduzir os conceitos de
endereço de memória, apontador e memória dinâmica.
Neste texto introduziu-se a noção de apontador e discutiu-se o problema da manipulação de
estruturas de dados dinâmicas em C. Apesar da introdução de diversas estruturas de dados, o
primeiro objectivo deste texto foi, sobretudo, o de tentar explicar os mecanismos essenciais de
manipulação de apontadores e gestão de memória dinâmica em C, utilizando-se algumas estruturas
de dados simples para exemplificar estes mecanismos. O aprofundamento destes temas tem o seu
seguimento natural numa disciplina especı́fica de Algoritmos e Estruturas de Dados, ou em textos
especı́ficos de algoritmia, como (Sedgwick, 1990; Cormen e outros, 1990; Knuth, 1973).
Bibliografia
Cormen, T. H., Leiserson, C. E., e Rivest, R. L. (1990). Introduction to Algorithms. MIT
Press/McGraw-Hill.
Kernighan, B. e Ritchie, D. (1978). The C Programming Language. Prentice-Hall.
Knuth, D. E. (1973). Fundamental Algorithms, volume 1 of The Art of Computer Programming,
section 1.2, páginas 10–119. Addison-Wesley, Reading, Massachusetts, second edition.
Martins, J. P. (1989). Introduction to Computer Science Using Pascal. Wadsworth Publishing Co.,
Belmont, California.
Ritchie, D. e Thmompson, K. (1974). The unix time sharing system. Commun ACM, páginas
365–375.
Sedgwick (1990). Algorithms in C. Addison Wesley.
B IBLIOGRAFIA 129
Apêndice A
Programa de teste que valida a consistência das atribuições da tabela 2.1, apresentada na
secção 2.6.4. Note-se que o facto de o programa compilar sem erros garante a consistência dos
tipos nas atribuições realizadas.
Nos processadores da famı́lia Intel, cada endereço de memória especifica apenas um byte,
apesar de, nos processadores 486 e seguintes, cada operação de acesso à memória se realizar em
palavras de 4 bytes (32 bits). De forma a obter-se valores equivalentes ao do modelo de memória
usado nos exemplos, realiza-se uma divisão por quatro e ajusta-se o endereço escrito no monitor
de forma a que o elemento x[0][0] surja com o valor 1001.
#include <stdio.h>
float x[3][2] = {{1.0,2.0},{3.0,4.0},
{5.0,6.0}};
void escreveEndereco(int n){
n = (n - (int) x)/4 + 1001;
printf("endereco = %d\n",n);
}
int main(){
float (*pv3)[2];
float *pf;
float f;
}
pv3 = x;
pv3 = x + 1;
escreveEndereco((int) pv3);
escreveEndereco((int)pv3);
pf = *(x+1);
pf = *(x+2)+1;
f = *(*(x+2)+1);
escreveEndereco((int)pf);
escreveEndereco((int)pf);
printf("%4.1f\n",f);
pf = *x;
escreveEndereco((int)pf);
f = **x;
f = *(*x+1);
printf("%4.1f\n",f);
printf("%4.1f\n",f);
pf = x[1];
return 0;
escreveEndereco((int)pf);
Download

Apontadores e Estruturas de Dados Dinâmicas em C - INESC-ID