Cracking OOP in Java: A PIE You’ll Want a Slice Of

Arshi Saxena - Oct 26 - - Dev Community

In this post, we’ll explore the four fundamental pillars of Object-Oriented Programming (OOP) in Java. These core principles help structure code to be modular, reusable, and maintainable. This post serves as an introduction, with upcoming entries diving deeper into each concept with nuanced discussions and examples.

To make it easy to remember, use the acronym “A PIE”: Abstraction, Polymorphism, Inheritance, and Encapsulation.


What Does It Mean to Be Object-Oriented?

Java is often described as an object-oriented language, but it’s not 100% object-oriented. Why? While most elements in Java revolve around objects (like classes, objects, and methods), it also uses primitive types (like int, boolean, and double), which are not objects.

Keeping primitive types in Java was a deliberate design choice. Here’s why:

  • Memory Efficiency: Primitive types take less memory compared to their object counterparts (like Integer or Boolean).

  • Performance Boost: Operations on primitives are faster since they avoid the overhead of object creation and reference management.

  • Convenience: Primitive types make code cleaner in simple cases, especially when dealing with arithmetic and logic operations.

In short, Java strikes a balance by providing primitives for performance and memory efficiency while also offering Wrapper Classes (like Integer) for when you need to treat these values as objects.


1. Abstraction: Hiding Unnecessary Details

Abstraction means hiding internal logic and exposing only the essential features to the user. It allows the user to interact with an object at a high level without worrying about the underlying complexity. Think of it as using an ATM—you just need to enter the amount to withdraw, without knowing how the ATM interacts with your bank to process the transaction.

In Java, abstract classes and interfaces help achieve abstraction by defining essential methods and leaving the internal details either to the child classes or within the parent class but hidden from the user.

Example:

abstract class Payment {
    // A method with concrete logic, hidden from the user.
    private void authenticate() {
        System.out.println("Authenticating payment...");
    }

    // Abstract method that child classes must implement.
    abstract void processPayment(double amount);

    // Public method exposing only the necessary details.
    public void makePayment(double amount) {
        authenticate();  // Hidden complexity
        processPayment(amount);  // Exposed to child classes
        System.out.println("Payment completed.");
    }
}

// Concrete class implementing the abstract method.
class CreditCardPayment extends Payment {
    @Override
    void processPayment(double amount) {
        System.out.println("Processing credit card payment of ₹" + amount);
    }
}

public class TestAbstraction {
    public static void main(String[] args) {
        Payment payment = new CreditCardPayment(); // Polymorphism in action.
        payment.makePayment(1000.00);  // Only high-level interaction.
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • Where is the complexity hidden?

    • The authenticate() method represents internal logic (e.g., user validation, encryption) that is private and hidden from both the child class and the user.
    • The makePayment() method is the only public method available to the user, providing a simple way to interact with the payment system.
  • How does the abstract class help?

    • It forces child classes (like CreditCardPayment) to implement the core logic (processPayment()), but the child class doesn’t need to know about authentication logic—it’s handled in the parent class.
  • What does the user see?

    • From the user’s perspective, they only interact with makePayment()—they don’t care about authentication or how the credit card payment works internally.

2. Polymorphism: Same Action, Different Forms

Polymorphism allows an object to behave differently in different situations. Java supports two types of polymorphism:

1. Compile-Time Polymorphism (Method Overloading): Achieved by defining multiple methods with the same name but different parameters.

Example:

class Calculator {
    // Compile-time polymorphism (Overloading)
    int add(int a, int b) {
        return a + b;
    }
    int add(int a, int b, int c) {
        return a + b + c;
    }

    public static void main(String[] args) {
        Calculator calc = new Calculator();
        System.out.println(calc.add(2, 3));  // Output: 5
        System.out.println(calc.add(2, 3, 4));  // Output: 9
    }
}

Enter fullscreen mode Exit fullscreen mode

2. Runtime Polymorphism (Method Overriding): Achieved when a subclass provides its specific implementation of a method declared in the parent class.

Example:

class Animal {
    void sound() {
        System.out.println("Animals make sounds.");
    }
}

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

public class TestPolymorphism {
    public static void main(String[] args) {
        Animal animal = new Dog();  // Runtime polymorphism
        animal.sound();  // Output: Dog barks
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

Compile-Time Polymorphism is demonstrated by overloading the add() method, while Runtime Polymorphism is shown by overriding the sound() method.

The sound() method behaves differently based on the object type. Although animal is of type Animal, at runtime, the overridden method in Dog is executed.


3. Inheritance: Code Reusability through Parent-Child Relationship

Inheritance allows a class (child) to reuse the properties and behavior of another class (parent). This promotes code reusability and establishes an IS-A relationship between classes. Java doesn’t support multiple inheritance through classes to avoid ambiguity but allows it through interfaces.

Example:

class Animal {
    void eat() {
        System.out.println("This animal eats food.");
    }
}

class Dog extends Animal {
    void bark() {
        System.out.println("Dog barks.");
    }
}

public class TestInheritance {
    public static void main(String[] args) {
        Dog dog = new Dog();
        dog.eat();  // Inherited method from Animal
        dog.bark(); // Dog’s own method
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

In this example:

  • Dog inherits from Animal, meaning the dog can both eat and bark.

  • This demonstrates code reuse—we didn’t need to rewrite the eat() method for the Dog class.


4. Encapsulation: Protecting Data with Access Control

Encapsulation means bundling the data (fields) and the methods that manipulate it into a single unit (class). It also ensures data-hiding by making fields private and exposing them through getters and setters.

Example:

class Student {
    private String name;

    // Getter
    public String getName() {
        return name;
    }

    // Setter
    public void setName(String name) {
        this.name = name;
    }
}

public class TestEncapsulation {
    public static void main(String[] args) {
        Student student = new Student();
        student.setName("John");  // Setting data through setter
        // Accessing data through getter
        System.out.println(student.getName());
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • The name field is private, meaning it can’t be accessed directly from outside the class.

  • Access is provided through public getters and setters, enforcing data-hiding.


Conclusion

Java’s OOP principlesAbstraction, Polymorphism, Inheritance, and Encapsulation—form the foundation for writing modular, maintainable, and efficient code. With these concepts in hand, you’ll be better prepared to design and understand complex systems.

In upcoming posts, we’ll dive deeper into each of these principles with more nuanced examples, best practices, and interview-focused tips. Stay tuned!


Related Posts

Happy Coding!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . .