Explore how to achieve code reuse and polymorphism in Clojure using composition and higher-order functions, replacing traditional inheritance hierarchies.
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.
Inheritance is a powerful tool in object-oriented programming (OOP) but comes with its own set of limitations:
In contrast, composition offers a more flexible and modular approach to building systems.
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 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.
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 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.
To better understand the transition from inheritance to composition, let’s compare the two approaches using a simple example: a system for calculating discounts.
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.
(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.
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.
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);
}
}
(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!")
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.
By embracing these concepts, you can build more robust and adaptable systems in Clojure, moving beyond the limitations of traditional inheritance.
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.