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. This can be seen in the ability to opt out of move semantics and even temporary references much of the time by using shared types which resemble programming in a garbage-collected language with boxed values.
Compared to other low-level languages, Ante is memory safe like Rust but tries to be easier in general, for example by allowing shared mutability by default. Generally, application-level Ante code is meant to be written with shared types to enable high-level code, while libraries are meant to use ownership & borrowing internally to improve performance.
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
// When no integer type is specified, integers default to `I32`
100 + 1 == 101
// Ante does not implicitly cast integer types. The following is a type error:
3u8 + 3u16
// 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.
// Floats without a specified type are polymorphic and default to `F64`
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 Strings 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
Ante supports several different string types for different use cases but the most common String
type which string literals are given by default is represented as a reference-counted pointer
to a growable UTF-8 string with copy-on-write semantics. String is not null terminated.
String literals in code are stored in read only memory and do not require heap allocation.
Attempting to mutate these values however, will be treated as if they are always aliased and
will invoke copy-on-write semantics which will copy to the heap before making the mutation.
This is meant to be relatively efficient for the general case, although users with more specific
optimization or representation requirements may wish to use alternative string types. Examples
of alternate types include the null-terminated C.String and the OS-dependent OsString.
var my_str = "Hello!"
hello = my_str // String implements `Copy` with a relatively cheap rc-increment
// Modifying `my_str` will not modify `hello`
my_str.replace "H" "Y"
print my_str //=> Yello!
print hello //=> Hello!
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 using the var keyword when defining the variable:
// Mutable variables can be created with `var`:
var pet_name = "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 using a
temporary mutable reference (mut):
// We can do this with mutable state:
count_evens (array: Array n t) (counter: mut I32) =
iter array fn elem ->
if even elem then
counter += 1
var counter = 0
count_evens [4, 5, 6] (mut counter)
count_evens [0, 2, 4] (mut counter)
print counter //=> 5
// Although in practice it is good to prefer immutability:
count_evens2 (array: Array n t): I32 =
array.filter even |> count
print (count_evens2 [4, 5, 6] + count_evens2 [0, 2, 4])
mut <expr> lets you take a temporary mutable reference to the given expression
on the right-hand side. In the case of variables and struct fields, this reference will refer
to the existing value, and will require the original variable to be mutable. In the case of
other values, such as those returned from a function, a temporary mutable reference is still
obtained.
var my_pair = 1, 2
my_pair.first := 3
// Without the `mut` this would copy the `second` field into a new variable
field_ref = mut 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 // error! `bad` is not mutable
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)
Functions can have their parameter types and return types specified via :
bar (a: U32) (b: U32): Unit =
print a
print b
print (a + b)
Module Namespacing
By default functions are placed in the current module. Optionally, functions may also be placed in a child module by prefixing the function’s name with the module name. For example:
Foo.bar (a: U32) (b: U32): Unit =
print "I am defined in Foo now"
Methods
Module namespacing is also how methods are defined. In Ante the . operator can be used
for method calls as well as field accesses. When used for method calls, the function name
will be searched for in the module with the same full path as the type of the first argument.
Methods may only be added to types defined in the current project. If the type is defined in a dependency, it may not have new methods added to it.
When defining a method, the self variable can be used as an argument which is implicitly
of the same type as the module name. If there is no such type (e.g. it is just a normal module),
an error will be given. Like other parameters, self on its own will use move semantics. It
can also be borrowed either mutably or immutably by prepending a reference type such as ref self.
For example, the standard library defines the Vec type for a mutable vector and defines
methods on it like so:
type Vec a = ... // implementation omitted
Vec.new () = ...
Vec.push (vec: mut Vec a) (elem: a): Unit = ...
// Call the methods. This can be done without explicitly importing `Vec.new` or `Vec.push`:
var vec = Vec.new ()
vec.push "called"
vec.push "a"
vec.push "method!"
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 eliding 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 with the pipeline operators we would end up with a long
chain of lines ending with \:
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, ergonomic, 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 without one of these tokens preceding 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 normal names like foo or bar, just with special parser
support so we can call them infix (foo + bar) instead of prefix (+ foo bar) like other names.
Most operators are defined in traits in the prelude. Here are some
common operators:
// The standard set of numeric ops with operator precedence as you'd expect
trait Add a =
(+): fn a a -> a
trait Sub a =
(-): fn a a -> a
trait Mul a =
(*): fn a a -> a
trait Div a =
(/): fn a a -> a
/// `%` is modulus rather than remainder. For unsigned numbers there is no
/// difference, but for signed numbers `-3 % 5` would be `2` for modulus and `-3` for remainder.
trait Mod a =
(%): fn a a -> a
/// `%%` is a convenience operator for checking if `a` is divisible by `b` without a remainder
(%%) a b = a % b == 0
trait Eq a =
(==): fn (ref a) (ref a) -> Bool
(!=) a b = not (a == b)
// Comparison operators are implemented in terms of the `Cmp` trait
trait Cmp a =
compare: fn (ref a) (ref 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, including +=, -=, *=, /=, and %=.
Logical operators have their names spelled out fully and will short-circuit:
if true and false then print "foo"
if false or true then print "bar"
if not false then print "baz"
// This will not call spill_the_soup
if true or spill_the_soup () then ..
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, and precedence of these
operators in other languages is often confused, there are no bitwise operators in Ante.
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 spelling of a[i]
would be ambiguous with a function call to a function a taking a single
argument that is a collection with 1 element i.
average_first_two array =
(array.[0] + array.[1]) / 2
Note that .[] has a high enough precedence to be used in function calls:
foo array.[0]
Additionally, references to elements can be retrieved using the subscript operator with a reference kind:
print (ref my_array.[0])
mutate (mut my_array.[1])
Dereference Operator, Copy, and Clone
Dereferencing a reference in Ante requires the element type of the reference to implement
either Copy or Clone. Both traits have the same semantics in that they both perform
copies (although certain values like Rc t may be shared), but types implementing Copy
are generally expected to be cheaper to copy than types only implementing Clone.
These traits can be called via the copy or clone functions, but there is also the
postfix .* operator available as an alias to copy. This operator has a higher precedence
than function calls and can be more convenient in some cases.
type Person = age: U8, name: String
foo (person: ref Person) (id: ref U32) =
bar person.age.* id.*
bar (a: U8) (b: U32) = ...
If you need to access a struct field, struct.field will retrieve a reference to the
given field if struct is a reference, otherwise it will attempt to copy or move the
field out of the struct. Also note that if a value was expected but a reference was
provided, there is a coercion such that the reference will be automatically copied,
providing its element type implements Copy. This means foo above could be rewritten to:
foo (person: ref Person) (id: ref U32) =
bar person.age id
There is also an equivalent coercion if an immutable reference (ref or imm) was expected
but a value was provided to automatically reference the value. Mutable references must
remain explicit however.
Note that there is no requirement for
Copytypes to be memcpy-able. Instead it is used for types which are “cheap” to copy - usually meaning they don’t need to allocate any memory on the heap. A result of this is thatRc timplementsCopy.
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 parentheses on one line.
print (sqrt (3 + 1))
// Could also be written as:
print <| sqrt <| 3 + 1
Pipelines and Methods
Note that method calls can still be used with the pipeline operators.
Method calls also work stand-alone (e.g. .push 3 is short for _.push 3)
as long as the expected object type can be figured out by the environment.
So one can write code such as:
Vec.of [1, 2, 3] |> .split_first // (1, [2, 3])
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
impls for them much easier. Let’s 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:
impl cast_pair_string: Cast (Pair a b) String with
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 alignment differences and reordering of fields).
-
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: fn (List (a, b)) -> List a, List b, we could useunzipeven on aList (a, b, c)to extract aList a, List (b, c)for us. This means if we wanted, we may implementunzip3usingunzip(though this would require two traversals instead of one):
// given we have unzip: fn (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. Let’s say we wanted to define a function
firstfor getting the first element of any tuple of length >= 2 (remember, we are using nested pairs, so there are no 1-tuples!), andthirdfor 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
thirdwill 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 parentheses compared to if we had tuples.
A common example is when enumerating a tuple, most languages would need two sets
of parentheses 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 parentheses:
for (i, (one, two)) in enumerate pairs do
print "Iteration $i: sum = ${one + two}"
// But since `,` is just a normal operator,
// the following version is equally valid
for i, one, two in enumerate pairs do
print "Iteration $i: sum = ${one + two}"
Finally, it’s necessary to mention that the earlier Cast example printed nested
pairs as 1, 2, 3 whereas the Show instances in Haskell printed tuples as (1, 2, 3).
If we wanted to surround our nested pairs with parentheses we have to work a bit
harder by specializing the impl for pairs:
impl cast_pair_string: Cast (Pair a b) String with
cast pair = "(${to_string_no_parens pair})"
// Convert a pair to a string without parens
to_string_no_parens (x, y) =
str = "${x}, "
rhs = if Type.of y |> is_pair_type then to_string_no_parens y else cast y
str ++ rhs
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.
All functions in Ante must have at least one parameter. When the first argument is excluded (as in fn -> body),
this is taken as sugar for a function taking a Unit parameter: fn () -> 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 in. 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 includes the traditional for and while loops along with break and continue.
for loops must iterate over an increasing range. For anything more complex, streams must be used.
for i in 0 .. 10 do
if i %% 3 then continue
if i > 7 then break
println i
while true do println "hello"
For more complex loops, Ante favors recursive functions like map, foldl, iter, and for_, which operate on streams:
iter (0..10) println // prints 0-9 inclusive
// `for_` allows using `continue_` and `break_` via the `Loop` effect
for_ (enumerate array) fn (i, elem) ->
if i %% 3 then continue_ ()
if i > 7 then break_ ()
print elem
Occasionally, it is natural to reach for a recursive function:
sum numbers =
go numbers total =
match numbers
| Nil -> total
| Cons x xs -> go xs (total + x)
go numbers 0
But this can be cumbersome when you just want a quick loop in the middle of a function.
For this case, Ante provides the loop and recur keywords for creating an immediately
invoked helper function. The following definition of sum is 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 -> ...
Note that in Ante variables must be lower case while type constructors are uppercase. This carries over to match expressions where each uppercase word is a tag to match on while each lowercase word is a variable to bind. This reduces the common error in other languages of misspelling a tag value and accidentally creating a new variable binding and match-all pattern in doing so.
If a variable binding is created but otherwise unused it will issue an unused warning unless its name starts with an underscore:
match foo
| Some bar -> () // warning: `bar` is unused
| None -> ()
match foo
| Some _bar -> () // ok!
| None -> ()
If a type has many fields to match on but several are unneeded, they can be omitted
with ..:
type MyStruct =
foo: I32
bar: I32
baz: I32
qux: I32
match MyStruct 1 2 3 4
| MyStruct .. -> print "This struct is indeed a struct"
// `..` also works for a subset of fields:
match MyStruct 1 2 3 4
| MyStruct my_foo my_bar .. -> print "foo = $my_foo, bar = $my_bar"
As seen above, structs are matched using positional argument order similar to how they are constructed.
They may also be matched by field name using the same with syntax for named struct field construction:
match MyStruct 1 2 3 4
| MyStruct with bar, qux, .. -> print "bar = $bar, qux = $qux"
// Fields can be renamed:
match MyStruct 1 2 3 4
| MyStruct with bar = bar2, .. -> print "bar = $bar2"
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 thematchinstead of twice which saves us valuable horizontal space.
is Operator
The is operator can be used to pattern match within arbitrary expressions.
The syntax for an is expression is <expr> is <pattern>. These expressions
can be used to test whether an expression matches a particular case, for example:
shared type Expr =
| Int I32
| Var String
| Add Expr Expr
is_variable (e: Expr) =
e is Var _
print (is_variable (Var "foo")) //=> true
print (is_variable (Int 3)) //=> false
If an and is used after the is expression, any variables defined in the pattern
will be in scope of the right-hand side of the and expression:
is_even_int (e: Expr) =
e is Int x and even x
Note that because <expr> is <pattern> is an expression and and also accepts two expressions,
chaining matches is also possible:
print_if_large_product (x: Maybe I32) (y: Maybe I32) =
if x is Some x2 and y is Some y2 and x2 * y2 > 1000 then
// x2 and y2 are still in scope
print (x2 * y2)
Additionally, as we saw above, if is expressions are used within an if condition (or match guard)
the variables defined within the is expression will also be in scope of the corresponding
if or match branch. Note that for these variables to be in scope, the is expression
must be in the outermost portion of the condition such that only and expressions may be
joining them. An is in a nested expression like if e is Var a or e is Int x then ... will
not have its variables in scope of the then branch since a or x may not actually be matched.
If this happens you’ll get a compiler warning that a and x cannot be used (since they will
never be in scope).
With these limitations in mind, is can still be a very useful operator to shorten code using
pattern matching.
incorrect_example (x: Maybe I32) =
if not (x is Some y) and y > 2 then //error! `y` is not in scope here: (x is Some y) may not match
...
evaluate (e: Expr) (env: HashMap String I32): I32 can Error =
match e
// We can check if `name` is in our HashMap within this match
| Var name if lookup env name is Some value -> value
| Var name -> error "${name} is not defined"
| Int x -> x
| Add lhs rhs -> evaluate lhs env + evaluate rhs env
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 and implicits, among other extensions.
This means Ante can infer variable types, parameter types, function return types, and even infer which traits and effects 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: fn it -> Maybe (it, elem)
first_equals it target =
match next it
| Some (_, x) -> x == target
| _ -> false
We never gave any type for first_equals yet Ante infers its type for us as
fn a b {Iterator a b} {Eq b} -> Bool - that is a function that returns a Bool and takes
two generic parameters along with an implicit parameter which is an instance
of the iterator trait for an iterator of type a producing elements of type b.
Type Inference in Idiomatic Code
Note that while global type inference is possible, it is not idiomatic to have large code bases omitting types on every function. Generally speaking, the larger the code base, the more important it is to have clear type signatures for globally visible functions to improve type errors in the case types change. Given it is preferred to have explicit type signatures for functions, one may wonder why offer type inference on them at all? There are a few reasons for this.
-
When contributing to a new or existing code base a developer often adds a couple of functions at a time. The intended work-flow of Ante is to omit the types of these functions, and when the programmer is satisfied, they can have the compiler write in the inferred function types itself after a successful compilation. This way the programmer does less unnecessary work but still gets explicit types in the end. They are also still free to write explicit types for any particularly difficult functions they need before then to help with type errors.
-
For smaller scripts it can be nice to write code without types. A type error affecting the inferred types of other functions is less of an issue when you only have a handful of them and don’t intend to write more.
-
Even in larger code bases, inferred types on functions can still be useful in some rare cases like particularly trivial helper functions, or trait methods where the trait always dictates the function type anyway.
-
In a teaching scenario, it can be useful to have the flexibility to defer teaching about types a little. They should likely still be taught early but any bit of lowering the initial shock value for students new to programming can help.
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 (ref 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
// `a` is a generic type parameter which can stand in for any type later. For example,
// `Vec I32` would be a vector of integers while `Vec String` would be a vector of strings.
type Vec a =
data: Ptr a
len: Usz
capacity: Usz
Optional Type Parameters
Type parameters can be made optional via a trailing ?. Optional type parameters must be at
the end of a type’s parameter list and are defaulted to a fresh type variable when unspecified.
These are commonly used in trait types.
/// We want to write a `Thunk` type alias for closure types but don't want
/// users to have to specify the closure environment type. Like normal closure
/// types, the optional `env?` here allows users to leave it implicit most of the
/// time but still specify it when needed.
type Thunk t env? =
fn Unit [env] -> t
run_thunk (thunk: Thunk I32): I32 =
thunk ()
/// All optional parameters must be explicit in type definitions
type TwoThunks env1? env2? =
thunk1: Thunk String env1
thunk2: Thunk U32 env2
Tagged Unions
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
Repeated Union Fields
Many tagged unions include one or more of the same fields between all variants. Often this leads to refactoring the tagged union into two types: the tagged union and a wrapper struct. This hampers readability though, and makes matching on these types more cumbersome, particularly hurting nested matches which now have to go through an additional struct.
shared type ExprInner =
| Int I32
| Var String
| Add Expr Expr
type Expr =
inner: ExprInner
location: Location
simplify (expr: Expr): Expr =
match expr.inner
| Int x -> Expr (Int x) expr.location
| Var s -> Expr (Var s) expr.location
| Add (Expr (Int 0) _lhs_loc) rhs -> rhs
| Add lhs (Expr (Int 0) _rhs_loc) -> lhs
| Add lhs rhs -> Expr (Add (simplify lhs) (simplify rhs)) expr.location
This pattern can be improved with the with keyword which will include a given list of fields
in all union variants, eliminating the need for a wrapper struct.
These extra fields are placed at the end of each variant’s list of fields.
shared type Expr =
| Int I32
| Var String
| Add Expr Expr
with location: Location
simplify (expr: Expr): Expr =
match expr
| Int x loc -> Int x loc
| Var s loc -> Var s loc
| Add (Int 0 _lhs_loc) rhs _loc -> rhs
| Add lhs (Int 0 _rhs_loc) _loc -> lhs
| Add lhs rhs loc -> Add (simplify lhs) (simplify rhs) loc
Each of the locations in the first two Add cases was written explicitly here to show where they would go, but if
these fields are unneeded in a pattern match they can also be excluded with .. which will
automatically fill in any remaining fields in a pattern:
simplify (expr: Expr): Expr =
match expr
| Int x .. -> Int x
| Var s .. -> Var s
| Add (Int 0..) rhs -> rhs
| Add lhs (Int 0..) -> lhs
| Add lhs rhs -> Add (simplify lhs) (simplify rhs)
Here the difference between ignoring a single field with _ and multiple fields with .. is minimal because there is only
one ignored field, but the difference will be larger when more ignored fields are involved:
is_int (expr: Expr): Bool =
// Ignore the `I32` and `Location` fields
expr is Int ..
Because the extra fields added by with are included on every variant, they can also be accessed on the
tagged union itself as if it were a struct type:
Expr.file (e: Expr): File =
// No need to match on each variant
e.location.file
Variant Types
Each variant of a tagged union is also defined as its own struct type. These types can be accessed in the namespace of the tagged union:
type Shape =
| Circle (radius: U32)
| Square (length: U32)
area_circle (circle: Shape.Circle): U32 =
radius = F64 circle.radius
result = F64.pi * radius * radius
result.truncate () // round towards 0
// Variant types can also have methods
Shape.Square.area self: U32 =
self.length * self.length
Normally when matching on tagged unions, you will need to match on each field of
each variant. To get a value of the variant type instead, you can collect all fields
to a single variable by placing .. immediately after the variant name:
Shape.area self: U32 =
match self
| Circle ..c -> area_circle c
| Square ..s -> s.area ()
This feature is not often useful in smaller types but can be useful in larger types
to break up code. For example, functions just matching on each variant of a tagged union
like Shape.area above can be derived such that users need only to implement the methods
for each individual variant like Shape.Square.area, Shape.Circle.area, etc.
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 annotating 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 constantly 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 - i.e. 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)`
Function Types
Function types in Ante are of the form fn arg1 arg2 .. argN -> return_type.
Note that functions in Ante always have at least one argument. Zero-argument functions
are usually encoded as functions accepting a single unit value as an argument, e.g. fn Unit -> I32,
which there is also sugar for: fn -> I32.
Function types can also have an optional effect clause at the end such as
fn a -> b can Fail, fn a -> b can Fail, Panic, or fn a -> b is pure for a function that uses no effects.
More on effects in Effects.
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 a debug field
that itself is printable and a prefix field that must be a string:
// Type inferred as:
// fn (prefix: String, debug: a) {Display a} -> Unit can Print
print_debug x =
prefix = x.prefix ++ ": "
print prefix
print x.debug
Ownership
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. If we ever want to use such values more than once, we would need to borrow them by creating temporary references to them which can be used any number of times, but prevent the underlying value from being moved until any references to it are no longer used.
The only values which may be used more than once without borrowing them are those
implementing the Copy trait. This trait signals a 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 there is an implementation for Copy I32, we can still refer to `x` after it was passed into `foo`
bar x
Borrowing
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 (ref s)
baz (ref s)
Creating a temporary reference to a value can be done via a ref <expr> expression.
These references do not allow mutation of the underlying value. If a mutable reference
is desired, they can be created via mut <expr>:
var s = "my string"
// This function call may modify our string
qux (mut s)
print s //=> "???"
Borrowing prevents the underlying value from being moved while any reference to it is still used:
bad (foo: Foo) =
// Error: Cannot move `foo` while the borrowed reference `ref foo` is still alive
bar (ref foo) foo
Trying to move the underlying value while the reference is still alive will result in an error. Additionally, we cannot return a reference to an outer scope after the variable it references may be dropped. To keep track of when a reference is valid, each reference stores the set of variables it may borrow from in its type.
Places
Each reference in Ante is parameterized by an element type and a place, where the place refers to what path(s) the
borrowed reference may refer to.
The full form of a reference type is <reference-kind> p t where p is the place and t is the element type.
The place can often be omitted from the type, in which case it will either be inferred or
a fresh place will be used.
The place parameter represents what a reference may borrow from. In the following example:
foo = 32
bar = ref foo
bar will have the type ref 'foo I32 because it is a reference to the variable foo which holds an I32.
It is also possible for a reference to borrow from multiple places:
foo = "foo"
bar = "bar"
baz = if rand () then ref foo else ref bar
Above, baz will have the type ref '(foo, bar) String because it may refer to either foo or bar.
When using baz, it will be valid for as long as both foo and bar remain in scope and are not moved.
Places can also refer to paths, as well as anonymous values in the local scope:
pair = 1, 2
one = ref pair.first
three = ref 3
foo three
In this case, one has type ref 'pair.first I32 and three has type ref 'a I32 where a is
a fresh name generated by the compiler, only valid for the current scope. It is as if the user had
written:
a = 3
three = ref a
foo three
Trying to return a reference past the scope where its places remain valid gives an error:
example (a: ref 'a I32) =
b = 1
if true then a else ref b // error! This reference to b outlives its scope
If this code were allowed we would return a dangling reference which will likely lead to a runtime crash when later dereferenced. Luckily, Ante prevents this for us with the above error.
When used in a function signature, places may be elided. When this happens, the following rules are used for determining what the place is assumed to be:
- If it is in a parameter, the place is assumed to be a unique, fresh variable.
- If it is in a return type:
- If there is a single place in the parameters, the return type must refer to that same place
- If there are multiple possible places (usually because there are multiple parameters), an error will be issued requiring users to explicitly specify which one to use.
Most of the time, these rules mean we can omit places unless the function both takes multiple reference parameters and returns a reference.
concat_foo (foo1: ref Foo) (foo2: ref Foo): String =
foo1.msg ++ foo2.msg
Types with Places
Places can also be added to type definitions. This is necessary if a type needs
to hold onto a temporary reference, although most of the time users should favor
wrapper types such as Arc t as these will generally be easier to work with. Place
parameters are distinguished from regular type parameters by the ' sigil:
type Context 'l =
global_context: ref 'l GlobalContext
Shared Mutability and Stability
Although largely built upon Rust, Ante’s borrowing semantics differ in that it allows shared (aliasable) mutability. This is done by tracking the “shape-stability” of a type.
While shared mutability may not be safe in general to allow since it can cause dangling references, among other issues:
bad (a: mut Vec t) (b: mut Maybe String) (c: String, String) =
elem = a.get 1
a.clear ()
println elem // Dangling ref!
s = if b is Some s then s else panic ""
b := None
println s // Dangling ref!
name = ref c.first
c.first := "Foo"
println name // Ok!
If the above were allowed, it’d be very bad! Shared mutability can be dangerous, but
at the same time it isn’t always unsafe, and being more permissive can help avoid
slowing down users by rejecting fewer valid programs. When looking at the above example,
what differentiates the mutation of a and b from c’s is that only c is shape-stable.
If we imagine what c and name look like in memory before and after the mutation:
Before mutation:
name
|
V
c: ( (first_ptr, first_len, ..), (second_ptr, second_len, ..) )
After mutation:
name
|
V
c: ( (first_ptr, first_len, ..), (second_ptr, second_len, ..) )
We can see that the pointer to name stably points to the first String struct value
in c regardless of how c is mutated. Now compare this with the Vec example of a and elem:
Before mutation:
a: (elem_ptr, length, capacity)
|
V
[0, 1, 2, 3]
^
|
elem
After mutation:
a: (elem_ptr, length, capacity)
|
V
[]
^
|
elem
elem clearly points to nothing now! The difference between these scenarios is that the shape of the data changed.
When the shape of some data changes, any references to it may be invalidated since what they point to may no longer be there.
If an operation may cause the shape of data to be changed, it is shape-unstable (or simply ‘unstable’).
Tracking Stability
To prevent issues like the above while still allowing shared mutability, Ante tracks stability in the type system.
Specifically, stability is tracked on places. An unstable place is marked with !. We can still get references
to unstable places:
example1 (v: mut Vec t) =
a: ref 'v! t = v.get 0
b: ref 'v! t = v.get 0
println a // ok!
println b // ok!
However, whenever a reference with place p is mutated, any unstable references with p as a parent, such as
ref 'p! I32 or ref 'p.bar!.baz I32, will be invalidated:
example2 (v: mut Vec String) =
a: ref 'v! String = v.get 0
b: mut 'v! String = v.get_mut 0
println a // ok!
b := "foo" // ok!
println b // ok! Mutating 'v! above only invalidates 'v!!
v.clear () // note: 'v mutated here
println b // error: 'v was mutated on the line above, which may invalidate b.
Generally, most collection types (with the exception of arrays) will be shape-unstable in their element types,
and pointer types that allow shared mutability like Rc will be shape-unstable as well.
Rc specifically, because each clone of it shares the same underlying value, has a place attached to it: Rc 'p t
is a reference-counted pointer to some data p of type t. This is sufficient to support type-safe shared
mutability on reference-counted pointers, including supporting cyclic data types:
type List 'p t =
| Nil
| Cons t (Rc 'p (List 'p t))
// Note that projecting through an Rc gives unstable refs:
Rc.get (rc: mut 'outer Rc 'inner t): mut '(outer!, inner) t = ...
main () =
var f = Rc.of (Cons false (Rc.of Nil)) // false -> []
var t = Rc.of (Cons true f) // true -> false -> []
if f is Cons _ tail then
// Note that `tail: mut '(f!!, a!) Rc 'a (List 'a Bool)`
// where 'a is shared by `f` and `t`. The double `!` comes from the instability of the Rc and the inner union.
// Taken as a whole, this means mutating either `f`, the list union, or inside the List may invalidate `tail`.
tail := t
// If we matched again:
// if tail is Cons _ tail2 then
// We would get:
// `tail2: mut '(f!!!!, a!!!, a!) Rc 'a (List 'a Bool)`
// Which would be invalidated by even more mutations. Cyclic refs are fragile in this way, by necessity.
emit_list t
|> take 5
|> iter println // true, false, true, false, true
emit_list (l: ref List t) = do
if l is Cons elem tail then
emit elem
emit_list tail
Although due to Rc semantics, t’s memory is still leaked at end of scope!
“This is complex and I don’t understand it!”
The good news is you largely don’t need to! For the most part, the standard library defines primitives like
Rc.getitself and users don’t need to worry about manually upholding invariants. Places can be inferred as well, so it is always valid to omit them and have the compiler write in the correct types to the source file if desired. You can also use shared types which provide a cleaner interface over types likeRc.
The Mutate Effect
When a reference mut 'e t is mutated, the compiler issues a Mutate 'e effect. This effect
is what is used to invalidate any value of a type referencing 'e! afterward. For convenience,
this effect is currently automatically added to a function’s
signature whenever the function mentions a mutable reference parameter.
The Mutate 'p effect cannot be manually handled by users but will be automatically handled once
the function signature it is propagated to no longer references 'p. In the case of mutating a
combination of places such as Mutate '(a, b) with only 'a going out of scope, the Mutate '(a, b)
effect will be propagated as Mutate 'b instead.
Mutate 'p ensures mutation is safe even in generic code:
twice (f: fn a a => Unit) (x: a) {Copy a} =
f x x
mutate_b (a: mut 'a Vec I32) (b: mut 'b Vec I32): Unit can Mutate 'b = ...
caller () =
var v = Vec.of [1, 2, 3]
// note: mutate_b used as `fn (mut 'v Vec I32) (mut 'v Vec I32) -> Unit can Mutate 'v`
// error: 'v is mutably aliased in mutate_b
twice mutate_b (mut v)
Another example with an effect and aliasing in a closure environment:
run (f: fn Unit => Unit can e) (xs: ref Vec I32): Unit can e =
first = xs.get 0
f () // If e is Mutate 'xs, the next line would be unsafe
println first
caller () =
var v = Vec.of [1, 2, 3]
// note: run used as `fn (fn Unit [mut 'v Vec I32] -> Unit can Mutate 'v) (ref 'v Vec I32) -> Unit can Mutate 'v`
// error: 'v is mutably aliased in run
run (do v.clear ()) v
In Loops
Similar to how a variable declared outside a loop cannot be moved within a loop, a variable declared outside a loop cannot be used in a loop at all if it’d be invalidated later in the same loop body:
foo1 () =
var v = Vec.of [1, 2, 3]
one = v.get 0
for i in 0 .. 3 do
println one // error: one may already be invalidated by code later in the loop
v.clear () // note: one invalidated here, causing the next iteration to use a dangling reference
Similarly, a closure that invalidates its own capture is a FnOnce, so the following is prevented:
foo2 () =
var v = Vec.of [1, 2, 3]
one = v.get 0
// error: repeat requires a Fn, but was passed a FnOnce
repeat 3 fn _ ->
println one
v.clear () // note: closure is a FnOnce because it invalidates a capture here
In Traits and Effect Handlers
For simplicity, effect handlers and traits do not have place parameters, but this means they have some restrictions:
- Trait implementations cannot capture places in their closure environment.
- Effect handler branches cannot
Mutateany places used in the handled expression. - Effect handler branches may be entered several times and are thus treated as loop bodies where they are not allowed to use any places that are invalidated later in the handler body (by any branch).
Also note that any effects performed in a handled expression are also seen by each handler branch, so the following:
bad1 () =
var v = Vec.of [1, 2, 3]
handle
emit (v.get 0)
v.clear ()
| emit elem ->
resume () // calls v.clear ()
println elem // println after the clear
Is prevented by the Mutate 'v effect of the handled expression which invalidates the element reference.
Note that this will invalidate elem even before resume is called as well.
Any temporary place passed to an effect handler is assumed to be dropped when resume is called:
bad2 () =
handle
v = Vec.of [1, 2, 3]
emit (v.get 0)
| emit elem ->
resume () // handled expr finishes, v is dropped
println elem // error: elem used here after being dropped by `resume`
Specifically, elem is given the type ref '(_, resume) I32, which is invalidated
once resume is moved by being called. Additionally, because of the anonymous local place '_
given to elem, the handle branch is not allowed to mutate elem which prevents code
like bad3 from compiling:
bad3 () =
handle
var v = Vec.of [1, 2, 3]
first = v.get 0
emit (mut v)
println first
| emit v2 ->
v2.clear () // error: Cannot mutate v2 which is shared by the handled expression
resume ()
This restriction may be removed in the future. It may be possible to assume
emit rfor some mutable referencerissues aMutate 'r_elemeffect which can then be tracked and used to invalidatefirst.
If mutating a value passed to an effect handler is needed, uniq references should be
used instead. A branch may mutate a uniq reference’s place but not any that may be shared
still (such as those through an Rc):
ok () =
handle
v = Vec.of [1, 2, 3]
first = v.get 0
emit (uniq v) // note: first dropped here when v was used again, which it borrows from
println first // error: Conflicting borrow, first is no longer valid
| emit v2 ->
v2.clear () // ok
resume ()
Distinct Places
Sometimes, code may only be safe if separate places are distinct:
foo (a: ref 'a Vec I32) (b: mut 'b Vec I32): Unit can Mutate 'b =
a_elem = a.get 0
b.clear ()
println a_elem // This would be unsafe if b aliases a
If foo’s caller is allowed to pass the same vector for a and b then we would print
a dangling reference to a cleared element. To prevent this, the compiler ensures any place
used in a Mutate effect must be distinct from other places given to the function:
caller1 (v: mut Vec I32) =
foo v v // error! 'a cannot alias 'b in call to foo, but 'v was used for both
Thread Safety of References
Since ref and mut both allow mutable aliasing, neither implements Send nor Sync.
Instead, Ante provides additional reference types imm and uniq. These additional reference
types are rarely used, typically only in multithreaded code, and come with stricter invariants:
- While
imm tis being borrowed, we can only borrow otherimmreferences fromt. - While
uniq tis being borrowed, we cannot borrow any other references fromt.
Since uniq t allows for mutability while imm t does not, we can freely Send (imm t) or Sync (imm t)
between threads, and can still Send (uniq t) as well. Once in another thread,
imm t can be used where a ref t is expected and uniq t can be used where any other reference
type is expected.
Shared Types
Shared types are a way to opt out of ownership rules for a type by automatically wrapping
it in a copyable wrapper. These types can be declared via shared type and also do not
require explicit boxing (they are always boxed):
// Immutable shared type
shared type Expr =
| Int I32
| Var String
| Add Expr Expr // No explicit boxing required
main () =
my_expr = Expr.Add (Int 3) (Var "foo")
// We can freely copy any shared type
alias1 = my_expr
alias2 = my_expr
You can think of these types as always being wrapped in a reference-counted pointer. They are
meant to be used when efficiency is less of a concern than code clarity. For example, when
gradually transitioning new users to use ownership rules it can be helpful if they have to worry
about it for fewer types - even if they still need to handle it for built-in types like Vec a.
These are also useful in cases when types need to be boxed anyway, such as Expr above or
the various shared, immutable container types.
It is possible to obtain refs to fields inside of shared types, but it is not possible to
receive mut references to them since shared types are immutable:
var_name1 (e: Expr): ref String can Fail =
if e is Var s then s // we'd get an error if we tried to return `mut String` here
else fail ()
Shared Mutable Types
In addition to shared type, which declares a shared, but immutable type, we can declare a shared,
mutable type via shared mut type:
shared mut type MutExpr =
| Int I32
| Var String
| Add MutExpr MutExpr
main () =
my_expr = MutExpr.Add (Int 3) (Var "foo")
// We can freely copy and mutate any shared mutable type
var alias1 = my_expr
alias2 = my_expr
// `alias1 := Int 0` would just rebind `alias1`
if alias1 is Add lhs _ then
lhs := Int 0
assert_eq my_expr (Add (Int 0) (Var "foo"))
assert_eq alias2 (Add (Int 0) (Var "foo"))
Using shared mutable types is meant to feel like using types in a high-level, garbage-collected language like Java.
Unlike normal shared types, shared mutable types allow mutation into their shared contents and are
thus not thread-safe. Similar to Rc 'p t, we must also track the places these types may refer to,
so an implicit place parameter is added to each shared mut type to keep shared mutability safe.
We can obtain ref or mut references inside shared mut types. When we do, the place these references
refer to will be the same as the implicit place variable on the type:
var_name2 (e: ref MutExpr): mut String can Fail =
if e is Var s then s
else fail ()
// Or with explicit places:
var_name2 (e: ref 'outer MutExpr 'inner): mut '(outer!!, inner!) String can Fail = ...
foo (expr: MutExpr 'e) =
name = var_name2 expr
...
Shared types are meant to be an easy-to-use alternative to explicit boxing. As an example, here’s the cyclic
List example from earlier, rewritten to use a shared mut type List instead:
shared mut type List t =
| Nil
| Cons t (List t)
main () =
f = Cons false Nil // false -> []
t = Cons true f // true -> false -> []
if f is Cons _ tail then
tail := t
emit_list t
|> take 5
|> iter println // true, false, true, false, true
emit_list (l: ref List t) = do
if l is Cons elem tail then
emit elem
emit_list tail
Internal Mutability
Although Ante provides type-system-checked shared mutability by tracking stability in a type,
it is sometimes necessary to track the validity at runtime instead. For this, Ante provides
several types implementing internal mutability to mutate what is otherwise an immutable-looking ref t type.
RefCell t will be a familiar sight to those used to Rust, but using this type entails runtime
checking to uphold reference safety: either a mutable reference can be made or multiple immutable
references, but never both at once.
Thread Safety
Ante uses the familiar Send and Sync traits from Rust for safe concurrency. It does
not innovate here but continues with the safe, tried and true model.
Implicits
In addition to normal, explicit parameters, functions can have implicitly passed parameters.
Implicit parameters are written with curly braces {} surrounding them to distinguish them
from normal parameters, and may have their names omitted if they are not otherwise used.
foo (x: I32) {y: I32}: I32 =
x + y
bar (x: I32) {I32}: I32 =
// bar's second parameter is automatically forwarded to `foo` here
foo x
When looking for an implicit value, the compiler will consider any implicit parameter already
in scope in addition to each definition with the implicit modifier:
implicit pi: I32 = 3 // close enough
main () =
// pi is the only implicit I32 in scope, so it is used
bar 0
When there are multiple conflicting values of the requested type to use, the compiler will issue an error:
implicit pi: I32 = 3
implicit zero: I32 = 0
main () =
// error: `bar` requests an implicit `I32` but there are multiple conflicting implicits in scope: `pi` and `zero`
// note: try explicitly specifying which implicit to use
bar 0
As the note tells us, when this happens we can disambiguate by explicitly passing the desired
value to bar. This can be done using curly braces:
implicit pi: I32 = 3
implicit zero: I32 = 0
main () =
bar 0 {pi}
Implicits are most commonly used for passing around trait values.
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 =
stringify: fn 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) {Stringify t}: Unit =
print (stringify x)
Each trait is just a type definition internally with:
- Each function in the trait translating to a field of type function.
- An accessor function defined for retrieving the field from an implicit value of that trait.
- Any captured data in closures is stored after each function in the trait struct, effectively
making it a vtable. This captured data is shown in the trait type as an optional parameter which
can be used to match each trait to a particular impl.
HashMap k v huses this for example to match multiple maps to the sameHash k himpl.
If we were to desugar the Stringify trait above, we’d get the following:
type Stringify t env? =
// The closure environment for stringify is the trait value itself
stringify: fn t [ref Stringify t env] -> String
impl_data: env
// This lets us call `stringify my_obj` and the constructor will look for
// an implicit `Stringify t` in scope to find how to stringify `t`.
stringify {s: Stringify t} x = s.stringify x
Since traits are just structs internally, we can construct them like any other struct:
implicit stringify_bool: Stringify Bool = Stringify with
stringify b _ = if b then "true" else "false"
impl_data = ()
implicit stringify_maybe {elem: Stringify t}: Stringify (Maybe t) = Stringify with
stringify m env = if m is Some x then env.impl_data.stringify x env.impl_data else "None"
impl_data = elem
But manually managing the impl_data environment field is laborious so Ante provides impl sugar
for defining an implicit trait value where each function’s captures are automatically collected
into the impl_data field:
impl stringify_bool: Stringify Bool with
stringify b = if b then "true" else "false"
impl stringify_maybe {Stringify t}: Stringify (Maybe t) with
stringify m = if m is Some x then stringify x else "None"
Additionally, each impl defines its own unique struct type for these closure captures. This
struct type has the same name as the impl value and can be used to ensure a selected impl remains
consistent within some context. Since Ante’s traits have no global coherence, this
is useful for some data structures like HashMap which need to ensure the trait implementation
they use is consistent:
type HashMap k v h = ...
/// Find a particular hash impl `h` on construction
HashMap.empty {Hash k h}: HashMap k v h = ...
/// ... and ensure it is consistent through each subsequent get/insert/eq with other maps, etc.
HashMap.get (map: mut HashMap k v h) (key: ref k) {Hash k h}: ref v can Fail = ...
Traits are often passed as implicit parameters into function calls
(see stringify_print above). Since implicit resolution only looks for implicit values in scope,
we need to ensure any trait values we use are either marked implicit, imported via import implicit,
or already in scope via an implicit parameter.
// Allow `stringify_bool` to be used implicitly in this module
impl stringify_bool: Stringify Bool with
stringify b = if b then "true" else "false"
// Or, in another module:
import implicit Example.stringify_bool
Multiple Type Parameters
Like any other type, traits can also have multiple type parameters.
We can use this to define relations over multiple types. For example,
we may want to be more general than the stringify function above and
have a trait to cast to any result type. To do this we can have a
trait that defines a cast function from one type to another:
trait Cast a b =
cast: fn a -> b
// Assuming we defined a `Cast I32 String`, we could now cast
// an I32 to a String via:
cast 3 : String
Inferred Implicit Parameters
When inferring a function’s type, if that function requires an
implicit that references a parameter type, the implicit will be inferred
to be a parameter of the function itself. That is, the following definitions
of double_cast are mostly the same:
// This:
double_cast x = cast (x + x)
// Is inferred as:
double_cast (x: t) {Add t} {Copy t} {Cast t u}: u =
cast (x + x)
There is one small difference between the two: implicits inferred to be parameters cannot be explicitly specified by users at call sites:
double_cast x = print (x + x)
main () =
// error! `double_cast` was not declared with any implicit arguments
_ = double_cast 2 {add_i32}
The reason for this is that if all implicits on a function are inferred, it would not be clear which order they should be passed in. For this reason, an error is issued if a user tries to specify implicit arguments on a function with inferred implicits.
Q: Why not have the compiler choose an ordering, such as ordering alphabetically?
A: If the compiler chose to order implicits alphabetically when inferred in a function signature, that would make renaming any type a breaking change since it may change the ordering of function parameters.
Since it is often a good idea to allow users of your library to specify implicits when necessary, explicitly specifying each function’s signature is encouraged. One pattern to consider is to write code with types inferred, then after a successful compilation, use Ante’s compiler option to write inferred types into the file.
Named Impls
Unlike trait implementations or typeclasses in other languages, trait values in Ante are normal values, and like other normal values, they can be named and imported/exported by name.
import implicit Foo.Impls.eq_foo
When an implicit parameter is ambiguous, you can just specify it explicitly:
import implicit Foo.Bar.stringify_bool
implicit conflicting_impl: Stringify t =
Stringify fn _ -> ""
print_to_string true {stringify_bool}
Having multiple conflicting implementations of a trait or typeclass 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.
Coherence
Ante has no concept of global coherence for traits, so it is perfectly valid to define overlapping implementations or define implementations for types outside of the modules the type or trait was declared in. If there are ever conflicts with multiple valid implementations 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 values or by explicitly specifying which implicit parameter to use:
implicit add = Combine I32 with (++) = (+)
implicit mul = Combine I32 with (++) = (*)
print (2 ++ 3) // Error, multiple matching implicits found! `add` and `mul` are both in scope
print (add.(++) 2 3) //=> 5
print (mul.(++) 2 3) //=> 6
Q: What about constructs like HashMap which rely on a consistent Hash implementation?
A: The plan is to have these types parameterized over the implementation chosen for them. This generic can then be used to ensure consistency everywhere the type is used.
The lack of global coherence also notably allows traits to be used in some places typical traits or interfaces are not, such as interning.
Example: Interning
Interning values is a common optimization but unfortunately often makes these interned values more cumbersome to work with. For example, often when implementing traits they require wrapper objects to be created first to bundle them with the appropriate context. Since we can define arbitrary functions to return trait values in Ante, we can define a closure which captures this context to implement any trait we need:
type Data = bytes: Vec U8
type DataId = id: U32
type Context =
// Each `DataId` is an index into this map
map: Vec Data
implicit display_data_id {ctx: ref Context} = Display DataId with
display (id: DataId) =
display (ctx.map.get id) ~> on_fail panic
Effects
Effects are a control-flow abstraction similar to a resumable exception. They are a useful tool since they can be used to abstract over several kinds of non-local control-flow (exceptions, generators, async, early-returns, etc.).
If you are familiar with monads, effects serve a similar purpose, but unlike monads, they compose together more naturally without the need to decide the handler ordering in the type itself.
We can create an effect in Ante using the effect keyword to define a type holding several
function values, similar to a trait:
effect Yield t =
yield: fn t -> Unit
Calling an effectful function like yield will perform the effect in the calling function.
To perform the effect we must specify the calling function can Yield (or let it be inferred).
If we are performing multiple effects, we can separate them with commas.
yield_and_return_10 (): I32 can Yield I32 =
yield 5
yield 7
10
Alone, yield 5 means nothing. To give meaning to an effect, we must handle it with an
effect handler. Handlers can be defined with the syntax: handle <expr> | <capability-pattern> -> <expr> | ....
This syntax defines a handler for the effect in <capability-pattern>. The | <capability-pattern> -> <expr>
portion must list each function of an effect and its implementation, separated by | if needed,
similar to match branches. Additionally, the special resume function will be visible within each handle
branch. This resume function is special - it lets us resume the function that called our effect function.
A good mental model of effects is that they’re like checked exceptions which we can throw by performing the effect,
catch by using effect handlers, but can also resume back to the code that performed the effect.
We’ll get into more of the implications of this later but for now let’s see a basic handler:
print_each_yield (f: fn Unit => a can Yield t) {Display t}: a can Print =
handle f ()
| yield elem ->
resume (println elem)
main () =
x = print_each_yield yield_and_return_10 // `5` and `7` are printed
assert_eq x 10
Above we define a handler inside print_each_yield and run f with that handler.
Then in main, we call print_each_yield with the yield_and_return_10 function from
before as an argument. This will run that function, and when yield 5 is encountered,
we will print 5 out before hitting the next yield, printing 7, and finally returning 10.
Aside from the new syntax, this should not be too surprising. The control-flow here is as
we’d expect from any other function - that is because when implementing yield we gave
it a function which calls resume in a tail position (i.e. as the last thing it does).
When called in a tail-position, the code is performing the entire function then finishing
and resuming back to where yield was called.
We can still do some interesting things with only resume in a tail position. For example,
we can collect each yielded value into a container:
// Collect each `yield elem` in `f` into a `Seq`, returning it alongside
// `f`'s original return value.
collect_yields_into_seq (f: fn Unit => a can Yield t): a, Seq t =
var yielded = Seq.empty ()
// ret will hold the result of `f ()`
ret = handle f ()
| yield elem ->
yielded := yielded.push elem
resume ()
ret, yielded
The real power of effects comes from when we call resume outside of tail-calls. For example,
we can choose to call it in the middle of our yield implementation or even not call it at all.
If we choose not to call resume at all, we should expect the code calling yield to never resume!
This may sound odd or undesired, but it is actually a very common use case: it is what exceptions do!
abort_after_first_yield (f: fn Unit => I32 can Yield I32): I32 =
handle f ()
| yield elem -> elem
main () =
x = abort_after_first_yield yield_and_return_10
assert (x == 5)
Now when we run the program, when yield 5 is first called, our handler returns 5 and does
not resume the call, so 5 is returned from abort_after_first_yield as well, changing
the value of x at the end.
If we resumed in the middle of our yield function (and performed more work afterward), then
that additional work would not be run until after the entire handled expression. This control-flow
can be difficult to conceptualize. As a mental model,
you can think of performing an effect as suspending the current call stack, jumping to the handler,
executing it, and jumping back when resume is called. If the handler didn’t finish (i.e. there is more
work to do after the resume call), it will accumulate extra stack frames to run when the computation
is finished.
This can be a lot to wrap one’s head around at first - a good way of learning may be by looking through some examples.
Error Handling
Some of the most common effects you’ll see are the Fail and Throw e effects for
error handling. These roughly correspond to the Maybe t and Result t e types respectively.
Being effects however, these do not need to be manually unpacked at each call site.
/// The Fail effect represents a generic failure. It is meant to be used
/// when the reason why is obvious and needs no extra information.
effect Fail =
fail: fn Unit -> Never
/// Throw on the other hand will throw a value to its handler.
/// It can be thought of as an exception.
effect Throw e =
throw: fn e -> Never
safe_div (a: U32) (b: U32): U32 can Fail =
fail_if (b == 0)
a / b
type Name = first: String, last: String
type ParseError = | NoName | NoLastName | ComplexName
parse_name (name: String): Name can Throw ParseError =
parts = Vec.of (name.split " ")
if parts.len () == 0 then
throw NoName
else if parts.len () == 1 then
throw NoLastName
else if parts.len () > 2 then
throw ComplexName
Name with first = parts.[0], last = parts.[1]
Handling these effects can be done via manual handler expressions, or
a variety of helper functions in the Std.Fail and Std.Throw modules.
Implementing these functions is generally simple. Effects are often described
as resumable exceptions, so if we want normal exceptions all we must do
is not call resume in the handler. A function like try will instead
return None while on_fail provides a default value on error.
try (f: fn Unit => a can Fail): Maybe a =
handle Some (f ())
| fail () -> None
catch (f: fn Unit => a can Throw e): Result a e =
handle Ok (f ())
| throw e -> Err e
print (safe_div 6 2 ~> try) //=> Some 3
print (safe_div 6 0 ~> on_fail do 42) //=> 42
print (parse_name "First Last" ~> catch) //=> Ok (Name "First" "Last")
print (parse_name "First" ~> catch) //=> Err NoLastName
print (parse_name "" ~> catch_or (Name "Bob" "Default")) //=> Name "Bob" "Default"
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 (): Unit can Throw FileError, Throw ParseError, Throw BarError =
f = File.open "foo.txt"
contents = parse (read f)
bar contents
Effect union type aliases may still be declared to cut down on typing if desired:
effect MyEffects = Throw FileError, Throw ParseError, Throw BarError
foo (): Unit can MyEffects =
f = File.open "foo.txt"
contents = parse (read f)
bar contents
Applying Handlers
Most handler functions like try or catch above take a function as an argument to supply
the handler for. Instead of manually wrapping each operation as in try (fn _ -> safe_div 6 2),
it is convenient to have alternate ways to apply handlers, similar to how we can apply normal
functions directly: f x, or with the pipeline operators: f <| x, x |> f.
Applying Handlers with ~>
~> works by automatically creating a closure such that try (fn _ -> safe_div 6 2) is equivalent
to safe_div 6 2 ~> try.
Applying Handlers with do
do x is sugar for fn _ -> x and can be used as a trailing argument on functions. This makes it
resemble the reverse of ~>. Where ~> has the function on the left and handler on the right, do
has the function on the right and handler on the left. We can also compare these to |> and <|,
where |> is to ~> as <| is to do.
It is most often used for handling entire blocks of code.
try fn _ ->
failable_function1 ()
failable_function2 ()
failable_function3 ()
// Equivalent to:
try do
failable_function1 ()
failable_function2 ()
failable_function3 ()
// Equivalent to:
try do
failable_function1 ()
failable_function2 ()
failable_function3 ()
Being sugar for a closure, do is also often used on functions like on_fail:
my_failable_fn 3 + 8
~> on_fail do panic "oh no!"
// Equivalent to:
on_fail
(fn _ -> (my_failable_fn 3) + 8)
(fn _ -> panic "oh no!")
Applying Handlers with Currying
Since the ~> operator introduces a new implicit, for patterns where you’re threading through
many implicits of the same effect (most notably generators), you may get “multiple matching implicits”
errors when using it. For this reason, generators in Ante are designed to return functions
directly instead (essentially manually currying them). This is why you’ll see the various stream functions defined as:
map (s: s) {Stream s a} (f: fn a => b) = fn () ->
...
// And since these functions already return
// functions, we can pipeline them easily:
doubled_evens stream =
filter stream (_ %% 2)
|> map (_ * 2)
|> Vec.of
Effect Control-Flow
Effects have a control-flow that is likely novel to many programmers. It is similar to an exception that may be resumed. We can create a handler to better show this unique control-flow:
effect MyEffect =
my_effect: fn String -> Unit
debug_effect_control_flow (f: Unit => a can MyEffect): a can Print =
handle f ()
| my_effect msg ->
// Print the message
println "my_effect '${msg}' called!"
// Resume the computation & finish it entirely (including other calls to my_effect!)
r = resume ()
// And only then print `finished`
println "resume '${msg}' finished"
r
foo () can MyEffect, Print =
println "foo called!"
_ = my_effect "foo a"
_ = my_effect "foo b"
println "foo finished"
bar () can MyEffect, Print =
println "bar called!"
_ = my_effect "bar a"
_ = my_effect "bar b"
println "bar finished"
example () can MyEffect, Print =
foo ()
bar ()
Now when we run debug_effect_control_flow example we get the following printouts:
foo called!
my_effect 'foo a' called!
my_effect 'foo b' called!
foo finished
bar called!
my_effect 'bar a' called!
my_effect 'bar b' called!
bar finished
resume 'bar b' finished
resume 'bar a' finished
resume 'foo b' finished
resume 'foo a' finished
Note that we do not get any of the “resume … finished” printouts until the entire
computation f () finishes. We are continually pushing stack frames to the handler to
finish later until all resumes finish from the last to the first as the stack frames
are popped.
The novel control-flow of this is all from code after the resume call in the handler. If the
handler does not have any code after resume (i.e. it is tail-resumptive) it can actually
be optimized into a normal function call. When performance is vital and an effect may be
handled in a tail-resumptive way, it is possible to specify when declaring the effect that
all handlers for it must be tail-resumptive. That way a library or application developer
can guarantee certain performance characteristics of the effect no matter its implementation.
Step-by-Step Evaluation
In case the above example was difficult to understand, we’ll walk through an example showing step-by-step how the function may be evaluated. This will be our example:
effect Foo =
foo: fn String -> I32
do_math (x: I32): I32 can Foo =
a = foo "zero"
b = foo "bar"
5 + a + b
count_foo_calls (f: fn Unit => a can Foo): I32 =
// This handler is in scope for `f (); 0`,
// so the `resume` call ends right after the `0`
handle f (); 0
| foo _ -> 1 + resume 0
do_math 5 ~> count_foo_calls //=> 2
This example can be confusing at first - how can we always return
an integer representing the number of foo calls if our function
says it returns some type a? Let’s work this out step by step
to see how it expands:
do_math 5 ~> count_foo_calls
// First we expand and substitute
handle
a = foo "zero"
b = foo "bar"
5 + a + b
0
| foo _ -> 1 + resume 0
// Then reduce via our `foo` rule - continuing
// the computation with the value 0 and adding 1 to the result
handle
1 + (
a = 0
b = foo "bar"
5 + a + b
0
)
| foo _ -> 1 + resume 0
// Reduce via foo again for b
handle
1 + (1 + (
a = 0
b = 0
5 + a + b
0
))
| foo _ -> 1 + resume 0
// Now we finish evaluating the function and would
// normally get a result of 5 - but it is sequenced immediately after,
// discarding the `5` and returning a `0` instead.
handle
1 + (1 + (
5
0
))
| foo _ -> 1 + resume 0
// After sequencing:
handle 1 + (1 + 0)
| foo _ -> 1 + resume 0
// The handled expression is now done evaluating, so the `handler` is finished.
1 + (1 + 0)
// Finally, 1 + 1 + 0 evaluates to 2 with no further effects
2
Resuming Multiple Times
In other languages with effects and handlers it may be possible to resume multiple times. This is currently not possible in Ante largely due to issues with mutability and efficiency, but may be allowed in the future.
Instead, resume in Ante is typed as a FnOnce which limits it to only
being called once. The plus side of this is that it opens up more opportunities
for implementing effects in an efficient way and limits unexpected interactions.
Useful Effects
Effects are a very broadly useful feature, yet the previous examples have been rather abstract. Here are some practical use cases for effects.
Exceptions
See Error Handling
Generators
The emit effect provides a way to implement generators.
This function is also often named yield.
effect Emit a =
emit: fn a -> Unit
/// Streams the contents of `t` to the emit handler
///
/// Most streams are generator functions, others are containers that supply a
/// function to emit each element.
trait Stream t a =
stream: fn t -> Unit can Emit a
/// Emit numbers from 0 to `n`, end-exclusive
/// This returns a function (along with most other functions below) since a
/// `fn Unit => Unit can Emit a` is itself a stream.
iota n = fn () ->
for i in 0usz .. n do emit i
/// Applies `f` to each element from the stream, re-emitting each result.
///
/// Given `a1, a2, .., aN`, emit `f a1, f a2, .., f aN`
map (s: s) {Stream s a} (f: fn a => b) = fn () ->
handle stream s
| emit a ->
emit (f a)
resume ()
/// Re-emits only the elements from the original stream for which `f elem` is true
///
/// E.g. `filter (iota 5) (_ > 2)` will emit `3` and `4`.
filter (s: s) {Stream s a} (f: fn (ref a) => Bool) = fn () ->
handle stream s
| emit a ->
if f (ref a) then emit a
resume ()
/// Infinite stream example
fibonacci (): Unit can Emit U64 =
var current, next = 0, 1
while true do
emit current
tmp = current + next
current := next
next := tmp
main () =
numbers = iota 5 // 0, 1, 2, 3, 4
|> filter (_ %% 2) // 0, 2, 4
|> map (_ + 1) // 1, 3, 5
|> Vec.of
iter fibonacci println // 0, 1, 1, 2, 3, 5, 8, ...
See the Stream module in the stdlib for more functions on streams.
Loops and Early-Returns
We can combine generators with a Loop effect that lets us continue and break
out of loops.
effect Loop =
break_: fn Unit -> Never
continue_: fn Unit -> Never
/// Consumes the given stream, applying `f` to each element, with
/// an additional Loop handler installed to allow breaking/continuing
/// within the overall loop.
for_ (s: s) {Stream s a} (f: fn a => b can Loop): Unit =
var broke = false
handle Stream.stream s
| emit a ->
handle f a; ()
| break_ () -> broke := true
| continue_ () -> ()
if not broke then resume ()
main () =
// Print `12457`:
for_ (iota 20) fn i ->
if i %% 3 then continue ()
if i > 7 then break ()
print i
Similarly, there is the EarlyReturn effect for early-returning. Since this is
an effect, we can use it even to early return out of multiple closures:
effect EarlyReturn a =
early_return: fn a -> Never
with_early_return (f: fn Unit => t can EarlyReturn t): t =
handle f ()
| early_return x -> x
/// Find the index of the given element in the sequence.
/// Fails if there is no matching element.
find_in_seq (seq: Seq t) (target: ref t) {Eq t}: Usz can Fail =
with_early_return do
enumerate seq |> iter fn (i, elem) ->
if target == elem then
early_return i
fail ()
/// If we wanted, we could even refactor `find_in_seq` into multiple functions
find_in_seq2 (seq: Seq t) (target: ref t) {Eq t}: Usz can Fail =
with_early_return do
enumerate seq |> iter (early_return_if_items_match _ target)
fail ()
early_return_if_items_match (i: Usz, a: ref t) (b: ref t) {Eq t}: Unit can EarlyReturn Usz =
if a == b then early_return i
In future versions of Ante, the
returnkeyword may be removed and replaced with theEarlyReturneffect entirely. This will only happen once the compiler can guarantee the efficiency ofEarlyReturnis always equivalent to that of a nativereturn.
Logging and Mocking
Testing logging output can be done in other languages, but this often involves refactoring code to be generic over a logging interface which can be mocked. Since effects in Ante must be used on any effectful function, and we can already swap out their implementation, we get this abstraction for free.
effect Print =
print: fn String -> Unit
effect QueryDatabase =
querydb: fn String -> Response
database f can IO =
db = Database.connect "..."
result = handle f ()
| querydb msg -> resume (db.send msg)
close db
result
ignore_db f =
handle f ()
| querydb _ -> resume 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 handling is built in, let Ante handle it
main () can Print, IO =
business_logic true ~> database
// Mock our business function. Use a different handler for
// testing instead of the database handler that will actually
// connect to the database.
test () can Fail =
handle business_logic false
| print msg ->
assert (msg == "did not query")
resume ()
| querydb _ ->
error "Tried to query when should_query = false!"
resume ()
logs = business_logic true ~> ignore_db ~> collect_prints
assert (not is_empty logs)
Others
Other examples include using effects to implement asynchronous functions, a clean design for handling animations in games, random state, or parsers, among others.
Capability-based Security
By requiring each effect used by a function to be documented in its type, Ante has
capability-based security. Library functions without a can Net effect for example may
not access the network. A pure function in a library may not later be updated to secretly
log user data without adding a Net effect - a breaking change.
There is a caveat here: if a function already has a can Net clause, a once-innocent function like innocent:
foo (bar: Bar) can Net =
innocent bar
my_network_fn ()
// In another library:
innocent (bar: Bar) = ...
May be updated to maliciously use a Net effect and foo wouldn’t require a source update
since it is already declared as can Net:
foo (bar: Bar) can Net =
innocent bar
my_network_fn ()
// In another library (updated):
innocent (bar: Bar) can Net =
send_user_data_to_private_servers bar
This is unfortunate and although it is a problem shared with more traditional effect systems, it is still weaker than other capability-based security models where everything must be passed explicitly. To mitigate this:
- The package manager can warn when a library is updated to require additional capabilities
- Ensure untrusted library functions are called in contexts with minimal effects.
foo (bar: Bar): Unit =
innocent bar // error! This requires a `Net` effect but `foo` is marked pure
my_non_network_fn ()
// In another library:
innocent (bar: Bar) can Net =
send_user_data_to_private_servers bar
Even with this downside however, Ante remains more secure than existing programming languages where all effects are untracked.
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. Using the module hierarchy
from the section above, in our Baz.Nested file we
may have:
nested_baz = 0
print_baz () =
print "baz"
get_baz () = "baz"
To use these definitions from Foo we can import them:
import Baz.Nested.nested_baz, get_baz
baz = get_baz ()
print "baz: $baz, nested_baz = $nested_baz"
Note that Ante does not support wildcard imports. This is an intentional decision to speed up the name resolution step in the compiler by enabling it to be done without collecting all names in the current project & dependencies first.
// 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 ()
You may also rename imports via as:
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 () = ...
Implicit Imports
To import a value into scope and enable any definitions searching for an implicit of the
same type to use it, the value must be imported via import implicit. This is most often
used to bring capabilities into scope:
import Lib.MyType
import implicit Lib.MyType.eq_mytype
main () =
x = MyType.new ()
print (x == x) // requires Eq MyType
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/stdlibfor the stdlib./depsfor 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
depsdirectory 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
depsdirectory 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.
A
/ \
B C
\ /
D
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 file paths 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).
A
/ \
B C
| |
D1 D2