The Language
This chapter is where I'm going to go over the different types of statements, operators, and expressions that make up solus, and how to successfully arrange them into a program. Statements will be grouped into related sections by their behavior and idiomatic usage, and I will later explain the difference between statements and expressions in detail. But first, I'll explain the basics.
Operators
Operators are the characters between identifiers (named values) or literals that perform a specific operation on or between the two. The standard arithmetic operators should be extremely familiar to you, as they're the exact same ones often used in standard math, but solus also has a handful of special operators that will be both familiar and unfamiliar to experienced programmers.
Arithmetic
Arithmetic operators are binary operators that perform some kind of math operation between two values. An important thing to note is that arithmetic operators may have prefix and in-place variants, but those are not the same operator and do not always work on the same types.
| Operator | Description | Types |
|---|---|---|
a + b | Add / Concatenate | i64 f64 str obj |
a - b | Subtract | i64 f64 bool |
a * b | Multiply | i64 f64 |
a / b | Divide | i64 f64 |
Prefix
Prefix operators are unary operators, meaning they perform a modification to a single value rather than between two. It's important to note again that although all prefix operators have in-place variants, prefix operators do not modify the original value but rather evaluate their modification as part of an expression.
| Operator | Description | Types |
|---|---|---|
-x | Negate | i64 f64 bool |
!x | Bang / Not | bool |
Postfix
Postfix operators come after a value, and in solus postfix operators are only used for one of two things: get operations and member calls. The . operator has special behavior that automates member calls, and this can be bypassed by using []. An example will be used rather than a table because there are only three postfix operators and they are all quite similar.
var x = object.member; # or object['member']
var y = object[0];
var c = function(arg1, arg2); # () qualifies as postfixComparison
Comparison operators are another set of math operators, often called equality operators as well. The and/or operators have dedicated keywords, which are in fact the recommended idiomatic option rather than the character alternative, as they behave differently from most languages in that they short circuit to the first available value.
| Operator | Description | Types |
|---|---|---|
a == b | Equal | any |
a != b | Not equal | any |
a < b | Less than | i64 f64 |
a > b | Greater than | i64 f64 |
a <= b | Less than or equal | i64 f64 |
a >= b | Greater than or equal | i64 f64 |
a and b a && b | Last truthy condition | i64 f64 |
a or b a || b | First truthy condition | i64 f64 |
Truthiness is an important topic to note, and that's whether a value when coerced into a bool should be true or false. Outside of condition contexts, and/or short-circuit and evaluate to the last and first truthy condition respectively. The truthiness conditions of different primitive types is explained in the table below.
| Primitive | Truthiness condition |
|---|---|
nil | Always false |
f64 | true unless nan |
i64 | Non-zero values are true |
dyn | Always true |
In-Place
In-place operators perform their operation on the original value, meaning they must be performed on either a variable of an object's member, and cannot be used on literals. There are also three different types of in-place operators, those being prefix, postfix, and equals.
The difference between prefix and postfix operators are the order the operation and evaluation are performed in, prefix operators performing the operation before evaluation, and postfix performing it after. That means that postfix operators evaluate to the original value prior to the performed operation.
| Prefix | Postfix | Equals | Unfolded | Types |
|---|---|---|---|---|
++x | x++ | x += 1 | x = x + 1 | i64 f64 |
--x | x-- | x -= 1 | x = x - 1 | i64 f64 |
x *= 1 | x = x * 1 | i64 f64 | ||
x /= 1 | x = x / 1 | i64 f64 | ||
!!x | x = !x | bool |
Note: the name of the !! operator is the toggle operator
Operator Overloading
Using metadata, you can overload most operators in solus with custom functions, allowing you to extend the behavior of dynamic objects to implicit operations. As usual this is done with a metadata entry prefixed with _. Overloaded operations also have another implicit behavior in that if they return a dynamic object, it will be assigned the same metadata as the original object.
{ 0, 0 }.usemeta({
_add = [](a, b) {
{ a[0] + b[0], a[1] + b[1] }
}
_get = [](obj, key, value) {
if key == 'x'
obj[0]
if key == 'y'
obj[1]
nil
}
});| Entry | Args | Operator |
|---|---|---|
_add | a b | + |
_sub | a b | - |
_mul | a b | * |
_div | a b | / |
_eq | a b | == |
_neg | self | - ! |
_set | self key value | . [] |
_get | self key | . [] |
_call | ... | () |
Variables
There are two types of declarations in solus, variables and named values. All variables are named values, but not all named values are variables. You can think of the keyword var as defining a name that can have values assigned to it, and val as assigning a name to an existing value. Purely named values cannot be modified, because they are simply a name assigned to a local value slot. Variables on the other hand are names whose value may vary.
var x = 2, y = 4, z;
val named = 'hello!';
x = named; # all good
named = x; # fails to compileAs noted above, whether a name can be reassigned or not is static and therefore up to the compiler to determine, not up to the runtime. If you're not sure what that means, it's that you'll get a detailed report of every error of this kind you make before your code runs rather than when it runs. Named values created with val are also not allowed to start uninitialized, as you'd be unable to set them to anything but nil after that.
Solus does not have a function or def keyword like other languages, instead you should either assign a function to a global (no declaration), or assign it to a local named value. Global variables are different from local ones in that they persist between files, through functions, and as long as you let them until the program has terminated. Globals can be accessed by indexing the global keyword or just using the name if no local variable already use it.
var x; # init nil
x = 4;
global.x = x; # you need global to avoid ambiguity
y = x + 10; # not needed here
val fun = [](a, b) { return a + b; };Scopes and Blocks
Scopes are a programming concept that groups related local names into a shared state, so that for example variables may shadow outer ones or release their name once they go out of scope. Understanding scopes is important to understanding where you're assigning a value.
var outer = 1;
if true {
var inner = 1;
var outer = 2; # shadow
}In solus, you can consider two main types of scopes: function scope and block scope. Function scope does not have access to outer variables unless they are brought in via capture (the bracketed list before the arguments). Capturing variables promotes them from local variables to shared variables, meaning they can be modified by external function calls and therefore escape their scope.
var abacab = 'blood';
val modify = [abacab](new) abacab = new;
modify('meow');Block scope begins at the start of a function, and the function's arguments and captures are inserted into that scope immediately. From then on, inner blocks are defined by curly braces {}, and out variable names may be shadowed by inner ones meaning that their name is reused for a new variable. If you need to create an inner scope in solus, you should use do {}, which also evaluates via trailing return.
var ev = do {
var x = io.fread('main');
if x.type() == 'err'
x = io.fread('backup');
x
};One thing I want to set straight in this handbook is that there is a preferred bracket style for most languages, and the one for solus is brackets on the same line as the statement or function.
Functions
Now onto the real meat and potatoes of solus, the fun type, or functions. Every unit of solus code is in fact a function, each file is simply a function that takes no arguments and returns nil by default. Standard arguments act the same as local variables, and they can be accessed with the same speed.
[self, local_var] (arg1, arg2) { arg1 + arg2 }
# ^- Captures ^- Arguments ^ Code / Return
object.call(arg1, arg2);Reminder of the function signature format
Another feature of the function system in solus is variadic arguments and the unfold operator. Variadic arguments can be accepted using the ...extra syntax, but you are free to name the array whatever you want. Excess arguments will be placed into that array, and arrays can be unfolded into function arguments by using the same syntax.
var variadic_print = [](...extra)
extra.foreach([](v) io.println(v));
var wrapper = [variadic_print](...args) {
io.println(args.len());
variadic_print(...args);
};
wrapper(nil, nan, inf);Returning values
There's two ways to return values in solus, and that's via keyword or via trailing return. Trailing return is a concept borrowed from rust that states a block should evaluate to or return the last statement if there is no semicolon at the end of the expression. You can use either method, but trailing return is recommended unless there is some ambiguity that makes it inappropriate.
[](a, b) {
if a.type() != b.type() or a.type() != 'obj'
err('Type Mismatch') # branched trailing return
a[0] + b[0] # return keyword can be used instead
}Self and Member Functions
The self keyword can be used in one of two places of the function: either the arguments or the captures, and never both. In the case of a self capture, the function becomes what's called a member function. Member functions behave differently in that if they are called via . operator, the self capture will automatically be updated to the value before the .. This is not true of the self argument, where the argument must be passed traditionally.
var o = {
name = 'sarah'
print = [self]() io.println(self.name)
};
o.print(); # sarahThe self operator is not exclusive to objects, but infact works on all dynamic types via extension. Remember that the function must be called via member call (.) for the self capture to be updated.
Another use of the self keyword is during object construction, in other words, within an object literal. During this phase, self refers to the object currently being constructed and may be used to access already initialized fields. Members are always initialized from top to bottom, so be sure to pay attention to the order of your members.
{
x = 2
y = self.x * 8
sub = do {
val s = self;
{
_ = io.println($'{s} != {self}')
}
}
}Importing Files
Because files are functions, including self-made modules is as simple as returning an object and then compiling and calling that module file as a function. This can be done one of two ways, via import/require or via include. The difference between import and require is minor, require simply panics instead of returning an error. It is the same as writing import(s).unwrap(). These functions load the file at runtime, failing if it's not found or fails to compile or run.
{
static = "yipn't"
exported = [self](new) self.static = new
} # this is the primary use case of trailing return =)var mod = import('mod').unwrap(); # or require('mod')
mod.exported('yippee!');
io.println(mod.static);Including files on the other hand is quite different. include is not in fact a function but a keyword, meaning that it's evaluated at compile time. It must be passed a string literal, and if it fails to compile, the outer file will also not compile. Including a file integrates it directly into the constant table of the outer file, meaning they can be consolidated into a single .solc file.
var compile_time = include("local_file"); # notice the keyword coloring?Branches and Loops
Branches are a type of statement that means a program's path of execution splits into two possible outcomes. These branch statements are very straightforward, so I'll spare the details of the definition of what the word 'if' means. None of these statements require brackets for single lines of code, but they may be preferred.
If and Else
var condition = io.input('Y/N: ') == 'Y';
if condition {
var x = 2;
# ...
return x;
} else if (global.parentheses)
io.println("You also don't need braces.");As you can see above, the if/else keywords form branching statements that should be familiar to anyone with a basic understanding of control flow. What may not be familiar to you is that there are no semicolons, parentheses, or brackets required.
if im_a_failure
err("It's so over...")
else
return err("It's STILL so over...");Another important thing to note here is that while branch statements do not evaluate, they can return the function when the semicolon is omitted. Keep this in mind when using if inside do {}, because it will act as the return keyword and not an evaluation.
While
While is the simplest type of loop in solus, simply testing a condition and executing a block as long as that condition remains true. A condition may be any expression that holds truthiness.
var i = 0;
while i < 100 {
io.println($'Loop {i + 1}');
i = i + 1;
}For
The for keyword isn't the most solus-coded statement type, but it's a staple of every programmer's toolkit and personally I take offense when the syntax of it is changed too much. The rundown of the for statement is that you have a before, condition, and after all in one loop, separated by semicolons. The before and after may be statements, but the *condition& may only be an expression.
var array = { 1, 2, 3, 4, 5 };
for var i = 0; i < array.len(); ++i
io.println(array[i]);When running through the for loop, the before statement will be executed, then the condition will be checked. If the condition passes, the block shall run, and then the after statement will be executed. Return to condition, rinse and repeat.
Objects
Although solus is not an object-oriented programming language, and I would consider it an "all of the above" kind of deal, it does support some concepts of object-oriented programming which you can use to your advantage. More information on the metadata showcased below can be found at the Metadata section.
var cat = {
fur_color = 'orange' # silly default
age = 0
babies = 0
gender = 'male'
give_birth = [self]() {
if self.gender == 'male' or age < 1
err("You can't do that")
io.println($'MEOW MEOW KITTENS! {++self.babies}');
}
grow_old = [self]()
self.age += 1/365
};
var jessica = { age = 1, gender = 'female' }.usemeta({ _extend = cat });
jessica.grow_old();
jessica.give_birth();Notice how I can set default values on the base object, and then setting them from within the member functions will set the instance's member to that value. This combined with member functions make for relatively powerful features that allows you to build classes.
One other thing I want to cover in this chapter related to objects is more efficient ways to traverse them. There are std functions that make working with objects a breeze, and luckily they're all inherited by every object.
val my_obj = {
1, 2, 3, 4,
member1 = 2
member2 = 'hehe'
};
my_obj.foreach([](v, i) io.println($"[{i}]: {v}")); # array
my_obj.pairs([](key, value) io.println($"{key}: {value}")); # members