Wednesday, October 14, 2009

Ruby's inject(): Putting the 'Fun' in 'Functional Programming' Since ... Oh, About 4:15 This Morning

Yesterday at work, I ran across the need to convert a number of seconds into its equivalent in days, hours, minutes, and seconds. The canonical imperative way to do this is something like:
seconds = 356521
days = seconds / (24 * 60 * 60)
seconds = seconds % (24 * 60 * 60)
hours = seconds / (60 * 60)
seconds = seconds % (60 * 60)
minutes = seconds / 60
seconds = seconds % 60
N.B.: This relies on Ruby's integer division -- dividing an integer by an integer results in an integer, with any fractional remainder discarded.

My pair and I ended up implementing something very much like this, but it left a bad taste in my mouth. I mean, it works and all, but it feels kind of ... non-functional. Ya know?

So, after much fretting and sleeplessness last night ... behold:
seconds = 356521

days, hours, minutes, seconds =
[1.day, 1.hour, 1.minute, 1.second].inject([]) do |acc, unit|
quotient, seconds = seconds.divmod unit
acc << quotient
end
This version needs to be run in a Rails script/console rather than irb, because it makes use of the Rails shortcut definitions of the number of seconds in various units of time. You could easily convert to plain irb-able Ruby by replacing 1.day et al above with their numeric equivalents.

The resulting code is both (more) functional, and more quintessentially Ruby-ish. divmod and multiple assignment let us figure out the quotient and the remainder of the division in one go, and inject lets us accumulate the results and ultimately multiply assign them to their respective units.

Neato.

I'm a little bummed, though, that this version has the side effect of destroying the original contents of seconds, as well as requiring seconds to be defined outside the scope of the inject. What would be really cool would be to have a version of inject that allowed for multiple accumulators (or, really, a 'decumulator' in this case) such that all side effects could be contained within the inject.

4 comments:

Bill Six said...

Hi David,

I came up with something similar that doesn't have any side effects, and thought that you may find it useful.

ScanL

Regards,
Bill

Jonas Elfström said...

How about

>> t=Time.at(356521-3600*25)
=> Sun Jan 04 03:02:01 0100 1970
>> t.day
=> 4
>> t.hour
=> 3
>> t.min
=> 2
>> t.sec
=> 1

David Rupp said...

@Jonas: I was hoping at first that I could use Time.at(), but some things about it bothered me. For example, when I run Time.at(356521 - 3600*25) locally (Atlanta, GA, US) I get Sat Jan 03 21:02:01 -0500 1970. Plus, I had to figure out why used 25 and not 24 (apparently you're in UTC + 1).

We can use your trick plus a quick call to utc() to correct for local timezones and the fact that the beginning of the epoch is Day 1 (not Day 0) thusly -- Time.at(356521 - 3600*24).utc.

Thanks for the tip. :-)

Jonas Elfström said...

If you need to handle years you also need to compensate for Time.at(0) not starting at year 0 but at 1970. Come to think of it I think I prefer your solution because it's general and needs no tweaking. There really should be a timespan somewhere in the Ruby core but I can't find it.
I don't know if you want to bring in the ActiveSupport beast but if you do the http://api.rubyonrails.org/classes/ActiveSupport/CoreExtensions/Numeric/Time.html might save your day.