Introduction to Object Oriented Programming

Object Oriented Programming System (OOPS) is a programming paradigm based on the concept of objects, in which everything is considered as an object, which can contain data (attributes) and code (methods). It allows for the creation of modular, reusable code.

Class and Object

Class:

  • A blueprint or template for creating objects.
  • Defines a datatype by bundling data (attributes) and methods (functions) that work on the data.

class Emp {
    public String name;
    public int salary;
    
    void displayDetails() {
        System.out.println("Name: " + name);
        System.out.println("Salary: " + salary);
    }
}

Object:

  • An instance of a class.
  • Contains real values instead of variables.

public class Main {
    public static void main(String[] args) {
        Emp emp1 = new Emp(); // Creating an object of Emp class
        emp1.name = "Ram";
        emp1.salary = 2000;
        emp1.displayDetails();
    }
}

Constructors and Getters, Setters in Java

1. Constructors

Constructors are special methods in the class which are automatically called when an object is created and are used to initialize the objects. They have the same name as the class and do not have a return type.

Types of Constructors:

  • Default Constructor: A no-argument constructor provided by Java if no other constructor is defined.
  • Parameterized Constructor: A constructor that takes arguments to initialize an object with specific values.
  • Copy Constructor: A constructor that initializes an object using another object of the same class.

Example:


class Car {
    String model;
    int year;

    // Default Constructor
    public Car() {

    }

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

    // Copy Constructor
    public Car(Car car) {
        this.model = car.model;
        this.year = car.year;
    }
}

public class Main {
    public static void main(String[] args) {
        Car car1 = new Car(); // Calls default constructor
        Car car2 = new Car("Toyota", 2021); // Calls parameterized constructor
        Car car3 = new Car(car2); // Calls copy constructor
        
        System.out.println("Car1 Model: " + car1.model + ", Year: " + car1.year);
        System.out.println("Car2 Model: " + car2.model + ", Year: " + car2.year);
        System.out.println("Car3 Model: " + car3.model + ", Year: " + car3.year);
    }
}

Most Important for Interview - Key Points:

  • If no constructor is defined in Class then, Java provides a default constructor automatically.
  • If any constructor (default or parameterized) is defined in Class then, Java does not provide a default constructor automatically.
  • Copy Constructor: A constructor used to create a new object as a copy of an existing object.

2. Getters and Setters

Getters and setters are methods used to access and update the values of private variables. This is a part of the encapsulation principle in OOP.

  • Getter: Method to retrieve the value of a private variable.
  • Setter: Method to set or update the value of a private variable.

Example:


class Car {

    private String model;
    private int year;

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

    // Getter for model
    public String getModel() {
        return model;
    }

    // Setter for model
    public void setModel(String model) {
        this.model = model;
    }

    // Getter for year
    public int getYear() {
        return year;
    }

    // Setter for year
    public void setYear(int year) {
        this.year = year;
    }
}

public class Main {
    public static void main(String[] args) {
        Car car = new Car("Honda", 2020);

        // Using getters to access the values
        System.out.println("Model: " + car.getModel());
        System.out.println("Year: " + car.getYear());

        // Using setters to update the values
        car.setModel("Ford");
        car.setYear(2022);
        System.out.println("Updated Model: " + car.getModel());
        System.out.println("Updated Year: " + car.getYear());
    }
}

Method Overloading in OOPS

Concept:

Method overloading is a feature in Java that allows a class to have more than one method with the same name but different signatures (different parameter lists).

We do Method overloading in three ways (different signature):

  • By changing datatype of arguments
  • By changing number of arguments
  • By changing the order of arguments

Benefits: This enhances readability and flexibility.

Example:


class MathOperations {
    // Method to add two integers
    public int add(int a, int b) {
        return a + b;
    }

    // Overloaded method to add three integers
    public int add(int a, int b, int c) {
        return a + b + c;
    }

    // Overloaded method to add two double values
    public double add(double a, double b) {
        return a + b;
    }
}

public class Main {
    public static void main(String[] args) {
        MathOperations math = new MathOperations();

        // Calling add method with two int arguments
        System.out.println("Sum of 2 and 3: " + math.add(2, 3));

        // Calling add method with three int arguments
        System.out.println("Sum of 2, 3, and 4: " + math.add(2, 3, 4));

        // Calling add method with two double arguments
        System.out.println("Sum of 2.5 and 3.5: " + math.add(2.5, 3.5));
    }
}

Most Important:

In OOPS, if two methods have the same name and the same parameters but different return types, it is not considered method overloading.


class Example {
    // Method with int return type
    public int add(int a, int b) {
        return a + b;
    }

    // Properly overloaded method with different parameter types
    public double add(double a, double b) {
        return a + b;
    }
}

public class Main {
    public static void main(String[] args) {
        Example example = new Example();
        
        int sumInt = example.add(5, 10); // Calls the method with int parameters
        double sumDouble = example.add(5.5, 10.5); // Calls the method with double parameters
        
        System.out.println("Sum of integers: " + sumInt);
        System.out.println("Sum of doubles: " + sumDouble);
    }
}

Encapsulation

Encapsulation is bundling the data and functions into a single unit. When we design a class, we can say we have achieved encapsulation because a class allows us to declare private data members and functions together.

Advantage

The major advantage offered by encapsulation is data security. This is because private data cannot be accessed from non-member functions outside the class, which makes our data safe.

Example:


public class Employee {
    // Private fields
    private String name;
    private int age;

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

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

    // Getter for age
    public int getAge() {
        return age;
    }

    // Setter for age
    public void setAge(int age) {
        this.age = age;
    }
}

Key Points:

  • Private data members (name and age) are not directly accessible from outside the class.
  • Public getter and setter methods provide controlled access to these private members.
  • This structure allows for data validation and additional logic in the setter methods if needed.

Inheritance in OOPS with Java

Inheritance extends the features of one class to another class. It's a fundamental concept in Object-Oriented Programming (OOPS) that allows a new class (subclass) to inherit properties and behaviors (fields and methods) from an existing class (superclass). This promotes code reuse and establishes a hierarchical relationship between classes.

  • Superclass (Parent Class): The class being inherited from.
  • Subclass (Child Class): The class that inherits from the superclass.

In Java, the 'extends' keyword is used to define a subclass.


class Superclass {
    // fields and methods
}

class Subclass extends Superclass {
    // additional fields and methods
}

Types of Inheritance in OOPS

  • Single Inheritance
  • Multiple Inheritance (Not supported by Java)
  • Multilevel Inheritance
  • Hierarchical Inheritance
  • Hybrid Inheritance (Not supported by Java)

Note: Multiple and Hybrid inheritance can be implemented in Java using interfaces.

Single Inheritance

One class inherits from another class.

Animal
Dog

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

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

Multilevel Inheritance

A class inherits from another class, which in turn inherits from another class.

Animal
Mammal
Dog

class Animal {
  public void eat() {
        System.out.println("Animal eats");
    }
}

class Mammal extends Animal {
  public void walk() {
        System.out.println("Mammal walks");
    }
}

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

Hierarchical Inheritance

Multiple classes inherit from one superclass.

Animal
Mammal
Dog

class Animal {
   public void eat() {
        System.out.println("Animal eats");
    }
}

class Mammal extends Animal {
  public void walk() {
        System.out.println("Mammal walks");
    }
}

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

Important Notes for Interview

  • When we create the parent class object, we can only access the parent class method, but when we create the child class object, it can access both its own methods and parent class methods.
  • When we create the child class object, the parent class constructor is called first, then the child class constructor.
  • Constructors cannot be inherited.
  • Parent class reference can point to the child class object, but the child class reference cannot point to the parent class object.

Method Overriding in Java

Whenever a child class contains a method with exactly the same prototype as the parent class method, we call it Method Overriding. Method overriding occurs when a subclass provides a specific implementation for a method that is already defined in its superclass. The overridden method in the subclass should have the same name, return type, and parameters as the method in the superclass.

Why do we need method overriding?

Sometimes, it happens that the functionality of a method defined in the superclass may not be suitable for the child class. Consider an Animal class with a method eat(). The method eat() might be too generic because different animals have different eating behaviors. For example, some animals are herbivores (eat plants), while others are carnivores (eat meat). By overriding the eat() method in specific animal subclasses, we can provide more appropriate implementations.

Example of Method Overriding


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

class Herbivore extends Animal {
    void eat() {
        System.out.println("Herbivore eats plants");
    }
}

class Carnivore extends Animal {
    void eat() {
        System.out.println("Carnivore eats meat");
    }
}

public class Test {
    public static void main(String[] args) {
        Animal myAnimal = new Animal();
        Animal myHerbivore = new Herbivore();
        Animal myCarnivore = new Carnivore();
        
        myAnimal.eat();    // Output: Animal eats food
        myHerbivore.eat(); // Output: Herbivore eats plants
        myCarnivore.eat(); // Output: Carnivore eats meat
    }
}

In this example, the eat() method is overridden in the Herbivore and Carnivore classes to provide specific implementations for different types of animals.

Polymorphism in OOPS

The word polymorphism is derived from a combination of (poly + morph): poly (means - multiple) and morph (means - forms), same thing with multiple forms. So when a single entity behaves differently in different situations, this is called polymorphism.

Example:

  • When we use + operator with integer then it acts like: (9+2) = 11
  • But when we use + operator with string then it acts like: ("Good" + " Morning") = Good Morning

+ operator behaves differently in different situations.

Types of Polymorphism

  1. Compile-time Polymorphism (Method Overloading)
  2. Run-time Polymorphism (Method Overriding)

1. Compile-time Polymorphism (Method Overloading)

Definition: Compile-time polymorphism, also known as method overloading, occurs when multiple methods in the same class have the same name but different signatures.

Example:


class MathOperations {
    int add(int a, int b) {
        return a + b;
    }

    double add(double a, double b) {
        return a + b;
    }

    int add(int a, int b, int c) {
        return a + b + c;
    }
}

public class TestOverloading {
    public static void main(String[] args) {
        MathOperations math = new MathOperations();
        System.out.println(math.add(2, 3));       // Output: 5
        System.out.println(math.add(2.5, 3.5));   // Output: 6.0
        System.out.println(math.add(1, 2, 3));    // Output: 6
    }
}

2. Run-time Polymorphism (Method Overriding)

Definition: Run-time polymorphism, also known as method overriding, occurs when a subclass provides a specific implementation of a method that is already defined in its superclass. The method to be executed is determined at runtime.


class Animal {
    void sound() {
        System.out.println("Animal makes a sound");
    }
}

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

class Cat extends Animal {
    void sound() {
        System.out.println("Cat meows");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal obj; //creating the reference of parent class
        obj = new Dog();
        obj.sound(); // Output: Dog barks
        obj = new Cat();
        obj.sound(); // Output: Cat meows
    }
}

Binding in OOPS

The word binding refers to the mechanism by which the compiler decides which function body will be executed for a given function call.

Types of Binding

  1. Early Binding / Compile time binding / static binding
  2. Late Binding / Runtime binding / dynamic binding

1. Early Binding

If the method being called is a static method, then Java selects the method by looking at the type of the reference used in the call and not the type of object to which the reference is pointing. Since this binding is done by Java by looking at the reference, and references in Java are created at compile time only, this kind of binding is also called compile time binding or static binding.


class Parent {
    // Static method
    public static void show() {
        System.out.println("Static method in Parent class");
    }
}

class Child extends Parent {
    // Static method
    public static void show() {
        System.out.println("Static method in Child");
    }
}

public class Main {
    public static void main(String[] args) {
        Parent p = new Child(); //parent class reference pointing the child class object
        p.show(); //parent class show method will call
    }
}

2. Late Binding

If the method being called is a non-static method (i.e., instance method), then Java selects the method by considering the type of object pointed to by the reference and not considering the type of reference itself. Since this binding is done on the basis of the object, and in Java objects are created at runtime, this binding is also called runtime binding or dynamic binding.


class Parent {
    // Non-static method
    public void display() {
        System.out.println("Non-static method in Parent class");
    }
}

class Child extends Parent {
    // Non-static method
    public void display() {
        System.out.println("Non-static method in Child");
    }
}

public class Main {
    public static void main(String[] args) {
        Parent p = new Child(); //parent class reference pointing the child class object
        p.display(); //child class display method will call
    }
}

Abstraction in OOPS

Abstraction is the concept given by OOPS which says that we must only provide essential information or features of an object to the end user and hide all other unnecessary details. This enables the programmer to implement more complex logic on top of the abstraction layer without letting the user know or even think about that.

Example:

To use a product like the Zoom app, the user is only required to know how to login, how to join the meeting, and how to use the controls like mic, video, etc. They are not required to know networking or protocols which are internally used, or client-server encryption decryption concepts.

Benefits of Abstraction

  1. Reduces Complexity: By hiding complex details, it makes the system easier to understand.
  2. Enhances Code Maintenance: Changes in the implementation do not affect the abstraction layer.
  3. Promotes Reusability: Common functionalities can be abstracted out and reused in multiple places.

Note: Abstraction is achieved using Abstract Classes and Interfaces.

Abstraction in Java allows developers to define the structure of an object without revealing the implementation details. This helps in building a robust, scalable, and maintainable codebase.

Example of Abstraction in Java


// Abstract class
abstract class Shape {
    // Abstract method (no implementation)
    public abstract double calculateArea();
    
    // Concrete method
    public void display() {
        System.out.println("This is a shape.");
    }
}

// Concrete class extending abstract class
class Circle extends Shape {
    private double radius;
    
    public Circle(double radius) {
        this.radius = radius;
    }
    
    @Override
    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}

public class Main {
    public static void main(String[] args) {
        Shape circle = new Circle(5);
        System.out.println("Area of circle: " + circle.calculateArea());
        circle.display();
    }
}

In this example, the Shape class is abstract and provides a common structure for all shapes. The Circle class extends Shape and provides a specific implementation for calculating its area.

Static Keyword in Java

The static keyword in Java is used for memory management and is applicable to variables, methods, blocks, and nested classes.

  • We can have static data or variables in Java
  • We can have static methods in Java
  • We can have static initializer blocks in Java
  • We can have static classes

1. Static Variable

  • Definition: A variable that is shared among all instances of a class. It is stored in the static memory.
  • Usage: Used for defining constants and variables that need to be shared across all instances.

Important Statements about Static variable:

  • Static variables are loaded before any non-static
  • Static variables are loaded only once
  • They have only one copy which is shared with all objects of that class

Example:


class Example {
    static int count = 0;
    
    Example() {
        count++;
    }
}

public class Main {
    public static void main(String[] args) {
        Example obj1 = new Example();
        Example obj2 = new Example();
        System.out.println(Example.count); // Output: 2
    }
}

2. Static Method

  • Definition: A method that belongs to the class rather than an instance of the class. It can be called without creating an object of the class.
  • Usage: Used for utility or helper methods that do not depend on instance variables.

Example:


class MathUtils {
    //static method
    static int add(int a, int b) {
        return a + b;
    }
}

public class Main {
    public static void main(String[] args) {
        int sum = MathUtils.add(5, 3);
        System.out.println(sum); // Output: 8
    }
}

3. Static Block

  • Definition: A block of code that gets executed when the class is loaded into memory. It is used for static initializations.
  • Usage: Used for initializing static variables or executing code that needs to run once when the class is loaded.

Example:


class Example {
    static int count;
    static {
        count = 10;
        System.out.println("Static block executed");
    }
}

public class Main {
    public static void main(String[] args) {
        System.out.println(Example.count); // Output: 10
    }
}

4. Static Nested Class

  • Definition: A class within another class that is marked with the static keyword. It does not have access to the instance variables of the outer class.
  • Usage: Used to group classes that will be used only in one place, making code more readable and maintainable.

Example:


class OuterClass {
    static class NestedStaticClass {
        void display() {
            System.out.println("Static nested class");
        }
    }
}

public class Main {
    public static void main(String[] args) {
        OuterClass.NestedStaticClass nested = new OuterClass.NestedStaticClass();
        nested.display(); // Output: Static nested class
    }
}

This Keyword in Java

The this keyword in Java is a reference variable that refers to the current object. It is used within an instance method or a constructor to refer to the current object.

  • this is automatically created by Java whenever we call a non-static method
  • this reference stores the address of the current calling object

Benefits of this keyword

  • By using this we can resolve the overlapping of instance members done by local variables of a method with the same name
  • By using this we can perform constructor chaining, but when we do this, two points should always be remembered:
    1. We can only perform constructor chaining in the body of a constructor, not in a normal method
    2. If we use this for constructor chaining, then the statement should be the first statement in the constructor body

Most Important: We must remember that we cannot assign any new object to this because this is final (constant) in Java.

Examples

→ To invoke the current class method


class Example {
    void display() {
        System.out.println("Display method");
    }
    
    void callDisplay() {
        this.display(); // calls the display() method
    }
}

public class Main {
    public static void main(String[] args) {
        Example e = new Example();
        e.callDisplay();
    }
}

→ To invoke the current class constructor (Constructor Chaining)


class Example {
    Example() {
        this(5); // calls the parameterized constructor
        System.out.println("Default Constructor");
    }
    
    Example(int num) {
        System.out.println("Parameterized Constructor: " + num);
    }
}

→ To return the current class instance


class Example {
    Example get() {
        return this; // returns the current instance
    }
    
    void display() {
        System.out.println("Display method");
    }
}

Final Keyword in Java

The final keyword in Java is used to define constants, prevent method overriding, and prevent class inheritance. Keyword final in Java can be used in 3 places:

  1. final data members
  2. final member functions or methods
  3. final class

1. Data member as final

  • If we use final with the data members of the class, we cannot change the values of these members; their value becomes constant
  • If the data has been declared as final, we are not allowed to leave it uninitialized, i.e., Java will not assign any value to final data members of the class
  • We can declare a final data member as static, but then it must be initialized at the point of declaration
  • It is advisable to declare that data in UPPER case, like:
    
    final static private double PI = 3.14;
    
    

2. Member functions or methods as final

A final method is a method which cannot be overridden by the child class.


class Parent {
    final void display() {
        System.out.println("This is a final method.");
    }
}

class Child extends Parent {
    // void display() {} // Error: cannot override the final method from Parent
}

3. Class as final

Java allows us to use the keyword final with the class. If a class is declared as final in Java, then we cannot inherit that class. Final classes can never become a superclass.


class Parent {
    final void display() {
        System.out.println("This is a final method.");
    }
}

class Child extends Parent {
    // void display() {} // Error: cannot override the final method from Parent
}

Conclusion

Using the final keyword helps in making your code more predictable and secure by enforcing immutability and consistent behavior.

Super Keyword in Java

The super keyword in Java serves several crucial roles in object-oriented programming (OOP). It is used by a child class to explicitly refer to the members of its parent class.

Using super becomes compulsory in 2 programming situations:

  • For calling parent class constructor from child class
  • For calling overridden method of parent class from the child class

Using "Super" for constructor calling

  1. In Java, whenever we create an object of child class, Java first executes the constructor of super class and then executes the constructor of sub class
  2. If the constructor of super class is not parameterized, then Java automatically calls this constructor on its own without any support from the programmer
  3. But if the constructor of parent class is parameterized, Java forces the programmer to do one thing: → programmer must call the parent class constructor inside the child class constructor body

Example 1: Non-parameterized constructor


class Animal {
    public Animal() {
        System.out.println("parent class constructor");
    }
}

class Dog extends Animal {
    public Dog() {
        // super(); // automatically by compiler
        System.out.println("child class constructor");
    }
}

public class Main {
    public static void main(String[] args) {
        Dog dog = new Dog();
    }
}

Example 2: Parameterized constructor


class Animal {
    public Animal(int a, int b) {
        System.out.println("parent class constructor");
    }
}

class Dog extends Animal {
    public Dog(int a, int b) {
        // now you have to call the parent class constructor by passing the required argument
        super(a, b);
        System.out.println("child class constructor");
    }
}

public class Main {
    public static void main(String[] args) {
        Dog dog = new Dog(2, 3);
    }
}

Super vs. This

Key Difference:

  • super refers to the superclass's methods, fields, or constructors.
  • this refers to the current class's methods, fields, or constructors.

Abstract Classes in Java

Definition

An abstract class in Java is a class that cannot be instantiated directly. It is declared using the abstract keyword and can have both abstract (unimplemented) methods and regular (implemented) methods.

Purpose

Abstract classes are used to provide a common base for subclasses to inherit and share common code while forcing them to implement certain methods.

Key Points

  • Cannot be instantiated: You cannot create an object of an abstract class directly.
  • Can have constructors: Abstract classes can have constructors, but these are only called when a subclass is instantiated.
  • Can have both abstract and non-abstract methods: Abstract methods are declared without a body, while non-abstract methods have an implementation.
  • Can have fields: Abstract classes can have fields, which can be inherited by subclasses.

Abstract Methods

Definition

A method without any implementation (body / definition) called as in a super class an abstract method.

Key Points

  • No implementation: Abstract methods do not have a body; the method body is provided by the subclass.
  • Must be in an abstract class: Any class that contains one or more abstract methods must be declared as abstract.
  • Must be overridden: Subclasses of the abstract class must override the abstract methods unless the subclass is also abstract.

Example Code


abstract class Animal {
    // Abstract method (does not have a body)
    abstract void sound();
    
    // Non-abstract method
    void sleep() {
        System.out.println("This animal is sleeping");
    }
}

// Subclass
class Dog extends Animal {
    // Providing the implementation of the abstract method
    void sound() {
        System.out.println("The dog barks");
    }
}

public class Main {
    public static void main(String[] args) {
        // Animal a = new Animal(); // This will cause an error as Animal is abstract
        Dog dog = new Dog();
        dog.sound();  // Output: The dog barks
        dog.sleep();  // Output: This animal is sleeping
    }
}

Use Cases

  • When to Use: Use abstract classes when you have a base class that should not be instantiated directly but should share common functionality with derived classes.
  • Polymorphism: Abstract classes help achieve polymorphism by allowing different subclasses to implement the abstract methods differently.

Points to Remember for Interviews

  • To make a method abstract, it is compulsory to use the keyword abstract in the method declaration.
  • An abstract method should never have any implementation in the class where it is being declared.
  • If a method is declared as abstract in the class, then the class must be prefixed with the keyword abstract. Although the reverse is not true.
  • We cannot declare any static method as abstract.
  • Constructors also cannot be made abstract.
  • Private methods also cannot be made abstract.
  • Final methods also cannot be made abstract.

Interface vs Abstract Class in Java

Interface

An interface in Java is a contract that specifies a set of abstract methods that a class must implement. It can also contain constants, default methods, and static methods.

Key Points

  • All methods are implicitly public and abstract (except for default and static methods introduced in Java 8).
  • All fields are implicitly public, static, and final.
  • A class can implement multiple interfaces, achieving a form of multiple inheritance.
  • Interfaces cannot be instantiated, but can be used as reference types.
  • Every method declared in an interface is automatically "public" and "abstract" by Java.
  • Interfaces can be inherited by child classes using the keyword "implements".
  • We can create a reference of an interface but cannot instantiate it.
  • Interface references can point to objects of its child classes, helping achieve Runtime Polymorphism.
  • If a class implements an interface, it must override all the methods of that interface, otherwise the derived class must be declared as abstract.

Interface Example


interface Animal {
    void sound(); // Abstract method (no body)
    
    default void sleep() {
        System.out.println("This animal is sleeping");
    }
}

class Dog implements Animal {
    public void sound() {
        System.out.println("The dog barks");
    }
}

When to Use

  • Interface: Use when you want to define a contract that multiple unrelated classes can implement, especially when you need to achieve multiple inheritance of type.
  • Abstract Class: Use when you want to provide a common base implementation for a group of related subclasses, especially when these subclasses share common methods or fields.

Why We Use Them

Interfaces

  • To achieve pure abstraction
  • To implement multiple inheritance of type
  • To define a contract for unrelated classes

Abstract Classes

  • To provide a common base implementation for related subclasses
  • To define a template for a group of subclasses
  • To provide default implementations while allowing overriding

Important Considerations

  1. An interface can extend multiple interfaces, while an abstract class can extend only one class (abstract or concrete).
  2. All methods in an interface are implicitly public, while abstract classes can have methods with any visibility.
  3. Interfaces are used to define a contract, while abstract classes are used to define a base implementation.
  4. Since Java 8, interfaces can have default and static methods with implementations.
  5. Abstract classes can have constructors, while interfaces cannot.
  6. A class must implement all methods of an interface (unless it's declared abstract), while it may choose not to implement all abstract methods of an abstract superclass (but then it must also be declared abstract).

Loosely Coupled and Tightly Coupled in Java

1. Introduction to Coupling:

Coupling refers to the degree of direct knowledge one class has of another in a software system. In simpler terms, it describes how closely connected two or more classes are. Coupling can be of two types:

  • Tightly Coupled
  • Loosely Coupled

Tightly Coupled

Definition: When two classes or components are heavily dependent on each other, they are considered to be tightly coupled.

Characteristics:

  • High dependency: Changes in one class often require changes in the other.
  • Reduced flexibility: The system becomes rigid and difficult to maintain or extend.
  • Poor reusability: Tightly coupled classes are hard to reuse in different contexts.

Example:


class Engine {
    public void start() {
        System.out.println("Engine started");
    }
}

class Car {
    private Engine engine;

    public Car() {
        engine = new Engine(); // Tightly coupled with Engine
    }

    public void drive() {
        engine.start();
        System.out.println("Car is moving");
    }
}

public class Main {
    public static void main(String[] args) {
        Car car = new Car();
        car.drive();
    }
}

Explanation: In the example above, the Car class directly instantiates the Engine class, leading to tight coupling. If the Engine class changes, the Car class will likely need to change as well. The Car class is not flexible, as it always requires an Engine object to function.

Loosely Coupled

Definition: When two classes or components have minimal dependencies on each other, they are considered to be loosely coupled.

Characteristics:

  • Low dependency: Classes are independent of each other, leading to better flexibility.
  • Enhanced flexibility: The system is easier to maintain, modify, and extend.
  • High reusability: Loosely coupled classes can be easily reused in different contexts.

Example:


interface Engine {
    void start();
}

class PetrolEngine implements Engine {
    public void start() {
        System.out.println("Petrol engine started");
    }
}

class DieselEngine implements Engine {
    public void start() {
        System.out.println("Diesel engine started");
    }
}

class Car {
    private Engine engine;

    public Car(Engine engine) {
        this.engine = engine; // Loosely coupled with Engine
    }

    public void drive() {
        engine.start();
        System.out.println("Car is moving");
    }
}

public class Main {
    public static void main(String[] args) {
        Engine petrolEngine = new PetrolEngine();
        Car car = new Car(petrolEngine);
        car.drive();
        
        Engine dieselEngine = new DieselEngine();
        Car carWithDiesel = new Car(dieselEngine);
        carWithDiesel.drive();
    }
}

Explanation: In the example above, the Car class depends on an Engine interface, not a specific implementation. This allows the Car class to be flexible, as it can work with any type of engine (e.g., PetrolEngine, DieselEngine). The Car class is now loosely coupled and can be easily modified or extended without affecting other parts of the code.

Covariant Return Type in Java

Covariant return types in Java allow a method in a subclass to return a type that is a subclass of the return type declared in the method of the superclass. This feature improves flexibility and eliminates the need for typecasting in many cases.

Before Java 5 (Non-Covariant Return Types):

Before Java 5, it was mandatory for an overriding method to return exactly the same type as the method it overrides, regardless of whether the class hierarchy allowed for more specific types.

After Java 5 (Covariant Return Types):

Java 5 introduced covariant return types, meaning that the return type of an overridden method can be a subclass of the return type in the superclass method. This feature applies only to non-primitive return types.

Example:


class Animal {
    public Animal get() {
        System.out.println("Animal");
        return this;
    }
}

class Dog extends Animal {
    @Override
    public Dog get() {
        System.out.println("Dog");
        return this;
    }
}

public class CovariantExample {
    public static void main(String[] args) {
        Animal animal = new Animal();
        Dog dog = new Dog();

        animal.get(); // Output: Animal
        dog.get();    // Output: Dog
    }
}

Explanation: In the superclass Animal, the get() method returns an Animal. In the subclass Dog, the get() method is overridden, and it returns a Dog. Thanks to covariant return types, the return type is allowed to be more specific (i.e., Dog, which is a subclass of Animal). The overridden method in the subclass still satisfies the contract of the superclass, but now it returns a more specific type.

Another Example:


class SuperClass {
    // Method in superclass returning List (generic type)
    public List getList() {
        System.out.println("Returning List from SuperClass");
        List list = new ArrayList<>();
        list.add("Item from SuperClass");
        return list;
    }
}

class SubClass extends SuperClass {
    // Overriding method with a more specific return type (ArrayList)
    @Override
    public ArrayList getList() {
        System.out.println("Returning ArrayList from SubClass");
        ArrayList list = new ArrayList<>();
        list.add("Item from SubClass");
        return list;
    }
}

public class CovariantReturnExample {
    public static void main(String[] args) {
        SuperClass superClass = new SuperClass();
        SubClass subClass = new SubClass();
        
        // Superclass method call
        List list1 = superClass.getList(); // Output: Returning List from SuperClass
        System.out.println(list1);
        
        // Subclass method call
        ArrayList list2 = subClass.getList(); // Output: Returning ArrayList from SubClass
        System.out.println(list2);
    }
}

Benefits of Covariant Return Types:

  • No need for casting: The returned object can be used directly without needing a cast, making the code cleaner and more readable.
  • Type safety: It allows the compiler to enforce that the return type of an overridden method is more specific, reducing runtime errors.

Association, Aggregation, and Composition in Java

Association (HAS-A)

Definition: Association refers to a relationship between two classes where one class holds a reference to objects of another class. It is often called a "HAS-A" relationship because one class has objects of another class.

Example: A car has an engine. A college has students. A house has rooms. In this relationship, one class holds a reference to another class and can access its properties and methods.


class Engine {
    public void start() {
        System.out.println("Engine starts");
    }
}

class MusicPlayer {
    public void start() {
        System.out.println("Music player starts");
    }
}

class Car {
    Engine engine = new Engine();
    MusicPlayer player = new MusicPlayer();

    public void startEngine() {
        engine.start();
    }

    public void startMusicPlayer() {
        player.start();
    }
}

public class UseCar {
    public static void main(String[] args) {
        Car car = new Car();
        car.startEngine();
        car.startMusicPlayer();
    }
}

In this example, the Car class has an Engine and a MusicPlayer, representing a "HAS-A" relationship (Association). The car can start both the engine and the music player, demonstrating how the Car class interacts with these components.

Aggregation

Definition: Aggregation is a special type of association where the associated objects can exist independently of each other. It represents a "HAS-A" relationship, but both the container and the contained objects have their own life cycle.

Example: A car has a music player. A computer has a hard drive. Even if the car object is destroyed, the music player can still exist independently.


class Department {
    private String deptName;

    public Department(String deptName) {
        this.deptName = deptName;
    }

    public String getDeptName() {
        return deptName;
    }
}

class College {
    private String collegeName;
    private Department department;

    public College(String collegeName, Department department) {
        this.collegeName = collegeName;
        this.department = department;
    }

    public void displayDetails() {
        System.out.println(collegeName + " has " + department.getDeptName() + " department.");
    }
}

public class TestAggregation {
    public static void main(String[] args) {
        Department dept = new Department("Computer Science");
        College college = new College("ABC College", dept);
        college.displayDetails();
    }
}

In this example, the College class is associated with the Department class. The department can exist independently of the college, showing an aggregation relationship.

Composition

Definition: Composition is a stronger form of association where one class contains another class, and the contained object cannot exist independently of the container. If the container object is destroyed, the contained object will also be destroyed. This is known as a "HAS-A" but a stronger relationship than aggregation.

Example: A college has departments. If the college doesn't exist, the department also cannot exist.


class Department {
    private String deptName;

    public Department(String deptName) {
        this.deptName = deptName;
    }

    public String getDeptName() {
        return deptName;
    }
}

class College {
    private String collegeName;
    private Department department;

    public College(String collegeName, Department department) {
        this.collegeName = collegeName;
        this.department = department;
    }

    public void displayDetails() {
        System.out.println(collegeName + " has " + department.getDeptName() + " department.");
    }
}

public class TestComposition {
    public static void main(String[] args) {
        Department dept = new Department("Computer Science");
        College college = new College("ABC College", dept);
        college.displayDetails();
    }
}

In this example, the College class contains the Department class. If the College object is destroyed, the Department object will also be destroyed, showing a composition relationship.

Key Differences

  • Association: General relationship where one class uses objects of another class.
  • Aggregation: A weaker relationship where the contained objects can exist independently.
  • Composition: A strong relationship where the contained objects cannot exist independently of the container.

Shallow Copy and Deep Copy in Java

1. Shallow Copy

In a shallow copy, only the fields of the object are copied. If the object has references to other objects, those references are copied as-is, meaning both the original and the copied object will refer to the same referenced objects.


class Address {
    String city;
    
    Address(String city) {
        this.city = city;
    }
}

class Person {
    String name;
    Address address;

    Person(String name, Address address) {
        this.name = name;
        this.address = address;
    }

    // Shallow copy constructor
    public Person(Person other) {
        this.name = other.name;
        this.address = other.address; // Shallow copy
    }
}

public class ShallowCopyExample {
    public static void main(String[] args) {
        Address address = new Address("New York");
        Person person1 = new Person("John", address);
        
        // Creating a shallow copy of person1
        Person person2 = new Person(person1);
        
        System.out.println(person1.address.city); // Output: New York
        System.out.println(person2.address.city); // Output: New York

        person2.address.city = "Los Angeles";
        
        System.out.println(person1.address.city); // Output: Los Angeles
        System.out.println(person2.address.city); // Output: Los Angeles
    }
}

In this example, changes to the address field in person2 also reflect in person1 because both objects share the same reference (shallow copy).

2. Deep Copy

In a deep copy, not only the object but also the objects referenced by it are copied. This creates an entirely new and independent copy.


// Deep Copy Example
class Address {
    String city;

    Address(String city) {
        this.city = city;
    }

    // Deep copy method
    public Address copy() {
        return new Address(this.city);
    }
}

class Person {
    String name;
    Address address;

    Person(String name, Address address) {
        this.name = name;
        this.address = address;
    }

    // Deep copy constructor
    public Person(Person other) {
        this.name = other.name;
        this.address = other.address.copy(); // Deep copy
    }
}

public class DeepCopyExample {
    public static void main(String[] args) {
        Address address = new Address("New York");
        Person person1 = new Person("John", address);

        // Creating a deep copy of person1
        Person person2 = new Person(person1);

        System.out.println(person1.address.city); // Output: New York
        System.out.println(person2.address.city); // Output: New York

        person2.address.city = "Los Angeles";

        System.out.println(person1.address.city); // Output: New York
        System.out.println(person2.address.city); // Output: Los Angeles
    }
}

In this example, changes to the address field in person2 do not affect person1, as each object has its own independent copy (deep copy).

Key Differences

Shallow Copy: Copies references to objects. The original and the copy share the same reference. Changes in one will reflect in the other.

Deep Copy: Copies the actual objects and their references. The original and the copy are completely independent. Changes in one do not affect the other.