2021-03-01
Ante is a low-level impure functional programming language. It is low-level in the sense that types are not boxed by default and programmers can still delve down to optimize allocation/representation of memory if desired. A central goal of ante however, is to not force this upon users and provide sane defaults where possible. Compared to other low-level languages, ante is safe like rust but tries to be easier in general, for example by allowing shared mutability by default and avoiding the need for lifetime annotations.
Literals
Integers
Integer literals can be of any signed integer type (I8, I16,
I32, I64, Isz) or any unsigned integer type (U8, U16, U32, U64, Usz) but by
default integer literals are polymorphic. Integers come in
different sizes given by the number in their type that specifies
how many bits they take up. Isz
and Usz
are the signed and unsigned
integer types respectively of the same size as a pointer.
// Integer Literals are polymorphic, so if we don't specify their
// type via a suffix then we can use them with any other integer type.
100 + 1usz == 101
// Type error: operands of '+' must be of the same type
3i8 + 3u8
// Large numbers can use _ to separate digits
1_000_000
54_000_000_u64
Floats
Floats in ante conform to the IEEE 754 standard for floating-point arithmetic
and come in two varieties: F32
and F64
for 32-bit floats and 64-bit
floats respectively. Floats have a similar syntax to integers, but with
a .
separating the decimal digits.
3.0 + 4.5 / 1.5
// 32-bit floats can be created with the F32 suffix
3.0f32
Like integers, floating-point literals are also polymorphic.
If no type is specified they will default to F64
.
Booleans
Ante also has boolean literals which are of the Bool
type and can be either
true
or false
.
Characters
Characters in ante are a single, 32-bit Unicode scalar value.
Note that since String
s are UTF-8, multiple characters are packed into strings and if
the string contains only ASCII characters, its size in memory is 1 byte per character in the string.
print 'H'
print 'i'
Character escapes can also be used to represent characters not on a traditional keyboard:
'\n' // newline
'\r' // carriage-return
'\t' // tab
'\0' // null character
'\xFFFF' // an arbitrary Unicode scalar value given by the
// number 'FFFF' in hex
Strings
Strings in ante are utf-8 by default and are represented via a pointer and
length pair. For efficient sub-string operations, strings are not null-terminated.
If desired, C-style null-terminated strings can be obtained by calling the c_string
function.
print "Hello, World!"
// The String type is equivalent to the following struct:
type String =
data: Ptr Char
len: Usz
// C-interop often requires using the `c_string` function:
c_string (s: String) : CString = ...
extern puts : CString -> I32
puts (c_string "Hello, C!")
String Interpolation
Ante supports string interpolation via $
or ${...}
within a string. Within
the brackets, arbitrary expressions will be converted to strings and spliced
at that position in the string as a whole.
name = "Ante"
print "Hello, $name!"
//=> Hello, Ante!
offset = 4
print "The ${offset}th number after 3 is ${3 + offset}"
//=> The 4th number after 3 is 7
Variables
Variables are immutable by default and can be created via =
.
Also note that ante is strongly, statically typed yet we do not
need to specify the types of variables.
This is because ante has global type inference.
n = 3 * 4
name = "Alice"
// We can optionally specify a variable's type with `:`
reading_about_variables: Bool = true
Mutability
A variable can be made mutable by adding the mut
keyword when defining the variable:
// Mutable variables can be created with `mut`:
pet_name = mut "Ember"
print pet_name //=> Ember
// And can be mutated with `:=`
pet_name := "Cinder"
print pet_name //=> Cinder
Here’s another example showing a function that can mutate the passed in parameter:
count_evens array counter =
for array fn elem ->
if even elem then
counter += 1
counter = mut 0
count_evens [4, 5, 6] (&mut counter)
print counter //=> 2
If you have a mutable struct, you can also transfer this mutability to its
fields to mutate them directly. This can be done via the .&
operator to retrieve
a reference to a field:
my_pair = mut (1, 2)
my_pair.&first := 3
field_ref = my_pair.&second
field_ref := 4
print my_pair //=> 3, 4
// The following two lines give an error because we never declared `bad` to be mutable
bad = 1, 2
bad.&first := 3
Functions
Functions in ante are also defined via =
and are just syntactic
sugar for assigning a lambda for a variable. That is, foo1
and foo2
below are exactly equivalent except for their name.
foo1 a b =
print (a + b)
foo2 = fn a b ->
print (a + b)
Since ante is impure, combining effects can trivially be done via sequencing
two expressions which can be done by separating the expressions with a newline.
;
can also be used to sequence two expressions on the same line if needed.
// We can specify parameter types and the
// function return type via `:`
foo1 (a: U32) (b: U32) : Unit =
print a
print b
print (a + b)
Type Inference
Types almost never need to be manually specified due to the global type inference algorithm which is based on an extended version of Hindley-Milner with let-polymorphism, multi-parameter typeclasses (traits) and a limited form of functional dependencies.
This means ante can infer variable types, parameter types, function return types, and even infer which traits are needed in generic function signatures.
// Something is iterable if we can call `next` on it and
// get either Some element and the rest of the iterator or
// None and we finish iterating
trait Iterator it -> elem =
next: it -> Maybe (it, elem)
first_equals_two it =
match next it
| Some (2, _) -> true
| _ -> false
We never gave any type for first_equals_two
yet ante infers its type for us as
a -> Bool given Iterator a I32
- that is a function that returns a Bool
and takes
a generic parameter of type a
which must be an iterator producing elements of type I32
.
Significant Whitespace
Ante uses significant whitespace to help declutter source code and prevent bugs (such as Apple’s infamous goto fail bug). In general, ante tries to be simple with its whitespace semantics: if the next line is indented 2+ or more spaces from the previous non-commented line then an indent token is issued. If the lines differ by only 1 space then it is considered to be a mistake and an error is issued. There is no notion of indenting to or past an exact column like in Haskell’s offside rule.
Secondly, anytime an unindent to a previous column occurs, an unindent token is issued.
Indents and unindents follow a stack discipline, each unindent is a return to a previous
indentation level rather than to a new one. So the following program is invalid since the
else
was not unindented to the previous indent level.
if true then
print "foo"
else
print "bar"
Thirdly, when a newline between two lines of code at the same indent level occurs, a newline token is issued to separate the two expressions.
From these three rules we get Indent
, Unindent
, and Newline
tokens which the
parser can parse just as if they were {
, }
, and ;
tokens in the source program.
Line Continuations
With the above 3 rules the syntax is transformed into one with the equivalent of
explicit {
, }
, and ;
tokens. ~95% of programs just work now and ante could stop
there if it wanted to, and for a long time it did. A problem arises however with the
third rule of using newlines as ;
tokens. Sometimes, users may wish to continue an
expression onto multiple lines. This is a problem with parallels of automatic semicolon
insertion in other languages. The main difference being ante also has significant whitespace
to help it clue into this problem.
Ante’s original solution was more of a band-aid. It followed the python example of continuing
lines with \
at the end of a line which would tell the lexer not to issue a newline token.
There was also a similar rule for elliding newlines while we were inside ()
or []
pairs.
This solution was quite annoying in practice however. Ante is much more expression-oriented
than python and particularly when working the pipeline operators we would end up with a long
chain of lines ending with \
:
// x |> f = f x in older versions of ante
data \
|> map (_ + 2) \
|> filter (_ > 5) \
|> max
a = 3 + 2 * \
5 + 4 \
* data
what_a_long_function_name \
function_arg_with_long_name1 \
function_arg_with_long_name2 \
(a + 1)
In practice this ugly bit of syntax tended to discourage the otherwise good practice of splitting long lines onto multiple lines. Ante thus needed a better solution. The goals of the new solution were to be unambiguous, ergnomic, match a developer’s mental model of their program, and to issue error messages if needed instead of silently inferring the wrong thing.
This was initially difficult to solve but eventually ante landed on a solution based upon the observance that when programmers want to continue lines, they almost always use indentation to do so. Thus, to continue an expression in ante, the continuation must just be indented and you can continue to use that same indentation level if you need multiple lines.
This is done by tracking when an indent is expected in the lexer and
only issuing the indent (and following unindent) if so. Ante’s grammar is designed in
such a way that the lexer only needs to look at the previous token to find out if it expects
an indent afterward or not. These tokens that may have indentation after them are if
, then
,
else
, while
, for
, do
, match
, with
, along with =
, ->
, and the assignment operators.
Semantically, these are the tokens needed for if-expressions, loops, match expressions, definitions,
and assignments. This is the list of tokens the programmer would normally need an indent for a block
of code after - it is analogous to knowing when you need to type {
in curly-braced languages.
Note that an important part of this being implemented entirely in the lexer is that operator precedence after continued lines just works (it is harder than it may seem if continuation is a parser rule).
When the lexer sees an indent that without one of these tokens preceeding it, it does not issue
an indent token and also does not issue newline tokens for any expression at that same level of
ignored indentation. Note that this is tracked on a per-block basis, so if we wanted we could
also still use constructs like if
inside these blocks with ignored indentation - since we’d
be indenting to a new level and that new level would have the indent tokens issued as normal.
With this rule, we can continue any line just by indenting it. Here’s the previous example again with the new rule:
// |> is actually the exception to the "programmers typically indent
// continuation lines" rule. Naively trying the following lines however
// shows us another nice property of the rules above: we get an error
// if we mess up.
data
|> map (_ + 2) // error here, |> has no lhs! We must continue the line by indenting it
|> filter (_ > 5)
|> max
// Here's the fixed, indented version
map data (_ + 2)
|> filter (_ > 5)
|> max
// The other examples work as expected
a = 3 + 2 *
5 + 4
* data
what_a_long_function_name
function_arg_with_long_name1
function_arg_with_long_name2
(a + 1)
Operators
Operators in ante are essentially infix functions, they’re even
defined in trait
s the same way many functions are. Here are some
standard one’s you’re probably used to:
// The standard set of numeric ops with operator precedence as you'd expect
trait Add a with
(+): a -> a -> a
trait Sub a with
(-): a -> a -> a
trait Mul a with
(*): a -> a -> a
trait Div a with
(/): a -> a -> a
// % is modulus, not remainder. So -3 % 5 == 2
trait Mod a with
(%): a -> a -> a
// Comparison operators are implemented in terms of the `Cmp` trait
trait Cmp a with
compare: a -> a -> Ordering
type Ordering = | Lesser | Equal | Greater
(<) a b = compare a b == Lesser
(>) a b = compare a b == Greater
(<=) a b = compare a b != Greater
(>=) a b = compare a b != Lesser
There are also various compound assignment operators for convenience
when mutating data (+=
, -=
, and friends).
Logical operators have their names spelled out fully:
if true and false then print "foo"
if false or true then print "bar"
if not false then print "baz"
and
binds tighter than or
, so the following prints true
:
if false and true or true and true then
print true
// parsed as:
if (false and true) or (true and true) then
print true
Since fiddling with individual bits is not a common operation, there
are no bitwise operators. Instead there are functions in the
Bits
module for dealing with bits.
Subscript Operator
The subscript operator for retrieving elements out of a collection
is spelled a#i
in ante. The more common a[i]
syntax would be
ambiguous with a function call to a function a
taking a single
argument that is a collection with 1 element i
.
#
has a higher precedence than all other binary operators. The following
example shows how to average the first and second elements in an array for
example:
average_first_two array =
(array#0 + array#1) / 2
No Dereference Operator
Dereferencing pointers in ante is somewhat uncommon, so ante provides no pointer
dereference operator. Instead, you can use the deref
function in the standard library.
If you need to access a struct field, struct.field
in will work as expected regardless
of whether struct
is a struct or a pointer to a struct.
Pipeline Operators
The pipeline operators .
and $
are sugar for function application and
serve to pipe the results from one function to the input of another.
x.f y
is equivalent to f x y
and functions similar to method syntax
x.f(y)
in object-oriented languages. It is left-associative so x .f y .g z
desugars to g (f x y) z
. This operator is particularly useful for chaining
iterator functions:
// Parse a csv's data into a matrix of integers
parse_csv (text: String) : Vec (Vec I32) =
lines text
.skip 1 // Skip the column labels line
.split ","
.map parse
.collect
In contrast to .
, $
is right associative and applies a function on its
left to an argument on its right. Where .
is used mostly to spread operations across multiple lines, $
is often
used for getting rid of parenthesis on one line.
print (sqrt (3 + 1))
// Could also be written as:
print $ sqrt $ 3 + 1
Pair Operator
Ante does not have tuples, instead it provides a right-associative pair
operator ,
to construct a value of the pair type. We can use it like
1, 2, 3
to construct a value of type I32, I32, I32
which in turn is just sugar for Pair I32 (Pair I32 I32)
.
Compared to tuples, pairs are:
- Simpler: They do not need to be built into the compiler or its type system. Instead, they can be defined as a normal struct type in the standard library:
type Pair a b = first: a, second: b
- Easier to work with: Because pairs are just normal data types, we get
all the capabilities of normal types for free. For example, we know all pairs
will have exactly two fields. This makes creating
impl
s for them much easier. Lets compare the task of converting a tuple to a string with doing the same for pairs. With tuples we must create a different impl for every possible tuple size. with pairs on the other hand the simple implementation works for all sizes:
cast_pair_string = impl Cast (Pair a b) String via
cast (a, b) = "$a, $b"
-
Just as efficient: both pairs and tuples have roughly the same representation in memory (the exact same if you discount allignment differences).
-
More composable: having the right-associative
,
operator means we can easily combine pairs or add an element if needed. For example, if we had a functionunzip : List (a, b) -> List a, List b
, we could useunzip
even on aList (a, b, c)
to extract aList a, List (b, c)
for us. This means if we wanted, we may implementunzip3
usingunzip
(though this would likely be inefficient):
// given we have unzip : List (a, b) -> List a, List b
unzip3 (list: List (a, b, c)) : List a, List b, List c =
as, bcs = unzip list
bs, cs = unzip bcs
as, bs, cs
-
Another place this shows up in is when deconstructing pair values. Lets say we wanted to define a function
first
for getting the first element of any tuple of length >= 2 (remember, we are using nested pairs, so there are no 1-tuples!), andthird
for getting the third element of any tuple of length >= 3. We can define the functions:first (a, _) = a third (_, _, c) = c first ("one", 2.0, 3, 4) == "one" third (1, "two", 3.0, "4", 5.5) == (3.0, "4", 5.5) // so the parser will parse `third` and the call as follows: // // third (_, (_, c)) = c // third (1, ("two", (3.0, ("4", 5.5)))) == (3.0, ("4", 5.5))
Note that to work with nested pairs of any length >= 3 instead of >= 4, our implementation of
third
will really return a nested pair of(third, rest...)
for pairs of length > 3. This is usually what we want when working with generic code (since it also works with nested pairs of exactly length 3 and enables the nice syntax in the next section).
One last minor advantage of pairs is that we can use the fact that ,
is
right-associative to avoid some extra parenthesis compared to if we had tuples.
A common example is when enumerating a tuple, most languages would need two sets
of parenthesis but in ante since tuples are just nested pairs you can just add another ,
:
pairs = [(1, 2), (3, 4)]
// Other languages require deconstructing with nested parenthesis:
for (enumerate pairs) fn (i, (one, two)) ->
print "Iteration $i: sum = ${one + two}"
// But since `,` is just a normal operator,
// the following version is equally valid
for (enumerate pairs) fn (i, one, two) ->
print "Iteration $i: sum = ${one + two}"
Finally, its necessary to mention that the earlier Cast
example printed nested
pairs as 1, 2, 3
where as the Show
instances in haskell printed tuples as (1, 2, 3)
.
If we wanted to surround our nested pairs with parenthesis we have to work a bit
harder by having a helper trait so we can specialize the impl for pairs:
trait ToStringHelper t with
to_string_helper (x: t) -> String = cast x
cast_pair_string = impl
Cast (Pair a b) String via
cast pair = "(${to_string_helper pair})"
// Specialize the impl for pairs so we can recurse on the rhs
ToStringHelper (Pair a b) via
to_string_helper (a, b) = "$a, ${to_string_helper b}"
And these two functions will cover all possible lengths of nested pairs.
Lambdas
Lambdas in ante have the following syntax:
fn arg1 arg2 ... argN -> body
. Additionally a function definition
foo a b c = body
is sugar for a variable assigned to
a lambda: foo = fn a b c -> body
. Lambdas can also capture part of
the variables in the scope they were declared. When they do this,
they are called closures:
augend = 2
data = 1..100
map data fn x -> x + augend
//=> 3, 4, 5, ..., 100, 101
Explicit Currying
While ante opts out of including implicit currying in favor of better
error messages, it does include an explicit version where arguments
of a function can be explicitly curried by placing _
where that argument
would normally go. For example, in the following example, f1
and f2
are
equivalent:
f1 = fn x -> x + 2
f2 = _ + 2
Compared to implicit currying, explicit currying lets us curry function arguments in whatever order we want:
add3 a b c = a + b + c
g1 = add3 _ 0 _
// g1 is equivalent to:
g2 = fn a c -> add3 a 0 c
Explicit currying only curries the innermost function, so using it with nested function calls will yield a type error unless the outermost function is expecting another function:
// Nesting _ like this gives a type error:
// add3 expects an integer argument but a function was given.
nested = add3 1 2 (_ + 3)
// To make nested a function, it needs to be rewritten as a lambda:
nested = fn x -> add3 1 2 (x + 3)
// Or a function definition
nested x = add3 1 2 (x + 3)
_
really shines when using higher order functions and iterators:
// Given a matrix of Vec (Vec I32), output a String formatted like a csv file
map matrix to_string
|> map (join _ ",") // join columns with commas
|> join "\n" // and join rows with newlines.
Control-Flow
Ante’s control flow keywords should be very familiar to any programmer used to expression-based languages.
If expressions expect a boolean condition (there are no falsey values) and conditionally evaluate and return the then branch if it is true, and the else branch otherwise. The else branch may also be omitted - in that case the whole expression returns the unit value. The if condition, then branch, or else branch can either be single expressions or an indented block expression.
three = if false then 2 else 3
if should_print () then
print three
Loops
Ante does not include traditional for or while loops since these constructs usually require mutability to be useful. Instead, ante favors recursive functions like map, fold_left, and for (which iterates over an iterable type, much like foreach loops in most languages):
// The type of for is:
// for : a -> (elem -> Unit) -> Unit given Iterator a elem
for (0..10) print // prints 0-9 inclusive
for (enumerate array) fn (index, elem) ->
// do something more complex...
print result
You may notice that there is no way to break or continue out of the for
function. Moreover if you need a more complex loop that a while loop may traditionally provide in other languages, there likely isn’t an already existing iterate function that would suit your need. Other functional languages usually use helper functions with recursion to address this problem:
sum numbers =
go numbers total =
match numbers
| Nil -> total
| Cons x xs -> go xs (total + x)
go numbers 0
This can be cumbersome when you just want a quick loop in the middle of a function though. It is for this reason that ante provides the loop and recur keywords which are sugar for an immediately invoked helper function. The following definition of sum is exactly equivalent to the previous:
sum numbers =
loop numbers (total = 0) ->
match numbers
| Nil -> total
| Cons x xs -> recur xs (total + x)
After the loop keyword comes a list of variables/patterns which are translated into the parameters of the helper function. If these variables are already defined like numbers is above, then the value of that variable is used for the initial invocation of the helper function. Otherwise, if the variable/pattern isn’t already in scope then it must be supplied an initial value via =, as is the case with total in the above example. The body of the loop becomes the body of the recursive function, with recur standing in for the name of the function.
Since loop/recur uses recursion internally it is even more general than loops, and can be used to translate otherwise complex while loops into ante. Take for example this while loop which builds up a list of the number’s digits, mutating the number as it goes:
list<unsigned int> get_digits(unsigned int x) {
list<unsigned int> ret;
while (x != 0) {
unsigned int last_digit = x % 10;
ret.push_front(last_digit);
x /= 10;
}
return ret;
}
This can be translated into ante as the following loop:
get_digits (x: U32) : List U32 =
loop x (digits = Nil) ->
if x == 0 then return digits
last_digit = x % 10
recur (x / 10) (Cons last_digit digits)
Pattern Matching
Pattern matching on algebraic data types can be done with a match
expression:
match foo
| Some bar -> print bar
| None -> ()
Since match
is an expression, each branch must match type. The value
of the matched branch is evaluated and becomes the result of the whole
match expression. The compiler will also warn us if we forget a case
or include one that is redundant and will never be matched.
// Error: Missing case: Some None
match foo
| Some (Some bar) -> ...
| None -> ...
In addition to the usual suspects (tagged-unions, structs, pairs), we can also include literals and guards in our patterns and it will work as we expect:
type IntOrString =
| Int I32
| String String
match Int 7
| Int 3 -> print "Found 3!"
| Int n if n < 10 -> print "Found a small Int!"
| String "hello" -> print "Found a greeting!"
| value -> print "Found something else: $value"
Note that there are a few subtle design decisions:
-
All type constructors must be capitalized in ante, so when we see a lower-case variable in a pattern match we know we will always create a new variable rather than match on some nullary constructor (like
None
). -
Each pattern is prefixed with
|
rather than being indented like in some other languages. Doing it this way means if we indent the body as well, we only need to indent once past thematch
instead of twice which saves us valuable horizontal space.
Types
Ante is a strongly, statically typed language with global type inference.
Types are used to restrict the set of values as best as possible such that
only valid values are representable. For example, since references in ante
cannot be null we can instead represent possibly null references with
Maybe &t
which makes it explicit whether a function can accept or
return possibly null values.
Type Definitions
Both struct and tagged union types can be defined with the
type Name args = ...
construct where args
is the space-separated
list of type variables the type is generic over.
You can define struct types with commas separating each field or newlines if the type spans multiple lines:
type Person = name: String, age: U8
type Vec a =
data: Ptr [a]
len: Usz
capacity: Usz
Tagged unions can be defined with |
s separating each variant.
The |
before the first variant is mandatory. Ante currently has
no support for untagged C unions.
type Maybe t =
| Some t
| None
type Result t e =
| Ok t
| Err e
Type Annotations
Even with global type inference, there are still situations where
types need to be manually specified. For these cases, the x : t
type annotation syntax can be used. This is valid anywhere an expression
or irrefutable pattern is expected. It is often used in practice
for annotatiing parameter types and for deciding an unbounded generic
type - for example when parsing a value from a string then printing it.
Both operations are generic so we’ll need to specify what type we should
parse out of the string:
parse_and_print_int (s: String) : Unit =
x = parse s : I32
// alternatively we could do
// x: I32 = parse s
print x
Int Type
Ante has quite a few integer types so one question
that gets raised is what is the type of an integer literal?
If we randomly choose a type like I32
then when using all
other integer types we’d have to constaintly annotate our
operations with the type used which can be annoying. Imagine
a + 1u64
every few lines.
Instead, integer literals are given the polymorphic Int a
type:
3 : Int a // for some unknown 'a' which will later be resolved
// to one of I8, I16, ..., U8, U16, ... etc.
When we use an integer with no specific type, the integer literal keeps this generic type. This sometimes pops up in function signatures:
// This works with any integer type
add1 (x: Int a) : Int a =
x + 1
If we do use it with a specific type however, then just like with
normal generics, the generic type variable is constrained to be
that concrete type (and the concrete type must satisfy the Int
constraint - ie it must be a primitive integer or we get a compile-time error).
// Fine, we're still generic over a
foo () : Int a =
0
x: I32 = 1 // also fine, we constrained 1 : I32 now
y = 2u16 // still fine, now we're specifying the type
// of the integer literal directly
Float Type
Like the Int type, there is also a polymorphic Float a
type:
3.0 // has the type `Float a` until it is later used in an expression
// which forces it to be either a F32 or F64.
Values of the Float a
type will default to F64
if they are never constrained:
print 5.0 // Since we can print any float type, we arbitrarily default 5.0 to an F64
// making this snippet equivalent to `print (5.0 : F64)`
Anonymous Struct Types
If we have multiple types with the same field in scope:
type A = foo: I32
type B = foo: String
Then we are left with the problem of deciding what the type
of an x.foo
expression should be:
// Does this work?
// - If so what type do we get?
// - If not, what is the error?
get_foo x = x.foo
Ante solves this with anonymous struct types which are row-polymorphic.
In other words, they are polymorphic over what fields are in the struct,
which allows any struct type to be used so long as it has the required
fields. For example, { x: I32 }
would be the type of any struct that
has a field x
of type I32
.
Using this, we can type get_foo
as a function which takes
any struct that has a field named foo
of type b
:
get_foo (x: { foo: b }) : b =
x.foo
As a more complex example, here’s a function that can print anything with debug
field
that itself is printable and a prefix
field that must be a string:
// Type inferred as:
// { prefix: String, debug: a } -> Unit given Print a
print_debug x =
prefix = x.prefix ++ ": "
print prefix
print x.debug
Move Semantics
Similar to Rust, values in Ante are affine by default (may be used 0 or 1 time
before they are dropped and deallocated). These values are called
“owned” values, in contrast with references which are “borrowed” and may be
used any amount of times. The only exception to owned values being used
at most once are types which implement the Copy
trait. This trait signals
the type may be trivially copied each time it is referred to:
s: String = "my string"
x: I32 = 42
// We've moved `s` into `foo`, trying to access it afterwards would give a compile-time error
foo s x
// Since I32 is a primitive type, we can still refer to `x` after it was passed into `foo`
bar x
Borrowing
Like Rust, if a value needs to be used multiple times, we can borrow references to it so that we can refer to the value as many times as we need.
s = "my string"
// References can be used as many times as needed
baz &s
baz &s
Mutable references can be borrowed from mutable values using &mut
:
s = mut "my string"
// This function call may modify our string
qux (&mut s)
print s //=> "???"
Restrictions on References
Compared to references in Rust, Ante’s references are also bound by lifetimes, although this lifetime cannot be explicitly referred to by a lifetime variable. This is a tradeoff which simplifies the language somewhat but means that the expressivity of references is also more restricted compared to Rust. Generally, references are meant to mostly be used as a parameter-passing method rather than stored deeply within types.
When used in a parameter position, each variable of a function is assumed to have a possibly different lifetime:
concat_foo (foo: &Foo) : String =
foo.first ++ foo.second
Taking a reference prevents moving the underlying value before the reference is dropped:
bad (foo: Foo) =
// Error: Cannot move `foo` while the borrowed reference `&foo` is still alive
bar &foo foo
Returning References
The next most common use of references is writing functions to lend references
to avoid cloning the inner value unnecessarily. Other languages with
references approach this differently. In Hylo for example, references may only
be returned in special “subscript” functions, which are in turn only able to be
called within another function call, e.g. foo(my_subscript(bar, baz), qux)
.
Ante recognizes this common case and tries to be more flexible in comparison. Functions returning references are allowed:
get_ref (foo: &Foo) (_bar: &Bar) : &Baz =
foo.&baz
If a reference is returned from a function, none of the referenced inputs can be moved until the returned reference is dropped.
example2 (foo: Foo) (bar: Bar) =
ref = get_ref &foo &bar
// Error: Cannot move `foo` while `ref` is still alive
drop foo
print ref
Since Ante has no lifetime annotations it does not know from which
variable the return result is borrowed from. To solve this, Ante conservatively
ties the lifetime of the return result to the lifetime of all of the reference
parameters to the function. In addition to not being able to move any of these
parameters afterward, this can also have implications for whether each reference
must be shared
or not (see shared mutability). Consider the following code:
example2 (foo: Foo) (bar: &mut Bar) =
ref = get_ref &foo bar
print bar
print ref
()
We get an error that bar
must be a shared
reference because the compiler sees
ref
as a reference potentially aliased to bar
, which would make it mutably
aliased since bar: &mut Bar
. This is despite us being able to peek into get_ref
to see it actually only ever returns a reference inside of foo
.
Lifetime-bound Types
Types which capture references must themselves be bound by the same lifetime as those
references. This is indicated using the ref
keyword. For example, if we had a struct:
type Context =
global_context: &GlobalContext
We would have to refer to this struct elsewhere as ref Context
, which will restrict
the lifetime of the Context
to that of the reference it contains internally. This is
similar to using a lifetime variable in Rust: Context<'a>
.
If a type captures multiple references, the lifetime used by Context
is the shortest
of all the captured lifetimes.
ref
is also often seen when using closures which capture references:
x = Bar
foo () = x
// Type of foo is: ref Fn Unit => &Bar
Shared Mutability
Another difference from Rust is that Ante allows shared (aliasable) mutability.
In addition to whether a reference is mut
able or not, a reference can also
be tagged whether it is own
ed or shared
. Additionally, if there is a mutable
reference borrwed from a value with at least 1 other borrowed reference to the same
value, all references are inferred to be mutably shared
.
The own
and shared
tags are used to prevent operations that would be unsafe
on a shared mutable reference. A common theme of these operations is that they hand
out references inside of a type with an unstable shape. For example, handing out
a reference to a Vec
element would be unsafe in a shared context since the Vec
may be reallocated by another reference. To prevent this, Vec.get
requires
an owned reference:
// Raises Fail if the index is out of bounds
get (v: &own Vec t) (index: Usz) : &own t can Fail
Other Vec functions like push
or pop
would still be safe to call on shared
references to Vecs since they do not hand out references to elements. If we did
need a Vec element when all we have is a &shared Vec t
, we can still retrieve
an element through Vec.get_cloned
:
// Raises Fail if the index is out of bounds
get_cloned (v: &Vec t) (index: Usz) : &t can Fail given Clone t
As a result, if you know you’re going to be working with mutably shared Vecs
or other container types, you may want to wrap each element in a pointer type
to reduce the cost of cloning: Vec (Rc t)
.
Reference Polymorphism
If the own
and shared
modifiers are omitted from the reference type, the
reference is considered to be polymorphic over both. Since most code will
not care if a reference is shared or not, this gives us some much needed
polymorphism while reducing notational burden. Vec.get_cloned
above
is one example of a function taking references polymorphic in whether they are owned or shared.
Note that a polymorphic reference will have the capabilities of the lowest
common denominator - a shared
reference. If an owned value is needed a
type error will be issued signalling the function will need to require an owned
reference instead.
Internal Mutability
Since mutating through an immutably borrowed reference &t
is otherwise impossible,
Ante provides several types for “internal mutability.” RefCell t
will be
a familiar sight to those used to Rust, but using this type entails runtime
checking to uphold the properties of an owned reference (either a mutable reference
can be made or multiple immutable references, but never both at once).
Since Ante natively supports shared references, it is also possibly to obtain a
shared reference directly through a shared pointer type like an Rc t
:
as_mut (rc: &own mut Rc t) : &shared mut t = ...
Note that like most pointer types, we still need an owned reference of the
pointer itself to obtain a reference to the inside. This is because otherwise,
the value would be able to drop out from under us if another shared reference
to the pointer swapped out the Rc struct itself for another. As a result, mutating
an Rc t
often requires cloning the outer Rc to ensure it isn’t dropped while the
inner references are lent out.
If we wanted to create a shared mutable container where each element is also
mutably shared, we could use a Vec (Rc t)
with this technique. Note that the
Vec
itself doesn’t need to be boxed since we can hand out &shared mut
references
from owned values already. If want to store the same Vec
reference in other data types
then we would need an Rc
or other shared wrapper around the Vec
.
This is essentially how many higher level languages make shared mutability work: by boxing each value. When we employ this strategy in Ante however, we don’t even need to box every value. Just boxing container elements and union data is often sufficient.
If an owned reference &own t
or &own mut t
is ever required however,
a different type such as RefCell t
would still need to be used to provide
the runtime tracking required to enforce this constraint.
Traits
While unrestricted generic functions are useful, often we don’t want to abstract over “forall t.” but rather abstract over all types that have certain operations available on them - like adding. In ante, this is done via traits. You can define a trait as follows:
trait Stringify t with
stringify: t -> String
Here we say stringify
is a function that takes a value of type t
and returns a
String
. With this, we can write another function that abstracts over all t
’s that
can be converted to strings:
stringify_print (x: t) : Unit given Stringify t =
print (stringify x)
Just like types and algebraic effects, we can leave out all our traits
in the given
clauses and they can still be inferred.
Traits can also define relations over multiple types. For example,
we may want to be more general than the Stringify
cast above -
that is we may want to have a trait that can cast to any result
type. To do this we can have a trait that constrains two generic types
such that there must be a cast function that can cast from the
first to the second:
trait Cast a b with
cast: a -> b
// We can cast to a String using
cast 3 : String
Impls
To use functions like stringify
or stringify_print
above,
we’ll have to impl
ement the trait for the types we want
to use it with. This can be done with impl
blocks:
stringify_bool = impl Stringify Bool via
stringify b =
if b then "true"
else "false"
Then, when we call a function like print_to_string
which
requires Stringify t
we can pass in a Bool
and the
compiler will automatically find the stringify_bool
impl
in scope and use that:
print_to_string true //=> outputs true
Named Impls
In contrast to other languages with traits or typeclasses, all impls are named in ante. This enables impls to be imported or hidden from scope in the same manner as any other construct: by name.
import Foo.hash_bar, std_impls hiding eq_foo
This also enables the ability to specify which impl to use via the via
keyword if there are ever multiple conflicting impls in scope.
empty = impl Stringify a via stringify _ = ""
print_to_string true via stringify_bool
Having multiple conflicting impls anywhere in a codebase is often an error in other languages, necessitating extensive use of the newtype pattern for otherwise unnecessary wrapper types and boilerplate. Ante does not enforce global coherence, instead opting for this name-based approach to disambiguate where necessary.
As a final example, note that names don’t have to be given to individual impls, we can also group impls together to reduce the notational burden of needing to name each individual impl. Note that becaues impls are disambiguated by name, we should avoid including multiple impls for the same trait in the same named group so that we can disambiguate between them if needed.
type Foo = first: Bar, second: Baz
foo_impls = impl
// We can impl multiple traits via the same method:
(Hash, Eq, Cmp) Foo via derive
// We can also forward to a subset of fields:
Print Foo via forward second
// And we can use manual impls
Combine Foo via
(++) a b = Foo a.first (qux a b)
Note that the mechanism to specify how to derive impls for custom traits is still experimental. See more on the ideas page.
Functional Dependencies
Some languages have a concept called associated types. These are often necessary to define some traits properly which have multiple type parameters but in which we want the type of some parameters to depend on others. Ante offers a limited form of functional dependencies for this which is equivalent to the associated types approach but with a nicer notation.
To illustrate the need for such a construct, lets say we wanted
to abstract over a vector’s get
function:
get (vector: Vec t) (index: Usz) : Maybe t = ...
We want to be able to use this with any container type, but how?
We can start out with a trait similar to our Cast
trait from
before:
trait Container c elem with
get: c -> Usz -> Maybe elem
At first glance this looks fine, but there’s a problem: we
can implement it with any combination of c
and elem
:
conflicting_impls = impl
Container (Vec I32) I32 via
get (v: Vec I32) (index: Usz) : Maybe I32 = ...
Container (Vec I32) String via
get (v: Vec I32) (index: Usz) : Maybe String = ...
But we already had an impl for Vec I32
, and defining a
way to get another element type from it makes no sense!
This is what associated types or ante’s restricted functional
dependencies solve. We can modify our Container trait to
specify that for any given type c
, there’s only 1 valid
elem
value. We can do that by adding an ->
:
trait Container c -> elem with
get: c -> Usz -> Maybe elem
vec_container = impl Container (Vec a) a via
get (v: Vec a) (i: Usz) : Maybe a = ...
This information is used during type inference
so if we have e.g. e = get (b: ByteString) 0
and there
is an impl for Container ByteString U8
in scope then we also
know that e : U8
.
Note that using a functional dependency in a trait signature
looks a lot like separating the return type from the arguments
of a function. This was intentional; a good
rule of thumb on when to use functional dependencies is if
the type in question only appears as a return type for the
function defined by the trait. For the Container
example
above, elem
is indeed only used in the return type of get
.
The most notable exception to this rule is the Cast
trait
defined earlier in which it is useful to have two impls
Cast I32 String
and Cast I32 F64
to cast integers
to strings and to floats respectively.
Coherence
Ante has no concept of global coherence for impls,
so it is perfectly valid to define overlapping impls or define
impls for types outside of the modules the type or trait were
declared in. If there are ever conflicts with multiple valid
impls being found, an error is given at the callsite and the
user will have to manually specify which to use either by only
importing one of these impls or with an explicit via
clause
at the callsite:
add = impl Combine I32 via (++) = (+)
mul = impl Combine I32 via (++) = (*)
print (2 ++ 3) // Error, multiple matching impls found! `add` and `mul` are both in scope
print (2 ++ 3) via add //=> 5
print (2 ++ 3) via mul //=> 6
Modules
Ante’s module system follows a simple, hierarchical structure based on the file system. Given the following file system:
.
├── foo.an
├── bar.an
├─┬ baz
│ ╰── nested.an
╰─┬ qux
├── nested.an
╰── qux.an
We get the corresponding module hierarchy:
Foo
Bar
Baz.Nested
Qux
Qux.Nested
Note how qux/qux.an
is considered a top-level module
because it matched the name of the folder it was in and
how baz/nested.an
is under Baz
’s namespace because it was
in the baz
folder. The two nested.an
files are also in
separate parent modules so there is no name conflict.
Imports
Importing symbols within a module into scope can be
done with an import
expression. Lets say
we were using the module hierarchy given in the
section above. In our Baz.Nested
file we
have:
nested_baz = 0
print_baz () =
print "baz"
get_baz () = "baz"
To use these definitions from Foo
we can import them:
import Baz.Nested.*
baz = get_baz ()
print "baz: $baz, nested_baz = $nested_baz"
We can also import only some symbols into scope:
// This syntax was chosen so that when adding new imports
// you only need to edit the end of the line rather than
// needing to add a '{' or similar token before print_baz as well.
import Baz.Nested.print_baz, get_baz
print (get_baz ())
print_baz ()
Now, lets say we are in module Bar and want to import Baz.Nested
but we already have a function named get_baz
in scope. We cannot
do import Baz.Nested.*
since there would be conflicting names in scope.
We could import only the functions we need but if we require many functions
from Baz.Nested
then this may take some time. For this reason, when using
wildcard imports, you can also specify definitions to exclude, via the hiding
clause:
import Baz.Nested.* hiding get_baz
// No error here
get_baz () = my_local_baz
Alternatively, you can also rename imports via as
:
import Baz.Nested.* hiding get_baz
// If we want to import everything from Baz.Nested while renaming
// some of the items we need to list them separately since `as`
// doesn't work with * imports:
import Baz.Nested.get_baz as other_get_baz
import Foo.a as foo_a, b, c, d as foo_d
// No error here
get_baz () = my_local_baz
Exports and Visibility
All names defined at global scope are by default visible to the entire
package but not to any external packages. Items can optionally be exported
across package boundaries by adding each name to an export
list at the top
of the module.
// fib and sum will be exported as library functions
export fib, sum
fib n = fib_helper n 0 1
fib_helper n a b =
if n <= 0 then a
else fib_helper (n - 1) b (a + b)
sum n = sum_helper n 0
sum_helper n acc =
if n <= 0 then acc
else sum_helper (n - 1) (acc + n)
Packages
In addition to modules, Ante has another unit of organization called packages. Each package is meant to correspond to a project where each dependency is also a package.
At the source code level, import paths are prefixed by a package name.
For example, in import Foo.Bar.Baz
, Foo
is the package to search for Bar.Baz
within. For new programs in an otherwise empty directory, the only packages
visible will be the current package, using the current directory’s name,
and the Std
package containing the standard library.
Packages are not required to all be in the same directory as the current project. Instead, the compiler searches for packages in a few directories by default:
.
for the current package/path/to/stdlib
for the stdlib./deps
for dependencies of the current package
These directories to search for packages in are called the “relative roots” and can be configured via compiler flags. The advantages of this design are as follows:
- An internet connection is never required to build a project
- This design is flexible and compatible with a package manager, although it does not require one
- Git repositories or other local projects can be cloned into the
deps
directory to quickly add local dependencies - Dependencies aren’t required to be registered with a package repository just to be used at the language level
- A package manager is free to configure the relative roots itself so that users never need to touch
the
deps
directory or relative roots if they use a package manager - Versioning is left to the package manager
- Multiple projects sharing the same dependencies can be accomplished by simple symlinks
- Diamond dependencies are naturally allowed
Diamond Dependencies
Diamond dependencies occur when two dependencies of a project both depend on the same
dependency, e.g. package A
has dependencies B
and C
which both depend on D
.
B
/ \
A D
\ /
C
This is a valid configuration, and whether or not the D
that is shared by B
and C
is the same D
is determined by the absolute file path to D
. If the file path is the
same, the package is the same and its types are thus interchangeable. This can be done
automatically - for example by a package manager recognizing both B
and C
require D
and providing the same D
to both by configuring the compiler’s relative roots or using symlinks.
Similarly, if B
and C
require different versions of D
, these will naturally be
located at separate filepaths and treated as different packages. So B
would require D1
and C
would require D2
. The result would be the following valid package graph, and
types from D1
would be incompatible with types from D2
(and vice-versa).
B - D1
/
A
\
C - D2
Extern
Ante’s C FFI is currently limited to extern
functions.
Without extern
, all definitions must be initialized
with a value and any names used may be mangled in the
compiled output.
You can use extern by declaring a value and giving it a type. Make sure the type is accurate as the compiler cannot check these signatures for correctness:
extern puts: C.String -> C.Int
You can also use extern with a block of declarations:
extern
exit: C.Int -> never_returns
malloc: Usz -> Ptr a
printf: C.String -> ... -> C.Int
Note that you can also use varargs (...
) in these declarations
and it will work as expected. There is currently no equivalent
to an untagged C union in ante so using any FFI that requires
passing in unions will require putting them behind pointers
in ante.
Algebraic Effects
Algebraic effects are a more complex topic described more in detail by research languages like Eff and Koka.
In short, algebraic effects are similar to a resumable exception, and they allow for non-local control flow that makes some programming styles more natural. Algebraic effects also serve as an alternative to monads for purely functional programming. Compared to monads, algebraic effects compose naturally but are slightly more restrictive.
Algebraic effects can be used first by declaring the effect with the effect
keyword,
then by performing the effect within a function. Once this happens,
the computation will suspend, and the program will call the most recent
statically-known effect handler.
From there, the effect handler can stop the computation and return a value as
with exceptions, or it can resume the computation and continue by calling resume
with the value to resume with. The type of value needed to resume depends on
the return type of the effect. For example, if our effect is:
effect GiveInt with
give_int: String -> I32
Then we will have to call resume
with an I32
to continue the original computation.
In an effect handler, we can match on any effects performed within the matched
expression. For example, if we want to write a handler for the GiveInt
effect above,
we may write a function like:
handle_give_int (f: Unit -> a can GiveInt) : a =
handle f ()
| give_int str ->
if str == "zero"
then resume 0
else resume 123
Finally, if we have a function do_math
which uses the GiveInt
effect, here’s
how we’d pass it to handle_give_int
to properly handle the effect:
do_math (x: I32) : I32 can GiveInt =
a = give_int "zero"
b = give_int "foo"
x + a + b
handle_give_int (fn () -> do_math 3) //=> 126
Sugar for applying handlers
You’ll notice handle_give_int
expects a function, so we have to wrap do_math 3
in a
lambda before we pass it into our handler. Since this operation is so common, ante provides
the with
operator which will wrap its left argument in a lambda and pass it to the function
on its right. Here is the definition of with
in pseudocode:
a with b
= b (fn () -> a)
With this we can rewrite the last line as:
do_math 3 with handle_give_int
There are also times when we want to use a handler to handle an entire block. For this,
we can use the using
keyword:
// try: (Unit -> a can Fail) -> Maybe a
using try
foo = failable_operation ()
bar = failable_operation ()
foo + bar * 2
Which desugars to:
try fn () ->
foo = failable_operation ()
bar = failable_operation ()
foo + bar * 2
This is often useful for error handling or early returns across function boundaries.
More on Handlers
Multiple Handlers
Unlike traits where impl search is automatic, effect handlers are
manually inserted by the programmer. This allows for multiple handlers
to be defined for any effect. As an example, lets define another handler
for GiveInt
in addition to handle_give_int
:
the_int (int: I32) (f: Unit -> a can GiveInt) : a =
handle f ()
| give_int _ -> resume int
do_math 1 with the_int 5 //=> 11
Matching on the Returned Value
Handle expressions can also match on the return value of the handled expression
count_giveint_calls (f: Unit -> a can GiveInt) : I32 =
handle f ()
| return x -> 0
| give_int _ -> 1 + resume 0
do_math 5 with count_giveint_calls //=> 2
This example can be confusing at first - how can we always return
an integer representing the number of GiveInt calls if our function
says it returns some type a
? Lets work this out step by step
to see how it expands:
do_math 5 with count_giveint_calls
// First we expand and substitute
handle
a = give_int "zero"
b = give_int "foo"
5 + a + b
| return x -> 0
| give_int _ -> 1 + resume 0
// Then reduce via our give_int rule - continuing
// the computation with the value 0 and adding 1 to the result
handle 1 + (a = 0; b = give_int "foo"; 5 + a + b)
| return x -> 0
| give_int _ -> 1 + resume 0
// Reduce via give_int again for b
handle 1 + (1 + (a = 0; b = 0; 5 + a + b))
| return x -> 0
| give_int _ -> 1 + resume 0
// Now we finish evaluating the function and would
// normally get a result of 5
handle 1 + (1 + (return 5))
| return x -> 0
| give_int _ -> 1 + resume 0
// Since our handler matches on this return value,
// we use that rule next to map it to 0
handle 1 + (1 + 0)
| return x -> 0
| give_int _ -> 1 + resume 0
// Finally, 1 + 1 + 0 evaluates to 2 with no further effects
2
Resuming Multiple Times
resume
is a first-class function like any other, so we
can call it multiple times, or pass it to higher-order functions
like map
and flatmap
:
these_ints (f: Unit -> a can GiveInt) (ints: Vec I32) : Vec a =
handle f ()
| return x -> [x]
| give_int _ -> flatmap ints resume
do_math 2
with these_ints [1, 3] //=> [4, 6, 6, 8]
This handler is similar to the list monad in that it will keep resuming from the given function with all combinations of the given values, returning a Vec of all the return values when finished.
Resuming Zero Times
Handlers may also choose not to resume at all, simply by
not calling resume
:
interpret (default_value: a) (f: Unit -> a can GiveInt) : a =
import Random.random
handle f ()
| give_int "zero" -> resume 0
| give_int "random" -> resume $ random ()
// Do not resume, return the default value instead
| give_int _ -> default_value
do_math 7 with interpret 42 //=> 42
Error Handling
Ante primarily uses the Fail
and Throw e
effects for error handling.
These roughly correspond to Maybe t
and Result t e
respectively.
Being effects however, these are automatically propagated up the callstack:
add_even_numbers (a: String) (b: String) : U64 can Fail =
n1 = parse a
n2 = parse b // parse: String -> U64 can Fail
if n1 % 2 == 0 and n2 % 2 == 0
then n1 + n2
else fail ()
Handling these effects can be done via manual handle
expressions, or
via the try
and catch
helper functions
which convert Fails to Maybe, and Throws to Results:
try (f: Unit -> a can Fail) : Maybe a =
handle f ()
| return x -> Some x
| fail () -> None
catch (f: Unit -> a can Throw e) : Result a e =
handle f ()
| return x -> Ok x
| throw e -> Error e
print (add_even_numbers "2" "4" with try) //=> Some 6
print (add_even_numbers "2" "5" with try) //=> None
Because effects can be naturally composed, functions returning multiple different errors can also be naturally composed without requiring users to define their own error unions:
foo () can Throw FileError, Throw ParseError, Throw BarError =
f = File.open "foo.txt"
contents = parse (read f)
bar contents
Useful Effects
Effects are a very broadly useful feature, yet the previous examples have been rather abstract. Here are some practical usecases for effects.
State
Algebraic Effects can be used to emulate mutable state - automatically threading stateful values through multiple functions.
effect Use a with
get: Unit -> a
put: a -> Unit
state (current_state: s) (f: Unit -> a can Use s) : a =
handle f ()
| put new_state -> resume () with state new_state
| get () -> resume current_state with state current_state
type Expr =
| Int I32
| Var String
| Add Expr Expr
| Let String (rhs: Expr) (body: Expr)
Eval = Use (Map String I32)
lookup (name: String) : Maybe I32 can Eval =
map = get ()
map.get name
define (name: String) (value: I32) : Unit can Eval =
map = get ()
put (map.insert name value)
eval (expr: Expr) : I32 can Eval =
match expr
| Int x -> x
| Var s -> lookup s .or_error "$s not defined"
| Add lhs rhs -> eval lhs + eval rhs
| Let name rhs body ->
define name (eval rhs)
eval body
main () =
e = Let "foo" (Int 1) (Add (Var "foo") (Int 2))
eval e with state Map.empty //=> 3
Note that compared to the monadic approach, we do not
need to explicitly bind between effectful operations,
so we are free to write an expression like define name (eval rhs)
or eval lhs + eval rhs
without needing to bind intermediate values to a name
or use a combinator function like liftM2
.
The imperative programmer may also note that nowhere do we have to
explicitly thread through our map of names to values. This same
technique can be used to obviate the need to explicitly pass around
context parameters to functions in larger codebases. Moreover, since
whether a function requires such a context or not can be inferred,
removing a context from a function no longer requires manually removing
function arguments from every call site of that function. We gain all
this while still keeping the use of a context explicit in the function’s
signature. We know any function marked can Eval
will
make use of this context and potentially modify it. If we wanted to
separate these two notions, we could split Get
and Put
into different
effects instead of including them both in a Use
effect
This example also highlights we can use type aliases for effect types.
Imperative Programming
Combining State
, IO effects, and a Loop
effect for loops that can Break
or Continue
lets us program in a very imperative style,
while remaining purely functional behind the scenes.
effect Loop with
break: Unit -> a
continue: Unit -> a
for (iter: i) (f: e -> Unit can Loop) : Unit can Iterate i e =
match next iter
| None -> ()
| Some (rest, elem) ->
handle f elem
| break () -> ()
| continue () -> for rest f
// If the body returns normally, we also want to continue the loop
| return _ -> for rest f
while (cond: a -> Bool) (body: a -> Unit) : Unit can State a =
if cond (get ()) then
body (get ())
while cond body
do_while (body: a -> Bool) : Unit can State a =
if body (get ()) then do_while body
// Loop until we eventually find a prime number through sheer luck
loop_examples (vec: Vec I32) : Unit can Print, State I32 =
for vec fn elem ->
largest = get ()
if largest > 100 then
print "oops, too big!"
break ()
else if largest < elem then
put elem
// While our current integer State value is_even, loop
while is_even fn x ->
put $ x + random_in (1..10)
do_while fn x ->
print "looping..."
put (x + 2)
not is_prime (x + 2)
find_random_prime (vec: Vec I32) : I32 can Print =
loop_examples vec with final_state 0
Generators
The yield effect provides a way to implement generators.
effect Yield a with
yield: Unit -> a
traverse (xs: List Int) : Unit can Yield Int =
match xs
| Cons x xs -> yield x; traverse xs
| None -> ()
filter (k: Unit -> a can Yield b) (f: b -> Bool) : a can Yield b =
handle k ()
| yield x ->
if f x then yield x
resume ()
iter (k: Unit -> a can Yield b) (f: b -> Unit) : a =
handle k ()
| yield x -> resume (f x)
yield_to_list (k: Unit -> a can Yield b) : List b =
handle k ()
| return _ -> []
| yield x -> Cons x (resume ())
main () =
odds = traverse [1, 2, 3]
with filter is_odd
with yield_to_list
// Above we see a benefit of the with operator over a different
// lambda sugar like {foo} for (fn () -> foo). With the later
// syntax, nesting is unavoidable:
// odds = {{traverse [1, 2, 3]}
// .filter is_odd}
// .yield_to_list
traverse [1, 2, 3] with filter is_odd with iter fn i ->
print "yielded $i"
Logging and Mocking
The ability to decide effect handlers at the callsite enables us to swap out the behavior of side-effectful operations to mock them for testing.
effect Print with
print: String -> Unit
effect QueryDatabase with
querydb: String -> Response
database f =
db = Database.connect "..."
result = handle f ()
| querydb msg -> resume (send db msg)
close db
result
ignore_db f =
handle f ()
| querydb _ -> Response.empty
business_logic (should_query: Bool) : Unit can Print, QueryDatabase =
if should_query then
print "querying..."
response = querydb "SELECT column FROM table"
...
print "done with db"
else
print "did not query"
// Print effect handling is builtin, let ante handle it
main () can Print =
business_logic true with database
// Mock our business function. Use a different handler for
// testing instead of the database handler that will actually
// connect to the database.
test () =
handle business_logic false
| print msg -> assert (msg == "did not query")
| querydb _ ->
error "Tried to query when should_query = false!"
logs = business_logic true with ignore_db with collect_prints
assert (not is_empty logs)
Expected Value
One of the more niche benefits of algebraic effects is the ability
to compute the expected value of numerical functions which internally
use an effect like Flip
to decide on branches to take within the function.
effect Flip with
flip: Unit -> Bool
calculation () =
if flip () then
unused = flip ()
if flip () then 0.5
else 4.0
else 1.0
expected_value (f: Unit -> F64 can Flip) : F64 =
handle f ()
| flip () -> (resume true + resume false) / 2.0
print (expected_value calculation) //=> 1.625
Parsers
Algebraic effects can also be used to write parser combinators. This parser is for a language with numbers, addition, multiplication, and parenthesized expressions. This example was adapted from this koka paper.
effect Repeat with
flip: Unit -> Bool
fail: Unit -> a
effect Parse with
// The parameter to Satisfy is a function which takes
// our current input and returns a pair of
// (result, rest_of_input) on success, or None on failure.
satisfy: (String -> Maybe (a, String)) -> a
choice p1 p2 =
if flip () then p1 () else p2 ()
many (p: Unit -> a can Repeat) : List a can Repeat =
choice (fn () -> many1 p)
(fn () -> Nil)
many1 p = Cons (p ()) (many p)
// Return all possible solutions from the given computation
solutions (f: Unit -> a can Repeat) : List a =
handle f ()
| return x -> [x]
| fail () -> []
| flip () -> resume false ++ resume true
// Return the first succeeding computation (taking the false Flip branch first)
eager (f: Unit -> a can Repeat) : Maybe a =
handle f ()
| return x -> Some x
| fail () -> None
| flip () ->
match resume false
| Some x -> Some x
| None -> resume true
// Handle any Parse effects (letting Repeat effects pass through)
parse (input: String) (f: Unit -> a can Parse, Repeat) : a, String can Repeat =
handle f ()
| return x -> x, input
| satisfy p ->
match p input
| None -> fail ()
| Some (x, rest) -> resume x with parse rest
// These will be our parsing primitives
symbol (c: Char) : Char can Parse =
satisfy fn input ->
match input
| Cons x rest if x == c -> Some (c, rest)
| _ -> None
digit () : Int can Parse =
satisfy fn input ->
match input
| Cons d rest if is_digit d -> Some (int (d - '0'), rest)
| _ -> None
number () =
many1 digit .foldl 0 fn acc d -> 10 * acc + d
// Now our actual parser with begin in proper:
binop sym op f =
a = f ()
symbol sym
b = f ()
op a b
add () = binop '+' (+) term
mul () = binop '*' (*) factor
expr () = choice add term
term () = choice mul factor
factor () : Int can Parse, Repeat =
choice number fn () ->
symbol '('
e = expr ()
symbol ')'
e
parse expr "1+2*3" with solutions
//=> [(7, ""), (3, "*3"), (1, "+2*3")]
parse expr "1+2*3" with eager
//=> Some (7, "")
Others
Other examples include using effects to implement asynchronous functions, implementing a clean design for handling animations in games, and using effects to emulate any monad except for the continuation monad (citation needed).