Introdução à Programação
Uma das características de um engenheiro é a capacidade de resolver problemas técnicos. Qualquer problema de engenharia é resolvido recorrendo a uma sequência de fases:
- a compreensão do problema -> corresponde a perceber e identificar de um modo preciso o problema que tem de ser resolvido;
- especificação do problema -> onde o problema é descrito e documentado de modo a remover dúvidas e imprecisões;
- desenvolvimento da solução (ou modelação da solução) -> utiliza-se a especificação do problema para produzir um esboço da solução do problema, identificando métodos apropriados de resolução de problemas e as suposições necessárias. O esboço da solução é progressivamente detalhado até se atingir um nível de especificação que seja adequado para a sua realização;
- concretização da solução -> as especificações desenvolvidas são concretizadas; - verificação e testes -> o resultado produzido é validado, verificado e testado.
A engenharia informática difere das outras pois trabalha com entidades imateriais, lida com entidades intangíveis que apenas podem ser observadas indirectamente através dos efeitos que produzem. Ela tem como finalidade a concepção e realização de abstracções ou modelos de entidades abstractas que fazem com que o computador apresente um comportamento que corresponde à solução de um dado problema.
Um computador é uma máquina cuja função é manipular a informação. Informação é qualquer coisa que pode ser transmitida ou registada e tem um significado associado à sua representação simbólica. A informação provém de muitos sítios diferentes. O que distingue o computador das outras máquinas que lidam com informação é o facto de este poder manipular a informação para além de a armazenar e transmitir. A manipulação da informação segue um sequência de instruções a que se dá o nome de programa. É portanto, uma “caixa electrónica” que tem a capacidade de compreender e de executar as instrucções que constituem os programas.
Um processo computacional é um ente imaterial que existe dentro de um computador durante a execução de um programa, e cuja evolução ao longo do tempo é ditada pelo programa.
1.1 Algoritmos
Um algoritmo é uma sequência de instrucções que podem ser executadas de um modo mecânico de modo a atingir um determinado objectivo.
A execução das instrucções do algoritmo garante que o seu objectivo é atingido. Cada algoritmo está também associado a um agente que deve executar as suas instrucções. O que é um algoritmo para um agente pode não ser para outro.
Embora um algoritmo não seja mais do que uma sequência de passos a seguir para atingir um determinado objectivo podem ser consideradas um algoritmo, pois todo o algoritmo deve possuir três características: ser rigoroso, ser eficaz, e ter a garantia de terminar.
Um algoritmo é rigoroso. Cada instrucção do algoritmo deve especificar exacta e rigorosamente o que deve ser feito, não havendo lugar para ambiguidade. O facto de um algoritmo poder ser executado mecanicamente obriga a que cada uma das suas instrucções tenha uma e só uma interpretação.
Um algoritmo é eficaz. Cada instrucção do algoritmo deve ser suficientemente básica e bem compreendida de modo a poder ser executada num intervalo de tempo finito, com uma quantidade de esforço finita.
Um algoritmo deve terminar. O algoritmo deve levar a uma situação em que o objectivo tenha sido atingido e não existam mais instrucções para ser executadas.
Um algoritmo é uma sequência finita de instrucções, bem definidas e não ambíguas, cada uma das quais pode ser executada mecanicamente num período de tempo finito com uma quantidade de esforço finita.
Um programa é um algoritmo escrito numa linguagem que é entendida por um computador, chamada uma linguagem de programação.
1.2 O desenvolvimento de programas
O desenvolvimento de programas utiliza muitas actividades e técnicas: a definição exacta do que se pretende fazer, removendo ambiguidades e incertezas que possam estar contidas nos objectivos a atingir (a compreensão do problema), a decisão do processo a utilizar para a solução do problema e o delineamento da solução utilizando um linguagem adequada (o desenvolvimento da solução); a codificação da solução numa linguagem de programação; a verificação e os testes.
De todas estas, a mais criativa e mais dificil é o desenvolvimento da solução. A sua maior dificuldade é o controlo da sua complexidade.
A abordagem do topo para a base é uma técnica que consiste em identificar os principais sub-problemas que constituem o problema a resolver, e em determinar qual a relação entre esses sub-problemas. O processo é aplicado repetitivamente para cada um dos sub-problemas até se atingirem problemas cuja solução não temos dificuldade em escrever.
A abstracção é uma técnica que consiste em ignorar informação irrelevante, num dado contexto.
1.3 Programas em Scheme
O Scheme é uma linguagem de programação, ou seja, corresponde a um formalismo para escrever programas. Uma forma é uma frase em Scheme.
A linguagem Scheme é constituída por formas. O interpretador do Scheme é uma “caixa electrónica” que compreende as formas da linguagem Scheme, ou seja, sabe reagir apropriadamente a uma forma da linguagem.
Em processamento interactivo, o utilizador dialoga com o computador, fornecendo uma forma de cada vez e esperando pela resposta do computador antes de fornecer a próxima forma.
A utilização interactiva do Scheme correpsonde à repetição de um ciclo chamado o ciclo lê-avalia-escreve.
Ao interagir com o interpretador do Scheme, sempre que escrevemos uma forma a seguir ao carácter de pronto, estamos a pedir ao interpretador que avalie essa forma, sendo o seu valor fornecido na linha imediatamente a seguir àquela que corresponde ao nosso pedido.
O Scheme apresenta dois aspectos distintos: as frases da linguagem e o significado associado às frases. Estes aspectos são denominados a sintaxe e a semântica da linguagem.
Sintaxe
A sintaxe de uma linguagem é o conjunto de regras que definem quais as relações válidas entre os componentes da linguagem. A sintaxe nada diz em relação ao significado das frases da linguagem.
Os símbolos que aparecem nas frases da linguagem são chamados símbolos terminais e são escritos, em notaçao BNF, sem qualquer símbolo especial à sua volta.
Um símbolo não terminal está sempre associado a um conjunto de entidades da linguagem.
O símbolo “|” (lido “ou”) representa possíveis alternativas.
O símbolo “::=” (lido “é definido como”) serve para definir componentes da linguagem.
A utilização do carácter “+” imediatamente após um símbolo não terminal significa que esse símbolo pode ser repetido zero ou mais vezes.
Semântica
A semântica de uma linguagem define qual o significado de cada frase da linguagem.
Cada forma em Scheme tem uma semântica, a qual representa a acção tomada pelo interpretador do Scheme ao avaliar essa forma, ou seja, o significado que o interpretador do Scheme atribui à forma. Esta semântica é definida por regras para extrair o significado de formas.
1.3.2 – Construcção de formas
Ao escrevermos programas em Scheme iremos produzir formas. As formas em Scheme são construídas de três modos diferentes:
- Formas primitivas. Estas formas representam as entidades mais simples da linguagem, as quais têm um significado predefinido para o interpretador do Scheme. Estas formas primitivas fazem parte do interpretador do Scheme, são as suas formas nativas.
- Modos de combinação. Estes modos permitem a construcção de formas compostas a partir de elementos mais simples.
- Modos de abstracção. Estes modos permitem que elementos compostos recebam nomes e sejam tratados como unitários.
1.4 – Expressões
Uma expressão é uma entidade computacional que tem um valor.
Uma expressão em Scheme pode ser uma constante, uma combinação ou um nome.
O valor de uma constante é a própria constante. Existem em Scheme os seguintes tipos de constantes:
- Números inteiros. Estes correspondem a números sem parte decimal (com ou sem sinal) e podem ser arbitrariamente grandes.
- Números racionais. Estes correspondem ao quociente de dois números inteiros, os quais são separados pelo símbolo “/”. Os números racionais são sempre representados pelo Scheme reduzidos à sua forma canónica (o numerador e o denominador são primos entre si) e podem ter ou não sinal. Um número racional com denominador 1, é automaticamente indicado como um número inteiro. - Números reais. Estes correspondem a números com parte decimal (com ou sem
sinal) e podem ser arbitrariamente grzndes ou arbitrariamente pequenos. Os números reais com valores absolutos muito pequenos ou muito grandes são apresentados em notação científica. Em notação científica, representa-se o número, com ou sem sinal, através de uma mantissa e de uma potência inteira de dez que multiplicada pela mantissa produz o número. A mantissa e o expoente são separados pelo símbolo”e”.
- Valores lógicos. Os quais são representados em Scheme por #t (verdadeiro) e #f (falso).
- Cadeias de caracteres. As quais correspondem a sequências de caracteres. O comprimento da cadeia é o número de caracteres que a constitui. As constantes das cadeias de caracteres são representadas em Scheme delimitadas por aspas. 1.4.2 – Combinações
Por procedimentos primitivos entendem-se operações que o interpretador de Scheme conhece, independentemente de qualquer indicação que lhe seja dada por um programa. Em Scheme, para qualquer destas operações existe uma indicação interna daquilo que o computador deve fazer quando surge uma expressão com essa operação. Este procedimento faz parte do próprio Scheme.
Os procedimentos primitivos podem ser utilizados através do conceito de combinação. Uma combinação corresponde ao conceito de aplicação de uma operação a uma sequência de operandos. Uma combinação é composta por um operador e por um certo número de operandos. Os operadores podem ser unários, binários, etc.
Em Scheme, as operações são escritas utilizando a notação prefixa: (1) o operador aparece antes dos operandos e (2) o operador e a sequência dos seus operandos são escritos dentro de parênteses.
Uma combinação é constituída por um operador seguido de zero ou mais expressões (os operandos).
Uma das vantagens da notação prefixa é a de evitar ambiguidade quanto ao domínio e prioridade do operador, o que nos permite a utilização de expressões encadeadas.
Para calcular o valor de uma combinação, avaliam-se as sub-expressões na combinação (por qualquer ordem). Após esta avaliação, aplica-se o procedimento correspondente ao valor da primeira sub-expressão (o operador) aos argumentos que correspondem aos valores das restantes sub-expressões (os operandos).
Cada operador aceita um certo número de operandos, os quais têm de ser de certo tipo.
O resultado da aplicação de alguns operadores depende do tipo dos seus operandos.
Operações numéricas em Scheme
Operação Número de
Argumentos
Tipo dos argumentos
(+ <e1> ... <en>) Dois ou mais Números A soma dos valores de <e1> ... <en>. (- <e1> ... <en>) Dois ou mais Números O resultado de
subtrair ao valor de <e1> os valores de
... e <en>
(- <e>) Um Número O simétrico de e
(* <e1> ... <en>) Dois ou mais Números O produto dos valores de <e1> ...
<en> (/ <e1> ... <en>) Dois ou mais Números O resultado de
dividir o valor de <e1> sucessivamente pelos valores de ... e
<en> (/ <e>) Um Número O inverso de <e>
(cos <e>) Um Número O coseno do valor
de <e>, tomado como um valor em
radianos
(sin <e>) Um Número O seno do valor de
<e>, tomado como um valor em
radianos (log <e>) Um Número O logaritmo natural
do valor de <e> (sqrt <e>) Um Número A raiz quadrada do
valor <e> (max <e1> ... <en>) Um ou mais Números O máximo dos
valores de <e1> ... <en>. (min <e1> ... <en>) Um ou mais Números O mínimo dos
valores de <e1> ... <en> (quotient <e1>
<e2>
Dois Números O resultado da
divisão inteira entre o valor de <e1> e
<e2> (remainder <e1>
<e2>
Dois Números O resto da divisão inteira entre o valor
de <e1> e <e2>
(round <e>) Um Número O resultado de
arredondar o valor de <e> (inexact->exact <e>) Um Número O resultado de converter o valor de <e> para o inteiro
Predicados primitivos em Scheme Predicado Número de argumentos Tipo dos argumentos Valor
(odd? <e>) Um Inteiro Tem o valor #t se e só se o valor de <e> for ímpar (even? <e>) Um Inteiro Tem o valor #t se e só se o valor de <e> for par (= <e1> ... <en>) Dois ou mais Números Tem o valor #t se e só se
os valores das expressões de <e1> ... <en> são
todos iguais (> <e1> ... <en>) Dois ou mais Números Tem o valor #t se e só se
os valores das expressões <e1> ... <en> se encontram ordenados por
ordem decrescente, não existindo dois valores
iguais.
(< <e1> ... <en>) Dois ou mais Números Tem o valor #t se e só se os valores das expressões
<e1> ... <en> se encontram ordenados por
ordem crescente, não existindo dois valores
iguais (>= <e1> ...
<en>)
Dois ou mais Números Tem o valor #t se e só se os valores das expressões
<e1> ... <en> se encontram ordenados por
ordem decrescente, podendo existir valores
iguais (<= <e1> ...
<en>)
Dois ou mais Números Tem o valor #t se e só se os valores das expressões
<e1> ... <en> se encontram ordenados por
ordem crescente, podendo existir valores
iguais Predicados e condições
As condições podem ser combinadas através de operações lógicas.
Uma operação que produz resultados do tipo lógico chama-se um predicado. Uma expressão cujo valor é do tipo lógico chama-se uma condição.
Operações lógicas primitivas em Scheme Operação Número de argumentos Tipo dos argumentos Valor (and <e1> ... <en>) Dois ou mais Condições A conjunção dos
valores de <e1> ... <en> (or <e1> ... <en>) Dois ou mais Condições A disjunção dos
valores <e1> ... <en>
(not <e>) Um Condição A negação do valor <e>
1.5 – Avaliação de expressões – primeira abordagem
Para avaliar expressões, o interpretador do Scheme utiliza regras de avaliação. Podemos considerar que exste uma regra de avaliação constituída pelas seguintes partes:
- se a expressão é uma constante, o seu valor é a própria constante
- se a expressão é uma operação primitiva, o seu valor é o procedimento interno associado a essa operação
- se a expressão é uma combinação, o seu valor é calculado do seguinte modo: o avaliam-se as sub-expressões na combinação (por qualquer ordem)
o aplica-se o procedimento que é o valor d primeira sub-expressão (o operador) aos argumentos que correspondem aos valores das restantes sub-expressões (os operandos).
Um dos aspectos importantes a notar em relação à regra de avaliação é que para avliar uma combinação temos de aplicar a própria regra de avaliação definida em tremos de si própria – é uma definição recursiva. Durante o processo de avaliação, a regra de avaliação é invocada por si própria múltiplas vezes. Esta invocação deixa de ser necessária quando se encontra uma entidade cujo valor é conhecido pelo avaliador do Scheme, ou seja quando o objecto computacional a avaliar é uma constante ou um procedimento primitivo.
Uma definição diz-se recursiva se a entidade que está a ser definida for definida em termos de si própria. A ideia fundamental numa deifinição recursiva consiste em definir um problema em termos de uma versão semelhante, embora mais simples, de si próprio.
As definições recursivas são constituídas por duas partes distintas:
- uma parte básica, ou caso terminal, a qual constitui a versão mais simples do problema para o qual a solução é conhecida;
- uma parte recursiva, ou caso geral, na qual o problema é definido em termos de uma versão mais simples de si próprio.
1.6 Nomes
A utilização de nomes corresponde a um nível de abstracção no qual deixamos de nos preocupar com a indicação directa do objecto computacional, referindo-nos a esse objecto pelo seu nome. A associação entre um nome e um valor é obtida pela operação de nomeação.
A operação de nomeação é realizada em Scheme através do procedimento primitivo chamado define. Este procedimento recebe dois operandos; o primeiro
corresponde ao nome que queremos usar para nomear o valor resultante da avaliação do segundo operando, o qual é uma expressão.
O procedimento primitivo define dá origem a uma forma que não corresponde a uma expressão (é uma definição).
Um ambiente é uma associação entre nomes e objectos computacionais.
O valor de um nome corresponde ao objecto computacional que está associado a esse nome no ambiente em consideração.
O valor de uma expressão depende do ambiente em que é avaliada.
A utilização de um nome que não é conhecido pelo interpretador do Scheme, origina um erro pois o computador “não sabe” o que o nome significa – o nome não faz parte do ambiente.
Um nome pode ser utilizado para referir o valor de uma expressão. A associação é feita entre o nome e o valor da expressão.
O valor de um nome é o objecto computacional associado a esse nome no ambiente em consideração.
1.7 – Formas Especiais
Existem em scheme algumas formas, chamadas formas especiais, que têm regras de avaliação especiais.
Cada forma especial tem o seu modo específico de avaliação. O operador define é uma forma especial porque tem uma regra de avaliação específica.
Os operadores lógicos and e or são formas especiais.
Ao avaliar a combinação (and), os predicados são avaliados pela ordem em que aparecem. Assim que o valor de um deles for falso, a avaliação termina com o valor falso. Se o valor de todos os predicados for verdadeiro, o valor da expressão é verdadeiro.
Ao avaliar a combinação (or), os predicados são avaliados pela ordem em que aparecem. Assim que o valor de um deles for verdadeiro, a avaliação termina com o valor verdadeiro. Se o valor de todos os predicados for falso, o valor da expressão é falso.
1.8 – Resumo
Um algoritmo é uma sequência finita de instrucções, cuja execução não necessita de inteligência, imaginação, intuição e que mais cedo ou mais tarde chega ao fim. A um algoritmo escrito em Scheme dá-se o nome de procedimento.
A execução de um procedimento pelo interpretador do Scheme dá origem a um processo computacional.
Um programa Scheme é constituído por formas, as quais são divididas em definições e em expressões. As definições permitem associar nomes a objectos computacionais. As expressões podem ser constantes, nomes ou combinações.
As formas em Scheme podem ainda ser classificadas segundo uma outra dimensão ortogonal à primeira, considerando o modo de avaliação. Segundo esta classificação as formas podem ser (1) normais, se seguem as regras gerais estabelecidas para a avaliação, ou (2) especiais, se têm regras próprias de avaliação.
Capítulo 2 – Procedimentos Compostos
2.1 – Definição de Procedimentos
O processo de utilização de procedimentos definidos pelo programador compreende dois aspectos distintos: a definição do procedimento que é feita fornecendo os argumentos do procedimento e um processo de cálculo para os valores do procedimento, e a aplicação do procedimento a um valor, ou valores, do(s) seu(s) argumento(s).
Uma forma de deifnir procedimentos compostos em Scheme é através de expressões lambda. Para tal, indicam-se os argumentos do procedimento, chamados parâmetros formais, seguidos da especificação do processo de cálculo que o procedimento deve seguir.
A aplicação de um procedimento em Scheme corresponde a uma combinação em que o operador corresponde ao procedimento e os operandos correspondem aos valores aos quais queremos aplicaro o procedimento. Estes operandos que correspondem a expressões, são designados por parâmetros concretos.
Podemos combinar a operação de nomeação de procedimentos com a definição de procedimentos para dar nomes aos nossos procedimentos.
Os procedimentos compostos podem sr usados do mesmo modo que os procedimentos primitivos.
A abstracção procedimental consiste em abstrair do modo como os procedimentos realizam as suas tarefas, concentrando-se apenas na tarefa que os procedimentos realizam. Ou seja, a separação do “como” de “o que”.
Açúcar sintáctico – alternativa para tornar a notação mais simples. 2.2 – Avaliação de expressões – segunda abordagem
Para avaliar expressões, o interpretador do Scheme utiliza a seguinte regra: 1. Se a expressão é uma constante, o seu valor é a própria constante;
2. Se a expressão é um nome, o seu valor é o objecto computacional associado ao nome;
3. Se a expressão corresponde a uma forma especial, o seu valor é calculado pelas regras de avaliação para essa forma especial;
4. Se a expressão é uma combinação, aplica-se o operador da combinação aos operandos, o que é feito do seguinte modo:
a.) Avaliam-se as sub-expressões na combinação;
b.) Associam-se os parâmetros formais do procedimento correspondente à primeira expressão (o operador) com os valores das restantes sub-expressões (os parâmetros concretos). Esta associação é feita com base na ordem das sub-expressões, isto é, o primeiro parâmetro concreto é associado ao primeiro parâmetro formal e assim sucessivamente;
c.) No ambiente definido pela associação entre os parâmetros formais e os parâmetros concretos, avalia-se o corpo do procedimento correspondente à primeira sub-expressão.
2.3 – Expressões condicionais
Uma expressão condicional é uma forma especial, iniciado pela palavra cond (um símbolo terminal) e seguida por um número arbitrário de cláusulas. Cada cláusula é
constituída por uma condição (uma expressão cujo valor é verdadeiro ou falso), seguida de um número arbitrário (eventualmente zero) de expressões.
Numa expressão condicional, as condições são avaliadas pela ordem em que aparecem. Assim que uma destas condições tiver o valor verdadeiro, as expressões que lhe estão associadas na cláusula são avaliadas, representando o valor da última expressão avaliada o valor da expressão condicional.
Em Scheme, existe um nome especial, designado por else, cujo valor é sempre verdadeiro, e que apenas pode ser usado como condição na última cláusula de uma expressão condicional.
2.4.1 – Cálculo de Potencias
Para calcular a potencia de um número fazemos “o produto de x por si próprio n vezes”.
(define (potencia x n) (if (= n 1)
x
(* x (potencia x (- n 1)))))
2.4.2 – Cálculo do máximo divisor comum
O máximo divisor comum entre dois inteiros m e n, escrito mdc(m,n), é o maior inteiro p tal que ambos m e n são divisíveis por p.
O ma´ximo divisor comum entre um número e zero é o próprio número.
Quando dividimos um número por um menor, o máximo divisor comum entre o resto da divisão e o divisor é o mesmo que o máximo divisor comum entre o dividendo e o divisor.
Podemos escrever o seguinte procedimento em Scheme para calcular o máximo divisor comum entre os inteiros m e n (com o procedimento primitivo remainder). (define (mdc m n)
(if (= n 0) m
(mdc n (remainder m n)))) 2.4.3 – Cálculo do arco de tangente
Não vamos obter o valor exacto do arco de tangente, mas sim uma aproximação que seja suficientemente boa para o fim em vista.
A função arco de tangente, designada por arctg, é a função inversa da função tangente e como tal pode ser definida do seguinte modo: arctg(x) é o número y tal que x=tg(y).
Consideremos então o seguinte algoritmo para calcular arctg(x), o qual começa com uma aproximação ao valor do arco de tangente (a qual pode ser o primeiro termo da série):
- Se a aproximação estiver suficientemente próxima do valor do arco de tangente (se esta for suficientemente boa), essa aproximação será o valor do arco de tangente;
- Em caso contrário, calculamos uma aproximação melhor, por adição de mais um termo da série.
(if (boa-aprox? Aprox x) aprox
(calc – arctg x
(+ aprox (termo x n))
(+ n 1))))
O procedimento calc-arctg recebe três argumentos: (1) o valor para o qual se pretende calcular o acro de tangente, x; (2) uma aproximação dada pela soma de um certo número de termos da série, aprox; e (3) o índice do próximo termo da série que pode ser utilizado para calcular uma nova aproximação, n:
- se a aproximação fornecida for satisfatória, essa aproximação será o valor do procedimento;
- em caso contrário o procedimento gera uma nova aproximação considerando mais um termo e usa o próprio procedimento calc-arctg para decidir sobre a nova aproximação.
(define (boa-aprox? Aprox x) (< (abs (- (tg aprox) x)) 0.001)) (define (tg x) (/ (sin x) (cos x))) (define (termo x n) (* (sinal n) (termo-sem-sinal x n))) (define (sinal n) (if (odd? n) -1 1)) (define (termo-sem-sinal x n) (/ (potencia x (dobro-mais-um n)) (dobro-mais-um n))) (define (dobro-mais-um n) (+ (* 2 n) 1)) (define (arctg x) (if (< (abs x) 1) (calc-arctg x x 1)
“arctg: o módulo do argumento não é menor que 1”))
O procedimento arctg é definido através de um agrupamento de outros procedimentos. Isto corresponde à abstracção procedimental.
2.6 – Estrutura de Blocos
Um bloco corresponde a um “conjunto” de instrucções. A importância dos blocos provém das seguintes regras informais:
- cada bloco deve corresponder a um sub-problema que o procedimento correspondente tem de resolver. Este aspecto permite modularizar o programa. - O algoritmo usado por um bloco está “escondido” do resto do programa. Isto
permite controlar a complexidade do programa.
- Toda a informação definida dentro de um bloco pertence a esse bloco, e só pode ser usada por esse bloco e pelos blocos definidos dentro dele. Isto permite a protecção efectiva da informação definida em cada bloco da utilização não autorizada por parte de outros blocos.
Em Scheme, qualquer procedimento pode ser considerado como um bloco, dentro do qual podem ser definidos outros blocos.
Todos os nomes que são definidos pela forma especial define, directamente avaliada após o carácter de pronto, pertencem a um ambiente a que se chama o ambiente global.
O ambiente global contém todos os nomes que foram fornecidos directamente ao interpretador do Scheme.
Um ambiente local corresponde a uma associação entre nomes e objectos computacionais, a qual é realizada durante o processo de avaliação de uma forma. Um ambiente local “desaparece” no momento em que termina a avaliação da forma que deu origem à sua criação.
Diz-se que um nome está ligado num dado ambiente se existe um objecto computacional associado ao nome nesse ambiente.
Quando se define procedimentos dentro de outros procedimentos (blocos) e quando se define procedimentos cujos nomes são colocados no ambiente global?
A decisão de se definir procedimentos no ambiente global vai-se prender com a utilidade do procedimento e com as restrições impostas à utilização do procedimento.
Um nome local apenas tem significado dentro do corpo de uma expressão lambda.
Um nome que aparece no corpo de uma expressão lambda e que não é um nome local diz-se não local.
Define-se domínio de um nome como a gama de formas nas quais o nome é conhecido, ou seja, o conjunto das formas onde o nome pode ser utilizado.
A utilização exclusiva de nomes locais permite manter a independência entre procedimentos, no sentido em que toda a comunicação entre eles é limitada à associação dos parâmetros concretos com os parâmetros formais. Quando este tipo de comunicação é mantido, para utilizar um procedimento apenas é preciso saber o que ele faz, e não como foi programado. A isto chama-se abstracção procedimental.
O tipo de domínio utilizado em Scheme é chamado domínio estático: o domínio de um nome é definido em termos da estrutura do programa (o encadeamento dos seus blocos) e não é influenciado pelo modo como a execução do programa é feita.
Capítulo 3 – Processos Gerados por Procedimentos
Um procedimento pode ser considerado como a especificação da evoluação local de um processo computacional. Por evolução local entenda-se que o procedimento define, em cada instante, o comportamento do processo computacional, ou seja, especifica como construir cada estágio do processo a partir do estágio anterior.
3.1 – Recursão Linear
3.1.4 – Caracterização de um processo recursivo linear
Os procedimentos são caracterizados por uma fase de expansão devido à existência de operações adiadas, seguida por uma fase de contracção em que essas operações são executadas. Este padrão de evolução de um processo é muito comum em programação e tem o nome de processo recursivo.
Num processo recursivo existe uma fase de expansão correspondente à construcção de uma cadeia de operações adiadas, seguida por uma fase de contracção correspondente à execução dessas operações.
Um processo recursivo é pois caracterizado pela construcção de uma cadeia de operações adiadas.
A um processo recursivo que cresce linearmente com um valor dá-se o nome de processo recursivo linear.
3.2 – Iteração Linear
3.2.4 – Caracterização de um processo iterativo linear
Um processo iterativo é caracterizado por um certo número de variáveis, chamadas variáveis de estado, juntamente com uma regra que especifica como as actualizar. Estas variáveis fornecem uma descrição completa do estado de computação em cada momento.
Um processo iterativo não expande nem contrai.
A um processo iterativo cujo número de operações cresce linearmente como um valor dá-se o nome de processo iterativo linear.
3.3 – Recursão em processos e em procedimentos
A palavra “recursão” tem dois significados distintos conforme se refere à recursão em procedimentos ou à recursão em processos. A recursão em procedimentos refere-se à definição do procedimento em termos de si próprio ao passo que a recursão em processos refere-se ao padrão de evolução do processo.
A evolução de processos pode ser classificada como uma evolução recursiva ou como uma evolução iterativa:
- Um processo recursivo é caracterizado por uma fase de expansão (correspondente à construcção de uma cadeia de operações adiadas) seguida de uma fase de contracção (correspondente à execução dessas operações). O interpretador mantém informação “escondida” que regista o ponto onde está o processo na cadeia de operações adiadas.
- Um processo iterativo não cresce nem contrai. Este é caracterizado por um conjunto de variáveis de estado e um conjunto de regras que define como estas
variáveis evoluem. As variáveis de estado fornecem uma descrição completa do estado do processo em cada ponto.
Um procedimento recursivo tanto pode gerar um processo recursivo como um processo iterativo.
A recursão em procedimentos refere-se à definição do procedimento em termos de si próprio ao passo que a recursão em processos refere-se ao padrão de evolução do processo.
3.4 Recursão em árvore
3.4.1 Os números de Fibonacci
A recursão em árvore apresenta um comportamento que se assemelha ao processo recursivo. Tem fases de crescimento, originadas por operações adiadas, seguidas por fases de contracção em que algumas das operações adiadas são executadas.
Ao contrário do que acontece com o processo recursivo linear estamos perante a existência de múltiplas fases de crescimento e de contracção, que são originadas pela dupla recursão que existe no procedimento. A este tipo de evolução de um procedimento dá-se o nome de recursão em árvore. Esta designação deve-se ao facto da evolução do processo ter a forma de uma árvore.
Este processo é muito inefieciente, pois existem muitos cálculos que são repetidos múltiplas vezes. Para além disso, o processo utiliza um número de passos que não cresce linearmente com o valor de n. Demonstra-se que este número cresce exponencialmente com o valor de n.
3.4.2 A torre de Hanói
(define (mova n origem destino aux)
(cond ((= n 1) (mova-disco origem destino)) (else (mova (- n 1) origem aux destino)
(mova-disco origem destino)
(mova (- n 1) aux destino origem))))
Este procedimento reflecte o desenvolvimento do topo para a base: o primeiro passo para a solução de um problema consiste na identificação dos sub-problemas que o constituem, bem como a determinação da sua inter-relação.
O procedimento display aceita como argumento uma expressão e, sempre que é avaliado, força o interpretador do Scheme a escrever o valor dessa expressão.
O procedimento newline informa o interpretador do Scheme que deve começar a escrever numa nova linha.
3.5 Sequenciação
Na expressão condicional, cada cláusula permite a utilização de várias expressões, as quais são avaliadas sequencialmente pela ordem em que aparecem, correspondendo o valor da cláusula ao valor da última expressão avaliada. Cada cláusula de uma expressão condicional introduz uma sequenciação implicita, ou seja, indica implicitamente qual a sequência de avaliação das expressões que contém.
Esta explicitação de avaliação sequencial é chamada sequenciação. O operador de sequenciação, realizado através da forma especial begin, tem a seguinte sintaxe:
<operação de sequenciação> ::= (begin <expressão>+)
A avaliação de uma expressão com a operação de sequenciação causa a avaliação sequencial das várias expressões que esta contém, sendo o valor da operação de sequenciação o valor da última expressão avaliada.
A sequenciação permite especificar que uma dada sequência de expressões deve ser avaliada pela ordem em que aparece.
3.6 Ordens de crescimento
Os processos gerados por procedimentos podem diferir drasticamente quanto à taxa a que consomem recursos computacionais. Assim, um dos aspectos que vamos ter que levar em linha de conta quando escrevemos programas é a minimização dos recursos computacionais consumidos (tempo e espaço). O tempo diz respeito ao tempo que o nosso programa demora a executar, e o espaço diz respeito ao espaço de memória do computador usado pelo nosso programa.
Utiliza-se o termo complexidade de um algoritmo como uma medida dos recursos computacionais que são consumidos durante a evolução do processo que corresponde ao algoritmo (ou ao procedimento que o realiza). Os recursos utilizados por um processo não dependem apenas do algoritmo utilizado mas também do grau de dificuldade ou dimensão do problema a ser resolvido.
A ordem de crescimento de um processo é uma medida grosseira dos recursos consumidos pelo processo em função do grau de dificuldade do problema.
Se R(n) for uma medida da quantidade de recursos consumidos por um processo computacional ao resolver um problema de “grau de dificuldade” n, dizemos que R(n) tem ordem de crescimento Ө(f(n)) se:
Эk1,k2 >0 : k1 f(n) ≤ R(n) ≤ k2 f(n)
Para valores suficientemente grandes de n.
Alguns valores típicos de ordens de crescimento são: - o crescimento constante, Ө(1);
- o crescimento logarítmico, Ө(log(n)); - o crescimento linear, Ө(n);
- o crescimento polinomial, Ө(np), sendo p um número natural;
- o crescimento exponencial, Ө(kn), sendo k um número natural; - o crescimento factorial, Ө(n!).
3.6.1 Potencia rápida (define (potencia-rapida x n)
(cond ((= n 1) x)
(( odd? n) (* x (potencia-rapida x (- n 1))))
4 – Procedimentos de ordem superior
Um objecto computacional é um objecto de primeira classe se: (1) pode ser nomeado;
(2) pode ser utilizado como argumento de procedimentos; (3) pode ser devolvido por procedimentos;
(4) pode ser utilizado como componenete de estruturas de informação.
A ideia subjacente à definição de um objecto computacional como um cidadão de primeira classe é a de que todos estes objectos computacionais têm os mesmos direitos e responsabilidades.
Um dos objectos computacionais que tradicionalmente não é tratado como cidadão de primeira classe é o procedimento.
Um procedimento que recebe outros procedimentos como parâmetros ou cujo valor é um procedimento é chamado um procedimento de ordem superior ou, alternativamente, um funcional.
4.1 Procedimentos como parâmetros
Os procedimentos correspondem a abstracções que definem operações compostas, independentemente dos valores por estes utilizados.
O poder da abstracção correspondente ao somatório permite lidar com o conceito de soma em vez de tratar apenas com somas particulares. A existência da abstracção correspondente ao somatório leva-nos a pensar em definir um procedimento correspondente a este abstracção em vez de apenas utilizar procedimentos que calculam somas particulares.
Um dos processos para tornar os procedimentos mais gerais corresponde a utilizar parâmetros adicionais que indicam quais as operações a efectuar sobre os objectos computacionais manipulados pelo procedimento.
4.1.1 Procedimentos como métodos gerais
A utilização de procedimentos como argumentos de procedimentos pode originar métodos gerais de computação, independentemente dos procedimentos envolvidos.
4.2 Procedimentos produzidos por procedimentos
Em Scheme os procedimentos podem ser utilizados como argumentos de outros procedimentos. Esta utilização permite a criação de abstracções mais gerais do que as obtidas até agora.
4.2.1 Cálculo de derivadas
O conceito de derivada de uma função é suficientemente importante para ser capturado por uma abstracção.
5 – Abstracção de dados
A resolução de um problema, com ou sem auxílio do computador, obriga à escolha de uma abstracção (à criação de um modelo) dos objectos do mundo real.
A informação utilizada numa dada aplicação representa uma abstracção da realidade.
O tipo da informação vai determinar o tipo das operações a efectuar sobre essa informação.
Ao resolver um problema com o computador é essencial decididr, não só qual a informação a considerar (ou seja, qual a informação a abstrair do mundo real), mas também como representar essa informação. A decisão quanto ao modo de representar a informação é de tal modo importante que, por vezes, pode ser um factor decisivo na escolha da linguagem de programação para resolver um dado problema. A importância de utilizar uma linguagem que ofereça modos de representação adequados traduz-se não só na facilidade de desenvolvimento do programa, como também na própria fiabilidade do programa.
Em programação é importante considerar que cada objecto computacional correspondente a um dado pertence a um certo tipo. Este tipo vai caracterizar a sua possível gama de valores e as operações a que pode ser sujeito.
A utilização de tipos para caracterizar objectos que correspondem a adados é muito importante em programação. Um tipo de informação é caracterizado por um conjunto de objectos e um conjunto de operações aplicáveis a esses objectos. Ao conjunto de objectos dá-se o nome de domínio do tipo. Cada um dos objectos do domínio do tipo é designado por elemento do tipo.
Um tipo de informação é constituído por um conjunto de objectos e um conjunto de operações aplicáveis a esses objectos.
Os tipos de informação podem-se dividir em dois grandes grupos: os tipos elementares e os tipos estruturados. Os tipos elementares são caracterizados pelo facto das suas constantes (os elementos do tipo) serem tomadas como incompatíveis (ao nível da utilização do tipo). Dentro dos tipos elementares podemos considerar o tipo lógico e o tipo escalar. Os tipos estruturados são caracterizados pelo facto das suas constantes serem constituídas por um agregado de valores.
Os tipos de informação elementares contêm elementos que não são deocmponíveis; os tipos de informação estruturados contêm elementos que são compostos por várias partes.
5.1 Aritmética dos números complexos
Vamos supor que existe em Scheme os procedimentos:
(cria-compl r i) procedimento que recebe como argumentos dois números reais, r e i, e constrói o número complexo cuja parte real é r e cuja parte imaginária é i;
(parte-real c) procedimento que recebe como argumento um número complexo, c, e que produz a parte real desse número;
(parte-imag c) procedimento que recebe como argumento um número complexo, c, e que produz a parte imaginária desse número.
A representação interna de um objecto computacional corresponde à representação manipulada pelo interpretador do Scheme.
A representação externa de um objecto computacional corresponde ao modo como esse objecto computacional é mostrado pelo Scheme ao mundo exterior.
5.1.1 Complexos como procedimentos (define (compl+ c1 c2)
(cria-compl (+ (parte-real c1) (parte-real c2)) (+ (parte-imag c1) (parte-imag c2)))) define (compl- c1 c2)
(cria-compl (- (parte-real c1) (parte-real c2)) (- (parte-imag c1) (parte-imag c2)))) define (compl* c1 c2)
(cria-compl (- (* (parte-real c1) (parte-real c2)) (* (parte-imag c1) (parte-imag c2))) (+ (* (parte-real c1) (parte-real c2)) (* (parte-imag c1) (parte-imag c2))))) 5.1.2 Essência da abstracção de dados
O comportamento anterior revela a independência entre os procedimentos que efectuam operações aritméticas sobre números complexas e a representação interna de números complexos. Este comportamento foi obtido através de uma separação clara entre as operações que manipulam números complexos e a representação interna de complexos. Esta separação permitenos alterar a representação de complexos sem ter que alterar o programa que lida com números complexos.
Esta é a esseência da abstracção de dados, a separação entre: (1) o estudo das propriedados dos dados e (2) os pormenores da realização dos dados numa linguagem de programação. Esta essência é traduzida pela separação das partes do programa que lidam com o modo como os dados são utilizados das partes que lidam com o modo como os dados são representados.
A abstracção de dados consiste na separação entre as partes do programa que lidam com o modo como os dados são utilizados das partes do programa que lidam com o modo como os dados são representados.
Para definir a parte do programa que lida com o modo como os dados são utilizados, devemos identificar, para cada tipo de dados, o conjunto das operações básicas que podem ser efectuadas sobre os elementos desse tipo.
Ao definir um tipo de informação não podemos definir todas as operações que manipulam os elementos do tipo. A ideia subjacente à definição de um novo tipo é a definição do mínimo possível de operações que permitam caracterizar o tipo. Estas operações são chamadas operações básicas e dividem-se em quatro grupos, os construtores, os selectores, os reconhecedores e os testes.
(1) Os construtores permitem construir novos elementos do tipo.
(2) Os selectores permitem aceder aos constituintes dos elementos do tipo.
(3) Os reconhecedores identificam elementos do tipo. Os reconhecedores são de duas categorias. Por um lado, fazem a distinção entre os elementos do tipo e os elementos de qualquer outro tipo, reconhecendo explicitamente os elementos que pertencem ao tipo. Por outro lado, identificam elementos do tipo que se individualizam dos restantes por possuírem certas propriedades particulares. (4) Os testes efectuam comparações entre os elementos do tipo.
Os construtores, celectores, reconhecedores e testes são chamados as operações básicas do tipo de dados. O papel destas operações é construirem elementos do tipo (os construtores), seleccionarem componenetes dos elementos do tipo (os selectores) e responderem a perguntas sobre os elementos do tipo (os reconhecedores e os testes).
5.1.3 – O tipo par
Em Scheme existe um tipo primitivo chamado “par” que aglomera dois elementos quaisquer num único elemento. Embora estes dois elementos sejam aglomerados numa única entidade, é possível recuperar tanto o primeiro elemento como o segundo elemento de um par.
O tipo par é definido através das seguintes operações básicas, todas elas correspondentes a procedimentos primitivos em Scheme:
1. Construtores: O procedimento cons aceita dois argumentos de qualquer tipo e tem como valor o par constituído por estes argumentos. O primeiro argumento fornecido a cons corresponde ao primeiro elemento do par e o segundo argumento fornecido a cons corresponde ao segundo elemento do par.
2. Selectores: O procedimento car aceita como argumento um par e tem como valor o primeiro elemento desse par. O procedimento cdr aceita como argumento um par e tem como valor o segundo elemento desse par.
3. Reconhecedores: O procedimento pair? Recebe como argumento um elemento de qualquer tipo e tem o valor #t apenas no caso de este elemento ser um par.
4. Testes: Não identificaremos testes para o tipo par.
Como a representação gráfica de pares com um grande grau de encadeamento se pode tornar pesada, é tradicional representar pares como caixas duplas cujo conteúdo não está representado directamente no interior de cada cauxa, mas é representado na extremidade de uma seta que sai da caixa – esta representação é chamada notação de caixas e ponteiros.
Os pares são estruturas que verificam uma propriedade importante: um elemento de um par pode ser, por sua vez, um par.
Existe uma semelhança entre combinações e pares: as combinações podem conter sub-expressões que, por sua vez, são combinações; os pares podem conter elemento que, por sua vez, são pares.
Esta semelhança é traduzida pela propriedade do fecho: uma operação para combinar entidades satisfaz a propriedade do fecho se os resultados da combinação de entidades com essa operaão puderem ser combinados através da mesma operação.
Uma operação para combinar entidades satisfaz a propriedade do fecho se os resultados da combinação de entidades com essa operação puderem ser combinados através da mesma operação.
Resumindo, o tipo par tem as seguintes propriedades: 1. Construtores:
Cons: universal x universal -> par
Cons (e1,e2) tem como valor o par cujo primeiro elemento é e1 e cujo segundo elemento é e2.
2. Selectores: Car: par -> universal
Car (p) tem como valor o primeiro elemento do par p. Cdr: par -> universal
Cdr (p) tem como valor o segundo elemento do par p. 3. Reconhecedores:
Pair?: universal -> lógico
Pair? (arg) tem o valor verdadeiro se arg é um par e tem o valor falso em caso contrário.
Estas operações têm que estar relacionadas entre si de modo a que o seu todo defina o tipo par. De um modo geral, o conjunto das interdependências entre as operações básicas de um tipo não é simples de definir. Estas interdependências são como um conjunto de igualdades que definem uma “espécie de contrato” a que as operações envolvidas têm que obedecer. Este conjunto de igualdades é designado por axiomatização das operações básicas.
Estas igualdades correspondem às relações que devem de existir entre as operações básicas do tipo par de moodo a que estas constituam um todo coerente.
5.1.4 – Complexos como pares
Os números complexos podem ser representados por pares. A interacção é exactamente igual à obtida com a representação de complexos através de procedimentos.
5.2 – Tipos abstractos de informação
Um tipo de informação é uma colecção de entidades, chamadas os elementos do tipo, conjuntamente com uma colecção de operações que podem ser efectuadas sobre essas entidades. Estas operações constroem novas entidades, seleccionam constituintes dessas entidades, identificam e comparam elementos dessas entidades.
Metodologia dos tipos abstractos de informação: a sua essência é a separação das partes do programa que lidam com o modo como as entidades do tipo são utilizadas das partes que lidam com o modo como as entidades são representadas. Na utilização da metodologia dos tipos abstractos de informação devem ser seguidos quatro passos sequenciais: (1) a identificação das operações básicas; (2) a axiomatização das operações básicas; (3) a escolha de uma representação para os elementos do tipo; e (4) a concretização das operações básicas para a representação escolhida.
Na metodologia dos tipos abstractos de informação são seguidos quatro passos sequenciais:
1. a identificação das operações básicas; 2. a axiomatização das operações básicas;
3. a escolha de uma representação para os elementos do tipo;
4. a concretização das operações básicas para a representação escolhida.
Esta metodologia permite a definição de tipos de informação que é independente da sua representação. Esta independência leva à designação destes tipos por tipos abstractos de informação.
5.2.1 – Identificação das operações básicas
O primeiro passo na construcção de um novo tipo abstracto de informação consiste em identificar quais são as operações básicas a efectuar sobre os elementos do tipo. Estas operações dividem-se em quatro grupos, os construtores, os selectores, os reconhecedores e os testes.
As operações básicas dividem-se em quatro grupos:
1. Os construtores permitem construir novos elementos do tipo.
2. Os selectores permitem aceder (isto é, seleccionar) aos constituintes dos elementos do tipo.
3. Os reconhecedores identificam elementos do tipo. Os reconhecedores são de duas categorias. Por um lado, fazem a distinção entre os elemento do tipo e os
elementos de qualquer outro tipo, reconhecendo explicitamente os elementos que pertencem ao tipo. Por outro lado, indentificam elementos do tipo que se individualizam dos restantes por possuírem certas propriedades particulares. 4. Os testes efectuam comparações entre os elementos do tipo.
Ao conjunto das operações básicas para um dado tipo dá-se o nome de assinatura do tipo.
Devemos definir uma representação externa para complexos.
O transformador de entrada transforma a notação externa para as entidades abstractas na sua representação interna e o transformador de saída transforma a representação interna das entidades na sua representação externa.
5.2.2 – Axiomatização
A axiomatização especifica o modo como as operações básicas se relacionam entre si.
O que fazemos é especificar quais as relações obrigatoriamente existentes entre as operações básicas para que estas definam o tipo de um modo coerente.
5.2.3 – Escolha da representação
O terceiro passo na definição de um tipo de informação consiste em escolher uma representação para os elementos do tipo em termos de oturos tipos existentes. 5.2.4 – Realização das operações básicas
O último passo na definição de um tipo de informação consiste em realizar as operações básicas definidas no primeiro passo em termos da representação definida no terceiro passo.
No passo correspondente à realização das operações básicas, devemos especificar os transformadores de entrada e de saída.
5.2.5 – Barreiras de abstracção
Depois de concluídos todos os passos na definição de um tipo abstracto de informação (a definição de como todos os elementos do tipo são utilizados e a definição de como eles são representados, bem como a escrita de procedimentos correspondentes às respectivas operações), podemos juntar o conjunto de procedimentos correspondente ao tipo a um programa que utiliza o tipo como se este fosse primitivo na linguagem. O programa acede a uma conjunto de operações que são específicas do tipo e que, na realidade, caracterizam o seu comportamento como tipo de informação. Qualquer manipulação efectuada sobre uma entidade de um dado tipo deve apenas recorrer às operações básicas para esse tipo.
As linguagens de programação que foram desenvolvidas antes do aparecimento da metodologia para os tipos abstractos de informação não possuem mecanismos para garantir que toda a utilização dos elementos de um dado tipo é efectuada recorrendo exclusivamente à operações específicas desse tipo. As linguagens de programação mais recentes garantem que as manipulações efectuadas sobre os elementos de um tipo apenas utilizam as operações básicas desse tipo. Este comportamento é obtido através da utilização de dois conceitos chamados encapsulação da informação e anonimato da representação. A encapsulação da informação corresponde ao conceito de que o
conjunto de procedimentos que representa o tipo de informação engloba toda a informação referente ao tipo. Estes procedimentos estão representados dentro de um módulo que está protegido dos acessos exteriores. Este módulo comporta-se de certo modo como um bloco, com a diferença que “exporta” certas operações, permitindo apenas o acesso às operações exportadas. O anonimato da representação corresponde ao conceito de que este módulo guarda como segredo o modo escolhido para representar os elementos do tipo. O único acesso que o programa tem aos elementos do tipo é através das operações básicas definidas dentro do módulo que corresponde ao tipo.
A encapsulação da informação corresponde ao conceito de que o conjunto de procedimentos que representa o tipo de informação engloba toda a informação referente ao tipo.
O anonimato da representação corresponde ao conceito de que o módulo correspondente aos procedimentos do tipo guarda como segredo o modo esecolhido para representar os elementos do tipo.
Ao construir um novo tipo abstracto de informação estamos a criar uma nova camada conceptual na nossa linguagem a qual corresponde ao tipo definido. Esta camada é separada da camada em que o tipo não existe por barreiras de abstracção. Estas barreiras impedem qualquer acesso aos elementos do tipo que não seja feito através das operações básicas.
As barreiras de abstracção impedem qualquer acesso aos elementos do tipo que não seja feito através das operações básicas.
Em cada uma destas camadas, a barreira de abstracção separa os programas que usam a abstracção de dados (que estão situados acima da barreira) dos programas que realizam a abstracção de dados (que estão situados abaixo da barreira).
Uma má práctica de programação, deve ser sempre evitada, pelas seguintes razões:
- A manipulação directa da representação do tipo faz com que o programa seja dependente dessa representação. Suponhamos que, após o desenvolvimento do programa, decidíamos alterar a representação de números complexos de pares para outra representação qualquer. No caso de termos violado a regra da metodologia, teríamos de percorrer todo o nosso programa e alterar todas as manipulações directas da estrutura que representa o tipo.
- O programa torna-se mais difícil de escrever e de compreender, uma vez que a manipulação directa da estrutura subjacente faz com que se perca o nível de abstracção correspondente à utilização do tipo.
A vantagem das linguagens que incorporam os mecanismos da metodologia dos tipos abstractos de informação reside no facto de estas proibirem efectivamente a utilização de um tipo abstracto através da manipulação directa da sua representação: toda a informação relativa ao tipo está contida no módulo que o define, o qual guarda como segredo a representação escolhida para o tipo.
5.3 A lista como tipo abstracto
Uma lista é um tipo estruturado cujos constituintes, os elementos da lista, têm uma ordem. Existe o primeiro elemento da lista, o segundo, etc. Independentemente desta ordem, podemos inserir elementos em qualquer posição da lista e podemos remover qualquer elemento da lista.
Uma lista contém uma colecção ordenada de elementos. Podemos inserir novos elementos em qualquer posição da lista (inserindo um elemento de cada vez) e podemos remover qualquer elemento da lista.
5.3.1 Listas simplificadas
Ao manipular listas, é muito frequente o acesso ao primeiro elemento da lista e à lista que contém todos os elementos menos o primeiro. Por esta razão, começamos por apresentar um tipo de listas, a que chamamos listas simplificadas, nas quais apenas podemos manipular o primeiro elemento da lista: inserimos elementos no início da lista, acedemos ao primeiro elemento da lista e apenas removemos o primeiro elemento da lista.
Operações básicas para lista
As operações básicas para o tipo de informação lista simplificada são: 1. Construtores. Os construtores são operações que constroem listas simplificadas. As listas são o primeiro exemplo de um tipo de informação cujo tamanho pode ser arbitrariamente grande. Podemos considerar listas sem elementos, a chamada lista vazia, ou podemos considerar listas com qualquer número de elementos. Ao manipularmos listas o tamanho da lista manipulada altera-se. Às estruturas de informação cujo tamanho pode variar durante a exeução de um programa dá-se o nome de estruturas dinâmicas. Todas as estruturas dinâmicas devem ter um construtor que permite criar uma nova estrutura a partir do nada, para além de construtores que permitem adicionar elementos a uma estrutura existente.
Os construtores para o tipo lista devem pois inclui uma operação que gera listas a partir do nada.
Um outro construtor será uma operação que recebe um elemento de uma lista e uma lista, e que insere esse elemento na lista. Como nas listas simplificadas apenas podemos manipular o primeiro elemento da lista, o local em que o elemento vai ser inserido na lista corresponde à primeira posição da lista.
2. Selectores. Os selectores são operações que seleccionam partes de listas simplificadas.
Deveremos ter um selector que escolha o primeiro elemento da lista e um selector que retire o primeiro elemento da lista.
A operação primeiro recebe como argumento uma lista e tem como valor o primeiro elemento da lista.
A operação resto recebe como argumento uma lista e tem como valor a lista em que o primeiro elemento é retirado.
3. Reconhecedores. Os reconhecedores são operações que identificam listas ou tipos especiais de listas.
A operação lista? Recebe como argumento um elemento de um tipo qualquer e decide se este pertence ao tipo lista.
A operação lista-vazia? Recebe como argumento uma lista e decide se esta corresponde à lista vazia.
4. Testes. Os testes são operações que relacinam listas entre si.
A operação listas=? Recebe como argumentos duas listas e decide se estas são ou não iguais.
Deve-se estabelecer uma representação externa para listas simplificadas. Representação de listas
Uma vez definidas as operações básicas para listas simplificadas, deveremos pensar numa representação interna.
Uma lista vazia é representada pelo objecto computacional ().
Uma lista não vazia é representada por um par. O primeiro elemento do par contém a representaão do primeiro elemento da lista. O segundo elemento do par contám a representação da lista que contém todos os elementos excepto o primeiro.
Existe um procedimento primitivo chamado list que constrói uma lista com um número especificado de elementos. A execução do procedimento primitivo
(list el1 el2 ...eln)
é açúcar sintáctico para a seguinte combinação (cons el1
(cons el2
(cons ...
...
(con eln (nova-lista)))))
5.3.2 Exemplos de utilização de listas
Uma das operações comuns com listas corresponde a calcular o número de elementos da lista, a que se chama o comprimento da lista.
O procedimento comprimento conta o número de elementos que uma lista possui, independentemente do tipo a que estes elementos pertencem.
Uma outra operação quando se trabalha com listas corresponde a juntar duas listas numa única lista, a qual contém os elementos da primeira lista seguidos dos elementos da segunda lista. A operação para juntar duas listas, junta, é a seguinte:
(define (junta l1 l2)
(if (lista-vazia? L1) l2
(insere (primeiro l1) (junta (resto l1) l2))))
Uma outra operação na utilização de listas consiste na inversão da posição dos elementos da lista, produzindo uma lista com os mesmos elemtnso em ordem inversa. Os procedimentos invertem os elementos de uma lista. A diferença ente eles resda no facto de o procedimento inverte-rec gerar um processo recursivo e o procedimento inverte-iter gerar um processo iterativo.
(define (inverte-rec l) (if (lista-vazia? L)
l
(junta (inverte-rec (resto l))
(insere (primeiro l) (nova-lista))))) (define (inverte-iter l) (define (inverte-iter-aux l1 l2) (if (lista-vazia? L1) l2 (inverte-iter-aux (resto l1) (insere (primeiro l1) l2)))) (inverte-iter-aux l (nova-lista))) 5.3.3 Listas completas
Para definir as operações básicas para listas completas consideramos cada um dos grupos de operações a definir para um novo tipo de informação:
1. Construtores. Os construtores são operações que constroem listas. Os construtores para o tipo lista completa devem incluir uma operação que gera listas a partir do nada, à qual chamaremos nova_lista. Esta operação é idêntica à operação correspondente das listas simplificadas. Um outro construtor será uma operação que recebe um elemento e uma lista, e que insere esse elemento na lista. No caso das listas completas, este elemento pode ser inserido em qualquer ponde da lista. A questão que pode ser levantada é qual o local em que o elemento vai ser inserido na lista. Vai depender da aplicação que desejarmos fazer de listas. Podemos ter listas em que os elementos estão ordenados, e quando inserimos elementos escolhemos a posição de inserção de tal modo que a lista resultante se mantém ordenada. É importante não esquecer que a ideia subjacente à definição das operações básicas de um tipo é definir o número mínimo de operações que definam univocamente o tipo. No caso das listas completas, o que interessas capturar neste construtor é o facto de podermos inserir elementos em qualquer posição. Definimos um construtor chamado insere_lista, que recebe como argumentos uma lista, um elemento a inserir na lista e um inteiro positico e que produz a lista resultante da inserção do elemento na lista na posição especificada. Se o inteiro fornecido for maior do que o número de elementos da lista mais um, o valor desta operação é indefinido. 2. Selectores. Os selectores são operações que seleccionam partes de listas.
Deveremos ter um selector que escolhe qualquer elemento da lista e um selector que retire qualquer elemento da lista. A operação elem_lista recebe como argumentos uma lista e um inteiro positivo e tem como valor o elemento da lista que se encontra na posição especificada pelo inteiro. Se o inteiro fornecido for maior do que o número de elementos da lista, esta operação é indefinida. A operação resto_lista recebe como argumentos uma lista e um inteiro positivo e tem como valor a lista em que o elemento na posição especificada pelo inteiro é retirado. Se o inteiro fornecido for maior do que o número de elementos da lista, esta operação é indefinida.
3. Reconhecedores. Os reconhecedores são operações que identificam tipos de listas. As listas completas têm os mesmos reconhecedores que as listas simplificadas.
4. Testes. Os testes são operações que relacionam listas entre se. As listas completas têm os mesmos testes que as listas simplificadas.
O tipo lista completa tem as seguintes operações básicas: Construtores:
Nova_lista: {} -> lista
Nova_lista () tem como valor uma lista sem elementos. Insere_lista: elemento x inteiro x lista -> lista
Insere_lista (elm, pos, lst) tem como valor a lista que resulta de inserir o elemento elm na posição pos da lista lst. Se pos for inferior a um ou for superior ao número de elementos da lista lst mais um, o valor desta operação é indefinido.
Selectores:
Elem_lista: inteiro x lista -> elemento
Elem_lista (pos, lst) tem como valor o elemento que se encontra na posição pos da lista lst. Se pos for inferior a um ou for superior ao número de elementos da lista lst o valor desta operação é indefinido.
Resto_lista: inteiro x lista -> lista
Resto_lista (pos, lst) tem como valor a lista que resulta de remover o elemento que se encontra na posição pos da lista lst, Se pos for inferior a um ou for superior ao número de elementos da lista lst o valor desta operação é indefinido.
Reconhecedores:
Lista?: universal -> lógico
Lista? (arg) tem o valor verdadeiro se arg é uma lista e tem o valor falso em caso contrário.
Lista_vazia?: lista -> lógico
Lista_vazia?(lst) tem o valor verdadeiro se a lista lst1 é igual à lista lst2 e tem o valor falso em caso contrário.
A representação externa de listas completas é idêntica à representação externa de listas simplificadas.
Axiomatização de listas completas
Entre as operações básicas para as listas completas devem verificar-se as seguintes relações (axiomatização), nas quais l é uma lista, e é um elemento de uma lista e p é um inteiro: Lista?(nova_lista()) = verdadeiro Lista?(insere_lista(e,p,l)) = verdadeiro Lista_vazia?(nova_lista()) = verdadeiro Lista_vazia?(insere_lista(e,p,l)) = falso Insere_lista(elem_lista(p,l),p,resto_lista(p,l)) = e
Listas=?(l, insere_lista(elem_lista(p,l),p,resto_lista(p,l))) = verdadeiro Representação de listas completas
Uma vez definidas as operações básicas para listas, deveremos pensar numa representação interna de listas completas. A representação interna de listas completas é idêntica à representação interna de listas simplificadas.
Realização das operações básicas sobre listas completas
Dado que uma lista é representada por um par, ao qual apenas podemos aceder ao primeiro ou ao segundo elemento, e dado que as operações insere_lista e resto_lista necessitam de aceder a uma posição arbitrária da lista, os procedimentos correspondentes têm que percorrer a lista, elemento a elemento. O seu comportamento é obtido costruindo a lista devolvida em simultaneo com a procura da posição de inserção ou de remoção.
5.4 Funcionais sobre listas
Com a utilização de listas é vulgar recorrer a um certo número de procedimentos de ordem superior (ou funcionais). Nesta secção apresentamos alguns dos procedimentos de ordem superior aplicáveis a listas simplificadas.
Um transformador é um funcional que recebe como argumentos uma lista e uma operação aplicável aos elementos da lista, e devolve uma lista em que cada elemento resulta da aplicação da operação ao elemento correspondente da lista original.
Um filtro é um funcional que recebe como argumentos uma lista e um predicado aplicável aos elementos da lista, e devolve a lista constituída apenas pelos elementos da lista original que satisfazem o predicado.
Um acumulador é um funcional que recebe como argumentos uma lista e uma operação aplicável aos elementos da lista, e aplica sucessivamente essa operação aos elementos da lista original, devolvendo o resltado da aplicação da operação a todos os elementos da lista.
5.5 A árvore como tipo abstracto
A árvore é uma estrutura de informação muito utilizada em informática.
Uma árvore é um tipo que apresenta uma relação hierárquica entre os seus constituintes. A terminologia utilizada para os constituintes de uma árvore mistura termos provenientes das árvores que aparecem na natureza e termos provenientes de arvores genealógicas. Uma árvore pode ser vazia ou ser constituída por um elemento, a raiz da árvore, a qual domina, hierarquicamente, outras árvores. Uma raiz que apenas domina árvores vazias é chamada uma folha. As razíes das árvores dominadas são chamadas as filhas da árvore dominadora e esta árvore é chamada a mãe das árvores dominadas. A ligação entre duas raízes à chamada um ramo da árvore.
Uma árvore é uma estrutura hierárquica composta por uma raiz a qual domina outras árvores.
Existe um caso particular de árvores, as árvores binérias, em que cada raiz domina exactamente duas árvores. Uma árvore binária ou é vazia ou é constituída por uma raiz que domina duas árvores binárias, a árvore esquerda e a árvore direita.
Uma árvore binária é uma estrutura hierárquica composta por uma raiz a qual domina exactamente duas árvores binárias.
5.5.1 Operações básicas para árvores Construtores:
Os construtores para o tipo árvore devem incluir uma operação que gera árvores a partir do nada, à qual chamaremos nova_arv.
O outro construtor é uma operação que recebe como argumentos uma raiz e outras duas árvores, e que cria uma árvore com essa raiz, cujas árvores esquerda e direita são as árvores recebidas. Esta operação será chamada cria_arv.
Selectores:
De acordo com o que dissemos sobre as características das árvores, deveremos ter um selector para escolher a raiz da árvore e selectores para a àrvore esquerda e para a árvore direita. Estes selectores são chamados, respectivamente, raiz, arv_esq, arv_dir. Reconhecedores:
A operação arvore? Tem como argumento um elemento de qualquer tipo e decide se este corresonde ou não a uma árvore.
A operação arvore_vazia? Tem como argumento uma árvore e decide se esta corresponde ou não à árvore vaiza (a árvore gerada por nova_arv).
Testes:
A operação arvores=? Tem como argumentos duas árvores e decide se estas são iguais.
Resumindo, o tipo árvore tem as seguintes operações básicas as quais se referem ao tipo elemento que corresponde ao tipo dos elementos da raiz: