Get started πŸ”—

Hello world πŸ”—

Let's take our first steps into programming with smoΞ» (pronounced like "small" but with "o" instead of "a"). The language 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 details either.

Our first program shows how to print a message. To set things up, download the smol executable from the language's 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. Create a file named main.s with the text below. Finally, open a terminal in the same folder and run smol main.s.

Simpler installation alternatives will be available in the future.
// main.s
@include std.core

service main()
    print("Hello world!")
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  26ms
compile --back gcc  --runtime std/runtime/auto.h  139ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

Hello world!

A bunch of messages appeared! So let's go through them in order. First, smoΞ» is a compiled language, meaning that it creates executable programs containing machine instructions. To this end, it first parses your program and transforms it to a simpler form during the codegen phrase. Then, that representation is turned into binary machine code using an external backend. Turning programs into code is broadly known as compiling them. Finally, the generated executable runs.

The last two bullet points come from running the program. These are printed by the language's default runtime, that is, the instructions embedded in the generated executable of how to work together with the operating system. The default runtime prints a link to the language's repository so that you can report bugs (and add a star!) and then automatically selects whether the application should be a single-threaded or multi-threaded one.

A quick preview on the source code of our first program: @include std.core adds basic functionality, like print. Then service main() is your actual program. Why it's called a service is a mystery that will be addressed later.

Advanced options. There are several options you can pass to smoΞ» to affect all phases of compilation. Some of these concepts can be understood only after reading the rest of this tutorial:

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. In programming, we say that you assign a value to a variable. Names cannot start with numbers or contain spaces or special symbols other than the underscore _. They can also not be existing variables or other operations.

In smoΞ», two consecutive underscores are not allowed either, because the language uses the combination for some internal workings. Some valid variable names are x, employee, my_property, _temp_computation, MyDataStructure, var123. Below is an example where we set a constant text known during program creation (cstr) to a variable.

// main.s
@include std.core

service main()
    greeting = "Hello world!"
    print(greeting)
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  26ms
compile --back gcc  --runtime std/runtime/auto.h  139ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

Hello world!

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

These are known as unsigned integers, signed integers, and float numbers, respectively. Notice that the type mnemonics combine the first letters of those types with the number 64. The is to let experienced programmers know that 64 bits are used to represent the numbers under-the-hood (there is some historically baggage concerning the C language on why programmers would not easily trust us if we did not explicitly promise a number). Given that many bits, unsigned integers can represent numbers 0 upto 2^65-1 and 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.

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 only compatible numbers. Below are some examples of numeric operations.

What happens on division by zero? 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 made accessible with @include std.math. Lack of native error checking from the standard results in performant code.
@include std.core

service main()
    print(1+2)
    print(2/3)     // unsigned integer division
    print(2.0/3.0) // float division
    minus_one = 0.i64()-1.i64()
    print(minus_one)
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  24ms
compile --back gcc  --runtime std/runtime/auto.h  69ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

3
0
0.666667
-1

Mutable variables: After setting a variable, its value cannot normally change. To allow changes, declare it as mutable by placing @mut before its first declaration/assignment. Variables are immutable -that is, not mutable- by default to avoid many logic bugs. Always look out for what might have changed if something is mutable.

// main.s
@include std.core

service main()
    @mut name = "Ada"
    print(name)
    name = "Lovelace" // allowed because we marked it mutable before
    print(name)
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  24ms
compile --back gcc  --runtime std/runtime/auto.h  69ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

Ada
Lovelace

Flow πŸ”—

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. There are two ways code blocks can end: either with return value that stops the function by returning a value (this will be covered below) or with then final_expression to run the block's last expression.

// main.s
@include std.core

service main()
    if true
        print("this always runs")
        then print("still in the if block")
    print("back in main now")
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  24ms
compile --back gcc  --runtime std/runtime/auto.h  69ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

this always runs
still in the if block
back in main now

Style guide: Prefer indenting code blocks for easier reading, as well as starting them in new lines. Proper indentation is a good coding practice for any language, so try to stick to it. As an exception, you can keep a block comprising a single then statement in the same line as the condition (see next example).

Booleans: Above, the tested condition is just the value true, so the message will always be printed. If you changed the condition to false, the inside would be skipped. These two values (true and false) are known as boolean ones, or bool for short. More often, the test contains numerical or other comparisons that evaluate to a boolean value. For example, 2 < 3 checks whether two is less than three.

// main.s
@include std.core

service main()
    if 2<3 then print("yes, two is smaller")
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  24ms
compile --back gcc  --runtime std/runtime/auto.h  69ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

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 use elif (else if) and else branches to cover alternatives. Each branch is tried in order, until one runs. The rest are skipped. Using then is mandatory only for the last branch, as in the other cases the language can understand where the block ends without it. But you can still add it for clarity, if you want to. Here is an example:

@include std.core

service main()
    x = 5
    if x>0 
        print("positive")
    elif x<0 
        print("negative")
    else 
        then print("zero")
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  24ms
compile --back gcc  --runtime std/runtime/auto.h  69ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

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. Similarly to conditions, the loop's contents end just after then last_expression. If a variable changes inside the loop, is needs to be mutable during its first assignment. Otherwise, the language would complain.

// main.s
@include std.core

service main()
    @mut i = 0
    while i<5
        print(i)
        then i = i+1
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  24ms
compile --back gcc  --runtime std/runtime/auto.h  69ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

0
1
2
3
4

The next snippet shows a pattern for looping through a range of unsigned integers 0 upto 4. You could skip the 0, argument for further simplicity. More details will be fully presented later, but it would be remiss to not mention this pattern here, as it prevents accidental bugs. Broadly, the next function progresses the range while tracking values by assigning them to mutable variable i. It also returns a boolean value on whether the loop should continue. The mutable variable is updated in every loop.

Notice the . before the while, which is how range is transferred to next. With this pattern, you do not need to manually handle the increment, which you might forget about or could be complicated. Similar patterns let you, for example, automate the process of reading from files.

// main.s
@include std.core

service main()
    range(0, 5).while next(@mut u64 i) 
        then print(i)

Algorithms πŸ”—

Sometimes, you may want to conditionally assign a value to a variable. If the variable is mutable, you can place a different assignment within each branch, though this requires the mutability -which is not as safe as immutable assignments- and a previously set default value. As an alternative, smoΞ» provides an algorithm control flow that starts a code block and captures values obtained from internal statements of the form return value. Returns immediately end the code block, even if they occur within internally defined blocks.

Below is an example, which evaluates to several possibilities. All returns must provide the same type of values. Do note that the return statement also ends blocks in place of then. But, even if there are nested conditions, all returns yield back a value to the algorithm.

@include std.core

service main()
    x = 0.0-2.0
    sign = algorithm
        if x>0.0 
            return "positive"
        if x<0.0 
            return "negative"
        return "zero"
    print(sign)
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  24ms
compile --back gcc  --runtime std/runtime/auto.h  69ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

negative

Breaking away from loops: The algorithm structure can be used as a means from breaking away from loops. Below is an example, where some commands are merged in the same line for conciseness. Having an explicit return statement in all situations is necessary - the language would complain otherwise. The example returns the ok value provided by std.core, which has no contents. As a side-note, the "algorithm" keyword is deliberately long to be easy to spot and match nested returns to.

// main.s
@include std.core

service main()
    @mut i = 0
    limit = 5
    algorithm while true
        print(i)
        if i==limit
            return ok
        then i = i+1
        return ok  // runs if nothing else is returned
    print("ended while")
    print(i)
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  24ms
compile --back gcc  --runtime std/runtime/auto.h  80ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

0
1
2
3
4
5
ended while
5

Functions πŸ”—

Arguments πŸ”—

You can name a block of code and call it with inputs to obtain none, one, or multiple outputs. The named block is called a function and its inputs are called arguments. There are two kinds of functions:

In simple programs, you will mostly declare def functions and let them freely fail. SmoΞ»'s philosophy is to not try to hopelessly recover from every failure state, but exit gracefully a bunch of dependent computations and try again. This is to strike a balance between error handling code that pollutes the codebase and recovering from impactful failures. Services are more complex in that they run independently -and potentially asynchronously- to each other.

Regardless of the type of function, you can declare arguments as comma-separated variable types and names (each type corresponds to a name, separated by space). 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. Arguments may also be nameless (consist of only the type), but more on this later. There may be some additional notation before types too. This is described below.

// main.s
@include std.core

def affine(f64 x, f64 y, f64 z) 
    return (x+y)*z

service main()
    result = affine(1.0, 2.0, 3.0)
    print(result)
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  27ms
compile --back gcc  --runtime std/runtime/auto.h  68ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

9.000000

Mutability: Inputs are passed "by value" (without affecting the call site) unless you explicitly allow changes. Place @mut before argument names to declare that the variable passed as an argument may be modified inside the function - and hence must already be mutable. This also makes the argument variable internally mutable. Importantly, services do not accept mutable arguments. The pattern there is to have one service control the creation process of data, and share the outcome with other services. Immutability by default is a contract of function inputs (not outputs); functions declare that they will never alter values, but what they return can be assigned to mutable variables. Below is an example.

@include std.core

def increment(@mut u64 x)
    x = x + 1

service main()
    @mut n = 10
    increment(n)
    print(n)
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  27ms
compile --back gcc  --runtime std/runtime/auto.h  68ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

11.000000

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

Functions as arguments: You can use functions as arguments to help disambiguate between similarly-named alternatives. In that case, simply skip the variable name. This is useful for choosing a behavior without passing a dummy value.

@include std.core

def zero(f64) 
    return 0.0

def zero(u64) 
    return 0

service main()
    a = zero(f64)
    b = zero(u64)
    print(a)
    print(b)
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  27ms
compile --back gcc  --runtime std/runtime/auto.h  68ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

0.000000
0

Currying: The dot notation first_argument.function_call(other_arguments) sends the value on the left as the first argument of a function. This reads left-to-right and can be chained. Here is an example:

// main.s
@include std.core

def triple(f64 x) 
    return x*3.0

service main()
    2
    .f64()
    .triple()
    .print()
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  27ms
compile --back gcc  --runtime std/runtime/auto.h  68ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

6.000000

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

Returns πŸ”—

You can return from a function early per return value. This works given that there is no algorithm environment capturing the returned value.

// main.s
@include std.core

def abs(f64 x)
    if x<0.0 
        return 0.0-x
    return x

service main()
    x = 0.0-1.0
    print(abs(x))
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  27ms
compile --back gcc  --runtime std/runtime/auto.h  68ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

1.000000

You can return several named values at once and then access them by their variable's name with a dot notation. For example, if the outcome returning with return x,y is stored into a variable p, individual values can be accessed via p.x or p.y. But p still represents the sequence of values x,y.

This is visually different from currying in that there is no function call parenthesis. But returned values would not be retrievable from return x+1,y+1, as the additions have not been stored in a named variable. This is also fine, and you may do it for convenience in some scenarios. Finally, @args is a shorthand that returns all inputs if placed at the beginning of a return statement. Below is an example.

\\ main.s
@include std.core

def Point(f64 x, f64 y) 
    return @args

def moved(Point p, f64 dx, f64 dy)
    nx = p.x + dx
    ny = p.y + dy
    return Point(nx, ny)

service main()
    @mut p = Point(1.0, 2.0)
    print(p.x)
    print(p.y)
    p = p.moved(3.0, 4.0)
    print(p.x)
    print(p.y)
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  27ms
compile --back gcc  --runtime std/runtime/auto.h  68ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

1.000000
2.000000
4.000000
6.000000

If a function returns only one value, use that directly without an extra field name.

To help you write secure code that does not arbitrarily access mutable fields, smoΞ» normally prevents you from accessing or setting them. To be able to do so, add the @access notation at the very start of respective arguments. Below is an example where, if one removed the notation, even immutable versions of a type would not allow viewing certain fields.

// main.s
@include std.core

def Point(f64 _x, f64 _y) 
    @mut x = _x
    @mut y = _y
    return x,y

def print(@access Point p)
    // p is not mutable but you still need `@access` 
    // to look at p.x, p.y because their original 
    // declaration was mutable
    printin(x)
    printin(",")
    print(y)

service main()
    @mut p = Point(1.0, 2.0)
    // print(p.x) // not allowed
    print(p)
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  27ms
compile --back gcc  --runtime std/runtime/auto.h  68ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

1.000000,2.000000

Error handling πŸ”—

Now is the right time to talk about services and their philosophy: they are basically functions that run in parallel threads (in truth: with a co-routine model but this is too advanced for this tutorial) and, when something is wrong -including in one of their called functions-, they complain and stop their work. When they stop, they also safely release any resources so that you do not have memory leaks or other similar issues. Anywhere, call fail("message") to stop the current service.

The caller can check result.err.bool(). If you don’t check but try to use a value, the error will bubble up until it reaches a place that does. Your code waits for computations to conclude by called services only when values are used.

// main.s
@include std.core

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

service main()
    r = divide(1.0, 0.0)
    if r.err.bool()
        print("Could not compute.")
    else
        then print(r)
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  27ms
compile --back gcc  --runtime std/runtime/auto.h  68ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

Division by zero
Could not compute.

This approach keeps the happy path simple; you try running the service and, if it fails, decide what the next step is (ask again, use a default, stop).

Advanced πŸ”—

Unions πŸ”—

Sometimes we want to treat several different types as if they were β€œthe same kind of thing.” For example, an integer, a float, and a signed integer are all numbers with similar operations defined. In other words, we can write code that reads largely the same for all three types of numbers. In smoΞ», you can avoid repeating that code by defining a union. The latter groups together several types under a single name and you can define functions that work with the union instead of each type separately. Its definition looks like an assignment, where the right-hand-side types are separated by or.

union Number = f64 or u64 or i64 

This definition means that Number can represent u64 class="language-smolambda", f64, or i64. Once the union is defined, you can write functions that take a Number and automatically work with whichever form it has. For example, the standard library has printing and arithmetic operator overloads: so you can call print on a Number and it β€œjust works.” Unions are lexically scoped within functions; the same name always represents the same type. Thus, in the example below both numbers must be of the same type. But you can add a union as part of another one to transfer all types under a different name.

// main.s
@include std.core // defines Number

def add1mul(Number a, Number b)
    // convert 1 to the Number correct primitive
    one = 1.Number()
    return (a+one)*(b+one)

service main()
    print(add1mul(1,2))
    print(add1mul(1.0, 2.0))
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  27ms
compile --back gcc  --runtime std/runtime/auto.h  68ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

6
6.000000

Behind the scenes, smoΞ» makes sure that union usage is safe and the right code runs depending on which variant you are using; new functions immediately check if they can work for all variations of union members to report errors early.

Why are unions useful?

Unions let use overload function names (like print) by dynamically adapting them on the type. However, only the declared member types are allowed. They further quickly create zero-cost overloaded variations of functions with the same code.

Think of unions as a way to say: β€œI don’t care if this is an i64 or an f64 β€” as long as it’s a Number, I can use the overloaded operations to write the same code conceptually.”

Memory πŸ”—

At this point, you may be wondering how could smoΞ» run operations that consume an indeterminate amount of memory, such as combining strings or managing lists of arbitrary sizes. This is where buffers and @include std.mem come into play. We start from touching on concepts from the included file, which paint a general picture. Broadly, there are two memory devices; the Stack and Heap, corresponding to the small but fast memory your operating system uses to run your program, and the full extend of available memory. These provide memory allocation capabilities, but also grant you the ability to allocate more memory either dynamically or as arenas at the respective device.

Memory follows the same pattern as all resources that smoΞ» uses; it is freed only when not in use by any program data. This is achieved with some minor restrictions that the compiler will let you know occasionally. For example, you need to declare memory management contexts (the on statements mentioned below) outside of loops that would leak the memory in each execution. However, the benefit is that there is no running overhead or bottleneck for testing when memory is no longer needed and freeing it. Instead, memory is released back to the operating system at function ends. Which memory is released is determined during compilation. That is, memory management is a zero-cost abstraction.

Arenas: Of the aforementioned types of memory, arenas are pre-allocated memory regions that cannot grow. They are typically made to consume a safe amount of memory to ensure that programs can successfully run, although this often comes with some wastage. New allocations on arenas are lightweight (they are reduced to simple additions) and therefore execute quickly. In smoΞ», arenas that run out of space cause services to fail, which is always done safely and without leaking resources. Here is how to work with arenas, where the on statement automatically adds the arena as a first argument to all operations inside the current code block.

// main.s
@include std.core
@include std.mem
@include std.vec

service main()
    // allocate a stack arena of 1024 bytes
    @mut memory = Stack.arena(1024)

    on memory
    v = vector(5) // calls vector(memory,5)
    print(v.len())
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  27ms
compile --back gcc  --runtime std/runtime/auto.h  68ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

5

If you switch to Heap instead of Stack, the same code would allocate on random access memory. The details of how heap memory is managed may be modified for different systems by passing a different runtime to the compiler.

In the above snippet, mutability is needed to allow modification of memory contents. However, temporary values thar have not yet been assigned to any symbol are mutable. You can also return a value from that block, therefore making the following equivalent syntax possible. Remember that the arena's consumed memory is not released at the end of the on block, but only when the vector is no longer needed.

//main.s
@include std.core
@include std.mem
@include std.vec

service main()
    on Stack.arena(1024) 
    v = vector(5)

    on ok
    print(v.len())
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  27ms
compile --back gcc  --runtime std/runtime/auto.h  68ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

5

Dynamic memory: You can also choose to maintain a collection of dynamically allocated memory segments, for example by calling on Heap.dynamic() instead of creating an arena. If multiple of those segments are attached to the same dynamic allocation, they are also released together, which means that they remain allocated as long as any is in use. However, you can also retain one allocation per dynamic memory.

For more types of memory and operations, refer to the standard library's documentation here. Among other things, the implemented memory model allows parameterization of functions by being able to allocate on segments of allocated memory.

Strings πŸ”—

SmoΞ» provides three main string types: cstr (a constant string enclosed in quotations during compilation, zero-terminated), nstr (zero-terminated with length information), and str (a string segment). For most purposes, you can use and convert results to str.

Most operations on strings are zero-cost abstractions over simple arithmetics, as happens for example when retrieving substrings. The most heavyweight operations are copying and concatenation, both of which require an arena or dynamic memory to store the result. Below is an example, where it is important to note that concatenation yields a nstr by default, which can be directly reduced to a simpler str if needed. Similarly, conversion of cstr to the other types is lightweight.

 // main.s  
@include std.core
@include std.mem

def Segment(nominal, str value)
    // return all inputs 
    // function returns are tuples of named elements
    return @args

def Segment(String _value)
    // convert from many string types
    value = _value.str()
    return nominal.Segment(value)

def combine(Segment[] segments)
    @mut combined = "".str()  // mutable string with known size
    on Stack.arena(1024)      // automatically use as argument if needed (for string operations)
    segments.len().range().while next(@mut u64 i) 
        then combined = str(combined+segments[i].value+" ")
    return combined

service main()
    segments = Segment[] // buffer
    .push("I think.".Segment())
    .push("Therefore I am.".Segment())
    segments.combine().print()
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  27ms
compile --back gcc  --runtime std/runtime/auto.h  68ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

I think. Therefore I am.

Buffers πŸ”—

Buffers represent collections of data that grow dynamically. The simplest syntax is type[], which produces a resizable array of that type. You can grow buffers or modify their elements only as long as they remain mutable. For example, you can create a buffer v = u64[].push(1).push(2) that is henceforth immutable.

// main.s
@include std.core

def data(u64 id, u64[] values) 
    return @args

service main()
    @mut vals = u64[]
    .push(1)
    .push(2)

    p = data[]
    .push(data(10, vals))

    print(p[0].id)
    print(p[0].values[0])
    print(vals[0])
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  27ms
compile --back gcc  --runtime std/runtime/auto.h  68ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

10
1
1

By default, buffers use dynamically sized Heap memory to store their data. However, you can also create them on memory allocations created from the standard library; all types of memory accept an ,allocate(size) function for setting aside a fixed-size region, within which buffers can grow. Below is an example. Do note that data can be pushed onto buffers during their creation, while they are temporary variables -and hence mutable- and the result can be assigned to an immutable variable.

// main.s
@include std.core

service main()
    vals = u64[Heap.allocate(1024)] // 1024 max buffer size
    .push(1)
    .push(2)

    print(vals[0])
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  27ms
compile --back gcc  --runtime std/runtime/auto.h  68ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

1

Buffers preserve mutability rules: if a buffer itself is mutable, its contents can be changed or extended. Strings can also be stored in buffers or arrays given that they reside in the same memory surface. Below is an example.

\\ main.s
@include std.core
@include std.mem

service main()
    boxes = str[]
    .push("buffer start".str())
    .push("buffer end".str())

    b = boxes[0]
    print(b)
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  27ms
compile --back gcc  --runtime std/runtime/auto.h  68ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

buffer start

Here we pushed two strings into a string array. Notice the explicit conversion to the correct string version using .str().

Returning buffers from services.

Buffers can be returned from services normally. In this case, their memory is released by the returning service. In the example below, samples constructs and returns a buffer. The caller can then access its elements with buf[index].

@include std.core

service samples()
    buf = u64[]
    .push(42)
    .push(10)
    return buf

service main()
    buf = samples()
    print(buf[0])
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  27ms
compile --back gcc  --runtime std/runtime/auto.h  68ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

42

Recursion πŸ”—

In most languages, recursion means that a function calls itself, or that it calls another function that eventually calls itself. This is useful, but it usually comes at a cost: every call stacks on top of the previous one, and if you recurse too deeply, you might run out of memory (a β€œstack overflow”). SmoΞ» functions can only look at previously declared or imported functions in an effort to protect such unconstrained behavior. But the same recursive computations are still possible.

In particular, instead of creating dependent calls, the language uses a trick called trampolining. Think of it like a ball bouncing back and forth between two or more functions: each one decides what the next step is, and we loop until the process finishes. This way, recursion does not pile up memory; it just reuses a single loop.

Below is an example that demonstrates how to do this using a tag primitive that we have not addressed so far. That consists of writing ;name and behaves like a number with a given value. Importantly, tag values, can overlap with function names, which a @dynamic instruction uses to its advantage to execute different functions given different tag values.

The tag primitive type lets us reference :ping or :pong anywhere, even before those are being defined. Then, the @dynamic instruction chooses to call a function among various options (ping or pong here) depending on the next tag value. Additional tag values are added as first arguments. If something unexpected happens, such as another tag was found, the service just fails safely, without memory leaks or crashes. At the same time, types are safe and are checked during compilation.

// main.s  
@include std.core

def lambda(tag func, u64 n) 
    return @args

def ping(u64 n, cstr message)
    printin(message)
    print("ping")
    return :pong.lambda(n)

def pong(u64 n, cstr message)
    if n == 0
        return :done.lambda(u64)
    printin(message)
    print("pong")
    return :ping.lambda(n-1)

service main()
    @mut pending = :pong.lambda(2)
    while :done!=pending.func
        then pending = @dynamic(ping,pong) pending("next - ")
> smol main.s
codegen --workers 1 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 7/7 files  22ms
compile --back gcc  --runtime std/runtime/auto.h  66ms
running tests/unit/tutorial/hello

πŸ”Ή https://github.com/maniospas/smol
πŸ”Ή single threaded

next - pong
next - ping
next - pong
next - ping

To sum up, in the example above ping and pong call each other back and forth. Instead of diving deeper into the stack, they just return a next state marked with a tag (:ping, :pong, or :done). The main loop keeps track of which step is next, and runs it until it reaches the :done tag.

So whenever you would normally write a recursive function, you can instead write it as a loop with tags. It feels a bit like building a tiny β€œinterpreter” for your recursive logic that is safe and efficient, and performant. Do note that @dynamic may inject code from all functions it handles, so do not overuse it to avoid creating exceptionally large executables.