The Vine Programming Language
Vine is an experimental new programming language based on interaction nets.
Vine is a multi-paradigm language, featuring seamless interop between functional and imperative patterns.
See
vine/examples/
for examples of Vine.
cargo run -r --bin vine run vine/examples/$NAME.vi
If you're curious to learn more, join the Vine Discord server.
(Vine is still under heavy development; many things will change.)
Getting Started
Let's get started! Once you've installed Vine, we'll take a look at a basic Hello, world! program.
Installation
Make sure you have Rust installed.
# clone the Vine repository
git clone https://github.com/VineLang/vine
cd ./vine
# install the Vine cli
cargo install --path cli
# make sure everything is working
vine run vine/examples/hello_world.vi
You should see:
Hello, world!
Head over to the next page to learn more about what just happened.
Optional: Vine VSCode Extension
Make sure you have Node.js installed.
cd lsp/client
npm i
Then, run "Developer: Install Extension from Location..." and select the
lsp/client
directory.
You can check that it's working by opening one of the example files and making a syntax error. When you save, you should see an error appear.
Optional: Vine Workspace Configuration
LSP
// .vscode/settings.json
{
// list all of your top-level Vine programs (globs are supported)
"vine.entrypoints": ["src/main.vi"]
}
Make sure you reload the window after changing this file.
Formatter
Make sure you have dprint
installed.
// dprint.json
{
"exec": {
"commands": [
{
"exts": ["vi"],
"command": "vine fmt"
}
]
},
"plugins": ["https://plugins.dprint.dev/exec-0.5.0.json"]
}
Hello, world!
Let's run our first Vine program!
// vine/examples/hello_world.vi
pub fn main(&io: &IO) {
io.println("Hello, world!");
}
We can run this with:
vine run vine/examples/hello_world.vi
You should see something like the following:
Hello, world!
Interactions
Total 297
Annihilate 159
Commute 0
Copy 17
Erase 34
Expand 46
Call 27
Branch 14
Memory
Heap 480 B
Allocated 6_016 B
Freed 6_016 B
Performance
Time 0 ms
Speed 8_054_237 IPS
At the top, notice that it successfully printed Hello, world!
.
At the bottom, you'll also see several statistics printed
out. You don't need to worry about them for now. If you want to disable them,
you can use --no-stats
.
Let's break down what just happened.
fn main
: declares a function, namedmain
pub
: makes it public(&io: &IO)
: it takes a single parameter:&IO
: of type&IO
&io
: to be dereferenced and bound to the variableio
{ ... }
: in the function bodyio.println
: uses theprintln
method onIO
("Hello, world!")
: calls it with the stringHello, world!
We'll go into this in more detail in future chapters.
Every Vine program must contain a pub fn main(&io: &IO) { ... }
("a public
function main
that takes a reference to IO").
Vine Language Features
An overview of Vine's language features, from the usual to the bizarre.
The Usual
Vine has the usual complement of standard programming language features, including:
- integer literals and operations:
1 + 2 * 3
- float literals and operations:
1.0 + 2.0 * 3.0
- booleans and boolean operators (including short-circuiting):
true && !(false || 1 == 2)
- character, string, and list literals and operations:
"abc" ++ "def" ++ ['g', 'h', 'i']
- tuples:
(1, 1.0, "abc")
,(1, 2).0
- variables:
let x = 5; x += 1
- basic control flow:
if condition { ... } else { ... }
while condition { ... }
loop { ... }
(loops untilbreak
)return value
break
,continue
- loop labels:
while.label ... { ... break.label ... }
(Try some of these snippets in the vine repl
!)
Many of Vine's features are influenced by Rust, and it has a similar expression-oriented syntax, type system, and module system.
Modules
Vine programs are structured into modules. Every Vine file is a module. Modules consist of a collection of items.
// main.vi
// function item named `main`
pub fn main(&io: &IO) {
io.println("Hello, world!");
}
// constant item named `answer`
const answer: N32 = 42;
Every item has a visibility, which determines where they can be accessed. By
default, items are private, and can only be accessed within the module they were
defined in. Items can be made public with the pub
keyword; public items can be
accessed anywhere.
Modules can have submodules.
// main.vi
mod messages {
pub const greeting: String = "Hello, world!";
pub const farewell: String = "Goodbye, world!";
}
pub fn main(&io: &IO) {
io.println(messages::greeting);
io.println(messages::farewell);
}
Submodules can be included from a separate file:
// messages.vi
pub const greeting: String = "Hello, world!";
pub const farewell: String = "Goodbye, world!";
// main.vi
mod messages = "./messages.vi";
pub fn main(&io: &IO) {
io.println(messages::greeting);
io.println(messages::farewell);
}
Items from other modules can be imported with a use
item:
// main.vi
mod messages = "./messages.vi";
use messages::{greeting, farewell};
pub fn main(&io: &IO) {
io.println(greeting);
io.println(farewell);
}
Types
Vine is statically typed. Every expression has a type, like Bool
or
List[N32]
. Type parameters are written in square brackets.
Types fall in to several categories:
- primitive types are fundamental types defined by the compiler
- standard types are commonly-used types defined by the standard library
- structs and enums are user-defined types
Primitive Types
N32
The type N32
describes natural numbers1, represented with 32 bits of
precision.
N32
values can be written as literals in decimal (46
), hex (0x2e
), or
binary (0b101110
). Digits can be separated with underscores (1_000_000
).
N32
s support the usual arithmetic and bitwise operators (4 * 11 + 2
,
5 << 3 | 6
)
F32
The type F32
describes 32-bit floating-point numbers (following IEEE 754).
F32
values can be written as literals (4.6e1
). The decimal point is
required.
F32
s support the usual arithmetic operators (3.6 * 12.3 + 1.72
).
Char
The type Char
describes Unicode scalar values. Char
s are primarily used
within String
s.
Char
values can be written as literals using single quotes ('.'
).
Char
s support adding an N32
, resulting in another Char
('a' + 4
), as
well as subtracting another Char
, resulting in an N32
('G' - 'A'
).
Bool
The type Bool
describes booleans.
The two Bool
values can be written as literals (true
, false
).
Bool
s support the usual short-circuiting logical operators (&&
, ||
, !
)
and non-short-circuiting ("bitwise") operators (&
, |
, ^
).
Expressions that evaluate to booleans are called conditions.
IO
IO
is a special primitive type used to interact with the outside world. Values
of this type cannot be explicitly constructed; instead, an IO
handle is passed
in to main
at the start of the program. See the section on IO for
more detail.
Natural numbers are non-negative integers. In other programming languages, they are often referred to as "unsigned integers". Seeing as positive integers do have a sign (namely, a positive sign), the only truly unsigned integer is zero.
Standard
The standard library defines various commonly-used types.
(In the following sections, the time complexity of operations is described in Big O notation.)
String
The type String
describes Unicode strings, represented as a list of Unicode
scalar values.
Strings can be written as literals using double quotes ("Hello, world!"
).
Strings can be concatenated with the ++
operator (O(1)
).
List
The generic type List[T]
describes lists of values of type T
.
Lists are optimized for fast concatenation and in-order iteration. Accessing the
first value in a list is O(1)
, but accessing an arbitrary value is O(n)
. An
array is a better choice if frequently accessing elements by index.
Lists can be written as literals using square brackets ([1, 2, 3]
).
Lists can be concatenated with the ++
operator (O(1)
).
Lists can be used as a queue by using .push_back
to enqueue and .pop_front
to dequeue (both O(1)
).
Lists can be used as a stack by using .push_front
to push and .pop_front
to
pop (both O(1)
).
Array
The generic type Array[T]
describes arrays of values of type T
.
Arrays are optimized for fast random access. Accessing an arbitrary value in the
array is O(log(n))
.
Elements can be added and removed from either the front or the back with
push_front
/pop_front
/push_back
/pop_back
(O(log(n))
).
Array
s can be converted to and from lists with
Array::to_list
/Array::from_list
(O(n)
).
Map
The generic type Map[K, V]
describes mappings from keys of type K
to values
of type V
.
Maps are sorted by their key, and can be efficiently iterated over using .iter
and .into_iter
.
Inserting, accessing, and removing a value by key can be done with
.insert
/.get
/.remove
(O(log(n))
).
The maximum/minimum key-value pair can be removed with
.remove_max
/.remove_min
(O(log(n))
).
Structs
Structs are fixed-sized, heterogenous collections of values.
Structs are either tuples or objects, and are either anonymous or named.
// anonymous tuple struct
let a = (1, 'a', 4.6); // a: (N32, Char, F32)
// anonymous object struct
let b = { p: false, q: "xyz" }; // b: { p: Bool, r: String }
// named tuple struct
struct Foo(N32, Char, F32);
let foo = Foo(1, 'a', 4.6); // foo: Foo
// named object struct
struct Bar { p: Bool, r: String }
let bar = Bar({ p: false, q: "xyz" }); // bar: Bar
The values of a tuple struct are accessed by their index.
let a = (1, 'a', 4.6);
a.0 // 1
a.1 // 'a'
a.2 // 4.6
a.2 *= 10.0;
a.2 // 46.0
The values of an object struct are accessed by their key.
let b = { p: false, q: "xyz" };
b.p // false
b.q // "xyz"
b.p = !b.p;
b.p // true
Named struct types can be generic.
struct Pair[T](T, T);
let u = Pair(1, 2); // u: Pair[N32]
let v = Pair(true, false); // v: Pair[Bool]
struct Box[T] { value: T }
let x = Box({ value: 46 }); // x: Box[N32]
let y = Box({ value: "abc" }); // y: Box[String]
Enums
An enum is a type with a fixed set of variants. An enum value is one of those variants.
enum Weekday {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
}
let day = Weekday::Friday; // day: Weekday
day is Weekday::Monday // false
day is Weekday::Friday // true
let mood = match day {
Weekday::Monday { ":(" }
Weekday::Friday { ":)" }
_ { ":|" }
};
mood // ":)"
Enum variants can have associated fields in the form of a tuple or an object.
enum Shape {
Point,
Circle(F32),
Rect { width: F32, height: F32 },
}
let x = Shape::Point; // x: Shape
let y = Shape::Circle(1.0); // y: Shape
let z = Shape::Rect({ width: 4.0, height: 6.0 }); // z: Shape
Pattern matching can be used to branch on the variant of the enum and access its fields.
fn perimeter(shape: Shape) -> F32 {
match shape {
Shape::Point {
0.0
}
Shape::Circle(radius) {
6.28 * radius
}
Shape::Rect({ width, height }) {
2.0 * (width + height)
}
}
}
perimeter(Shape::Point) // 0.0
perimeter(Shape::Circle(1.0)) // 6.28
perimeter(Shape::Rect({ width: 4.0, height: 6.0 })) // 20.0
Pattern matching is discussed further in the Patterns section.
Standard Enums
The standard library defines the enum types Option[T]
and Result[T, E]
:
enum Option[T] {
Some(T),
None,
}
enum Result[T, E] {
Ok(T),
Err(E),
}
Methods
User-defined types can have methods, functions defined in the type's module that take the type as their first parameter.
enum Shape {
Circle(F32),
Rect { width: F32, height: F32 },
}
mod Shape {
pub fn perimeter(shape: Shape) -> F32 {
match shape {
Shape::Circle(radius) {
6.28 * radius
}
Shape::Rect({ width, height }) {
2.0 * (width + height)
}
}
}
}
let shape = Shape::Circle(1.0);
shape.perimeter() // 6.28
The first parameter of a methods can also be a reference, allowing the method to mutate the value it is called upon.
mod Shape {
pub fn scale(&shape: &Shape, factor: F32) {
match &shape {
&Shape::Circle(radius) {
radius *= factor;
}
&Shape::Rect({ width, height }) {
width *= factor;
height *= factor;
}
}
}
}
let shape = Shape::Rect({ width: 4.0, height: 6.0 });
shape.perimeter() // 20.0
shape.scale(2.3);
shape.perimeter() // 46.0
IO
All IO in Vine is done by calling functions on an IO handle, passed in to main
at the start of the program. For example, here is a basic cat
program:
use std::option::Option::Some;
pub fn main(&io: &IO) {
while io.read_line() is Some(line) {
io.println(line);
}
}
Any function that needs to do IO must take a reference to this handle.
use std::option::Option::Some;
pub fn main(&io: &IO) {
if io.prompt("What is your name? ") is Some(name) {
greet(&io, name);
}
}
fn greet(&io: &IO, name: String) {
io.println("Hello, " ++ name ++ "!");
}
Any function that does not get passed a reference to an IO handle is pure (i.e. does not interact with the outside world).
IO calls are asynchronous and do not block the execution of the rest of the program.
Every IO function takes the IO handle by reference, and the IO handle is updated after every low-level IO operation (like printing or reading a byte). This ensures that the IO is executed in the expected order, despite being asynchronous.
Patterns
Patterns appear in:
let
bindings (let pattern = ...
)fn
parameters (fn foo(pattern: ...) { ... }
)match
arms (match foo { pattern => { ... } }
)is
expressions (foo is pattern
)
Patterns are used to declare variables, unwrap data, and match values.
Complete Patterns
Variables are declared using variable patterns.
let x = 1; // here, `x` is a variable pattern
Variable patterns are often subpatterns of other patterns.
Tuple patterns unwrap tuples:
let tuple = (1, 2);
let (x, y) = tuple; // here, `(x, y)` is a tuple pattern
// and `x` and `y` are variable subpatterns
x // 1
y // 2
Similarly, struct patterns unwrap structs:
struct Foo(N32, String);
let foo = Foo(123, "abc");
let Foo(x, y) = foo;
x // 123
y // "abc"
The _
pattern is a complete pattern that discards the matched value.
let tuple = (1, 2);
let (x, _) = tuple; // discard the second field
x // 1
These are all complete patterns, because they match all values of their type.
Only complete patterns can be used in let
bindings and fn
parameters.
Other kinds of complete patterns include:
Incomplete Patterns
Enum patterns match values of an enum type. They are incomplete patterns, because they do not match all values of their type; they only match one variant of the enum. Any pattern with incomplete subpatterns is also incomplete.
Incomplete patterns can be used in match
expressions to perform pattern
matching. For example:
fn display(score: Option[N32]) -> String {
match score {
Some(value) => "score: " ++ value.to_string(),
None => "no score",
}
}
display(Some(123)) // "score: 123"
display(None) // "no score"
Incomplete patterns can also be used with the
is
operator.
Conditions
Conditions are expressions which evaluate to booleans (the Bool
type).
Conditions include:
- the usual:
- boolean literals (
true
,false
) - short-circuiting logical operators (
&&
,||
,!
) - non-short-circuiting ("bitwise") operators (
&
,|
,^
) - comparison operators (
==
,!=
,<
,<=
,>
,>=
)
- boolean literals (
- comparison chains
- the
is
operator - the implication operator (
=>
)
Comparison Chains
In Vine, you can chain comparison operators.
For example, a < b < c
is equivalent to (a < b) & (b < c)
.
More generally, a() < b() < c()
is equivalent to
do {
let x = a();
let y = b();
let z = c();
(x < y) & (y < z)
}
Note that comparison chains do not short-circuit; all of the subexpressions are evaluated exactly once.
All of the comparison operators can be chained. As a contrived example, these are equivalent:
1 == 1 < 2 <= 3 > 0 != 5 // true
(1 == 1) & (1 < 2) & (2 <= 3) & (3 > 0) & (0 != 5) // true
The is
Operator
The is
operator checks if an expression matches some pattern, and returns a
boolean.
let option = Some(1);
option is Some(_); // true
option is None; // false
Any variables bound by the patterns are in scope in subsequent true-paths
including &&
chains, then-blocks of an if
, and the body of a while
.
let option = Some(1);
option is Some(value) && value > 0; // true
if option is Some(value) {
value; // 1
} else {
// `value` is not bound in this scope
}
A true-path is broken by negation (!
) and disjunction (||
).
// invalid:
!(option is Some(value)) && value > 0
// invalid:
option is Some(value) || value > 0
Implication
In logic, the statement "P implies Q" is true if, whenever P is true, Q is also
true. This is equivalent to "P is false, or Q is true". Vine has an implication
operator, =>
, with the same semantics.
true => true // true
true => false // false
false => true // true
false => false // true
The implies operator also continues the true-path; variables bound in the left-hand side are in scope in the right-hand side:
let x = Some(1);
x is Some(value) => value > 0 // true
x is Some(value) => value == 0 // false
let y = None;
y is Some(value) => value > 0 // true
y is Some(value) => value == 0 // true
A common pattern in other languages is to write
value == null || validate(value)
to validate a nullable value. In Vine, this
is written with the implication operator:
value is Some(value) => validate(value)
Values, Spaces, and Places
The result of an expression in Vine has one of three forms: a value, a space, or a place.
- a value represents some piece of data
- a space represents somewhere that data can be put
- a place is the combination of a value and a space.
We'll use the following code example to explore these concepts further.
let x = 1 + 2;
x + 1;
x = 5;
x *= 2;
On line 1, the expression 1 + 2
results in the value 3
. The variable x
is
then declared with an initial value of 3
.
On line 2, the expression x
results in its value 3
, so the expression
x + 1
results in the value 4
. This expression is unused so its result is
simply discarded.
On line 3, we have an assignment statement. This expects a space on the
left-hand side, and a value on the right-hand side, and will put the value into
the space. In this particular case, the expression x
on the left-hand side
results in a space – whatever value is put into this space will become the new
value of x
. Then, the assignment operator puts the value 5
into the space.
The value of x
is now 5
.
On line 4, the *=
operator expects a place on the left-hand side, and a value
on the right-hand side. Thus, x
results in its place, which is the combination
of the value 5
and a space to put a new value for x
. The *=
operator uses
the value to calculate 5 * 2
, and puts the value 10
into x
's space. Thus,
the value of x
is now 10
.
Expression Resolution
Expression Positions
Every expression is located in either a value position, a space position, or a place position. The form of the expression's position determines the form of the result of the expression.
We saw above that the expression x
could result in a value, a space, or a
place; this is determined by the form of its position.
- On line 2, in
x + 1
,x
is in a value position, so it results in a value. - On line 3, in
x = 5
,x
is in a space position, so it results in a space. - On line 4, in
x *= 2
,x
is in a place position, so it results in a place.
Expression Forms
Independent of its position, every expression has a form - it is either a value
expression, a space expression, or a place expression. For example, variable
expressions, like x
, are place expressions.
The form of an expression determines the form of what the expression evaluates to. If this form does not match the form of the expression's position, it is coerced to become the result.
- On line 2, in
x + 1
,x
evaluates to a place. It is in a value position, so it is coerced to a value. - On line 3, in
x = 5
,x
evaluates to a place. It is in a space position, so it is coerced to a space. - On line 4, in
x *= 2
,x
evaluates to a place. It is in a place position, so it is not coerced.
Coercion
There are two rules for coercion.
-
When a place is coerced to a value, the resulting value is a copy of the place's value; the other copy is put into the place's space.
-
When a place is coerced to a space, the resulting space is the place's space; the place's value is discarded.
These two rules result in the behavior described in the first section.
Values and spaces cannot be coerced. It is an error to have a value expression in a non-value position, or a space expression in a non-space position.
References
A reference is a value that represents a place.
References can be created using the &
reference operator.
let x = 1; // x: N32
let r = &x; // r: &N32
r
is now a reference to the place that x
evaluated to. It is of type &N32
,
the type representing references to N32
s.
References can be unwrapped using the &
reference pattern.
let x = 1;
let r = &x;
let &y = r; // unwrap the reference `r` into a place, and name it `y`
y = 2; // write the value `2` into the place
x // 2
References can be passed to function calls to allow mutation of local variables:
fn increment(&n: &N32) {
n += 1;
}
let x = 0;
increment(&x);
x // 1
Under the hood, methods that mutate their receiver use references:
let x = [1, 2, 3];
x.push_back(4); // equivalent to: `List::push_back(&x, 4)` (note the reference!)
x // [1, 2, 3, 4]
References are also useful when a function only needs to access part of a data structure:
// Inefficient
fn is_empty(list: List[N32]) -> N32 {
list.len() != 0
}
let x = [1, 2, 3, 4];
let e = is_empty(x); // inefficient; clones the whole list
x // [1, 2, 3, 4]
e // false
// Efficient
fn is_empty(&list: &List[N32]) -> N32 {
list.len() != 0
}
let x = [1, 2, 3, 4];
let e = is_empty(&x); // efficient; no clones necessary
x // [1, 2, 3, 4]
e // false
(This is why List::len
takes its receiver by reference, despite not needing to
mutate it.)
Dereference Operator
The place contained in a reference can also be accessed with the *
dereference
operator:
let x = 1;
let r = &x;
*r = 2;
x // 2
(Note that there are currently some bugs/limitations with this; if you have a reference stored in a variable, it is preferred to use reference patterns instead.)
This can be useful when a function returns a reference:
let x = [1, 2, 3];
*x.get(2) = 4;
x // [1, 2, 4]
The *
operator can also be written as a postfix operator using .*
:
let x = [1, 2, 3];
x.get(2).* = 4;
x // [1, 2, 4]
Dereference Pattern
The *
dereference pattern takes a value pattern of type &T
, and yields a
place pattern of type T
. This can be used, for example, to "split" a reference
to a struct into references to its fields.
struct Pair(N32, N32);
fn increment(&n: &N32) {
n += 1;
}
fn increment_pair(r: &Pair) {
let &Pair(*x, *y) = r; // x: &N32, y: &N32
increment(x);
increment(y);
}
let pair = Pair(0, 0);
increment_pair(&pair);
pair // Pair(1, 1)
The Inverse
Vine's concept of the inverse is not something seen in other programming languages, as it is a concept that is only possible due to the unique properties of interaction nets.
The inverse type ~N32
represents an expectation of an N32
. Such an
expectation must be fulfilled with an N32
. The type ~~N32
is equivalent to
the type N32
.1
The inverse operator can be applied to a value, a space, or a place.
-
The inverse operator, when applied to a space
s
of typeT
, evaluates to a value of type~T
. This value is the expectation of aT
that will be put into the spaces
. -
The inverse operator, when applied to a value
v
of typeT
, evaluates to a space of type~T
. Whatever expectation is put into the space will be fulfilled with the valuev
. -
The inverse operator, when applied to a place
p
of typeT
, evaluates to a placeq
of type~T
. The value ofq
is the inverse of the space ofp
; the space ofq
is the inverse of the value ofp
.
The inverse pattern is essentially the reverse of the inverse operator, and can be used to unwrap inverse types.
Out Parameters
The inverse operator can be used to implement out parameters:
// `foo` takes an expectation of an `N32` as its parameter
fn foo(~n: ~N32) {
// and fulfills it with `123`
n = 123;
}
let x: N32;
let e = ~x; // e: ~N32
// `e` is now an expectation of an `N32` to put into `x`
foo(e); // we pass `e` to `foo`, which fulfills it with `123`
x // 123
(References can serve a similar role, but references also carry with them a current value.)
DIY Functions
The inverse operator can also be used to implement functions from scratch. The following code using functions:
let add_one = fn(x: N32) { x + 1 };
let a = 1;
let b = add_one(a);
b // 2
Could be written without functions like so:
// `add_one` will be a tuple:
// - the first element will be an expectation of the input
// - the second element will be the output
let add_one: (~N32, N32) = do {
let x: N32;
(
~x, // create an expectation for the input, which will be put into `x`
x + 1, // calculate the output
)
};
let a = 1;
let b = do {
let (~i, o) = add_one; // destructure the tuple
i = a; // fulfill the input expectation with `a`
o // evaluate to the output
};
b // 2
Note that here, the order in which these computations can resolve does not align
with the imperative order of execution; the fulfillment on line 14 must be
resolved before x + 1
on line 8 can be resolved. This value is in a sense
flowing "backwards"; counter to the usual forward flow.
Backwards Flow
This effect can be generalized; if you invert all uses of a variable, the variable will flow "backwards":
// Normal, forward flow:
let x: String;
x = "a";
io.println("0: " ++ x);
io.println("1: " ++ x);
x = "b";
io.println("2: " ++ x);
io.println("3: " ++ x);
x = "c";
0: a
1: a
2: b
3: b
// Inverted, backward flow:
let ~x: String;
~x = "a";
io.println("0: " ++ ~x);
io.println("1: " ++ ~x);
~x = "b";
io.println("2: " ++ ~x);
io.println("3: " ++ ~x);
~x = "c";
0: b
1: b
2: c
3: c
Normally, writing to a variable affects accesses on later lines. For an inverted variable, writing to a variable affects accesses on earlier lines.
This gets extra peculiar when you initialize the variable:
// Normal, forward flow:
let x = "a";
io.println("0: " ++ x);
io.println("1: " ++ x);
x = "b";
io.println("2: " ++ x);
io.println("3: " ++ x);
x = "c";
io.println("4: " ++ x);
io.println("5: " ++ x);
0: a
1: a
2: b
3: b
4: c
5: c
// Inverted, backward flow:
let ~x = "a";
io.println("0: " ++ ~x);
io.println("1: " ++ ~x);
~x = "b";
io.println("2: " ++ ~x);
io.println("3: " ++ ~x);
~x = "c";
io.println("4: " ++ ~x);
io.println("5: " ++ ~x);
0: b
1: b
2: c
3: c
4: a
5: a
The initialization of a normal variable affects accesses on lines before any reassignment of the variable. The initialization of an inverted variable affects accesses on lines after any reassignment of the variable.
Time Travel
At this point, a natural question is: when is this useful?
It turns out that there are many situations where the inverse operator is useful, and allows writing code that could not be expressed without it.
Consider the following function:
fn sub_min(&list: &List[N32]) {
let min_acc = None[N32];
let it = list.iter();
while it.next() is Some(&val) {
if min_acc is Some(m) => val < m {
min_acc = Some(val);
}
}
let min = min_acc.unwrap();
let it = list.iter();
while it.next() is Some(&val) {
val -= min
}
}
This function calculates the minimum of a list of numbers, and subtracts every number in the list by that minimum. For example:
let x = [4, 3, 7, 9];
sub_min(&x);
x // [1, 0, 4, 6]
This function currently iterates over the list twice; once to calculate the minimum, and once to do the subtraction. At a first glance, it looks like there is no way to merge these two loops, because you need to calculate the minimum of all the numbers before you can subtract from any of them.
The only way you could merge these loops is if you could somehow know what the minimum value was before the loop even started.
As it turns out, this is possible, using the inverse operator. Since an inverted variable flows "backwards in time", we can use one to send the minimum values from the end of the loop back to all of the previous iterations of the loop.
fn sub_min(&list: &List[N32]) {
// our accumulator will still flow forwards, as usual
let min_acc = None[N32];
// but we'll have an inverted variable to store the final minimum value
let ~min: N32;
let it = list.iter();
while it.next() is Some(&val) {
// as we iterate over the list, we update `min_acc` as usual
if min_acc is Some(m) => val < m {
min_acc = Some(val);
}
// but we simultaneously subtract the final minimum from the value
val -= ~min;
}
// now that we know the real minimum, we set the inverted variable,
// propagating this value back to all of the loop iterations
~min = min_acc.unwrap();
}
This is mind-bending to think about, but extremely useful once you get the hang of it!
Any value can be considered to be an expectation of an expectation of
itself. Consider the value 5
. It expects to be used, at some point. Let's
call the thing that will use it u
. u
is expecting an N32
. (That
expectation will eventually be fulfilled with 5
.) Since u
is expecting
an N32
, u
is an ~N32
. Since 5
is expecting u
, and u
is an
~N32
, 5
is an ~~N32
(in addition to being an N32
). Thus, the types
N32
and ~~N32
are equivalent.
The Vine Compiler
An introduction to the internals of the Vine compiler. Here be dragons!
Architecture
The cli is the entrypoint to the compiler, and collects all of the compilation options. Most notably, it creates a list of entrypoints, paths to top-level modules (the main module, the standard library, and any third-party libraries).
The loader then takes this list of entrypoints and queries the file system for their contents. It invokes the parser (which in turn invokes the lexer) to parse the file into an AST. The loader then finds any files included in this AST, and recursively loads them. Once this process is complete, the loader returns an AST node representing the entire compilation unit.
The resolver then takes the AST and uses it to build a module graph. The
nodes of the module graph are called definitions. Definitions can have
different kinds of bindings, usually represented as AST nodes (taken out of
the original compilation unit AST). For example, an fn
item will have a value
binding, and the parameters and body of the function will be represented as AST
nodes. The resolver also disambiguates certain AST nodes, e.g. differentiating
local variables from constants.
The checker then passes over the module graph and checks the types and forms of all expressions. It also disambiguates AST nodes based on the inferred types and forms; e.g. desugaring method calls and inserting explicit coercions.
The distiller then takes every value binding and distills the AST into Vine Intermediate Representation. VIR is much simpler than the AST; compound expressions are distilled into sequential, atomic steps, and high-level control-flow constructs are distilled into a Stacked Flow Graph.
The normalizer then transforms the VIR to remove all of the divergence.
The analyzer then performs various analyses on the VIR, including reachability analysis, and dataflow analysis (determining which locals are used by which stages, and how).
The emitter then converts the VIR into a collection of Ivy nets.
Stacked Flow Graphs
Many compilers use Control Flow Graphs (CFGs) to represent the control flow of a function. The Vine compiler uses Stacked Flow Graphs (SFGs), an extension of CFGs, a representation that allows for greater parallelization (among other benefits).
Layers, Stages, and Steps
An SFG consists of a forest of layers. Each layer is itself analogous to a CFG, and consists of several stages (analogous to basic blocks in a CFG).
Flow begins in the initial stage of the initial layer. Flow can transfer between layers. Once flow exits a layer, flow returns to the previous layer. This is analogous to a call stack (hence the "stacked" flow graph).
Each stage contains a series of steps. Flow visits each step in a stage sequentially.
A step can:
- invoke a variable
- transfer to another layer
- diverge to an ancestor layer
- perform a primitive operation, such as constructing a tuple, or calling a function
After the last step of a stage, if the stage has a terminator, flow transfers to another stage in the same layer. Otherwise, flow exits the layer.
Interfaces and Transfers
Every stage has an interface. Within a layer, multiple stages may have the same interface. Every interface has one or more stages; interfaces with exactly one stage are unconditional.
A transfers specifies a target interface. If there are multiple stages with that interface, the transfer must supply a payload which will determine which stage is transferred to. The interface specifies the type of this payload, and how it is used.
For example, a boolean interface has two stages. One stage is associated with a
payload of true
; the other with false
.
Forestry and Divergence
Recall that layers are structured in a forest; the forest has one or more root layers, and every non-root layer has a single parent layer.
When transferring to another layer (i.e. in a transfer step), the target must be in either a root layer, or a child of the current layer.
A step can diverge, causing flow to immediately exit several layers. A diverge step specifies a target ancestor layer, and optionally a stage in that layer. Flow exits layers until it reaches the target ancestor layer. If a stage is specified, flow transfers to that stage; otherwise, the target layer is exited as well.
(Divergence is used to implement features like return
, break
, and
continue
. In the 'call stack' analogy, divergence is analogous to throwing an
exception that is caught by a function higher up on the stack.)
In Vine, SFGs are normalized before being emitted. Normalization removes all divergence by splitting stages at points where flow may diverge.
Ivy and the IVM
Vine compiles to Ivy, a low-level interaction-combinator programming language.
Ivy code runs on the IVM, a performant interaction combinator runtime.
Ivy Overview
An Ivy program consists of a series of named global nets. The name of a global
net must start with ::
, and may contain arbitrary identifier characters or
additional ::
s (e.g. ::foo::bar
).
Ivy nets are specified with a syntax based on the interaction calculus; each net has a root tree, attached to its singular free port; any number of pairs of trees; and a wiring specified by pairs of variables.
// definition of the net `::main` (which is the entrypoint of the program)
::main {
fn(io _) // <-- the root tree, a combinator with label `fn`
// ^ ^-- eraser node
// '----- a variable, representing one half of a wire
io = @io_print_char(::char::i @io_print_char(::char::v _))
// ^-- pair ^^^^^^^^^ ^^^^^^^^^
// global net reference extrinsic function node
}
// more global net definitions; here serving the role of constants
::char::i { 105 }
::char::v { 118 }
::char::nl { 10 }
// ^^-- external value node
Ivy's Interaction System
Ivy's interaction system is based on the symmetric interaction combinators, with various extensions.
Agent Types
Combinators
Ivy has a (theoretically) unlimited number of binary combinator agent types, each identified with a label. Two combinators of the same label annihilate, whilst two combinators of different labels commute.
Ivy also has an eraser agent, which is a nilary combinator.
Globals
Ivy programs are structured as a collection of named global nets. Each global net corresponds to a nilary global agent. A global agent expands into the corresponding global net when necessary during interaction.
Extrinsics
Extrinsic agents represent entities and operations external to the interaction net.
- extrinsic values are nilary agents that represent external entities
- extrinsic functions are n-ary agents that represent external operations
- extrinsic branches are ternary agents that represent a boolean query on an external entity
Extrinsics are discussed in more detail on the corresponding page.
Interaction Rules
Ivy has 7 categories of interaction rules:
- Annihilate
- Commute
- Copy
- Erase
- Expand
- Call
- Branch
For combinator agents, the Annihilate, Commute, Copy, and Erase rules behave equivalently to the standard rules for symmetric interaction combinators.
Annihilate
When two non-nilary agents of the same type interact, they annihilate. The wires previously connected to their auxiliary ports are linked together.
Commute
When two non-nilary agents of different types interact, they commute, analogously to interaction combinators.
Copy
When a nilary agent and a non-nilary agent of certain types interact, the nilary agent is copied to each of the non-nilary agent's auxiliary wires. This happens when:
- the nilary agent is an eraser
- the nilary agent is an extrinsic value, and the non-nilary agent is a combinator
- the nilary agent is a global agent, and the non-nilary agent is a combinator of a label that does not appear in the global net (or any global net transitively referenced)
Erase
When two nilary agents interact, they are erased.
Expand
When a global agent interacts with a non-nilary agent, it is expanded (unless the Copy rule above applies). The global agent is simply replaced with the corresponding global net (and the other agent is untouched).
Call
When an extrinsic value interacts with an extrinsic function, the associated operation is performed on the associated entity, and some extrinsic value is returned.
Branch
When an extrinsic value interacts with an extrinsic branch, the associated query is performed on the associated entity. Based on the boolean result, one of the first two auxiliary wires is linked to the third, and the other is connected to an eraser.
Statistics
The IVM collects various statistics during execution. The Vine and Ivy CLIs display these statistics after running a program.
Let's take a look at a sample stat block, section by section.
Interactions
Interactions
Total 11_106_946
Annihilate 4_319_083
Commute 33_750
Copy 2_378_685
Erase 315_132
Expand 556_910
Call 3_227_767
Branch 275_619
Total
shows the total number of interactions performed in the execution of the
program. This measures how much "work" was done, in a deterministic and
universal way; the same program will always have the exact same interaction
count, no matter what machine it is run on.
The other numbers break down the various types of interactions.
Memory
Memory
Heap 784 B
Allocated 242_850_688 B
Freed 242_850_688 B
Heap
shows the greatest size of the runtime's heap over the course of the
program, measured in bytes. In this case, it never needed to use more than a
kilobyte of heap space throughout the entire program.
Allocated
shows the total number of bytes allocated through the program, and
Freed
shows the total number of bytes freed. These numbers should always
match; if they differ, that indicates that there was a vicious circle in the
interaction net.
In this case, Allocated
and Freed
are much greater than Heap
; this shows
that the evaluator was reusing its memory buffer very effectively. (This is very
common for the IVM.)
These numbers are deterministic when the program is executed sequentially, but can vary when executed in parallel (since the OS's thread scheduling is non-deterministic).
Performance
Performance
Time 175 ms
Speed 63_375_794 IPS
Time
is the amount of time elapsed over the execution of the program.
Speed
is the speed of the execution, measured in IPS (interactions per
second), and equal to Interactions / Time
.
Parallel Statistics
Some statistics only apply to parallel execution.
Workload
Workload
Workers 16
Active 16
Minimum 312_096
Average 694_184
Maximum 855_071
Moved 111
Workers
is the number of worker threads available to the IVM.
Active
is the number of worker threads that were used.
Minimum
, Average
, and Maximum
describe the statistics of the number of
interactions performed by each active worker.
Moved
is the number of active pairs that were moved between workers.
Performance
Performance
Time 19 ms
Speed 569_167_395 IPS
Working 230 ms
Rate 48_201_985 IPS
Time
and Speed
are the same as in sequential execution.
Working
is the total of the amounts of time each worker was active.
Rate
is the average speed of an individual worker, and equal to
Interactions / Working
.
Extrinsics
Extrinsics are how programs running in the IVM interact with the outside world.
An extrinsic value is a nilary agent that represents an external entity. They are opaque to the interaction net, and can only be manipulated and queried via extrinsic functions and extrinsic branches.
An extrinsic function is an agent that represents an external operation. They operate solely on extrinsic values, and return only extrinsic values. Extrinsic functions may have side effects. Extrinsic functions are the only way for the interaction net to manipulate extrinsic values.
An extrinsic branch is a ternary agent that represents a boolean query on an external entity. They query a single extrinsic value, and select one of two branches. Extrinsic branches are the only way for the interaction net to inspect extrinsic values.
Currently, there is a fixed set of extrinsics built-in to IVM. In the future, they will be extensible (#54).
Extrinsic Value Types
N32
An N32
extrinsic value represents a 32-bit natural number. They can be written
as integer literals in Ivy programs.
Vine's N32
, Char
, and Bool
types are all represented by N32
extrinsic
values.
F32
An F32
extrinsic value represents a 32-bit floating-point number. They can be
written as float literals in Ivy programs.
IO
An IO
extrinsic value represents an IO handle, and can be used to perform IO.
IO
values cannot be written in Ivy programs. When an Ivy program is run, the
::main
net is connected to an IO
value. This is the only way to obtain an
IO
value.
Extrinsic Functions
General Arithmetic
The @add
/@sub
/@mul
/@div
/@rem
extrinsic functions perform arithmetic on
N32
or F32
values. They input two values and output one. If both inputs are
N32
s, the output will be an N32
; otherwise, the output is an F32
.
Comparisons
The @eq
/@ne
/@lt
/@le
extrinsic functions take two N32
or two F32
values and return an N32
value representing the result of the comparison (1
for true, and 0
for false).
Specialized Arithmetic
The
@n32_shl
/@n32_shr
/@n32_rotl
/@n32_rotr
/@n32_and
/@n32_or
/@n32_xor
extrinsic functions take two N32
values, perform a bitwise operation, and
output an N32
value.
The @n32_add_high
/@n32_mul_high
extrinsic functions return the 32
most-significant bits of the 64-bit result of an operation on two N32
values.
IO
The @io_print_char
extrinsic functions takes an IO
value and an N32
value
representing a Unicode scalar value, prints the character to stdout, and returns
an IO
value.
The @io_print_byte
extrinsic function takes an IO
value and an N32
value,
prints the least-significant byte to stdout, and returns an IO
value.
The @io_flush
extrinsic function takes an IO
value and an N32
value,
flushes stdout, discards the N32
, and returns an IO
value.
The @io_read_byte
extrinsic function takes an IO
value and an N32
value,
reads a byte from stdin, and returns that byte as an N32
, or the N32
parameter if no byte could be read.
The @seq
extrinsic function takes two extrinsic values and returns the first.
It is useful for sequentializing side effects from other extrinsic functions.