Generics
Type parameters, bidirectional inference, bounds checked at the definition, and monomorphization.
A type parameter list in square brackets makes a function, struct, or enum generic. A parameter name stands for a type not yet known, and may appear anywhere a type may — a parameter type, a return type, a field type, a variant payload, a local annotation.
id[T](x: T) -> T = x
struct Pair[A, B]
first: A
second: B
end Pair
var p = Pair(1, "one")
print(id(7), id("hi"), p.first, p.second)
7 hi 1 one
Three further declarations take parameters without declaring a type of their own: an impl block,
whose subject is a generic type or a composed shape applied to them; a member, which may be generic
over types of its own beyond its type’s; and a trait, which is then a family of promises rather
than one. All three are on traits.
[] means type application in a type, indexing in an expression
Square brackets are reused for two things, disambiguated by position, with no new token:
- in a type,
Box[int]andResult[Box[int], string]apply type arguments to a generic type, and nesting is ordinary; - in an expression,
a[0]indexes, and never reads as a type application.
The reuse is unambiguous because a type and an expression never occupy the same grammatical slot.
What it looks like it costs is explicit type arguments at a call head — id[int](7) reads as
“index id by int, then call” — but only to the parser. The compiler resolves the head first, and
a function is not a thing that can be indexed, so there is no second reading to protect:
id[T](x: T) -> T = x
print(id[int](7), id[string]("s"))
7 s
The nearest binding wins, which is the rule every call form follows. A local standing over a function’s name makes the brackets an index again, and its author is never told about a feature they did not reach for.
Writing the type arguments
Five heads take the list. A value argument is written exactly as a type one is, since the two share a list and a position:
| written | what it names |
|---|---|
id[int](7) | a function, qualified or not |
chunk[8]() | a value parameter — [const N: usize] |
Pair[int, real](1, 2.5) | a constructor |
x.pick[int](3) | a method — its own parameters, not the receiver’s |
Maybe[int].Just(1) | a variant, which is a construction of its type |
va_arg[int](ap) | a special form: va_arg and ptr_cast |
Inference is still what supplies them nearly everywhere, and a list inference would have found is
noise. What earns the syntax is a signature neither direction of inference reaches — a kernel whose
width is a value parameter it names in no argument and answers unit with:
add[const W: usize](a: []const f32, b: []const f32, out: []f32)
var i: usize = 0
while i + W <= a.len
val l: <W>f32 = a.load(i)
val r: <W>f32 = b.load(i)
out.store(i, l + r)
i += W
end add
val xs: [8]f32 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]
val ys: [8]f32 = [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0]
var out: [8]f32
add[4](xs[..], ys[..], out[..])
print(out[0], out[7])
11 88
Written without the brackets there is nothing anywhere to say what W is, and the message says so
rather than naming an annotation that cannot exist:
add[const W: usize](a: []const f32, out: []f32)
val v: <W>f32 = a.load(0)
out.store(0, v)
end add
var xs: [8]f32
var out: [8]f32
add(xs[..], out[..])
'W' is in neither the parameters of 'add' nor its result
Two things stay inferred. A type pack — ..A — stands for a list of types rather than one and
has no expression spelling, so its instantiation is always solved. And an associated function
selected from an applied type, Box[int].of(1), reads its type’s parameters and its own from a
single solve; the annotation on the binding reaches it, and unlike the kernel above there is always
one, since an associated function has a result.
There is also a hole shared with the address form: the brackets read the expression grammar, so
a type the two grammars do not spell alike has no form there. []int, weak T, volatile T,
<4>f32 and a callable are refused by the parser. The annotation reaches every one of them.
The address form came first: &f[T] names one instantiation of a generic function, and the case
that earned it is a C callback whose signature mentions the type parameter nowhere. See
the FFI reference.
Construction
Applying a generic type names a concrete instance: Box[int] is the type of a box of int,
Pair[int, string] a pair. Constructing one is the ordinary construction, with the type arguments
inferred from the arguments — Box(41), Pair(1, "one") — or written on the name, which means what
the annotation means: Pair[int, real](1, 2.5) fixes the instantiation and checks the fields against
it. The memory mode is the usual per-declaration choice: Box(41) is a value unless a &Box[int] is
expected.
Inference is bidirectional
Type arguments are inferred, and from two directions.
From the arguments. id(7) infers T = int, and inference reaches through a generic
construction, so every nested parameter is solved at once:
struct Box[T]
v: T
end Box
id[T](x: T) -> T = x
var b = id(Box(id(5)))
print(b.v)
5
From the expected type, when the arguments cannot determine a parameter. A nullary generic has
nothing in its argument list to fix T, so the declaration supplies it and the return type flows
inward:
empty[T]() -> Option[T] = None
var e: Option[real] = empty()
e match
Some(v) -> print("some", v)
None -> print("none")
none
The model is unification: each parameter is solved by matching the declared parameter and return types against the actual argument types and the expected type. When a parameter is left undetermined by both directions, that is a compile error asking for an annotation on the binding — never a silent default and never a stuck inference variable.
A literal is consulted last, because a literal has no type of its own to offer. It takes one from where it appears, and a parameter still being solved is not yet a place that can give it one. So what is already a type settles the parameter first, then the expected type, and a literal’s default only where nothing else reached it:
pick[T: Add](a: T, b: T, c: T) -> T = a + b + c
print(pick(1, 2, 250u8))
253
That is a u8 because one argument knew and two did not, while id(7) is still an int because none
did. Once the parameter is a type the literals are read against it — the same order the operand rule
uses inside an expression.
A parameter is solved to the type that was written. Where that is a transparent subtype, the parameter carries the subtype rather than the base it is stored as — and it does so however the call said which type it is at:
type Age = int within 0..150
widest[T]() -> T = T::Max
val written = widest[Age]()
val expected: Age = widest()
print(int(written), int(expected))
150 150
The routes have to agree, because from the reader’s side they are three ways of saying one thing.
Writing Age at the call, handing an Age as an argument, and annotating the binding Age are each
a way of naming the type this call is at; an answer that depended on which one was available would be
an answer about the call rather than about the type.
That a transparent subtype is its base — an Age stands where an int is asked for, and the
reverse — is unchanged, and holds for every value that flows. A type parameter is not a value: it is
the name a body is written about, and the body may ask it something its base would answer
differently. T::Max is the only such question there is.
null is not consulted at all — it waits, and what separates it from a literal is that it has no
default to be consulted for. The argument that cannot contribute is set aside, the rest solve the
parameter, and it is then read against what they said:
two[T](a: *T, b: *T) -> bool = a == b
var x: int = 3
print(two(&x, null), two(null, &x))
false false
Either order answers alike, since waiting is not queueing — and setting it aside cannot lose the solution, because an argument with no type of its own has nothing to unify. Where nothing else reached the parameter it is refused rather than defaulted: inference does not invent a pointee.
one[T](a: *T) -> bool = true
print(one(null))
'null' takes its type from its context, and there is none here
A parameter that names no type parameter is not part of the question, and its argument is checked against it exactly as a plain callee’s is:
at[T](x: T, n: usize) -> string = str(n)
print(at("v", 7))
7
That is worth saying because inference has to look at the arguments before it knows what anything is,
which would otherwise cost a generic callee the rules that need an expected type — a parameter’s type
fixing an unsuffixed literal, and the coercions to &T and to a trait object. A declaration having a
T somewhere does not make its usize any less a usize.
Members and associated functions
A method never asks the question. Its receiver already is a Box[int], so the type’s arguments
are read rather than solved.
An associated function has no receiver, which puts it in the position a generic free function is always in — so it is inferred by exactly the rule above and needs no machinery of its own:
struct Box[T]
v: T
of(x: T) -> Box[T] = Box(x)
end Box
var b = Box.of(41)
print(b.v)
41
Self in the signature is the type applied to its own parameters, so writing -> Self and writing
-> Box[T] infer alike.
A member’s own type parameters are inferred the same way. The receiver says what the type’s arguments are and nothing about the member’s, which leaves those exactly where the rule already reaches:
struct Pair[A, B]
first: A
second: B
end Pair
struct Box[T]
v: T
with[U](self, x: U) -> Pair[T, U] = Pair(self.v, x)
end Box
var b = Box(1)
var p = b.with("x")
print(p.first, p.second)
1 x
The two lists are held to their bounds separately and under the name each was written in. That they must not collide is the one thing the two-list form adds, and it is settled by refusing a member that spells one of its own the way its type spells one of its.
Bounds
A type parameter is bounded by a trait, and the bound is what the body of a generic is allowed to assume about the parameter.
An unbounded parameter permits only what every type supports
With no bound, T may be used only for the operations every sysl value has — which, because of the
memory model, is a genuinely useful set: copied, assigned, passed, returned, and stored in a struct
field, an enum payload, an array, or a slice.
That set is exactly id[T], Box[T], Pair[A, B], and every other container: they move data around
without inspecting it, and they need no bound.
struct Box[T]
v: T
end Box
keep[T](x: T) -> Box[T] = Box(x)
var a = keep(5)
var b = keep("hi")
print(a.v, b.v)
5 hi
Every sysl value is copyable — assignment copies, and copying a value holding a &T retains it —
so there is no Copy bound to write, ever. That is a real simplification over Rust, where T is
move-by-default and T: Copy / T: Clone litter generic signatures. Here, “hold and hand along any
T“ is the free, unmarked baseline.
What an unbounded T may not do is anything that assumes structure: no operator, no method call,
no field access, no index. Each is a capability some types have and others do not, so each requires a
bound that guarantees it — and each is refused at the definition, naming the bound to write:
sum[T](a: T, b: T) -> T = a + b
print(sum(2, 3))
'+' needs 'T: sysl.Add'
A subscript is among them, because a subscript is Index‘s one method, so it is asked of the bounds
exactly as a dot call is.
A field is the exception that proves the rule. Every other unlicensed use names the bound that would allow it — the diagnostic’s whole job is to say what to write. A field names none, because a trait promises behaviour and a field is layout, so no bound could ever supply one:
first[T](x: T) -> int = x.v
print(1)
'T' is a type parameter, so it has no fields to read — a field is layout, and no trait declares a property 'v' that a bound could promise instead
It is therefore settled outright at the definition rather than deferred to the types that turn up:
first[T](x: T) = x.v is wrong even if every call happens to pass a type with a v. Reaching a
value’s data through a generic means going through a member the bound declares, which is also what lets
two types satisfy one bound while storing the value differently.
x.v is spelled like a field and need not be one, so the diagnostic is reached only after looking for
a property of that name: a property is behaviour, a trait may declare one, and reading it through a
bound is as ordinary as calling a method.
A bound is a trait, written [T: Trait]
sum[T: Add](a: T, b: T) -> T = a + b
smaller[T: Ord](a: T, b: T) -> T = if a < b then a else b
print(sum(2, 3), smaller(9, 4))
5 4
a + b inside sum type-checks because T: Add promises the operator; drop the bound and sum
fails at its own definition, pointing at the line that made the unsupported assumption. That is the
whole payoff over the template model: the error lands on the definition that is wrong, not on some
caller three files away that instantiated it with the wrong type. It is the Swift/Kotlin/Scala
consensus — all three check bounds at the definition, and only C++ defers to instantiation.
Operators are available through bounds because operators are trait
methods: + is Add, < is Ord, == is Eq.
Multiple bounds join with +: [T: Ord + Hash] requires both. The + reads unambiguously in a
bound position, since it stands between trait names rather than values.
A bound is the trait applied, so it carries the trait’s own type arguments where it has any:
| written | means |
|---|---|
[X: Sink[int]] | the body’s x.put(…) takes an int |
[X: Into[Y], Y] | and Y is solved at the call |
[X: Into[Y], Y: Display] | a body that may also print what the conversion yields |
The arguments are ordinary types, which is what lets one name another parameter of the same declaration — and what a body may then do with that parameter is what its bounds promise. Inference does not run backwards through a bound: a parameter appearing only there is solved from the result type or annotated.
A trait object satisfies a bound on the trait it dispatches through
A bound asks whether the members it names can be called on the value. A *Trait or a &Trait has
forgotten which type it holds, but it carries a table of exactly those members — so it answers
yes, and one generic function takes both a concrete type and the object erased from it:
trait Shape
area(self) -> int
struct Rect
w: int
h: int
impl Shape for Rect
area(self) -> int = self.w * self.h
total[T: Shape](x: T) -> int = x.area()
var o: &Shape = Rect(3, 4)
print(total(Rect(3, 4)))
print(total(o))
12
12
Nothing about monomorphization is bent to allow it. An object type is a
concrete type — a pair of words — so the body is instantiated at &Shape exactly as it is at Rect,
once each. What differs between the two instantiations is what x.area() compiles to: a direct call
in one, an indirect call through the table in the other. That is the same difference the two
instantiations would have had anyway, and it is decided the same way — by looking at the type the
parameter was bound to.
It is total, and that is a property of object safety rather than a promise made here. A trait
with a member that cannot be dispatched — one mentioning Self away from its receiver, say — has no
object at all in sysl, rather than an object missing that member. So a &Shape existing is already
the proof that every member of Shape is reachable through it, and there is no “this method is
unavailable on the object” case for a bound to trip over.
It follows for required traits with no rule of its own. trait Shape: Display puts Display‘s
slots in the object’s table, and a bound on a trait is already satisfied wherever a bound on one that
requires it is — so a show[T: Display](x: T) takes the same o.
What an object still does not satisfy is a bound on any other trait: the table is what answers, and a trait with no slots in it has nothing to answer with.
trait Shape
area(self) -> int
trait Weighed
weight(self) -> int
struct Rect
w: int
h: int
impl Shape for Rect
area(self) -> int = self.w * self.h
heft[T: Weighed](x: T) -> int = x.weight()
var o: &Shape = Rect(3, 4)
print(heft(o))
'heft' requires its type parameter 'T' to implement 'Weighed', but &Shape does not
Satisfying a bound is not the same as being erasable again. A bound asks what may be called
through a value; forming an object asks what may be assembled from its type, and a table is laid
out from a type’s implementations. An object has none, so a &Shape cannot be turned into a
&Display even though Display‘s slots are sitting inside its table — a run of slots in one table
is not a table of its own ([traits]({{< ref “traits” >}})).
A type’s own parameters carry bounds too
The same bracketed list, in the same place, means the same thing on a struct or an enum — and that is where the type says what it assumes:
struct SortedPair[T: Ord]
lo: T
hi: T
ordered(self) -> bool = self.lo < self.hi
end SortedPair
var s = SortedPair(1, 2)
print(s.ordered())
true
It buys two things, worth stating separately.
Everything applying the type must supply it, wherever the application is written — a declared parameter, a result, a field of another type, a variant’s payload, a construction:
struct SortedPair[T: Ord]
lo: T
hi: T
end SortedPair
struct P
v: int
end P
var s = SortedPair(P(1), P(2))
print(1)
'SortedPair' requires its type parameter 'T' to implement 'sysl.Ord', but P does not
Where the argument is itself a type parameter, the answer is what its own bounds promise, so a
function taking a SortedPair[U] must bound U by at least what SortedPair asks. A bound is
satisfied by a bound, one step out.
And the type’s members may assume it, so they are checked at their definition. That is what having somewhere to write the bound is for: a member of a generic type is walked once, with the parameters standing in for themselves, by the same pass that walks a bounded generic function — so a method calling something no bound licenses is reported on its own line whether or not anything instantiates the type. A generic type’s fields are laid out once the same way, which is what catches a field applying another bounded type to this one’s parameter.
A bound is declared once, at the type, and is in force everywhere its parameters appear — a member’s signature and body, a field’s type, a variant’s payload. It is not restated per member. Restating it is Rust’s rule and its own users regret it; declaring once is the Swift/Kotlin behaviour and the one that matches how the bound reads.
A parameter may carry a default
A parameter of a trait, a struct, or an enum may name the type to use where a use leaves it out:
struct Pair[A, B = A]
x: A
y: B
end Pair
total(p: Pair[int]) -> int = p.x + p.y
print(total(Pair(1, 2)))
3
The bound comes first and the default last — [R: Show = Self] — and either may be written without
the other. Four rules govern them:
- Defaults are filled left to right, each resolved under the arguments already fixed, so a default may name a parameter written before it, and naming one written after it is the forward reference it looks like.
- They are a suffix. A parameter with no default may not come after one that has, because arguments are written in order and nothing could leave out the earlier one and still supply the later.
- The filling happens before anything is keyed on the arguments, so
Pair[int]andPair[int, int]are one instantiation rather than two that happen to have the same fields. - A default is exposed like a field. A public declaration may not default to a type that reaches less far than it does, or a caller who leaves the argument out ends up holding something they could not have written and cannot name. And a default may not lead back to the declaration it belongs to, directly or through another’s.
Self is the case the feature exists for. In a trait’s default it means the implementing type,
exactly as in a method’s signature — so impl Scale for P is the impl Scale[P] for P it reads as,
and [T: Scale] asks for Scale[T]. A struct and an enum have no implementing type, so Self in one
of their defaults is refused. Neither has a trait object: an object has forgotten which type it
holds, so a default of Self has nothing to name and the argument is written out.
Only those three declarations may carry one. A function’s, a method’s, and an impl block’s type
parameters are solved from what they are given rather than written where they are used, so there is
no argument list with a gap for a default to fill:
f[T = int](x: T) -> T = x
print(f(1))
'T' is a type parameter of the function 'f', whose type parameters are solved from what it is given rather than written where it is used — so '= int' has nothing to stand in for
What would be useful there is a fallback for an inference that found nothing, which is a different
feature; f[T = int](x: T) is refused rather than quietly meaning that.
Converting through a parameter
A conversion is the one capability with no bound to promise it, because a conversion is between the concrete scalar kinds rather than something a trait declares. Both directions are written, and both are checked at each instantiation rather than at the definition:
low[T](x: T) -> u8 = u8(x)
make[T](b: u8) -> T = T(b)
var n: int = make(7)
print(low(321), n)
65 7
T(b) is a conversion written where the parameter’s name stands, resolved at each instantiation, so
the two directions of one conversion are one rule. An instantiation at a constrained subtype or a
simple enum takes that type’s own checked cast, since the scalar conversion has no meaning for
either and the form written under the type’s name does — so T(x) at an Age is the Age(x) a
reader would have written, trap included.
Construction is deliberately not among the forms it reaches. A struct’s positional constructor takes a field list rather than a value, and a generic body filling in an unknown struct’s fields by position is not something to arrive at by accident:
struct P
v: int
end P
make[T](b: u8) -> T = T(b)
var p: P = make(7)
print(p.v)
cannot convert byte to P
T(b) stays a conversion at every instantiation, so at a struct it is refused exactly as u8(x)
at one is — naming the struct, and not quietly becoming a constructor. What a container that wants to
build a T reaches for is a bound that says so.
The parameter wins over a declaration of the same name, which closes an inconsistency rather than
opening one: var y: T inside a [T] body has always meant the parameter, so T(x) one line later
means it too.
Monomorphization
Each distinct set of type arguments produces its own specialized function or aggregate. id
called at int and at real emits two functions; a Box[int] and a Box[string] are two distinct
layouts. The IR carries exactly one definition per instantiation, not one per call.
That is why bounds can be checked once at the definition yet lower to direct, monomorphic code with no dictionary passed at runtime.
- The cost is code size, the standard monomorphization tradeoff — C++ templates and Rust generics make the same one. It is the right default for a systems language, where the direct, inlinable call matters and the dynamic path is available when one copy is preferable.
- Recursion is fine. A recursive generic function recurses at a fixed instantiation, so it monomorphizes like any other.
Variance does not arise
There is no variance question in sysl, by construction. Variance is about when G[A] may stand in for
G[B], and that needs a subtyping relation to be interesting — which sysl does not have among concrete
types. There is no inheritance, and a trait bound is a constraint rather than a supertype relation
between values.
trait Animal
noise(self) -> string
struct Cat
n: string
end Cat
impl Animal for Cat
noise(self) -> string = "meow"
struct Box[T]
v: T
end Box
hear(b: Box[&Animal]) -> string = b.v.noise()
var c = Box(Cat("ada"))
print(hear(c))
'b' of 'hear' is Box[&Animal], but Box[Cat] was given
Box[Cat] and Box[&Animal] are simply unrelated types. That deletes an entire category of design
difficulty that afflicts languages with nominal subtyping, and it should stay deleted: polymorphism
over a set of types is expressed by a bound or a trait object, never by a covariant container.
A parameter may stand for a value
A type parameter stands for a type. A parameter written const stands for a value — which is
what lets one declaration cover every array length:
total[const N: usize](xs: [N]int) -> int
var t = 0
for i in 0..<N do t = t + xs[i]
t
var a: [3]int = [1, 2, 3]
var b: [5]int = [10, 20, 30, 40, 50]
print(total(a))
print(total(b))
6
150
N is inferred from the argument exactly as a type parameter is: matching [3]int against [N]int
binds N to 3, the way matching Box[int] against Box[T] binds T. Inside the body N is an
ordinary usize — it can be looped to, computed with, and passed on.
total at N = 3 and at N = 5 are two functions, the same way id at int and at real are
two: the value joins the type arguments the instantiation is keyed on, so the length is a constant
inside each copy.
It is the same const as everywhere else
A constant is declared const NAME: Type = expr. A value parameter is that declaration with the
initializer left for the caller, so [const N: usize] is existing grammar in a new position rather
than a new idea.
The marker is not decoration. [N: usize] on its own is indistinguishable from a bounded type
parameter — [T: Ord] has the same shape — and only name resolution could tell them apart, by asking
whether the thing after the colon is a trait or a type. A trait name misspelled into a type name
would then silently change what kind of parameter it is.
Which values
Integers, bool, char, and a simple enum’s variants. A value parameter puts a value into a
type’s identity, so the compiler has to decide when two of them are the same value and has to write
one into a mangled name — and each of these compares and mangles.
A type declares its value parameters the same way, and its arguments are written out rather than inferred, because a type has no call to infer them from:
struct Buf[const N: usize]
data: [N]byte
struct Flag[const B: bool]
v: int
var buf: Buf[4] = Buf([1, 2, 3, 4])
var on: Flag[true] = Flag(1)
print(buf.data.len, on.v)
4 1
Buf[2] and Buf[4] are two types with two layouts, and neither stands where the other is wanted.
A member reads the parameter as an ordinary value of its declared type, and the two lengths are two bodies rather than one compiled at whichever came first:
struct Buf[const N: usize]
used: usize
room(self) -> usize = N - self.used
end Buf
var a: Buf[8] = Buf(3)
var b: Buf[2] = Buf(1)
print(a.room())
print(b.room())
5
1
Floats are excluded: NaN != NaN under the ordinary comparison, which would make a type unequal to
itself. Strings are excluded until two spellings of one text are one value.
What may be written with N
N may stand as an array’s length, and a body may compute with it freely. What neither may do is
carry the result of a computation into a type:
f[const N: usize](xs: [N]int) -> [N + 1]int = xs
print(1)
this length does arithmetic on 'N'
Deciding that N + 1 and 1 + N are one type — and that 2 * N and N + N are — is type-level
arithmetic, which is a feature of its own.
Writing a type parameter where a length belongs is refused for the mirror reason, since a length is a value:
f[T](xs: [T]int) -> usize = 0
print(1)
'T' is a type parameter, and an array's length is a value rather than a type
What it is for: a fixed array renders
impl[const N: usize, T: Display] Display for [N]T is one block covering every array there is, which
is why print takes one:
var a: [3]int = [1, 2, 3]
var b: [2]string = ["x", "y"]
print(a)
print(b)
[1, 2, 3]
[x, y]
Before this, a length was part of a type’s shape: [2]T and [3]T were two shapes with no way to
be generic over the difference, so no library could implement a trait for arrays in general.
An array still has two shapes an impl may match — the length written out, and the length as a
parameter — and the written-out one is more specific, so it is found first. A block for [2]T still
wins over a block for [N]T on arrays of two, which is the same “written-out beats a parameter”
ordering override uses.
A member declares its own
A method’s parameter list is its own, exactly as a function’s is — so it may take a value parameter that the type’s parameters know nothing about. The type’s are fixed by the receiver; the member’s are solved at the call:
struct Sum
seen: usize
take[const N: usize](*self, xs: [N]int) -> usize
self.seen = self.seen + N
N
end Sum
var s = Sum(0)
print(s.take([1, 2, 3]))
print(s.take([4, 5]))
print(s.seen)
3
2
5
Both calls reach one written method and neither passes a length, because N is read off the argument
the same way it is for a free function.
A parameter may stand for a list of types
A type parameter stands for one type and a const parameter for one value. A parameter written ..
stands for a list of types — a pack — which is what lets one declaration cover every tuple:
joined[..A: Display](t: (..A)) -> string
var s = ""
for const i in 0..<A.len
if i > 0 then s = s + "-"
s = s + str(t.i)
s
print(joined((1, 2)))
print(joined((1, "two", true, 4.5)))
1-2
1-two-true-4.5
Three things are new there and each does one job.
..A declares the pack, and (..A) is the tuple of it — the only spelling a pack has, wherever
it is declared.
It matches a tuple of any arity, and the arity is inferred from the argument exactly as an array’s
length is: a (int, string, bool) matched against (..A) binds A to those three.
The bound distributes over the members. [..A: Display] says every type in A implements
Display, and that is the whole of the bound syntax a pack needs — the ordinary [T: Display] read
over a list. It is what makes the membership answerable before any body is compiled, so a tuple
holding something unprintable is refused where it is written:
struct Point
x: int
y: int
joined[..A: Display](t: (..A)) -> string = str(t.0)
print(joined((1, Point(2, 3))))
Display
for const is unrolled. Its range must be known when the program is compiled — A.len is how
many types the pack stands for — and the body is repeated once per value, with i folded in as a
compile-time usize. Each copy is type-checked on its own, which is the point: the parts of a
tuple have different types, so t.i is a different selection in each copy and one written line
covers all of them.
t.i at a compile-time i is the selection t.0 already is, with the position arriving as a
constant rather than as a literal. It reaches the parts of a tuple and nothing else — a struct’s
fields have names, and a number does not address one.
What a for const will not do
A range computed at run time cannot be unrolled, and the ordinary for is what walks one:
f(n: usize)
for const i in 0..<n
print(i)
f(3)
must be known at compile time
break and continue are refused as well. There is no loop at run time for either to act on: the
copies are straight-line code inside whatever the for const was written in, so a break would
leave that loop — one copy at a time, and silently. A loop written inside the body is an ordinary
loop and breaks out of itself as usual.
return does work, and it is what Eq and Ord on a tuple are written with — an unrolled copy is
straight-line code in the enclosing function, so a return in one is an ordinary return.
What it is for: the catalog covers every tuple
impl[..A: Display] Display for (..A) is one block covering every tuple there is, and Eq, Ord
and Hash are each one more. Before this, a tuple’s arity was part of its shape: a pair and a
triple were two shapes with no way to be generic over the difference, so the library wrote a row per
arity and stopped at three — and a tuple of four parts implemented nothing.
A tuple now has three shapes an impl may match, and they are found most specific first: the tuple
written out in full, then one arity, then every arity.
trait Tag
tag(self) -> string
impl[..A: Display] Tag for (..A)
tag(self) -> string = "any"
impl[A: Display, B: Display] Tag for (A, B)
tag(self) -> string = "pair"
override impl Tag for (int, int)
tag(self) -> string = "two ints"
print((1, 2).tag(), (1, "x").tag(), (1, 2, 3).tag())
two ints pair any
That is the same “written-out beats a parameter” ordering an array’s two shapes have, one rung
longer — and the override is the separate rule it has always been. Coherence says where a block
may be written; override says which of two blocks that both have a home answers. A block for a
tuple written out in full is the specific one, so it is the one that says so.
A member declares one too, unless a trait requires it
A pack stands in a method’s own parameter list on the same terms a value parameter does, and for the same reason — the list belongs to the member:
struct Row
n: usize
take[..A: Display](*self, t: (..A))
for const i in 0..<A.len
self.n = self.n + str(t.i).len
end Row
var r = Row(0)
r.take((1, "abc", true))
print(r.n)
8
A trait’s member is refused, and not for a reason about packs. No member a trait requires may declare parameters of its own of any kind: a table slot cannot hold a function that does not exist until a call names its types. A pack is one more way of writing that list, so it meets the rule already there.
trait Take
take[..A: Display](self, t: (..A)) -> usize
declares type parameters of its own, which a trait's member may not
A struct, an enum and a trait are refused a pack for a different reason — their parameters
are their shape, and there is nothing to spread a list over. A member has no shape of its own, so
that reason never reached one.
This is not variadic functions
A pack is a compile-time list of types. C’s ellipsis is a run-time walk over untyped storage
(ffi), and the two share nothing. There is also no pack expansion: a pack
cannot be spread into a call’s arguments, and (..A, int) cannot append to one. The unrolled loop
stands in for expansion, in the one direction the catalog needs.
What is deliberately not here
| absent | why, and what to write instead |
|---|---|
| explicit type arguments at a call | id[int](7) collides with indexing; annotate what receives the result. At an address they are written — &f[T] |
where clauses | the inline [T: A + B] list is the settled baseline; an out-of-line form is a possible ergonomic addition |
type-level arithmetic ([N + 1]T) | a value parameter may stand as a length but not be computed with in a type; deciding that N + 1 and 1 + N are one type is a feature of its own |
pack expansion (f(..a), (..A, int)) | a pack may be matched and walked, not spread into an argument list or appended to; for const is what stands in for it |
higher-kinded parameters (F[_]) | excluded, not deferred — it pushes inference toward undecidable, and abstraction over containers is served by traits and bounds |
The first of those has one position where the annotation costs more than a word. A nullary generic
has no argument to be inferred from, so buf() and map() are solved by what receives the result and
nothing else — and buf[u8]() is the first thing a reader tries. That form is refused by name,
naming the annotation that stands in for it, rather than by a general complaint about a callee that is
not a name.
Next: modules.