Exercism: The Gigasecond Exercise

Posted by on June 9, 2014

The Readme

The Test Suite

Honestly, as the tests are written there’s not much to dig into here. Nearly every first pass at this code looks something like mine:

class Gigasecond
  def initialize(start_date)
    self.start_date = start_date
  end

  def date
    start_date + 11574 #number of days in a gigasecond
  end

  private
  attr_accessor :start_date
end

Some people convert the Date to Time and then add 1 billion seconds to it, but the concept is the same. Ruby already has good date operators; there’s no reason to reinvent the wheel.

So, this works and is totally readable. We could probably just leave it at that. But then I wouldn’t get to type a rambling blog post. That’s no fun. Let’s dig deeper.

The test suite only includes Date objects. Even though we know that Ruby has other ways of representing time, let’s set that aside for now and assume we’re only going to get Dates.

require 'delegate'
class Gigasecond < SimpleDelegator
  def date
    __getobj__ + 11574 #number of days in a gigasecond
  end
end

With this our tests still pass and we’ve saved a few lines of code. That tiny refactoring done, we revisit the problem of Time and DateTime, Ruby’s other classes for representing dates.

As written, this code will work with Date and DateTime, as they both use the same implementation for the + operator. They add days. But Time’s + operator adds seconds.

d = Date.today
#=> #<Date: 2014-06-09 ((2456818j,0s,0n),+0s,2299161j)>
d + 1
#=> #<Date: 2014-06-10 ((2456819j,0s,0n),+0s,2299161j)>

#######

dt = DateTime.now
#=> #<DateTime: 2014-06-09T13:46:20-05:00 ((2456818j,67580s,427220000n),-18000s,2299161j)>
dt + 1
#=> #<DateTime: 2014-06-10T13:46:20-05:00 ((2456819j,67580s,427220000n),-18000s,2299161j)>

#######

t = Time.now
#=> 2014-06-09 13:46:37 -0500
t + 1
#=> 2014-06-09 13:46:38 -0500

So, if we want to support Time, we’ll have to handle that difference. Of course we want to support Time! But let’s make sure Date works first:

rubyrequire 'delegate'
class Gigasecond < SimpleDelegator
  def date
    __getobj__.gigaseconds_since
  end
end

class Date
  def gigaseconds_since
    self + 11574
  end
end

The gigaseconds_since method naming follows the convention of Date helpers in Rails. Ruby doesn’t have helper methods like this, but I figured people would be familiar with the Ralis methods, so I stuck to similar naming.

I don’t have to implement DateTime because it inherits from Date. So now I just need to add Time support. Easy peasy.

class Time
  def gigaseconds_since
    self + 1_000_000_000
  end
end

It was about here when I looked at the initial test suite and realized that it didn’t exercise DateTime or Time objects. Also, I think its use of static dates is a liability. Random dates in the tests might find weird edge cases. So I wrote a test like the following for Time, Date and DateTime:

def test_date
  1000.times do |x|
    random_date = Time.at(rand * Time.now.to_i).to_date
    expected = random_date + 11574

    gs = Gigasecond.new(random_date)
    assert_equal expected, gs.date
    assert_equal expected, random_date.gigaseconds_since(1)
  end
end

This test makes sure my Gigasecond.new syntax works, as well as checking the gigaseconds_since syntax. And, sure, why not run it 1000 times? These 3000 tests still pass in 0.1 seconds, so I’m not concerned with how ridiculous it looks. If there is some weird date that breaks my implementation, this approach is more likely to find it. But, yes, it’s ridiculous. I’m not ashamed.

Now we can easily tell people what Time/DateTime/Date is 1 gigasecond after the Time/DateTime/Etc. they provide. But why just 1 gigasecond? Who doesn’t immediately start thinking about 2, 3, 1000 gigaseconds? Only people with no joy in their hearts, that’s who.

The Rails _since methods that I copied accept parameters. Time.now.hours_since(5) will return a Time 5 hours in the future. So let’s take that same approach.

class Date
  def gigaseconds_since(multiple)
    self + (11574 * multiple)
  end
end

class Time
  def gigaseconds_since(multiple)
    self + ((10**9) * multiple)
  end
end

And we hardcode the Gigasecond implementation to always use just 1 lowly gigasecond.

class Gigasecond < SimpleDelegator
  def date
    __getobj__.gigaseconds_since(1)
  end
end

Now we have to change our tests. First we need to show that the Date/Time implementation can use any number of gigaseconds, and we should show that Gigasecond is just an alias for gigaseconds_since(1)

def test_gigasecond_wraps_date_methods
  random_date = Minitest::Mock.new
  gs = Gigasecond.new(random_date)
  random_date.expect(:gigaseconds_since, 1) { true }
  gs.date
end

def test_date
  1000.times do |x|
    random_date = Time.at(rand * Time.now.to_i).to_date
    random_gigaseconds = rand(1000)
    expected = random_date + (10**9 * random_gigaseconds / (24 * 60 * 60))

    assert_equal expected, random_date.gigaseconds_since(random_gigaseconds)
  end
end

### similar tests for DateTime and Time

Now it’s time to tackle a problem that’s been lingering in the bacground, the Magic Numbers. What is the meaning of 11574? What is 10**9? What is 24 * 60 * 60?

In the context of the code these numbers aren’t that hard to figure out. But if we can reduce cognitive overhead, we should. We can inspiration from Rails again and look at the methods it adds to Numeric.

t = Time.now
#=> 2014-06-09 15:43:14 -0500
t + 1.hour
#=> 2014-06-09 16:43:14 -0500

Methods like hour return a Duration instance, which is probably more than we need. We can keep this pretty simple.

class Date
  SECONDS_PER_DAY = 86400
  def gigaseconds_since(multiple)
    self + (multiple.gigaseconds / SECONDS_PER_DAY)
  end
end

class Time
  def gigaseconds_since(multiple)
    self + multiple.gigaseconds
  end
end

class Numeric
  def gigaseconds
    self * 1_000_000_000
  end
  alias :gigasecond :gigaseconds
end

The simplicity of our approach forces us to use the SECONDS_PER_DAY constant. The gigaseconds method will only return seconds and we need to convert it to days. We could do the math in the method, but a named constant helps us remember what 86400 means.

If we had implemented something like Duration we could have added a handy to_days methods as a way around this problem. But, as it is, I don’t think that extra code is worth the effort.

An as-yet undiscussed side-effect of the changes we’ve made is that we can now work with fractional gigaseconds. Time.now.gigaseconds_since(1.33) will work. It worked in earlier implementations as well, but we could have easily broken the functionality had we monkey patched Integer instead of Numeric.

And that’s that. Well. Almost. I was happy with this code until I realized that I’d left myself a surprise:

d = Gigasecond.new(Date.today)
#=> #<Date: 2014-06-09 ((2456818j,0s,0n),+0s,2299161j)>
irb(main):011:0> d.date
#=> #<Date: 2046-02-15 ((2468392j,0s,0n),+0s,2299161j)>
irb(main):012:0> d.to_time
#=> 2014-06-09 00:00:00 -0500

So date gives me a date 32 years in the future, but to_time gives me today. That’s the little bomb I planted for myself when I used SimpleDelegator, which happily forwards any method it doesn’t know about to the object I instantiated it with.

Delegation only hurts us, so let’s go back to a simpler approach.

class Gigasecond
  def initialize(start_date)
    self.start_date = start_date
  end

  def date
    start_date.gigaseconds_since(1)
  end

  private

  attr_accessor :start_date
end

Put everything together and here’s the final implementation:

require 'delegate'
class Gigasecond
  def initialize(start_date)
    self.start_date = start_date
  end

  def date
    start_date.gigaseconds_since(1)
  end

  private

  attr_accessor :start_date
end

class Date
  SECONDS_PER_DAY = 86400
  def gigaseconds_since(multiple)
    self + (multiple.gigaseconds / SECONDS_PER_DAY)
  end
end

class Time
  def gigaseconds_since(multiple)
    self + multiple.gigaseconds
  end
end

class Numeric
  def gigaseconds
    self * 1_000_000_000
  end
  alias :gigasecond :gigaseconds
end

Summary

And that’s how you take a dead-simple problem and talk about it for 1000+ words. For the problem as stated, the initial implementation is just fine. In my quick survey of the solutions on Exercism, nearly everyone does it exactly that way. But that doesn’t mean you have to leave it there. That code solves only one problem, how to add 1 gigasecond to a Date. If you were a client of that code, would you be surprised that it was so limited? I would.

There’s certainly an argument to be made for YAGNI. No one has asked for time, or for multiple gigaseconds, so why bother? But I’d argue that the initial implmenation was unfinished. It was like finding a huge cookbook that only contained a single recipe, “How to make toast.” We’re not so much adding unnecessary features as we are adding the implementation that I would expect if I used this code. The final version more fully completes the gigasecond functionality so that you don’t have to keep rewriting it to handle new features. An extra hour’s worth of work at the beginning might save you a ton of time down the road.