Hello world 馃敆

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.

Variables 馃敆

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

Conditions 馃敆

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

Arguments 馃敆

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.

Returns 馃敆

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