Previous Up Next
3.2 Understanding objects and classes


In this section, we formalize the core of the object layer of OCaml. In this presentation, we make a few simplifications that slightly decrease the expressiveness of objects and classes, but retain all of their interesting facets.

One of the main restrictions is to consider fields immutable. Indeed, mutable fields are important in practice, but imperative features are rather orthogonal to object-oriented mechanisms. Imperative objects are well explained as the combination of functional objects with references: mutable fields can usually be replaced by immutable fields whose content is a reference. There is a lost though, which we will discuss below. Thus we shall still describe mutable fields, including their typechecking, but we will only describe their semantics informally.

As was already shown in the informal presentation of objects and classes, polymorphism is really a key to the expressiveness of OCaml's objects and classes. In particular, it is essential that: Besides increasing expressiveness, polymorphism also plays an important role for type inference: it allows to send messages to objects of unknown classes, and in particular, without having to determine the class they belong to. This is permitted by the use of structural object types and row variables. Structural types mean that the structure of types is always transparent and cannot be hidden by opaque names. (We also say that object-types have structural equality, as opposed to by-name equality of data-types.) Of course, objects are ``first class'' entities: they can be parameters of functions, passed as arguments or return as results.

On the contrary, classes are ``second class'' entities: they cannot be parameters of other expressions. Hence, only existing classes, ie. of known class types can be inherited or instantiated into objects. As a result, type reconstruction for classes does not require much more machinery than type inference for components of classes, that is, roughly, than type inference for expressions of the core language.

Syntax for objects and classes
We assume three sets u Î F of field names, m Î M of method names, and z Î Z of class names.
Figure 3.1: Syntax for objects and classes
Expressions
a ::= ... | new a | a#m | class z = d in a
Class expressions
d ::=
 
object B end
creation
|
 
z | l x. d | d a
abstraction and instantiation
Class bodies
B ::= Ø | B inherit d | B
 
u = a
field
| B
 
m = V(x)  a
method

To take objects and classes into account, the syntax of the core language is extended as described in fig 3.1. Class bodies are either new classes, written object Bend, or expressions of a small calculus of classes that including class variables, abstraction (over regular values ---not classes), and application of classes to values.

A minor difference with OCaml is we chose to bind self in each method rather than once for all at the beginning of each class body. Methods are thus of the form V(x)  m where x is a binder for self. Of course, we can always see the OCaml expression object (x) u = a; m1 = a1; m2 = a1 end as syntactic sugar for object u = a; m1 = V(x)  a1; m2 = V(x)  a1 end. Indeed, the latter is easier to manipulate formally, while the former is shorter and more readable in programs.

As suggested above, type-checking objects and classes are orthogonal, and rely on different aspects of the design. We consider them separately in two different sections.

3.2.1 Type-checking objects

The successful type-checking of objects results from a careful combination of the following features: structural object types, row variables, recursive types, and type abbreviations. Structural types and row polymorphism allow polymorphic invocation of messages. The need for recursive types arise from the structural treatment of types, since object types are recursive in essence (objects often refer to themselves). Another consequence of structural types is that the types of objects tend to be very large. Indeed, they describe the types of all accessible methods, which themselves are often functions between objects with large types. Hence, a smart type abbreviation mechanism is used to keep types relatively small and their representation compact. Type abbreviations are not required in theory, but they are crucial in practice, both for smooth interaction with the user and for reasonable efficiency of type inference. Furthermore, observing that some forms of object types are never inferred allows to keep all row variables anonymous, which significantly simplifies the presentation of object types to the user.

Object types
Intuitively, an object type is composed of the row of all visible methods with their types (for closed object types), and optionally ends with a row variable (for open object types). However, this presentation is not very modular. In particular, replacing row variables by a row would not yield a well-formed type. Instead, we define types in two steps. Assuming a countable collection of row variables h Î R, raw types and rows are described by the following grammars:
t ::= ... | árñ     r ::= 0 | h | m : t; r
This prohibits row variables to be used anywhere else but at the end of an object type. Still, some raw types do not make sense, and should be rejected. For instance, a row with a repeated label such as (m : t; m : t'; r) should be rejected as ill-formed. Other types such as ám : t; rñ ® árñ should also be ruled out since replacing r by m : t'; r would produce an ill-formed type. Indeed, well-formedness should be preserved by well-formed substitutions. A modular criteria is to sort raw types by assigning to each row a set of labels that it should not define, and by assigning to a toplevel row r (one appearing immediately under the type constructor á · ñ) the sort Ø.

Then, types are the set of well-sorted raw types. Furthermore, rows are considered modulo left commutation of fields. That is, m : t; (m' : t'; r) is equal to m' : t'; (m : t; r). For notational convenience, we assume that (m : _; _) binds tighter to the right, so we simply write (m : t; m' : t'; r).
Remark 6   Object types are actually similar to types for (non-extensible) polymorphic records: polymorphic record access corresponds to message invocation; polymorphic record extension is not needed here, since OCaml class-based objects are not extensible. Hence, some simpler kinded approach to record types can also be used [57]. (See end of Chapter 2, page ?? for more references on polymorphic records.)

Message invocation
Typing message invocation can be described by the following typing rule:
Message
A |- a : ám : t; rñ
A |- a#m : t
That is, if an expression a is an object with a method m of type t and maybe other methods (captured by the row r), then the expression a#m is well-typed and has type t.

However, instead of rule Message, we prefer to treat message invocation (a#m) as the application of a primitive (_#m) to the expression a. Thus, we assume that the initial environment contains the collection of assumptions ((_.m) : " a, h.  ám : a; hñ ® a)m Î M. We so take advantage of the parameterization of the language by primitives and avoid the introduction of new typing rules.

Type inference for object types
Since we have not changed the set of expressions, the problem of type inference (for message invocation) reduces to solving unification problems, as before. However, types are now richer and include object types and rows. So type inference reduces to unification with those richer types.

The constraint that object types must be well-sorted significantly limits the application of left-commutativity equations and, as a result, solvable unification problems possess principal solutions. Furthermore, the unification algorithm for types with object-types can be obtained by a simple modification of the algorithm for simple types.
Figure 3.2: Unification for object types

Use rules of table 1.5 where Fail excludes pairs composed of two symbols of the form (m : _; _). and add the following rule:
Mute   if m1 ¹ m2 and a Ï {a1, a2} È ftv(e)
(m1 : a1; h1) = (m2 : a2; h2) = e
->
$ h.  (m1 : a1; m2 : a2; h) = e Ù h1 = (m2 : a2; h) Ù h2 = (m1 : a1; h)

Exercise 27  [(**) Object types]   Check that the rewriting rules preserves the sorts.

Anonymous row variables
In fact, OCaml uses yet another restriction on types, which is not mandatory but quite convenient in practice, since it avoids showing row variables to the user. This restriction is global: in any unificand we forces any two rows ending with the same row variable to be equal. Such unificands can always may be written as
U Ù ÙiÎ I ( $ hi.  ámi : ti; hiñ = ei )
where U, all ti's and ei's do not contain any row variable. In such a case, we may abbreviate $ hi.  ámi : ti; hiñ = ei as ámi : ti; 1ñ = e, using an anonymous row variable 1 instead of h.

It is important to note that the property can always be preserved during simplification of unification problems.
Exercise 28  [(**) Unification for object types]   Give simplification rules for restricted unification problems that maintain the problem in a restricted form (using anonymous row variables).
Answer

An alternative presentation of anonymous row variables is to use kinded types instead: The equation a = ám : t; 1ñ can be replaced by a kind constraint a :: ám : t; 1ñ (then a = ám : t; 0ñ is also replaced by a :: ám : t; 0ñ).

Recursive types
Object types may be recursive. Recursive types appear with classes that return self or that possess binary methods; they also often arise with the combination of objects that can call one another. Unquestionably, recursive types are important.

In fact, recursive types do not raise any problem at all. Without object types, ie. in ML, types are terms of a free algebra, and unification with infinite terms for free algebras is well-known: deleting rule Cycle from the rewriting rules of figure 1.5 provides a unification algorithm for recursive simple types.

However, in the presence of object types, types are no longer the terms of a free algebra, since row are considered modulo left-commutativity axioms. Usually, axioms do not mix well with recursive types (in general, pathological solutions appear, and principal unifiers may be lost.) Unrestricted left-commutativity axiom is itself problematic. Fortunately, the restriction of object types by sort constraints, which limits the use of left-commutativity, makes objects-types behave well with recursive types.

More precisely, object-types can be extended with infinite terms exactly as simple types. Furthermore, a unification algorithm for recursive object types can be obtained by removing the Cycle rule from the rewriting rules of both figures 1.5 and 3.2.
Remark 7   Allowing recursive types preserves type-soundness. However, it often turns programmers' simple mistakes, such as the omission of an argument, into well-typed programs with unexpected recursive types. (All terms of the l-calculus ---without constants--- are well-typed with recursive types.) Such errors may be left undetected, or only detected at a very late stage, unless the programmer carefully check the inferred types.

Recursive types may be restricted,
eg. such that any recursive path crosses at least an object type constructor. Such a restriction may seem arbitrary, but it is usually preferable in practice to no restriction at all.

Type sharing
Finite types are commonly represented as trees. Recursive types, ie. infinite regular trees, can be represented in several ways. The standard notation µ q. t can be used to represent the infinitely unfolded tree t [q ¬ t [q ¬ ...]]. Alternatively, a regular tree can also be represented as a pair t | U of a term and a set of equations in canonical form. The advantage of this solution is to also represent shared sub-terms. For instance, the type a ® a | a = t is different from the type t ® t when sharing is taken into account. Sharing may be present in the source program (for instance if the user specifies type constraints;) it may also be introduced during unification. Keeping sharing increases efficiency; when displaying types, it also keeps them concise, and usually, more readable.

Moreover, type sharing is also used to keep row variables anonymous. For instance, ám : int; hñ ® ám : int; hñ, is represented as a ® a where a = ám : int; 1ñ. The elimination of sharing would either be incorrect or require the re-introduction of row variables.

In OCaml, type sharing is displayed using the as construct. A shared sub-term is printed as a variable, and its equation is displayed in the tree at the left-most outer-most occurrence of the sub-term. The last example is thus displayed as (ám : int; 1ñ as a) ® a. The previous example is simply (t as a) ® a. The recursive type µ a. ám : int ® añ, which is represented by a where a = ám : int ® añ, is displayed as (ám : int ® añ as a).

Remark that the as construct binds globally (while in (µ a. t) ® a, the variable a used to name the recursive sub-term of the left branch is a free variable of the right branch).

Type abbreviations
Although object types are structural, there are also named, so as to be short and readable. This is done using type abbreviations, generated by classes, and introduced when taking object instances.

Type abbreviations are transparent, ie. they can be replaced at any time by their definitions. For instance, consider the following example
      
class c = object method m = 1 end;;
      
class c : object method m : int end
      
let f x = x#m in let p = new c in f p;;
The function f expects an argument of type ám : a; 1ñ while p has type c. If c were a regular type symbol, the unification of those two types would fail. However, since it is an abbreviation, the unification can proceed, replacing c by its definition ám : intñ, and finally returning the substitution a |® int.

3.2.2 Typing classes


Typechecking classes is eased by a few design choices. First, we never need to guess types of classes, because the only form of abstraction over classes is via functors, where types must be declared. Second, the distinction between fields and methods is used to make fields never visible in objects types; they can be accessed only indirectly via method calls. Last, a key point for both simplicity and expressiveness is to type classes as if they were taking self as a parameter; thus, the type of self is an open object type that collects the minimum set of constraints required to type the body of the class. In a subclass a refined version of self-type with more constraints can then be used.

In summary, the type of a basic class is a triple z(t)(F;M) where t is the type of self, F the list of fields with their types, and M the list of methods with their types. However, classes can also be parameterized by values, ie. functions from values to classes. Hence, class types also contain functional class types. More precisely, they are defined by the following grammar (we use letter j for class types):
j ::= z(t)(F; M) | t ® j

Class bodies
Typing class bodies can first be explored on a small example. Let us consider the typing of class object u = au; m = V(x)  am end in a typing context A. This class defines a field u and a method m, so it should have a class type of the form z(t)(u : tu; m : tm). The computation of fields of an object takes place before to the creation of the object itself. So as to prevent from accessing yet undefined fields, neither methods nor self are visible in field expressions. Hence, the expression au is typed in context A. That is, we must have A |- au : tu. On the contrary, the body of the method m can see self and the field u, of types t and tu, respectively. Hence, we must have A, x : t, u : tu |- am : tm. Finally, we check that the type assumed for the m method in the type of self is the type inferred for method m in the class body. That is, ie. we must have t = ám : tm; rñ.
Figure 3.3: Typing rules for class bodies
Empty
A |- Ø : z(t)(Ø; Ø)
Field
A |- B : z(t)(F; M)      A |- a : t'
A |- (B, u = a) : z(t)(F Å u : t'; M)

Method
A |- B : z(t)(F; M)      A, x : t, F |- a : t'
A |- (B, m = V(x)  a) : z(t)(F; M Å m : t')

Inherit
A |- B : z(t)(F; M)      A |- d : z(t)(F'; M')
A |- B inherit d : z(t)(F Å F'; M Å M')

The treatment of the general case uses an auxiliary judgment A |- B : z(t)(F; M) to type the class bodies, incrementally, considering declarations from left to right. The typing rules for class bodies are given in figure 3.3. To start with, an empty body defines no field and no method and leaves the type of self unconstrained (rule Empty). A field declaration is typed in the current environment and the type of body is enriched with the new field type assumption (rule Field). A method declaration is typed in the current environment extended with the type assumption for self, and all type assumptions for fields; then the type of the body is enriched with the new method type assumption (rule Method). Last, an inheritance clause simply combines the type of fields and methods from the parent class with those of current class; it also ensures that the type of self in the parent class and in the current class are compatible. Fields or methods defined on both sides should have compatible types, which is indicated by the Å operator, standing for compatible union.

Class expressions

Figure 3.4: Typing rules for class expressions
Object
A |- B : z(t)(F; M)      t= áM; rñ
A |- object B end : z(t)(F; M)
Class-Var
d : " a.  j
A |- d : j [a¬ t]

Class-Fun
A, x : t |- d : j
A |- l x. d : t ® j
Class-App
A |- d : t ® j      A |- a : t
A |- d a : j

The rules for typing class expressions, described in Figure 3.4, are quite simple. The most important of them is the Object rule for the creation of a new class: once the body is typed, it suffices to check that the type of self is compatible with the types of the methods of the class. The other rules are obvious and similar to those for variables, abstraction and application in Core ML.
Figure 3.5: Extra typing rules for expressions
Class
A |- d : j
A, z : " (ftv(j) \ ftv(A)).  j |- a : t
A |- class z = d in a : t

New
A |- d : z(t)(F; M)      t= áM; 0ñ
A |- new d : t

Finally, we must also add the rules of Figure 3.5, for the two new forms of expressions. A class binding class z = d in a is similar to a let-binding (rule Class): the type of the class d is generalized and assigned to the class name z before typing the expression a. Thus, when the class z is inherited in a, its class type is an instance of the class type of d. Last, the creation of objects is typed by constraining the type of self to be exactly the type of the methods of the class (rule  New). Note the difference with Object rule where the type of self may contain methods that are not yet defined in the class body. These methods would be flagged virtual in OCaml. Then the class itself would be virtual, which would prohibit taking any instance. Indeed, the right premise of the New rule would fail in this case. Of course, the New rule enforces that all methods that are used recursively, ie. bound in present type of self, are also defined.

Mutable fields
The extension with mutable fields is mostly orthogonal to the object-oriented aspect. This could use an operation semantics with store as in Section 2.2.

Then, methods types should also see for each a field assignment primitive (u ¬ _) for every field u of the class. Thus the Method typing rule could be changed to
Method
A |- B : z(t)(F; M)      A, x : t, F, F¬|- a : t'
A |- (B, m = V(x)  a) : z(t)(F; M Å m : t')
where F¬ stands for {(u ¬ _ : F(u) ® unit) | u Î dom (F)}.

Since now the creation of objects can extend the store, the New rule should be treated as an application, ie. preventing type generalization, while with applicative objects it could be treated as a non-expansive expression and allow generalization.

Overriding
As opposed to assignment, overriding creates a new fresh copy of the object where the value of some fields have been changed. This is an atomic operation, hence the overriding operation should take a list of pairs, each of which being a field to be updated and the new value for this field.

Hence, to formalize overriding, we assume given a collection of primitives {áu1 = _; ...; un = _ñ} for all n Î IN and all sets of fields {u1, ... un} of size n. As for assignment, rule methods should make some of these primitives visible in the body of the method, by extending the typing environment of the Method rule of Figure 3.3. We use the auxiliary notation {áu1 : t1; ... un : tnñ}t for the typing assumption
({áu1 = _; ... un = _ñ} : t1 ® ... tn ® t)
and F * t the typing environment ÈF' Ì F {áF'ñ}t. Then, the new version of the Method rule is:
Method
A |- B : z(t)(F; M)      A, x : t, F, F * t |- a : t'
A |- (B, m = V(x)  a) : z(t)(F; M Å m : t')

Subtyping
Since uses of subtyping are explicit, they do not raise any problem for type inference. In fact, subtyping coercions can be typed as applications of primitives. We assume a set of primitives (_ : t1 :> t2) of respective type scheme " a.  t1 ® t2 for all pairs of types such that t1 £ t2. Note that the types t1 and t2 used here are given and not inferred.
Figure 3.6: Closure and consistency rules for subtyping
Closure rules
  t1 ® t2 £ t1' ® t2' Þ t1' £ t1 Ù t2 £ t2'
  átñ £ át'ñ Þ t £ t'
  (m : t1; t2) £ (m : t'1; t2') Þ t1 £ t1' Ù t2 £ t2'
Consistency rules
  t £ t1 ® t2 Þ t is of the shape t1' ® t2'
  t £ át0ñ Þ t is of the shape át0'ñ
  t £ (m : t1; t2) Þ t is of the shape (m : t1'; t2')
  t £ Abs Þ t = Abs
  t £ a Þ t = a

The subtyping relation £ is standard. It is structural, covariant for object types and on the right hand side of the arrow, contravariant on the left hand side of the arrow, and non-variant on other type constructors. Formally, the relation £ can be defined as the largest transitive relation on regular trees that satisfies the closure and consistency rules of figure 3.6:

Subtyping should not be confused with inheritance. First, the two relations are defined between elements of different sets: inheritance relates classes, while subtyping relates object types (not even class types). Second, there is no obvious correspondence between the two relations. On the one hand, as shown by examples with binary methods, if two classes are in an inheritance relation, then the types of objects of the respective classes are not necessarily in a subtyping relation. On the other hand, two classes that are implemented independently are not in an inheritance relation; however, if they implement the same interface (eg. in particular if they are identical), the types of objects of these classes will be equal, hence in a subtyping relation. (The two classes will define two different abbreviations for the same type.) This can be checked on the following program:
      
class c1 = object end
class c2 = object end;;
fun x -> (x : c1 :> c2);;
We have c1 £ c2 but c1 does not inherit from c2.
Exercise 29  [Project ---type inference for objects]   Extend the small type checker given for the core calculus to include objects and classes.


Previous Up Next