Controlling Instance Search
An instance of the Add
class is sufficient to allow two expressions with type Pos
to be conveniently added, producing another Pos
.
However, in many cases, it can be useful to be more flexible and allow heterogeneous operator overloading, where the arguments may have different types.
For example, adding a Nat
to a Pos
or a Pos
to a Nat
will always yield a Pos
:
def addNatPos : Nat → Pos → Pos
| 0, p => p
| n + 1, p => Pos.succ (addNatPos n p)
def addPosNat : Pos → Nat → Pos
| p, 0 => p
| p, n + 1 => Pos.succ (addPosNat p n)
These functions allow natural numbers to be added to positive numbers, but they cannot be used with the Add
type class, which expects both arguments to add
to have the same type.
Heterogeneous Overloadings
As mentioned in the section on overloaded addition, Lean provides a type class called HAdd
for overloading addition heterogeneously.
The HAdd
class takes three type parameters: the two argument types and the return type.
Instances of HAdd Nat Pos Pos
and HAdd Pos Nat Pos
allow ordinary addition notation to be used to mix the types:
instance : HAdd Nat Pos Pos where
hAdd := addNatPos
instance : HAdd Pos Nat Pos where
hAdd := addPosNat
Given the above two instances, the following examples work:
#eval (3 : Pos) + (5 : Nat)
8
#eval (3 : Nat) + (5 : Pos)
8
The definition of the HAdd
type class is very much like the following definition of HPlus
with the corresponding instances:
class HPlus (α : Type) (β : Type) (γ : Type) where
hPlus : α → β → γ
instance : HPlus Nat Pos Pos where
hPlus := addNatPos
instance : HPlus Pos Nat Pos where
hPlus := addPosNat
However, instances of HPlus
are significantly less useful than instances of HAdd
.
When attempting to use these instances with #eval
, an error occurs:
#eval HPlus.hPlus (3 : Pos) (5 : Nat)
typeclass instance problem is stuck, it is often due to metavariables
HPlus Pos Nat ?m.7527
This happens because there is a metavariable in the type, and Lean has no way to solve it.
As discussed in the initial description of polymorphism, metavariables represent unknown parts of a program that could not be inferred.
When an expression is written following #eval
, Lean attempts to determine its type automatically.
In this case, it could not.
Because the third type parameter for HPlus
was unknown, Lean couldn't carry out type class instance search, but instance search is the only way that Lean could determine the expression's type.
That is, the HPlus Pos Nat Pos
instance can only apply if the expression should have type Pos
, but there's nothing in the program other than the instance itself to indicate that it should have this type.
One solution to the problem is to ensure that all three types are available by adding a type annotation to the whole expression:
#eval (HPlus.hPlus (3 : Pos) (5 : Nat) : Pos)
8
However, this solution is not very convenient for users of the positive number library.
Output Parameters
This problem can also be solved by declaring γ
to be an output parameter.
Most type class parameters are inputs to the search algorithm: they are used to select an instance.
For example, in an OfNat
instance, both the type and the natural number are used to select a particular interpretation of a natural number literal.
However, in some cases, it can be convenient to start the search process even when some of the type parameters are not yet known, and use the instances that are discovered in the search to determine values for metavariables.
The parameters that aren't needed to start instance search are outputs of the process, which is declared with the outParam
modifier:
class HPlus (α : Type) (β : Type) (γ : outParam Type) where
hPlus : α → β → γ
With this output parameter, type class instance search is able to select an instance without knowing γ
in advance.
For instance:
#eval HPlus.hPlus (3 : Pos) (5 : Nat)
8
It might be helpful to think of output parameters as defining a kind of function. Any given instance of a type class that has one or more output parameters provides Lean with instructions for determining the outputs from the inputs. The process of searching for an instance, possibly recursively, ends up being more powerful than mere overloading. Output parameters can determine other types in the program, and instance search can assemble a collection of underlying instances into a program that has this type.
Default Instances
Deciding whether a parameter is an input or an output controls the circumstances under which Lean will initiate type class search. In particular, type class search does not occur until all inputs are known. However, in some cases, output parameters are not enough, and instance search should also occur when some inputs are unknown. This is a bit like default values for optional function arguments in Python or Kotlin, except default types are being selected.
Default instances are instances that are available for instance search even when not all their inputs are known. When one of these instances can be used, it will be used. This can cause programs to successfully type check, rather than failing with errors related to unknown types and metavariables. On the other hand, default instances can make instance selection less predictable. In particular, if an undesired default instance is selected, then an expression may have a different type than expected, which can cause confusing type errors to occur elsewhere in the program. Be selective about where default instances are used!
One example of where default instances can be useful is an instance of HPlus
that can be derived from an Add
instance.
In other words, ordinary addition is a special case of heterogeneous addition in which all three types happen to be the same.
This can be implemented using the following instance:
instance [Add α] : HPlus α α α where
hPlus := Add.add
With this instance, hPlus
can be used for any addable type, like Nat
:
#eval HPlus.hPlus (3 : Nat) (5 : Nat)
8
However, this instance will only be used in situations where the types of both arguments are known. For example,
#check HPlus.hPlus (5 : Nat) (3 : Nat)
yields the type
HPlus.hPlus 5 3 : Nat
as expected, but
#check HPlus.hPlus (5 : Nat)
yields a type that contains two metavariables, one for the remaining argument and one for the return type:
HPlus.hPlus 5 : ?m.7706 → ?m.7708
In the vast majority of cases, when someone supplies one argument to addition, the other argument will have the same type.
To make this instance into a default instance, apply the default_instance
attribute:
@[default_instance]
instance [Add α] : HPlus α α α where
hPlus := Add.add
With this default instance, the example has a more useful type:
#check HPlus.hPlus (5 : Nat)
yields
HPlus.hPlus 5 : Nat → Nat
Each operator that exists in overloadable heterogeneous and homogeneous versions follows the pattern of a default instance that allows the homogeneous version to be used in contexts where the heterogeneous is expected. The infix operator is replaced with a call to the heterogeneous version, and the homogeneous default instance is selected when possible.
Similarly, simply writing 5
gives a Nat
rather than a type with a metavariable that is waiting for more information in order to select an OfNat
instance.
This is because the OfNat
instance for Nat
is a default instance.
Default instances can also be assigned priorities that affect which will be chosen in situations where more than one might apply. For more information on default instance priorities, please consult the Lean manual.
Exercises
Define an instance of HMul (PPoint α) α (PPoint α)
that multiplies both projections by the scalar.
It should work for any type α
for which there is a Mul α
instance.
For example,
#eval {x := 2.5, y := 3.7 : PPoint Float} * 2.0
should yield
{ x := 5.000000, y := 7.400000 }