cd ..

Complete Guide to Object-Oriented Programming

Learn Java essentials with this guide on OOP, threading, file handling, and exception management, complete with examples and best practices.

Sun 12 Jan, 2025 • Java

What is Java?

Java is a widely-used programming language known for its platform independence, scalability, and robust ecosystem. This guide starts with the basics and builds up to advanced topics, ensuring you have a clear understanding of the core concepts.

  • Platform-Independent: "Write once, run anywhere" (WORA) principle.
  • Object-Oriented: Focuses on using objects to model real-world entities.
  • High-Level and Secure: Abstracts complex system operations while ensuring security.

Object-Oriented Programming Fundamentals

What is a Class?

A class is like a blueprint that defines what properties (attributes) and behaviors (methods) something should have. An object is an actual instance created from that blueprint.

public class Student {
  // Properties (what a student has)
  private String name;
  private int grade;
 
  // Constructor (how we create a student)
  public Student(String name, int grade) {
    this.name = name;
    this.grade = grade;
  }
 
  // Method (what a student can do)
  public void study() {
    System.out.print("%s is studying!", name);
  }
}
  • The Student class has two properties: name and grade
  • private means these properties can't be accessed directly from outside the class
  • The constructor is a special method that sets up a new student
  • The study() method represents an action a student can take

Static vs. Non-Static Classes

What is a Static Class?

A static class is a class that is declared using the static keyword. In Java, the concept of a truly static class does not exist for top-level classes, but inner classes can be declared as static. A static inner class belongs to the outer class, but it does not require an instance of the outer class to be instantiated.

Key characteristics of static classes include:

  • They can contain only static methods and variables.
  • They are used when functionality or data is not tied to an instance of a class.
  • They cannot directly access non-static members of the enclosing class.

What is a Non-Static Class?

A non-static class is the default type of class in Java. It requires an instance of the class to access its members. Non-static classes can access both static and non-static members of the enclosing class and are generally used to represent entities that have unique instances.

Key characteristics of non-static classes include:

  • They can contain both static and non-static methods and variables.
  • They are instantiated using the new keyword.
  • They can directly access all members (static and non-static) of the enclosing class.

What is an Object?

An object is a specific instance of a class, created using the new keyword. It represents a real-world entity.

// Create two students
Student alice = new Student("Alice", 95);
Student bob = new Student("Bob", 87);
 
// Make them study
alice.study();  // Outputs: Alice is studying!
bob.study();    // Outputs: Bob is studying!

Access Modifiers

Access modifiers determine the visibility or accessibility of classes, methods, and variables in Java. They control which parts of your program can use or modify certain elements.

Types of Access Modifiers

  1. public

    • When a class, method, or variable is declared as public, it can be accessed from anywhere in your program.
public class Example {
  public String message = "This is public!";
}
  1. private

    • When a method or variable is declared as private, it can only be accessed within the same class. It is the most restrictive access level.
public class Example {
  private String password = "12345";
 
  public void showPassword() {
    System.out.println(password);
  }
}
  1. protected

    • When a method or variable is declared as protected, it can be accessed within the same package or by subclasses in other packages.
public class Parent {
  protected String secret = "This is protected.";
}
 
class Child extends Parent {
  public void showSecret() {
    System.out.println(secret);
  }
}
  1. default (no keyword)

    • When no access modifier is specified, the element is accessible only within the same package. This is known as package-private visibility.
class Example {
  String message = "Default access modifier.";
}

Object-Oriented Programming Concepts

1. Encapsulation

Encapsulation hides the internal state of an object and allows controlled access via methods. It ensures better control over data and enhances security.

  • Fields (attributes) are declared as private.
  • Public methods (getters and setters) control how these fields are accessed or modified.
public class BankAccount {
  private double balance; // Private field
 
  // Public getter
  public double getBalance() {
    return balance;
  }
 
  // Public setter
  public void deposit(double amount) {
    balance = balance + amount;
  }
}

Benefits of Encapsulation:

  • Data Security: Protects sensitive data from unintended modifications.
  • Improved Maintenance: Internal changes to a class don't affect external code.
  • Controlled Access: Ensures only valid data is entered using validation in setters.

2. Inheritance

Inheritance allows a class (child) to inherit methods and properties from another class (parent). It promotes code reuse and logical hierarchy.

  • Use the extends keyword to establish a parent-child relationship.
public class Vehicle {
  protected String brand;
 
  public void honk() {
    System.out.println("Beep!");
  }
}
 
public class Car extends Vehicle {
  public void startEngine() {
    System.out.printf("%s engine starting!", brand);
  }
}

Benefits of Inheritance:

  • Code Reusability: Avoids duplicating code by reusing parent class methods and attributes.
  • Improved Organization: Establishes a clear hierarchy (e.g., parent and child classes).
  • Scalability: New functionalities can be easily added by extending classes.

3. Polymorphism

Polymorphism allows methods to perform different tasks depending on the object or the context in which they are invoked.

  1. Compile-time Polymorphism: Achieved using method overloading (same method name, different parameters).
  2. Runtime Polymorphism: Achieved using method overriding (child class redefines a parent method).

Method Overloading

public class Calculator {
  public int add(int a, int b) {
    return a + b;
  }
 
  public double add(double a, double b) {
    return a + b;
  }
}

Method Overriding

class Animal {
  public void makeSound() {
    System.out.println("Some generic sound");
  }
}
 
class Dog extends Animal {
  @Override
  public void makeSound() {
    System.out.println("Woof!");
  }
}

Benefits of Polymorphism:

  • Code Flexibility: Methods behave differently based on the object.
  • Extensibility: Easily add new functionalities by overriding or overloading methods.
  • Improved Readability: Simplifies how methods are invoked.

4. Abstraction

Abstraction hides complex implementation details and shows only the essential features, focusing on "what" rather than "how."

  • Use abstract classes or interfaces to define methods that subclasses must implement.

What is super?

The super keyword refers to the parent class (also called the superclass) of the current object. It is used to:

  1. Call the constructor of the parent class.
  2. Access the methods or fields of the parent class.

What is @Override?

The @Override annotation is used to indicate that a method in a child class is overriding a method in the parent class.

Why Use @Override?

  • Clarity: Makes it clear that the method is being overridden.
  • Compile-Time Check: The compiler checks that the parent class method exists and has the same signature. This prevents accidental errors (e.g., incorrect method names).

Abstract Class

An abstract class is a class that cannot be instantiated and may contain abstract (unimplemented) and concrete (implemented) methods.

  • Can have both abstract and non-abstract methods.
  • Can define fields (variables) and provide implementations for some methods.
  • Supports constructors.
  • Allows protected and private access modifiers for methods and fields.
  • Supports single inheritance (a class can extend only one abstract class).
abstract class Animal {
  protected String name;
 
  public Animal(String name) {
    this.name = name;
  }
 
  // Abstract method (to be implemented by subclasses)
  abstract void makeSound();
 
  // Concrete method
  public void eat() {
    System.out.printf("%s is eating.", name);
  }
}
 
class Dog extends Animal {
  public Dog(String name) {
    super(name);
  }
 
  @Override
  void makeSound() {
    System.out.printf("%s says: Woof!", name);
  }
}
 
public class Main {
  public static void main(String[] args) {
    Dog myDog = new Dog("Buddy");
    myDog.makeSound();    // Buddy says: Woof!
    myDog.eat();          // Buddy is eating.
  }
}

Interface

An interface is a completely abstract type that defines a contract or a set of methods a class must implement.

  • Can only have abstract methods (prior to Java 8). From Java 8, default and static methods are allowed.
  • Does not contain constructors (cannot initialize fields).
  • Fields are implicitly public, static, and final.
  • Supports multiple inheritance (a class can implement multiple interfaces).
interface AnimalActions {
  void makeSound();   // Abstract method
  void sleep();       // Abstract method
}
 
class Bird implements AnimalActions {
  @Override
  public void makeSound() {
    System.out.println("Bird says: Tweet!");
  }
 
  @Override
  public void sleep() {
    System.out.println("Bird is sleeping.");
  }
}
 
public class Main {
  public static void main(String[] args) {
    AnimalActions myBird = new Bird();
    myBird.makeSound();   // Bird says: Tweet!
    myBird.sleep();       // Bird is sleeping.
  }
}

When to Use Abstract Classes vs Interfaces

Use Abstract Class When:

  • You want to provide common functionality that all subclasses share.
  • You need to define fields or methods with any access modifier (e.g., protected).
  • You want to enforce a strict hierarchical relationship (parent-child).

Use Interface When:

  • You want to define a contract for unrelated classes to follow (e.g., Comparable or Runnable).
  • You need multiple inheritance, as a class can implement multiple interfaces.
  • You want to define default behavior for some methods (Java 8+).
Combining Abstract Class and Interface
abstract class Vehicle {
  protected String brand;
 
  public Vehicle(String brand) {
    this.brand = brand;
  }
 
  public void start() {
    System.out.printf("%s is starting.", brand);
  }
 
  abstract void move();
}
 
interface Electric {
  void chargeBattery();
}
 
class ElectricCar extends Vehicle implements Electric {
  public ElectricCar(String brand) {
    super(brand);
  }
 
  @Override
  void move() {
    System.out.printf("%s is moving silently.", brand);
  }
 
  @Override
  public void chargeBattery() {
    System.out.printf("%s is charging its battery.", brand);
  }
}
 
public class Main {
  public static void main(String[] args) {
    ElectricCar tesla = new ElectricCar("Tesla");
    tesla.start();           // Tesla is starting.
    tesla.move();            // Tesla is moving silently.
    tesla.chargeBattery();   // Tesla is charging its battery.
  }
}

Benefits of Abstraction:

  • Simplifies Design: Focuses on essential features without worrying about implementation details.
  • Encourages Modularity: Breaks down complex problems into simpler, smaller components.
  • Enhances Security: Hides sensitive details from external users.

Singleton Design Pattern

The Singleton design pattern ensures that a class has only one instance and provides a global point of access to it. This is commonly used in scenarios like logging, database connections, or configuration management.

Key Features of Singleton Pattern:

  1. Private Constructor: Ensures that no other class can directly instantiate the Singleton class.
  2. Static Instance: Holds the single instance of the class.
  3. Public Static Method: Provides a way to access the instance.
public class Singleton {
  // Static instance of the class
  private static Singleton instance;
 
  // Private constructor to prevent instantiation
  private Singleton() {}
 
  // Public method to provide access to the instance
  public static Singleton getInstance() {
    if (instance == null) {
      instance = new Singleton();
    }
    return instance;
  }
 
  public void showMessage() {
    System.out.println("Singleton Instance: Hello, World!");
  }
}
 
public class Main {
  public static void main(String[] args) {
    // Access the Singleton instance
    Singleton singleton = Singleton.getInstance();
 
    // Call a method on the Singleton instance
    singleton.showMessage();
  }
}

Benefits of Singleton:

  1. Controlled Access: Ensures only one instance exists.
  2. Memory Optimization: Saves memory by not creating multiple instances.
  3. Global Access: Provides a single access point to the instance.

Exception Handling

Exception handling in Java allows developers to manage runtime errors in a controlled way, ensuring the program continues to operate without crashing unexpectedly. Exceptions are objects that represent an error or an unusual condition.

public class ExceptionHandling {
  public static void main(String[] args) {
    try {
      int result = 10 / 0; // This will throw ArithmeticException
    } catch (ArithmeticException e) {
      System.out.println("Error: Cannot divide by zero.");
    } finally {
      System.out.println("Execution complete.");
    }
  }
}

Custom Exceptions

You can create your own exceptions by extending the Exception or RuntimeException class.

class InsufficientBalanceException extends Exception {
  public InsufficientBalanceException(String message) {
    super(message);
  }
}
 
public class CustomException {
  public static void withdraw(double amount, double balance) throws InsufficientBalanceException {
    if (amount > balance) {
      throw new InsufficientBalanceException("Insufficient balance for withdrawal.");
    } else {
      System.out.printf("Withdrawal successful. Remaining balance: %d", (balance - amount));
    }
  }
 
  public static void main(String[] args) {
    double balance = 500.0;
 
    try {
      withdraw(600.0, balance); // This will throw the exception
    } catch (InsufficientBalanceException e) {
      System.out.printf("Exception caught: %s", e.getMessage());
    }
  }
}

File Handling

File handling in Java allows programs to read, write, create, and manipulate files on the file system. Java provides the java.io and java.nio packages for file handling, offering simple and advanced functionality.

Types of File Handling Operations

  1. Create a File: Create a new file if it does not exist.
  2. Write to a File: Add content to a file.
  3. Read from a File: Retrieve data from a file.
  4. Delete a File: Remove a file from the file system.

Classes Used in File Handling

  1. File: Represents file and directory pathnames.
  2. FileWriter and BufferedWriter: Used for writing text to files.
  3. FileReader and BufferedReader: Used for reading text from files.
  4. Scanner: Also used for reading files line by line.
  5. PrintWriter: For writing formatted text to files.

1. Creating a File

Use the File class to create a new file. The createNewFile() method returns true if the file was created successfully.

import java.io.File;
import java.io.IOException;
 
public class CreateFileExample {
  public static void main(String[] args) {
    File file = new File("example.txt");
 
    try {
      if (file.createNewFile()) {
        System.out.printf("File created: %s", file.getName());
      } else {
        System.out.println("File already exists.");
      }
    } catch (IOException e) {
      System.out.println("An error occurred.");
      e.printStackTrace();
    }
  }
}

2. Writing to a File

Use the FileWriter class to write data to a file. You can append to a file by setting FileWriter to append mode.

import java.io.FileWriter;
import java.io.IOException;
 
public class WriteToFileExample {
  public static void main(String[] args) {
    try {
      FileWriter writer = new FileWriter("example.txt");
      writer.write("Hello, World!\n");
      writer.write("File handling in Java is easy!");
      writer.close();
      System.out.println("Successfully wrote to the file.");
    } catch (IOException e) {
      System.out.println("An error occurred.");
      e.printStackTrace();
    }
  }
}

3. Reading from a File

You can read files using:

  1. FileReader and BufferedReader for line-by-line reading.
  2. Scanner for more flexibility (e.g., reading tokens).

Using BufferedReader

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
 
public class ReadFileExample {
  public static void main(String[] args) {
    try (BufferedReader reader = new BufferedReader(new FileReader("example.txt"))) {
      String line;
      while ((line = reader.readLine()) != null) {
        System.out.println(line);
      }
    } catch (IOException e) {
      System.out.println("An error occurred.");
      e.printStackTrace();
    }
  }
}

Using Scanner

import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;
 
public class ReadWithScanner {
  public static void main(String[] args) {
    try {
      File file = new File("example.txt");
      Scanner scanner = new Scanner(file);
 
      while (scanner.hasNextLine()) {
        String line = scanner.nextLine();
        System.out.println(line);
      }
 
      scanner.close();
    } catch (FileNotFoundException e) {
      System.out.println("File not found.");
      e.printStackTrace();
    }
  }
}

4. Deleting a File

The delete() method of the File class is used to delete a file.

import java.io.File;
 
public class DeleteFileExample {
  public static void main(String[] args) {
    File file = new File("example.txt");
 
    if (file.delete()) {
      System.out.printf("File deleted: %s", file.getName());
    } else {
      System.out.println("Failed to delete the file.");
    }
  }
}

Error Handling in File Operations

File operations can throw exceptions like:

  • IOException: When file operations fail (e.g., write failure).
  • FileNotFoundException: When trying to read a file that doesn’t exist.

Always use try-catch blocks or throws declarations to handle these errors gracefully.

Threading

Threading is a technique in Java that allows multiple parts of a program (threads) to run simultaneously. This is especially useful for performing tasks concurrently, such as downloading files while processing user input.

What is a Thread?

  • A thread is the smallest unit of execution in a program.
  • Every Java application starts with a main thread that runs the main() method.
  • Multiple threads can be created to perform tasks concurrently.

Thread States

A thread in Java can be in one of the following six states as defined by the Thread.State enum:

  1. NEW

    • The thread has been created but has not yet started.
    • A thread is in this state immediately after the Thread object is instantiated, and before the start() method is called.
  2. RUNNABLE

    • The thread is either running or ready to run whenever it gets CPU time.

    • This state includes what some operating systems may call “ready” and “running” states.

  3. BLOCKED

    • The thread is blocked, waiting for a monitor lock (e.g., attempting to enter a synchronized block or method) that another thread is currently holding.
  4. WAITING

    • The thread is waiting indefinitely for another thread to perform a particular action (e.g., a call to wait() without a timeout).
    • This usually happens when a thread calls Object.wait() and is waiting for a notify() or notifyAll() on the same object.
  5. TIMED_WAITING

    • The thread is waiting for another thread to perform an action for a specified waiting time (e.g., Thread.sleep(long millis), Object.wait(long timeout), Thread.join(long millis)).
    • After this time elapses, or if the event it’s waiting for occurs, the thread transitions back to RUNNABLE.
  6. TERMINATED (or DEAD)

    • The thread has completed its execution, either by returning from the run() method or by throwing an uncaught exception.

You can retrieve a thread’s current state by calling the getState() method on a Thread object.

Creating Threads

Creating Threads in Java

  1. Extending the Thread class
  2. Implementing the Runnable interface

1. Extending the Thread Class

In this approach, you create a new class that extends Thread and override its run() method.

class SomeThread extends Thread {
  @Override
  public void run() {
    System.out.printf("Thread is running: %s", Thread.currentThread().getName());
  }
}
 
public class Example {
  public static void main(String[] args) {
    SomeThread thread1 = new SomeThread();
    SomeThread thread2 = new SomeThread();
 
    thread1.start(); // Starts thread1
    thread2.start(); // Starts thread2
  }
}

2. Implementing the Runnable Interface

This is a more flexible approach because the class implementing Runnable can extend another class.

class Task implements Runnable {
  @Override
  public void run() {
    System.out.printf("Thread is running: %s", Thread.currentThread().getName());
  }
}
 
public class Example {
  public static void main(String[] args) {
    Thread thread1 = new Thread(new Task());
    Thread thread2 = new Thread(new Task());
 
    thread1.start(); // Starts thread1
    thread2.start(); // Starts thread2
  }
}

Thread Synchronization

When multiple threads access shared resources (e.g., variables or files), synchronization ensures that only one thread can access the resource at a time to prevent data inconsistency.

class Counter {
  private int count = 0;
 
  public synchronized void increment() {
    count = count + 1;
  }
 
  public int getCount() {
    return count;
  }
}
 
public class SynchronizedExample {
  public static void main(String[] args) throws InterruptedException {
    Counter counter = new Counter();
 
    Thread thread1 = new Thread(() -> {
      for (int i = 0; i < 1000; i++) counter.increment();
    });
 
    Thread thread2 = new Thread(() -> {
      for (int i = 0; i < 1000; i++) counter.increment();
    });
 
    thread1.start();
    thread2.start();
 
    thread1.join();
    thread2.join();
 
    System.out.printf("Final count: %d", counter.getCount());
  }
}

Inter-Thread Communication

Java provides methods like wait(), notify(), and notifyAll() to allow threads to communicate with each other and coordinate their work. All these methods are defined in the Object class (not Thread), and must be called inside a synchronized context.

  1. wait()

    • Causes the current thread to release the lock it holds on the object’s monitor and go into the WAITING state.
    • The thread remains in this state until another thread calls notify() or notifyAll() on the same object (or until it is interrupted).
  2. notify()

    • Wakes up one thread that is waiting on the same object’s monitor.
    • If more than one thread is waiting, the one chosen is not specified (i.e., it’s up to the JVM’s thread scheduler).
  3. notifyAll()

    • Wakes up all threads that are waiting on the same object’s monitor.
    • The awakened threads will then compete to reacquire the lock.
import java.util.LinkedList;
 
class ProducerConsumer {
  private LinkedList<Integer> queue = new LinkedList<>();
  private final int CAPACITY = 5;
 
  public synchronized void produce() throws InterruptedException {
    int value = 0;
    while (true) {
      while (queue.size() == CAPACITY) {
        wait(); // Wait if the queue is full
      }
      queue.add(value);
      System.out.printf("Produced: %d", value);
      value = value + 1;
      notifyAll(); // Notify the consumer thread
      Thread.sleep(1000);
    }
  }
 
  public synchronized void consume() throws InterruptedException {
    while (true) {
      while (queue.isEmpty()) {
        wait(); // Wait if the queue is empty
      }
      int value = queue.removeFirst();
      System.out.printf("Consumed: %d", value);
      notifyAll(); // Notify the producer thread
      Thread.sleep(1000);
    }
  }
}
 
public class ProducerConsumerExample {
  public static void main(String[] args) {
    ProducerConsumer pc = new ProducerConsumer();
 
    Thread producer = new Thread(() -> {
      try {
        pc.produce();
      } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
      }
    });
 
    Thread consumer = new Thread(() -> {
      try {
        pc.consume();
      } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
      }
    });
 
    producer.start();
    consumer.start();
  }
}

Other Thread Methods

  1. Thread.sleep(long millis)

    • Pauses the current thread for the specified time (milliseconds).
    • The thread is placed in the TIMED_WAITING state.
    • Does not release any locks held by the thread.
  2. join()

    • A calling thread will wait until the target thread (on which join() is called) finishes its execution (TERMINATED state).
    • Often used to make sure one thread completes before another thread starts or continues.
  3. Thread.yield()

    • A hint to the thread scheduler that the current thread is willing to yield its current use of the CPU.
    • The scheduler may or may not choose to honor this hint.
  4. Thread.interrupt()

    • Signals the thread that it should stop its current activity if it’s designed to respond to interruption.
    • Throws an InterruptedException if the thread is blocked (e.g., sleeping, waiting).
    • It’s up to the thread’s run() method to handle InterruptedException properly.

Benefits of Multithreading

  • Improved Performance: Multiple threads allow tasks to be executed in parallel, utilizing CPU cores efficiently.
  • Responsiveness: Increases the responsiveness of applications, e.g., user interface stays active while a task runs in the background.
  • Better Resource Utilization: Threads share the same memory and resources, reducing overhead compared to processes.
  • Scalability: Makes it easier to design scalable systems that handle multiple tasks simultaneously.