Hash Table

Hash Tables são poderosas estruturas de dados, associando chaves com valores de uma forma muito eficiente. Hash Tables são preferíveis à association lists em casos que performance é essencial, porém introduzem um pouco de overhead, o que torna assoc lists melhores caso a quantidade de pares key-value seja baixa.

Mas às vezes Alists podem ser usadas de forma diferentes:

  • podem ser ordenadas
  • é possível fazer um push em cons cells que possuem a mesma key, remover a primeira e obter uma pilha
  • possuem uma representação sã aos olhos humanos
  • podem ser fácilmente (de)serializadas
  • devido ao RASSOC, chaves e valores em alists são essêncialmente intercâmbiáveis; já em hash tables chaves e valores desempenham papéis bem diferentes (Veja CLRecipes para mais).

Criando uma Hash Table

Hash Tables são criadas usando a função make-hash-table. Ela não requer argumentos, e o seu argumento mais usado é :test, especificando a função usada para testar a equalidade das chaves.

Se a biblioteca do cl21 for usada, é possível criar a hash table e adicionar elementos ao mesmo tempo com a nova sintáxe do reader #H :

(defparameter *my-hash* #H(:name "Eitaro Fukamachi"))

e acessamos um elemento com

(getf *my-hash* :name)

Obtendo um valor de uma Hash Table

A função gethash recebe dois argumentos obrigatórios: uma chave, e uma hash table. E retorna dois valores: o valor correspondente à chave na hash table (ou nil se não for encontrado), e um boolean indicando se a chave foi encontrada ou não. O segundo valor é necessário, já que nil é um valor válido em um par chave-valor, então receber nil como primeiro argumento de gethash não significa necessáriamente que a chave não foi encontrada.

Obtendo uma chave que não existe com um valor default

gethash poossui um terceiro argumento opcional

(gethash 'bar *my-hash* "default-bar")
;; => "default-bar"
;;     NIL

Obtendo todas as chaves ou todos os valores de uma hash table

A biblioteca Alexandria (em Quicklisp) tem as funções hash-table-keys e hash-table-values com esse propósito.

(ql:quickload :alexandria)
;; […]
(alexandria:hash-table-keys *my-hash*)
;; => (BAR)

Adicionando um elemento a uma Hash Table

Se deseja adicionar um elemento a uma hash table, você pode usar a função gethash para obter os elementos da hash table, em conjunto com setf.

CL-USER> (defparameter *my-hash* (make-hash-table))
*MY-HASH*
CL-USER> (setf (gethash 'one-entry *my-hash*) "one")
"one"
CL-USER> (setf (gethash 'another-entry *my-hash*) 2/4)
1/2
CL-USER> (gethash 'one-entry *my-hash*)
"one"
T
CL-USER> (gethash 'another-entry *my-hash*)
1/2
T

Testando a presença de uma chave em uma Hash Table

O primeiro valor retornado por gethash é o objeto da hash table associado à chave provida por você como argumento a gethash, ou nil caso nenhum valor exista para esta chave. Esse valor pode ser usado como um boolean generalizado se você deseja testar a existência de chaves.

CL-USER> (defparameter *my-hash* (make-hash-table))
*MY-HASH*
CL-USER> (setf (gethash 'one-entry *my-hash*) "one")
"one"
CL-USER> (if (gethash 'one-entry *my-hash*)
           "Key exists"
           "Key does not exist")
"Key exists"
CL-USER> (if (gethash 'another-entry *my-hash*)
           "Key exists"
           "Key does not exist")
"Key does not exist"

Mas perceba que isso não funciona caso nil esteja entre os valores que você deseja pôr no hash.

CL-USER> (setf (gethash 'another-entry *my-hash*) nil)
NIL
CL-USER> (if (gethash 'another-entry *my-hash*)
           "Key exists"
           "Key does not exist")
"Key does not exist"

Neste caso, você deve verificar o segundo valor de retorno de gethash que sempre retornará nil se nenhum valor for encontrado, e T caso contrário.

CL-USER> (if (nth-value 1 (gethash 'another-entry *my-hash*))
           "Key exists"
           "Key does not exist")
"Key exists"
CL-USER> (if (nth-value 1 (gethash 'no-entry *my-hash*))
           "Key exists"
           "Key does not exist")
"Key does not exist"

Deletando de uma Hash Table

Use remhash para deletar uma entrada. Tanto a chave quanto seu valor associado serão removidos da tabela. remhash retorna T se a entrada existe, e nil se não.

CL-USER> (defparameter *my-hash* (make-hash-table))
*MY-HASH*
CL-USER> (setf (gethash 'first-key *my-hash*) 'one)
ONE
CL-USER> (gethash 'first-key *my-hash*)
ONE
T
CL-USER> (remhash 'first-key *my-hash*)
T
CL-USER> (gethash 'first-key *my-hash*)
NIL
NIL
CL-USER> (gethash 'no-entry *my-hash*)
NIL
NIL
CL-USER> (remhash 'no-entry *my-hash*)
NIL
CL-USER> (gethash 'no-entry *my-hash*)
NIL
NIL

Percorrendo uma Hash Table

Se você deseja realizar uma ação em cada par chave-valor em uma hash table você pode usar:

maphash que itera sobre todas as entradas na tabela. Seu primeiro argumento deve ser uma função que aceita dois argumentos, a chave e o valor de cada entrada.

Perceba que pela natureza das Hash Tables é impossível controlar a ordem em que as entradas são providas pela maphash (ou outras funções que percorram hash tables). maphash sempre retorna nil.

CL-USER> (defparameter *my-hash* (make-hash-table))
*MY-HASH*
CL-USER> (setf (gethash 'first-key *my-hash*) 'one)
ONE
CL-USER> (setf (gethash 'second-key *my-hash*) 'two)
TWO
CL-USER> (setf (gethash 'third-key *my-hash*) nil)
NIL
CL-USER> (setf (gethash nil *my-hash*) 'nil-value)
NIL-VALUE
CL-USER> (defun print-hash-entry (key value)
    (format t "The value associated with the key ~S is ~S~%" key value))
PRINT-HASH-ENTRY
CL-USER> (maphash #'print-hash-entry *my-hash*)
The value associated with the key FIRST-KEY is ONE
The value associated with the key SECOND-KEY is TWO
The value associated with the key THIRD-KEY is NIL
The value associated with the key NIL is NIL-VALUE

Também é possível usar with-hash-table-iterator, uma macro que faz do seu primeiro argumento um iterador (usando macrolet) que retorna três valores por hash table para cada vez que for invocada - um boolean generalizado que é true se uma entrada for retornada, a chave para a entrada, e o valor da entrada. Se não houverem mais entradas, apenas o valor é rotornado - nil

;;; same hash-table as above
CL-USER> (with-hash-table-iterator (my-iterator *my-hash*)
           (loop
              (multiple-value-bind (entry-p key value)
                  (my-iterator)
                (if entry-p
                    (print-hash-entry key value)
                    (return)))))
The value associated with the key FIRST-KEY is ONE
The value associated with the key SECOND-KEY is TWO
The value associated with the key THIRD-KEY is NIL
The value associated with the key NIL is NIL-VALUE
NIL

TODO: Consigo traduzir isso não moisés Tome nota da seguinte ressalva do Hyperspec: "Não é especificado o que pode acontecer se qualquer estado interior implícito de uma iteração for retornada fora da extensão dinamica da form with-hash-table-iterator, como retornar um término da form de invocação.

E sempre existe a opção do loop:

;;; same hash-table as above
CL-USER> (loop for key being the hash-keys of *my-hash*
           do (print key))
FIRST-KEY
SECOND-KEY
THIRD-KEY
NIL
NIL
CL-USER> (loop for key being the hash-keys of *my-hash*
           using (hash-value value)
           do (format t "The value associated with the key ~S is ~S~%" key value))
The value associated with the key FIRST-KEY is ONE
The value associated with the key SECOND-KEY is TWO
The value associated with the key THIRD-KEY is NIL
The value associated with the key NIL is NIL-VALUE
NIL
CL-USER> (loop for value being the hash-values of *my-hash*
           do (print value))
ONE
TWO
NIL
NIL-VALUE
NIL
CL-USER> (loop for value being the hash-values of *my-hash*
           using (hash-key key)
           do (format t "~&~A -> ~A" key value))
FIRST-KEY -> ONE
SECOND-KEY -> TWO
THIRD-KEY -> NIL
NIL -> NIL-VALUE
NIL

**TODO: Procurar link certo pro cl21 Por último, existe também o (doeach ((key val) *hash*) ...) de cl21.

Percorrendo chaves ou valores

Para fazer um map pelas chaves ou pelos valores, é possível usar maphash-keys e maphash-values de Alexandria.

Contando as entradas em uma Hash Table

Não é preciso usar seus dedos (rs) - Common Lisp já possui uma função para isso hash-table-count.

CL-USER> (defparameter *my-hash* (make-hash-table))
*MY-HASH*
CL-USER> (hash-table-count *my-hash*)
0
CL-USER> (setf (gethash 'first *my-hash*) 1)
1
CL-USER> (setf (gethash 'second *my-hash*) 2)
2
CL-USER> (setf (gethash 'third *my-hash*) 3)
3
CL-USER> (hash-table-count *my-hash*)
3
CL-USER> (setf (gethash 'second *my-hash*) 'two)
TWO
CL-USER> (hash-table-count *my-hash*)
3
CL-USER> (clrhash *my-hash*)
#<EQL hash table, 0 entries {48205F35}>
CL-USER> (hash-table-count *my-hash*)
0

Problemas de Performance: Tamanho da Hash Table

A função make-hash-table possui alguns parâmetros opcionais que controlam o tamanho inicial da sua Hash Table e de que forma ela vai crescer, caso preciso. Esse pode ser um iimportante problema de performance se você está trabalhando com hash tables grandes. Aqui está um exemplo (não muito científico) usando CMUCL pre-18d em um sistema Linux:

CL-USER> (defparameter *my-hash* (make-hash-table))
*MY-HASH*
CL-USER> (hash-table-size *my-hash*)
65
CL-USER> (hash-table-rehash-size *my-hash*)
1.5
CL-USER> (time (dotimes (n 100000) (setf (gethash n *my-hash*) n)))
Compiling LAMBDA NIL:
Compiling Top-Level Form:

Evaluation took:
  0.27 seconds of real time
  0.25 seconds of user run time
  0.02 seconds of system run time
  0 page faults and
  8754768 bytes consed.
NIL
CL-USER> (time (dotimes (n 100000) (setf (gethash n *my-hash*) n)))
Compiling LAMBDA NIL:
Compiling Top-Level Form:

Evaluation took:
  0.05 seconds of real time
  0.05 seconds of user run time
  0.0 seconds of system run time
  0 page faults and
  0 bytes consed.
NIL

Os valores para hash-table-size e para hash-table-rehash-size são dependentes da implementação. No nosso caso, CMUL escolhe um tamanho inicial de 65, e vai aumentar o tamanho da tabela em 50% sempre que ela tiver que crescer. Vejamos quantas vezes nós vamos ter que redimensionar o tamanho da nossa hash table até que ela chegue ao seu tamanho final...

CL-USER> (log (/ 100000 65) 1.5)
18.099062
CL-USER> (let ((size 65)) (dotimes (n 20) (print (list n size)) (setq size (* 1.5 size))))
(0 65)
(1 97.5)
(2 146.25)
(3 219.375)
(4 329.0625)
(5 493.59375)
(6 740.3906)
(7 1110.5859)
(8 1665.8789)
(9 2498.8184)
(10 3748.2275)
(11 5622.3413)
(12 8433.512)
(13 12650.268)
(14 18975.402)
(15 28463.104)
(16 42694.656)
(17 64041.984)
(18 96062.98)
(19 144094.47)
NIL

Ele teve que crescer 19 vezes até ser grande o suficiente para comportar 100.000 entradas. Isto explica o porque de tantos consings e a relativa demora para preencher a tabela. Explica também porque a segunda vez foi bem mais rápida - a hash table já estava com seu tamanho correto.

Aqui está uma forma mais rápida de fazer esse redimensionamento: Se já soubermos de antemão quão grande a nossa tabela será, nós podemos de cara começar com o tamanho exato:

CL-USER> (defparameter *my-hash* (make-hash-table :size 100000))
*MY-HASH*
CL-USER> (hash-table-size *my-hash*)
100000
CL-USER> (time (dotimes (n 100000) (setf (gethash n *my-hash*) n)))
Compiling LAMBDA NIL:
Compiling Top-Level Form:

Evaluation took:
  0.04 seconds of real time
  0.04 seconds of user run time
  0.0 seconds of system run time
  0 page faults and
  0 bytes consed.
NIL

Esta forma é obviamente muito mais rápida. Além disso não envolveu consing de forma alguma, pois não houve redimensionamento. Se não se sabe o tamanho final, mas é possível estimar a taxa de crescimento da hash table, pode-se passar este valor para make-hash-table. Ao se passar um número inteito especifica-se um crescimento absoluto, já um numero de ponto flutuante um crescimento relativo.

CL-USER> (defparameter *my-hash* (make-hash-table :rehash-size 100000))
*MY-HASH*
CL-USER> (hash-table-size *my-hash*)
65
CL-USER> (hash-table-rehash-size *my-hash*)
100000
CL-USER> (time (dotimes (n 100000) (setf (gethash n *my-hash*) n)))
Compiling LAMBDA NIL:
Compiling Top-Level Form:

Evaluation took:
  0.07 seconds of real time
  0.05 seconds of user run time
  0.01 seconds of system run time
  0 page faults and
  2001360 bytes consed.
NIL

Relativamente rápido (apenas um redimensionamento é necessário) mas com muito mais consing, pois quase toda a hash table (tirando os 65 elementos iniciais) teve de ser construída durante o loop.

Note também que você pode especificar o rehash-threshold enquanto cria uma nova hash table. E por último, sua implementação pode ignorar completamente os valores os valores passados por rehash-size e rehash-threshold ...