GuideThe Silo HandbookVariadic Functions and Reflection

Chapter 18 Variadic Functions and Reflection

Source

Two separate-but-related features that share this chapter because they lean on the same trick — the compiler statically knowing a count or a shape that most languages only figure out at runtime.

Variadic functions let a word take a statically-known number of arguments without hard-coding it. Reflection lets you write code that depends on a type's shape — field names, variant counts — with the inspection happening at compile time and no runtime representation needed. The first is about how many values; the second is about what values. Both are checked before your program runs.

Variadic input

A word's arity — how many values it consumes — is part of its name, written as a /N suffix. Declaring a variadic word uses a range where the N would normally be:

:fn sum/1.. ( ..AnyInt -> AnyInt )
  pop-> len
  len 1 - [ drop + ] times
:end

sum/1.. means "sum takes at least one argument." Callers specify the arity at the call site with a concrete count:

3 5 sum/2                    # ⌊8⌉
1 2 3 4 sum/4                # ⌊10⌉

The compiler checks the count is within the declared range. The ..AnyInt on the input side of the signature is the type-level way of saying "N values of type AnyInt", matched to the arity the caller asked for.

The range forms that appear in a declaration suffix:

Suffix Meaning
/.. zero or more
/1.. one or more
/2..10 between 2 and 10 (inclusive)

A single-point range /N..N is legal but an anti-pattern — a word that always takes a fixed number of values should just be an ordinary :fn with the inputs listed explicitly in the stack effect:

:fn triple ( AnyInt AnyInt AnyInt -> AnyInt )
  + +
:end

Reach for variadic syntax only when the arity genuinely varies at the call site.

Calling a variadic word

Call sites pick a concrete arity with a bare /N:

3 5 sum/2                      # ⌊8⌉      — 2 arguments
1 2 3 4 sum/4                  # ⌊10⌉    — 4 arguments

The compiler checks N falls within the range declared at definition time.

Variadic output

A word can also produce a statically-known number of outputs, by putting ..Type on the output side. The callee pushes the values, then pushes the count last:

:fn unpack/1.. ( (Vec a | n) -> ..a )
  ...                         # push elements, then push the count
:end

Callers use /N to specify how many outputs to expect:

my-vec unpack/3 pop-> c pop-> b pop-> a

This is the mechanism behind .collect. A quotation pushes some number of values; the compiler counts them statically; .collect reads the count and builds a Vec of exactly that length:

[ 1 2 3 ] .collect             # ⌊[1 2 3]⌉ : (Vec Int | 3)
[ "a" "b" ] .collect           # ⌊["a" "b"]⌉ : (Vec Str | 2)

Output variadics are what make .collect able to return a length-indexed Vec — the length isn't "whatever the runtime produces", it's a compile-time constant.

Variadic type parameters

The /N.. suffix works on record declarations too, producing types parameterised by a variable number of other types:

:record Tensor/1.. elem (..AnyUInt)
  .data Bytes
:end

(Tensor F64 3 4 5)             # 3D tensor, dims 3×4×5
(Tensor F64 28 28)             # 2D tensor, dims 28×28

Tensor/1.. takes one ordinary type parameter (elem) and one or more AnyUInts (the dimensions). Each concrete application fills in a specific rank.

This is how anything rank-polymorphic — matrices, tensors, heterogeneous tuples — gets expressed without baking in a fixed rank.

Compile-time reflection

:type-info(T) is a compile-time expression that produces a TypeInfo value describing the shape of T. The TypeInfo is erased at runtime — the compiler inlines the answer — so there is no runtime cost. You get full shape information with zero footprint.

:fn type-name ( a -> Str )
  drop :type-info(a) :match
    | RecordInfo info =>
      info .name
    | UnionInfo info =>
      info .name
    | IntInfo _ =>
      "Int"
    | _ =>
      "unknown"
  :end
:end

42 type-name                   # ⌊"Int"⌉
2.0 1.0 Point type-name        # ⌊"Point"⌉

TypeInfo is a union — RecordInfo, UnionInfo, EnumInfo, IntInfo, FloatInfo, FnInfo, and so on — each variant carrying a record that describes its shape. For a record, that includes the name, the fields (with names and types), and any derives on it.

You use reflection by pattern-matching on the :type-info result, same as any other union:

:fn field-count ( a -> Int )
  drop :type-info(a) :match
    | RecordInfo info =>
      info .fields .len
    | _ =>
      0
  :end
:end

:type-info on a value's type (the a above, consumed by drop) is the common case. :type-info(SomeConcreteType) reflects on a specific named type even when you don't have an instance.

Reflection-driven serialisation

Reflection is the substrate for things that would otherwise need a Serialize / Deserialize trait hierarchy. Silo's standard serialisation libraries (JSON, CBOR, Styx, etc.) walk :type-info(T) at compile time, produce a specialised serialiser for each concrete type, and monomorphise it down to the structural shape you'd have written by hand.

The practical consequence: there is no :derive Serialize on your record types. You just call the serialiser; it reflects on the shape; the compiler inlines the code. Add a field to a record and the serialiser picks it up automatically next compile, with no trait impls to update.

The same substrate drives the :derive lists you've already seen — [Eq Hash Debug] on a record declaration walks the record's :type-info at compile time and generates the field-by-field implementations. Everything derivable is derived by reflection.

When you actually use variadics

Most code doesn't declare variadic :fns. The common pattern is to call variadic words (vec/3, sum/4) without defining your own, and to use output variadics implicitly via .collect.

When you do define a variadic, it's usually because the alternative — a list of positional arguments wrapped in a record — would be noisier than the caller's /N annotation. Reach for variadics when the arity is a natural parameter at the call site.

Key points

  • :fn name/N.. declares a variadic word. Range forms cover /.. (zero+), /1.. (one+), /M..N (bounded), /N (exact).
  • ..Type on either side of the signature is a variadic slot; the caller's /N suffix fixes the count.
  • (named block placeholder leaked into renderer — pair_named_blocks not run)
    how .collect returns length-indexed Vecs — the count is compile-time.
  • Records can be variadic too (:record Tensor/1..), giving you rank-polymorphic types without a fixed rank.
  • :type-info(T) is a compile-time reflection primitive producing a TypeInfo union. Zero runtime cost.
  • Serialisation, :derive lists, and any structural-code-gen in the standard library are built on :type-info, not on a Serialize-like trait hierarchy.

Next: macros — how to extend the compiler with your own syntactic transformations.