Object-Oriented Programming (OOP) in JAVA

Vahe Aslanyan
35 min readJan 4, 2024

--

At this juncture in our journey through Java programming, it’s time to go deeper into the concepts of Object-Oriented Programming (OOP). Equipped with the fundamental skills you’ve learned during the preceeding chapters, you now stand at a crucial juncture: OOP is at Java’s heart.

By diving headfirst into its complex layers, you are prepared to unlock its secrets while drawing parallels back to earlier fundamental concepts you’ve already mastered.

In my own journey with Java, I recall the powerful mix of enthusiasm and uncertainty that defined its beginning stages for me.

Like Bill Cage in “Edge of Tomorrow,” my introduction was sudden yet disorienting. I was thrust into advanced topics such as inheritance and encapsulation without understanding foundational elements like methods or loops. This felt like being thrust onto an unfamiliar aircraft without prior instruction or guidance.

Adversity in learning often results in greater comprehension. And you’re now ready, equipped with the essential programming knowledge to navigate OOP without experiencing its initial disorientation like I had.

OOP in Java goes beyond being just another chapter — it represents Java’s essence. Here we fully leverage its abilities, simulating real world complexities accurately while building upon prior lessons accumulated over time. This next phase will bring you insightful as well as instinctive learning experiences.

Resources

If you’re keen on furthering your Java knowledge, here’s a guide to help you conquer Java and launch your coding career. It’s perfect for those interested in AI and machine learning, focusing on effective use of data structures in coding. This comprehensive program covers essential data structures, algorithms, and includes mentorship and career support.

Additionally, for more practice in data structures, you can explore these resources:

  1. Java Data Structures Mastery — Ace the Coding Interview: A free eBook to advance your Java skills, focusing on data structures for enhancing interview and professional skills.
  2. Foundations of Java Data Structures — Your Coding Catalyst: Another free eBook, diving into Java essentials, object-oriented programming, and AI applications.

Visit LunarTech’s website for these resources and more information on the bootcamp.

Connect with Me:

Now lets begin!

What is Object-Oriented Programming?

OOP (Object Oriented Programming) centers around classes and objects as the cornerstones. A class serves as the blueprint, much like architectural plans are used when constructing multiple buildings. Similarly multiple objects may be instantiated from one class.

Imagine it this way: think of a prototype class displaying attributes and behaviors while its manifestation exists as a tangible manifestation.

Understanding Classes in Java: The Blueprint

At its essence, a class encapsulates data for the object and methods to manipulate that data. The data, or attributes, represents the state, and the methods define behavior.

How to Declare and Define a Class in Java

A class in Java is introduced using the class keyword, followed by its name.

class Car {
String color; // attribute
void drive() { // method
System.out.println("Car is driving");
}
}

Objects in Java: Instances of Classes

An object is a specific instance of a class. Each object has a unique identity but shares the structure provided by its class.

Objects are instantiated using the new keyword.

Car myCar = new Car();

Post-instantiation, the object’s attributes and methods can be accessed using the dot operator.

myCar.color = "Red";
myCar.drive();

Constructors: The Object Initializers

Constructors play a pivotal role in object instantiation, allowing for immediate attribute setting.

  • Default Constructor: Provided by Java if no constructor is defined.
  • Parameterized Constructor: Accepts parameters to initialize attributes.
  • Constructor Overloading: Multiple constructors with different parameters.
class Car {
String color;
Car() {
this.color = "Unknown";
}
Car(String c) {
this.color = c;
}
}

The this Keyword in Java

The this keyword refers to the current instance of an object. It's particularly useful when differentiating instance variables from method parameters.

void setColor(String color) {
this.color = color;
}

Garbage Collection and Destructors

Java inherently handles memory management. Objects no longer in use are automatically cleared by the garbage collector. The finalize() method allows an object to clean up resources before it's removed.

Static vs. Non-static

A static member belongs to the class itself, rather than any specific instance. For instance, a static variable will share its value across all instances of the class. Non-static members, conversely, are unique to each instance.

class Car {
static int carCount; // static variable
Car() {
carCount++;
}

The Final Keyword with Classes and Objects

The final keyword, when applied, ensures immutability. A final variable can't be modified, a final method can't be overridden, and a final class can't be subclassed.

final class ImmutableCar {}

Real-world Analogies & Practical Applications

In the world of programming, understanding complex concepts through simple, relatable analogies can be a game-changer.

This solution dives into the analogy of a cookie cutter representing a Java class and cookies as its objects. Let’s implement this to understand how a class provides structure, while objects of the class can have variations.

Java Implementation:

// The CookieCutter class represents the analogy's cookie cutter.
class CookieCutter {
    // Common shape for all cookies made using this cutter.
String shape;
// Constructor to initialize the shape of the cookie cutter.
public CookieCutter(String shape) {
this.shape = shape;
}
// Method to create a new cookie with the specified flavor using this cutter's shape.
public Cookie makeCookie(String flavor) {
return new Cookie(this.shape, flavor);
}
}
// The Cookie class represents the cookies made using the cookie cutter.
class Cookie {
// Every cookie will have a shape and a flavor.
String shape;
String flavor;
// Constructor to initialize the shape and flavor of the cookie.
public Cookie(String shape, String flavor) {
this.shape = shape;
this.flavor = flavor;
}
// Method to describe the cookie.
public void describe() {
System.out.println("This is a " + flavor + " flavored " + shape + " cookie.");
}
}
public class CookieFactory { public static void main(String[] args) { // Creating a heart-shaped cookie cutter.
CookieCutter heartShapedCutter = new CookieCutter("heart");
// Using the heart-shaped cutter to create cookies with different flavors.
Cookie chocoHeartCookie = heartShapedCutter.makeCookie("chocolate");
Cookie vanillaHeartCookie = heartShapedCutter.makeCookie("vanilla");
// Describing the cookies.
chocoHeartCookie.describe();
vanillaHeartCookie.describe();
}
}

Expected Output:

This is a chocolate flavored heart cookie.
This is a vanilla flavored heart cookie.

Explanation:

  1. We’ve defined a CookieCutter class, representing the cookie cutter. It has an attribute shape and a method makeCookie to create cookies of a particular flavor but with the cutter's shape.
  2. The Cookie class represents individual cookies. Each cookie has a shape and flavor.
  3. In the CookieFactory main class, we created a heart-shaped CookieCutter and used it to make two different flavored cookies. Despite the flavor difference, both cookies retain the heart shape.

In conclusion, much like our analogy, the CookieCutter class dictates the structure (shape) while allowing individual objects (Cookie) to possess unique attributes (flavor).

Exercises and Practice Questions

  1. Design a Person class with attributes like name and age, and methods such as speak().
  2. Instantiate three different Person objects and call their methods.
  3. Experiment with creating constructors, using the this keyword, and making static variables.

Understanding the basic structure of a class and the instantiation of objects is fundamental to Java programming. In this exercise, we’ll design a simple Person class, explore object instantiation, and dive into constructors, the this keyword, and static variables.

Java Implementation:

javaCopy code
// Definition of the Person class.
class Person {
    // Attributes of the Person class.
String name;
int age;
// Static variable to keep count of the number of Person objects created.
static int personCount = 0;
// Default constructor.
public Person() {
personCount++; // Increment the count whenever a new Person object is created.
}
// Parameterized constructor using the 'this' keyword to initialize the attributes.
public Person(String name, int age) {
this.name = name;
this.age = age;
personCount++; // Increment the count whenever a new Person object is created.
}
// speak() method to let the person introduce themselves.
public void speak() {
System.out.println("Hello! My name is " + name + " and I am " + age + " years old.");
}
// Static method to display the number of Person objects created.
public static void displayCount() {
System.out.println("Total number of persons: " + personCount);
}
}
public class PersonTest { public static void main(String[] args) { // Instantiating three different Person objects.
Person person1 = new Person("Alice", 25);
Person person2 = new Person("Bob", 30);
Person person3 = new Person("Charlie", 35);
// Calling the speak() method for each Person object.
person1.speak();
person2.speak();
person3.speak();
// Displaying the number of Person objects created using the static method.
Person.displayCount();
}
}

Expected Output:

Hello! My name is Alice and I am 25 years old.
Hello! My name is Bob and I am 30 years old.
Hello! My name is Charlie and I am 35 years old.
Total number of persons: 3

Explanation:

  1. We’ve created the Person class with attributes name and age.
  2. We’ve also included a static variable personCount to keep track of the number of Person objects instantiated.
  3. Two constructors are provided: a default constructor and a parameterized constructor. The this keyword in the parameterized constructor helps distinguish between instance variables and constructor parameters.
  4. The speak() method lets a person introduce themselves.
  5. The static method displayCount() showcases the use of the static variable and provides a count of the number of Person objects created.
  6. In the PersonTest main class, we've instantiated three Person objects and invoked their methods.

Through this implementation, we’ve successfully encapsulated the foundational concepts of class design, object instantiation, constructors, the this keyword, and static variables in Java.

Understanding Constructors

A constructor in Java is a special block of code that initializes the newly created object. It holds the same name as its class and behaves like a method, though it doesn’t have any return type. Constructors breathe life into an object, setting initial values and ensuring that the object is in a valid state upon creation.

Types of Constructors:

Default Constructor: A default constructor is one without parameters. If not explicitly defined, Java provides one implicitly to ensure every class has a constructor.

public class MyClass {
// Default constructor
public MyClass() {
// Initialization process
}
}

Parameterized Constructor: At times, it’s beneficial to initialize an object with specific values. This is where parameterized constructors come into play.

Unlike the default constructor, parameterized constructors accept arguments to initialize the attributes of the object.

public class MyClass {
int a;
// Parameterized constructor
public MyClass(int x) {
a = x;
}
}

Constructor Overloading: Constructors can be overloaded, much like methods. This means a class can have multiple constructors, differentiated by their parameter list.

public class MyClass {
int a, b;
// Constructor with one parameter
public MyClass(int x) {
a = x;
}
// Constructor with two parameters
public MyClass(int x, int y) {
a = x;
b = y;
}
}

This flexibility ensures objects can be initialized in multiple ways as per the requirement.

this Keyword in Constructors: Often, parameter names in a constructor might conflict with instance variable names. The this keyword helps differentiate.

public class MyClass {
int a;
public MyClass(int a) {
this.a = a; // Differentiating using 'this'
}
}

The super() Call: The super() call proves invaluable. It invokes the parent class constructor, ensuring a structured initialization.

class Parent {
// Parent class constructor
}
class Child extends Parent {
public Child() {
super(); // Calling parent constructor
}
}

Copy Constructor: A copy constructor, as the name suggests, copies the values of one object into another.

public class MyClass {
int a;
public MyClass(MyClass obj) {
a = obj.a; // Copying value
}
}

Chaining Constructors: A constructor can call another constructor in the same class using this.

public class MyClass {
int a, b;
// Default constructor
public MyClass() {
this(0); // Calling parameterized constructor
}
public MyClass(int x) {
a = x;
}
}

Practical Examples & Use Cases:

Throughout the Java ecosystem, constructors lay the groundwork, whether it’s in creating simple objects or intricate structures like GUI components. Examining code snippets from popular Java libraries can offer insightful applications of constructors.

Now let’s talk about some best practices when working with constructors:

Constructors should remain clutter-free, focusing solely on initialization. Avoid heavy computations and, importantly, be cautious of calling overridable methods in constructors.

class Base {
// Overridable method
void setup() {
System.out.println("Base setup");
}
    // Base constructor
Base() {
System.out.println("Base constructor");
// Calling overridable method inside constructor
setup();
}
}
class Derived extends Base {
private int value;
// Overriding the setup method
@Override
void setup() {
value = 42;
System.out.println("Derived setup with value: " + value);
}
// Derived class constructor
Derived() {
System.out.println("Derived constructor");
}
public static void main(String[] args) {
Derived d = new Derived();
System.out.println("Derived object value: " + d.value);
}
}

When you run the above code, the output will be:

kotlinCopy code
Base constructor
Derived setup with value: 42
Derived constructor
Derived object value: 0

What’s going on in this code?

  • When the Derived class object is created, the base class constructor is called first.
  • Within the base class constructor, the setup method is invoked. Since this method is overridden in the derived class, the derived class's version of setup is executed. Here, value is set to 42.
  • After the base constructor completes, the derived class constructor runs.
  • However, after everything, the value of value in the derived object remains 0 because instance variable initializations occur after the superclass constructor has completed but before the derived class constructor body is executed. This causes a misleading situation.

The call to the overridable method (setup) within the base class constructor leads to unpredictable behavior. Avoid calling overridable methods inside constructors. Always aim for constructors to be simple, straightforward, and focused solely on initialization.

Exercises and Practice Questions:

The following challenges range from creating simple classes to deciphering constructor-related code snippets.

Challenge 1: Basic — Create a Simple Class

  1. Design a class named Book with two attributes: title and author.
  2. Implement a method showBookInfo which prints the book's title and author.
  3. Instantiate the class and call the method to display a book’s details.

Challenge 2: Intermediate — Working with Default Constructors

  1. Using the Book class from Challenge 1, create a default constructor that initializes the title and author to "Unknown".
  2. Instantiate the class without passing any arguments and use the showBookInfo method. Verify that it displays "Unknown" for both title and author.

Challenge 3: Intermediate — Introducing Parameterized Constructors

  1. Enhance the Book class to have a parameterized constructor that accepts the title and author of the book.
  2. Instantiate the class by passing specific book details and then use the showBookInfo method. Ensure it displays the passed details correctly.

Challenge 4: Advanced — Constructor Overloading

  1. In the Book class, add another parameterized constructor that only accepts a title (the author is set to "Unknown").
  2. Create objects using both constructors to ensure overloading works as expected.

Challenge 5: Expert — this Keyword in Action

  1. Modify the Book class so that the parameter names in the constructors are the same as the class attributes.
  2. Utilize the this keyword to differentiate between instance variables and constructor parameters.
  3. Instantiate the class and verify that attributes are still correctly initialized.

Challenge 6: Super Expert — Analyze Constructor Flow Given the following code snippet:

class Parent {
Parent() {
System.out.println("Parent Constructor");
}
}
class Child extends Parent {
Child() {
System.out.println("Child Constructor");
}
}
public class ConstructorFlow {
public static void main(String[] args) {
Child obj = new Child();
}
}
  1. Predict the output without running the code.
  2. Execute the code to confirm your prediction.
  3. Modify the Parent and Child classes to include parameterized constructors. Ensure the child class calls the parent's parameterized constructor using the super keyword. Verify the flow by instantiating the Child class with necessary parameters.

Engaging with these challenges will offer a progression in understanding constructors, from their basic usage to more nuanced aspects. As always, practice is key to a deeper understanding.

Solution to Challenge 1: Basic — Create a Simple Class

class Book {
// Attributes for the Book class
String title;
String author;
    // Method to display book information
void showBookInfo() {
System.out.println("Title: " + title + ", Author: " + author);
}
public static void main(String[] args) {
// Creating an object of the Book class
Book myBook = new Book();
myBook.title = "The Great Gatsby";
myBook.author = "F. Scott Fitzgerald";
// Displaying the book's details
myBook.showBookInfo();
}
}

Solution to Challenge 2: Intermediate — Working with Default Constructors

class Book {
String title;
String author;
    // Default constructor initializing the attributes to "Unknown"
Book() {
title = "Unknown";
author = "Unknown";
}
void showBookInfo() {
System.out.println("Title: " + title + ", Author: " + author);
}
public static void main(String[] args) {
// Instantiating the class without passing any arguments
Book unknownBook = new Book();
unknownBook.showBookInfo(); // This will print: Title: Unknown, Author: Unknown
}
}

Solution to Challenge 3: Intermediate — Introducing Parameterized Constructors

class Book {
String title;
String author;
    // Parameterized constructor accepting title and author
Book(String t, String a) {
title = t;
author = a;
}
void showBookInfo() {
System.out.println("Title: " + title + ", Author: " + author);
}
public static void main(String[] args) {
// Instantiating the class with specific details
Book specificBook = new Book("1984", "George Orwell");
specificBook.showBookInfo(); // This will print: Title: 1984, Author: George Orwell
}
}

Solution to Challenge 4: Advanced — Constructor Overloading

class Book {
String title;
String author;
    Book() {
title = "Unknown";
author = "Unknown";
}
Book(String t) {
title = t;
author = "Unknown";
}
Book(String t, String a) {
title = t;
author = a;
}
void showBookInfo() {
System.out.println("Title: " + title + ", Author: " + author);
}
public static void main(String[] args) {
Book onlyTitle = new Book("Brave New World");
onlyTitle.showBookInfo(); // This will print: Title: Brave New World, Author: Unknown
Book fullDetails = new Book("The Hobbit", "J.R.R. Tolkien");
fullDetails.showBookInfo(); // This will print: Title: The Hobbit, Author: J.R.R. Tolkien
}
}

Solution to Challenge 5: Expert — this Keyword in Action

javaCopy code
class Book {
String title;
String author;
    Book(String title, String author) {
this.title = title; // 'this' keyword differentiates instance variable from parameter
this.author = author;
}
void showBookInfo() {
System.out.println("Title: " + title + ", Author: " + author);
}
public static void main(String[] args) {
Book exampleBook = new Book("Moby Dick", "Herman Melville");
exampleBook.showBookInfo(); // This will print: Title: Moby Dick, Author: Herman Melville
}
}

Solution to Challenge 6: Super Expert — Analyze Constructor Flow

The given code will print:

Parent Constructor
Child Constructor

This is because the parent class constructor gets executed before the child class constructor.

For the third part of the challenge:

class Parent {
Parent(int a) {
System.out.println("Parent Constructor with parameter: " + a);
}
}
class Child extends Parent {
Child(int b) {
super(b); // Calling the parent's parameterized constructor
System.out.println("Child Constructor with parameter: " + b);
}
}
public class ConstructorFlow {
public static void main(String[] args) {
Child obj = new Child(5);
}
}

This will print:

sqlCopy code
Parent Constructor with parameter: 5
Child Constructor with parameter: 5

These solutions provide practical insight into the mentioned challenges, giving you a detailed understanding of the respective concepts.

What is Inheritance in Object-Oriented Programming?

Understanding inheritance is key to becoming an adept Java developer. Inheritance allows a class to acquire properties and methods belonging to another class through inheritance, creating code reusability as well as hierarchical relationships among classes.

At its heart, inheritance resembles real-world inheritance: just as children inherit traits from their parents in real life, Java classes inherit features and methods from their parent classes as inheritance.

Advantages of using Inheritance

  1. Code Reusability: Avoid redundant code by inheriting functionalities from the parent class.
  2. Enhanced Readability: Hierarchical structures give a clearer, more intuitive view of related classes.
  3. Improved Maintainability: Change the parent class, and the child classes get updated accordingly.

The extends keyword

In Java, inheritance refers to a process by which one class inherits properties (attributes and methods) from another. A key feature of inheritance in this context is the extends keyword. It marks out hierarchical relationships between classes in an effort to streamline code, enhance reusability and establish clear lineage among related classes.

When a class inherits from another, two main roles are defined:

  1. Superclass (or Parent Class): This class acts as the source for inheritance. Its blueprint forms the basis from which subsequent classes inherit attributes or methods from.
  2. Subclass (or Child class): This is the class that does the inheriting. It will naturally incorporate all non-private properties and methods from its superclass and can also have additional properties and methods of its own.

As an example, consider the Vehicle class with attributes and methods like color and start().

If we wanted to create more specific classes such as Car instead of recreating everything from scratch, instead we can have Car extend Vehicle. It would then automatically include its color attribute, start() method as well as possible specific attributes like numberOfDoors for this example Car class.

Basic Inheritance

  • Parent and Subclass Relations: Central to inheritance is the relationship between parent and child classes. For instance, inheritor subclass inherits all accessible attributes and methods from its superclass. This creates a clear lineage from one class to the other — for instance all physicians fall under Human’s subclass but not all humans fall within Doctor.
  • Utilizing Attributes and Methods from the Parent Class: When inheriting from a parent class, its attributes and methods become accessible (with some access-level restrictions). This means that when used directly without having to redefine them first. Also, inheritant classes can extend or override these properties for their unique requirements.

Real-world example to illustrate this idea: Consider an experienced artist. Their apprentice doesn’t begin their learning from scratch — rather, leveraging foundational skills taught by their artist (superclass), they then add their unique flair, creating their masterpiece (subclass).

Exercise:

class Animal {
String name;
String species;
    // Constructor
public Animal(String name, String species) {
this.name = name;
this.species = species;
}
}
class Dog extends Animal {
// Constructor
public Dog(String name, String species) {
super(name, species);
}
void bark() {
System.out.println(name + " is barking!");
}
public static void main(String[] args) {
Dog myDog = new Dog("Buddy", "Golden Retriever");
System.out.println(myDog.name); // Outputs: Buddy
System.out.println(myDog.species); // Outputs: Golden Retriever
myDog.bark(); // Outputs: Buddy is barking!
}
}

Method Overriding: Method overriding allows a subclass to provide its unique version of a method already defined in its superclass. For instance, a Bird superclass might have a method sound(), which returns "Bird makes a sound". A Sparrow subclass can override this to return "Sparrow chirps", reflecting its specific behavior.

class Bird {
// Method in the superclass
String sound() {
return "Bird makes a sound";
}
}
class Sparrow extends Bird {
// Overriding the method from superclass
@Override
String sound() {
return "Sparrow chirps";
}
}
public class OverrideExample {
public static void main(String[] args) {
Sparrow mySparrow = new Sparrow();
System.out.println(mySparrow.sound()); // This will print "Sparrow chirps"
}
}

Method Overloading vs. Method Overriding: Method overloading lets a class have several methods with the same name but different parameters, allowing varied actions based on parameters.

In contrast, method overriding enables a subclass to offer a distinct behavior for an inherited method.

For instance, a Calculator might have overloaded add() methods for two or three integers, whereas a subclass ScientificCalculator could override the sqrt() method to modify its behavior.

class Calculator {
// Method overloading - same method name with different parameters
int add(int a, int b) {
return a + b;
}
    int add(int a, int b, int c) {
return a + b + c;
}
}
class ScientificCalculator extends Calculator {
// Overriding the method to modify its behavior in the subclass
@Override
int add(int a, int b) {
return a + b + 10; // Just for illustration: adding 10 to the result
}
}
public class OverloadOverrideExample {
public static void main(String[] args) {
ScientificCalculator myCalc = new ScientificCalculator();
System.out.println(myCalc.add(5, 3)); // This will print 18 because of the overridden method
System.out.println(myCalc.add(5, 3, 2)); // This will print 10 because of method overloading
}
}

The @Override Annotation: The @Override annotation in Java indicates that a method is meant to override one in its superclass. It's a safeguard, ensuring that the overriding is intentional and correctly done, helping catch errors during compile time.

class Printer {
void print() {
System.out.println("Printing from base class");
}
}
class LaserPrinter extends Printer {
// Using the @Override annotation to signify intention to override
@Override
void print() {
System.out.println("Laser printing in progress");
}
}
public class OverrideAnnotationExample {
public static void main(String[] args) {
LaserPrinter lp = new LaserPrinter();
lp.print(); // This will print "Laser printing in progress"
}
}

Constructors in Inheritance

Chain of Constructor Calls: Whenever an object of a subclass is instantiated, its constructor does not just run in isolation. Instead, a series of constructor calls are initiated that traverse from topmost superclass down to actual subclass being instantiated.

Imagine having a hierarchy composed of Grandparent, Parent (that extends Grandparent), and Child classes. When creating objects from this class (Child), its constructor will first call Grandparent’s constructor before proceeding onward to Parent and then finally Child. This ensures the inheritance chain starts off correctly.

Example:

class Grandparent {
Grandparent() {
System.out.println("Grandparent's constructor called.");
}
}
class Parent extends Grandparent {
Parent() {
System.out.println("Parent's constructor called.");
}
}
class Child extends Parent {
Child() {
System.out.println("Child's constructor called.");
}
}
public class ConstructorChainExample {
public static void main(String[] args) {
new Child(); // This will print messages from all three constructors in the order: Grandparent, Parent, Child
}
}

Use of super() to Call Parent Class Constructor: In Java, super() can be used within subclass constructors to call their parent class's constructor. By default, Java will insert an indirect call for you via no-argument super. When there's an optional parameterized constructor it's essential that super be called with its exact arguments to invoke its constructor correctly.

Example:

javaCopy code
class Parent {
Parent(String message) {
System.out.println(message);
}
}
class Child extends Parent {
Child() {
super("Parent's constructor called with a message."); // Explicitly calling parent's constructor with a message
System.out.println("Child's constructor called.");
}
}
public class SuperExample {
public static void main(String[] args) {
new Child(); // This will print both messages: one from the Parent's constructor and one from the Child's constructor
}
}

As shown above, the Child class’s constructor explicitly calls its parent class’s constructor using super() with all required arguments passed as parameters.

How to Access Superclass Methods

Let’s now see the super keyword in action.

Consider a scenario where we have a Vehicle class with a method description(), and a Car class that extends Vehicle. The Car class wants to provide additional details in the description but also wants to keep the basic details provided by the Vehicle class. This is where super comes into play.

Example:

class Vehicle {
void description() {
System.out.println("This is a generic vehicle.");
}
}
class Car extends Vehicle {
@Override
void description() {
super.description(); // Calling the parent class's description method
System.out.println("More specifically, this is a car.");
}
}
public class SuperUsageExample {
public static void main(String[] args) {
Car car = new Car();
car.description();
// Output:
// This is a generic vehicle.
// More specifically, this is a car.
}
}

In the above example, the Car class's description() method first calls the Vehicle class's description() method using super.description(). After that, it adds its own specific message. This allows the Car class to reuse the general description from the Vehicle class and then provide additional details that are specific to cars.

Multiple Inheritance

interface Person {
void displayPersonDetails();
}
interface Address {
void displayAddressDetails();
}
class Contact implements Person, Address {
// Define attributes for both interfaces and provide implementation for both methods
// This exercise illustrates how one class can inherit from multiple interfaces.
}

The above are just the first few key concepts from the Java Inheritance Masterclass. As we dive deeper into topics like abstract classes, polymorphism, protected members, and various forms of inheritance, remember that the aim is not just to grasp the syntax, but to deeply understand the foundational concepts. Only by internalizing these principles can you craft code that’s both efficient and elegant.

What is Polymorphism in Object-Oriented Programming?

Polymorphism — from Greek words meaning many forms — is an indispensable concept in Object-Oriented Programming (OOP). It serves to ensure that all entities of different types behave similarly when interacting together, adding depth to OOP concepts like class hierarchy.

Polymorphism plays an essential part of Java’s OOP language by providing seamless interactions among class entities. This results in rich OOP concepts that add greater dimension.

At its core, polymorphism enables us to view objects of diverse classes as instances of one superclass, creating adaptability within code. This flexibility helps facilitate better reusability. Common behavior can be easily inherited while deviations managed seamlessly. It also improves the readability of code while opening doors for scalable software solutions.

Types of Polymorphism

  • Compile-time Polymorphism (Static Polymorphism): This type of polymorphism is achieved when we overload a method.
void print(int a) { ... }
void print(double b) { ... }

Here, the method’s name remains the same, but the parameter lists vary — this distinction in parameters is known as method signatures.

  • Run-time Polymorphism (Dynamic Polymorphism): This involves overriding methods from a superclass in its subclass.
class Animal {
void sound() { ... }
}
class Dog extends Animal {
void sound() { ... }
}

At runtime, Java uses the object’s actual class (like Dog) to determine which version of an overridden method should execute.

Casting in Polymorphism

  • Upcasting: This involves casting an object to one of its superclass types. Being an implicit conversion, it’s safe.
Dog myDog = new Dog();
Animal myAnimal = myDog; // Upcasting
  • Downcasting: Here, we cast an object to one of its subclass types. It must be done explicitly due to potential risks.
Animal myAnimal = new Dog();
Dog myDog = (Dog) myAnimal; // Downcasting

It’s important to be cautious and make sure you do this correctly, as forced incorrect downcasting can lead to errors.

The Utility of the instanceof Operator

The instanceof operator is integral for type verification, often used before downcasting to prevent unwarranted ClassCastException.

if (myAnimal instanceof Dog) {
Dog myDog = (Dog) myAnimal;

By confirming type beforehand, we establish a safe environment for typecasting.

Benefits of Polymorphism

  • Reusability: With Polymorphism, code components can be leveraged across multiple classes, curtailing redundancy.
  • Extensibility: As business needs evolve, Polymorphism ensures minimal disruptions when expanding functionalities.
  • Flexibility: Modules remain distinct, making systems more manageable.
  • Simplified Design: Systems designed with Polymorphism are inherently organized and intuitive.
  • Interchangeability: With Polymorphism, varying implementations can be switched seamlessly.
  • Enhanced Maintainability: With standardized structures, tasks like debugging and updates become less cumbersome.

Practical Scenarios and Use-cases

Polymorphism shines in various real-world applications. From GUI systems where different button types inherit from a generic button class, to database interactions where varied database entities are managed under a universal interface, or even gaming where different character classes derive from a primary character blueprint, its presence is undeniable.

Here are few examples to showcase polymorphism in action.

Basic Example: Animal Sounds

// The superclass Animal has a method sound(), which provides a generic implementation.
class Animal {
void sound() {
System.out.println("Animal makes a sound");
}
}
// Dog is a subclass of Animal and overrides the sound() method.
class Dog extends Animal {
@Override
void sound() {
System.out.println("Dog barks");
}
}
// Cat is another subclass of Animal and also overrides the sound() method.
class Cat extends Animal {
@Override
void sound() {
System.out.println("Cat meows");
}
}
// This class demonstrates polymorphism.
// We're able to treat both Dog and Cat as Animal and call the sound() method.
public class TestPolymorphism {
public static void main(String[] args) {
Animal a; // Reference variable of type Animal
a = new Dog(); // a now refers to a Dog object
a.sound(); // Calls the Dog's overridden sound() method
a = new Cat(); // a now refers to a Cat object
a.sound(); // Calls the Cat's overridden sound() method
}
}

Intermediate Example: Payment Methods

// Abstract class defining the contract for payment methods.
abstract class PaymentMethod {
abstract void pay(double amount);
}
// CreditCard is a concrete subclass that provides an implementation of the pay() method.
class CreditCard extends PaymentMethod {
@Override
void pay(double amount) {
System.out.println("Paid $" + amount + " using Credit Card.");
}
}
// PayPal is another concrete subclass with its own implementation of pay().
class PayPal extends PaymentMethod {
@Override
void pay(double amount) {
System.out.println("Paid $" + amount + " using PayPal.");
}
}
// Demonstrates polymorphism by treating both CreditCard and PayPal as PaymentMethod.
public class PaymentTest {
public static void main(String[] args) {
PaymentMethod p; // Reference of type PaymentMethod
p = new CreditCard(); // p now refers to a CreditCard object
p.pay(100.50); // Calls CreditCard's implementation of pay()
p = new PayPal(); // p now refers to a PayPal object
p.pay(200.75); // Calls PayPal's implementation of pay()
}
}

Advanced Example: UI Elements & Events

// Interface for elements that respond to click events.
interface OnClickListener {
void onClick();
}
// Abstract superclass defining a contract for all UI elements.
abstract class UIElement {
abstract void draw();
abstract void setOnClickListener(OnClickListener listener);
}
// Button is a subclass of UIElement and also implements OnClickListener.
// Demonstrates multiple polymorphism (with both superclass and interface).
class Button extends UIElement implements OnClickListener {
private OnClickListener listener;
@Override
void draw() {
System.out.println("Drawing a button...");
}
@Override
public void setOnClickListener(OnClickListener listener) {
this.listener = listener;
}
// Simulates a click event.
void click() {
if(listener != null) {
listener.onClick();
}
}
@Override
public void onClick() {
System.out.println("Button was clicked!");
}
}
// Dropdown is another subclass of UIElement.
// It can potentially implement OnClickListener but for brevity, it's omitted here.
class Dropdown extends UIElement {
@Override
void draw() {
System.out.println("Drawing a dropdown...");
}
@Override
public void setOnClickListener(OnClickListener listener) {
// Potential implementation for dropdown click.
}
}
// Test class to demonstrate polymorphism in action, especially with interfaces.
public class UIElementTest {
public static void main(String[] args) {
Button btn = new Button();
btn.draw();
btn.setOnClickListener(btn); // Setting the button itself as the click listener
btn.click();
}
}

In these examples, polymorphism allows us to write code that treats objects of different classes as objects of a common superclass or interface. This gives flexibility, as demonstrated by the ability to easily switch between different subclass objects (for example, Dog, Cat, CreditCard, PayPal) using a common reference type (Animal, PaymentMethod).

What is Encapsulation in Object-Oriented Programming?

Among the foundational quartet of OOP principles, encapsulation primarily focuses on bundling the data and operations on that data into a single unit. This ensures that objects maintain their integrity by preventing unauthorized access and modifications.

Beyond just a programming technique, encapsulation is indispensable in fostering secure coding practices and achieving a modular software design.

How Encapsulation Works

At its core, encapsulation is about data protection and controlled access. It can be analogized as a protective shell that guards the delicate internal workings of a system.

Consider a watch: while users can see the time and adjust settings using knobs, the intricate machinery inside remains hidden, safeguarding its functionality.

How to Implement Encapsulation

Java provides us with access modifiers to enforce encapsulation. The most restrictive of these is private, ensuring that class members are only accessible within that class. By declaring variables as private, we can shield them from unintended external interference.

private int age;
public int getAge() {
return age;
}
public void setAge(int age) {
if (age > 0) {
this.age = age;
}
}

In the above code, encapsulation ensures that age can never be set to a negative value.

Benefits of Encapsulation

Control: Using encapsulation, we can add conditions to control how data is accessed or modified.

public class Account {
private double balance;
    // Getter method for balance
public double getBalance() {
return balance;
}
// Setter method to control the deposit operation
public void deposit(double amount) {
if (amount > 0) {
balance += amount;
} else {
System.out.println("Invalid deposit amount!");
}
}
// Setter method to control the withdraw operation
public void withdraw(double amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
} else {
System.out.println("Invalid withdrawal amount!");
}
}
}

Flexibility and Maintenance: By encapsulating data, any internal changes to a class won’t directly affect its interactions with other classes.

public class Vehicle {
private int speed;
    // Now, if we decide to measure speed in terms of mph instead of kph in the future,
// we just have to change this class without affecting classes that use `Vehicle`.
public int getSpeedInMph() {
return speed * 5/8; // converting kph to mph
}
public void setSpeed(int speed) {
this.speed = speed;
}
}
public class Race {
public void startRace(Vehicle v1, Vehicle v2) {
// Uses Vehicle class but is not dependent on how Vehicle internally represents speed.
int diff = v1.getSpeedInMph() - v2.getSpeedInMph();
System.out.println("Speed difference is: " + diff + " mph");
}
}

Increased Security: Shielding class members and only allowing them to be changed through controlled methods ensures security.

public class PasswordManager {
private String encryptedPassword;
    public void setPassword(String password) {
// Assuming encrypt() is a method that encrypts the password.
this.encryptedPassword = encrypt(password);
}
public boolean validatePassword(String password) {
return encrypt(password).equals(encryptedPassword);
}
private String encrypt(String data) {
// Encryption logic here
return /* encrypted data */;
}
}

Modular Approach: Encapsulation allows a system to be split into clear, well-defined modules, which can then be developed and maintained separately.

// User module
public class User {
private String name;
private String email;
    // getters and setters
}
// Product module
public class Product {
private String productId;
private String description;
// getters and setters
}
// Billing module
public class Invoice {
private User user;
private Product product;
private double amount;
// getters and setters
}

Each of these modules (User, Product, Invoice) can be developed, expanded, or maintained independently of the others.

Real-world Analogy of Encapsulation

Imagine a bank account system. Account holders can deposit, withdraw, and check their balance, but the detailed mechanics of how the bank processes these requests remain concealed.

Just as the bank hides the intricacies of its operations while exposing essential functionalities, encapsulation in programming hides the details while providing necessary operations.

Advanced Encapsulation Concepts

Creating immutable classes ensures that once an object is created, it cannot be altered. This is achieved by making all members final and providing no setters.

The final keyword can also restrict inheritance and prevent method overriding, adding another layer of encapsulation.

While encapsulation focuses on bundling data and its operations, abstraction, another OOP principle, emphasizes hiding complex implementations and exposing only relevant features. Although intertwined, they serve distinct roles.

// Creating immutable class in Java using final keyword
public final class ImmutableClass {
private final String name;
    public ImmutableClass(String name) {
this.name = name;
}
public String getName() {
return name;
}
// No setter methods – this makes the class immutable
}
// Using final keyword to prevent method overriding
class ParentClass {
public final void showFinalMethod() {
System.out.println("This is a final method from ParentClass");
}
}
class ChildClass extends ParentClass {
// Attempting to override the final method from parent class would result in a compile-time error
// public void showFinalMethod() {
// System.out.println("Trying to override final method");
// }
}

In the above code:

  • The ImmutableClass is an example of an immutable class. Once an ImmutableClass object is created, its name property can't be changed because there's no setter method.
  • In the ParentClass and ChildClass example, the showFinalMethod in ParentClass is declared as final, so it can't be overridden in ChildClass.

Common Mistakes and Pitfalls

Failing to validate data in setter methods can lead to inconsistencies. Consider a Person class with an age field. We should validate data in the setter method to ensure the age can't be set to a negative value.

public class Person {
private int age;
    public void setAge(int age) {
if(age < 0) {
System.out.println("Age can't be negative.");
} else {
this.age = age;
}
}
}

Overexposing class details dilutes the essence of encapsulation. If we have a BankAccount class with a balance field, we shouldn't expose this detail directly. Instead, we can provide public methods to deposit, withdraw and check the balance.

public class BankAccount {
private double balance;
    public void deposit(double amount) {
if(amount > 0) {
balance += amount;
}
}
public void withdraw(double amount) {
if(amount > 0 && amount <= balance) {
balance -= amount;
}
}
public double checkBalance() {
return balance;
}
}

Underutilizing or misusing access modifiers can compromise data integrity. If we have a Car class with speed field, we should declare it as private to prevent uncontrolled access. We can then provide public getter and setter methods to control how speed is accessed and modified.

public class Car {
private int speed;
    public int getSpeed() {
return speed;
}
public void setSpeed(int speed) {
if(speed >= 0) {
this.speed = speed;
}
}
}

Practical Scenarios and Use-cases

Encapsulation finds its mettle in:

  • Crafting secure login systems where users’ credentials are shielded.
  • Building configuration managers for applications where system settings are protected yet adjustable.
  • Designing settings or preferences modules in software where users can personalize their experience while core configurations remain intact.

What is Abstraction in Object-Oriented Programming?

Abstraction in object-oriented programming (OOP) is an integral component that allows developers to streamline complex systems while keeping focus on essential details. Abstraction involves extracting relevant data while hiding irrelevant implementation details.

Abstraction allows developers to build models of real-world objects, systems or processes by abstracting away complexity while only exposing essential characteristics. This helps create more manageable and understandable code in turn.

Abstraction can help with designing modular and maintainable software by providing a clear separation between internal implementation and outside world.

Developers can then define abstract classes and interfaces which serve as blueprints to create objects properly while assuring smooth implementation of objects created through abstraction.

Abstractions enable us to work at a deeper level of comprehension by concentrating on essential behaviors and functionalities rather than getting bogged down in details. By harnessing abstraction, developers can easily craft clean code which is easy for others to read, understand, and maintain.

Abstraction plays an instrumental role in helping developers to effectively manage complexity and develop applications that comply with object-oriented programming principles.

The Significance of Abstraction in OOP

Abstraction plays an essential role in object-oriented programming (OOP), helping developers craft modular and maintainable code. By emphasizing essential details while concealing unnecessary ones, abstraction enables system designers to easily design their solutions while keeping implementation costs manageable.

Now, we’ll explore this aspect further while emphasizing its role in developing code which is both scalable and adaptable.

Creating Modular Code

Abstracting allows developers to break complex systems down into manageable modules for easier understanding and updating of codebases.

By abstracting away underlying implementation details, designers can focus more on designing user-friendly interfaces while also reusing code components across their software suite. This improves readability, maintainability and scalability in general.

// Abstract Module class
abstract class Module {
// Abstract method to perform module-specific functionality
public abstract void performAction();
}
// Concrete LoginModule
class LoginModule extends Module {
@Override
public void performAction() {
System.out.println("LoginModule: User logged in successfully.");
// Add login logic here
}
}
// Concrete PaymentModule
class PaymentModule extends Module {
@Override
public void performAction() {
System.out.println("PaymentModule: Payment processed.");
// Add payment processing logic here
}
}
public class ModularCodeExample {
public static void main(String[] args) {
// Create instances of modules
Module loginModule = new LoginModule();
Module paymentModule = new PaymentModule();
// Perform actions using the modules
loginModule.performAction(); // Perform login
paymentModule.performAction(); // Process payment
}
}
LoginModule: User logged in successfully.
PaymentModule: Payment processed.

In this code:

  • We introduce an abstract class Module, with an abstract method performAction() that represents the idea of a module without providing details about its implementation.
  • LoginModule and PaymentModule, two concrete classes that extend Module, each contain specific implementations of its performAction() method to represent various modules within our software system.
  • In the main() method, we create instances of LoginModule and PaymentModule which encase login and payment functionality, respectively.
  • After creating these instances, we invoke their performAction() methods in order to carry out their actions.

This example demonstrates how abstraction allows us to write modular code by defining a clear interface (Module), then implementing specific functionalities as separate modules (LoginModule and PaymentModule). This approach increases readability, maintainability and scalability by compartmentalizing functions within each module.

Encapsulating Complexity

Abstraction helps in encapsulating complexity by separating the high-level behavior from the intricate implementation details. By defining abstract classes and methods, developers can specify common behavior and provide a clear interface for interacting with the underlying system.

This level of abstraction allows for the development of more flexible and extensible software, facilitating easier modification and updates.

// Abstract Shape class defining common behavior
abstract class Shape {
// Abstract method to calculate the area of the shape
public abstract double calculateArea();
}
// Concrete Circle class
class Circle extends Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
// Concrete Rectangle class
class Rectangle extends Shape {
private double width;
private double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double calculateArea() {
return width * height;
}
}
public class AbstractionExample {
public static void main(String[] args) {
// Create instances of shapes
Shape circle = new Circle(5.0);
Shape rectangle = new Rectangle(4.0, 6.0);
// Calculate and display the areas
System.out.println("Area of Circle: " + circle.calculateArea());
System.out.println("Area of Rectangle: " + rectangle.calculateArea());
}
}
Area of Circle: 78.53981633974483
Area of Rectangle: 24.0

In this code:

  • We define an abstract class Shape with an abstract method calculateArea(). This abstract class represents the concept of a shape without specifying its implementation details.
  • We create two concrete classes, Circle and Rectangle, that extend the Shape class. These concrete classes provide specific implementations of the calculateArea() method, representing different shapes (circle and rectangle).
  • In the main() method, we create instances of Circle and Rectangle, which encapsulate the specific shapes and their dimensions.
  • We invoke the calculateArea() method on each shape to calculate and display their respective areas.

This example demonstrates how abstraction allows us to encapsulate complexity by defining a clear interface (Shape) and implementing specific behavior for different shapes (Circle and Rectangle).

The level of abstraction provided by the Shape class enables the development of flexible and extensible software, making it easier to modify and update the code for new shapes or changes in behavior.

Promoting Code Reusability

One of the major advantages of abstraction is code reuse. By creating abstract classes with common behaviors and inheriting them into subclasses, developers can build a basis that can be reused across several subclasses quickly. This saves both time and effort during development processes while creating consistency in software applications by standardizing common practices.

// Abstract Vehicle class defining common behavior
abstract class Vehicle {
private String make;
private String model;
    public Vehicle(String make, String model) {
this.make = make;
this.model = model;
}
// Abstract method for starting the vehicle
public abstract void start();
// Abstract method for stopping the vehicle
public abstract void stop();
public String getMake() {
return make;
}
public String getModel() {
return model;
}
}
// Concrete Car class
class Car extends Vehicle {
public Car(String make, String model) {
super(make, model);
}
@Override
public void start() {
System.out.println("Car started.");
}
@Override
public void stop() {
System.out.println("Car stopped.");
}
}
// Concrete Motorcycle class
class Motorcycle extends Vehicle {
public Motorcycle(String make, String model) {
super(make, model);
}
@Override
public void start() {
System.out.println("Motorcycle started.");
}
@Override
public void stop() {
System.out.println("Motorcycle stopped.");
}
}
public class CodeReuseExample {
public static void main(String[] args) {
// Create instances of vehicles
Vehicle car = new Car("Toyota", "Camry");
Vehicle motorcycle = new Motorcycle("Honda", "CBR 1000RR");
// Start and stop the vehicles
car.start();
car.stop();
motorcycle.start();
motorcycle.stop();
}
}
Car started.
Car stopped.
Motorcycle started.
Motorcycle stopped.

Enabling Future Extensibility

Abstraction allows developers to construct software systems that are easily extensible. Utilizing abstract classes and interfaces, developers can design code to be open to future modifications and additions without disrupting existing codebases or disrupting future requirements. Abstraction thus contributes to long-term software project maintainability and sustainability.

Version 1 (Before Extension):

// Abstract Shape class representing a basic shape
abstract class Shape {
public abstract double calculateArea();
}
// Concrete Circle class
class Circle extends Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
// Concrete Rectangle class
class Rectangle extends Shape {
private double width;
private double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double calculateArea() {
return width * height;
}
}
public class AbstractionExampleBeforeExtension {
public static void main(String[] args) {
Circle circle = new Circle(5.0);
Rectangle rectangle = new Rectangle(4.0, 6.0);
System.out.println("Area of Circle: " + circle.calculateArea());
System.out.println("Area of Rectangle: " + rectangle.calculateArea());
}
}

In this version, we have a basic shape handling system with abstraction. It includes a Shape abstract class with concrete subclasses Circle and Rectangle.

Version 2 (After Extension):

// Abstract Shape class representing a basic shape
abstract class Shape {
public abstract double calculateArea();
}
// Concrete Circle class
class Circle extends Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
// Concrete Rectangle class
class Rectangle extends Shape {
private double width;
private double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double calculateArea() {
return width * height;
}
}
// Concrete Triangle class (new shape added)
class Triangle extends Shape {
private double base;
private double height;
public Triangle(double base, double height) {
this.base = base;
this.height = height;
}
@Override
public double calculateArea() {
return 0.5 * base * height;
}
}
public class AbstractionExampleAfterExtension {
public static void main(String[] args) {
Circle circle = new Circle(5.0);
Rectangle rectangle = new Rectangle(4.0, 6.0);
Triangle triangle = new Triangle(3.0, 4.0);
System.out.println("Area of Circle: " + circle.calculateArea());
System.out.println("Area of Rectangle: " + rectangle.calculateArea());
System.out.println("Area of Triangle: " + triangle.calculateArea());
}
}

In this version, we have extended the system by adding a new concrete class Triangle representing a new shape. We did this without modifying the existing code, thanks to the use of abstraction.

This demonstrates how abstraction enables extensibility and the seamless integration of new features without disrupting existing code.

By encapsulating complexity, promoting code reusability, and enabling future extensibility, abstraction enhances the overall efficiency of the development process. Embracing abstraction in software design empowers developers to create scalable and adaptable systems that meet the evolving needs of users and businesses.

Resources

If you’re keen on furthering your Java knowledge, here’s a guide to help you conquer Java and launch your coding career. It’s perfect for those interested in AI and machine learning, focusing on effective use of data structures in coding. This comprehensive program covers essential data structures, algorithms, and includes mentorship and career support.

Additionally, for more practice in data structures, you can explore these resources:

  1. Java Data Structures Mastery — Ace the Coding Interview: A free eBook to advance your Java skills, focusing on data structures for enhancing interview and professional skills.
  2. Foundations of Java Data Structures — Your Coding Catalyst: Another free eBook, diving into Java essentials, object-oriented programming, and AI applications.

Visit LunarTech’s website for these resources and more information on the bootcamp.

Connect with Me:

--

--

Vahe Aslanyan
Vahe Aslanyan

Written by Vahe Aslanyan

Studying Computer Science and experienced with top tech firms, I co-founded LunarTech to revolutionize data science education. Join us for excellence.

No responses yet