Function Types and the Strategy Pattern - Wednesday's Kotlin Kuppa #2
Exploring Strategy Pattern in Kotlin: Interfaces or Functions?
Hello, it's Mirco! Welcome to the second issue of Wednesday's Kotlin Kuppa ☕️
Every Wednesday, I'll brew a fresh, hands-on Kotlin tip just for you. Are you curious about my previous insights?Check out my take on Type Aliases in Kotlin 🔗.
Don't miss out on these weekly sips—subscribe if you haven't!
Imagine you're finishing up a grocery shopping trip 🛒. At the register, you pause for a moment. Will you use the crumpled bills from your morning coffee run, the debit card linked to your grocery budget, or tap your phone for a quick digital transaction and be on your way?
Choosing the right payment method echoes the Strategy pattern in programming. Let's see how Kotlin brings this pattern to life 💻!
The Problem
First, let's put Kotlin aside and talk about the Strategy pattern in general. It is one of the classic behavioral patterns from the timeless book "Design Patterns: Elements of Reusable Object-Oriented Software."
In the Strategy pattern, different strategies are used to achieve a specific goal, much like there are various methods to make a payment—be it with cash, credit card, or another method—where the end result is transferring a certain amount of money to a vendor. Similarly, log messages can be written to different outputs, such as the console, a file, or over the network, but the goal remains to save a string of information in a retrievable medium. Likewise, files can be compressed using ZIP, RAR, GZIP, or other formats, yet the objective is consistent: to combine and compress multiple files into a single, more manageable file.
The choice of Strategy depends on the specific requirements at hand, and this concept can be directly translated into our code. To illustrate, consider the payment process for an e-commerce application that accepts both PayPal and credit card payments. The initial implementation might appear as follows:
class ShoppingCart {
fun process(totalPrice: BigDecimal, paymentMethod: PaymentMethod): PaymentState {
return when (paymentMethod) {
PaymentMethod.CREDIT_CARD -> chargeCreditCard(totalPrice)
PaymentMethod.PAYPAL -> chargePayPal(totalPrice)
}
}
private fun chargeCreditCard(amount: BigDecimal): PaymentState {
...
}
private fun chargePayPal(amount: BigDecimal): PaymentState {
...
}
}
This is a basic example, but you've probably seen code like this a lot, especially in older programs. It seems okay at first, but it gets tough to handle after a while. Say you want to add a new way to pay with debit cards. You'd have to cram more stuff into the ShoppingCart
class, making it too complicated. Also, if you're fixing a mistake with the PayPal payment, you might mess up the credit card payment by accident, especially if they use shared steps. Plus, the more you add to this class, the more likely it is that changes will clash with each other.
Additionally, consider a scenario where your company wants to allow other businesses to create add-ons for your e-commerce app. However, if new payment methods can only be added by changing your source code, that's not an ideal situation.
With these challenges in mind, let's explore how implementing the Strategy pattern can streamline the addition of new payment methods without complicating the existing codebase.
The Strategy Pattern
The core idea of the Strategy pattern is to encapsulate logic into separate classes or functions. The pattern has three main components:
All Strategies offer the same interface to solve the task at hand in their own way.
Clients choose which Strategy to use; think of a dropdown field in the checkout view.
Context Objects receive an instance of the Strategy and execute it.
In Kotlin, you have two options to implement the Strategy pattern.
Implementation with Interfaces
Using interfaces is the textbook way of implementing strategies since most languages support it. First, we create an interface that all strategies must implement:
fun interface PaymentStrategy {
fun charge(amount: BigDecimal) : PaymentState
}
Next, we implement the interface for all the different payment methods we want to support:
class CreditCardPaymentStrategy : PaymentStrategy {
override fun charge(amount: BigDecimal) : PaymentState = PaymentState.PAID
}
class PayPalPaymentStrategy : PaymentStrategy {
override fun charge(amount: BigDecimal) = PaymentState.PAID
}
Since we created a fun interface
(functional interface), we can also create new payment methods ad-hoc, which is especially useful in tests:
val alwaysFailingStrategy = PaymentStrategy { amount -> PaymentState.FAILED }
With these classes at hand, we can reimplement the shopping cart like this:
class ShoppingCart2(private val paymentStrategy: PaymentStrategy) {
fun process(totalPrice: BigDecimal) = paymentStrategy.charge(totalPrice)
}
The benefits are easy to see:
The
ShoppingCart
code got much cleaner. 🧽Adding new payment methods is just a matter of implementing the interface.
Payment methods can be tested in isolation.
In other words, we now value the Open/Closed principle. The Strategy pattern makes it easy to extend the functionality of the shopping cart without touching existing classes. Great!
Implementation with Function Types
The second way to implement the Strategy pattern in Kotlin is to use higher-order functions. Since Kotlin treats functions as first-class citizens, we can pass them around like any other object. Here's how the pattern looks using functions:
class ShoppingCart3a(private val paymentProcessor: (BigDecimal) -> PaymentState) {
fun process(totalPrice: BigDecimal) = paymentProcessor(totalPrice)
}
As we learned in the last issue, we can add a type alias to make the API a little bit more expressive:
typealias PaymentStrategy3 = (BigDecimal) -> PaymentState
class ShoppingCart3b(private val paymentProcessor: PaymentStrategy3) {
fun process(totalPrice: BigDecimal) = paymentProcessor(totalPrice)
}
And this is how you would use it:
val creditCardPaymentProcessor = { amount: BigDecimal -> ... }
val payPalPaymentProcessor = { amount: BigDecimal -> ... }
val bitcoinPaymentProcessor = { amount: BigDecimal -> ... }
Interface vs. Function Types
When deciding between interfaces and function types for Strategy patterns in Kotlin, each approach has its merits and drawbacks. Let's delve into the comparative advantages and challenges:
Thoughts on Interfaces:
👍 Type Safety: Interfaces ensure strict adherence to defined contracts, preventing the inadvertent use of incorrect function types.
👍 Discoverability: With IDE support, interfaces and their implementations are effortlessly traceable.
👍 Reusability: Interfaces facilitate the sharing of common logic via abstract base classes, particularly useful in scenarios like cryptocurrency payment systems.
👎 Boilerplate code: Implementing interfaces can introduce unnecessary verbosity, especially for straightforward functionalities.
👎 Ridgit: Refactoring can become intricate when interfaces are extensively intertwined within the codebase.
Thoughts on Function Types:
👍 Brevity: Kotlin's function types often allow for succinct, one-liner implementations, reducing code clutter.
👍 Ease of Use: For elementary strategies, function types contribute to lower code complexity and heightened readability.
👎 Reduced Navigability: Unlike interfaces, function types can be less apparent when trying to locate usage patterns across the codebase.
👎 Refactoring Difficulty: Altering the signature of widely-used function types can complicate refactoring efforts due to their dispersed usage as lambdas or anonymous functions.
In the end, choosing between interfaces or function types is all about what works best for you and your project. I lean towards interfaces because they're really clear about types and easy to work with. But I know some folks prefer the short and adaptable style of function types. How about you? Drop your thoughts in the comments and let's chat about it! 👇
To Conclude
The Strategy pattern brings great flexibility to you code, and Kotlin offers interesting ways to implement it. It is related to the Template Method Pattern, yet based on composition and not on inheritance.
Thank you for investing your time in reading this post! 🙏 If you found it valuable, please leave a comment 💬 and share it with your network 📢.
If you find this valuable, please consider to buy me a virtual coffee on Ko-Fi!