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.
Status
This document is incomplete. Sections on the following topics are missing:
- Errors handling
- Type casting
- Numeric overflow/underflow
- Anonymous structs
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;
}
default fn main() -> i32 {
std::io::print("Hello World!");
return 0;
}
Primitive types
The set of primitive types is the following.
u8 u16 u32 i8 i16 i32 bool char str
Note that there are no floats or 64-bit types in R’.
Literals
Booleans – bool
.
true false
Integers – i8 i16 i32 u8 u16 u32
.
8 41 -9 48193 0
Characters – char
.
'a' 'z', '\n'
Strings – str
.
"Hello World!\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.
Operators
R’ has arithmetic operators:
+ - * / %
Comparison operators, that yield bool
:
== != < > <= >=
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`.
}
Match statements
Match statements work similarly to other languages, and unlike C’s switch
,
there is no fallthrough.
match (x) {
case 1 => {
// `x` is `1`.
},
case 2, 3 => {
// `x` is `2` or `3`.
}
else => {
// `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 (let num in numbers) {
// `num` takes each value in the array.
}
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.
Argument labels can be optionally used for any of the arguments, as long as they are in the correct position.
// These are all equivalent.
add(a: 3, b: 2);
add(a: 3, 2);
add(3, b: 2);
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.length // 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
The underlying value 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.length // 3
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.
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 Color {
red,
green,
blue,
}
// Creating enum values.
let c: Color = Color::red;
// Comparing enum values.
if (c == Color::red) {
// Do something if color is blue.
}
Match statements can be used to match all enum variants:
match (color) {
case Color::red => ...
case Color::green => ...
case Color::blue => ...
}
Unions
A union
is a type that can take one of multiple values. It is like an
enum with associated data, or a C union
with an associated “tag”.
union Response {
success: Data,
failure: struct {
errorCode: u32,
message: str
}
offline,
}
The match
statement is useful for handling all union cases:
// Exhaustively match all union variants.
// Binds the associated value to an identifier via `as`.
match (resp) {
case Response::success as d: Data => {
// Data value assigned to `d`.
}
case Response::failure as f => {
handleFailure(f.errorCode, f.message);
}
case Response::offline => {
// Handle offline.
}
}
Constants
Constants are values defined at compile time.
const MAX_ENTITIES: usize = 4096;
const DEFAUL_COLOR: Color = Color::red;
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. There is no module definition syntax: each source file defines a module, named after the file. Sub-modules are defined using directories, 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.
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 match on the presence of a value with:
if match (lookup(k)) as path {
// This branch is evaluated if `lookup` returns a value,
// with `path` bound to it.
} else {
// This branch is evaluated if `lookup` returns `nil`.
}
We can also loop while a function returns a value, breaking as soon
as it returns nil
:
while match (nextResult()) as r {
// Evaluated as long as `r` is not `nil`.
}
Chaining:
// Assigns nil to `value` if it encounters a nil in the chain.
let value: ?str = getObject(name)?.path?.baseName();
Coalescing:
// Assigns `Path::new("/")` if `optionalPath` is nil.
let value: Path = optionalPath ?? Path::new("/");
A regular match
statement can also be used:
match (optional) {
case value => {
// Handle presence of value.
}
else => {
// Handle absence of value.
}
}
Naming conventions
- Types use
PascalCase
. - Constants use
SCREAMING_SNAKE_CASE
. - Everything else uses
camelCase
, eg. local variables, enum variants, struct fields, modules and functions.