Ynot

The Ynot Project: Overview


Background and Motivation

Dependent type theories, such as the Calculus of Inductive Constructions (as implemented in the Coq proof assistant) provide a powerful, uniform way to write (a) functional programs, (b) specifications and models that capture desired properties of programs ranging from simple typing and safety properties up to full correctness, and (c)  formal, machine-checked proofs and tactics that a given program meets its specification.  For example, Xavier Leroy and his students have used Coq to construct an optimizing compiler that translates a (well-defined) subset of C to PowerPC code, defined operational semantics for both C and PowerPC code, and mechanically proved that when the compiler succeeds in producing target code, that code behaves the same as the source code, thereby establishing the correctness of the compiler.  Coq is not alone in providing support for this style of program development:  Other examples include ACL2, IsabelleNuPRL and MetaPRL, and Epigram.

Nevertheless, today's dependent programming environments suffer from a number of limitations that limit their applicability.  One serious shortcoming, and the shortcoming on which Ynot is focused, is that we are limited to writing and reasoning about only purely functional programs with absolutely no side effects, including recursion, mutable state, exceptions, I/O, concurrency, etc.  While some programming tasks, such as a compiler, can be formulated as a pure, terminating function, most important tasks cannot.  Furthermore, even "functional" programs such as a compiler need to use asymptotically efficient algorithms and data structures (e.g., hash-tables) but current dependently typed languages prevent us from doing so.

Why We Found Alternative Approaches Insufficient

An alternative to starting with a dependent type theory is to start with a standard effectful programming language, such as Java, C#, Haskell or ML, and attempt to retrofit facilities for expressing models, specifications, dependency, refinement, and proofs.  For example, the JML and Spec# projects extend Java and C# respectively with support for Hoare-style pre- and post-conditions as well as object invariants and other features needed to capture safety and correctness requirements of code.  For Spec# (and the ESC-Java system which is based on a subset of JML), an automated SMT-style prover is used to try to discharge proof obligations and establish that the code meets its specification. 

While there have been great advances in automated prover technology over the past few years, these systems still have trouble discharging all but the most "shallow" specifications.  In particular, they are extremely useful for weeding out low-level coding problems (such as an array index out of bounds or a NULL-pointer dereference), but their capabilities fall well short of something like Leroy's certified compiler.  In contrast, dependent type theories make it possible to utilize automated deduction techniques, but when they fall short, also allow the programmer to construct explicit proofs of "deep" properties of programs.  For programs where deep reasoning matters, we must acknowledge that programmers will play an integral role in co-developing models, specifications, programs, and proofs

Furthermore, the modeling and specification languages used by JML and Spec# are too weak to support a truly modular development of programs and specifications.  As a simple example, neither language has the expressive power to capture a principal specification for "compose" or "map" or any other higher-order combinator.  And while each language provides impressive mechanisms for helping to modularize the reasoning about some common classes of effectful computations (e.g., through modifies clauses, ownership types, object invariants, etc.), these mechanisms end up being simultaneously complicated and yet frustratingly incomplete. 

There is another emerging class of projects that are attempting to retrofit dependent typing to effectful programming languages based on indexed or phase-split dependent types.  Notable examples include Hongwei Xi's DML and more recent ATS, Tim Sheard's Omega, and Walid Taha's Concoqtion.  These systems have a strong separation between the programming language (which includes effects) and the specification languages (which do not.)  For example, in Concoqtion, the programming language is based on OCaml, but the specification language is based on Coq.  Coq terms can be used to index ML type constructors that classify values, so it becomes possible, for instance, to use the types to capture correctness requirements.  The systems have re-discovered the power of inductive definitions through the use of so-called generalized abstract data types (GADTs) which have long been present in Coq, but only recently reflected (in a limited fashion) into languages such as Haskell and OCaml. 

While indexed types and GADTs are one way to solve the inherent problems needed to ensure soundness of a dependency in the pressence of effects, they have many shortcomings in the current realizations.  First, neither ATS, Omega, nor Concoqtion can use indexed types to suitably capture and reason about effects themselves (e.g., updates to a mutable store.)  Thus, while programmers can write effectful code, they can only reason completely about the pure subset, and cannot capture specifications or requirements on effects.  Second, the awkward split between models, specifications, and code means that many definitions must be duplicated leading to a verbose style that is difficult to write and maintain.  For example, to fully capture the behavior of a function that appends two lists, we must, in essence, code the append operation at both the specification level and the program level and link them through the use of singleton types. 

Yet another approach for incorporating dependency and refinement is typified by the Sage and Deputy projects and more generally with "design-by-contract" languages such as Eiffel and the excellent work in PLT Scheme.  Here, programmers write boolean expressions as pre- and post-conditions that are intended to be executed at run-time.  In Sage and Deputy, an analysis is used to try to optimize away contracts, or to show that the contracts will fail at run-time.  A key advantage of these systems is that programmers need not learn a new specification language and programmers can take advantage of existing code within their specifications.  Furthermore, by restricting the specifications to boolean functions, we can always fall back on run-time tests when a theorem prover is unable to discharge the requirement at compile time. 

In practice, however, these systems have a number of problems:  Of course, one problem is the potential overhead of running the contracts and thus programmers who worry about performance will tend to only use simple "local" contracts.  Another problem is that, especially in the presence of concurrency, one must be careful about race conditions, deadlocks, etc. when the contracts examine shared state.  Finally, they key issue is that the boolean functions used as contracts must only contain "benign" effects in order to safely optimize them away without changing the behavior of the program.  If  we enforce that the boolean functions must be "pure" according to some syntactic or typing criteria, then in practice, we cannot always use existing or efficient code for the contract.  As a simple example, consider a splay-tree lookup which happens to re-balance the tree:  The effects are benign (because eliminating the lookup is safe) but demands a deep proof.  If on the other hand, we don't enforce purity, then the "meaning" of the contract is suspect and we lack coherence. 

In practice, all of these approaches are good practical steps towards realizing more advanced programming environments with some support for dependent types and in practice, we believe that a combination of these ideas, built on a more general framework, will provide better solutions.

Our Approach:  Hoare Type Theory

The approach we are taking is to extend Coq, which already has good support for functional programming, inductive definitions, specifications, proofs, and tactics by adding a new type constructor known as the Hoare Type, and written as ST p a q.  Intuitively, the type constructor ST classifies delayed, possibly effectful computations, much the way that the IO-monad is used to separate effectful computations in Haskell.  Unlike Haskell, our monad is indexed not only by the return type of the computation, but also a pre-condition and post-condition.  Intuitively, when a computation of type ST p a q is run in a world w1 satisfying the pre-condition p, then if the command terminates, it will produce a value x of type a and result in a new world w2 such that the predicate q x w1 w2 holds.  Currently, worlds are modeled as heaps that map locations to values.  Computations can allocate new locations, read or write the contents of a location (performing strong updates), and even deallocate locations.  Computations can also throw and catch exceptions, or diverge. 

As a simple example, here is a Ynot definition that increments the contents of a location, and returns the original value:

  Definition inc x := v <-- !!x; x ::= (v+1);; ret v.

This definition takes advantage of some syntactic definitions.  In particular "x <-- M; N" is short-hand for "bind M (fun x => N)", "M ;; N" is short-hand for "bind M (fun _ => N)", "!!M" is short-hand for "read M", and "M ::= N" is short-hand for "write M N".  Thus, when de-sugared, the actual definition is given by:

  Definition inc x :=
   bind (read x) (fun v => bind (write x (v+1)) (fun _ => ret v))).


So the basic operations for constructing and composing ST computations consist of the "ret" (return) and "bind" operations for the monad, together with primitive computations such as read and write.  Let us examine the types of each of these primitives and see how they fit together.  Note that in what follows, we will gloss over exceptions to keep the presentation simple.

The type for return is as follows:

  ret : forall (A:Type) (x:A),
         ST (fun w1 => True) A (fun v w1 w2 => v=x /\ w1 = w2)


In English, "ret T M", where "T" is the type of the term "M", yields a computation which can be run in any world (the pre-condition maps worlds to True) and yields a value v which must be equal to M, and a new world equal to the original.  The return operation allows us to lift a pure Coq computation into the world of effectful commands.  Thus, as an imperative language, Ynot inherits all of Coq for its "pure" subset, and the types are reflecting the distinctions between pure and impure code.  Note that in Coq, certain arguments, such the Type argument A, can be automatically inferred so we only need to write "ret M".  In the subsequent examples, we will elide the type and predicate arguments as Coq can infer these.

The type for read is as follows:

  read : forall (A:Type)(x:loc),
          ST (fun w1 => exists v:A, points_to x v w1)
              A
             (fun v w1 w2 => points_to x v w1 /\ w2 = w1)


Thus "read M" yields a computation which can only be run in a state where M points to some value v of type A, and it produces that value as a result, leaving the state of the world unchanged.  The type for write is similar:

  write : forall (A:Type)(x:loc)(v:A),
         ST (fun w1 => exists B, exists z:B, points_to x z w1)
            unit
            (fun _ w1 w2 => w2 = update w1 x z)


Therefore, "write M N" yields a computation which can be run in a state where M points to some value and it yields a new state that is equal to the functional update of the original state at location M with the value N of type A.  Note that the update is strong in the sense that it doesn't require that we write values of the same type into locations. 

Finally, the type for "bind" is the crucial one, as this is what allows us to (sequentially) compose operations:

  bind :  forall (A B:Type)
                 (p: pre)(q : post A)
                 (r:A->pre)(s:A->post B)
                 (c1 : ST p A q)(c2 : forall x:A, ST (r x) B (s x)),
                   ST (fun w1 => p w1 /\ forall x w, q x w1 w -> r x)
                       B
                      (fun y w1 w2 => exists x w, q x w1 w /\ s x y w w2)

In words, "bind M N", where M has type ST p A q and N has type forall x:A, ST (r x) B (s x) yields a computation where the pre-condition includes p, but also requires that whenever we have an intermediate state satisfying the post-condition of M, then this should imply the pre-condition of N, namely r.  This is the weakest pre-condition of the compound command that ensures we can safely run M and then N.  The post condition is simply the relational composition of q and s, the two post-conditions of the commands.  Again, this is the strongest post-condition that one can  formulate.  Notice that the definition of bind required that we abstract not only over types and commands, but also predicates in order to ensure we can get the weakest pre-condition and strongest post-condition (i.e., a principle type.)  This shows how central predicate abstraction is to composition when we wish to employ Hoare-style reasoning. 

Ynot will automatically calculate the weakest pre-condition and strongest post-condition for the command, just as it will infer types.  Unfortunately, these specifications are not always readable or what you might expect.  For instance, Ynot infers the following type for inc:

  forall x : loc,
   ST (bind_pre (read_pre nat x) (read_post nat x)
        (fun v : nat => bind_pre (allocated x) (write_post x (v+1))
        (fun _ : unit => nopre)))
      nat
      (bind_post (read_pre nat x) (read_post nat x)
        (fun v : nat => bind_post (allocated x) (write_post x (v+1))
        (fun _ : unit => ret_post v)))


which uses a number of auxilliary definitions that abstract the pre and post-conditions of the various commands, but even when these are reduced the result is unreadable.  We use the "do" primitive to coerce a computation with one specification into a specification that is either equivalent, or implied by the principal specification inferred by Ynot.  The type of the "do" primitive is:

  do : forall (A:Type)(p r:pre)(q s:post A)
              (pf : forall w1, r w1 ->
                    (p w1 /\ forall x w2, q x w1 w2 -> s x w1 w2)),
          ST p A q -> ST r A s.


So do takes an ST p A q and coerces it to an ST r A s, but we must provide a proof that this coercion is justified.  In particular, we must show that r implies p, and q implies s, which corresponds the the usual rule of consequence in Hoare logic.  As an example, we can use "do" to ascribe a more readable specification to the inc definition as follows:

  Program Definition inc(x:loc) : ST (fun w1 => exists v:nat, points_to x v)
                                     nat
                                    (fun v w1 w2 => points_to x v w1 /\
                                                    w2 = update w2 x v)
   := do _ (v <-- !!x; x ::= (v+1);; ret v).

This specification is a natural one for inc that is logically equivalent to what Ynot infers, but we can also choose to strengthen the pre-condition (e.g., require that x's initial value is greater than zero) or weaken the post-condition.  In the example above, we used Coq's Program construct to avoid having to enter the proof obligation that "do" demands.  Rather, we've put in a place-holder (the underscore) which allows us to write the definition and type-check it, and then discharge the proof obligation using a proof script and/or tactics.  In this particular case, we have a simple tactic (avcsimps) that can automatically discharge the proof obligation.  A long term goal for us is to develop more tactics and decision procedures, as well as more refined definitions that make discharging proof obligations simpler.

In practice, the ST monad should not be directly used for programming because it forces you to reason about the entire world (i.e., the whole heap).  To address this problem, we have defined an alternative monad, STsep on top of the ST monad which provides a "small footprint" approach to specifying and reasoning about pointer-manipulating programs in the spirit of Separation Logic.  However, we do not yet have good tactics for discharging the attendent proof obligations and see this as a major research issue.

Finally, in addition to the ST and STSep monads, we have developed the theory (but not yet the implementation) of a concurrent version of Ynot with support for software-based transactional memory in the style of GHC's STM monad.  We hope to release definitions and code soon, but for now, please contact us if you are interested in more details.