4 Commits

Author SHA1 Message Date
f35583e079 fix!: removed all 'mixed pointer'-edness
All checks were successful
CI / Check PR Title (pull_request) Successful in 31s
CI / Makefile Lint (pull_request) Successful in 45s
CI / Markdown Lint (pull_request) Successful in 34s
CI / Unit Tests (pull_request) Successful in 41s
CI / Go Lint (pull_request) Successful in 56s
CI / Fuzz Tests (pull_request) Successful in 1m39s
CI / Mutation Tests (pull_request) Successful in 1m5s
2026-04-15 22:59:31 -04:00
ce41d4fba2 feat: add recvcheck linter 2026-04-15 22:58:14 -04:00
42c5b5f8f4 feat!: update get from (V, error) to (V, bool) (#20)
All checks were successful
CI / Check PR Title (push) Has been skipped
CI / Go Lint (push) Successful in 43s
CI / Makefile Lint (push) Successful in 41s
CI / Markdown Lint (push) Successful in 32s
CI / Unit Tests (push) Successful in 39s
CI / Fuzz Tests (push) Successful in 1m44s
CI / Mutation Tests (push) Successful in 1m28s
## Description

Currently, the signature for `Table.Get` is `func (K) (V, error)`. This is not very Go-idiomatic, which prefers to return a boolean instead of an error. For instance, a built-in Go map is used like so:

```go
if value, ok := users[id]; !ok {
  // ...
}
```

Updating our table to look like that is best practice. In that same vein, to support direct lookup (i.e. `v := users[id]`), this PR also adds `Table.Find`.

## Changes

- BREAKING CHANGE: Update contract of `Table.Get` to `func (K) (V, bool)`. Returns 'false' is the item cannot be found, and 'true' if it is found.
- Add `Table.Find`.
- Updated tests and documentation to match the change.

### Design Decisions

- Chose to make this decision because throwing an error implies that there is something 'wrong' with the table. There is nothing wrong with the table; it is just that the item does not exist.

## Checklist

- [x] Tests pass
- [x] Docs updated

Reviewed-on: #20
Co-authored-by: M.V. Hutz <git@maximhutz.me>
Co-committed-by: M.V. Hutz <git@maximhutz.me>
2026-04-14 01:58:15 +00:00
867a1d49df feat: sentinel error ErrBadHash (#19)
All checks were successful
CI / Check PR Title (push) Has been skipped
CI / Makefile Lint (push) Successful in 1m4s
CI / Markdown Lint (push) Successful in 32s
CI / Go Lint (push) Successful in 1m15s
CI / Unit Tests (push) Successful in 38s
CI / Fuzz Tests (push) Successful in 1m34s
CI / Mutation Tests (push) Successful in 1m31s
## Description

Currently, the errors are not sentinel, and so are hard to test for. This PR makes sure hash collision errors are accounted for.

## Changes

- Add `ErrBadHash`. Happens when there are too many collisions for an item to be added.

### Design Decisions

- Chose to name `ErrBadHash` over `ErrCycle` because the feedbach that the user should be given is to evaluate their hash functions. Cycle collision is a bit esoteric.

## Checklist

- [x] Tests pass
- [x] Docs updated

Reviewed-on: #19
Co-authored-by: M.V. Hutz <git@maximhutz.me>
Co-committed-by: M.V. Hutz <git@maximhutz.me>
2026-04-14 00:38:11 +00:00
6 changed files with 51 additions and 32 deletions

View File

@@ -114,6 +114,9 @@ linters:
# Reports uses of functions with replacement inside the testing package.
- usetesting
# Reports mixed receiver types in structs/interfaces.
- recvcheck
settings:
revive:
rules:
@@ -198,7 +201,7 @@ linters:
# warns when initialism, variable or package naming conventions are not followed.
- name: var-naming
misspell:
# Correct spellings using locale preferences for US or UK.
# Setting locale to US will correct the British spelling of 'colour' to 'color'.

View File

@@ -19,11 +19,11 @@ type bucket[K, V any] struct {
// location determines where in the bucket a certain key would be placed. If the
// capacity is 0, this will panic.
func (b bucket[K, V]) location(key K) uint64 {
func (b *bucket[K, V]) location(key K) uint64 {
return b.hash(key) % b.capacity
}
func (b bucket[K, V]) get(key K) (value V, found bool) {
func (b *bucket[K, V]) get(key K) (value V, found bool) {
if b.capacity == 0 {
return
}
@@ -54,7 +54,7 @@ func (b *bucket[K, V]) resize(capacity uint64) {
b.size = 0
}
func (b bucket[K, V]) update(key K, value V) (updated bool) {
func (b *bucket[K, V]) update(key K, value V) (updated bool) {
if b.capacity == 0 {
return
}

View File

@@ -73,16 +73,16 @@ func FuzzInsertLookup(f *testing.F) {
delete(expected, step.key)
_, err = actual.Get(step.key)
assert.Error(err)
_, ok := actual.Get(step.key)
assert.False(ok)
} else {
err := actual.Put(step.key, step.value)
assert.NoError(err)
expected[step.key] = step.value
found, err := actual.Get(step.key)
assert.NoError(err)
found, ok := actual.Get(step.key)
assert.True(ok)
assert.Equal(step.value, found)
}

View File

@@ -108,12 +108,12 @@ func TestGetMany(t *testing.T) {
}
for i := range 2_000 {
value, err := table.Get(i)
value, ok := table.Get(i)
if i < 1_000 {
assert.NoError(err)
assert.True(ok)
assert.Equal(value, true)
} else {
assert.Error(err)
assert.False(ok)
}
}
}

View File

@@ -14,19 +14,19 @@ func Example_basic() {
fmt.Println("Put error:", err)
}
if item, err := table.Get(1); err != nil {
fmt.Println("Error:", err)
if item, ok := table.Get(1); !ok {
fmt.Println("Not Found 1!")
} else {
fmt.Println("Found 1:", item)
}
if item, err := table.Get(0); err != nil {
fmt.Println("Error:", err)
if item, ok := table.Get(0); !ok {
fmt.Println("Not Found 0!")
} else {
fmt.Println("Found 0:", item)
}
// Output:
// Found 1: Hello, World!
// Error: key '0' not found
// Not Found 0!
}

View File

@@ -1,12 +1,21 @@
package cuckoo
import (
"errors"
"fmt"
"iter"
"math/bits"
"strings"
)
// ErrBadHash occurs when the hashes given to a [Table] cause too many key
// collisions. Try rebuilding the table using:
//
// 1. Different hash seeds. Equal seeds produce equal hash functions, which
// always cycle.
// 2. A different [Hash] algorithm.
var ErrBadHash = errors.New("bad hash")
// A Table is hash table that uses cuckoo hashing to resolve collision. Create
// one with [NewTable]. Or if you want more granularity, use [NewTableBy] or
// [NewCustomTable].
@@ -18,12 +27,12 @@ type Table[K, V any] struct {
// TotalCapacity returns the number of slots allocated for the [Table]. To get the
// number of slots filled, look at [Table.Size].
func (t Table[K, V]) TotalCapacity() uint64 {
func (t *Table[K, V]) TotalCapacity() uint64 {
return t.bucketA.capacity + t.bucketB.capacity
}
// Size returns how many slots are filled in the [Table].
func (t Table[K, V]) Size() int {
func (t *Table[K, V]) Size() int {
return int(t.bucketA.size + t.bucketB.size)
}
@@ -31,11 +40,11 @@ func log2(n uint64) (m int) {
return max(0, bits.Len64(n)-1)
}
func (t Table[K, V]) maxEvictions() int {
func (t *Table[K, V]) maxEvictions() int {
return 3 * log2(t.TotalCapacity())
}
func (t Table[K, V]) load() float64 {
func (t *Table[K, V]) load() float64 {
// When there are no slots in the table, we still treat the load as 100%.
// Every slot in the table is full.
if t.TotalCapacity() == 0 {
@@ -86,24 +95,31 @@ func (t *Table[K, V]) shrink() error {
return t.resize(t.bucketA.capacity / t.growthFactor)
}
// Get fetches the value for a key in the [Table]. Returns an error if no value
// is found.
func (t Table[K, V]) Get(key K) (value V, err error) {
// Get fetches the value for a key in the [Table]. Matches the comma-ok pattern
// of a builtin map; see [Table.Find] for plain indexing.
func (t *Table[K, V]) Get(key K) (value V, ok bool) {
if item, ok := t.bucketA.get(key); ok {
return item, nil
return item, true
}
if item, ok := t.bucketB.get(key); ok {
return item, nil
return item, true
}
return value, fmt.Errorf("key '%v' not found", key)
return
}
// Find fetches the value of a key. Matches direct indexing of a builtin map;
// see [Table.Get] for a comma-ok pattern.
func (t *Table[K, V]) Find(key K) (value V) {
value, _ = t.Get(key)
return
}
// Has returns true if a key has a value in the table.
func (t Table[K, V]) Has(key K) (exists bool) {
_, err := t.Get(key)
return err == nil
func (t *Table[K, V]) Has(key K) (exists bool) {
_, exists = t.Get(key)
return
}
// Put sets the value for a key. Returns error if its value cannot be set.
@@ -128,7 +144,7 @@ func (t *Table[K, V]) Put(key K, value V) (err error) {
}
if t.load() < t.minLoadFactor {
return fmt.Errorf("bad hash: resize on load %d/%d = %f", t.Size(), t.TotalCapacity(), t.load())
return fmt.Errorf("hash functions produced a cycle at load %d/%d: %w", t.Size(), t.TotalCapacity(), ErrBadHash)
}
if err := t.grow(); err != nil {
@@ -152,7 +168,7 @@ func (t *Table[K, V]) Drop(key K) (err error) {
}
// Entries returns an unordered sequence of all key-value pairs in the table.
func (t Table[K, V]) Entries() iter.Seq2[K, V] {
func (t *Table[K, V]) Entries() iter.Seq2[K, V] {
return func(yield func(K, V) bool) {
for _, slot := range t.bucketA.slots {
if slot.occupied {
@@ -174,7 +190,7 @@ func (t Table[K, V]) Entries() iter.Seq2[K, V] {
// String returns the entries of the table as a string in the format:
// "table[k1:v1 h2:v2 ...]".
func (t Table[K, V]) String() string {
func (t *Table[K, V]) String() string {
var sb strings.Builder
sb.WriteString("table[")