2.2Dados Hierárquicos e a Propriedade de Fechamento
Como vimos, os pares fornecem uma "cola" primitiva que podemos usar para
construir objetos de dados compostos. A
Figura 2.2 mostra uma maneira padrão de
visualizar um par — neste caso, o par formado por
(cons 1 2). Nesta representação, que é chamada de
notação de caixa e ponteiro, cada objeto é mostrado como um
ponteiro para uma caixa. A caixa
para um objeto primitivo contém uma representação do objeto. Por
exemplo, a caixa para um número contém um numeral. A caixa para um par é
na verdade uma caixa dupla, a parte esquerda contendo (um ponteiro para)
o car do par e a parte direita contendo o cdr.
Figura 2.2: Representação de caixa e ponteiro de
(cons 1 2).
Já vimos que cons pode ser usado para combinar não apenas
números, mas também pares. (Você usou esse fato, ou deveria ter usado,
ao fazer os Exercício 2.2 e
Exercício 2.3.) Como
consequência, os pares fornecem um bloco de construção universal a
partir do qual podemos construir todos os tipos de estruturas de dados.
A Figura 2.3 mostra duas maneiras de usar
pares para combinar os números 1, 2, 3 e 4.
Figura 2.3: Duas maneiras de combinar 1, 2, 3 e 4
usando pares.
A capacidade de criar pares cujos elementos são pares é a essência da
importância da estrutura de listas como uma ferramenta de representação.
Referimo-nos a essa capacidade como a
propriedade de fechamento de cons. Em geral, uma
operação para combinar objetos de dados satisfaz a propriedade de
fechamento se os resultados de combinar coisas com essa operação podem
ser combinados usando a mesma operação.72
O fechamento é a chave para o poder em qualquer meio de combinação
porque nos permite criar estruturas hierárquicas — estruturas feitas de partes, que por sua vez são
feitas de partes, e assim por diante.
Desde o início do Capítulo 1,
usamos essencialmente o fechamento ao lidar com procedimentos, porque
todos, exceto os programas mais simples, dependem do fato de que os
elementos de uma combinação podem ser combinações. Nesta seção,
abordamos as consequências do fechamento para dados compostos.
Descrevemos algumas técnicas convencionais para usar pares para
representar sequências e árvores, e exibimos uma linguagem gráfica que
ilustra o fechamento de uma maneira vívida.73
2.2.1Representando Sequências
Uma das estruturas úteis que podemos construir com pares é uma
sequência — uma coleção ordenada de
objetos de dados. Existem, é claro, muitas maneiras de representar
sequências em termos de pares. Uma representação particularmente direta
é ilustrada na Figura 2.4, onde a
sequência 1, 2, 3, 4 é representada como uma cadeia de pares. O
car de cada par é o item correspondente na cadeia, e o
cdr do par é o próximo par na cadeia. O cdr do
par final sinaliza o fim da sequência apontando para um valor
distinguido que não é um par, representado em diagramas de caixa e
ponteiro como uma linha diagonal e em programas como o valor da variável
nil. A sequência inteira é construída por operações
cons aninhadas:
(cons 1
(cons 2
(cons 3
(cons 4 nil))))
Figura 2.4: A sequência 1, 2, 3, 4 representada
como uma cadeia de pares.
Essa sequência de pares, formada por cons aninhados, é
chamada de lista, e Scheme fornece uma
primitiva chamada list para ajudar na construção de
listas.74
A sequência acima poderia ser produzida por (list 1 2 3 4).
Em geral,
(list ⟨a₁⟩ ⟨a₂⟩ … ⟨aₙ⟩)
é equivalente a
(cons ⟨a₁⟩
(cons ⟨a₂⟩
(cons …
(cons ⟨aₙ⟩
nil)…)))
Os sistemas Lisp convencionalmente imprimem listas imprimindo a
sequência de elementos, entre parênteses. Assim, o objeto de dados na
Figura 2.4 é impresso como
(1 2 3 4):
Cuidado para não confundir a expressão (list 1 2 3 4) com a
lista (1 2 3 4), que é o resultado obtido quando a
expressão é avaliada. Tentar avaliar a expressão
(1 2 3 4) sinalizará um erro quando o interpretador tentar
aplicar o procedimento 1 aos argumentos 2,
3, 4.
Podemos pensar em car como selecionando o primeiro item da
lista, e em cdr como selecionando a sublista consistindo de
todos os itens, exceto o primeiro. Aplicações aninhadas de
car e cdr podem ser usadas para extrair o
segundo, terceiro e itens subsequentes na lista.75
O construtor cons faz uma lista como a original, mas com um
item adicional no início.
O valor de nil, usado para terminar a cadeia de pares, pode
ser pensado como uma sequência de nenhum elemento, a
lista vazia. A palavra
nil é uma contração da palavra latina
nihil, que significa "nada".76
Operações com Listas
O uso de pares para representar sequências de elementos como listas é
acompanhado por técnicas convencionais de programação para manipular
listas, "descendo" sucessivamente as listas com cdr. Por
exemplo, o procedimento list-ref toma como argumentos uma
lista e um número
e retorna o
item da lista. É costume numerar os elementos da lista começando com 0.
O método para calcular list-ref é o seguinte:
Para
, list-ref deve retornar o car da lista.
Caso contrário, list-ref deve retornar o
-ésimo item do cdr da lista.
Muitas vezes "descemos" a lista inteira. Para ajudar nisso, Scheme
inclui um predicado primitivo null?, que testa se seu
argumento é a lista vazia. O procedimento length, que
retorna o número de itens em uma lista, ilustra esse padrão típico de
uso:
O procedimento length implementa um plano recursivo
simples. O passo de redução é:
O length de qualquer lista é 1 mais o
length do cdr da lista.
Isso é aplicado sucessivamente até chegarmos ao caso base:
O length da lista vazia é 0.
Também poderíamos calcular length em um estilo iterativo:
(define (length items)
(define (length-iter a count)
(if (null? a)
count
(length-iter (cdr a)
(+ 1 count))))
(length-iter items 0))
Outra técnica convencional de programação é "construir" uma lista de
respostas enquanto "descemos" uma lista, como no procedimento
append, que toma duas listas como argumentos e combina seus
elementos para fazer uma nova lista:
Exercício 2.17: Defina
um procedimento last-pair que retorna a lista que contém
apenas o último elemento de uma lista dada (não vazia):
(last-pair (list 23 72 149 34))
(34)
Exercício 2.18: Defina
um procedimento reverse que toma uma lista como argumento
e retorna uma lista dos mesmos elementos em ordem inversa:
(reverse (list 1 4 9 16 25))
(25 16 9 4 1)
Exercício 2.19:
Considere o programa de contagem de moedas de
1.2.2. Seria bom poder
facilmente mudar a moeda usada pelo programa, para que pudéssemos
calcular o número de maneiras de trocar uma libra britânica, por
exemplo. Como o programa está escrito, o conhecimento da moeda está
parcialmente no procedimento first-denomination e
parcialmente no procedimento count-change (que sabe que
há cinco tipos de moedas dos EUA). Seria melhor poder fornecer uma
lista de moedas a serem usadas para fazer a troca.
Queremos reescrever o procedimento cc para que seu
segundo argumento seja uma lista dos valores das moedas a serem
usadas, em vez de um inteiro especificando quais moedas usar.
Poderíamos então ter listas que definem cada tipo de moeda:
Para fazer isso, será necessário mudar o programa cc um
pouco. Ele ainda terá a mesma forma, mas acessará seu segundo
argumento de forma diferente, como segue:
Defina os procedimentos first-denomination,
except-first-denomination e no-more? em
termos de operações primitivas sobre estruturas de listas. A ordem da
lista coin-values afeta a resposta produzida por
cc? Por que ou por que não?
Exercício 2.20: Os
procedimentos +, * e list tomam
um número arbitrário de argumentos. Uma maneira de definir tais
procedimentos é usar define com
notação de cauda pontilhada. Em uma definição de
procedimento, uma lista de parâmetros que tem um ponto antes do último
nome do parâmetro indica que, quando o procedimento é chamado, os
parâmetros iniciais (se houver) terão como valores os argumentos
iniciais, como de costume, mas o valor do parâmetro final será uma
lista de quaisquer argumentos
restantes. Por exemplo, dada a definição
(define (f x y . z) ⟨body⟩)
o procedimento f pode ser chamado com dois ou mais
argumentos. Se avaliarmos
(f 1 2 3 4 5 6)
então, no corpo de f, x será 1,
y será 2 e z será a lista
(3 4 5 6). Dada a definição
(define (g . w) ⟨body⟩)
o procedimento g pode ser chamado com zero ou mais
argumentos. Se avaliarmos
(g 1 2 3 4 5 6)
então, no corpo de g, w será a lista
(1 2 3 4 5 6).77
Use essa notação para escrever um procedimento
same-parity que toma um ou mais inteiros e retorna uma
lista de todos os argumentos que têm a mesma paridade (par ou ímpar)
que o primeiro argumento. Por exemplo,
Uma operação extremamente útil é aplicar alguma transformação a cada
elemento em uma lista e gerar a lista de resultados. Por exemplo, o
seguinte procedimento escala cada número em uma lista por um fator dado:
Podemos abstrair essa ideia geral e capturá-la como um padrão comum
expresso como um procedimento de ordem superior, assim como em
1.3. O procedimento de ordem
superior aqui é chamado map. Map toma como
argumentos um procedimento de um argumento e uma lista, e retorna uma
lista dos resultados produzidos pela aplicação do procedimento a cada
elemento na lista:78
Map é uma construção importante, não apenas porque captura
um padrão comum, mas porque estabelece um nível mais alto de abstração
ao lidar com listas. Na definição original de scale-list, a
estrutura recursiva do programa chama a atenção para o processamento
elemento por elemento da lista. Definir scale-list em
termos de map suprime esse nível de detalhe e enfatiza que
a escala transforma uma lista de elementos em uma lista de resultados. A
diferença entre as duas definições não é que o computador está
realizando um processo diferente (não está), mas que pensamos sobre o
processo de maneira diferente. Efetivamente, map ajuda a
estabelecer uma barreira de abstração que isola a implementação de
procedimentos que transformam listas dos detalhes de como os elementos
da lista são extraídos e combinados. Como as barreiras mostradas na
Figura 2.1, essa abstração nos
dá a flexibilidade de mudar os detalhes de baixo nível de como as
sequências são implementadas, enquanto preserva o quadro conceitual de
operações que transformam sequências em sequências. A seção
2.2.3 expande esse uso de sequências
como um quadro para organizar programas.
Exercício 2.21: O
procedimento square-list toma uma lista de números como
argumento e retorna uma lista dos quadrados desses números.
(square-list (list 1 2 3 4))
(1 4 9 16)
Aqui estão duas definições diferentes de square-list.
Complete ambas preenchendo as expressões faltantes:
Exercício 2.23: O
procedimento for-each é semelhante a map.
Ele toma como argumentos um procedimento e uma lista de elementos. No
entanto, em vez de formar uma lista dos resultados,
for-each apenas aplica o procedimento a cada um dos
elementos, da esquerda para a direita. Os valores retornados pela
aplicação do procedimento aos elementos não são usados —
for-each é usado com procedimentos que realizam uma ação,
como imprimir. Por exemplo,
O valor retornado pela chamada a for-each (não ilustrado
acima) pode ser algo arbitrário, como verdadeiro. Dê uma implementação
de for-each.
2.2.2Estruturas Hierárquicas
A representação de sequências em termos de listas generaliza
naturalmente para representar sequências cujos elementos podem ser
sequências. Por exemplo, podemos considerar o objeto
((1 2) 3 4) construído por
(cons (list 1 2) (list 3 4))
como uma lista de três itens, o primeiro dos quais é uma lista,
(1 2). De fato, isso é sugerido pela forma na qual o
resultado é impresso pelo interpretador. A
Figura 2.5 mostra a representação dessa
estrutura em termos de pares.
Figura 2.5: Estrutura formada por
(cons (list 1 2) (list 3 4)).
Outra maneira de pensar em sequências cujos elementos são sequências é
como árvores. Os elementos da
sequência são os ramos da árvore, e os elementos que são eles mesmos
sequências são subárvores. A
Figura 2.6 mostra a estrutura na
Figura 2.5 vista como uma árvore.
Figura 2.6: A estrutura de lista na
Figura 2.5 vista como uma árvore.
A recursão é uma ferramenta natural para lidar com estruturas de
árvores, já que muitas vezes podemos reduzir operações em árvores a
operações em seus ramos, que por sua vez se reduzem a operações nos
ramos dos ramos, e assim por diante, até chegarmos às folhas da árvore.
Como exemplo, compare o procedimento length de
2.2.1 com o procedimento
count-leaves, que retorna o número total de folhas de uma
árvore:
(define x (cons (list 1 2) (list 3 4)))
(length x)
3
(count-leaves x)
4
(list x x)
(((1 2) 3 4) ((1 2) 3 4))
(length (list x x))
2
(count-leaves (list x x))
8
Para implementar count-leaves, lembre-se do plano recursivo
para calcular length:
O length de uma lista x é 1 mais o
length do cdr de x.
O length da lista vazia é 0.
Count-leaves é semelhante. O valor para a lista vazia é o
mesmo:
O count-leaves da lista vazia é 0.
Mas no passo de redução, onde retiramos o car da lista,
devemos levar em conta que o car pode ser uma árvore cujas
folhas precisamos contar. Assim, o passo de redução apropriado é
O count-leaves de uma árvore x é
count-leaves do car de x mais
count-leaves do cdr de x.
Finalmente, ao tirar cars, chegamos a folhas reais, então
precisamos de outro caso base:
O count-leaves de uma folha é 1.
Para ajudar a escrever procedimentos recursivos em árvores, Scheme
fornece o predicado primitivo pair?, que testa se seu
argumento é um par. Aqui está o procedimento completo:79
Exercício 2.24: Suponha
que avaliamos a expressão (list 1 (list 2 (list 3 4))).
Dê o resultado impresso pelo interpretador, a estrutura correspondente
de caixa e ponteiro, e a interpretação disso como uma árvore (como na
Figura 2.6).
Exercício 2.25: Dê
combinações de cars e cdrs que selecionarão
7 de cada uma das seguintes listas:
(1 3 (5 7) 9)
((7))
(1 (2 (3 (4 (5 (6 7))))))
Exercício 2.26: Suponha
que definimos x e y como duas listas:
(define x (list 1 2 3))
(define y (list 4 5 6))
Qual resultado é impresso pelo interpretador em resposta à avaliação
de cada uma das seguintes expressões:
(append x y)
(cons x y)
(list x y)
Exercício 2.27:
Modifique seu procedimento reverse do
Exercício 2.18 para produzir um
procedimento deep-reverse que toma uma lista como
argumento e retorna como seu valor a lista com seus elementos
invertidos e com todas as sublistas invertidas também. Por exemplo,
Exercício 2.28: Escreva
um procedimento fringe que toma como argumento uma árvore
(representada como uma lista) e retorna uma lista cujos elementos são
todas as folhas da árvore arranjadas da esquerda para a direita. Por
exemplo,
Exercício 2.29: Um
móvel binário consiste em dois ramos, um ramo esquerdo e um ramo
direito. Cada ramo é uma haste de um certo comprimento, da qual pende
ou um peso ou outro móvel binário. Podemos representar um móvel
binário usando dados compostos, construindo-o a partir de dois ramos
(por exemplo, usando list):
(define (make-mobile left right)
(list left right))
Um ramo é construído a partir de um length (que deve ser
um número) junto com uma structure, que pode ser um
número (representando um peso simples) ou outro móvel:
Escreva os seletores correspondentes left-branch e
right-branch, que retornam os ramos de um móvel, e
branch-length e branch-structure, que
retornam os componentes de um ramo.
Usando seus seletores, defina um procedimento
total-weight que retorna o peso total de um móvel.
Um móvel é dito equilibrado se
o torque aplicado por seu ramo superior esquerdo é igual ao torque
aplicado por seu ramo superior direito (ou seja, se o comprimento da
haste esquerda multiplicado pelo peso pendurado nessa haste é igual
ao produto correspondente para o lado direito) e se cada um dos
submóveis pendurados em seus ramos é equilibrado. Projete um
predicado que testa se um móvel binário é equilibrado.
Suponha que mudamos a representação de móveis para que os
construtores sejam
(define (make-mobile left right)
(cons left right))
(define (make-branch length structure)
(cons length structure))
Quanto você precisa mudar seus programas para se converter para a
nova representação?
Mapeando sobre árvores
Assim como map é uma abstração poderosa para lidar com
sequências, map junto com recursão é uma abstração poderosa
para lidar com árvores. Por exemplo, o procedimento
scale-tree, análogo ao scale-list de
2.2.1, toma como argumentos um fator
numérico e uma árvore cujas folhas são números. Ele retorna uma árvore
da mesma forma, onde cada número é multiplicado pelo fator. O plano
recursivo para scale-tree é semelhante ao de
count-leaves:
Outra maneira de implementar scale-tree é tratar a árvore
como uma sequência de subárvores e usar map. Mapeamos sobre
a sequência, escalando cada subárvore por sua vez, e retornamos a lista
de resultados. No caso base, onde a árvore é uma folha, simplesmente
multiplicamos pelo fator:
Muitas operações em árvores podem ser implementadas por combinações
semelhantes de operações de sequência e recursão.
Exercício 2.30: Defina
um procedimento square-tree análogo ao procedimento
square-list do
Exercício 2.21. Ou seja,
square-tree deve se comportar da seguinte forma:
Defina square-tree tanto diretamente (ou seja, sem usar
nenhum procedimento de ordem superior) quanto usando
map e recursão.
Exercício 2.31:
Abstraia sua resposta ao
Exercício 2.30 para produzir um
procedimento tree-map com a propriedade de que
square-tree poderia ser definido como
Exercício 2.32: Podemos
representar um conjunto como uma lista de elementos distintos, e
podemos representar o conjunto de todos os subconjuntos do conjunto
como uma lista de listas. Por exemplo, se o conjunto é
(1 2 3), então o conjunto de todos os subconjuntos é
(() (3) (2) (2 3) (1) (1 3) (1 2) (1 2 3)). Complete a
seguinte definição de um procedimento que gera o conjunto de
subconjuntos de um conjunto e dê uma explicação clara de por que ele
funciona:
No trabalho com dados compostos, enfatizamos como a abstração de dados
nos permite projetar programas sem nos envolvermos nos detalhes das
representações de dados, e como a abstração preserva para nós a
flexibilidade de experimentar com representações alternativas. Nesta
seção, introduzimos outro princípio poderoso de design para trabalhar
com estruturas de dados — o uso de
interfaces convencionais.
Em 1.3, vimos como abstrações de
programas, implementadas como procedimentos de ordem superior, podem
capturar padrões comuns em programas que lidam com dados numéricos.
Nossa capacidade de formular operações análogas para trabalhar com dados
compostos depende crucialmente do estilo em que manipulamos nossas
estruturas de dados. Considere, por exemplo, o seguinte procedimento,
análogo ao procedimento count-leaves de
2.2.2, que toma uma árvore como
argumento e calcula a soma dos quadrados das folhas que são ímpares:
Na superfície, este procedimento é muito diferente do seguinte, que
constrói uma lista de todos os números de Fibonacci
, onde
é menor ou igual a um dado inteiro
:
(define (even-fibs n)
(define (next k)
(if (> k n)
nil
(let ((f (fib k)))
(if (even? f)
(cons f (next (+ k 1)))
(next (+ k 1))))))
(next 0))
Apesar do fato de que esses dois procedimentos são estruturalmente muito
diferentes, uma descrição mais abstrata das duas computações revela uma
grande semelhança. O primeiro programa
enumera as folhas de uma árvore;
filtra-as, selecionando as ímpares;
eleva ao quadrado cada uma das selecionadas; e
acumula os resultados usando +, começando com 0.
O segundo programa
enumera os inteiros de 0 a
;
calcula o número de Fibonacci para cada inteiro;
filtra-os, selecionando os pares; e
acumula os resultados usando cons, começando com a lista
vazia.
Um engenheiro de processamento de sinais acharia natural conceituar
esses processos em termos de sinais fluindo através de uma cascata de
estágios, cada um dos quais implementa parte do plano do programa, como
mostrado na Figura 2.7. No
sum-odd-squares, começamos com um
enumerador, que gera um "sinal"
consistindo nas folhas de uma árvore dada. Esse sinal é passado por um
filtro, que elimina todos os
elementos exceto os ímpares. O sinal resultante é então passado por um
map, que é um "transdutor" que aplica o
procedimento square a cada elemento. A saída do map é então
alimentada para um acumulador,
que combina os elementos usando +, começando de um 0
inicial. O plano para even-fibs é análogo.
Figura 2.7: Os planos de fluxo de sinal para os
procedimentos sum-odd-squares (topo) e
even-fibs (fundo) revelam a comunalidade entre os dois
programas.
Infelizmente, as duas definições de procedimentos acima não exibem essa
estrutura de fluxo de sinal. Por exemplo, se examinarmos o procedimento
sum-odd-squares, descobrimos que a enumeração é
implementada parcialmente pelos testes null? e
pair? e parcialmente pela estrutura recursiva da árvore do
procedimento. Da mesma forma, a acumulação é encontrada parcialmente nos
testes e parcialmente na adição usada na recursão. Em geral, não há
partes distintas de qualquer procedimento que correspondam aos elementos
na descrição do fluxo de sinal. Nossos dois procedimentos decompõem as
computações de uma maneira diferente, espalhando a enumeração pelo
programa e misturando-a com o map, o filtro e a acumulação. Se
pudéssemos organizar nossos programas para tornar a estrutura de fluxo
de sinal manifesta nos procedimentos que escrevemos, isso aumentaria a
clareza conceitual do código resultante.
Operações de Sequência
A chave para organizar programas de forma a refletir mais claramente a
estrutura de fluxo de sinal é concentrar-se nos "sinais" que fluem de um
estágio no processo para o próximo. Se representarmos esses sinais como
listas, então podemos usar operações de lista para implementar o
processamento em cada um dos estágios. Por exemplo, podemos implementar
os estágios de mapeamento dos diagramas de fluxo de sinal usando o
procedimento map de 2.2.1:
(map square (list 1 2 3 4 5))
(1 4 9 16 25)
Filtrar uma sequência para selecionar apenas os elementos que satisfazem
um predicado dado é realizado por:
Tudo o que resta para implementar diagramas de fluxo de sinal é enumerar
a sequência de elementos a serem processados. Para
even-fibs, precisamos gerar a sequência de inteiros em um
determinado intervalo, o que podemos fazer da seguinte forma:
Agora podemos reformular sum-odd-squares e
even-fibs como nos diagramas de fluxo de sinal. Para
sum-odd-squares, enumeramos a sequência de folhas da
árvore, filtramos para manter apenas os números ímpares na sequência,
elevamos ao quadrado cada elemento e somamos os resultados:
Para even-fibs, enumeramos os inteiros de 0 a
, geramos o número de Fibonacci para cada um desses inteiros, filtramos
a sequência resultante para manter apenas os elementos pares e
acumulamos os resultados em uma lista:
O valor de expressar programas como operações de sequência é que isso
nos ajuda a fazer designs de programas que são modulares, ou seja,
designs que são construídos combinando peças relativamente
independentes. Podemos incentivar o design modular fornecendo uma
biblioteca de componentes padrão junto com uma interface convencional
para conectar os componentes de maneiras flexíveis.
A construção modular é uma estratégia poderosa para controlar a
complexidade no design de engenharia. Em aplicações reais de
processamento de sinais, por exemplo, os designers regularmente
constroem sistemas por cascateamento de elementos selecionados de
famílias padronizadas de filtros e transdutores. Da mesma forma, as
operações de sequência fornecem uma biblioteca de elementos de programa
padrão que podemos misturar e combinar. Por exemplo, podemos reutilizar
peças dos procedimentos sum-odd-squares e
even-fibs em um programa que constrói uma lista dos
quadrados dos primeiros
números de Fibonacci:
Também podemos formular aplicações convencionais de processamento de
dados em termos de operações de sequência. Suponha que temos uma
sequência de registros de pessoal e queremos encontrar o salário do
programador mais bem pago. Suponha que temos um seletor
salary que retorna o salário de um registro, e um predicado
programmer? que testa se um registro é para um programador.
Então podemos escrever:
Esses exemplos dão apenas uma ideia da vasta gama de operações que podem
ser expressas como operações de sequência.81
Sequências, implementadas aqui como listas, servem como uma interface
convencional que nos permite combinar módulos de processamento. Além
disso, quando representamos estruturas uniformemente como sequências,
temos localizado as dependências de estrutura de dados em nossos
programas para um pequeno número de operações de sequência. Ao mudar
essas, podemos experimentar com representações alternativas de
sequências, enquanto deixamos o design geral de nossos programas
intacto. Vamos explorar essa capacidade em
3.5, quando generalizarmos o
paradigma de processamento de sequências para admitir sequências
infinitas.
Exercício 2.33:
Preencha as expressões faltantes para completar as seguintes
definições de algumas operações básicas de manipulação de listas como
acumulações:
Exercício 2.34: Avaliar
um polinômio em
em
um dado valor de
pode ser formulado como uma acumulação. Avaliamos o polinômio
usando um algoritmo bem conhecido chamado
Regra de Horner, que
estrutura a computação como
Em outras palavras, começamos com
, multiplicamos por
, adicionamos
, multiplicamos por
, e assim por diante, até chegarmos a
.82
Preencha o seguinte modelo para produzir um procedimento que avalia um
polinômio usando a Regra de Horner. Suponha que os coeficientes do
polinômio estão arranjados em uma sequência, de
até
.
Exercício 2.36: O
procedimento accumulate-n é similar a
accumulate, exceto que ele toma como seu terceiro
argumento uma sequência de sequências, que são todas assumidas como
tendo o mesmo número de elementos. Ele aplica o procedimento de
acumulação designado para combinar todos os primeiros elementos das
sequências, todos os segundos elementos das sequências, e assim por
diante, e retorna uma sequência dos resultados. Por exemplo, se
s é uma sequência contendo quatro sequências,
((1 2 3) (4 5 6) (7 8 9) (10 11 12)), então o valor de
(accumulate-n + 0 s) deve ser a sequência
(22 26 30). Preencha as expressões faltantes na seguinte
definição de accumulate-n:
(define (accumulate-n op init seqs)
(if (null? (car seqs))
nil
(cons (accumulate op init ⟨??⟩)
(accumulate-n op init ⟨??⟩))))
Exercício 2.37: Suponha
que representamos vetores v =
como sequências de números, e matrizes m =
como sequências de vetores (as linhas da matriz). Por exemplo, a
matriz
é representada como a sequência
((1 2 3 4) (4 5 6 6) (6 7 8 9)). Com essa representação,
podemos usar operações de sequência para expressar de forma concisa as
operações básicas de matriz e vetor. Essas operações (que são
descritas em qualquer livro de álgebra linear) são as seguintes:
(define (dot-product v w)
(accumulate + 0 (map * v w)))
Preencha as expressões faltantes nos seguintes procedimentos para
computar as outras operações de matriz. (O procedimento
accumulate-n é definido em
Exercício 2.36.)
Exercício 2.38: O
procedimento accumulate também é conhecido como
fold-right, porque combina o primeiro elemento da
sequência com o resultado de combinar todos os elementos à direita. Há
também um fold-left, que é similar ao
fold-right, exceto que ele combina elementos trabalhando
na direção oposta:
(define (fold-left op initial sequence)
(define (iter result rest)
(if (null? rest)
result
(iter (op result (car rest))
(cdr rest))))
(iter initial sequence))
Podemos estender o paradigma de sequência para incluir muitas
computações que são comumente expressas usando loops aninhados.84
Considere este problema: Dado um inteiro positivo
, encontre todos os pares ordenados de inteiros positivos distintos
e
, onde
, tal que
é primo. Por exemplo, se
é 6,
então os pares são os seguintes:
Uma maneira natural de organizar essa computação é gerar a sequência de
todos os pares ordenados de inteiros positivos menores ou iguais a
, filtrar para selecionar aqueles pares cuja soma é prima, e então,
para cada par
que passa pelo filtro, produzir o triplo
.
Aqui está uma maneira de gerar a sequência de pares: Para cada inteiro
, enumeramos os inteiros
, e para cada
e
geramos o par
. Em termos de operações de sequência, mapeamos ao longo da sequência
(enumerate-interval 1 n). Para cada
nessa
sequência, mapeamos ao longo da sequência
(enumerate-interval 1 (- i 1)). Para cada
nessa
última sequência, geramos o par (list i j). Isso nos dá uma
sequência de pares para cada
. Combinando todas as sequências para todos os
(por
acumulação com append) produz a sequência necessária de
pares:85
(accumulate
append
nil
(map (lambda (i)
(map (lambda (j)
(list i j))
(enumerate-interval
1
(- i 1))))
(enumerate-interval 1 n)))
A combinação de mapeamento e acumulação com append é tão
comum nesse tipo de programa que vamos isolá-la como um procedimento
separado:
Agora filtre essa sequência de pares para encontrar aqueles cuja soma é
prima. O predicado do filtro é chamado para cada elemento da sequência;
seu argumento é um par e ele deve extrair os inteiros do par. Assim, o
predicado para aplicar a cada elemento na sequência é:
Finalmente, gere a sequência de resultados mapeando sobre os pares
filtrados usando o seguinte procedimento, que constrói um triplo
consistindo dos dois elementos do par junto com sua soma:
Mapeamentos aninhados também são úteis para sequências que não enumeram
intervalos. Suponha que desejamos gerar todas as permutações de um
conjunto
isto é, todas as maneiras de ordenar os itens no conjunto. Por exemplo,
as permutações de
são
,
,
,
,
, e
. Aqui está um plano para gerar as permutações de
: Para cada item
em
, gere recursivamente a sequência de permutações de
,86
e adicione
à
frente de cada uma. Isso produz, para cada
em
, a sequência de permutações de
que
começam com
. Combinando essas sequências para todos os
dá
todas as permutações de
:87
(define (permutations s)
(if (null? s) ; conjunto vazio?
(list nil) ; sequência contendo o conjunto vazio
(flatmap (lambda (x)
(map (lambda (p)
(cons x p))
(permutations
(remove x s))))
s)))
Observe como essa estratégia reduz o problema de gerar permutações de
ao
problema de gerar as permutações de conjuntos com menos elementos que
. No caso terminal, trabalhamos até a lista vazia, que representa um
conjunto sem elementos. Para isso, geramos (list nil), que
é uma sequência com um item, ou seja, o conjunto sem elementos. O
procedimento remove usado em
permutations retorna todos os itens em uma sequência dada,
exceto um item dado. Isso pode ser expresso como um simples filtro:
Exercício 2.40: Defina
um procedimento unique-pairs que, dado um inteiro
, gera a sequência de pares
com
. Use unique-pairs para simplificar a definição de
prime-sum-pairs dada acima.
Exercício 2.41: Escreva
um procedimento para encontrar todos os triplos ordenados de inteiros
positivos distintos
, , e
menores ou iguais a um dado inteiro
que
somam a um dado inteiro
.
Exercício 2.42: O
"quebra-cabeça das oito rainhas" pergunta como colocar oito rainhas em
um tabuleiro de xadrez de forma que nenhuma rainha esteja em cheque de
qualquer outra (ou seja, nenhuma duas rainhas estão na mesma linha,
coluna ou diagonal). Uma solução possível é mostrada na
Figura 2.8. Uma maneira de resolver o
quebra-cabeça é trabalhar através do tabuleiro, colocando uma rainha
em cada coluna. Uma vez que colocamos
rainhas, devemos colocar a
rainha em uma posição onde ela não esteja em cheque de nenhuma das
rainhas já no tabuleiro. Podemos formular essa abordagem
recursivamente: Assuma que já geramos a sequência de todas as maneiras
possíveis de colocar
rainhas nas primeiras
colunas do tabuleiro. Para cada uma dessas maneiras, gere um conjunto
estendido de posições colocando uma rainha em cada linha da
coluna. Agora filtre essas, mantendo apenas as posições para as quais
a rainha na
coluna está segura em relação às outras rainhas. Isso produz a
sequência de todas as maneiras de colocar
rainhas nas primeiras
colunas. Continuando esse processo, produziremos não apenas uma
solução, mas todas as soluções para o quebra-cabeça.
Figura 2.8: Uma solução para o quebra-cabeça das
oito rainhas.
Implementamos essa solução como um procedimento queens,
que retorna uma sequência de todas as soluções para o problema de
colocar
rainhas em um tabuleiro de xadrez
. Queens tem um procedimento interno
queen-cols que retorna a sequência de todas as maneiras
de colocar rainhas nas primeiras
colunas do tabuleiro.
(define (queens board-size)
(define (queen-cols k)
(if (= k 0)
(list empty-board)
(filter
(lambda (positions)
(safe? k positions))
(flatmap
(lambda (rest-of-queens)
(map (lambda (new-row)
(adjoin-position
new-row
k
rest-of-queens))
(enumerate-interval
1
board-size)))
(queen-cols (- k 1))))))
(queen-cols board-size))
Neste procedimento, rest-of-queens é uma maneira de
colocar
rainhas nas primeiras
colunas, e new-row é uma linha proposta na qual colocar a
rainha para a
coluna. Complete o programa implementando a representação para
conjuntos de posições do tabuleiro, incluindo o procedimento
adjoin-position, que adiciona uma nova posição de
linha-coluna a um conjunto de posições, e empty-board,
que representa um conjunto vazio de posições. Você também deve
escrever o procedimento safe?, que determina, para um
conjunto de posições, se a rainha na
coluna está segura em relação às outras. (Note que precisamos apenas
verificar se a nova rainha está segura—as outras rainhas já estão
garantidamente seguras em relação umas às outras.)
Exercício 2.43: Louis
Reasoner está tendo um tempo terrível fazendo
Exercício 2.42. Seu procedimento
queens parece funcionar, mas roda extremamente devagar.
(Louis nunca consegue esperar o suficiente para resolver até mesmo o
caso
.) Quando Louis pede ajuda a Eva Lu Ator, ela aponta que ele trocou a
ordem dos mapeamentos aninhados no flatmap, escrevendo-o
como:
(flatmap
(lambda (new-row)
(map (lambda (rest-of-queens)
(adjoin-position
new-row k rest-of-queens))
(queen-cols (- k 1))))
(enumerate-interval 1 board-size))
Explique por que essa troca faz o programa rodar devagar. Estime
quanto tempo o programa de Louis levará para resolver o quebra-cabeça
das oito rainhas, assumindo que o programa em
Exercício 2.42 resolve o
quebra-cabeça em tempo
.
2.2.4Exemplo: Uma Linguagem de Imagens
Esta seção apresenta uma linguagem simples para desenhar imagens que
ilustra o poder da abstração de dados e do fechamento, e também explora
procedimentos de ordem superior de maneira essencial. A linguagem é
projetada para facilitar a experimentação com padrões como os da
Figura 2.9, que são compostos de elementos
repetidos que são deslocados e escalados.88
Nessa linguagem, os objetos de dados sendo combinados são representados
como procedimentos em vez de estruturas de lista. Assim como
cons, que satisfaz a propriedade de fechamento, nos
permitiu construir facilmente estruturas de lista arbitrariamente
complicadas, as operações nessa linguagem, que também satisfazem a
propriedade de fechamento, nos permitem construir facilmente padrões
arbitrariamente complicados.
Figura 2.9: Designs gerados com a linguagem de
imagens.
A linguagem de imagens
Quando começamos nosso estudo de programação em
1.1, enfatizamos a importância de
descrever uma linguagem focando nos primitivos da linguagem, seus meios
de combinação e seus meios de abstração. Vamos seguir esse framework
aqui.
Parte da elegância dessa linguagem de imagens é que há apenas um tipo de
elemento, chamado de pintor. Um
pintor desenha uma imagem que é deslocada e escalada para caber dentro
de um quadro em forma de paralelogramo designado. Por exemplo, há um
pintor primitivo que chamaremos de wave que faz um desenho
rudimentar, como mostrado na Figura 2.10.
A forma real do desenho depende do quadro—todas as quatro imagens na
figura 2.10 são produzidas pelo mesmo pintor wave, mas com
respeito a quatro quadros diferentes. Pintores podem ser mais elaborados
que isso: O pintor primitivo chamado rogers pinta uma
imagem do fundador do MIT, William Barton Rogers, como
mostrado na Figura 2.11.89
As quatro imagens na figura 2.11 são desenhadas com respeito aos mesmos
quatro quadros que as imagens wave na figura 2.10.
Figura 2.10: Imagens produzidas pelo pintor
wave, com respeito a quatro quadros diferentes. Os
quadros, mostrados com linhas pontilhadas, não fazem parte das
imagens.
Figura 2.11: Imagens de William Barton Rogers,
fundador e primeiro presidente do MIT, pintadas com
respeito aos mesmos quatro quadros da
Figura 2.10 (imagem original do
Wikimedia Commons).
Para combinar imagens, usamos várias operações que constroem novos
pintores a partir de pintores dados. Por exemplo, a operação
beside toma dois pintores e produz um novo pintor composto
que desenha a imagem do primeiro pintor na metade esquerda do quadro e a
imagem do segundo pintor na metade direita do quadro. Da mesma forma,
below toma dois pintores e produz um pintor composto que
desenha a imagem do primeiro pintor abaixo da imagem do segundo pintor.
Algumas operações transformam um único pintor para produzir um novo
pintor. Por exemplo, flip-vert toma um pintor e produz um
pintor que desenha sua imagem de cabeça para baixo, e
flip-horiz produz um pintor que desenha a imagem original
do pintor invertida da esquerda para a direita.
Figura 2.12 mostra o desenho de um pintor
chamado wave4 que é construído em dois estágios a partir de
wave:
Figura 2.12: Criando uma figura complexa, começando
do pintor wave da
Figura 2.10.
Ao construir uma imagem complexa dessa maneira, estamos explorando o
fato de que os pintores são fechados sob os meios de combinação da
linguagem. O beside ou below de dois pintores
é ele mesmo um pintor; portanto, podemos usá-lo como um elemento para
fazer pintores mais complexos. Assim como construir estruturas de lista
usando cons, o fechamento de nossos dados sob os meios de
combinação é crucial para a capacidade de criar estruturas complexas
enquanto usa apenas algumas operações.
Uma vez que podemos combinar pintores, gostaríamos de ser capazes de
abstrair padrões típicos de combinação de pintores. Vamos implementar as
operações de pintor como procedimentos Scheme. Isso significa que não
precisamos de um mecanismo especial de abstração na linguagem de
imagens: Como os meios de combinação são procedimentos Scheme comuns,
temos automaticamente a capacidade de fazer qualquer coisa com operações
de pintor que podemos fazer com procedimentos. Por exemplo, podemos
abstrair o padrão em wave4 como:
Também podemos definir operações recursivas. Aqui está uma que faz
pintores se dividirem e ramificarem para a direita, como mostrado na
Figura 2.13 e
Figura 2.14:
(define (corner-split painter n)
(if (= n 0)
painter
(let ((up (up-split painter (- n 1)))
(right (right-split painter
(- n 1))))
(let ((top-left (beside up up))
(bottom-right (below right
right))
(corner (corner-split painter
(- n 1))))
(beside (below painter top-left)
(below bottom-right
corner))))))
Figura 2.14: As operações recursivas
right-split e corner-split aplicadas aos
pintores wave e rogers. Combinando quatro
figuras corner-split produz designs simétricos
square-limit como mostrado na
Figura 2.9.
Ao colocar quatro cópias de um
corner-split apropriadamente, obtemos um padrão chamado
square-limit, cuja aplicação a wave e
rogers é mostrada na
Figura 2.9:
Exercício 2.44: Defina
o procedimento up-split usado por
corner-split. Ele é similar ao right-split,
exceto que ele troca os papéis de below e
beside.
Operações de ordem superior
Além de abstrair padrões de combinação de pintores, podemos trabalhar em
um nível mais alto, abstraindo padrões de combinação de operações de
pintores. Ou seja, podemos ver as operações de pintores como elementos a
serem manipulados e podemos escrever meios de combinação para esses
elementos — procedimentos que recebem operações de pintores como
argumentos e criam novas operações de pintores.
Por exemplo, flipped-pairs e square-limit cada
um organiza quatro cópias da imagem de um pintor em um padrão quadrado;
eles diferem apenas na forma como orientam as cópias. Uma maneira de
abstrair esse padrão de combinação de pintores é com o seguinte
procedimento, que recebe quatro operações de pintores de um argumento e
produz uma operação de pintor que transforma um pintor dado com essas
quatro operações e organiza os resultados em um quadrado.
Tl, tr, bl e br são
as transformações a serem aplicadas à cópia superior esquerda, à cópia
superior direita, à cópia inferior esquerda e à cópia inferior direita,
respectivamente.
Exercício 2.45:Right-split e up-split podem ser expressos
como instâncias de uma operação de divisão geral. Defina um
procedimento split com a propriedade de que avaliar
produz procedimentos right-split e
up-split com os mesmos comportamentos dos já definidos.
Quadros
Antes de podermos mostrar como implementar pintores e seus meios de
combinação, devemos primeiro considerar quadros. Um quadro pode ser
descrito por três vetores — um vetor de origem e dois vetores de borda.
O vetor de origem especifica o deslocamento da origem do quadro em
relação a alguma origem absoluta no plano, e os vetores de borda
especificam os deslocamentos dos cantos do quadro em relação à sua
origem. Se as bordas forem perpendiculares, o quadro será retangular.
Caso contrário, o quadro será um paralelogramo mais geral.
Figura 2.15 mostra um quadro e seus
vetores associados. De acordo com a abstração de dados, não precisamos
ser específicos ainda sobre como os quadros são representados, exceto
para dizer que há um construtor make-frame, que recebe três
vetores e produz um quadro, e três seletores correspondentes
origin-frame, edge1-frame e
edge2-frame (veja
Exercício 2.47).
Figura 2.15: Um quadro é descrito por três vetores
— uma origem e duas bordas.
Usaremos coordenadas no quadrado unitário
para especificar imagens. Com cada quadro, associamos um
mapa de coordenadas do quadro, que será usado para deslocar e
escalar imagens para caber no quadro. O mapa transforma o quadrado
unitário no quadro mapeando o vetor
para a soma vetorial
Por exemplo, (0, 0) é mapeado para a origem do quadro, (1, 1) para o
vértice diagonalmente oposto à origem, e (0.5, 0.5) para o centro do
quadro. Podemos criar o mapa de coordenadas de um quadro com o seguinte
procedimento:92
Observe que aplicar frame-coord-map a um quadro retorna um
procedimento que, dado um vetor, retorna um vetor. Se o vetor de
argumento estiver no quadrado unitário, o vetor resultante estará no
quadro. Por exemplo,
((frame-coord-map a-frame) (make-vect 0 0))
retorna o mesmo vetor que
(origin-frame a-frame)
Exercício 2.46: Um
vetor bidimensional
que vai da origem a um ponto pode ser representado como um par
consistindo de uma coordenada
e uma coordenada
.
Implemente uma abstração de dados para vetores fornecendo um
construtor make-vect e seletores correspondentes
xcor-vect e ycor-vect. Em termos de seus
seletores e construtor, implemente procedimentos
add-vect, sub-vect e
scale-vect que realizam as operações de adição de
vetores, subtração de vetores e multiplicação de um vetor por um
escalar:
Exercício 2.47: Aqui
estão dois possíveis construtores para quadros:
Para cada construtor, forneça os seletores apropriados para produzir
uma implementação para quadros.
Pintores
Um pintor é representado como um procedimento que, dado um quadro como
argumento, desenha uma imagem específica deslocada e escalada para caber
no quadro. Ou seja, se p é um pintor e f é um
quadro, então produzimos a imagem de p em
f chamando p com f como
argumento.
Os detalhes de como os pintores primitivos são implementados dependem
das características particulares do sistema gráfico e do tipo de imagem
a ser desenhada. Por exemplo, suponha que temos um procedimento
draw-line que desenha uma linha na tela entre dois pontos
especificados. Então podemos criar pintores para desenhos de linhas,
como o pintor wave na
Figura 2.10, a partir de listas de
segmentos de linha da seguinte forma:93
Os segmentos são dados usando coordenadas com respeito ao quadrado
unitário. Para cada segmento na lista, o pintor transforma os pontos
finais do segmento com o mapa de coordenadas do quadro e desenha uma
linha entre os pontos transformados.
Representar pintores como procedimentos ergue uma poderosa barreira de
abstração na linguagem de imagens. Podemos criar e misturar todos os
tipos de pintores primitivos, baseados em uma variedade de capacidades
gráficas. Os detalhes de sua implementação não importam. Qualquer
procedimento pode servir como um pintor, desde que receba um quadro como
argumento e desenhe algo escalado para caber no quadro.94
Exercício 2.48: Um
segmento de linha direcionado no plano pode ser representado como um
par de vetores — o vetor que vai da origem ao ponto inicial do
segmento, e o vetor que vai da origem ao ponto final do segmento. Use
sua representação de vetor do
Exercício 2.46 para definir uma
representação para segmentos com um construtor
make-segment e seletores start-segment e
end-segment.
Exercício 2.49: Use
segments->painter para definir os seguintes pintores
primitivos:
O pintor que desenha o contorno do quadro designado.
O pintor que desenha um “X” conectando os cantos opostos do quadro.
O pintor que desenha uma forma de diamante conectando os pontos
médios dos lados do quadro.
O pintor wave.
Transformando e combinando pintores
Uma operação em pintores (como flip-vert ou
beside) funciona criando um pintor que invoca os pintores
originais com respeito a quadros derivados do quadro de argumento.
Assim, por exemplo, flip-vert não precisa saber como um
pintor funciona para invertê-lo — ele só precisa saber como virar um
quadro de cabeça para baixo: O pintor invertido simplesmente usa o
pintor original, mas no quadro invertido.
As operações de pintores são baseadas no procedimento
transform-painter, que recebe como argumentos um pintor e
informações sobre como transformar um quadro e produz um novo pintor. O
pintor transformado, quando chamado em um quadro, transforma o quadro e
chama o pintor original no quadro transformado. Os argumentos para
transform-painter são pontos (representados como vetores)
que especificam os cantos do novo quadro: Quando mapeados no quadro, o
primeiro ponto especifica a origem do novo quadro e os outros dois
especificam as extremidades de seus vetores de borda. Assim, argumentos
dentro do quadrado unitário especificam um quadro contido dentro do
quadro original.
Aqui está como inverter as imagens do pintor verticalmente:
(define (flip-vert painter)
(transform-painter
painter
(make-vect 0.0 1.0) ; nova origem
(make-vect 1.0 1.0) ; novo fim de edge1
(make-vect 0.0 0.0))) ; novo fim de edge2
Usando transform-painter, podemos facilmente definir novas
transformações. Por exemplo, podemos definir um pintor que encolhe sua
imagem para o quarto superior direito do quadro que lhe é dado:
A transformação de quadros também é a chave para definir meios de
combinar dois ou mais pintores. O procedimento beside, por
exemplo, recebe dois pintores, transforma-os para pintar nas metades
esquerda e direita de um quadro de argumento, respectivamente, e produz
um novo pintor composto. Quando o pintor composto recebe um quadro, ele
chama o primeiro pintor transformado para pintar na metade esquerda do
quadro e chama o segundo pintor transformado para pintar na metade
direita do quadro:
Observe como a abstração de dados do pintor, e em particular a
representação de pintores como procedimentos, torna
beside fácil de implementar. O procedimento
beside não precisa saber nada sobre os detalhes dos
pintores componentes, exceto que cada pintor desenhará algo em seu
quadro designado.
Exercício 2.50: Defina
a transformação flip-horiz, que inverte os pintores
horizontalmente, e transformações que giram os pintores no sentido
anti-horário em 180 graus e 270 graus.
Exercício 2.51: Defina
a operação below para pintores. Below recebe
dois pintores como argumentos. O pintor resultante, dado um quadro,
desenha com o primeiro pintor na parte inferior do quadro e com o
segundo pintor na parte superior. Defina below de duas
maneiras diferentes — primeiro escrevendo um procedimento que é
análogo ao procedimento beside dado acima, e novamente em
termos de beside e operações de rotação adequadas (do
Exercício 2.50).
Níveis de linguagem para design robusto
A linguagem de imagens exercita algumas das ideias críticas que
introduzimos sobre abstração com procedimentos e dados. As abstrações de
dados fundamentais, pintores, são implementadas usando representações
procedurais, o que permite que a linguagem lide com diferentes
capacidades básicas de desenho de maneira uniforme. Os meios de
combinação satisfazem a propriedade de fechamento, o que nos permite
facilmente construir designs complexos. Finalmente, todas as ferramentas
para abstrair procedimentos estão disponíveis para nós para abstrair
meios de combinação para pintores.
Também obtivemos um vislumbre de outra ideia crucial sobre linguagens e
design de programas. Esta é a abordagem de
design estratificado, a
noção de que um sistema complexo deve ser estruturado como uma sequência
de níveis que são descritos usando uma sequência de linguagens. Cada
nível é construído combinando partes que são consideradas primitivas
naquele nível, e as partes construídas em cada nível são usadas como
primitivas no próximo nível. A linguagem usada em cada nível de um
design estratificado tem primitivas, meios de combinação e meios de
abstração apropriados para aquele nível de detalhe.
O design estratificado permeia a engenharia de sistemas complexos. Por
exemplo, na engenharia de computadores, resistores e transistores são
combinados (e descritos usando uma linguagem de circuitos analógicos)
para produzir partes como portas AND e portas OR, que formam as
primitivas de uma linguagem para design de circuitos digitais.97
Essas partes são combinadas para construir processadores, estruturas de
barramento e sistemas de memória, que por sua vez são combinados para
formar computadores, usando linguagens apropriadas para arquitetura de
computadores. Computadores são combinados para formar sistemas
distribuídos, usando linguagens apropriadas para descrever interconexões
de rede, e assim por diante.
Como um pequeno exemplo de estratificação, nossa linguagem de imagens
usa elementos primitivos (pintores primitivos) que são criados usando
uma linguagem que especifica pontos e linhas para fornecer as listas de
segmentos de linha para segments->painter, ou os
detalhes de sombreamento para um pintor como rogers. A
maior parte de nossa descrição da linguagem de imagens focou em combinar
esses primitivos, usando combinadores geométricos como
beside e below. Também trabalhamos em um nível
mais alto, considerando beside e below como
primitivos a serem manipulados em uma linguagem cujas operações, como
square-of-four, capturam padrões comuns de combinação de
combinadores geométricos.
O design estratificado ajuda a tornar os programas
robustos, ou seja, torna provável que
pequenas mudanças em uma especificação exijam mudanças
correspondentemente pequenas no programa. Por exemplo, suponha que
quiséssemos mudar a imagem baseada em wave mostrada na
Figura 2.9. Poderíamos trabalhar no nível
mais baixo para mudar a aparência detalhada do elemento
wave; poderíamos trabalhar no nível médio para mudar a
forma como corner-split replica o wave;
poderíamos trabalhar no nível mais alto para mudar como
square-limit organiza as quatro cópias do canto. Em geral,
cada nível de um design estratificado fornece um vocabulário diferente
para expressar as características do sistema, e um tipo diferente de
capacidade para mudá-lo.
Exercício 2.52: Faça
mudanças no limite quadrado de wave mostrado na
Figura 2.9 trabalhando em cada um dos
níveis descritos acima. Em particular:
Adicione alguns segmentos ao pintor primitivo wave do
Exercício 2.49 (para adicionar um
sorriso, por exemplo).
Mude o padrão construído por corner-split (por exemplo,
usando apenas uma cópia das imagens up-split e
right-split em vez de duas).
Modifique a versão de square-limit que usa
square-of-four para montar os cantos em um padrão
diferente. (Por exemplo, você pode fazer o grande Sr. Rogers olhar
para fora de cada canto do quadrado.)
Notas de rodapé
72 O uso
da palavra “fechamento” aqui vem da álgebra abstrata, onde um
conjunto de elementos é dito fechado sob uma operação se aplicar a
operação a elementos no conjunto produz um elemento que é novamente
um elemento do conjunto. A comunidade Lisp também (infelizmente) usa
a palavra “fechamento” para descrever um conceito totalmente não
relacionado: Um fechamento é uma técnica de implementação para
representar procedimentos com variáveis livres. Não usamos a palavra
“fechamento” neste segundo sentido neste livro.
73 A
noção de que um meio de combinação deve satisfazer o fechamento é
uma ideia simples. Infelizmente, os combinadores de dados fornecidos
em muitas linguagens de programação populares não satisfazem o
fechamento, ou tornam o fechamento difícil de explorar. Em Fortran
ou Basic, normalmente se combinam elementos de dados montando-os em
arrays — mas não se pode formar arrays cujos elementos são eles
mesmos arrays. Pascal e C admitem estruturas cujos elementos são
estruturas. No entanto, isso exige que o programador manipule
ponteiros explicitamente e adira à restrição de que cada campo de
uma estrutura pode conter apenas elementos de uma forma
pré-especificada. Ao contrário do Lisp com seus pares, essas
linguagens não têm uma cola de propósito geral embutida que facilita
a manipulação de dados compostos de maneira uniforme. Essa limitação
está por trás do comentário de Alan Perlis em seu prefácio a este
livro: “Em Pascal, a proliferação de estruturas de dados declaráveis
induz uma especialização dentro de funções que inibe e penaliza a
cooperação casual. É melhor ter 100 funções operando em uma
estrutura de dados do que ter 10 funções operando em 10 estruturas
de dados.”
74 Neste
livro, usamos lista para
significar uma cadeia de pares terminada pelo marcador de fim de
lista. Em contraste, o termo estrutura de lista refere-se a qualquer estrutura de dados
feita de pares, não apenas a listas.
75 Como
aplicações aninhadas de car e cdr são
trabalhosas de escrever, dialetos de Lisp fornecem abreviações para
elas — por exemplo,
(cadr ⟨arg⟩) = (car (cdr ⟨arg⟩))
Os nomes de todos esses procedimentos começam com c e
terminam com r. Cada a entre eles
representa uma operação car e cada
d representa uma operação cdr, a ser
aplicada na mesma ordem em que aparecem no nome. Os nomes
car e cdr persistem porque combinações
simples como cadr são pronunciáveis.
76 É
notável quanta energia na padronização de dialetos de Lisp foi
dissipada em argumentos que são literalmente sobre nada:
nil deveria ser um nome comum? O valor de
nil deveria ser um símbolo? Deveria ser uma lista?
Deveria ser um par? Em Scheme, nil é um nome comum, que
usamos nesta seção como uma variável cujo valor é o marcador de fim
de lista (assim como true é uma variável comum que tem
um valor verdadeiro). Outros dialetos de Lisp, incluindo Common
Lisp, tratam nil como um símbolo especial. Os autores
deste livro, que suportaram muitas brigas de padronização de
linguagens, gostariam de evitar toda a questão. Uma vez que
introduzimos a citação em 2.3,
denotaremos a lista vazia como '() e dispensaremos a
variável nil inteiramente.
(define f (lambda (x y . z) ⟨body⟩))
(define g (lambda w ⟨body⟩))
78 Scheme
fornece um procedimento map mais geral do que o
descrito aqui. Este map mais geral recebe um
procedimento de
argumentos, junto com
listas, e aplica o procedimento a todos os primeiros elementos das
listas, todos os segundos elementos das listas, e assim por diante,
retornando uma lista dos resultados. Por exemplo:
79 A
ordem das duas primeiras cláusulas no cond importa,
pois a lista vazia satisfaz null? e também não é um
par.
80 Este
é, de fato, precisamente o procedimento fringe do
Exercício 2.28. Aqui o renomeamos
para enfatizar que ele faz parte de uma família de procedimentos de
manipulação de sequências gerais.
81
Richard
Waters (1979)
desenvolveu um programa que analisa automaticamente programas
tradicionais em Fortran, vendo-os em termos de mapas, filtros e
acumulações. Ele descobriu que 90% do código no Fortran Scientific
Subroutine Package se encaixa perfeitamente nesse paradigma. Uma das
razões para o sucesso do Lisp como linguagem de programação é que as
listas fornecem um meio padrão para expressar coleções ordenadas
para que possam ser manipuladas usando operações de ordem superior.
A linguagem de programação APL deve muito de seu poder e apelo a uma
escolha semelhante. Em APL, todos os dados são representados como
arrays, e há um conjunto universal e conveniente de operadores
genéricos para todos os tipos de operações de arrays.
82 De
acordo com Knuth 1981, esta
regra foi formulada por W. G. Horner no início do século XIX, mas o
método foi realmente usado por Newton mais de cem anos antes. A
regra de Horner avalia o polinômio usando menos adições e
multiplicações do que o método direto de primeiro calcular
, depois adicionar
, e assim por diante. Na verdade, é possível provar que qualquer
algoritmo para avaliar polinômios arbitrários deve usar pelo menos
tantas adições e multiplicações quanto a regra de Horner, e assim a
regra de Horner é um algoritmo ótimo para avaliação de polinômios.
Isso foi provado (para o número de adições) por A. M. Ostrowski em
um artigo de 1954 que essencialmente fundou o estudo moderno de
algoritmos ótimos. A afirmação análoga para multiplicações foi
provada por V. Y. Pan em 1966. O livro de
Borodin e Munro (1975)
fornece uma visão geral desses e outros resultados sobre algoritmos
ótimos.
83 Esta
definição usa a versão estendida de map descrita na
Nota de rodapé 78.
84 Esta
abordagem para mapeamentos aninhados foi mostrada a nós por David
Turner, cujas linguagens KRC e Miranda fornecem formalismos
elegantes para lidar com esses constructos. Os exemplos nesta seção
(veja também Exercício 2.42) são
adaptados de Turner 1981.
Em 3.5.3, veremos como
esta abordagem se generaliza para sequências infinitas.
85
Estamos representando um par aqui como uma lista de dois elementos
em vez de como um par Lisp. Assim, o “par”
é representado como (list i j), não
(cons i j).
86 O
conjunto
é o conjunto de todos os elementos de
, excluindo
.
87 Ponto
e vírgula em código Scheme são usados para introduzir
comentários. Tudo do ponto e
vírgula até o final da linha é ignorado pelo interpretador. Neste
livro não usamos muitos comentários; tentamos fazer nossos programas
auto-documentados usando nomes descritivos.
88 A
linguagem de imagens é baseada na linguagem que Peter Henderson
criou para construir imagens como a xilogravura “Square Limit” de
M.C. Escher (veja
Henderson 1982). A
xilogravura incorpora um padrão escalado repetido, semelhante aos
arranjos desenhados usando o procedimento
square-limit nesta seção.
89
William Barton Rogers (1804-1882) foi o fundador e primeiro
presidente do MIT. Um geólogo e professor talentoso,
ele ensinou no William and Mary College e na Universidade da
Virgínia. Em 1859 ele se mudou para Boston, onde teve mais tempo
para pesquisa, trabalhou em um plano para estabelecer um “instituto
politécnico” e serviu como o primeiro Inspetor de Medidores de Gás
do Estado de Massachusetts.
Quando o MIT foi estabelecido em 1861, Rogers foi
eleito seu primeiro presidente. Rogers defendia um ideal de
“aprendizado útil” que era diferente da educação universitária da
época, com sua ênfase excessiva nos clássicos, que, como ele
escreveu, “ficam no caminho de uma instrução e disciplina mais
amplas, mais elevadas e mais práticas das ciências naturais e
sociais.” Essa educação também deveria ser diferente da educação
estreita de escolas técnicas. Nas palavras de Rogers:
A distinção imposta pelo mundo entre o trabalhador prático e o
trabalhador científico é completamente fútil, e toda a experiência
dos tempos modernos demonstrou sua completa inutilidade.
Rogers serviu como presidente do MIT até 1870, quando
renunciou devido a problemas de saúde. Em 1878, o segundo presidente
do MIT, John Runkle, renunciou sob a pressão de uma
crise financeira causada pelo Pânico de 1873 e a tensão de lutar
contra tentativas de Harvard de assumir o MIT. Rogers
retornou para ocupar o cargo de presidente até 1881.
Rogers desmaiou e morreu enquanto discursava para a turma de
formandos do MIT nas cerimônias de formatura de 1882.
Runkle citou as últimas palavras de Rogers em um discurso memorial
proferido no mesmo ano:
“Enquanto estou aqui hoje e vejo o que o Instituto é, … lembro-me
dos primórdios da ciência. Lembro-me que há cento e cinquenta anos
Stephen Hales publicou um panfleto sobre o assunto do gás de
iluminação, no qual ele afirmou que suas pesquisas haviam
demonstrado que 128 grãos de carvão betuminoso – ” “Carvão
betuminoso”, estas foram suas últimas palavras na terra. Aqui ele
se inclinou para frente, como se consultasse algumas notas na mesa
diante dele, então lentamente retomando uma posição ereta,
levantou as mãos e foi transladado da cena de seus trabalhos e
triunfos terrenos para “o amanhã da morte”, onde os mistérios da
vida são resolvidos, e o espírito desencarnado encontra satisfação
infinita ao contemplar os novos e ainda insondáveis mistérios do
futuro infinito.
Nas palavras de Francis A. Walker (terceiro presidente do
MIT):
Toda a sua vida ele se conduziu de maneira mais fiel e heroica, e
ele morreu como um bom cavaleiro certamente desejaria, em
armadura, em seu posto, e na própria parte e ato do dever público.
91Rotate180 gira um pintor em 180 graus (veja
Exercício 2.50). Em vez de
rotate180 poderíamos dizer
(compose flip-vert flip-horiz), usando o procedimento
compose do
Exercício 1.42.
92Frame-coord-map usa as operações de vetor descritas no
Exercício 2.46 abaixo, que
assumimos terem sido implementadas usando alguma representação para
vetores. Devido à abstração de dados, não importa qual seja essa
representação de vetor, desde que as operações de vetor se comportem
corretamente.
93Segments->painter usa a representação para segmentos
de linha descrita no
Exercício 2.48 abaixo. Ele também
usa o procedimento for-each descrito no
Exercício 2.23.
94 Por
exemplo, o pintor rogers da
Figura 2.11 foi construído a partir
de uma imagem em tons de cinza. Para cada ponto em um quadro dado, o
pintor rogers determina o ponto na imagem que é mapeado
para ele sob o mapa de coordenadas do quadro e o sombreia de acordo.
Ao permitir diferentes tipos de pintores, estamos capitalizando a
ideia de dados abstratos discutida em
2.1.3, onde argumentamos
que uma representação de número racional poderia ser qualquer coisa
que satisfaça uma condição apropriada. Aqui estamos usando o fato de
que um pintor pode ser implementado de qualquer maneira, desde que
desenhe algo no quadro designado.
2.1.3 também mostrou
como pares poderiam ser implementados como procedimentos. Pintores
são nosso segundo exemplo de uma representação procedural para
dados.
95Rotate90 é uma rotação pura apenas para quadros
quadrados, porque também estica e encolhe a imagem para caber no
quadro girado.
96 As
imagens em forma de diamante na
Figura 2.10 e
Figura 2.11 foram criadas com
squash-inwards aplicado a wave e
rogers.