-
Notifications
You must be signed in to change notification settings - Fork 53
Add a hash map which keeps its keys as they are #468
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
76b6d9b
eeeb392
6ea44a6
3f7d039
0521e2a
629095c
e781fcc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| package FastHashMap | ||
| import NoWurst | ||
| import Wurst | ||
|
|
||
| /** A hash map which keeps its keys as they are. | ||
|
|
||
| `HashMap` casts every key to an int and stores it in a `Table`, which works for | ||
| handles and for anything castable but loses the type on the way in: two keys which | ||
| cast to the same int collide, and a key which is not castable cannot be used at all. | ||
| This map takes a bound on its key type instead, so hashing and comparing are done by | ||
| the key's own implementation and the key is stored as itself. | ||
|
|
||
| The bound is `Hashable`, which asks for a hash and an equality: | ||
|
|
||
| implements Hashable<vec2> | ||
| function hash(vec2 v) returns int | ||
| return v.x.toInt() * 31 + v.y.toInt() | ||
| function equals(vec2 a, vec2 b) returns boolean | ||
| return a == b | ||
|
|
||
| let seen = new FastHashMap<vec2, unit>() | ||
| seen.put(caster.getPos(), caster) | ||
|
|
||
| Instances for `int` and `string` come with this package. Declare one beside your own | ||
| type to use it as a key. | ||
|
|
||
| Storage is one array per specialisation, carved into a section per instance, the way | ||
| `ArrayList` works. A section is `CAPACITY` slots and does not grow, so a map which | ||
| fills up refuses further keys rather than rehashing - see `isFull`. Raise | ||
| `FastHashMap_CAPACITY` in your build config if you need larger maps; every map of one | ||
| key and value type pays that size. | ||
|
|
||
| Collisions are handled by linear probing inside the section. A removed slot becomes a | ||
| tombstone rather than empty, so a probe which passed over it still finds keys put down | ||
| beyond it. | ||
| */ | ||
|
|
||
| /** What a key type has to provide to be used in a `FastHashMap`. */ | ||
| public interface Hashable<T:> | ||
| /** Any int; keys which are equal must hash alike, or a lookup will miss them. */ | ||
| function hash(T x) returns int | ||
| function equals(T a, T b) returns boolean | ||
|
|
||
| implements Hashable<int> | ||
| function hash(int x) returns int | ||
| return x | ||
| function equals(int a, int b) returns boolean | ||
| return a == b | ||
|
|
||
| implements Hashable<string> | ||
| function hash(string x) returns int | ||
| return x.getHash() | ||
| function equals(string a, string b) returns boolean | ||
| return a == b | ||
|
|
||
| /** Slots per map. Fixed at compile time: every map of one key and value type is this | ||
| size, so raising it costs memory across all of them. */ | ||
| @configurable public constant FASTHASHMAP_CAPACITY = 32 | ||
|
|
||
| /** The number of maps of one key and value type which can exist. Sections are handed out | ||
| and never reclaimed, so this is a total over the run rather than a live count. */ | ||
| @configurable public constant FASTHASHMAP_MAX_INSTANCES = 256 | ||
|
|
||
| constant SLOTS = FASTHASHMAP_CAPACITY * FASTHASHMAP_MAX_INSTANCES | ||
|
|
||
| public class FastHashMap<K: Hashable, V:> | ||
| private static K array keys | ||
| private static V array values | ||
| private static boolean array used | ||
| /** A removed slot cannot go back to empty: a probe which stopped there would miss keys | ||
| put down beyond it. It becomes a tombstone instead - passed over when searching, | ||
| reused when putting. */ | ||
| private static boolean array dead | ||
| /** Never written, so a read yields V's default. That is the only way to say "no value" | ||
| for a type parameter, and it costs an array read rather than a branch. */ | ||
| private static V array none | ||
| private static int nextFree = 0 | ||
|
|
||
| private int base | ||
| private int count = 0 | ||
|
|
||
| construct() | ||
| if nextFree + FASTHASHMAP_CAPACITY > SLOTS | ||
| error("FastHashMap: out of sections. Raise FASTHASHMAP_MAX_INSTANCES.") | ||
| base = -1 | ||
| else | ||
| base = nextFree | ||
| nextFree += FASTHASHMAP_CAPACITY | ||
|
|
||
| /** The slot holding key, or the one it belongs in: the first tombstone passed over, | ||
| else the empty slot the probe stopped at. Capacity is fixed, so a full table | ||
| returns -1 rather than probing forever. */ | ||
| private function slotFor(K key) returns int | ||
| var i = K.hash(key) mod FASTHASHMAP_CAPACITY | ||
| if i < 0 | ||
| i += FASTHASHMAP_CAPACITY | ||
| var firstDead = -1 | ||
| var probes = 0 | ||
| while probes < FASTHASHMAP_CAPACITY | ||
| let s = base + i | ||
| if used[s] and K.equals(keys[s], key) | ||
| return s | ||
| if not used[s] and not dead[s] | ||
| if firstDead >= 0 | ||
| return firstDead | ||
| return s | ||
| if dead[s] and firstDead < 0 | ||
| firstDead = s | ||
| i = (i + 1) mod FASTHASHMAP_CAPACITY | ||
| probes++ | ||
| return firstDead | ||
|
|
||
| /** Stores value under key, replacing what was there. A full map keeps what it has. */ | ||
| function put(K key, V value) | ||
| if base < 0 | ||
| return | ||
| let s = slotFor(key) | ||
| if s < base | ||
| return | ||
| if not used[s] | ||
| used[s] = true | ||
| dead[s] = false | ||
| keys[s] = key | ||
| count++ | ||
| values[s] = value | ||
|
|
||
| /** The value stored under key, or V's default when there is none. */ | ||
| function get(K key) returns V | ||
| if base < 0 | ||
| return none[0] | ||
| let s = slotFor(key) | ||
| if s < base or not used[s] | ||
| return none[0] | ||
| return values[s] | ||
|
|
||
| /** Whether a value is stored under key. */ | ||
| function has(K key) returns boolean | ||
| if base < 0 | ||
| return false | ||
| let s = slotFor(key) | ||
| return s >= base and used[s] | ||
|
|
||
| /** Removes key, returning whether it was there. */ | ||
| function remove(K key) returns boolean | ||
| if base < 0 | ||
| return false | ||
| let s = slotFor(key) | ||
| if s < base or not used[s] | ||
| return false | ||
| used[s] = false | ||
| dead[s] = true | ||
|
Comment on lines
+223
to
+224
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On Lua, removing an entry only changes the occupancy flags, while the static Useful? React with 👍 / 👎. |
||
| count-- | ||
| return true | ||
|
|
||
| /** How many keys are stored. */ | ||
| function size() returns int | ||
| return count | ||
|
|
||
| /** Whether the map is empty. */ | ||
| function isEmpty() returns boolean | ||
| return count == 0 | ||
|
|
||
| /** Whether a further key would be refused. A map at capacity accepts writes to keys it | ||
| already holds, and refuses new ones. */ | ||
| function isFull() returns boolean | ||
| return count >= FASTHASHMAP_CAPACITY | ||
|
|
||
| /** Forgets every key, leaving the section reusable by this map. */ | ||
| function clear() | ||
| if base < 0 | ||
| return | ||
| for i = 0 to FASTHASHMAP_CAPACITY - 1 | ||
| used[base + i] = false | ||
| dead[base + i] = false | ||
| count = 0 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| package FastHashMapTests | ||
| import FastHashMap | ||
|
|
||
| @Test | ||
| function testPutGet() | ||
| let map = new FastHashMap<int, int>() | ||
| map.put(1, 10) | ||
| map.put(2, 20) | ||
| map.get(1).assertEquals(10) | ||
| map.get(2).assertEquals(20) | ||
|
|
||
| @Test | ||
| function testHas() | ||
| let map = new FastHashMap<int, int>() | ||
| map.has(5).assertEquals(false) | ||
| map.put(5, 1) | ||
| map.has(5).assertEquals(true) | ||
|
|
||
| /** A missing key reads as the value type's default rather than as an error. */ | ||
| @Test | ||
| function testMissingKeyIsDefault() | ||
| let map = new FastHashMap<int, int>() | ||
| map.get(7).assertEquals(0) | ||
| let strings = new FastHashMap<int, string>() | ||
| strings.get(7).assertEquals(null) | ||
|
|
||
| @Test | ||
| function testPutReplaces() | ||
| let map = new FastHashMap<int, int>() | ||
| map.put(3, 30) | ||
| map.put(3, 31) | ||
| map.get(3).assertEquals(31) | ||
| map.size().assertEquals(1) | ||
|
|
||
| /** Keys 1 and 1 + CAPACITY land in the same slot, so the probe path is taken. */ | ||
| @Test | ||
| function testCollidingKeys() | ||
| let map = new FastHashMap<int, int>() | ||
| map.put(1, 10) | ||
| map.put(1 + FASTHASHMAP_CAPACITY, 90) | ||
| map.get(1).assertEquals(10) | ||
| map.get(1 + FASTHASHMAP_CAPACITY).assertEquals(90) | ||
| map.size().assertEquals(2) | ||
|
|
||
| /** A removed slot has to stay passable, or a key probed past it goes missing. */ | ||
| @Test | ||
| function testRemoveKeepsLaterKeysReachable() | ||
| let map = new FastHashMap<int, int>() | ||
| map.put(1, 10) | ||
| map.put(1 + FASTHASHMAP_CAPACITY, 90) | ||
| map.remove(1).assertEquals(true) | ||
| map.has(1).assertEquals(false) | ||
| map.get(1 + FASTHASHMAP_CAPACITY).assertEquals(90) | ||
| map.size().assertEquals(1) | ||
|
|
||
| @Test | ||
| function testRemoveMissingKey() | ||
| let map = new FastHashMap<int, int>() | ||
| map.remove(4).assertEquals(false) | ||
| map.size().assertEquals(0) | ||
|
|
||
| /** A tombstone is reused rather than left as a hole. */ | ||
| @Test | ||
| function testTombstoneIsReused() | ||
| let map = new FastHashMap<int, int>() | ||
| map.put(2, 20) | ||
| map.remove(2) | ||
| map.put(2, 21) | ||
| map.get(2).assertEquals(21) | ||
| map.size().assertEquals(1) | ||
|
|
||
| @Test | ||
| function testSizeAndEmpty() | ||
| let map = new FastHashMap<int, int>() | ||
| map.isEmpty().assertEquals(true) | ||
| map.put(1, 1) | ||
| map.isEmpty().assertEquals(false) | ||
| map.size().assertEquals(1) | ||
|
|
||
| @Test | ||
| function testClear() | ||
| let map = new FastHashMap<int, int>() | ||
| map.put(1, 1) | ||
| map.put(2, 2) | ||
| map.clear() | ||
| map.size().assertEquals(0) | ||
| map.has(1).assertEquals(false) | ||
| map.put(1, 5) | ||
| map.get(1).assertEquals(5) | ||
|
|
||
| /** A full map keeps what it has and refuses new keys rather than overwriting. */ | ||
| @Test | ||
| function testFullMapRefusesNewKeys() | ||
| let map = new FastHashMap<int, int>() | ||
| for i = 0 to FASTHASHMAP_CAPACITY - 1 | ||
| map.put(i, i) | ||
| map.isFull().assertEquals(true) | ||
| map.size().assertEquals(FASTHASHMAP_CAPACITY) | ||
| map.put(FASTHASHMAP_CAPACITY + 1000, 1) | ||
| map.size().assertEquals(FASTHASHMAP_CAPACITY) | ||
| map.get(0).assertEquals(0) | ||
| // a key it already holds is still writable | ||
| map.put(0, 99) | ||
| map.get(0).assertEquals(99) | ||
|
|
||
| /** Two maps of the same types hold separate sections. */ | ||
| @Test | ||
| function testInstancesAreIndependent() | ||
| let a = new FastHashMap<int, int>() | ||
| let b = new FastHashMap<int, int>() | ||
| a.put(1, 10) | ||
| b.put(1, 20) | ||
| a.get(1).assertEquals(10) | ||
| b.get(1).assertEquals(20) | ||
|
|
||
| /** The string instance comes with the package. */ | ||
| @Test | ||
| function testStringKeys() | ||
| let map = new FastHashMap<string, int>() | ||
| map.put("alpha", 1) | ||
| map.put("beta", 2) | ||
| map.get("alpha").assertEquals(1) | ||
| map.get("beta").assertEquals(2) | ||
| map.has("gamma").assertEquals(false) | ||
|
|
||
| /** Each key type takes its own instance, so one map class serves several. */ | ||
| @Test | ||
| function testTwoSpecialisationsCoexist() | ||
| let ints = new FastHashMap<int, string>() | ||
| let strings = new FastHashMap<string, string>() | ||
| ints.put(1, "one") | ||
| strings.put("one", "uno") | ||
| ints.get(1).assertEquals("one") | ||
| strings.get("one").assertEquals("uno") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
FASTHASHMAP_CAPACITY * FASTHASHMAP_MAX_INSTANCESexceedsJASS_MAX_ARRAY_SIZE—for example, after following the API documentation's advice to raise the capacity while retaining 256 instances—the allocator accepts sections whose indices exceed the fixed JASS array bounds. Those maps then access invalid slots and cannot reliably store or retrieve entries; unlikeArrayList.allocateStorage, there is no native-target limit check. Validate the configuration or shard the storage before allocating such sections.AGENTS.md reference: AGENTS.md:L12-L12
Useful? React with 👍 / 👎.