• No results found

Why termination checking?

In previous chapters we described a dependently typed language without any termi- nation checking at all. This is certainly a respectable point in the design space: other languages making similar choices include Cayenne [10], Cardelli’s Type:Type lan- guage [29], and ΠΣ [9]—and even recent versions of Haskell, if you squint a bit [50, 79]. Even so, for the full Zombie type system we believe that support for provably total functions is essential. There are two separate reasons for this.

7.1.1

Precision

The first reason is to better support program verification. Dependent types support both programming and verification in a single language, but without termination- checking, there are limitations about which properties are expressible as types. Most obviously, without termination checking one can prove partial but not total correct- ness properties. For example, we mentioned previously (Section 3.3) a function to match regular expressions:

match : (s:String) → (r:Regexp) → Maybe (Matches s r)

The type guarantees that if the function returnsJust m then the string matches. On the other hand, without termination checking there is clearly no way to express the fact that the function always returns.

Phrasing it differently, a language without termination checking can provesafety but not liveness properties. For example, if we use dependent types to encode access control (as in Aura [70] or Aglet [92]), then without termination checking we can be sure that the read function will not give access to unauthorized users, but it may still get stuck in an infinite loop.

For lightweight verification, not being able to prove liveness is perhaps OK. In real applications we want to known not only that a function is terminating but that it terminates reasonably quickly—a function which uses exponential time or space is as vulnerable to denial-of-service attacks as one which does not terminate at all. For most projects, formally proving space/time bounds does not provide enough benefit to justify the cost, and the same could be said for termination proofs.

Rather, the main reason we are interested in termination checking is to make more

properties and proofs expressible. For example, even putting aside the fact thatmatch may not terminate, the type given above has another drawback: it leaves open the possibility that the match incorrectly returns Nothing. In order to ensure that the function classifies strings correctly we want to give it a more expressive type, such as

match : (s:String) → (r:Regexp) → Either (Matches s r)

(Matches s r→ False) This type forces the programmer to construct a proof either that the string matches or that itdoesn’t match (using the standard definition of negation,¬P ≡P →False). But without termination checking, the typeMatches s r → Falseis uninformative— it is inhabited by the trivial functionλx.loop()—so types can only express properties which can be witnessed by some first-order datatype, such as Matches.

Without termination checking we could express soundness of match, but not com- pleteness. The same pattern holds for many examples beyond regular expression matching. For example, one classic illustration of the power of dependent types is

implementing correct-by-construction type checkers [86]. Here again, without ter- mination checking one can express that the typechecker is sound but not that it is complete.

A particularly important example is verified SAT-solvers for propositional logic (either the scaled-down example in Section 2.4, or full-strength solvers like Versat [100]). Without termination checking we can express that the solver is sound, i.e. that if it returns a variable assignment then that assignment does satisfy the formula. But that is a very uninteresting property! The user can easily check that manually, by just evaluating the formula with the given assignment. The critical question is completeness: can we trust the solver when it declares a formula to be unsatisfiable (after a long search using subtle techniques to cut down the search space)? Some SAT solvers can construct explicit proofs of unsatisfiability (in some given logic), but such proof terms can be hundreds of megabytes in size and take longer to verify than to generate in the first place [130]. It is better to prove the SAT-solver itself partially correct. But the property “the formula φ is unsatisfiable” involves a function space (we can write it as the dependent implication (σ : Assignment) → eval σ φ = false), so we need a type of total functions in order to express it.

Apart from stating properties, we also need termination-checking to prove them. Almost all interesting proofs involve induction. But the computational meaning of induction is just structural recursion; you are licensed to invoke the induction hy- pothesis on any structurally smaller term. The checks needed to make sure that an inductive proof is valid are exactly what is needed to termination-check a function. To summarize, while having a way to prove programs terminate is nice, having impli- cations and inductive proofs is crucial. In some language designs these would be two separate things. For example, in F* [133] types and propositional formulas are two syntactically separate categories, one of which is inhabited by program terms and the other automatically proved by an SMT solver. One of the benefits of making Zombie core a full-spectrum dependent language with unified syntax is that it avoids this duplication: by adding induction to the logic we get termination-checked programs for free.

7.1.2

Performance

The other reason to consider termination checking is to enable erasure. In the words of Randy Pollack, the point of writing a proof in a strongly normalizing calculus is that you don’t need to normalize it. For example, in Section 3.3 we mentioned that a function like

is still type safe if we call it with an infinite loop as the proof of the precondition; the expression safediv 3 0 (loop()) simply diverges. However, that means that we actually keep the function argument in the compiled code and execute it at runtime. In the case of safediv this is doubly unsatisfying: first because we know that the implementation of safediv just throws away that argument, and second because it is an equation, so it can in any case only evaluate to the uninformative value join. For function preconditions like these, we only need to know that the expression has

some value, but it doesn’t matter which one. Full Zombie handles this gracefully. We mark the precondition as an erased argument:

safediv : Nat → (y:Nat) → [p: isZero y = false] → Nat

Now, as long as proof a is known-terminating, Zombie allows it to be used as an implicit argument safediv 3 0 [a]. In this way, there is no trace of it at runtime. The functionmatch illustrates the dual property. In the previous subsection we gave it the type

match : (s:String) → (r:Regexp) → Maybe (Matches s r)

but in the case when it returns Just m, this requires constructing (and allocating memory for) an explicit witnessm. The clients to matchnever care about the specific value returned, only that there exists some value witnessing the postcondition. For regular expression matching the overhead is perhaps tolerable, but as we mentioned above, explicit witnesses for propositional unsatisfiability can easily be hundreds of megabytes in size. Again, full Zombie handles this gracefully. We define a different datatypeEMaybewhere the argument is erased, and as long asmis known-terminating match can return EJust [m], which erases to just a unit value at runtime.

Erasability in this sense is necessary for any practical dependently typed language. The precise design of erasure in Zombie is slightly ambitious because it internalizes information about erasiblity into the equational theory by making expressions which only differ in erased positions provably equal; in the terminology of Abel [4] this is “internal erasure”. This feature is not available in e.g. Coq without axioms (see Section 8.1). But even Coq provides “external erasure”: when using Coq’s program extraction feature to compile a function, all proof arguments of sort Prop are erased from the runtime representation. Even just external erasure requires termination- checking.

On the other hand, Coq and Agda do not erase expressions when evaluating them at type-checking time (in order to ensure strong normalization of open terms). And practical experience with Coq and Agda illustrates how crucial proof erasure is for performance! For example, it is folklore that any dependently typed formalization of category theory will grind to a halt about halfway through, when the typechecker runs out of memory. The most natural way to phrase the definitions involves many

type indices and Σ-types, both of which can cause the size of goals and proofs to blow up. Gross et al. [65] describe how to avoid some of the pitfalls through carefully designing the definitions and judiciously using abstraction and opaqueness. They also mention that extending Coq’s definitional equality with irrelevant arguments would be very helpful, because Coq could then judge types equal without having to process the (large) proof terms embedded inside the type.