Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ nav:
- Language features:
- howto/main_function.md
- howto/generics.md
- howto/structs.md
- Reference:
- reference/python_builtins.md
- reference/spy_builtin_functions.md
Expand Down
221 changes: 221 additions & 0 deletions docs/src/howto/structs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
title: Structs
---

Structs (currently the only classes in SPy) are immutable data structures analogous to C structs.

/// warning
Class construction and layout are a part of SPy that's rapidly evolving. All of the constructs, names, functions, or decorators here are likely to change!
///

## Declaration

Structs are declared with the `@struct` decorator on a CPython-style class definition. Their fixed list of fields follows with type annotations. Note that default values for these fields are not currently supported.

```py
@struct
class Person:
name: str
age: int

def main() -> None:
p = Person('Alice', 99)
print(p.name, "is", p.age, "years old") # Alice is 99 years old

```

Structs may also be defined with the [generic class syntax](../howto/generics.md#generic-class-syntax), which is syntactic sugar for a generic function which defines an internal struct. See the [generics](../howto/generics.md) documentation for more info.

## Attributes

Structs are shallow immutable. Attributes cannot be set after creation, nor can new attributes be assigned to an instance of a struct class after creation:

```py
@struct
class Person:
name: str
age: int

def main() -> None:
p = Person('Alice', 99)
p.name = 'Bob' # TypeError: type `Person` does not support assignment to attribute 'name'
p.height = 190 # TypeError: type `Person` does not support assignment to attribute 'height'
```



However, objects which are attributes of struct classes may be mutated.

```py
@struct
class Person:
name: str
age: int
books: list[str]


def main() -> None:
p = Person('Alice', 99, [])
print(p.name, "has", len(p.books), "book(s)") # Alice has 0 book(s)

p.books.append("An Introduction to Python")
print(p.name, "has", len(p.books), "book(s)") # Alice has 1 book(s)
```

If the struct attribute is a pointer to an object, that object at that pointer may also be mutated. (See [Constructors](#constructors) below for info on the `__new__` method):

<!-- TODO once there is reference documentation on pointers/memory, link to it here -->

```py
from unsafe import gc_ptr, gc_alloc

@struct
class Person:
name: str
age: gc_ptr[int]

def __new__(name: str, age: int) -> Person:
_age = gc_alloc[int](1)
_age[0] = age
return Person.__make__(name, _age)

def do_birthday(self) -> None:
self.age[0] = self.age[0] + 1


def main() -> None:
p = Person('Alice', 99)
print(p.name, "is", p.age[0], "years old") # Alice is 99 years old

p.do_birthday()
print(p.name, "is", p.age[0], "years old") # Alice is 100 years old
```

## Pointers to Structs

If a struct is manually allocated using `gc_alloc` or similar, its attributes can be mutated. See the [low level memory docuementary on heap-allocated structs](../llmem.md#heap-allocated-structs) for more info.

```py
from unsafe import gc_alloc, gc_ptr

@struct
class Point:
x: int

# spy build foo.spy
def main() -> None:
p = gc_alloc[Point](1)
p.x = 1
print(p.x)
p.x = 2
print(p.x)
```

## Constructors

Structs use a default constructor which populates all their attributes in-order, and all attributes must be provided each time the default constructor is called. E.g. the example above, the constructor `p = Person('Alice', 99, [])` requires an empty list to be passed for the `books` attribute. We can call this constructor explicitly using the `__make__` method:

```py
@struct
class Person:
name: str
age: int
books: list[str]

def main() -> None:
p = Person.__make__('Bob', 6, [])
print(p.age) # 6
```

User-facing constructors can be customized by overwriting the `__new__` method; the `__make__` must be called within to handle the initialization of the complete struct. Note that, as in Python, `__new__` functions like a staticmethod (it does not take a `self` parameter)

```py
@struct
class Person:
name: str
age: int
books: list[str]

def __new__(name: str, age: int) -> Person:
_books: list[str] = []
return Person.__make__(name, age, _books)

def main() -> None:
p = Person('Alice', 99)
print(p.name, "has", len(p.books), "books") # Alice has 0 books
```

By convention, if a struct is simply being used to wrap a lower-level type (to add constructors, methods, etc), that lower level type is denoted as `__ll__`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about something like this:

## "New type" pattern

A common idiom is to have a struct with a single field of type `T`: this creates a zero-overhead wrapper around `T`, which is a completely new type and makes it possible to enforce compile time type guarantees and/or to add methods and special methods to it.

The zero-overhead is guaranteed by the fact that structs are allocated "on the stack", passed by value, and translated into C structs by the C backend. 

This is similar and inspired by the [newtype idiom in rust](https://doc.rust-lang.org/rust-by-example/generics/new_types.html):

```python
@struct
class Kilometers:
    v: float

    def to_miles(self) -> Miles:
        return Miles(self.v * 1.609344)

@struct
class Miles:
    v: float

    def to_kilometers(self) -> Kilometers:
        return Kilometers(self.v * 1.609344)


def is_a_marathon(distance: Miles) -> bool:
    return distance.v >= 26.2

def main() -> None:
    distance = Miles(30.0)
    distance_km = distance.to_kilometers()
    print("Is a marathon?", is_a_marathon(distance))
    print("Is a marathon?", is_a_marathon(distance_km.to_miles()))
    # print("Is a marathon?", is_a_marathon(distance_km))  ## TypeError
```

## `__ll__` pattern

This is a special case of the "new type pattern".  It is used when you want to create a high-level type in terms of a low-level, possibly unsafe, implementation which is hidden to the end user.

Currently, `__ll__` is just a naming convention but in the future it might become special-cased by the compiler to make it truly private.

INSERT THE BookData example

Note that in this example, `BookData` is potentially a big struct, while `Book` is a wrapper around a *pointer* to it, so more efficient to pass around.
Normal `class`es will be (automatically) implemented using this idiom, but currently they are not supported and must be done manually.

This is also how many builtin types are implemented: `list[T]` is a pointer to `ListData[T]`, `str` is a pointer to `StrObject`, etc.  See `stdlib/*.spy` for more examples.


See the [smallpoint example](https://github.com/spylang/spy/examples/3_low_level/smallpoint.spy) for a more in-depth example and explanation. See the implementations of the [list](https://github.com/spylang/spy/stdlib/_list.spy), [dict](https://github.com/spylang/spy/stdlib/_dict.spy), and [file](https://github.com/spylang/spy/stdlib/_file.spy) objects in spy for usage of this pattern in the SPy standard library.

/// info
Eventually, the `__ll__` notation may likely be special-cased to make it a truly private attribute within the struct. Currently, it is just a naming convetion.
///

```py
from unsafe import gc_ptr, gc_alloc

@struct
class BookData:
name: str
stars: int # out of 10

@struct
class Book:
__ll__: gc_ptr[BookData]

def __new__(name: str, stars: float) -> Book:
data = gc_alloc[BookData](1)
data.name = name
data.stars = stars
return Book.__make__(data)

def plus_one_star(self) -> None:
self.__ll__.stars = self.__ll__.stars + 1.0

def __repr__(self) -> str:
return "Book(name='" + self.__ll__.name + "', stars=" + str(self.__ll__.stars) + ")"


def main() -> None:
b = Book("An Introduction to Python", 10.0)
print(b) # Book(name='An Introduction to Python', stars=10.0)
b.plus_one_star() # Book(name='An Introduction to Python', stars=11.0)
print(b)
```

<!--
TODO Add notes about metafuncs as constructors. This may be more generally useful once typing for things
like `str | None` is available.
-->

## Methods

Structs may have methods defined inside their class body; the struct object itself is passed as the first parameter (usually called `self`), just as in CPython:

```py
@struct
class Person:
name: str

def say_hi_to(self, other: Person) -> None:
print("Hi " + other.name + "! My name is " + self.name)

def is_teenager(self) -> bool:
return 13 <= self.age and self.age <= 19

def main() -> None:
c = Person("Charlie", 55)
c.say_hi_to(Person("Donna", 57)) # Hi Donna! My name is Charlie
print(c.is_teenager()) # False
```

Methods may also be [blue functions](../reference/spy_builtin_functions.md#blue), [generic blue functions](../reference/spy_builtin_functions.md#bluegeneric), or [metafunctions](../reference/spy_builtin_functions.md#bluemetafunc).

## Inheritance

Inheriting from a base class is not currently implemented in SPy.

## Structs as Arguments

When used as a function argument, Structs are passed by value, meaning a copy of the struct is created for each call. The performance implications of this should be kept in mind for large structs.
Loading