FEATURED IN RUBY WEEKLY ISSUE #229
Once in a while working with Rails we encounter something that makes us scratch our heads. When this happens, I make a point to try to figure it out. There is a lot to be learned in the process.
In this adventure, David stumbles upon some strange Rails behavior and together we investigate.
UPDATE: David encounters some more fun with Rails dates and Duration
in this follow-up post: Rails’ 1.month
has a variable length
There is some strange behavior involving Rails Date
class and durations like 1.day
.
> Date.current + 1.day
=> 2015-01-15
> 1.day
=> 86400
> Date.current + 86400
=> 2251-08-05
Adding 1.day
to the current date returns tomorrow’s date, as expected. 1.day
returns 86400
the number of seconds in a day. But adding 86400
to the current date returns the date 86,400 days from today. Huh?
They must be different classes?
> 1.day.class
=> Fixnum
> 86400
=> Fixnum
No, they’re both Fixnum
. How does Date#+
know the difference?
# rails/activesupport/lib/active_support/core_ext/date/calculations.rb
class Date
...
def plus_with_duration(other) #:nodoc:
if ActiveSupport::Duration === other
other.since(self)
else
plus_without_duration(other)
end
end
alias_method :plus_without_duration, :+
alias_method :+, :plus_with_duration
...
end
So Rails’ Date#+
(Date#plus_with_duration
) has special handling for ActiveSupport::Duration
instances, and everything else goes to default Ruby Date#+
.
But 1.day
is a Fixnum
, right?
> 1.day.class
=> Fixnum
> 1.day.is_a? ActiveSupport::Duration
=> true
Apparently it’s also an ActiveSupport::Duration
.
Why is 1.day
a Fixnum
as well as an ActiveSupport::Duration
? Does Duration
inherit from Fixnum
, or override #class
?
https://github.com/rails/rails/blob/4-1-stable/activesupport/lib/active_support/duration.rb
Neither. It inherits from ProxyObject
. Hmm, this looks like something:
# rails/activesupport/lib/active_support/duration.rb
module ActiveSupport
...
class Duration < ProxyObject
attr_accessor :value, :parts
def initialize(value, parts) #:nodoc:
@value, @parts = value, parts
end
...
private
def method_missing(method, *args, &block) #:nodoc:
value.send(method, *args, &block)
end
end
end
https://github.com/rails/rails/blob/4-1-stable/activesupport/lib/active_support/duration.rb
If a method is missing, it calls that method on @value
, which would be a Fixnum
. That makes sense.
So with 1.day
(a Duration
), #class
is missing, #class
is called on @value
(1
), and 1.class
returns Fixnum
. That’s why 1.day.class
returns Fixnum
.
But why would #class
be missing on a Duration
? All objects respond to #class
. Isn’t Duration
an Object
?
> 1.day.is_a?(Object)
=> true
At first glance, yes, but upon closer inspection, not really…
> ActiveSupport::Duration.superclass
=> ActiveSupport::ProxyObject
> ActiveSupport::ProxyObject.superclass
=> BasicObject
> BasicObject.superclass
=> nil
Actually Duration
does not inherit Object
, @value
does, and Duration#is_a?
delegates to @value.is_a?
.
# rails/activesupport/lib/active_support/duration.rb
module ActiveSupport
...
class Duration < ProxyObject
...
def is_a?(klass) #:nodoc:
Duration == klass || value.is_a?(klass)
end
...
end
end
So ActiveSupport::Duration
inherits from ActiveSupport::ProxyObject
which inherits from BasicObject
which inherits from nothing. An ActiveSupport::Duration
is technically not an Object
.
So what is a BasicObject
?
“BasicObject is the parent class of all classes in Ruby. It’s an explicit blank class.”
http://ruby-doc.org/core-2.2.0/BasicObject.html
It’s a blank class.
> BasicObject.new.class
NoMethodError: undefined method `class' for #<BasicObject:0x007ff3927616b8>
from (pry):40:in `<main>'
Indeed, it doesn’t even respond to #class
. That’s why Duration#class
hits method_missing
.
The End.
NOTE: In Rails 4.2, ActiveSupport::Duration
does not inherit ActiveSupport::ProxyObject
thus not a BasicObject
, and 1.day.class
returns ActiveSupport::Duration
.
Published January 14, 2015