Fundamental Types
Graphix has a few fundamental data types, the Graphix shell is a good way to
explore them by trying out small Graphix expressions. You can run the Graphix
shell by invoking graphix with no arguments.
Numbers
i8, u8, i16, u16, i32, u32, i64, u64, f32, f64, and
decimal are the fundamental numeric types in Graphix. Literals are written with their type prefixed, except
for i64 and f64 which are written bare. for example, u32:3 is a u32
literal value.
decimal is an exact decimal representation for performing financial
calculations without rounding or floating point approximation errors.
The basic arithmetic operations are implemented on every number type. Both
operands of an operation are one type: an f64 and an i64 do not mix
without a cast.
| Operation | Unchecked | Checked |
|---|---|---|
| Add | + | +? |
| Subtract | - | -? |
| Multiply | * | *? |
| Divide | / | /? |
| Mod | % | %? |
Unchecked integer +, - and * wrap on overflow (`i64:9223372036854775807
- 1
is the smallesti64). Unchecked/and%return bottom (no value) on a zero divisor (the interpreter also logs it; compiled code does not, so run with–no-fusionto see the log), and/also on the one quotient that overflows, the smallest signed value divided by-1;x % -1is0`. A bottomed expression simply stops updating until its inputs change to values that produce a valid result.
Checked operators return a union type [T, Error<ArithError(string)>], allowing you to handle arithmetic errors explicitly using ?, $, or select`.
Mixing two numeric types is refused at compile time; cast one side:
〉1. + 1
error: in expr
Caused by:
0: at: line: 1, column: 1, in: 1.0 + 1
1: cannot compute f64 + i64: arithmetic is fn<'a: Number + Singleton>(x: 'a, y: 'a) -> 'a — both operands must be one numeric type (cast one side explicitly)
〉1. + cast<f64>(1)$
-: f64
2
With unchecked operators, a division by zero returns bottom (logged by the interpreter only) – the expression produces no value until the inputs change. This means downstream expressions simply stop updating until the arithmetic becomes valid again.
〉0 / 0
-: i64
No value is printed because the division by zero produces bottom. If the divisor later changes to a non-zero value, the expression will resume producing values.
With checked operators, you get an explicit error value you can handle:
〉0 /? 0
-: [i64, Error<`ArithError(string)>]
error:`ArithError("attempt to divide by zero")
You can use ? to propagate, $ to swallow, or select to match on the result:
〉(0 /? 0)?
-: i64
// throws ArithError to the nearest installed catch
〉(0 /? 0)$
-: i64
// logs warning, returns bottom
〉u32:0 -? u32:1
-: [u32, Error<`ArithError(string)>]
error:`ArithError("attempt to subtract with overflow")
v32, z32, v64, z64
These number types are the same as the normal types except when they are sent over the wire via netidx (or written to a file) they use variable width encoding instead of normal encoding. The number of bytes used varies for 64 bit numbers to between 1 and 10. Small numbers (below 127) are encoded in 1 byte, larger number use more bytes. The type correspondence is,
| Compact | Normal |
|---|---|
| v32 | u32 |
| z32 | i32 |
| v64 | u64 |
| z64 | i64 |
Number Sets
There are a few sets of number types that classify numbers into various kinds.
Number being the most broad, it contains all the number types. Int contains
only integers, Float the two float types, Real contains only reals
(decimal plus the two float types), Sint contains signed integers, Uint
contains unsigned integers. A set is a type for a variable to range over
('a: Number); a value of a set type is one of its members, and
arithmetic takes one member type at a time.
Bool
Boolean literals are written as true and false, and the name of the boolean
type is bool.
Boolean expressions using &&, ||, and ! are supported. These operators
only operate on bool. They can be grouped with parenthesis. && and ||
are strict: they do not short circuit, and an operand that is bottom makes
the result bottom (false && never() produces nothing). For example,
〉true && false
-: bool
false
〉true || false
-: bool
true
〉!true
-: bool
false
〉!1
error: in expr
Caused by:
0: at: line: 1, column: 2, in: i64:1
1: type mismatch bool does not contain i64
Duration
A time duration. The type name is duration, and the literals are written as,
duration:1.0s, duration:1.0ms, duration:1.0us, duration:1.0ns (and
m, h, d, M and y). The arithmetic operators do not apply to
durations; sys::time does the arithmetic.
〉sys::time::add_dur(duration:1.0s, duration:1.0s)$
-: duration
2.s
〉sys::time::scale(duration:1.0s, 50.0)$
-: duration
50.s
〉sys::time::scale(duration:1.0s, 0.02)$
-: duration
20.ms
DateTime
A date and time in the UTC time zone. The type name is datetime and literals
are written in RFC3339 format inside quotes. For example,
datetime:"2020-01-01T00:00:00Z". sys::time::add and sys::time::sub
move a datetime by a duration, and sys::time::diff is the duration
between two.
〉sys::time::add(datetime:"2020-01-01T00:00:00Z", duration:30.s)
-: datetime
2020-01-01 00:00:30 UTC
You can enter datetime literals in local time and they will be converted to UTC. For example,
〉datetime:"2020-01-01T00:00:00-04:00"
-: datetime
2020-01-01 04:00:00 UTC
String
Strings in Graphix are UTF8 encoded text. The type name is string and the
literal is written in quotes "this is a string". The escapes are \n, \r,
\t, \0, \\, \", and \[ / \] for a literal bracket (a bare [
starts an interpolation): "this is \" a string with a quote and a \n". Non printable
characters such as newline will be escaped by default when strings are printed
to the console, you can use print to print the raw string including control
characters.
String Interpolation
String literals can contain expressions that will be evaluated and joined to the string,
such expressions are surrounded by unescaped [] in the string. For example,
〉let row = 1
〉let column = 999
〉"/foo/bar/[row]/[column]"
-: string
"/foo/bar/1/999"
Values in an interpolation need not be strings, they are printed into the
string when they are used. You can write a literal [ or ] in a string by escaping
it.
〉"this is a string with a \[ and a \] but it isn't an interpolation"
-: string
"this is a string with a [ and a ] but it isn't an interpolation"
String literals may span multiple lines; the newlines become part of the string.
Triple Quoted Strings (Templates)
A string delimited by """ is the TEMPLATE form: literal text is the
common case there, so the marking flips relative to a normal string.
Bare ", [, and ] are all plain content, and interpolation is the
marked thing — a splice is written \[expr]. That makes templates the
natural home for blocks of quoted or bracket-heavy text — embedded
code, shell, TUI layouts — with reactive values spliced in:
let name = "world";
let page = """
say "hello" to \[name]
keys: [a]pprove · [d]eny
shell embeds verbatim: [ -z "$d" ] && x=$1
value = \[1 + 1]
"""
The rule is a symmetric inversion, easy to remember: in strings, interpolation is bare and literal brackets are marked; in templates, brackets are bare and interpolation is marked.
One newline immediately after the opening """ is stripped, so the
template’s first line does not have to share a line with the
delimiter. The content ends at the first unescaped """; to include a
literal """, escape one of the quotes (\"""), and a " that would
touch the closing delimiter must likewise be escaped ("""ends in \"""").
The other escapes (\n, \t, \\, \") work as in normal strings;
\] is an error (a bare ] is always writable), and a literal
backslash-then-bracket is \\[.
Raw Strings
A raw string is written r"…", or with hashes — r#"…"#,
r##"…"##, and so on — exactly as in Rust. There are NO escapes and
no interpolation: the content is verbatim, ending at the first "
followed by the opener’s hash count. Any string at all can be written
by choosing enough hashes. This is the form for embedding scripts,
regexes, or other bracket- and backslash-heavy text:
let pat = r"[\[\]0-9]+";
let script = r#"
awk '{ print "field:", $1 }'
"#
Raw strings do not strip any newlines: the content is exactly what is between the delimiters.
Any
The Any type is a type that unifies with any other type, it corresponds to the
underlying variant type that represents all values in Graphix (and netidx). It
is not used very often, as it provides very few guarantees, however it has it’s
place. For example, queue’s #clock argument is Any: any value at all can
tick it. Usually the first thing you do with a value of type Any is call
cast to turn it into the type you expect (or an error), or use a select
expression to match its type (more on select later). A cast keeps a
value’s kind: a number converts to another number (an integer type only
when it holds the value exactly), and a union target takes a member of
the value’s own kind first, so cast<[i8, Array<f64>]>([1, 2]) is
[1., 2.]. Across kinds a cast only parses a string (cast<i64>("42"),
cast<bool>("true")), prints a number, bool, datetime or duration
plainly (cast<string>(42) is "42"), converts between a number and
a datetime (Unix seconds) or a duration (seconds), turns a bool into a
number (1 or 0), or reads an array of numbers as bytes; anything else,
such as cast<bool>(1), cast<string>([1, 2]) or cast<Array<i64>>(5),
is an InvalidCast error.
Reading data whose type
the program cannot see, a netidx subscription or a json document, is
type-directed instead: sys::net::subscribe returns the type you annotate
and casts what arrives to it.
Null
Null is nothing, just like in many other languages. Unlike most other languages
null is a type not a catch all. If the type of a value does not include null
then it can’t be null. The set ['a, null] (alias Option<'a>) is commonly
used to represent things that will sometimes return null.
Array
Arrays are immutable, contiguous, and homogeneous. They are
parameterized, Array<string> indicates an array of strings. Arrays
are zero indexed a[0] is the first element. Array elements can be
any type, including other arrays at arbitrary levels of nesting. There
is a special array type that acts like Array<Any>, that represents
the fundamental array type in the underlying value representation.
Array literals are written like [x, y, z]. There are many functions
in the array module of the standard library for working with arrays.
Array Slicing and Indexing
Graphix supports array subslicing, the syntax will be familiar to Rust programmers.
a[2..]a slice from index 2 to the end of the arraya[..4]a slice from the beginning of the array to index 3a[1..3]a slice from index 1 to index 2a[-1]the last element in the arraya[-2]the second to last element in the arraya[-2..]the last two elements of the arraya[..-1]every element but the last
A negative index or slice bound counts from the end of the array. A bound past either end is an out-of-bounds error, as is a slice whose start comes after its end.
..= is not supported however, the second part of the slice will always be the
exclusive bound. Literal numbers can always be replaced with a Graphix
expression, e.g. a[i..j] is perfectly valid.
Mutability and Implementation
Arrays are not mutable, like all other Graphix values. All operations that “change” an array, actually create a new array leaving the old one unchanged. This is even true of the connect operator, which we will talk more about later.
There are a couple of important notes to understand about the implementation of Arrays.
-
Arrays are memory pooled, in almost all cases (besides really huge arrays) creating an array does not actually allocate any memory, it just reuses a previously used array that has since become unused. This makes using arrays a lot more efficient than you might expect.
-
Arrays are contiguous in memory. This means they are generally very memory efficient, each element is 2 machine words, and O(1) to access. However there are a few cases where this causes a problem, such as building up an array by appending one element at a time. This is sadly an O(n^2) operation on arrays. You may wish to use another data structure, such as a map (which would be O(n log(n)) if you need to build a large data structure this way.
-
Array slices are zero copy, and also pooled. They simply create a light weight view into the array. This means algorithms that progressively deconstruct an array by slicing are O(N) not O(N^2) and the constants are very fast.
List
List<'a> is an immutable singly linked list. Lists complement
arrays: an array is contiguous — compact and O(1) to index, but O(n²)
to build by appending one element at a time — while a list is built
front to back one O(1) cons at a time, and two lists with a common
tail share memory. When you are accumulating an unknown number of
elements, cons onto a list as you go and convert once at the end
(list::to_array, or list::to_array_rev to undo the reversal that
front-consing produces, in one walk).
List literals are written like array literals with angle brackets added,
let l = [<1, 2, 3>];
l
$ graphix test.gx
[<1, 2, 3>]
The empty list is [<>]. On its own it usually needs a type
annotation, since there is no element to infer the element type from,
let empty: List<i64> = [<>]
Elements can be any type, including other lists. The
list module in the standard library has the
functions for working with lists — list::cons, list::len,
list::map, list::fold and many more — and lists are destructured
with list patterns in select.
Mutability and Implementation
Like all Graphix values lists are immutable: consing onto a list
creates a new list whose tail is the old list, unchanged and fully
shared — this is what makes lists cheap to build incrementally. The
cells are pooled like arrays, so building a list does not usually
allocate. Indexed access (list::nth) walks the spine and is O(n);
if you need random access, convert to an array.
Lists as Data
Data carries no list cells: every writer (json, toml, pack, netidx)
writes a list as a flat array, so pack::write_bytes([<1, 2, 3>]) is the
same bytes as pack::write_bytes([1, 2, 3]). A typed read or a cast to a
List builds the list from the array again, so what a writer emits reads
back into its type, and a cast of an Any holding an array of rows reads
the list directly:
let v: Any = [[1, 2], [3, 4]];
cast<List<Array<i64>>>(v)
is [<[1, 2], [3, 4]>].
Tuples
Tuples are written (x, y), they can be of arbitrary length, and each element
may have a different type. Tuples may be indexed using numeric field indexes.
Consider
let x = (1, 2, 3, 4);
x.0
Will print 1.
Map
Maps in Graphix are key-value data structures with O(log(N)) lookup, insert, and
remove operations. Maps are parameterized by their key and value type, for
example Map<string, i64> indicates a map with string keys and integer values.
There are many functions for working with maps in the map standard library
module
Map Literals
Maps can be constructed using the {key => value} syntax:
〉{"a" => 1, "b" => 2, "c" => 3}
-: Map<'_1893: string, '_1895: i64>
{"a" => 1, "b" => 2, "c" => 3}
Keys and values can be any Graphix type, for example here is a map where the key
is a Map<string, i64>.
{{"foo" => 42} => "foo", {"bar" => 42} => "bar"}
-: Map<'_1919: Map<'_1915: string, '_1917: i64>, '_1921: string>
{{"bar" => 42} => "bar", {"foo" => 42} => "foo"}
Map Indexing
Maps can be indexed using the map{key} syntax to retrieve values:
〉let m = {"a" => 1, "b" => 2, "c" => 3}
〉m{"b"}
-: ['_1907: i64, Error<`MapKeyError(string)>]
2
If a key is not present in the map, indexing returns a MapKeyError:
〉m{"missing"}
-: ['_1907: i64, Error<`MapKeyError(string)>]
error:`MapKeyError("map key \"missing\" not found")
Mutability and Implementation
Like all Graphix values, maps are immutable. All operations that “change” a map actually create a new map, leaving the original unchanged. Maps are memory pooled and very efficient - creating new maps typically reuses existing memory rather than allocating new memory.
Maps maintain their key-value pairs in a balanced tree structure, ensuring O(log(N)) performance for all operations regardless of map size.
Chaining Access Operators
The postfix access operators — struct field .name, tuple index .0, array
index/slice [i] / [i..j], map index {key}, and function call (args) —
can be chained directly, left to right, without intermediate parentheses:
// field, then index, then field
data.users[0]$.name
// map index, then field
config{"db"}$.port
// nested array index
matrix[i]$[j]$
// tuple index, then field
point.0.x
// a call whose result is itself called
make_handler(cfg)(event)
Each operator applies to the value produced by everything to its left, so a
chain reads in evaluation order. Array and map indexing return a Result, so
the ? or $ that unwraps one sits right after that access, before the
chain goes on: data.users[0]?.name propagates an error from the indexing.
Parentheses are only needed when the source of an access is not itself a
simple value or another access, for example (a, b).0 (index into a tuple
literal). A bare identifier or an existing chain never needs them.
Error
Error is the built in error type. It carries a type parameter indicating the
type of error, for example Error<MapKeyError(string)>is an error that carries a ``MapKeyError `` variant. You can access the inner error value
using e.0 e.g.,
〉let e = error(`MapKeyError("no such key"))
〉e.0
-: `MapKeyError(string)
`MapKeyError("no such key")
More information about dealing with errors is available in the section on error handling.