The Praxis Type System
This post explains the main distinguishing features of the Praxis language’s type system.
Affine Types
Praxis has a type system which includes affine types. A value of an affine type is a value that must be used at most once. This is closely related to linear types whose values must be used exactly once. Both affine and linear type systems allow the use of destructive updates whilst maintaining purity. This allows us to combine the efficiency of strict mutable data structures (like an imperative language) with the reasoning power of immutability (like a pure functional language).
Let’s start by looking at an example of an affine type in Praxis, a dynamic array:
new : forall a. (USize -> a, USize) -> Array a
set : forall a. (Array a, USize, a) -> Array a
operator (_ [ _ ] <- _) = set
xs = new (const False, 3) -- [False, False, False]
ys = xs[0] <- True -- [True, False, False]
A couple of things to note:
new
andset
are functions (with implementations omitted) for creating and updating an array, respectively. In Praxis we tend to prefer uncurried functions over curried functions, for reasons that will be explained later on.operator
is used to define a mixfix operator forset
. Praxis has a unique system for defining associativity and precedence of user-defined operators, but we can ignore that for now.
The fact that Array
is an affine type means we can interpret the latter snippet in two equivalent ways:
xs
is a mutable array, and when it is destructively updated it is given a new nameys
.xs
andys
are both immutable arrays. We can treatxs
as immutable because the only way to observe a mutation ofxs
would be to read it after the update. This is not possible since that would mean usingxs
more than once.
References
Continuing with arrays, we naturally want to write a get
function to read an element of an array at a given index. We might naively think of a function with the following signature:
get : (Array a, USize) -> a
operator (_ [ _ ]) = get
y0 = ys[0]
y1 = ys[1] -- Not allowed! ys has already been used.
There is a big problem here: After the first get
call we have used ys
, and so can’t use it again. An array that you can only access once is not very useful! To remedy this, Praxis has references, which allow unlimited read-only access.
References are created using a read expression, where read x in e
means to take the variable x
as a reference within the expression body e
. This comes with a few rules:
- Reference Type
If ordinarilyx : a
, thenx
is a reference with type&l a
withine
, where&l
is a unique compiler-generated reference label.- Note: References are not affine, so
x
may occur arbitrarily many times withine
.
- Note: References are not affine, so
- Reference Lifetime
The reference bound tox
must not outlive the scope ofe
. Specifically, the reference label&l
must not appear anywhere in the type ofe
.- For example, neither
read x in x
norread x in (x, y)
are allowed.
- For example, neither
- Reads Before Use
Multiple read expressions ofx
are allowed, but none may occur afterx
is used. - Syntax Sugar
When usingread
for a function call, likeread x in f x
, the syntax sugarf &x
can be used. This also applies through tuples, soread x in f (x, e)
can be writtenf (&x, e)
.
Now using references and reads, we have:
get : forall &r a. (&r Array a, USize) -> &r a
operator (_ [ _ ]) = get
y0 = &ys[0] -- read ys in get (ys, 0)
y1 = &ys[1] -- read ys in get (ys, 1)
Here &r
is a reference variable, which abstracts over reference labels. Note that applications of &r
are right-associative, so &r Array a
means &r (Array a)
.
Since get
is given a reference to an array, the element returned is also a reference. The same reference label is used to indicate that the lifetime of the element is coupled to the lifetime of the array. However, a reference of a non-affine type simplifies to the type itself. So in this example the return type &r Bool
simplifies to Bool
.
Comparisons with Rust
As a brief aside, for those familiar with Rust you may notice there is a similarity here to Rust’s model of ownership and borrowing. One can think of the affine restriction in Praxis as being simlar to ownership in Rust, with only immutable borrowing allowed, and with reference labels analagous to lifetime parameters.
These concepts tend to have simpler but more restrictive semantics in Praxis compared to their Rust counterparts. This is because they are checked solely through the type checker as opposed to having a separate stage like Rust’s borrow checker.
Views
Returning back to our example of arrays, let’s take a look at what a “map” function looks like.
In fact, there are two ways we could think of typing map, one using values and the other using references:
map_value : forall a b. -> (a -> b) -> Array a -> Array b
map_reference : forall &r a b. -> (&r a -> b) -> &r Array a -> Array b
These are both useful, for different situations:
map_value
takes ownership of the input array and this allows it to apply a transformation which takes ownership of the elements. In addition, the implementation may (subject to the sizes and alignments ofa
andb
) reuse the underlying storage of the input array for the output array.map_reference
operates on a reference to an array and so it is only allowed to apply a transformation which operates on references to the elements.
There are many other functions which, similar to map, could be written either using values or using references. Thankfully, Praxis provides a way to unify the two forms, using views:
map : forall ?v a b. -> (?v a -> b) -> ?v Array a -> Array b
Here ?v
is a view variable, which can be either a reference or the identity view @
, where @ a ~ a
. Note that as with references, views are right-associative.
Views are a powerful tool to abstract over mutability; Instead of having to write a mutable and non-mutable version of the same function or structure (e.g. an iterator), we can write one unifying version using views.
Pattern Matching
Let’s now take a break from arrays, to take a look at an implementation of map
for a list:
data List a = Nil () | Cons (a, List a)
map : forall ?v a b. -> (?v a -> b) -> ?v List a -> List b
map f = cases
Nil () -> Nil ()
Cons (x, xs) -> Cons (f x, map f xs)
Note: cases
is a function variant of case
, i.e. \x -> case x of ...
.
If you are familiar with functional languages, then you should be able to see how map
can be typed as:
forall a b. (a -> b) -> List a -> List b
.
The magic here is that map
can also be typed as:
forall &r a b. (&r a -> b) -> &r List a -> List b
.
And moreover, also as the (most general) type:
forall ?v a b. (?v a -> b) -> ?v List a -> List b
.
The reason this works is because pattern matching plays nicely with views and references. When you pattern match a value, reference, or view, the components are made available as values, references, or views (respectively).
For a simpler example, consider pairs. When matched against the pattern (x, y)
:
- A pair with type
(a, b)
yieldsx : a
andy : b
. - A reference to a pair with type
&r (a, b)
yieldsx : &r a
andy : &r b
. - A view of a pair with type
?v (a, b)
yieldsx : ?v a
andy : ?v b
.
Clone, Copy, Dispose, Capture
The type system contains four special constraints used to enforce affine semantics:
-
Clone a
requiresclone : forall &r. &r a -> a
. This is an explicit copy which affine types may or may not implement. It can have a trivial implementation (a bit-wise copy). -
Copy a
requiresClone a
and is the exact opposite of affine. When a variable with aCopy
type is used more than once, it will be implicitly copied usingclone
. -
Dispose a
requiresdispose : a -> ()
. This is invoked when an unused value goes out of scope. It can have a trivial implemtnation (a no-op). Currently all types in Praxis automatically implementDispose
, but the implementation may be overridden. In theory, linear types could be trivially added to Praxis by allowingDispose
to be unimplemented for some types. -
Capture a
means that a value of typea
may be safely captured in a function closure. It requires bothCopy a
and thata
does not contain any references or views.
Closures
We’ll finish with some notes on function closures.
Closures have a rather harsh requirement that captured variables must satisfy the aforementioned Capture
constraint.
The reason is to keep function types simple:
- Captured variables must satisfy
Copy
so that all function types satisfyCopy
. - Captured variables must not include references, otherwise closures could be used to smuggle references out of
read
.
This explains why functions in Praxis are often uncurried - to avoid unnecessary closures that could fall foul of Capture
.
As a final note, this preference against currying leads us to a different applicative operator, which corresponds neatly to the notion of a cartesian product:
prod : forall ?v ?w a b f | Cartesian f. (?v f a, ?w f b) -> f (?v a, ?w b)
operator (_ <*> _) = prod
For a formal specification of the type system, see this post.