Substructural Typing: Usually, when we consider typing judgments G |- e : t, we treat the context as a partial function from variables to types, so order doesn't matter. Furthermore, we usually allow extra "gunk" in the context than we actually need. For instance, with the rule: G |- x : G(x) we must have x in G, but can also have zero or more other variables in G. Finally, there's nothing preventing us from using an assumption more than once. For instance, we can write: G |- x : G(x) G |- x : G(x) --------------------------------- G |- (x,x) : G(x) * G(x) An alternative is to treat G not as a partial function, but as a list, and to make the lookups explicit. For instance, we might write: G ::= . | G,x:t and then have a variable rule that looks like this: .,x:t |- x : t That is, we can only assign x a type if it's the *only* thing in the context. Of course, to recover the expressiveness of partial functions, we would need to add a few more rules such as the following: 1. Weakening: G |- e : t --------------- (x not in G) G,x:t' |- e : t 2. Interchange: G1,x:t1,y:t2,G2 |- e : t ------------------------- G1,y:t2,x:t1,G2 |- e : t 3. Contraction: G,x1:t',x2:t' |- e : t -------------------------- G,x:t' |- e[x/x1,x/x2] : t These rules are called the "standard structural" rules because they are manipulating the structure of the context. The Weakening rule lets us drop assumptions that we don't really need. It's crucial if we take the variable rule to be the one above where x is the only variable allowed to be in the context. The interchange rule lets us swap the order of two variables within the context. (Technically, we need to define a couple of rules to support an inductive definition for interchange.) The contraction rule is a very strange beast but it essentially allows us to duplicate an assumption. One reason to make structural rules explicit is that they might correspond to run-time operations. For instance, in the low-level abstract machine I showed earlier, it was necessary to execute n steps to extract the nth variable from the environment. This corresponds to a setting where weakening (and interchange) are made explicit. In particular, if we took as our variable lookup rule: G,x:t |- x : t and added a weakening rule: G |- e : t -------------- (x not in t) G,x:t' |- e: t then the first rule is witnessed by extracting the first element in the list that makes up the environment (i.e., head env) whereas the second rule corresponds to taking the tail of the environment before processing the expression e. In this setting, making the lookup explicit in the typing can be advantageous because we can do common-sub-expression elimination for the steps that make up variable lookups by re-arranging the proof. For instance, if we have: .,x:int |- x:int .,x:int |- x:int ------------------------ ------------------------ .,x:int,y:int |- x : int .,x:int,y:int |- x : int ---------------------------------------------------- .,x:int,y:int |- (x,x) : int*int then this corresponds to the following code: (head (tail env), head (tail env)) whereas the following alternative proof, where we weaken first: .,x:int |- x:int .,x:int |- x:int ----------------------------------- .,x:int |- (x,x) : int*int -------------------------------- .,x:int,y:int |- (x,x) : int*int corresponds to the more optimized: let env' = tail env in (head env, head env) In this setting, interchange would correspond to re-arranging the environment to, for instance, move frequently accessed variables towards the front of the list. Doing interchange in conjunction with weakening would allow us to trim out any values for variables that are not needed---which can result in a significant space savings (in particular, if the garbage collector ends up preserving a large data structure because you didn't weaken...) The point is that when we make the substructural rules explicit, we're reflecting computational steps into the proofs and in some sense, optimizing the proofs corresponds to optimizing the code. In other words, sub-structural logics correspond to "lowering" in a compiler. ----------------------------------------------------------------------- Linearity: So what good is the contraction rule? Well to some degree, we tend to take for granted that copying something is an operation that should always be allowed. Why shouldn't you be allowed to copy an integer or a pointer? That's why we take contraction for granted. Similarly, we tend to take for granted that we can forget a resource. That's what the garbage collector is supposed to do -- clean up resources we've forgotten. But in some settings we want to limit the ability to duplicate or forget a resource, in which case having implicit weakening and contraction (for that resource) is a bad thing. For example, it's common in the C library to return an error code for I/O operations, but few people remember to check that code. In a linear setting, we can force them to check the code because the won't be allowed to just forget it. As another example, in security settings, it's common to think of controlling access to something with a key or capability that must be presented each time the resource is meant to be accessed. One property capabilities should have is that a principle shouldn't be able to manufacture a capability to a given resource out of thin air (else, anyone can access the resource at any time.) That is, capabilities should be "unforgeable". The good thing about a type-safe language is that we can use standard types and procedural abstraction to build unforgeable capabilities. But the second property that capabilities should have is that you shouldn't be able to make copies of the capability (without permission.) Otherwise, once a capability is given out, then it's effectively impossible to revoke access to the resource. If you attempt to collect the capabilities you handed out, you can never be sure that the bad guys haven't made copies that they will later use. So traditional type systems, because they support copying (i.e., implicit contraction), make it impossible to capture capabilities in the types. Rather, we're forced to resort to run-time state and dynamic checks (which might fail.) The situation is similar for weakening. In particular, lack-of- weakening is useful for ensuring that someone actually uses a resource. For instance, suppose we want to transfer a resource to Bob, but the only way we can that is by first giving it to Alice and then hope that she passes the resource along to Bob. If Alice can forget the resource, then we have no way of convincing ourselves that she *must* transfer the resource to Bob. All we can say is that she *may* transfer it. If she doesn't have weakening, then we know she has to get rid of the resource explicitly. If the only way for her to do so is to transfer the resource to Bob, then we can be assured that she'll complete the transaction. So let's introduce a little language in which weakening and contraction are *not* present by default. The types of the language look like this: b ::= char | string | fd | ... t ::= b | 1 | t1 x t2 | t1 --o t2 | !t Here, the 1 and (t1 x t2) are the tensor unit and product respectively, and --o (pronounced "lolli" since it looks like a lollipop) is the tensor function space. All of these constructors are considered "multiplicative" and as we'll see, there are "additive" versions of these constructors as well. (This is a reflection of the fact that linear logic is more expressive/fine-grained than standard intuitionistic logic.) The "fd" type will stand for file-descriptors and we'll be using it as an example below. Finally, the type ! constructor (sometimes called bang or of-course) is used to describe *unrestricted* resources--- resources for which we want to allow weakening and contraction. So for instance, our normal notion of an unrestricted string would have the type !string. If something's not !'d, then it's considered linear. Our expressions are going to look like this: e ::= c | s | () | (e1,e2) | let (x1,x2) = e1 in e2 | \x.e | e1 e2 | dup e | drop e1 in e2 | open e | read e | close e | fd(i) s ::= nil | c::s v ::= c | s | () | (v1,v2) | \x.e | fd(i) Here, values include file descriptor values of the form fd(i) where i is a natural number. The intention is that there will be at most one (usable) copy of a file descriptor indexed by i in a given program. Because I want to model I/O with a file system, I'm going to use configurations for evaluation of the form: (F,D,e) where F : string->string is a "file system" mapping file names to file contents, and where D ::= {i1->s1,...,in->sn} is a "descriptor table" mapping file descriptor values to strings. The primitive evaluation rules for this language are as follows: proj (F,D,let (x1,x2) = (v1,v2)) in e -> (F,D,e[v1/x1,v2/x2]) app (F,D,(\x.e) v) -> (F,D,e[v/x]) dup (F,D,dup v) -> (F,D,(v,v)) drop (F,D,drop v in e) -> (F,D,e) open (F,D,open s) -> (F,D+{i->F(s)},fd(i)) (i not in Dom(D)) read (F,D+{i->c::s},read fd(i)) -> (F,D+{i->s},(fd(i),c)) close (F,D+{i->s},close fd(i)) -> (F,D,()) and of course we have all of the congruence rules which I'll capture by specifying evaluation contexts: E ::= [] | (E,e) | (v,E) | E e | v E | dup E | drop E in e | open E | read E | close E and the following rule: (F,D,e1) -> (F,D',e1') cong ------------------------------ (F,D,E[e1]) -> (F,D',E[e1']) Our goal with the typing rules is to ensure the following theorem: If |- e : 1 then (F,{},e) ->* (F,{},()) Notice that as a corrollary, e cannot have any open file descriptors when it terminates. That is, if e opens a file, then it must close it before it terminates. (In this simple language, every program terminates. In a Turing-complete language, we'd only be able to say that if e terminates, then it will have closed all files.) The contexts for our abstract machine are going to look like this: (variable context) G |- . | G,x:t (file descriptor context) P |- . | P,fd(i) and we'll identify both kinds of contexts up to re-ordering. That is, we'll have implicit interchange for contexts. Our judgments are going to be of the form P;G |- e : t. We don't really need P for source-level programs. It's a technical device used for the intermediate states of the abstract machine to keep track of the file descriptor values that have been opened. So you can more or less ignore it below. In the typing rules below, I make use of operators G1+G2 and P1+P2 which are intended to merge two disjoint contexts. In particular, we can define: G1 + . = G1 G1 + G2,x:t = (G1 + G2),x:t (x not in Dom(G1+G2)) P1 + . = P1 P1 + P2,fd(i) = (P1 + P2),fd(i) (fd(i) not in P1+P2) char .;. |- c : char string .;. |- s : string unit .;. |- () : unit var .;(.,x:t) |- x : t fd (.,fd(i));. |- fd(i) : fd P1;G1 |- e1 : t1 P2;G2 |- e2 : t2 x-I -------------------------------------- (P1+P2);(G1+G2) |- (e1,e2) : t1 x t2 P1;G1 |- e1 : t1 x t2 P2;(G2,x1:t1,x2:t2) |- e2 : t x-E ----------------------------------------------------- (P1+P2);(G1+G2) |- let (x1,x2) = e1 in e2 : t P;G,x:t1 |- e : t2 --o-I ----------------------- (x not in Dom(G)) P;G |- \x.e : t1 --o t2 P1;G1 |- e1 : t'--o t P2;G2 |- e2 : t' --o-E ---------------------------------------- (P1+P2);(G1+G2) |- e1 e2 : t P;G |- e : !t dup ---------------------- P;G |- dup e : !t x !t P1;G1 |- e1 : !t' P2;G2 |- e2 : t drop ------------------------------------- (P1+P2);(G1+G2) |- drop e1 in e2 : t .;!G |- e : t !-I ------------- .;G |- e : !t P;G |- e : !t !-E ------------- P;G |- e : t P;G |- e : string open ------------------ P;G |- open e : fd P;G |- e : fd read -------------------------- P;G |- read e : fd x !char P;G |- e : fd close ------------------- P;G |- close e : !1 A few notes about the rules: First, remember that we don't have implicit weakening or contraction. However, for ! types, we can use the dup and drop operations to simulate these rules for a given variable. An alternative would be to just add these rules in directly for variables of ! type: G |- e : t weaken ---------------- G,x:!t' |- e : t G,x1:!t',x2:!t' |- e : t contract --------------------------- G,x:!t' |- e[x/x1,x/x2] : t Second, notice that we force the context to be split into two, disjoint contexts (i.e., P = (P1+P2)) when typing a compound expression such as (e1,e2) or e1 e2 or let (x1,x2) = e1 in e2. This ensures that a given file descriptor fd(i) or a given variable x can occur in at most one sub-expression. Furthermore, note that in the base cases, all of the assumptions in the context must be used. These properties ensure that in fact, there's at most one free occurrence of a given variable or file-descriptor within a sub-expression. However, such a requirement is in general too strong. In particular, consider what happens when we add something like "if then else": P1;G1 |- e : bool P2;G2 |- e1 : t P2;G2 |- e2 : t --------------------------------------------------------- (P1+P2);(G1+G2) |- if e then e1 else e2 : t Notice that we're allowed to duplicate the context for the "then" and "else" clauses e1 and e2. That's because we're only going to run one of those two sub-expressions. If we tried to use something like free variables to account for resources, instead of the typing rules, we'd run into trouble here. Notice also that typing rule (and evaluation rule) for read. Because we're passing in the file descriptor, to read, there can't be another (usable) copy of it in the program. So we must return that copy if we're going to ensure that the program will actually close the file explicitly. Finally, regarding the ! constructor: First, notice that we require a !-type for both dup and drop, reflecting our desire to limit weakening and contraction to only unrestricted resources. Notice also that you can always treat an unrestricted type as if it were linear by using the !-E rule. An alternative would be to add some notion of subtyping whereby !t <= t and add a subsumption rule. This allows us to, for instance, pass a !string to the open operation. Of course, if you're not careful, moving from !t to t may leave you with no way to get rid of the resource! Finally, the most confusing rule in the whole bunch is the one for !-introduction. Essentially, this rule says that you can create a value of !t but only out of ! assumptions. The notation !G is meant to apply the ! constructor to all of the assumptions in G, unless already present. In particular, we define: !(.) = . !(G,x:!t) = (!G),x:!t Notice that !G is undefined when G has linear assumptions, so the rule only applies when the context is unrestricted. This rule lets us derive, for instance: string----------------- string----------------- .;. |- s : string .;. |- s : string !-I ------------------ !-I ------------------ .;. |- s : !string .;. |- s : !string x-I ---------------------------------------------- .;. |- (s,s) : !(!string x !string) !-I -------------------------------------- .;. |- (s,s) : !(!string x !string) As another example, we can derive: x:!char |- x : !char x1:!char |- x1 : !char x2:!char |- x2 : !char -------------------------------- ---------------------------------------------- x:!char |- dup x : !char x !char x1:!char,x2:char |- (x1,x2) : !char x !char ------------------------------------------------------------------------------- x:!char |- let (x1,x2) = dup x in (x1,x2) : !char x !char ------------------------------------------------------------- x:!char |- let (x1,x2) = dup x in (x1,x2) : !(!char x !char) but notice that we *cannot* derive: .;. |- fd(i) : !fd since the fd rule demands that P hold fd(i). Now you see the role of P --- it's tracking the linear *values* while G is tracking the linear variables. Interestingly, our typing rules actually admit proving the following: |- \x.dup x : !fd --o !fd x !fd which seems like something we want to prevent. However, it turns out that there's *no* way to construct a !fd, so the function is essentially dead-code. Similarly, we can build a function that has type !(fd x t) --o t but we'll never be able to run such a function because we can't build an unrestricted container that has within it a linear value. That's a good thing, for then otherwise we could use the dup or drop rules to duplicate or forget the container, thereby duplicating or forgetting the value that's supposed to be linear. Personally, I think this is a mis-feature of the type-system. An alternative approach would be to introduce kinding rules that talk about which types can and can't have ! applied to them. That way, the user would get a type-error early on... Now we also need to give typing rules for the configuations. They are as follows: . |- {} ok P |- D ok ---------------------- (fd(i) not in P) P,fd(i) |- D+{i->s} ok P |- D ok P;. |- e : 1 -------------------------- |- (F,D,e) So the file-descriptor values are introduced by the abstract machine, and the expression must account for all of the file descriptors that are currently open. With all of these pieces in place, it's possible to prove the following lemmas: [Canonical Forms] If P;. |- v : t then (a) if t == 1 then P = . and v = () (b) if t == char then P = . and v = c for some c (c) if t == string then P = . and v = s for some s (g) if t == fd then P = .,fd(i) and v = fd(i) for some i (d) if t == t1 x t2 then P = P1+P2 and v = (v1,v2) and Pi;. |- vi : ti (e) if t == t1 --o t2 then v = \x.e and P;x:t1 |- e : t2 (f) if t == !t then P = . and .;. |- v : t [Preservation] If |- (F,D,e) and (F,D,e) -> (F,D',e') then |- (F,D',e'). [Progress] If |- (F,D,e) then either: (a) e is a value or else (b) there exists a D' and e' such that (F,D,e) -> (F,D',e') >From these we can conclude that not only will a well-typed expression never get stuck, but also that it will close any files that it opens. That is, we can conclude: [Theorem] If |- e : 1 then either (F,{},e) doesn't terminate or else (F,{},e) ->* (F,D,v) and |- (F,D,v) : 1 and thus by canonical forms, D = {} and v = (). The key to this is really the canonical forms lemma which tells us that the only closed values of type fd must be accounted for by the P we construct to describe the file descriptor table D. --------------------------------------------------------------------- Other constructors: Adding sums to our language is also straightforward: t ::= ... | t1 + t2 e ::= ... | inl(e) | inr(e) | case e of x1=>e1 | x2=>e2 G |- e : t1 -------------------- G |- inl e : t1 + t2 G |- inr e : t2 + t1 G1 |- e : t1+t2 G2,x1:t1 |- e1 : t G2,x2:t2 |- e2 : t ---------------------------------------------------------- G1+G2 |- case e of x1=>e1 | x2=>e2 : t and there are no real surprises. As with the if-then-else, we allow assumptions to be duplicated because we will execute only one path. However, as I mentioned earlier, there's another kind of product that one can add to the language that's somewhat similar. It looks like this in a call-by-name setting: t ::= ... | t1 & t2 e ::= ... | [e1,e2] | #i e #i [e1,e2] -> ei G |- e1 : t1 G |- e2 : t2 ---------------------------- G |- [e1,e2] : t1 & t2 That seems funny! We're allowed to duplicate the resources. But the reason is that we can only get out one of the values and then we have lost access to the pair. In some sense, t1 * t2 is a promise to use both components, so they must have separate resources, whereas t1 & t2 is a promise to use only one of the components, so they can share resources. In a CBV-setting, we must restrict the rule to only allow *values*: G |- v1 : t1 G |- e2 : t2 --------------------------- G |- [v1,v2] : t1 & t2 because otherwise, we could write: \f:fd.[close fd, close fd] which would close a file-descriptor twice! However, with the value rule, we *can* write the following: \f:fd.#1 [\x:!1.drop x in close fd,\x:!1.drop x in close fd] Now recall that we can encode sums (t1+t2) as something that looks like this in the normal, intuitionsitic setting. T[t1+t2] = All 'a.([t1]->'a * [t2]->'a) -> 'a E[inl] : [t1] -> [t1+t2] = \x.\(f,g).f x E[inr] : [t2] -> [t1+t2] = \y.\(f,g).g y E[case e of x1=>e1 | x2=>e2] = E[e] (\x1.E[e1], \x2.E[e2]) This encoding, as written, doesn't type-check in a linear setting where we use the tensor product. The reason is that inl and inr only use *one* of their assumptions. If instead, we use &, then the code type-checks. This is why in some sense, & is "additive" whereas tensor is "multiplicative" --- the negation of + is &. Of course, this begs the question what the negation of t1 * t2 is... (Fun thought exercise...) --------------------------------------------------------------------- Encoding State Machines: When combined with ADTs, linearity allows us to capture state transitions in an interface. Consider a finite-state machine for the security policy "no message can be sent after a file has been read". We can encode such a policy with the following interface: type cansend type cantsend type 'a State readfile : fd --o ('a State) --o (cantsend State) x fd x !char sendmsg : string --o (cansend State) --o (cansend State) runapplet : ((cansend State) -> 'a State) -> 1 An applet here is a function which is given a capability to send messages (cansend State). The sendmsg operation demands that we pass in this capability and it returns it again for future use. The readfile operation, in contrast, can be run in any state, but it consumes the capability and returns something a (cantsend State) capability. Since the only way a cansend is manufactured is by the run-time system which invokes the applet, and since the applet can't forge or duplicate a capability, then we're assured that the applet can't send a message after reading a file. If we wanted to further constrain the applet so that when (really, if) it terminates, it *must* have sent a message, we could change the type of runapplet to: runapplet : ((cansend State) -> cantsend State) -> 1 In general, if you have a finite-state machine with states d1,...,dn and a set of operations op1,...,opm and a partial transition function T : State x Opn -> State + _|_, and distinguished start and finish states ds and df respectively, you can encode the machine in a linear interface: type d1,...,dn type 'a State val op1d1 : d1 State -> T[d1,op1] State val op1d2 : d2 State -> T[d2,op1] State ... val op1dn : dn State -> T[dn,op1] State val op2d1 : d1 State -> T[d1,op2] State val op2d2 : d2 State -> T[d2,op2] State ... val op2dn : dn State -> T[dn,op2] State ... val opmd1 : d1 State -> T[d1,opm] State val opmd2 : d2 State -> T[d2,opm] State ... val opmdn : dn State -> T[dn,opm] State run : (ds State -> df State) -> 1 where you leave out opjdi whenever T[di,opj] is undefined. Of course, this kind of encoding is a little bit painful because we have to duplicate the operation for each possible state. On the other hand, if we have a powerful enough type language (e.g., F-omega plus kind polymorphism or data kinds) then we can encode T at the type-level. And of course, we can use monads to structure the code so that we don't have to pass around this capability by hand. For instance, we could define (using Haskell notation): type M s1 s2 a = State s1 -> State s2 x a return : 'a -> M s s a >>= : M s1 s2 a -> (a -> M s1 s2 b) -> M s1 s2 b run : M start finish a -> a Then commands specific to the particular state machine would be encoded with types of the form: op : t1 -> M start finish t2 For instance, our readfile and sendmsg operations might be defined as: readfile : fd -> M 'a cantsend (fd x !char) sendmsg : string -> M cansend cansend unit runapplet : M cansend 'a unit -> unit