Quite a few students reported problems
with one of the two core concepts
involved in
Assignment #1001.
These two concepts are:
(A) Recursively-defined data structures;
and
(B) Extending a collaboration of classes.
Most students seemed to understand
the first concept,
as used in the class Phrase
specified in
Assignment #1000.
Further,
most students understood how to extend a single class
as was specified
for class CognatePair
in the same assignment.
However,
when it came time
to implement class CognatePhrases
from the next assignment,
many students got lost
and the reason appears
to be that CognatePhrases
is extended as part of a collaboration
with CognatePair.
I'll call this type of problem type chasing,
and it is associated with two key questions:
(A) What type (aka, class) of instance am I
really using?
and
(B) What method am I really calling?
Before continuing with the specifics of Assignment #1001, I'm going to bring together the definitions of inheritance and collaboration so as to tie them to the assignment. Our textbook defines inheritance in a semi-formal fashion. I'll simply elaborate this definition. However, our textbook leaves the notion of collaboration unspecified, leaving it to your intuition to understand it. Although it is fairly intuitive and the definition that I gave in class (two or more classes that work together) is fine, I'll elaborate the definition further.
Phrase and
Word collaborate to build a list data structure that
represents a phrase.
The items are represented by the Word class,
which, in our assignments,
is a wrapper class
wrapping a String that is required
to satisfy properties
defining legal words
(non-null,
non-empty, and no embedded whitespace).
For the order aspect,
we have Phrase,
a recursively-defined class
which specifies that a leading word
must occur before
a suffix phrase.
Phrase uses
class Word.
extends keyword
is used to specify this.
For example,
a Java class header such as
class Child extends Parent
specifies that Child is a sub-class
of another class named Parent.
Indirect inheritance from a super-class to a
sub-class occurs when there is a chain of direct inheritance
relationships that leads from the sub-class to the
super-class.
For example,
if two classes are defined with headers as shown below
then classclass Child extends Parent class Parent extends GrandParent
Child inherits indirectly from
class GrandParent.
a",
we say that the sub-class
inherits a from the super-class.
Changing our viewpoint,
we can also say that a super-class' member is
inherited by the sub-class.
In Java,
all instance variables are inherited by a sub-class,
and all instance non-private methods
are also inherited by a sub-class.
Note that this is different than access!
It's perfectly possible (and common for data fields) for a
sub-class to inherit a member without being able to
access it directly.
For example, an instance variable may be declared
private in the super-class, therefore preventing
the sub-class from directly accessing the field.
Yet, that instance variable is still inherited by the the
sub-class, and, if a getter method is accessible by the
sub-class, the getter can be used to access the instance
variable.
CognatePair class extends Word,
which means that a CognatePair instance
can be used as a Word
anywhere a Word can occur.
If a method has a parameter of type Word,
then that method can be invoked with an argument
of type CognatePair as that
Word parameter.
Further, if a method is declared with return type
Word, then that method can be defined to return
an instance of type CognatePair.
This is not true for constructors, and it's one of the
biggest differences between methods and constructors.
A constructor is pre-defined by its enclosing class definition
to build exactly an instance of that class.
It's not possible for a constructor to build a sub-class
instance.
In BlueJ class diagrams,
classes are shown as labeled boxes,
and some of the key relationships between classes
are shown via arrows.
A solid arrow with a hollow arrowhead
indicates inheritance,
while a dashed arrow indicates a uses
relationship.
Now,
let's consider a class diagram
for the classes
that should be defined after Part 1
of
Assignment #1001.
is completed.
That class diagram,
except for the package hierarchy,
is shown below:
This diagram shows three "uses" relationships:
(A) Phrase uses Word;
(B) CognatePhrases uses CognatePair;
and
(C) PigLatin uses PigLatinCognate.
All of these relationships have the same core.
They're all structurally the same as the Phrase
to Word relationship.
The Phrase to Word relationship
is a list collaboration,
where Word defines the list items,
while Phrase defines the list order.
Ok, so how do these three collaborations differ?
First, the Phrase class can work with any
Word sub-class, including Word itself.
Further, Phrase implicitly defines the
order relationship between Word instances
by specifying leading word references to precede
suffix phrase references.
Since direct instances of the base class Word
(not its sub-classes)
only contain members referring to a single
base language (English in our assignments),
Phrase is
deliberately defined
so as not
to refer to a cognate language.
As a result, the Phrase to Word
collaboration works with a single language.
Second, the next level, where class CognatePhrases
uses class CognatePair, introduces another
language and specifies a defining property for
legal collaborations.
However, this level collaborates in the same way as the
base Phrase to Word collaboration.
A CognatePhrases instance will be composed of a
sequence of CognatePair instances in the
same way:
A leading word precedes a suffix phrase
in that order.
Therefore, we re-use that collaboration by defining
both CognatePair and
CognatePhrases to inherit from
Word and Phrase,
respectively and in parallel.
There are three parts remaining in the implementation at this level.
We add a cognate language by introducing a new instance
variable and supporting methods in CognatePair.
Then, the constructors of CognatePhrases are
defined so that only CognatePair instances
can be used to build instances of CognatePhrases.
Finally, we ensure that all words in a
CognatePhrases instance have the same
cognate language by making that check in the appropriate
CognatePhrases constructor.
Therefore, when an instance ofCognatePhrasesexists, it is guaranteed to be an instance ofPhrase(because of inheritance), and everyWordin this instance is really aCognatePair!
Third, the Pig Latin level further extends the basic phrase/word collaboration with an additional property: The cognate language is Pig Latin! Again, this is enforced by constructors that allow only that possibility.
Focus on CognatePair and
CognatePhrases:
Most of the confusion on
Assignment #1001
seemed to occur with the second level: The collaboration of
CognatePhrases with CognatePair.
That's where I'll focus, and I'll start by narrowing our class
diagram to just those classes and their immediate super-classes,
Phrase and Word.
Refer to the modified class diagram below:
The modified diagram organizes these four classes by both
collaboration and inheritance.
The rows show the collaborations:
Row #1 is the Phrase to Word
collaboration which defines the basic structure
for one language;
and
row #2 is the CognatePhrases to
CognatePair collaboration which extends the basic
collaboration to include another cognate language.
The columns show the inheritance hierarches:
Column #1 contains the classes involving one word
and column #2 contains the classes combining
multiple words into phrases.
When writing code, we'll use this diagram to help ourselves keep track of the class(es) we're referencing, and that, in turn, will help us determine which methods to invoke and/or implement. To do this, we have to continually ask ourselves the following questions:
Once we've answered these two questions, we've determined the class that we're working with. Next, when seeking an instance method or instance variable, we often must ask a third question: In which classes can this method or instance be defined? Since implementation sharing can only occur with inheritance, we need only examine classes in the same column! In fact, we must not allow ourselves to jump from column to column when seeking instance members.
Type Chasing:
These are the fundamental questions
of type-chasing,
which I'll define as
determining where the hell I'm
supposed to access/define
this method/variable.
Let me give the simplest example that was a sticking point for
most students:
Implementing method toPhraseString() in class
CognatePhrases.
Those students who, during office hours, worked out a solution
to this example were generally able to continue on to successful
implementations of methods toCognateString() and
toString(), which, successively, introduce more
complications.
We'll start with the part that,
because of its placement in the instructions,
is almost too stupid to consider further:
Determining where this method should be defined.
We ask the two or three questions from above.
We examine the name, toPhraseString(),
which alludes to a phrase.
Therefore,
it's in the multiple-word column.
Further, it only involves the base language,
so it's in the one language row.
The conclusion?
This method should be defined in class
Phrase!
Ironically, the instructions imply, by their placement
though not their wording,
that this method should be in class CognatePhrases,
instead.
It is, indeed, possible to implement this method in
CognatePhrases and most students took this
approach.
The reason that this is possible is that
CognatePhrases inherits all the necessary methods
from Phrase.
Strictly speaking, though, the best placement is in class
Phrase, and a couple of students worked this out.
We'll proceed by implementing toPhraseString()
in class CognatePhrases,
even though
this isn't the best place for it.
Implementing it in Phrase
would be essentially similar,
except that one key part would be simpler.
Let's start our implementation by outlining the method. In this outline, we'll place comments and "..." describing the steps we want to take. Later, we'll finish our implementation by substituting real code for the "...". Here's our outline:
public String toPhraseString() {
// We'll build the String to return in two steps.
//
// In the first step, we'll initialize it to a String
// representing the English form of the leading Word.
//
String result = ... ;
// In the second step, which we'll do only if the suffix
// is non-null, we'll append the English form of the suffix.
// We'll include a space separator, of course.
//
if (getSuffix() != null) {
// If there is a suffix, convert it to a String,
// then append it to the result with a space separator:
result += " " + ... ;
}
return result ;
}
How is the first step handled?
There, we want to get the English form of the leading
Word as a String.
How do we get the leading Word?
Well, there's an accessor named getWord() in
Phrase, and CognatePhrases
inherits that accessor.
However, that method does not return a String.
Instead, it returns a Word.
So, how is that converted to a String?
It's tempting to answer "toString()", but,
since inheritance is involved, we first have to ask our
type-chasing questions from above.
The second question, "how many words are involved", is
easy since the return type of getWord() is a
Word.
Therefore, we already know that we're dealing with a class in
the first column of our diagram.
Answering the first question,
"how many languages are involved", is a little harder
because we have to know
if a sub-class of Word can be returned.
The answer is "yes",
since we're invoking getWord()
from within a CognatePhrases instance,
which is defined to only allow a CognatePair sub-class
as a leading Word, which involves two
languages.
Therefore, we're really dealing with a
CognatePair instance as the return value
from getWord().
Now, the temptation from above was to invoke
toString() on the leading word in order to get the
English form of the word.
If we examine the source code of the Word class,
we see that, there, toString() is defined to return
the English form as a String.
However, that won't work for us, because we're
really dealing with a CognatePair
instance, and toString() was overridden in
CognatePair to return some other value.
This is an example demonstrating the importance of
type-chasing questions when working with inheritance
relationships in Java.
The next step is to examine the inheritance hierarchy under
Word to determine whether any method of
Word returns the English form
in all sub-classes.
The answer is "yes" and we see that there is another
method of Word that is defined to return the
English form as a String, yet is never overridden.
That method is getWord()
in the Word class.
Therefore, the correct way, in CognatePhrases, to
return the English form of the leading Word is to
evaluate getWord().getWord().
Including that expression to implement the first step in our
code for toPhraseString() results in the following
revised outline, which has only one more "..." to
define:
public String toPhraseString() {
// We'll build the String to return in two steps.
//
// In the first step, we'll initialize it to a String
// representing the English form of the leading Word.
//
String result = getWord().getWord() ;
// In the second step, which we'll do only if the suffix
// is non-null, we'll append the English form of the suffix.
// We'll include a space separator, of course.
//
if (getSuffix() != null) {
// If there is a suffix, convert it to a String,
// then append it to the result with a space separator:
result += " " + ... ;
}
return result ;
}
It remains to implement the second step of this outline.
Invoking getSuffix(),
when it returns a non-null reference,
gives us an instance of type Phrase.
How can we convert such an instance to a String
representing its English form?
Again, it's tempting to invoke toString(),
but the wisdom we've gained from the leading word example tells
us, again, to ask our type-chasing questions.
This time, we again conclude that we
really have an instance of CognatePhrases
which overrides the toString() method of
Phrase.
So, again we conclude that toString() will
not work, and, again, we investigate other methods
available in CognatePhrases.
The only other method of CognatePhrases that refers
to an English form of the phrase is
toPhraseString(),
the very method we're implementing!
That suggests a recursive invocation of
toPhraseString() on the suffix.
So, how can we invoke this method on the instance returned by
getSuffix()?
Remember that getSuffix() has a return type of
Phrase, but we're implementing the method
toPhraseString() in CognatePhrases, a
sub-class of Phrase.
So, it won't work to simply say
getSuffix().toPhaseString().
That's a compile-time error, since the compiler can't
determine when, if ever, getSuffix()
really returns an instance of
CognatePhrases.
Let's first consider the instructions for this method:
"... returns the English form of the represented phrase. If the suffix isnull, this returns only the leading word in English. This, again, will probably require you to cast the leading word to aCognatePairin order to invoke an appropriate accessor. If, however, the suffix is non-null, concatenate the English leading word and the English form of the suffix (how do you access this?) with a space separator, and return the result."
The hint provided by these instructions is that of
casting a Word to a
CognatePair.
We did not have to do this on the leading
Word (why?), but the hint does apply to
both parts of another method,
toCognateString(), and it applies to the
suffix part of toPhraseString().
That's the answer to the question,
"how do you access this?",
included in the instructions above.
We know, by answering our type-chasing questions,
that getSuffix() really returns, when
non-null, a reference to an instance of
CognatePhrases.
Therefore, we can cast that reference to
CognatePhases and invoke
toPhraseString() on the result.
That leads to the following expression:
((CognatePhrases) getSuffix()) . toPhraseString()
When we substitute this expression for the
"..."
in the second step of our method's outline,
we get the following
complete definition
of toPhraseString()
when it's implemented in CognatePhrases:
public String toPhraseString() {
// We'll build the String to return in two steps.
//
// In the first step, we'll initialize it to a String
// representing the English form of the leading Word.
//
String result = getWord().getWord() ;
// In the second step, which we'll do only if the suffix
// is non-null, we'll append the English form of the suffix.
// We'll include a space separator, of course.
//
if (getSuffix() != null) {
// If there is a suffix, convert it to a String,
// then append it to the result with a space separator:
result += " " + ((CognatePhrases) getSuffix()) . toPhraseString() ;
}
return result ;
}
That's how type-chasing works.
Similar reasoning applies to both methods
toCognateString() and toString() in
class CognatePhrases.
Further, and more importantly,
the same type of reasoning applies to other inheritance
hierarchies, and it's an essential type of reasoning when
working with Java and most other object-oriented programming
languages.