Let's take our first steps into programming with smo位 (pronounced like "small" but with "o" instead of "a"). I must mention that 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.
To start using smo位, download it from its latest release. Ensure that both the smol executable and GCC are in your system path. For code writing prefer VSCode and install the smo位 (smolambda) extension to help with syntax highlighting. We are now ready to create and run a first program! This tutorial will have source code followed by terminal commands to run it and outputs - execute those commands in your operating system's terminal/console or on the terminal provided by VSCode.
Save the source code file below as main.s and run the command smol main.s
. This
creates an executable file named main or main.exe whose name depends on the source file
and the extension depends on your operating system. The command also runs the program right away,
so you can easily test it. Assuming that everything was set up correctly, you will see "Hello world!" print on the console.
To only create an executable, an act that is called compiling in all languages that support it,
run smol main.s --task compile.
// main.s
@include std.builtins
service main()
print("Hello world!")
--
> smol main.s Hello world!
We will now look at the anatomy of this first program. To begin with,
we have a line comment // main.s
.
This starts with double slash and ignores the remainder of the line. In
general, it is useful to comment code so that we can remember what we
had in mind. But take care not to write too trivial comments that would
require a lot of effort to keep up to date as your code evolves.
Next, we have the @include std.builtins
statement. This includes the contents of the file std/builtins.s
in our code. If the file cannot be found in your project, it is sought out among the files accompanying
the smol executable. By the way, std is a common naming convention
to refer to the standard library of programming languages. This has a lot of common
functionalities; in our case we import some builtin operations for printing and reading
from the console, and performing basic math (additions, multiplications, etc). The
word print
encountered later is declared
in the builtins we just imported.
Now we get to the mysterious service main()
declaration. In smo位, services are basically chunks of code that perform a designated
task. Other services can ask them to run again for some inputs.
They are like normal functions of other programming languages, but also
automatically handle safety details like memory and errors for you (more on this stuff later).
After telling the language that we want to create a service with the
service
keyword, we set a name as
well as comma-separated arguments inside parentheses ()
.
We will talk about arguments in a bit, but our first service has none of them. By
convention, in most languages the entry point of programs is the service/function
called main. In other words, our program is just a call to the main service,
which executes all the commands found there.
Now we get to the juicy part; the actual code we are running inside our services!
Basically everything else is preparation for allowing us to write said code.
We have two lines of code. Of these, print("Hello world!")
calls print
with a string (that is, text inside quotation marks)
argument. You may wonder whether print is also a service.
It is actually a bit more lightweight - something we call a runtype.
I will explain what that means in more detail later.
For now, let us skip the specifics and focus on the last line of code: --
marks the end of our service's implementation.
Many programming languages are centered around the concept of variables that keep track of specific values.
For example, when computing the circumference of a circle as 2蟺r
the radius of the circle
r
may be stored in a variable and the result in yet another one.
Contrary to math, programming variables represent a specific outcome of computations:
their values remain set once computed and do not change automatically upon each other's changes.
In smo位, declaring a variable
is as simple as starting from a name that consists of some combination of letters, numbers, or underscores and
and using the equality symbol =
to assign the outcome of some expression
or constant. Variable names cannot start with a number and may not contain two consecutive underscores (this is reserved
for some special cases and the language will complain if you use it arbitrarily).
An example of setting and using a variable is shown below.
// main.s
@include std.builtins
service main()
message = "Hello world!"
print(message)
--
> smol main.s Hello world!
Normally, you cannot reassign a new value to already set variables in smo位.
In many languages we say that such variables are immutable. To be able to
modify set values you need to prepend the name with the &
symbol the first time you assign a value to it. It seems a bit bothersome to explicitly
declare which variables can be modified, but the reasons will become more obvious as we
progress through this tutorial; immutability brings logic safety and helps prevent many errors.
Conversely, you need to pay special attention to variables marked for modification
as they could be modified by called functions. An example of mofifying set values shown below.
// main.s
@include std.builtins
service main()
&message = "hello"
print(message)
message = "world"
print(message)
--
> smol main.s hello world
So far we only worked with printing some strings. But there are more types of values built in the language and its standard library - the standard library is a collection of helper implementations that usually comes alongside languages. You will also be able to define more types of variables later to give structure to complicated data. For now, let us see some basic types.
First are integers, which are represented by writing some digits, like 0,42,128
.
These can be the subject of binary arithmetic operations like +-*/%
, where the last one is the
modulo operator. By default, integers in smo位 are represented with 64 bits and are unsigned in that
they are non-negative. For example ...0010
in binary corresponds to the number 2
.
This type is commonly known as u64
and being the default makes it convenient to work with memory
while avoiding a whole host of errors that arise when considering signs.
If an operation is invalid, for example if a larger unsigned integer
is subtracted from a smaller one like 0-1
or if there is a division by zero, the current service immediately fails. Below is an
example of working with unsigned integers.
// main.s
@include std.builtins
service main()
x = 2
y = 3
print("Integer division of 2/3")
print(x/y)
--
> smol main.s Integer division of 2/3 1
In many applications you will need to work with negative integers too, which in smo位 are often
represented with signed 64-bit representations referred to as i64
;
the sign is indicated by the first bit, but negative numbers
are stored in what is known as two's complement. Explaining this is perhaps too much detail for this tutorial, but if
interested in details you can visit the Wikipedia entry here.
Unsigned integers can represent numbers
0 upto 2^65-1
but signed ones
can represent numbers -2^64 upto 2^64-1
.
Signed integers have the same available operations as unsigned ones, with the bonus that any subtraction is valid.
However, you can not natively represent them. Instead, convert to unsigned counterparts to signed ones
by calling i64(value)
or, with an equivalent notation that we will explain
later value:i64
. A similar call can convert back the representation per
u64(value)
. Below is an example of how to yield minus one. It is deliberately
a bit harder to work with negative numbers in this language by making it very explicit because they can be
very hard to debug if we are not careful. For example, you might accidentally add a negative number in some computation
where you would not consider that possible.
// main.s
@include std.builtins
service main()
minus_one = 0:i64-1:i64
print(minus_one)
--
> smol main.s -1
We next visit floating point numbers. Raw numbers are designated by adding
a dot to separate between the integer and decimal part. For example, 3.14159
is a floating point
number. Again, 64 bits are used to hold the repsentation, this time following the IEEE 754 standard, which
is typically accurate to 15-17 significant digits and smo位.
The float type is called f64
and there exist similar conversions between that and signed and unsigned integers. These conversions may lose
information. Importantly, 0
is an unsigned integer and not a float;
its float counterpart is 0.0
.
Notably, 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 calling isnan,isinf
.
Lack of native error checking from the standard results of very performant floating point code.
Of course, nothing stops you from creating
safe variations of the type that are as fast as possible while failing when computing NaN results.
// main.s
@include std.builtins
service main()
x = 2.0
y = 3.0
print("Float division of 2/3")
print(x/y)
print("Is float division of 2/0 an infinity?")
print(isinf(1.0/0.0))
--
> smol main.s Float division of 2/3 1.5 Is float division of 2/0 an infinity? true
The last example also contains a check of whether some property is true
or false
. This is a called a boolean value, or bool
for short. Boolean values usually hold comparisons. Numeric types also implement
comparisons like >
(greater than), <
(less than),
>=
(greater than or equal to), <=
(less than or equal to),
==
(equal to), and !=
(not equal to). These evaluate to boolean values.
The last two are also implemented for boolean values themselves. Below is an example
// main.s
@include std.builtins
service main()
print("Is 2 less than 3?")
answer = 2<3
print(answer)
--
> smol main.s Is 2 less than 3? true
In programming you can change the sequence in which your command run, also known as the control flow of your program.
The most common -and default- control flow in most languages is to just call one command after the other. But you can also
have conditional execution that changes which program segment is executed, depending on some boolean value. The syntax takes
the form of if condition case_true --
or, to also consider the alternative, if condition case_true -- else case_false --
. Notice that each
case is terminated with the same --
symbol as the service's ending. In general, this symbol
declares the end of the current block of code so that previous control flow can resume. Increase the indentation of each code block
to make it easy to visually recognize them.
Below is an example that prints yes/no depending on some boolean condition. For fewer lines of code, merge --
with other statements of control flow syntax, such as more copies of the same symbol or else
.
Look how multiple copies of the symbol forms a nice visual barrier at the end of the main service. This is a unique
feature of this language with an added bonus for good code writing: if you cannot easily tell how many blocks are ending,
you have probably created overcomplicaed code that should be simplified, for example by being split into more manageable segments.
// main.s
@include std.builtins
service main()
print("Is 2 less than 3?")
if 2<3
print("yes")
----
> smol main.s Is 2 less than 3? yes
Conditions are also language expressions in that they may compute to a value.
The syntax mentioned above does not compute any such value, but you can replace
--
with ->
followed by an expression to return the latter's outcome. In this case, the
else
segment is mandatory, and both
segments should return the same type of value. This is needed because smo位
follows the so-called static typing paradigm, in which the type of every value
needs to be known at compile time. There are some dynamic features, like buffers
that hold data whose type changes while programs run, but this introductory tutorial
is not the proper place to learn about those.
An example of returning from a conditional statement is presented below. That also
demonstrates usage of the elif
keyword, which
basically translates to the sequence else->if
.
All cases return a string, otherwise the language would complain that they
do no return the same type.
// main.s
@include std.builtins
service main()
x = 1.0-2.0
sgn = if x>0.0 -> "positive"
elif x<0.0 -> "negative"
else -> "zero"
print(sgn)
> smol main.s negative
As a final note on conditions, and to keep code easy-to-read,
it is fine to return the outcomes of computations that do not yield any value,
as long as you do not assign the outcome to a variable. For example, the pattern
if x>0.0 -> print("positive")
is acceptable. Read that as
if x is greater than zero end the statement with the outcome of printing "positive".
A variation of conditions are loops. Those keep executing a code block as long
a condition, which is re-evaluated on each loop, stays true
. They take
the form while condition case_true --
but
they cannot have a return value. Below is a simple loop that prints the integers 0,1,2,3,4
by starting from a zero-valued variable and continuously incrementing it. Recall that &
before a variable's first assignment allows its value to be modified. Again, the closing
--
are merged in one line to create compact code.
// 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
There is often the need for control flow to also terminate its parent
flow. In typical languages you can do this with break
statements
or ways to pre-emptively stop functions/services. In smo位 there is
a mechanism called uplifting that proives a lot of control but is not completely
free so that it is easy to recognize the exit point of each block.
This mechanism
consists of prepending |
to the return statements
of code blocks, for example in the form of |--
or |->
. Below is an example of uplifting that prematurely
ends a loop under a certain condition. You can have multiple
levels of uplifting to end/return further upwards in the nested block hierarchy.
// main.s
@include std.builtins
service main()
&i = 0
while true
print(i)
if i==5 |--
i = i+1
----
> smol main.s 0 1 2 3 4 5
Moving on, let's see how we can declare and call services and the more lightweight runtypes. The two share most of the interface, with runtypes being more lightweight by zero-cost abstractions over performant machine instructions. But they do need to be enclosed in larger services that create clear compartmenization of where error messages can be intercepted. The reason why smo位 uses the term runtypes instead of declaring that these are simple inlineed functions (that is, whose code is "copy-pasted" by the language when called) will be presented later.
As a glimpse on safety, memory that is reserved for operation outcomes, such as keeping a vector of numbers, is automatically deallocated only at points when services come at an end, but not when runtypes conclude. There are ways to control deallocation, but these are too advanced to tackle in this tutorial. Controlling where memory is allocated and deallocated is required by security-critical software, where milliseconds of overhead while waiting for the operating system to grant the memory could incur huge penalties (delayed responses by vehicle safety systems or medilcal equiment malfunctions).
Adding arguments to a service is as simple as adding a list of comma-separated
variable names. However, those variable names need to be prepended by their type
so that the service can know what inputs to expect.
For example, f64 x
denotes a service input
variable that is a float named x
. To declared a runtype, change the
service
keyword to smo
.
To call a service or runtype, you need a comma-separated list of input arguments, which are matched to variables in its arguments based on the order in which they are provided. Place inputs in a parenthesis next to the service name, and the latter will run and give you back a result, if any. Below is an example that adds two floats and multiplies the result with a third one. Since the return statement is very simple and does not require any intermediate computations, we put it at the same line as the service. Otherwise, for visual clarity, indent the contents of services as we have done so far.
Services and runtypes may return no value to their caller, in which case they end with --
,
or they can return a value, in which case they end with ->
followed by an
expression that computes the value to be returned. This corresponds to return statements of other languages,
but is also the same notation for all other code blocks in control flow. Returns can declare and return types, but returning
will be addresse in the next section.
// main.s
@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)
--
> smol main.s 9.0
Up until now, arguments have been passed by value. This means that they cannot be
modified inside the service. However, smo位
also allows passing arguments by reference, making modifications to propagate back to the caller.
To do so, prepend the argument name with an &
in the parameter
list of the service. This makes the internal variable mutable, and must be matched by mutable inputs,
since they would not be modifiable otherwise. Below is an example, where
the variable x
in main must also be marked as mutable - the language
would complain otherwise.
// main.s
@include std.builtins
smo update(f64 &x)
x = x + 1.0
--
service main()
&x = 2.0
update(x)
print(x)
--
> smol main.s 3.0
You can also provide types (instead of values) as arguments, without attaching them to variables or values. This is useful when you want to operate on a family of types generically, for example when you want a specific output. To pass a type, just use its name as an argument. Below is an example that creates a different
// main.s
@include std.builtins
smo zero(f64) -> 0.0
smo zero(u64) -> 0
service main()
x = zero(f64)
y = zero(u64)
print(x)
print(y)
--
> smol main.s 0.0 0
This is an oportune moment to learn how you can read from the console too; the standard library implements a read
runtype.
Currying allows for chaining runtype and service calls in a way that mimics method dispatch. When using the
:
operator, the left-hand-side becomes the first argument of the next runtype or service.
This keeps code compact and easy to read, especially when chaining operations. Below is an example, which demonstrate
the advantage of currying in chaining service or runtype calls.
// main.s
@include std.builtins
smo triple(f64 x) -> x * 3.0
service main()
print(2:f64:triple)
--
> smol main.s 6.0
The currying notation also extends naturally to loops. If you curry into while
, this is transferred as the
first argument of the condition, enabling a pattern like range(n):while next(u64 &i)
,
where the variable i
is defined and incremented according to the standard lbirar'y implementation
smo next(range self, u64& i)
,
where the state holding the current progress and ending condition is provided by range
.
Getting the next state also returns a boolean value of whether the loop goes on.
Currying into loops is an expressive yet safe pattern of iterating through values. This is why constructs like range
are commonly known as iterators across programming languages. Importantly, iterators do not allocate memory for each element,
but are responsible from generating a next state from the current one.
Uplifting also works when returning from services. Below is an example where the absolute value is computed by the programming pattern of returning early from running code.
// main.s
@include std.builtins
smo abs(f64 x)
if x<0.0 |-> 0.0-x
-> x
service main()
x = 0.0-1.0
absx = abs(x)
print(x)
print(absx)
--
> smol main.s -1.0 1.0
A final feature of services is that they compartmenize code to execute safely; any errors do not immediately
stop execution at the place where services are called, but leave you the opportunity to handle what happens in the event of failure.
Errors will cascade into becoming the calling service's errors only if you do not handle them.
For example, if there are no error checks, the whole program terminates on the first error.
Handling errors consists of checking whether
result.err:bool
is true
, in which case you should avoid using
the result variable further down the line. The field .err
is automatically added to all services, but
is ignored (after a check that they have not failed) when treating them as tuples.
Error messages are printed in the console, and typically start with Error:
. You may trigger an error
manually with a string message per if condition -> fail("Error: custom message here")
.
Errors immediately interupt the calling service, though any resources like open files or memory in use are automatically
released. This is what makes smo位's services to execute, even in long-running programs.
// main.s
@include std.builtins
service affine(u64 x, u64 y, u64 z) -> (x+y)/z
service main()
result = affine(1, 2, 0)
if result.err:bool |-> print("We failed to execute the code")
print(result)
--
> smol main.s Error: division by zero We failed to execute the code