Skip to content
volt
GitHub

Basics

val declares a name whose value can’t change: it can’t be reassigned, and nothing changes it through a reference either (references to a val). var declares one that can. The type comes from the value, or is written after a colon. A var declared without a value starts at zero.

use std::io;
fn main() -> void {
val answer = 42; // i32: an integer literal's default type
val ratio = 0.5; // f64
var count: u8 = 1;
count += 2;
var buffer: u8[4]; // zeroed
buffer[0] = count;
std::println("{} {} {} {}", answer, ratio, count, buffer[0]);
}
// expect: 42 0.5 3 3

Function parameters are vals too; fn f(var x: i32) gives the function its own mutable copy.

Type What it is
i8 i16 i32 i64 i128, isize signed integers
u8 u16 u32 u64 u128, usize unsigned integers (usize for sizes and indexes)
f16 f32 f64 f128 floating point
bool true or false
str UTF-8 text: a pointer and a length, not null-terminated
cstr a null-terminated C string (string literals convert to it)
void, never no value; never returns (@panic, exit, an endless loop)
T[N], T[] an array; T[] takes its length from the initializer
T[..] a slice: a view into an array (pointer and length)
(A, B) a tuple
T? an optional
E!T an error union
T&, T* a reference and a pointer
fn(A) -> R a function value
type a type, as a value at compile time

f128 is IEEE binary128. Most CPUs have no instructions for it, so its arithmetic is calls to soft-float routines: the C runtime’s (libgcc’s or compiler-rt’s), and where there is none with them (aarch64 macOS, bare metal and WebAssembly) Volt’s own std::softfloat, exported under the same names. % is always std::softfloat’s: the exact remainder, as C’s fmod gives it, on every target.

An f128 constant has all of binary128’s precision. A literal like 0.1 is read straight to the nearest binary128, not through an f64, and val THIRD: f128 = 1.0 / 3.0; is folded at compile time in binary128, rounding each step as the same operation at run time would.

type name = T; gives a type another name. It’s the same type, not a new one: a meters is an f64 wherever an f64 goes. An alias can be generic, taking its arguments where it’s used, and in a package it’s the package’s own unless marked public, like any other declaration.

use std::io;
type meters = f64;
<T: type>
type list = std::vec<T>;
fn total(a: meters, b: meters) -> meters {
return a + b;
}
fn main() -> void {
var xs: list<i32> = {};
xs.push(3);
std::println("{} {}", total(1.5, 2.0), xs.len);
}
// expect: 3.5 1

An alias can’t be defined in terms of itself (type a = b; type b = a; is an error).

use std::io;
fn main() -> void {
val big = 1_000_000;
val mask = 0xFF;
val bits = 0b1010;
val perms = 0o755;
val sci = 1.25e1;
val letter = 'a'; // a u8
val text = "tab\there"; // escapes: \n \t \r \0 \\ \" \' \xNN \u{...}
std::println("{} {} {} {} {} {}", big, mask, bits, perms, sci, letter);
std::println("{}", text);
}
// expect: 1000000 255 10 493 12.5 97
// expect: tab here

A literal takes the type its place wants. On its own, an integer is an i32 (an i64, then an i128, when it doesn’t fit), a float an f64, and a character its code point: a u8 up to 255, a u32 past it. Integer literals go up to u128’s maximum (2^128 - 1); one past i128’s maximum needs a u128 place (val all: u128 = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF; or @cast<u128>(...)).

""" starts a string on the next line, and a """ on a line of its own ends it. The closing quotes’ indentation comes off every line, so the text sits indented with the code around it; the lines are joined with \n, without one after the last (leave an empty line before the closing quotes for that). Escapes work as in "...", and \""" puts three quotes in. r"..." and r"""...""" are raw: a backslash is just a backslash.

use std::io;
fn main() -> void {
val page = """
<ul>
<li>one</li>
</ul>
""";
std::println(page);
std::println(r"C:\temp\new");
std::println(r"""
\d+\.\d+ "quoted"
""");
}
// expect: <ul>
// expect: <li>one</li>
// expect: </ul>
// expect: C:\temp\new
// expect: \d+\.\d+ "quoted"

The arithmetic, comparison, logical and bitwise operators are C’s. What’s different:

  • Integer overflow panics in debug builds and wraps in --release builds (dividing by zero panics in debug builds too: see Safety). +%, -% and *% always wrap, for hashes and checksums; on two literals they wrap in the type the value gets (val b: u8 = 134 *% 115; is 50).
  • A shift by at least the type’s width (x >> 64 on a u64) panics in debug builds; --release builds shift by the amount modulo the width.
  • x++ and x-- are statements, not expressions.
  • &, | and ^ bind tighter than comparisons: x & 1 == 1 is (x & 1) == 1, not C’s x & (1 == 1). as binds tighter than arithmetic: n as i64 * 2 converts n, then multiplies.
  • A value converts by itself where nothing can be lost: an integer to a wider one of the same signedness (u32 to u64, i8 to i32), an unsigned one to a wider signed one (u32 to i64), usize and u64 either way on a 64-bit target (and isize and i64), u32 and usize either way on a 32-bit one (usize is as wide as a pointer), an integer to a float that holds all its values (i32 or u32 to f64, u16 to f32), and a float to a wider one (f32 to f64). So xs.len passes as a u64, and n * 1.5 with an i32 n is an f64.
  • x as T converts only when nothing can be lost (widening an integer or a float, a bool to an integer, T& to T* or void*; any integer to a float, rounding when it must, and a plain enum to an integer); anything else is a compile error. @cast<T>(x) converts anything to anything, unchecked: narrowing (u64 to u32) and changing sign take it.
  • a ?? b is the value of optional a, or b when it’s null.
  • == and != on a struct (or an enum with payloads) call its eq(other): one it attaches, like std::string’s (attach operator == is another way to write it), or a derived one. A type without one can’t be compared. Structs and enums get the other operators the same way: see Operators. eq takes one side by reference, so both sides can’t be temporaries (make() == make(): store one in a variable first). Arrays, tuples and slices don’t take ==; a slice’s eq(&other) compares it element by element.
  • a..b and a..=b are ranges (exclusive and inclusive).
  • Operands and arguments are evaluated left to right. place = value evaluates the value first, then the place; place += value evaluates the place first, since it reads it.
  • A statement that only computes a value (x + 1;, a == b;) is an error, since it does nothing. When it looks like a missing =, the message says so: x + 1; suggests +=, a == b; suggests =.
use std::io;
fn main() -> void {
val small: u8 = 200;
val wide = small as i32; // fine: every u8 fits in an i32
val back = @cast<u8>(wide + 100); // unchecked: 300 becomes 44
val hash: u32 = 4000000000 *% 3; // wraps on purpose
std::println("{} {} {}", wide, back, hash > 0);
}
// expect: 200 44 true

if and while take a bool in parentheses (or an optional or a pointer, which they check for null and narrow), and their bodies always have braces (an if without them is a value, below).

use std::io;
fn main() -> void {
var n = 27;
var steps = 0;
while (n != 1) {
if (n % 2 == 0) {
n /= 2;
} else {
n = 3 * n + 1;
}
steps++;
}
std::println("{}", steps);
}
// expect: 111

if (val v = e) and while (val x = e) bind the value of an optional or an error union when it has one; see Binding in if and while.

Without braces, if (c) a else b is an expression: its value is a when c holds, else b. The else is required, else if chains, and the arms are expressions, as a match’s are; they take their type from where the value goes. A condition narrows an optional in the arm as it does in a statement if, and the binding forms work too. An arm can also leave (else return 0), or be a block that leaves (else { ...; return; }).

use std::io;
fn sign(x: i32) -> str {
return if (x < 0) "negative" else if (x == 0) "zero" else "positive";
}
fn main() -> void {
val name: str? = "volt";
val len = if (val n = name) n.len else 0;
val carry: u32 = if (len > 3) 1 else 0;
std::println("{} {} {}", sign(-5), len, carry);
}
// expect: negative 4 1

Choosing a value this way compiles to a branch-free select where it can, as C’s ?: does; an if statement that assigns in both branches may compile to a jump instead, which costs more when the condition is hard to predict.

for walks a range, an array, a slice or a str (its bytes). A second name gets the index. for (x&) binds each element by reference instead of copying it, so the loop can change it.

use std::io;
fn main() -> void {
var scores: i32[] = { 70, 85, 90 };
for (s&) in scores {
*s += 5;
}
for (s, i) in scores {
std::print("{}:{} ", i, s);
}
std::println("");
for (i) in 0..=3 {
std::print("{} ", i);
}
std::println("");
}
// expect: 0:75 1:90 2:95
// expect: 0 1 2 3

A type that attaches next(this: T&) -> X? is an iterator: for calls next until it returns null, binding each value. next can return a pointer, X*, instead: each element then binds as an X&, so the loop can change what it points at, and collections iterate without copying. An iterator in a var is advanced by the loop; a val one is copied first, and a temporary (like m.iter()) lives until the loop ends. The index, labels, break, continue and accumulators work as they do for arrays.

use std::io;
struct countdown {
n: i32;
}
attach fn next(this: countdown&) -> i32? {
if (this.n == 0) {
return null;
}
this.n -= 1;
return this.n + 1;
}
fn main() -> void {
val c: countdown = { n: 3 };
for (x, i) in c {
std::print("{}{}", x, i);
}
std::println("");
}
// expect: 302112

loop repeats until a break. break can carry a value, which makes loop and labelled blocks expressions. A label is :name before a loop or a block; break :name and continue :name use it.

use std::io;
fn main() -> void {
var x = 1;
val first = loop {
if (x * x > 50) {
break x;
}
x++;
};
val sign = :pick {
if (first < 0) {
break :pick "negative";
}
break :pick "positive";
};
:rows for (r) in 0..3 {
for (c) in 0..3 {
if (c > r) {
continue :rows;
}
std::print("{}{} ", r, c);
}
}
std::println("| {} {}", first, sign);
}
// expect: 00 10 11 20 21 22 | 8 positive

A for can also build a value: after the iterable, [ var name: T = start ] declares an accumulator, and the loop’s value is what it holds at the end.

use std::io;
fn main() -> void {
val sum = for (v) in 1..=10 [ var acc: i32 = 0 ] {
acc += v;
};
std::println("{}", sum);
}
// expect: 55

=> expr after the iterable (before any accumulator) replaces each element with a value worked out from it and the index, before the body runs:

use std::io;
fn main() -> void {
val words: str[] = { "volt", "is", "fast" };
val letters = for (w) in words => w.len [ var n: usize = 0 ] {
n += w;
};
std::println("{}", letters);
}
// expect: 10

An array’s length is part of its type, and an array type reads left to right: u8[2][3] is three u8[2]s. Slicing an array (a[1..3], a[1..] and a[..3] to an end, or a[..] for all of it) gives a T[..] that points into it without copying. Indexing is bounds-checked, in --release builds too (as a trap).

use std::io;
fn sum(xs: i32[..]) -> i32 {
var total = 0;
for (x) in xs {
total += x;
}
return total;
}
fn main() -> void {
val primes: i32[] = { 2, 3, 5, 7, 11 };
val middle = primes[1..4];
val evens: i32[] = 0..5; // a range fills an array: 0 1 2 3 4
std::println("{} {} {} {}", primes.len, sum(primes[..]), sum(middle), evens[4]);
}
// expect: 5 28 15 4

{} is an array of zeros (what a var without an initializer holds), and {x; n} is n copies of x, which runs once; n is a constant, the array’s length. Both work wherever an array is expected, struct field defaults and compile time included:

use std::io;
struct frame {
data: u8[16] = {};
tag: u8[2] = { 0xFF; 2 };
}
fn main() -> void {
val f: frame = {};
val ones: i32[] = { 1; 4 };
std::println("{} {} {}", f.data[15], f.tag[1], ones.len);
}
// expect: 0 255 4

Tuples group values without declaring a struct. Destructure them with val (a, b) = ..., or reach into them with .0, .1. Their elements can have names.

use std::io;
fn min_max(xs: i32[..]) -> (i32, i32) {
var lo = xs[0];
var hi = xs[0];
for (x) in xs {
if (x < lo) { lo = x; }
if (x > hi) { hi = x; }
}
return (lo, hi);
}
fn main() -> void {
val data: i32[] = { 4, -2, 9 };
val (lo, hi) = min_max(data[..]);
val point: (x: i32, y: i32) = (3, 4);
std::println("{} {} {} {}", lo, hi, point.x, point.1);
}
// expect: -2 9 3 4