Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

A tour of Vx

This chapter covers the ordinary parts of the language — everything you would need to write a command-line program, with no accelerator in sight. If you have written Rust, most of this will look familiar; the differences are called out where they matter.

Values and types

#![allow(unused)]
fn main() {
let x : i32 = 21;      // annotated
let y = 21;            // inferred from context
let mut count = 0;     // mutable
}

Bindings are immutable unless you write mut.

The primitive types are the ones you would expect: i8 i16 i32 i64, u8 u16 u32 u64, f16 f32 f64, and bool.

There are no implicit numeric conversions. An i32 does not become an i64 because the context wants one; you write the conversion. Integer literals infer to the type the context requires, so let n : i64 = 5; is fine, but mixing two differently-typed values in one expression is an error. This is deliberate — silent widening is a common source of both bugs and unintended performance cliffs.

Functions

#![allow(unused)]
fn main() {
fn add(a : i32, b : i32) -> i32 {
    return a + b;
}
}

Parameter types and the return type are both mandatory — there is no inference for either, and a function with no useful result is written -> void. A function may end with a bare expression instead of return, as in Rust.

Control flow

#![allow(unused)]
fn main() {
if x > 10 {
    // ...
} else if x == 10 {
    // ...
} else {
    // ...
}

for i in 0..n {
    // ...
}

loop {
    // forever, until you break
    if done { break; }
}
}

There is no while. It is not a keyword, and writing one is a parse error. The two loops are for over a range and bare loop with an explicit break.

0..n is a half-open range: it includes 0 and excludes n.

Structs

struct Point {
    x: i32,
    y: i32,
}

fn main() -> i32 {
    let p = Point { x: 3, y: 4 };
    let a = p.x;
    return a;
}

Methods go in an impl block, and the receiver is written out in full — there is no implicit self:

#![allow(unused)]
fn main() {
impl Point {
    fn magnitude_squared(self: &Point) -> i32 {
        return self.x * self.x + self.y * self.y;
    }

    fn translate(self: &mut Point, dx: i32, dy: i32) -> void {
        self.x += dx;
        self.y += dy;
    }
}
}

A return type is never optional. A function that produces no useful result returns void, and exits early with a bare return;.

&Point borrows immutably, &mut Point mutably. A method with no self parameter is an associated function, called as Point::make(...).

Enums and pattern matching

Enums carry data:

#![allow(unused)]
fn main() {
enum Result {
    Ok(i32),
    Err(i32),
}

enum Color {
    Red, Green, Blue,
}
}

Construct a variant with ::, and take it apart with match:

#![allow(unused)]
fn main() {
fn unwrap_or(r: Result, default: i32) -> i32 {
    let mut out = default;
    match r {
        Result::Ok(val) => { out = val; },
        Result::Err(code) => { out = -code; },
    }
    return out;
}
}

A data-carrying enum is laid out as a tag plus a payload.

A match can also be used as a value directly, with each arm evaluating to a result:

#![allow(unused)]
fn main() {
fn pick(x : i32) -> i32 {
    match x { 0 => { 7 }, _ => { 9 } }
}
}

Arrays and tensors

An array literal is a tensor:

#![allow(unused)]
fn main() {
let a : Tensor<f32, [4]> = [ 1.0, 2.0, 3.0, 4.0 ];
let first = a[0];
}

The shape is part of the type. Tensor<f32, [4]> has four elements, known at compile time. A ? marks a dimension that is only known at runtime:

#![allow(unused)]
fn main() {
fn matmul(a : Tensor<f32, [?, ?]>, b : Tensor<f32, [?, ?]>) -> Tensor<f32, [?, ?]> {
    let mut result : Tensor<f32, [?, ?]> =
        Tensor<f32, [?, ?]>::uninit([a.extent(0), b.extent(1)]);

    for i in 0..a.extent(0) {
        for j in 0..b.extent(1) {
            result[i][j] = 0.0;
            for k in 0..a.extent(1) {
                result[i][j] += a[i][k] * b[k][j];
            }
        }
    }
    return result;
}
}

.extent(n) reads the size of dimension n. Shapes that are known statically get checked statically — a matmul whose inner dimensions disagree is a compile error rather than a runtime one.

Collections

The standard library ships the usual containers. Vec<T> is a growable array:

import std::vec;

fn main() -> i32 {
    let mut v = Vec<i32>::new();
    v.push(10);
    v.push(32);
    return v.get(0) + v.get(1);
}

Also available: HashMap, HashSet, Option, Result, String, Box, and iterator adaptors. See the standard library for the full list.

Modules

One file is one module. import pulls another in:

#![allow(unused)]
fn main() {
import std::vec;
import std::io;
import graph::traversal;
}

std:: resolves against the standard library shipped with your toolchain. Anything else resolves against the library search path and then the current directory.

Unsafe

Raw pointers exist, and the operations that can go wrong with them require unsafe:

extern "C" {
    fn vx_vec_new_i32() -> *mut i8;
    fn vx_vec_push_i32(vec : *mut i8, val : i32) -> i32;
}

fn main() -> i32 {
    unsafe {
        let v = vx_vec_new_i32();
        vx_vec_push_i32(v, 42);
    }
    return 0;
}

Dereferencing a raw pointer, indexing through one, reading a field through one, and calling an unsafe fn all require an unsafe block. A function that takes a caller’s raw pointer and dereferences it is itself unsafe fn, so the obligation is visible in its signature rather than buried in its body.

extern "C" blocks declare foreign functions. Vx’s C ABI interop is zero-overhead: there is no marshalling layer.

Comptime

if comptime selects a branch at compile time. The branches not taken are pruned before semantic analysis, so they may refer to things that do not exist on the target being compiled for:

#![allow(unused)]
fn main() {
let mut val = 0;

if comptime Topology::Current == Topology::CPU {
    val = 1;
} else if comptime Topology::Current == Topology::CPU_AVX512 {
    val = 2;
} else {
    val = 3;
}
}

Topology::Current is the topology the current region compiles for. This is how one source file carries code for several targets without the dead paths having to typecheck against all of them.

if is also an expression:

#![allow(unused)]
fn main() {
return if val == 1 { 0 } else { 1 };
}

Const generics let a value appear in a type — Tensor<f32, [N]> for a const N : i32 — and are resolved by monomorphization.

What is next