A safe & fast low-level language.
Smoλ creates zero-cost abstractions for managing data and functions. Its core is tiny — really tiny! So tiny, in fact, that functions like printing and basic arithmetic operators are part of the standard library. There's a direct-to-C++ interface for extensible functionality.
Look at an example program:
@include std
smo Point(i64 x, i64 y) -> @new
smo Field(Point start, Point end) -> @new
service main()
p = Point(3,4)
f = Field(1,2,p)
print(f.start.x)
print(f.end.y)
i = add(f.start.x, f.start.y)
print(i)
--
First, @include
brings code from
other files with the .s extension. Paths are separated by dots.
For safety, includes cannot occur within definitions.
Circular dependencies or re-imports are not allowed either. Here we use the
standard library, but you may create your own flavor.
Next are some so-called runtype definitions, indicated by the smo
keyword. These merge the concept of types and functions, where the keyword ->
returns a value or tuple. If variable names are part of the returned tuple, this can be accessed as fields
from the result. @new
is a shorthand for
returning a tuple of all internal declarations.
Once you write something like p = Point(...)
,
you can access all returned named variables using notation like
p.x
and p.y
.
You can also unpack the result as part of tuples; these are alwways flat
(there is no tuple of tuples) and can be reinterpreted in various ways
as long as primitive types match.
Put all main business logic inside a service main()
definition.
Being a service means that errors occuring inside are "caught", perform safe allocation and deallocation,
and do not escape to the any code calling it. More on services later.
For now, know that the main service is the entry point of programs and returns no value, as indicated by
the --
symbol at its end.
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(i64 x, i64 y)
@body{i64 z=x+y;}
-> z
This tells us that we are defining an integer addition runtype with the corresponding arguments.
When called from other code, the definition is inlined in an optimization-friendly. For example,
despite the illusion of typing, everything consists of direct variable operations. For example
f.start.x
is represented by f__start__x
under the hood.
Return a value or tuple of values with ->
,
and use @body
to write C++ code.
If you don't want to return anything, use --
.
Return symbols form 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.
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
smo inc(i64 &x)
x = add(x,1)
--
service main()
x = 1
inc(x)
print(x) // 2
Runtypes accept currying notation that transfers a computed value to the arguments of the next call.
The curry operator is |
and can be chained.
If you have multiple calls in the currying chain, preffer placing those at separate lines. This is only
a stylistic choice.
Currying feature 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.
@include std
service main()
1
| add(2)
| mul(3)
| print()
// equivalent to print(mul(add(1,2), 3))
The assignment operator copies the outcome of function calls to variables.
However, only returned symbols can be accessed as fields.
For example, below the input variable x
cannot be accessed after computations conclude. If you return the outcome of some
computations without packing it into a comma-separated tuple, this is not exposed either.
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
smo Test(i64 x)
incx = add(x,1)
-> incx
service main()
p = Test(1)
print(p.incx)
print(p.x) // CREATES AN ERROR
Overload runtypes that are structurally different when converted to a flat representation of primitives. Runtypes equivalent in terms of 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(i64 x, i64 y) @body{i64 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
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 ommit 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 = read(f64)
print(x)
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 yo can create different ones for independence. 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
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)
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 too.
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 occur to terminate a service- and is injected automatically by smoλ. That said, data are stored in buffers without even primitive types and unpacking relies solely on the programmer.
Buffer elements are unpacked from the front until no
more entries are required for desired runtype calls.
Popping is memory safe in that it smoothly fails any service
(e.g., main
)
with an appropriate error message.
@include std
smo Point(i64 x, i64 y) -> @new
smo Field(Point start, Point end) -> @new
service main()
buf = buffer(1,2,3,4,5)
f = Field(buf)
print(f.start.x) // 1
print(i64(buf)) // 5
u64
number indicating a starting element to be followed
to the end, or contain starting and non-inclusive end elements separated by a double colon.
Below is an example that demonstrates element access.
All operations are near-zero-cost abstractions, with the exception that a slice with specified
end checks for bounds.
@include std
service main()
buf = buffer(0,1,2,3,4,5)
slice = buf[u64(1):u64(3)]
print(i64(slice)) // 1 (pops front from slice)
print(i64(buf[u64(0)])) // 0 (buf[0] is also a slice)
print(i64(buf)) // 0
print(i64(slice)) // 2 (slice remains unaffected)
print(i64(slice)) // CREATES RUNTIME ERROR
Normally, you can have a buffer as the last argument so that popping knows how many elements
it needs to consume. The language offers three operations for buffers:
reserve
preallocates memory
(for optimization, otherwise size is doubled when needed),
push
adds data at the end,
copy
copies contents without
altering the original, and
assigning inserts data at the beginning as long as the size is compatible
@include std
smo Point(i64 x, i64 y) -> @new
service main()
buf1 = buffer(1,2)
buf2 = buffer()|reserve(2)|push(3,4)
buf = buf1
| copy()
| push(buf2.copy())
// buf1 and buf2 are empty now
p1 = Point(buf)
p2 = Point(buf)
print(p1.x) // 1
print(p2.y) // 4
Conditional if
statements are second-class citizens in smoλ
and are manually implemented in the standard library.
They do no accept an alternative branch.
Either run multiple of those, or enclose them in the scope
runtype. The latter creates an independent scope from which you can return. An example follows.
@include std
smo print_sign(i64 i)
sgn = do
if(i|gt(0)) -> 1
if(i|lt(0)) -> -1
-> 0
print(sgn)
--
service main()
print_sign(100)
print_sign(-42)
Like all runtypes, you can return values from conditional statements. If the condition does not run, variables are zeroed out as default behavior.
@include std
service sign(i64 i)
if(gt(i,0)) --> 1
if(lt(i,0)) --> -1
-> 0
service main()
x = sign(100)
y = sign(-42)
print(x)
print(y)