Subtype Polymorphism
Introduction
We now explore the concept of subtyping, one of the key features of object-oriented languages. Subtyping was first introduced in SIMULA, considered the first object-oriented programming language. Its inventors Ole-Johan Dahl and Kristen Nygaard later went on to win the Turing Award for their contribution to the field of object-oriented programming. SIMULA introduced a number of innovative features that have become the mainstay of modern OO languages including objects, subtyping and inheritance.
The concept of subtyping is closely tied to those of inheritance and polymorphism and offers a formal way of studying them. It is best illustrated by means of an example:
A Subtype Hierarchy
This is an example of a subtype hierarchy, which describes the relationship between different entities. In this case, the Student and Staff types are both subtypes of the Person type (alternately, Person is the supertype of Student and Staff). Similarly, TA is a subtype of the Student and Person types and so on. A subtype relationship can also be thought of in terms of subsets. For example, this example can be visualized with the help of the following Venn diagram:
Subtypes as subsets
The
Subtyping as inclusion
The statement
Subtyping rules
The informal interpretation of the subtype relationship
(Sub) |
Notice that the right premise in this rule is actually a side
condition, relying on a separate, new judgment of the form
The subsumption rule is a perfectly well-defined typing rule, but it has one serious problem for practical application: it is not syntax-directed. Given a judgment to be derived, we can't tell whether to use the subsumption rule or a rule whose conclusion matches the syntax of the desired judgment.
The subtyping relationship is reflexive and transitive:
(Refl) |
(Trans) |
Since the
-
The unit type 1: Being the top type, any type can be treated
as a subtype of 1. If a context expects something of type 1, it can
never be disappointed by a different value. Therefore,
. In Java, this is much like the typevoid
that is the type of statements and of methods that return no value. -
We can also introduce a bottom type 0 that is a universal
subtype. This type can be accepted by any context in lieu of any
other type: i.e.,
. This type is useful for describing the result of a computation that never produces a value. For example, it describes a computation that never terminates, or that perhaps transfers control somewhere else in the program rather than producing a value for the context in which it is evaluated.
The type hierarchy thus looks as shown:
Type Hierarchy
Subtyping on product types (tuples)
The simplest kind of data structure is a pair (a 2-tuple), with the
type
(PairSub) |
This rule is covariant: the direction of subtyping
in the arguments to the type constructor
(
Records
We are particularly interested in how to handle subtyping in the context of object-oriented languages. Record types correspond closely to object types and yield some useful insights.
A record is a collection of immutable named fields,
each with its own type. We extend the grammar of
with the following typing rules:
(Tuple) |
(Select) |
What we can see from this is that the record type
There are actually three reasonable subtyping rules for records:
-
Depth subtyping: a covariant subtyping relation between
two records that have the same number of fields.
(Depth) -
Width subtyping: a subtyping relation between
two records that have different number of fields.
(Width) -
Permutation subtyping: a relation between records with the
same fields, but in a different order. Most languages don't
support this kind of subtyping because it prevents compile-time
mapping of field names to fixed offsets within memory (or to fixed
indices in a tuple). Notice that permutation subtyping is not
antisymmetric.
(Permutation)
The depth and width subtyping rules for records can be combined to yield a single equivalent rule that handles all transitive applications of both rules:
| ||
(Width) |
Function subtyping
Based on the subtyping rules we have encountered up to this point, our first impulse is perhaps to write down something like the following to describe the subtyping relation for function types:
| ||
(BrokenFunSub) |
However, this rule is incorrect, despite being adopted by the Eiffel programming language and, more recently, TypeScript! To see why, consider the following code snippet:
f: τ1→τ2 = f1 f′: τ1′→τ2′ = f v: τ1 = ... f′(v)
In the example above, since the subtype of f
is a subtype of the type
of the type of f′
(according to the broken rule), we should be able to use
f
where f′
is expected. Therefore we should be able to
call f(t′)
. But f
expects an input of type τ1
and gets instead an input
of type τ′1
, so we should be able to use τ′1
where τ1
is expected—which in fact implies that we should
have τ′1 ≤ τ1
instead of τ1 ≤ τ′1
as given.
Function subtyping
We can derive the correct subtyping rule for functions by thinking
about the figure above. The outer box represents a context
expecting a function of type
| ||
(FunSub) |
The function subtyping rule is our first example of contravariance
in subtyping—the direction of the subtyping relation is reversed in the
premise for the first argument (
The rule for function subtyping determines how object-oriented
languages can soundly permit subclasses to override the types of
methods. If we write a declaration C extends D
in a
Java program, for example, it had better be the case that the types
of methods in
C
are subtypes of the corresponding types in D
. Checking this
is known as checking the conformance of C
with D
.
It is sound for object-oriented languages to check conformance by
using a more restrictive rule than (FunSub), and Java
does: as of Java 1.5, it allows the return types of methods to be
refined covariantly in subclasses (the
It took a surprisingly long time for everyone to agree on the right subtyping rule for functions. The broken rule (BrokenFunSub) was actually used for conformance checking in the language Eiffel. Run-time type-checking had to be added later to make the language type-safe. More recent work on family inheritance mechanisms such as virtual classes and nested inheritance shows how to soundly permit some of the covariant overriding that the Eiffel designers wanted.
Subtyping rules for arrays
What about subtyping on array types?
For the subtyping rule, our first impulse, especially if we have been doing a lot of Java programming, might be to write down a covariant rule:
(JavaArraySub) |
However, this rule is again incorrect. To see why, consider the following example:
x: TA[] = new TA[0] y: Person[] = x y[0] = undergrad1; x[0].gradeAssignment() // something that only TAs can do!
Even though this code type-checks with the given subtyping rule for
array types, it will cause a run-time error, because in the last
line x[0]
does not evaluate to a TA. To avoid
this problem, the subtyping relation on array types should be
invariant in
Since the equivalent code type-checks in Java, you might be wondering
how Java can get away with a covariant subtyping rule. The answer
is that Java uses extra run-time checking. At the third line, the
assignment to y[0]
will failed with a run-time
exception: ArrayStoreException
.
The extra run-time checking not only makes code less robust but also imposes a significant
run-time cost on use of arrays.
Type checking with subtyping
Since the subsumption rule is not syntax-directed, including directly in the type checker might seem to make type checking very expensive. At each call to the type checker, the syntax doesn't tell us whether to use subsumption or not, suggesting that we might have to do an exponential-time search. Fortunately, such a search is normally not needed.
Instead, we fold all possible use of subsumption into each of the ordinary syntax-directed typing rules. This is made possible by designing each rule so that it produces a principal type: a best possible type that loses no information. In the case of subtyping, the principal type should be a subtype of all other types the expression might have. We then adjust all typing rule premises so that they do not require subexpressions to have the exact type expected, but instead any subtype of the expected type.
For example, let us consider the rule for assigning to an array element in Xi:
| | ||
(ArrAssign) |
In the presence of subtyping, the type derived for
| | | ||
(ArrAssignSub) |
This recipe works well for most typing rules. One issue that can come up is when two premises are required to produce the same type. For example, suppose that we wanted a “ternary expression” ala C or Java. In the absence of subtyping, the rule is straightforward:
| | ||
(Ternary) |
Now imagine using subsumption to derive the types of both
| | |||||
|
| |||||
(Ternary) |
Unlike with the previous example, we cannot just read out a new syntax-directed rule
from the five premises at the top, because they do not say how to choose
| | |||
(Ternary) |
Taking the least upper bound is not necessarily possible for an arbitrary
subtyping relation. For example, in Java two classes C1
and
C2
can each implement two interfaces I1
and
I2
. Both I1
and I2
are upper bounds for
the two classes but neither one is more precise than the other. In this case
the type checker may do some approximation by coarsening the result type upward
in the subtyping order. Alternatively it may introduce a new type constructor
for constructing the least upper bound of two types—at the cost of new complexity
elsewhere in the type system and type checker.