Java Composition Tutorial

Avatar

By squashlabs, Last Updated: October 26, 2023

Java Composition Tutorial

Introduction to Composition

Composition is a fundamental concept in object-oriented programming that allows objects to be composed of other objects. It enables the creation of complex structures by combining simpler objects, promoting code reuse and modularity. In Java, composition is achieved by creating classes that contain references to other classes as instance variables.

Related Article: How To Parse JSON In Java

Basics of Composition

In composition, a class is composed of one or more objects of other classes. These objects are typically instantiated within the class and can be accessed and used by its methods. The composed objects become an integral part of the containing class, and their behavior can be leveraged to implement the desired functionality.

Let’s consider an example where we have a Car class that is composed of an Engine and a set of Wheels. The Car class represents the whole object, while the Engine and Wheels classes represent its parts. Here’s an example implementation:

class Car {
    private Engine engine;
    private Wheel[] wheels;

    public Car(Engine engine, Wheel[] wheels) {
        this.engine = engine;
        this.wheels = wheels;
    }

    // Other methods and functionalities
}

class Engine {
    // Engine implementation
}

class Wheel {
    // Wheel implementation
}

In this example, the Car class contains an instance variable engine of type Engine and an array of wheels of type Wheel[]. Through composition, the Car class can leverage the functionality provided by the Engine and Wheel classes to perform operations such as starting the engine or rotating the wheels.

Composition vs Inheritance

Composition and inheritance are two key concepts in object-oriented programming. While both approaches facilitate code reuse and promote modularity, they differ in their mechanisms and usage.

Inheritance allows a class to inherit properties and behaviors from a parent class, forming an “is-a” relationship. On the other hand, composition enables a class to be composed of other objects, forming a “has-a” relationship.

Composition offers more flexibility and is generally preferred over inheritance in many scenarios. It avoids the potential drawbacks of deep inheritance hierarchies, such as tight coupling, fragile base class problem, and limited code reuse. With composition, classes can be easily extended and modified by changing the composed objects, promoting better encapsulation and maintainability.

Consider the following example:

class Car extends Vehicle {
    // Car-specific implementation
}

In this example, the Car class inherits from the Vehicle class, implying that a car is a type of vehicle. However, this approach can become limiting if we want to introduce new types of vehicles or modify the behavior of specific vehicle types. With composition, we can achieve greater flexibility:

class Car {
    private Vehicle vehicle;

    public Car(Vehicle vehicle) {
        this.vehicle = vehicle;
    }

    // Car-specific methods and functionalities
}

Now, the Car class can be composed of a Vehicle object, allowing for easy modification and extension of the car’s behavior without affecting other vehicle types.

Principles of Composition

When using composition in Java, it is important to follow certain principles to ensure effective and maintainable code:

1. Favor composition over inheritance: As mentioned earlier, composition provides more flexibility and promotes code reuse without the limitations of inheritance. It allows for easy modification and extension of functionality.

2. Use interfaces and abstractions: By programming to interfaces and using abstractions, you can decouple the code from specific implementations. This makes it easier to switch or modify composed objects without affecting the containing class.

3. Encapsulate composed objects: Encapsulating the composed objects within the containing class ensures that their internal state and behavior are not directly accessible from outside. This promotes encapsulation and information hiding, making the code more robust and maintainable.

4. Use dependency injection: Instead of instantiating composed objects within the containing class, consider using dependency injection to inject them from external sources. This allows for better testability, modularity, and separation of concerns.

Related Article: How To Convert Array To List In Java

Real World Example: Composition in an eCommerce Application

In the context of an eCommerce application, composition can be applied to model the relationships between different entities, such as products, orders, and customers.

Let’s consider an example where we have a Order class that is composed of Product objects and associated with a Customer object. Here’s an example implementation:

class Order {
    private List<Product> products;
    private Customer customer;

    public Order(List<Product> products, Customer customer) {
        this.products = products;
        this.customer = customer;
    }

    // Other methods and functionalities
}

class Product {
    // Product implementation
}

class Customer {
    // Customer implementation
}

In this example, the Order class is composed of a list of Product objects and associated with a Customer object. By using composition, the eCommerce application can manage orders with multiple products and associate them with the corresponding customer.

Use Case: Using Composition in Database Connections

In the context of database connections, composition can be used to manage the lifecycle of connections and provide a clean interface to interact with the database.

Let’s consider an example where we have a DatabaseConnection class that is composed of a Connection object from a database driver library. Here’s an example implementation using the JDBC library for connecting to a MySQL database:

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

class DatabaseConnection {
    private Connection connection;

    public DatabaseConnection(String url, String username, String password) throws SQLException {
        this.connection = DriverManager.getConnection(url, username, password);
    }

    public void close() throws SQLException {
        connection.close();
    }

    // Other methods and functionalities to interact with the database
}

In this example, the DatabaseConnection class is composed of a Connection object. The constructor initializes the connection using the provided URL, username, and password. The close() method is used to close the connection when it is no longer needed.

By using composition, the DatabaseConnection class encapsulates the complexity of managing the database connection and provides a clean interface to interact with the database. This promotes better separation of concerns and makes the code more maintainable.

Use Case: Composition in User Authentication

User authentication is another area where composition can be effectively used to manage the authentication process and provide a flexible solution.

Let’s consider an example where we have a UserAuthenticator class that is composed of one or more Authenticator objects. Each Authenticator represents a specific authentication method, such as username/password authentication, social login authentication, or multi-factor authentication.

interface Authenticator {
    boolean authenticate(String username, String password);
}

class UsernamePasswordAuthenticator implements Authenticator {
    public boolean authenticate(String username, String password) {
        // Username/password authentication logic
    }
}

class SocialLoginAuthenticator implements Authenticator {
    public boolean authenticate(String username, String password) {
        // Social login authentication logic
    }
}

class MultiFactorAuthenticator implements Authenticator {
    public boolean authenticate(String username, String password) {
        // Multi-factor authentication logic
    }
}

class UserAuthenticator {
    private List<Authenticator> authenticators;

    public UserAuthenticator(List<Authenticator> authenticators) {
        this.authenticators = authenticators;
    }

    public boolean authenticate(String username, String password) {
        for (Authenticator authenticator : authenticators) {
            if (authenticator.authenticate(username, password)) {
                return true;
            }
        }
        return false;
    }
}

In this example, the UserAuthenticator class is composed of a list of Authenticator objects. The authenticate() method iterates through the authenticators and attempts to authenticate the user using each method until a successful authentication is achieved.

By using composition, the UserAuthenticator class provides a flexible solution that can support multiple authentication methods. New authenticators can be easily added or modified without affecting the overall authentication process.

Related Article: How To Iterate Over Entries In A Java Map

Best Practice: Composition Over Inheritance

The principle of “composition over inheritance” suggests that favoring composition is often a better design choice than relying solely on inheritance. This best practice promotes code reuse, modularity, and flexibility.

Inheritance can lead to tight coupling and make the codebase more rigid and difficult to maintain. By using composition, classes can be easily extended and modified by changing the composed objects. This promotes better encapsulation, separation of concerns, and code reuse.

Consider the following example:

class Vehicle {
    // Vehicle implementation
}

class Car extends Vehicle {
    // Car-specific implementation
}

In this example, the Car class inherits from the Vehicle class. However, if we want to introduce new types of vehicles or modify the behavior of specific vehicle types, it can become limiting. By using composition, we can achieve greater flexibility:

class Car {
    private Vehicle vehicle;

    public Car(Vehicle vehicle) {
        this.vehicle = vehicle;
    }

    // Car-specific methods and functionalities
}

Now, the Car class can be composed of a Vehicle object, allowing for easy modification and extension of the car’s behavior without affecting other vehicle types.

Best Practice: Ensuring Robustness through Composition

In addition to promoting code reuse and flexibility, composition can also contribute to the robustness of a software system. By encapsulating composed objects and using interfaces or abstractions, you can ensure that the code is resilient to changes and errors.

Consider the following example:

interface Logger {
    void log(String message);
}

class FileLogger implements Logger {
    public void log(String message) {
        // Log message to a file
    }
}

class ConsoleLogger implements Logger {
    public void log(String message) {
        // Log message to the console
    }
}

class Application {
    private Logger logger;

    public Application(Logger logger) {
        this.logger = logger;
    }

    public void run() {
        try {
            // Application logic
        } catch (Exception e) {
            logger.log("Error occurred: " + e.getMessage());
        }
    }
}

In this example, the Application class is composed of a Logger object. The run() method of the application logs any errors that occur during execution using the logger. By using composition and programming to an interface (Logger), the application is resilient to changes in the logging implementation. It can easily switch between different loggers (e.g., FileLogger, ConsoleLogger) without modifying the application code.

By encapsulating composed objects and programming to abstractions, you can ensure that the code is more robust and adaptable to changes and errors. This promotes better error handling and maintainability.

Code Snippet: Implementing Composition in a Class

To implement composition in a class, you need to define instance variables that represent the composed objects and use them within the class methods to perform desired operations.

Here’s an example of implementing composition in a class representing a House:

class House {
    private Room kitchen;
    private Room livingRoom;
    private Room bedroom;

    public House(Room kitchen, Room livingRoom, Room bedroom) {
        this.kitchen = kitchen;
        this.livingRoom = livingRoom;
        this.bedroom = bedroom;
    }

    public void enterHouse() {
        System.out.println("Entering the house...");
        kitchen.prepareFood();
        livingRoom.watchTV();
        bedroom.sleep();
    }

    // Other methods and functionalities
}

class Room {
    public void prepareFood() {
        System.out.println("Preparing food in the room...");
    }

    public void watchTV() {
        System.out.println("Watching TV in the room...");
    }

    public void sleep() {
        System.out.println("Sleeping in the room...");
    }
}

In this example, the House class is composed of three Room objects: kitchen, livingRoom, and bedroom. The enterHouse() method demonstrates how the composed objects can be used to perform specific operations. When calling enterHouse(), the house is entered, and actions are performed in each room.

By using composition, the House class can leverage the functionality provided by the Room objects to perform operations specific to each room, promoting modularity and code reuse.

Related Article: How To Split A String In Java

Code Snippet: Accessing Methods through Composition

To access methods of composed objects through composition, you can use the instance variables representing the composed objects and invoke their methods within the containing class.

Here’s an example that demonstrates accessing methods of composed objects in a Car class:

class Car {
    private Engine engine;
    private Wheel[] wheels;

    public Car(Engine engine, Wheel[] wheels) {
        this.engine = engine;
        this.wheels = wheels;
    }

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

    public void rotateWheels() {
        for (Wheel wheel : wheels) {
            wheel.rotate();
        }
    }

    // Other methods and functionalities
}

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

class Wheel {
    public void rotate() {
        System.out.println("Wheel rotated");
    }
}

In this example, the Car class is composed of an Engine object and an array of Wheel objects. The startEngine() method invokes the start() method of the Engine object, and the rotateWheels() method iterates through the wheels array and invokes the rotate() method of each Wheel object.

By using composition, the Car class can access and utilize the methods of the composed objects to perform operations specific to the car, promoting modularity and encapsulation.

Code Snippet: Overriding Methods in Composition

In composition, methods of composed objects can be overridden in the containing class to customize behavior or add additional functionality.

Here’s an example that demonstrates overriding a method of a composed object in a Car class:

class Car {
    private Engine engine;

    public Car(Engine engine) {
        this.engine = engine;
    }

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

    // Other methods and functionalities

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

    class ElectricEngine extends Engine {
        @Override
        public void start() {
            super.start();
            System.out.println("Electric engine started");
        }
    }
}

In this example, the Car class is composed of an Engine object. The startEngine() method invokes the start() method of the Engine object. The Engine class defines the default behavior of starting an engine, while the ElectricEngine class extends the Engine class and overrides the start() method to add additional functionality specific to an electric engine.

By using composition and method overriding, the Car class can customize the behavior of the composed Engine object to handle different engine types.

Code Snippet: Composition with Interfaces

Composition with interfaces allows for greater flexibility and decoupling of code from specific implementations. By programming to interfaces, classes can be composed of objects that implement the same interface, making it easier to switch or modify composed objects without affecting the containing class.

Here’s an example that demonstrates composition with interfaces in a Car class:

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;
    }

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

    // Other methods and functionalities
}

In this example, the Engine interface defines the contract for an engine, and the PetrolEngine and DieselEngine classes implement the Engine interface. The Car class is composed of an Engine object, which can be either a PetrolEngine or a DieselEngine object.

By using composition with interfaces, the Car class can be easily configured to use different engine types by providing the appropriate implementation of the Engine interface. This promotes better modularity, code reuse, and flexibility.

Related Article: How To Convert Java Objects To JSON With Jackson

Code Snippet: Composition in Multithreaded Environment

When using composition in a multithreaded environment, it is important to ensure thread safety and proper synchronization to avoid race conditions and other concurrency issues.

Here’s an example that demonstrates composition in a multithreaded environment using the ExecutorService class from the java.util.concurrent package:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class Task implements Runnable {
    public void run() {
        // Task implementation
    }
}

class TaskExecutor {
    private ExecutorService executorService;

    public TaskExecutor() {
        this.executorService = Executors.newFixedThreadPool(5);
    }

    public void executeTask(Task task) {
        executorService.execute(task);
    }

    public void shutdown() {
        executorService.shutdown();
    }
}

In this example, the TaskExecutor class is composed of an ExecutorService object, which is responsible for executing tasks in a multithreaded environment. The executeTask() method submits a Task object to the executor service for execution, and the shutdown() method shuts down the executor service when it is no longer needed.

By using composition and the ExecutorService class, the TaskExecutor class can leverage the thread management capabilities provided by the executor service to handle concurrent execution of tasks.

Performance Considerations: Composition and Memory Usage

When using composition, it is important to consider the impact on memory usage, especially when dealing with large-scale systems or resource-constrained environments.

Composition involves creating objects and holding references to them within other objects. This can result in increased memory usage, as each composed object requires memory allocation and storage.

To mitigate excessive memory usage, consider the following best practices:

1. Minimize unnecessary composition: Only compose objects when it is necessary for the desired functionality. Avoid excessive nesting of objects or creating overly complex object hierarchies.

2. Use lazy initialization: Delay the instantiation of composed objects until they are actually needed. This can help reduce memory usage by creating objects on-demand rather than upfront.

3. Implement object pooling: Reuse already created objects instead of creating new objects when possible. This can be particularly useful in scenarios where objects are frequently created and destroyed.

4. Employ efficient data structures: Choose appropriate data structures to store composed objects. For example, use arrays or lists where appropriate to efficiently store collections of objects.

Performance Considerations: Composition and CPU Usage

In addition to memory usage, composition can also impact CPU usage, especially when dealing with computationally intensive operations or frequent method invocations.

When using composition, each method invocation on a composed object adds an additional layer of method dispatch and execution, which can result in increased CPU usage.

To optimize CPU usage in composition-heavy code, consider the following best practices:

1. Minimize unnecessary method invocations: Only invoke methods on composed objects when necessary. Avoid redundant or unnecessary method calls that do not contribute to the desired functionality.

2. Use method memoization: Cache the results of expensive method invocations on composed objects to avoid recomputation. This can be particularly useful in scenarios where the same method is invoked multiple times with the same arguments.

3. Optimize method implementations: Optimize the implementation of methods in composed objects to reduce their computational complexity. Employ efficient algorithms and data structures to improve the performance of critical operations.

4. Employ parallelization: If applicable, parallelize computationally intensive operations across multiple threads or processes to leverage the full processing power of the system.

Related Article: Storing Contact Information in Java Data Structures

Performance Considerations: Composition and Speed

Composition can have an impact on the speed of an application, particularly in terms of method dispatch and object traversal. While the performance impact can vary depending on the specific implementation and use case, it is important to be mindful of potential bottlenecks.

To optimize speed in composition-heavy code, consider the following best practices:

1. Minimize method dispatch overhead: Reduce the number of method invocations and method dispatches by carefully designing the composition structure and minimizing unnecessary method calls.

2. Optimize object traversal: When traversing composed objects, use efficient algorithms and data structures that minimize the number of object lookups or iterations. This can help improve the speed of operations that involve accessing or manipulating composed objects.

3. Employ caching and memoization: Cache frequently accessed or computed values to avoid redundant computations and improve overall speed. This can be particularly useful in scenarios where the same values are accessed or computed multiple times.

4. Profile and optimize critical sections: Identify and profile critical sections of the code that are performance-sensitive. Use profiling tools to identify potential bottlenecks and optimize the corresponding code to improve speed.

Advanced Technique: Composition and Design Patterns

Composition can be effectively used in conjunction with various design patterns to solve complex software engineering problems. By leveraging composition, design patterns can provide elegant and flexible solutions to common design challenges.

Here are a few examples of design patterns that make use of composition:

1. Strategy Pattern: The strategy pattern allows you to define a family of algorithms, encapsulate each one as a separate class, and make them interchangeable. Composition can be used to inject the appropriate strategy object into the context class.

2. Observer Pattern: The observer pattern defines a one-to-many dependency between objects, where changes in one object trigger updates in dependent objects. Composition can be used to maintain a collection of observers and manage their registration and notification.

3. Decorator Pattern: The decorator pattern allows behavior to be added to an individual object dynamically. Composition is used to wrap the original object with a series of decorators, each adding a specific behavior.

4. Composite Pattern: The composite pattern allows you to compose objects into tree structures to represent part-whole hierarchies. Composition is used to represent the relationships between composite and leaf objects.

By combining composition with design patterns, you can create flexible, modular, and extensible software designs that are easier to understand, maintain, and evolve.

Advanced Technique: Composition in Distributed Systems

Composition can be applied to distributed systems to build scalable and modular architectures. By composing small, independent services, you can create complex systems that are easier to develop, deploy, and manage.

In a distributed system, each service encapsulates a specific functionality and communicates with other services through well-defined interfaces. This composition of services enables the system to scale horizontally, handle failures gracefully, and evolve independently.

Here are a few key considerations when using composition in distributed systems:

1. Service Interface Design: Define clear and well-documented interfaces for each service to promote loose coupling and interoperability. Use technologies such as REST or gRPC to facilitate communication between services.

2. Service Discovery and Orchestration: Utilize service discovery mechanisms to locate and connect services dynamically. Use orchestration tools like Kubernetes or Docker Swarm to manage the deployment and lifecycle of services.

3. Cross-Service Communication: Design communication patterns between services, such as synchronous request-response, asynchronous messaging, or event-driven architectures. Use message queues, publish-subscribe systems, or event-driven frameworks to enable seamless communication.

4. Fault Tolerance and Resilience: Implement fault tolerance mechanisms, such as retries, circuit breakers, and bulkheads, to handle failures and ensure the system remains operational even in the presence of partial failures.

By leveraging composition in distributed systems, you can create scalable and modular architectures that are resilient, flexible, and easier to maintain.

Related Article: How to Convert JSON String to Java Object

Error Handling with Composition

Error handling with composition involves managing errors that occur within composed objects and propagating them to the containing class or handling them locally.

Here are a few best practices for error handling with composition:

1. Propagating Errors: When an error occurs within a composed object, propagate the error to the containing class by throwing an exception or returning an error code. This allows the containing class to handle the error appropriately.

2. Error Handling Strategies: Define error handling strategies for the containing class to handle propagated errors. This can include logging the error, retrying the operation, or providing fallback behavior.

3. Error Recovery in Composed Objects: Implement error recovery mechanisms within composed objects to handle errors locally and provide appropriate fallback behavior. This can involve retrying the operation, using default values, or rolling back changes.

4. Consistent Error Reporting: Use consistent error reporting mechanisms across composed objects to provide clear and meaningful error messages. This helps with debugging and identifying the root cause of errors.

How to Generate Random Integers in a Range in Java

Generating random integers within a specific range in Java is made easy with the Random class. This article explores the usage of java.util.Random and ThreadLocalRandom... read more

How to Reverse a String in Java

This article serves as a guide on reversing a string in Java programming language. It explores two main approaches: using a StringBuilder and using a char array.... read more

How to Retrieve Current Date and Time in Java

Obtain the current date and time in Java using various approaches. Learn how to use the java.util.Date class and the java.time.LocalDateTime class to retrieve the... read more

Java Equals Hashcode Tutorial

Learn how to implement equals and hashcode methods in Java. This tutorial covers the basics of hashcode, constructing the equals method, practical use cases, best... read more

How To Convert String To Int In Java

How to convert a string to an int in Java? This article provides clear instructions using two approaches: Integer.parseInt() and Integer.valueOf(). Learn the process and... read more

Java Hashmap Tutorial

This article provides a comprehensive guide on efficiently using Java Hashmap. It covers various use cases, best practices, real-world examples, performance... read more