plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
plum-std/Map.plum
module std
import std/List
import std/Option
import std/Array
import std/Bool
import std/Number
import std/Str
# Any type usable as a `Map` key needs a `hash`, so keys landing in different
# buckets (`hash(k) % BUCKET_COUNT`) can be told apart in O(1) without a full
# linear scan of the whole map — see `Str.hash`/`Int.hash` in `str.plum`/
# `int.plum` for the two implementations `libs/std` provides today. Declared
# here (rather than alongside `ToStr`/`Comparable` in `str.plum`) since `Map`
# is its only real consumer; like `Comparable`/`Readable`/`Writable`
# elsewhere in `libs/std`, `Str`/`Int` don't formally list it in their own
# `implements` clause (that would need them to import `map.plum`, an
# unwanted dependency direction for such foundational types) — this is
# checked the same "unenforced, trust the method exists" way those already
# are (see the README's note on `checkTraitConformance`).
trait Hashable =
hash() -> Int
# A Pair is a grouping of a key with a value.
#
# `toStr` (unlike `Option`'s — see `libs/std/option.plum`'s note on
# `toOptionStr`) IS safe as an ordinary method here: `Pair[K, V]` is only
# ever used as `List[Pair[K, V]]`'s element type inside `Map[K, V]`, and
# `List[T: ToStr]` already requires its own `T` to support `toStr` —
# so a `Pair` used as a `List` element always has `K`/`V: ToStr` too,
# and `List`'s own eagerly-compiled `join`/`toStr` (which call `.toStr()` on
# every element) need this to exist for every `Map` specialization anyway.
enum Pair[K, V](ToStr) =
| Pair(key: K, val: V)
fun toStr(self) -> Str =
return "{self.key.toStr()}: {self.val.toStr()}"
# Number of buckets every `Map` allocates at construction — fixed for the
# map's whole lifetime, no rehashing/resizing as it grows. A real hash table
# would grow this as `size` outpaces it (keeping average bucket length, and
# so average `get`/`set` cost, roughly constant); that's future work; for now
# a big-enough fixed count keeps small-to-medium maps fast in practice.
BUCKET_COUNT = 16
# A Map is a hash table: `BUCKET_COUNT` buckets, each an association-list
# chain (`List[Pair[K, V]]`) of every key hashing into it — O(1) average
# `get`/`set`/`remove` (versus the single, whole-map linear scan a plain
# association list costs), degrading to O(n) only if many keys collide into
# the same bucket.
enum Map[K: Hashable, V] =
| Map(buckets: Array[List[Pair[K, V]]], size: Int)
# `Map[Str, Int]()` — explicit generics required (nothing in a no-arg call
# pins down `K`/`V`), builds `BUCKET_COUNT` empty bucket lists up front via
# `Array`'s own `push`-to-grow (see `libs/std/array.plum`'s header comment
# on why `Array` itself has no direct "make me length N" constructor).
fun init() -> Map[K, V] =
b := Array[List[Pair[K, V]]]()
i := 0
while i < BUCKET_COUNT
b = b.push(List[Pair[K, V]]())
i = i + 1
return Map(buckets: b, size: 0)
# `k`'s bucket index — `hash`'s sign is unconstrained (a plain `%` on a
# negative `Int` stays negative in Plum, matching wasm's signed
# `i64.rem_s`), so this folds a negative result back into `[0,
# BUCKET_COUNT)` rather than indexing `buckets` out of range.
fun bucketIndex(self, k: K) -> Int =
m := k.hash() % BUCKET_COUNT
return m < 0 ? m + BUCKET_COUNT : m
# Gets the value for `k`, or `None` if absent.
fun get(self, k: K) -> Option[V] =
current := self.buckets.get(self.bucketIndex(k)).head
while current != None
match current
Some(Node(Pair(key, val), _, next)) =>
if key == k
return Some(val)
current = next
None =>
break
return None
fun has(self, k: K) -> Bool =
return self.get(k) != None
# Sets `k`'s value to `v` — replacing an existing entry for `k` in place
# (mutating the stored `Pair`'s `val` field) rather than appending a
# duplicate, so a repeated `set` on the same key doesn't grow its bucket
# unboundedly or leave a stale value reachable by `get`.
fun set(self, k: K, v: V) -> Unit =
bucket := self.buckets.get(self.bucketIndex(k))
current := bucket.head
while current != None
match current
Some(Node(pair, _, next)) =>
if pair.key == k
pair.val = v
return
current = next
None =>
break
bucket.add(Pair(key: k, val: v))
self.size = self.size + 1
# Sets `k`'s value to `v` only if `k` isn't already present.
fun putIfAbsent(self, k: K, v: V) -> Unit =
if !self.has(k)
self.set(k, v)
# Removes `k`'s entry, if present.
fun remove(self, k: K) -> Unit =
bucket := self.buckets.get(self.bucketIndex(k))
current := bucket.head
while current != None
match current
Some(node) =>
if node.value.key == k
bucket.unlink(node)
self.size = self.size - 1
return
current = node.next
None =>
break
fun length(self) -> Int =
return self.size
fun isEmpty(self) -> Bool =
return self.size == 0
fun clear(self) -> Unit =
i := 0
while i < self.buckets.length()
self.buckets.get(i).clear()
i = i + 1
self.size = 0
# Calls `cb` with each key and value in the map. Order is bucket order,
# then insertion order within a bucket — NOT overall insertion order (that
# would need a separate side list to track, which nothing here needs today).
fun each(self, cb: fn(K, V)) -> Unit =
i := 0
while i < self.buckets.length()
current := self.buckets.get(i).head
while current != None
match current
Some(Node(Pair(key, val), _, next)) =>
cb(key, val)
current = next
None =>
break
i = i + 1
fun keys(self) -> List[K] =
result := List(head: None, tail: None, size: 0)
self.each(|k, v| result.add(k))
return result
fun values(self) -> List[V] =
result := List(head: None, tail: None, size: 0)
self.each(|k, v| result.add(v))
return result
fun map(self, cb: fn(K, V) -> Pair[X, Y]) -> Map[X, Y] =
b := Array[List[Pair[X, Y]]]()
i := 0
while i < BUCKET_COUNT
b = b.push(List[Pair[X, Y]]())
i = i + 1
result := Map(buckets: b, size: 0)
j := 0
while j < self.buckets.length()
current := self.buckets.get(j).head
while current != None
match current
Some(Node(Pair(key, val), _, next)) =>
pair := cb(key, val)
result.set(pair.key, pair.val)
current = next
None =>
break
j = j + 1
return result
test "get/set/has round-trip and set replaces an existing key in place"
m := Map[Str, Int]()
assert m.has("a") == False
m.set("a", 1)
assert m.has("a") == True
assert m.get("a").unwrap() == 1
assert m.length() == 1
m.set("a", 2)
assert m.get("a").unwrap() == 2
assert m.length() == 1
test "putIfAbsent only sets a key that isn't already present"
m := Map[Str, Int]()
m.putIfAbsent("a", 1)
m.putIfAbsent("a", 2)
assert m.get("a").unwrap() == 1
test "remove deletes an entry and clear empties the map"
m := Map[Str, Int]()
m.set("a", 1)
m.set("b", 2)
m.remove("a")
assert m.has("a") == False
assert m.length() == 1
m.clear()
assert m.isEmpty() == True
test "keys/values list every entry's key/value"
m := Map[Str, Int]()
m.set("a", 1)
m.set("b", 2)
assert m.length() == 2
assert m.keys().contains("a")
assert m.keys().contains("b")
assert m.values().contains(1)
assert m.values().contains(2)
test "each calls the callback with every key and value"
m := Map[Str, Int]()
m.set("a", 1)
m.set("b", 2)
# A closure captures its outer variables BY VALUE at creation time, so
# reassigning a captured `Int` inside the callback wouldn't be visible out
# here — mutate a captured `List` (a class, reference semantics) instead.
seen := List[Int]()
m.each(|k, v| seen.add(v))
assert seen.length() == 2
assert seen.contains(1)
assert seen.contains(2)
test "map transforms every key/value pair into a new map, changing the value type"
m := Map[Str, Int]()
m.set("a", 1)
m.set("b", 2)
stringified := m.map(|k, v| Pair(key: k, val: v.toStr()))
assert stringified.get("a").unwrap() == "1"
assert stringified.get("b").unwrap() == "2"
assert stringified.length() == 2
test "many keys landing in the same bucket still resolve correctly (collision chaining)"
m := Map[Int, Int]()
i := 0
while i < 40
m.set(i, i * i)
i = i + 1
assert m.length() == 40
j := 0
while j < 40
assert m.get(j).unwrap() == j * j
j = j + 1
m.remove(5)
assert m.has(5) == False
assert m.get(4).unwrap() == 16
assert m.get(21).unwrap() == 441
assert m.length() == 39