Skip to content

Structs

Structs are value types that do not support inheritance but can implement interfaces.

struct Vector2:
    """A 2D vector value type."""

    x: float
    y: float

    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    def magnitude(self) -> float:
        return (self.x ** 2 + self.y ** 2) ** 0.5

    def __add__(self, other: Vector2) -> Vector2:
        return Vector2(self.x + other.x, self.y + other.y)

Struct Rules: - All fields must be declared at struct level - If no constructor is defined, fields are zero-initialized (matching C# 9.0 struct semantics) - Users can define additional constructors that initialize all or some fields - When a constructor is defined, it must initialize all fields (C# requirement) - Cannot inherit from other structs or classes - Can implement interfaces (including interfaces with default methods) - Value semantics: copied when assigned or passed

Default Initialization:

C# structs always have an implicit parameterless constructor that zero-initializes all fields. Sharpy structs inherit this behavior:

struct Point:
    x: int
    y: int

struct Vector:
    x: float
    y: float

    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

def main() -> None:
    # Using implicit parameterless constructor (zero-initialized)
    p1 = Point()           # x = 0, y = 0

    # Using explicit constructor
    v1 = Vector(1.0, 2.0)  # x = 1.0, y = 2.0
    v2 = Vector()          # x = 0.0, y = 0.0 (implicit parameterless still exists)

Structs and Interface Default Methods:

Structs can implement interfaces that have default method implementations. However, be aware of boxing implications:

interface IDescribable:
    def describe(self) -> str:
        return "An object"  # Default implementation

struct Point(IDescribable):
    x: int
    y: int

    def __init__(self, x: int, y: int):
        self.x = x
        self.y = y

    # Can override default, or use it as-is
    @override
    def describe(self) -> str:
        return f"Point({self.x}, {self.y})"

def main() -> None:
    # Direct call - no boxing
    p = Point(10, 20)
    print(p.describe())  # "Point(10, 20)" - efficient

    # Interface call - requires boxing (allocates)
    d: IDescribable = p  # Boxing occurs here
    print(d.describe())  # "Point(10, 20)" - works but allocates

Performance Note: When a struct is assigned to an interface variable or passed as an interface parameter, the struct is boxed (copied to the heap). For performance-critical code, prefer calling struct methods directly rather than through interface references.

Default Field Values

Struct fields can have default values, enabling partial construction:

struct Point:
    x: int
    y: int = 0

def main() -> None:
    p1 = Point(1, 2)   # x = 1, y = 2
    p2 = Point(3)      # x = 3, y = 0 (default)

All fields can have defaults:

struct Config:
    width: int = 800
    height: int = 600
    fullscreen: bool = False

def main() -> None:
    c1 = Config()                    # 800, 600, False
    c2 = Config(1024, 768, True)     # 1024, 768, True
    c3 = Config(1920)                # 1920, 600, False

Ordering rule: Once a field has a default value, all subsequent fields must also have defaults (same as function parameters):

# OK: defaults at the end
struct Good:
    x: int
    y: int = 0

# ERROR: non-default field after field with default value
struct Bad:
    x: int = 0
    y: int         # Error: cannot follow a field with a default value

The compiler auto-generates a constructor with optional parameters for fields that have defaults.

Mutable-collection defaults are per-instance. A list, dict, or set literal, a list()/dict()/set() call, or a comprehension used as a field default is initialized per instance -- exactly as for a dataclass field -- so each struct value gets its own fresh collection rather than sharing one:

struct Basket:
    x: int = 0
    tags: list[int] = [1]

def main() -> None:
    p = Basket(3)
    q = Basket(4)
    p.tags.append(2)
    print(p.tags)   # [1, 2]
    print(q.tags)   # [1]  -- q is unaffected

The synthesized constructor takes a nullable sentinel parameter (tags: Sharpy.List<int>? = null) and assigns this.Tags = tags ?? new Sharpy.List<int>() { 1 };. A mutable-collection default on a nullable-typed field is refused with SPY0400, the same rule dataclasses follow.

A const is class-level storage, not per-instance state, so it is not an instance field: it is neither a constructor parameter nor a constructor assignment, and it does not take part in the ordering rule above. A const may therefore be declared before a field with no default:

struct Point:
    const DIMENSIONS: int = 2
    x: float
    y: float

def main():
    p = Point(2.0, 5.0)      # the const is not a parameter
    print(p.x)               # 2.0
    print(Point.DIMENSIONS)  # 2
    q = Point()              # parameterless: zero-initialized instance fields
    print(q.x)               # 0.0

Output:

2.0
2
0.0

Defaults on auto-properties. A defaulted auto-property is a constructor parameter exactly like a defaulted field: the synthesized constructor takes it as an optional parameter, and when every constructor parameter has a default, the explicit parameterless constructor applies every default -- H() never zero-initializes a defaulted member:

struct H:
    property x: int = 3

def main() -> None:
    print(H().x)       # 3
    print(H(x=5).x)    # 5

Output:

3
5

A property whose default is not a constant (a list literal, a call) is not a constructor parameter: it keeps its initializer, which every constructor -- the parameterless one included -- runs. With an explicit __init__ no constructor is synthesized, and a property default is an ordinary initializer.

When to Use Structs: - Small data structures (typically < 16 bytes) - Immutable value types (Vector2, Point, Color) - Types that benefit from value semantics

Visibility

Struct members follow the class access rules (decorators.md) with one difference: a struct is implicitly sealed, so nothing derives from it and protected has no meaning. The _name convention -- protected in a class -- means private in a struct, and __name is private as everywhere. An explicit @protected on a struct member (field, method, property, event, nested type) is refused (SPY0415, the same code as @virtual on a struct method); write @private, or use the _name convention. Access from outside the struct (c._count) is refused exactly as for a class member (SPY0283).

struct Counter:
    _count: int

    def __init__(self, start: int):
        self._count = start

    def _step(self) -> int:
        return 2

    def advance(self) -> int:
        self._count += self._step()
        return self._count

def main() -> None:
    c = Counter(5)
    print(c.advance())   # 7
struct Gauge:
    level: int

    @protected                # ERROR SPY0415: structs are sealed
    def calibrate(self) -> int:
        return 1

Value Semantics

Structs in Sharpy are value types, meaning they have fundamentally different behavior from classes (reference types):

Copy-on-Assignment

Structs are copied when assigned to a new variable:

struct Point:
    x: int
    y: int

def main() -> None:
    p1 = Point(10, 20)
    p2 = p1              # p2 is a COPY of p1

    p2.x = 99            # Only p2.x changes
    print(p1.x)          # Prints: 10 (p1 is unchanged)
    print(p2.x)          # Prints: 99

This is different from classes, where assignment creates a new reference to the same object:

class PointClass:
    x: int
    y: int

    def __init__(self, x: int, y: int):
        self.x = x
        self.y = y

def main() -> None:
    p1 = PointClass(10, 20)
    p2 = p1              # p2 references the SAME object as p1

    p2.x = 99            # Changes the shared object
    print(p1.x)          # Prints: 99 (p1.x also changed!)
    print(p2.x)          # Prints: 99

Pass-by-Value

Structs are copied when passed to functions by default:

struct Counter:
    count: int

def increment(c: Counter) -> None:
    c.count += 1

def main() -> None:
    counter = Counter(10)
    increment(counter)
    print(counter.count)  # Prints: 10 (unchanged - function modified a copy)

Inline Storage

Structs are stored inline wherever they are declared:

  • In local variables → stored on the stack
  • In class/struct fields → stored inline in the containing object (no separate heap allocation)
  • In arrays → stored contiguously in memory (no indirection)

This provides excellent cache locality and performance, but can be inefficient for large structs.

Because a struct is stored inline, it cannot contain itself by value — through a field of its own type, another struct, a tuple, T?, T | None, Result[T, E], or a generic struct's type argument (Box[Node]). Such a struct is refused with SPY0616 at the field that starts the cycle. Collections and classes hold a reference, so kids: list[Node] is fine, and so is making Node a class.

Avoiding Copies with Parameter Modifiers

For large structs or performance-critical code, use parameter modifiers to avoid expensive copies:

in T - Read-Only Reference

Pass a struct by reference without allowing modifications:

struct LargeData:
    buffer: list[int]  # Assume this is large

    def process(self) -> int:
        return sum(self.buffer)

def analyze(data: in LargeData) -> int:
    # 'data' is passed by reference (no copy)
    # 'data' cannot be modified (read-only)
    return data.process()

def main() -> None:
    large = LargeData([1, 2, 3, 4, 5])
    result = analyze(large)  # No copy! Efficient.
    print(result)            # 15

Use in T when: - You need to read the struct but not modify it - The struct is large (> 16 bytes) - Performance is critical

ref T — Mutable Reference

Pass a struct by reference and allow modifications:

struct Counter:
    count: int

def increment(c: ref Counter) -> None:
    # 'c' is passed by reference
    # Changes to 'c' affect the original struct
    c.count += 1

def main() -> None:
    counter = Counter(10)
    increment(ref counter)
    print(counter.count)  # Prints: 11 (modified!)

Use ref T when: - You need to modify the caller's struct - You want to avoid copies for large structs

out T - Output Parameter

Initialize a struct and return it via parameter:

struct Point:
    x: int
    y: int

def try_parse_point(text: str, result: out Point) -> bool:
    # 'result' must be assigned before returning
    parts = text.split(',')
    if len(parts) != 2:
        result = Point(0, 0)
        return False

    result = Point(int(parts[0]), int(parts[1]))
    return True

def main() -> None:
    if try_parse_point("10,20", out parsed: Point):
        print(f"Parsed: ({parsed.x}, {parsed.y})")

Use out T when: - Implementing try-parse patterns - Returning multiple values (one via return, others via out)

Performance Guidelines

Struct Size Assignment/Parameter Passing Recommendation
≤ 16 bytes Cheap to copy Pass by value (default) is fine
> 16 bytes Expensive to copy Use in T for read-only, ref T for mutation
Very large Very expensive Consider using a class instead

Immutability Best Practice

For best performance and safety, prefer immutable structs:

struct Vector2:
    x: float
    y: float

    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    # Return new instances instead of modifying
    def __add__(self, other: Vector2) -> Vector2:
        return Vector2(self.x + other.x, self.y + other.y)

    def scale(self, factor: float) -> Vector2:
        return Vector2(self.x * factor, self.y * factor)

def main() -> None:
    # Immutable usage pattern
    v1 = Vector2(1.0, 2.0)
    v2 = Vector2(3.0, 4.0)
    v3 = v1 + v2           # Creates new Vector2
    v4 = v3.scale(2.0)     # Creates new Vector2
    print(v4.x)            # 8.0

Benefits of immutable structs: - Thread-safe by default (no shared mutable state) - Easier to reason about (no hidden state changes) - Can be safely passed by value without defensive copies

Implementation - ✅ Native - Direct mapping to C# struct. - ✅ Native - in T maps to C# in T parameter modifier (space-separated syntax) - ✅ Native - ref T maps to C# ref T parameter modifier (space-separated syntax) - ✅ Native - out T maps to C# out T parameter modifier (space-separated syntax)