Collections¶
ppy has nine collection types that compile to native code: growable
arrays, queues, heaps, a linked list, hash maps and sets, and ordered maps
and sets. You use them like Python containers, with no pointers. They hold
numbers, tuples, dataclasses, or other collections, and a function that uses
them goes native under ppy run, in a standalone binary, and in emitted C
and C++.
from ppy import Deque, Vec
def farthest(adjacent: Vec[Vec[int]]) -> int:
seen = Vec[int](len(adjacent)) # len(adjacent) zeros
queue = Deque[int]()
queue.push_back(0)
seen[0] = 1
best: int = 0
while queue:
node: int = queue.pop_front()
for other in adjacent[node]:
if seen[other] == 0:
seen[other] = seen[node] + 1
best = max(best, seen[other])
queue.push_back(other)
return best - 1
Under CPython each type is a Python class in ppy._collections, and that
class is the reference: native code gives the same answers. The
Collections example runs five problems with
them and times each path.
The types¶
| Type | What it is |
|---|---|
Vec[T] |
a growable array |
Deque[T] |
a double-ended queue |
Heap[T], MaxHeap[T] |
a priority queue, smallest or largest first |
LinkedList[T] |
a doubly linked list whose nodes are named by integer ids |
HashMap[K, V] |
a hash map in insertion order |
HashSet[K] |
a hash set in insertion order |
TreeMap[K, V] |
a map in key order |
TreeSet[K] |
a set in key order |
Make one by subscripting the class and calling it: Vec[int](),
HashMap[int, float](). Vec[T](n) starts with n zeros of T: 0,
0.0, False, a tuple of zeros, a dataclass of zero fields, or a new empty
collection in each slot.
What a collection holds¶
An element, or a map's value, is one of:
- a number:
int,float,bool, or a fixed width such asppy.i32, held as anint - a tuple of numbers:
Vec[tuple[int, float]] - a dataclass whose fields are numbers:
Vec[Point] - another collection:
Vec[Vec[int]],HashMap[int, Vec[int]]
A key of a map or a set is an int or a tuple of them:
HashMap[tuple[int, int], int] for a grid, TreeSet[tuple[int, int]] for
pairs in order.
from dataclasses import dataclass
from ppy import HashMap, Heap, Vec
@dataclass(order=True)
class Edge:
cost: int
to: int
def cheapest(edges: Vec[Vec[Edge]]) -> int:
best = HashMap[int, int]()
frontier = Heap[tuple[int, int]]()
frontier.push((0, 0))
while frontier:
cost, node = frontier.pop()
if node in best:
continue
best[node] = cost
for edge in edges[node]:
if edge.to not in best:
frontier.push((cost + edge.cost, edge.to))
return len(best)
A value is converted to the element type when it is stored, so
Vec[float] holds 3.0 after push(3), as native memory of doubles would,
and a dataclass with a float field holds a float there.
Order¶
sort, a heap, and a tree compare elements or keys with <, as Python
does. Numbers compare as numbers and tuples compare item by item. A
dataclass compares field by field when it is declared
@dataclass(order=True); without that it has no order, and sorting or heaping
it is refused. A collection has no order either.
Aliases¶
A collection element read into a name is the same collection, not a copy,
as it is in Python: after row = grid[0], row.push(1) changes
grid[0]. A dataclass element is a value: it is read and written whole,
and setting a field of one in place keeps the function in Python.
Generic functions¶
A generic function can take, make, and return collections of its type parameter. Native code makes one instance per type it is called with, as a C++ template is instantiated:
from ppy import Heap, Vec
def smallest[T: int | float](values: Vec[T], count: int) -> Vec[T]:
heap = Heap[T]()
for value in values:
heap.push(value)
taken = Vec[T]()
while heap and len(taken) < count:
taken.push(heap.pop())
return taken
smallest(Vec[int]...) and smallest(Vec[float]...) are two native
functions. Under CPython the type parameter is not known, so Vec[T]
stores values as they are given, and Vec[T](n) has no zero to start with.
A value is converted to the element type when it is stored, so
Vec[float] holds 3.0 after push(3), as native memory of doubles would.
Methods¶
Vec[T]¶
v.push(x) |
add x at the end |
v.pop() |
remove and return the last element |
v.last() |
the last element |
v[i], v[i] = x |
read or write index i |
v.sort(), v.reverse() |
in place; the sort is stable |
v.clear(), len(v), for x in v |
Deque[T]¶
d.push_back(x), d.push_front(x) |
add at either end |
d.pop_back(), d.pop_front() |
remove and return from either end |
d.front(), d.back() |
the elements at the ends |
d[i], d[i] = x |
read or write index i, counted from the front |
d.clear(), len(d), for x in d |
Heap[T] and MaxHeap[T]¶
h.push(x) |
add x |
h.pop() |
remove and return the smallest (Heap) or largest (MaxHeap) |
h.peek() |
the element pop would return |
h.clear(), len(h) |
A heap has no iteration order and no index. To keep a value with its
priority, pack both into one integer, such as priority * N + node.
LinkedList[T]¶
Every insertion returns the new node's id, an int. The methods that take a
node use that id. -1 means no node.
l.push_back(x), l.push_front(x) |
add at either end; returns the id |
l.insert_after(node, x), l.insert_before(node, x) |
add next to node; returns the id |
l.remove(node) |
take node out and return its value |
l.pop_front(), l.pop_back() |
remove and return from either end |
l.front(), l.back() |
the values at the ends |
l.head(), l.tail() |
the ids at the ends, or -1 |
l.next(node), l.prev(node) |
the neighboring ids, or -1 |
l.value(node), l.set(node, x) |
read or replace what node holds |
l.clear(), len(l), for x in l |
Ids are handed out in order from 0, and the id of the most recently removed
node is the next one handed out. clear starts the ids from 0 again.
HashMap[K, V] and HashSet[K]¶
m[key], m[key] = value |
read or write; reading a missing key raises KeyError |
m.get(key, default) |
the value, or default |
m.pop(key) |
remove key and return its value |
s.add(key), s.remove(key), s.discard(key) |
the set's methods; remove raises KeyError for a missing key |
key in c, key not in c |
|
c.clear(), len(c), for key in c |
keys in insertion order |
TreeMap[K, V] and TreeSet[K]¶
These have the same methods as the hash types, iterate in ascending key order, and add:
t.min(), t.max() |
the smallest and largest key |
t.floor(key) |
the largest key at most key |
t.ceiling(key) |
the smallest key at least key |
t.lower(key) |
the largest key below key |
t.higher(key) |
the smallest key above key |
floor and the others raise KeyError when there is no such key, and min
and max raise IndexError on an empty tree.
Rules¶
These hold on every path, which is why a few differ from Python's own containers:
- An index runs from
0tolen - 1. A negative index raisesIndexErrorlike any other index out of range; there is no counting from the end. - Removing from an empty collection raises
IndexError. - A collection is true when it holds anything, so
while queue:works. - Iterating a
VecorDequereads the length at every step, as iterating alistdoes, so a loop that pushes sees what it pushed. - Iterating a linked list follows
nextfrom each node after the loop body ran. If the body removed that node, the walk ends there. - Adding or removing a key while iterating a map or a set raises
RuntimeError, asdictdoes. Replacing a value does not.
In native code¶
A collection is a handle into a small C runtime
(ppy_runtime/collections.c) that works on eight-byte words and knows
nothing of types. The compiler writes the typed reads and writes for each
element type, which is where a Vec[tuple[int, float]] and a Vec[Edge]
differ.
The runtime is the same text in each place it runs:
- Under
ppy run, it is a shared library compiled on first use and cached. That needs a C compiler; without one, functions that use collections stay in Python. - In a standalone binary and in emitted C or C++, the functions a program calls are carried in as shims.
Memory¶
References are counted. A name holding a collection keeps one reference, and so does a collection holding another; a collection is freed when the last reference goes, together with the collections it holds. Rebinding a name, returning from a function, and removing an element all let go of what they held, so a program frees what it made without saying so. The tests run the emitted C under AddressSanitizer with leak detection.
Guards¶
A check that CPython would raise for is a guard: an index out of range, a
pop from an empty collection, a missing key, a map changed while it is
walked. Under ppy run, a failed guard hands the call back to Python, which
runs it again and raises the same exception. A standalone binary stops with
exit status 70.
Functions that take or return collections¶
A native function can take a collection as a parameter, as farthest above
takes adjacent, and can return one. Native callers pass and receive the
handle, and writes through a parameter land in the caller's collection.
Such a function has no Python boundary: Python code cannot call it
natively, so it runs as Python when called from Python.
Limitations¶
- Keys are
intor tuples ofint. get(key, default)natively takes a map whose values are numbers.- A dataclass element is read and written whole; setting a field of one in place keeps the function in Python.
- When a guard falls back under
ppy run, the collections the native call made so far are not freed. - A standalone binary reports a failed guard with a generic message rather than the exception's text.
- A collection holds a user class's instances natively when the class is a value class or an object class; see Classes.
Examples: Collections.