GuideThe Silo HandbookCollections

Chapter 11 Collections

Source

Silo's standard collections come in two flavours: containers — concrete data structures like Vec, HashMap, HashSet — and the traits that describe how to traverse and transform them (Foldable, Mappable, Filterable, Iterator). The traits are where most of the interesting code lives: the same .map, .filter, .fold methods work on every container that implements them.

This chapter covers the main containers, the traversal traits, and the typical pipelines you'll write with them.

Vec — length in the type

Vec is the workhorse sequential container. Its length can appear in the type, which lets the compiler track size invariants through chained operations.

There are three common ways to construct one:

1 2 3 vec/3                    # variadic call — explicit count
1 2 3 vec/3 .collect           # same, via a quotation of pushes
(Vec .default)                 # an empty (Vec a | 0)

Any quotation that pushes a statically-known number of values can be fed through .collect:

[ 1 2 3 4 5 ] .collect         # (Vec Int | 5)
[ "a" "b" ] .collect           # (Vec Str | 2)

The length shows up in the type, and the compiler tracks it through operations that preserve or combine lengths:

1 2 vec/2 3 4 vec/2 .append    # (Vec Int | 4)

.first and .last retrieve the endpoints, always returning (Option elem):

1 2 3 vec/3 .first             # ⌊Some(1)⌉
(Vec .default) .first          # ⌊None⌉

Each has an unwrapping ! sibling whose signature includes +Panic — it panics on empty input. The interesting bit is that the +Panic gets elided when the compiler can prove the input is non-empty from its type alone:

1 2 3 vec/3 .first!            # ⌊1⌉ — type encodes length 3, +Panic elided
some-vec .first!               # needs +Panic; caller must discharge it

On a (Vec Int | 3) the panic branch is unreachable, so the compiler drops +Panic from the call's effect row. On an existential-length input like (Vec Int), emptiness is a runtime question and the caller inherits +Panic as normal.

Existential-length Vec

When a Vec's length comes from a runtime source — user input, an I/O result, another Vec of unknown size — the type (Vec elem) marks the length as existential: present at runtime, erased at the type level. Most library functions accept (Vec elem) so they compose with both length-indexed and existential-length Vecs.

HashMap and HashSet

Hash containers follow the standard conventions. Both are mutable (via .insert etc.) and both derive their capabilities from the element or key type implementing Eq and Hash.

(HashMap .default) pop-> m
"Alice" "name" m .insert pop-> m
30 "age" m .insert pop-> m

"name"  m .lookup              # ⌊Some("Alice")⌉
"email" m .lookup              # ⌊None⌉

.lookup returns (Option val). .insert returns a pair of (updated map, displaced value if any), so you typically re-bind the map after each mutation.

Sets follow the same shape:

(HashSet .default) pop-> s
1 s .insert pop-> s
2 s .insert pop-> s
1 s .insert pop-> s            # still { 1 2 } — duplicates ignored

For ordered variants (iteration order matters, range queries work, but hashing isn't required), reach for BTreeMap / BTreeSet. Same API, different complexity trade-offs.

Ranges

Ranges are first-class, not syntactic sugar. They're ordinary values implementing Foldable:

0..10 [ "{}" format print ] .for-each
0..100 0 [ + ] .fold           # ⌊4950⌉

Half-open (a..b), from-only (a..), to-only (..b), and unbounded (..) all exist. An unbounded range isn't foldable — it has no end — but it's still useful in other contexts (slicing, pattern matching).

Traversal traits

The heavy lifting happens in four traits:

Trait Core methods Semantics
Foldable .fold, .for-each, .any, .all, .count Eager whole-collection
Mappable .map, .flat-map Shape-preserving xform
Filterable .filter Keep some, drop others
Iterator .next Lazy, element-at-a-time

Vec, HashMap, HashSet, BTreeMap, BTreeSet, Range, and Str all implement Foldable / Mappable / Filterable. Iterator is implemented by types that need lazy traversal — directory iterators, character iterators over strings, lookahead parsers — and is a sibling to Foldable rather than a replacement: you reach for Iterator when short-circuiting or statefulness matters, otherwise the Foldable surface is cleaner and more efficient.

See the traits chapter for how traits compose; see :impl Iterator in the standard library for the core pattern, and the pattern matching chapter for how (Option elem) returns interact with :let/:if-let.

Functional pipelines

The typical shape is: source -> transforms -> terminator.

1 2 3 4 5 6 7 8 9 10 vec/10
  [ 2 * ] .map                 # ⌊[2 4 6 8 10 12 14 16 18 20]⌉
  [ 10 ">" ] .filter             # ⌊[12 14 16 18 20]⌉
  0 [ + ] .fold                # ⌊80⌉

.map preserves length and shape; .filter preserves shape but shortens; .fold terminates the pipeline into a scalar. The same pattern works with any Foldable / Mappable / Filterable source — replace vec/10 with a range, a HashSet, or a string-as-codepoint-sequence and the middle doesn't change.

Effects flow through naturally. A .map body with +Console just makes the whole pipeline +Console:

:fn chatty-double ( (Vec Int) -> (Vec Int) ) +Console
  [ peek-> x x "doubling {}" format print x 2 * ] .map
:end

The .map method's signature is polymorphic over the quotation's effect row (see quotations), so it composes cleanly.

Building collections

.collect is the universal "materialise this pipeline into a collection" terminator. It works because output variadics let the quotation signal its element count to the runtime:

[ 1 2 3 ] .collect                      # (Vec Int | 3)
0..100 [ 2 * ] .collect                 # (Vec Int | 100)
0..10 [ peek-> n n 2 * ] .collect       # (Vec (Pair Int Int) 10)

The target type (Vec, HashSet, HashMap, ...) is inferred from context. When context doesn't determine the target, name it explicitly with qualified dispatch:

[ 1 2 3 ] (HashSet .collect)            # (HashSet Int)

Unwrapping ! siblings

Several endpoint accessors come in two shapes: the Option-returning safe form and an !-suffixed unwrapping sibling.

Safe form Returns Unwrapping sibling Signature returns
.first (Option elem) .first! elem, with +Panic
.last (Option elem) .last! elem, with +Panic
.min (Option elem) .min! elem, with +Panic
.max (Option elem) .max! elem, with +Panic
.reduce (Option elem) .reduce! elem, with +Panic

The ! variants declare +Panic because they panic on empty input. They don't require a non-empty type — they work on any Vec — but when the call site's type proves the input is non-empty, the compiler elides +Panic from the caller's effect row. That's how you get the "unwrap freely when it's safe, see the panic effect when it isn't" behaviour without two separate method names or manual proof.

Use the safe form when you want to branch on emptiness explicitly; reach for the ! sibling when the type already makes emptiness impossible — you'll get the bare value and no effect-row cost.

Key points

  • Vec tracks length in its type when it can; existential length is (Vec elem). Most library functions accept the existential form.
  • .collect is the idiomatic way to build a Vec (or any target container) from a quotation of pushes. Output variadics let the compiler know the element count.
  • HashMap and HashSet use .insert / .lookup / .contains. Ordered siblings are BTreeMap / BTreeSet.
  • Ranges are ordinary values implementing Foldable. Bounded ranges are foldable; unbounded ranges aren't.
  • Four traversal traits: Foldable, Mappable, Filterable, Iterator. The first three are the eager surface; Iterator is lazy, element-at-a-time.
  • .first / .last / .min / .max / .reduce always return (Option elem). Their ! siblings return the bare element with +Panic declared; the +Panic is elided when the input's type proves non-emptiness.

Next: strings and formatting — the string-specific API on top of the generic collection protocols.