5.3.1 Representação de Memória
A memória de um computador convencional pode ser pensada como um array de compartimentos, cada um dos quais pode conter uma peça de informação. Cada compartimento tem um nome único, chamado de seu endereço ou localização. Sistemas de memória típicos fornecem duas operações primitivas: uma que busca os dados armazenados em uma localização especificada e uma que atribui novos dados a uma localização especificada. Endereços de memória podem ser incrementados para suportar acesso sequencial a algum conjunto de compartimentos. De forma mais geral, muitas operações importantes de dados requerem que endereços de memória sejam tratados como dados, que podem ser armazenados em localizações de memória e manipulados em registradores da máquina. A representação de estrutura de lista é uma aplicação de tal aritmética de endereços.
Para modelar a memória de um computador, usamos um novo tipo de estrutura de dados chamada vetor. Abstratamente, um vetor é um objeto de dado composto cujos elementos individuais podem ser acessados por meio de um índice inteiro em um tempo que é independente do índice.1 Para descrever operações de memória, usamos duas funções para manipular vetores:2
vector_ref(vector, n)retorna o n-ésimo elemento do vetor.vector_set(vector, n, value)define o n-ésimo elemento do vetor para o valor designado.
Por exemplo, se v é um vetor, então vector_ref(v, 5) obtém a quinta entrada no vetor v e vector_set(v, 5, 7) muda o valor da quinta entrada do vetor v para 7.3 Para memória de computador, este acesso pode ser implementado através do uso de aritmética de endereços para combinar um endereço base que especifica a localização inicial de um vetor na memória com um índice que especifica o deslocamento de um elemento particular do vetor.
Representando dados
Podemos usar vetores para implementar as estruturas de par básicas necessárias para uma memória estruturada em lista. Vamos imaginar que a memória do computador está dividida em dois vetores: the_heads e the_tails. Representaremos a estrutura de lista da seguinte forma: um ponteiro para um par é um índice nos dois vetores. O head do par é a entrada em the_heads com o índice designado, e o tail do par é a entrada em the_tails com o índice designado. Também precisamos de uma representação para objetos que não sejam pares (como números e strings) e uma maneira de distinguir um tipo de dado do outro. Existem muitos métodos para realizar isso, mas todos se reduzem a usar ponteiros tipados, ou seja, estender a noção de "ponteiro" para incluir informação sobre o tipo de dado.4 O tipo de dado permite que o sistema distinga um ponteiro para um par (que consiste do tipo de dado "par" e um índice nos vetores de memória) de ponteiros para outros tipos de dados (que consistem de algum outro tipo de dado e qualquer coisa que esteja sendo usada para representar dados daquele tipo). Dois objetos de dados são considerados os mesmos (===) se seus ponteiros forem idênticos. A Figura 5.14 ilustra o uso deste método para representar list(list(1, 2), 3, 4), cujo diagrama de caixas e ponteiros também é mostrado. Usamos prefixos de letra para denotar a informação de tipo de dado. Assim, um ponteiro para o par com índice 5 é denotado p5, a lista vazia é denotada pelo ponteiro e0, e um ponteiro para o número 4 é denotado n4. No diagrama de caixas e ponteiros, indicamos no canto inferior esquerdo de cada par o índice do vetor que especifica onde o head e o tail do par são armazenados. As localizações em branco em the_heads e the_tails podem conter partes de outras estruturas de lista (não de interesse aqui).
p2 (índice 2)Figura 5.14: Representações de caixas e ponteiros e vetores de memória da lista list(list(1, 2), 3, 4).
Um ponteiro para um número, como n4, pode consistir de um tipo indicando dados numéricos junto com a representação real do número 4.5 Para lidar com números que são grandes demais para serem representados na quantidade fixa de espaço alocado para um único ponteiro, poderíamos usar um tipo de dado distinto bignum, para o qual o ponteiro designa uma lista na qual as partes do número são armazenadas.6
Uma string pode ser representada como um ponteiro tipado que designa uma sequência dos caracteres que formam a representação impressa da string. O analisador constrói tal sequência quando encontra um literal de string, e o operador de concatenação de strings + e funções primitivas produtoras de strings como stringify constroem tal sequência. Como queremos que duas instâncias de uma string sejam reconhecidas como a "mesma" string por === e queremos que === seja um teste simples de igualdade de ponteiros, devemos garantir que se o sistema vê a mesma string duas vezes, ele usará o mesmo ponteiro (para a mesma sequência de caracteres) para representar ambas as ocorrências. Para realizar isso, o sistema mantém uma tabela, chamada pool de strings, de todas as strings que ele já encontrou. Quando o sistema está prestes a construir uma string, ele verifica o pool de strings para ver se já viu a mesma string antes. Se não viu, ele constrói uma nova string (um ponteiro tipado para uma nova sequência de caracteres) e insere este ponteiro no pool de strings. Se o sistema viu a string antes, ele retorna o ponteiro de string armazenado no pool de strings. Este processo de substituir strings por ponteiros únicos é chamado internação de strings.
Implementando as operações primitivas de lista
Dado o esquema de representação acima, podemos substituir cada operação de lista "primitiva" de uma máquina de registradores por uma ou mais operações primitivas de vetor. Usaremos dois registradores, the_heads e the_tails, para identificar os vetores de memória, e assumiremos que vector_ref e vector_set estão disponíveis como operações primitivas. Também assumimos que operações numéricas em ponteiros (como incrementar um ponteiro, usar um ponteiro de par para indexar um vetor, ou adicionar dois números) usam apenas a porção de índice do ponteiro tipado.
Por exemplo, podemos fazer uma máquina de registradores suportar as instruções
se implementarmos estas, respectivamente, como
As instruções
são implementadas como
A operação pair é realizada alocando um índice não utilizado e armazenando os argumentos de pair em the_heads e the_tails naquela posição indexada do vetor. Presumimos que há um registrador especial, free, que sempre contém um ponteiro de par contendo o próximo índice disponível, e que podemos incrementar a parte de índice daquele ponteiro para encontrar a próxima localização livre.7 Por exemplo, a instrução
é implementada como a seguinte sequência de operações de vetor:8
A operação ===
simplesmente testa a igualdade de todos os campos nos registradores, e predicados como is_pair, is_null, is_string e is_number precisam verificar apenas o campo de tipo.
Implementando pilhas
Embora nossas máquinas de registradores usem pilhas, não precisamos fazer nada especial aqui, já que pilhas podem ser modeladas em termos de listas. A pilha pode ser uma lista dos valores salvos, apontada por um registrador especial the_stack. Assim, save(reg) pode ser implementado como
Similarmente, restore(reg) pode ser implementado como
e perform(list(op("initialize_stack"))) pode ser implementado como
Essas operações podem ser expandidas ainda mais em termos das operações de vetor dadas acima. Em arquiteturas de computador convencionais, no entanto, geralmente é vantajoso alocar a pilha como um vetor separado. Então, empurrar e desempilhar a pilha pode ser realizado incrementando ou decrementando um índice naquele vetor.
Exercício 5.19
Desenhe a representação de caixas e ponteiros e a representação de vetor de memória (como na figura 5.14) da estrutura de lista produzida por
com o ponteiro free inicialmente p1. Qual é o valor final de free? Que ponteiros representam os valores de x e y?
Exercício 5.20
Implemente máquinas de registradores para as seguintes funções. Assuma que as operações de memória de estrutura de lista estão disponíveis como primitivas da máquina.
a. count_leaves recursiva:
b. count_leaves recursiva com contador explícito:
Exercício 5.21
O exercício 3.12 da seção 3.3.1 apresentou uma função append que anexa duas listas para formar uma nova lista e uma função append_mutator que junta duas listas. Projete uma máquina de registradores para implementar cada uma dessas funções. Assuma que as operações de memória de estrutura de lista estão disponíveis como operações primitivas.