I was pleased and surprised to find that ActiveSupport does month sums in the way I wanted it to. Regardless of how many days are in the months in question, adding 1.month to a particular Time will land you on the same day-of-the-month as the Time.
> Time.utc(2012,2,1)
=> Wed Feb 01 00:00:00 UTC 2012
> Time.utc(2012,2,1) + 1.month
=> Thu Mar 01 00:00:00 UTC 2012
the months method in Fixnum provided by activesupport does not give clues:
def months
ActiveSupport::Duration.new(self * 30.days, [[:months, self]])
end
Following the + method in Time...
def plus_with_duration(other) #:nodoc:
if ActiveSupport::Duration === other
other.since(self)
else
plus_without_duration(other)
end
end
...leads us to since in Fixnum...
def since(time = ::Time.current)
time + self
end
...which leads us nowhere.
How/where is ActiveSupport (or something else) doing clever month math instead of just adding 30 days?
That's a really good question. The short answer is that 1.month is an ActiveSupport::Duration object (as you already saw) and its identity is defined in two different ways:
30.days (in case you need/try to convert it to a number of seconds), and
You can see that it still knows that it is equivalent to 1 month by inspecting its parts method:
main > 1.month.parts
=> [[:months, 1]]
Once you see proof that it still knows that it's exactly 1 month, it's less mysterious how calculations like Time.utc(2012,2,1) + 1.month can give the correct result even for months that don't have exactly 29 days, and why it gives a different result than Time.utc(2012,2,1) + 30.days gives.
How do ActiveSupport::Duration conceal their true identity?
The real mystery for me was how it hides its real identity so well. We know that it is a ActiveSupport::Duration object, yet it's very difficult to get it to admit that it is!
When you inspect it in a console (I'm using Pry), it looks exactly like (and claims to be) a normal Fixnum object:
main > one_month = 1.month
=> 2592000
main > one_month.class
=> Fixnum
It even claims to be equivalent to 30.days (or 2592000.seconds), which we've shown to be not true (at least not in all cases):
main > one_month = 1.month
=> 2592000
main > thirty_days = 30.days
=> 2592000
main > one_month == thirty_days
=> true
main > one_month == 2592000
=> true
So to find out whether an object is a ActiveSupport::Duration or not, you can't rely on the class method. Instead, you' have to ask it point-blank: "Are you or are you not an instance of ActiveSupport::Duration?" Confronted with such a direct question, the object in question will have no choice but to confess the truth:
main > one_month.is_a? ActiveSupport::Duration
=> true
Mere Fixnum objects, on the other hand, must hang their heads and admit that they are not:
main > 2592000.is_a? ActiveSupport::Duration
=> false
You can also tell it apart from regular Fixnums by checking if it responds to :parts:
main > one_month.parts
=> [[:months, 1]]
main > 2592000.parts
NoMethodError: undefined method `parts' for 2592000:Fixnum
from (pry):60:in `__pry__'
Having an array of parts is great
The cool thing about having an array of parts is that it allows you to have duration defined as a mix of units, like this:
main > (one_month + 5.days).parts
=> [[:months, 1], [:days, 5]]
This allows it to accurate calculate such things as:
main > Time.utc(2012,2,1) + (one_month + 5.days)
=> 2012-03-06 00:00:00 UTC
... which it would not be able to calculate correctly if it simply stored only a number of days or seconds as its value. You can see this for yourself if we first convert 1.month to its "equivalent" number of seconds or days:
main > Time.utc(2012,2,1) + (one_month + 5.days).to_i
=> 2012-03-07 00:00:00 UTC
main > Time.utc(2012,2,1) + (30.days + 5.days)
=> 2012-03-07 00:00:00 UTC
How does ActiveSupport::Duration work? (Gory implementation details)
ActiveSupport::Duration is actually defined (in gems/activesupport-3.2.13/lib/active_support/duration.rb) as a subclass of BasicObject, which according to the docs, "can be used for creating object hierarchies independent of Ruby's object hierarchy, proxy objects like the Delegator class, or other uses where namespace pollution from Ruby's methods and classes must be avoided."
ActiveSupport::Duration uses method_missing to delegate methods to its @value variable.
Bonus question: Does anyone know why an ActiveSupport::Duration object claims to not respond to :parts even though it actually does, and why the parts method isn't listed in the methods list?
main > 1.month.respond_to? :parts
=> false
main > 1.month.methods.include? :parts
=> false
main > 1.month.methods.include? :since
=> true
Answer: Because BasicObject does not define a respond_to? method, sending respond_to? to an ActiveSupport::Duration object will end up calling its method_missing method, which looks like this:
def method_missing(method, *args, &block) #:nodoc:
value.send(method, *args, &block)
end
1.month.value is simply the Fixnum 2592000, so it effectively ends up calling 2592000.respond_to? :parts, which of course is false.
This would be easy to solve, though, by simply adding a respond_to? method to the ActiveSupport::Duration class:
main > ActiveSupport::Duration.class_eval do
def respond_to?(name, include_private = false)
[:value, :parts].include?(name) or
value.respond_to?(name, include_private) or
super
end
end
=> nil
main > 1.month.respond_to? :parts
=> true
The explanation for why methods incorrectly omits the :parts method is the same: because the methods message simply gets delegated to value, which of course does not have a parts method. We could fix this bug as easily as adding our own methods method:
main > ActiveSupport::Duration.class_eval do
def methods(*args)
[:value, :parts] | super
end
end
=> nil
main > 1.month.methods.include? :parts
=> true
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With