A Low-Level Programming Language

If I had the chance of designing a low-level programming language, similar to C, how would I go about it? Unlike C, though, I wanted to make this a strongly typed language and meant to be portable across a wide variety of devices without worrying too much about target differences.

Monotype text in bold is used for reserved words, and should not be used for new identifiers. Monotype text in italic is meant to be replaced by actual identifiers or other code.

The Source Code

The source code is divided between files of two types: ch and cm, just like C. ch contains the declarations and public bits of the program, while cm holds the implementations and all the private things.

There are two kinds of comments: single-line and multi-line. The first runs from a semicolon until the end of the line:

; single line comment

The second kind is text delimited by braces:

{ multiple line comment
  { nested comment }
  the rest of the first comment }

An identifier is a sequence of characters that begins with either an underscore or a letter (uppercase or lowercase) from a to z and is followed by more underscores or letters, or digits. Identifiers follow camel case notation. Variables and functions begin with a lowercase letter. Constants begin with the character 'k'. Types begin with uppercase letters.

In a way similar to Java, ch files begin with

package developer-name:module-name

Here, developer-name and module-name are identifiers, and are used together as a sort of namespace. Then follow the imports:

import file-name, ...
import developer-name:module-name, ...
import developer-name:module-name:file-name, ...
...

These are useful not only for not specifying during use the full name of the entities declared in ch files, but also for telling the compiler about such entities and their features in order to aid in error prevention. file-name does not have an extension - it's assumed to always be ch.

Basic Types

The most basic type is the familiar type

void

used to tell the compiler that a function returns no value, or that it receives no values, or that a pointer variable is able to point to anything in memory. Then comes the boolean type

bool

There are only two literals of this type, namely

false
true

We also have the integral signed types

byte
short
int
long

and the unsigned versions

ubyte
ushort
uint
ulong

which are, each respectively, 8, 16, 32, and 64 bits wide. Literals of these types can be

0b01, -0o07, 0h0F
0d32, 32
-5, +5

0d is optional, and is used to write a decimal number. 0b just before a number tells the compiler that that number is in binary, 0o is used for octal numbers, and 0h for hexadecimal ones. When writing literals, there is no need to specify their sizes - the compiler adjusts them accordingly. The floating point types

float
double

are 32 or 64 bits wide, respectively. Some examples of literals of these types are

0.0, 1.0, -3.1415
2.0e-5, -4.75E14

It is mandatory to have digits before and after the decimal period. There are three more numeric types, namely

word
uword
fword

Their sizes depend on the architecture where they are used. In 8-bit CPUs they are 8 bits wide, in 16-bit CPUs they are 16 bits wide, and so forth. Then there are the (Unicode) characters, declared to be of type

char

A literal of this type is any character enclosed in single quotes, as in

'a', '\n', '\u0011EEFF'

The last examples show that escape sequences can also be used to define characters. A

string

is a series of (Unicode) characters, one after the other. It is written as those characters enclosed in double quotes, as in

"This is a string!"

A string is a bit safer than C's strings, for it is not null-terminated. Instead, its size is recorded along with its contents. If we wish, we could write

"This is a string!\0"

Finally, the last basic type is the

range

A literal of this type is written

0..10

This is useful in loops, as we'll see below. The extremes (0 and 10 in the example) are part of the range.

Variables (or Instances) and Constants

A variable called name of a certain type is declared using the keyword data, like this:

type data name

However, data is optional, so we can write more briefly

type name

We can declare more than one variable of the same type at the same time using commas, and we can also assign values to them at the time of declaration, using the assignment operator ':=', like this:

int data i1, i2, i3 := 1

Here i3 is initialized with the value one. If no assignment takes place, the default value of zero is used, that is, upon program entry variables are always initialized to their type's default value. Pointers are declared in the usual way of C, so:

int data *p1, *p2 := &i1

Notice that p2 was initialized with the address of i1 using the symbol '&'. Also, it is sometimes useful to indicate that a pointer points to nothing with the keyword

nil

An array is also declared in the usual way:

byte data a1[5], a2[6][7][8], a3[] := [1, 2, 3, 4]

Variables a1, a2, and a3 are not pointers, as in C, because this language is strongly typed, but the address of a2 (say) can be obtained by saying

byte data *pa2 := &a2[0][0][0]

a3 above was initialized with byte values separated by commas and enclosed in braces. Its size is 4. This is also valid:

char data sa[3][2] := [['a', 'b'], ['y', 'z'], ['0', '1']]

We can say

sa.count

to obtain the size of the array (3, in this case). If we say sa[1].count, we obtain the value 2. All arrays are unidimensional. Multiple sequences of square brackets are just syntatic sugar. Instances can be inline, static, and extern. A constant named name of type type with value value is declared by

inline type data name := value

It's OK to omit data. Unlike variables, constants can be initialized only once, but, like variables, we can declare more than one constant in a single inline data clause using commas, and we can also initialize them in the same way as we initialized variables. However, unlike C, if we want a constant pointer to a constant int, for instance, we must first declare the int, and only then the pointer, as in:

inline int ic := 1, *cpic := &ic

In this way, nor ic nor cpic can be changed in future parts of the code.

Operators and Expressions

The usual operators are included, plus a few new ones. They are:

() [] . -> :
! ~ + - ++ -- * &
* / \
+ -
& | ^ << >> <<< >>>
..
== != < <= > >=
&& ||
?:
:= *= /= \= += -= &= |= ^= <<= >>= <<<= >>>=

: is the scope resolution operator (same as :: in C++). Then come the usual unary operators. \ is the remainder of the integer division of two numbers. <<< and >>> are the cyclic rotation operators. They work in unsigned numbers and rotate their contents, with one bit coming out of one end and entering the other end. .. is the range operator. With these operators in place, we can write expressions like:

a /= (x + 3.5) * (2 <<< 5)
k := b != 1 && c < 5 ? t & 0b01001011 : ~0b11001001

Functions

A function is defined using the syntax

return-type func name(args-list)
  statement-1
  statement-2
  statement-3
  ...

If we're just declaring it (instead of defining it), the statements are simply absent. Notice that there are no braces enclosing the statements - they're indented instead. Nor are there semicolons at the end of a statement, as the end-of-line character also works as an end-of-statement character, if appropriate. Here's an example of a function that swaps two integers:

void func swap(int &a, int &b)
  int data tmp := a
  a := b
  b := tmp

We can assign predetermined values to the last arguments of the function, like this:

int f(int a1, int a2 := 0, int a3 := 1)

This also shows that the keyword func can be omitted. Also, some of the arguments can be constant, thus telling the compiler that they cannot be modified. Here's an example:

float f(double a1, inline double a2, int data a3 := 1)

a2 cannot be modified by the function. Constants can also be given predetermined values, like this:

char f(string data a1, inline char data a2 := 'a')

This makes the character 'a' the value of a2, if the function is called f("A string!"), or, if the function is called f("A string!", 'c'), the value of a2 is now 'c' instead. Also, functions can be overloaded, so that the above three functions called f can co-exist in the same program at ease. All that is required is that the types of the arguments that have no predetermined values be different among the various versions of the function.

If a function returns no values, or has no arguments, the keyword void is used to explain it:

char g(void)
 
void func h(int data a)

Functions that receive a variable number of parameters are declared using the ellipsis, like this:

void k(byte b, ...)

Then, inside its body, its variadic arguments can be accessed with the special array args, as in args[0], args[1], args[2], etc. Also, we can write args.count to determine how many variadic parameters there are.

To call a function, simply type its name, followed by the comma-separated arguments enclosed in parentheses. For example,

example(100, 40, 1)

As in C, a function that has no arguments still needs to be called using parentheses:

char data c := g()

Functions can be inline, static, and extern, as in C++.

Creating New Types

All entries in this section define new types, like enumerations, structures, classes, and so forth. If a type has name type-name, we can declare a variable of that type (and named name) in the usual way, like this:

type-name data name

The same applies to constants.

Enumerations

This language supports enums, in a way similar to C. To define them, we write:

super-enums-list enum name
  enum-entry-1
  enum-entry-2
  enum-entry-3a, enum-entry-3b
  ...

Here we can see that individual entries appear by themselves in a single line, but we can create synonyms by separating them with commas. Here is a simple example of an enumeration:

enum CardinalPoints
  East
  North
  West
  South

And then, we can add to this definition the following:

CardinalPoints enum MoreCardinalPoints
  NorthEast
  NorthWest
  SouthWest
  SouthEast

CardinalPoints is the supertype of MoreCardinalPoints, and so we can say

MoreCardinalPoints data compass1 := South, compass2 := NorthEast

Enums can have more than one supertype (as long as there are no repeated entries), in which case the new enum will consist of all the superenums plus its new definitions. For example:

enum Enum0
  A
  B
  C
  D
 
enum Enum1
  X
  Y, Z
 
Enum0, Enum1 enum Enum2
  Zero
  One
  Two

In Enum1 (and Enum2), Y and Z are the same and can be used interchangeably. Enums and the integral types are different: assigning an integer to a variable of an enum type is not valid, and vice-versa.

Errors

Errors are special kinds of enums, and are defined in a similar way:

super-errors-list errors name
  error-entry-1
  error-entry-2
  error-entry-3a, error-entry-3b
  ...

Everything that was said about enums also applies to errors. They differ in that only errors can be used with the statements error() and iferror(). See more about these statements below, in their own section. Here is an example of a list of errors:

errors FileError
  ReadAfterEOF
  DiskFull
  BadHandle
  FileAlreadyOpen

Bitfields

Bitfields are a way of separating a sequence of bits into logically meaningful sections. They are declared like this:

super-bitfields-list bitfield name
  bitfield-entry-1
  bitfield-entry-2
  bitfield-entry-3
  ...

Bitfield entries can be any of the following:

bool bool-name
  
bit bit-name
  
ubits(total-bits) ubits-name
  ubits-enum-1 := value-1
  ubits-enum-2 := value-2
  ubits-enum-3 := value-3
  ...
  
sbits(total-bits) sbits-name
  sbits-enum-1 := value-1
  sbits-enum-2 := value-2
  sbits-enum-3 := value-3
  ...

A bool is simply a bit that can be false or true. A bit can be 0 or 1. ubits are unsigned bits and sbits are signed bits, in two's complement. There's one special name called

reserved

that can be used as many times as needed instead of a usual field name. Also, ubits and sbits can have constants assigned to individual values. These constants are optional and don't have to be exhaustive. For example:

bitfield ExampleBits
  bool data isAccessed, isDirty
  bit reserved
  bit data reserved
  ubits(4) pageType
    kPageTypeSys := 1
    kPageTypeDrv := 2
    kPageTypeLib := 4
    kPageTypeApp := 8
  ubits(8) reserved
  sbits(16) data offset
  
ExampleBits data myBits := [false, false, , , kPageTypeSys, , -14]

Unions

A union is declared like this

super-unions-list union name
  union-entry-1
  union-entry-2
  union-entry-3
  ...

For example:

union Union0
  int i
  double d
 
union Union1
  byte data b

As shown in Union1, unions can have just one field. Unions can have more than one super union, as long as fields are not repeated in the hierarchy:

Union0, Union1 union Union2
  long l
  double f

If we declare u2 to be a union of type Union2, so:

Union2 data u2

we can access its members with the member access operator:

u2.i := 15
u2.b := 1
u2.f := 0.5

In case pu2 is a pointer to a union of type Union2, as in

Union2 data *pu2 := alloc(Union2)

its members are accessed thus:

pu2->b := 0
pu2->f := 0.5

We can also say:

Union2 data myUnion := [1, 2.0, 255, 17269182756915, 1.25e-300]

A union can be anonymous, in which case its entries have a scope identical to the one where the union was declared. For that, the entries must not have been declared elsewhere in that scope, that is, no repeated names must occur. For example, we can declare

union
  byte b
  char c

if b and c have not been declared anywhere else in the module. In this way, no confusion with repeated identifiers can happen. To access these union entries, we simply type, for example,

b := 5
 
f(c)

for some function f.

Structures

Structures are similar to the ones in C, operate in a way similar to unions as described above, and are declared by:

super-structs-list struct name
  struct-entry-1
  struct-entry-2
  struct-entry-3
  ...

Everything that was said about unions also applies to structures. In addition, we can nest types as we wish, like this:

enum StructType
  ItsAnAge
  ItsAName
 
struct PersonalData
  StructType type
  union
    int age
    string name
 
PersonalData data me
 
me.type := ItsAnAge
me.age := 42
 
inline PersonalData data someone := [ItsAName, "Name"]

Notice the use of an anonymous union. In this case, the identifiers age and name have struct PersonalData scope, so, for this to be valid, no other identifiers in the structure must match them.

Classes and Protocols

A protocol is simply a list of function declarations:

super-protocols-list protocol name
  function-declaration-1
  function-declaration-2
  function-declaration-3
  ...

It can inherit from other protocols, so that a class implementing it must implement all the functions in the super protocols along with the newly declared functions.

A class is a structure containing members which can be instances, constants, or functions.

super-class, protocols-list class name
  member-declaration-1
  member-declaration-2
  member-declaration-3
  ...

It may not have a super class, in which case it inherits from no class. Also, its protocols list may be empty. For example:

Object class Rectangle
  float data x, y
  float w, h
  
  Rectangle* func init(float x, float y, float w, float h)
  float area(void)

This class has four instances (all floats) and two methods, one to initialize its contents and another to return an area. Also, its superclass is the class Object. The class declaration for Rectangle above goes in a ch header file, if we wish to make it public. Then, in a cm file comes the implementation of the declared function members, in a way similar to C++, so:

Rectangle* func Rectangle:init(float x, float y, float w, float h)
  self->x := x
  self->y := y
  self->w := w
  self->h := h
  return(self)
  
float Rectangle:area(void)
  return(w * h)

Now, when we wish to use the class Rectangle, we simply declare it and initialize it, like this:

Rectangle data *myRect := Rectangle->alloc()->init(0.0, 0.0, 1.0, 0.5)
log("The area is \(myRect->area()).")

This creates an instance of class Rectangle by allocating space in the heap first, and then initializing the x, y, w, and h members with some appropriate values.

Members can be static, like the alloc() function above, and also they can be overloaded. Also, members can be inline (and so they are either constant fields, and thus cannot be setters, or inlined functions). Polymorphism works as expected, and because of it, only pointers to classes are acceptable. That is, an instance of a class cannot be declared so:

Rectangle data thisIsNotAValidRectDeclaration

It must be declared as a pointer, like this:

Rectangle data *thisIsAValidRectDeclaration

Generally, we declare a class instance as

class-name data *instance-name

The keyword data is optional. A constant is declared similarly:

inline class-name data *constant-name := value

If we wish to use any class that implements a certain protocol, we can declare it so:

protocol-name data *instance-name
  
inline protocol-name data *constant-name := value

There is one primitive type that may sometimes be useful: obj. It stands for any class and we can use it thus:

obj data *instance-name
  
inline obj data *constant-name := value

From then on, instance-name (or constant-name) may refer to any class we desire. If the class has at least one member function that is not implemented anywhere, it is said to be abstract, and cannot be instantiated. We'll have to create a subclass that implements that function and then use that subclass. The class Rectangle above is a subclass of the class Object. Here is an example of a subclass of Rectangle called Lozenge:

Rectangle class Lozenge
  float data side
  
  override Lozenge* func init(float x, float y, float w, float h)
  float side(void)

It has a new member instance, called side, it redefines the init() member function (note the mandatory use of override used when redefining a function with equal name and arguments as the ones in the super class), and it adds a new member function called side() to obtain the length of its side (however, see getter below). The implementations could be like this:

Lozenge* func Lozenge:init(float x, float y, float w, float h)
  super->init(x, y, w, h)
  self->side := sqrt(((w * w) + (h * h)) / 2.0)
  return(self)
  
float Lozenge:side(void)
  return(side)

Additionally, this illustrates the use of

super

and also of

self

Extensions work like categories in Objective-C. They allow the addition of new member functions to existing classes without having to subclass those classes. Extensions cannot contain new member instances, only functions, like this:

class name "ExtensionName"
  function-declaration-1
  function-declaration-2
  function-declaration-3
  ...

Here is an example:

class Rectangle "Perimeter"
  float func perimeter(void)

This code is inserted into a file called RectanglePerimeter.ch if we wish to make it public. Then in RectanglePerimeter.cm we add:

float Rectangle:perimeter(void)
  return(2 * (w + h))

Member instances, like x, y, w, and h of the class Rectangle above, are always private, while member functions declared in a ch file are always public. If we wish to access the instances outside of the class, we must either add member functions to do so, or we must use the getter and setter modifiers. An instance member can be a getter and a setter at the same time. Here's an example:

class Circle
  getter float x, y
  setter float radius
  
  Circle* func init(float x, float y, float radius)
  float area(void)
  float perimeter(void)

Now, x and y are getters, so they can appear on the right-hand side of an assignment. Similarly, radius is a setter, and that means it can appear on the left-hand side of an assignment. Like so:

Circle data *c := Circle->alloc()->init(0.0, 1.0, 2.0)
float data myX := c->x, myDoubleY := 2.0 * c->y
c->radius := 3.0

If we wish to declare member functions as private we simply do it in a cm file. There are no private and public keywords.

Aliases

We can create aliases of type names, to call them by another name, like so:

type typedef new-name

For example,

uword typedef Handle

After this, uword and Handle can be used interchangeably. We can also create aliases of functions in a similar way:

return-type(arg-types-list) typedef new-name

For example, if we have a function declared as:

int func f(int i, float data f[], inline char *c)

we can say:

int(int, float[], char*) typedef MyFuncType

With this new definition, instead of typing:

void g(char data c, int(int, float[], char*) data myFunc)

we can type:

void g(char data c, MyFuncType data myFunc)

and then we can call g like this:

g('a', f)

Inside g we use f, or a similar function passed as the myFunc argument, so:

int i := myFunc(1, [1.0, 2.0, 3.0], &c)

Generics

Generics are accomplished through the introduction of a new primitive type:

type

which stands for any type in the language, and through the ability to give parameters to almost any type. The exceptions are the functions, which accept type parameters using angle brackets:

<name>

The reason for these two different mechanisms is that function types are built up of instance types rather than type names, like the other types.

Generics in Functions

The simplest case concerns functions. As we've seen, they already accept parameters, so we can say things like:

void func f(int i, <T> t)

To call such a function we say, for example:

f(-1, "A string!")

and T becomes type string during this call to f. Functions can also return values of generic types, like this:

<T> func f(<T> t[])

So we can write:

char data c := f(['a', 'b', 'c'])

Using this function f as an example, we can also say

char(char[]) typedef MyFuncType
  
MyFuncType data myFunc := f
  
char data c := myFunc(['a', 'b', 'c'])

to achieve the same result as the call to f just above. Also, note that more than one parameter can be of a generic type. Here's another example to further illustrate the use of typedefs with generics in functions. Suppose we have the following function defined somewhere:

<Value> map(<Key> k, bool erase)

Its type is <Value>(<Key>, bool). Elsewhere we can say something like:

int(string, bool) typedef MyMapFuncType
  
MyMapFuncType data f1 := map
  
int value1 := f1("key", true)

This is the same as saying

int value1 := map("key", true)

with the generic type <Value> becoming int and <Key> becoming string during this call to map. Here's another example using map and this time typedef with parameters:

int(key, false) typedef AnotherMapFuncType(<Key> key)
  
AnotherMapFuncType(<Key> key) data f2 := map
  
int value2 := f2('M')
int value3 := f2(3.14159)

Note the default value of false for the bool parameter. This is the same as saying:

int value2 := map('M', false)
int value3 := map(3.14159, false)

And now, a typical example. Let us build a generic swap function:

void func swap(<T> &a, <T> &b)
  T tmp := a
  a := b
  b := tmp

We can now write:

int myInt1 := 2, myInt2 := 4
swap(myInt1, myInt2)
 
char myChar1 := 'A', myChar2 := 'B'
swap(myChar1, myChar2)

Generics in Unions and Structs

Unions and structs can also receive parameters, of any type:

super-unions-list union name(args-list)
  union-entry-1
  union-entry-2
  union-entry-3
  ...
super-structs-list struct name(args-list)
  struct-entry-1
  struct-entry-2
  struct-entry-3
  ...

There follows an example of a struct with two parameters. Note the use of type instead of the angle brackets seen previously in the case of functions.

struct STList(type S, type T)
  S s
  T t
  STList(S, T) data *next
  
STList(int, float) data myIntFloatList
myIntFloatList.s := 10
myIntFloatList.t := 2.5
myIntFloatList.next := nil

Here's an example of another struct, that also illustrates the use of typedef with parameters:

struct Year(type data Day, ushort data totalDays := 365)
  int year
  Day days[totalDays]
  
Year(D, 366) typedef LeapYear(type D)
  
LeapYear(float) data myLeapYear
myLeapYear.year := 2016
for(ushort i := 0, i < 366, i++)
  myLeapYear.days[i] := f(i)

In this last line, assume f is declared somewhere as

float f(ushort day)

Generics in Classes and Protocols

Protocols and classes can also accept parameters. The general syntax is as follows and these work in a way similar to structs and unions.

super-protocols-list protocol name(args-list)
  function-declaration-1
  function-declaration-2
  function-declaration-3
  ...
super-class, protocols-list class name(args-list)
  member-declaration-1
  member-declaration-2
  member-declaration-3
  ...

Here is a familiar example:

Dictionary(string, int) data *myDictionary := Dictionary(string, int)->alloc()->init()
myDictionary->add("key1", 1024)
myDictionary->add("key2", 2048)

Statements

The simplest statement is

nop

which does nothing! Expressions and declarations are statements also:

expression
  
data-declaration

Then, there are the usual if statements:

if(boolean-expression)
  true1-statement-1
  true1-statement-2
  true1-statement-3
  ...
else if(boolean-expression)
  true2-statement-1
  true2-statement-2
  true2-statement-3
  ...
...
else
  false-statement-1
  false-statement-2
  false-statement-3
  ...

And the usual switch statement:

switch(expression)
  case(constant-1a, constant-1b, ...)
    case1-statement-1
    case1-statement-2
    case1-statement-3
    ...
  case(constant-2a, ...)
    case2-statement-1
    case2-statement-2
    case2-statement-3
    ...
  ...
  else
    false-statement-1
    false-statement-2
    false-statement-3
    ...

There is no fallthrough to the next case. Instead, we have to use the statement

next

as the last statement in the case to achieve that result. There can be more than one constant in each case. Notice the lack of braces and semicolons. Instead, we have indentation - one tab per level, here shown as two spaces, but in the source code they should be tabs. Next we have loops. The simplest one is

loop
  statement-1
  statement-2
  statement-3
  ...

It runs forever. We can add a stopping condition at the end with:

loop
  statement-1
  statement-2
  statement-3
  ...
until(boolean-expression)

which is the familiar do-while loop of other languages. We can also add a stopping condition at the beginning, like this:

loop(boolean-expression)
  statement-1
  statement-2
  statement-3
  ...

or like this:

loop(boolean-expression)
  statement-1
  statement-2
  statement-3
  ...
until(boolean-expression)

In this last loop, both expressions are evaluated, one at the beginning of each iteration, the other at the end. The loop stops when the expression at the beginning becomes false or when the expression at the end becomes true. Next we have what other languages call the for-each loop:

loop(instance-declaration, iterable-object)
  statement-1
  statement-2
  statement-3
  ...

or this one with a condition check at the end of each iteration:

loop(instance-declaration, iterable-object)
  statement-1
  statement-2
  statement-3
  ...
until(boolean-expression)

See above for examples of instance declarations. iterable-object can be a string, an array, a range, or any instance of a class that implements the Iterable protocol. Here's an example (assume stringAppend() and charUpper() are functions defined somewhere in the program):

string data s1 := "Example", s2
  
loop(char data c, s1)
  stringAppend(s2, charUpper(c))

After running this program, s2 contains the string "EXAMPLE". The final loop is the usual for loop of other languages:

loop(instance-declaration, boolean-expression, iteration-expression)
  statement-1
  statement-2
  statement-3
  ...

This loop also exists in a variant with a condition check at the end of each iteration:

loop(instance-declaration, boolean-expression, iteration-expression)
  statement-1
  statement-2
  statement-3
  ...
until(boolean-expression)

To exit a loop or a switch use the statement

break

and to perform the next loop iteration use

continue

The statement

return

exits from a function. If it is the last statement in the function, it can be omitted. Its variant

return(expression)

is used when the function wants to return a value. Next, comes the inline assembler statement. It goes like this:

asm(cpu-name-1)
  asm-op-1
  label-example:asm-op-2
  asm-op-3
  ...
asm(cpu-name-2)
  asm-op-1
  asm-op-2
  asm-op-3
  ...
...

Only the currently targeted CPU is compiled; the others are ignored. Finally, we have the errors statements. To throw an error, we simply say:

error(type.name)

To catch errors say

iferror(type.name, ...)
  statement-1
  statement-2
  statement-3
  ...
iferror(type, ...)
  statement-1
  statement-2
  statement-3
  ...
...
purge
  statement-1
  statement-2
  statement-3
  ...

These iferror() statements usually come at the end of a function. Their arguments can be type.name to catch a single error, or simply type to catch a whole family of errors. These names are defined by the errors enumeration, as described in its own section above. Also, more than one error (or family) can be caught in each iferror() statement. Finally, the purge statements are executed after the code to handle the error is executed.

Conditional Compilation

Finally, we have the #if directives. The general structure is like this:

#if name-1
  code-1
  ...
#if name-2
  code-2
  ...
...
#else
  code-false
  ...
#end

name-1 and the others may have been defined in the call to the compiler, through the use of an option, say

cubo -dname-1 file-name

If name-1 has been defined in this way, then code-1 is compiled, otherwise it is ignored, and the next #if is checked. This process repeats itself until #end is reached. The code of multiple #if's may be compiled, if more than one is satisfied before reaching the #end. #else may or may not be present. Its code is compiled if no previous #if was satisfied. Following each #if may be more complicated boolean expressions, involving names defined (or not) in the call to the compiler, in a way similar to this:

(name-1 && name-2) || !name-3