Skip to content
This repository has been archived by the owner on Oct 14, 2023. It is now read-only.

Refactoring structures

Floris van Doorn edited this page Apr 25, 2017 · 2 revisions

The current approach

Encoding inheritance

We currently flat all inherited fields. For example, given

structure foo :=
(x : nat) (y : nat)

structure boo :=
(x : nat) (z : nat)

structure bla extends foo, boo :=
(w : nat)

The structure bla is internally represented as

structure bla :=
(x : nat) (y : nat) (z : nat) (w : nat)

We automatically merge "duplicate" fields (e.g., x). Lean will sign an error if the type of duplicate fields do not match. Remark, the files at library/init/algebra use multiple inheritance in many different places, and there are many duplicate fields.

We also automatically generate the coercions

def bla.to_foo (b : bla) : foo :=
{x := b^.x, y := b^.y}

def bla.to_boo (b : bla) : foo :=
{x := b^.x, z := b^.z}

This way of encoding coercions guarantee that given b : bla, b^.x, b^.to_foo^.x and b^.to_boo^.x are all definitionally equal. This would not be the case if we had encoded the coercions as:

def bla.to_foo : bla → foo 
| ⟨x, y, z, w⟩ := ⟨x, y⟩

def bla.to_foo : bla → boo 
| ⟨x, y, z, w⟩ := ⟨x, z⟩

When declaring a structure, we often need to refer to a parent structure. @semorrison makes a heavy use of this feature, and the algebraic hierarchy indirectly uses it to be able to use notation while declaring the structure. For example, we can write

class group (α : Type u) extends monoid α, has_inv α :=
(mul_left_inv : ∀ a : α, a⁻¹ * a = 1)

Because group has the parents monoid (which is indirectly an instance of has_mul and has_one) and has_inv. If we try

set_option pp.all true
#check @group.mul_left_inv

We have that the reference to parent structure monoid is encoded as

(@monoid.mk α (@group.mul α c) (@group.mul_assoc α c) (@group.one α c)
              (@group.one_mul α c) (@group.mul_one α c))

The type of group.mul_left_inv is already big, but, in principle, it can be simplified. For example, the term

(@semigroup.to_has_mul α (@monoid.to_semigroup α 
   (@monoid.mk α (@group.mul α c) (@group.mul_assoc α c) (@group.one α c) 
                 (@group.one_mul α c) (@group.mul_one α c))))

reduces to @has_mul.mul α (@group.mul α c). This kind of reduction minimizes the performance problem, but does not solve it. In this example, we got a substantial reduction because we were coercing the parent structure to a structure with a single field has_mul. For an extreme case, we can try

set_option pp.all true
#check @ordered_ring.mk

Advantages:

1- We can use multiple inheritance even if there are duplicate fields.

2- We can rename inherited field names.

3- Projections are fast.

4- The object memory layout is very similar to the memory layout used in programming languages such as C/C++.

Disadvantages:

1- Coercion to the parent structure is slow.

2- There is not cheap way of referring to the parent structure at declaration time.

3- Our structures keep getting bigger and bigger. For example, "single liners" such as

class decidable_linear_ordered_semiring (α : Type u) extends linear_ordered_semiring α, decidable_linear_order α

are monstrosities behind the scenes.

Encoding the algebraic hierarchy

We use type based indexed type classes to encode the algebraic hierarchy. The main problem with this approach is that classes such as semigroup, monoid, comm_semigroup, comm_monoid and group may have many different instances for the same type. Haskell has a similar problem with the Monoid type class. Another problem is that we can construct a monoid using list.append and list.nil, but none of the monoid lemmas can be easily used since they are all stated using the constants mul and one. For example, if we mark the monoid lemmas as [simp], and use the simp tactic, we will not be able to reduce the subterm l ++ [] into l.

Another problem with the current hierarchy is that we have many duplicates (e.g., add_semigroup, add_monoid, etc). We use tactics to reduce the burden of creating these copies, but it still looks like an ugly hack.

The main advantage of this approach is that most lemmas can be viewed as rewriting rules, and we can use a minor variation of first-order matching (e.g., we need to add support for type class resolution).

The new proposal

Encoding inheritance

If we disallow duplicate fields in parent structures, then we can have a more efficient encoding for inheritance. Example:

structure foo :=
(x : nat) (y : nat)

structure boo :=
(w : nat) (z : nat)

structure bla extends foo, boo :=
(bit : bool)

The structure bla would be encoded as

structure bla extends foo, boo :=
(foo_parent : foo)
(boo_parent : boo)
(bit        : bool)

That is, we have field for each parent. Now, coercion to parent structures are very efficient, and we can easily reference parent structures at declaration time. The drawbacks are: parent structures cannot have duplicate fields. We also need to provide the "illusion" to users that bla inherited the fields from foo and boo. For example, given b : bla, we want to be able to write b^.x, {b with x := 1, bit := ff}. Tactics such as cases should also provide this illusion. That is, cases b would introduce 5 hypotheses x, y, w, z, bit instead of foo_parent and boo_parent. Field renaming at declaration time would also be disallowed.

As described above, we use multiple inheritance with duplicate fields in many different places at library/init/algebra. Example:

class decidable_linear_ordered_semiring (α : Type u) extends linear_ordered_semiring α, decidable_linear_order α

I believe this would be a minor annoyance. First, we can manually show that a decidable_linear_ordered_semiring α is also a decidable_linear_order α. Second, the following modification will also minimize the problem.

Encoding the algebraic hierarchy

We switch to unbundled type classes for all type classes in library/init/algebra/order.lean, library/init/algebra/group.lean, and library/init/algebra/ordered_group.lean. The idea is to replace type classes such as

class semigroup (α : Type u) extends has_mul α :=
(mul_assoc : ∀ a b c : α, a * b * c = a * (b * c))

class comm_semigroup (α : Type u) extends semigroup α :=
(mul_comm : ∀ a b : α, a * b = b * a)

with

class is_semigroup {α : Type u} (op : α -> α -> α) :=
(assoc : ∀ a b c : α, op (op a b) c = op a (op b c))

class is_comm_semigroup (α : Type u) (op : α -> α -> α) extends is_semigroup op :=
(comm : ∀ a b : α, op a b = op b a)

That is, we use the operations to index the type classes too.

We are already using two unbundled type classes with some success https://github.com/leanprover/lean/blob/master/library/init/algebra/classes.lean#L10-L14 They are used by theory_ac.cpp. They are very convenient to use. Check the tests at tests/lean/run. The AC support in the congruence closure module works for +, *, union, intersection, etc. We just defined the instances and the automation works. We want to continue this approach in the SMT module, and use it in the arithmetic/algebraic normalizer. The goal is to delete all the little lemmas we have in the algebraic hierarchy, and keep only the ones we need for the automation.

The unbundled approach doesn't work with the simplifier as is. The problem is that the lemmas are hard to index. For example: the left hand side of the assoc lemma is

(?op (?op ?a ?b) ?c)

where ?op, ?a, ?b and ?c are metavariables (aka matching variables). We don't have a single constant that we can use to filter the locations where this lemma can be applied.

We think this is not a big problem, because in the long term, we want to use the algebraic normalizer to apply the lemmas. That is, after we have the arithmetic/algebraic normalizer, we will not need simp lemmas such as mul_assoc and mul_comm. Actually, we may not even need type classes such as semigroup, monoid, etc.

By parametrizing the arithmetic and smt theory modules with unbundled type classes, we will have a very flexible approach. The main drawback is that the simplifier will not be able to use the fully generic lemmas. Another potential problem is performance: theory_ac.cpp generates many type class resolution problems. It generates at least one for every subterm of the form f a b. I'm not sure how expensive it will be. The simplifier also generates a lot of type class resolution problems.

Given a subterm f a b, the algebraic normalizer will have to decide whether the following instances have inhabitants: is_semigroup A f, is_comm_semigroup A f, is_monoid A f _, is_comm_monoid A f _, is_group A f _ _, etc where A is the type of a. This is a potential performance bottleneck because the algebraic normalizer will have to make these decisions for many subterms. We can cache results (successes and failures). We will potentially have many more failures than successes. We need a mechanism for failing quickly. One idea is to add a new attribute @[algebraic_class], and use to mark algebraic classes such as is_semigroup. Then whenever we add a new instance for one of the algebraic classes, we record in a set S all constants (and local constants) occurring as parameters. Now, if the head symbol of f is not in the set S we don't even try to synthesize the type classes is_semigroup A f, is_comm_semigroup A f, ... This trick is similar to the keyed-matching approach we use in the rewrite tactic. Remark: we may potentially miss solutions when the head symbol of f is not in S because of is not reduced. Example: S = {add, mul}, and f is @id _ add _ _

We can also use the new "output" parameters available in Lean to encode the unbundled type classes. The "output" parameters in Lean are really input/output. So, we can retrieve is_monoid instances by providing just α, (α, op), (α, u), and (α, op, u). There is a performance overhead for type classes that have output parameters.

class is_monoid {α : Type u} (op : out_param $ α → α → α) (u : out_param α) extends is_semigroup op := 
...

The main objective is to make sure we can effectively use these type classes in applications. We will find instances of algebraic structures in many different applications (See Haskell type classes). Group theory is used in crypto. More generally, it is relevant in any domain where we have transformations, an identity transformation, a composition operator, and an inverse. It feels weird to tell users that whenever they have a group in their application, they MUST use the constants mul or add. Otherwise, the automation will not work. The list.append and list.nil example emphasizes this issue. It is a monoid, a left_cancel_semigroup, and a right_cancel_semigroup. We want users to be able to use the algebraic normalizer without forcing them to use add for list.append and zero for list.nil.

For more complex structures (e.g., rings and fields), it may be reasonable to force users to stick to some notation, and tell them that the automation will only work if they use add and mul. Note that we already do it in many cases (e.g., monad, functor, ...). I think this approach works reasonably well whenever type indexed type classes is a good match. By a good match, I meant "given a type A, there is only one instance of type class C".

Other goodies

We want to be able to define notation as we declare structures. Example:

class functor (f : Type u → Type v) : Type (max u+1 v) :=
(map : Π {α β : Type u}, (α → β) → f α → f β)
(map_const : Π {α : Type u} (β : Type u), α → f β → f α := λ α β, map ∘ const β)
(infixr <$> := map)
(infixr <$ := map_const)

and avoid artificial declarations such as

def fmap {f : Type u → Type v} [functor f] {α β : Type u} : (α → β) → f α → f β :=
functor.map

def fmap_const {f : Type u → Type v} [functor f] {α : Type u} : Π (β : Type u), α → f β → f α :=
functor.map_const

For example, in the current approach we use, we rely on the fact that @add nat nat_has_add is not reduced to nat.add when we use dsimp or the SMT preprocessor. This is not the case if we use has_add.add instead of add. For example, dsimp will reduce @has_add.add nat nat_has_add into nat.add.

Clone this wiki locally