sysl

Reflection

What a type answers about its own make-up while compiling — kind, name, fields, variants, annotations — and how a library writes a derivation from it.

Reflection is a set of questions a type answers while the program is being compiled, and nothing else. A struct can be asked what its fields are, an enum what its variants are, any type what kind of thing it is; the answers are plain values and descriptors that a for const walks, and a body written over them is checked and unrolled once per type, so the code that comes out is the code a person would have written by hand. There is no run-time half: no any, no type tags on a value, no table the program carries to look a field up by name.

It is built from forms that are described elsewhere, and this page is where they meet:

writtenanswerscovered in
T::Kind, T::Name, T::BaseNamewhat a type is, and what it is calledbelow
T::Fields, T::Membersthe stored parts of a struct, a tuple or a unionfields
T::Variantsthe variants of an enumvariants
v.(f), f::Typeone field of a value, and its typewalking fields
x is c, x.(c), c::Payload, T::Make(c, p)testing, reading and building a variantvariants
if const T: Traitwhat a type can doasking for a trait
@annotation, f::Annotation[A]a program’s own marks on a field, variant or typeannotations

The other :: attributes — Color::First, Int::Max, T::Min — are on attributes. for const and if const are on generics.

What a type is, and what it is called

T::Kind is a value of the standard enum Kind, one variant per make-up a type can have: Struct, Tuple, SimpleEnum, DataEnum, Union, Array, Vector, Slice, Int, Float, Fixed, Bool, Char, String, Unit, Ref, Weak, Pointer, Fn, Trait and Opaque. A subtype is the kind of the type it narrows, a bitfield struct is a Struct, and a closure — which is reached through a reference — is a Ref.

struct Point
    x: int
    y: int

enum Light
    Red
    Green

enum Shape
    Dot
    Circle(r: int)

union Bits
    i: u32
    f: f32

type Pair = (int, real)
type Four = [4]int

print(Kind::Image(Point::Kind), Kind::Image(Light::Kind), Kind::Image(Shape::Kind), Kind::Image(Bits::Kind))
print(Kind::Image(Pair::Kind), Kind::Image(Four::Kind), Kind::Image(string::Kind))
print(Kind::Image(f64::Kind), Kind::Image(bool::Kind), Kind::Image(unit::Kind))
Struct SimpleEnum DataEnum Union
Tuple Array String
Float Bool Unit

Kind is an ordinary enum, so it compares with == and is the usual head of an if const: if const T::Kind == Kind.Struct keeps one arm of a body and discards the rest, per type.

T::Name is the type as a program spells it, with its arguments, and T::BaseName is the declared name alone — what Debug and Display print in front of a value’s parts:

struct Box[T]
    v: T

type Pair = (int, real)

print(Box[int]::Name, Box[int]::BaseName)
print(Pair::Name, int::Name)
Box[int] Box
(int, real) int

T::BaseName is refused where the type has no declared name, as a tuple has none, and Box::Name with no arguments is refused because Box alone is not one type; both are on what it refuses. An anonymous struct or union block answers the word that opened it.

Fields and members

T::Fields lists the named fields of a struct, a tuple or a union, in declaration order. Each element is a sysl.reflect.Field, a plain value that exists while compiling:

memberis
name: stringthe field’s name; a tuple’s parts are "0", "1", and so on
index: usizeits position in T::Fields, counting from 0
offset: usizewhere it begins, in bytes from the start of the value
public: boolwhether code outside the type’s own file may name it
struct Account
    id: int
    private secret: u8
    owner: string

type Pair = (int, real)

for const f in Account::Fields
    print(f.name, f.index, f.offset, f.public)

for const f in Pair::Fields
    print(f.name, f.index, f.offset)
id 0 0 true
secret 1 4 false
owner 2 8 true
0 0 0
1 1 8

A union’s fields all begin at offset 0. T::Fields.len is the number of fields, a constant, and the list is a for const subject like any other.

T::Members lists everything a struct, tuple or union stores: its named fields, each anonymous struct or union block, and each unnamed _ bitfield. A zero-width _ stores nothing and is not listed. Its elements are sysl.reflect.Members — a Field‘s four, plus anonymous: bool, true for a block, whose name is ""; an unnamed bitfield’s name is "_".

struct Packet
    tag: u8
    union
        word: u32
        real: f32
    lo: u4 in u32
    _: u4 in u32
    hi: u8 in u32

print(Packet::Members.len, Packet::Fields.len)

for const m in Packet::Members
    print(m.index, m.name, m.anonymous)

for const f in Packet::Fields
    print(f.index, f.name)
5 3
0 tag false
1  true
2 lo false
3 _ false
4 hi false
0 tag
1 lo
2 hi

T::Fields is the named fields alone, so a derived Eq or Hash never reads an unnamed bitfield and an anonymous block is not a field of its own; T::Members is what Debug walks, since it shows the bytes a value holds. Each list numbers its own elements: a Field‘s index is its position in T::Fields and a Member‘s its position in T::Members, which is why lo is field 1 and member 2 above. So f.index > 0 is “not the first field”, whatever members come before it.

A member that is an anonymous block is read and assigned as its own struct or union, and its ::Type says which:

struct Packet
    tag: u8
    union
        word: u32
        real: f32

for const m in Packet::Members
    if const m.anonymous
        print(Kind::Image(m::Type::Kind), m::Type::Fields.len)
Union 2

Walking the fields

for const f in T::Fields unrolls once per field, and f is a compile-time descriptor in each copy. v.(f) selects that field of a value v of type T, typed as the field and readable and assignable as v.x is. Each copy is checked on its own, so one written line covers fields of different types:

struct Point
    x: int
    y: real

twice[T](v: T) -> T
    var out = v

    for const f in T::Fields
        out.(f) = v.(f) + v.(f)

    out

val p = twice(Point(3, 1.5))

print(p.x, p.y)
6 3

A copy that the field’s type cannot do is refused naming the copy: twice over a struct holding a bool says so for that field only.

f::Type stands for the field’s type, wherever a type is written. No type is ever a value — it is how a generic is called, or a local declared, once per field:

struct Point
    x: int
    y: real

zero[T]() -> T
    var out: T

    for const f in T::Fields
        var tmp: f::Type
        out.(f) = tmp

    out

val p = zero[Point]()

print(p.x, p.y)
0 0

Building a value is var out: T and one assignment per field. out starts at the type’s zero as any var does, so a field not assigned keeps it; a type with no zero is refused at the var.

Variants

T::Variants lists the variants of an enum, simple or data, in declaration order. Each is a sysl.reflect.Variant, with name: string and index: usize, and for const c in T::Variants unrolls once per variant. A variant’s payload is described by c::Fields, the same descriptors as a struct’s, so it walks the same way:

enum Shape
    Dot
    Circle(r: int)
    Rect(w: int, h: int)

for const c in Shape::Variants
    print(c.name, c.index, c::Fields.len)

for const c in Shape::Variants
    for const f in c::Fields
        print(c.name, f.name, f.index)
Dot 0 0
Circle 1 1
Rect 2 2
Circle r 0
Rect w 0
Rect h 1

Three operations on a value are what an encoder and a decoder need, and none writes a match:

  • x is c is true when x holds that variant. It is a run-time test in each copy, so the walk reaches the one that matches.
  • x.(c) is the payload, and it traps when x holds another variant. The payload is unit for a variant with no fields, the field itself for a variant with one, and a tuple for more.
  • T::Make(c, p) builds the variant c of T from a payload p of that same shape.
enum Shape derives Eq
    Dot
    Circle(r: int)
    Rect(w: int, h: int)

describe(s: Shape) -> string
    var out = ""

    for const c in Shape::Variants
        if s is c then out = c.name

    out

radius(s: Shape) -> int
    for const c in Shape::Variants
        if const c.name == "Circle"
            if s is c then return s.(c)

    -1

print(describe(Shape.Rect(2, 3)), describe(Shape.Dot))
print(radius(Shape.Circle(5)), radius(Shape.Dot))
Rect Dot
5 -1

Reading a variant of a value that holds another stops the program, having written variant 'Shape.Circle' was asked for, and the value is 'Shape.Dot' (main.sysl:9:18) to standard error the way a failed bounds check does:

enum Shape
    Dot
    Circle(r: int)

val s = Shape.Dot

for const c in Shape::Variants
    if const c.name == "Circle"
        print(s.(c))

c::Payload stands for a variant’s payload type, where a type is written. A decoder builds a variant with one generic body: declare the payload, assign each field of c::Fields with p.(f), and hand it to T::Make. Over a payload of one field p.(f) is p itself, and assignable:

enum Shape derives Eq
    Dot
    Circle(r: int)
    Rect(w: int, h: int)

build(i: usize) -> Shape
    var out = Shape.Dot

    for const c in Shape::Variants
        if c.index == i
            var p: c::Payload

            for const f in c::Fields
                p.(f) = 10 * int(f.index + 1)

            out = Shape::Make(c, p)

    out

print(build(0) == Shape.Dot, build(1) == Shape.Circle(10), build(2) == Shape.Rect(10, 20))
true true true

A simple enum is walked the same way — its variants have no payload, and x is c and c.name are the whole of what a serializer needs.

Asking what a type can do

if const T: Trait is true when T implements Trait, asked as a bound is asked: T: Display + Hash, T: Conv[int] and f::Type: Display all read. It is an operand of an if const or elif const, so it joins &&, ||, ! and ordinary constants. A serializer uses it to choose between a type’s own implementation and walking its fields:

trait Named
    name(self) -> string

impl Named for int
    name(self) -> string = "int"

struct Pin
    on: bool

describe[T]() -> string
    if const T: Named
        "named"
    elif const T: Display
        "displayable"
    else
        "plain"

print(describe[int](), describe[string](), describe[Pin]())
named displayable plain

Annotations

A program may declare an annotation: a struct written under @annotation, whose fields are the arguments a use gives it. It is then legal above a field, a variant or a type, and reflection reads it: f::Annotation[A], c::Annotation[A] and T::Annotation[A] answer an Option[A], None where the mark is absent. The built-in attributes stay reserved; an annotation is only ever one the program declared.

@annotation
struct label
    text: string

@annotation
struct secret
    level: int

@label("a shape")
enum Shape
    @label("nothing") Dot
    Circle(r: int)

struct User
    @label("the id") id: int
    @secret(2) password: string
    name: string

for const f in User::Fields
    print(f.name, f::Annotation[label].map(l -> l.text).unwrap_or("-"), f::Annotation[secret].is_some())

for const c in Shape::Variants
    print(c.name, c::Annotation[label].map(l -> l.text).unwrap_or("-"))

print(Shape::Annotation[label].map(l -> l.text).unwrap_or("-"), User::Annotation[label].is_some())
id the id false
password - true
name - false
Dot nothing
Circle -
a shape false

The arguments are checked against the annotation’s fields as a call’s are, a mark of a name that is not an annotation is refused, and one written twice above the same thing is refused:

@annotation
struct label
    text: string

struct S
    @label(5) a: int
'text' of 'label' is string, but int was given
struct S
    @label("x") a: int
'@label' names no annotation
@annotation
struct label
    text: string

struct S
    @label("a") @label("b") a: int
'@label' is written twice above 'a', and it says nothing the once does not

Privacy

A query lists every field, and f.public says which are public. v.(f) on a private field is allowed only in the type’s own file, or in a derivation the type asked for with derives: the author of a type opts in by writing the clause, and anyone else sees only the public surface.

// secrets/main.sysl
module secrets

struct Secret
    shown: int
    private hidden: int

dump[T](v: T) -> string
    var out = ""

    for const f in T::Fields
        out = out + s"${v.(f)}${if f.public then "" else "!"} "

    out

print(dump(Secret(shown = 1, hidden = 2)))
1 2! 

Inside the file that declares the type the walk reaches hidden. A generic written in any other file is refused at the copy for that field, naming it, and the same holds for T::Members.

Generic types and opaque ones

A generic type is reflected as an instance. Box[int]::Fields lists the fields of that instance, with int for v; Box::Fields with no arguments is refused, as Box::First would be, because there is no one type to ask.

struct Box[T]
    v: T
    n: int

for const f in Box[string]::Fields
    print(f.name, f.offset)
v 0
n 24

An opaque struct that has fields is a struct in its own module. There, and in a derivation it asked for, T::Kind is Struct and T::Fields and T::Members list its fields; anywhere else it is Opaque and has none. An opaque struct with no body is Opaque everywhere.

// lib/main.sysl
module lib

opaque struct H
    x: int
    private y: u8

opaque struct Bare

print(Kind::Image(H::Kind), Kind::Image(Bare::Kind))

for const m in H::Members
    print(m.name, m.offset)
Struct Opaque
x 0
y 4

From another module the same H is Opaque and H::Fields is refused.

There is no run-time reflection

Every query is answered while compiling. A descriptor is plain data, so a program may lay a type’s name or a table of its field names into the object file — print(T::Name) is a string constant — but there is nothing to ask of a value at run time. No any type exists, a trait object carries no description of the type behind it, and a program that does not reflect pays nothing for the feature. A function’s methods and a module’s declarations are not reflected either; what a type’s methods are varies with what is imported.

Writing a derivation

A trait may carry a derive member, a body written with the queries above, and a derives clause instantiates it for a type as the impl block a person would have written. Everything the standard library derives — Eq, Ord, Hash, Display, Debug — is written this way, and a library’s own trait is derived exactly as they are. The clause, what the body is held to, and how a field that cannot do the work is refused are on traits.

The program below is a complete serializer. Json is declared with an encode member and the reflection-written derivation of it; a struct is walked field by field, a simple enum is written as its variant’s name with T::Image(self) (in the enum’s copy T is the enum, and answers what it does), @rename changes a key, and a field’s own encode is reached through self.(f).encode(w):

@annotation
struct rename
    name: string

trait Json
    encode(self, w: *Writer)

    derive[T] encode(self: T, w: *Writer)
        if const T::Kind == Kind.Struct
            w.write("{".bytes)

            for const f in T::Fields
                if f.index > 0 then w.write(",".bytes)

                val key = f::Annotation[rename].map(r -> r.name).unwrap_or(f.name)

                w.write(s"\"$key\":".bytes)
                self.(f).encode(w)

            w.write("}".bytes)
        elif const T::Kind == Kind.SimpleEnum
            w.write(s"\"${T::Image(self)}\"".bytes)
        else
            @assert(false, s"Json cannot derive for ${T::Name}")

impl Json for int
    encode(self, w: *Writer) = w.write(s"$self".bytes)

impl Json for string
    encode(self, w: *Writer) = w.write(s"\"$self\"".bytes)

enum Role derives Json
    Admin
    Guest

struct Address derives Json
    city: string
    zip: int

struct User derives Json
    @rename("id") user_id: int
    name: string
    role: Role
    home: Address

User(7, "ann", Role.Admin, Address("Oslo", 150)).encode(stdout())
print("")
{"id":7,"name":"ann","role":"Admin","home":{"city":"Oslo","zip":150}}

User gets an impl Json for User whose body is four unrolled field writes, and self.(f).encode(w) is checked for each, so a field whose type is not Json is refused at the derives naming the field. A type the derivation does not handle says so in a message of the library’s own making: the @assert‘s computed message is shown where the type asked.

trait Named
    name(self) -> string

    derive[T] name(self: T) -> string
        if const T::Kind == Kind.Struct
            T::BaseName
        else
            @assert(false, s"Named cannot derive for ${T::Name}, which is not a struct")
            ""

enum Light derives Named
    Red
    Green
assertion failed: Named cannot derive for Light, which is not a struct

What it refuses

Each refusal names the type and what it is, or the descriptor and what it answers:

for const f in int::Fields
    print(f.name)
'int::Fields' lists the fields of a struct, a tuple or a union, and 'int' is an integer
struct Box[T]
    v: T

print(Box::Name)
'Box' is generic, so 'Box::Name' has no single type to read — write the arguments, 'Box[…]::Name'

A descriptor answers what its own kind of thing has: a field its ::Type, a variant its ::Fields and ::Payload.

struct P
    x: int

for const f in P::Fields
    var a: f::Payload
'f' is the descriptor 'P.x', which answers '::Type', the field's type, and not '::Payload'
enum E
    A(x: int)

for const c in E::Variants
    var a: c::Type
'c' is the descriptor 'E.A', which answers '::Fields', its payload's fields, and '::Payload', its payload's type, and not '::Type'

A name a type does not answer is refused with the type named:

struct P
    x: int

print(P::Bogus)
'P' has no type attributes

Search

Esc
↑↓ to navigate ↵ to open Esc to close