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

# Using implicit parameterless constructor (zero-initialized)
p1 = Point()           # x = 0, y = 0

# Using explicit constructor
struct Vector:
    x: float
    y: float

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

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
    def describe(self) -> str:
        return f"Point({self.x}, {self.y})"

# 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.

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

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

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

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

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.

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()

large = LargeData([1, 2, 3, 4, 5])
result = analyze(large)  # No copy! Efficient.

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

mut[T] - Mutable Reference

Pass a struct by reference and allow modifications:

struct Counter:
    count: int

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

counter = Counter(10)
increment(counter)
print(counter.count)  # Prints: 11 (modified!)

Use mut[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

point: Point
if try_parse_point("10,20", point):
    print(f"Parsed: ({point.x}, {point.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, mut[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)

# 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

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 - ✅ Native - mut[T] maps to C# ref T parameter modifier - ✅ Native - out[T] maps to C# out T parameter modifier