smoλ 🔗

A safe & fast low-level language.

Logo

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++ 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 runtype overloads and unions, as well as basic type inference - just enough magic to keep code simple without hiding behavior. All code is fastly executed on the stack, but there are heap buffers to handle variadic data.

Quickstart 🔗

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. Ensure that both the smol executable and GCC are in your system path.

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

What's interesting? 🔗

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

@include std.builtins

smo Point(f64 x, f64 y) -> @new
smo Field(Point start, Point end) -> @new

service main()
    // raw numbers you write are either 
    // f64 (floats) or u64 (unsigned ints)
    p = Point(3.0,5.0)
    f = Field(1.0,2.0,p)
    print(f.start.x + f.end.y) // 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. @new 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.

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 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
. 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 from loops inside conditions
@new 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 buffer of 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
nom Nominal type checking Automatic unique value in arguments
i64, u64, f64, ... Builtin runtypes Can be called to convert to each other
buffer(...) Dynamic heap list Variadic input/output
[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++ ABI
@body{...} Inline C++ code ABI-level logic inside runtypes
@head{...} Include C++ headers Prepended once per program
@fail{...} C++ for error handling Prefer fail(str message)
@finally{...} C++ to free resources Can also tie the resources to a variable

Flow 🔗

smoλ offers if-statements and loops. These return values -or no values- to designate the end of code blocks and are used to execute code blocks conditionally and repeatedly. In addition to those base structures, there is also the option to defer assignments for later to have more elegant loops, as well as a structure for compile-time typechecking to perform conditional execution. One of the language's goals is to give the illusion of developing on a higher-level counterpart despite providing fast zero cost abstractions over machine code.

Jump to: Conditions & loops · @next · Uplifting · Conditional typechecking


Conditions & loops 🔗

Since these concepts are widely known, below is an example. Conditions must always evaluate to booleans. That is, there are no implicit casts. The else branch of ifs is optional. You can also add parentheses for clarity, but this is not required; 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` 

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
    ----

It is required that all if statements return the same type of value -or no value- and that this is a runtype. Below is an exampe that demonstrates chained if-else statements. Use :i64 to convert numbers to signed integers (unsigned ones are the default) and carefull to use the floating point zero 0.0. Different types of numbers can be converted to each other bur 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; that 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 define as part of the language and apply only on bool inputs. They further short-circuit logic expressions, that is, do not compute redundant segments as shown below. These operations are part of the language because runtype or service declarations are not allowed to modify control flow.

@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()
    --

@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. Note the @ prefix to indicate that it might affect your code later.

@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)
    ------

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 insert > in the middle of returns. You can uplift several times to escape from nested control flow. For example, ->- breaks from a condition within a loop and ->>> to return a runtype result from a condition within a loop.

@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 an example where 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") 
    ------

Conditional typechecking 🔗

Before closing this secion on control flow, we will take quick peek at a feature that also gives the illusion of altering control flow, namely compile-time typechecking. This effectively tries to execute an alternative across several code blocks.

The syntax designates the start of a code block with with and has at least one follow-up code blocks designated by else. The first block that compiles without creating an error about invalid runtypes is the one selected each time.

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) -> @new
union Elements (vec, point, f64, i64)
smo first(Elements elements)
    with ->> elements:vec[0]
    else ->> elements:point.x
    else ->> elements:f64
    --

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

Runtypes 🔗

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++ code. The ABI 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.

Jump to: Primitives · Fields · Mutability · Currying · Overloading · Call by type · Unions · @new


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++'s double numbers.

In addition, buffer is a special primitive that safely interfaces with heap memory while remaining performant. It is the preferred way for moving large chunks of data around and, in general, has enough bells and whistles that a separate section is dedicate to describing it later. These previous types are curated to use the same number of 64 bits so as not to create alignment issues when stored and retrieved from buffers.

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++ 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. If these are stored in buffers, they still use a forcefully imposed 64-bit alignment, unless special runtypes, like strings, hand-craft more efficient computations through the C++ interface. Similarly, you can find the errcode builtin as a means of handling failing services. This has an unknown number of bits because it is implemented as a C++ int.

Fields and tail move 🔗

Values returned from runtypes are always organized into a tuple. 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 the return is treated as if returning that value directly. That is, you access that value's fields. An example of this concept is presented below, where the overloaded Point runtype with no arguments directly returns a version constructed with two arguments.

This mechanism allows for wrapper runtypes that perform concise substitution of original functionality. For example, you can overload multiple versions of the same runtype that yield the same result from different argumets, like in the example.

@include std.builtins

smo Point(f64 x, f64 y) -> @new
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 to variables. However, only returned symbols can be accessed as fields and only if there are multiple of them packed into a comma-separated tuple. 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 @new 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
    --

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:rt(args) is conceptually similar. Note that mutability should be explicitly declared if you want rt to have side-effects.

If currying is prepended to if or while then the expression is computed and transferred to the computed condition. This let you define runtypes that serve as iterators by modifying a provided iterated value by reference. Below is an example, where u64 i declares a previously zero-initialized version of the runtype. For safety, this pattern is available only if i has not been declared and only for passing arguments.

@include std.builtins

smo range(nom, u64 start, u64 end) -> @new // more on `nom` later
smo next(range &r, u64 &current) 
    current = r.start 
    r.start = r.start + 1
    -> r.start <= r.end

service main()
    nom:range(0,10):while next(u64 i) 
        print(i) 
        --
    --

Overloading 🔗

Overload runtypes that are structurally different when converted to a flat representation of primitives (i64, u64, f64, etc). Runtypes that are equivalent in their expansion into primitives cannot be used as part of function signatures due to ambiguity. More on circumventing this issue later. As an example, the following definitions come from the standard library.

smo print(f64 message)
    @head{#include <stdio.h>}
    @body{printf("%.6f\n", message);}
    --
smo print(i64 message)
    @head{#include <stdio.h>}
    @body{printf("%ld\n", message);}
    --
smo add(u64 x, u64 y) @body{u64 z=x+y;} -> z
smo add(f64 x, f64 y) @body{f64 z=x+y;} -> z

service main()
    print(add(1,1)) // 2
    print(add(0.2,0.3)) // 0.5
    --

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 skip declaring variable names in signatures, and you can omit parenthesis-based argumets that would create dummy data.

Below is a segment of the standard library that shows 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)
    --

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, create separate unions for independent resolution. Unions are resolved during compilation and, like many features of smoλ, are zero-cost abstractions. They can also unpack unions provided as arguments.

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) -> @new
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)
    --

@new 🔗

Till now we used ->@new returns as a shorthand for returning input values. However, this type of return 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+"!")
    --

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 with nameless returned values. That is, their returns are unpacked and can be parts of arguments normally, but do not accept field access because it would be either unsafe or create hidden computational costs. More on why later

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) -> mul(x,x)
service main()
    y = square(2.0)
    print(y)
    --

Jump to: Why services? · Error handling


Why services? 🔗

Semantically, services are equivalent to runtypes with error handling and the implementation restriction that they do not accept arguments by reference. 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 runtype and service 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()
    age = get_age()
    while age.err:bool age = get_age() --
    print("You've seen at least \{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.

Buffers 🔗

Handle dynamic memory with the special buffer runtype. Think of this as a list where new data are pushed to the back and popped from the front. The definition is part of the language and it is how one would handle functions with variadic inputs or outputs too. (Variadic is a fancy way of calling an arbitrary number of inputs or outputs.)

Buffers are unpacked into other runtypes by consuming elements from their start. However, unpacking feasibility is checked at runtime. Memory deallocation is safe, occurs always -even if runtime errors terminate services- and is injected automatically by smoλ. Data are stored in buffers without even primitive types and correct unpacking either relies solely on the programmer or on safe runtypes that will be discussed later.

Jump to: Local buffers · Returning buffers · Slicing · Concatenation · Nominal typing


Local buffers 🔗

Buffer elements are unpacked from the front until no more entries are required for desired runtype calls. Popping is memory safe in that buffer overflows smoothly fail any service (such as main) with an appropriate error message. You can check the number of remaining elements with the len runtype.

@include std.builtins

smo Point(u64 x, u64 y) -> @new
smo Field(Point start, Point end) -> @new

service main()
    buf = buffer(1,2,3,4,5)
    print(buf:len)    // 5
    f = buf:Field     // pops front
    print(f.start.x)  // 1
    print(buf:u64)    // 5
    --

Returning buffers 🔗

Runtypes and services can return buffers, but only if this is the only thing returned. The result is interpreted as a buffer again to maintain its special operations. For example, services that return buffers do not have an error field .err that you can check. Instead, identify failure by checking that the returned buffer has non-zero length. Below is an example.

@include std.builtins

service test()
    buf = buffer(0,1,2.0,3,4) // accidentally has an f64
    iterator = buf
    while iterator:len:bool
        print(iterator:u64)
    ---> buf

service main()
    buf = test()
    if buf:len:bool:not -> print("failed to generate buffer")
    --
> smol main.s
0
1
Runtime error: buffer element has different primitive type
failed to generate buffer

Slicing 🔗

Buffers can be sliced to obtain a sub-view of elements. This happens irrespectively of other operations applied on the original. Slices can be obtained from square brackets that either contain their starting element as u64 number, or contain starting and non-inclusive end elements separated by to.

You can change the separating operator to upto to make the last element inclusive, or lento to specify the number of elements in the slice. Below is an example of slicing. All operations are near-zero-cost abstractions, with the exception that specifying an end also checks for bounds compared to the enclosing slice.

@include std.builtins

service main()
    buf = buffer(0,1,2,3,4,5)
    slice = buf[1 to 3]
    print(slice:u64)    // 1 (pops front from slice)
    print(buf[0]:u64)   // 0 (buf[0] is also a slice)
    print(buf:u64)      // 0 (slice remains unaffected)
    print(slice:u64)    // 2 (continuing from where we left)
    print(slice:u64)    // CREATES RUNTIME ERROR
    --

Concatenation 🔗

Normally, you can have a buffer as the last argument so that popping knows how many elements it needs to consume. As an exception, you can have a buffer as a first argument to a buffer too, for example to copy it or concatenate it with more data. Furthermore, put assigns runtype values to the front elements. If you want highly optimized allocation, prefer using vectors from the standard library instead of buffers. Below is an example on buffer concatenation and modification.

@include std.builtins

smo Point(u64 x, u64 y) -> @new

service main()
    buf = buffer(1,2):buffer(1,2)
    buf[2]:put(3,4)
    p1 = Point(buf)
    p2 = Point(buf)
    print(p1.x) // 1
    print(p2.y) // 4
    --

You can provide a buffer as the sole argument of another buffer to copy its data. Below is an example.

@include std.builtins

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

service main()
    buf = buffer(1.0,2.0)
    buf2 = buf:buffer
    buf2[0]:put(0.0)
    print(buf2[0]:f64)
    print(buf[0]:f64)
    --

Nominal typing 🔗

A last feature that has been delayed till this point is how to make buffer reads semantically secure in not front-popping unintended runtypes. The mechanism for doing so is presented at the runtype level by setting an nom primitive as the first argument.

This primitive is an expression of the so-called nominal typing: if also contained in the returned tuple, it ties it back to the runtype. You cannot normally set values to it.

Compiled assembly often ignores nominal typing primitives, as they are mostly used for compilation checks, where they disambiguate strucurally equivalent runtypes among overloaded operations, or within unions. Nominal typing has a small dynamic overhead when popping values from buffers.

@include std.builtins

smo Type1(nom, f64 value) -> @new
smo Type2(nom, f64 value) -> @new

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

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

In general, having nominal typing in runtypes is optional, as it comes with storage costs. That said, the compiler imposes the restriction when ptr data are returned.

smoλ is safe as long as you do not use ptr in your code and use implementations (e.g., the standard library) that safely implement pointers and provide alignment.

@include std.builtins

smo Point(nom, f64 x, f64 y) -> @new

service main()
    p = nom:Point(1.0, 2.0)
    buf()
    print(buf[1]:f64) // 1.0
    print(buf:f64) // will create an error - nominal typing ensures correctness 
    --

Std 🔗

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.

Jump to: Console · Strings · Allocators · Files · Vectors · Math


Console 🔗

Basic functionalities are introduced for the console, namely reading and printing. These support all escape characters 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++ 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 do aply bound checks but do not copy memory and are therefore exceptionally 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

service main()
    p = heap:allocate(1024, char) // allocates 1024 bytes
    @delete p
    --

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(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.