CSE 341: Static typing and multiple inheritance in object-oriented languages

Type safety in OO languages

A program is "safe", roughly, if it does not cause dynamic errors of a certain kind (type errors). This definition is only meaningful relative to some particular language's definition of what a unsafe operation (type error) is:

As we noted when we covered the principles of static type systems, we would like an automated procedure for determining whether a program is safe, but computing this property is undecidable in the general case. Therefore, a static type system for an object-oriented language defines a conservative approximation --- it guarantees that there will be no "message not understood" errors, but also rejects some programs that would not be erroneous if written and executed in a dynamically typed language.

All type systems operate by assigning types to expressions, and then checking that each expression is used only in ways compatible with its type. They differ only in (a) what the types describe, and (b) what operations are allowed on those types.

In object-oriented type systems, the type of an expression E describes what messages may be understood by the value that E will evaluate to. The operation allowed on an expression is a message send, and message sends are handled by methods; therefore, the type system verifies the following:

  1. For every message send, the receiver's type must define a method that can handle that message.
  2. Every method's body must return the value that it is declared to return.

These are simply the OO analogues of functional type checking: in ML, function calls had to be checked to ensure that the argument types conformed to the signature, and function bodies had to be checked to ensure that they returned the type specified in the signature. (In many cases, ML infers the result type from the body, but if the programmer ascribes a return type to a function then ML must check the body against that return type.)

Object-oriented languages also require several additional checks beyond those of functional or procedural languages. In particular, OO languages have bundle methods in classes, and link classes by inheritance; this leads to the potential for new errors, and therefore new requirements:

  1. Every class must implement its declared type(s).
  2. Every class's type must be a compatible extension of the type of its superclass. (This condition is admittedly rather vague; we will define this more precisely later.)

The four conditions enumerated above describe what properties any object-oriented type system must preserve in order to be called safe. In the remainder of these notes, we describe how an object-oriented type system preserves these properties.

A note on syntax: Statically typed object-oriented languages generally do not rely on type inference (in fact, type inference for many varieties of object-oriented languages is undecidable). Therefore, in these notes, we will use Java-like syntax with explicitly-declared types. We will, however, make certain keywords different to emphasize the fact that we are speaking of an "ideal" statically typed OO language, rather than Java itself.

Interfaces: Object types and subtyping

Some terminology: classes vs. types

A class describes an implementation. It defines instance variables and methods that "do stuff" at runtime. A class tells the compiler what kind of code to generate: what methods should handle what messages, how much memory to allocate for an instance, etc.

A type describes an interface. It describes the messages understood by a value. A type tells the programmer and type checker how an expression may be used --- i.e., what messages may be sent to the value resulting from that expression.

In practice, people often confuse these two notions. In this course, we'll try our best to keep these two ideas separate.

Of course, types and classes are closely related --- in most (statically typed) object-oriented languages, every class defines a type, and types are closely tied to classes, but nevertheless it helps to keep the two ideas separate.

Describing object types

Object types resemble record types. An object type consists of a set of unique names (i.e., instance variable and method names), each of which has a type. We could write object types like ML record types:

{ fieldName1:type1,
  ...,
  fieldNameN:typeN,
  methodName1:argType1 -> returnType1,
  ...,
  methodNameM:argTypeM -> returnTypeM }

However, we will instead use a Java-like syntax, which might be more familiar to mainstream programmers:

signature SigName {
  type1 fieldName1;
  ...  
  typeN fieldNameN;
  returnType1 methodName1(argType1,1, ... argType1,p1);
  ...
  returnTypeM methodNameM(argTypeM,1, ... argTypeM,pM);
}

For example, here is an interface for Points with two fields and a single method:

signature Point {
  Integer x;
  Integer y;
  Point move(Integer dx, Integer dy);
}

Note the following:

Ignoring fields

Consider any object type with a read-only field of type Foo:

signature { Foo x; }

It is clear that such a type is somehow equivalent to a type with a "getter" method:

signature { Foo getX(); }

Likewise, if x is a mutable field, then it is equivalent to the following type:

signature {  Foo getX();  void setX(Foo x);  }

All interesting type checking issues in OO languages can be described at the method level. The rules for a type with some field(s) are exactly the rules for a type with the method(s) that are "equivalent" (in the above sense) to the field(s).

In the remainder of these notes, we will assume that our object types contain only methods.

Permutation

As with records, we assume that we can freely permute the members of an object type, without changing the meaning of the type. Or, in other words, order of members in an object type do not matter; two object types are equal if they contain exactly the same names and types, in whatever order.

Subtyping

The essence of object-oriented static typing is subtyping, or subtype polymorphism. In a type system with subtyping, a type T1 may be a subtype of another type T2, which means that all instances of T1 can be treated as instances of T2 --- i.e., an instance of T1 can appear anywhere an instance of T2 appears, without causing a message-not-understood error. In other words:

T1 is a subype of T2 if instances of T1 are substitutable for instances of T2.

This is called the substitutability principle.

In order for T1 to be substitutable for T2, any operation that is legal for T2 must be legal for T1. In object-oriented languages, this translates into:

T1 subtypes T2 if

We will sometimes write "T1 subtypes T2" using the notation T1 <: T2. Also, we will sometimes say that a subtype is more specific than its supertypes, and supertype is more general than its subtypes.

Subtyping is reflexive and transitive

Trivially, T1 <: T2 is if T1 is exactly like T2; or, to put it another way, every type is a subtype of itself. We therefore say that subtyping is reflexive.

Definition: We say that a subtype T1 is a strict subtype of T2 if T1 <: T2 and T1 is not equal to T2. We will sometimes write "T1 strictly subtypes T2" as T1 < T2.

Another way to have T1 <: T2 is if T1 <: T3, and T3 <: T2. That is, if T1 is a subtype of T3, and T3 is a subtype of T2, then T1 is a subtype of T2. We therefore say that subtyping is transitive.

Width and depth subtyping

One way to have T1 <: T2 is if T1 is like T2, but with more methods. Consider the following:

signature Point {
   Integer x();
   Integer y();
   Point move(Integer dx, Integer dy);
}

signature ColoredPoint {
   Integer x();
   Integer y();
   Color color();
   Point move(Integer dx, Integer dy);
}

Clearly, any object satisfying ColoredPoint will also satisfy Point, because ColoredPoint contains all of Point's methods (with exactly the same types), plus some extra methods. Generalizing, we obtain the following rule:

T1 <: T2 if T1 possesses all of T2's methods, and also adds some additional methods.

This rule --- that a subtype can be formed by adding more methods --- is called width subtyping, because we are making the type "wider" by adding more fields.

Conversely, suppose we define two types

signature Rectangle {
   Point topLeft();
   Point bottomRight();
}

signature ColoredRectangle {
   ColoredPoint topLeft();
   ColoredPoint bottomRight();
}

In this case, we are making ColoredRectangle's methods return subtypes of the equivalent types in Rectangle. Note that this still satisfies our subtyping condition (b) above --- the result of sending topLeft() and bottomRight() to a value of ColoredRectangle will be compatible with the result of sending these same messages to a value of Rectangle.

Generalizing, we have the following rule:

T1 < T2 if T1 possesses all of T2's methods, except that some methods of T1 subtype the equivalently named methods of T2.

This is called depth subtyping, because we are making some parts of T1 "deeper" in the subtype hierarchy than the equivalent members of T2.

Method subtyping

In the preceding section, we used the phrase "some methods of T1 subtype the equivalently named methods of T2". However, we have never precisely defined subtyping over methods. It may seem obvious, but there is actually a fairly subtle issue here. Consider the following types:

signature Fruit  { String name(); }
signature Apple  { String name(); Stem stem(); }
signature Banana { String name(); void slipOnPeel(); }

signature FruitPlant { Fruit produce(); }
signature ApplePlant { Apple produce(); }

signature FruitFly { void eat(Fruit f); }
signature AppleFly { void eat(Apple a); }

It seems obvious that

And, indeed, width subtyping gets us these relationships.

However, for the plants and flies, we must think a little harder. Which plant is a subtype of which? And which fly is a subtype of which? Consider the following code:

ApplePlant ap = ...;
FruitPlant fp = ap;
Fruit f = fp.produce();
String s = f.name();

Notice that fp will point to some value that satisfies ApplePlant. When we call produce() on this value, it will produce an Apple --- which meets the definition of Fruit. So we're safe --- it seems ApplePlant is substitutable for FruitPlant. More generally:

Method return subtyping: In order for method M1 to subtype method M2, M1 must return a type at least as specific as of M2's return type. In other words, M1's return type must subtype M2's return type.

This is pretty much what you expect. However, consider the following code:

AppleFly af = ...;   // 1
FruitFly ff = af;    // 2
Fruit aFruit = ...;  // 3
ff.eat(aFruit);      // 4

Notice that ff points to a value that satisfies AppleFly. Later, on line 4, we apply ff's eat to a fruit --- but this is not sound! Suppose that the AppleFly implementor's eat method calls stem() on its argument:

    void eat(Apple a) {
        System.out.println("Yummy stem: " + a.stem().toString());
    }

If we pass a Fruit to the eat method --- for example, a Banana that does not implement stem() --- then we will be sending a message to an object that does not understand it, and breaking type safety! Where did we go wrong?

The answer is at line 2 in the code sample above, where we assume that AppleFly is a subtype of FruitFly, and assign af to ff.

In fact, exactly the converse is true: FruitFly is a subtype of AppleFly! Think of this in English, and it makes sense: a FruitFly can eat everything an AppleFly can eat, and more. Therefore, a FruitFly can be substituted for an AppleFly. But the converse is not true: an AppleFly cannot eat everything a FruitFly can; it is "more picky" about its food.

Generalizing, we obtain the following rule:

Method argument subtyping: In order for method M1 to subtype method M2, M1 must take arguments at least as general as the arguments of M2. In other words, M2's argument type(s) must subtype M1's argument type(s).

Note that this is "backwards": as the method's argument gets more general, the method itself gets more specific. Because of this "backwardness", this is known as the contravariant type rule for argument types ("contra" is a Latin root meaning "against" --- as in "contrast" or "counterpoint").

By contrast, we say that return types are covariant ("co" is the Latin root meeting "with", as in "cooperate") --- as the return type gets more specific, the method itself gets more specific.

Other method subtyping rules

The contravariant argument rule is, in some sense, the "natural" one. However, real-world languages have often used different ones. There are two possibilities:

Java and C++ use the invariant argument type rule. In doing so, they lose some flexibility --- in these languages, is not possible for FruitFly to subtype AppleFly. However, in doing so, they gain the benefit of static overloading --- see the notes on overloading under "Miscellaneous issues", near the end of these notes.

In fact, the Java designers were so fond of invariant subtyping that they selected the invariant return type rule as well --- for a method M1 to subtype a method M2, they must return exactly the same types. This choice is truly bizarre, since it does not even help with overloading. By contrast, C++ uses the "natural", covariant return type rule.

The covariant argument type rule is unsound --- it is, in fact, exactly the rule that leads to the broken example above, wherein we assign a AppleFly to a reference of type FruitFly. However, the Eiffel language uses covariant argument subtyping. To recover soundness, it catches the error at runtime --- in the example above, line 4 would fail with a dynamic error if the value was not an Apple. Notice that this is different from having a "message not understood" error in the body of PomumPhage.eat --- the error is caught when the ill-typed argument is passed, not when some invalid message is sent to that argument.

Implementations: Classes, methods, and inheritance

In the previous section, we defined the pure subtyping relation. The rules we defined --- reflexivity, transitivity, width, depth, and contravariance --- allow us to tell which types are subtypes of others. However, they don't tell us anything about how to make classes that conform to those types.

(We will continue to omit any discussion here of constructors and instance variables. There are actually surprisingly few interactions between constructors and subtyping. Constructors in object-oriented languages generally construct an exact class, which is used as the type of the constructor expression. In the following discussion, we simply assume that there's some mechanism for invoking constructors and initializing instance variables, e.g. new.)

Let us assume that a class must declare its superclass, plus all the types that it implements, and that it defines some methods with bodies:

class C1
  subclasses C2
  implements S1, S2, ... SN
{
  returnType1 methodName1(argType1,1, ... argType1,p1) { body1 }
  ...
  returnTypeN methodNameM(argType1,M, ... argType1,pM) { bodyM }
}

(As with signature, we use the keyword subclasses to emphasize the fact that this is not Java.)

A static type system must impose some requirements on classes. Here's the most basic requirement:

Completeness of implementation: A class C must define or inherit a method to handle every message in its types.

If a class does not satisfy this requirement, then it cannot possibly be sound to regard that class as an instance of its type(s). For example, consider the broken Apple class:

class MauvaisePomme
    subclasses Object
    implements Apple {
    String name() { return "BadApple"; }
}

MauvaisePomme mp = ...;  // 1
Apple a = mp;            // 2
Stem s = a.stem();       // 3

There will be a "message not understood" error at line 3, becuase a MauvaisePomme does not define stem(). It is not a complete implementation of its declared types.

Note that a class might implement its interface by inheriting from some other class:

class BonFruit
    subclasses Object
    implements Fruit {
    String name() { return "some kind of fruit"; }
}

class BonnePomme
    subclasses BonFruit
    implements Apple {
    Stem stem() { return new Stem(...); }
}

Abstract and concrete classes

Many OO languages allow the programmer to define abstract methods, i.e. declarations of methods with no body, with the intention that such methods will be overridden with a non-abstract method in a subclass. We write abstract classes as follows:

class C1
    subclasses C2
    implements S1, ... SN
{   ... // as before, plus:
    abstract returnType1 methodName1(argType1,1, ... argType1,qM);
    ...
    abstract returnTypeK methodNameK(argTypeK,1, ... argTypeK,qK);
}

For example:

class AbstractPoint subclasses Object implements Point {
    abstract Integer x();
    abstract Integer y();
    abstract Point copy(Integer newX, Integer newY);
    Point move(Integer dx, Integer dy) { return clone(x()+dx, y()+dy); } }

This class provides a default implementation of Move, and leaves the question of how to implement x(), y(), and copy() to subclasses.

In languages with abstract methods, we say that a class C is abstract if it defines or inherits some abstract method that is not overridden by a non-abstract method, or if it does not implement all the methods in its type(s). C is concrete if it is not abstract.

Notice that we have relaxed the "completeness of implementation" rule --- only concrete classes are now required to have a method for every message in the type. This re-opens the possibility for "message not understood" errors; to ensure that there are no such errors, static type systems with abstract classes usually require the following:

Concrete instantiation restriction: Only concrete classes can be instantiated.

(Notice that this condition is not the only way to prevent abstract classes from receiving messages they cannot handle. One could, instead, allow abstract classes to be instantiated, but to require that no instance of an abstract class ever be sent a message that might lead to an invocation of an abstract method. However, this leads to a more complicated type system, and in most designs it's not really sensible to instantiate abstract classes anyway, so language designers usually choose to restrict instantiation rather than message send.)

Compatible extension

Consider the following classes (assume the class/type definitions for BonFruit etc. defined previously):

signature Bogus {
    Integer name();
}

class Papaya
    subclasses BonFruit
    implements Bogus {
    Integer name() { return 456; }
}

In this case, Papaya overrides the name() method with a method that returns Integer. This would be okay, except that we usually want all subclasses of a class C to have subtypes of C's type. In fact, most languages require that this be true --- i.e., the subtype hierarchy must include the inheritance hierarchy.

In such languages, we must ensure that subclasses do not override superclass methods in some "incompatible" way. To ensure this, we impose the following restriction:

Subtype overriding restriction: A class may only override a superclass method M1 with method M2 if M2 subtypes M1.

This rule ensures that every subclass instance is substitutable for every superclass instance, and would cause Papaya to be rejected.

Miscellaneous issues

We have already covered most of the important ideas in static typing for object-oriented languages. This section covers several miscellaneous, less-important topics.

Access protection

In the entire discussion above, we have ignored access protections, assuming all methods are public. Most practical object-oriented languages have some form of access protection, but precisely describing the semantics of access protection is rather complex.

Intuitively, we must do the following:

Due to space and time constraints, we won't cover the details in this class.

Structural subtyping vs. nominal subtyping

In the presentation we've given here, we use structural subtyping: T1 subtypes T2 if T1's members have a "structure" that satisfies some conditions with respect to T2.

An alternative organization, and the one actually used by most practical languages, is nominal subtyping (a.k.a. by-name subtyping): T1 subtypes T2 if T1's members structurally subtype T2, and T1 declares that it subtypes T2.

(This is called by-name subtyping because T1 must "name" T2 in order to subtype it.)

For example, Java uses by-name subtyping: a Java interface I1 only subtypes I2 if I1 explicitly declares the subtype relationship. Therefore, the following interfaces have no subtype relationship, even though width subtyping would give us that relationship:

interface I1 { void foo(); }
interface I2 { void foo(); void bar(); }

In order to make I2 subtype I1, we must add an extends clause:

interface I1 { void foo(); }
interface I2 extends I1 { void foo(); void bar(); }

By-name subtyping is widely regarded in the academic language community as inferior, because it requires that the programmer "plan ahead" for all the possible subtyping relationships (s)he might want in the future. However, by-name subtyping is somewhat easier to implement efficiently, and is more familiar to users of past languages, so it has been a popular choice in mainstream languages.

Principal typing of classes

Many practical languages do not force the programmer to explicitly declare the type separately from a class. For example, Java allows the user to simply write a class, and refer directly to the class as if it were a type:

class Point {
  Integer x() { ... }
  Integer y() { ... }
  ...
}

Point p = new Point(...);

What's going on here? Well, Java is automatically defining a principal type for the Point class (a principal type is the unique "best type" for an expression or declaration --- recall that we used this term in ML). When you refer to Point as a type (for example, by declaring a variable of that type), you are referring to the principal type of Point.

When you inherit from a class, you are assumed to implement its principal type.

(Aside: there is some controversy in the language design community as to whether types in a by-name subtyping regime can truly be called principal types. This hinges on some rather pointy-headed arguments about terminology, and I am not going to make a big deal out of it. For our purposes, the type of the Point class is "principal enough".)

Overloading vs. overriding

Some languages allow overloading in addition to overriding. For example, consider the following Java class:

class Point extends Object {
    Integer x() { ... }
    Integer y() { ... }
    Point move(Integer dx, Integer dy) { ... }
    Point move(Float dx, Float dy) { ... } }

The two move methods are not really related to each other. Neither overrides the other; no message could never dispatch to both. The above class could just as easily be written:

class Point extends Object {
    Integer x() { ... }
    Integer y() { ... }
    Point moveInt(Integer dx, Integer dy) { ... }
    Point moveFloat(Float dx, Float dy) { ... } }

Since a class that uses overloading can always be rewritten to use different method names for the overloaded functions instead, overloading is purely a syntactic sugar feature.

Overloading is sometimes called ad hoc polymorphism (in contrast to subtype polymorphism, which is the mechanism whereby a subtype can be substituted for its supertypes).

Overloading is always resolved statically, at compile time. This sometimes has some surprising results:

class Shape extends Object {
   boolean overlaps(Shape other) { ... }
}

class Rectangle extends Shape {
   boolean overlaps(Rectangle other) { ... }
}

Rectangle r = new Rectangle(...);
Shape s = new Rectangle(...);
boolean b = r.overlaps(s);        // X

The method that will be invoked at line X will be Shape.overlaps(Shape other), not Rectangle.overlaps(Rectangle other). This is because overloading is resolved using only the static types of the argument expressions, not the dynamic value.

Thought exercise: why would overloading interact in strange ways with contravariance of argument types? Can you construct an example where it would not be clear whether a method is being overloaded or overridden?

Subtyping of mutable objects

Consider the following types:

signature FruitRef {
    Fruit fruit();
    void setFruit(Fruit f);
}

signature AppleRef {
    Apple fruit();
    void setFruit(Apple a);
}

Is there any subtyping relationship between them under the "natural" subtyping rule? The (perhaps surprising) answer is no!

By covariance of return types, we get the method subtyping relationship Apple fruit() <: Fruit fruit(), but not vice versa.

However, by contravariance of argument types, we get the opposite method subtyping relationship: void setFruit(Fruit) <: void setFruit(Apple), but not vice versa.

Neither signature is a subtype of the other! Now, recall that the above types are "equivalent" to the following types with mutable fields:

signature FruitRef {
    mutable Fruit fruit;
}

signature AppleRef {
    mutable Apple fruit;
}

We can therefore see that objects with mutable fields do not have the subtype relationship one might initially expect. Indeed, here is proof that subtyping of mutable fields is unsound:

class BananaImplementor
    extends Object
    implements Banana {
    String name() { ... }
    void slipOnPeel() { ... }
}

AppleRef ar = new AppleRefImplementor(); // 1
FruitRef fr = ar;                        // 2
fr.fruit = BananaImplementor();          // 3
Apple anApple = ar.fruit;                // 4
Stem s = anApple.stem()                  // 5

We have used substitutability to assign an AppleRef to a FruitRef pointer. Then, in line 3, we update the FruitRef using a legal assignment. In line 4, we retrieve that reference at the type Apple. Finally, in line 5, we prove that this is unsafe by calling stem(), which the object bound to anApple will not understand.