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