Java - Week 4

Abstract Classes, Interfaces, Inner Classes, Encapsulation, Callbacks, and Iterators

Grouping together classes in Java

In object-oriented programming, related classes like Circle, Square, and Rectangle can extend a base class Shape to group shared behavior. To ensure consistency, the Shape class can define a public double perimeter() method. A default implementation, such as public double perimeter() { return -1.0; }, relies on subclasses to override it but risks incorrect behavior if they don't, making this approach dependent on programmer discipline.

Abstract Classes

A better solution is to make the perimeter() method abstract in the Shape class by defining it as public abstract double perimeter();. This ensures every subclass, such as Circle, Square, and Rectangle, provides a concrete implementation. Since abstract classes cannot be instantiated, the Shape class must also be declared abstract using public abstract class Shape. This design enforces consistency and eliminates reliance on programmer discipline.

Although abstract classes cannot be instantiated directly, variables of their type can be declared. For example, you can create an array Shape shapearr[] = new Shape[3]; and populate it with subclass objects: shapearr[0] = new Circle(...);, shapearr[1] = new Square(...);, and shapearr[2] = new Rectangle(...);. Iterating through the array and calling shapearr[i].perimeter() invokes the appropriate implementation for each subclass, demonstrating polymorphism and ensuring flexible, reusable code.

Summary of Abstract Class

It is a Partial Implementation of class.

Syntax:

Java
abstract class ClassName {
    // the body
}

What are abstract methods:

  • They don't have a body.
  • It cannot be private.
  • It cannot be static.
  • It cannot be final.
  • They must be overridden in the ChildClass.

Example Code:

Java
abstract class Class1 {
    abstract int func1();
    abstract void func2(int arg1, String arg2);

    int func3() {
        System.out.println("This is concrete method in abstract class");
        return 0;
    }
}

Generic functions

Abstract classes define shared properties and behaviors for related classes, enabling reusable and flexible code. When an abstract class implements the Comparable interface, it enforces comparison logic in its subclasses, allowing sorting of objects that extend the abstract class. This ensures consistency and promotes code reuse across different object types.

Example

Let's say Payment is an abstract class with two instance variables and one abstract method process() and one concrete method print. And two classes CreditCard and UPI extending the abstract class.

Java
abstract class Payment {
    double amount;
    static final int MAX = 10;

    Payment(double amount) {
        this.amount = amount;
    }

    abstract void process();

    void print() {
        System.out.println("Amount paid: " + amount);
    }
}

class CreditCard extends Payment {
    CreditCard(double amt) {
        super(amt);
    }

    void process() {
        System.out.println("Processing Payment through a Credit Card");
    }
}

class UPI extends Payment {
    UPI(double amt) {
        super(amt);
    }

    void process() {
        System.out.println("Processing Payment through UPI");
    }
}

public class Test {
    public static void main(String[] args) {
        Payment p1 = new CreditCard(5000);
        Payment p2 = new UPI(4000);

        p1.process(); // Through Credit Card
        p1.print();

        p2.process(); // UPI
        p2.print();
    }
}

Multiple Inheritance & Interfaces

Java does not support multiple inheritance with classes to avoid ambiguity and complexity. However, it allows classes to extend a single class and implement multiple interfaces, enabling a form of multiple inheritance for flexibility in design.

Sorting Circle Objects

Consider the need to sort Circle objects using generic functions in SortFunctions. Since Circle already extends the Shape class, it cannot extend another class like Comparable. This limitation arises because Java does not support multiple inheritance. However, by using interfaces, we can achieve the desired functionality.

Interfaces in Java

An interface in Java is similar to an abstract class but contains no concrete methods. It specifies a set of methods that a class must implement. For example, the Comparable interface can be defined as follows:

Java
public interface Comparable {
    public abstract int cmp(Comparable s);
}

A class that implements an interface is required to define all the methods declared in that interface.

Implementing the Comparable Interface

To make the Circle class sortable, we can implement the Comparable interface alongside extending the Shape class.

Java
public class Circle extends Shape implements Comparable {
    // Assuming radius is defined somewhere in this class or Shape
    private double radius;
    
    public double perimeter() {
        return 2 * Math.PI * radius;
    }

    public int cmp(Comparable s) {
        if (this.radius > ((Circle) s).radius) return 1;
        if (this.radius < ((Circle) s).radius) return -1;
        return 0;
    }
}

An interface in Java is like a blueprint where all the methods are abstract and have no implementation. Unlike classes, a class can implement multiple interfaces, allowing it to inherit different functionalities without any conflicts. Other classes only need to know what the interface can do, not how it works, which helps keep the code simple and flexible.

Using Comparable for Quicksort

The Comparable interface defines the comparison behavior. The quicksort algorithm relies solely on this capability, ignoring all other details of the objects.

Java
interface Comparable {
    public abstract int cmp(Comparable s);
}

class Myclass implements Comparable {
    private double size;
    public Myclass(double size) {
        this.size = size;
    }
    public int cmp(Comparable s) {
        if (s instanceof Myclass) {
            Myclass other = (Myclass) s;
            if (this.size < other.size) return -1;
            else if (this.size == other.size) return 0;
            else return 1;
        }
        return 0;
    }
    public double getSize() {
        return size;
    }
}

class SortFunctions {
    public static void quicksort(Comparable[] a) {
        quicksort(a, 0, a.length - 1);
    }
    private static void quicksort(Comparable[] a, int low, int high) {
        if (low < high) {
            int pivotIndex = partition(a, low, high);
            quicksort(a, low, pivotIndex - 1);
            quicksort(a, pivotIndex + 1, high);
        }
    }
    private static int partition(Comparable[] a, int low, int high) {
        Comparable pivot = a[high];
        int i = low - 1;
        for (int j = low; j < high; j++) {
            if (a[j].cmp(pivot) < 0) {
                i++;
                Comparable temp = a[i];
                a[i] = a[j];
                a[j] = temp;
            }
        }
        Comparable temp = a[i + 1];
        a[i + 1] = a[high];
        a[high] = temp;
        return i + 1;
    }
}

public class Main {
    public static void main(String[] args) {
        Myclass[] arr = {
            new Myclass(3.5), new Myclass(1.2), new Myclass(4.7),
            new Myclass(2.6), new Myclass(0.9)
        };
        System.out.println("Before sorting:");
        for (Myclass m : arr) System.out.println(m.getSize());
        
        SortFunctions.quicksort(arr);
        
        System.out.println("\nAfter sorting:");
        for (Myclass m : arr) System.out.println(m.getSize());
    }
}

Adding Methods to Interfaces

Java interfaces have evolved to include new features, making them more versatile and functional. Earlier, interfaces could only have abstract methods, but now they can include static methods and default methods, which enhance their usability without breaking existing code.

Static Methods in Interfaces

Static methods in interfaces allow defining utility or helper methods directly within the interface. These methods:

  1. Cannot access instance variables or methods of implementing classes.
  2. Are invoked directly using the interface name, rather than an instance of the implementing class.
Java
public interface Comparable {
    public static String cmpdoc() {
        String s = "Return -1 if this < s, ";
        s += "0 if this == s, ";
        s += "+1 if this > s.";
        return s;
    }
}
// Can be called using: System.out.println(Comparable.cmpdoc());

Default Methods in Interfaces

Default methods enable interfaces to provide a basic implementation of certain methods. This allows developers to:

  1. Add new methods to interfaces without forcing all implementing classes to define them.
  2. Provide a default behavior that implementing classes can optionally override.
Java
public interface Comparable {
    public default int cmp(Comparable s) {
        return 0;
    }
}

public class Circle implements Comparable {
    @Override
    public int cmp(Comparable s) {
        return -1;  // Example: Circle is "less than" other objects
    }
}
// Called via instance: c1.cmp(c2);

Dealing with Conflicts in Interfaces

Java provides mechanisms to handle conflicts that can arise from the old problem of multiple inheritance, which is more prominent with the introduction of default methods.

Types of Conflicts

  1. Conflict Between Static and Default Methods: When a static method in a class or interface conflicts with a default method in an interface, the subclass must provide a fresh implementation to resolve the conflict.
  2. Conflict Between a Class and an Interface: If a method is inherited from both a class and an interface, the method in the class "wins." Java assumes that class methods are already well-defined and should take precedence over interface methods.
Java
// Class defines a method
public class Person {
    public String getName() {
        return "No name";
    }
}

// Interface defines a default method
public interface Designation {
    public default String getName() {
        return "No designation";
    }
}

// Class extends Person and implements Designation
public class Employee extends Person implements Designation {
    // Person's getName() method wins! No need to override.
}

// Output of new Employee().getName() will be "No name"

If the Employee class wants to define its own behavior, it can override the method explicitly to resolve the conflict with a fresh implementation.


Nested Objects and Inner Classes

When designing a LinkedList, its fundamental building block is the Node. A Node represents an individual element in the list and typically contains Data, a Next Reference, and optionally a Previous Reference.

Why Should Node Be Private?

  • Encapsulation: Keeps the internal workings hidden. Users interact with LinkedList methods, not the internal structure.
  • Ease of Maintenance: Changes to Node (e.g., adding prev for doubly linked) don't impact the public API.
  • Improved Access Control: Prevents accidental misuse from outside the list class.

An inner class, like Node, can access all private components of its enclosing class. Here is a full implementation:

Java
class LinkedList {
    private int size;
    private Node first;

    public Object head() {
        return (first != null) ? first.data : null;
    }

    public void insert(Object newData) {
        Node newNode = new Node(newData);
        newNode.next = first;
        if (first != null) {
            first.prev = newNode;
        }
        first = newNode;
        size++;
    }

    // Private inner class
    private class Node {
        public Object data;
        public Node next;
        public Node prev;

        public Node(Object data) {
            this.data = data;
            this.next = null;
            this.prev = null;
        }
    }

    public int getSize() {
        return size;
    }
}

Interaction with state (Encapsulation)

Encapsulation is one of the fundamental principles of OOP. It involves hiding internal data and providing controlled access via getters and setters to maintain object integrity.

Advantages of Encapsulation

  • Data Integrity: Enforces rules and validation.
  • Flexibility: Modify internal implementation without affecting external code.
  • Simplified Maintenance: Isolates changes to a single class.
  • Improved Debugging: Changes go through a single point.

The Date Class Example

By encapsulating fields, we ensure invalid dates cannot be set.

Java
class Date {
    private int day;
    private int month;
    private int year;

    public int getDay() { return day; }
    public int getMonth() { return month; }
    public int getYear() { return year; }

    public void setDate(int day, int month, int year) {
        if (isValidDate(day, month, year)) {
            this.day = day;
            this.month = month;
            this.year = year;
        } else {
            System.out.println("Invalid date");
        }
    }

    private boolean isValidDate(int day, int month, int year) {
        if (year < 1 || month < 1 || month > 12) return false;
        int maxDays;
        switch (month) {
            case 4: case 6: case 9: case 11: maxDays = 30; break;
            case 2: maxDays = (isLeapYear(year)) ? 29 : 28; break;
            default: maxDays = 31;
        }
        return day >= 1 && day <= maxDays;
    }

    private boolean isLeapYear(int year) {
        return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
    }
}

Controlled interaction with Objects

Imagine a system where people check train seat availability. To prevent spamming, we only allow logged-in users to query, and we limit how many queries they can make (e.g., 3 queries per login).

  1. Logging in: Provides the user with a QueryObject if successful.
  2. Using the Object: Tracks limits and fetches data from the DB.
  3. Interfaces: A QIF interface guarantees the object has a getStatus method.
Java
import java.util.Date;

interface QIF {
    int getStatus(int trainNo, Date date);
}

class BookingDB {
    public static int getAvailableSeats(int trainNo, Date date) {
        if (trainNo == 101) return 50;
        if (trainNo == 102) return 30;
        return 0; 
    }
}

class RailwayBooking {
    private BookingDB railwayDB = new BookingDB();

    private boolean validLogin(String username, String password) {
        return "user".equals(username) && "pass".equals(password);
    }

    public QIF login(String username, String password) {
        if (validLogin(username, password)) {
            return new QueryObject();
        }
        return null;
    }

    private class QueryObject implements QIF {
        private int numQueries = 0;
        private static final int QLIM = 3;

        public int getStatus(int trainNo, Date date) {
            if (numQueries >= QLIM) {
                System.out.println("Query limit reached.");
                return -1;
            }
            numQueries++;
            int seatsAvailable = BookingDB.getAvailableSeats(trainNo, date);
            System.out.println("Seats for Train " + trainNo + ": " + seatsAvailable);
            return seatsAvailable;
        }
    }
}

Callback in Java

A callback is a way for one object (e.g., a Timer) to notify another object that something has happened.

Step 1: Specific Timer

The timer is strictly tied to Myclass. It cannot be used by other classes without modifying the Timer.

Java
class Timer implements Runnable {
    private Myclass owner;
    public Timer(Myclass o) { owner = o; }
    public void start() { owner.timerdone(); }
    public void run() { start(); }
}

Step 2: Generic Timer with Object

We use Object but this requires risky casting (ClassCastException risk).

Java
class Timer implements Runnable {
    private Object owner;
    public Timer(Object o) { owner = o; }
    public void start() { ((Myclass) owner).timerdone(); }
    public void run() { start(); }
}

Step 3: Using Interfaces for Safety

The best approach is an interface. The Timerowner interface ensures any object passed in has the timerdone() callback method.

Java
interface Timerowner {
    void timerdone();
}

class Myclass implements Timerowner {
    public void f() {
        Timer t = new Timer(this);
        t.start();
    }
    public void timerdone() {
        System.out.println("Timer is done!");
    }
}

class Timer implements Runnable {
    private Timerowner owner;
    public Timer(Timerowner o) { owner = o; }
    public void start() { owner.timerdone(); }
    public void run() { start(); }
}

Java Iterator

An Iterator is a design pattern used to traverse a collection (like a list) without exposing its underlying structure. It simplifies accessing and iterating over elements, ensuring uniformity.

Implementing Iterator in LinearList

The Iter class implements Iterator, starting at the list's head and moving through the nodes.

Java
public interface Iterator {
    boolean has_next();
    Object get_next();
}

class Linearlist {
    private Node head;

    private class Node {
        Object data;
        Node next;
        Node(Object data) { this.data = data; this.next = null; }
    }

    public void append(Object data) {
        Node newNode = new Node(data);
        if (head == null) { head = newNode; } 
        else {
            Node temp = head;
            while (temp.next != null) temp = temp.next;
            temp.next = newNode;
        }
    }

    private class Iter implements Iterator {
        private Node position = head;

        public boolean has_next() { return position != null; }

        public Object get_next() {
            if (has_next()) {
                Object data = position.data;
                position = position.next;
                return data;
            }
            return null;
        }
    }

    public Iterator get_iterator() { return new Iter(); }
}

class Main {
    public static void main(String[] args) {
        Linearlist l = new Linearlist();
        l.append("Hello");
        l.append("World");

        Iterator i = l.get_iterator();
        while (i.has_next()) {
            System.out.println(i.get_next()); // Hello, World
        }
    }
}

If needed, you can create multiple iterators to traverse the list at different points simultaneously without them interfering with each other.