Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Polymorphism

All functions are polymorphic, even without annotations, argument and return types are inferred at each call site, and thus may differ from one site to another. Any internal constraints are calculated when the definition is compiled and are enforced at each call site. For example consider,

〉let f = |x, y| x + y
〉f
-: fn<'_2073: unbound: Error<ErrChain<`ArithError(string)>>, '_2069: unbound: Number, '_2067: unbound: Number, '_2071: unbound: Number>('_2067: unbound, '_2069: unbound) -> '_2071: unbound throws '_2073: unbound
159

The type is a bit of a mouthfull, lets format it a bit so it's easier to read.

fn<'_2073: unbound: Error<ErrChain<`ArithError(string)>>,
   '_2069: unbound: Number,
   '_2067: unbound: Number,
   '_2071: unbound: Number>
('_2067: unbound, '_2069: unbound) -> '_2071: unbound throws '_2073: unbound

After fn the stuff between the <> are the type constraints, the syntax in this readout is a colon separated list of,

  • type variable name, for example '_2073
  • current value, or unbound if there is no current value
  • constraint type

We can remove the (unbound) current values and it becomes even easier to read,

fn<'_2073: Error<ErrChain<`ArithError(string)>>,
   '_2069: Number,
   '_2067: Number,
   '_2071: Number>
('_2067, '_2069) -> '_2071 throws '_2073

Here we can see that '_2067, '_2069, and '_2071 represent the two arguments and the return type of the function. They are all unbound, meaning that when the function is used they can have any type. They are also all constrained to Number, and this will be enforced when the function is called, it's arguments must be numbers and it will return a number. We learned this because internally the function uses +, which operates on numbers, this constraint was then propagated to the otherwise free variables representing the args and the return type.

So in plain English this says that the arguments to the function can by any type as long as it is a number, and the function will return some type which is a number. None of the three numbers need to be the same type of number.

Finally lets address throws '_2073. This states that the function may throw an error, and if it does it's type will be '_2073, which in this case is constrained to be

Error<ErrChain<`ArithError(string)>>.

This is what happens in the case of overflow, underflow, and other arithmetic errors. The throws clause of the type is used by the try catch(e) => ... expression to compute the type of e, which is just the union of all the throws types within the try catch.

We can indeed call f with different number types, and it works just fine,

〉f(1.0, 1)
-: Number
2

The type we get back really depends on the values we pass. For example,

〉f(1.1212, 1)
-: Number
2.1212

Wherever we use f the compiler will force us to handle every possible case in the Number type

Explicit Type Specifications

While the compiler does a pretty good job of inferring the types of functions, sometimes you want to express a constraint that can't be inferred. Suppose we wanted to modify the example in the last section to say that while you can pass any type of number to f, it has to be the same type for both arguments, and the return type will be the same as the argument type. We can say that using type annotations.

〉let f = 'a: Number |x: 'a, y: 'a| -> 'a x + y
〉f
-: fn<'a: unbound: Number, '_2101: unbound: Error<ErrChain<`ArithError(string)>>>('a: unbound, 'a: unbound) -> 'a: unbound throws '_2101: unbound
160

In type annotations of lambda expressions,

  • The constraints come before the first |, separated by commas if there are multiple constrained type variables. e.g. 'a: Number
  • Each argument may optionally have a : Type after it, and this will set it's type, e.g. x: 'a
  • After the second | you can optionally include an -> Type which will set the return type of the function, e.g. -> 'a
  • After the return type, you can optionally specify a throws type, throws Type, which will set the type that is thrown by the function

The type we ended up with is actually quite a bit simpler, but lets format it anyway,

fn<'a: Number,
   '_2101: Error<ErrChain<`ArithError(string)>>>
('a, 'a) -> 'a throws '_2101

We just have two variables now, 'a representing both argument types and the return type, and '_2101 representing the throws type. We can still call this f with any number type,

〉f(1.212, 2.0)
-: f64
3.2119999999999997

However notice that we get back the explicit type we passed in,

〉f(2, 2)
-: i64
4

In one case f64, in the other i64. We can't pass numbers of different types,

〉f(1, 1.2)
error: in expr

Caused by:
    0: at: line: 1, column: 6, in: f64:1.2
    1: type mismatch 'a: i64 does not contain f64

Here the compiler is saying that 'a is already initialized as i64 and i64 doesn't unify with f64.

Higher Order Functions

Since functions are first class, they can take other functions as arguments, and even return functions. These relationships can be often inferred automatically without issue, but sometimes annotations are required.

〉 let apply = |x: 'a, f: fn('a) -> 'b throws 'e| -> 'b throws 'e f(x)
〉 apply
-: fn<'e: unbound: _>('a: unbound, fn('a: unbound) -> 'b: unbound throws 'e: unbound) -> 'b: unbound throws 'e: unbound
163

Here we've specified a single argument apply, it takes an argument, and a function f, and calls f on the argument. Note that we've explicitly said that whatever type of error f throws, apply will throw as well. That was constrained by the compiler to _ meaning basically this could throw anything or also not throw at all, it just depends on f.

We can see a more practical example in the type of array::map (this implementation of which I will not repeat here), which is,

fn(Array<'a>, fn('a) -> 'b throws 'e) -> Array<'b> throws 'e

So map takes an array of 'a, and a function mapping 'a to 'b and possibly throwing 'e and returns an array of 'b possibly throwing 'e.