Encore Syntax

This section describes the syntactic and semantics of various aspects of Encore.

Blocks and Delimiters

The basic delimiter in Encore is a linebreak (newline). Blocks are keyword-based, and in most cases end with an end keyword.

With the exception of if and let expressions, a newline is mandatory to open a new block, so after do and with, the newline is required:

while Expr do     for Id <- Expr do     match Expr with
  ...               ...                   case Pattern => Expr
end               end                   end

let               let Id = Expr in      repeat Id <- Expr do
  Id = Expr         ...                   ...
in                end                   end
  ...
end

These "in-line forms" are allowed (single line, no line breaks):

let Id = Expr in ... end

if Expr then Expr end

if Expr then Expr else Expr end

Encore also allows one-line blocks on the form { Expr (; Expr)* }.

Expressions and Statements

Encore is an expression-based language, meaning that many constructs that would be expressions in many mainstream imperative languages are expressions. For example:

  • Block (e.g., do ... end) returns the value of the last expression in the body/block
  • if Expr then Block1 else Block2 end returns the value of the evaluated block
  • if Expr then Block end returns Nothing if Expr is false, else it returns Just(v) where v is the return value of Block (cf. maybe types below)
  • x = Expr returns () which is the "unit value"

While assignments are expressions, variable declarations are not.

Note that since the return value of a method body is the last expression in the body, explicit return is mostly useful to return from the middle of the body. The following two methods are equivalent:

fun id[t](x:t) : t
  x
end

fun id[t](x:t) : t
  return x
end

Note that return is not allowed from inside a closure.

Maybe types and the absence of NULL

Encore does not have a NULL type. Instead, Encore relies on a maybe type. There are two possible values in a type Maybe[t]:

  1. Just(v) where v is a value of type t
  2. Nothing which is a value denoting the absence of a t-typed value (in this case)

Where a C or Java programmer might use NULL or null, the Encore programmer will use Nothing. The key advantage of maybe types is that they capture precisely where absences of values may occur in practise, and in that respect they are similar to non-null types of some imperative languages, where non-null is the default for a type (e.g., t) and must be opted in (i.e., Maybe[t]).

The maybe type is immutable and has value semantics and thus may be passed by copy.

How to extract v from a maybe value is covered below under conditionals.

Conditionals

Examples:

if foo < bar then
  print("Foo is less than bar")
else
  print("Bar is less than or equal to foo")
end

if foo < bar then
  print("Foo is less than bar")
else if foo > bar then
  print("Bar is less than foo")
else
  print("Foo and bar are equal")
end

Encore support three basic forms of conditionals:

  • if ... then ... end
  • if ... then ... else ... end

Conditionals branch on expressions of boolean types and Maybe types. In the latter case, Just(_) coerces to true and Nothing coerces to false.

Here is an example of branching on a Maybe type, returned by the to_i() method:

let 
  x = "42".to_i
in
  if x then
    print("String was successfully converted to an int")
  else
    print("String could not be converted to an int")
  end
end

Typing Conditionals

Typing of conditionals is straightforward. Note that a conditional is an expression and the type of a conditional is the union type of the blocks:

Expr1 : bool \/ Expr1 : Maybe[T1]
Expr2 : T2 
Expr3 : T3
T = T2 & T3
---------------------------------------
if Expr1 then Expr2 else Expr3 end : T

This means that a conditional whose branches both return the type T will have the type T itself. Thus, the following is well-typed:

var tmp : int = if coin_toss() then 42 else 4711 end

However, the following is not:

var tmp : int = if coin_toss() then 42 else "42" end

because the type int & String does not equal int. Read more about union types in its section of the Encore manual.

Conditionals that lack an else-branch have a Maybe type, where taking the else-branch returns Nothing:

Expr1 : bool \/ Expr1 : Maybe[T1]
Expr2 : T2
---------------------------------------
if Expr1 then Expr2 end : Maybe T2

This can be explained as the following transformation:

if Expr1 then Expr2 end

is translated into

if Expr1 then Just(Expr2) else Nothing end

Pattern matching

Examples:

val v = lookup_person(social_sec_number)
match v with
  case Just(person) => p.name
  case _            => "No person found!"
end

val s = get_shape()
match s with
  case Circle(radius) => ...
  case Rectangle(Point(x1,y1), Point(x2,y2)) => ...
end

Encore supports pattern matching on objects and values. A pattern match expression has the following syntactical form:

match Expr with
  case Pattern1 => Expr   -- for simple patterns
  case Pattern2 do        -- blocks for complicated patterns
    ...
  end
  case ...
end

Pattern are tested in the order they appear, so Pattern1 will be tested before Pattern2.

A match expression that does not match on a pattern results in a run-time error.

The expression Expr can be of any type, including objects, primitives, tuples and Maybe types. For example, the example above branching on a Maybe in a conditional is equivalent to the following code:

let 
  x = "42".to_i
in
  match x with
    case 42   => print("{} can be converted to a number\n", x) 
    otherwise => print("{} can not be converted to a number\n", x) 
  end
end

Patterns can have any of the following form:

  • Binders (e.g., variables like x)
  • Primitive value (e.g., 1, 42.0, false)
  • Literal patterns like tuples, arrays, strings and maybes (e.g., (42, x), [1,2,x], "foo", and Just(42))
  • Extractor patterns, which involve calling extractor methods (see below)

Binders are free variables which match against anything and are bound in the corresponding block. Primitive value patterns use equality comparison. Literal patterns can be reduced to a structural match, followed by zero or more primitive value comparisons and/or binders. Thus, the following match will return 3 because the array pattern matches the array v as they have the same number of elements, and the point-wise comparison of each element matches: 1 is 1, 2 is 2 and z matches is bound to 3. Hence the entire match returns 3.

val v = (1,2,3)
match v with
  case (1,2,z) => z
  case ...
  ...
end

Tuple, maybe and string patterns are tested according to similar principles: the elements of the structures are matched against each other.

In Encore objects may define extractor methods which allows running arbitrary, side-effect free code as part of a pattern matching expression. To designate a method as an extractor, add a match modifier to the method declaration. Match methods must return values of Maybe type. Extractor methods allow an object to define arbitrary patterns which can be used to extract values. Consider this example:

class Point
  var x : int
  var y : int
  def match origo() : Maybe[unit]
    if this.x == 0 && this.y == 0 then 
      Just(())
    end
  end
  def match Point() : Maybe[(int, int)]
    Just(this.x, this.y)
  end
end

The methods origo() and Point() are extractor methods, which can be used as patterns in a match expression:

fun example(p:Point) : unit
  match p with
    case origo()      => println("Point has coordinates (0,0)") 
    case Point(47,11) => println("Point has coordinates (47,11)") 
    case Point(42,y)  => println("Point has coordinates (42,{})", y) 
    case Point(a, b)  => println("Point has coordinates ({}, {})", a, b) 
  end
end

The first pattern, origo() will match when p.x == 0 && p.y == 0 holds. The second pattern demonstrates patterns with values, and will only match when p.x == 47 and p.y == 11. The third pattern matches against any value of p.y and binds the pattern variable y to p.y in the corresponding block. Note that it also requires that the value of p.x == 42. Finally, the fourth pattern matches any value on p.x and p.y and binds the pattern variables a and b to p.x and p.y respectively in the corresponding block.

The return type of match expressions works the same as conditionals, i.e., it is the union type of all return types of all case blocks.

Note that where clauses can be used to reduce clutter when cases in a match statement contains many statements:

fun example(p:Point) : unit
  match p with
    case Pattern1 => ...
    case Pattern2 do 
      ... 
    end
    case ...
  end
end

can be written as:

fun example(p:Point) : unit
  match p with
    case Pattern1 => foo()
    case Pattern2 => bar()
    ...
  end
where
  fun foo() => ...
  fun bar() 
    ... 
  end
end

Encore allows calling match methods just like regular, and pattern matching on any method that returns a maybe type, not just methods explicitly declared as match. However, code that uses matches extremely rarely expects side-effects or "long-running patterns". Pattern matching on non-match methods should be avoided, and all methods that are intended for use in pattern matching should be explicitly designated as match methods.

Patterns and Shadowing

Note that variables in a pattern are always binders, meaning they always match against any value (modulo guards), and extract it. Thus, the following code always extracts the x and y coordinates of the point p:

match p with
  case Point(x, y) => ...
end

It is not possible to change this behaviour by declaring x and y in the surrounding scope. The following code will not change the match expression to only match points with x coordinate of 42 and y coordinate of 7:

let 
  x = 42
  y = 7
in 
  match p with
    case Point(x, y) => ...
  end
  ...

To match against a specific point there are two possibilities shown in the two cases below:

let
  x = 42
  y = 7
in 
  match p with
    case Point(42, 7)                          => ...
    case Point(x', y') when x == x' && y == y' => ...
  end
  ...

Identity or Equality in Matches

As variables only appear as binders in patterns, the programmer has to explicitly make the choice between identity and equality comparisons. This compares identity (and therefore never matches):

match new Object() with
  case x when x == new Object() => 1
  _                             => 0
end

This compares equality and thus may match depending on how equality is defined for Object:

match new Object() with
  case x when x.eq(new Object()) => 1
  _                              => 0
end

Typing Match Expressions

Similar to an if-expression, the type of a match is the union of the return types of the cases, e.g., in the following, the type is T1 & T2 & T3:

match foo with
  case 1 => T1()
  case 2 => T2()
  case 3 => T3()
end

Since match expressions that do not match result in an error, the return types is not wrapped in a maybe type, like an if without an else.

Encore tries very hard to hide union types from the programmer, and so union types will currently fail a program unless there is a common interface in the types, which will only be the case when the participating types are composition of traits. Please see the documentation on traits.

While and Do/While Loops

Examples:

-- calculate 10! 
var sum = 1
var N = 10
while N > 0 do
  sum = sum * N
  N = N - 1
end

Encore's while loops follow standard conventions:

while Expr do
  ...
end

and the do/while version:

do
  ...
while Expr 

The break and continue keywords work in the tradition of C (except that there are no labels), meaning break exits the current loop and continue immediately skips to the next iteration. Thus, the following loop will only run 10 iterations:

var i = 0
while true do
  if i < 10 then
    i += 1
  else
    break
  end
end

Similarly, this loop will print all odd numbers:

var i = 0
while i < 1024 do
  if i % 2 == 0 then
    continue
  end
  print(i)
end

Except for for comprehensions (see below), loops do not have a return value.

For Comprehensions

For comprehensions have not yet been integrated into the main branch.

Examples:

for x <- [1, 2, 3, 4] do
  print(x)
end

The Encore for comprehension is convenience syntax that logically transforms into more verbose statements at compile-time. The example above, for instance, is logically transformed into

[1, 2, 3, 4].foreach(fun (x : int) print(x) end)

For comprehensions can equally be used to produce collections of values:

list = for x <- [1, 2, 3, 4] do
  x * x
end

is logically transformed into

list = [1, 2, 3, 4].map(lambda x do x *  end)

For comprehensions may involve several sources of values, i.e.,

for 
  x <- [1, 2, 3]
  y <- [4, 5, 6]
do
  x + y
end

which is almost the same as nested loops, i.e.,

for x <- [1, 2, 3] do
  for y <- [4, 5, 6] do
    x + y
  end
end

The difference between the first and the second is that the first returns [5, 6, 7, 6, 7, 8, 7, 8, 9] and the second returns [[5, 6, 7], [6, 7, 8], [7, 8, 9]].

For comprehensions can be used to extract values from maybe types, e.g., if m and n have type Maybe[int] below, the for comprehension will produce the sum of their values, else it will not run.

for 
  a <- m
  b <- n
do
  a + b    -- a and b have type int here
end

Notably, the entire for comprehension returns a Maybe[int] as it is not guaranteed to run -- if either m or n holds Nothing, the value of the entire expression is Nothing.

The binders in for comprehensions are pattern matching, meaning it is possible to filter out certain results:

var sum = 0
for 
  Person(_, age) <- vs
do
  sum += age
end

The code above will iterate over all values of vs and for each object that matches the pattern Person(_, age), it will execute the do ... end block with age bound to the age of the current person. It is possible to further narrow down filtering, e.g.:

  Person(_, age) where age > 18 <- vs

To iterate over a collection with index, rely on the standard zip_with() in the Iterable interface:

for
  (i, v) <- values.zip_with(InfiniteRange())
do
  ... 
end

or its convenience method with_index():

for
  (i, v) <- values.with_index()
do
  ... 
end

The with_index() method takes 2 parameters -- start index and step, whose default values are 0 and 1 respectively. So does the constructor of InfiniteRange().

Asynchronous For Comprehensions

Example:

for 
  n1 <- graph1 ! find_smallest()
  n2 <- graph2 ! find_largest()
do 
  n1.size() == n2.size()
end

Asynchronous for comprehensions, such as the one above, only involve source producing future values. Asynchronous for comprehensions are lifted into asynchronous blocks, meaning they are not part of the control flow of the method where they are specified. The return value of the for comprehension above is a Future[bool].

See also: attached and detached closures.

Binary Operators

Encore supports the following binary operators:

  • == -- equality comparison (a -> b -> bool)
  • && -- boolean conjunction (bool -> bool -> bool)
  • || -- boolean disjunction (bool -> bool -> bool)
  • xor -- exclusive or (bool -> bool -> bool)
  • != -- negated ==
  • % -- modulo (int -> int -> int)
  • / -- integer division (int -> int -> int)
  • + -- addition (num -> num -> num)
  • - -- subtraction (num -> num -> num)
  • * -- multiplication (num -> num -> num)

where num ranges over int and float types.

Note that identity comparison == is only allowed on types that implement the Id interface, plus, primitive types, maybe types and tuples.

In addition to those above, Encore supports the following convenience operators, from the C-family of languages:

  • +=
  • -=
  • *=
  • /=

Examples:

foo += bar   -- equivalent to foo = foo + bar

There are no ++ or -- operations.

Operator Precedence

Below is a full table of operators in precedence order.

- (unary minus)
* / % 
+ - 
< >
<= =>
== !=
not
&& ||
a(32) a(32) = 32 
!
: Type
~~> 
=
+= -= *= /=

NOTE: The table above does not contain Par T types.

Short-Circuiting Boolean Operators

The && and || operators are short-circuiting in the style of C and Java. This means that when evaluating Expr1 && Expr2, if Expr1 is found to return false, Expr2 will not be evaluated, since there is no way in which the entire expression can become true. Similarly, Expr1 || Expr2 will not evaluate Expr2 if Expr1 evaluates to true since there is no way the entire expression can become false.

Local variables and Fields

Examples:

val constant = 3.14
var indiana_pi : float = 3

let 
  x = 100    -- x and y implicitly val
  y = x * 2
in
  new Point(x, y)
end

Local variables are declared using the val or var keywords, indicating whether the variable is constant (aka final) or not. This is the generalised syntax for local variables:

modifier Id [: Type] = Expr

where modifier is either val or var.

In addition, a let expression allows declaring many local constants for one block.

Shadowing of local variable names is not allowed, however, a local variable name can have the same name as a local field, since fields are always accessed through this.

Types of variable declarations are inferred, but types may be explicitly stated as exemplified above. Variables must always be initialised.

Fields

This is the generalised syntax for fields (instance variables):

modifier Id [: Type] = Expr

where modifier is either val, var, once or spec. The once and spec modifiers are special, and are used in lock-free programming to specify that a field is a possible point of contention in a data structure, and therefore must be written using special rules, see section Type System for more details

Calling Methods and Sending Messages

Method calls and message sends in Encore always have a receiver. Logically, method calls are synchronous whereas message sends are asynchronous. Syntactically, method calls have the form:

Target . method_name( Arguments )

where Target can be an arbitrary expression and Arguments is a comma-separated list of zero or more expressions. Note that the receiver must always be explicit. Simply writing

method_name( Arguments )

is an error, unless there is a function in the current scope with that name. Functions can be declared at the top-level of a module, inside a class, or introduced locally for a method using a where-clause.

Message sends have the same syntax as method calls, except they use ! instead of . as the operator:

Target ! message_name( Payload )

where Payload is a comma-separated list of zero or more expressions. Message sends are only allowed if the receiver is an actor.

Note that there are strict rules on payloads with respect to how objects may be shared across actors. This is covered in the section about actors.

Finally, as a convenience, there is a ?. operator for performing method calls on targets of maybe type.

Target ?. method_name( Arguments )

which is the same as

match Target with
  case Just x  => x.method_name( Arguments )
  case Nothing => pass
end

And an equivalent ?! operator for message sends.

Literals

Tuples

Example:

val person = ("Bob", 42)   -- create tuple with 2 elements
var name = person.0        -- extract first element

Tuples are immutable collections of size 2 or higher of values of any type. The syntax for tuples is ( Exprs ) where Exprs is a comma-separated list of expressions. Tuples have value semantics, but may contain values with reference semantics. The following code snippet illustrates that while a and b denote two different copies of the tuple (42, new Person("Bob")), identity comparison of the second element of both tuples holds. It also illustrates the creation of tuples and extracting of elements from tuples.

let
  a = (42, new Person("Bob"))
  b = a
in
  a.0 == b.0  -- returns true

Statically, a tuple's complete type, including its size, must always be known. Accesses to non-existing elements of tuples will be caught at compile-time. It is not possible to loop over the elements in a tuple.

Arrays

Multi-dimensional array support is not available in the main Encore branch. Arrays are created with new [t](size)

Example of creation:

-- create 1-d array with 100 elements of type t
val arr1 = Array1[t](10 * 10) 

-- create 2-d array with 10x10 elements of type t
val arr2 = Array2[t](10, 10)

-- create 1-d array with 10 elements of type Array1[t]
val arr3 = Array1[Array1[t]](10) 

Array literals:

-- create array of type Array1[int]
val arr4 = [1, 2, 3, 4] 

Example of manipulation:

-- update 5th element with contents of variable inkal
arr1(4) = inkal

-- update foo and bar with element 3x4 of array
foo = arr2(2, 3)
bar = arr3(2, 3)

Arrays have a fixed size which can not be changed after creation. The standard library types Array1 and Array2 implement one-dimensional and two-dimensional arrays, respectively. The difference between Array1[Array1[t]] and Array2[t] is that in the second case, all columns have an equal amount of rows, meaning it can be efficiently projected onto a single one-dimensional array.

Array index out of bounds exceptions will be caught at run-time.

Arrays implement the standard iterator interface -- thus, it is possible to iterate over arrays in e.g. a for loop.

Strings and String Representations

Example:

"I am a string."

Strings are immutable, have reference semantics but not identity. Thus, it is not possible to compare two strings for identity. For this, atoms should be used (Atoms have not yet been implemented). C-family escape characters are supported, e.g. \n, \t etc.

The Encore string type is String. The operations on strings are described in the String class in the standard bundle. Because of the immutability of strings, all operations on strings return new string objects. Encore does not offer any binary operators on string, i.e., "4" + "2" is not well-typed. Instead, write "4".concatenate("2").

Encore provides global print() and println() functions that work similar to C-style printf() meaning it has a variable arity. The first argument is a format string, using the special symbol {} for the places where the string representation of a value should appear. Example:

print("{} + {} = {}\n", x, y, string_from_int(x + y))

When x and y are integers valued 5 and 7 respectively, this will print

5 + 7 = 12

Note that the call to string_from_int() is not necessary. Converting values into their corresponding string representations is automatic for the following types:

  1. primitives
  2. tuples (when all its element types have a string representation)
  3. maybe types (when its element type has a string representation)
  4. futures (when its element type has a string representation)

All other objects must implement the Repr trait, which requires a user-defined str_repr() method that computes and returns the string representation.

Note that using print() is only advisable for debugging and toy programs. Instead, the libraries for asynchronous I/O should be used.

Ranges

Example:

new Range(1, 10)                  -- the range [1..10]
new Range(1, 10).by(2)            -- the range [1,3,5,7,9]
new Range(1, 10).before()         -- the range [1..10)
new Range(1, 10).after()          -- the range (1..10]
new Range(1, 10).after().before() -- the range (1..10)
new Range(1, 10).to_a()           -- the array [1,2,3,4,5,6,7,8,9,10]

A Range object is an efficient representation of an ordered collection of integers. Ranges implement a standard iterator. Their key use case is to be iterated over.

Ranges can be used to create arrays as demonstrated above.

Note: Ranges have not yet been implemented.

Object Creation

Example:

-- create a new instance of class person and invoke its constructor
-- with two arguments, name and age
new Person(name, age)

-- create a new instance of class Symbol, which does not have a constructor
new Symbol()

Object creation takes the shape of a class name called as a function, similar to Python. This calls the constructor (if any) for the class synchronously for passive objects, and asynchronously for actors.

In the latter case, it is not possible to observe that a constructor has finished by waiting on its return value. But observe that the contructor message is guaranteed to be the first message received by an actor.

Embedding C Code

Examples:

EMBED(int)
  int64_t value = 4711;
  value;
END

fun perror(s:String) : unit
  let cstring = s.data in
    EMBED(unit)
      fprintf(stderr, "Error: %s\n", cstring);
    END
  end
end

class String
  data : EMBED char * END
end

Encore supports embedding of arbitrary C expressions. For now, Encore compiles to C code, and names of fields and local variables are available in each name space, using a name mangling scheme to protect clashes between generated names in the C code and names in Encore programs.

For plenty of examples on how to use EMBED, see String.enc in modules/standard.

Return and Forward

Examples:

return 17           -- returns 17
return              -- returns ()
forward(this.foo()) -- fulfils the current future with this.foo()

The return keyword ends the current method or function and returns a value. If no value is specified, unit is returned.

The keyword is optional in the sense that the last expression to be evaluated by a method or function is its return value, even without an explicit return.

The forward keyword also ends the current method or function. If forward is used in a place where there is no current future, it works just like return which blocks until there is a value to return. Otherwise, a forwarded expression will be used to fulfil the current future. Consider the following scenario:

active class Link
  value:int
  next:Maybe[Link]
  def sum(aggregate:int) : int
    match this.next with
      Just(next) => forward(next.sum(aggregate + this.value))
      _          => forward(aggregate + this.value)
    end
  end
end

first ! sum(0) 

This is defining a recursive method for calculating the sum of integers in a linked list. The forward keyword lets us chain the future through the entire recursive call to be fulfilled by the last link in the list.

If sum() is called completely synchronously, i.e., there is no future, forward simply works as a return.

Unit

The type unit is the unit type — the equivalent of void in the C family of languages. A key difference is that unit is actually a value — which is important in the case of futures. The following method, when called asynchronously as part of an actor, returns a Future[unit] which the caller can query for its fulfilment. This allows the caller to check whether the print has completed or not.

The literal for unit is ().

def print(s:String) : unit
  ...
end

Shadowing

Fields are accessed through the this. prefix — similar for method calls. This avoids name clashes with local variables and global functions. Parameters are implicitly val meaning they cannot be reassigned. Explicitly declaring a parameter var is permitted.

Encore does not allow shadowing of local names, so the following is not allowed:

for i <- Range(1, 10) do
  for i <- Range(0, i) do  -- the first i will cause an error
    print(i)
  end
end

Note: the above is currently permitted by the compiler (Issue #713)

However, the following is allowed:

local class Cell[t]
  var value : t
  def set(value : t) : unit
    this.value = value
  end
end

Because of the val-ness of parameters, the following will be a compile-time error:

local class Cell
  var value : int
  def set(value : int) : unit
    value = 42
  end
end

Local variables and local functions are allowed to shadow global names. For example, if importing a function foo() : bool into the top-level, the following code snippets are still correct, and shadow the top-level foo with foo's of their own:

fun bar() : int
  foo(42) 
where
  fun foo(x : int) : int
    x
  end
end

fun baz() : int
  val foo = fun (x : int) => x 
  foo(42)
end

results matching ""

    No results matching ""