Skip to content

The Basics

In this chapter you'll learn about the basic building blocks of code in solus and how to use them. This will prepare you for the next chapter where you'll get to see the statements that arrange these expressions into functional programs. If you've written an interpreted language before like Lua or Python many if not all of these concepts will be very familiar to you.

Values

All variables and literals in solus refer a value. A value is some data plus information about its type, and in the case of dynamic types metadata as well. All values are in fact the same size, so using a bool versus an i64 yields the same memory usage. Dynamic values include types stored on the heap, and they can define special behavior. Assigning a dynamic value to another variable will 'point' to the same instance, unless copied.

solus
var x = 4;
val y = 2.2f;

Values can be assigned to local variables using the var (mutable) and val (mutable) keywords. Local variables are the fastest to access and should always be used over global variables when possible, the reasons for which I will explain in the next section. Local variables also must be explicitly captured, another concept that will be explained later. All function arguments are also stored as local variables in the first register slots.

solus
my_val = 'hello!';
io.println(global.my_val);

Global variables can be declared by omitting the var/val keyword if that name is not in use in the current scope. A global variable that has not yet been set will evaluate to nil. In case of name shadowing, the global keyword refers to the global namespace object for direct access. You should avoid polluting the global namespace, as object accesses become slower the more members they contain, and local variables are stored directly in the VM registers.

solus
var o = {};
o.x = 4;
o[0] = 'meow :3';

The last place that you will find values stored is inside objects, just like the global namespace object. Objects act as both valmaps and valvecs, names that refer to the individual map and vector components of the object. Accessing members from objects is typically extremely fast, especially with shorter member names (<40) due to string optimization.

Literals

Something I noticed is that a lot of new programmers don't know the meaning of the word literal. A literal is a constant value that's written into the source code. Literals in solus are stored in the functions' constant tables, and they cannot be modified. Most strings in solus are also literals and therefore cannot be modified. The literal versions of solus' types will be covered in the next section.

solus
# All of the values on the right hand side are "literals"
var i64 = 4; # i64 literal
var f64 = 4.4f; # f64 literal
var str = 'hello'; # str literal

Types

As previously mentioned, every value has a type. There are two different sets of types in solus: ptypes (primitive types) and dtypes (dynamic types), which I will refer to as simply 'primitives' and 'dyns' going forward for simplicity. Every value is a primitive, but not every value is a dyn.

PrimitiveDescription
nilDenotes the lack of a value
f6464 bit floating point (decimal) number
i6464 bit integer (whole) number
boolBoolean, either true or false
dynPointer to a dynamic value
DynamicDescription
strString of characters, aka text
errError value containing a reason string
objObject made of map and vector components
funCallable unit of solus or C code

nil

Nil, often referred to as null in other languages, is the lack of a value. Nil itself has no value, no comparison will be made outside of the type when checking for nil internally. When accessing a missing member or array index from an object you will be met with nil.

solus
io.println(global.defined);     # nil, undefined
global.defined = 4;
io.println(global.defined);     # 4

f64

The f64 type is a 64 bit IEEE 754 floating point number. This is a relatively accurate way to store floating point decimal numbers, meaning the decimal point can have any number of digits before or after it. This type is often simply called "number" in other scripting languages. The f suffix is optional, and can be used to distinguish f64 from i64 with whole numbers.

solus
2f / 1.5f * 88.0    # 117.333f

i64

The i64 type is a 64 bit integer, a type that can represent whole numbers in the range -2^63 to 2^63 - 1. A good rule to live by is to always default to integers and only use floats when necessary, as integers offer superior precision, comparisons, and often speed. Though in solus the speed of operations between integers and floats is negligible.

solus
2 / 3       # 0
2 * 2 / 4   # 2

bool

Boolean refers to a value that can only be true or false. It is important not to think of the bool type in solus as a number as it may be in other languages, where any non-zero value is true, as the bool in solus is strictly true and false. Negating a bool will result in the opposite value from the original as a result of this.

solus
var c = true;
c = !c;
c = -c;     # same as !c

str

Strings are the second most important types in any language behind numbers, they're the text that you use to look up members and output to the console. Strings are shorthandedly referred to as str in solus, outside of the name of the std module. Strings are immutable in solus as they are in many scripting languages due to an optimization technique known as String interning.

Another feature of strings in solus is the format string, created by prefixing the string literal with $. Within the format string, braces indicate expression insertion. You can escape brackets by using double pairs. Note that $ does not count as an operator but rather part of the literal itself.

solus
val str = 'hi';
io.println(str[0]); # h

val o = {};
o[str] = 2;
io.println(o.hi);   # 2

val fstr = $"{{ hi = {o.hi} }}";

err

Error type that is part of the result system. You can call .unwrap() on any variable, and if it is an error it will be escalated to a runtime panic (aka crash/halt). These can be used for simple error handling, returning either a good value or an err, and then checking the type of or unwrapping the result after the fact.

solus
val fun = [](arg) {
    if arg.type() == 'i64'
        err('We hate integers!')
    arg
};

fun(4).unwrap(); # Runtime panic: We hate integers!
return fun(4f).unwrap(); # 4f

obj

Objects contain two main components, the valmap and the valvec. These two components make up the map and vector parts of it respectively, and allow you to index "members" by both string and integer. The global namespace object is an example of an object that is often indexec by strings, but you may also use them just like they were arrays. New objects can be created by concatenation.

One important thing to note is that commas are not required between named members in an object literal. They are required to separate array members in the object literal though. I personally recommend that you use commas in object literals when they are one-liners, and not to use them when they take up multiple lines.

solus
val main_o = { b = 4 };
val copy_o = {} + main_o;
val ref_o = main_o;

main_o.a = 2;  # New member
main_o -= 'b'; # Delete member
main_o += { c = 3 }; # Append members

io.println(main_o.a); # 2
io.println(copy_o.a); # nil
io.println(ref_o.a);  # 2
io.println(ref_o.c);  # 3

Another method of setting the member of an object is via index specifier which means using brackets around an expression to evaluate the key. You can also use this to initialize arrays of a specific size.

solus
val array = { [99] = nil }; # array of size [100]
val obj = {
    mem_fmt = args.name
    [$'{self.mem_fmt}_awesome'] = 'prefixed'
};

fun

Functions are the main units of solus code, whether already compiled (solc) or run from source, every file is in reality just one big function containing many smaller functions and returning a value. Functions are made up of a few components I'll go over here, but you'll have to wait until the next chapter to see how they're called and used commonly in code.

solus
 [self, local_var]      (arg1, arg2)            { arg1 + arg2 }
# ^- Captures           ^- Arguments            ^ Code / Return
# These are the         Args are passed at      This is the main block
# values you wish       the call site and       of the function and
# to take in from       act the same as         the code it executes.
# the outer scope.      local variables.        Braces are optional.

var x = 2;
var fun = [x](new_val) x = new_val; # One liner returns its expression
io.println(fun(4)); # 4
io.println(x);      # 4

Constants

There are some language constants in solus that are fixed, immutable values of certain primitive types. The constants are listed below, and they are highlighted differently from regular keywords.

ConstantType
nilnil
nanf64
inff64

Expressions

Expressions are pieces of code that evaluate to something, in other words they hold a value as meaning. Expressions in programming work very similarly to math, in that the order of operations is respected (PEMDAS) and importantly, parentheses control the order in which expressions evaluate.

solus
x = (x + 4) * y
#   ^  ^ Operator
#   | Expression

This is a simple expression, and yes that includes the left hand side of the = operator. In solus, most things are in fact expressions, = being a binary expression. This will evaluate to the right hand side since it is an assignment. The value held here is likely i64, but that's just what can be inferred by the multiplication. Notice that the inner expression is used as part of the outer expression.

solus
var x = do {
    var l = call();
    l.update();
    l # No semicolon
};

Something you'll see a lot in solus is the trailing return where a semicolon is emitted in order to implicitly evaluate to the expression at the end of the block. This not only works for functions, but for some expressions like do. Other than that, expressions in solus follow the same rules as pretty much any other language, and that is typically the rules of algebra.

Error Handling

Error handling in solus is quite simple, and it revolves around the previously explained type err and the functions panic and unwrap. Functions that have a failure condition in solus can either panic, halting execution immediately, or return an err type with a description string (a good place to use format strings).

solus
var f = io.fread($"sprites/{name}.png"); # f may be err
f.unwrap(); # f becomes panic
var f = f.unwrap(); # unwrap returns non-err values

Now that you've seen how to handle errors, let's show how to cause your own problems. Notice the omitted semicolon for trailing return. The test function only sets the a member of self if it's of type obj or str, otherwise it returns an err.

solus
{
    test = [self](a) {
        if a.type() == 'obj' or a.type() == 'str' {
            self.a = a;
            nil
        }
        err('a must be obj or str!')
    }
}.test(1).unwrap(); # uh oh!

As for handling panics, because every programmer knows that no matter how much you try to avoid exception like behavior you're gonna end up with something like it eventually, you'll need to use catch or attempt.

Input and Output

The std functions can be found in detail in The Standard Library, but here I'm going to go over briefly how to get input and output from your programs. Solus provides a print and println function, as well as input. All three of these functions simply take a string, unlike languages with variadic formatting. You are encouraged to use format strings to format output.

solus
var name = io.input("Name: ");
io.println($"Hello, {name}!");

Here you can see that I take input from the console of the user's name, which returns a string, and then print a greeting to the user. But I'm sure you're now wondering how to take input of types other than strings, and that's simple thanks to robust conversion functions.

solus
var age = io.input("Age: ").i64().unwrap();
io.println($"{age} is extremely old, lay down");

Notice that I use .unwrap() to handle the case of an error, which may occur if the string contains no numbers for example. These conversion functions can only fail on strings, so if you're sure your variable is not a string, it wouldn't be necessary.

Metadata

This is a concept that I was wary about including this early on but I feel that it's important to grasp when thinking about how dynamic types interact in the background. In solus, all dynamic types carry metadata, essentially meaning they hold a reference to some objects in the environment that define their behavior at runtime.

The most important concept of metadata is extension, meaning when attempting to get a value from another value (typically an object), if it fails on the outer object it will attempt on the extension recursively until it either reaches the end or finds something.

Primitives

All primitives use a single shared instance of metadata called builtin. The builtin metadata behaves differently from how metadata works with dynamic types, only being used to look up method calls. This allows you to call the builtin std functions on primitive types and essentially nothing more. Primitives cannot hold metadata otherwise.

solus
unwrap(x);
x.unwrap(); # Member call lookup with builtin metadata

Dynamics

Dynamic types are the way metadata works, and every dynamic type listed in the Types can make use of it. When creating a dynamic type they all come with their own default extensions, and all of them extend builtin at the end of the line.

DynamicMeta Extension
strstring module
errbuiltin module
objobj module
funbuiltin module

This extension is what allows member calls, function calls that behave dynamically using the extension metadata to look up functions. Member calls, unlike Lua, use the same . operator as regular get operations, and use the self keyword in order to avoid passing the value as the first argument.

solus
val ext = {
    add = [self](n) # self will always be updated at call site
        self.x += n
};
val o = { x = 4 }.usemeta({ _extend = ext });
io.println(o.add(2)); # lookup through extend, result is 6

Memory

For the most part solus tries to obscure the memory model and management of the language so the programmer doesn't have to think about the consequences of creating a million strings outside of a slow program, but it's still pretty important that you understand how dynamic types behave in memory so that you can better understand them in-language.

Dynamic types are what's known as "Reference types" in other languages, in that they are just a reference to a dynamic object that lives somewhere in memory. When you assign one reference to another, they both refer to the same location in memory unless otherwise the original is otherwise copied. It's very important to remember to make proper copies of your objects by concatenating them before trying to modify two separate instances at once, or you will be modifying the same instance!