7.1
Encapsulation, Inheritance, Packages, and `super`
Protect an object's rules, then share a genuine parent type without losing control of construction.
Previously on
Phase VI built classes, constructors, references, and object state. You also saw that private alone cannot protect a mutable object returned through a getter.
This lesson turns those observations into two design tools. Encapsulation protects valid state. Inheritance represents a real type relationship and shares its implementation.
The problem
Consider a bank account with a public balance:
class BankAccount {
public int balance;
}Any caller can do this:
account.balance = -50_000;The class has no place to reject a negative withdrawal, record an operation, or change how money is stored. Its rules are optional because callers can bypass them.
Now consider three registry units:
class GroundUnit {
String name;
int id;
int readiness;
}
class AirUnit {
String name;
int id;
int readiness;
int maximumAltitude;
}The repeated fields suggest a common concept. Copying them shares text, not a contract.
We need one tool to control access and another to express that an air unit is a unit.
The idea
Encapsulation keeps state changes behind valid operations
class BankAccount {
private int balance;
BankAccount(int openingBalance) {
if (openingBalance < 0) {
throw new IllegalArgumentException("negative opening balance");
}
balance = openingBalance;
}
int getBalance() {
return balance;
}
void deposit(int amount) {
requirePositive(amount);
balance += amount;
}
void withdraw(int amount) {
requirePositive(amount);
if (amount > balance) {
throw new IllegalArgumentException("insufficient funds");
}
balance -= amount;
}
private static void requirePositive(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
}
}Callers can ask for the current balance. They cannot assign it. Every change passes through an operation that understands the rule.
This is stronger than adding a setter:
void setBalance(int balance) {
this.balance = balance;
}That setter gives callers the old power with extra punctuation. deposit and withdraw describe allowed state transitions.
Encapsulation also lets the implementation change. The class could later store money in paise, add transaction logging, or make operations thread-safe.
Inheritance expresses a more specific type
class Unit {
private final int id;
private final String name;
private int readiness;
Unit(int id, String name, int readiness) {
if (readiness < 0 || readiness > 100) {
throw new IllegalArgumentException("readiness must be 0 to 100");
}
this.id = id;
this.name = name;
this.readiness = readiness;
}
int getId() {
return id;
}
String getName() {
return name;
}
int getReadiness() {
return readiness;
}
void train(int points) {
if (points < 0) {
throw new IllegalArgumentException("negative training points");
}
readiness = Math.min(100, readiness + points);
}
}
class AirUnit extends Unit {
private final int maximumAltitude;
AirUnit(int id, String name, int readiness, int maximumAltitude) {
super(id, name, readiness);
if (maximumAltitude <= 0) {
throw new IllegalArgumentException("altitude must be positive");
}
this.maximumAltitude = maximumAltitude;
}
int getMaximumAltitude() {
return maximumAltitude;
}
}AirUnit extends Unit declares an inheritance relationship.
An AirUnit receives the accessible behaviour of Unit and adds altitude. More importantly, every AirUnit is valid where a Unit is required.
The test is a sentence: an air unit is a unit. If that sentence is false, inheritance is probably the wrong relationship.
Under the hood
Going deeperAccess modifiers define who may name a member
Java has four member-access levels:
| Declaration | Code that may access the member |
|---|---|
private |
code in the declaring class |
| no modifier | code in the same package |
protected |
code in the same package, plus subclasses under the protected inheritance rules |
public |
code anywhere that can access the class |
No modifier is called package-private access. Some material calls it default access, but there is no default keyword on the declaration.
protected includes the entire package. Across packages, it also gives subclasses controlled access to the inherited member.
Cross-package protected access has an extra rule. Subclass code cannot use an arbitrary superclass object as a back door into that object’s protected state.
Begin with the narrowest access that supports the design. Widen access when a real collaborator needs it.
private controls source access, not object layout
An AirUnit object is one object. Its state includes the fields declared by Unit and the field declared by AirUnit.
AirUnit object
Unit state: id, name, readiness
AirUnit state: maximumAltitudeSubclass source cannot write readiness because that field is private to Unit. The train method can update it because that method’s code belongs to Unit.
Calling scout.train(5) runs Unit code with the same AirUnit object as its receiver.
Private members are not inherited as directly accessible subclass members. Their storage and behaviour still contribute to the complete object.
A constructor builds the superclass part first
Constructors are not inherited. Every class defines how its own instances begin.
The first statement of a constructor must be one of these:
this(...); // delegate to another constructor in the same class
super(...); // delegate to a constructor in the direct superclassIf you write neither, the compiler inserts super().
The chain must eventually reach a superclass constructor. Construction proceeds from Object down toward the requested class.
class Unit {
Unit(int id) {
System.out.println("Unit " + id);
}
}
class AirUnit extends Unit {
AirUnit(int id) {
super(id);
System.out.println("AirUnit " + id);
}
}new AirUnit(7) prints:
Unit 7
AirUnit 7If Unit declares only Unit(int id), this constructor fails:
AirUnit() {
// compiler inserts super(), but Unit() does not exist
}The child must choose an available parent constructor explicitly.
Do not call overridable methods during construction
The superclass constructor runs before the subclass constructor body. Dynamic method dispatch can still reach a subclass override during that time.
class Parent {
Parent() {
printState();
}
void printState() {
System.out.println("parent");
}
}
class Child extends Parent {
private String label = "ready";
@Override
void printState() {
System.out.println(label);
}
}Creating Child can print null. The parent constructor calls the override before the child’s field initializer assigns "ready".
Constructors should initialise state. Avoid calling methods that subclasses can override.
super can select the parent implementation
A subclass may extend inherited behaviour:
class Unit {
String summary() {
return "unit";
}
}
class AirUnit extends Unit {
@Override
String summary() {
return super.summary() + ", air capable";
}
}super.summary() asks for the superclass implementation. It does not create a separate parent object.
this refers to the current object under ordinary method lookup. super starts lookup at the direct superclass.
Packages create named boundaries
A package declaration belongs at the top of a source file:
package registry.units;
public class Unit {
}The class’s full name is now registry.units.Unit. Source files normally follow the same directory structure:
registry/
units/
Unit.javaCode in another package can import the type:
package registry.app;
import registry.units.Unit;An import does not copy code or load a class. It lets source use Unit instead of writing the fully qualified name each time.
Types in java.lang, such as String and Math, are imported automatically. Types in the same package need no import.
Packages also support package-private collaboration. That boundary is useful for implementation details shared by several classes but hidden from the rest of the program.
Java classes have one direct superclass
A class may extend only one class:
class AmphibiousUnit extends GroundUnit, NavalUnit { } // invalid JavaTwo implementation parents can create conflicts in inherited state, constructors, and method bodies. Java uses a single class chain to keep that relationship unambiguous.
A class can still promise several capabilities through interfaces. The next lesson shows that design.
Test the relationship, not the amount of reused code
Inheritance is not a general code-reuse command.
A Car has an Engine. A car is not an engine. This should use composition:
class Car {
private final Engine engine;
Car(Engine engine) {
this.engine = engine;
}
}Inheritance also promises substitutability. Code written for Unit should remain correct when it receives an AirUnit.
If a child must reject ordinary parent operations or change their meaning, the hierarchy is lying. Composition provides reuse without making that type promise.
What it costs
Encapsulation adds method boundaries. Those boundaries pay for themselves when they preserve an invariant or hide a changeable implementation.
Inheritance couples a child to its parent’s API, behaviour, and construction rules. Deep hierarchies spread one object’s behaviour across many files.
Use inheritance for a stable is-a relationship. Use composition when you mainly want to reuse a collaborator.
Check yourself
Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.
Why is a class with a getter and setter for every field not automatically well encapsulated?
Show the answer
Encapsulation protects rules, not field syntax. An unrestricted setter may allow invalid state. A getter may leak a mutable internal object. Operations such as
withdrawexpress intent and keep validation inside the class.A subclass constructor does not begin with an explicit this(...) or super(...). What does Java do?
Show the answer
The compiler inserts
super(). That call must resolve to an accessible no-argument constructor in the direct superclass. If none exists, the subclass constructor does not compile.Are a superclass's private fields absent from a subclass object?
Show the answer
No. One subclass object contains the state declared throughout its class hierarchy. Subclass source cannot directly name private superclass fields, but superclass methods can still read and change that state on the same object.
When should composition be preferred to inheritance?
Show the answer
Prefer composition when the relationship is "has a" or "uses a," or when the proposed child cannot honour the parent's behaviour. Inheritance declares that every child object may be used wherever the parent type is expected.
Exercises
Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.
4 exercises105 pointsabout 115 minutes
Close the Fields, Keep the Rules
ex-7-1-aA constructor that validates, and a public field that lets anyone walk straight past it.
Close the fields. Then resist the reflex to add a getter and setter for each one, because that puts you exactly back where you started with more typing.
Add deposit and withdraw instead, and make them own the rules.
Then demonstrate the difference concretely. Write setBalance(getBalance() - 100) next to withdraw(100) and show what each one does with an amount larger than the balance. One of them can say no. That is the whole point of the section, in two lines.
What to write
These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.
class Closed {
static String tryDeposit(double opening, double amount)
static String tryWithdraw(double opening, double amount)
static double balanceAfterWithdraw(double opening, double amount)
static double balanceIfSetterExisted(double opening, double amount)
}Account keeps its constructor check and gains deposit and withdraw, both of which throw IllegalArgumentException when they refuse. The two try methods open an account, run one operation, and return "ok" or the exception's simple name. balanceAfterWithdraw returns what the account is left holding, refused or not. balanceIfSetterExisted runs the same request through LooseAccount, which stores whatever it is handed.
What your program must do
- Make every field of Account private
- Add deposit and withdraw that refuse invalid amounts
- Show why a public setBalance puts you back where you started
- Say which access level you chose for each member, and why
public class Closed {
static class Account {
String owner; // TODO: close these two
double balance;
Account(String owner, double balance) {
if (balance < 0) throw new IllegalArgumentException("negative opening balance");
this.owner = owner;
this.balance = balance;
}
String getOwner() { return owner; }
double getBalance() { return balance; }
// TODO: refuse anything that is not a positive amount
void deposit(double amount) { }
// TODO: refuse a non positive amount, and refuse an overdraft
void withdraw(double amount) { }
}
// Leave this one exactly as it is. It is here to be compared against.
static class LooseAccount {
private double balance;
LooseAccount(double balance) { this.balance = balance; }
double getBalance() { return balance; }
void setBalance(double balance) { this.balance = balance; }
}
// "ok" if the account allowed it, or the exception's simple name if it refused.
static String tryDeposit(double opening, double amount) { return ""; } // TODO
static String tryWithdraw(double opening, double amount) { return ""; } // TODO
// What the account is left holding after the request, allowed or refused.
static double balanceAfterWithdraw(double opening, double amount) { return -1; } // TODO
// The same request through LooseAccount: setBalance(getBalance() - amount).
static double balanceIfSetterExisted(double opening, double amount) { return -1; } // TODO
public static void main(String[] args) {
// TODO: ask for 500 out of a balance of 100, both ways, and compare
}
}
Hint 1
setBalance, you have reopened the same hole with two extra method calls.Hint 2
withdraw(double amount) can check that the amount is positive and that the balance covers it. setBalance cannot, because by the time it is called the caller has already decided the answer.Hint 3almost the answer
Follow the Constructor Chain
ex-7-1-bThree constructors in a chain. Predict the order of the printed lines before running.
Then break it. Remove super(name) from AirUnit. Something you did not write will fail, and the error will be reported on a class where nothing is missing.
Read that error properly. It is one of the more confusing messages in Java, and it stops being confusing the moment you know that Java inserts an invisible super() when you do not write one.
Then fix it two ways: put super(name) back, and separately, add a no-argument Unit() constructor. Both work. Say which one you would actually do, and why the second one might be a bad idea.
What to write
These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.
class Chain {
static List<String> buildOrder()
static boolean compiles(boolean parentHasNoArgConstructor, boolean childCallsSuperExplicitly)
static String whatJavaInserts()
static String errorReportedOn()
static String whatIsMissingIsIn()
}Each constructor records its own class name in the shared log, so buildOrder returns the three names in the order the bodies ran. compiles is the rule, not the example: given whether the parent offers a no-argument constructor and whether the child writes its own super(...) call, say whether the child compiles. The last two return class names as plain strings.
What your program must do
- Predict the order of the three names before running
- Remove super(name) from AirUnit and record the exact compile error
- Say which class the error is reported on, and which class actually caused it
- Work out when an omitted super(...) is fine and when it is not
import java.util.*;
public class Chain {
static final List<String> log = new ArrayList<>();
static class Unit {
protected String name;
Unit(String name) {
log.add("Unit");
this.name = name;
}
}
static class AirUnit extends Unit {
private int maxAltitude;
AirUnit(String name, int maxAltitude) {
super(name);
log.add("AirUnit");
this.maxAltitude = maxAltitude;
}
}
static class StealthAirUnit extends AirUnit {
StealthAirUnit(String name) {
super(name, 20000);
log.add("StealthAirUnit");
}
}
// Build one StealthAirUnit and return the names in the order the bodies ran.
// PREDICT the order before you run it.
static List<String> buildOrder() { return List.of(); } // TODO
// The rule, not this example. Does a child compile, given what its parent offers
// and whether the child writes its own super(...) call?
static boolean compiles(boolean parentHasNoArgConstructor, boolean childCallsSuperExplicitly) {
return true; // TODO
}
// What does Java put in front of a constructor that calls neither super nor this?
static String whatJavaInserts() { return ""; } // TODO
// Remove super(name) from AirUnit and read the error.
static String errorReportedOn() { return ""; } // TODO: which class is blamed
static String whatIsMissingIsIn() { return ""; } // TODO: which class is short of a constructor
public static void main(String[] args) {
System.out.println("building: " + buildOrder());
// TODO: remove super(name) from AirUnit. Predict the error, then read it.
// TODO: add a no-arg Unit() and try again. Predict what changes.
}
}
Hint 1
Hint 2
super(name), Java inserts super() for you. There is no no-argument Unit constructor, so that call cannot be resolved.Hint 3almost the answer
super(...) naming a constructor that exists, or the parent offers a no-argument constructor for the inserted call to land on. Only when neither is true does it fail, and the error is reported on AirUnit while the missing constructor belongs to Unit.Is-A or Has-A
ex-7-1-cSix pairs of classes. For each one, decide whether inheritance, composition, or neither is right, and write down the reason.
Then do the famous one properly. Implement Square extends Rectangle and find the case where it misbehaves. Write the code that breaks it, do not just describe it. Watching a method that works on a Rectangle produce a wrong answer on a Square is the point.
Then build it again without inheritance, and show the problem is gone.
Finish by stating the substitution test in your own words. If your version mentions maths, try again. The problem with Square extends Rectangle is not mathematical, it is about a promise the parent makes that the child cannot keep.
What to write
These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.
class IsA {
static int resizeAndArea(Rectangle r)
static String relationship(int pair)
static boolean squarePassesSubstitutionTest()
static String whyItFails()
}Rectangle has setWidth, setHeight and area. Square extends it and keeps itself square. SquareBySide does the same job by holding a Rectangle and exposing only a side. Leave resizeAndArea exactly as written, since the point is that it was compiled against Rectangle and never mentions Square. relationship answers pairs 1 to 6 with one lowercase word out of inheritance, composition or neither. whyItFails names the promise the parent makes that the child cannot keep.
What your program must do
- Decide inheritance, composition or neither for all six, with a reason
- Implement Square extends Rectangle and show a case where it misbehaves
- Reimplement it without inheritance and show the problem is gone
- State the substitution test in your own words
// Six proposed relationships. For each: inheritance, composition, or neither?
//
// 1. AirUnit and Unit
// 2. Car and Engine
// 3. Square and Rectangle
// 4. Stack and ArrayList
// 5. SavingsAccount and Account
// 6. Employee and Person
public class IsA {
static class Rectangle {
private int width;
private int height;
void setWidth(int width) { } // TODO
void setHeight(int height) { } // TODO
int area() { return 0; } // TODO
}
// Number 3, with inheritance. Keep it square whatever anyone sets.
static class Square extends Rectangle {
// TODO: override both setters
}
// Number 3 again, without inheritance. It HAS a rectangle and only offers a side.
static class SquareBySide {
SquareBySide(int side) { } // TODO
void setSide(int side) { } // TODO
int area() { return 0; } // TODO
}
// Leave this exactly as it is. It was written against Rectangle and has never
// heard of Square, which is the entire point.
static int resizeAndArea(Rectangle r) {
r.setWidth(3);
r.setHeight(4);
return r.area();
}
// One lowercase word per pair: inheritance, composition or neither.
static String relationship(int pair) { return ""; } // TODO
static boolean squarePassesSubstitutionTest() { return true; } // TODO
// Name the promise Rectangle makes that a square cannot keep.
static String whyItFails() { return ""; } // TODO
public static void main(String[] args) {
// TODO: run resizeAndArea on a Rectangle and on a Square, and compare
}
}
Hint 1
Hint 2
Hint 3almost the answer
The Registry Gets a Family
ex-7-1-dBuild a small family of Registry units where the shared parts live in the parent and nothing else does.
The temptation is to push fields upwards so everything can reach them. Resist it. A maxAltitude on Unit means every ground unit carries a field that means nothing, and every reader has to work out which types it applies to.
The real test is the report loop at the end. It must walk a Unit[] with no instanceof and no casts anywhere.
If you find yourself needing one, that is a signal, not a failure. It means some behaviour that belongs to the unit is currently sitting in the loop instead. Move it into a method and the loop gets simpler, which is exactly what the next section is about.
What to write
These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.
class RegistryFamily {
Unit(String name, String id, int readiness, boolean active)
GroundUnit(String name, String id, int readiness, boolean active, int armourRating)
AirUnit(String name, String id, int readiness, boolean active, int maxAltitude, boolean weatherClear)
NavalUnit(String name, String id, int readiness, boolean active, int displacement)
Unit: String getName(), String getId(), int getReadiness(), boolean isActive()
Unit: boolean isDeployable(), String describe()
RegistryFamily: static int readyCount(Unit[] units)
RegistryFamily: static String report(Unit[] units)
}The skeletons and the getters are given so the file compiles. What goes where is the exercise. Unit holds exactly the four shared fields and no more. Each child holds only its own. isDeployable is declared on Unit with the common rule and overridden by the types that differ. One test builds a unit type of its own and hands it to readyCount, so a loop that checks types instead of asking each unit will get the wrong answer.
What your program must do
- Put every shared field in Unit, and nothing that is not shared
- Store the constructor arguments, chaining every child to super
- Give Unit the common deployability rule and override it only where it differs
- Write readyCount over a Unit[] with no instanceof and no casts anywhere
// Shared by all: name, id, readiness, active
// GroundUnit adds: armourRating
// AirUnit adds: maxAltitude, weatherClear
// NavalUnit adds: displacement
//
// Deployability: ground needs readiness >= 60
// air needs readiness >= 60 AND clear weather
// naval needs readiness >= 50
//
// The skeletons are here so the file compiles. Everything inside is yours.
class Unit {
private final String name;
private final String id;
private final int readiness;
private final boolean active;
Unit(String name, String id, int readiness, boolean active) {
// TODO: store all four
this.name = null;
this.id = null;
this.readiness = 0;
this.active = false;
}
String getName() { return name; }
String getId() { return id; }
int getReadiness() { return readiness; }
boolean isActive() { return active; }
// TODO: the rule that most units follow. The ones that differ override it.
boolean isDeployable() { return false; }
// TODO: something readable that includes the id and the name
String describe() { return "TODO"; }
}
class GroundUnit extends Unit {
private final int armourRating;
GroundUnit(String name, String id, int readiness, boolean active, int armourRating) {
super(name, id, readiness, active);
this.armourRating = armourRating;
}
int getArmourRating() { return armourRating; }
// TODO: does ground need its own rule, or is the one on Unit already right?
}
class AirUnit extends Unit {
private final int maxAltitude;
private final boolean weatherClear;
AirUnit(String name, String id, int readiness, boolean active,
int maxAltitude, boolean weatherClear) {
super(name, id, readiness, active);
this.maxAltitude = maxAltitude;
this.weatherClear = weatherClear;
}
int getMaxAltitude() { return maxAltitude; }
boolean isWeatherClear() { return weatherClear; }
// TODO: air is the only type with a second condition
}
class NavalUnit extends Unit {
private final int displacement;
NavalUnit(String name, String id, int readiness, boolean active, int displacement) {
super(name, id, readiness, active);
this.displacement = displacement;
}
int getDisplacement() { return displacement; }
// TODO: naval sails ten points lower than everybody else
}
public class RegistryFamily {
// TODO: count the deployable units. No instanceof and no casts.
static int readyCount(Unit[] units) {
return 0;
}
// TODO: one line per unit, then a summary line
static String report(Unit[] units) {
return "TODO";
}
public static void main(String[] args) {
Unit[] units = {
new GroundUnit("Anvil", "G-01", 74, true, 3),
new AirUnit("Kestrel", "A-07", 88, true, 15000, false),
new NavalUnit("Trident", "N-02", 55, true, 9000),
};
System.out.println(report(units));
}
}
Hint 1
name, id, readiness and active go in Unit. If you find yourself putting maxAltitude there so that everything can share it, stop: three quarters of your units would carry a field that means nothing to them.Hint 2
Hint 3almost the answer
if (u.isDeployable()) ready++;. If you needed an instanceof, some behaviour that belongs on the unit is sitting in the loop instead.After the credits
You can now answer four separate questions about a class:
- Which states are valid?
- Which operations may change that state?
- Which code may see each member?
- Is this class genuinely a more specific form of its parent?
The next lesson uses that parent type to hold many child implementations at once.
Threads you opened in this section
- EncapsulationImmutability is encapsulation taken all the way.8.2 - Immutable Classes
- EncapsulationAn interface hides everything except the promise.8.5 - Interfaces Deep Dive
- InheritanceYou have been inheriting from Object this whole time.8.3 - The Object Class: `equals`, `hashCode`, `toString`
- InheritanceThe whole exception system is one big inheritance tree.Phase XII. When Things Go Wrong
Inheritance will return in 7.2 - Abstraction, Polymorphism, Abstract Classes, and Interfaces