Tutorial

This page contains a tutorial introduction to the Tetra programming Language. Its intent is to be a quick and straightforward introduction to Tetra for experienced programmers.


Hello World

By law and custom, we will begin with the "Hello world" program which in Tetra can be written as:


# hello world example
def main():
    print("Hello World!")

Comments in Tetra begin with the # character and continue to the end of the line. The first line of this program is a comment, and the rest is the body of the program.

Every Tetra program must contain a function called main which defines where the program begins executing. Functions are defined with the def keyword, then the name of the function, followed by the list of parameters. The main function takes no parameters. The colon character is used to mark the beginning of the function body which must be indented one level over.

The print function is used for printing things, so we pass the string "Hello World!" into it to print it to the screen.


Simple Types

Tetra contains six simple types. Each of these is indivisible, but can be combined up in lists, dictionaries, classes etc. as discussed later on. The simple types are:


Functions

As described above, functions are introduced with the def keyword, and also have names and parameter lists. Functions can also return values, and their return type (if any) goes after the parameter list, but before the colon.

Below is a function which computes the average of three parameters:


# return the average of three parameters
def average_of_three(v1 real, v2 real, v3 real) real:
    return (v1 + v2 + v3) / 3.0;

Note that types go after their variables and not before. The return statement is used to specify the return value of a function and return control back to the caller.

Functions are called similarly to how they are in most programming languages, with the values of parameters passed in at the call. The average_of_three function above might be called as follows in main:


# test of the average_of_three function
def main():
    avg = average_of_three(4.5, 7.2, 6.3)
    print(avg)


Variables

If you look at the example call to average_of_three above, you will notice that there is no declaration of the type of the avg variable. Local variables (those which exist only inside of a function) do not need type declarations in Tetra, the compiler will figure out the type of each based on how the variable is used. Only function parameters and return types need to be given type declarations.

We can declare the type of local variables. Sometimes this is needed, for example if we want to create a variable, but don't want to give it an initial value, we would need to declare the type:


# Tetra will figure out the type
value1 = 5

# we tell Tetra the type
value2 as int

We can also declare multiple variables of the same type in one line:


# declare three variables of the real type
x, y, z as real

Global variables can also be created, by using the global keyword, as shown in the following example:


# declare a global variable
global count = 0

# access the global
def increment():
    count += 1

# should print 3
def main( ):
    increment()
    increment()
    increment()
    print(count)

Of course global variables should be avoided in general!

Tetra also allows constant variables to be created with the constant keyword:


constant PI = 3.141592653589

Notice that globals and constants also do not need type declarations if they are assigned a value.


Operators

Tetra supports many common operators. The following table shows the operators that Tetra supports organized by precedence levels, from highest precedence to lowest, along with the associativity:

OperatorMeaningAssociativity
**ExponentiationRight
+ - ~Unary plus, unary minus, and unary bitwise notUnary
% / *Modulus, division, multiplicationLeft
+ -Addition and subtractionLeft
<< >>Bitwise left shift and right shiftLeft
&Bitwise andLeft
^Bitwise xorLeft
|Bitwise orLeft
< <= > >= == !=Comparison operatorsLeft
notLogical notUnary
andLogical andLeft
orLogical orLeft
=Assignment (including +=, -= etc)Right

Selection Statements

Tetra provides the if, elif, else statements, which work similarly to other programming languages. Below is a program which prints out whether a number is positive, negative, or zero:


# print the sign of the parameter
def print_sign(value int):
    if val < 0:
        print(val, " is negative")
    elif val > 0:
        print(val, " is positive")
    else:
        print(val, " is zero")

def main( ):
    print("Enter a value: ")
    value = read_int()
    print_sign(value)

Notice that the keyword is elif all as one word, and not "else if". Also, notice that print can take multiple parameters, and that the read_int function can be used to read an int from the user.


While Loops

While loops are blocks of code that continue to execute while some condition is true. For example, the following function returns the factorial of a number using a while loop:


# calculate the factorial of a parameter
def fact(x int) int:
    result = 1
    while x > 0:
        result *= x
        x -= 1
    return result

The while loop starts by evaluating its condition and, if true, executes the body of the loop. It continues to do this until the condition is false at which point it will continue to the code after the loop.

Tetra also includes the break keyword, which causes a loop to immediately exit, the continue keyword, which causes a loop to abort the current iteration and move on to the next one, and the pass keyword which has no effect but can be used where a statement is needed.


Parallel Execution

Normally, Tetra functions are executed sequentially line by line, following the control flow as dictated by function calls and returns, loops, and selection statements. However, we can also specify that statements should be executed in parallel.

This is done using the parallel keyword, followed by a number of statements in the parallel block. For example, this program calls the print function two times in parallel, and then calls it a third time:


def main():
    # each statement directly under the parallel block executed in parallel
    parallel:
        print("Print statement number 1!\n")
        print("Print statement number 2!\n")
    print("Print statement number 3!\n")

When this program is run, the two print statements will be executed at the same time. This program may print the first two messages in any order. Sometimes the first message appears first, and other times the second one will appear first. It is even possible that they will appear inter-leaved, with the beginning of one message, followed by the beginning of the other, followed by the ends of each of them.

Because the third print call is outside of the parallel block, it will not start executing until the other two have both completed.

Of course normally we would only execute different pieces of code in parallel if they can be executed together in any order without changing the behavior of the program. For example, we might call multiple functions in parallel to compute values which are independent of each other:


def main():
    parallel:
        result1 = process(data_set1)
        result2 = process(data_set2)
        result3 = process(data_set3)
    display_results(result1, result2, result3)

Here, the three calls to a function called "process" will proceed in parallel independently of each other. When all three have returned, the program will move past the parallel block to call the "display_results" function.

Note that the parallel works with statements. Often the statements will be function calls, but they do not have to be. We could run two while loops in parallel for instance:


def main():
    i = 1
    j = 1
    sum = 0
    product = 1
    # each while loop is done in parallel
    parallel:
        while i < 10:
            sum += i
            i += 1
        while j < 10:
            product *= j
            j += 1

Here, the two while loops are executed in two parallel threads. Each while loop itself is implemented sequentially, so this program will compute the sum and product of the numbers 1-10 in two parallel loops.


Lists

Lists package together multiple variables of the same type, such that they can be accessed by an integer index. Lists are indexed by placing the index between square brackets after the name of the list. The following example program demonstrates how a list might be created and indexed:


def main( ):
    numbers = [10, 20, 30]
    total = numbers[0] + numbers[1] + numbers[2]
    print(total)

Data type declarations, such as with function parameters, with lists are done by placing the type of each element in brackets. For example, the following function takes a list of numbers and returns whether or not the length of it is even:


# return true if the length of the list is even
def even_length(values [int]) bool:
    if len(values) % 2 == 0:
        return true
    else:
        return false

Multi-dimensional lists can also be created. In this case, you would simply increase the number of brackets in the type. For example, a 2D list of reals would be declared as having type [[real]].


Ranges

We can create lists containing ranges of integers using the ... operator. For instance, we can create a list containing the values one to ten inclusive with:


numbers = [1 ... 10]

This can be done with variables as well. We could use a range to write our factorial function as:


# calculate the factorial using a range
def fact(n int) int:
    result = 1
    for num in [1 ... n]:
        result *= num
    return result


For Loops

Lists lead us naturally to the Tetra for loop which loop over each item in a list. For example, the following function loops over the list it is given and prints each element:


# print all values in a list being passed in
def print_all(values [int]):
    for value in values:
        print(value, "\n")

This for loop assigns each element in the list that it is given to the loop variable one by one, then executes the loop body for that particular value.


Parallel For Loops

We can also use the parallel for loop which is similar to the for loop as described above, except that it is preceded with the parallel keyword, and each of its loop iterations can be executed in parallel.

If we make the for loop above into a parallel for loop, then the calls to print can be executed all at the same time and so can appear inter-leaved and in any order. Calling the function with the values 1 through 16, as in this program:


# print all values in a list in parallel
def print_all(values [int]):
    parallel for value in values:
        print(value, "\n")

def main():
    print_all([1 ... 16])

Produces different results at each run. The beginning of one run is shown below:

13
45
2
6

7
8
10
11
12
91314

15
16

Notice that the results are not only out of order, but in this case inter-leaved since sometimes multiple numbers get printed back to back, and sometimes multiple new lines are printed back to back.

We would only want to use parallel for loops when the order of the loop iterations is not important for the program to be correct. As an example, suppose we are writing a function which takes a list of numbers and returns a list of those numbers squared. Here, each of the elements can be computed independently, and so could be done with a parallel for loop:


# returns the squares of an array as another array
def square_all(values [real]) [real]:
    squares [real] = list(len(values))
    parallel for i in [0 ... len(values) - 1]:
        squares[i] = values[i] * values[i]
    return squares

The list function creates a list of a given size.


Background Execution

The parallel keyword can be used to launch multiple statements at the same time, which allows the original thread to launch multiple tasks at once, and pause until they are done.

Something else we might want to do is have a thread launch one task which begins executing, but the returns control back to the original thread immediately. For instance, we may want to launch a long-running task, but not wait for it to finish immediately so we can move on to other things, such as handling user input.

This can be done with Tetra using the background statement. The following program shows how this may be done to write a simple server type of program:


# a simple server type program
def main():
    while true:
        request = wait_for_request()
        background:
            print("Handling a request!")
            handle_request(request)

This program uses a background block to handle each request it receives in a separate thread of execution. After the wait_for_request function returns, the program will launch a thread to handle the background block's body, and then immediately call wait_for_request again.

Note that the two statements inside the background block are executed serially: first the print will execute and then the handle_request function will be called.


Waiting

Sometimes we may want to launch a thread in the background, but then wait for it to finish executing at some later point in time. For instance, suppose we are writing a program which needs to download some data for use, but that it can start the download before it really needs the data. We can use a background block with wait statement to implement this:


def main():
    data [string]
    background download_task:
        data = download_data()

    # do other things that don't need the data
    # ...
    
    wait download_task

    # now we use the data we got


Here we have given the background block a name, "download_task". After launching the task in the background, we can carry on doing things which don't require the data. Once we do need the data, we wait for it with the wait statement which takes the task we wish to wait for.

If the task has finished already, the wait statement takes no time at all. If it hasn't finished, it will not complete until the task we're waiting for has completed.

The named background statement creates a variable of type task which is a first class data type in Tetra. This means we can return tasks from functions, put them in data structures etc.


Locks

Sometimes when we have parallel code, we will need to prevent the threads from interfering with each other. For instance, if we are trying to find the largest value in a list, we may keep track of the largest value we have seen so far:


# find the max of a list
def max(nums [int]) int:
    largest = 0
    parallel for num in nums:
        if num > largest:
            largest = num
    return largest

However, this program may produce the wrong answer because two threads may execute the if condition at the same time, and both may find they have a value which is larger than then current largest. We would then have a situation where only one of the threads actually writes its value, meaning one will be lost, which may in fact have been the maximum.

To get around this, we put a lock statement around the code which updates the largest:


# find the max of a list
def max(nums [int]) int:
    largest = 0
    parallel for num in nums:
        if num > largest:
            lock:
                if num > largest:
                    largest = num
    return largest

The lock statement creates a block of code which only one thread can enter at a time. Now, all of the threads can check if their value is larger than the current largest, in parallel. Once one finds that its value is in fact larger, it tries to enter the block of code under the lock. If another thread is already in the block, the thread waits until it has finished before entering. The thread then checks if its value is still larger than the current largest (because it's possible a thread which just left the lock has modified it since the last check) and then overwrites the largest variable.

The lock statement allows creating sections of our program which only one thread can enter at a time which is often necessary when writing parallel code.


Named Locks

Sometimes it is necessary to have multiple sections of code which are locked together. For instance if we have a variable or resource that is being accessed at multiple points in the program, we would want to have those locks linked together, such that no more than one thread can be in any of those sections at a time.

For instance the following program uses a global lock variable to prevent two functions from over-writing the "negatives" value at the same time:


# demonstrates a named lock
def main():
    negatives = 0

    parallel:
        for i in data1:
            if i < 0:
                lock neg_lock:
                    negatives += 1
        for i in data2:
            if i < 0:
                lock neg_lock:
                    negatives += 1
    print("There were ", negatives, " negative values.")

In this example, we are checking two sets of values for negative numbers in parallel. We have to protect the negatives variable, but it is accessed in two distinct places in the code. The fact that the locks are both named "neg_lock" ensures that they are linked together. Only one thread can be in either block at the same time.

The named lock statement actually creates a variable of the mutex type. We can lock an existing mutex variable which has been declared elsewhere. For instance the following program uses a global mutex to allow two separate functions to make sure that they call print separately to avoid the issue of inter-leaved output:


# here the named lock is shared amongst functions, so is global
global print_lock as mutex

def f1():
    # ...
    lock print_lock:
        print("Function 1 says hi!")
    # ...

def f2():
    # ...
    lock print_lock:
        print("Function 2 says hi!")
    # ...


Tuples

In addition to lists, Tetra includes a tuple type which can also group multiple pieces of data together. Unlike a list, a tuple can contain elements of different types. For example, we cannot store an int and a string together in a list, but we can in a tuple.

Another difference between lists and tuples is that lists of any length can be passed into a function, defined as variables etc. while tuples have the number (and type) of their elements fixed as part of their type. For instance, we can write a function which takes a list of strings of any size, but a function which takes a tuple must specify its length.

Below we have a function which takes a tuple containing a string and an int, and then call it in main:


# takes a tuple and prints it to the screen
def print_info(info (string, int)):
    print("Name is ", info[0])
    print("Age is ", info[1])

def main():
    info = ("Bob", 42)
    print_info(info)

Notice that parentheses are used to declare the type of a tuple, and also to give the value of one.

Tuples can also be returned from a function which allows functions to effectively have multiple return values. The following function returns a tuple of two real values representing the roots of a quadratic function, and also a bool which indicates if the results are valid (e.g. if the function has no real roots this will be false):


# function which returns a tuple of three values
def roots(a real, b real, c real) (real, real, bool):
    rad = b*b - 4*a*c
    if rad < 0:
        return (0.0, 0.0, false)
    else:
        return ((-b + sqrt(rad)) / 2*a, (-b - sqrt(rad)) / 2*a, true)

When calling this function, we can put the results into a tuple as follows:


def main():
    # results is the tuple which was returned
    results = roots(1, 0, -16)

    if results[2]:
        print("Roots are ", results[0], " and ", results[1])
    else:
        print("No real roots!")

However, we can "unpack" the tuple right away by assigning the result of the function call into separate variables:


def main():
    # the tuple is now unpacked
    # TODO
    root1, root2, valid = roots(1, 0, -16)

    if valid:
        print("Roots are ", root1, " and ", root2)
    else:
        print("No real roots!")


Dictionaries

The last built in data structure in Tetra is the dictionary, which other languages call associative arrays, hashes or maps.

Dictionaries in Tetra create a mapping from one set of data (the keys) to another set (the values). For example, the following program creates a phone book which maps names to phone numbers:


# create a dictionary modeling a phone book
def main():
    phone_book = {"alice" : "555-5555", "bob" : "123-4567", "claire" : "765-4321"}

The curly braces contain all elements in the dictionary. Each element contains the key (on the left of the colon) and the value (on the right of the colon). The elements are separated by commas.

To access the elements of a dictionary, we place the key we want to look up inside of brackets:


def main():
    phone_book = {"Alice" : "555-5555", "Bob" : "123-4567", "Claire" : "765-4321"}
    print("Bob's phone number is ", phone_book["Bob"]

When declaring the type of a dictionary, such as in a function parameter or return type declaration, we place the key type and the value type inside of curly braces, separated by a colon. For instance, the following function takes a dictionary from string to int as a parameter which represents the ages of a group of people and prints them out:


# function which prints the whole dictionary passed in
def print_book(ages {string:int}):
    for person in keys(ages):
        print(person, " is ", ages[person], " years old.")

This function uses the keys function which returns a list of all of the keys in a dictionary. This can be used to loop over all of the elements in a dictionary.


Classes

Classes in Tetra are created with the class keyword, followed by the name of the class followed by a colon. Classes can contain variable declarations. These declarations must have a type declaration, and cannot be given a value.

Class blocks can contain also function definitions, which are just like regular functions except they can reference any variables declared inside of the class.

For example, the following class represents a square:


# creates a class representing a square shape
class Square:
    # the size of the square
    size as real

    # member function to compute the area
    def area() real:
        return size ** 2

We can create a Square object by using the name of the class as if it were a function which returns an object of that class:


# test out the class
def main():
    s = Square()
    s.size = 5.0
    print("Size = ", s.area())


Initializers

Initializers are functions included in a class with the name init which allow class objects to be initialized based on certain parameters. For instance, we may want to allow the user to pass in the size of a square when they create one:


class Square:
    # the size of the square
    size as real

    # an initializer
    def init(size real):
        self.size = size

    # member function to compute the area
    def area():
        return size ** 2

Notice that the init function uses the self keyword to specify the size belonging to the object as opposed to the local parameter called size.

When creating a square object now, we must pass in the size:


def main():
    # now we pass the initializer the size
    s = Square(5.0)
    print("Size = ", s.area())

If any initializers are defined, we must call one of them. With the class definition above, we would not be able to forgo supplying the size. We can however have multiple initializers with different parameter lists. Below we add in an initializer which takes no parameters:


class Square:
    # the size of the square
    size as real

    # an initializer with a real parameter
    def init(size real):
        self.size = size

    # an initializer with no parameters
    def init():
        self.size = 0.0

    # member function to compute the area
    def area():
        return size ** 2

Tetra allows multiple functions with the same name in other contexts as well. We can have regular functions or class member functions with the same names as long as they have different parameter lists.

Any object can also be assigned the none value which means that the object does not exist and none of its fields can be referenced.


Modules

Modules in Tetra allow for packaging related code together. Each module is a separate Tetra file. All code in a module can access the rest of the code in that same module transparently. For instance the programs above are each assumed to be in the same file, so the main functions can call the other functions in that same file (module) just by name.

We can also write code which accesses code in other modules, but must do so explicitly. This is done with either the import or open keyword. Each statement is given a module name. For instance, if we have a file called "fact.ttr" which contains the following code:


# fact.ttr

def factorial(x int):
    if x == 0:
        return 1
    else
        return x * fact(x - 1)

Then we can access this function from another file called "program.ttr" which contains a main function using import:


# program.ttr

import fact

def main():
    value = fact.factorial(5)
    print("5! = ", value)

The import statement will fail if the fact.ttr file can not be found, but if it is found, then all definitions in that file are available. To access them, we prefix the reference with the name of the module, as seen above.

The open statement is similar except that it does not require the references to be prefaced with the name of the module. We could write the program.ttr file as follows instead:


# program.ttr

open fact

def main():
    value = factorial(5)
    print("5! = ", value)

We can now call the factorial function directly. open can be used when there are no naming conflicts amongst the file(s) being opened, whereas import avoids any naming conflicts.

When using import or open, Tetra will search in the TETRAPATH environment variable which should include the Tetra standard library location and also the current directory, but can include other directories as well.

All Tetra modules can have a main function which is never included when that module is imported or opened. The main function is only used when a module is executed directly. For example, we could add a main function to the fact.ttr file which tests out the factorial function. To test the module, we can run it directly. When importing the module, the main is not used and does not conflict with a main function in the file which is importing fact.


Function Types

Tetra also has first-class functions which means that functions can be passed to and from other functions, stored in variables and data structures etc. When declaring a function type, the parameter types are listed in parentheses, followed by the -> symbol, followed by the return type of the function.

The following example shows how a function in Tetra can be written which takes a function as a parameter. This is done to implement the "reduce" function of "map reduce" fame:


# just add two numbers
def add(a int, b int) int:
    return a + b

# just multiply two numbers
def mult(a int, b int) int:
    return a * b

# reduce a list to one value using some function
def reduce(data [int], initial int, f (int, int) -> int)) int:
    result = initial
    for d in data:
        result = f(result, d)
    return result

# test out reduce
def main():
    # our data
    data = [1 ... 10]

    # calculate the sum
    sum = reduce(data, 0, add)

    # calculate the product
    product = reduce(data, 1, mult)


Lambda Functions

Tetra supports lambda functions which are functions created anonymously inside of an expression. This can be done to avoid having to define very small functions. For instance, we could use a lambda to specify the addition and multiplication functions directly in the call to reduce:


def main():
    data = [1 ... 10]

    # calculate the sum
    sum = reduce(data, 0, lambda a int, b int: a + b)

    # calculate the product
    product = reduce(data, 1, lambda a int, b int: a * b)

The syntax for a lambda function is shown above. It begins with the lambda keyword, followed by the parameter list, then a colon, and then the function expression.

Lambda functions cannot contain statements or span multiple lines. If a larger or more complex function is needed, you must use def.

Copyright © 2018 Tetra | Licensed under the MIT License