Tuples¶
Tuples are a basic construct in Pyrope. Tuples are sequences of fields that can be named. Unnamed (positional) fields are ordered; named fields are not — they are accessed by name only, and their relative order carries no meaning (tools canonicalize named fields alphabetically, but that is a convention, not a requirement). Arrays/memories are a subcategory of tuples by requiring all the entries to have the same type. Internally, there is not a difference between tuples and arrays, but it is possible to check that all the fields are the same (hence array) by using brackets instead of parenthesis.
mut b = (const f1=3, const f2=4) // b is named (field order has no meaning)
mut c = (1, const d=4) // c mixes an unnamed (ordered) entry and a named one
mut d = (1,2,3,4) // array or tuple
cassert(d == [1,2,3,4]) // the [] also check that all the fields have same type
cassert(b.f1 == 3 and b.f2 == 4)
cassert(c[0] == 1 and c.d == 4)
assert (true,1) != [true,1] // error: true is not the same type as 1
Named fields are accessed by name with . or ['name']. Integer []
selection is only for unnamed positional entries; it never aliases a named
field.
mut a = (
,const r1 = (const b=1, const c=2)
,(3,4)
)
cassert(a.r1 == (1,2))
cassert(a[0] == (3,4)) // first unnamed entry
// different ways to access the same field
cassert(a.r1.c == 2 == a['r1'].c)
cassert(a[0][1] == 4)
const named = (const b = 1, const c = 2)
const bad = named[0] // error: named entries are name-access only
There is introspection to check for an existing field with the has operator.
Negate with not (...) (there is no dedicated !has operator).
mut a = (const foo = 3, 10)
cassert(a has 'foo')
cassert(not (a has 'bar'))
cassert(a has 0) // one unnamed entry, at position 0
cassert(not (a has 1))
Tuple named fields can have a default type and or contents:
mut val = 4
mut x = (
,const field1=1 // field1 with implicit type and 1 value
,const field2:string = nil // field2 with explicit type and invalid default value
,const field3:signed = 3 // field3 with explicit type and 3 value
,val // unnamed field with value `val` (4)
)
cassert(x.field1 == 1 and x.field3 == 3)
cassert(x[0] == 4) // `val` is the first (and only) unnamed entry
Selector expressions¶
A selector [...] takes a single expression. The expression can be an integer,
a string (named field), a range, or any expression that produces one of those
(including a conditional if ... {} else {}). Multi-entry tuple indices like
a[0,1] or a['x','y'] are not allowed: write one assignment per field
instead. This keeps the bit/field layout local and avoids ordering ambiguity.
type Person = (const name:string = "", mut age:u32 = 0)
mut a = (
,mut one:Person = (name="one", age=0)
,mut two:Person = (name="two", age=0)
)
a['one'].age = 10
a['two'].age = 20
cassert(a.one.age == 10 and a.two.age == 20)
const pick = if cond { 'one' } else { 'two' }
a[pick].age = 7 // conditional expression as index is fine
Tuple and scope¶
Since tuples can be named or unnamed, an entry like xx=(foo) creates a tuple xx
and copies the current scope variable foo contents as the first entry. In many cases
it is required to pass a sequence of strings or identifiers. A solution is to
name all the fields or quote as strings:
mut x=100
mut tup1 = ('x', const y=4)
mut tup2 = (x, const y=4)
cassert(tup1[0] == 'x')
cassert(tup2[0] == 100)
Some constructs like enumerates and attributes typically pass identifiers
without assigning a value. The problem is that the syntax becomes not so
"nice". To address these cases, Pyrope does not use a variable reference but a
"string" in the enumerate (enum(a,b=3)) and attribute set/read forms
(foo::[attr] at declaration, foo.[attr] to read).
const aa = 3
const a = enum(,aa, ,b=3)
cassert(a==b)
Everything is a tuple¶
In Pyrope everything is a Tuple, and it has some implications that this section tries to clarify.
A tuple starts with ( and finishes with ). In most languages, the
parentheses have two meanings, operation precedence and/or tuple/record.
In Pyrope, since a single element is a tuple too, the parenthesis always means
a tuple.
A code like (1+(2),4) can be read as "Create a tuple of two entries. The
first entry is the result of the addition of 1 (which is a tuple of 1) and a
tuple that has 2 as a unique entry. The second entry in the tuple is 4".
The tuple entries are separated by comma (,). Extra commas do not add meaning.
mut a = (1,2) // tuple of 2 entries, 1 and 2
mut b = (1) // tuple of 1 entry, 1
mut c = 1 // tuple of 1 entry, 1
mut d = (,,1,,) // tuple of 1 entry, 1
cassert(a[0] == b[0] == c[0] == d[0])
cassert(a!=b)
cassert(b == c == d)
A tuple with a single entry element is called a scalar.
Tuples are used in many places:
- The arguments for a function call are a tuple. E.g:
fcall(a=1,b=2) - The return of a function call is always a tuple. E.g:
foo = fcall() - The index for a selector
[...]is a single expression (integer, string, range, or conditional). Integer indices select unnamed positional entries only; named fields use strings or dot syntax. Multi-entry tuple indices are not allowed. - Complex type declarations are tuples. E.g:
type Xtype = (const f=1, const b:string = "")
Dotted Field Expansion¶
Tuple literals may spell nested named fields in expanded dotted form. This is
the same flattening idea used by function-call argument expansion:
const a.b=1 and const a.c=2 construct the nested field
a=(const b=1,const c=2).
const compact = (const a=(const b=1, const c=2), 7, 3, const d=3)
const expanded = (const a.b=1, const a.c=2, const d=3, 7, 3)
cassert(compact == expanded)
The expanded form is legal but usually less readable than the compact tuple form. It is useful when matching flattened hardware interfaces, because the generated LGraph/Verilog port structure follows the expanded field paths. Unnamed entries keep their normal positional order among the other entries.
Tuple mutability¶
The tuple entries can be mutable/immutable and named/unnamed. Tuple entries
follow the variable mutability rules. A named tuple field is a declaration, so
it must use a kind keyword: (mut a=3) or (const a=3). Bare (a=3) is valid
as a function-call argument and as the value for an explicitly typed
construction, but not as a standalone tuple field declaration.
The enclosing binding wins. A field's effective mutability is the
intersection of the outer binding's mutability and the field's own
declaration. In particular, an outer const makes every field immutable
regardless of inner mut markers — the binding to the whole tuple is
immutable, so no field can be reassigned through it. Inner const on a
field of an outer mut tuple still pins that field as read-only.
| Outer | Inner field | Effective |
|---|---|---|
mut |
mut |
writable |
mut |
const |
read-only |
const |
mut |
read-only (outer wins) |
const |
const |
read-only |
mut c = (mut x=1, const b=2, mut d=3)
c.x = 3 // OK (mut tuple, default-mut field)
c.b = 10 // error: 'c.b' is immutable (inner const)
c.d = 30 // OK (mut tuple, mut field)
const d = (mut x=1, const y=2, mut z=3)
d.x = 2 // error: 'd' is immutable — inner `mut` is overridden
d.z = 4 // error: 'd' is immutable — outer `const` wins over inner `mut z`
mut e:d = nil
assert(e.x==1 and e.y==2 and e.z==3)
e.x = 30 // OK
e.y = 30 // error: 'e.y' is immutable (inner const)
e.z = 30 // OK (outer mut + inner mut)
Unnamed tuple entries are ordered (positional). A field
without a name is positional; it can still carry a kind keyword
(const / mut) as a prefix on the value to override the default
mutability inherited from the enclosing tuple.
mut b = 100
mut a = (b, b, mut b:u8 = nil, const c=4) // a[0] and a[1] are unnamed; b and c are name-access only
a.b = 200
assert(a == (100, 100, const b=200, const c=4))
mut f = (mut b=3, const e=5)
f.b = 4 // OK
f.e = 10 // error: `f.e` is immutable
const x = (1,2)
x[0] = 3 // error: 'x' is immutable
mut y = (1, const 3) // 2nd field is positional and immutable
y[0] = 100 // OK
y[1] = 101 // error: `y[1]` is immutable
While the tuple entries can be either mutable or immutable, the field
name/types are immutable. New tuples are built with the ... splice operator,
which inserts a tuple's fields into the surrounding tuple literal:
mut a=(mut a=1, mut b=2)
const b=(const c=3)
const ccat1 = (...a, ...b)
assert(ccat1 == (const a=1, const b=2, const c=3))
assert(ccat1 == (1,2,3))
mut ccat2 = (...a, const d=20, ...b)
assert(ccat2 == (const a=1, const b=2, const d=20, const c=3))
assert(ccat2 == (1,2,20,3))
mut join2 = (...a, ...(const b=20)) // error: 'b' already exists
A ... splice concatenates by field name: if field names do not match, or
entries have no name, a new entry is created. If the same field exists in both
tuples and both values are tuples, the splice recursively merges their
subfields. If the same final field exists on both sides, the merge is allowed
only when one side is nil or constant propagation proves both sides have the
same value; otherwise it is a compile error instead of accumulating the two
values.
assert((...(1,const cfg=(lo=2),const c=3), ...(const cfg=(hi=20),33,const d=30,4)) == (1,const cfg=(lo=2,hi=20),const c=3,33,const d=30,4))
assert((...(const a=2,const b=nil), ...(const a=2,const b=10)) == (const a=2,const b=10))
const bad = (...(const a=2), ...(const a=20)) // error: 'a' already exists
Because ... splices fields at its position, it can also insert in the middle
of a literal and add arguments to a function call:
comb foo(a, b, c) -> (r) { r = a + b + c }
const rest = (const b=2, mut c=3)
cassert(foo(a=1, ...rest) == 6) // same as foo(a=1, b=2, c=3)
Field access¶
Since everything is a tuple, any variable can do variable[0][0][0] because it
literaly means, return the tuple first entry for four times.
Another useful shortcut is when a tuple has a single field or entry, the tuple contents can be accessed without requiring the individual position or field entry name. This is quite useful for function return tuples with a single entry.
const x = (const first=(const second=3))
cassert(x.first.second == 3)
cassert(x.first == 3)
cassert(x == 3)
cassert(x[0].second == 3)
cassert(x.first[0] == 3)
cassert(x[0] == 3)
Tuples can also use structural binding to unpack a tuple multiple fields into separate variables.
const x = (const f1=(const f1a=1, const f1b=3), const f2=4)
const (y,z) = x
cassert(y == (1,3) and z == 4)
cassert(y.f1a == 1 and y.f1b == 3)
cassert(y == (const f1a=1, const f1b=3))
Tuples vs arrays¶
Tuples are ordered, as such, it is possible to use them as arrays. Tuples and arrays share most behavior/operations, the key difference is that arrays are unnamed with the same type for all the entries.
mut bund1 = (0,1,2,3,4) // ordered and can be used as an array
mut array1 = [0,1,2,3,4] // [] force array, so all the entries have same type
mut bund2 = (bund1,bund1,((10,20),30))
cassert(bund2[0][1] == 1)
cassert(bund2[1][1] == 1)
cassert(bund2[2][0] == (10,20))
cassert(bund2[2][0][1] == 20)
cassert(bund2[2][1] == 30)
Pyrope tries to be compatible with synthesizable Verilog. In Verilog, when an
out of bounds, access is performed in a packed array (unpacked arrays are not
synthesizable), or an index has unknown bits (?), a runtime warning can be
generated and the result is an unknown (0sb?). Notice that this is a
pessimistic assumption because maybe all the entries have the same value when
the index has unknowns.
The Pyrope compile will trigger compile errors for out-of-bound access. It is not possible to create an array index that may perform an out of bounds access.
mut array = (0,1,2) // size 3, not 4
const tmp = array[3] // error: out of bounds access
mut index = 2
if runtime {
index = 4
}
// Index can be 2 or 4
mut res1 = array[index] // error: out of bounds access
mut res2 = 0sb? // Possible code to be compatible with Verilog
if index<3 {
res = array[index] // OK
}
Pyrope compiler will allow an index of an array/tuple with unknowns. If the
index has unknown bits (0sb? or 0ub1?0) but the compiler can not know, the
result will have unknowns (see internals for more details).
Notice that the only way to have unknowns is that somewhere else a variable or
a memory was explicitly initialized with unknowns. The default initialization
in Pyrope is 0, not unknown like Verilog.
Concatenate fields¶
Each tuple field must be unique. Inside a tuple literal a field may only be
introduced once, and only with a plain =. Repeating a field name is an
error, and so is a compound assignment (+=, -=, ...) inside the literal —
there is no prior value to update while the tuple is still being built.
mut x = (
,ff = 1
,ff = 2 // error: 'ff' already declared in the tuple
)
mut y = (
,ff = 1
,ff += 2 // error: compound assignment is not allowed inside a tuple literal
,zz += 3 // error: compound assignment is not allowed inside a tuple literal
)
To extend a tuple — adding fields or growing an overload set — splice the
original into a new tuple with ...:
const y = (const ff=1, const zz=3)
const y2 = (...y, const qq=2) // y2 == (ff=1, zz=3, qq=2)
const ops = [add1, add2]
const ops2 = (...ops, add3) // extend the overload set into a new binding
A mut variable that is already a tuple may also self-assign — the base
type does not change, only the arity grows. This is the array-comprehension
idiom:
mut acc:[] = nil
for i in 0..<3 { acc = (...acc, i) }
cassert(acc == (0,1,2))
Self-splicing a non-tuple value is the one case that fails: mut s = 5; s =
(...s, 1) would redefine s's base type from a scalar to a tuple, a compile
error. Use a fresh binding (mut xx = (...s, 1)) when the source is not
already a tuple.
Optional tuple parenthesis¶
Parenthesis marks the beginning and the end of a tuple. Tuple parentheses can be omitted in only one place:
- A single element lambda return value.
Everywhere else — function calls, selectors [...], for ... in, match
case patterns — tuples are always parenthesized. Bare-tuple sugar inside
in, […], and call arguments has been removed.
for a in (1,2,3) {
x = a
}
y = match z {
in (1,2) { 4 }
else { 5 }
}
y2 = match mut one=1 ; (one, ...z) { // same as: y2 = match (1,z) {
== (1,2) { 4 }
else { 0 }
}
comb addb(a, b:u32) -> (a:u32) {
a = a + b
}
A named tuple parenthesis can be omitted on the left-hand side of an assignment. This is to mutate or declare multiple variables at once. It is not allowed to avoid the parenthesis at the right-hand-side of the statement. The reason is that it is a bit confusing.
mut a,b = (2,3) // error: left-hand-side must be a tuple (a,b)
mut (a,b) = 2,3 // error: right-hand-side must be a tuple (2,3)
mut (a,b) = (2,3)
cassert(a==2 and b==3)
mut (c,d) = 1..=2 // error: range is a single entry assignment
mut c = 1..=2 // OK
mut (c,d) = 1 // error: 2 entry tuple in lhs, same in rhs
mut (c,d) = (1,2) // OK
cassert(c == 1 and d == 2)
Named-tuple destructuring¶
When the RHS is a named tuple (e.g. a lambda return whose outputs are declared by name, or any tuple literal with named fields), destructuring on the LHS matches by name, not by position. This mirrors the call-site rule for named arguments and prevents the silent-rebind footgun where two outputs are swapped in the declaration.
- A bare LHS name like
bmatches the RHS field whose name is alsob. If no such field exists, it is a compile error. - An explicit
local = source.pathslot binds the selected RHS field path to locallocal. Use this when you want a different local name from the field name, when selecting a nested field, or when a RHS tuple contains multiple lambda-call results and the lambda name disambiguates the source. - LHS order is irrelevant under named binding:
(b, c) = rand(c, b) = rare the same.
comb dox(a) -> (b, c) { b = a + 1; c = a + 2 }
comb deep(a) -> (payload, code) { payload = (const inner = (const value = a + 1)); code = a + 10 }
(b, c) = dox(a=3) // local `b` ← dox.b, local `c` ← dox.c
(c, b) = dox(a=3) // same: order doesn't matter
(x=dox.b, y=dox.c) = dox(a=3) // rename: dox.b → x, dox.c → y
(v=deep.payload.inner.value) = deep(a=3) // nested field selection
(y, x) = dox(a=3) // error: `y` is not a field of dox's return
When the RHS is an unnamed tuple (no field labels — e.g. a literal
(2, 3) or 1..=2), there are no names to match against, so destructuring
falls back to positional binding by tuple index:
mut (a, b) = (2, 3) // a=2, b=3 (positional — RHS has no names)
mut (b, a) = (2, 3) // a=3, b=2 (still positional)
One thing to remember is that the = separates the statement in two parts
(left and right), this is not the case with type or attributes that always
apply to the immediatly declared variable or item.
const c = 4
cassert(c does u3) // type check on 'c' is a separate statement
const (x, b) = (true, c) // assign x=true, b=4
cassert(x == true)
cassert(b == 4)
Enumerate (enum)¶
Enumerates, or enums for short, use the familiar tuple structure, but there is a significant difference in initialization. Enums require named tuples, but in most cases the named tupled should not have a set value. Enums automatically assigns values, tuples need explicit value initialization.
const b = "foo"
const c = 1
const test1 = enum(a=c,b) // OK
const something = (b) // OK
cassert(something == "foo")
cassert(test1.a != test1.b)
cassert(test1.a==1 and test1.b==2)
The enum keyword does not reference scope variables unless the reference is
on the right-hand-side.
If an external variable wants to be used as a field, there has to be an explicit expression with a string type or a named tuple.
const a = "field"
const c = (const foo=4)
const my_other_enum = enum(...a,b=3,...c)
cassert(my_other_enum.field != my_other_enum.b)
cassert(my_other_enum.b == 3)
cassert(my_other_enum.foo == 4)
cassert(my_other_enum.foo != my_other_enum.b)
The enum default values are NOT like typical non-hardware languages. The enum auto-created values use a one-hot encoding. The first entry has the first bit set, the 2nd the 2nd bit set. If any entry is given an explicit value (or the enum has an integer type), the whole enumerate switches to a traditional sequential numbering.
Warning
Enum values should always be compared against named enum entries, never
against raw integer literals. The underlying numeric encoding (one-hot by
default) is an implementation detail. Use state == MyEnum.idle, not
state == 0 or state == 1. To inspect the raw bit representation, use
state#[..].
enum V3 = (
,a
,b
,c
)
cassert(V3.a == 1)
cassert(V3.b == 2)
cassert(V3.c == 4)
// Always compare against enum entries, not raw values:
mut state:V3 = V3.a
cassert(state == V3.a) // correct
// cassert(state == 1) // discouraged: relies on encoding details
enum V4 = (
,a
,b=5
,c
)
cassert(V4.a == 0)
cassert(V4.b == 5)
cassert(V4.c == 6)
Hierarchical enumerates¶
Enum can accept hierarchical tuples. Each enum level follows the same algorithm. Each entry tries to find a new bit. In the case of the hierarchy, the lower hierarchy level bits are kept.
enum Animal = (
,bird =(,eagle, ,parrot)
,mammal=(,rat , ,human )
)
cassert(Animal.bird.eagle != Animal.mammal)
cassert(Animal.bird != Animal.mammal.human)
cassert(Animal.bird == Animal.bird.parrot)
cassert(signed(Animal.bird ) == 0ub000001)
cassert(signed(Animal.bird.eagle ) == 0ub000011)
cassert(signed(Animal.bird.parrot ) == 0ub000101)
cassert(signed(Animal.mammal ) == 0ub001000)
cassert(signed(Animal.mammal.rat ) == 0ub011000)
cassert(signed(Animal.mammal.human) == 0ub101000)
In general, for each leaf enum, the number of bits is equivalent to the number of entries in the leaf tuple.
It is possible to use a sequence that is more consistent with traditional
programming languages, but this only works with non-hierarchical enumerates
when an integer type (:signed, :u32, :s4 ...) is used.
enum V5 = (
,a
,b=5
,c
)
cassert(signed(V5.a) == 0)
cassert(signed(V5.b) == 5)
cassert(signed(V5.c) == 6)
The same syntax is used for enums to different objects. The hierarchy is not allowed when an ordered numbering is requested.
Enumerates of the same type can perform bitwise binary operations
(and/or/xor) and the set operator in (negate with not (...)).
const human_rat = Animal.mammal.rat | Animal.mammal.human // union op
assert(Animal.mammal in human_rat)
assert(Animal.mammal.rat in human_rat)
assert(not (Animal.bird in human_rat))
Enumerate typecast¶
To convert a string back and forth to an enumerate, explicit typecast is needed but possible.
enum E3 = (
,l1=(
,l1a
,l1b
)
,l2
)
cassert(string(E3.l1.l1a) == "E3.l1.l1a")
cassert(string(E3.l1) == "E3.l1")
cassert(E3("l1.l1b") == E3.l1.l1b)
Payload enumerates (tagged unions)¶
Enum cases can carry a typed payload, which makes the enum a tagged union. Only the active case can be read; reading any other case is a compile error.
type Vtype = enum(str:string, num:signed, b:bool)
mut vv:Vtype = (num=0x65)
cassert(vv.num == 0x65)
const xx = vv.str // error: active case is `num`
const x1a:Vtype = "hello" // implicit case selection by payload type
const x1b:Vtype = (str="hello") // explicit case
cassert(x1a.str == "hello" and x1a == "hello")
const err = x1a.num // error: active case is `str`
Payloads can be tuples, including references to the enum itself, which allows
algebraic data types. A match with does arms selects on the active case:
enum Expr = (
,number:signed = nil
,add:(Expr, Expr) = nil
)
comb eval(e:Expr) -> (r:signed) {
r = match e {
does Expr.number { e.number }
does Expr.add { eval(e.add[0]) + eval(e.add[1]) }
else { 0 }
}
}
const expr = Expr.add(Expr.number(2), Expr.number(3))
cassert(eval(expr) == 5)