Example of Null Object
A more detailed example of how a Null Object mentioned in my previous post could be used for good in a more down to earth example.
As before, clearly defining what a “null” behavior in all your specific cases should be is the key towards successfully defining good behavior for your null object. Failure to do so will only cause your client code to implement handling of a special case around your Null Object. This would bring you back to the square one because the goal is to eliminate the necessity of special treatment in the first place.
The example: expenses and order
Let’s start from a factory method used to construct or retrieve objects compatible with expense_t
.
This method is used by expense report processing code, explained later.
get_expense_details(id: id_t, aux: aux_data_t) -> expense_t { // Do some querying, looking up etc. if (...) { return valid_result; } else { return Null; } }
Note that this function can return Null
when no valid expense could be returned.
Later, by repeatedly calling get_expense_details()
, we form a collection
of expenses and put them into an order
.
Then we have two functions that calculate some results from order
.
First, we have code that finds total price of all entries in order
.
Null
-entries are explicitly skipped.
get_total_price(order:list[expense_t] ) -> price_t { price_t sum = 0; for (expense in orders) { if (expense != Null) { sum = sum + expense.price(); } } return sum; }
Another function counts total size of entries inside order
.
As is with get_total_price()
, this code has to skip invalid entries.
get_total_count(order:list[expense_t] ) -> int { int count = 0; for (expense in orders) { if (expense != Null) { count = count + 1; } } return count; }
Code that produces Null forces other code to check for Null
Because order
is not guaranteed to have only valid entries, every
consumer of data generated by get_expense_details()
has to deal with
the possibility of it returning Null
.
Without it, an attempt to call method price()
will fail. Similarly,
the algorithm for total count will return an incorrect result unless
it is checking for Null
.
With more code introduced to deal with order
, every place that uses it
will be forced to deal with possible Null
contained inside it.
Removing burden from callers
Let’s try to fix it by introducing and “teaching” the Null Object to do the right thing for all its clients.
First, we rework get_expense_details()
:
get_expense_details(id: id_t, aux: auxiliary_data_t) -> expense_t { // Do some querying, looking up etc. if (...) { return valid_result; } else { return EmptyExpense(); } }
There is no long a Null
returned from it, instead, it returns an instance of EmptyExpense
.
EmptyExpense
is a real object (new or existing, we don’t care because we will not be mutating it) that implements the same interface as expense_t
does (by inheritance, duck typing or any other mechanism). Its implementation of price()
returns zero.
The total price calculation function can now safely and uniformly sum prices for all the items inside order
without fearing for any of them throwing an exception:
get_total_price(order:list[expense_t] ) -> price_t { price_t sum = 0; for (expense in orders) { sum = sum + expense.price(); } return sum; }
Because the addition operation +
works correctly with zero, and
EmptyExpense
is not null, there is no longer a need for checking for Null
.
What about get_total_count()
? A little bit of fantasy is required
to understand what is common in it with get_total_price()
. Most importantly, it allows us to understand where the literal 1
really belongs to.
Here is how the loop without null-checks looks like:
get_total_count(order:list[expense_t] ) -> int { int count = 0; for (expense in orders) { count = count + expense.count(); } return count; }
For this new method to work, expense_t
is extended to provide a new method count()
. It returns 1 for all normal expenses, while EmptyExpense
‘s implementation of count()
returns zero.
Where design appears
As with the total price calculation, get_total_count()
no longer needs
to guard against invalid inputs. But additionally, we have relocated a misplaced piece of
logic (the constant literal 1
inside get_total_count()
) into
where it can be better controlled. By adding count()
we can now express how much “weight” regular expenses
carry (= 1) and that EmptyExpense
carries no weight.
Introduction of EmptyExpense
helped us to realize that the notion
of “count” should be owned by the individual expense inside order
,
instead of being placed in the external code performing calculation over it.
Alternatives
I am not saying that this is the only way to deal with the Null
s.
Another approach could have been to sanitize (filter out) null entries
beforehand in one swipe, and only then pass the now sanitized order
further down to the calculations. That could work better in some cases.
But imagine you will need to implement compound expenses, each of which is counted not as one, but as a number of sub-expenses contained inside it.
In this case, the discovered count()
method allows to introduce a new class
compound_expense_t
which is derived from expense_t
, but it redefines the
price()
method, but will also change the count()
method to return something
different from one.
Surprisingly, the client code (get_total_count()
) will not have to be changed
at all! If we filtered out Null
s from order
, now we would have had harder time with discovering
the count()
metaphor (zero for empty expenses, one for simple expenses and different
value returned by compound expenses).