Browse Clojure Foundations for Java Developers

Replacing Inheritance with Composition in Clojure

Explore how to achieve code reuse and polymorphism in Clojure using composition and higher-order functions, replacing traditional inheritance hierarchies.

11.4.3 Replacing Inheritance with Composition§

As experienced Java developers, we are accustomed to using inheritance as a primary mechanism for code reuse and polymorphism. However, in Clojure, a functional programming language, we can achieve these goals more effectively through composition and higher-order functions. This section will guide you through the transition from inheritance-based designs to composition-centric approaches in Clojure, leveraging its unique features such as protocols and multimethods.

Understanding the Limitations of Inheritance§

Inheritance is a powerful tool in object-oriented programming (OOP) but comes with its own set of limitations:

  • Tight Coupling: Inheritance creates a strong relationship between parent and child classes, making changes in the parent class potentially disruptive to child classes.
  • Fragile Base Class Problem: Modifications to a base class can inadvertently affect all derived classes, leading to unexpected behaviors.
  • Limited Flexibility: Inheritance hierarchies can become rigid, making it difficult to adapt to new requirements without significant refactoring.

In contrast, composition offers a more flexible and modular approach to building systems.

Embracing Composition in Clojure§

Composition in Clojure involves building complex functionality by combining simpler, reusable components. This approach aligns well with Clojure’s functional programming paradigm, where functions are first-class citizens.

Higher-Order Functions§

Higher-order functions are functions that take other functions as arguments or return them as results. They are a cornerstone of functional programming and enable powerful composition patterns.

(defn apply-discount [discount-fn price]
  (discount-fn price))

(defn percentage-discount [percent]
  (fn [price]
    (* price (- 1 (/ percent 100)))))

;; Usage
(def ten-percent-off (percentage-discount 10))
(apply-discount ten-percent-off 100) ; => 90

In this example, apply-discount is a higher-order function that applies a discount function to a price. The percentage-discount function returns a new function that calculates the discounted price, demonstrating how functions can be composed to achieve desired behaviors.

Protocols and Polymorphism§

Clojure’s protocols provide a way to define a set of functions that can be implemented by different data types, offering polymorphic behavior without inheritance.

(defprotocol Discountable
  (apply-discount [this price]))

(defrecord PercentageDiscount [percent]
  Discountable
  (apply-discount [this price]
    (* price (- 1 (/ percent 100)))))

(defrecord FixedDiscount [amount]
  Discountable
  (apply-discount [this price]
    (- price amount)))

;; Usage
(def ten-percent (->PercentageDiscount 10))
(def five-dollars (->FixedDiscount 5))

(apply-discount ten-percent 100) ; => 90
(apply-discount five-dollars 100) ; => 95

Here, the Discountable protocol defines a polymorphic apply-discount function. Different discount strategies are implemented using records, each providing its own implementation of the protocol.

Multimethods for Flexible Dispatch§

Multimethods in Clojure allow for flexible method dispatch based on arbitrary criteria, not just the type of a single argument.

(defmulti discount-type (fn [discount price] (:type discount)))

(defmethod discount-type :percentage [discount price]
  (* price (- 1 (/ (:percent discount) 100))))

(defmethod discount-type :fixed [discount price]
  (- price (:amount discount)))

;; Usage
(def percentage-discount {:type :percentage :percent 10})
(def fixed-discount {:type :fixed :amount 5})

(discount-type percentage-discount 100) ; => 90
(discount-type fixed-discount 100) ; => 95

Multimethods provide a powerful mechanism for achieving polymorphic behavior based on multiple criteria, offering greater flexibility than traditional inheritance.

Comparing Composition and Inheritance§

To better understand the transition from inheritance to composition, let’s compare the two approaches using a simple example: a system for calculating discounts.

Java Inheritance Example§

abstract class Discount {
    abstract double apply(double price);
}

class PercentageDiscount extends Discount {
    private final double percent;

    public PercentageDiscount(double percent) {
        this.percent = percent;
    }

    @Override
    double apply(double price) {
        return price * (1 - percent / 100);
    }
}

class FixedDiscount extends Discount {
    private final double amount;

    public FixedDiscount(double amount) {
        this.amount = amount;
    }

    @Override
    double apply(double price) {
        return price - amount;
    }
}

In Java, we define an abstract Discount class and extend it to create specific discount types. This approach uses inheritance to achieve polymorphism.

Clojure Composition Example§

(defprotocol Discount
  (apply-discount [this price]))

(defrecord PercentageDiscount [percent]
  Discount
  (apply-discount [this price]
    (* price (- 1 (/ percent 100)))))

(defrecord FixedDiscount [amount]
  Discount
  (apply-discount [this price]
    (- price amount)))

In Clojure, we use protocols and records to achieve the same polymorphic behavior. This approach is more flexible and decoupled, allowing for easier modifications and extensions.

Advantages of Composition Over Inheritance§

  • Flexibility: Composition allows for more flexible designs, as components can be easily swapped or combined.
  • Reusability: Functions and protocols can be reused across different contexts without the constraints of an inheritance hierarchy.
  • Decoupling: Systems built with composition are less tightly coupled, making them easier to maintain and extend.

Practical Exercise: Refactor an Inheritance-Based Design§

Let’s refactor a simple Java inheritance-based design into a Clojure composition-based design. Consider a system with different types of notifications: email and SMS.

Java Inheritance Example§

abstract class Notification {
    abstract void send(String message);
}

class EmailNotification extends Notification {
    @Override
    void send(String message) {
        System.out.println("Sending email: " + message);
    }
}

class SMSNotification extends Notification {
    @Override
    void send(String message) {
        System.out.println("Sending SMS: " + message);
    }
}

Clojure Composition Example§

(defprotocol Notification
  (send-notification [this message]))

(defrecord EmailNotification []
  Notification
  (send-notification [this message]
    (println "Sending email:" message)))

(defrecord SMSNotification []
  Notification
  (send-notification [this message]
    (println "Sending SMS:" message)))

;; Usage
(def email (->EmailNotification))
(def sms (->SMSNotification))

(send-notification email "Hello via Email!")
(send-notification sms "Hello via SMS!")

Try It Yourself§

Experiment with the Clojure code by adding a new type of notification, such as a push notification. Implement it using the Notification protocol and test it with different messages.

Summary and Key Takeaways§

  • Composition over Inheritance: Clojure encourages composition over inheritance, promoting flexibility and modularity.
  • Protocols and Multimethods: Use protocols for polymorphism and multimethods for flexible dispatch based on multiple criteria.
  • Higher-Order Functions: Leverage higher-order functions for powerful composition patterns.

By embracing these concepts, you can build more robust and adaptable systems in Clojure, moving beyond the limitations of traditional inheritance.

Further Reading§

Exercises§

  1. Refactor a Java class hierarchy into a Clojure composition-based design.
  2. Implement a new feature using protocols and multimethods.
  3. Explore the use of higher-order functions to simplify complex logic.

Now that we’ve explored how to replace inheritance with composition in Clojure, let’s apply these concepts to refactor and enhance your existing Java applications.

Quiz: Mastering Composition in Clojure§