Skip to content

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 unsafe module 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.

examples/3_low_level/point.spy
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:

42
20.0
20.0

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.

examples/3_low_level/smallpoint.spy
@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:

1
2
65538

6
7

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_copy panics on overlapping regions; use ptr_move when regions may overlap.

  • Bounds are checked at runtime: out-of-bounds access panics.
  • gc_alloc is 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.

examples/3_low_level/mem.spy
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:

all assertions passed