This answer will be a bit long, but will give a moderately sophisticated view of OO programming and how to think about it. It is unlikely that focusing specifically on inheritance will be very fruitful for your students. Inheritance is often overused and used in poor ways.
The example here is adapted, with permission, from Joseph Bergin's Polymorphism Companion which is, itself a follow up to his Polymorphism: As It Is Played. The program in the book, and here, is a simple simulation of disease spread within a population. The version here has removed a bit of the complexity, but at the expense of some flexibility in exploring the concept of disease spread.
There will be eight separate files, two interfaces, two enumerations, and four classes, one of which just explores the value of using interfaces in the first place. The only inheritance is that of implementing an interface.
The more important lesson (than inheritance) for a beginner is to learn encapsulation and building objects by composition. OO can fruitfully be thought of as language features for enabling composition. The various implementations of Population are the chief place in this example that illustrate it.
The fundamental concept here is that a Population consists of cells of some kind (a generic type parameter in the original, but omitted here). and a Disease spreads from an infected cell to adjacent cells (and otherwise) depending on the characteristics of the disease and the population. In this simplification, the population substrate is a rectangular arrangement of cells (a two dimensional array), though extensions can, for example, use hexagonal cells or other shapes.
Note that interfaces are good for defining concepts in a programming system without reference to any implementation. The Java libraries define Set, for example, as an interface, more or less representing a mathematician's concept of a Set. There are various implementations which have different efficiencies, depending on the use.
The first Interface here is Disease:
package buffy.disease;
/** A disease that may be introduced into a population
* @author buffy (derived from work of J Bergin, with permission)
*
*/
public interface Disease {
/** Set the probability that contact with an infected "location" will result in infection.
* @param probability the probability of infection in range [0.0, 1.0]
*/
public void setInfectionProbability(double probability);
/** The current probability of infection
* @return the probability that contact with a location will result in infection.
*/
public double infectionProbability();
/** Some diseases transform over time. This permits signaling the disease that it
* should update itself.
* @param value value needed to drive the change
*/
public void update(Integer value);
}
An infected cell can infect adjacent cells in an iteration of the simulation with a probability determined by the disease. Diseases can morph over time (or space, or other dimensions in general), so an update method is provided to allow this. Later we will see two implementation of this: SimpleDisease and VirulentDisease.
The second interface is Population:
package buffy.disease;
import java.util.Set;
import java.awt.Point;
/** A population into which a disease may be introduced to study how it spreads. A population has a "shape" which helps
* determine how a disease spreads. A population may be mobile or not. If it is mobile, a
* disease may jump over distance,
* otherwise it spreads only locally. In this model the state of a population location is
* either immune, infected, or
* available for infection (healthy). An extension might include recovered as a state. An
* immune barrier is a set of
* cells that might separate the population into regions. A disease can't spread across an
* immune barrier, but might
* jump it if the mobility > 0. Normally a disease can spread only to adjacent locations.
* @author buffy (derived from work of J Bergin, with permission)
*
* @param <P> a "point" or "location" within the population
* @param <D> a "direction", usually from a location
*/
public interface Population {
/** A logical name for the population, perhaps its shape
* @return the given name
*/
public String name();
/** The number of cells in the population
* @return the number of cells
*/
public int size();
/** Try to infect this location with the probability associated with the disease
* @param location the location to be (possibly) infected
*/
public void infect(Point location);
/** Introduce a disease into a random location of a population
* @param <T> a change measure such as time
* @param disease the disease to be introduced
*/
public void introduce(Disease disease);
/** The current number of infected cells
* @return the number of infected cells
*/
public int numberInfected();
/** How many cells are neither infected nor immune
* @return the number of cells available to be infected
*/
public int numberHealthy();
/** How many cells have been immunized
* @return the number of immunized cells
*/
public int numberImmune();
/** Spread the disease for a certain number of cycles. On a cycle an infected cell will attempt
* to infect its neighbors
* @param cycles the number of cycles to run the simulation
*/
public void spread(int cycles);
/** Immunize a proportion of the population against the disease (assumes only one disease)
* @param percentage the probability a given cell is immunized.
* @return the number actually immunized
*/
public int immunize(double percentage);
/** Permits members of a cell that visit, and possibly infect, other random cells
* in the population
* @param howMany the number of cells to "travel" and thus spread to the destination,
* or get infected there.
*/
public void shuffle(int howMany);
/** Create an immunization barrier in the population in a given direction starting at a given location. If
* the population wraps back on itself the barrier will also, back to the location.
* Otherwise it will extend to the
* "edge" of the population. This can be used to create "islands" in the population.
* @param direction The direction to which the barrier extends
* @param location the end of the barrier
* @param distance the length of the barrier. If 0 it extends to the edge of the
* population (or wraps around)
*/
public void immunizeFrom(Point location, Direction direction, int distance);
/** Once each cycle, spread invokes shuffle. The mobility value determines how many
* members of the population visit
* other cells. Set this to 0 to avoid "travel".
* @param mobility the number of population cells that visit other cells per cycle in spread.
*/
public void setMobility(int mobility);
/** The currently infected cells
* @return a set of infected cells
*/
public Set<Point> infected();
/** Determine if a location is infected
* @param location the location to check
* @return true if the location is infected
*/
public boolean isInfected(Point location);
/** Determine if a location is immune to infection
* @param location the location to check
* @return true if the location is immune
*/
public boolean isImmune(Point location);
/** Determine if the location is available for infection
* @param location the location to check
* @return true if the location is available for infection
*/
public boolean isHealthy(Point location);
/** A random location within the population
* @return a random location
*/
public Point randomLocation();
/** Spread the disease from a given location for one "time" cycle
* @param location the lodation from which to spread
*/
public void spreadFrom(Point location);
/** Is this location within the population
* @param location a location to test
* @return true if the location is within the location
*/
public boolean includes(Point location);
/** For an arbitrary location return one within the location
* This is usually used to "wrap" a physical model into the desired "logical" shape
* @param trial an arbitrary location, perhaps outside the population
* @return a location within the population, logically "near" the trial location.
*/
public Point reflect(Point trial);
}
Note that we can introduce only one disease into a population (extensions can be built, of course) but the particular kind of disease is independent of the population. This is the beauty and usefulness of using interfaces here. To explore how a different disease spreads in a given population you don't need to re-write any existing code, but just build a new implementation of the Disease interface.
It is possible, with this Population concept to introduce barriers to spread of disease, much as an ocean serves as a barrier to the spread of natural diseases. However, infection can also spread at a distance, perhaps across barriers if the "cells" of the population move (shuffle) or visit other locations, much as air travel has reduced the safety of isolation for such diseases as Ebola.
The simulation also has two Enumerations, Direction and State. The Direction is used to help in setting up barriers.
package buffy.disease;
/** The directions in which a disease might spread (for example) or in
* which an immune barrier is extended
* @author buffy (derived from work of J Bergin, with permission)
*
*/
public enum Direction {
NORTH,
EAST,
SOUTH,
WEST;
}
In the original the Direction is actually a generic parameter, permitting directions other than the four cardinal directions. That is one of the simplifications here.
The other enumeration gives the state of a cell at a given time:
package buffy.disease;
/** The state of a cell in a population and its visual representation in an ascii display
* This might be extended with RECOVERED.
* @author buffy (derived from work of J Bergin, with permission)
*
*/
public enum State{
IMMUNE(" - "),
INFECTED(" + "),
HEALTHY(" . ");
private String symbol;
State(String symbol){
this.symbol = symbol;
}
public String toString(){
return symbol;
}
}
Initially in a test run all cells are either healthy or immune. They may become infected as the simulation runs, but we won't show recovery here, though it is an obvious extension.
Next we will see two examples of diseases, neither very sophisticated. A simple disease does not morph over the run of a simulation.
package buffy.disease;
public class SimpleDisease implements Disease {
private double probability = 0.0d;
public SimpleDisease(double initialProbability){
this.probability = initialProbability;
}
public SimpleDisease(){
//nothing
}
@Override
public void setInfectionProbability(double probability) {
probability = Math.min(1.0, probability);
this.probability = Math.max(0, probability);
}
@Override
public double infectionProbability() {
return probability;
}
@Override
public void update(Integer value) {
//nothing
}
}
Note a couple of things about this class. First it isn't very interesting since it has no methods representing actions of any kind. It is just an information carrier. Note that it is, however completely encapsulated. It has two mutators (setInfectionProbability, and update) and an accessor for the infection probability. This is not completely desirable, since in order to do anything with a disease you have to retrieve information from it and then carry out the action elsewhere in the program. It is preferable, if you can arrange it, to have objects actually carry out actions on a client's behalf rather than giving out information and having the client do the action, hoping that the client does the right thing with the information. This is the "Tell, don't ask" principle, which is important, but is not exhibited here.
Note also, that the probability set in the mutator is "normalized" to assure that it is indeed a valid probability in the range 0.0 .. 1.0.
Also, when a method body is intentionally left empty it is documented with a comment so that a future reader needn't wonder whether the original programmer just forgot something.
A VirulentDisease is similar, but has an interesting update method:
package buffy.disease;
/** A virulent disease is characterized by an increase in the liklihood of infection as
* the simulation proceeds.
* @author buffy (derived from work of J Bergin, with permission)
*
*/
public class VirulentDisease implements Disease {
private double probability = 0.0d;
public VirulentDisease(double initialProbability){
this.probability = initialProbability;
normalize();
}
public VirulentDisease(){
//nothing
}
private void normalize(){
probability = Math.min(1.0, probability);
this.probability = Math.max(0, probability);
}
@Override
public void setInfectionProbability(double probability) {
this.probability = probability;
normalize();
}
@Override
public double infectionProbability() {
return probability;
}
@Override
public void update(Integer value) {
value = Math.min(10, value);
probability += (1.0 - probability) / Math.max(1, 11 - value);
normalize();
}
}
Note here that we need to assure that the normalize method can never be overridden, since it is invoked from a constructor. It is private here so there is no issue, but if it can't be private it must be at least final otherwise incorrect things can occur in the construction of an object.
Very importantly, note that the two disease classes have only public methods defined in the interface. It is improper and leads to gnarly code to have a subclass or an implementing class that actually extends the public interface of its parent. In that case future programmers need to know too much (and keep in mind too often) all of the intermediate classes and the specific types of the variables in a program.
The simulation can have various kinds of populations as well as diseases. We will only show one here: a square bounded world.
package buffy.disease;
import java.awt.Point;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
/** Create a square-array based population. It does not wrap around, but
* represents a finite square plane of cells.
* It represents a finite rectangular planar world. Diseases are stopped at the edges
* @author buffy (derived from work of J Bergin, with permission)
*
*/
public class BoundedSquarePopulation implements Population {
private int width = 0;
private State [][] population = null;
private Random random = new Random();
private Disease disease = null;
private Set<Point> infected = new HashSet<Point>();
private int[] range = null;
private int shuffleNumber = 0;
private String name = "Planar";
/** Create a planar world
* @param width the width (and height) of the world.
*/
public BoundedSquarePopulation(int width){
this.width = width;
this.population = new State[width][width];
this.range = new int[width];
for(int i = 0; i < width; ++i){
this.range[i] = i;
}
for(int i : this.range) {
for(int j : this.range){
this.population[i][j] = State.HEALTHY;
}
}
}
public BoundedSquarePopulation( int width, String name){
this(width);
this.name = name;
}
@Override
public String name(){
return this.name;
}
@Override
public Point randomLocation(){
return new Point(this.random.nextInt(this.width) , this.random.nextInt(this.width));
}
/** Guarantee a point is within the population
* @param trial a suggested point
* @return trial is returned, possibly modified
*/
@Override
public Point reflect(Point trial){
trial.x = Math.abs(trial.x) % this.width;
trial.y = Math.abs(trial.y) % this.width;
return trial;
}
@Override
public boolean includes(Point location){
return location.x >= 0 && location.y >= 0 &&
location.x < this.width &&
location.y < this.width;
}
@Override
public void spreadFrom(Point location){
Point temp = new Point(0, 0);
for(int i = location.x - 1; i < location.x + 2; ++i){
for(int j = location.y - 1; j < location.y + 2; ++j){
temp.move(i, j);
infect(temp);
}
}
}
private double normalize(double percentage){
percentage = Math.max(0, percentage); // normalize
percentage = Math.min(1, percentage);
return percentage;
}
@Override
public String toString(){
String result = "";
for(int i : this.range){
for(int j : this.range){
result += this.population[j][i] + " ";
}
result += "\n";
}
return result;
}
@Override
public boolean isInfected(Point location){
return this.population[location.x][location.y] == State.INFECTED;
}
@Override
public boolean isImmune(Point location){
return this.population[location.x][location.y] == State.IMMUNE;
}
@Override
public boolean isHealthy(Point location){
return this.population[location.x][location.y] == State.HEALTHY;
}
@Override
public void immunizeFrom(Point location, Direction direction, int distance){
distance = Math.abs(distance);
int endpoint;
switch(direction){
case NORTH: {
endpoint = distance == 0? 0 : Math.max(0, location.y - distance + 1);
for(int i = endpoint; i <= location.y; ++i){
if(this.population[location.x][i] == State.HEALTHY) {
this.population[location.x][i] = State.IMMUNE;
}
}
}
break;
case EAST: {
endpoint = distance == 0? this.width : Math.min(this.width, location.x + distance );
for(int i = location.x; i < endpoint; ++i){
if(this.population[i][location.y] == State.HEALTHY) {
this.population[i][location.y] = State.IMMUNE;
}
}
}
break;
case SOUTH: {
endpoint = distance == 0? this.width: Math.min(this.width, location.y + distance );
for(int i = location.y; i < endpoint; ++i){
if(this.population[location.x][i] == State.HEALTHY) {
this.population[location.x][i] = State.IMMUNE;
}
}
}
break;
case WEST: {
endpoint = distance == 0? 0 : Math.max(0, location.x - distance + 1);
for(int i = endpoint; i <= location.x; ++i){
if(this.population[i][location.y] == State.HEALTHY) {
this.population[i][location.y] = State.IMMUNE;
}
}
}
break;
}
}
@Override
public int immunize(double percentage){
percentage = normalize(percentage); // normalize
int result = 0;
for(int i: this.range){
for(int j: this.range){
if(this.population[i][j] == State.HEALTHY && this.random.nextDouble() < percentage){
this.population[i][j] = State.IMMUNE;
result++;
}
}
}
return result;
}
@Override
public void infect(Point location){
if(includes(location) && isHealthy(location) &&
this.random.nextDouble() < this.disease.infectionProbability()){
this.population[location.x][location.y] = State.INFECTED;
this.infected.add((Point)location.clone());
System.out.println("infecting: " + location);
}
}
@Override
public int size() {
return this.width * this.width;
}
@Override
public void introduce(Disease disease) {
this.disease = disease;
Point p = randomLocation();
System.out.println("Introducing disease at: " + p);
infect(p);
}
@Override
public int numberInfected() {
return this.infected.size();
}
@Override
public int numberHealthy(){
int result = 0;
for(int i : this.range){
for(int j : this.range){
if(this.population[i][j] == State.HEALTHY){
result++;
}
}
}
return result;
}
@Override
public int numberImmune(){
int result = 0;
for(int i : this.range){
for(int j : this.range){
if(this.population[i][j] == State.IMMUNE){
result++;
}
}
}
return result;
}
@Override
public void spread(int cycles) {
for(int i = 0; i < cycles; i++){
@SuppressWarnings("unchecked")
HashSet<Point> temp = (HashSet<Point>)((HashSet<Point>) this.infected).clone();
for(Point p:temp){
shuffle(this.shuffleNumber);
spreadFrom(p);
}
}
}
@Override
public void shuffle(int howMany) {
for(int i = 0; i < howMany; ++i){
Point from = randomLocation();
Point to = randomLocation();
if(isImmune(from) || isImmune(to)){
return;
}
if(isInfected(from)){
infect(to);
}
if(isInfected(to)){
infect(from);
}
}
}
@Override
public void setMobility(int mobility) {
mobility = Math.max(0, mobility);
this.shuffleNumber = mobility;
}
@SuppressWarnings("unchecked")
@Override
public Set<Point> infected() {
return (Set<Point>) ((HashSet<Point>)this.infected).clone();
}
}
It is a bit long, but note that, again, all of its public methods are defined in the Population interface.
This class is interesting and exhibits better OO characteristics than the disease classes. It is built from composition, both of library types like set and bespoke types like Disease.
This class is also a better example of "Tell, don't Ask" in, for example the immunize and spreadFrom methods. The accessors are mostly used for reporting out the results of the simulation, not for enabling computation to be done elsewhere.
Finally, here is a main that can be used to run a sample of the simulation.
package buffy.disease;
import java.awt.Point;
public class TrialRun {
public static void main(String[] args){
Population population = new BoundedSquarePopulation(15);
population.setMobility(3);
Point where = new Point(10, 5);
population.immunizeFrom(where, Direction.NORTH, 0);
Disease disease = new SimpleDisease();
disease.setInfectionProbability(0.6);
population.introduce(disease);
population.spread(3);
System.out.println("Number infected: " + population.numberInfected() + "/" +
population.size());
System.out.println(population.toString());
System.out.println("Number available but not infected: " +
population.numberHealthy() + "/" + population.size());
System.out.println("Number immunized: " + population.numberImmune() + "/" +
population.size());
}
}
This just uses ASCII graphics to show the final result of a test run, but the other classes and interfaces could, instead, serve as a Model in a Model-View-Controller graphical program that shows the results of infection spread as they occur in a graphical application.
Not shown here is the possibility of creating alternative world (population) geometries. For example a linear (single dimension array) is easy to do. You can also create a world as a Möbius band by "connecting" the left and right edges with reversed orientation so that a disease spreading off the left-top will spread into the right-bottom. Other connections are possible, creating such things as a torus, sphere, tube, etc. Combining that with immunity barriers can yield fairly realistic simulations.