The Other Trade-off: Separating Data and Behaviour
Wednesday 27 January 2016 at 08:00 GMT
True object-oriented programming brings with it a set of trade-offs: while you couple data to behaviour, you get a large number of advantages as well. Now let’s look at it from the other side.
Yesterday, in our Account
class, we had a Transaction
type, one of the implementations being Withdrawal
. Let’s take a look at that in broader detail.
class Withdrawal implements Transaction {
private final Money amount;
private final LocalDate date;
// constructor, getters (no setters), equals, hashCode and toString.
}
Now, that’s not very useful in our object-oriented world, as it has no behaviour—it’s just a holder of some data. One useful behaviour of withdrawals might be to be treated as a transformation on a balance—that is, they might apply themselves to a balance to create a balance that had the amount deducted.
interface Transaction {
Money apply(Money balance);
}
class Withdrawal implements Transaction {
private final Money amount;
private final LocalDate date;
// constructor
@Override
public Money apply(Money balance) {
return balance.minus(amount);
}
}
Of course, the Deposit
class would look pretty similar. Great, job done. No exposed getters, and it does the right thing. And we can add more transaction types trivially, just by implementing the apply
method!
Wait. Are there more transaction types?
So far, there are Deposit
and Withdrawal
objects. We could also name them Debit
and Credit
. Are there any others? I can’t think of any in this circumstance. When I try to think of extended functionality related to transactions, I think of new behaviours. For example, what if we wanted to view all transactions for a given date, or a given month? Perhaps we might like to see only withdrawals, so we can get a feel for how much spending there is? Maybe we want to tie together a withdrawal in a customer’s current account with a deposit in their savings account so we can understand how they’re saving money?
Lots of potential behaviours, but the types of data are few. This means that adding a new feature probably requires changing three different files, which breaks the open-closed principle… not exactly great.
Now let’s look at an alternative implementation. I’ll be using Scala for these examples, but I will try and explain them for people who are not so familiar with the language or functional concepts.
Here’s our original classes, but in Scala this time:
sealed trait Transaction
case class Deposit(amount: Money, date: LocalDate) extends Transaction
case class Withdrawal(amount: Money, date: LocalDate) extends Transaction
This is simply a much terser version of the same thing (Scala’s good at terse). We have a trait
(which you can pretend is an interface
), Transaction
, and two implementations, each of which have two immutable properties, amount
and date
. In Scala, mutability is the exception, and so they have no setters.
All of that is fairly routine. The important part is the keyword sealed
. What this means is that this trait
can only be extended by classes in the same file. We can’t extend it anywhere else. As we only have two types of transaction and aren’t planning on adding any more, this isn’t a problem.
Now, of course, that behaviour needs to go somewhere, so let’s create a function that loops through a sequence of transactions and applies each one to a balance:
class Transactions(transactions: Seq[Transaction]) {
def applyTransactions(balance: Money): Money = {
var currentBalance = balance
transactions.foreach {
case Deposit(amount, _) =>
currentBalance += amount
case Withdrawal(amount, _) =>
currentBalance -= amount
}
currentBalance
}
}
A few things. First of all, case
is much more powerful in Scala than in Java, allowing us to deconstruct objects according to their construction. Secondly, that block passed to foreach
is really a function; you can think of it as a lambda without an arrow. Thirdly, Scala has operator overloading, so +=
and -=
are really calling methods named +
and -
.
In case you’re curious about the functional equivalent, you can do the same thing with foldRight
:
class Transactions(transactions: Seq[Transaction]) {
def applyTransactions(balance: Money): Money = {
transactions.foldRight(balance) { (currentBalance, transaction) =>
transaction match {
case Deposit(amount, _) => currentBalance + amount
case Withdrawal(amount, _) => currentBalance - amount
}
}
}
}
Either way, this function applies each transaction in turn to the original balance to calculate the new balance.
Now, let’s add a new feature. Perhaps we want to know all the transactions on a given date:
def transactionsOn(date: LocalDate): Seq[Transaction] =
transactions.filter {
case Deposit(_, transactionDate) => date == transactionDate
case Withdrawal(_, transactionDate) => date == transactionDate
}
Because the date is not part of the interface, even though both the Deposit
and the Withdrawal
types have dates, we need to decompose them. We could add the date to the Transaction
trait to make this easier, but doing the same thing with amount
would be a bad idea, because the amount really does mean a different thing for each implementation.
Or maybe we’d like to know about just the amount being spent:
def amountWithdrawn: Money =
transactions
.collect { case withdrawal: Withdrawal => withdrawal }
.map(_.amount)
.sum
This function filters for withdrawals, extracts the amounts from the withdrawals, and then sums them to get the total amount withdrawn.
Notice that these functions are independent of any Transaction
object. They generally operate on sequences of transactions, and so it makes sense that they exist on the Transactions
class. Each cares about a different facet of the two implementations of Transaction
. What’s more, it was easy to add each one: we just had to add more code, not change existing code and potentially break existing functionality. This is something that an object-oriented design would have made harder. By contrast, adding a new implementation of Transaction
would be quite difficult, as every function would need to be changed to add a new case to the pattern-match.
Just like object-oriented programming, structured (and therefore imperative or functional) programming has a set of trade-offs. Structured programming allows us to quickly add behaviour to fixed data representations, but adding new data representations is much trickier. Often, this is what we need, and it would be a misuse of object-oriented design to push the behaviour too deep into classes. Good software development includes designing for maintainability, and this means we need to make predictions about what might change in the future, then design accordingly.
If you enjoyed this post, you can subscribe to this blog using Atom.
Maybe you have something to say. You can email me or toot at me. I love feedback. I also love gigantic compliments, so please send those too.
Please feel free to share this on any and all good social networks.
This article is licensed under the Creative Commons Attribution 4.0 International Public License (CC-BY-4.0).