5.3Alocação de Armazenamento e Coleta de Lixo

Na seção 5.4, mostraremos como implementar um avaliador de Scheme como uma máquina de registradores. Para simplificar a discussão, assumiremos que nossas máquinas de registradores podem ser equipadas com uma memória estruturada em listas, na qual as operações básicas para manipular dados estruturados em listas são primitivas. Postular a existência de tal memória é uma abstração útil quando se está focando nos mecanismos de controle em um interpretador de Scheme, mas isso não reflete uma visão realista das operações primitivas de dados em computadores contemporâneos. Para obter uma visão mais completa de como um sistema Lisp opera, devemos investigar como a estrutura de listas pode ser representada de uma forma compatível com as memórias convencionais de computadores.

Há duas considerações na implementação da estrutura de listas. A primeira é puramente uma questão de representação: como representar a estrutura de "caixa e ponteiro" dos pares de Lisp, usando apenas as capacidades de armazenamento e endereçamento das memórias típicas de computadores. A segunda questão diz respeito ao gerenciamento da memória à medida que a computação prossegue. A operação de um sistema Lisp depende crucialmente da capacidade de criar continuamente novos objetos de dados. Isso inclui objetos que são explicitamente criados pelos procedimentos Lisp que estão sendo interpretados, bem como estruturas criadas pelo próprio interpretador, como ambientes e listas de argumentos. Embora a criação constante de novos objetos de dados não representasse um problema em um computador com uma quantidade infinita de memória rapidamente endereçável, as memórias de computadores estão disponíveis apenas em tamanhos finitos (infelizmente). Assim, os sistemas Lisp fornecem uma facilidade de alocação automática de armazenamento para suportar a ilusão de uma memória infinita. Quando um objeto de dados não é mais necessário, a memória alocada para ele é automaticamente reciclada e usada para construir novos objetos de dados. Existem várias técnicas para fornecer essa alocação automática de armazenamento. O método que discutiremos nesta seção é chamado de coleta de lixo.

5.3.1Memória como Vetores

Uma memória de 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 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 outra que atribui novos dados a uma localização especificada. Endereços de memória podem ser incrementados para suportar o acesso sequencial a algum conjunto de compartimentos. De forma mais geral, muitas operações importantes de dados exigem que os endereços de memória sejam tratados como dados, que podem ser armazenados em localizações de memória e manipulados em registradores de máquina. A representação da estrutura de listas é uma aplicação dessa aritmética de endereços.

Para modelar a memória do computador, usamos um novo tipo de estrutura de dados chamada vetor. Abstratamente, um vetor é um objeto de dados composto cujos elementos individuais podem ser acessados por meio de um índice inteiro em um tempo que é independente do índice.290 Para descrever as operações de memória, usamos dois procedimentos primitivos de Scheme para manipular vetores:

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) altera o valor da quinta entrada do vetor v para 7.291 Para a memória do computador, esse acesso pode ser implementado por meio 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 Lisp

Podemos usar vetores para implementar as estruturas básicas de pares necessárias para uma memória estruturada em listas. Vamos imaginar que a memória do computador é dividida em dois vetores: the-cars e the-cdrs. Representaremos a estrutura de listas da seguinte forma: Um ponteiro para um par é um índice nos dois vetores. O car do par é a entrada em the-cars com o índice designado, e o cdr do par é a entrada em the-cdrs com o índice designado. Também precisamos de uma representação para objetos que não são pares (como números e símbolos) e uma maneira de distinguir um tipo de dado de outro. Existem muitos métodos para realizar isso, mas todos se resumem ao uso de ponteiros tipados, ou seja, estender a noção de "ponteiro" para incluir informações sobre o tipo de dado.292 O tipo de dado permite que o sistema distinga um ponteiro para um par (que consiste no tipo de dado "par" e um índice nos vetores de memória) de ponteiros para outros tipos de dados (que consistem em algum outro tipo de dado e o que quer que esteja sendo usado para representar dados desse tipo). Dois objetos de dados são considerados iguais (eq?) se seus ponteiros forem idênticos.293 Figura 5.14 ilustra o uso desse método para representar a lista ((1 2) 3 4), cujo diagrama de caixa e ponteiro também é mostrado. Usamos prefixos de letras para denotar as informações de tipo de dado. Assim, um ponteiro para o par com índice 5 é denotado por p5, a lista vazia é denotada pelo ponteiro e0, e um ponteiro para o número 4 é denotado por n4. No diagrama de caixa e ponteiro, indicamos no canto inferior esquerdo de cada par o índice do vetor que especifica onde o car e o cdr do par estão armazenados. As localizações em branco em the-cars e the-cdrs podem conter partes de outras estruturas de listas (que não são de interesse aqui).

SVG

Figura 5.14: Representações de caixa e ponteiro e vetor de memória da lista ((1 2) 3 4).

Um ponteiro para um número, como n4, pode consistir em um tipo indicando dados numéricos junto com a representação real do número 4.294 Para lidar com números que são grandes demais para serem representados no espaço fixo alocado para um único ponteiro, poderíamos usar um tipo de dado bignum, para o qual o ponteiro designa uma lista na qual as partes do número são armazenadas.295

Um símbolo pode ser representado como um ponteiro tipado que designa uma sequência dos caracteres que formam a representação impressa do símbolo. Essa sequência é construída pelo leitor de Lisp quando a string de caracteres é inicialmente encontrada na entrada. Como queremos que duas instâncias de um símbolo sejam reconhecidas como o "mesmo" símbolo por eq? e queremos que eq? seja um teste simples para igualdade de ponteiros, devemos garantir que, se o leitor vir a mesma string de caracteres duas vezes, ele usará o mesmo ponteiro (para a mesma sequência de caracteres) para representar ambas as ocorrências. Para realizar isso, o leitor mantém uma tabela, tradicionalmente chamada de obarray, de todos os símbolos que já encontrou. Quando o leitor encontra uma string de caracteres e está prestes a construir um símbolo, ele verifica o obarray para ver se já viu a mesma string de caracteres antes. Se não tiver visto, ele usa os caracteres para construir um novo símbolo (um ponteiro tipado para uma nova sequência de caracteres) e insere esse ponteiro no obarray. Se o leitor já viu a string antes, ele retorna o ponteiro de símbolo armazenado no obarray. Esse processo de substituir strings de caracteres por ponteiros únicos é chamado de internamento de símbolos.

Implementando as operações primitivas de listas

Dado o esquema de representação acima, podemos substituir cada operação "primitiva" de lista de uma máquina de registradores por uma ou mais operações primitivas de vetor. Usaremos dois registradores, the-cars e the-cdrs, 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 parte do índice do ponteiro tipado.

Por exemplo, podemos fazer uma máquina de registradores suportar as instruções

(assign ⟨reg₁⟩ (op car) (reg ⟨reg₂⟩))
(assign ⟨reg₁⟩ (op cdr) (reg ⟨reg₂⟩))

se as implementarmos, respectivamente, como

(assign ⟨reg₁⟩ 
        (op vector-ref)
        (reg the-cars)
        (reg ⟨reg₂⟩))
(assign ⟨reg₁⟩
        (op vector-ref)
        (reg the-cdrs)
        (reg ⟨reg₂⟩))

As instruções

(perform (op set-car!) (reg ⟨reg₁⟩) (reg ⟨reg₂⟩))
(perform (op set-cdr!) (reg ⟨reg₁⟩) (reg ⟨reg₂⟩))

são implementadas como

(perform (op vector-set!)
         (reg the-cars)
         (reg ⟨reg₁⟩)
         (reg ⟨reg₂⟩))
(perform (op vector-set!)
         (reg the-cdrs)
         (reg ⟨reg₁⟩)
         (reg ⟨reg₂⟩))

Cons é realizado alocando um índice não utilizado e armazenando os argumentos para cons em the-cars e the-cdrs 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 do índice desse ponteiro para encontrar a próxima localização livre.296 Por exemplo, a instrução

(assign ⟨reg₁⟩
        (op cons)
        (reg ⟨reg₂⟩)
        (reg ⟨reg₃⟩))

é implementada como a seguinte sequência de operações de vetor:297

(perform (op vector-set!)
         (reg the-cars)
         (reg free)
         (reg ⟨reg₂⟩))
(perform (op vector-set!)
         (reg the-cdrs)
         (reg free)
         (reg ⟨reg₃⟩))
(assign ⟨reg₁⟩ (reg free))
(assign free (op +) (reg free) (const 1))

A operação eq?

(op eq?) (reg ⟨reg₁⟩) (reg ⟨reg₂⟩)

simplesmente testa a igualdade de todos os campos nos registradores, e predicados como pair?, null?, symbol?, e number? precisam apenas verificar o campo de tipo.

Implementando pilhas

Embora nossas máquinas de registradores usem pilhas, não precisamos fazer nada especial aqui, pois as 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

(assign the-stack 
        (op cons)
        (reg ⟨reg⟩)
        (reg the-stack))

Da mesma forma, (restore ⟨reg⟩) pode ser implementado como

(assign ⟨reg⟩ (op car) (reg the-stack))
(assign the-stack (op cdr) (reg the-stack))

e (perform (op initialize-stack)) pode ser implementado como

(assign the-stack (const ()))

Essas operações podem ser expandidas em termos das operações de vetor fornecidas acima. Em arquiteturas de computadores convencionais, no entanto, geralmente é vantajoso alocar a pilha como um vetor separado. Então, empilhar e desempilhar podem ser realizados incrementando ou decrementando um índice nesse vetor.

Exercício 5.20: Desenhe a representação de caixa e ponteiro e a representação de vetor de memória (como na Figura 5.14) da estrutura de lista produzida por

(define x (cons 1 2))
(define y (list x x))

com o ponteiro free inicialmente p1. Qual é o valor final de free? Quais ponteiros representam os valores de x e y?

Exercício 5.21: Implemente máquinas de registradores para os seguintes procedimentos. Assuma que as operações de memória de estrutura de lista estão disponíveis como primitivas de máquina.

  1. count-leaves recursivo:
    (define (count-leaves tree)
      (cond ((null? tree) 0)
            ((not (pair? tree)) 1)
            (else 
             (+ (count-leaves (car tree))
                (count-leaves (cdr tree))))))
  2. count-leaves recursivo com contador explícito:
    (define (count-leaves tree)
      (define (count-iter tree n)
        (cond ((null? tree) n)
              ((not (pair? tree)) (+ n 1))
              (else 
               (count-iter 
                (cdr tree)
                (count-iter (car tree) 
                            n)))))
      (count-iter tree 0))

Exercício 5.22: Exercício 3.12 de 3.3.1 apresentou um procedimento append que anexa duas listas para formar uma nova lista e um procedimento append! que emenda duas listas. Projete uma máquina de registradores para implementar cada um desses procedimentos. Assuma que as operações de memória de estrutura de lista estão disponíveis como operações primitivas.

5.3.2Mantendo a Ilusão de Memória Infinita

O método de representação delineado em 5.3.1 resolve o problema de implementar a estrutura de listas, desde que tenhamos uma quantidade infinita de memória. Com um computador real, eventualmente ficaremos sem espaço livre para construir novos pares.298 No entanto, a maioria dos pares gerados em uma computação típica são usados apenas para manter resultados intermediários. Após esses resultados serem acessados, os pares não são mais necessários—eles são lixo. Por exemplo, a computação

(accumulate 
 +
 0
 (filter odd? (enumerate-interval 0 n)))

constrói duas listas: a enumeração e o resultado da filtragem da enumeração. Quando a acumulação estiver completa, essas listas não serão mais necessárias, e a memória alocada pode ser recuperada. Se pudermos organizar para coletar todo o lixo periodicamente, e se isso reciclar memória na mesma taxa em que construímos novos pares, teremos preservado a ilusão de que há uma quantidade infinita de memória.

Para reciclar pares, devemos ter uma maneira de determinar quais pares alocados não são mais necessários (no sentido de que seu conteúdo não pode mais influenciar o futuro da computação). O método que examinaremos para realizar isso é conhecido como coleta de lixo. A coleta de lixo é baseada na observação de que, a qualquer momento em uma interpretação de Lisp, os únicos objetos que podem afetar o futuro da computação são aqueles que podem ser alcançados por alguma sucessão de operações car e cdr a partir dos ponteiros que estão atualmente nos registradores da máquina.299 Qualquer célula de memória que não seja acessível dessa forma pode ser reciclada.

Existem muitas maneiras de realizar a coleta de lixo. O método que examinaremos aqui é chamado de stop-and-copy. A ideia básica é dividir a memória em duas metades: "memória de trabalho" e "memória livre". Quando cons constrói pares, ele aloca esses pares na memória de trabalho. Quando a memória de trabalho estiver cheia, realizamos a coleta de lixo localizando todos os pares úteis na memória de trabalho e copiando esses pares para localizações consecutivas na memória livre. (Os pares úteis são localizados rastreando todos os ponteiros car e cdr, começando com os registradores da máquina.) Como não copiamos o lixo, presumivelmente haverá memória livre adicional que podemos usar para alocar novos pares. Além disso, nada na memória de trabalho é necessário, pois todos os pares úteis nela foram copiados. Assim, se trocarmos os papéis da memória de trabalho e da memória livre, podemos continuar o processamento; novos pares serão alocados na nova memória de trabalho (que era a antiga memória livre). Quando essa memória estiver cheia, podemos copiar os pares úteis para a nova memória livre (que era a antiga memória de trabalho).300

Implementação de um coletor de lixo stop-and-copy

Agora usamos nossa linguagem de máquina de registradores para descrever o algoritmo stop-and-copy em mais detalhes. Assumiremos que há um registrador chamado root que contém um ponteiro para uma estrutura que eventualmente aponta para todos os dados acessíveis. Isso pode ser organizado armazenando o conteúdo de todos os registradores da máquina em uma lista pré-alocada apontada por root logo antes de iniciar a coleta de lixo.301 Também assumimos que, além da memória de trabalho atual, há memória livre disponível na qual podemos copiar os dados úteis. A memória de trabalho atual consiste em vetores cujos endereços base estão nos registradores chamados the-cars e the-cdrs, e a memória livre está nos registradores chamados new-cars e new-cdrs.

A coleta de lixo é acionada quando esgotamos as células livres na memória de trabalho atual, ou seja, quando uma operação cons tenta incrementar o ponteiro free além do final do vetor de memória. Quando o processo de coleta de lixo estiver completo, o ponteiro root apontará para a nova memória, todos os objetos acessíveis a partir de root terão sido movidos para a nova memória, e o ponteiro free indicará o próximo local na nova memória onde um novo par pode ser alocado. Além disso, os papéis da memória de trabalho e da nova memória terão sido trocados—novos pares serão construídos na nova memória, começando no local indicado por free, e a (anterior) memória de trabalho estará disponível como a nova memória para a próxima coleta de lixo. Figura 5.15 mostra o arranjo da memória logo antes e logo após a coleta de lixo.

SVG

Figura 5.15: Reconfiguração da memória pelo processo de coleta de lixo.

O estado do processo de coleta de lixo é controlado mantendo dois ponteiros: free e scan. Esses ponteiros são inicializados para apontar para o início da nova memória. O algoritmo começa realocando o par apontado por root para o início da nova memória. O par é copiado, o ponteiro root é ajustado para apontar para a nova localização, e o ponteiro free é incrementado. Além disso, a localização antiga do par é marcada para mostrar que seu conteúdo foi movido. Essa marcação é feita da seguinte forma: Na posição car, colocamos uma tag especial que sinaliza que este é um objeto já movido. (Tal objeto é tradicionalmente chamado de coração partido.)302 Na posição cdr, colocamos um endereço de encaminhamento que aponta para a localização para a qual o objeto foi movido.

Após realocar o root, o coletor de lixo entra em seu ciclo básico. A cada passo do algoritmo, o ponteiro scan (inicialmente apontando para o root realocado) aponta para um par que foi movido para a nova memória, mas cujos ponteiros car e cdr ainda se referem a objetos na memória antiga. Esses objetos são cada um realocado, e o ponteiro scan é incrementado. Para realocar um objeto (por exemplo, o objeto indicado pelo ponteiro car do par que estamos escaneando), verificamos se o objeto já foi movido (como indicado pela presença de uma tag de coração partido na posição car do objeto). Se o objeto ainda não foi movido, nós o copiamos para o local indicado por free, atualizamos free, configuramos um coração partido na localização antiga do objeto e atualizamos o ponteiro para o objeto (neste exemplo, o ponteiro car do par que estamos escaneando) para apontar para a nova localização. Se o objeto já foi movido, seu endereço de encaminhamento (encontrado na posição cdr do coração partido) é substituído pelo ponteiro no par que está sendo escaneado. Eventualmente, todos os objetos acessíveis terão sido movidos e escaneados, momento em que o ponteiro scan ultrapassará o ponteiro free e o processo terminará.

Podemos especificar o algoritmo stop-and-copy como uma sequência de instruções para uma máquina de registradores. O passo básico de realocar um objeto é realizado por uma sub-rotina chamada relocate-old-result-in-new. Essa sub-rotina obtém seu argumento, um ponteiro para o objeto a ser realocado, de um registrador chamado old. Ela realoca o objeto designado (incrementando free no processo), coloca um ponteiro para o objeto realocado em um registrador chamado new e retorna saltando para o ponto de entrada armazenado no registrador relocate-continue. Para iniciar a coleta de lixo, invocamos essa sub-rotina para realocar o ponteiro root, após inicializar free e scan. Quando a realocação de root for concluída, instalamos o novo ponteiro como o novo root e entramos no loop principal do coletor de lixo.

begin-garbage-collection
  (assign free (const 0))
  (assign scan (const 0))
  (assign old (reg root))
  (assign relocate-continue 
          (label reassign-root))
  (goto (label relocate-old-result-in-new))
reassign-root
  (assign root (reg new))
  (goto (label gc-loop))

No loop principal do coletor de lixo, devemos determinar se há mais objetos a serem escaneados. Fazemos isso testando se o ponteiro scan coincide com o ponteiro free. Se os ponteiros forem iguais, todos os objetos acessíveis terão sido realocados, e saltamos para gc-flip, que limpa as coisas para que possamos continuar a computação interrompida. Se ainda houver pares a serem escaneados, chamamos a sub-rotina de realocação para realocar o car do próximo par (colocando o ponteiro car em old). O registrador relocate-continue é configurado para que a sub-rotina retorne para atualizar o ponteiro car.

gc-loop
  (test (op =) (reg scan) (reg free))
  (branch (label gc-flip))
  (assign old 
          (op vector-ref)
          (reg new-cars)
          (reg scan))
  (assign relocate-continue 
          (label update-car))
  (goto (label relocate-old-result-in-new))

Em update-car, modificamos o ponteiro car do par que está sendo escaneado, então prosseguimos para realocar o cdr do par. Retornamos para update-cdr quando essa realocação for concluída. Após realocar e atualizar o cdr, terminamos de escanear esse par, então continuamos com o loop principal.

update-car
  (perform (op vector-set!)
           (reg new-cars)
           (reg scan)
           (reg new))
  (assign  old 
           (op vector-ref)
           (reg new-cdrs)
           (reg scan))
  (assign  relocate-continue
           (label update-cdr))
  (goto (label relocate-old-result-in-new))
update-cdr
  (perform (op vector-set!)
           (reg new-cdrs)
           (reg scan)
           (reg new))
  (assign  scan (op +) (reg scan) (const 1))
  (goto (label gc-loop))

A sub-rotina relocate-old-result-in-new realoca objetos da seguinte forma: Se o objeto a ser realocado (apontado por old) não for um par, então retornamos o mesmo ponteiro para o objeto inalterado (em new). (Por exemplo, podemos estar escaneando um par cujo car é o número 4. Se representarmos o car por n4, como descrito em 5.3.1, então queremos que o ponteiro car "realocado" ainda seja n4.) Caso contrário, devemos realizar a realocação. Se a posição car do par a ser realocado contiver uma tag de coração partido, então o par já foi movido, então recuperamos o endereço de encaminhamento (da posição cdr do coração partido) e retornamos isso em new. Se o ponteiro em old apontar para um par ainda não movido, então movemos o par para a primeira célula livre na nova memória (apontada por free) e configuramos o coração partido armazenando uma tag de coração partido e um endereço de encaminhamento na localização antiga. Relocate-old-result-in-new usa um registrador oldcr para armazenar o car ou o cdr do objeto apontado por old.303

relocate-old-result-in-new
  (test (op pointer-to-pair?) (reg old))
  (branch (label pair))
  (assign new (reg old))
  (goto (reg relocate-continue))
pair
  (assign  oldcr 
           (op vector-ref)
           (reg the-cars)
           (reg old))
  (test (op broken-heart?) (reg oldcr))
  (branch  (label already-moved))
  (assign  new (reg free)) ; nova localização para o par
  ;; Atualiza o ponteiro `free`.
  (assign free (op +) (reg free) (const 1))
  ;; Copia o `car` e o `cdr` para a nova memória.
  (perform (op vector-set!)
           (reg new-cars)
           (reg new)
           (reg oldcr))
  (assign  oldcr 
           (op vector-ref)
           (reg the-cdrs)
           (reg old))
  (perform (op vector-set!)
           (reg new-cdrs)
           (reg new)
           (reg oldcr))
  ;; Constrói o coração partido.
  (perform (op vector-set!)
           (reg the-cars)
           (reg old)
           (const broken-heart))
  (perform (op vector-set!)
           (reg the-cdrs)
           (reg old)
           (reg new))
  (goto (reg relocate-continue))
already-moved
  (assign  new
           (op vector-ref)
           (reg the-cdrs)
           (reg old))
  (goto (reg relocate-continue))

No final do processo de coleta de lixo, trocamos os papéis das memórias antiga e nova trocando os ponteiros: trocamos the-cars com new-cars, e the-cdrs com new-cdrs. Estaremos então prontos para realizar outra coleta de lixo na próxima vez que a memória se esgotar.

gc-flip
  (assign temp (reg the-cdrs))
  (assign the-cdrs (reg new-cdrs))
  (assign new-cdrs (reg temp))
  (assign temp (reg the-cars))
  (assign the-cars (reg new-cars))
  (assign new-cars (reg temp))

Notas de rodapé

290 Poderíamos representar a memória como listas de itens. No entanto, o tempo de acesso não seria independente do índice, pois acessar o n ésimo elemento de uma lista requer n 1 operações cdr.

291 Para completude, deveríamos especificar uma operação make-vector que constrói vetores. No entanto, na presente aplicação, usaremos vetores apenas para modelar divisões fixas da memória do computador.

292 Essa é exatamente a mesma ideia de "dados marcados" que introduzimos no Capítulo 2 para lidar com operações genéricas. Aqui, no entanto, os tipos de dados são incluídos no nível primitivo da máquina, em vez de serem construídos por meio do uso de listas.

293 As informações de tipo podem ser codificadas de várias maneiras, dependendo dos detalhes da máquina na qual o sistema Lisp será implementado. A eficiência de execução dos programas Lisp dependerá fortemente de quão inteligentemente essa escolha for feita, mas é difícil formular regras gerais de design para boas escolhas. A maneira mais direta de implementar ponteiros tipados é alocar um conjunto fixo de bits em cada ponteiro para ser um campo de tipo que codifica o tipo de dado. Questões importantes a serem abordadas no projeto de tal representação incluem: Quantos bits de tipo são necessários? Quão grandes devem ser os índices dos vetores? Quão eficientemente as instruções primitivas da máquina podem ser usadas para manipular os campos de tipo dos ponteiros? Máquinas que incluem hardware especial para o manuseio eficiente de campos de tipo são chamadas de arquiteturas marcadas.

294 Essa decisão sobre a representação de números determina se eq?, que testa a igualdade de ponteiros, pode ser usado para testar a igualdade de números. Se o ponteiro contiver o número em si, então números iguais terão o mesmo ponteiro. Mas se o ponteiro contiver o índice de uma localização onde o número está armazenado, números iguais terão ponteiros iguais apenas se tivermos o cuidado de nunca armazenar o mesmo número em mais de uma localização.

295 Isso é como escrever um número como uma sequência de dígitos, exceto que cada "dígito" é um número entre 0 e o maior número que pode ser armazenado em um único ponteiro.

296 Existem outras maneiras de encontrar armazenamento livre. Por exemplo, poderíamos vincular todos os pares não utilizados em uma lista livre. Nossas localizações livres são consecutivas (e, portanto, podem ser acessadas incrementando um ponteiro) porque estamos usando um coletor de lixo compactador, como veremos em 5.3.2.

297 Isso é essencialmente a implementação de cons em termos de set-car! e set-cdr!, como descrito em 3.3.1. A operação get-new-pair usada nessa implementação é realizada aqui pelo ponteiro free.

298 Isso pode não ser verdade eventualmente, porque as memórias podem ficar grandes o suficiente para que seja impossível ficar sem memória livre durante a vida útil do computador. Por exemplo, há cerca de 3 10 13 microssegundos em um ano, então se fizermos cons uma vez por microssegundo, precisaríamos de cerca de 10 15 células de memória para construir uma máquina que pudesse operar por 30 anos sem ficar sem memória. Essa quantidade de memória parece absurdamente grande pelos padrões atuais, mas não é fisicamente impossível. Por outro lado, os processadores estão ficando mais rápidos e um computador futuro pode ter um grande número de processadores operando em paralelo em uma única memória, então pode ser possível usar a memória muito mais rápido do que postulamos.

299 Assumimos aqui que a pilha é representada como uma lista, como descrito em 5.3.1, de modo que os itens na pilha são acessíveis por meio do ponteiro no registrador da pilha.

300 Essa ideia foi inventada e implementada pela primeira vez por Minsky, como parte da implementação de Lisp para o PDP-1 no Laboratório de Pesquisa em Eletrônica do MIT. Foi desenvolvida por Fenichel e Yochelson (1969) para uso na implementação de Lisp para o sistema de tempo compartilhado Multics. Posteriormente, Baker (1978) desenvolveu uma versão "em tempo real" do método, que não requer que a computação pare durante a coleta de lixo. A ideia de Baker foi estendida por Hewitt, Lieberman e Moon (ver Lieberman e Hewitt 1983) para aproveitar o fato de que alguma estrutura é mais volátil e outra estrutura é mais permanente.

Uma técnica alternativa comumente usada para coleta de lixo é o método mark-sweep. Isso consiste em rastrear toda a estrutura acessível a partir dos registradores da máquina e marcar cada par que alcançamos. Em seguida, varremos toda a memória, e qualquer localização que não esteja marcada é "varrida" como lixo e disponibilizada para reutilização. Uma discussão completa do método mark-sweep pode ser encontrada em Allen 1978.

O algoritmo Minsky-Fenichel-Yochelson é o algoritmo dominante em uso para sistemas de grande memória porque examina apenas a parte útil da memória. Isso contrasta com o mark-sweep, no qual a fase de varredura deve verificar toda a memória. Uma segunda vantagem do stop-and-copy é que ele é um coletor de lixo compactador. Ou seja, no final da fase de coleta de lixo, os dados úteis terão sido movidos para localizações consecutivas de memória, com todos os pares de lixo compactados. Isso pode ser uma consideração de desempenho extremamente importante em máquinas com memória virtual, nas quais acessos a endereços de memória amplamente separados podem exigir operações de paginação extras.

301 Essa lista de registradores não inclui os registradores usados pelo sistema de alocação de armazenamento—root, the-cars, the-cdrs, e os outros registradores que serão introduzidos nesta seção.

302 O termo coração partido foi cunhado por David Cressey, que escreveu um coletor de lixo para MDL, um dialeto de Lisp desenvolvido no MIT durante o início dos anos 1970.

303 O coletor de lixo usa o predicado de baixo nível pointer-to-pair? em vez da operação de estrutura de lista pair? porque em um sistema real pode haver várias coisas que são tratadas como pares para fins de coleta de lixo. Por exemplo, em um sistema Scheme que segue o padrão IEEE, um objeto de procedimento pode ser implementado como um tipo especial de "par" que não satisfaz o predicado pair?. Para fins de simulação, pointer-to-pair? pode ser implementado como pair?.