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 EmptyExpenses 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 Nulls. 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 Nulls 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).


Written by Grigory Rechistov in Uncategorized on 09.01.2024. Tags: null object, polymorphism,


Copyright © 2024 Grigory Rechistov