Let's take our first steps into programming with smoλ (pronounced like "small" but with "o" instead of "a"). The language is a bit atypical in that it simplifies a lot of traditional programming concepts while keeping the ability to write very fast yet safe code. Some level of control is sacrificed in the process, but this means that you do not need to worry too much about technical details.
Our first program shows how to run a program and print a message.
Download the smol
executable
from the latest release.
Place it alongside the std
directory and add both the containing folder
and a C/C++ compiler (e.g., GCC) to your system PATH. Finally, create a file named main.s
with the text below. Open a terminal in the same folder and run
smol main.s
.
// main.s
@include std.builtins
service main()
print("Hello world!")
--
> smol main.s Hello world!
As a quick preview, @include std.builtins
adds basic tools like print
,
service main()
is where your program starts,
and the line with --
marks the end
of the program.
A variable is a named box that holds a value. You give it a name, then
put a value in it once with the pattern variable_name = value
.
This process is called an assignment. Names cannot start with numbers or contain
spaces or special symbols other than the underscore _
.
Having two consecutive underscores is also not allowed. Below is an example where we set a
constant text known during program creation (cstr
)
to a variable.
@include std.builtins
service main()
greeting = "Hello world!"
print(greeting)
--
Numbers are values too. There are three kinds you will use often:
u64
— whole numbers without a sign (0, 1, 2, ...). The default when writing 2
or 42
.i64
— whole numbers with a sign (-1, +0, +1, ...) obtained by transforming u64
values.f64
— numbers with a decimal point. The default when writing 2.0
or 3.14
.These are known as unsigned integers, signed integers, and float numbers, respectively.
The above names add 64 next to a mnemonic symbol to reassure
experienced programmers that we will use 64 bits to represent the numbers under-the-hood.
That is, unsigned integers can represent numbers 0 upto 2^65-1
but signed ones can represent numbers -2^64 upto 2^64-1
.
Floats follow the IEEE 754 standard, which is typically accurate to 15-17 significant digits.
Invalid operations:
For safety, you cannot mix operations between different types of numbers. For example, you cannot subtract
a float from 0
but only from 0.0
.
However, you can convert between number formats with value:type
.
You would be surprised how many bugs are prevented by requiring explicit data conversions.
See below for examples. Integer division by zero creates a runtime failure that you can intercept and handle at the level
of services. For floats, the IEEE 754 standard allows invalid operations like division by zero and lets them yield positive infinity,
negative infinity, or NaN (not a number) values. You can check for those properties by correspondingly calling one of
is_inf
or is_nan
that can be imported from std.math.
Lack of native error checking from the standard results in performant code.
@include std.builtins
service main()
print(1+2) // 3
print(2/3) // integer division (u64): 0
print(2.0/3.0) // float division (f64): a decimal result
minus_one = 0:i64-1:i64
print(minus_one) // -1
--
Mutable variables: After setting a variable, its value cannot normally change. To allow changes, declare it as mutable
by placing &
before its name in its first assignment.
Variables are immutable by default to avoid many logic bugs. Look out for the marking to spot values
that can may change.
// main.s
@include std.builtins
service main()
&name = "Ada"
print(name)
name = "Lovelace" // allowed because we marked it mutable before
print(name)
--
> smol main.s Ada Lovelace
Sometimes we only want certain lines to run if a tested condition is true.
This is done with an if
block.
The word if
starts the block, then comes a condition,
then the code that should run if the test passes. Prefer indenting the latter for easier reading,
as well as starting it in a new line.
Like before, the block ends at --
.
// main.s
@include std.builtins
service main()
if true
print("this always runs")
--
-- // could merge both lines into ---- here
> smol main.s this always runs
Here the test is just the value true
, so the message will always print.
If you changed the condition to false
, the inside will be skipped. These are
known as boolean values, or bool
for short.
More often, the test contains numerical or other comparisons that evaluate to a boolea value.
For example, 2 < 3
checks whether two is less than three.
// main.s
@include std.builtins
service main()
if 2<3
print("yes, two is smaller")
----
> smol main.s yes, two is smaller
There are several comparison operators you can use - some of these are defined for data other than numbers too:
==
equal to!=
not equal to<
less than<=
less than or equal>
greater than>=
greater than or equal
We can also add elif
(else if)
and else
branches to cover other cases.
Each branch is tried in order until one runs.
@include std.builtins
service main()
x = 0.0-2.0
sign = if x>0.0
-> "positive"
elif x<0.0
-> "negative"
else
-> "zero"
print(sign)
--
> smol main.s negative
Notice here that, above, each branch returns a string. All branches must return the same type, or nothing. For example, you could also just perform an action inside each branch:
@include std.builtins
service main()
x = 5
if x>0
print("positive")
--
elif x<0
print("negative")
--
else
print("zero")
--
--
> smol main.s positive
If you prefer, it is okay to keep code compact when it reads naturally, for example by writing one line:
if x>0 -> print("positive")
.
A loop repeats a block while a condition is true. Syntactically, it
starts with while
followed by a condition
and the block's contents. Those must end at --
.
If a variable changes inside the loop, make it mutable during its first assignment.
// main.s
@include std.builtins
service main()
&i = 0
while i<5
print(i)
i = i+1
----
> smol main.s 0 1 2 3 4
The next snippet shows a pattern for looping through a range of unsigned integers. This is
less prone to accidental bugs is a pattern that will be fully explained later.
This pattern is known as an iterator because it builds on some data that can
be traversed and assigns to a variable while traversing them. Here the data is a
a range(5)
indicating values 0 upto 4
and
traversed values are assigned to i
.
Iterators are convenient in that you do not need to manually handle the increment, which
you might forget about or could be complicated. For example, reading from
files is also implemented as an iterator.
// main.s
@include std.builtins
service main()
range(5):while next(u64 &i)
print(i)
----
Sometimes you want to stop not just the current block, but also its parent blocks
up to a certain level. In those cases,
put a vertical bar |
before the return symbol
for each level you want to “jump up”. For example,
|--
ends the current block and its parent.
Similarly, |->
returns a to the parent.
Two bars (||
) jump two levels, and so on.
@include std.builtins
service main()
&i = 0
while true
print(i)
if i==5 |-- // end loop early
i = i+1
----
You can name a block of code and call it with inputs. These are called arguments. There are two kinds of named blocks of code:
smo
— a lightweight function. It is called a runtype.service
— a safer unit that handles errors and resources for you.For most simple programs, you will only mostly runtypes. Services are complex pieces of code that run
independently to each other. Adding arguments to a service is as simple as adding a list of comma-separated
variable types and names. Types are needed so that the service can know what inputs to expect.
For example, f64 x
denotes an argument that is a float named x
.
@include std.builtins
smo affine(f64 x, f64 y, f64 z)
-> (x+y)*z
service main()
result = affine(1.0, 2.0, 3.0)
print(result) // 9.0
--
Inputs are passed "by value" (without affecting the call site) unless you explicitly allow changes.
Place &
before argument names to declare that
the variable passed as an argument may be modified inside the runtype or service - and hence
must already be mutable. This also makes the argument variable internally mutable.
Below is an example.
@include std.builtins
smo increment(u64 &x)
x = x + 1
--
service main()
&n = 10
increment(n)
print(n) // 11
--
Similarly to mutable variables declared within blocks of code,
mutable arguments make prospective changes happen easy to spot.
Conversely, if you do not see &
,
nothing changes.
You can use types as arguments to help disambiguate between similarly-named runtypes. This is useful for choosing a behavior without passing a dummy value.
@include std.builtins
smo zero(f64)
-> 0.0
smo zero(u64)
-> 0
service main()
a = zero(f64)
b = zero(u64)
print(a) // 0.0
print(b) // 0
--
The colon :
sends the value on the left as the first argument on the right.
This reads left-to-right and removes extra parentheses when you chain steps.
@include std.builtins
smo triple(f64 x)
-> x*3.0
service main()
print(2:f64:triple) // 6.0
--
The same colon also works with loops provided by the standard library (like range
) so you can write readable iterations.
A block can return a value using ->
, or end with no value using --
.
Returning early is just a return higher up the page; use uplifting if you need to jump out of an extra level.
@include std.builtins
smo abs(f64 x)
if x<0.0
|-> 0.0-x
-> x
service main()
x = 0.0-1.0
print(abs(x)) // 1.0
--
You can return several named values at once and then access them as fields.
@nominal
is a shorthand that returns all inputs by name.
@include std.builtins
smo Point(f64 x, f64 y) -> @args
smo moved(Point p, f64 dx, f64 dy)
&nx = p.x + dx
&ny = p.y + dy
-> Point(nx, ny)
service main()
p = Point(1.0, 2.0)
print(p.x) // 1
print(p.y) // 2
q = p:moved(3.0, 4.0)
print(q.x) // 4
print(q.y) // 6
--
If a runtype returns only one value, you use that directly (no extra field name needed).
When something is wrong, say it clearly and stop that unit of work.
In a service, call fail("message")
. The caller can check result.err:bool
.
If you don’t check, the error will bubble up until it reaches a place that does.
@include std.builtins
service divide(f64 x, f64 y)
if y==0.0
-> fail("Division by zero")
-> x/y
service main()
r = divide(1.0, 0.0)
if r.err:bool
print("Could not compute.")
--
else
print(r)
----
> smol main.s Division by zero Could not compute.
This approach keeps the main path simple. You try the thing. If it fails, you decide what the next step is (ask again, use a default, stop).