Course4: Advanced dependent types

Preliminaries

The Coq file supporting today's course is available at
https://gitlab.math.univ-paris-diderot.fr/saurin/coq-lmfi-2023/-/blob/main/Lectures/Course4.v.
https://gitlab.math.univ-paris-diderot.fr/saurin/coq-lmfi-2023/-/blob/main/TP/TP4.v.
The aim of this fourth lecture is to deepend the investigation of dependent types in programming, pursuing the journey started in the previous lecture.


Wrap up on basic dependent types

Let us sum up what we saw during the introductory lecture on dependent types.

Definition nat_or_bool (b:bool) : Type :=
  if b then nat else bool.

An example of value in this type.

Definition value_nat_or_bool (b:bool) : nat_or_bool b :=
  match b return (nat_or_bool b) with
  | true ⇒ 0
  | falsefalse
  end.

Its type is not bool nat_or_bool: it is a dependent type, that is the return type depends on the value of the input:

Check value_nat_or_bool.

value_nat_or_bool : b : bool, nat_or_bool b
This match b ... is roughly equivalent to if b then 0 else false but writing a if here would not give Coq enough details. Even putting the explicit output type isn't enough, we add to use an extra syntax return ... to help Coq.
Let us "program" the desired type, starting from the number n. The obtained type depends from the value of this number: that's hence a dependent type. (Remember the power type considered in Lecture 2 and compare the following type with it.)

Fixpoint n_tuple (A:Type) (n: nat) :=
 match n with
 | 0 ⇒ A
 | S n ⇒ ((n_tuple A n) × A)%type
 end.

Check n_tuple.

Type -> nat -> Type

Fixpoint ints n : n_tuple nat n :=
  match n with
  | 0 ⇒ 0
  | S n' ⇒ ((ints n'), n)
  end.

Note that in the previous match the two branches have different types :
  • it is nat in the first case and
  • it is (n_tuple nat n' × nat) in the second case.
This is a dependent match, whose typing is quite different from all earlier match we have done, where all branches were having the same common type. Here Coq is clever enough to notice that these different types are actually the correct instances of the claimed type n_tuple nat n, respectively n_tuple nat 0 and n_tuple nat (S n'). But Coq would not be clever enough to guess the output type n_tuple nat n by itself, here it is mandatory to write it.

Compute ints 99 : n_tuple nat 99.
a 100-uple

Some explanations about implicit arguments

You already noticed that the type notations in Coq can be very heavy espacially to handle type instanciation and all the more when starting working with dependent types.
An essential feature of Coq to work smoothly with those types is the ability to declare some arguments as being implicit and let Coq infer the corresponding argument.

Definition compose A B C (f: BC) (g: AB) : AC :=
fun xf (g x).

Check compose.
Print compose.

An option, is, at the time of the definition, to specify which argument is implicit:

Definition compose_impl {A B C} (g: BC) (f: AB) : AC :=
fun xg (f x).

Another option is to automatically declare arguments as implicit when they can be inferred as such:

Set Implicit Arguments.

Definition compose' A B C (f: BC) (g: AB) : AC :=
fun xf (g x).

Check compose'.
Print compose'.

Fail Definition thrice (A:Set)(f:AA) := compose f (compose f f).

Definition thrice' (A:Set)(f:AA) := compose' f (compose' f f).

Check thrice'.
Print thrice'.

Unset Implicit Arguments.

One can deactivate implicite arguments by using @:

Check compose_impl.

Check @compose_impl.

It can also happen that one need to explicitly provide explicit arguments, for instance because of a partial application of arguments:

Check (compose' S).

Check (compose' (A := nat) S).

Inductive dependent types

In the previous lecture on dependent types, we saw a first dependent type encoding n-tuples:

Fixpoint n_tuple (A:Type) (n: nat) :=
 match n with
 | 0 ⇒ A
 | S n ⇒ ((n_tuple A n) × A)%type
 end.


We shall now study another encoding of n-tuples. We almost reuse the inductive definition of lists, but we add an extra parameter representing the length of the list so that the type itself is equiped with the information of the length of the list.

Inductive vect (A:Type) : natType :=
 | Vnil : vect A 0
 | Vcons n : Avect A nvect A (S n).

Notice that this is a inductive type definition, but the type being defined depends on values of type nat: it is a inductive dependent type:

Check vect.

vect : Type nat Type
This type vect is implemented in Coq standard library, see file Vector.v.
One can declare the domain arguments of the constructors (Vnil and Vcons) as being implicit arguments:

Arguments Vnil {A}.

Arguments Vcons {A}.

Let us see the encoding of triple (0,2,3) :

Check (Vcons 2 0 (Vcons 1 2 (Vcons 0 3 Vnil))).

The first argument of each constructor (numbers 2, 0, 1) indicates the lengths of each sub-vector. Since this is pretty predictable, and hence "boring", we could even hide the n argument of Vcons. As follows:

Arguments Vcons {A} {n}.

Check (Vcons 0 (Vcons 2 (Vcons 3 Vnil))).

But this n argument is still there internally and can be printed by requiring to print all information (in CoqIDE, this is managed in the "View" panel):

Set Printing All.
Check (Vcons 0 (Vcons 2 (Vcons 3 Vnil))).
@Vcons nat 2 0 (@Vcons nat 1 2 (@Vcons nat 0 3 (@Vnil nat))) : vect nat 3

Check (cons 0 (cons 2 (cons 3 nil))).
0 :: 2 :: 3 :: @nil nat)%list

Unset Printing All.

Since vectors are lists with some additional information, one can of course define a conversion function from vectors to lists:

Require Import List.
Import ListNotations.

Fixpoint v2l {A} {n} (v : vect A n) : list A :=
  match v with
  | Vnil ⇒ []
  | Vcons x vx::(v2l v)
  end.

But it is also possible to define the conversion in the other direction:

Fixpoint l2v {A} (l: list A) : vect A (length l) :=
  match l with
  | [] ⇒ Vnil
  | x :: lVcons x (l2v l)
  end.

In the previous definition, we benefited greatly from the implicit argument management offered by Coq. Try to define the same conversion function without declaring the nat argument as implicit...

The following definition (re-)computing the length of a vector is actually useless since we already known this length, it is the parameter n mentionned in the type vect. Later, we could prove that A n (v:vect A n), length v = n.

Fixpoint length {A} {n} (v: vect A n) : nat :=
 match v with
 | Vnil ⇒ 0
 | Vcons _ v ⇒ 1 + length v
 end.

We could more simply define it as:

Definition length' {A} {n} (v: vect A n) : nat := n.

With such vectors, we avoid the usual issues encountered when defining head and cons on lists, requiring to use a default value or an option type. Indeed, we can specify that we want to operate only on non-empty vectors, since they have a Vect A (S n) type for some n.
On lists we can define the head function using a default value:

Definition head {A} (l:list A) (default : A) : A :=
 match l with
 | [] ⇒ default
 | x :: _x
 end.

Or returning in the option A type:

Definition head_opt {A} (l:list A) : option A :=
 match l with
 | [] ⇒ None
 | x :: _Some x
 end.

On vectors, we can more simply restict the domain of the function Vhead:

Definition Vhead {A} {n} (v:vect A (S n)) : A :=
 match v with
 | Vcons x _x
 end.

Notice that the match branch for Vnil just vanished, and Coq did not complained about a missing case ! Actually, there does exist such a case internally, filled by Coq with a somewhat arbitrary code, that will never be accessed during computations.

Print Vhead.

Some check to see what is going on with the missing branch:

Print idProp.
Check idProp.
Print IDProp.

Compute Vhead ((Vcons 0 (Vcons 2 (Vcons 3 Vnil)))).

Fail Compute Vhead Vnil.

The error message is:
The term "Vnil" has type "vect ?A 0" while it is expected to have type "vect ?A (S ?n)".
That is, there is a type mismatch.

Everything goes smoothly here, since this function mimics the equations of the addition on nat.

Print Nat.add.
0 + m = m
S p + m = S (p+m)

Fixpoint Vapp {A} {n m} (v:vect A n) (v':vect A m) : vect A (n+m) :=
  match v with
  | Vnilv'
  
of type vect A m = vect A (0+m) = vect A (n+m) here
  
  | Vcons x vVcons x (Vapp v v')
                 
of type vect A (S (p+m)) = vect A ((S p)+m) = vect A (n+m) here
  end.

Alas, for other algorithms, we aren not so lucky. For instance, consider the (naive) definition of the reverse of a vector. A direct attempt is rejected as badly-typed. Coq would need to know that vect A (n+1) is equal to vect A (1+n). This is provably true, but not computationally true (a.k.a "convertible" in the Coq jargon). More precisely, we cannot have n+1 = 1+n by just the above (Peano) equations, this would later need a proof by induction.

Fail Fixpoint Vrev {A} {n} (v:vect A n) : vect A n :=
  match v with
  | VnilVnil
  | Vcons x vVapp (Vrev v) (Vcons x Vnil)
  end.

Which returns the following error message:
The term "Vapp (Vrev A n0 v0) (Vcons x Vnil)" has type "vect A (n0 + 1)" while it is expected to have type "vect A (S n0)".


We shall see two solutions to that problem:
A first solution is to use the Coq equality to "cast" between vectors of equal lengths. We'll detail more later how Coq represents its logical equality, for now let's just say that it's internally a syntactic correspondence between the two side of the equation. Hence "matching" over this proof h of type n=m would formally "equate" n with m. And all it well.

Print eq.

Definition Vcast {A} {n} {m} (v: vect A n)(h : n = m) : vect A m :=
  match h with
  | eq_reflv
  end.

But for defining our Vrev, we need a proof of n+1=1+n.

Require Import Arith.

SearchPattern (_ + 1 = _).
That is a lemma called Nat.add_1_r, exactly what we need: remember indeed that 1 + n reduces to S n

Fixpoint Vrev {A} {n} (v:vect A n) : vect A n :=
  match v with
  | VnilVnil
  | Vcons x vVcast (Vapp (Vrev v) (Vcons x Vnil)) (Nat.add_1_r _)
  end.

Or, forcing the implicit argument to be treated explicitly:

Fixpoint Vrev' {A} {n} (v:vect A n) : vect A n :=
  match v with
  | VnilVnil
  | @Vcons _ m x vVcast (Vapp (Vrev' v) (Vcons x Vnil)) (Nat.add_1_r m)
  end.

Issue: all computations with this Vrev is blocked, since they cannot access Coq standard proof of Nat.add_1_r (such a proof is said to be "opaque").

Compute Vrev (Vcons 1 (Vcons 2 (Vcons 3 Vnil))).

Solution inside the solution : do the proof ourself, in a "transparent" way. Forget about this proof for the moment.

Lemma add_1_r n : n + 1 = S n.
Proof.
 induction n; simpl; trivial. now f_equal.
Defined.

Print Nat.add_1_r.
Print add_1_r.

And not the usual Qed keyword, that would be opaque:
  • Qed ends an opaque proof while
  • Defined ends a transparent proof.

Fixpoint Vrev_transparent {A} {n} (v:vect A n) : vect A n :=
  match v with
  | VnilVnil
  | Vcons x vVcast (Vapp (Vrev_transparent v) (Vcons x Vnil)) (add_1_r _)
  end.

Compute Vrev_transparent (Vcons 1 (Vcons 2 (Vcons 3 Vnil))).

Another solution : another definition of Vcast, way more complex, that proceeds by induction over the vector instead of doing an induction over the equality proof. You can skip the (ugly) details of this function Vcast2, just notice that it does exist.

Definition Vcast2: {A m} (v: vect A m) {n}, m = nvect A n.
Proof.
 refine (fix cast {A m} (v: vect A m) {struct v} :=
  match v in vect _ m' return n, m' = nvect A n with
  |Vnilfun nmatch n with
    | 0 ⇒ fun HVnil
    | S _fun HFalse_rect _ _
  end
  |Vcons h wfun nmatch n with
    | 0 ⇒ fun HFalse_rect _ _
    | S n'fun HVcons h (cast w n' (f_equal pred H))
  end
 end); discriminate.
Defined.

Computing with this Vcast2 means deconstructing the whole vector v before reconstructing everything (but with a length expressed with the other side of the equality).

Print Vcast2.
fix ... match v ...Vil => ... Vnil ... | Vcons n h w => ... Vcons h ...
To get the underlying algorithm behind all these details, a nice way it to use the "extraction" tool, that convert a Coq definition into some corresponding OCaml code, and drop on the fly all logical parts (such as the equalities). More on that to come later.

Require Extraction.
Extraction Vcast2.

And finally :

Fixpoint Vrev2 {A} {n} (v:vect A n) : vect A n :=
  match v with
  | VnilVnil
  | Vcons x vVcast2 (Vapp (Vrev2 v) (Vcons x Vnil)) (Nat.add_1_r _)
  end.

And we can compute here, even with Coq standard proof of n+1=1+n.

Compute Vrev2 (Vcons 1 (Vcons 2 (Vcons 3 Vnil))).

As a conclusion:
  • programming with dependent type may help a lot by ruling out some impossible cases and producing functions with rich types describing precisely the current situation.
  • but it is not always convenient to proceed in this style, and may lead you to some definitional nightmare such as this Vrev.



Some more details about dependent types

Just as vectors can be compared to lists, we add an extra argument in nat to perfect binary trees, here encoding the depth of the tree.

Inductive fulltree (A:Type) : natType :=
| FLeaf : Afulltree A 0
| FNode n : fulltree A nfulltree A nfulltree A (S n).

Again, arguments of constructors may be treated implicitly:

Arguments FLeaf {A}.
Arguments FNode {A} {n}.

Check FNode (FNode (FLeaf 1) (FLeaf 2)) (FNode (FLeaf 1) (FLeaf 2)).

We have seen that the arrow type AB has a dependent counterpart, the product x:A, B x (also called Π-type).
Similarly, the pair type A×B, has a dependent version which is called sigT (for Σ-type), with a notation {x:A & B x}.

Print sigT.

Inductive sigT (A : Type) (P : A Type) : Type :=
existT : x : A, P x {x : A & P x}.
which is the inductive type, with syntax { ... & ... }

Check existT.

and its constructor

Just as the conclusion of a product may have a type B x which depends on the value x of the input, here the right component of this pair {x:A & B x} will have a type B x which depends on the value x present on the left of this pair. As the name existT of the constructor may suggests, this type can also be seen as an existential type : "there exists an x in A such that the right component is in B x".
Example : a type of all perfect binary trees on a domain A, regardless of theirs depth

Definition all_fulltrees A := { n : nat & fulltree A n }.

We can exhibit an inhabitant of this sum type, that is the pair of an m: nat and an inhabitant of fultree A m.

Definition some_fulltree : all_fulltrees nat :=
  existT _ 1 (FNode (FLeaf 1) (FLeaf 2)).

the _ is here the "predicate" B, that Coq can infer here as being fulltree nat.
Actually, Coq could even guess here the second argument (1), by typing the last one (obtaining fulltree A 1 here).

Check existT _ _ (FNode (FLeaf 1) (FLeaf 2)).

To manipulate dependent sums, we have some predefined projections:

Compute projT1 some_fulltree.
Compute projT2 some_fulltree.

of type : (fun n : nat => fulltree nat n) (projT1 some_fulltree) which is convertible to : fulltree nat 1

Check projT1.
Check projT2.



Definition blist (A:Type) := list { n & fulltree A n }.

For instance :

Definition singleton {A} (a:A) : blist A := [ existT _ 0 (FLeaf a) ].

We will see later a few other existential types :
  • the type of existential statements x:A, B x where A and B are in universe Prop.
  • the "mixed" existential type {x:A | B x} (with underlying type name sig), where B is a logical statement in Prop, but A is in Type (we call A an "informative" or "relevent" type).

Another famous dependent type : type Fin n, encoding a canonical finite set with exactly n elements. Or said otherwise, the type of all numbers strictly less than n. So this type is also called the "bounded integers".

Inductive Fin : natType :=
 | Zero n : Fin (S n)
 | Succ n : Fin nFin (S n).

As usual, let us get rid of some "boring" arguments
Arguments Zero {n}.
Arguments Succ {n}.

Nobody can be in type Fin 0, since all constructors of Fin have final types of the form Fin (S ...).
Then Fin 1 is a type with just one element

Check (Zero : Fin 1).
Fail Check (Succ Zero : Fin 1).

Then Fin 2 is a type with two elements, but no more

Check (Zero : Fin 2).
Check (Succ Zero : Fin 2).
Fail Check (Succ (Succ Zero) : Fin 2).

If we convert inhabitants of Fin n back to nat by forgetting all the inner implicit arguments, then we indeed get all nat numbers strictly less than n.

Fixpoint fin2nat {n} (m : Fin n) : nat :=
 match m with
 | Zero ⇒ 0
 | Succ m'S (fin2nat m')
 end.

Definition all_fin3 : list (Fin 3) := [Zero; Succ Zero; Succ (Succ Zero)].
Compute List.map fin2nat all_fin3.

Note : another approach for defining such "bounded" integers is to use an existential type to restrict nat.

Definition bounded_nat n := { p:nat | p < n }.

  • Pros : easy projection to nat, no need for a reconstruction like fin2nat above.
  • Cons : This implies to work with logical predicate < (in Prop, not the boolean comparison <? we have been using up to now) and requires to build arithmetical proofs. This is hence less suitable for the Vnth function below (no nice inductive structure).

The type Fin of "bounded" integers provides a neat way to specify and implement a safe access to the n-th element of a vector (type vect), recall:
Inductive vect' (A:Type) : nat Type :=
| Vnil : vect' A 0
| Vcons n : A vect' A n vect' A (S n).
Arguments Vnil {A}.
Arguments Vcons {A} {n}.
For a vector v in type vect A n, we can access any position p as long as p is in Fin n (hence garanteed to represent a number strictly less than n.

Fixpoint Vnth {A} {n} (p:Fin n) : vect A nA :=
 match p with
 | Zerofun vmatch v with Vcons x _x end
 | Succ pfun vVnth p (match v with Vcons _ vv end)
 end.

Notice that this type of programming is still relatively new in Coq, and still very fragile. For instance, in the previous example, one may be tempted to move the recursive call Vnth inside the final match v, and hence write:
| Succ p => fun v => match v with Vcons _ v => Vnth p v end
But this is rejected by Coq for the moment. Similarly, no way (yet ?) to move out the two fun v and factorize them in one fun v outside of match p.
Example of use: with a vector of size 3, one may access to elements at position 0, 1, 2 but not 3.

Definition testvec := Vcons 1 (Vcons 2 (Vcons 3 Vnil)).

Compute Vnth Zero testvec.
Compute Vnth (Succ Zero) testvec.
Compute Vnth (Succ (Succ Zero)) testvec.
Fail Compute Vnth (Succ (Succ (Succ Zero))) testvec.



This page has been generated by coqdoc