Loading [MathJax]/jax/output/HTML-CSS/jax.js

Sunday, February 05, 2017

On the Meaning of Casts and Blame for Gradual Typing

Gradually typed languages enable programmers to choose which parts of their programs are statically typed and which parts are dynamically typed. Thus, gradually typed languages perform some type checking at compile time and some type checking at run time. When specifying the semantics of a gradually typed language, we usually express the run time checking in terms of casts. Thus, the semantics of a gradually typed language depends crucially on the semantics of casts. This blog post tries to answer the question: "What is a cast?"

Syntax and Static Semantics of Casts

Syntactically, a cast is an expression of the form e:AB where e is a subexpression; A and B are the source and target types, respectively. The is what we call a blame label, which records the location of the cast (e.g. line number and character position).

Regarding the static semantics (compile-time type checking), a cast enables the sub-expression e of static type A to be used in a context expecting a different type B. Γe:AΓ(e:AB):B In gradual typing, A and B typically differ in how "dynamic" they are but are otherwise similar to each other. So we often restrict the typing rule for casts to only allow source and target types that have some values in common, that is, when A and B are consistent. Γe:AABΓ(e:AB):B For example, if we let be the unknown type (aka. dynamic), then we have Int and Int but IntIntInt. Here are the rules for consistency with integers, functions, and the dynamic type. IntIntABABAABBAB

Dynamic Semantics of Casts

The dynamic semantics of a cast is to check whether the value produced by subexpression e is of the target type B and if so, return the value; otherwise signal an error. The following is a strawman denotational semantics that expresses this basic intuition about casts. Suppose we have already defined the meaning of types, so T[|A|] is the set of values of type A. The meaning function E[|e|] maps an expression to a result (either a value v or error blame). E[|e:AB|]={vif vT[|B|]blameif vT[|B|]where v=E[|e|]

If we restrict ourselves to first-order types such as Int, it is straightforward to define T and check whether a value is in the set. T[|Int|]=Z The story for function types, that is, for AB, is more complicated. In a denotational setting, it traditionally takes sophisticated mathematics to come up with mathematical entities that can serve as function values when the type is involved (Scott 1970, 1976). The primary challenge is that one cannot simply use the usual notion of a mathematical function to represent function values because of a cardinality problem. Suppose that D is the set of all values. The set of mathematical functions whose domain and codomain is D is necessarily larger than D, so the mathematical functions cannot fit into the set of all values. There is nothing wrong with sophisticated mathematics per se, but when it comes to using a specification for communication (e.g. between language designers and compiler writers), it is less desirable to require readers of the specification to fully understand a large number of auxiliary definitions and decide whether those definitions match their intuitions.

Competing Operational Semantics for Casts

We'll come back to denotational semantics in a bit, but first let's turn to operational semantics, in particular reduction semantics, which is what the recent literature uses to explains casts and the type (Gronski 2006, Siek 2007, Wadler 2009). In a reduction semantics, we give rewrite rules to say what happens when a syntactic value flows into a cast, that is, we say what expression the cast reduces to. Recall that a syntactic value is just an expression that cannot be further reduced. We can proceed by cases on the consistency of the source type A and target type B.

  • Case (v:IntInt). This one is easy, the static type system ensures that v has type Int, so there is nothing to check and we can rewrite to v. v:IntIntv
  • Case (v:). This one is also easy. v:v
  • Case (v:AABB). This one is more complicated. We'd like to check that the function v has type BB. Suppose B=Int. How can we determine whether a function returns an integer? In general, that's just as hard as the halting problem, which is undecidable. So instead of checking now, we'll delay the checking until when the function is called. We can accomplish this by rewriting to a lambda expression that casts the input, calls v, and then casts the output. v:AABBλx:B.(v(x:BA)):AB Here we see the importance of attaching blame labels to casts. Because of the delayed checking, the point of error can be far removed from the original source code location, but thanks to the blame label we can point back to the source location of the cast that ultimately failed (Findler and Felleisen 2002).
  • Case (v:A). For this one there are multiple options in the literature. One option is declare this as a syntactic value (Siek 2009), so no rewrite rule is necessary. Another option is to factor all casts to through the ground types G: G::=Int Then we expand the cast from A to into two casts that go through the unique ground type for A. v:A(v:AG):Gwhere AG,AG,A and then declare that expressions of the form (v:G) are values (Wadler 2009).
  • Case (v:B). There are multiple options here as well, but the choice is linked to the above choice regarding casting from A to . If v=(v:A), then we need the following rewrite rules (v:A):Bv:ABif AB(v:A):Bblameif AB On the other hand, if we want to factor through the ground types, we have the following reduction rules. v:Bv:GBif BG,BG,B(v:G):Gv(v:G):Gblameif GG

Given that we have multiple options regarding the reduction semantics, an immediate question is whether it matters, that is, can we actually observe different behaviors for some program? Yes, in the following example we cast the identity function on integers to an incorrect type. letid=(λx:Int.x)inletf=(id:IntInt1)inletg=(f:2(IntInt)Int)ingid If we choose the semantics that factors through ground types, the above program reduces to blame1. If we choose the other semantics, the above program reduces to blame2. Ever since around 2008 I've been wondering which of these is correct, though for the purposes of full disclosure, I've always felt that blame2 was the better choice for this program. I've also been thinking for a long time that it would be nice to have some alternative, hopefully more intuitive, way to specify the semantics of casts, with which we could then compare the above two alternatives.

A Denotational Semantics of Functions and Casts

I've recently found out that there is a simple way to represent function values in a denotational semantics. The intuition is that, although a function may be able to deal with an infinite number of different inputs, the function only has to deal with a finite number of inputs on any one execution of the program. Thus, we can represent functions with finite tables of input-output pairs. An empty table is written , a single-entry table has the form vv where v is the input and v is the corresponding output. We build a larger table out of two smaller tables v1 and v2 with the notation v1v2. So, with the addition of integer values nZ, the following grammar specifies the values. v::=nvvvv

Of course, we can't use just one fixed-size table as the denotation of a lambda expression. Depending on the context of the lambda, we may need a bigger table that handles more inputs. Therefore we map each lambda expression to the set of all finite tables that jive with that lambda. To be more precise, we shall define a meaning function E that maps an expression and an environment to a set of values, and an auxiliary function F that determines whether a table jives with a lambda expression in a given environment. Here's a first try at defining F. F(n,λx:A.e,ρ)=falseF(,λx:A.e,ρ)=trueF(vv,λx:A.e,ρ)=T(A,v) and vE[|e|]ρ(x:=v)F(v1v2,λx:A.e,ρ)=F(v1,λx:A.e,ρ) and F(v2,λx:A.e,ρ) (We shall define T(A,v) shortly.) We then define the semantics of a lambda-expression in terms of F. E[|λx:A.e|]ρ={vF(v,λx:A.e,ρ)} The semantics of function application is essentially that of table lookup. We write (v2v)v1 to say, roughly, that v2v is an entry in the table v1. (We give the full definition of in the Appendix.) E[|e1e2|]ρ={v|v1v2.v1E[|e1|]ρ and v2E[|e2|]ρ and (v2v)v1} Finally, to give meaning to lambda-bound variables, we simply look them up in the environment. E[|x|]ρ={ρ(x)}

Now that we have a good representation for function values, we can talk about giving meaning to higher-order casts, that is, casts from one function type to another. Recall that in our strawman semantics, we got stuck when trying to define the meaning of types in the form of map T from a type to a set of values. Now we can proceed based on the above definition of values v. (To make the termination of T more obvious, we'll instead define T has a map from a type and a value to a Boolean. The measure is a lexicographic ordering on the size of the type and then the size of the value.) T(Int,v)=(n.v=n)T(,v)=trueT(AB,n)=falseT(AB,)=trueT(AB,vv)=T(A,v) and T(B,v)T(AB,v1v2)=T(AB,v1) and T(AB,v2) With T defined, we define the meaning to casts as follows. E[|e:AB|]ρ={vvE[|e|]ρ and T(B,v)}{blame|v.vE[|e|]ρ and ¬T(B,v)and (l.vblamel)}{blameblameE[|e|]ρ} This version says that the result of the cast should only be those values of e that also have type B. It also says that we signal an error when a value of e does not have type B. Also, if there was an error in e then we propagate it. The really interesting thing about this semantics is that, unlike the reduction semantics, we actually check functions at the moment they go through the cast, instead of delaying the check to when they are called. We immediately determine whether the function is of the target type. If the function is not of the target type, we can immediately attribute blame to this cast, so there is no need for complex blame tracking rules.

Of course, we need to extend values to include blame: v::=nvvvvblame and augment T and F to handle blame. T(AB,blame)=falseF(blame,λx:A.e,ρ)=false To propagate errors to the meaning of the entire program, we augment the meaning of other language forms, such as function application to pass along blame. E[|e1e2|]ρ={v|v1v2.v1E[|e1|]ρ and v2E[|e2|]ρand (v2v)v1}{blameblameE[|e1|]ρ or blameE[|e2|]ρ}

Two Examples

Let us consider the ramifications of this semantics. The following example program creates a function f that returns 1 on non-zero input and returns the identity function when applied to 0. We cast this function to the type IntInt on two separate occasions, cast 3 and cast 4, to create g and h. We apply g to 1 and h to its result. letf=(λx:Int.ifxthen(0:Int1)else((λy:Int.y):IntInt2))inletg=(f:Int3IntInt)inleth=(f:Int4IntInt)inletz=(g1)in(hz) The meaning of this program is {blame3,blame4}. To understand this outcome, we can analyze the meaning of the various parts of the program. (The semantics is compositional!) Toward writing down the denotation of f, let's define auxiliary functions id and F. id(n)=falseid()=trueid(vv)=(v=v)id(v1v2)=id(v1) and id(v2)id(blame)=falseF(n)=falseF()=trueF(0v)=id(v)F(n0)=(n0)F(v1v2)=F(v1) and F(v2)F(blame)=false The denotation of f is E[|f|]={vF(v)} To express the denotation of g, we define G G(n)=falseG()=trueG(n0)=(n0)G(v1v2)=G(v1) and G(v2)G(blame)=false The meaning of g is all the values that satisfy G and also blame3. E[|g|]={vG(v)}{blame3} The meaning of h is similar, but with different blame. E[|h|]={vG(v)}{blame4} The function g applied to 1 produces {0,blame3}, whereas h applied to 0 produces {blame4}. Thus, the meaning of the whole program is {blame3,blame4}.

Because cast 3 signals an error, one might be tempted to have the meaning of g be just {blame3}. However, we want to allow implementations of this language that do not blame 3 (g is never applied to 0 after all, so its guilt was not directly observable) and instead blame 4, who was caught red handed. So it is important for the meaning of g to include the subset of values from f that have type IntInt so that we can carry on and find other errors as well. We shall expect implementations of this language to be sound with respect to blame, that is, if execution results in blame, it should blame one of the labels that is in the denotation of the program (and not some other innocent cast).

Let us return to the example (P0). The denotation of that program is {blame2} because the cast at 2 is a cast to (IntInt)Int and the identity function is not of that type. The other case at 1 is innocent because it is a cast to and all values are of that type, including the identity cast.

Discussion

By giving the cast calculus a denotational semantics in terms of finite function tables, it became straightforward to define whether a function value is of a given type. This in turn made it easy to define the meaning of casts, even casts at function type. A cast succeeds if the input value is of the target type and it fails otherwise. With this semantics we assign blame to a cast in an eager fashion, without the need for the blame tracking machinery that is present in the operational semantics.

We saw an example program where the reduction semantics that factors through ground types attributes blame to a cast that the denotational semantics says is innocent. This lends some evidence to that semantics being less desirable.

I plan to investigate whether the alternative reduction semantics is sound with respect to the denotational semantics in the sense that the reduction semantics only blames a cast if the denotational semantics says it is guilty.

Appendix

We give the full definition of the cast calculus here in the appendix. The relation that we used to define table lookup is the dual of the subtyping relation for intersection types. The denotational semantics is a mild reformation of the intersection type system that I discussed in previous blog posts.

Syntax A::=IntABe::=nop(e,e)ifetheneelseexλx:Aeee:AB Consistency IntIntABABAABBAB Type System Γn:IntΓe1:IntΓe2:IntΓop(e1,e2):IntΓe1:IntΓe2:AΓe3:AΓife1thene2elsee3:Ax:AΓΓx:AΓ,x:Ae:BΓλx:A.e:ABΓe1:ABΓe2:AΓe1e2:BΓe:AABΓ(e:AB):B Values v::=nvvvvblame Table Lookup (Value Information Ordering) nnv1v1v2v2v1v2v1v2blameblamev1v1v2v2v1v2v1v3v2v3v1v2v3v1(v2v3)(v1v2)(v1v3)v1v2 \noindent Semantics of Types T(Int,v)=(n.v=n)T(,v)=trueT(AB,n)=falseT(AB,)=trueT(AB,vv)=T(A,v) and T(B,v)T(AB,v1v2)=T(AB,v1) and T(AB,v2)T(AB,blame)=false Denotational Semantics E[|n|]ρ={n}E[|op(e1,e2)|]ρ={v|v1v2n1n2.v1E[|e1|]ρv2E[|e2|]ρn1v1n2v2v=[|op|](n1,n2)}{blameblame(E[|e1|]ρE[|e2|]ρ)}E[|ife1thene2elsee3|]ρ={v|v1n.v1E[|e1|]ρnv1(n=0vE[|e3|]ρ)(n0vE[|e2|]ρ)}{blameblame(E[|e1|]ρE[|e2|]ρE[|e3|]ρ)}E[|x|]ρ={ρ(x)}E[|λx:A.e|]ρ={vF(v,λx:A.e,ρ)}E[|e1e2|]ρ={v|v1v2.v1E[|e1|]ρv2E[|e2|]ρ(v2v)v1}{blameblame(E[|e1|]ρE[|e2|]ρ)}E[|e:AB|]ρ={vvE[|e|]ρ and T(B,v)}{blame|v.vE[|e|]ρ and ¬T(B,v)and (.vblame)}{blameblameE[|e|]ρ}F(n,λx:A.e,ρ)=falseF(,λx:A.e,ρ)=trueF(vv,λx:A.e,ρ)=T(A,v) and vE[|e|]ρ(x:=v)F(v1v2,λx:A.e,ρ)=F(v1,λx:A.e,ρ) and F(v2,λx:A.e,ρ)F(blame,λx:A.e,ρ)=false

References

  • (Findler 2002) Contracts for higher-order functions. R. B. Findler and M. Felleisen. International Conference on Functional Programming. 2002.
  • (Gronski 2006) Sage: Hybrid Checking for Flexible Specifications. Jessica Gronski and Kenneth Knowles and Aaron Tomb and Stephen N. Freund and Cormac Flanagan. Scheme and Functional Programming Workshop, 2006.
  • (Scott 1970) Outline of a Mathematical Theory of Computation. Dana Scott. Oxford University. 1970. Technical report PRG-2.
  • (Scott 1976) Data Types as Lattices. Dana Scott. SIAM Journal on Computing. 1976. Volume 5, Number 3.
  • (Siek 2009) Exploring the Design Space of Higher-Order Casts. Jeremy G. Siek and Ronald Garcia and Walid Taha. European Symposium on Programming. 2009.
  • (Wadler 2009) Well-typed programs can't be blamed. Philip Wadler and Robert Bruce Findler. European Symposium on Programming. 2009.

1 comment:

  1. Great post! I found these explanations very interesting, helpful and thought-provoking.

    In particular: I must admit that (1) I always prefer an operational view of semantics, if there is one that's applicable and (2) I humbly take objection to classifying casts as values.

    In particular, I'd prefer an operational account of casts/blame that does not classify casts as values (that is, the ordinary syntax of values is preserved, and has no cast form).

    So my question is: Has anyone explored an environment-passing-style way of doing casts that accomplishes (1) and (2) above?

    Specifically, suppose I extend environments with lists of "cast types" for each variable-value pairing:

    Closing environments (with cast-type lists):
    ρ ::= ε | ρ, x : v ► τs
    τs ::= ε | τs, τ

    We could normalize the list of types, where we coalesce types that are equivalent. Alternatively, we could keep the whole list and just check against *all* of them. Those two approaches should be equivalent (ignoring space/time costs).

    Finally, I’m proposing that we hijack the usual environment-projection notation `ρ(x) = v` to mean something like this:

    (x : v ► τs) in Γ
    ⊢ v : τs
    ----------------- :: env-lookup
    ρ(x) "is" v

    Where `⊢ v : τs` is the ordinary notion of value typing, but “lifted” to lists of types.

    I should note that we're doing something like this in our "VM of the Future" work (https://arxiv.org/abs/1608.06012). In particular, we use environment-passing style, and keep type information in the environment as we interleave program evaluation and checking. My proposal above is an attempt to transport that idea back into the world of gradual typing.

    Any comments or advice is much appreciated!

    ReplyDelete