Java Fundamentals

Constructors and Inheritance

Object construction, the 'this' keyword, and inheritance mechanisms

Summary of the last lectures

We know how to :
  • Design and create classes
  • Define properties and methods for those classes
  • Control the flow of our program and repeat statements
We saw basic (default) constructors , and how to call it through a java code to create new instances
How to construct complex objects? How to take advantages of the inheritance mechanism? What is the Abstraction concept

Constructors : recall

  • Definition
    A constructor is a dedicated mechanism that is necessary to construct the object. The mechanism of using a constructor to build a new instance is called instantiation
  • Parameters
    The constructor can take parameters, like a method, to initialize the current instance with a particular value

Constructors : schema

package fr.tbr.exercises;

/**
 * Example of a car, constructor with
 * a "color" parameter
 * @author Tom
 */
public class Car {
	private String color;

	public Car(String color) {
		this.color = color;
	}

}
        

Class vs Instance

  • Notice in the previous example the this keyword.
  • this allows to access to the current instance values
  • An instance is a representation with some particular values of the class, Examples:
    • A blue car is a representation of the class Car
    • You are representations of the Human class.
      The class Human, is describing the shape of each Human. You are a specific representation (with your own characteristics) of this class

Class vs Instance (2)

  • The this keyword can be used in any non-static methods
  • Example: interest calculation in the bank account system
    /**
     * return the calculated interest on one year
     * warning, this method updates the balance with the result of that computation
     * @return the interest
     */
    public double calculateInterest(){
    	double	result = this.interestRate * this.balance;
        this.balance += result; //equivalent to this.balance = this.balance + result
        return result;
    }
To precise whether you are using a local variable or a class field, the this keyword is strongly recommended

Taking advantage of inheritance

  • The inheritance mechanism should always represent a IS-A relationship
  • When it applies, this relationship allows to factor a lot of code
  • Example: Inheritance from quadrilateral to rectangle

public class Quadrilateral {

	private double sideA;
	private double sideB;
	private double sideC;
	private double sideD;
    /*...*/
	public double calculatePerimeter(){
		return this.sideA + this.sideB
        + this.sideC + this.sideD;
	}

}
public class Rectangle extends Quadrilateral{
 public Rectangle(double height, double width)
 {
   super (height, height, width, width);
 }

}

Taking advantage of inheritance (2)

  • Notice the super keyword: it allows the access of the inherited Class (here the Quadrilateral Class)
  • As the Rectangle Class inherits from the Quadrilateral class, you don't have to write the calculatePerimeter() method again
Warning: The inheritance mechanism allows natural code factoring, but if the relationship between inheriting objects is not a real IS-A relationship, the code maintenance can be very painful

Exercise

In the geometry example found here, add the Ellipse object and try to generalize the Circle class methods

Inheritance problems

When you are using inheritance you have to face several problems
  • You can't change the characteristics of a super object methods
  • You have to define clearly what methods can be overridden or not
  • You have to foresee that the behaviour of the overridden methods can be unpredictable

Abstract Classes: Introduction

  • Problem: Sometimes you want to define a class that provides common behavior, but it doesn't make sense to instantiate it directly
  • Example: A generic "Shape" class - you never want just a "Shape", you want a Circle, Rectangle, etc.
  • Solution: Use abstract classes
Abstract classes can't be instantiated directly - they serve as templates for subclasses

Abstract Classes: Syntax

public abstract class Shape {
    protected String color;

    public Shape(String color) {
        this.color = color;
    }

    // Abstract method - no implementation
    public abstract double calculateArea();

    // Concrete method - has implementation
    public String getColor() {
        return this.color;
    }
}
  • Use abstract keyword for the class
  • Can contain abstract methods (no implementation)
  • Can contain concrete methods (with implementation)

Abstract Classes: Implementation

public class Circle extends Shape {
    private double radius;

    public Circle(String color, double radius) {
        super(color);  // Call parent constructor
        this.radius = radius;
    }

    @Override
    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}
  • Subclasses must implement all abstract methods
  • Use @Override annotation to indicate you're implementing an abstract method

Interfaces: Introduction

  • Limitation of inheritance: Java doesn't support multiple inheritance
  • Problem: What if a class needs to have multiple "types" of behavior?
  • Solution: Use interfaces
An interface is a contract that specifies what methods a class must implement, without providing the implementation
Example: A Car can be both Drivable and Lockable

Interfaces: Syntax

public interface Drawable {
    // All methods in interfaces are implicitly public and abstract
    void draw();
    double getArea();

    // Since Java 8: default methods with implementation
    default void display() {
        System.out.println("Drawing shape with area: " + getArea());
    }
}
  • Use interface keyword instead of class
  • Methods are implicitly public abstract
  • Can have default methods (since Java 8) with implementation
  • Can have static methods

Interfaces: Implementation

public class Rectangle extends Shape implements Drawable, Comparable<Rectangle> {
    private double width;
    private double height;

    public Rectangle(String color, double width, double height) {
        super(color);
        this.width = width;
        this.height = height;
    }

    @Override
    public double calculateArea() {
        return width * height;
    }

    @Override
    public void draw() {
        System.out.println("Drawing a " + color + " rectangle");
    }

    @Override
    public double getArea() {
        return calculateArea();
    }

    @Override
    public int compareTo(Rectangle other) {
        return Double.compare(this.calculateArea(), other.calculateArea());
    }
}

Abstract Classes vs Interfaces

Feature Abstract Class Interface
Inheritance Can extend only one Can implement multiple
Fields Can have any fields Only public static final
Methods Abstract + concrete Abstract + default + static
Constructor Yes No
Use case IS-A relationship CAN-DO behavior

When to Use Which?

Use Abstract Class When:

  • You have closely related classes
  • You want to share code among several related classes
  • You need non-public fields or methods
  • Classes have a true IS-A relationship
Example: Shape → Circle, Rectangle

Use Interface When:

  • Unrelated classes implement your interface
  • You want to specify behavior without implementation
  • You want multiple inheritance of type
  • Classes CAN-DO something
Example: Comparable, Drawable, Serializable

Polymorphism with Interfaces

public class ShapeDemo {
    public static void main(String[] args) {
        // Polymorphism: treat all shapes uniformly
        List<Shape> shapes = new ArrayList<>();
        shapes.add(new Circle("Red", 5.0));
        shapes.add(new Rectangle("Blue", 4.0, 6.0));
        shapes.add(new Triangle("Green", 3.0, 4.0));

        // Calculate total area using polymorphism
        double totalArea = 0;
        for (Shape shape : shapes) {
            totalArea += shape.calculateArea();  // Calls correct method
            System.out.println(shape.getClass().getSimpleName() +
                             " area: " + shape.calculateArea());
        }

        System.out.println("Total area: " + totalArea);
    }
}
Polymorphism allows you to treat different types uniformly through their common interface or superclass

Real-World Example: Geometry System

// Abstract base class
public abstract class Shape {
    protected String color;

    public Shape(String color) {
        this.color = color;
    }

    public abstract double calculateArea();
    public abstract double calculatePerimeter();
}

// Interface for drawable objects
public interface Drawable {
    void draw();
    default String getDescription() {
        return "A drawable shape";
    }
}

// Concrete implementation
public class Circle extends Shape implements Drawable, Comparable<Circle> {
    private double radius;

    public Circle(String color, double radius) {
        super(color);
        this.radius = radius;
    }

    @Override
    public double calculateArea() {
        return Math.PI * radius * radius;
    }

    @Override
    public double calculatePerimeter() {
        return 2 * Math.PI * radius;
    }

    @Override
    public void draw() {
        System.out.println("Drawing a " + color + " circle with radius " + radius);
    }

    @Override
    public int compareTo(Circle other) {
        return Double.compare(this.radius, other.radius);
    }
}

Exercise: Geometry System

Create a complete geometry system with the following:

  1. Create an abstract Shape class with:
    • A color field
    • Abstract methods: calculateArea() and calculatePerimeter()
    • A concrete method: getColor()
  2. Create a Drawable interface with a draw() method
  3. Implement these concrete classes:
    • Circle (radius)
    • Rectangle (width, height)
    • Triangle (base, height)
  4. Create a ShapeManager class that:
    • Stores a list of shapes
    • Calculates the total area of all shapes
    • Finds the shape with the largest area
    • Draws all shapes
Bonus: Make your shapes implement Comparable to sort them by area

The Object Class: The Root of All Classes

  • In Java, every class implicitly extends java.lang.Object
  • Even when you don't write extends Object, Java does it automatically
  • The Object class provides several important methods that all objects inherit:
Method Purpose
toString() Returns a string representation of the object
equals(Object) Checks if two objects are logically equal
hashCode() Returns an integer hash code for the object
getClass() Returns the runtime class of the object

toString(): String Representation

  • By default, toString() returns: ClassName@hexHashCode
  • This is not very useful for debugging or logging!
public class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

// Without overriding toString()
Person p = new Person("Alice", 25);
System.out.println(p);  // Output: Person@1a2b3c4d  (not helpful!)
Best practice: Always override toString() for meaningful output

toString(): Overriding

public class Person {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return "Person{name='" + name + "', age=" + age + "}";
    }
}

// Now we get useful output
Person p = new Person("Alice", 25);
System.out.println(p);  // Output: Person{name='Alice', age=25}

// toString() is called automatically in many situations:
System.out.println("User: " + p);  // String concatenation
String.format("User: %s", p);      // Format strings

equals(): Logical Equality

  • == compares references (are they the same object in memory?)
  • equals() compares values (are they logically equivalent?)
Person p1 = new Person("Alice", 25);
Person p2 = new Person("Alice", 25);
Person p3 = p1;

// Reference comparison
System.out.println(p1 == p2);   // false (different objects)
System.out.println(p1 == p3);   // true (same reference)

// Without overriding equals(), it behaves like ==
System.out.println(p1.equals(p2));  // false (by default)
Problem: We often want two Person objects with the same name and age to be considered "equal"

equals(): Overriding

public class Person {
    private String name;
    private int age;

    @Override
    public boolean equals(Object obj) {
        // 1. Check if same reference
        if (this == obj) return true;

        // 2. Check if null or different class
        if (obj == null || getClass() != obj.getClass()) return false;

        // 3. Cast and compare fields
        Person other = (Person) obj;
        return age == other.age &&
               Objects.equals(name, other.name);
    }
}

// Now logical comparison works
Person p1 = new Person("Alice", 25);
Person p2 = new Person("Alice", 25);
System.out.println(p1.equals(p2));  // true!

hashCode(): The Contract with equals()

  • hashCode() returns an integer "fingerprint" of the object
  • Critical rule: If two objects are equals(), they must have the same hashCode()
  • This is essential for HashMap, HashSet, etc.
@Override
public int hashCode() {
    return Objects.hash(name, age);
}

// Complete equals/hashCode contract:
Person p1 = new Person("Alice", 25);
Person p2 = new Person("Alice", 25);

System.out.println(p1.equals(p2));              // true
System.out.println(p1.hashCode() == p2.hashCode()); // MUST be true!
Rule: If you override equals(), you must also override hashCode()

Why hashCode() Matters for Collections

// Without proper hashCode(), HashSet doesn't work correctly!
Set<Person> people = new HashSet<>();

Person p1 = new Person("Alice", 25);
Person p2 = new Person("Alice", 25);  // Same values as p1

people.add(p1);
people.add(p2);  // Should be ignored (duplicate)

// Without hashCode() override:
System.out.println(people.size());  // 2 (WRONG! Both were added)

// With proper hashCode() override:
System.out.println(people.size());  // 1 (Correct! Duplicate detected)
  • HashSet uses hashCode() to find the "bucket" for an object
  • Then uses equals() to check for duplicates in that bucket
  • If hashCodes differ, equals() is never even called!

The final Keyword

The final keyword can be applied to:

Applied to Meaning
Variable Cannot be reassigned (constant)
Method Cannot be overridden in subclasses
Class Cannot be extended (no subclasses)

final Variables (Constants)

public class Circle {
    // final static = class-level constant (by convention UPPER_CASE)
    public static final double PI = 3.14159265359;

    // final instance variable - must be initialized once
    private final double radius;

    public Circle(double radius) {
        this.radius = radius;  // OK: initialized in constructor
    }

    public void setRadius(double r) {
        // this.radius = r;  // ERROR: cannot reassign final variable
    }

    public double getArea() {
        return PI * radius * radius;
    }
}

// Usage
final int maxAttempts = 3;
// maxAttempts = 5;  // ERROR: cannot reassign

final Methods and Classes

// final method - cannot be overridden
public class BankAccount {
    private double balance;

    // Subclasses cannot change this security-critical method
    public final void validateTransaction(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Invalid amount");
        }
    }
}

// final class - cannot be extended
public final class SecurityUtils {
    public static String hashPassword(String password) {
        // ...
    }
}

// This would cause a compile error:
// public class HackedSecurityUtils extends SecurityUtils { }  // ERROR!
Example: String is a final class in Java - you cannot extend it

Records: Immutable Data Carriers (Java 16)

A record is a concise way to create immutable data classes:

Traditional Class

public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() { return x; }
    public int getY() { return y; }

    // + equals, hashCode, toString
}

Record (equivalent!)

public record Point(int x, int y) { }

// That's it! You get:
// - Constructor
// - x() and y() accessors
// - equals()
// - hashCode()
// - toString()

Using Records

// Define a record
public record Customer(String name, String email, int age) { }

// Create instances
var customer = new Customer("Alice", "alice@email.com", 30);

// Access components (note: accessors don't use "get" prefix)
String name = customer.name();      // "Alice"
String email = customer.email();    // "alice@email.com"

// Automatic toString
System.out.println(customer);
// Output: Customer[name=Alice, email=alice@email.com, age=30]

// Automatic equals and hashCode
var customer2 = new Customer("Alice", "alice@email.com", 30);
System.out.println(customer.equals(customer2));  // true

Records: Additional Features

public record BankAccount(String accountNumber, double balance) {

    // Compact constructor for validation
    public BankAccount {
        if (balance < 0) {
            throw new IllegalArgumentException("Balance cannot be negative");
        }
        accountNumber = accountNumber.toUpperCase();  // Normalize
    }

    // Custom methods are allowed
    public BankAccount deposit(double amount) {
        return new BankAccount(accountNumber, balance + amount);
    }

    // Static methods too
    public static BankAccount empty(String accountNumber) {
        return new BankAccount(accountNumber, 0.0);
    }
}
Note: Records are immutable - methods return new instances instead of modifying state

Records vs Classes: When to Use

Use Records WhenUse Classes When
Data is immutable (read-only) State needs to change over time
Simple data carrier (DTO, value object) Complex behavior and state management
You want automatic equals/hashCode You need custom inheritance hierarchy
API response/request models Domain entities with lifecycle
// Perfect for records
record OrderItem(String productId, int quantity, double price) { }
record ApiResponse(int status, String message, Object data) { }

Pattern Matching with instanceof (Java 16)

Combine type checking and casting in one step:

Traditional

if (shape instanceof Circle) {
    Circle c = (Circle) shape;
    double area = c.getArea();
}

if (obj instanceof String) {
    String s = (String) obj;
    System.out.println(s.length());
}

Pattern Matching

if (shape instanceof Circle c) {
    // c is already a Circle!
    double area = c.getArea();
}

if (obj instanceof String s) {
    // s is already a String!
    System.out.println(s.length());
}
The pattern variable (c, s) is automatically cast and in scope!

Pattern Matching: Practical Examples

// Process different shape types
public double calculateArea(Shape shape) {
    if (shape instanceof Circle c) {
        return Math.PI * c.getRadius() * c.getRadius();
    } else if (shape instanceof Rectangle r) {
        return r.getWidth() * r.getHeight();
    } else if (shape instanceof Triangle t) {
        return 0.5 * t.getBase() * t.getHeight();
    }
    throw new IllegalArgumentException("Unknown shape");
}

// With negation
if (!(obj instanceof String s)) {
    return;  // Early exit
}
// s is in scope here!
System.out.println(s.toUpperCase());

Sealed Classes (Java 17)

Sealed classes restrict which classes can extend them:

// Only Circle, Rectangle, and Triangle can extend Shape
public sealed class Shape
    permits Circle, Rectangle, Triangle {
    // ...
}

public final class Circle extends Shape { }      // Cannot be extended further
public final class Rectangle extends Shape { }   // Cannot be extended further
public non-sealed class Triangle extends Shape { } // Open for extension

Sealed Classes + Pattern Matching

Sealed classes enable exhaustive pattern matching:

public sealed interface Result permits Success, Failure { }
public record Success(String data) implements Result { }
public record Failure(String error) implements Result { }

// Compiler knows all possible cases!
String process(Result result) {
    return switch (result) {
        case Success s -> "Got: " + s.data();
        case Failure f -> "Error: " + f.error();
        // No default needed - compiler knows it's exhaustive!
    };
}
Benefit: If you add a new subclass, compiler forces you to handle it everywhere!

Modern Java OOP Features Summary

FeatureJava VersionUse Case
Records 16 Immutable data carriers (DTOs, value objects)
Pattern Matching instanceof 16 Cleaner type checks without explicit casts
Sealed Classes 17 Controlled inheritance hierarchies
Pattern Matching in Switch 21 Type-based branching with exhaustiveness
// All together: Sealed record hierarchy with pattern matching
sealed interface PaymentResult permits Approved, Declined { }
record Approved(String transactionId) implements PaymentResult { }
record Declined(String reason) implements PaymentResult { }

Exercise: Implementing Object Methods

Update your Shape classes to properly implement Object methods:

  1. Override toString() in the Circle class to return: "Circle[color=red, radius=5.0]"
  2. Override equals() to compare circles by color and radius
  3. Override hashCode() to be consistent with equals
  4. Test your implementation:
    Circle c1 = new Circle("red", 5.0);
    Circle c2 = new Circle("red", 5.0);
    Circle c3 = new Circle("blue", 5.0);
    
    System.out.println(c1);              // Should print nicely
    System.out.println(c1.equals(c2));   // Should be true
    System.out.println(c1.equals(c3));   // Should be false
    
    Set<Circle> circles = new HashSet<>();
    circles.add(c1);
    circles.add(c2);  // Should not add (duplicate)
    System.out.println(circles.size());  // Should be 1

Key Takeaways

  • Object Class Methods: toString(), equals(), hashCode()
  • The final Keyword: Constants, prevent override, prevent inheritance
  • Abstract vs Interfaces: IS-A shared behavior vs CAN-DO contracts
  • Polymorphism: Treat different types uniformly
  • Records (Java 16): Concise immutable data classes
  • Pattern Matching instanceof (Java 16): Combine type check + cast
  • Sealed Classes (Java 17): Controlled inheritance hierarchies

Slide Overview