10. Inheritance
In the previous lecture, we introduced subtypes, a powerful feature of object-oriented programming that allows us to model hierarchical relationships between types. We leverage this through subtype polymorphism, where we can use dynamic dispatch to invoke different behaviors on objects of different types using a single piece of code.
In today’s lecture, we’ll look at a more structured way to establish a subtype relationship, inheritance. While interfaces ensure the availability of certain method signatures within the subtype class, defining a subclass with inheritance allows for the sharing of method definitions and fields, which can help improve the organization of our code. In today’s lecture, we’ll introduce new syntax and augment our object diagrams and understanding of runtime dispatch to accommodate subclass relationships. We’ll also discuss some additional factors to consider when designing new classes.
Subclass Relationships
We used an example of a personal finance app to help motivate interfaces and polymorphism. We will continue with this example in today’s lecture. As a reminder, we created the following classes for CheckingAccount
s and SavingsAccounts
. We have made a small enhancement to CheckingAccount
s that will help illustrate concepts from today’s lecture. Now, a CheckingAccount
has a monthly maintenance fee (ACCOUNT_FEE
) that must be paid if the account balance ever drops below a certain amount (MINIMUM_BALANCE
) during that month. The declaration of public static final
variables with names in MACRO_CASE
is how we model constants in Java. We added a new boolean
field chargeFee
to the CheckingAccount
class that tracks whether the fee should be applied at the end of the current month and modified the logic in some of the methods to incorporate this feature.
The full starting source code for both classes is provided below as a reference. Take a look through this source code, and particularly take note of the commonalities between both classes, which have been highlighted.
SavingsAccount.java
|
|
|
|
CheckingAccount.java
|
|
|
|
We see that a lot of the code is repeated. A lot of repetition is a “code smell”, an undesirable characteristic that suggests that we can improve our design. Having large blocks of repeated code increases the maintenance burden of our code, as changes to one of the copies will likely need to be made in the other copies as well. If we fail to make these modifications in one of the copies, it may cause inconsistent behaviors across the implementations, which increases the likelihood of bugs. We’d like a way to extract this common code into a single location and then access it from both the CheckingAccount
and SavingsAccount
classes. Inheritance provides this ability.
Inheritance is an object-oriented programming tool that is used to establish a subclass relationship, a specialization of a subtype relationship, between two classes. A subclass S
of a class T
is a refinement of T
that introduces new behaviors (and possibly new fields) within S
while retaining the fields and methods from T
.
Rather than modeling Account
as an interface, we will model it as a class.
Account.java
|
|
|
|
Within the scope of this class, we can declare the fields and provide complete definitions of methods that are common to both account types. Then, we’ll adjust the CheckingAccount
and SavingsAccount
classes to remove this common code and instead inherit it from the Account
class. We establish an inheritance (i.e., subclass) relationship using the extends
keyword in Java.
SavingsAccount.java
|
|
|
|
In the following sections, we’ll walk through the process of writing code that involves inheritance, including new syntax and design considerations.
Defining the Superclass
Let’s start by defining the superclass (or base class, or parent class), Account
. This class should model the state and behaviors that are common to all account types. Looking at the highlighted code in our earlier class definitions, we identify the common fields name
, balance
, and transactions
. Almost every method, including all of the methods from our previous Account
interface, the constructors, and private helper methods resetTransactionLog()
and centsToString()
, are either identical or contain a large proportion of shared behavior. Let’s extract this common code into the Account
class. We’ll omit the visibility modifiers in this initial version, as this will be the next topic of discussion.
Account.java
|
|
|
|
protected
Visibility
Previously, we have defined two types of visibility (beyond Java’s default visibility):
- At one extreme,
public
visibility allows a method or field to be accessed from any other class. This means thatpublic
members will be accessible to the client and make up the client interface of the class (i.e., they define what clients can see and do with objects of this type).public
members are also accessible to the implementer of a subclass of this type. - At the other extreme,
private
visibility prevents a method or field from being accessed anywhere outside of the class in which it is defined. The client cannot accessprivate
members, and neither can the implementer of a subclass.
When we consider the possibility of inheritance, a third type of visibility is desirable. We would like a visibility that allows common private
(to the client) code to be extracted into the superclass while remaining visible to its subclasses. In other words, we want a visibility modifier that only provides access within a class and its subclasses. These are the semantics of the protected
modifier.
protected
The protected
visibility modifier grants access to a field or method within the class where this member is defined and within all of its subclasses. In general, protected
members are not visible to the client.
There is a small caveat to the above definition; protected
members are visible within any class in the same package where the field or method was defined. So far in the course, we haven't focused on packages, so we'll ignore this possibility in our subsequent discussion.
The addition of protected
visibility creates a second “interface” that we must manage when we define a class, the specialization interface.
The specialization interface of a class includes all fields and methods of the class that are visible to implementers of its subclasses but not visible to other external classes (i.e., all protected
fields and methods).
When we design a well-encapsulated class, we must carefully consider the visibility of each field and method in order to maintain both the client and specialization interfaces. In the end, it is the base class’ responsibility for managing its class invariant; it cannot delegate this to its subclasses or guarantee that they will respect it, so members should only be marked as protected
when it will not compromise the class invariant and when it provides some additional utility to the subclasses. Let’s go through the members of the Account
class now to decide their appropriate visibilities.
The name()
, balance()
, depositFunds()
, transferFunds()
, and transactionReport()
methods make up the client interface of Account
s; these are the methods that can be accessed by our personal finance app, so they must be marked as public
. To encapsulate our code, no other fields or methods should have public
visibility.
The balance
and report
fields have a class invariant of consistency (documented in the method specifications). Whenever the client changes the value of balance
, this is reflected through a transaction in report
. If we gave a subclass direct access to these fields, they could modify their values in a way that was inconsistent with this invariant. Therefore, we’ll mark them as private
to prevent this possibility. Note that the subclass can still access the value of balance
through the public balance()
accessor method; private
visibility prevents write access to the variable. We’ll also mark name
as private
to maintain control over it in the Account
class.
The resetTransactionLog()
is a helper method that called from the constructor and from transactionReport()
. A subclass should never call this method directly, as doing this could violate the class invariant; some transactions could disappear before being sent to the client. Thus, we’ll mark this method as private
. However, it is important that this method is always called from transactionReport()
, We’ll modify the specifications to ensure this.
The centsToString()
method is a static helper method. It does not modify any fields, so it is safe to provide this as a convenience to any subclasses. We’ll mark it as protected
.
We’ll revisit the visibility of the constructor later in the lecture.
Account.java
|
|
|
|
At this point, we have completed our initial implementation of the Account
superclass and can shift our focus to its subclasses.
Defining Subclasses
SavingsAccount
Subclass
Let’s start by defining the SavingsAccount
subclass. Since SavingsAccount
will extend Account
, it inherits its fields and methods. We do not need to (nor can we) recreate the name
or balance
fields since these already exist within the Account
class. We also do not need to take any action for name()
or balance()
methods since these already exist within the Account
class and have the desired behavior. A client can invoke name()
on a SavingsAccount
object, and this will dispatch to Account.name()
. As we design this subclass, we should ask ourselves “in what ways is a SavingsAccount
different from an Account
?” From the specifications, the primary difference is the support for account interest. Our previous SavingsAccount
implementation had a rate
field, and this field is not present in Account
. We will need to add this to our new SavingsAccount
implementation. We can visualize this new field in a slightly expanded object diagram that accounts for inheritance.
SavingsAccount
objects have four fields, name
, balance
, transactions
, and rate
. In this diagram, we use a dashed line to separate the rate
field (which was declared in the SavingsAccount
class) from the other three fields (which were inherited from the Account
class).
Our previous SavingsAccount
implementation also had a public
accessor method interestRate()
and a private
helper method accrueMonthlyInterest()
that we will need to include. Note that we’ll need to make a small modification to the definition of accrueMonthlyInterest()
. The old definition accessed this.balance
, which was a field within the SavingsAccount
class. Now that balance
is a private
field of the Account
superclass, we cannot access it directly from this method. Instead, we’ll need to use the public
accessor this.balance()
.
SavingsAccount.java
|
|
|
|
We are not yet finished with the SavingsAccount
implementation. We need a way to modify the implementation of transactionReport()
to add the interest payment. We’ll also need to provide a new SavingsAccount
constructor that will accept an interest rate parameter from the client.
Method Overrides and super
When we implement an interface, we use the @Override
syntax to mark that a method was declared in the interface. In other words, we are overriding the method’s signature by providing a definition of this method that is specific to the class we are defining. We can do the same thing in a subclass.
When we override a method from a superclass, we declare a method with the same signature within the subclass.
As we will soon discuss, this subclass version of the method will take precedence over the superclass definition; calling this method on a subclass object (i.e., an object whose dynamic type is the subclass) from within the client code will lead to the subclass version of the method being executed. Thus, overriding provides us a mechanism to modify the superclass behaviors.
In our example, the SavingsAccount
class must override the transactionReport()
method to add a call to accrueMonthlyInterest()
. However, after this, we want the method to proceed exactly as in the superclass. We can do this using the keyword super
followed by the method invocation. Writing super.transactionReport()
tells Java “now, invoke the transactionReport()
method that was defined in my superclass on me.” We achieve our desired behavior with the definition:
SavingsAccount.java
|
|
|
|
For the SavingsAccount
constructor, we’ll need to initialize the rate
field directly (as this was defined in the SavingsAccount
class). However, the remaining fields must be initialized by the Account
constructor. We again access this using the super
keyword, this time without a method name. Java requires that the first line in any constructor is a call to a superclass constructor, so we arrive at the constructor definition:
SavingsAccount.java
|
|
|
|
When you do not include an explicit call to the superclass constructor, Java will automatically add an implicit call to super()
, the default, no-arguments constructor of the superclass. This will suffice when there is such a default constructor (e.g., when no constructor is explicitly defined in the superclass). However, if the superclass does not have a default constructor, you are required to write an explicit superclass constructor call, meaning you are required to write a constructor for your subclass.
Altogether, our new SavingsAccount
definition that leverages inheritance is much shorter than our implementation that implemented an interface. Most of the work has been delegated to the Account
superclass. Within the SavingsAccount
class, we only need to handle interest, the unique feature of savings accounts.
CheckingAccount
Subclass
Now, let’s use similar ideas to define the CheckingAccount
subclass. In this case, the feature that is unique to a CheckingAccount
is the potential monthly fee, and we can provide support for this feature by doing the following.
- We must add the
MINIMUM_BALANCE
andACCOUNT_FEE
constants and thechargeFee
boolean
field to theCheckingAccount
class. - We must add a
CheckingAccount
constructor that calls theAccount
superclass constructor and then initializeschargeFee
based on the starting balance. - We must add a check to
transferFunds()
to see if the transfer caused the account balance to drop belowMINIMUM_BALANCE
. If so, we must updatechargeFee
to true. - We need to add the
processMonthlyFee()
helper method and call this from the overriddentransactionReport()
class, which must also resetchargeFee
to satisfy its class invariant. At this point, the state of ourCheckingAccount
class is as follows:
CheckingAccount.java
|
|
|
|
There is an issue with the code as we have written it. Take some time to try to identify this issue before reading on.
Within the processMonthlyFee()
helper method, we are currently decrementing this.balance -= ACCOUNT_FEE
. However, balance
is a private
member of the Account
superclass, so we don’t have visibility to this field. Similarly, on the following lines, we try to call append()
on this.transaction
, which is also a private
member of Account
. Recall that we disallowed direct access to these fields to protect the Account
class invariant. Therefore, the modification to these fields will need to happen within the Account
class.
We can add a method withdrawFunds()
method to Account
that parallels depositFunds()
to handle the withdrawal of the account fee. We don’t want to change the client interface of Account
(although an argument can be made that this method would allow us to support a useful feature to our application), and we need to access this method from CheckingAccount
, so it must be marked as protected
. The definition is given below.
Account.java
|
|
|
|
We can utilize this method to update our processMonthlyFee()
helper method in the CheckingAccount
class as follows:
CheckingAccount.java
|
|
|
|
This restructuring, while primarily aimed at addressing a visibility error in our original implementation, also provides a much clearer division of responsibilities between the CheckingAccount
and Account
classes; CheckingAccount
tracks the account balance to determine whether the fee is charged, and Account
is responsible for actually charging and reporting the fee. This demonstrates some of the subtleties involved in designing classes that leverage inheritance. Thinking carefully about the visibility of the fields and methods helps us to end up with a well-encapsulated design with a clear separation of roles. Often, this requires us to rethink and refactor our code as we are developing it.
As a final bit of refactoring for now, we can rewrite our Account.transferFunds()
method to make use of the new withdrawFunds()
method and cut down on code duplication.
Account.java
|
|
|
|
Type Hierarchies
We can model subclass relationships in type hierarchies in a similar manner to subtype relationships that arise from implementing an interface. We use a solid arrows rather than dashed arrow to model inheritance from a (concrete) superclass. In addition, “Account” is no longer italicized in the diagram since it is now a class instead of an interface. The new hierarchy for our account classes is
Java is an object-oriented language with single inheritance, meaning almost every class extends exactly one superclass.
In an object-oriented programming language with single inheritance, such as Java, essentially every class has exactly one superclass.
This can allow us to create very deep type hierarchies, when a class extends another class, which itself extends a class, etc.
If we continue to follow these superclass relationships upward in the type hierarchy, we will eventually reach the top “super-est of all classes”, the Object
class. The Object
class defines a small set of behaviors that are common to all reference types. We will explore these in more detail as they become relevant in the course. Object
is the only exception to the single inheritance rule; as the top of the hierarchy, Object
does not have a superclass. When a class does not explicitly extend another class, its implicit superclass is Object
.
Single inheritance has some great benefits. As we will soon discuss, it helps to provide an unambiguous procedure for reasoning about method dispatches (which “version” of a method is actually executed when a particular call is made) at runtime. This contrasts with multiple inheritance, supported by some earlier programming languages such as C++, which introduces some subtle complications (e.g., the diamond problem in type hierarchies).
Inheritance vs. Interfaces
As we have seen, inheritance offers us a convenient way to cut down on repeated code by extracting common behaviors into a superclass. For this reason, inheritance is often a popular choice for beginning developers. However, it is often not the ideal design choice because of Java’s single inheritance limitation. We are only allowed to select one class as our superclass, meaning we can only inherit code from one class (and its superclasses). This means that when we choose to model a subtype with inheritance, we “tie our hands” and prevent the inheritance of code from any other class. We should only do this when the benefits that arise from code reuse outweigh the rigidity of this choice. Interfaces are more flexible in this regard, and more often than not will be our choice for modeling subtype relationships in CS 2110. There is no limit on the number of interfaces that a class may implement.
Remember, interfaces simply model the availability of particular methods, so conflicts do not arise if a class implements multiple. Instead, additional interfaces just increase the responsibilities on the class implementer to include all of the required method definitions. In this way, interfaces provide a more flexible way to enable polymorphism; one can implement an interface to leverage polymorphism without sacrificing the ability to select a superclass.
Abstract Classes
There is a third construct in Java that combines features of classes and interfaces, an abstract class. To declare an abstract class, we modify the class declaration with the abstract
keyword, as in
|
|
|
|
The word abstract hints at modeling the idea or concept of a type without actually instantiating it. This intuition helps to explain the two primary features of abstract
classes.
- A client cannot construct objects whose dynamic type is an abstract class. If we make our
Account
classabstract
, then clients can no longer access theAccount()
constructor to directly createAccount
objects. Instead, they would need to construct an object of anAccount
subtype (such as aSavingsAccount
or aCheckingAccount
) which can then call anAccount
superconstructor. In this way, abstract classes are similar to interfaces, which also cannot be instantiated. - Within an
abstract
class, one can declareabstract
methods. Similar to an interface, abstract methods only declare a method signature, but delegate the responsibility of providing the method definition to their subclasses. A non-abstract (i.e., concrete) class cannot haveabstract
methods. If a subclass of anabstract
class does not provide a definition for an inheritedabstract
method, it must also be declared asabstract
.
Abstract classes offer a way to delegate some responsibility to the subclass implementer while still offering a mechanism to extract common code into a superclass. They are good for modeling general categories of types that are not yet specific enough to be realized but are still specific enough to have some common fields and behaviors. In this way, our Account
class is a good candidate to be abstract
. Every bank account has a type, whether this is a checking account, a savings account or something else, that gives it some unique characteristics. It does not make sense to instantiate a general “account” object without it belonging to one of these subtypes. We’ll see some advantages of this approach in the next section.
As classes, abstract
classes are still bound by the single inheritance rule. A class still may only choose one superclass, even if this class is abstract
. For this reason, an interface may be preferable if there are not enough shared fields or shared method definitions to warrant establishing an inheritance relationship.
Refactoring our Accounts Example
Let us mark our Account
class as abstract
. When we do this, a natural choice of visibility modifier for the Account()
constructor is protected
. This way, the constructor is still accessible to its subclasses (which will need to invoke it) but not to any other classes.
Java permits abstract
classes to have public
constructors, even though being abstract
precludes outside classes from calling these constructors (making them function as if they were protected
). I feel it's clearer to mark these constructors as protected
to better reflect their semantics.
Next, we might consider whether it makes sense to mark any methods as abstract
. As written, all of the Account
methods provide reasonable implementations of their behaviors that are leveraged in the Account
subclasses. This suggests that making these methods abstract
would not be beneficial. However, we can note one “code smell” in our current implementation. The transactionReport()
method is overridden in both subclasses and contains an awkward specification that it must be called from within the subclasses. This places a burden on the subclass implementer and can violate the Account
invariant if not handled correctly. Can we refactor things to make this better?
The reason for the transactionReport()
overrides is to process some end-of-month transactions that are unique to each account type (interest for savings accounts, fees for checking accounts). Let’s instead define a closeOutMonth()
method in the Account
class that is called from the Account.transactionReport()
where these actions can take place. We’ll mark this method as abstract
, since there are no “default” end-of-month actions that all accounts have. Instead, we want to delegate responsibility for modeling end-of-month actions to the subclass implementer. We’ll give this method protected
visibility so that it is not exposed in the client interface but is present in the specialization interface.
Account.java
|
|
|
|
Notice here that we also added the final
keyword to the transactionReport()
method. This prevents this method from being overridden in any Account
subclasses, allowing the compiler (and dynamic dispatch, as we will discuss shortly) to automatically enforce our cumbersome “this base implementation must be called” constraint.
Now, we can remove the transactionReport()
overrides in the SavingsAccount
and CheckingAccount
classes and refactor the code from these and their private
helper methods to closeOutMonth()
.
SavingsAccount.java
|
|
|
|
CheckingAccount.java
|
|
|
|
Our final implementation of our account classes is provided as part of the lecture source code. You are highly encouraged to download, play around with, and expand this code to understand its design decisions. Some avenues for explanation are suggested in the lecture exercises.
Execution with Inheritance
To wrap up this lecture, let’s revisit some of the topics from last lecture, the compile-time reference rule and dynamic dispatch, to see how they interact with inheritance.
The compile-time reference rule works exactly as before, just with our modified understanding of a class’ members. Recall that we may only access fields and invoke methods on a variable that are declared for its static type. In an inheritance relationship, the subclass inherits fields and methods of its superclass. This means that even if a method is not declared within a class, it still may belong to that class’ type. For example, the client code,
|
|
|
|
compiles despite the fact that the method name()
is not declared within the CheckingAccount
class. Since the name()
method is inherited from the Account
superclass, the CheckingAccount
type still supports this behavior. Practically, this means that the compiler must traverse upward through the inheritance hierarchy to check for (both the availability and visibility of) a requested field or method.
As we saw earlier, a subclass can override a method (which is not marked as final
) that is declared/defined in its superclass (or some other class even higher in its inheritance hierarchy). This allows the subclass to refine the behavior of the method to support more specific behaviors and/or maintain its own enhanced class invariant, as long as it still satisfies the specifications of the original method. To ensure that objects maintain all of their invariants, the overridden method definitions have precedence over their superclass definitions. This means that at runtime, every method dispatch must begin at the dynamic type of the target and proceed upward in the type hierarchy only until a suitable method signature is found. We sometimes refer to these refined semantics of dynamic dispatch as the bottom-up rule.
The bottom-up rule says that when a method is invoked on a target, Java will search for that method's definition beginning with the dynamic type of the target and then proceeding upward in the type hierarchy until the correct method signature is found.
The one time that this dispatch does not begin at the dynamic type is when a method is invoked with the super
keyword. In this case, Java begins the method dispatch in the superclass of the class where the method was invoked, and then continues to proceed up the type hierarchy.
Let’s practice these semantics by tracing through the method dispatches during the execution of some client code in our final refactoring of the code utilizing an abstract Account
class. As a reference, we include snippets of the relevant code from each of our three account classes.
Account.java
|
|
|
|
SavingsAccount.java
|
|
|
|
CheckingAccount.java
|
|
|
|
previous
next
This animation demonstrates many of the intricacies that arise when we leverage dynamic dispatch and inheritance in our code. It is important that you are comfortable tracing through executions similar to the one above, as we will make extensive use of these object-oriented language features when we start to design data structures in upcoming lectures. The lecture exercises provide more opportunities to practice these concepts.
Main Takeaways:
- Inheritance provides a way to reduce code duplication by extracting common fields and behaviors into a superclass that are inherited by its subclasses. Inheritance relationships are established using the
extends
keyword in Java. - The
protected
visibility modifier makes a field or method visible to subclasses but invisible to external client code. Theprotected
members of a class make up its specialization interface while itspublic
members make up its client interface. Good class design requires careful consideration of what members belong to each of these. - A subclass can override methods of its superclass to refine their behaviors. The
super
keyword provides access to overridden superclass implementations from within the class. - A type hierarchy summarizes the subtype relationships between different classes and interfaces. Java has single inheritance, so each class (besides
Object
) has exactly one superclass, but may implement multiple interfaces. Single inheritance makes subclassing a very rigid mechanism, so it should be used sparingly. -
abstract
classes cannot be instantiated directly, but can declareabstract
methods which delegate their definition to a (concrete) subclass. - The bottom-up rule says that the dispatch of every method call begins at the target's dynamic type and proceeds upward in the type hierarchy until a suitable method definition is found.
Exercises
Consider the following family of classes:
|
|
|
|
|
|
|
|
Consider the classes below. We show only the parts of the classes that mention the name()
method.
|
|
|
|
EXP
should be a call that executes the body of name()
as implemented in class A
. Is this possible? If so, how should EXP
be written?Consider the two classes shown below. Assume that elided implementations comply with their specifications.
|
|
|
|
|
|
|
|
Account
s
CD
and InvestmentAccount
subtype classes from Exercise 9.2 so that they still have the correct behavior now that Account
is a class (as opposed to an interface). Remove as much redundancy or other "code smells" as you can.
Herbivore
to mark classes with graze()
behavior and a supertype Nocturnal
to mark classes with useNightVision()
. You anticipate defining a Quokka
class that is both nocturnal and an herbivore. Should Herbivore
and Nocturnal
be abstract classes or interfaces?
private abstract
Methods
abstract
methods as private
. Try defining one in IntelliJ. Explain why this is a reasonable restriction.
abstract
class to represent math functions.
|
|
|
|
evaluate
and refine the specifications.
Given the slope and y-intercept, \( m, b \in \mathbb{R} \), a linear function is defined as
\[ f(x) = mx + b. \]
|
|
|
|
Given a degree \( n \in \mathbb{N} \) and coefficients \( a_0, a_1, \ldots, a_n \in \mathbb{R} \), a polynomial function is defined as
\[ f(x) = a_nx^n + a_{n-1}x^{n-1} + \ldots + a_1x+a_0=\sum_{i=0}^na_ix^i. \]
|
|
|
|
Given base \( b \in \mathbb{R} \) with \( b > 0 \) and \( b\neq 1 \), a logarithmic function is defined as
\[ f(x) = \log_b(x). \]Java’s Math
class has no method to compute the logarithm of a number with an arbitrary base. However, you might find the following fact useful, where \( a > 0 \) and \( a \neq 1 \).
|
|
|
|
|
|
|
|
Object
class that should have a supertype relationship of any class.
SuperWallet <: X
. Which class or interfaces in the diagram can we substitute in for X
to make this statement true?
R <: S
and S <: T
, then R <: T
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|