II. Co-contextual Type Checkers for Functional Languages
9. Co-contextual Featherweight Java
9.4. Efficient Incremental FJ Type Checking
The co-contextual FJ model from Section 9.1 and 9.2 was designed such that it closely resembles the formulation of the original FJ type system, where all differences are motivated by dually replacing contextual operations with co-contextual ones. As such, this model served as a good basis for the equivalence proof from the previous section. However, to obtain a type checker implementation for co-contextual FJ that is amenable to efficient incrementalization, we have to employ a number of behavior-preserving optimizations. In the present section, we describe these optimization and the resulting incremental type checker implementation for co-contextual FJ. The source code is available online at
https://github.com/seba--/incremental.
Condition normalization. In our formal model from Section 9.1 and 9.2, we represent context requirements as a set of conditional class requirements CR ⊂ Creq × cond . Throughout type checking, we add new class requirements using function merge, but we only discharge class requirements in ruleTC-Programat the very end of type checking. Since
merge generates 3 ∗ m ∗ n conditional requirements for inputs with m and n requirements respectively, requirements quickly become intractable even for small programs.
The first optimization we conduct is to eagerly normalize conditions of class requirements. Instead of representing conditions as a type of type equalities and inequalities, we map receiver types to the following condition representation (shown as Scala code):
case class Condition(notGround: Set[CName], notVar: Set[UCName],
9.4. Efficient Incremental FJ Type Checking A condition is true if the receiver type is different from all ground types (CName) and unification variables (UCName) in notGround and notVar, if the receiver type is equal to all unification variables in sameVar, and if sameGroundAlternatives is either empty or the receiver type occurs in it. That is, ifsameGroundAlternatives is non-empty, then it stores a set of alternative ground types, one of which the receiver type must be equal to.
When adding an equation or inequation to the condition over a receiver type, we check whether the condition becomes unsatisfiable. For example, when equating the receiver type to the ground typeCandnotGround.contains(C), we mark the resulting condition to be unsatisfiable. Recognizing unsatisfiable conditions has the immediate benefit of allowing us to discard the corresponding class requirements right away. Unsatisfiable conditions occur quite frequently because merge generates both equations and inequations for all receiver types that occur in the two merged requirement sets.
If a condition is not unsatisfiable, we normalize it such that the following assertions are satisfied:
(i) the receiver type does not occur in any of the sets
(ii)sameGroundAlternatives.isEmpty || notGround.isEmpty
(iii)notVar.intersect(sameVar).isEmpty.
Since normalized conditions are more compact, this optimization saves memory and time required for memory management. Moreover, it makes it easy to identify irrefutable conditions, which is the case exactly when all four sets are empty, meaning that there are no further requisites on the receiver type. Such knowledge is useful when merge generates conditional constraints, because irrefutable conditions can be ignored. Finally, condition normalization is a prerequisite for the subsequent optimization.
In-depth merging of conditional class requirements. In PCF (Chapter3), the number of requirements of an expression was bound by the number of free variables that occur in that expression. To this end, the merge operation used for co-contextual PCF identifies subexpression requirements on the same free variable and merges them into a single requirement. For example, the expression x + x has only one requirement {x : U1}|{U1=U2}, even though the two subexpressions propagate two requirements {x : U1}
and {x : U2}, respectively.
Unfortunately, the merge operation of co-contextual FJ given in Section 9.1.2 does not enjoy this property. Instead of merging requirements, it merely collects them and updates their conditions. A more in-depth merge of requirements is possible whenever two code fragments require the same member from the same receiver type. For example, the expression this.x + this.x needs only one requirement {U1.x() : U2}|{U
1=U3,U2=U4},
even though the two subexpressions propagate two requirements {U1.x() : U2} and {U3.x() : U4}, respectively. Note that U1 = U3 because of the use of this in both
subexpressions, but U2= U4 because of the in-depth merge.
However, conditions complicate the in-depth merging of class requirements: We may only merge two requirements if we can also merge their conditions. That is, for conditional requirements (creq1, cond1) and (creq2, cond2) with the same receiver type, the merged
requirement must have the condition cond1 ∨ cond2. In general, we cannot express cond1 ∨ cond2 using our Condition representation from above because all fields except
sameGroundAlternativesrepresent conjunctive prerequisites, whereas sameGroundAlternatives represents disjunctive prerequisites. Therefore, we only support in-depth merging when the conditions are identical up tosameGroundAlternatives and we use the union operator to combine theirsameGroundAlternatives fields.
This optimization may seem a bit overly specific to certain use cases, but it turns out it is generally applicable. The reason is that function removeExt creates requirements of the form (D.f : T0, cond ∪ (T = Ci)) transitively for all subclasses Ci of D where no class between Ci and D defines field f . Our optimization combines these requirements into a single one, roughly of the form (D.f : T0, cond ∪ (T =W
iCi)). Basically, this requirement
concisely states that D must provide a field f of type T0 if the original receiver type T corresponds to any of the subclasses Ci of D.
Incrementalization and continuous constraint solving. We adopt the general incrementalization strategy from co-contextual PCF (Chapter 3): Initially, type check the full program bottom-up and memoize the typing output for each node (including class requirements and constraint system). Then, upon a change to the program, recheck each node from the change to the root of the program, reusing the memoized results from unchanged subtrees. This way, incremental type checking asymptotically requires only log n steps for a program with n nodes.
In our formal model of co-contextual FJ, we collect constraints during type checking and solve them at the end to yield a substitution for the unification variables. As discussed in Chapter 3 for co-contextual PCF, this strategy is inadequate for incremental type checking, because we would memoize unsolved constraints and thus only obtain an incremental constraint generator, but even a small change would entail that all constraints had to be solved from scratch.
In our implementation, we follow PCF’s strategy of continuously solving constraints as soon as they are generated, memoizing the resulting partial constraint solutions. In particular, equality constraints that result from merge and remove operations can be solved immediately to yield a substitution, while subtype constraints often have to be deferred until more information about the inheritance hierarchy is available. In the context of FJ with its nominal types, continuous constraint solving has the added benefit of enabling additional requirement merging, for example, because two method requirements share the same receiver type after substitution.
Tree balancing. Even with continuous constraint solving, co-contextual FJ as defined in Section 9.2 still does not yield satisfactory incremental performance. The reason is that the syntax tree is deformed due to the root node, which consists of a sequence of all class declarations in the program. Thus, the root node has a branching factor only bound by the number of classes in the program, whereas the rest of the tree has a relative small branching factor bound by the number of arguments to a method. Since incremental type checking recomputes each step from the changed node to the root node, the type checker would have to repeat discharging class requirements at the root node after every code change, which would seriously impair incremental performance.
To counter this effect, we apply tree balancing as our final optimization. Specifically, instead of storing the class declarations as a sequence in the root node, we allow sequences
9.5. Performance Evaluation