-
-
Notifications
You must be signed in to change notification settings - Fork 23
Onyx Overview Documentation
This is a high-level overview of some the features of the Onyx programming language. A basic knowledge of programming and computer systems is assumed. This document is not designed to be read top-to-bottom, so feel free to jump around as it makes sense. Most of the examples can be copied into the main
procedure on Onyx Playground.
The following is the famous "Hello, World!" program, implemented in Onyx.
#load "core/std"
use core
main :: (args: [] cstr) {
println("Hello, World!");
}
When your program is saved to hello.onyx
, you can now compile and run it:
onyx run hello.onyx
You can also compile Onyx to a WebAssembly binary, and run it later:
onyx hello.onyx -o hello.wasm
onyx-run hello.wasm
Onyx shares many lexical/syntactic elements with Odin, and Jai (unreleased at time of writing). When I first started creating Onyx, I did not know the syntax I wanted so I started with something similar to those languages. After programming with it for a while, I fell in love with the syntax and it stuck. All credit for much of the syntactic consistency goes to Jai, with Odin as secondary inspiration.
Onyx has two comment types, very similar to another C-like languages. Unlike C, Onyx supports nested multi-line comments, which makes quickly commenting a large block of code easier, as you do not have to worry about a multi-line comment in the commented section.
// A single line comment
/*
A multi-line comment
*/
/*
/*
A nested multi-line comment
*/
*/
A complete list of Onyx's keywords:
package struct enum use
if else elseif defer
while for do switch case
break continue fallthrough return
sizeof alignof typeof cast
macro interface where
global (deprecated)
In order to reduce the number of keywords in the language, Onyx uses directives, which are symbols with a #
in front of them. They serve as keywords, without cluttering the list of reserved words. Some examples of directives are:
#load #load_all #load_path
#quote #match #foreign
#library #export #auto
There are too many directives to list here, and listing them does not help anyone. Most directives appear only in one specific place, and are not valid anywhere else in the code.
Onyx is largely white-space agnostic. White-space is only needed to separate keywords and symbols. Onyx does not care about spaces vs tabs. That being said, most code written in Onyx uses 4 spaces per indentation level.
Onyx uses semi-colons to delineate between statements; Because Onyx is white-space agnostic, something is needed to separate statements. Would you rather it be question marks?
Symbols in Onyx start with an alphabetic character or an underscore (_
), followed by 0 or more alphanumeric characters or underscores. They can be described using the following regular expression:
[a-zA-Z_][a-zA-Z0-9_]*
Onyx contains standard the boolean literals: true
and false
. They must be spelled all-lowercase as they are actually just global symbols. These means if you are feeling particularly evil, you could change what true
and false
mean in a particular scope.
Onyx contains the following numeric literals:
123 // Standard integers
0x10 // Hexadecimal integers
4.0 // Floating point
2.3f // Floating point, of type f32.
#char "a" // Character literals, of type u8.
Integer literals are special in that they are "un-typed" until they are used. When used, they will become whatever type is needed, provided that there is not loss of precision when converting. Here are some examples,
x: i8 = 10;
y := x + 100; // 100 will of type i8, and that is okay because
// 100 is in the range of 2's-complement signed
// 8-bit numbers.
x: i8 = 10;
y := x + 1000; // This will not work, as 1000 does not fit into
// an 8-bit number. This will result in a compile
// time error.
x: f32 = 10.0f;
y := x + 100; // 100 will be of type f32. This will work, as 100
// fits into the mantissa of a 32-bit floating
// point number, meaning that there is no loss
// of percision.
Onyx contains the following string-like literals:
"Hello!" // Standard string literals, of type 'str'.
#cstr "World" // C-String literals, of type 'cstr'.
""" // A multi-line string literal, of type 'str'.
Multi // Note that the data for the multi-line literal
line // starts right after the third quote, so technically
string // all of these "comments" would actually be part of the
literal // literal.
"""
In Onyx, there are 2 string types, str
and cstr
. cstr
is analogous to a char *
in C. It is a string represented as a pointer to an array of bytes, that is expected to end in a '\0'
byte. For compatibility with some C libraries, Onyx also has this type.
Most Onyx programs solely use str
, as it is safer and more useful. A str
is implemented as a 2 element structure, with a pointer to the data
, and an integer count
. This is safer, as a null-terminator is not necessary, so a buffer-overflow is much harder. To convert a cstr
to str
, use
string.from_cstr
.
null // Represents an empty pointer
null_proc // Represents an empty function pointer
You may be wondering why there is a separate value for an empty function pointer. This is due to the securer runtime of Onyx over C. In WebAssembly (Onyx's compile target), functions are completely separated from data. Function references are not pointers, they are indicies. For this reason, there are two different values that represent "nothing" for their respective contexts.
<variable name> : <declared type> = <initial value> ;
<declared type> is optional if <intial value> is present.
= <initial value> is optional.
Examples:
x: i32 = 10;
y: i32;
z := 10;
If a <declared type>
is not placed between the :
and =
, the type is inferred to be the same as the type of the <initial value>
.
There are 3 ways of expressing a block of code in Onyx, depending on the number of statements in the block.
{ stmt1; stmt2; ... } // The standard way, separating statements using semicolons
do stmt; // 'do' is used for a single statement.
--- // --- is used for no statements. Equivalent to {}.
You can of course write { stmt; }
instead of do stmt;
if you prefer.
Onyx uses a consistent syntax for binding "things" (procedures, structures, enums, etc.) to symbols.
symbol_name :: entity
entity
can be anything that is a compile-time known value.
Bindings can occur at the outermost scope, inside procedure bodies, or inside structure definitions.
This section describes the control flow mechanisms of Onyx. They are very similar to other C-inspired languages, with some notable exceptions.
if
statements allow the programmer to optionally execute a block of code, if a condition is met. If-statements in Onyx are written like this:
if condition {
println("The condition was true!");
}
Notice that there does not need to be parentheses around the condition. One thing to note is that the syntax for an else-if chain uses the keyword elseif
, not else if
.
if x >= 100 {
println("x is greater than 100");
} elseif x >= 10 {
println("x is greater than 10");
} else {
println("x is not special.");
}
If-statements can also have initializers, which are statements that appear before the condition. They allow you to declare variables that are only available in the scope of the if-statement, or any of the else
blocks.
can_error :: () -> (i32, bool) ---
if value, errored := can_error(); !errored {
printf("The value was {}!\n", value);
}
// value is not visible here.
while
statements are very similar to if
statements, except when the bottom of the while-loop body is reached, the program re-tests the condition, and will loop if necessary. While-statements have the same syntax as if-statements.
x := 10;
while x >= 0 {
println(x);
x -= 1;
}
while
statements can also have initializers, meaning the above code could be rewritten as:
while x := 10; x >= 0 {
println(x);
x -= 1;
}
while
statements can also have an else
block after them. The else
block is executed if the condition for the while
loop was never true.
while false {
println("Never printed.");
} else {
println("This will print.");
}
switch
statements are used to simplify a chain of if-else statements. Switch statements look a little different in Onyx compared to say C. This is because case
blocks are actually blocks, not just jump targets.
value := 10;
switch value {
case 5 {
println("The value was 5.");
}
case 10 do println("The value was 10.");
case #default {
println("The value was not recognized.");
}
}
#default
is used for the default case. The default case must be listed lexicographical as the last case.
case
blocks in Onyx automatically exit the switch
statement after the end of their body, meaning an ending break
statement is not needed. If you do however want to fallthrough to the next case like in C, use the fallthrough
keyword.
switch 5 {
case 5 {
println("The value was 5.");
fallthrough;
}
case 10 {
println("The value was (maybe) 10.");
}
}
switch
statements also allow you to specify a range of values using ..
. Note that this range is inclusive on both ends.
switch 5 {
case 5..10 {
println("The value was between 5 and 10.");
}
}
switch
statements can operate on any type of value, provided that an operator overload for ==
has been defined.
Point :: struct {x, y: i32;}
#operator == (p1, p2: Point) => p1.x == p2.x && p1.y == p2.y;
switch Point.{10, 20} {
case .{0, 0} do println("0, 0");
case .{10, 20} do println("10, 20");
case #default do println("None of the above.");
}
switch
statements can also optionally have initializers like while
and if
statements.
defer
statements allow you to run a statement or block when the enclosing block is exited.
{
println("1");
defer println("3");
println("2");
}
// Prints 1 2 3
defer
statements are pushed onto a stack. When the block exits, they are popped off the stack in reverse order.
{
defer println("3");
defer println("2");
defer println("1");
}
defer
statements enable the following "create/destroy" pattern.
thing := create_something();
defer destroy_something(thing);
Because deferred statements run in any case that execution leaves a block, they safely guarantee that the resource will be destroyed. Also, because defer
statements are stacked, they guarantee destroying resources happens in the correct order.
outer_thing := create_outer_thing();
defer destroy_outer_thing(outer_thing);
inner_thing := create_inner_thing(outer_thing);
defer destroy_inner_thing(inner_thing);
for
loops are the most powerful control flow mechanism in Onyx. They enable:
- Iteration shorthand
- Custom iterators
- Removing elements
- Scoped resources
A basic for
loop in Onyx. This will iterate from 1 to 9, as the upper bound is not included.
for i: 1 .. 10 {
println(i);
}
This for
loop is iterating over a range
. Ranges represent half-open sets, so the lower bound is included, but not the upper bound.
for
loops can also iterate over array-like types: [N] T
, [] T
, [..] T
. Use ^
after for
to iterate over the array by pointer.
primes: [5] i32 = .[ 2, 3, 5, 7, 11 ];
for value: primes {
println(value);
}
// This modifies the array so each element
// is double what it was.
for^ value: primes {
// value is a ^i32.
*value *= 2;
}
Naming the iteration value is optional. If left out, the iteration value will be called it
.
for i32.[2, 3, 5, 7, 11] {
println(it);
}
The final type that for
loops can iterate over is Iterator(T)
. Iterator
is a built-in type that represents a generic iterator. An Iterator
has 4-elements:
-
data
- a pointer to the context for the iterator. -
next
- a function to retrieve the next value out of the iterator. -
remove
- an optional function to remove the current element. -
close
- an optional function to cleanup the iterator's context. Thecore.iter
package provides many utilities for working with iterators.
Here is a basic example of creating an iterator from a range
, then using map
to double the values. Iterators are lazily evaluated, so none of the actual doubling happens until values are pulled out of the iterator by the for
loop.
doubled_iterator := iter.as_iterator(1 .. 5)
|> iter.map(x => x * 2);
for doubled_iterator {
println(it);
}
The above for
loop loosely translates to the following code.
doubled_iterator := iter.as_iterator(1 .. 5)
|> iter.map(x => x * 2);
{
defer doubled_iterator.close(doubled_iterator.data);
while true {
it, cont := doubled_iterator.next(doubled_iterator.data);
if !cont do break;
println(it);
}
}
As you can see, the close
function is always called after the loop breaks. If this is not the desired behavior, you can add #no_close
after for
to forego inserting the close
call.
doubled_iterator := iter.as_iterator(1 .. 5)
|> iter.map(x => x * 2);
for #no_close doubled_iterator {
println(it);
}
// Later:
iter.close(doubled_iterator);
The final feature of for
loops is the #remove
directive. If the current Iterator
supports it, you can write #remove
to remove the current element from the iterator.
// Make a dynamic array from a fixed-size array.
arr := array.make(u32.[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
// as_iterator with a pointer to a dynamic array
// supports #remove.
for iter.as_iterator(^arr) {
if it % 2 == 0 {
// Remove all even numbers
#remove;
}
}
// Will only print odd numbers
for arr do println(it);
The following keywords can be used to exit a block of code early.
break
can be used to jump execution to after the body of the enclosing loop.
// Prints 0 to 5.
for 0 .. 10 {
println(it);
if it == 5 do break;
}
continue
can be used to jump execution to the condition of the enclosing loop.
// Prints 5 to 9.
for 0 .. 10 {
if it < 5 do continue;
println(it);
}
fallthough
is discussed above in Onyx-Overview Documentation#switch statements section.
return
is used to end execution of the current procedure. It is also used to provide return values.
fact :: (n: i32) -> i32 {
if n <= 1 do return 1; // Early return
return fact(n - 1) * n; // Providing result
}
As a convenience, multiple jump statements can be placed next to each other. This changes the target of the jump statement. Consider this example.
for y: 0 .. 20 {
for x: 0 .. 20 {
if x == 15 && y == 3 {
// If you want to completely leave this block
// of loops, you can use `break break` to tell
// the outer for loop to break.
break break;
}
}
}
Other languages employ a block labeling system for this mechanism. That is not consistent with Onyx's programming style, so I decided to go with this slightly more verbose syntax.
Procedures allow the programmer to encapsulate behavior inside a reusable form. Other languages call them functions, subroutines or methods. "Procedures" is a super-set of all of those terms.
Procedures in Onyx are written simply as: (parameters) -> return_type { body }
. Generally procedures are bound to a name, but not necessarily.
say_hello :: () -> void {
println("Hello!");
}
procedure_as_an_expression :: () -> void {
// Assign to a local variable
say_hello := () {
println("Hello!");
};
say_hello();
}
If the procedure returns void
(i.e. returns nothing), the return type can be completely removed.
say_hello :: () {
println("Hello, no void!");
}
Procedures can take 0 or more parameters. All parameters are passed by value. Parameters that are passed by pointer copy the pointer value, not the data where the pointer is pointing.
print_add :: (x: i32, y: i32) {
printf("{} + {} = {}\n", x, y, x + y);
}
compute_add :: (out: ^i32, x: i32, y: i32) {
*out = x + y;
}
If two parameters have the same type, they can be written using the type only once.
// Because x and y are the same type,
// the ': i32' is not needed for x.
print_add :: (x, y: i32) {
// ...
}
Parameters can have default values. The default value is computed on the caller's side. In other words default values are not part of the procedures type. They are only a conveniences provided by a given procedure.
print_msg_n_times :: (n: i32, msg: str = "Hello, World!") {
for n do println(msg);
}
print_msg_n_times(10);
The type of a defaulted parameter can be omitted if the type of the expression is known.
// Because "Hello, World!" is known to be of type 'str',
// the type of msg can be omitted.
print_msg_n_times :: (n: i32, msg := "Hello, World!") {
for n do println(msg);
}
print_msg_n_times(10);
Procedures can return 0 or more values. Return types are specified after procedure arguments using an ->
. If multiple return values are desired, the return types have to be enclosed in parentheses. return
is used to specify returned values.
// A single integer return value.
add :: (x, y: i32) -> i32 {
return x + y;
}
// Returning 2 integers.
swap :: (x, y: i32) -> (i32, i32) {
return y, x;
}
z := add(2, 3);
a, b := 10, 20;
a, b = swap(a, b);
Note, returned values are passed by value.
Sometimes, the exact type of returned value is cumbersome to write out. In this case, #auto
can be provided as the return type. It automatically determines the return type given the first return
statement in the procedure.
// #auto would automatically determined to be:
// Iterator(i32), bool, str
weird_return_type :: (x: i32) -> #auto {
return iter.as_iterator(1 .. 5) , false, "Hello, World!";
}
In some cases in Onyx, it is actually impossible to write the return type. #auto
can be used in this case, and the compiler will figure out what type needs to be there.
Calling any procedure-like thing in Onyx uses the traditional ()
post-fix operator, with arguments in between. Arguments are separated by commas. Arguments can also be named. Once arguments start being named, all subsequent arguments must be named.
magnitude :: (x, y, z: f32) -> f32 {
return math.sqrt(x*x + y*y + z*z);
}
// Implicit naming
println(magnitude(10, 20, 30));
// Explicit naming
println(magnitude(10, y=20, z=30));
// Explicit naming, in diffrent order
println(magnitude(z=30, y=20, x=10));
Variadic procedures allow a procedure to take an arbitrary number of arguments. This function takes any number of integers and returns their sum.
// Variadic lists behave exactly like slices.
sum :: (ints: ..i32) -> i32 {
result := 0;
for ints do result += it;
return result;
}
println(sum(1, 2, 3, 4, 5));
Variadic procedures can also use the special type any
, to represent heterogeneous values being passed to the function. This function prints the type of each of the values given.
print_types :: (arr: ..any) {
for arr {
println(it.type);
}
}
// This outputs:
// [] u8
// i32
// (..any) -> void
print_types("Hello", 123, print_types);
Using #Runtime Type Information, functions can introspect the values given and perform arbitrary operations. For example, conv.format
uses type information to print anything of any type in the program.
// printf uses conv.format for formatting.
printf("{} {} {}\n", "Hello", 123, context);
Polymorphic procedures allow the programmer to express type-generic code, code that does not care what type is being used. This is by far the most powerful feature in Onyx.
Polymorphic procedures use polymorphic variables. A polymorphic variable is declared using a $
in front of the name. When calling a polymorphic procedure, the compiler will try to solve for all of the polymorphic variables. Then, it will construct a specialized version of the procedure with the polymorphic variables substituted with their corresponding value.
Here is an example of a polymorphic procedure that compares two elements.
min :: (x: $T, y: T) -> T {
if x < y do return x;
else do return y;
}
x := min(10, 20);
y := min(40.0, 30.0);
// Errors
// z := min("Hello", "World");
$T
declares T
as a polymorphic variable. When min
is called with two i32
s, the compiler solves for T
, finding it to be i32
. Then a specialized version of min
is constructed that operates on i32
s. A very similar thing happens for the second call, except in that case T
is f64
. Notice that any error will occur if min
is called with something that does not define the operator <
for T
.
Polymorphic variables can occur deeply nested in a type. The compiler employs pattern matching to solve for the polymorphic variable.
foo :: (x: ^[] Iterator($T)) {
// ...
}
val: ^[] Iterator(str);
foo(val);
Here is a simple pattern matching process that the compiler goes through to determine the type of $T
.
Variable Type | Given Type |
---|---|
^[] Iterator($T) |
^[] Iterator(str) |
[] Iterator($T) |
^[] Iterator(str) |
Iterator($T) |
Iterator(str) |
$T |
str |
If at any point the types do not match, an error is given. |
Parameters can also be polymorphic variables. If a $
is placed in front of a parameter, it becomes a compile-time "constant". A specialized version of the procedure is made for each value given.
add_constant :: ($N: i32, v: i32) -> i32 {
// N is a compile-time known integer here.
// It is the equivalent of writing '5'.
return N + v;
}
println(add_constant(5, 10));
Types can be passed as constant through polymorphic variables. Consider this example.
make_cubes :: ($T: type_expr) -> [10] T {
arr: [10] T;
for 0 .. 10 {
arr[it] = cast(T) (it * it * it);
}
return arr;
}
arr := make_cubes(f32);
Because T
is a constant, it can be used in the type of arr
, as well as in the return type.
With polymorphic variables and #auto
it is possible to write a completely type-generic procedure in Onyx.
print_iterator :: (msg: $T, iterable: $I) -> #auto {
println(msg);
for iterable {
println(it);
}
return 1234;
}
print_iterator("List:", u32.[ 1, 2, 3, 4 ]);
print_iterator(8675309, 5 .. 10);
No types are given in the procedure above. msg
and iterable
can be any type, provided that iterable
can be iterated over using a for
loop. This kind of procedure, one with no type information, is given a special shorthand syntax.
print_iterator :: (msg, iterable) => {
println(msg);
for iterable {
println(it);
}
return 1234;
}
print_iterator("List:", u32.[ 1, 2, 3, 4 ]);
print_iterator(8675309, 5 .. 10);
Here the =>
signifies that this is a quick procedure. The types of the parameters are left out, and can take on whatever value is provided. Programming using quick procedures feels more like programming in JavaScript or Python, so don't abuse them. They are very useful when passing procedures to other procedures.
map :: (x: $T, f: (T) -> T) -> T {
return f(x);
}
// Note that the paraentheses are optional if
// there is only one parameter.
y := map(5, value => value + 4);
println(y);
You can also have a mix between quick procedures and normal procedures. This examples shows an alternative way of writing -> #auto
.
// The => could be -> #auto
find_smallest :: (items: [] $T) => {
small := items[0];
for items {
if it < small do small = it;
}
return small;
}
println(find_smallest(u32.[6,2,5,1,10]));
Overloaded procedures allow a procedure to have multiple implementations, depending on what arguments are provided. Onyx uses explicitly overloaded procedures, as opposed to implicitly overloaded procedures. All overloads for the procedure are listed between the {}
of the #match
expression, and are separated by commas.
to_int :: #match {
(x: i32) -> i32 { return x; },
(x: str) -> i32 { return cast(i32) conv.str_to_i64(x); },
(x: f32) -> i32 { return cast(i32) x; },
}
println(to_int(5));
println(to_int("123"));
println(to_int(12.34));
The order of procedures does matter. When trying to find the procedure that matches the arguments given, the compiler tries each function according to specific order. By default, this order is the lexical order of the functions listed in the #match
body. This order can be changed using the #precedence
directive.
options :: #match {
#precendence 10 (x: i32) { println("Option 1"); },
#precendence 1 (x: i32) { println("Option 2"); },
}
// Option 2 is going to be called, because it has a smaller precedence.
options(1);
Rather non-intuitively, the lower precedence values are given higher priority.
Overloaded procedures as described would not be very useful, as all of the procedures would have to be known when writing the overload list. To fix this issue, Onyx has a second way of using #match
to add overload options to an already existing overloaded procedure.
options :: #match {
(x: i32) { println("Int case."); }
}
// #match can be used as a directive to specify a new overload option for
// an overloaded procedure. Directly after #match is the overloaded procedure,
// followed by the new overload option.
#match options (x: f32) { println("Float case."); }
#match options (x: str) { println("String case."); }
// As an alternative syntax that might become the default for Onyx,
// #overload can be used with a '::' between the overloaded procedure
// and the overload option.
#overload
options :: (x: cstr) { println("C-String case."); }
// A precedence can also be specified like so.
#match options #precedence 10 (x: i32) { println("Other int case."); }
Sometimes, the ability to add new overload options should be disabled to prevent undesired behavior. For this Onyx has two directives that can be added after #match
to change when procedures can be added.
-
#locked
- This prevents added overload options. The only options available are the ones between the curly braces. -
#local
- This allows options to be added, but only within the same file. This can be used to clean-up code that is over-indented.
length :: #match #local {}
#overload
length :: (x: u32) => 4
#overload
length :: (x: str) => x.count
Overloaded procedures provide the backbone for type-generic "traits" in Onyx. Instead of making a type/object oriented system (i.e. Rust), Onyx uses overloaded procedures to provide type-specific functionality for operations such as hashing. Multiple data-structures in the core
package need to hash a type to a 32-bit integer. Map
and Set
are two examples. To provide this functionality, Onyx uses an overloaded procedure called to_u32
in the core.hash
package. This example shows a Point
structure that can be hashed into a u32
.
Point :: struct {x, y: i32;}
#overload
core.hash.to_u32 :: (p: Point) => cast(u32) (x ^ y);
Interfaces allow for type constraints to be placed on polymorphic procedures. Without them, polymorphic procedures have no way of specifying which types are allowed for their polymorphic variables. Interfaces are best explained through example, so consider the following.
CanAdd :: interface (t: $T) {
{ t + t } -> T;
}
t
is a value of type T
. The body of the interface is specifying that two values of type T
can be added together and the result is of type T
. Any expression can go inside of the curly braces, and it will be type checked against the type after the arrow. This interface can be used to constrict which types are allowed in polymorphic procedure using a where
clause.
CanAdd :: interface (t: $T) {
{ t + t } -> T;
}
sum_array :: (arr: [] $T) -> T where CanAdd(T) {
result: T;
for arr do result += it;
return result;
}
// This is allowed
sum_array(f32.[ 2, 3, 5, 7, 11 ]);
// This is not, because '+' is not defined for 'str'.
sum_array(str.[ "this", "is", "a", "test" ]);
The second call to sum_array
would generate an error anyway when it type checks the specialized procedure with T=str
. However, this provides a better error message and upfront clarity to someone using the function what is expected from the type.
Interface constraints can also take on a more basic form, where the expected type is omitted. In this case, the compiler is only checking if there are no errors in the provided expression.
// This does not check if t + t is of type T.
CanAdd :: interface (t: $T) {
t + t;
}
Interfaces can be used in conjunction with #match
blocks to perform powerful compile-time switching over procedures. Consider the following extension to the previous example.
CanAdd :: interface (t: $T) {
{ t + t } -> T;
}
sum_array :: #match {
(arr: [] $T) -> T where CanAdd(T) {
result: T;
for arr do result += it;
return result;
},
(arr: [] $T) -> T {
printf("Cannot add {}.", T);
result: T;
return result;
}
}
// This is allowed
sum_array(f32.[ 2, 3, 5, 7, 11 ]);
// This is now allowed, but will print the error.
sum_array(str.[ "this", "is", "a", "test" ]);
First the compiler will check if T
is something that can be added. If it can, the first procedure will be called. Otherwise the second procedure will be called.
Macros in Onyx are very much like procedures, with a couple notable differences. When a macro is called, it is expanded at the call site, as though its body was copy and pasted there. This means that macros can access variables in the scope of their caller.
print_x :: macro () {
// 'x' is not defined in this scope, but it can be used
// from the enclosing scope.
println(x);
}
{
x := 1234;
print_x();
}
{
x := "Hello from a macro!";
print_x();
}
Because macros are inlined at the call site and break traditional scoping rules, they cannot be used as a runtime known value.
There are two kinds of macros: block macros, and expression macros. The distinguishing factor between them is the return type. If a macro returns void
, it is a block macro. If it returns anything else, it is an expression macro.
Block and expression macros behave different with respect to some of the language features. Expression macros behave exactly like an inlined procedure call with dynamic scoping.
add_x_and_y :: macro (x: $T) -> T {
defer println("Deferred print statement.");
return x + y;
}
{
y := 20.0f;
z := add_x_and_y(30.0f);
printf("Z: {}\n", z);
}
// This prints:
// Deferred print statement.
// Z: 50.0000
This example shows that defer
statements are cleared before the expression macro returns. Also, the return
statement is used to return from the macro
with a value.
Block macros behave a little differently. defer
statements are not cleared, and return
statements are used to return from the caller's procedure.
early_return :: macro () {
return 10;
}
defer_a_statement :: macro () {
defer println("Deferred a statement.");
}
foo :: () -> i32 {
defer_a_statement();
println("About to return.");
early_return();
println("Never printed.");
}
// foo() will print:
// About to return.
// Deferred a statement.
In foo
, the call to defer_a_statement
adds the deferred statement to foo
. Then the first println
is run. Then the early_return
macro returns the value 10 from foo
. Finally, the deferred print statement is run.
This distinction between block and expression macros allows for an automatic destruction pattern.
// These are not the actual procedures to use mutexes.
grab_mutex :: macro (mutex: Mutex) {
mutex_lock(mutex);
defer mutex_unlock(mutex);
}
critical_procedure :: () {
grab_mutex(a_mutex);
}
grab_mutex
will automatically release the mutex at the end of critical_procedure
. This pattern of creating a resource, and then freeing it automatically using defer
is very common.
To make macros even more powerful, Onyx provides compile-time code blocks. Code blocks capture code and treat it as a compile-time object that can be passed around. Use #quote
to create a code block. Use #unquote
to "paste" a code block.
say_hello :: #quote {
println("Hello!");
}
#unquote say_hello;
Code blocks are not type checked until they are unquoted, so they can contain references to references to variables not declared within them.
Code blocks can be passed to procedures as compile-time values of type Code
.
triple :: ($body: Code) {
#unquote body;
#unquote body;
#unquote body;
}
triple(#quote {
println("Hello!");
});
Code blocks can be passed to macros without being polymorphic variables, because all parameters to macros are compile-time known.
triple_macro :: macro (body: Code) {
#unquote body;
#unquote body;
#unquote body;
}
triple_macro(#quote {
println("Hello!");
});
There are two syntactic short-hands worth knowing for code blocks. A single statement/expression in a code block can be expressed as: #(expr)
#(println("Hello"))
// Is almost the same the as
#quote { println("Hello"); }
The practical difference between #()
and #quote {}
is that the latter produces a block of code, that has a void
return type, while the former results in the type of the expression between it. The core.array
package uses this features a lot for creating a "lambda/capture-like" syntax for its procedures.
use core.array {fold}
find_largest :: (x: [] $T) -> T {
return fold(x, 0, #(it if it > acc else acc));
}
A code block can also be passed to a macro or procedure simply by placing a block immediately after a function call. This only works if the function call is a statement.
skip :: (arr: [] $T, $body: Code) {
for n: 0 .. arr.count {
if n % 2 == 1 do continue;
it := arr[n];
#unquote body;
}
}
// This prints: 2, 5, 11
skip(.[2, 3, 5, 7, 11, 13]) {
println(it);
}
Onyx's operator overloading syntax is very similar to its #match
syntax, except #operator
is used, followed the operator to overload. For example, this defines the +
operator for str
.
#operator + (s1, s2: str) -> str {
return string.concat(s1, s2);
}
The following operators can be overloaded:
Arithemetic: + - * / %
Comparison: == != < <= > >=
Bitwise: & | ^ << >> >>>
Logic: && ||
Assignment: += -= *= /= %=
&= |= <<= >>= >>>=
Subscript: [] []= ^[]
Most of these are self explanatory.
Onyx is a strictly typed programming language. Every expression has a type, even if it is not explicitly written. Onyx provides features and mechanisms to eliminate the need of specifying types.
Onyx contains the following primitive types.
void // Empty, 0-size type
bool // Booleans
u8 u16 // Unsigned integers: 8, 16, 32, and 64 bit.
u32 u64
i8 i16 // Signed integers: 8, 16, 32, and 64 bit.
i32 i64
f32 f64 // Floating point numbers: 32 and 64 bit.
rawptr // Pointer to an unknown type.
type_expr // The type of a type.
any // Used to represent any value in the language.
str // A slice of bytes ([] u8)
cstr // A pointer to bytes (^u8) with a null-terminator.
range // Represents a start, end, and step.
v128 // SIMD types.
i8x16 i16x8
i32x4 i64x2
f32x4 f64x2
Pointers contain an address of a value of the given type. A ^T
is a pointer to value of type T
. If a pointer is not pointing to anything, its value is null
.
Use the ^
operator to take the address of a value. Note the consistency between the type and operation used to create a pointer.
x: i32 = 10;
p: ^i32 = ^x;
Use the *
operator to retrieve the value out of a pointer. This is not a safe operation, so faults can occur if the pointer is pointing to invalid memory.
x := 10;
p := ^x;
printf("*p is {}.\n", *p);
Onyx does support pointer addition and subtraction. Pointer values change in multiples of the size of their type.
x := 0;
p: ^i32 = ^x;
println(p);
println(p + 1); // 4 bytes greater than p
println(p + 2); // 8 bytes greater than p
Fixed-size arrays store a fixed number of values of any type. A [N] T
array hold N
values of type T
. The []
operator can be used to access elements of the array.
arr: [5] u32;
arr[0] = 1;
arr[1] = 2;
arr[2] = 3;
arr[3] = 4;
arr[4] = 5;
Fixed-size arrays are passed by pointer to procedures. However, the =
operator copies the contents of the array to the destination.
mutate_array :: (arr: [5] u32) {
arr[3] = 1234;
}
arr: [5] u32;
mutate_array(arr);
println(arr[3]); // Prints 1234
arr2: [5] u32 = arr; // This is an element by element copy.
arr2[3] = 5678; // This does not modify the original array.
println(arr[3]); // So this also prints 1234
Fixed-sized arrays can be constructed using an array literal. Array literals have the form type.[elements]
. The type
is optional if the type of the elements can be automatically inferred.
arr := u32.[1, 2, 3, 4];
assert((typeof arr) == [4] u32, "type does not match");
floats := .[5.0f, 6.0f, 7.0f];
assert((typeof floats) == [3] f32, "type does not match");
Slices are arrays with a runtime known size. A slice [] T
is equivalent to the following structure.
[] T == struct {
data: ^T;
count: u32;
}
Slices are the most common array-like type used in practice. Slices do not hold the data of their contents directly, but rather through a pointer.
Slices can be used to represent a sub-array. A slice can be created using the []
operator on an array-like type, but providing a range
instead of an integer. Note that the range is half-open, meaning the upper bound is not included.
arr := u32.[1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
slice: [] u32 = arr[4 .. 7];
for slice {
println(it); // Prints 5, 6, 7
}
All array-like types implicitly cast to a slice. The following function works on fixed-size arrays, slices, and dynamic arrays.
product :: (elems: [] $T) -> T {
result := 1;
for elems do result *= it;
return result;
}
data := .[1, 2, 3, 4];
println(product(data));
println(product(data[2 .. 4]));
println(product(array.make(data)));
Dynamic arrays have a variable size that can be changed after they are created. A [..] T
is a dynamic array of T
. Functionality for dynamic arrays is provided in the Onyx standard library in the core.array
package.
use core
arr: [..] i32;
array.init(^arr);
defer array.free(^arr);
for 0 .. 10 {
array.push(^arr, it);
}
for arr {
println(it);
}
See the core/container/array.onyx
for a full list of functions provided.
Dynamic arrays store an #Allocator to know how to request more memory for the array. By default context.allocator
is used. However, an alternate allocator can be specified in array.make
or array.init
.
Because dynamic arrays are so common and useful, Onyx provides some operator overloads for dynamic arrays. The most useful is <<
, which is used to append elements.
// Same example as above.
use core
// Dynamic arrays are safely automatically allocated
// on the first push, so there is no need to explicitly
// allocate it if you are using context.allocator.
arr: [..] i32;
defer if arr.data != null do array.free(^arr);
for 0 .. 10 {
// No need to take the address 'arr'.
arr << it;
}
for arr {
println(it);
}
Structures are the record type in Onyx. A structure is declared using the struct
keyword and is normally bound to a symbol. Members of a structure are declared like declarations in a procedure.
Point :: struct {
x: i32;
y: i32;
}
Member access is done through the .
operator. Note that accessing a member on a pointer to a structure uses the same .
syntax.
p: Point;
p.x = 10;
p.y = 20;
ptr := ^p;
ptr.x = 30;
Structure literals are a quicker way of creating a value of a struct type. They have the form, Type.{ members }
. The members
can be partially, or completely named. The same rules apply for when giving members as do for arguments when calling a procedure. Note that structure literals are r-values, so a value must be given for all members, unless the member has a default value.
// Naming members
p1 := Point.{x=10, y=20};
// Leaving out names. Follows order of members declared in the structure.
p2 := Point.{10, 20};
Members can be given default values. These values are used in structure literals if no other value is provided for a member. They are also used by __initialize
to initialize a structure.
Person :: struct {
name: str = "Joe";
// If the type can be inferred, the type can be omitted.
age := 30;
}
sally := Person.{ name="Sally", age=42 };
println(sally);
// Because name is omitted, it defaults to "Joe".
joe := Person.{ age=31 };
println(joe);
// __initialize is a special intrinsic procedure that initializes
// any value provided to it. For structures, it sets all members
// to their default value.
use core.intrinsics.onyx {__initialize}
joe2: Person;
__initialize(^joe2);
println(joe2);
Structures have a variety of directives that can be applied to them to change their properties. Directives go before the {
of the structure definition.
Directive | Function |
---|---|
#size n |
Set a minimum size |
#align n |
Set a minimum alignment |
#pack |
Disable automatic padding |
#union |
A members are at offset 0 (C Union) |
Structures can be polymorphic, meaning they accept a number of compile time arguments, and generate a new version of the structure for each set of arguments.
// A 2d-point in any field.
Point :: struct (T: type_expr) {
x, y: T;
}
Complex :: struct {
real, imag: f32;
}
int_point: Point(i32);
complex_point: Point(Complex);
Polymorphic structures are immensely useful when creating data structure. Consider this binary tree of any type.
Tree :: struct (T: type_expr) {
data: T;
left, right: ^Tree(T);
}
root: Tree([] u8);
When declaring a procedure that accepts a polymorphic structure, the polymorphic variables can be explicitly listed.
HashMap :: struct (Key: type_expr, Value: type_expr, hash: (Key) -> u32) {
// ...
}
put :: (map: ^HashMap($Key, $Value, $hash), key: Key, value: Value) {
h := hash(key);
// ...
}
Or they can be omitted and a polymorphic procedure will be created automatically. The parameters to the polymorphic structure can be accessed as though they were members of the structure.
HashMap :: struct (Key: type_expr, Value: type_expr, hash: (Key) -> u32) {
// ...
}
put :: (map: ^HashMap, key: map.Key, value: map.Value) {
h := map.hash(key);
// ...
}
Onyx does not support inheritance. Instead, a composition model is preferred. The use
keyword specifies that all members of a member should be directly accessible.
Name_Component :: struct {
name: str;
}
Age_Component :: struct {
age: u32;
}
Person :: struct {
use name_comp: Name_Component;
use age_comp: Age_Component;
}
// 'name' and 'age' are directly accessible.
p: Person;
p.name = "Joe";
p.age = 42;
println(p);
Onyx supports sub-type polymorphism, which enable a safe and automatic conversion between pointer types B
to A
if the following conditions are met:
- The first member of
B
is of typeA
. - The first member of
B
is used.
Person :: struct {
name: str;
age: u32;
}
Joe :: struct {
use base: Person;
pet_name: str;
}
say_name :: (person: ^Person) {
printf("Hi, I am {}.\n", person.name);
}
joe: Joe;
joe.name = "Joe";
// This is safe, because Joe "extends" Person.
say_name(^joe);
Enumerations or "enums" give names to values, resulting in cleaner code. Enums in Onyx are declared much like structures.
Color :: enum {
Red;
Green;
Blue;
}
col := Color.Red;
Notice that enums use ;
to delineate between members.
By default, enum members are automatically assigned incrementing values, starting at 0. So above, Red
would be 0, Green
would be 1, Blue
would be 2. The values can be overridden if desired. A ::
is used because these are constant bindings.
Color :: enum {
Red :: 123;
Green :: 456;
Blue :: 789;
}
// Values are automatically incremented from the previous
// member if no value is given.
Color2 :: enum {
Red :: 123;
Green; // 124
Blue; // 125
}
// Values can also be expressed in terms of other members.
Color3 :: enum {
Red :: 123;
Green :: Red + 2;
Blue :: Red + Green;
}
By default, enums values are of type u32
. This can also be changed.
Color :: enum (u8) {
Red; Green; Blue;
}
Enums can also represent a set of bit-flags, using the #flags
directive. In an enum #flags
, values are automatically doubled instead of incremented.
Settings :: enum #flags {
Vsync; // 1
Fullscreen; // 2
Borderless; // 4
}
settings: Settings;
settings |= Settings.Vsync;
settings |= Settings.Borderless;
println(settings);
As a convenience, when accessing a member on an enum
type, if the type can be determined from context, the type can be omitted.
Color :: enum {
Red; Green; Blue;
}
color := Color.Red;
// Because something of type Color only makes
// sense to compare with something of type Color,
// Red is looked up in the Color enum. Note the
// leading '.' in front of Red.
if color == .Red {
println("The color is red.");
}
Distinct types wrap a primitive type, which allows for strong type checking and operator overloads. Consider this example about representing a timestamp.
Time :: #distinct u32
Duration :: #distinct i32
#operator - (end, start: Time) -> Duration {
return ~~(cast(u32) end - cast(u32) start);
}
start: Time = ~~1000;
end: Time = ~~1600;
duration := end - start;
println(typeof duration);
println(duration);
With distinct types, more semantic meaning can be given to values that otherwise would be nothing more than primitives.
Distinct types can be casted directly to their underlying type, and vice versa. Distinct types cannot be casted directly to a different type.
It should be noted that when a distinct type is made, all operators are no longer defined for the new type. In the previous example, two Time
values would not be comparable unless a specific operator overload was provided.
Time :: #distinct u32
#operator == (t1, t2: Time) => cast(u32) == cast(u32) t2;
Procedure types represent the type of a procedure (duh). They are used when passing a procedure as an argument. They are written very similar to procedures, except they must have a return type, even if it is void.
map :: (x: i32, f: (i32) -> i32) -> i32 {
return f(x);
}
// Explicit version of a procedure
println(map(10, (value: i32) -> i32 {
return value * 2;
}));
Using procedure types for parameters enables quick procedures to be passed.
map :: (x: i32, f: (i32) -> i32) -> i32 {
return f(x);
}
// Quick version of a procedure
// Because 'map' provides the type of the argument
// and return value, this quick procedure can be passed.
println(map(10, x => x * 2));
As a convenience, procedure types can optionally have argument names to clarify what each argument is.
handle_player_moved: (x: i32, y: i32, z: i32) -> void
// Elsewhere in the code base.
handle_player_moved = (x, y, z) => {
println("Player moved to {} {} {}\n", x, y, z);
}
handle_player_moved(10, 20, 30);
To organize a code base, Onyx provides a simple package system. Every file is part of 1 package. All [public](#Scope Control) symbols declared in the file are then made available in that package. Package declarations must be the first statement in a file. If no package declaration is made, the file is part of package main
.
// This file is part of the 'foo' package.
package foo
say_hello :: () {
println("Hello!");
}
To use the code above from a different file, the package
foo must be referenced. This can either be some verbosely inline, as a binding, or in a use
statement. Note that top-level packages can be accessed directly through a global symbol.
package main
main :: (args: [] cstr) {
{ // Verbosely
(package foo).say_hello();
// `package` is optional
foo.say_hello();
}
{ // Through a binding
foo :: package foo
foo.say_hello();
}
{ // Through a use statement
use foo
say_hello();
}
}
Packages can be nested.
package foo.bar.baz
say_hello :: () {
println("Hello!");
}
Note that if a different file is part of the package foo
, it can directly access package foo.bar
without binding/using it, as bar
is a symbol in the public scope of package foo
.
package foo
do_something :: () {
bar.baz.say_hello();
}
This is actually how the standard library works in Onyx. All standard libraries are a sub-package of package core
. This way, when core
is used, all sub-packages are directly accessible.
use core
// Now you can use
// array.
// map.
// math.
// etc...
Implementation details are important to hide if they do not concern the end user. Onyx provides a simple method for hiding symbols in a package.
package foo
// say_hello can only be accessed from inside this package.
#package say_hello :: () {
println("Hello!");
}
// say_world can only be accessed from inside this file.
#local say_world :: () {
println("World!");
}
// #local and #package can also be "blocks"
#local {
func1 :: () { }
func2 :: () { }
}
// This is normally used for "imports", or a set
// of package bindings at the top of a file.
#local {
gl :: package opengl
glfw :: package glfw3
al :: package openal
vec :: package math.vector
}
Package hierarchies do not have to follow with the directory structure of a program. Any file can be part of any package. However, it is helpful if there is some consistency. Onyx just does not enforce it.
A module is set of Onyx files that provide some functionality. The normal structure for a module is the following.
module_name/
file1.onyx
file2.onyx
file3.onyx
...
module.onyx
All files in the module are part of the same package. External imports are placed the #package
scope in module.onyx
. module.onyx
also #load
s all files in the module. This way, only one file has to be #load
ed to load the entire module.
package module_name
#package {
core :: package core
gl :: package opengl
}
#load "./file1.onyx"
#load "./file2.onyx"
#load "./file3.onyx"
// ...
// Or load all files in the directory of module.onyx
#load_all "./."
context
is a thread-local global variable and contains information about the current thread such as:
- The global allocator
- The global logger
- The current thread id
- The assert handler
context.allocator
is the general purpose allocator that is used by all data structures in the standard library. By default, it is the global heap allocator.
context.temp_allocator
is an allocator used for small, temporary allocations. Allocations in the temp_allocator
should not have to be freed. By default, it is a 16KB
ring-buffer.
context.logger
is the current logger, used by the log function. By default, it is a wrapper around println
.
context.thread_id
is the current thread's id. It should not be changed, as it is used for book-keeping about thread states.
An Allocator
represents an interface for requesting and releasing memory. An Allocator
is made of two parts:
-
func
- A procedure reference that encapsulates allocating, resizing, and freeing memory. -
data
- A pointer passed tofunc
, allowing for additional data to be stored pertaining to the allocator.
The type of func
is:
func: (data: rawptr, action: AllocationAction, size: u32, align: u32, oldptr: rawptr) -> rawptr
-
data
is the samedata
member stored on theAllocator
. -
action
is either,.Alloc
,.Resize
, or.Free
, representing the action the allocator should take. This prevents having multiple procedure references for different actions. -
size
is the size in bytes of the requested allocation. This value is ignored whenaction
is.Free
. -
align
is the alignment in bytes of the requested allocation. This value is ignored whenaction
is.Free
. -
oldptr
is a pointer value that originated from thisAllocator
. It is used by.Free
and.Resize
.
To use allocators directly, use raw_alloc
, raw_free
, and raw_resize
.
allocator := context.allocator;
// Allocate 32 bytes
data := raw_alloc(allocator, size=32);
// Resize to 64 bytes
data = raw_resize(allocator, data, size=64);
// Free the allocated data
raw_free(allocator, data);
To use the context.allocator
directly, use calloc
, cresize
, cfree
.
The preferred method of allocating out of the context.allocator
is using new
and delete
. Provide new
with the type of thing to allocate, and it will return a type-casted pointer to initialized memory for that type.
Point :: struct {
x: f32 = 10;
y: f32;
}
p := new(Point);
// p.x is 10
// p.y is 0, because new automatically zeros all data
// allocated, for enhanced security.
println(p);
delete(p);
Note, the allocator used by new
and delete
can be changed by passing an argument named allocator
.
One final allocation procedure to know is make
. make
is the same as new
, except it does not initialize the memory, only allocates and zeros the memory. make
can also be used to create and initialize slices and dynamic arrays.
Point :: struct {
x: f32 = 10;
y: f32;
}
p := make(Point);
// p.x is 0 and p.y is 0 because make does not initialize memory.
delete(p);
// Create dynamic array of type i32.
// Equivalent to writing `array.make(i32)`, but
// does not require `use package core`.
arr := make([..] i32);
// Push ten elements into the array.
for 10 {
arr << it;
}
delete(arr);
Here is a mixture of features in Onyx that did not fit into previous sections of this document.
use
statements brings symbols from a namespace into the current namespace. The following things can be use
d in Onyx:
- Packages
- Structures
- Enums
{
// Using a package.
// This brings all public symbols in the
// core.array package into the current scope.
use core.array;
}
{
API :: struct {
do_something :: () { }
}
// Using a structure.
// This brings all symbols bound inside the structure body
// into the current scope. In this way, structures can be
// used as "subpackages", without creating a new file.
use API;
do_something();
}
{
Color :: enum {
Red;
Green;
Blue;
}
// Using an enum.
// This brings all enum values into the current scope.
// This is mostly unnecessary, as enum values can be
// looked up quickly using a prefixed `.`.
use Color;
color := Red;
}
use
statements can specify which symbols are made available.
use core {array}
// A symbol can also be give a different name using `::`.
use core {A :: array}
arr := A.make(i32);
When two types do not match, a cast is necessary. If the destination type is known, the auto-cast operator (~~
) can be used. It is an awkward operator on purpose, as it should not be over used.
f := 10.0f;
i := 10;
i += ~~f; // Equivalent to cast(i32) f
The pipe operator (|>
) can be used to chain function calls together. It works by inserting the expression on its left as the first argument to the call on its right. It is similar to Elixir's |>
.
f :: (x) => x * 2
g :: (y) => y + 3
// These two statements are equivalent.
println(f(g(5)));
5 |> g() |> f() |> println();
use core {math, println}
Vector :: struct {
x, y: f32;
magnitude :: (v: Vector) -> f32 {
return math.sqrt(v.x * v.x + v.y * v.y);
}
}
v := Vector.{3, 4};
// All of the following are equivalent.
Vector.magnitude(v) |> println();
v.magnitude(v) |> println();
v->magnitude() |> println();
#inject
lets you add a symbol to a scope, outside of where you normally could add symbols. For example, structures can have static symbols defined in them and act like a "namespace". With #inject
, you can add to that namespace.
MathUtils :: struct {
maximum :: (x: ..i32) -> i32 { ... }
minimum :: (x: ..i32) -> i32 { ... }
}
// This adds the 'gcd' procedure to the MathUtils scope.
#inject
MathUtils.gcd :: (x: ..i32) -> i32 { ... }
{
g := MathUtils.gcd(18, 24, 22);
}
If you want to inject multiple symbols into a scope, you can use the block version of the syntax.
// MathUtils is declared empty
MathUtils :: struct {}
// This adds 'gcd', 'maximum' and 'minimum' to the MathUtils scope.
#inject MathUtils {
gcd :: (x: ..i32) -> i32 { ... }
maximum :: (x: ..i32) -> i32 { ... }
minimum :: (x: ..i32) -> i32 { ... }
}
With this syntax, you can achieve something like Rust's impl
semantics, where you can separate the implementation of a structures methods from the data in the structure.
// Structure defintition
Vec2 :: struct {
x, y: f32;
}
// Structure method implementations
#inject Vec2 {
magnitude :: (v: Vec2) => math.sqrt(v.x * v.x + v.y * v.y);
norm :: (v: Vec2) => {
l := v->magnitude();
return Vec2.{v.x / l, v.y / l}
}
}
{
vec := Vec2.{1, 1};
println(vec->norm()); // prints 'Vec2 { x = 0.707, y = 0.707 }'
}
Combining this with interfaces, you can create something similar to Rust's trait
semantics. This is not how Iterable
is defined in Onyx, but it could be.
// Define something like a "trait"
Interable :: interface (t: $T) {
{ t->AsIterator() } -> Iterator;
}
// Restrict arguments to have Iterable "trait"
iterate :: (i: $T/Iterable) {
for i->AsIterator() {
println(it);
}
}
MyRange :: struct {
begin, end: i32;
}
// Implementation of the "trait" Iterable
#inject MyRange {
AsIterator :: (r: MyRange) -> Iterator(i32) {
return // ...
}
}
{
iterate(MyRange.{0, 10});
}
The contents of a file can be included directly as a byte array using #file_contents
. Note that this feature can be disabled using the --no-file-contents
flag on the command line, for added security.
contents: [] u8 = #file_contents "./test.txt";
println(contents);
Types in Onyx can be used at runtime. The compiler assigns a unique 32-bit integer to every type in the program. When a type is used at runtime, it is simply equivalent to an integer constant. In this way, you can compare types at runtime.
passing_types_as_parameters :: (t: type_expr) {
if t == i32 do println("t is i32");
if t == f32 do println("t is f32");
if t == bool do println("t is bool");
}
passing_types_as_parameters(i32);
passing_types_as_parameters(f32);
passing_types_as_parameters(bool);
Variables cannot be created with runtime known types, nor can expressions be casted using runtime known types.
Most of the usefulness of runtime types comes from the runtime.type_info
package. The compiler adds a lot of data to a compiled binary about every type used in the program. The integer that corresponds to a given type is actually the index into an array called type_table
. This array holds pointers to Type_Info
. Each Type_Info
stores info about a particular type. A comprehensive list of everything available through type_info
is available in core/runtime/info/type_info.onyx
. Here is an example of printing all of the members and their types in a structure.
// Notice the T is runtime known (no $).
print_members :: (T: type_expr) {
use type_info;
info := cast(^Type_Info_Struct) get_type_info(T);
if info.kind != .Struct do return;
for info.members {
printf("{} is of type {}.\n", it.name, it.type);
}
}
Person :: struct {
name: str;
age: u32;
}
print_members(Person);
conv.format
, the generic formatting procedure behind printf
, io.write_format
, and more, uses type information to know how to print anything of any type. If you are curious how this works, take a look at format_any
in core/conv.onyx
.
By default, the compiler targets the onyx
runtime. This is a custom runtime that supports networking, multi-threading, spawning processes, and more. This is normally what you want.
The other runtimes available are:
Runtime | Description |
---|---|
wasi |
Targetting Wasi for use with Wasmer and WasmTime |
js |
Minimal runtime that for linking with JavaScript |
custom |
A small subset of the standard libraries |
This is going to be described in a separate article as the process is quite involved.