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.
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.
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.
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 |
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 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
----
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
--
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")
------
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.
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.
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
--
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
--
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)
----
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)
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)
--
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
nominal
values 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+"!")
--
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)
--
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
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)
--
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.
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.
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.
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.
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)
--
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)
--
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))
--
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)
----
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)
--
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}")
--
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.