Hello world 🔗

Let's take our first steps into programming with smoλ (pronounced like "small" but with "o" instead of "a"). The language is a bit atypical in that it simplifies a lot of traditional programming concepts while keeping the ability to write very fast yet safe code. Some level of control is sacrificed in the process, but this means that you do not need to worry too much about technical details.

Our first program shows how to run a program and print a message. Download the smol executable from the latest release. Place it alongside the std directory and add both the containing folder and a C/C++ compiler (e.g., GCC) to your system PATH. Finally, create a file named main.s with the text below. Open a terminal in the same folder and run smol main.s.

// main.s
@include std.builtins

service main()
    print("Hello world!")
    --
> smol main.s
Hello world!

As a quick preview, @include std.builtins adds basic tools like print, service main() is where your program starts, and the line with -- marks the end of the program.

Variables 🔗

A variable is a named box that holds a value. You give it a name, then put a value in it once with the pattern variable_name = value. This process is called an assignment. Names cannot start with numbers or contain spaces or special symbols other than the underscore _. Having two consecutive underscores is also not allowed. Below is an example where we set a constant text known during program creation (cstr) to a variable.

@include std.builtins

service main()
    greeting = "Hello world!"
    print(greeting)
    --

Numbers are values too. There are three kinds you will use often:

These are known as unsigned integers, signed integers, and float numbers, respectively. The above names add 64 next to a mnemonic symbol to reassure experienced programmers that we will use 64 bits to represent the numbers under-the-hood. That is, unsigned integers can represent numbers 0 upto 2^65-1 but signed ones can represent numbers -2^64 upto 2^64-1. Floats follow the IEEE 754 standard, which is typically accurate to 15-17 significant digits.

Invalid operations: For safety, you cannot mix operations between different types of numbers. For example, you cannot subtract a float from 0 but only from 0.0. However, you can convert between number formats with value:type. You would be surprised how many bugs are prevented by requiring explicit data conversions.

See below for examples. Integer division by zero creates a runtime failure that you can intercept and handle at the level of services. For floats, the IEEE 754 standard allows invalid operations like division by zero and lets them yield positive infinity, negative infinity, or NaN (not a number) values. You can check for those properties by correspondingly calling one of is_inf or is_nan that can be imported from std.math. Lack of native error checking from the standard results in performant code.

@include std.builtins

service main()
    print(1+2)      // 3
    print(2/3)      // integer division (u64): 0
    print(2.0/3.0)  // float division (f64): a decimal result
    minus_one = 0:i64-1:i64
    print(minus_one) // -1
    --

Mutable variables: After setting a variable, its value cannot normally change. To allow changes, declare it as mutable by placing & before its name in its first assignment. Variables are immutable by default to avoid many logic bugs. Look out for the marking to spot values that can may change.

// main.s
@include std.builtins

service main()
    &name = "Ada"
    print(name)
    name = "Lovelace" // allowed because we marked it mutable before
    print(name)
    --
> smol main.s
Ada
Lovelace

Conditions 🔗

Sometimes we only want certain lines to run if a tested condition is true. This is done with an if block. The word if starts the block, then comes a condition, then the code that should run if the test passes. Prefer indenting the latter for easier reading, as well as starting it in a new line. Like before, the block ends at --.

// main.s
@include std.builtins

service main()
    if true
        print("this always runs")
        --
    -- // could merge both lines into ---- here
> smol main.s
this always runs

Here the test is just the value true, so the message will always print. If you changed the condition to false, the inside will be skipped. These are known as boolean values, or bool for short. More often, the test contains numerical or other comparisons that evaluate to a boolea value. For example, 2 < 3 checks whether two is less than three.

// main.s
@include std.builtins

service main()
    if 2<3
        print("yes, two is smaller")
    ----
> smol main.s
yes, two is smaller

There are several comparison operators you can use - some of these are defined for data other than numbers too:

We can also add elif (else if) and else branches to cover other cases. Each branch is tried in order until one runs.

@include std.builtins

service main()
    x = 0.0-2.0
    sign = if x>0.0 
          -> "positive"
        elif x<0.0 
          -> "negative"
        else
          -> "zero"

    print(sign)
    --
> smol main.s
negative

Notice here that, above, each branch returns a string. All branches must return the same type, or nothing. For example, you could also just perform an action inside each branch:

@include std.builtins

service main()
    x = 5
    if x>0
        print("positive")
        --
    elif x<0
        print("negative")
        --
    else
        print("zero")
        --
    --
> smol main.s
positive

If you prefer, it is okay to keep code compact when it reads naturally, for example by writing one line: if x>0 -> print("positive").

Loops 🔗

A loop repeats a block while a condition is true. Syntactically, it starts with while followed by a condition and the block's contents. Those must end at --. If a variable changes inside the loop, make it mutable during its first assignment.

// main.s
@include std.builtins

service main()
    &i = 0
    while i<5
        print(i)
        i = i+1
    ----
> smol main.s
0
1
2
3
4

The next snippet shows a pattern for looping through a range of unsigned integers. This is less prone to accidental bugs is a pattern that will be fully explained later. This pattern is known as an iterator because it builds on some data that can be traversed and assigns to a variable while traversing them. Here the data is a a range(5) indicating values 0 upto 4 and traversed values are assigned to i. Iterators are convenient in that you do not need to manually handle the increment, which you might forget about or could be complicated. For example, reading from files is also implemented as an iterator.

// main.s
@include std.builtins

service main()
    range(5):while next(u64 &i) 
        print(i)
    ----

Uplifting 🔗

Sometimes you want to stop not just the current block, but also its parent blocks up to a certain level. In those cases, put a vertical bar | before the return symbol for each level you want to “jump up”. For example, |-- ends the current block and its parent. Similarly, |-> returns a to the parent. Two bars (||) jump two levels, and so on.

@include std.builtins

service main()
    &i = 0
    while true
        print(i)
        if i==5 |-- // end loop early
        i = i+1
    ----

Arguments 🔗

You can name a block of code and call it with inputs. These are called arguments. There are two kinds of named blocks of code:

For most simple programs, you will only mostly runtypes. Services are complex pieces of code that run independently to each other. Adding arguments to a service is as simple as adding a list of comma-separated variable types and names. Types are needed so that the service can know what inputs to expect. For example, f64 x denotes an argument that is a float named x.

@include std.builtins

smo affine(f64 x, f64 y, f64 z) 
    -> (x+y)*z

service main()
    result = affine(1.0, 2.0, 3.0)
    print(result) // 9.0
    --

Mutability 🔗

Inputs are passed "by value" (without affecting the call site) unless you explicitly allow changes. Place & before argument names to declare that the variable passed as an argument may be modified inside the runtype or service - and hence must already be mutable. This also makes the argument variable internally mutable. Below is an example.

@include std.builtins

smo increment(u64 &x)
    x = x + 1
    --

service main()
    &n = 10
    increment(n)
    print(n) // 11
    --

Similarly to mutable variables declared within blocks of code, mutable arguments make prospective changes happen easy to spot. Conversely, if you do not see &, nothing changes.

Type arguments 🔗

You can use types as arguments to help disambiguate between similarly-named runtypes. This is useful for choosing a behavior without passing a dummy value.

@include std.builtins

smo zero(f64) 
    -> 0.0

smo zero(u64) 
    -> 0

service main()
    a = zero(f64)
    b = zero(u64)
    print(a) // 0.0
    print(b) // 0
    --

Currying 🔗

The colon : sends the value on the left as the first argument on the right. This reads left-to-right and removes extra parentheses when you chain steps.

@include std.builtins

smo triple(f64 x) 
    -> x*3.0

service main()
    print(2:f64:triple) // 6.0
    --

The same colon also works with loops provided by the standard library (like range) so you can write readable iterations.

Returns 🔗

A block can return a value using ->, or end with no value using --. Returning early is just a return higher up the page; use uplifting if you need to jump out of an extra level.

@include std.builtins

smo abs(f64 x)
    if x<0.0 
        |-> 0.0-x
    -> x

service main()
    x = 0.0-1.0
    print(abs(x)) // 1.0
    --

Fields 🔗

You can return several named values at once and then access them as fields. @nominal is a shorthand that returns all inputs by name.

@include std.builtins

smo Point(f64 x, f64 y) -> @args

smo moved(Point p, f64 dx, f64 dy)
    &nx = p.x + dx
    &ny = p.y + dy
    -> Point(nx, ny)

service main()
    p = Point(1.0, 2.0)
    print(p.x) // 1
    print(p.y) // 2
    q = p:moved(3.0, 4.0)
    print(q.x) // 4
    print(q.y) // 6
    --

If a runtype returns only one value, you use that directly (no extra field name needed).

Error handling 🔗

When something is wrong, say it clearly and stop that unit of work. In a service, call fail("message"). The caller can check result.err:bool. If you don’t check, the error will bubble up until it reaches a place that does.

@include std.builtins

service divide(f64 x, f64 y)
    if y==0.0 
        -> fail("Division by zero")
    -> x/y

service main()
    r = divide(1.0, 0.0)
    if r.err:bool
        print("Could not compute.")
        --
    else
        print(r)
    ----
> smol main.s
Division by zero
Could not compute.

This approach keeps the main path simple. You try the thing. If it fails, you decide what the next step is (ask again, use a default, stop).