Object Oriented Design and Development

Topic 2: Encapsulation and Inheritance

Encapsulation

Object-oriented programming makes use of a technique called encapsulation to control operations on the attributes of a class and prevent unintended errors and data corruption occurring. If we revise last week's Cat class:

public class Cat
{
    private String name;
    private int age, weight;
    
    public Cat (String nameIn, int ageIn, int weightIn)
    {
        this.name = nameIn;
        this.age = ageIn;
        this.weight = weightIn;
    }
    
    public void walk()
    {
        this.weight--;
    }

    public String toString()
    {
        return "Name: " + this.name + " Age: " + this.age + " Weight: " + this.weight;
    }
}
you can see the use of the keywords private and public throughout the code. What do these mean? So you can see that in this example, the methods can be used outside the class but the attributes can only be used inside it.. The main() - repeated below - calls the two methods, walk() and display(), and all references to the attributes are within the methods inside the class.
public class CatApp
{
    public static void main (String[] args)
    {
        Cat clyde = new Cat ("Clyde", 2, 5);
        Cat binnie = new Cat ("Binnie", 5, 4);
        
        System.out.println(clyde);
        System.out.println(binnie);
        
        clyde.walk();
        binnie.walk();
        
        System.out.println(clyde);
        System.out.println(binnie);
        
        clyde.walk();
        System.out.println(clyde);
    }
}

This is common practice and is known as encapsulation. Encapsulation means to keep the inner workings of the class hidden from the outside world, and control access to those inner workings by means of the methods, which act as "gateways" between the outside world and the interior of the class.

Use of toString()

Note that the display() method has been replaced by a toString() method. What does this do? It returns the details of the cat as a String, rather than printing it. Why is that better? It gives more flexibility to the code using the class: it allows the code using the Cat class (CatApp in this case) to choose how to display it, which may not be via System.out.println. For example, a Java web application (which we are looking at later) does not use System.out.println() to display things.

Note also how we are printing the cats directly:

System.out.println(clyde);
System.out.println(binnie);
When we do this, toString() is used to figure out how to display the cat (or, in general, whatever object we're trying to print).

For the flexibility reason above, you should in general use toString() rather than trying to write methods which print to the console.. It allows your code to be used in non-console environments, e.g. web or Android.

Why perform encapsulation?

Why is encapsulation performed? Consider this new version of the Cat class:

public class Cat
{
    private String name;
    private int age, weight;
    
    public Cat (String nameIn, int ageIn, int weightIn)
    {
        this.name = nameIn;
        this.age = ageIn;
        this.weight = weightIn;
    }
    
    public void walk()
    {
        if(this.weight <= 5)
        {
            System.out.println("Can't walk any further... the poor cat will be starved!");
        }
        else
        {
            this.weight--;
        }
        
    }

    public String toString()
    {
        return "Name: " + this.name + " Age: " + this.age + " Weight: " + this.weight;
    }
}
This new version includes an if statement inside the walk() method, which prevents the cat from walking if the weight is 5 or less. Thus we are controlling how the cat's weight can be altered using the walk() method. So if we tried the following in the main():
public class EncapsulationApp
{
    public static void main (String[] args)
    {
        Cat sniffy = new Cat ("Sniffy", 5 , 7);
        
        sniffy.walk();
        sniffy.walk();
        
        sniffy.walk();
    }
}
The first two calls to walk() would succeed, as the weight would be reduced from 7 to 6 and then from 6 to 5. However the third call to walk() would fail, as the weight would now be 5 and cannot be reduced any further.

Note how we have used encapsulation to prevent unrealistic things happening. The following code will not compile but only because weight is private:

public class NoEncapsulationApp
{
    public static void main (String[] args)
    {
        Cat sniffy = new Cat ("Sniffy", 5 , 7);
    
        sniffy.weight = -1;
    }
}
If the weight was not private, you would legitimately be able to set it to -1 from the main() as in the above example. This illustrates the whole concept of encapsulation: to keep the inner workings of a class private and control access from the outside world, to prevent the outside world corrupting it.

Accessor and mutator methods

It is fairly common in object-oriented programming that we wish to access a single attribute, such as, for example, the name of the cat. We wish to keep this attribute private, so that the outside world cannot arbitarily change it, but we still wish the outside world to access it. How can we do this? We can use an accessor method, also referred to as a "getter". Here is a version of the Cat class with accessor methods for the three attributes:

public class Cat
{
    private String name;
    private int age, weight;
    
    public Cat (String nameIn, int ageIn, int weightIn)
    {
        this.name = nameIn;
        this.age = ageIn;
        this.weight = weightIn;
    }
    
    public void walk()
    {
        if(this.weight <= 5)
        {
            System.out.println("Can't walk any further... poor cat will be starved!");
        }
        else
        {
            this.weight--;
        }
        
    }

    public String toString()
    {
        return "Name: " + this.name + " Age: " + this.age + " Weight: " + this.weight;
    }
    
    
    public String getName()
    {
        return this.name;
    }
    
    public int getAge()
    {
        return this.age;
    }
    
    public int getWeight()
    {
        return this.weight;
    }
    
}

Specifying the return types of methods

Note how these three methods, getName(), getAge() and getWeight(), are returning the corresponding attribute. Note also how we specify the return type when writing the method:

public String getName()
When writing methods, the return type always immediately precedes the method name. So we are saying that the getName() method returns a String. Likewise, getAge() and getWeight() both return an int.

Using the accessor methods from the main()

The example below shows how we can use the accessor methods in the main() to get individual pieces of information about the cat object:

public class AccessorsApp
{
    public static void main (String[] args)
    {
        Cat sniffy = new Cat ("Sniffy", 5 , 7);
        Cat tom = new Cat ("Old Tom", 10, 10);
    
        System.out.println (sniffy.getName());
        System.out.println (sniffy.getAge());
        System.out.println (tom.getWeight());
    }
}
Note how we use commands such as:
System.out.println (sniffy.getName());
What this line is doing is printing the return value of the getName() method, which, as we can see above, is the name attribute of the Cat object.

This example illustrates how we can use getter methods to provide "read" access to an attribute but to prevent "write" access. We can obtain the value of the attribute by using the getter method, but we cannot change its attribute. This is a further example of encapsulation: we allow the outside world to access the attributes but we do not allow the outside world to arbitrarily change them.

Mutator methods

As well as accessor methods, we can also add mutator methods to update attributes. These are also called setter methods because we use them to set data. However, unlike accessors, they typically have some sort of controls to prevent the data being set to unrealistic values. For example, a setWeight() method for a Cat would probably prevent the weight being set to less than 0. An example of such a method is shown below.

public class Cat
{
    private int weight;
    
    // ...other code omitted...
    
    public void setWeight (int newWeight)
    {
        if(newWeight > 0)
        {
            this.weight = newWeight;
        }
        else
        {
            System.out.println("Cannot set weight to 0 or less.");
        }
    }
}

Passing Parameters to Methods

You have already seen in COM411 that you can pass parameters to methods. Here is an enhanced version of Cat showing how you can do this in Java:

public class Cat
{
    private String name;
    private int age, weight;
    
    public Cat (String nameIn, int ageIn, int weightIn)
    {
        this.name = nameIn;
        this.age = ageIn;
        this.weight = weightIn;
    }
    
    public void walk (int distance)
    {
        // Check the weight will be 5 or more after walking that distance
        if(this.weight - distance < 5) 
        {
            System.out.println("The poor cat will be starved if they walk that far!");
        } 
        // If it is, reduce the weight by the distance.
        else 
        {
            this.weight -= distance;
        }
    }

    public String toString()
    {
        return "Name: " + this.name + " Age: " + this.age + " Weight: " + this.weight;
    }
}
Note how the walk() method now takes one parameter, representing the distance walked. Note how, unlike Python, the data type (int) must be declared. We also reduce the weight by the distance. (Note that this.weight -= distance; is a shorter way of writing this.weight = this.weight - distance;; the -= operator reduces a variable by a given value. There are similar +=, *= and /= operators). This could be called in a main() as follows:
public class CatAppWithParameters
{
    public static void main (String[] args)
    {
        Cat sniffy = new Cat ("Sniffy", 10, 10);
        
        sniffy.walk (5);
        System.out.println(sniffy);
        sniffy.walk (3);
        System.out.println(sniffy);
    }
}
Note how we are passing the distance to the walk method. Note the difference between arguments and parameters. The value passed into a method is called an argument, whereas the parameter is the variable in the method representing that value. So, here, 5 and 3 are the arguments whereas distance is the parameter.

Inheritance

As you saw last year in COM411, inheritance allows us to use an existing class as a basis for a new, related class. Imagine we wanted to write classes representing different types of vehicle, e.g. Car, Bus, Motorbike. If we were to write the three classes entirely separately, we'd be repeating a good deal of code - e.g. the code for starting and stopping the engine is common to all three classes.

So what we could do in this case is create a Vehicle class, containing common functionality for all types of vehicle, and then inherit various subclasses of Vehicle (such as Car, Bus, and Motorbike) which provide additional functionality specific to that type of Vehicle. We can say that:

How is inheritance achieved in Java?

In Java, we use the keyword extends, e.g.

public class Car extends Vehicle
{
.... Car attributes and methods
}
This means that the Car class inherits from Vehicle. All attributes and methods in Vehicle will be inherited by Car, so when you create a Car, all the Vehicle methods and attributes will be available.

Java Inheritance Example

Below is a Vehicle superclass with Bike and Car subclasses.

Vehicle class (Vehicle.java):

// Generic Vehicle superclass
public class Vehicle 
{
    // Attributes common to all vehicles
    protected int topSpeed, nWheels; 
    protected String make;

    public Vehicle (String makeIn, int topSpeedIn, int nWheelsIn)
    {
        this.make=makeIn;
        this.topSpeed=topSpeedIn;
        this.nWheels=nWheelsIn;
    }

    public void move()
    {
        System.out.println("Moving along...");
    }

    public String toString()
    {
        return( "Make : " + make + "\nTop speed: " + 
            topSpeed + "\nno. wheels: " + nWheels);
    }
}

Car class (Car.java):

public class Car extends Vehicle 
{
    // Car-specific data
    private int engineCapacity; 
    private boolean engineRunning;

    public Car(String makeIn, int topSpeedIn, int engineCapacityIn)
    {
        // Call the superclass (Vehicle) constructor to construct the Vehicle 
        // component of the Car. We will pass in the top speed, make, and
        // number of wheels (which we know will be 4)
        super(makeIn, topSpeedIn, 4);

        // Set up the Car-specific attributes (engine capacity)
        this.engineCapacity = engineCapacityIn;
    }

    // Overridden move() for cars
    public void move()
    {
        if (engineRunning)
        {
            System.out.println("Driving along...");
        }
        else
        {
            System.out.println
                ("Can't drive the car if the engine's stopped!!!");
        }
    }

    // Car-specific methods
    public void start()
    {
        engineRunning=true;
    }

    public void stop()
    {
        engineRunning = false;
    }

    // Return string representing the car. Note the use of super.toString() to
    // call the superclass (Vehicle) version of toString() to print the
    // make, top speed and number of wheels.
    public String toString() 
    {
        return super.toString() + " Engine running? 
                    " + engineRunning + "\nEngine capacity: " +
                            engineCapacity;
    }
}

Bike class (Bike.java):

public class Bike extends Vehicle 
{
    // Bike-specific data
    private boolean isOffRoad;
    private int nGears; 

    public Bike(String makeIn,int topSpeedIn,boolean isOffRoadIn,int nGearsIn)
    {
        //Call the superclass (Vehicle) constructor to construct the Vehicle
        // component of the Bike. We will pass in the top speed, make, and
        // number of wheels (which we know will be 2)
        super(makeIn,topSpeedIn,2);

        // Set up the Bike-specific attributes (off-road status and gears)
        this.isOffRoad = isOffRoadIn;
        this.nGears = nGearsIn;
    }

    public String toString() 
    {
       return super.toString() + " Off road? " + 
                            isOffRoad + "\nNo. Gears: " +
                            nGears;
    }
}

public class InheritanceTestApp 
{
    public static void main (String args[])
    {
        Car c= new Car("Ford",120,2000);
        Bike b = new Bike("Raleigh",30,true,27);

        System.out.println(c);
        System.out.println(b);

        c.move();
        c.start();
        c.move();
        c.stop();

        b.move();
    }
}

How is this working?

Overriding Methods

In the example, Vehicle and Car both have a method called move(). We have overridden the original version in Vehicle with the version in Car - in other words, replaced the original Vehicle version of move() with a new version in Car. This will mean that:

In the same way, toString() is overridden in both Car and Bike. However, unlike for move(), the overridden version of toString() calls the original, Vehicle version of toString() using the super keyword: super.toString();.

Abstract classes

You also met abstract classes in COM411. An abstract class is a class that will never be instantiated; it serves only to be the superclass of inherited classes, and to provide common data and methods. Abstract classes typically contain abstract methods, which are methods containing no code; they will be overridden in subclasses. For example we could have an Animal abstract class with a makeNoise() abstract method (as there is no default noise made by a generic Animal!):

public abstract class Animal
{
    protected int age, weight;

    public Animal(int ageIn, int weightIn)
    {
        this.age = ageIn;
        this.weight = weightIn;
    }

    public abstract void makeNoise();
}
and then inherit Cat and Dog from it, overriding makeNoise() appropriately. Cat and Dog are concrete classes: classes you can create instances of.
public class Cat extends Animal
{
    public Cat(int ageIn, int weightIn)
    {
        super(ageIn, weightIn);
    }

    public void makeNoise()
    {
        System.out.println("Meow!");
    }
}
public class Dog extends Animal
{
    public Dog(int ageIn, int weightIn)
    {
        super(ageIn, weightIn);
    }

    public void makeNoise()
    {
        System.out.println("Woof!");
    }
}

Exercises

  1. Update your Cat project from last week by adding the walk() method from the "Passing parameters to methods" example, above, to your Cat class. Also modify the Cat's eat() method so that the weight can never go above 20. Print a suitable error message if an attempt is made to increase the weight beyond that amount.
  2. Change the display() method to a toString() method, as in the example above.
  3. Modify your Cat's eat() method further, so that it now takes a parameter of amount. The weight should increase by the specified amount.
  4. Return to your Student project from last week. Add three simple getter methods, getName(), getCourse() and getMark() method to your Student class. These should simply return the appropriate attribute.
  5. Change printDetails() to a toString() method which returns the details as a string.
  6. Add a setMark() method to your Student class, to set the student's mark. The method must validate the mark, and check that it is between 0 and 100. The mark should only be updated if it is valid.
  7. Add a getGrade() method to Student. This should return the student's grade as a String based on the mark, according to this scheme :
  8. Add a didPass() method to Student. This should return a boolean, depending on whether the mark is above or below 40.
  9. Change the Student constructor so that the mark is no longer passed in as a parameter, and instead, set the mark to 0. The idea is that setMark() will be used instead to set the mark later
  10. Replace the printDetails() method with a toString() method.
  11. Test out the above methods by modifying the main() method of your existing program so that:
  12. Create an inheritance hierarchy to represent different types of student. The classes Undergraduate and Masters should extend from Student. Make Student an abstract class and change the getGrade() method of Student to be abstract, and provide overridden versions of getGrade() for Undergraduate and Masters. The undergraduate version should work exactly the same as the version above. The masters version should instead return the following grades: Test it by creating a few undergraduate and masters' students in your main(), and printing their grades. You do not need to read them in from the keyboard; you can just hard-code them.