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:
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:
Output:
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)