How to write a type-safe unwrap (aka fromJust)
Tips and tricks for writing functions that take proofs as arguments.
Let's say you've written a programming language in Coq. You have nice inductives for your ASTs; one for untyped terms (UntypedAST) and one for typed terms (TypedAST). You wrote a simple typechecker, and maybe an interpreter, too!
You write a few programs…
Example well_typed := UAdd (UNat 1) (UNat 1). Example ill_typed := UAdd (UNat 1) (UBool true).
… typecheck them:
Definition tc_good := typecheck well_typed.Definition tc_bad := typecheck ill_typed.
… and attempt to run them:
D'oh! interp takes a TypedAST, but typecheck returns an option. What do we do?
We can write a simple wrapper though, with a default value for the None case:
Definition unwrap_default (o: option TypedAST) : TypedAST := match o with | Some t => t | None => {| ast := TNat 1 |} end.
But now we silently swallow type errors, which isn't ideal:
Let's see how we can get a safe but convenient version of unwrap (aka fromJust in the Haskell world and Option.get in OCaml).
Take 1: Pass a proof as an extra argument
The most straightforward way is to generalize unwrap by adding a proof that its argument is not None:
Definition unwrap {A} (o: option A) (not_none: o <> None) : A := match o return _ = o -> A with | Some a => fun _ => a | None => fun is_none => False_rect _ (not_none is_none) end eq_refl.
… it works, but it's not much fun for callers:
We can improve things slightly with tactics in terms:
… but the generated terms are not pretty, so if you ever store them unreduced anywhere, you're in for all sorts of unpleasantness:
Not great. Still, here is another example for comparison, this time using known-good indices into a list:
Definition nth_in_bounds {A} (l: list A) (n: nat) (in_bounds: n < List.length l) := unwrap (List.nth_error l n) (proj2 (List.nth_error_Some l n) in_bounds).
Note that (maybe surprisingly) the computation doesn't block, despite the fact that the definition of nth_in_bounds uses an opaque proof List.nth_error_Some. The reason is that, as we've seen, unwrap doesn't actually look at the proof. In fact, in general, proofs don't tend to block computation, because Coq disallows elimination of informative Props into type (that is, programs that return non-Prop results can't inspect proofs — except non-informative ones, like eq_refl).
Take 2: Use an equality proof
The main pain point in the previous example was the complexity of the proof terms, so let's simplify them. Instead of proving o <> None, we'll prove that is_some o = true, and the proof will always be eq_refl:
Definition is_some {A} (o: option A) : bool := if o then true else false.A: Type
o: option Ais_some o = true -> o <> NoneA: Type
o: option Ais_some o = true -> o <> NoneA: Type
a: Ais_some (Some a) = true -> Some a <> NoneA: Typeis_some None = true -> None <> Noneall: congruence. Qed.A: Type
a: Atrue = true -> Some a <> NoneA: Typefalse = true -> None <> None
Now we can define a new variant of unwrap:
Definition unwrap_dec {A} (o: option A) (is_some_true: is_some o = true) : A := unwrap o (is_some_not_none is_some_true).
Much nicer! Now the proof is always the same, and we can even define a notation to hide it:
Notation unwrap_dec' o := (unwrap_dec o eq_refl).Here's how it looks for list indices:
Definition nth_in_bounds_dec {A} (l: list A) (n: nat) (lt_true: (n <? List.length l) = true) := nth_in_bounds l n (proj1 (Nat.ltb_lt _ _) lt_true).
One significant advantage of this strategy is that we can control the reduction strategy used to check that eq_refl has the right type (ensuring that the application of unwrap_dec is well-typed requires checking that eq_refl: is_some _ = true, which requires reducing is_some _ to unify it with true). Concretely, we can write (@eq_refl bool true : is_some tc_good = true) to using normal unification, (@eq_refl bool true <: …) to call vm_compute, and <<: to call native_compute.
As before, though, the proof term that we're passing is in fact dead code, and the error messages are not ideal:
Take 3: Use a dependent return type
We know that we only intend to call unwrap with arguments that reduce to Some _. We can make this explicit in the return type, instead of changing the arguments:
Inductive error : string -> Type := Err (s: string) : error s. Definition unwrap_dep {A} (o: option A) : if o then A else error _ := match o with | Some a => a | None => Err "Expecting Some, got None" end.
Here we're saying that we'll return an A if given a Some, and an error otherwise. And indeed, the error messages are much nicer:
Here's how it looks for list indices:
Definition nth_in_bounds_dep {A} (l: list A) (n: nat)
: if lt_dec n (List.length l) then A else error _ :=
match lt_dec n (List.length l) as cmp
return (if cmp then A else error _) with
| left in_bounds => nth_in_bounds l n in_bounds
| right _ => Err "Index is out of bounds"
end.Bonus 1: Using unification
After I wrote this post, my colleague Jason Gross showed me another quite clever implementation of unwrap, leveraging inference:
Notation unwrap_refl o := ((fun v (pf : o = Some v) => v) _ eq_refl) (only parsing).
The trick here is to force unification to infer the value inside the option: Coq will unify o = Some ?v (the type of pf) with ?a = ?a (the type of eq_refl), and instantiate ?v in passing, which the function then returns. Nifty!
Bonus 2: Using tactics in terms
Here's one final way to proceed with this, using tactics in terms:
Notation unwrap_tac opt :=
ltac:(match (eval hnf in opt) with
| Some ?v => exact v
| ?other => fail "Error:" other "isn't [Some _]"
end) (only parsing).In practice, it works OK, but hnf is very slow (it's based on the same code as simpl). The cbv tactic and its faster cousins like vm_compute and native_compute are usually faster, but they get very costly if the terms are large and don't need to be fully normalized to determine whether we're in the Some or None case (think of a case like Some (very large term), where hnf will be free and cbv very slow).
Knowing this, it's a bit easier to understand why the unwrap_dec trick above works well: the type check that ensures that eq_refl has type is_some opt = true is essentially computing the head-normal form of opt and comparing it to Some, but it does that using Coq's fast reduction tactics. In fact, Jason has done a lot of work on exploring alternative strategies that combine reflection and fast full-reduction tactics such as vm_compute or native_compute to give fine-grained control over reduction.