From 709d977436884351f66b18ff4bc000235fed8a12 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Thu, 16 Jul 2026 23:14:42 +0200 Subject: [PATCH] simple hashmap implementation --- source/main.hon | 1 + std/hashmap/hashmap.hon | 158 +++++++++++++++++++++++++++++++++++ std/hashmap/hashmap.test.hon | 13 +++ std/mem/mem.hon | 32 ++++--- std/testing/testing.hon | 4 + 5 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 std/hashmap/hashmap.hon create mode 100644 std/hashmap/hashmap.test.hon diff --git a/source/main.hon b/source/main.hon index f1306cb..790bcfb 100644 --- a/source/main.hon +++ b/source/main.hon @@ -4,6 +4,7 @@ import "@std/mem" import "@std/arraylist" test import "@std/arraylist" +test import "@std/hashmap" program :: `# these are immutable diff --git a/std/hashmap/hashmap.hon b/std/hashmap/hashmap.hon new file mode 100644 index 0000000..4b7c518 --- /dev/null +++ b/std/hashmap/hashmap.hon @@ -0,0 +1,158 @@ +import "@std/mem" + +PutError :: enum { key_exists } + +Entry func($K, $V type) type { + return struct { + # hash = 0 means empty + hash usize = 0 + key K + value V + } +} + +HashMap func( + $K, $V type, + $hash_key func(key K) usize, + $keys_eql func(a, b K) bool, +) type { + return struct { + entries []mut Entry(K, V) + count usize + allocator mem.Allocator + } +} + +StringHashMap func($V type) type { + return HashMap([]u8, V, str_hash, str_eql) +} + +init func( + $K, $V type, + $hash_key func(key K) usize, + $keys_eql func(a, b K) bool, + allocator mem.Allocator, +) HashMap(K, V, hash_key, keys_eql) { + return HashMap(K, V, hash_key, keys_eql){ + entries = mem.empty(Entry(K, V)), + count = 0, + allocator = allocator, + } +} + +# frees the entries in the hash map and invalidates it. +deinit func( + $K, $V type, + $hash_key func(key K) usize, + $keys_eql func(a, b K) bool, + map @HashMap(K, V, hash_key, keys_eql), +) void { mem.free(map.allocator, map.entries) } + +get func( + $K, $V type, + $hash_key func(key K) usize, + $keys_eql func(a, b K) bool, + map @HashMap(K, V, hash_key, keys_eql), + key K, +) ?V { + if (map.count == 0) return none + + hash :: normalize(hash_key(key)) + idx usize = hash & (map.entries.len - 1) + + while true { + entry :: map.entries[idx] + if (entry.hash == 0) return none + + if (entry.hash == hash and keys_eql(entry.key, key)) { + return entry.value + } + + idx = (idx + 1) & (map.entries.len - 1) + } +} + +put func( + $K, $V type, + $hash_key func(key K) usize, + $keys_eql func(a, b K) bool, + map @mut HashMap(K, V, hash_key, keys_eql), + key K, + value V, +) void ! (PutError | mem.AllocError) { + threshold :: map.entries.len - divtrunc!(map.entries.len, 4) + if (map.entries.len == 0 or map.count + 1 > threshold) { + # grow entries array + old_entries :: map.entries + new_size :: if (old_entries.len > 0) old_entries.len * 2 else 8 + new_entries :: try mem.alloc(Entry(K, V), map.allocator, new_size) + + # zero new entries + for 0..new_entries.len |i| { + new_entries[i].hash = 0 + } + + # move old entries + for old_entries |entry| { + if (entry.hash == 0) continue + + # find an empty slot + idx usize = entry.hash & (new_entries.len - 1) + while new_entries[idx].hash != 0 { + idx = (idx + 1) & (new_entries.len - 1) + } + + new_entries[idx] = entry + } + + map.entries = new_entries + mem.free(map.allocator, old_entries) + } + + hash :: normalize(hash_key(key)) + idx usize = hash & (map.entries.len - 1) + + while true { + entry :: map.entries[idx] + if entry.hash == 0 { + map.entries[idx] = Entry(K, V){ + hash = hash, + key = key, + value = value, + } + map.count += 1 + return + } + + if (entry.hash == hash and keys_eql(entry.key, key)) { + return .key_exists + } + + idx = (idx + 1) & (map.entries.len - 1) + } +} + +hide normalize func(hash usize) usize { + # mapping both 0 and 1 to 1 is safe because equality resolves + # collisions (since hash and key must both be equal). + if (hash == 0) return 1 + return hash +} + +#! FNV-1a hash implementation. +#! note: vulnerable to collision attacks. +hide str_hash func(key []u8) usize { + hash u32 = 2166136261 # offset basis + prime u32 = 16777619 + + for key |byte| { + product u64 :: u64(hash xor u32(byte)) * prime + hash = u32(product & u64(maxval!(u32))) + } + + return usize(hash) +} + +hide str_eql func(a, b []u8) bool { + return mem.eql(a, b) +} diff --git a/std/hashmap/hashmap.test.hon b/std/hashmap/hashmap.test.hon new file mode 100644 index 0000000..57ff8a4 --- /dev/null +++ b/std/hashmap/hashmap.test.hon @@ -0,0 +1,13 @@ +import "@std/mem" +import "@std/testing" + +handles_put_and_get test { + map StringHashMap(u32) = init(mem.c_allocator) + defer deinit(&map) + + try put(&map, "key", 42) + value :: get(&map, "key") + + try testing.expect_type(?u32, value) + try testing.expect_equal(42, value?) +} diff --git a/std/mem/mem.hon b/std/mem/mem.hon index 6f97d01..81da7fd 100644 --- a/std/mem/mem.hon +++ b/std/mem/mem.hon @@ -41,17 +41,7 @@ eql func($T type, left, right []T) bool { return true } -hide empty_storage [1]mut u64 = [0] - -hide empty_slice func($T type, count usize) []mut T { - pointer *mut T :: ptrcast!(T, (&empty_storage).ptr) - return pointer[..count] -} - -empty func($T type) []mut T { - return empty_slice(T, 0) -} - +# allocate memory for a slice of type `T` with `count` elements. alloc func($T type, allocator Allocator, count usize) []mut T ! AllocError { if count == 0 { return empty_slice(T, 0) @@ -73,10 +63,14 @@ alloc func($T type, allocator Allocator, count usize) []mut T ! AllocError { return .out_of_memory } +# reallocate memory for a slice of type `T` with `new_count` elements. +# reallocating with `new_count == 0` will free the memory and return an empty slice. +# note: memory must be reallocated with the same allocator that was used to allocate it. realloc func($T type, allocator Allocator, memory []mut T, new_count usize) []mut T ! AllocError { if new_count == memory.len { return memory } + if new_count == 0 { free(allocator, memory) return empty_slice(T, 0) @@ -107,15 +101,31 @@ realloc func($T type, allocator Allocator, memory []mut T, new_count usize) []mu pointer *mut T :: ptrcast!(T, bytes) return pointer[..new_count] } + return .out_of_memory } +# free memory allocated for a slice of type `T`. +# note: memory must be freed with the same allocator that was used to allocate it. free func($T type, allocator Allocator, memory []mut T) void { if memory.len != 0 and sizeof!(T) != 0 { raw_free(allocator, ptrcast!(u8, memory.ptr), memory.len * sizeof!(T), alignof!(T)) } } +# get an empty slice of type `T` with `count` elements. +empty_slice func($T type, count usize) []mut T { + pointer *mut T :: ptrcast!(T, (&empty_storage).ptr) + return pointer[..count] +} + +# get an empty slice of type `T` with 0 elements. +empty func($T type) []mut T { + return empty_slice(T, 0) +} + +hide empty_storage [1]mut u64 = [0] + hide malloc_alignment usize :: 16 # ponytail: aarch64-macos libc malloc alignment assumption. hide power_of_two func(value usize) bool { diff --git a/std/testing/testing.hon b/std/testing/testing.hon index 3a05e90..3de655a 100644 --- a/std/testing/testing.hon +++ b/std/testing/testing.hon @@ -24,6 +24,10 @@ expect_equal func($T type, expected, actual T, location SourceLocation) void ! E } } +expect_type func($Expected, $Actual type, _ Actual, location SourceLocation) void ! Error { + try expect($(Expected == Actual), location) +} + run func(name []u8, callback *func() void ! Error) bool { callback() catch |_| { debug.print("{s} [failed]\n", {name,})