3 ยท Low Level¶
Structs, pointers, and direct memory access via unsafe
Note
This page is auto-generated from the examples in
examples/3_low_level/.
Want to play with these examples yourself?
Try them in the online playground.
point.spy¶
Things to notice:
-
The unsafe module allows C-level direct memory access to pointers and unsafe arrays.
-
Structs map directly to C structs.
- Structs are passed by values (i.e. copied).
- Most users will never have to deal with this directly: using the
unsafemodule is the equivalent of writing C extensions or using Cython.
Exercise for the reader: write a Rect struct with two points, and check what its C layout.
from unsafe import gc_ptr, gc_alloc
@struct
class Point:
x: f64
y: f64
# eventually structs will get an automatic ctor, like dataclasses
def new_point(x: f64, y: f64) -> gc_ptr[Point]:
p = gc_alloc[Point](1) # allocate 1 Point
p.x = x
p.y = y
return p
def squared_distance(p0: Point, p1: Point) -> f64:
dx = p0.x - p1.x
dy = p0.y - p1.y
return dx * dx + dy * dy
def main() -> None:
arr: gc_ptr[i32] = gc_alloc[i32](5) # allocates an array of 5 i32
arr[0] # read item 0 of the array
arr[0] = 42 # write item 0
print(arr[0])
p0 = Point(1, 2)
p1 = Point(5, 4)
# defined on the stack => immutable
# p1.x = 3 # would raise an error
print(squared_distance(p0, p1))
ptr0 = new_point(1, 2)
ptr1 = new_point(3, 4)
# heap-allocated => mutable
ptr1.x = 5
print(squared_distance(ptr0[0], ptr1[0]))
Output:
smallpoint.spy¶
This example show how to create a new high level type based on a lower level type.
The new type has its own set of methods and operators and it is purely a static typing construct with zero runtime overhead. It is declared like this:
@struct
class MyNewType:
__ll__: MyLowLevelType
At the moment, __ll__ is purely a naming convention. Eventually, we will introduce
some rule to prevent __ll__ to be accessed from arbitrary code.
The low-level mechanism to initialize the struct, is to call the staticmethod
MyNewType.__make__, as is it standard for all structs:
y = MyNewType.__make__(x)
Usually, high level types have a __new__ which provides a nicer way to initialize it:
@struct
class MyNewType:
__ll__: MyLowLevelType
def __new__(...) -> MyNewType:
return MyNewType.__make__(...)
Moreover, as in Python we can overload __operators__. In this example we overload
__add__. Note that in this case add is statically typed, so if you try to call +
with the wrong type, you get a nice TypeError.
@struct
class SmallPoint:
"""
Point with two fields, x and y, 16 bits each. They are packed into a
single 32 bit integer.
"""
__ll__: i32
def __new__(x: i32, y: i32) -> SmallPoint:
# pack the two integers into a single value
val = (x << 16) | (y & 0xFFFF)
return SmallPoint.__make__(val)
@property
def x(self) -> i32:
return (self.__ll__ >> 16) & 0xFFFF
@property
def y(self) -> i32:
return self.__ll__ & 0xFFFF
def __add__(self, p: SmallPoint) -> SmallPoint:
x0 = self.x + p.x
y0 = self.y + p.y
return SmallPoint(x0, y0)
def main() -> None:
# all these become super fast bitwise operations. After redshifting, there
# is ZERO memory allocation as everything is just an i32.
p1 = SmallPoint(1, 2)
print(p1.x)
print(p1.y)
print(p1.__ll__)
print("")
p2 = p1 + SmallPoint(5, 5)
print(p2.x)
print(p2.y)
# try to uncomment this to see which error you get
# p2 + 3
Output:
mem.spy¶
Things to notice:
-
ptr_copy, ptr_move, ptr_setbytes, ptr_cmp are the primary mem ops; they work on ptr[T] for any type T, and count is expressed in items, not bytes.
-
Each op has a _slice variant that avoids the need for pointer arithmetic:
ptr_copy_slice(dst, dstart, dend, src, sstart, send). -
ptr_copypanics on overlapping regions; use ptr_move when regions may overlap. - Bounds are checked at runtime: out-of-bounds access panics.
gc_allocis used here; the GC handles memory release automatically.
Note: memcpy, memmove, memset, memcmp are byte-only shims (ptr[u8]/ptr[i8]) that mirror the C standard library.
from unsafe import (
gc_alloc,
gc_ptr,
ptr_copy,
ptr_copy_slice,
ptr_cmp,
ptr_cmp_slice,
ptr_move,
ptr_move_slice,
ptr_setbytes,
ptr_setbytes_slice,
)
def demo_ptr_setbytes() -> None:
# ptr_setbytes broadcasts a byte value across n*sizeof(T) bytes;
# for T longer than u8 it is mainly useful for zeroing memory
buf = gc_alloc[i32](4)
ptr_setbytes(buf, 0, 4)
total = buf[0] + buf[1] + buf[2] + buf[3]
assert total == 0
# for u8 buffers, any byte value works as expected
bytes_buf = gc_alloc[u8](4)
ptr_setbytes(bytes_buf, 7, 4)
total = bytes_buf[0] + bytes_buf[1] + bytes_buf[2] + bytes_buf[3]
assert total == 28
# _slice variant: only fill a sub-range [1, 3)
buf2 = gc_alloc[u8](4)
ptr_setbytes(buf2, 0, 4)
ptr_setbytes_slice(buf2, 1, 3, 5)
assert buf2[0] == u8(0)
assert buf2[1] == u8(5)
assert buf2[2] == u8(5)
assert buf2[3] == u8(0)
def demo_ptr_copy() -> None:
src = gc_alloc[i32](4)
dst = gc_alloc[i32](4)
src[0] = 10
src[1] = 20
src[2] = 30
src[3] = 40
ptr_copy(dst, src, 4)
total: i32 = dst[0] + dst[1] + dst[2] + dst[3]
assert total == 100
# _slice variant: copy src[1:3] into dst[2:4]
dst2 = gc_alloc[i32](4)
ptr_setbytes(dst2, 0, 4)
ptr_copy_slice(dst2, 2, 4, src, 1, 3)
assert dst2[0] == 0
assert dst2[1] == 0
assert dst2[2] == 20
assert dst2[3] == 30
def demo_ptr_move() -> None:
# ptr_move_slice allows shifting data within a buffer using overlapping
# regions, without needing pointer arithmetic
buf = gc_alloc[i32](6)
buf[0] = 1
buf[1] = 2
buf[2] = 3
buf[3] = 0
buf[4] = 0
buf[5] = 0
# shift buf[0:3] into buf[2:5] (overlapping)
ptr_move_slice(buf, 2, 5, buf, 0, 3)
assert buf[2] == 1
assert buf[3] == 2
assert buf[4] == 3
def demo_ptr_cmp() -> None:
a = gc_alloc[i32](4)
b = gc_alloc[i32](4)
ptr_setbytes(a, 42, 4)
ptr_setbytes(b, 42, 4)
assert ptr_cmp(a, b, 4) == 0 # equal
b[3] = 99
assert ptr_cmp(a, b, 4) < 0
# _slice variant: compare only the middle two bytes
a[1] = 7
a[2] = 7
b[1] = 7
b[2] = 7
assert ptr_cmp_slice(a, 1, 3, b, 1, 3) == 0
def main() -> None:
demo_ptr_setbytes()
demo_ptr_copy()
demo_ptr_move()
demo_ptr_cmp()
print("all assertions passed")
Output: