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.
Status
This document is incomplete. Sections on the following topics are missing:
- Errors handling
- 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;
}
use std;
fn main() -> i32 {
std::io::print("Hello World!");
return 0;
}
Reserved words
R’ has 29 reserved words. These words cannot be used as module, variable or function names.
mod use fn if else while loop for
extern pub return break continue switch case in
let mut union enum struct const as
and or not true false nil
Primitive types
The set of primitive types is the following.
u8 u16 u32 i8 i16 i32 bool
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.
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`.
}
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 num in numbers {
// `num` takes each value in the array.
}
for
loops have an optional else
clause that is executed when the iterator
is exhausted.
let numbers: [i32; 5] = [1, 2, 3, 4, 5];
for num in numbers {
// `num` takes each value in the array.
} else {
std::io::print("Visited all numbers!");
}
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.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
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.len // 3
// Slice pointer. Points to first element of slice and has type *[u8].
suffix.ptr
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.
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 => ...
}
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 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.
}
}
Constants
Constants are values defined at compile time.
const MAX_ENTITIES: u32 = 4096;
const DEFAUL_CHANNEL: Channel = Channel::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. 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
condition yields nil
, but not if the loop exits from a break
statement.
while let r = nextResult() {
// Evaluated as long as `r` is not `nil`.
} else {
std::io::print("Results have been exhausted!\n");
}
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.
} else {
// Handle absence of value.
}
}
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.
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.