Quickstart 🔗

About 🔗

Smoλ (pronounced like "small" but with "o" instead of "a") is a low-level language with fast zero-cost abstractions that are organized into failsafe services. Its core is tiny — really tiny! So tiny, in fact, that printing and basic arithmetics are externally implemented in the standard library with a C/C++ interface. Follow on GitHub to track progress.

Overall, there are two main constructs: a) runtypes denoted by smo that declare short inlined operations, and b) services that are executed in parallel and handle internal failures gracefully. Use the former for speedy intermediate operations and the latter for error handling over large chunks of business logic.

The type system is algebraic with overloads, unions, and basic type inference - just enough to keep code simple without hiding behavior. All code is fastly executed on the stack, but there are heap buffers to handle variadic data or exchange data between services.

Setup 🔗

Download smoλ from its latest release or build it from source by cloning the repository and running g++ src/smolang.cpp -o smol -O2 -std=c++23. The language is so lightweight that there is no need for a build system and its main executable consumes less than 300kB ... plus a GCC distribution if you want more optimizations. Make sure that the smol executable and GCC is in your system path. Alternatively, add --back tcc to use tiny C or another lightweight compiler as a backend. If you weave in raw C++ code instead of simple C, you can also use a compiler for that language.

A language server is provided as a VSCode extension named smoλ (smolambda); search for smolambda in the extensions tab, or get the extension from here. The language server offers tooltips, error traces, and jumping to definitions.

Basic structure 🔗

Look at an example of how smoλ manages data structures:

@include std.builtins

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

smo Field(nominal, Point start, Point end) 
    -> @args

service main()
    // raw numbers you write are either 
    // f64 (floats) or u64 (unsigned ints)
    p = Point(3.0,5.0)
    f = nominal:Field(1.0,2.0,p)
    print(f.start.x + f.end.y) // 6
    --
> smol main.s
6

First, @include brings code from other files with the .s extension. Paths are separated by dots. Here we include builtins from the standard library that we are going to use, mainly arithmetic operations and printing. Nothing exciting yet.

Next are some so-called runtypes, declared by the smo keyword. These merge the concept of types and functions, where -> returns a value or tuple. If variable names are part of the returned tuple, they can be accessed as fields from the result. @nominal is a shorthand for returning a tuple of all arguments. It is useful for declaring structural data without functionality - though those data may make runtime assertions.

In general, runtypes are structural in that they are identified by their primitive layout. For example, Point2D is recognized at any place where two f64 values exist. You can switch to nomal typing that ensures only a type-resolved version is applied by having nominal as an argument. This is needed as the first argument during calls later on, where : is the currying notations that passes a left-hand-side value as the first argument of a runtype. That is, obj:method(args) is equivalent to method(obj, args).

Write something like p = Point(...) to access all returned named variables using notation like p.x and p.y. Unpack the result directly in other runtype calls, provided that primitive types match. All arguments are flat -tuples are unwrapped to more elements- and can be reinterpreted in various ways as long as primitive types match. Safety options for this are presented later.

Put all main business logic inside a service main() definition. Services "catch" internal errors, safely handling allocation and deallocation. They are also intrinsically parallel, can call each other, and handle returned errors. But more on them later. For now, the main service is the program and the -- symbol at its end indicates no returns.

Cheatsheet 🔗

Here is a summary of the language's core. More operations, including string and file system manipulation, are available in the standard library.

Symbol Description Notes
Declarations
smo Runtype definition Defines an inlined type-function hybrid
service Service definition Parallel, safe, error-capturing function
union Type alternatives Enables compile-time type matching
Type(values) Call/cast Values expand into primitives
@include Import external file Dot-path notation used for `.s` files
@noshare Mark as unsafe to share Prevents sharing to other services
@release Release all resources Invalidates any variables using those
. Field access For named returns of runtypes
: Currying operator Passes value as first argument
Returns
-> Return value(s) Used to return from runtype or service
-- Return with no value No return value, is an explicit terminator
|-- Uplift one level Break from a loop or if-block
|-> Uplift return one level Break from nested blocks
||-> Uplift return two levels E.g., escape loops from inside conditions
@nominal Return all inputs Prioritized on runtype conflicts
value.err Check if service failed Omitted in tuple unpack
& Call by reference Before name in signature, mutates values
Control flow
if Condition Can be used with else, can yield value
else Alternative Matches conditions
elif Alternate condition Shorthand for else->if
while Loop Can yield a value from internal returns
with Type conditioning Needs else, runs first compilable branch
fail(str message) Manual error trigger Causes service to fail and propagate error
@next Deferred assignment Evaluated now, assigned at end of block
Builtins
nominal Nominal type checking Automatic unique value in arguments
i64, u64, f64, ... Builtin runtypes Can be called to convert to each other
[start to end] Slicing (exclusive end) Returns a view of contents
[start upto end] Slicing with inclusive end Variant of range slicing
[start lento count] Slicing by length Number of elements instead of end
C/C++ ABI
@body{...} Inline C/C++ code ABI-level logic inside runtypes
@head{...} Include C/C++ headers Prepended once per program
@fail{...} C/C++ for error handling Prefer fail(str message)
@finally{...} C/C++ to free resources Can also tie the resources to a variable

Control Flow 🔗

Conditions 🔗

smoλ offers if-statements and while loops to respectively execute code blocks conditionally and repeatedly. These return values per -> value or return with no values per --. Return statements designate the end of code blocks. Below is an example, where &i=0 means that the value cstored in i can be freely modfied in subsequent code.

Conditions must always evaluate to booleans, as there are no implicit casts. The else branch is optional. Similarly, adding parentheses may help clarity but is not required because the end of expressions is always unique.

@include std.builtins

service main()
    &i = 0
    while i<10
        if 0==i%2 -> print("Even "+i:str)
        else      -> print("Odd "+i:str)
        i = i+1
    ---- // ends `while` then `main` 

All branches of if statements must return either the same runtype or nothing. Below is an exampe that demonstrates chained if-else statements. Use :i64 to convert numbers to signed integers (recalled that unsigned integers are the default) and use the floating point version of numbers in corresponding operations. For example, we use 0.0 below. Different types of numbers can be converted to each other but are normally not allowed to mingle for safety.

@include std.builtins

service main()
    x = f64:read // more on this syntax later
    sgn = 
        if x>0.0        -> 1:i64
        else->if x==0.0 -> 0:i64
        else            -> 0:i64-1:i64
    print(sgn)
    --

Equivalently, replace the pattern else->if with the elif keyword, which is more ergonomic. Below is the same example rewritten more concisely.

@include std.builtins

service main()
    x = f64:read
    sgn = 
        if x>0.0    -> 1:i64
        elif x==0.0 -> 0:i64
        else        -> 0:i64-1:i64
    print(sgn)
    --

Finally, smol supports logical operators and and or that are applied on boolean values. These operators are defined as part of the language and apply only on bool inputs. They further short-circuit logic expressions. That is, they do not compute redundant segments, as shown below.

@include std.builtins

smo test() 
  print("called") 
  -> true

service main()
    print("Normal conditions") // prints
    true and test() // prints
    false or test() // prints
    print("The rest are short-circuited away")
    false and test()
    true or test()
    --

Loops 🔗

Loops are similar to conditions with the difference being that they keep repeating as long as their condition does not evaluate to false and nothing is returned. See the uplifting operation below on understanding values returned by loops.

To better organize conditions or loops of only one statement, return a parsed expression like the variation below. There, prints do not return values and you would therefore get an error if you tried to return a value in one of the branches. Syntax is equivalent to printing and then ending code blocks with --.

@include std.builtins

service main()
    &i = 0
    while i<10
        if 0==i%2 -> print("Even "+i:str)
        else      -> print("Odd "+i:str)
        i = i+1
    ----

@next 🔗

You may want to compute a value but assign it at the variable at the end of the current control flow's body, for example to denote the next value in a loop. This is achieved with the @next instruction. The @ prefix indicates that the instruction might affect your code non-locally. Below is an example of using this construct to write loops, although we will later see a simpler notation.

@include std.builtins

service main()
    &i=0 while i<10 @next i = i+1
    &j=0 while j<10 @next j = j+1
        print(i:str+" "+j:str)
    ------

ℹ️ The standard library promotes an iteration-based style based on currying into loops. Prefer using that when writing finite loops, as it is more resistant against mistakes. As a sneak peek, printing numbers 0,1,...,9 looks like this:

range(10):while next(u64& i) -> print(i)

Aside from loops, the same mechanism is useful for scrambling values; below if true is used to isolate the next values and intercept the internal return.

@include std.builtins

service main()
    &i = 1
    &j = 2
    if true
        @next i = j
        @next j = i
        --
    print(j) // 1
    --

Uplifting (breaking with a return) 🔗

There are no break and continue statements for loops because these can be emulated. First, as a styling choice, do not indent conditions that run from a position until the end of the loop's body but instead put the end condition's and loop's ending together. An example is shown below.

The example also demonstrates how to break loops by returning multiple steps back. This operation is called uplifting and its syntax is to prepend | to returns. You can uplift several times to escape from nested control flow. For example, |-- breaks from a condition within a loop and ||-> can be used to return a value from a condition within a loop. Next is a simple example.

@include std.builtins

service main()
    &i = 511 while i<10 @next i = i+1
        if i%7==0 
            |-- // break
        if i%2==0 
            -> print(i)
    ---- // ends `while`, then `main`

Below is another example, in which uplifting returns even more steps back. Note that print returns no value, which would only create an error if one tried to assign the value returned by the top loop to a variable.

@include std.builtins

service main()
    &i=0 while i<10 @next i = i+1
    &j=0 while j<10 @next j = j+1
        print(i:str+" "+j:str)
        if 5==i+j 
            |||-> print("done") 
    ------

Runtypes 🔗

Type+function 🔗

Types and functions are the same thing in smoλ and marked as smo followed by a name and a parenthesis with some arguments. We call the merged concept runtypes. As an example, look at a definition from the standard library, which also gives a taste of the C++ ABI:

smo add(f64 x, f64 y)
    @body{f64 z=x+y;}
    -> z

This tells us that we are defining an 64-bit floating number runtype with the corresponding arguments. When called from other code, the definition is inlined in an optimization-friendly way. For example, despite the illusion of typing, everything consists of direct variable operations; under the hood, field access like f.start.x is replaced with variables like f__start__x.

Return a value or tuple of values with ->, and use @body to write C/C++ code. The interaction is described later, but for now notice that a basic scan is also made to expose primitive types from inside the ABI.

If you don't want to return anything, use --. Return symbols act as visual barriers that are easy to spot while remaining ... small. Note that smoλ does not require semicolons becase boundaries are unique: everything ends at return statements, at the end of file, or resides in-between parentheses and commas.

Primitives 🔗

smoλ has several primitive types for building more complicated runtypes. The standard library implements several operations for primitives. The ones built in the language are 64-bit integers, their unsigned version, and floats. These are respectively denoted by i64, u64, and f64. The last one is equivalent to C/C++'s double numbers.

The language also offers a ptr type as a means to represent 64-bit address. In most situations you will not use addresses, but they are useful for interweaving more complicated C/C++ code in ways that the compiler can understand (this requires a lot of casts, though you can pack complicated bits in .cpp files if you want).

For convenience, you can find the char and bool primitives, which are the C equivalents. The errcode builtin captures the error values of failing services. nder the hood, it is implemented as a C/C++ int to remain compatible with the operating system.

Return 🔗

Values returned from runtypes are always organized into tuples. However, you can access named fields of that tuple, given that internal variables are returned. In previous examples, this means that we could access fields like x,y,start,end given that they have been internally assigned to variables.

Field access works a little differently when there is only one value returned: in this case, you get direct access to that value, without of treating it as a field. An example of this concept is presented below, where the overloaded Point runtype with no arguments directly returns a version constructed with two arguments.

@include std.builtins

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

smo Point() 
    start = Point(0.0, 0.0)
    -> start // as a single argument it is directly unpacked

service main()
    s = Point()
    print(s.x) // not s.start.x
    --

The assignment operator copies the outcome of function calls. This is usually a so-called "deep" copy, up to the point of data residing in pointers. However, only returned symbols can be accessed as fields. For example, below the input variable x cannot be accessed after computations conclude. We already saw that it may be convenient to unpack all runtype inputs with @nominal to directly declare a structural type without internal implementation.

@include std.builtins

smo multi_out(u64 x)
    xinc = add(x,1)
    -> xinc, x

smo single_out(u64 x)
    x = add(x,1)
    -> x

service main()
    p1 = multi_out(1)
    print(p1.x)        // 1
    print(p1.incx)     // 2
    p2 = single_out(1)
    print(p2)          // 2
    print(p2.x)        // CREATES AN ERROR
    --

Mutability (call by reference) 🔗

Runtypes are called by value, that is, without internal computations affecting external arguments. You can make calls occur by reference by prepending & to variable names in the signature. In this case, internal modifications are retained. Below is an example.

@include std.builtins

smo inc(u64 &x)
    x = add(x,1)
    --

service main()
    x = 1
    inc(x)
    print(x) // 2
    --

Mutable fields can be mutated only if arguments are mutable too. This way, mutations are explicit. Below is an example where, due to structural typing of the first Point p, its mutable fields can be Passed as mutable arguments. The same DOES hold true for the nominally typed Point pt.


@include std.builtins

smo Point(f64 px, f64 py)
    &x = px
    &y = py
    -> x,y

smo Point(nominal type, f64 px, f64 py)
    &x = px
    &y = py
    -> type, x,y

smo scale(Point &p, f64 factor) 
    // & in the signature is required to allow any modification
    // this is an overloaded method for both Point implementations
    p.x = p.x*factor
    p.y = p.y*factor
    --

service main()
    p = Point(1.0,1.0)  
    p.x = p.x + 1.0
    p:scale(5.0)
    print(p.x) // 10

    tp = nominal:Point(1.0,1.0)
    tp:scale(5.0) // creates an ERROR
    --

ℹ️ As a rule of thumb, allow maximal extensibility by only adding & if the compiler complains about mutability.

A special rule is that ptr primitives that are not mutables cannot be assigned to fields of mutable variables. This allows treating pointers as the representation of memory contents, where mutability means that memory contents also donnot change. The next example shows how the commented out &mutmap = map is not allowed, and in turn this prevents the modification of the immutable map's entries.


@include std.builtins
@include std.map

service map_printer(Map map)
    with 
        map.Keys:is(str) 
        map.Values:is(u64)
        --
    //&mutmap = map // ERROR - cannot transfer immutable pointers
    //mutmap:put("123", 2) 
    print(map["123"])
    --

service main() 
    on Heap:dynamic // automatically pass dynamic memory as first argument
        &map = map(100, str, u64) // flatmap with 100 slots
        --
    map:put("123", 1)
    map:map_printer
    --

Currying 🔗

Runtype calls accept currying notation that transfers a precomputed value to the first argument. The curry operator is : and can be chained. Furthermore, you can omit parentheses if there is only one argument and you curry it. Below is an example, where this notation is used to have as little nesting as possible.

@include std.builtins

service main()
    1:add(2):mul(3):print
    // equivalent to print(mul(add(1,2), 3))
    --

In general, runtypes are all first-class citizens of the language in that they cannot be set as variables. However, you can pass a known or type as an argument to denote dummy empty objects. Currying lets smoλ avoid methods as fields, as the notation obj:runtype(args) is conceptually similar. Note that mutability should be explicitly declared if you want rt to have side-effects.

If currying is followed by if or while then the curried expression result is transferred to the condition. This enables runtypes that serve as iterators by modifying an iterated value by reference. Below is an example, where u64 &i declares a zero-initialized variable to hold the iteration state. For safety, this pattern is available only if i has not been declared. The standard library provides a richer range based on the same principles.

@include std.builtins


smo custom_range(u64 start, u64 end) 
    &pos = start
    -> pos, end
smo next(custom_range &r, u64 ¤t) 
    current = r.pos 
    r.pos = r.pos + 1
    -> r.pos <= r.end

service main()
    custom_range(0,10):while next(u64 &i) 
        print(i) 
    ----

Type system 🔗

Overloading 🔗

Overload runtypes that are structurally different when converted to a flat representation of primitives (i64, u64, f64, etc). Consider nominal types presented later as primitives. For now, you can see the automatic application of the appropriate runtype. Do note that conflicting definitions will create errors.

@include std.builtins 

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

smo circle(point center, f64 r)
    -> @args

smo print(point p)
    printin("point (") // print without changing line
    printin(p.x)
    printin(",")
    printin(p.y)
    print(")")
    --

smo print(circle c)
    printin("circle radious ")
    printin(c.r)
    printin(" centered at ")
    print(c.center)
    --

smo add(point a, point b) 
    -> point(a.x+b.x, a.y+b.y)

smo add(circle a, circle b) 
    -> circle(a.center, a.r+b.r)

service main()
    p1 = point(1.0, 2.0)
    p2 = point(3.0, 4.0)
    c1 = circle(p1, 10.0)
    c2 = circle(p2, 10.0)
    print(add(p1,p2))
    print(add(c1,c2))
    print(0.0, 0.0, 0.0) // understood as a circle
    --
> smol main.s
point (4.000000,6.000000)
circle radious 20.000000 centered at point (1.000000,2.000000)
circle radious 0.000000 centered at point (0.000000,0.000000)

Call by type 🔗

You might want to choose a runtype's version based on another without actually passing data. For example, something different should be called based on the expected outcome. In those cases, you can add signature variable that does not have a name. Similarly, pass the type of the variable as an argument.

Below is a segment of the standard library that demonstrates how the correct version of an evoked method is applied. Runtypes without parentheses refer to zeroed out input data. You can also use a value as reference - that would be ignored.

smo not(bool x) @body{bool z=!x;} 
    -> z
smo print(f64 message)
    @head{#include <stdio.h>}
    @body{printf("%.6f\n", message);}
    --

smo read(i64)
    @head{#include <stdio.h>}
    @body{i64 number = 0; bool success = scanf("%ld", &number);}
    if success:not @fail{printf("Invalid integer\n");} --
    -> number

smo read(f64)
    @head{#include <stdio.h>}
    @body{f64 number = 0; bool success = scanf("%lf", &number);}
    if success:not @fail{printf("Invalid number\n");} --
    -> number

service main()
    x = f64:read // x = read(f64)
    print(x)
    --

Nominal typing 🔗

Often we want runtypes to enforce certain relations between their values that are registered on the runtype name. This is achieved by setting the runtype by providing a nominal first argument and ensuring that this is returned. The returned tuple adheres to the so-called nominal typing. Do note that nominalvalues are zero-cost in that they do not consume runtime resources; they just enforce type comparisons.

Compilation outcomes ignore nominal (e.g., it does not take up storage), as it is mostly used for disambiguating between strucurally equivalent runtypes.

@include std.builtins

smo Type1(nominal, f64 value) 
    -> @args
    
smo Type2(nominal, f64 value) 
    -> @args

smo recognize(Type1 p) 
    print("this is Type1") 
    --

smo recognize(Type2 p) 
    print("this is Type2") 
    --

service main()
    p1 = nominal:Type1(1.0)
    p2 = nominal:Type2(2.0)
    recognize(p1) 
    recognize(p2) 
    --

Till now we used ->@nominal returns as a shorthand for returning input values. However, this also helps disambiguate overloading when there is no clear resolution. In particular, if exactly one of the competing runtypes has this kind of return declaration or argument, that is used as the choice that breaks the stalemate.

For example, consider the following very simple program that reads from the console and manipulates strings with a standard library implementation. The str runtype is overloaded to allow various conversions to other primitives. However, we are still able to identify a specific variation, which in turn is used to identify and call str:read.

@include std.builtins

service main()
    print("What's your name?")
    name = str:read
    print("Hi "+name+"!")
    --

Unions 🔗

Sometimes, you want to define code that is automatically adjusted to different runtypes. This can be achieved by declaring runtype unions, which are resolved to one of their options. The resolution persists to dependent calls, so create separate unions for independent resolution. Unions are resolved during compilation and, like many features of smoλ, are zero-cost abstractions. They can also contain other unions or nominal types.

For example, the Type union below is determined to be f64 while calling inc to match the Point argument and carries over to the internal implementation. Therefore, the f64 primitive is used for reading, constructing a two-dimensional point, for casting the value of 1 to the appropriate type, and calling the corresponding overloaded addition. Unions account for overloads of their options up to the point where they are defined.

@include std.builtins

union Type
    i64 
    f64 
    u64
    --

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

smo inc(Point &p)
    p.x = add(p.x, Type(1))
    p.y = add(p.y, Type(1))
    --

service main()
    value = f64:read()
    p = Point(value, value)
    p:inc()
    print(p.x)
    --

Buffers 🔗

All types admit a buffered variation that can hold an arbitrary number of copies. Declare buffers by appending type names with []. The language accepts three special runtypes: push to grow the buffer with an additional item at its end, at that is also used for bracket-based indexing, and len to count the number of elements in the buffer. Below is a simple example:

@include std.builtins
@include std.mem

service main()
    boxes = str[]
    :push("buffer start":str)
    :push("buffer end":str)
    b = boxes[0]
    print(b) 
    --
> smol main.s
Buffer start

Immutable buffers have immutable contents. This includes not allowing retrieval of ptr data to accidentally place them on mutable variables. There is a lot of flexibility in defining your own buffers, but smoλ actively promotes the faster memory management of its standard library

@include std.builtins

smo strbuf(nominal, str[] &ref)
    -> @args

smo strbuf(String value)
    -> nominal:strbuf(str[]:push(value:str))

smo put(strbuf &buf, String value)
    -> buf.ref:put(0, value:str)

smo str(strbuf &buf)
    -> buf.ref[0]:str

service main()
    &boxes = strbuf[]  // must be mutable to grant mutable access
    :push("first element":strbuf)
    :push("second element":strbuf)
    &b = boxes[0]
    // the line bellow is fancier than b:put("overwritten element")
    on b 
        -> "overwritten element":put 
    print(b:str) 
    --
> smol main.s
overwritten element

Typechecking 🔗

There is the option to perform compile-time typechecking. This capability is provided because one of smoλ's goals is to give the illusion of a higher-level counterpart despite providing fast zero cost abstractions over bare metal assembly.

This feature tries to compile several alternative code blocks, picking up the first valid one (validity is checked only in terms of runtype arguments). It does so by designating the start of a code block with with and having at least zero or more follow-up block designated by else.

This feature is particularly useful with union types that let you write the same generic code once and call it with various kinds of data. In those cases, you might want to have code behave differently depending on the obtained data. Below is a first glimpse on this dynamism, where first employs a different pattern depending on the data it is called with. The implementation could also be overloaded, though code selection is an ergonomic alternative. More on overloading later.

@include std.builtins
@include std.vec // more on vectors later

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

union Elements 
    vec
    point
    f64
    i64
    --

smo first(Elements elements)
    with 
        res = elements:vec[0]
        --
    else
        res = elements:point.x
        --
    else 
        res = elements:f64
    ---> res

service main()
    x1 = vec:rand(10)
    print(x1:first)
    print(z:first)
    print(point(1.0,2.0):first)
    --

Services 🔗

Why services? 🔗

So far we have been writing service main() as the entrant point of programs. Armed with the basics, we can now look at what that service keyword is all about. Functionally, services are runtypes. Below is an example of declaring and calling a service. Syntactically, this is near-identical to working with runtypes so that it's easy to change your mind as you write code. You will often not notice anything different.

@include std.builtins

service square(f64 x) 
    -> x*x

service main()
    y = square(2.0)
    print(y)
    --

Semantically, services are equivalent to runtypes with error handling and the restriction that they a) do not accept arguments by reference, b) stop further mutation of mutables provided as arguments. However, they cost some additional operations per call, as they need to actually push the call to the stack and cascade unhandled errors. So they are less efficient if you, say, call them millions of times per second.

That said, they have three distinct advantages. First, they run independently and in parallel. Second, they can call each other regardless of definition order, and even allow recursion (by comparison, simpler runtypes can only "see" previous declarations). And, third, they provide a compartmentalized execution environment that does not let internal errors escape.

Error handling 🔗

The last point means that, after calling a service within another one, you need to consider how to handle prospective errors. The pattern discussed so far blindly unpacks results into further service and runtime calls. This elegantly fails if an error is returned: it is cascaded through the service call stack until handled. Or it eventually terminates the main service.

@include std.builtins

service square(f64 x) 
    fail("Don't wanna!") // manually fail
    -> mul(x,x)

service main()
    y = square(2.0)
    print(y)
    --
> smol main.s
Don't wanna!
Runtime error: `square y` contains an error

You can check for the error code of services by accessing it from the result per result.err. By convention, error codes are skipped when unpacking values to let service returns be used interchangeably to runtypes in the hotpath.

@include std.builtins

service square(f64 x) 
    fail("Don't wanna!") // manually fail
    -> mul(x,x)

service main()
    y = square(2.0)
    if y.err:bool -> print("Something went wrong")
    else          -> print(y)
    --
> smol main.s
Don't wanna!
Something went wrong

Services fail safely by deallocating resources, and in fact smoλ encourages letting services fail and retrying instead of creating hard-to-implement recovery. Below is an example, where a service is created to handle bug-prone inputs in an isolated environment. You could also implement it as a runtype and then have the whole main service fail too.

@include std.builtins

service get_age() 
    print("What's your age?")
    age = u64:read
    if age<=1
        -> fail("Too young for this stuff")
    if age<5
        -> fail("Still too young")
    if age>=140 
        -> fail("Too old for this stuff")
    -> age

service main()
    while true
        age = get_age() 
        if age.err:bool:not 
            |--
        --
    on Heap:dynamic
        print("You've seen at least "+str(age-1)+" years gone by.")
    ----
> smol main.s
What's your age?
1000
Too old for this stuff
What's your age?
0
Too young for this stuff
What's your age?
10
You've seen at least 9 years gone by.

Std 🔗

Beyond the core 🔗

The standard library contains implementations for common programming needs. Besides overloading pairswise numerical and comparison operators for basic arithmetic types and booleans, it also supports string handling and contains a backbone for memory management.

Memory 🔗

Perhaps the most important set of operations in smoλ's standard library is its memory model. This leverages functionality from the language's core to. Two types of memory are generally assumed: Stack memory that is fastly allocated in the stack of services and Heap memory that is a bit slower to manage (and access) but which resides in the main computer memory and can be used for large chunks of data. These memory types are provided every time; in devices where heap memory is not available, appropriate runtimes can still use a large static buffer under the hood.

Memory operations are safe in that there are no memory errors, such as use-after-free or corruptions; unused memory is released when services end or when resources are invaldiated with @release (see next). The main language contract is that memory or other resources can be released only when services end.

Console 🔗

Basic functionalities are introduced for the console, namely reading and printing. These support all escape characters C/C++ can support, including ANSI codes for colors. They have also been overloaded for integers, floats, and -as seen in the next segment- strings. Reading failure fails the current service under smoλ's guidelines that services should fail completely and rerun instead of trying to recover from invalid states. Here's an example:

@include std.builtins

service main()
    success = false
    x = f64:read()
    print(x)
    --

Strings 🔗

String manipulation is included from the builtin functionalities of the standard library. Below is an example that demonstrates conversion from primitives. Similarly overload str(Type obj) for custom types. Strings admit the following two optimizations under the hood to enable very fast handling without creating excessive bloat when passed around: a) they retain c-style strings for quoted characters, and b) they keep track of the first character to enable fast comparison without going through heap data.

@include std.builtins

service main()
    i = 0
    while i<=10 
        if 0==i%2 
            -> print("Even "+i:str)
        else      
            -> print("Odd  "+i:str)
        i = i+1
    ---- // end while and main with no return

Buffers and standard library implementations, like strings, are safe to return from services. In that case, deallocation instructions are transferred upwards, but still performed on failure. Note that the C/C++ interface is generally unsafe and requires extensive tests to demonstrate correctness. However, once safe implementations are provided, you can reuse those.

Strings are immutable and therefore allow for very fast operations for obtaining substrings by increasing pointers and decreasing length under the hood. Those operations apply bound checks but do not copy memory and are therefore performant. Here is an example of obtaining substrings with the slicing notation.

@include std.builtins
service main()
    s = "I like bananas!"
    print(s[2 to 7])  // prints "like"
    --

As a trade-off for performance and safety, manipulating individual characters in strings can only be done by spliting and recombining them, which may be computationally intensive by moving memory. Manipulating characters at individual string positions without losing safety will be covered in future versions of the standard library.

@include std.builtins

service add_service(str v1, str v2) 
    i = 0
    while i<10
        v2 = v2+"c"
        i = i+1
    ---> v1+v2

service main()
    r1 = "aa":str
    r2 = "bb":str
    r1 = add_service(r1,r2)
    print(r1)
    --

Allocators 🔗

Section under construction.

@include std.builtins
@include std.mem

smo zero(Memory, u64 n)
    v = nominal:allocate(Memory, n, f64)
    i = 0 
    while i<n
        @next i = i+1
        v:put(i, 0.0)
    ---> v
 
smo dot(allocate x, allocate y)
    if x.size!=y.size -> fail("Mismatching size")
    sum = x.Primitive:zero
    i = 0
    while i<x.size
        @next i = i+1 
        with 
            sum = sum + x[i]*y[i]
            --
        else 
    -----> sum // all closing statements

service main()
    n = 10000
    x = heap:zero(n)
    y = heap:zero(n)
    x:put(0, 0.1)
    y:put(0, 0.2)
    print(dot(x,y))
    --

Files 🔗

Section under construction.

@include std.builtins
@include std.file
@include std.mem

service main()
    f = file("README.md")
    chunk = "":str
    nom
    :chunks(f, 4096, heap)
    :while next(str& chunk) 
        print(chunk)
    ----

Vectors 🔗

Under construction.

Below is an example that demonstrates vector computations. These include the generation of vectors with uniformly random elements.

@include std.builtins
@include std.vec
@include std.time

service main()
    n = 100000
    x1 = vec(n)
    x2 = vec(n)
    tic = time()
    z = dot(x1,x2)
    print("Elapsed \{time()-tic} sec")
    print(z)
    --

Math 🔗

Various trigonometric operations are available, namely cos, sin, tan, acos, asin, atan. In addition to those, you can compute multiples of π=3.14159... per value:pi like below.

@include std.builtins
@include std.math

service main()
    print("Give a number of radians")
    rads = f64:read:pi
    print("cos \{rads:cos}")
    print("sin \{rads:sin}")
    print("tan \{rads:tan}")
    --
Furthermore, exp,log,pow,sqrt are also available. Importantly, the logarithm requires a positive inputs, the square root requires non-negative input, and power computations require non-negative base.