CSE 331: Subtyping
Course motto: Your job is to deliver code that you know works.
Every argument this course has made about a method call used the specification of the method called, and never its code. Lecture 2 made that the point of a specification: the client reads the promise and reasons from it, and the implementation can change underneath. Java adds a twist. A method written to take one type can be handed an object of another type, as long as the compiler agrees, and the object it is handed may belong to a class that did not exist when the method was written. This lecture asks what that does to the argument. When a client was reasoned about against one type's specification, is the reasoning still right once an object of a subtype arrives? The answer is a definition, the behavioral subtype, and a rule that the compiler cannot keep for you.
A SaleProduct where a Product is expected
Here is a method that adds up the prices in a shopping cart. Its specification is one line of Javadoc, and its loop asks each product in the cart for its price:
| Products.java | |
|---|---|
25 26 27 28 29 30 31 32 | |
total is written against one class, Product. Its parameter is a
list of Products, and the only method of Product it calls is
getPrice, in its loop.
Here is a main that fills a cart with two products and hands the
cart to total:
| Products.java | |
|---|---|
15 16 17 18 19 20 21 22 23 | |
println prints an object by calling the object's toString
method,3 so main 's loop prints each product as its
toString says. Here are the two classes, Product and SaleProduct:
| Products.java | |
|---|---|
35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | |
SaleProduct is declared extends Product. It adds one
field, discount, the fraction of the price taken off. Its
constructor passes the name and the price on to Product's, with
super(name, price). Its getPrice asks Product's
getPrice for the price, with super.getPrice(), and
takes the discount off.
When a class writes a method with the same name and parameters as
a method of the class it extends, the class is said to
override that method. A call of the method on an object of the
class then runs the class's own body, even in code written against
the class it extends. So a call of getPrice on a SaleProduct
runs SaleProduct's body, not Product's. The @Override above
SaleProduct's getPrice asks the compiler to check that Product
has a getPrice to override. Product's toString likewise
overrides the one every Java class starts with, from Object.
Now look at the call of add that puts the m&ms in the cart.
cart is a List<Product>, so its add method takes a Product.
That call hands add a SaleProduct instead. Does the compiler
accept that call? If it does, what do the two calls of println
print? Write down your prediction before you read the run.
$ java Products
skittles: $2.0
m&ms: $2.0
total: $3.0
The compiler accepted that call. main ran to the end. The last
line is the one total computed, so start there. The skittles cost
2 dollars. The m&ms cost 2 dollars with half taken off, which is
1 dollar. So $3.0 is what the two cost together. To get it,
total's loop called getPrice on the SaleProduct, and the call
ran SaleProduct's getPrice, though nothing in total mentions
SaleProduct.
The second line is another matter. The m&ms are half off, and the
line shows their full price, $2.0! Add up the first two lines,
and you get 4 dollars, not the $3.0 under them. Keep that line
in mind.
A wider rule. Many of us learned a simple rule for Java's
types: a parameter of type Product must be passed a Product. That
rule is why javac refuses a call that passes 17 to a parameter
of type String. The call that puts the m&ms in the cart breaks the
simple rule, yet javac accepted that call. There, the compiler
checked something wider: that SaleProduct reaches Product through
the extends in SaleProduct's declaration.
Definition: Java subtype
A type B is a Java subtype of a type A if the compiler
accepts a B where an A is expected. The compiler accepts it
when B reaches A by a chain of extends and implements:
B extends or implements A, or B extends or implements a type
that reaches A. A is then a Java supertype of B.
Here is what three of the box's words mean:
- Expected means the compiler knows the type a place needs: the
argument for a parameter of type A, a value stored in a variable
declared with type A, or a value returned from a method declared
to return an A. In the call that puts the m&ms in the cart, the
place was
add's parameter. - Chain means a Java subtype may be more than one step away.
SaleProduct reaches Product in one step, by its
extends. Java's ArithmeticException reaches Exception in two: ArithmeticException extends RuntimeException, which extends Exception. - Java is in the name because the compiler decides the question
from the
extendsandimplementsdeclarations alone, and never from a specification.1
Lecture 10
made the same point about implements: "The compiler cannot
check whether each body does what its specification says."
You have been handing Java subtypes to code written for their supertypes for weeks, code that never mentions the subtype:
- An ArrayList in a List variable.
mainabove stores an ArrayList in a variable declared as aList<Product>, and ArrayList implements List. Lecture 1's Sum did the same with its variablelist, and so did Lecture 10's ListQueue. - Lecture 10's interfaces and test subclasses. ListQueue
implements HelpQueue, so ListQueueSpecTest's
emptyQueue, declared to return a HelpQueue, could return a ListQueue. And ListQueueSpecTest extends HelpQueueTest, so JUnit could run the five tests HelpQueueTest declares on a ListQueueSpecTest. - Lecture 13's exception classes. A
catchfor a class, that lecture said, takes an exception "of that class or of any class that extends it". The calculator'scatchfor CalcException took a DivideByZeroException, one step down. TraceSwallow'scatchfor Exception took an ArithmeticException, two steps down.
The lecture's question. total is one more such case. Here is
the argument that total satisfies its specification, made from
specifications alone, as
Lecture 2
reasoned across a call. sum starts at 0.0. Each time
around the loop, total adds p.getPrice() to sum. Product's
specification of getPrice promises the price of
this product, in dollars. So after the loop, sum is the sum of
the prices of the products in cart. That is the promise in
total's Javadoc.2
That argument used one fact about the products in cart:
Product's promise about getPrice. The argument used nothing
from Product's body. total never mentions SaleProduct, so the
argument could not have used SaleProduct's body either.
SaleProduct could have been written a year after total, by
someone else. Yet main handed total a SaleProduct, and
total's call of getPrice ran SaleProduct's getPrice. Its
specification promises something Product's specification does
not: the price of this product with its discount taken off.
total was argued correct against Product's specification. So the
question for the rest of this lecture is this: when a SaleProduct
arrives, or any Java subtype of Product written later, is that
argument still right? And what would a Java subtype have to
promise to make it right?
Before we can answer, we need to know what extends Product gave
SaleProduct besides the compiler's permission to put a SaleProduct
in cart. The m&ms line, at full price, is where to start.
Subclassing is copying code
The m&ms are half off, so they should print at $1.0. Yet the run
printed m&ms: $2.0. In the same run, total counted the m&ms at
$1.0, since the total came to $3.0 and not $4.0. So one object
reported two different prices in one run. To see how, we need to
know what extends Product did for SaleProduct. Here is
SaleProduct again:
| Products.java | |
|---|---|
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | |
SaleProduct's own lines declare just three things: the field
discount, a constructor, and the method getPrice. Yet they
declare no name field and no toString method, and the run
still printed the m&ms by name, through a toString. Those come
from Product. extends Product makes SaleProduct behave
as if Product's fields and methods had been copied into it. So
every SaleProduct object has the fields name and
price.4 It also has the methods getName and
toString, exactly as Product wrote them. The constructor's first
line, super(name, price), runs Product's constructor.
Product's constructor sets the copied fields.
SaleProduct also declares getPrice, a method Product already
has, so SaleProduct's getPrice overrides Product's, as the last
section said. For a SaleProduct object, SaleProduct's getPrice
runs in place of Product's, and super.getPrice()
reaches the one it replaced, to get the full price before the
discount comes off.
This copying is called inheritance. A class gets the fields
and the methods of another class without writing them out, except
the methods it overrides. What is the alternative? Without
extends, we could write SaleProduct out in full ourselves. We
would copy Product's class, rename it, add the
discount, and change getPrice. That would work. The program
would print the same three lines. Now suppose the store decides to
print prices with two decimal places, $1.00 instead of $1.0.
That change belongs in toString, and there are now two copies of
toString to change. With a third kind of product copied the
same way, there would be three. A copy that missed the change
would print its prices the old way. Nothing would warn us. With
inheritance there is one toString, in Product. A change there
reaches SaleProduct, and every other subclass of Product, with no
copy to forget.
Copying explains the bug too. main's loop prints each product
through its toString. SaleProduct does not override toString,
so for the m&ms the toString that runs is Product's, the one
SaleProduct inherited:
| Products.java | |
|---|---|
55 56 57 58 59 | |
Meanwhile total asks each product in the cart for its price:
| Products.java | |
|---|---|
25 26 27 28 29 30 31 32 | |
Each listing has one line that gets a price for the m&ms. Before you read on, find the line that gives the m&ms their full price, and say why the other line does not.
Product's toString reads the field price itself. For the m&ms,
that field holds 2.0, the price main gave the m&ms, which
Product's constructor stored. Overriding getPrice changed which
getPrice runs on a SaleProduct. It did not change the field, and
toString never calls getPrice. Picture the copy of toString
inside SaleProduct: it still reads the field, and the field still
holds the full price.
total, on the other hand, calls getPrice. The variable p is
declared a Product, but Java chooses which getPrice runs by the
class of the object p refers to, not by the type p was
declared with. This choice, made each time the call runs, is
called dynamic dispatch. On the m&ms, p refers to a
SaleProduct, so the call in total runs SaleProduct's getPrice
and gets 1.0. So one object reported two prices because total
called the overridden getPrice and toString read the field.
When toString was written, reading price and calling
getPrice gave the same answer, since Product's getPrice just
returns price. The two stopped agreeing only once a subclass
overrode getPrice. ProductsFixed.java is Products.java changed in
toString alone, to call getPrice. Its opening comment is four
lines shorter, so the changed line is line 54 there:
| ProductsFixed.java | |
|---|---|
51 52 53 54 55 | |
Now toString calls getPrice, and on the m&ms dynamic dispatch
runs SaleProduct's getPrice, as it does in total:
$ java ProductsFixed
skittles: $2.0
m&ms: $1.0
total: $3.0
The m&ms now print at $1.0. The total is still $3.0.
Four words. Writing extends Product did two
things at once. SaleProduct got Product's code. And a SaleProduct
became something Java lets us put in cart, a List<Product>, and
hand to total. These are different ideas, and Java joins them in
one keyword. The rest of the lecture keeps them apart with four
words: one for each of the two ideas, one for the keyword that
joins them, and one for a third idea, which the next section
defines:
- Inheritance: a class gets the fields and the methods of another class without writing them out, except the methods it overrides.
- Java subclassing: writing
class B extends A, which in Java grants inheritance and Java subtyping together. - Java subtyping: B is a Java subtype of A if the compiler
accepts a B where an A is expected, which it does when B reaches
A by a chain of
extendsandimplements. - Behavioral subtyping: B is a behavioral subtype of A when a B keeps every promise that A's specification makes, an idea the next section makes exact.
The first three words are about code: what gets copied, and what
the compiler accepts. You write Java subclassing, as extends,
and it brings the other two with it. Java subtyping can come
without inheritance. ListQueue implements HelpQueue in
the lecture on ADTs,
so a ListQueue may go where a HelpQueue is expected. Nothing is
copied, since HelpQueue has no fields and no method bodies. The
fourth word is about specifications. How much of a specification
the compiler checks is a question for later in this lecture.
The fixed program printed the right price on every line. But look
at what total used to get its answer right, in both programs.
total was written against Product. Its loop calls getPrice,
whose Javadoc in Product promises the price of this product, in
dollars. Nothing in total mentions SaleProduct, and total
could have been written, and reasoned about, before SaleProduct
existed. Our reasoning about total never looked at SaleProduct's
code at all. So when a SaleProduct arrives at total's call of
getPrice, what was that reasoning relying on?
A behavioral subtype keeps every promise
total's argument used one promise about products: Product's
specification of getPrice, that it returns the price of the
product, in dollars. The argument never looked at SaleProduct.
It could not have, since a class like SaleProduct can be written
a year after total, by someone who never read total, and Java
will still let it into the cart.
Lecture 2 set the standard such an argument has to meet. A client is correct, it said, "if it works with every implementation that meets the specifications it relies on". Call the type a client was argued against A, and the type that arrives later B. An object of B is one more implementation of A's methods. So every argument made against A's specification is still right for a B exactly when B's specification implies A's: when every object that satisfies B's specification also satisfies A's.
Lecture 2 made that comparison between two specifications of a method, with meets for our satisfies, and called the first one stronger:
A specification S1 is stronger than a specification S2 if every implementation that meets S1 also meets S2.
Here is the same comparison made for a whole type at once.
Definition: Behavioral subtype
A type B is a behavioral subtype of a type A if every object that satisfies B's specification also satisfies A's.
Method by method, each method B shares with A has a specification in B at least as strong as its specification in A: a precondition no stronger, and a postcondition no weaker.
A type here is a class or an interface, and an object satisfies a type's specification when each of its methods satisfies that method's specification in the type. Method by method applies Lecture 2's rule: S1 is stronger than S2 "when S1's precondition is weaker than or the same as S2's, and, on every call where S2's precondition is true, S1's postcondition is stronger than or the same as S2's." So no stronger means weaker or the same, and no weaker is read on the calls A's precondition allows. B may also add methods, which a client argued against A never calls.
IndexOf.java puts the definition to work on two pairs of
interfaces. Each declares one method, indexOf, which searches
an array a for a value x:
| IndexOf.java | |
|---|---|
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 | |
Java accepts both pairs: IndexOf.java compiles. But one pair is a behavioral subtype and the other is not. Compare each pair's preconditions, then its postconditions, and decide which is which before you read on.
First is a behavioral subtype of Any. The two preconditions
are the same, x occurs in a. So we need to compare only the
postconditions, as Lecture 2 did for its specifications B and D.
The smallest index i such that a[i] == x is in particular an
index i such that a[i] == x. So First's postcondition is
stronger than Any's, and every object that satisfies First
satisfies Any.
PresentFinder is not a behavioral subtype of Finder. This
time the postconditions agree wherever both apply: on a call
where x occurs, each promises the smallest index. The
preconditions differ. Finder has none, so a call where x does
not occur meets it, and on that call Finder promises -1.
PresentFinder requires x to occur in a, so the same call
breaks its precondition, and PresentFinder promises nothing. Its
precondition is stronger, so its specification is weaker, as
Lecture 2 found its specification A weaker than D. An object
that satisfies PresentFinder may do anything at all when x is
absent, so it need not satisfy Finder.
Does the difference matter, when Java accepted both? Here is a client written against Finder:
| IndexOf.java | |
|---|---|
16 17 18 | |
Let's argue that contains returns whether x occurs in a,
using Finder's specification alone. Finder's indexOf has no
precondition, so the call in contains meets it, whatever a and
x are. The postcondition says the call returns the smallest
index where x occurs, or -1 if x does not occur. No index of
an array is -1. So the call returns -1 exactly when x does not
occur in a, and contains returns true exactly when x occurs.
The argument used nothing but Finder's specification, so it is
right for every object that satisfies Finder, including objects
of classes not yet written.
main hands contains two finders:
| IndexOf.java | |
|---|---|
20 21 22 23 24 25 26 27 28 | |
Scan implements Finder: it returns the first match, or -1 once its loop has passed the end. ScanAssuming implements PresentFinder:
| IndexOf.java | |
|---|---|
79 80 81 82 83 84 85 86 87 88 | |
ScanAssuming is Lecture 2's
Implementation 3,
with the whole array as its range: it stops at the first match and
never checks for the end of the array. It satisfies PresentFinder's
specification, since on every call that meets the precondition there
is a match to stop at. The declaration of assuming in main
compiles, because a ScanAssuming is a PresentFinder, and to Java a
PresentFinder is a Finder. Predict what main prints, then read the
run:
$ java IndexOf
3 in a: true
7 in a: false
3 in a: true
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 4 out of bounds for length 4
at ScanAssuming.indexOf(IndexOf.java:83)
at IndexOf.contains(IndexOf.java:17)
at IndexOf.main(IndexOf.java:27)
Scan gives the right answer both times, and so does ScanAssuming
for 3. For 7, ScanAssuming runs off the end: its test a[i] != x
reads a[4] in an array of length 4. The call in contains ran
the indexOf of ScanAssuming, the class of the object finder
refers to, though finder is declared a Finder.
So which code is wrong? Read each piece against its own
specification. ScanAssuming satisfies PresentFinder's: on the last
call, 7 does not occur in a, so the call broke the precondition,
and PresentFinder promises nothing. contains is correct against
Finder's specification, by the argument above. The mistake is the
extends in PresentFinder's declaration. It declares that a
PresentFinder may go wherever a Finder is expected, and
PresentFinder's specification does not keep Finder's promise for a
value that is absent.
Said from the side of a client, which was argued against A and is handed a B, the definition is the Liskov substitution principle: if B is a behavioral subtype of A, then a B may be used wherever an A is expected, and every argument made against A's specification is still right for the B. The principle is named for Barbara Liskov, who stated the idea in a talk in 1987.5
Java accepted both pairs: the extends in First's declaration,
which keeps Any's promises, and the one in PresentFinder's, which
does not keep Finder's. Which of these promises does the compiler
check at all?
What the compiler checks
To see what the compiler checks, we need a pair of interfaces it
can refuse. In both IndexOf pairs, the two declarations of
indexOf have the same types, so neither pair gives javac
anything to refuse. Checks.java has a pair with more in its
types. A Source is a sequence of numbers kept somewhere, perhaps
in a file, and its method positions finds a value among them.
An ArraySource keeps its numbers in an array in memory. Before
reading on, look at the two declarations of positions and ask
which parts of their specifications javac could compare.
| Checks.java | |
|---|---|
20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | |
Reading a file can fail, so Source's positions declares
IOException, the checked exception that
Lecture 9's server
declared for the network. An ArraySource's numbers are already in
memory, so its positions cannot fail to read them. ArraySource's
positions declares no exception. It also returns an ArrayList
where Source's returns a List. The two declarations have the same
name and parameter types, so ArraySource's positions overrides
Source's, as SaleProduct's getPrice overrides Product's.
Now compare the two specifications by
Lecture 2's rule.
The two have the same precondition, none. Every ArrayList is a
List, so a client promised an ArrayList has been promised a List
and more. Source promises an IOException when the numbers could
not be read, and an ArraySource's numbers can always be read, so
that case never arises. So ArraySource's specification is the
stronger, and ArraySource is a behavioral subtype of Source.
javac Checks.java compiles the file and prints nothing.
The return type. Now change one line. Here is a copy of
Checks.java in which ArraySource's positions returns a
java.util.Collection<Integer> instead. Collection is a Java
supertype of List, so every list is a Collection, but a
Collection need not be a list. (The java.util. in front saves
an import.) Should javac accept the copy?
$ javac Checks.java
Checks.java:36: error: positions(int) in ArraySource clashes with positions(int) in Source
java.util.Collection<Integer> positions(int x);
^
return type Collection<Integer> is not compatible with List<Integer>
1 error
javac refuses it. A client of Source may call get(0) on what
positions returns, and only a list is sure to have a get. So
an override may return the same type as the method it overrides,
or a subtype of that type, as ArraySource's positions in
Checks.java returns an ArrayList. It may never return a supertype.
The checked exceptions. Next, a copy in which ArraySource's
positions declares throws Exception. Should javac accept
this one?
$ javac Checks.java
Checks.java:36: error: positions(int) in ArraySource clashes with positions(int) in Source
ArrayList<Integer> positions(int x) throws Exception;
^
overridden method does not throw Exception
1 error
javac refuses this one too. A client of Source has to catch
IOException or declare it, by
Lecture 6's rule.
Say it catches. A catch for a class takes an exception of that
class or of any class that extends it, as
Lecture 13 showed.
So an Exception that is not an IOException would pass that client's
catch by. Each checked exception an override declares must
therefore be one that the overridden method declares, or a subclass
of one. An override may declare fewer checked exceptions, as
ArraySource's positions declares none, or narrower ones. It may
never declare a new one.6
Both checks are Lecture 2's comparison, made on the part of a
postcondition that the types carry. Lecture 2 put the postcondition
"at the point where it returns or throws",
so what a method returns and which exceptions it throws belong to the
postcondition. A return type and a throws clause write some of that
in code, where javac reads it. javac checks that the override's
part is the same or stronger. An unchecked exception appears in no
throws clause, so javac checks nothing about one. ScanAssuming
threw an ArrayIndexOutOfBoundsException that Finder never mentions,
and IndexOf.java compiled.
What the compiler cannot check. The rest of a specification is in
the Javadoc. javac checks no @requires, @return, or @effects
clause against anything. Look again at Finder and PresentFinder in
IndexOf.java. Finder's declaration of indexOf and PresentFinder's
are the same line. What makes PresentFinder's specification weaker,
its @requires clause, is in a comment. So javac sees two
identical declarations and accepts the extends in PresentFinder's
declaration. Swap the two Javadoc comments, so that Finder asks for x
to occur and PresentFinder promises -1, and javac accepts that
program too. Swapped, PresentFinder would be a behavioral subtype of
Finder. So javac accepts the extends whether or not PresentFinder
keeps Finder's promises.
A wider parameter. Of the precondition, Lecture 2's rule asks
only that "S1's precondition is weaker than or the same as S2's".
So a subtype may also strengthen a specification by weakening its
precondition. In the types, a weaker precondition is a wider
parameter type, one that accepts more values. A long holds
every int and more, as
Lecture 1's exercise 6
showed. So here is a copy in which ArraySource's positions takes
long x. The copy also adds @Override above that declaration,
to ask javac to check that the method overrides one:
$ javac Checks.java
Checks.java:36: error: method does not override or implement a method from a supertype
@Override
^
1 error
The method overrides nothing. Java tells two methods apart by
their names and their parameter types, so positions(long) is a
second method named positions, beside the positions(int) that
ArraySource gets from Source. Two methods with the same name and
different parameter types are overloads of the name. Without
the @Override line, javac accepts the copy. ArraySource then has
two methods named positions. A client of Source still calls
positions(int), which promises what it always did. So Java
gives an override no way to accept a wider parameter type. A
subtype can weaken a precondition only in its Javadoc, where
javac does not look.
So javac checks an override's return type and its checked
exceptions, in the direction Lecture 2's rule asks, and no
@requires or @effects clause. The rest of the comparison
falls to the programmer who writes extends.
Rule: declare only behavioral subtypes
Write B extends A or B implements A only if B is a
behavioral subtype of A.
javac checks the return types and the checked exceptions of
B's overrides against A's methods, and nothing in A's
Javadoc. A B that breaks one of A's other promises compiles,
and every client reasoned about against A goes on believing
that promise about the B it is handed.
The reason is the client's argument. contains was argued
against Finder's specification alone, and that argument is still
right for every behavioral subtype of Finder, including one
written after contains. PresentFinder is not a behavioral
subtype of Finder, so the argument for contains has to be made
again for a PresentFinder. So does the argument for every other
client of Finder. For a type with many clients, as Lecture 2 said
of a library, no one can check them all.
The rule sounds easy to keep: compare the specifications, then
write extends. The next section is a case where the is-a test
(is every B an A?) says yes and the specifications say no.
Is a Square a Rectangle?
To keep the rule, we have to decide whether a class is a
behavioral subtype before we write extends. The test most
programmers reach for first is the is-a test: B should extend
A when every B is an A. A square is a rectangle whose width and
height happen to be equal, so every square is a rectangle. So
let's write it that way. Shapes.java has a class Rectangle with a
width and a height, a method to get each, and a method to set
each, setWidth and setHeight. Here is its Square:
| Shapes.java | |
|---|---|
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 | |
Square declares its own setWidth, which Java runs in place of
Rectangle's on any Square, even through a variable declared as a
Rectangle. (Java says Square overrides setWidth.) In it,
super.setWidth(w) calls Rectangle's setWidth on this square.
So a square's setWidth sets both its width and its height to
w, and Square's setHeight does the same. javac
compiles all of it, so Square is a Java subtype of Rectangle.
Now for a client. widen takes any Rectangle and changes its
width:
| Shapes.java | |
|---|---|
14 15 16 17 18 19 20 21 22 23 | |
widen was written, and reasoned about, against Rectangle's
specification of setWidth:
| Shapes.java | |
|---|---|
57 58 59 60 61 62 63 64 | |
widen first saves r 's height in h. Next it calls setWidth,
whose specification promises that afterward r 's width is w and
its height is still h. So the area of r is w * h, which widen
returns. This argument used only Rectangle's specification, so it is
right for every object that satisfies that specification. main
widens a 2 by 3 rectangle and a 2 by 2 square to a width of 5, and
prints what widen returned beside the area each shape then has.
Predict the two lines, then compare:
$ java Shapes
rectangle: widen returned 15, area is 15
square: widen returned 10, area is 25
The rectangle line agrees: the rectangle is now 5 by 3, with an
area of 15. The square line does not. widen returned 10, five
times the old height of 2. But the square is now 5 by 5, with an
area of 25, so widen's @return is false on this run.
Before reading on, decide which method is to blame: widen,
Rectangle's setWidth, or Square's setWidth?
Rectangle's setWidth satisfies its specification, and Square's
satisfies its own. widen's argument is right
for any object that satisfies Rectangle's specification. So the
trouble is in how the two specifications of setWidth compare.
Lecture 2
defined the comparison: a specification S1 is stronger than S2
"if every implementation that meets S1 also meets S2", and two
that are neither stronger nor weaker are incomparable. Neither
specification of setWidth has a precondition, so we compare
their postconditions on the run's call, setWidth(5) on a 2 by 2
square:
- Square's is not stronger. Square's
setWidthsatisfies its own specification and leaves the height 5, where Rectangle's postcondition says 2. - Square's is not weaker. Rectangle's
setWidthsatisfies its own specification and leaves the height 2, where Square's postcondition says 5.
The two specifications are incomparable. A behavioral subtype needs,
for each method it shares with its supertype, a specification
at least as strong.
setWidth fails that, and setHeight fails it the same way. So
Square is a Java subtype of Rectangle that is not a behavioral
subtype, and the extends in Square's declaration breaks the rule.
The methods to blame are the two that set, and the fault is in their
specifications, not their code.
No better setWidth could fix Square. A square promises that its
width and height are equal, and Rectangle's setWidth promises a
new width with the old height. On a square, with w different
from the side, no call can keep both promises. A square that
never changed would break neither.
Way out 1: shapes that cannot change. A class is
immutable if no object of the class can change once its
constructor has finished. ImmutableShapes.java has a Rectangle
like that. Its class comment says A rectangle
cannot be changed once it is made. In place of setWidth, it
has a method that makes a new rectangle:
| ImmutableShapes.java | |
|---|---|
49 50 51 52 53 54 55 | |
withHeight is the same with the width and the height trading
places. Square extends this Rectangle and declares nothing but a
constructor, super(side, side). Does a Square keep withWidth 's
promise? main calls withWidth(5) on a 2 by 3 rectangle and on a 2
by 2 square. Work out the square's line before you look:
$ java ImmutableShapes
rectangle: 2 by 3, withWidth(5) returned 5 by 3
square: 2 by 2, withWidth(5) returned 5 by 2
The square kept the promise: it is unchanged, and the rectangle
it returned has width 5 and the square's height. Square inherits
every method of Rectangle, so each of Square's methods has
Rectangle's specification, word for word. A specification is as
strong as itself, so Square is now a behavioral subtype of
Rectangle. (A square's withWidth returns a Rectangle. One that
returned a 5 by 5 Square would break the promise.)
Way out 2: hold a rectangle instead. Sometimes shapes must
change in place. Then Square can use Rectangle's code without
being a Java subtype of it. In ComposedSquare.java, Rectangle and
widen are Shapes.java's, unchanged, and Square holds a Rectangle
in a field instead of extending it:
| ComposedSquare.java | |
|---|---|
77 78 79 80 81 82 83 84 85 86 87 88 89 | |
Building a class from an object of another class, held in a
field, is called composition. Each method of Square does its
work by calling methods of rect. A method that passes its work
on to an object held in a field this way is forwarding the
call. getHeight forwards the same way. setSide calls
rect.setWidth(s) and then rect.setHeight(s), so it keeps the
representation invariant stated above rect. Square reuses
Rectangle's code without copying it, and it offers no setWidth.
Nor is a Square a Java subtype of Rectangle any more. A copy of
ComposedSquare.java whose main passes its square to widen, as
Shapes.java's did, does not compile: javac reports Square cannot
be converted to Rectangle.
The JDK has a class built the way Shapes.java built Square.
java.util.Properties, a table of string keys and string values,
extends java.util.Hashtable, a table of any objects. Its
documentation warns that Hashtable's put can store a key that
is not a string, after which Properties's store fails. It says
of put and putAll: "Their use is strongly discouraged."7
A Properties that held its Hashtable in a field would have kept
put out of reach.
Every square is a rectangle, but whether Square may extend
Rectangle depends on what Rectangle promises. The lecture asked
whether a client's reasoning against A is still right once a B
arrives. widen's argument used only Rectangle's promises, and a
Square that broke one of them made its conclusion false. The
reasoning a client did against the supertype stays right exactly
when the subtype keeps every promise. So we never declare a Java
subtype that is not a behavioral subtype.
-
The Java Language Specification, Java SE 25 edition, section 4.10, gives the full definition. It covers primitive types and arrays too, and counts every type a Java subtype of itself. ↩
-
Up to rounding, that is: each
+in the loop rounds to adouble, and this lecture leaves rounding aside. ↩ -
The documentation of
PrintStream.println(Object)in Java 25 says that it callsString.valueOfon the object, andString.valueOfreturns the object'stoString, or "null" for a null reference. ↩ -
The two fields are
private, so only Product's own lines can name them. SaleProduct's lines reach the price throughsuper.getPrice(). The Java Language Specification (Java SE 25) says private members are not inherited, in §8.2, and also that a new object has room for "all the instance variables declared in each superclass of the class", in §12.5. So in the specification's words SaleProduct does not inherit the two fields, yet every SaleProduct carries both. ↩ -
Barbara Liskov, "Data Abstraction and Hierarchy", her keynote at the OOPSLA conference, 1987. Barbara Liskov and Jeannette Wing, "A Behavioral Notion of Subtyping", ACM Transactions on Programming Languages and Systems, 1994, define a behavioral subtype by its specifications, with a rule for each method like the box's. ↩
-
The Java Language Specification, Java SE 25 edition, states both requirements in section 8.4.8.3, and section 9.4.1.2 applies them to an interface's methods. For a primitive return type such as
int, section 8.4.5 asks for the identical type. Section 9.4.2 calls a name shared by two methods with different parameter types overloaded. ↩ -
Oracle, Java SE 25 API Specification (2025), the documentation of the class
java.util.Properties, at https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/Properties.html. ↩