A safe & fast low-level language.
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.
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.
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.
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 |
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
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()
--
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
--
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")
------
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)
--
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
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.
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
--
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
--
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 ¤t)
current = r.start
r.start = r.start + 1
-> r.start <= r.end
service main()
nom: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).
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
--
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)
--
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)
--
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+"!")
--
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
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.
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.
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
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
--
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
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
--
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)
--
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
--
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
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)
--
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)
--
Section under construction.
@include std.builtins
@include std.mem
service main()
p = heap:allocate(1024, char) // allocates 1024 bytes
@delete p
--
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) --
--
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.