Records without names
There are two kinds of record type in Praxis. A struct declaration makes a
nominal one: Point is Point because it is that declaration, and a second
declaration with identical fields is a different type. The other kind is
anonymous: { x: Int, y: Int } is that field set and nothing else, and any
two of them with the same fields are the same type.
Two things produce an anonymous record, and they produce the same one. You write
a literal with no name in front of it — { x: 1, y: 2 }, whose type is the
fields it just listed. Or a named capture in a parser expression derives one
with no literal at all:
var rows = read lines(`{x:int},{y:int}`)
for r in rows {
out(r.x * r.y)
}
out(rows)
Given
1,2
3,4
5,6
it prints
2
12
30
[{ x: 1, y: 2 }, { x: 3, y: 4 }, { x: 5, y: 6 }]
rows is a Vec[{ x: Int, y: Int }], and it is that type before the program
runs. How a parser expression arrives at it is
type derivation.
Writing one
A record literal with no name in front of it builds an anonymous record. Nothing is declared first, because there is nothing to declare: the fields are the type.
var p = { x: 1, y: 2 }
out(p.x + p.y)
p.x = 9
out(p)
var name = "origin"
var tagged = { name, pos: p }
out(tagged)
out(tagged.pos.y)
3
{ x: 9, y: 2 }
{ name: origin, pos: { x: 9, y: 2 } }
2
Fields are read and assigned like any other record’s, they nest, and { name }
puns — it takes the field from the binding of that name, exactly as the headed
form does. The parser and the literal build the same type, so a helper written
for rows a template produced takes one you wrote by hand:
fn area(r) { r.w * r.h }
var rooms = read lines(`{w:int}x{h:int}`)
var default_room = { w: 2, h: 5 }
for room in rooms {
out(area(room))
}
out(area(default_room))
Given
3x4
10x10
it prints
12
100
10
The one brace that stays a block
A { where an expression must begin could open either a block or a record, and
the tie is broken by what a block cannot be: a name followed by a :, or a
name followed by a ,. So { x: 1 }, { x: 1, y: 2 } and { x, y } are
records, and everything else at that position is the block it always was.
{ x } is the one case the rule cannot have both ways. It is a well-formed
block whose value is x and a well-formed one-field punned record, and blocks
had the spelling first:
var x = 7
// A block, whose value is its last statement.
var from_block = { x }
out(from_block)
// A record with one field, which needs the field written out.
var from_record = { x: x }
out(from_record)
7
{ x: 7 }
The other one is { x:bp }, which is a block holding the statement x with a
:bp marker on it. { x: bp } with a space is the
record whose field is the binding bp — the same adjacency that separates
min= from min =.
A keyword head does not suppress the literal, and does not need to. What the
record-literal suppression protects is p { … }, a
name followed by the brace that could be the if’s own block; a brace where
an operand is still required cannot be that block, because the block comes after
a complete condition. So if { hit: true }.hit { … } reads both braces the way
you would expect.
Same fields, same type
Two anonymous records are the same type when their field-name sets match and their field types unify. Identity is established by unification rather than by a lookup at construction, which is what makes the field types get checked rather than assumed.
The practical effect is that a helper written for one parser’s rows works for another’s, with no declaration in between:
fn area(r) {
r.w * r.h
}
var rooms = read lines(`{w:int}x{h:int}`)
var default_room = parse("2x5", `{w:int}x{h:int}`)
for room in rooms {
out(area(room))
}
out(area(default_room))
Given
3x4
10x10
it prints
12
100
10
rooms’s element type and default_room’s type were synthesized by two
separate walks over two separate parser expressions, and they are one type.
Disagree about a field name and they are not:
fn area(r) {
r.w * r.h
}
var rooms = parse("3x4", `{w:int}x{h:int}`)
var boxes = parse("3x4", `{w:int}x{d:int}`)
out(area(rooms))
out(area(boxes))
$ praxis check different-fields.px --color never
error[Y001]: expected ({ w: Int, h: Int }) -> Int, found ({ w: Int, d: Int }) -> ?T
different-fields.px:9:5
9 | out(area(boxes))
| ^^^^^^^^^^^ expected ({ w: Int, h: Int }) -> Int, found ({ w: Int, d: Int }) -> ?T
praxis: 1 error(s)
The expected half is area’s inferred signature — the first call pinned its
parameter, because a field read pins its receiver the same way a method call
pins its own (see Generalization).
An anonymous record is an ordinary record
It has fields you read and assign, it matches a record pattern, it compares
structurally, and it can be a Map key or a Set element. The only thing it
lacks is a name.
var p = read `{x:int},{y:int}`
match p {
{ x, y } => out(x + y)
}
p.x = 9
out(p)
var seen = Set()
seen.insert(p)
seen.insert(read `{x:int},{y:int}`)
out(seen.len())
Given
1,2
it prints
3
{ x: 9, y: 2 }
2
Note the last read: every read parses the whole input from its start, so the
second one produces a fresh { x: 1, y: 2 } — a different value of the same
type, which is why the set holds two.
Field order does not decide the type, and the type decides field order
{ w: Int, h: Int } and { h: Int, w: Int } are one type. Unification matches
fields by name, not by position.
That has a second half worth stating outright, because it is the one people do not expect: one type has one field order, and it is the order the shape was first written in anywhere in the program. Every value of it is laid out and printed that way, whichever spelling built it.
fn width_of(r) { r.w }
var a = parse("3x4", `{w:int}x{h:int}`)
var b = parse("4x3", `{h:int}x{w:int}`)
out(a)
out(b)
out(width_of(a))
out(width_of(b))
{ w: 3, h: 4 }
{ w: 3, h: 4 }
3
3
b’s template writes h first and b prints w first, because a got there
first and fixed the shape’s order. The values are still what the input said:
b read 4 as its h and 3 as its w, so width_of(b) is 3.
The order has to come from something that has seen every spelling, and within
one compile that is the type arena, which registered a definition for each. It
cannot be a property of the template or literal doing the building, because the
whole point of the rule above is that those all produce one type — and a field
read compiles to a slot index against that one type. A value laid out in its own
producer’s order would put w in h’s slot for whichever spelling was not
canonical, and the read would answer the wrong field with nothing to report.
All this costs is that display order depends on where the first spelling appears in the file. A program with a single spelling of each shape — which is nearly all of them — cannot tell.
Nominal identity is a definition applied to arguments
A struct or enum type is a definition plus its type arguments, and its
identity is the definition — not its name and not its shape. Two declarations
with identical fields are two definitions and therefore two types:
struct Point { x: Int, y: Int }
struct Velocity { x: Int, y: Int }
fn magnitude(p) {
abs(p.x) + abs(p.y)
}
out(magnitude(Point { x: 1, y: 2 }))
out(magnitude(Velocity { x: 3, y: 4 }))
$ praxis check two-structs.px --color never
error[Y001]: expected (Point) -> Int, found (Velocity) -> ?T
two-structs.px:9:5
9 | out(magnitude(Velocity { x: 3, y: 4 }))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected (Point) -> Int, found (Velocity) -> ?T
praxis: 1 error(s)
The same rule separates a declared record from a derived one, even when the fields line up exactly:
struct Room { w: Int, h: Int }
fn area(r) {
r.w * r.h
}
var parsed = parse("3x4", `{w:int}x{h:int}`)
out(area(parsed))
out(area(Room { w: 2, h: 5 }))
$ praxis check nominal-is-not-structural.px --color never
error[Y001]: expected ({ w: Int, h: Int }) -> Int, found (Room) -> ?T
nominal-is-not-structural.px:10:5
10 | out(area(Room { w: 2, h: 5 }))
| ^^^^^^^^^^^^^^^^^^^^^^^^^ expected ({ w: Int, h: Int }) -> Int, found (Room) -> ?T
praxis: 1 error(s)
There is no conversion and no coercion between the two. A parser derives an
anonymous record; a struct literal builds a nominal one; nothing turns one
into the other except writing the fields out.
Option is the one generic definition in the language: Option[Int] and
Option[Text] are one definition at two arguments, which is why they print
their argument and why the monomorphizer can tell them apart. There is no
struct P[T] syntax — a user definition has no parameters, so substitution is
free and identity is just the definition.
When to declare a struct instead
Use an anonymous record — derived or written — when the shape appears once and
is used near where it was made. That is most of a puzzle solution, and declaring
a struct to hold what lines(`{x:int},{y:int}`) already produces buys
nothing.
Declare a struct when you want one of these two things:
A name in every diagnostic. expected (Point) -> Int reads better than
expected ({ x: Int, y: Int }) -> Int, and the difference grows with the field
count.
Two shapes kept apart. This is the real one, and it is the only thing a
literal cannot do for you. Two anonymous records with the same fields are the
same type, so nothing stops a { x: Int, y: Int } meant as a position being
passed where one meant as a velocity is expected. Two structs make that a
compile error, as above.
Carrying a field the parser did not produce is not a third reason: an anonymous record literal holds whatever fields you write into it.
Converting to a declared one is a loop and a literal:
struct Segment { x1: Int, y1: Int, x2: Int, y2: Int }
var rows = read lines(`{x1:int},{y1:int} -> {x2:int},{y2:int}`)
var segments = Vec()
for r in rows {
segments.push(Segment { x1: r.x1, y1: r.y1, x2: r.x2, y2: r.y2 })
}
for s in segments {
out(s)
}
Given
0,9 -> 5,9
8,0 -> 0,8
it prints
{ x1: 0, y1: 9, x2: 5, y2: 9 }
{ x1: 8, y1: 0, x2: 0, y2: 8 }
Note that a nominal record prints as its fields too — out shows
{ x1: 0, … }, with no Segment in front of it. The name is for the type
system and the diagnostics, not for the output. Records
covers the declaration form in full.