Structs, Impl Blocks, and Memory Layout
Structs and impl blocks are separated:
structdeclarations define pure data layout.implblocks attach behavior to types without changing their layout.
struct Declarations#
Structs define a composite data type made of named fields:
struct Frame {
sequence: u32,
size: u16,
flag: u8,
}
Key rules:
- Structs contain only data members.
- Memory layout and padding are well-defined so that FFI and ABI rules can rely on them.
- Stack vs heap allocation is specified in memory model.
Generic structs#
Structs may declare type parameters:
struct Data(T) {
value: T,
}
Rules:
- A generic
struct Name(T, ...)introduces a type constructorName. - Outside a generic context, uses of the type must be fully applied (for
example
Data(u8)), not bareData. - A declaration name may not be reused across different generic arities (for
example
struct Foo { ... }andstruct Foo(T) { ... }cannot both exist in the same namespace).
Field Default Initializers#
Struct fields may include an optional default initializer expression:
struct Point {
x: int = 0,
y: int = 0,
}
When a struct literal omits a field, the compiler initializes the field from its default expression.
In Silk currently, a field default must be a compile-time evaluable expression. This is broader than the current default-function-argument rule.
Supported forms currently include:
- literals,
constbindings,- calls to
const fn, - struct literals, and
- field access over compile-time values.
Current limits:
newis still rejected,- ordinary runtime helper calls are rejected, and
- the expression still has to type-check against the field type.
Example:
const DEFAULT_Y: int = 0;
struct Point {
x: int = 0,
y: int = DEFAULT_Y,
}
fn main () -> int {
let p = Point{ x: 5 };
return p.y; // defaults to 0
}
Single Inheritance (extends)#
Silk supports single inheritance for struct declarations via extends.
Surface syntax:
struct Base {
x: int,
y: int = 0,
}
struct Derived extends Base {
z: int,
}
Semantics (Supported forms):
- A derived struct inherits all fields of its base struct.
- The derived struct’s field sequence is:
- all base fields (in declaration order), then
- all derived fields (in declaration order).
- Field access on the derived struct can refer to inherited base fields
directly (
d.x,d.y). - Default field initializers are inherited:
- a
Derived{ ... }literal may omit inherited fields that have defaults in the base struct.
Type checking rules (Supported forms):
extendsis permitted only on non-opaquestructdeclarations.- The base name must resolve to a
structtype in the compiled module set. - Cycles in
extendschains are rejected. - A derived struct may not declare a field whose name conflicts with an inherited field name.
Notes:
extendsdoes not imply implicit subtyping in Silk currently: there is no implicit coercion fromDerivedtoBase(or&Derivedto&Base) yet.
Opaque Structs (FFI Handles)#
Opaque structs are a special form of struct declaration intended for safely
representing foreign pointers/handles from C APIs.
Syntax:
// Declares an opaque handle type.
struct MyFFIHandle;
An opaque struct has no fields and no Silk-defined layout. It exists only as a nominal handle type that can be passed around safely.
Rules:
- Opaque structs cannot be instantiated (no struct literals).
- Opaque structs do not support field/member access (
./?.). - Opaque structs must not be used by value in type positions (locals,
parameters, results). Only the reference form
&MyFFIHandleis allowed.
These rules increase safety at the language boundary:
- Eliminates type confusion: distinct handle types such as
&DatabaseHandleand&FileHandleare not interchangeable. - Prevents invalid operations in Silk: Silk code cannot read/write fields or assume a size/layout for the foreign type.
Safety and Undefined Behavior (UB)#
Opaque handles do not carry lifetime information. You are responsible for calling the corresponding destruction/free function provided by the foreign library.
Using an opaque handle after it has been destroyed is undefined behavior. The compiler does not currently enforce this at compile time.
ABI and Lowering#
In Silk, an &Opaque value is lowered as a single pointer
scalar (u64 on the current linux/x86_64 target), rather than as a
struct-of-pointers like &struct borrows.
Memory Layout (Intended Contract)#
The long-term Silk design is for struct layout to match conventional C layout
rules for the corresponding field types on the target:
- Sequential layout: fields appear in memory in the exact order they are
declared in the
structdefinition. - Alignment and padding: each field is placed at an offset that is a multiple of the field type’s required alignment. The compiler inserts padding bytes where necessary.
- Final padding: the overall struct size is padded to a multiple of the struct’s alignment (typically the maximum alignment of its fields), so arrays of the struct keep each element correctly aligned.
Example (typical C layout on linux/x86_64):
struct Frame {
sequence: u32, // 4 bytes
size: u16, // 2 bytes
flag: u8, // 1 byte
}
Conceptually, this layout would be:
sequenceat offset0(4 bytes)sizeat offset4(2 bytes)flagat offset6(1 byte)- 1 byte of tail padding at offset
7to make the total size a multiple of 4
Total size: 8 bytes (alignment 4).
Memory Layout#
the compiler does not implement packed C-like struct layout yet. Instead, it uses a scalar slot model:
- A
structvalue is lowered into a sequence of scalar “slots” in source order, after recursively expanding certain composite field types: stringcontributes two slots:(u64 ptr, i64 len).- nested non-opaque structs contribute their slot sequence.
- optionals contribute
(bool tag, payload slots...), where payload slots follow the lowering of the underlying non-optional type. - When a
structis stored in memory (stack locals and heap boxes), each slot is stored in a separate 8-byte cell. - This means sub-64-bit fields (
bool,i8/u8,i32/u32,f32,char, etc.) are not packed yet. - Values are still typed as their declared scalar kinds (the checker and IR track widths/sign), but the physical in-memory representation is widened to one 8-byte slot per scalar.
This design keeps lowering/codegen simple and lets the compiler support nested aggregates without committing to a final packed layout. The trade-off is that the in-memory representation is not ABI-compatible with a C struct unless the struct is restricted to ABI-safe 64-bit slots.
Example : the Frame above is lowered as 3 scalar
slots and occupies 24 bytes when stored in memory (3 × 8-byte cells), even
though the intended C-like packed layout would be 8 bytes.
ABI and Code Generation#
The Silk language design includes full support for user-defined structs, nested aggregates, and FFI-safe ABI mapping. The current compiler/backend implementation supports only a narrow, explicitly documented subset:
- Only "plain" structs with 0+ fields are supported by codegen.
- Empty structs (
struct Empty {}) are currently represented as a single placeholderu64slot in the scalar-slot model. - Fields may be:
- scalar primitive types (
bool, fixed-width integers,int,char,f32/f64,Instant,Duration), string(lowered as{ ptr: u64, len: i64 }),- nested (non-opaque) structs,
- and optionals (
T?) of supported payload types. - At ABI boundaries (exported functions and
extdeclarations), structs must be ABI-safe: after slot-flattening, all slots must bei64/u64/f64(for examplestringfields are ABI-safe because they lower to(u64, i64), butbool,char, andf32fields are not). - Such structs are passed and returned by value by lowering them to their scalar slots in order and following the System V AMD64 ABI rules for those scalar slots:
- integer-like slots consume general-purpose argument slots (
rdi,rsi,rdx,rcx,r8,r9, then the stack), f32/f64slots consume XMM argument slots (xmm0..xmm7, then the stack),- 1–2 slot results use
rax/rdxfor integer-like slots andxmm0/xmm1for float slots, with mixed aggregates using both, - 3+ slot results return indirectly via a hidden sret pointer passed in
rdi(caller-allocated return buffer), with the callee storing each scalar slot sequentially and returning the pointer inrax.
Note: at the C ABI surface, exported functions accept ABI-safe structs by flattening parameters to their scalar slots in order. For 1–2 slot structs this is ABI-compatible with passing an equivalent by-value C struct parameter, while for 3+ slot structs downstream C callers should declare separate scalar parameters for the slots. Struct returns with 3+ slots use sret and are ABI-compatible with returning an equivalent C struct by value.
This subset is intentionally small so that we can validate the end-to-end type pipeline (parsing → checking → lowering → IR→ELF codegen) while keeping ABI behavior consistent with C for the supported cases.
impl Blocks#
impl blocks attach functions and methods to existing types without affecting
memory layout.
The intent is to provide “high-level” APIs without baking behavior into struct
layout. In the implementation, impl blocks are syntax and
type-checking structure; code generation treats methods as ordinary functions
that follow the same calling conventions as other Silk functions.
Generic impl blocks#
If a type is declared with type parameters (struct or enum), its impl blocks must provide a full type-argument list of the same arity.
Each position in the impl Name(...) argument list may be:
- a type parameter name (a generic impl), or
- a concrete primitive type name (an impl specialization for that argument).
struct Data(T) { value: T }
// Generic impl (applies to all specializations of Data(T)).
impl Data(T) {
fn get(self: &Self) -> T { return self.value; }
}
// Specialized impl (applies only to Data(u8)).
impl Data(u8) {
fn is_zero(self: &Self) -> bool { return self.value == 0; }
}
Specialized impl blocks are merged with any other applicable impl blocks for the same type specialization, subject to the usual duplicate method-name rules.
Supported forms limitation:
- Only primitive type names (for example
u64,string,bool) are recognized as concrete specialization arguments inimpl Name(...). Any other identifier in animplargument position is treated as a type parameter name.
Syntax#
impl List {
// Ordinary static method (no receiver).
fn init (cap: i64) -> List { ... }
// Heap constructor used by `new List(...)` (special name, receiver + `void`).
fn constructor (mut self: &Self, cap: i64) -> void { ... }
// Instance method (receiver as first parameter).
public fn len (self: &List) -> i64 { ... }
// Mutating instance method (mutable receiver).
public fn push (mut self: &List, value: u8) -> void { ... }
}
Rules:
- An
implblock attaches methods to exactly one nominal type name (astructor anenum). - Multiple
implblocks may exist for the same type name; the compiler merges their methods (subject to duplicate-name rules). - Methods inside an
implblock arefndeclarations (with bodies). - The receiver, when present, is the first parameter named
selfand must be either: - a borrowed reference to the
impltype (self: &Type/mut self: &Type), or - an owned value of the
impltype (self: Type/mut self: Type). - Within an
implblock, the special type nameSelfmay be used anywhere a type name is accepted, and is treated as an alias for theimpltype. For example,self: &Selfis equivalent toself: &Type, and-> Selfis equivalent to-> Type. - Static methods omit the receiver parameter.
- Method visibility:
- Methods are private by default: a method declared without an explicit
visibility modifier is callable only within the defining
impl { ... }block. public fnmarks a method as callable from outside the definingimplblock.private fnis permitted to make intent explicit.exportis reserved for static members (noselfreceiver) and is not permitted on instance methods; usepublic fninstead.- When an
implblock declares conformance to an interface (impl T as I), the interface’s required methods are public by definition: - the corresponding impl methods may omit
public, but - they may not be explicitly marked
private. See interfaces. - The method named
constructoris treated specially: - it is only meaningful for
structtypes (it backsnew Type(...)); enums do not supportconstructormethods in the Supported forms, - it is
publicby default, - when explicitly marked
private, it is callable only within the definingimpl { ... }block, - it may be declared multiple times in a single
implblock (an overload set), - its overload set includes
constructordeclarations across all mergedimplblocks for the type, - it is invoked by:
- heap allocation (
new Type(...)), - empty struct literals (
Type{}and contextual{}) when a visible default constructor exists (see literals aggregate), - and certain call-argument coercions (see types),
new Type(args...)invokes the unique overload whose receiver ismut self: &Type, whose return type isvoid, and whose non-receiver parameter list matchesargs...after applying the normal call-argument type-checking rules,- if multiple overloads are applicable, the compiler prefers overloads that do
not rely on implicit call-argument coercions (notably the
U -> &Tconstructor coercion for&Tparameters); if multiple overloads remain tied, the call is rejected as ambiguous.
Call syntax#
The surface call syntax uses field-access + call:
- Instance method call:
value.method(arg0, arg1, ...) - Static method call:
Type.method(arg0, arg1, ...)
Semantically, method calls behave like ordinary function calls where the receiver is passed as an explicit first argument.
Static-method receiver sugar (Supported forms):
-
If
value.method(...)does not resolve to an instance method (a method whose first parameter is a receiverself: &Type/mut self: &Type), the compiler may resolve it as a call to a visible static method of the receiver type by inserting the receiver as the first argument:Type.method(value, ...). -
This supports fluent chaining for value-consuming helper APIs like
std::result::Result.unwrap_or:let r: R = /* ... */; let x: int = r.unwrap_or(0); // sugar for `R.unwrap_or(r, 0)`
Mutability rule (Supported forms):
- If the method receiver is
self: &Type, the call site passes a read-only borrow of the receiver (for examplevalue.method(...)). - If the method receiver is
mut self: &Type, the call site must pass a mutable borrow of the receiver. - When the receiver is a name binding that is mutable (
let mut value = ...) or a mutable reference binding (for example amut self: &Typereceiver), the compiler treatsvalue.method(...)as a mutable receiver call (no(mut value)wrapper required). - The explicit
value.method(...)form is permitted but is no longer required for name receivers. - If the method receiver is
self: Typeormut self: Type, the call site passes the receiver by value. For ownership-tracked values (for example types withDrop), this consumes the receiver binding (use after move is rejected); for plain scalars and POD structs it behaves like a copy.
Supported forms limitations:
- Mutable borrow receiver calls (
mut self: &Type) must use a name receiver; mutable borrows from non-name receiver expressions (for examplemake().push(1)) are rejected. - Non-
mutreceivers may be arbitrary expressions (including calls), so chaining likeurl.href().as_string()is permitted.
Compiler requirements:
- Keep data layout and behavior separate in the IR.
- Preserve struct layout exactly for ABI and FFI.
- Enforce rules for opaque structs and UB as described in this document and the ABI spec.
Source repository · Edit this page · View Markdown