A coworker of mine recently submitted a pull request introducing some simple
reporting into an application, where he was displaying an average time to
complete a survey or
"not enough results" otherwise.
Here’s the code:
def completion_time_average_in_words if team_result_completion_times.empty? "not enough results" else distance_of_time_in_words(average_team_completion_time) end end def average_team_completion_time team_result_completion_times.inject(0.0, :+)/team_result_completion_times.size end
In this case,
team_result_completion_times (not shown) can return an array
of zero or more integers representing the number of seconds it took to complete a
survey. When we have results, we display the length of time (e.g.
What struck me as interesting was that checking emptiness of
team_result_completion_times was effectively a guard clause against
dividing by zero. This results in interacting with
team_result_completion_times in two different methods, and any other places
team_result_completion_times need to also check for the length of
the array (or risk odd behavior).
Let’s take a quick tangent on division in Ruby, and why it can exhibit odd behavior.
First, let’s assume a simple case, where we don’t look carefully at types and are attempting to calculate an average:
def average(collection) collection.inject(:+)/collection.length end
Running this in a console leads to some interesting results:
>> average  # 1 - looks good! >> average [1, 3] # 2 - looks good! >> average [1, 6] # 3 - wait, what? >> average [1, 6.0] # 3.5 - looks good!
The odd behavior occurs when dividing two
Fixnums; in the last example,
Fixnum and a
Float results in a
Float, so division returns a
Float and we see the expected significant digits.
To fix this, we can update
average to use an initial value of
0.0, as it
will cast addition to a
Float for us:
def average(collection) collection.inject(0.0, :+)/collection.length end
And the results:
>> average  # 1.0 - looks good! >> average [1, 3] # 2.0 - looks good! >> average [1, 6] # 3.5 - looks good! >> average [1, 6.0] # 3.5 - looks good!
Finally, let’s look at one final case: dividing by zero.
>> average  # NaN - looks... good?
For other forms of division by zero, we see different results:
>> 1/0 # raises ZeroDivisionError >> 0/0 # raises ZeroDivisionError >> 1/0.0 # Infinity >> 0/0.0 # NaN
Let’s look at a similar solution in Elm.
completionTimeAverageInWords : List Float -> String completionTimeAverageInWords teamResultCompletionTimes = let completionTime = averageTeamCompletionTime teamResultCompletionTimes in Maybe.withDefault "not enough results" <| Maybe.map distanceOfTimeInWords completionTime averageTeamCompletionTime : List Float -> Maybe Float averageTeamCompletionTime teamResultCompletionTimes = case List.length teamResultCompletionTimes of 0 -> Nothing completionTimesLength -> Just <| (List.sum teamResultCompletionTimes)/(completionTimesLength |> toFloat)
Here, we have two functions (named similarly so it’s easier to draw parallels between the two implementations).
The difference here is that
averageTeamCompletionTime returns a
Float (take a look at the docs on Elm’s implementation of
you’re unfamiliar), and we encapsulate both when the list is empty and when
the list has values in only one spot. Instead of having both functions operate
on the length of the list to determine which string to display,
completionTimeAverageInWords relies on
averageTeamCompletionTime to do all
the work, just like we want.
Maybe in Ruby might be overkill (or it might not be?), but it
did hone in on the problem of spreading length logic across potentially
multiple methods. Instead, it might be safest to check for
nil (since, if
it’s forgotten, it’ll be easier to spot when something breaks):
def completion_time_average_in_words if result = average team_result_completion_times distance_of_time_in_words result else "not enough results" end end def average(collection) if collection.length.zero? nil else collection.inject(0.0, :+)/collection.length end end
I considered using a
case statement in Ruby checking when the value was
Float::NAN and handling the “nothing” case there; however, I realized that
forgetting to check for both paths and performing an operation “infinity”
times or transferring “infinity” dollars would be problematic.
While perhaps not as elegant as Elm’s handling of
Maybe, this seems like a
better direction to go in than introspecting on an external source of
information in two methods. This then places the responsibility of caller of
average to handle both when the average present and when it is