Amulet updates

Posted on August 11, 2018
Word count: 1058

Jesus, it’s been a while. Though my last post was almost 6 months ago (give or take a few), I’ve been busy working on Amulet, which continues to grow, almost an eldritch abomination you try desperately, but fail, to kill.

Since my last post, Amulet has changed a ton, in noticeable and not-so-noticeable ways. Here are the major changes to the compiler since then.

Parser improvements

No language is good to use if it’s inconvenient. So, in an effort to make writing code more convenient, we’ve removed the need for ;; after top-level declarations, and added a bunch of indentation sensitivity, thus making several keywords optional: begin and end are implicit in the body of a fun, match, or let, which has made those keywords almost entirely obsolete. The body of a let also need not be preceded by in if meaning is clear from indentation.

To demonstrate, where you would have

let foo =
  let bar = fun x -> begin
  end in begin
    bar ();
    bar 1;
  end ;;

One can now write

let foo =
  let bar = fun x ->
  bar ()
  bar 1

Moreover, we’ve added shorthand syntax for building and destructuring records: { x, y, z } is equivalent to { x = x, y = y, z = z } in both pattern and expression position.

Changes to record typing

Whereas { x with a = b } would extend the record x to contain a new field a (with value b), it’s now monomorphic update of the record x. That is: x must already contain a field called a, with the same type as b.

This lets you write a function for updating a field in a record, such as the one below, which would previously be impossible. Supporting polymorphic update is not a priority, but it’d be nice to have. The way PureScript, another language with row-polymorphic records, implements polymorphic update does not fit in with our constraint based type system. A new type of constraint would have to be introduced specifically for this, which while not impossible, is certainly annoying.

let foo : forall 'row. { 'row | x : int } -> { 'row | x : int } =
  fun r -> { r with x = r.x + 1 }

The impossibility of supporting polymorphic update with regular subsumption constraints aba \le b stems from the fact that, when faced with such a constraint, the compiler must produce a coercion function that turns any aa into a bb given the types alone. This is possible for, say, field narrowing—just pick out the fields you want out of a bigger record—but not for update, since the compiler has no way of turning, for instance, an int into a string.

Stronger support for Rank-N Types

Changes to how we handle subsumption have made it possible to store polymorphic values in not only tuples, but also general records. For instance:

let foo = {
  apply : forall 'a 'b. ('a -> 'b) -> 'a -> 'b =
    fun x -> x
} (* : { apply : forall 'a 'b. ('a -> 'b) -> 'a -> 'b } *)

foo is a record containing a single polymorphic application function. It can be used like so:

let () =
  let { apply } = foo
  apply (+ 1) 1
  apply (fun x -> x) ()

Pattern-matching Let

A feature I’ve desired for a while now, let expressions (and declarations!) can have a pattern as their left-hand sides, as demonstrated above. These can be used for any ol’ type, including for cases where pattern matching would be refutable. I haven’t gotten around to actually implementing this yet, but in the future, pattern matching in lets will be restricted to (arbitrary) product types only.

type option 'a = Some of 'a | None
type foo = Foo of { x : int }

let _ =
  let Some x = ... (* forbidden *)
  let Foo { x } = ... (* allowed *)

Even more “in-the-future”, if we ever get around to adding attributes like OCaml’s, the check for this could be bypassed by annotating the declaration with (say) a [@partial] attribute.

Unfortunately, since Amulet is a strict language, these are a bit limited: They can not be recursive in any way, neither directly nor indirectly.

(* type error *)
let (a, b) = (b, a)

(* similarly *)
let (a, b) = x
and x = (a, b)

Cycle detection and warnings

A verification pass is run over the AST if type-checking succeeds, to forbid illegal uses of recursion (strict language) and, as an additional convenience, warn when local variables go unused.

For instance, this is forbidden:

let f = (fun x -> x) g
and g = (fun x -> x) f

And this gives a warning:

let _ =
  let { a, b } = { a = 1, b = 2 }

Plans for this include termination (and/or productivity) (as a warning) and exhaustiveness checks (as an error).

No more main

Since pattern-matching lets are allowed at top-level, there’s no more need for main. Instead of

let main () =

Just match on () at top-level:

let () =

This gets rid of the (not so) subtle unsoundness introduced by the code generator having to figure out how to invoke main, and the type checker not checking that main has type unit -> 'a, and also allows us to remove much of the silly special-casing around variables called main.

let main x = x + 1
(* attempt to perform arithmetic on a nil value *)

Implicit Parameters

A bit like Scala’s, these allow marking a function’s parameter as implicit and having the type checker find out what argument you meant automatically. Their design is based on a bit of reading other compiler code, and also the paper on modular implicits for OCaml. However, we do not have a ML-style module system at all (much to my dismay, but it’s being worked on), much less first class modules.

Implicit parameters allow ad-hoc overloading based on dictionary passing (like type classes, but with less inference).

type show 'a = Show of 'a -> string
let show ?(Show f) = f

let implicit show_string =
  Show (fun x -> x)

let "foo" = show "foo"

Here, unification makes it known that show is looking for an implicit argument of type show string, and the only possibility is show_string, which is what gets used.

Implicit laziness

There is a built-in type lazy : type -> type, a function force : forall 'a. lazy 'a -> 'a for turning a thunk back into a value, and a keyword lazy that makes a thunk out of any expression. lazy 'a and 'a are mutual subtypes of eachother, and the compiler inserts thunks/forces where appropriate to make code type-check.

let x && y = if x then force y else false
let () =
  false && launch_the_missiles ()

(* no missiles were launched in the execution of this program *)

General refactoring

  • Literal patterns are allowed for all types, and they’re tested of using (==).

  • Amulet only has one type constructor in its AST for all its kinds of functions: forall 'a. 'a -> int, int -> string and show string => unit are all represented the same internally and disambiguated by dependency/visibility flags.

  • Polymorphic recursion is checked using “outline types”, computed before fully-fledged inference kicks in based solely on the shape of values. This lets us handle the function below without an annotation on its return type by computing that { count = 1 } must have type { count : int } beforehand.

    Combined with the annotation on x, this gives us a “full” type signature, which lets us use checking for size, allowing polymorphic recursion to happen.

  type nested 'a = Nested of nested ('a * 'a) * nested ('a * 'a) | One of 'a
  let size (x : nested 'a) =
    match x with
    | One _ -> { count = 1 }
    | Nested (a, _) -> { count = 2 * (size a).count }
  • The newtype elimination pass was rewritten once and, unfortunately, disabled, since it was broken with some touchy code.

  • Operator declarations like let 2 + 2 = 5 in 2 + 2 are admissible.

  • Sanity of optimisations is checked at runtime by running a type checker over the intermediate language programs after all optimisations

  • Local opens are allowed, with two syntaxes: Either M.( x + y) (or M.{ a, b }) or let open M in x + y.

  • Amulet is inconsistent in some more ways, such as type : type holding.

  • There are no more kinds.


This post was a bit short, and also a bit hurried. Some of the features here deserve better explanations, but I felt like giving them an outline (haha) would be better than leaving the blag to rot (yet again).

Watch out for a blog post regarding (at least) implicit parameters, which will definitely involve the changes to subtyping involving records/tuples.