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:
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:
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.
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.
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:
public
, protected
,
private
). We will discuss these later. For now,
assume everything is public.signature
to begin object
types in order to avoid confusion between types and classes, or
between our object types and Java's interface
construct, which we'll discuss later. (Our syntax also
highlights the connection between object types and module
signatures in ML --- both describe the types of a collection of
named values.)Point
above), when we don't need to refer to
it.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.
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.
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
- instances of T1 can receive every message that instances of T2 can receive, and
- for every message send, T1 always returns a result compatible with the result of the same message sent to T2.
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.
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.
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.
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
Apple <: Fruit
Banana <: Fruit
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.
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.
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(...); } }
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.)
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.
We have already covered most of the important ideas in static typing for object-oriented languages. This section covers several miscellaneous, less-important topics.
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:
public
,
private
, etc.) to interfaces and to class
members.Due to space and time constraints, we won't cover the details in this class.
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.
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".)
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?
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.