Fundamentals in Basic Storm

This tutorial introduces and illustrates the fundamentals of Basic Storm. The tutorial is not aimed at teaching programming, and it therefore assumes familiarity with programming. Ideally in imperative languages like C, C++, and Java.

The code produced in this tutorial is available in the directory root/tutorials/fundamentals in the release of Storm. You can run it by typing tutorials:grammar:<step> in the Basic Storm interactive top-loop. Replace <step> with the name of the step for the relevant caption. Note that the code in the distribution differs slightly from what is presented here to allow the different steps to coexist in the same package. The only difference is that the name of the function main has been changed.

Setup

Before starting to write code, we need somewhere to work. For this tutorial, it is enough to create a file with the extension .bs somewhere on your system. The tutorial uses the name fundamentals.bs for this, but any name that contains only letters works.

When you have created a file, open a terminal and change to the directory where you created the file. You can instruct Storm to run the file by typing:

storm fundamentals.bs

Note that based on how you have installed Storm, you might need to modify the command-line slightly. Also, if you named your file something different than fundamentals.bs, you need to modify the name in the command accordingly.

If you have done everything correctly, the command will currently not appear to do anything since the file did not contain a syntax error, nor a function named main. You can exit the top-loop by typing exit and pressing Return. If you see a line starting with WARNING:, however, you have most likely specified the wrong name of the file, or have changed into the wrong directory in your terminal. Storm will print the full path of the file it attempts to open in the warning to aid in debugging.

To verify that Storm is examining the right file, we can simply define a main function as follows and try again:

void main() {
}

If done correctly, Storm will now instead stay silent and exit almost immediately. This is the starting point we wish to have for the remaining steps in the tutorial.

Outputting Text

The code for this part of the tutorial is in the file root/tutorials/fundamentals/text.bs and can be run by typing tutorials:fundamentals:text in the Basic Storm top-loop.

A good first tool when working with a new programming language is the ability to print things to standard output. This lets us inspect and test a great number of things, from printing the results of computations to verify our understanding of the language, to printing traces that lets us debug the behavior of programs.

In Storm, this is most conveniently done with the print function. The full name of the print function is void print(core.Str s). From the name, we can deduce that the print function is located in the package named core, and that it accepts a single core.Str (i.e. a string) as a parameter. Luckily for us, Basic Storm and many other languages import the core package for us automatically, so we can use it right away.

We can call the print function from the main function we created above as follows as a first step:

void main() {
    print("My first program in Basic Storm!");
}

If we run the program now (using storm fundamentals.bs), we will see that our message is being printed as we would expect:

My first program in Basic Storm!

Let's try printing something more interesting. For starters, let's just write a list of numbers from 0 to 9 using a for-loop:

void main() {
    for (Nat i = 0; i < 10; i++) {
        print(i);
    }
}

From the code above, we can immediately note two things. First, that Storm (and thereby Basic Storm) uses the convention that all type names start with an uppercase letter. From this, we can deduce that Nat is the name of a type. It is the name of the unsigned integer type (for natural number) in Storm. We could use Int as well if we wished, but Storm tends to use unsigned types wherever it is suitable.

Apart from these two observations, the code above looks fairly similar to other C-style languages such as C++ and Java. However, if we try to run it, we get the following error:

@/home/storm/fundamentals.bs(57-62): Syntax error:
Can not find "print" with parameters (core.Nat&).

This message tells us that Basic Storm could not find a version of the print function that accepts a Nat as a parameter. It turns out that Storm only provides an overload that accepts strings. This means that we need to convert the number into a string. Luckily, nearly all types in Storm provides a function called toS() (short for "to string") that creates a string representation of objects. We can now update our program to fix the error:

void main() {
    for (Nat i = 0; i < 10; i++) {
        print(i.toS());
    }
}

Note that Basic Storm makes no difference between empty parentheses and no parentheses at all. This means that it is possible to simply write print(i.toS) if desired. This is useful when refactoring code, as it allows turning member variables into member functions without changing other code.

If we run the program again, we see the output we would expect:

0
1
2
3
4
5
6
7
8
9

Let's continue with our program and have it compute the Fibonacci numbers:

void main() {
    Nat current = 0;
    Nat next = 1;
    for (Nat i = 0; i < 10; i++) {
        print("fib(" + i.toS() + ") is: " + current.toS());
        Nat sum = current + next;
        current = next;
        next = sum;
    }
}

We can note that Storm overloads the + operator to allow string concatenation. Just as with the print function, we must first convert any non-strings into strings using the toS function. As expected, we get the following output:

fib(0) is: 0
fib(1) is: 1
fib(2) is: 1
fib(3) is: 2
fib(4) is: 3
fib(5) is: 5
fib(6) is: 8
fib(7) is: 13
fib(8) is: 21
fib(9) is: 34

As we can see, this quickly gets rather inconvenient (and inefficient, since each + needs to create a new string). One option is to use interpolated strings to quickly and efficiently create the desired representation. In contrast to some other languages, interpolated strings in Basic Storms are resolved entirely at compile time, and compile into code that uses the core.StrBuf class (short for "string buffer") to efficiently build the string representation. It also helps us with converting values into strings automatically where possible. It is therefore very convenient during debugging. In our code, we can use it as follows:

void main() {
    Nat current = 0;
    Nat next = 1;
    for (Nat i = 0; i < 10; i++) {
        print("fib(${i}) is: ${current}");
        Nat sum = current + next;
        current = next;
        next = sum;
    }
}

As we can see, interpolated strings allows us to include a $-sign followed by curly braces. Whatever is inside of the curly braces is an expression that is evaluated and inserted at that position in the string. As noted before, this is done during compilation, so you will get a compile-time error if you have mistyped a variable name for example.

The interpolated string syntax also allows us to format our table nicely. We can specify formatting options for an inserted value by adding a comma (,) followed by formatting options. For example, we can set the width of a field by specifying a number, and right-align the numbers by specifying r. In our case, it may look like this:

print("fib(${i,2}) is: ${current,5}");

A full reference of the formatting options available in interpolated strings is available here.

Containers

Storm's standard library contains a number of useful containers. These containers are generic which means that they can store any type in the system. The type is specified as a parameter to the type, much like templates in C++ or generics in Java.

Arrays

The code for this part of the tutorial is in the file root/tutorials/fundamentals/containers.bs and can be run by typing tutorials:fundamentals:arrays in the Basic Storm top-loop.

The most fundamental container is perhaps the Array<T>. It is an array of elements of type T. Basic Storm provides a shorthand for the type as T[]. Elements in arrays are accessed using the [] operator as in many other languages. New elements can be added with the push function, or with the << operator. With this in mind, we can rewrite the previous example to compute the first few Fibonacci numbers and store them in an array:

void main() {
    Nat[] numbers = [0, 1];
    for (Nat i = 2; i < 15; i++) {
        numbers << numbers[i - 1] + numbers[i - 2];
    }
    print(numbers.toS());
}

The first line in the program uses an array literal to create the initial array. This syntax attempts to automatically deduce the array type based on the elements and the context. If the automatic deduction fails, it is possible to specify the desired type as follows:

Nat[] numbers = Nat:[0, 1];

This is usually only necessary to do when working with empty arrays and/or overloaded functions.

In the loop, we then use the << operator to add elements to the array, and the [] operator to access elements. Finally, we use the toS function to generate a string representation.

If we wish to format the output a bit nicer, we can iterate through the array using a regular for-loop as follows:

for (Nat i = 0; i < numbers.count; i++) {
    print("${i,2}: ${numbers[i],3}");
}

Storm does, however, also provide a loop construct that is specificially designed to iterate through elements in containers. It is semantically equivalent to the code above, but utilizes iterators if possible. With it, we can instead write:

for (k, v in numbers) {
    print("${k,2}: ${v,3}");
}

In the loop above, we provide two iteration variables named k and v. The first one is optional, and is used for the "key" for the container we iterate through. For arrays, the key is the index of the element. The second is the "value" for the container. This is naturally the element itself for arrays. As we shall see, this scheme naturally extends to maps as well.

Since the key is optional, we could simply write the following if we were only interested in the value itself:

for (v in numbers) { /* ... */ }

Finally, Storm provides a function called join that is useful for concating arrays of elements into strings. For example, we can create a representation that separates the computed Fibonacci numbers with arrows as follows:

print(join(numbers, " -> ").toS());

By default, the join function uses the standard to-string implementation. It is, however, possible to provide a lambda function that specifies how to convert elements into strings before concatenating them.

Maps

The code for this part of the tutorial is in the file root/tutorials/fundamentals/containers.bs and can be run by typing tutorials:fundamentals:maps in the Basic Storm top-loop.

Another useful container is the Map<K, V> type. Similarly to arrays, Basic Storm provides a shorthand syntax for the Map<K, V> type as: K->V. A map contains an set of pairs. Each pair contains a key (of type K) and a value (of type V). The map indexes the keys so that lookups based on keys are efficient. In contrast to the Array<T>, however, the Map<K, V> is unordered. This means that while it is possible to iterate through the elements in a map, there is no guarantees that the order in which elements appear are the same as the one they were inserted in. Furthermore, the order may even change between different iterations, even if the map has not been modified.

Elements are inserted into the map with the put function as follows:

void main() {
    Nat->Str numbers;
    numbers.put("zero", 0);
    numbers.put("one", 1);
    numbers.put("two", 2);
    numbers.put("three", 3);
    numbers.put("ten", 10);

    print(numbers.toS);
}

By running the above example, we can clearly observe that the order of elements are not preserved. The toS operator iterates through the elements when creating the string representation, and places them in the following order, that is seemingly arbitrary:

{three -> 3, one -> 1, ten -> 10, zero -> 0, two -> 2}

The map requires that keys are unique, so if the key passed to put already exists, then put will update the existing pair. The put function actually returns a boolean that indicates if it inserted a new pair or not. If put returns true, then a new pair was inserted. Otherwise, an existing pair was updated.

We can iterate through the map manually using the for loop as with arrays as follows:

for (k, v in numbers) {
    print("${k,5l} -> ${v,2}");
}

If the keys are not desired for some reason, it is possible to omit the k variable, as with arrays.

The map type also provides a number of ways to look up and modify the elements in the array. The simplest of them is has that checks if a key is present in the map:

numbers.has("one"); // -> true
numbers.has("twenty"); // -> false

We can then get the value associated with a key using the get function. It retrieves the value associated with a key, and throws an exception if the it does not exist. For example:

print(numbers.get("one")); // -> 1
print(numbers.get("twenty")); // -> throws MapError

It is also possible to provide a default element to the get function that will be returned in case the requested key does not exist. Note that the default value is not inserted into the map:

print(numbers.get("one", 0)); // -> 1
print(numbers.get("twenty", 0)); // -> 0

Note, however, that since get is a normal function, the default value is always created, even if it is not needed. This means that it is probably not a good idea to create a large object and pass it as the default value to get. This means that the large object is always created, even if it is not used. For such cases, it is likely better to use at (see below).

Whenever the type V has a default constructor, the map also provides a [] operator that allows treating it like an array. The [] operator works like in C++ in that it never throws. Rather, if the requested key was not found, a default-constructed value is inserted in the map, and then a reference to that value is returned. To illustrate the behavior, we can write as follows:

print(numbers["one"]); // -> 1
print(numbers["twenty"]); // -> inserts "twenty" -> Nat(), returns 0

This might seem strange at first. It is, however, quite useful in many situations. For example, if words is an array of strings that each contain a word, we can compute a word frequency table as follows:

Str->Nat wordFrequency(Str[] words) {
    Str->Nat result;
    for (word in words) {
        result[word] += 1;
    }
    return result;
}

The final function for accessing elements is the at function. It attempts to make it convenient to look up a key and extracting the value, while taking into account the fact that the key may not exist. One way to do this is to use the has and get functions as follows:

if (numbers.has("twenty")) {
    return numbers.get("twenty");
} else {
    return 0;
}

This does, of course, work as intended. It does, however, require two searches in the map. The map is implemented as a hash table, so lookups are fairly cheap, but they are far from free. The at function solves this by looking up the key, and returning the value as a Maybe<V>, to represent the fact that the value may not exist. This means that we can implement our lookup as follows:

if (value = numbers.at("twenty")) {
    return value;
} else {
    return 0;
}

To fully understand why this works, we need to understand Maybe<T> types. They are introduced in the section below this.

Before we move on to maybe types, it is worth mentioning some details regarding how the map handles keys. In general, the hash table uses the same concept of equality as the == operator does. That is, value- and class-types are compared by-value, while actor types are compared by-reference.

As mentioned previously, the map is implemented as a hash table. This means that the Map<K, V> requires that the type K has a hash() function and a comparison operator. Most primitive types in Storm implement this functionality, but it is not generated automatically for custom types. As such, to use custom types as keys in a map, it is necessary to implement a hash function (that returns a Nat), and a comparison function (a function named ==).

Maybe

The code for this part of the tutorial is in the file root/tutorials/fundamentals/maybe.bs and can be run by typing tutorials:fundamentals:maybe in the Basic Storm top-loop.

By default, Storm (and thereby also Basic Storm) assumes that values are always properly initialized. This is true even for class- and actor types that are handled by reference. As such, it is not possible to store null in a Str variable, even though the variable is represented as a pointer internally.

It is, however, useful to be able to represent the absence of a value. One example is the at function in the map container. It needs to be able to both return a value corresponding to the sought-after key, or a value that indicates that no such value exists. It achieves this by returning the type Maybe<V>, where V is the type of the values stored in the map.

The type Maybe<T> thereby represent a value that can either be a valid instance of the type T, but it may also contain the special value null to indicate that no value of the type T is present. As with arrays and maps, Basic Storm provides the shorthand syntax T? that is equivalent to Maybe<T>.

The benefits of using a separate type for nullable values are twofold: first, a special type makes it very clear both to the compiler and the programmer which values may be null. Secondly, since nullability is not tied to a particular set of types, it is possible to make any type in the system nullable. For example, Storm allows Maybe<Int> for an integer that may be null. However, languages like Java requires wrapping the primitive int in the class Integer to make it nullable.

The first point above is used by Basic Storm to ensure that it is not possible to use a value that may be null. The type Maybe<T> is a completely separate type from T in the type system. Furthermore, Maybe<T> provides no way of directly accessing the contained value. It only provides the members any and empty that allows checking for the presence or absence of a value. Instead, Basic Storm provides a special construct for converting a Maybe<T> into a T. Since this conversion may fail, it needs to occur inside an if-statement that checks if the conversion was successful.

To illustrate how this works, let's start by defining a function that attempts to parse a string into an integer:

Int? parseInt(Str from) {
    if (from.isInt()) {
        return from.toInt();
    } else {
        return null;
    }
}

The idea is that the function returns an Int if the conversion was successful, and null otherwise. This is represented by the function returning Maybe<Int>, written as Int?. From the example, we can also see that Storm is able to automatically convert values from Int into Maybe<Int> (on the line return from.toInt();).

We can test so that the function works as intended by defining the following main function:

void main() {
    print(parseInt("10").toS);
    print(parseInt("five").toS);
}

This program will print 10 followed by null as we expect. However, if we try to use the value from parseInt we will run into problems. For example, let's say we wanted to perform some computations with the returned Int?:

void main() {
    Int? converted = parseInt("10");
    var modified = converted * 2;
    print(modified.toS);
}

If we run the program above we will get the following error:

@/home/storm/root/fundamentals.bs(182-183): Syntax error:
Can not find an implementation of the operator * for core.Maybe(core.Int)&, core.Int.

The error tells us that Storm has no operator for multiplying a Maybe<Int> with an Int. This means that we need to "extract" the Int that is inside the Maybe<Int> before we can do anything useful with it. We do this using a weak cast as follows:

void main() {
    Int? converted = parseInt("10");
    if (converted) {
        Int modified = converted * 2;
        print(modified.toS);
    }
}

The weak cast is a special form of the if statement. When the condition is an expression that evaluates to Maybe<T>, the if statement will attempt to unwrap the contained T. The conversion is considered successful if the Maybe<T> value was not null. The if statement makes the extracted T available in the "true" branch by declaring a variable that shadows the original one if possible. The example above is roughly equivalent to the following:

if (converted.any()) {
    Int converted = converted.extract();
    // ...
}

Note that the function extract does not exist in Storm. It is only present to illustrate what the weak cast does internally. The important part is that the if statement declares a variable wit the type Int that shadows the original one. It thus appears as if converted has changed type inside the body of the if statement. An important implication of this is that assigning to converted inside the if-statement will only change the local copy, and will not modify the original variable.

The if statement is only able to create a variable that shadows the original one in this manner if the condition to the if-statement is the name of a variable. To illustrate this, assume that we rewrite our program like this:

void main() {
    if (parseInt("10")) {
        print("Success!");
        // How to access the result?
    }
}

This would work in the sense that the program would print Success!. However, it is currently not possible to access the value produced by parseInt. For cases like this, it is possible to explicitly specify a variable name that should contain the result of the conversion. In our case, we can write like this:

void main() {
    if (converted = parseInt("10")) {
        Int modified = converted * 2;
        print(modified.toS);
    }
}

This is, of course, also possible to do even if the expression to the right of the equal sign is just a variable.

The weak casts as presented above are enough to work with the Maybe-type. However, it gets a bit cumbersome in certain cases. For example, consider a function add that adds two Maybe<Int> together. We can write it as follows:

Int? add(Int? a, Int? b) {
    if (a) {
        if (b) {
            return a + b;
        }
    }
    return null;
}

void main() {
    Int? result = add(parseInt("10"), parseInt("20"));
    print(result.toS);
}

Since it is not possible to use operators like & to combine different weak casts, we need to nest the casts inside each other. This is not too bad in the case of the add function. However, it quickly gets difficult to read if more casts are needed. In situations like this, we would rather like to write something like this:

Int? add(Int? a, Int? b) {
    if (!a)
        return null;
    if (!b)
        return null;

    return a + b;
}

Unfortunately, this will not work either since there is no ! operator for the Maybe<T> type. Even if it would provide one, this would mean that the expression in the if-statement would evaluate to a Bool, and thus it would no longer be a weak cast.

Fortunately, Basic Storm provides the conditional unless that can be thought of as an inverted if statement. In order to work properly with weak casts, the unless statement is a bit peculiar in that it requires that the body either returns or throws an exception. This is to guarantee that the code located after the entire unless statement is only executed if the weak cast succeeded.

To illustrate the semantics, consider the unless statement below:

unless (<condition>) {
    <if-failed>;
}
<if-successful>;

It is equivalent to the following if-statement:

if (<condition>) {
    <if-successful>;
} else {
    <if-failed>;
}

Using the unless statement we can write the add function as below. This approach has the benefit that it remains easy to follow even if multiple weak casts are necessary.

Int? add(Int? a, Int? b) {
    unless (a)
        return null;
    unless (b)
        return null;

    return a + b;
}

Of course it is possible to rename the result variable in the unless statement using the equals sign, just as in the if statement. For example:

Int? add(Int? a, Int? b) {
    unless (unwrappedA = a)
        return null;
    unless (unwrappedB = b)
        return null;

    return unwrappedA = unwrappedB;
}

Trailing Returns

The code for this part of the tutorial is in the file root/tutorials/fundamentals/trailing.bs and can be run by typing tutorials:fundamentals:trailing in the Basic Storm top-loop.

Many constructs that are statements in other languages are expressions in Basic Storm. Two examples of this are blocks and if-statements. This feature was mainly designed to simplify the implementation of syntax extensions, but it turns out that it is also useful in other situations as well.

Let us first consider blocks. As in C, C++, and Java, it is possible to introduce new blocks anywhere inside a Basic Storm function in order to delimit the scope of local variables. For example, consider the case where we wish to use a string buffer (StrBuf) to create a string, but we do not wish to keep the temporary variable longer than necessary:

void main() {
    Str string;
    {
        StrBuf tmp;
        for (Nat i = 0; i < 10; i++) {
            if (i > 0)
                tmp << ", ";
            tmp << i;
        }
        string = tmp.toS();
    }
    // "tmp" is no longer accessible.
    print(string.toS);
}

One difference from the aforementioned languages is that blocks can also be used as expressions. In such cases, a block evaluates to the value of the last statement inside the block. We can use this to make it clearer that the purpose of the block is to initialize the variable string. As an additional bonus, we also avoid creating an empty string to initialize the string variable that we almost immediately discard.

void main() {
    Str string = {
        StrBuf tmp;
        for (Nat i = 0; i < 10; i++) {
            if (i > 0)
                tmp << ", ";
            tmp << i;
        }
        tmp.toS(); // Last statement, will be the result of the block.
    };
    // "tmp" is no longer accessible.
    print(string.toS);
}

Note that it is necessary to add a semicolon after the closing curly brace to terminate the statement that initializes string.

This structure is very convenient when implementing macro-like constructs that need to use temporary variables. For example, the array initialization syntax expands to a structure similar to the above. In particular, the literal Array<Int> x = [1, 2, 3]; expands to the following:

Array<Int> x = {
    Array<Int> tmp;
    tmp.reserve(3);
    tmp << 1;
    tmp << 2;
    tmp << 3;
    tmp;
};

This idea also extends to the top-level block in functions: by default, a function returns the value of the last statement in the topmost block of the function. This means that it is possible to implement an add function without using return like this:

Int add(Int a, Int b) { a + b; }

Finally, the same idea also extends to if statements. For example, we could rewrite the if-statement in the loop above as follows:

void main() {
    Str string = {
        StrBuf tmp;
        for (Nat i = 0; i < 10; i++) {
            tmp << if (i > 0) { ", "; } else { ""; };
            tmp << i;
        }
        tmp.toS(); // Last statement, will be the result of the block.
    };
    // "tmp" is no longer accessible.
    print(string.toS);
}

Since the if statement can be used as an expression, Basic Storm does not need a dedicated ternary operator like C, C++, and Java. While using the if-statement as an expression did perhaps not improve the readability in the example above, this ability can be quite useful when working with Maybe types. For example, consider a case where we accept a Maybe<Nat> as a parameter to a function. If the supplied value was not null, we wish to use the value. Otherwise, we wish to use some default value. We can implement this compactly as follows:

Nat computeStuff(Nat? input) {
    Nat input = if (input) {
        input;
    } else {
        0;
    };
    // Our computations go here:
    input + 1;
}