Java Full Course: Mastering the Language Object Oriented Programming in Java

Object Oriented Programming in Java

(Complete Notes | Beginner to Advanced | Professional & Exam-Oriented | Masterclass)

1. Introduction to Object Oriented Programming

Java is fundamentally an object oriented language. At its core, it models real world entities as objects that combine data (attributes) and behavior (methods). This approach makes programs more modular, reusable, and easier to understand.

The four main pillars of object oriented programming are:

  • Encapsulation – Bundling data and methods together, and hiding internal details.
  • Inheritance – Creating new classes based on existing ones, promoting code reuse.
  • Polymorphism – Allowing objects to take many forms, enabling flexible and extensible code.
  • Abstraction – Hiding complex implementation details and exposing only essential features.

In Java, these concepts are implemented using classes, objects, interfaces, and packages. Mastering OOP is essential for building scalable, maintainable, and robust applications.

In the following sections, we will explore each pillar in depth, along with related topics like constructors, access modifiers, method overloading, overriding, abstract classes, and interfaces.


2. Classes and Objects

Java is an object oriented programming language, meaning it organizes code around objects rather than functions and logic alone. An object is a self contained entity that consists of state (data) and behavior (methods). A class is a blueprint or template from which objects are created.

  • Class – Defines the structure (fields) and capabilities (methods) that its objects will have.
  • Object – An instance of a class, created at runtime, with its own copy of the instance fields.

2.1 Defining a Class

A class definition contains fields (variables) and methods (functions) that operate on those fields.

Syntax:

[access modifier] class ClassName { // fields (instance variables) // constructors // methods }

Example:

public class Car { // Fields (instance variables) String brand; String model; int year; double speed; // Method void accelerate() { speed += 10; } void brake() { speed -= 5; if (speed < 0) speed = 0; } void displayInfo() { System.out.println(brand + " " + model + " (" + year + ") - Speed: " + speed); } }
  • The public keyword is an access modifier (discussed later).
  • Fields are declared like regular variables but inside the class.
  • Methods define behaviors.

2.2 Creating Objects (Instantiating a Class)

Objects are created using the new keyword, which allocates memory for the object and returns a reference to it.

Syntax:

ClassName objectReference = new ClassName();

Example:

Car myCar = new Car(); // create a Car object
  • myCar is a reference variable that holds the memory address of the object.
  • new Car() invokes the constructor (see below).

Accessing fields and methods:

myCar.brand = "Toyota"; myCar.model = "Camry"; myCar.year = 2022; myCar.accelerate(); myCar.displayInfo(); // Toyota Camry (2022) - Speed: 10.0

2.3 Memory Allocation: Heap and Stack

  • Heap – All objects are stored in the heap. When you write new Car(), memory is allocated on the heap for that object.
  • Stack – Local variables (including reference variables) are stored on the stack. The reference variable myCar lives on the stack and holds the address of the object on the heap.

When a method is called, a new stack frame is created. When the method ends, the frame is popped. Objects on the heap remain until they are no longer referenced (garbage collected).


2.4 Constructors

A constructor is a special method that initializes an object when it is created. It has the same name as the class and no return type (not even void).

a) Default Constructor

If you do not provide any constructor, Java provides a default constructor with no parameters that initializes fields to default values (0, false, null).

Car myCar = new Car(); // uses default constructor // fields are null, 0, 0.0

b) Parameterized Constructor

You can define constructors with parameters to set initial values.

public class Car { String brand; String model; int year; // Parameterized constructor public Car(String brand, String model, int year) { this.brand = brand; this.model = model; this.year = year; } }

Usage:

Car myCar = new Car("Honda", "Civic", 2023);

c) Constructor Overloading

You can define multiple constructors with different parameter lists.

public Car() { } // default public Car(String brand) { this.brand = brand; } public Car(String brand, String model, int year) { this.brand = brand; this.model = model; this.year = year; }

d) The this Keyword in Constructors

this refers to the current object. It is used to distinguish instance variables from parameters with the same name, or to call another constructor.

Calling another constructor:

public Car(String brand) { this(brand, "Unknown", 0); // calls the three parameter constructor }
Crucial

[!IMPORTANT] this() must be the first statement in a constructor.


2.5 Methods

Methods define the behavior of objects. They can be instance methods (belong to an object) or static methods (belong to the class).

a) Instance Methods

  • Operate on instance fields.
  • Can access other instance methods and fields.
void displayInfo() { System.out.println(brand + " " + model); }

b) Static Methods

  • Declared with the static keyword.
  • Belong to the class, not to any object.
  • Cannot directly access instance fields or methods (they can only access static members).
static void showDescription() { System.out.println("This is a Car class."); }

Calling a static method: Car.showDescription();

c) Method Overloading

Multiple methods can have the same name but different parameter lists (type, number, or order).

void accelerate() { ... } void accelerate(int increment) { ... }

The compiler determines which method to call based on the arguments.

d) Return Types

A method can return a value using the return keyword. The return type must be specified.

int getSpeed() { return speed; }

If no value is returned, use void.


2.6 Access Modifiers

Access modifiers control the visibility of classes, fields, methods, and constructors.

ModifierSame ClassSame PackageSubclass (different package)Any Class
private
(default)
protected
public
  • private – Accessible only within the same class.
  • default (no modifier) – Accessible within the same package.
  • protected – Accessible within the same package and by subclasses (even in different packages).
  • public – Accessible from anywhere.
Tip

[!TIP] Best practice: Keep fields private and provide getter/setter methods for controlled access (encapsulation).

private int year; public int getYear() { return year; } public void setYear(int year) { if (year > 1885) { // basic validation this.year = year; } }

2.7 The this Keyword

this is a reference to the current object. It is used for:

  • Distinguishing instance variables from parameters: this.brand = brand;
  • Calling another constructor: this(...);
  • Passing the current object as a parameter: someMethod(this);
  • Returning the current object from a method (method chaining):
Car setBrand(String brand) { this.brand = brand; return this; } // usage: myCar.setBrand("Ford").setModel("Mustang");

2.8 Garbage Collection

Java automatically manages memory. When an object is no longer reachable (no references pointing to it), the garbage collector reclaims its memory.

  • The finalize() method (deprecated in recent Java) was used for cleanup before garbage collection.
  • To explicitly suggest garbage collection, use System.gc() (but it's not guaranteed).

Example of object becoming eligible for GC:

Car myCar = new Car(); myCar = null; // no reference to the object → eligible for GC

2.9 Complete Example

public class Student { // Private fields (encapsulation) private int id; private String name; private double grade; // Constructors public Student() { this(0, "Unknown", 0.0); } public Student(int id, String name, double grade) { this.id = id; this.name = name; this.grade = grade; } // Getters and Setters public int getId() { return id; } public void setId(int id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public double getGrade() { return grade; } public void setGrade(double grade) { if (grade >= 0 && grade <= 100) { this.grade = grade; } else { System.out.println("Invalid grade"); } } // Instance method public void display() { System.out.println("ID: " + id + ", Name: " + name + ", Grade: " + grade); } // Static method public static void printSchool() { System.out.println("XYZ University"); } public static void main(String[] args) { // Creating objects Student s1 = new Student(101, "Alice", 85.5); Student s2 = new Student(); s2.setId(102); s2.setName("Bob"); s2.setGrade(92.0); // Using instance methods s1.display(); s2.display(); // Using static method Student.printSchool(); // Modifying via setter with validation s1.setGrade(105); // Invalid grade s1.display(); // grade remains 85.5 } }

Output:

ID: 101, Name: Alice, Grade: 85.5 ID: 102, Name: Bob, Grade: 92.0 XYZ University Invalid grade ID: 101, Name: Alice, Grade: 85.5

2.10 Common Pitfalls and Best Practices

PitfallExplanation / Solution
Forgetting to use newCar myCar; only declares a reference, no object. Use new.
Accessing uninitialized fieldsFields have default values (0, false, null), but objects referenced may be null. Always initialize before use.
Using == for object equality== compares references, not content. Use equals() for content.
Not providing getters/settersDirect field access violates encapsulation. Use accessor methods.
Exposing mutable objectsReturning a reference to a mutable object (e.g., an array) breaks encapsulation. Return a copy or use immutable types.
Constructors with too many parametersUse builder pattern or refactor.
Static methods accessing instance fieldsCauses compilation error. Use instance methods for instance fields.

Best Practices:

  • Keep fields private and provide getters/setters.
  • Use meaningful class and method names.
  • Use constructors to ensure objects are properly initialized.
  • Avoid unnecessary mutable state; prefer immutability where possible.
  • Use this only when needed (e.g., to disambiguate).
  • Follow the Single Responsibility Principle – a class should have one reason to change.

2.11 Key Points to Remember

  • A class is a blueprint; an object is an instance.
  • Objects are created with new.
  • Constructors initialize new objects; if none is provided, a default constructor exists.
  • Fields can be instance (per object) or static (shared across all objects).
  • Methods can be instance or static.
  • this refers to the current object.
  • Access modifiers control visibility.
  • Encapsulation hides internal state and exposes controlled access.
  • Garbage collector automatically reclaims unreachable objects.

3. Inheritance in Java

3.1 What is Inheritance?

Inheritance is a mechanism in object‑oriented programming where one class (the child or subclass) acquires the properties (fields) and behaviors (methods) of another class (the parent or superclass). It establishes an is‑a relationship: a subclass is a specialized version of the superclass.

Benefits:

  • Code reusability – common code is written once in the superclass.
  • Method overriding – subclasses can provide specific implementations.
  • Polymorphism – enables flexible and extensible designs.
  • Hierarchical classification – models real‑world relationships.

3.2 Syntax and Basic Example

Use the extends keyword to create a subclass.

class Parent { // fields and methods } class Child extends Parent { // additional fields and methods }

Example:

class Animal { String name; void eat() { System.out.println(name + " is eating."); } } class Dog extends Animal { void bark() { System.out.println(name + " is barking."); } } public class TestInheritance { public static void main(String[] args) { Dog d = new Dog(); d.name = "Buddy"; d.eat(); // from Animal d.bark(); // from Dog } } // Output: // Buddy is eating. // Buddy is barking.
  • Dog inherits name and eat() from Animal.
  • Dog adds its own method bark().

3.3 Types of Inheritance in Java

Java supports single inheritance for classes – a class can extend only one direct superclass. However, it supports multiple inheritance of type through interfaces.

a) Single Inheritance

One subclass inherits from one superclass.

class A { } class B extends A { }

b) Multilevel Inheritance

A class extends a subclass, creating a chain.

class A { } class B extends A { } class C extends B { }

C inherits from both B and indirectly from A.

c) Hierarchical Inheritance

Multiple subclasses extend the same superclass.

class A { } class B extends A { } class C extends A { }

d) Multiple Inheritance (via interfaces)

A class can implement multiple interfaces, achieving multiple inheritance of behavior.

interface X { } interface Y { } class Z implements X, Y { }
Crucial

[!IMPORTANT] Java does not allow a class to extend more than one class (no multiple inheritance of classes) to avoid the diamond problem (ambiguity when two parent classes have the same method).


3.4 The super Keyword

super is used to refer to the immediate superclass. It can be used for:

  • Accessing superclass fields (if hidden by subclass fields).
  • Invoking superclass methods (overridden ones).
  • Calling superclass constructors.
class Vehicle { String brand = "Generic"; void display() { System.out.println("Vehicle brand: " + brand); } Vehicle() { System.out.println("Vehicle constructor"); } Vehicle(String type) { System.out.println("Vehicle constructor with " + type); } } class Car extends Vehicle { String brand = "Toyota"; // hides superclass field void display() { super.display(); // call superclass method System.out.println("Car brand: " + brand); System.out.println("Super brand: " + super.brand); } Car() { super(); // calls Vehicle() System.out.println("Car constructor"); } Car(String model) { super("Car"); // calls Vehicle(String) System.out.println("Car model: " + model); } }

Output for new Car():

Vehicle constructor Car constructor

Rules for super:

  • super() must be the first statement in a constructor.
  • If not explicitly written, the compiler inserts super() (calling the no‑arg superclass constructor).
  • If the superclass has no no‑arg constructor, you must explicitly call a parameterized constructor using super(...).

3.5 Method Overriding

Method overriding occurs when a subclass provides a specific implementation of a method already defined in its superclass.

Rules:

  • The method name, return type, and parameters must exactly match (covariant return types are allowed – subclass can return a subtype).
  • The access modifier cannot be more restrictive than the superclass method (e.g., if superclass method is public, subclass cannot make it private).
  • final methods cannot be overridden.
  • static methods are not overridden; they are hidden (method hiding).
  • abstract methods must be overridden unless the subclass is also abstract.

Example:

class Animal { void sound() { System.out.println("Animal makes a sound"); } } class Cat extends Animal { @Override void sound() { System.out.println("Cat meows"); } } public class TestOverride { public static void main(String[] args) { Animal a = new Cat(); a.sound(); // Cat meows (runtime polymorphism) } }
Tip

[!TIP] The @Override annotation is optional but recommended. It tells the compiler that the method is intended to override a superclass method, helping catch errors (e.g., misspelling).


3.6 Constructors in Inheritance

  • The superclass constructor is always called before the subclass constructor body executes.
  • If you don't explicitly call a superclass constructor, the compiler inserts super() (the no‑arg constructor).
  • If the superclass does not have a no‑arg constructor, you must explicitly call a parameterized constructor using super(...) as the first statement in the subclass constructor.
class Parent { Parent(int x) { System.out.println("Parent with " + x); } } class Child extends Parent { Child() { super(10); // required System.out.println("Child"); } }

3.7 final Classes and Methods

  • A final class cannot be extended.
  • A final method cannot be overridden.
final class Utility { } // cannot be subclassed class Parent { final void show() { } // cannot be overridden }

3.8 Abstract Classes

An abstract class is a class that cannot be instantiated. It may contain abstract methods (without body) that must be overridden by concrete subclasses.

abstract class Shape { abstract double area(); // no body void display() { // concrete method System.out.println("Area: " + area()); } } class Circle extends Shape { double radius; Circle(double r) { radius = r; } @Override double area() { return Math.PI * radius * radius; } }
  • Abstract classes can have constructors (called when subclass is instantiated).
  • If a subclass does not implement all abstract methods, it must also be abstract.

3.9 The Object Class

Every class in Java implicitly extends java.lang.Object (unless it already extends another class). Object provides basic methods:

  • toString()
  • equals(Object)
  • hashCode()
  • getClass()
  • clone()
  • finalize() (deprecated)
  • wait(), notify(), notifyAll()

Overriding toString() and equals() is common.

@Override public String toString() { return "MyClass{" + "field=" + field + "}"; }

3.10 Polymorphism and Inheritance

Polymorphism (many forms) allows a reference of a superclass type to hold an object of a subclass type. The actual method executed is determined at runtime (dynamic method dispatch).

Animal a1 = new Animal(); Animal a2 = new Dog(); // Dog extends Animal a1.sound(); // Animal's sound a2.sound(); // Dog's sound (overridden)

This enables writing flexible code that works with any subclass of a given superclass.


3.11 Complete Example

// Superclass class Employee { private int id; private String name; private double salary; public Employee(int id, String name, double salary) { this.id = id; this.name = name; this.salary = salary; } public void work() { System.out.println(name + " is working."); } public double getSalary() { return salary; } public String getName() { return name; } public int getId() { return id; } @Override public String toString() { return "Employee{" + id + ", " + name + ", " + salary + "}"; } } // Subclass class Manager extends Employee { private double bonus; public Manager(int id, String name, double salary, double bonus) { super(id, name, salary); // call superclass constructor this.bonus = bonus; } @Override public void work() { System.out.println(getName() + " is managing the team."); } public double getTotalPay() { return getSalary() + bonus; } } // Another subclass class Developer extends Employee { private String programmingLanguage; public Developer(int id, String name, double salary, String language) { super(id, name, salary); this.programmingLanguage = language; } @Override public void work() { System.out.println(getName() + " is coding in " + programmingLanguage); } } public class InheritanceDemo { public static void main(String[] args) { Employee e = new Employee(1, "Alice", 50000); Manager m = new Manager(2, "Bob", 80000, 20000); Developer d = new Developer(3, "Charlie", 70000, "Java"); e.work(); m.work(); d.work(); System.out.println(m.getTotalPay()); // 100000.0 // Polymorphic array Employee[] employees = { e, m, d }; for (Employee emp : employees) { System.out.println(emp); // toString called } } }

3.12 Common Pitfalls and Best Practices

PitfallExplanation / Solution
Forgetting super()If superclass lacks a no‑arg constructor, you must call a parameterized super(...).
Overriding without @OverrideAccidentally creating a new method instead of overriding. Use the annotation.
Calling overridden method in constructorThe overridden method might be called before subclass fields are initialized. Avoid calling overridable methods in constructors.
Using protected or public fieldsExposes internal details; prefer private with getters/setters.
Deep inheritance hierarchiesHard to maintain; prefer composition over inheritance when possible.
Confusing method overriding with overloadingOverriding changes behavior in subclass; overloading adds variants.
Not using polymorphismWriting code that references concrete classes reduces flexibility. Program to interfaces/superclasses.

Best Practices:

  • Favor composition over inheritance unless an is‑a relationship truly exists.
  • Keep inheritance hierarchies shallow.
  • Use @Override consistently.
  • Use protected only when subclasses genuinely need direct access.
  • Declare methods final if they should not be overridden.
  • Use abstract classes to provide common base behavior with some implementation.

3.13 Key Points to Remember

  • Inheritance is an is‑a relationship (e.g., Dog is an Animal).
  • Java supports single inheritance for classes; multiple inheritance is achieved via interfaces.
  • extends keyword creates a subclass.
  • super accesses superclass members and constructors.
  • Method overriding allows a subclass to provide a specific implementation.
  • Constructors are not inherited; superclass constructors are called first.
  • Every class extends Object implicitly.
  • Abstract classes cannot be instantiated; they may contain abstract methods.
  • Polymorphism allows using a superclass reference to refer to a subclass object.

4. Encapsulation in Java

4.1 What is Encapsulation?

Encapsulation is one of the four fundamental OOP concepts (the others being inheritance, polymorphism, and abstraction). It refers to the bundling of data (fields) and methods (behavior) that operate on that data into a single unit (a class). It also involves hiding the internal state of an object and requiring all interaction to occur through well‑defined interfaces.

In Java, encapsulation is typically achieved by:

  • Declaring fields as private to prevent direct external access.
  • Providing public methods (getters and setters) to access and modify the fields.

The main idea is: the internal representation of an object is hidden from the outside world.


4.2 Why Encapsulation?

Encapsulation provides several important benefits:

  • Data Hiding – The internal state cannot be accessed directly; only controlled methods can change it.
  • Controlled Access – Getters and setters can include validation, logging, or other logic.
  • Flexibility – You can change the internal implementation without affecting code that uses the class (as long as the public interface remains the same).
  • Maintainability – Changes are localized; bugs are easier to isolate.
  • Security – Sensitive data can be protected from accidental or malicious modification.
  • Reusability – Well‑encapsulated classes are easier to reuse in different contexts.

4.3 How to Achieve Encapsulation

The typical pattern:

public class Person { // private fields private String name; private int age; // public constructor public Person(String name, int age) { this.name = name; setAge(age); // use setter for validation } // public getters public String getName() { return name; } public int getAge() { return age; } // public setters with validation public void setName(String name) { if (name != null && !name.trim().isEmpty()) { this.name = name; } else { throw new IllegalArgumentException("Name cannot be empty"); } } public void setAge(int age) { if (age >= 0 && age <= 150) { this.age = age; } else { throw new IllegalArgumentException("Invalid age"); } } }

Now the Person class hides its fields and exposes controlled access.


4.4 Access Modifiers and Their Role

Access modifiers determine the visibility of classes, fields, methods, and constructors. For encapsulation, we use them to restrict access.

ModifierSame ClassSame PackageSubclass (different package)Any Class
private
(default)
protected
public
  • private – The most restrictive. Fields are almost always private. Only methods within the same class can access them.
  • protected – Allows access to subclasses (and same package). Used when you want to give inheriting classes access.
  • default (no modifier) – Package‑private. Accessible only within the same package.
  • public – Accessible everywhere. Use sparingly; typically for the class itself and its public interface (methods).

4.5 Getters and Setters (Accessors and Mutators)

  • Getter – returns the value of a private field. Often named getFieldName() (for non‑boolean) or isFieldName() (for boolean).
  • Setter – modifies the value of a private field, optionally with validation. Often named setFieldName().

Example with boolean:

private boolean active; public boolean isActive() { // not getActive() return active; } public void setActive(boolean active) { this.active = active; }

Validation inside setters:

public void setSalary(double salary) { if (salary >= 0) { this.salary = salary; } else { throw new IllegalArgumentException("Salary cannot be negative"); } }

Logic inside getters:

public String getFullName() { return firstName + " " + lastName; // derived from private fields }

4.6 Immutability as a Form of Encapsulation

An immutable class is one whose state cannot be changed after construction. This is the ultimate form of encapsulation because the object is read‑only. To create an immutable class:

  1. Declare all fields private and final.
  2. Do not provide setters.
  3. Do not allow subclasses (make the class final or use private constructors with factory methods).
  4. If fields are mutable (e.g., collections), return defensive copies or unmodifiable views.

Example:

public final class ImmutablePoint { private final int x; private final int y; public ImmutablePoint(int x, int y) { this.x = x; this.y = y; } public int getX() { return x; } public int getY() { return y; } }

4.7 Protecting Mutable Fields

When a field is a reference to a mutable object (e.g., an array or a Date), simply returning that reference breaks encapsulation because the caller can modify the object.

Solution: Return a defensive copy or an unmodifiable view.

Example with array:

private int[] scores; public int[] getScores() { return scores.clone(); // defensive copy } public void setScores(int[] scores) { this.scores = scores.clone(); // also copy on input }

Example with List:

private List<String> names; public List<String> getNames() { return Collections.unmodifiableList(names); // read‑only view } public void addName(String name) { names.add(name); // controlled modification }

4.8 Complete Example

import java.util.*; public class BankAccount { // private fields private String accountNumber; private double balance; private List<String> transactionHistory; // constructor public BankAccount(String accountNumber, double initialDeposit) { this.accountNumber = accountNumber; this.balance = initialDeposit; this.transactionHistory = new ArrayList<>(); addTransaction("Account opened with " + initialDeposit); } // getters (no setter for accountNumber – immutable) public String getAccountNumber() { return accountNumber; } public double getBalance() { return balance; } // controlled method to modify balance public void deposit(double amount) { if (amount <= 0) { throw new IllegalArgumentException("Deposit amount must be positive"); } balance += amount; addTransaction("Deposited " + amount); } public void withdraw(double amount) { if (amount <= 0) { throw new IllegalArgumentException("Withdrawal amount must be positive"); } if (amount > balance) { throw new IllegalArgumentException("Insufficient funds"); } balance -= amount; addTransaction("Withdrew " + amount); } // private helper – not exposed private void addTransaction(String description) { transactionHistory.add(description); } // expose immutable view of transaction history public List<String> getTransactionHistory() { return Collections.unmodifiableList(transactionHistory); } // no setter for balance – only deposit/withdraw methods }

Usage:

BankAccount acc = new BankAccount("12345", 1000.0); acc.deposit(500); acc.withdraw(200); System.out.println(acc.getBalance()); // 1300.0 System.out.println(acc.getTransactionHistory()); // read‑only list

4.9 Common Pitfalls

PitfallExplanation / Solution
Exposing mutable objects directlyReturn defensive copies or unmodifiable wrappers.
Providing unnecessary settersNot all fields need setters; some may be immutable after construction.
Setters without validationCan lead to invalid object states. Always validate in setters.
Using public fieldsBreaks encapsulation entirely; avoid.
Returning this in methods that modify stateAllows method chaining but can expose mutable state; ensure you're okay with that.
Leaking references in constructorsStoring a reference to an externally mutable object (e.g., a passed array) without copying.
Not making fields privateEven protected fields can be accessed by subclasses, breaking encapsulation. Use private and protected getters/setters if needed.

4.10 Best Practices

  • Declare fields private – always, unless you have a very good reason not to.
  • Provide getters only where needed – don't expose data unnecessarily.
  • Provide setters only when modification should be allowed – and include validation.
  • Use defensive copying for mutable objects passed in or returned.
  • Consider immutability – if a class is meant to be read‑only, make it immutable.
  • Use package‑private or protected access only when designing for extension within the same package.
  • Keep the public interface minimal – hide implementation details.

4.11 Key Points to Remember

  • Encapsulation bundles data with methods and hides internal state.
  • Achieved by private fields and public methods (getters/setters).
  • Provides data hiding, controlled access, and maintainability.
  • Setters can include validation to ensure object integrity.
  • Getters should return copies or unmodifiable views for mutable fields.
  • Immutable classes (all fields final, no setters) are a strong form of encapsulation.
  • Encapsulation is not just about hiding fields; it's about designing a clear contract between a class and its clients.

Encapsulation is the foundation of modular, robust software. By carefully controlling access to internal state, you create classes that are easier to use, test, and evolve.


5. Polymorphism in Java

5.1 What is Polymorphism?

Polymorphism (from Greek poly = many, morph = form) is the ability of an object to take on many forms. In Java, polymorphism allows a single interface (method name, class reference) to be used for different underlying implementations. It is one of the four pillars of object‑oriented programming (along with encapsulation, inheritance, and abstraction).

Polymorphism enables:

  • Code reusability – write once, use with many types.
  • Flexibility – easily extend systems with new classes.
  • Maintainability – changes in one part do not break other parts.

Java supports two types of polymorphism:

  1. Compile‑time polymorphism (method overloading)
  2. Runtime polymorphism (method overriding)

5.2 Compile‑Time Polymorphism (Method Overloading)

Method overloading allows a class to have multiple methods with the same name but different parameter lists (number, type, or order of parameters). The appropriate method is determined at compile time based on the arguments passed.

a) Rules for Overloading

  • Method name must be the same.
  • Parameter list must differ (number, type, or order).
  • Return type may be different, but it alone is not sufficient to distinguish overloaded methods.
  • Access modifiers can be different.
  • Can occur in the same class or in a subclass (but then it's not overriding; it's a separate overload).

b) Examples

class Calculator { // overloaded methods int add(int a, int b) { return a + b; } double add(double a, double b) { return a + b; } int add(int a, int b, int c) { return a + b + c; } }

c) Varargs and Overloading

Varargs can cause ambiguity if not careful.

void print(int... nums) { } // void print(int a, int... nums) { } // Can be problematic depending on call

d) Return Type Not Considered

int get() { return 1; } // double get() { return 1.0; } // Compilation error – duplicate method

5.3 Runtime Polymorphism (Method Overriding)

Method overriding allows a subclass to provide a specific implementation of a method already defined in its superclass. The correct method is chosen at runtime based on the actual object type, not the reference type.

a) Rules for Overriding

  • Method signature (name + parameter list) must be exactly the same.
  • Return type must be the same or a covariant subtype (Java 5+).
  • Access modifier cannot be more restrictive than the superclass method.
  • final methods cannot be overridden.
  • static methods are hidden, not overridden.
  • abstract methods must be overridden unless the subclass is also abstract.
  • Use @Override annotation to let the compiler verify.

b) Example

class Animal { void sound() { System.out.println("Animal makes a sound"); } } class Dog extends Animal { @Override void sound() { System.out.println("Dog barks"); } } class Cat extends Animal { @Override void sound() { System.out.println("Cat meows"); } }

c) Covariant Return Types

A subclass can return a subtype of the superclass method's return type.

class Super { Number getValue() { return 0; } } class Sub extends Super { @Override Integer getValue() { return 10; } // Integer is a subclass of Number }

d) Access Modifiers in Overriding

  • Cannot reduce visibility: publicpublic only; protectedpublic or protected.
  • Can increase visibility: protectedpublic is allowed.

5.4 Polymorphism with Interfaces and Abstract Classes

Both abstract classes and interfaces enable polymorphic references.

Example with interface:

interface Drawable { void draw(); } class Circle implements Drawable { public void draw() { System.out.println("Circle"); } } class Square implements Drawable { public void draw() { System.out.println("Square"); } } public class Main { public static void main(String[] args) { Drawable d1 = new Circle(); Drawable d2 = new Square(); d1.draw(); // Circle d2.draw(); // Square } }

Example with abstract class:

abstract class Vehicle { abstract void start(); } class Car extends Vehicle { void start() { System.out.println("Car starts"); } }

5.5 Polymorphic References and Casting

A reference variable of a superclass can refer to an object of any subclass. This is a polymorphic reference.

Animal a = new Dog(); // upcasting – implicit a.sound(); // Dog's sound (runtime)

To call methods specific to the subclass, you need downcasting (explicit cast).

if (a instanceof Dog) { Dog d = (Dog) a; d.bark(); // method only in Dog }

instanceof is used to check the actual type before downcasting to avoid ClassCastException.


5.6 Dynamic Method Dispatch

Dynamic method dispatch is the mechanism by which Java decides at runtime which overridden method to execute based on the actual object type, not the reference type.

Animal animal; animal = new Dog(); animal.sound(); // Dog's sound animal = new Cat(); animal.sound(); // Cat's sound

The decision is made by the JVM at runtime, enabling polymorphic behavior.


5.7 Benefits of Polymorphism

  • Flexibility – Write code that works with the superclass type; it automatically works with any subclass.
  • Extensibility – New subclasses can be added without modifying existing code (Open/Closed Principle).
  • Code reuse – Common logic can be placed in superclass, specialized in subclasses.
  • Simplified API – A single method name can handle different types (e.g., draw() for shapes).

5.8 Method Overloading vs. Method Overriding

FeatureOverloadingOverriding
PurposeSame name, different parametersRedefine inherited method
BindingCompile‑time (static)Runtime (dynamic)
Occurs inSame class or subclass (different signature)Subclass only (same signature)
Return typeMay differ, but not used for resolutionMust be same or covariant
Access modifierCan be differentCannot be more restrictive
final methodCan be overloadedCannot be overridden
static methodCan be overloadedCannot be overridden (hidden)
private methodCan be overloadedCannot be overridden (not inherited)

5.9 Common Pitfalls and Best Practices

PitfallExplanation / Solution
Calling overridden method in constructorThe overridden method may be called before subclass fields are initialized. Avoid calling overridable methods in constructors.
Forgetting @OverrideMistakenly creating a new method instead of overriding. Use the annotation.
Confusing overloading with overridingOverloading changes parameters; overriding keeps the same signature.
Using instanceof excessivelyOveruse may indicate poor design; often polymorphism should handle the logic.
Downcasting without checkingCan cause ClassCastException. Always use instanceof (or patten matching for instanceof in newer Java).
Returning mutable objects in overridden methodsBreaks encapsulation; return defensive copies or immutable views.

Best Practices:

  • Favor polymorphism over instanceof and downcasting.
  • Use @Override for clarity and safety.
  • Keep method signatures stable when designing for extension.
  • Prefer interfaces for polymorphic behavior to allow multiple inheritance of type.
  • Design with the Liskov Substitution Principle – subclasses should be substitutable for their superclasses without altering the correctness of the program.

5.10 Complete Example

import java.util.*; // Abstract class demonstrating polymorphism abstract class Payment { protected double amount; public Payment(double amount) { this.amount = amount; } public abstract void process(); // polymorphic method } class CreditCardPayment extends Payment { private String cardNumber; public CreditCardPayment(double amount, String cardNumber) { super(amount); this.cardNumber = cardNumber; } @Override public void process() { System.out.println("Processing credit card payment of $" + amount + " using card ending with " + cardNumber.substring(cardNumber.length() - 4)); } } class PayPalPayment extends Payment { private String email; public PayPalPayment(double amount, String email) { super(amount); this.email = email; } @Override public void process() { System.out.println("Processing PayPal payment of $" + amount + " from account " + email); } } class CashPayment extends Payment { public CashPayment(double amount) { super(amount); } @Override public void process() { System.out.println("Processing cash payment of $" + amount); } } public class PolymorphismDemo { public static void main(String[] args) { // Polymorphic list List<Payment> payments = new ArrayList<>(); payments.add(new CreditCardPayment(100.50, "1234-5678-9012-3456")); payments.add(new PayPalPayment(75.25, "user@example.com")); payments.add(new CashPayment(50.00)); // Runtime polymorphism: each object's own process() is called for (Payment p : payments) { p.process(); } // Method overloading example Calculator calc = new Calculator(); System.out.println(calc.add(5, 10)); // int overload System.out.println(calc.add(2.5, 3.7)); // double overload System.out.println(calc.add(1, 2, 3)); // three‑int overload } } class Calculator { // overloaded methods int add(int a, int b) { return a + b; } double add(double a, double b) { return a + b; } int add(int a, int b, int c) { return a + b + c; } }

Output:

Processing credit card payment of $100.5 using card ending with 3456 Processing PayPal payment of $75.25 from account user@example.com Processing cash payment of $50.0 15 6.2 6

5.11 Key Points to Remember

  • Polymorphism means “many forms” – a single interface can represent different actual implementations.
  • Compile‑time polymorphism = method overloading – same method name, different parameters.
  • Runtime polymorphism = method overriding – subclass provides specific implementation.
  • Overriding uses dynamic method dispatch: the method of the actual object is called, not the reference type.
  • @Override annotation helps prevent mistakes.
  • Covariant return types allow a subclass to return a more specific type.
  • Polymorphic references (superclass type, subclass object) are the foundation of polymorphic code.
  • Use instanceof sparingly; prefer polymorphic method calls.
  • Polymorphism promotes loose coupling, extensibility, and maintainability.

Polymorphism is a powerful tool for writing flexible, reusable code. When combined with inheritance and abstraction, it forms the basis of many design patterns and robust software architectures.


6. Abstraction in Java

6.1 What is Abstraction?

Abstraction is the process of hiding implementation details and exposing only essential features to the user. In object‑oriented programming, abstraction allows you to focus on what an object does rather than how it does it.

Java provides two main ways to achieve abstraction:

  1. Abstract classes – classes that cannot be instantiated and may contain abstract methods (methods without a body).
  2. Interfaces – contracts that specify a set of methods that implementing classes must provide.

Abstraction helps reduce complexity, increase code reusability, and create well‑defined contracts between different parts of a program.


6.2 Abstract Classes

a) Definition

An abstract class is a class declared with the abstract keyword. It can contain:

  • Abstract methods – declared without an implementation (only signature).
  • Concrete methods – fully implemented methods.
  • Fields (instance variables), constructors, static methods, etc.

b) Syntax

abstract class ClassName { // fields, constructors, concrete methods abstract void methodName(); // no body }

c) Rules for Abstract Classes

  • You cannot instantiate an abstract class (no new).
  • If a class contains at least one abstract method, it must be declared abstract.
  • A subclass of an abstract class must implement all inherited abstract methods, unless it is also abstract.
  • Abstract classes can have constructors (called when a concrete subclass is instantiated).
  • Abstract classes can have final methods (cannot be overridden) and static methods.

d) Example

abstract class Shape { protected String color; public Shape(String color) { this.color = color; } // abstract method – no implementation public abstract double area(); // concrete method public void display() { System.out.println("This is a " + color + " shape."); } } class Circle extends Shape { private double radius; public Circle(String color, double radius) { super(color); this.radius = radius; } @Override public double area() { return Math.PI * radius * radius; } } class Rectangle extends Shape { private double length, width; public Rectangle(String color, double length, double width) { super(color); this.length = length; this.width = width; } @Override public double area() { return length * width; } } public class Main { public static void main(String[] args) { // Shape s = new Shape("red"); // error – abstract Shape c = new Circle("blue", 5); Shape r = new Rectangle("green", 4, 6); c.display(); // from abstract class System.out.println("Area: " + c.area()); r.display(); System.out.println("Area: " + r.area()); } }

Output:

This is a blue shape. Area: 78.53981633974483 This is a green shape. Area: 24.0

6.3 Interfaces

a) Definition

An interface is a reference type that defines a contract of methods that a class must implement. Prior to Java 8, interfaces contained only public abstract methods and public static final constants. From Java 8 onward, interfaces can also contain:

  • Default methods – methods with a default implementation (using default keyword).
  • Static methods – methods belonging to the interface.
  • Private methods (Java 9+) – to share code between default methods.

b) Syntax

interface InterfaceName { // constants (public static final by default) int CONSTANT = 10; // abstract method (public abstract by default) void methodName(); // default method (Java 8+) default void defaultMethod() { // implementation } // static method (Java 8+) static void staticMethod() { // implementation } }

c) Rules for Interfaces

  • All fields in an interface are implicitly public static final.
  • All methods (unless default, static, or private) are implicitly public abstract.
  • A class implements an interface using the implements keyword.
  • A class can implement multiple interfaces.
  • An interface can extend multiple interfaces (using extends).
  • Interfaces cannot have constructors.

d) Example

interface Drawable { void draw(); // public abstract default void print() { System.out.println("Printing..."); } static void info() { System.out.println("Drawable interface"); } } interface Colorable { void setColor(String color); } class Circle implements Drawable, Colorable { private String color; @Override public void draw() { System.out.println("Drawing circle"); } @Override public void setColor(String color) { this.color = color; System.out.println("Circle color set to " + color); } } public class InterfaceDemo { public static void main(String[] args) { Circle c = new Circle(); c.draw(); c.setColor("red"); c.print(); // default method from Drawable Drawable.info(); // static method called on interface } }

6.4 Abstract Class vs. Interface (Java 8+)

FeatureAbstract ClassInterface (Java 8+)
Keywordabstractinterface
InstantiationCannot be instantiatedCannot be instantiated
Multiple inheritanceA class can extend only one abstract classA class can implement many interfaces
FieldsCan have instance variables (non‑final)All fields are public static final (constants)
ConstructorsCan have constructorsNo constructors
Method typesAbstract, concrete, static, finalAbstract (default), default, static, private
Access modifiersCan have all (private, protected, etc.)Methods are public (except private)
When to useWhen classes share a common base with stateWhen defining a capability or contract

6.5 When to Use Abstract Class vs. Interface

Use an abstract class when:

  • You have a common base with shared fields or methods that need to be inherited.
  • You want to provide a partial implementation that subclasses can extend.
  • You need constructors to initialize common state.
  • The relationship is a clear is‑a hierarchy (e.g., Vehicle → Car, Motorcycle).

Use an interface when:

  • You want to define a contract that unrelated classes can implement (e.g., Serializable, Comparable).
  • You need multiple inheritance of type (a class can implement several interfaces).
  • You want to provide a capability (e.g., Flyable, Swimmable) that can be mixed into various classes.
  • You want to leverage default methods to evolve an API without breaking existing implementations.

6.6 Advanced Interface Features

a) Default Methods (Java 8)

Allow adding new methods to interfaces without breaking existing implementing classes. They can be overridden if needed.

interface Vehicle { default void start() { System.out.println("Vehicle starting..."); } }

b) Static Methods (Java 8)

Belong to the interface, called using the interface name.

interface MathUtils { static int square(int x) { return x * x; } } // Usage: MathUtils.square(5);

c) Private Methods (Java 9)

Used to share common code between default methods, keeping the interface clean.

interface Logger { default void logInfo(String msg) { log("INFO", msg); } default void logError(String msg) { log("ERROR", msg); } private void log(String level, String msg) { System.out.println(level + ": " + msg); } }

d) Functional Interfaces

An interface with exactly one abstract method is a functional interface. It can be used with lambda expressions (Java 8+). The @FunctionalInterface annotation is optional but recommended.

@FunctionalInterface interface Calculator { int calculate(int a, int b); } // Usage: Calculator add = (a, b) -> a + b;

6.7 Complete Example: Abstraction in Action

// Abstract class providing common state and behavior abstract class Employee { protected String name; protected int id; public Employee(String name, int id) { this.name = name; this.id = id; } public abstract double calculateSalary(); // abstract method public void displayInfo() { System.out.println("ID: " + id + ", Name: " + name); } } // Interface for bonus eligibility interface BonusEligible { double calculateBonus(); // implicit public abstract default void printBonus() { System.out.println("Bonus: " + calculateBonus()); } } // Concrete class extending abstract class and implementing interface class Manager extends Employee implements BonusEligible { private double baseSalary; private double bonusRate; public Manager(String name, int id, double baseSalary, double bonusRate) { super(name, id); this.baseSalary = baseSalary; this.bonusRate = bonusRate; } @Override public double calculateSalary() { return baseSalary + calculateBonus(); } @Override public double calculateBonus() { return baseSalary * bonusRate; } } // Another concrete class (no bonus) class Developer extends Employee { private double hourlyRate; private int hoursWorked; public Developer(String name, int id, double hourlyRate, int hoursWorked) { super(name, id); this.hourlyRate = hourlyRate; this.hoursWorked = hoursWorked; } @Override public double calculateSalary() { return hourlyRate * hoursWorked; } } public class AbstractionDemo { public static void main(String[] args) { Employee e1 = new Manager("Alice", 101, 50000, 0.2); Employee e2 = new Developer("Bob", 102, 50, 160); e1.displayInfo(); System.out.println("Salary: " + e1.calculateSalary()); if (e1 instanceof BonusEligible) { ((BonusEligible) e1).printBonus(); } e2.displayInfo(); System.out.println("Salary: " + e2.calculateSalary()); } }

Output:

ID: 101, Name: Alice Salary: 60000.0 Bonus: 10000.0 ID: 102, Name: Bob Salary: 8000.0

6.8 Common Pitfalls and Best Practices

PitfallExplanation / Solution
Using abstract classes when interfaces sufficeLeads to tight coupling and prevents multiple inheritance. Prefer interfaces for capabilities.
Adding abstract methods without considering backward compatibilityBreaks existing subclasses. Use default methods in interfaces to evolve APIs.
Misusing default methodsDefault methods can cause diamond problem if multiple interfaces provide the same default method. The implementing class must override.
Making everything abstractNot all code needs abstraction; over‑abstraction increases complexity.
Forgetting that abstract classes can have constructorsSubclass constructors must call super() to initialize abstract class fields.
Using instanceof to check interface implementationPolymorphism should handle most cases; use instanceof sparingly.

Best Practices:

  • Prefer interfaces to define contracts; use abstract classes only when you need to share code or state.
  • Keep interfaces small and focused (Interface Segregation Principle).
  • Use default methods carefully; they should provide a reasonable default that most implementations can use.
  • Annotate functional interfaces with @FunctionalInterface to enforce single abstract method.
  • Favor composition over inheritance; use abstraction to define clear boundaries.

6.9 Key Points to Remember

  • Abstraction hides implementation details and exposes only the essential behavior.
  • Abstract classes – cannot be instantiated; can have fields, constructors, and concrete methods.
  • Interfaces – define a contract; from Java 8 onward, can have default, static, and private methods.
  • A class can extend only one abstract class but implement multiple interfaces.
  • Use abstract classes when classes share a common base with state.
  • Use interfaces to define capabilities or to achieve multiple inheritance of type.
  • Abstraction enables loose coupling, testability, and maintainability.

Abstraction is a fundamental tool for designing clean, modular Java applications. Mastering abstract classes and interfaces will help you write code that is both flexible and easy to evolve.


Hi! Need help with studies? 👋
AI