R'
R’, pronounced “R prime” is a lower level unsafe subset of the Radiance programming language, that serves as the foundation for building the Radiant operating system and bootstrapping the full Radiance language. By providing direct memory access, pointer arithmetic, and hardware control capabilities, R’ enables the construction of essential system components like the kernel, device drivers, and runtime environment, on top of which Radiance applications can be built.
The R’ subset will therefore be designed and implemented first, after which the full language will be built using R’ as the implementation language. R’ is focused on simplicity of implementation, predictability, and familiarity with other system languages.
Compared to Radiance
Compared to Radiance, R’ has:
- No polymorphism
- No floating point types
- No dynamic memory allocation
- No reference type
- No methods
This keeps the language small and simpler to reason about.
Basic syntax
R’ programs consist of statements that end with semicolons. Blocks of code are enclosed in curly braces.
// This is a single-line comment.
let x: i32 = 42;
if x > 0 {
x = x * 2;
}
use std;
fn main() -> i32 {
std::io::print("Hello World!");
return 0;
}
Reserved words
R’ has 43 reserved words. These words cannot be used as module, variable or function names.
mod use fn if else while loop throw
extern pub return break continue switch case throws
and or not true false for catch
let mut enum struct in try
undefined nil align static const as
Primitive types
The set of primitive types is the following. Primitive type names are part of the reserved word set.
u8 u16 u32 i8 i16 i32 bool void
Note that there are no floats or 64-bit types in R’.
Primitive type literals
Booleans – bool.
true false
Integers – i8 i16 i32 u8 u16 u32.
8 41 -9 48193 0
Characters – u8.
'a' 'z', '\n'
Nil. Used when there is an absence of value.
nil
Bindings
R’ has immutable let and mutable mut bindings.
// Immutable binding.
let f: u8 = 42;
// Mutable binding.
mut g: u8 = 24;
Values can be re-assigned if declared with mut.
mut x: u8 = 16;
x = x * 2;
x = x + 1;
Bindings must always include a type annotation separated by a : from the
identifier. Note that assignment is not an expression, unlike in C.
If a value is to be left uninitialized, it can be assigned the undefined
value.
let xs: [u8; 256] = undefined;
Bindings can also have their storage aligned to a value greater than the alignment of the type being bound.
let xs: [u8; 256] align(16) = undefined;
Operators
R’ has arithmetic operators:
+ - * / %
Comparison operators, that yield bool:
== != < > <= >=
Bitwise operators, that yield any integer type:
& | ^ ~
And logical operators that take boolean operands and yield a bool.
and or xor not
Comments
As seen above, comments start with // and go until the end of the line.
// This is a comment.
// The comment continues on the next line.
If statements
if x > 0 {
// Code executed if `x > 0`.
} else if x < 0 {
// Code executed if `x < 0`.
} else {
// Code executed if `x == 0`.
}
Switch statements
Switch statements work similarly to other languages, and unlike C’s switch,
there is no fallthrough.
switch x {
case 1 => {
// `x` is `1`.
},
case 2, 3 => {
// `x` is `2` or `3`.
}
default => {
// `x` is another value.
}
}
Loops
The simplest loop is the unconditional loop, introduced with the loop keyword.
The break statement can be used to exit a loop, and continue can be used
to jump back to the beginning of the loop.
mut i: i32 = 0;
loop {
i = i + 1;
if i > 8 {
break; // Exit the loop.
}
if i % 2 == 0 {
continue; // Skip to next iteration.
}
}
While loops
While loops specify a condition under which the loop should continue.
mut i: i32 = 0;
while i < 8 {
i = i + 1;
}
For loops
For loops are used for iterating over a collection.
let numbers: [i32; 5] = [1, 2, 3, 4, 5];
for num in numbers {
// `num` takes each value in the array.
}
for num in 1..6 {
// This loop is equivalent to the one above.
}
for loops have an optional else clause that is executed if the loop body
was not entered due to the iterator being empty.
let numbers: [i32; 5] = [1, 2, 3, 4, 5];
for num in numbers {
// `num` takes each value in the array.
} else {
std::io::print("No numbers to iterate over!");
}
Functions
Functions are defined using the fn keyword.
// Function with no parameters and a `i32` return type.
fn answer() -> i32 {
return 42;
}
// Function with parameters.
fn add(a: i32, b: i32) -> i32 {
return a + b;
}
// Function with no return value.
fn processOp(op: u32) {
// An optional `return` statement with no value can be used
// to return early.
return;
}
Functions are called using () syntax.
let x: i32 = answer(); // `42`
let y: i32 = add(3, 2); // `5`
processOp(0); // Does not return a value. Cannot be assigned.
Functions can be stored and passed to other functions.
fn request(r: Req, callback: fn (Res) -> bool) {
...
}
request(req, handleResponse);
Arrays
Arrays are fixed-size collections of elements of the same type.
array type = "[" element type ";" array length "]"
// Declaration and initialization.
mut numbers: [i32; 5] = [1, 2, 3, 4, 5];
// Accessing elements (zero-indexed).
let first: i32 = numbers[0]; // 1
let last: i32 = numbers[4]; // 5
// Array elements can be assigned if the array is declared as mutable.
numbers[2] = 42;
// Arrays can have their length taken.
numbers.len // 5
Arrays are passed and returned by value from functions.
Pointers
Pointers provide a way to refer to a value without copying it.
let x: i32 = 42;
let p: *i32 = &x; // `p` is a pointer to `x`.
Pointers can be dereferenced using the * operator:
let y: i32 = *p; // y = 42
Mutable pointers can be used to pass a mutable reference to a value.
let p: *mut i32 = &mut x;
The underlying value of a mutable pointer can be modified via dereferencing:
*p = 100; // `x` is now `100`
Slices
let numbers: [i32; 5] = [0, 1, 2, 3, 4];
// Full slice.
let full: *[i32] = &numbers[..]; // Elements `[0, 1, 2, 3, 4]`
// Partial slice with start and end indices.
let middle: *[i32] = &numbers[1..4]; // Elements `[1, 2, 3]`
// Slice from start to a specific index.
let prefix: *[i32] = &numbers[..3]; // Elements `[0, 1, 2]`
// Slice from a specific index to the end.
let suffix: *[i32] = &numbers[2..]; // Elements `[2, 3, 4]`
// Slice length.
suffix.len // 3
// Slice pointer. Points to first element of slice and has type *[u8].
suffix.ptr
Mutable slices, like mutable pointers allow modification of the referenced values.
let slice: *mut [i32] = &mut numbers[..];
slice[3] = 6; // `numbers` is now `&[0, 1, 2, 6, 4]`.
Strings
Strings are u8 slices, or *[u8]. Here is a string literal:
"Hello World!\n"
Structs
Structs are custom data types that group related values called fields.
// A struct definition.
struct Point {
x: i32,
y: i32,
}
Struct literal syntax allows for struct initialization.
// Creating a struct instance via a struct literal.
mut p: Point = Point { x: 10, y: 20 };
Structs are accessed via the . field access syntax.
p.y = p.x * 2;
Structs can have default field values, making their initialization optional:
struct Size {
w: u32 = 1, // Set default value to `1`.
h: u32 = 1, // Set default value to `1`.
}
// `Size { w: 2, h: 1 }`.
let s: Size = Size { w: 2 };
Structs are passed and returned by value from functions.
Anonymous structs allow for inline ad-hoc struct definitions.
// An anonymous struct binding.
let s: struct { x: i32, y: i32 } = { 1, 2 };
Enums
Enumerations define a type with a fixed set of possible values, called variants. Unlike C enumerations, they are strongly typed in R’ and can’t be compred to integers without casting.
// Enum definition with three variants.
enum Channel {
Red,
Green,
Blue,
}
// Creating enum values.
let c: Channel = Channel::Red;
// Comparing enum values.
if c == Channel::Red {
// Do something if channel is red.
}
Switch statements can be used to match all enum variants:
switch channel {
case Channel::Red => ...
case Channel::Green => ...
case Channel::Blue => ...
}
Associated data
The enum type can also be used to represent tagged unions.
enum Response {
Success: Data,
Failure: struct {
errorCode: u32,
message: str
}
Offline: void,
}
The switch statement is useful for handling all union cases:
// Exhaustively match all union variants.
// Binds the associated value to an identifier via `as`.
switch resp {
case Response::Success(d) => {
// Data value assigned to `d`.
}
case Response::Failure(f) => {
handleFailure(f.errorCode, f.message);
}
case Response::Offline => {
// Handle offline.
}
}
If only one variant needs to be matched, if case can be used:
if case Response::success(d) = resp {
// Handle success case.
}
Constants
Constants are values defined at compile time. They must be initialized with a constant expression.
const MAX_ENTITIES: u32 = 4096;
const DEFAUL_CHANNEL: Channel = Channel::red;
Static
Static variables, unlike constants have storage at a fixed memory location.
However, unlike regular variables, static variables live for the entire
duration of the program.
static COUNTER: u32 = 0;
Modules
Modules are used to organize code. They effectively function as both
compilation units, and namespaces. Modules can have sub-modules, and expose
public functions and constants. Each source file defines a module, named after
the file. Sub-modules are defined using directories and declared with the mod
keyword. Hence, the following hierarchy:
std/
└─ net/
└─ tcp.r
Defines the following module hierarchy:
std::net::tcp
Modules control the visibility of their members via the pub keyword, which
signifies public visibility. Members not marked as pub are private.
For example, inside std/net/tcp.r we could define a public function as such:
pub fn connect(addr: Ip) {
...
}
To use this function, the use keyword is employed with a module name.
use std;
...
std::tcp::connect(addr);
We can directly import sub-modules, which avoids having to repeat the full module path on every call site:
use std::tcp;
...
tcp::connect(addr); // `std` can be omitted, since `tcp` is imported.
Module declarations create the necessary symbols for importing modules. In
std/net.r, we would declare the tcp module as so:
pub mod tcp;
Optional types
To deal with the absence of a value, R’ has the concept of optional types, or
optionals. The syntax for optionals is ?T, where T is any regular type.
For example ?i32 represents an optional i32. Optional types either carry
the base value T, or nil.
Given a function lookup that returns an optional Path:
fn lookup(key: u32) -> ?Path {
...
}
We can branch on the presence or absence of a value with if let:
if let path = lookup(k) {
// This branch is evaluated if `lookup` returns a value,
// with `path` bound to it.
} else {
// This branch is evaluated if `lookup` returns `nil`.
}
An optional guard can be added to the if let condition.
if let path = lookup(k); path != "" {
// Path was found and is not empty.
}
We can also loop while a function returns a value, breaking as soon
as it returns nil, with a similar construct:
while let r = nextResult() {
// Evaluated as long as `r` is not `nil`.
}
An optional guard can be added to the while let condition.
while let r = nextResult(); r.status > 0 {
// Evaluated as long as `r` is not `nil`.
}
while loops have an optional else clause as well that is executed when the
loop condition is never met.
while let r = nextResult() {
// Evaluated as long as `r` is not `nil`.
} else {
std::io::printLn("No results found!");
}
Chaining:
// Assigns nil to `value` if it encounters a nil in the chain.
let value: ?*[u8] = getObject(name)?.path?.baseName();
Coalescing:
// Assigns `Path::new("/")` if `optionalPath` is nil.
let value: Path = optionalPath ?? Path::new("/");
A regular switch statement can also be used:
switch optional {
case value {
// Handle presence of value.
} default {
// Handle absence of value.
}
}
Errors
R’ has built-in error handling similar to languages like Swift and Zig.
/// A network error.
enum NetworkError {
DialError,
PermissionError,
Disconnected,
}
/// Function that sends a request and can fail with `NetworkError`.
fn request(req: Req) -> Res throws NetworkError {
...
if not online {
// Throw an error.
throw NetworkError::Disconnected;
}
...
return res;
}
// Try the request, and propagate the error if it fails.
let resp: Res = try request(req);
// Try the request, and panic if it fails.
let resp: Res = try! request(req);
// Try the request, and convert result into an optional.
if let resp = try? request(req) {
// Success.
}
// Try the request, and handle the error if it fails.
let resp: Res = try request(req) catch err {
return Status::Failed;
};
Type casting
Casting between numeric types is possible using the as keyword.
let x: i16 = 13;
let y: i8 = x as i8;
If the integer doesn’t fit in the destination type, it is truncated.
Compiler built-ins
R’ provides several predefined constants that are available in the language environment. These constants are set at compile time.
@file // The current file path, eg. `/lib/core/io.rad`
@module // The current module path, eg. `core::io`
@function // The current function name, eg. `print`
@line // The current line number (1-indexed)
@column // The current column number (1-indexed)
Functions that work on types are also provided. These are evaluated at compile time.
@sizeOf(T) // Get the size of a type or expression
@alignOf(T) // Get the alignment of a type or expression
Integer overflow
Integer overflow and underflow wrap around according to RISC-V arithmetic behavior, where values that exceed the type’s range wrap to the opposite end of the representable range.
// 32-bit signed integer overflow.
let max: i32 = 2147483647;
let overflow: i32 = max + 1; // -2147483648
// 32-bit signed integer underflow.
let min: i32 = -2147483648;
let underflow: i32 = min - 1; // 2147483647
// 32-bit unsigned integer overflow.
let umax: u32 = 4294967295u;
let uoverflow: u32 = u_max + 1; // 0
Naming conventions
- Types use
PascalCase. - Constants use
SCREAMING_SNAKE_CASE. - Everything else uses
camelCase, eg. local variables, enum variants, struct fields, modules and functions.