Suppose somewhere within our Rails application, we have something like the following line of code.
(10.days.ago..2.days.ago).include? Date.today
The formulation represents the power of Ruby and the expressiveness of Rails. All seems fine and well. That is, until the code is executed. Suddenly, our snappy application grounds to a halt and our server log spits out a single warning over and over creating a wall of text, as if we have entered an infinite loop. The source of the warning comes from deep within ActiveSupport:
activesupport/lib/active_support/time_with_zone.rb:364: warning: Time#succ is obsolete; use time + 1
Totally confused as to why our seemingly idiomatic Rails code breaks, we ask a helpful colleague who promptly tells us, “Oh, just use the ‘cover’ method instead of ‘include’ on that range and you’ll be fine.” So we switch method calls and test the following code in the Rails console:
(10.days.ago..2.days.ago).cover? Date.today
Without a hiccup, the method blithely and immediately returns false
.
So what happened there? We might dig around ActiveSupport::TimeWithZone — the class of objects like 10.days.ago
— but we won’t discover the root of the problem unless we go straight to the Ruby source and look at how Range#include?
and Range#cover?
differ.
To start, we need to look at range.c, the file where the behavior of our favorite Ruby class is defined. It’s in the function Init_Range
all the way down on line 1314 where the magic happens.
void
Init_Range(void) // line 1314
{
// ...
rb_define_method(rb_cRange, "include?", range_include, 1); // line 1355
rb_define_method(rb_cRange, "cover?", range_cover, 1); // line 1356
}
Within Init_Range
, all the various methods on the Range class (e.g., #min
, #max
, #first
, etc.) are hooked up to C functions. For our purposes, line 1355 and the following line are the most important. On those lines, we have calls to rb_define_method
which takes a defined class, a method name, as well as a pointer to an actual C function which does all the work. The final argument defines the arity of the method.
So basically, we need to understand the difference between range_cover
(defined here) and range_include
(defined here).
To start, let’s take a look at the simpler (and shorter) of the two: range_cover
.
static VALUE
range_cover(VALUE range, VALUE val)
{
VALUE beg, end;
beg = RANGE_BEG(range);
end = RANGE_END(range);
if (r_le(beg, val)) {
if (EXCL(range)) {
if (r_lt(val, end))
return Qtrue;
}
else {
if (r_le(val, end))
return Qtrue;
}
}
return Qfalse;
}
The function begins by looking up the beginning and ending values of the range by using the macros RANGE_BEG
and RANGE_END
, both of which are defined at the file’s head. Typically, macros in the Ruby source are denoted by all caps and often can be found in the same file.
Next, the function asks if the range’s beginning value is less than or equal to (i.e., r_le
, “range less than or equal to”) the value in question. If that test succeeds and we enter the if-branch, the function then asks if the range is an exclusive range (e.g., 1..10
which includes 10 vs. 1...10
which does not). The exclusive range test (i.e., EXCL
) is yet another macro.
If we do have an exclusive range, then the next question is to ask if our value is less than (i.e., r_lt
, “range less than”) the end value. If that’s true, we’re done and we return Qtrue
, the Ruby wrapper for a truthy boolean value. If we do not have an exclusive range, we ask instead if our value is less than or equal to (again with r_le
) the end of the range, again returning Qtrue
if the test is successful. Finally, if we haven’t already returned Qtrue
, we know to return Qfalse
.
Now with an understanding of what Range#cover?
is doing under the hood, it’s easy to see why it’s quick to return. In effect, Range#cover?
is simply comparing the beginning and ending points of our range with the passed-in value. It’s fast, it works, and doesn’t spend any time iterating through the inclusive values.
If Range#cover?
gets the job done, what’s Range#include?
doing so differently?
A quick scan of range_include
reveals an interesting twist: the function contains the entirety of range_cover
.
static VALUE
range_include(VALUE range, VALUE val)
{
// ... lines 1141-1149
if (r_le(beg, val)) {
if (EXCL(range)) {
if (r_lt(val, end))
return Qtrue;
}
else {
if (r_le(val, end))
return Qtrue;
}
}
return Qfalse;
// ... lines 1161-1182
}
Incidentally, an early implementation of Range#include?
written by Matz was practically identical to the current implementation of Range#cover?
. So what is Range#include?
doing that causes the endless warning messages about Time#succ
being obsolete?
Since range_include
is twice as long as range_cover
, let’s break the function up into parts and look at it piece by piece.
To start, we have some setup:
static VALUE
range_include(VALUE range, VALUE val)
{
VALUE beg = RANGE_BEG(range);
VALUE end = RANGE_END(range);
int nv = FIXNUM_P(beg) || FIXNUM_P(end) ||
rb_obj_is_kind_of(beg, rb_cNumeric) ||
rb_obj_is_kind_of(end, rb_cNumeric);
// ... lines 1147-1182
}
Like range_cover
, we start by looking up the beginning and ending values of our range. We also assign an int
variable to tell us if we have a numeric value (hence the name “nv”). The variable nv
will be truthy (i.e., non-zero in C) if at least one of four conditions is met: 1) we have a pointer to a Fixnum (checked by the macro FIXNUM_P
) for the beginning value, 2) a Fixnum pointer for an ending value, 3) our beginning value is a kind of Numeric class, or 4) our ending value is a kind of Numeric class.
Next we test to see if we can use our range_cover
logic.
static VALUE
range_include(VALUE range, VALUE val)
{
// ...
if (nv ||
!NIL_P(rb_check_to_integer(beg, "to_int")) ||
!NIL_P(rb_check_to_integer(end, "to_int"))) {
// execute range_cover logic
}
// ... lines 1162-1182
}
Aside from checking if we have a numeric value (as defined above), we also check to see if we can convert the beg
or end
variable with the to_int
Ruby method. If one of our three tests succeeds, we execute the range_cover
algorithm. In Ruby land, this code covers the case for ranges like (1..10)
(Note that we’re overlooking the cases when to_int
returns a numeric value). Clearly, we have not found the source of the warning message yet.
At this point, we have two suspects to check for our endless warning messages: 1) an else
branch which follows from the if
branch above and 2) a call to super, i.e., rb_call_super
, all the way at the end of range_include
. Let’s start with the else
branch.
static VALUE
range_include(VALUE range, VALUE val)
{
// ... lines 1141-1161
else if (RB_TYPE_P(beg, T_STRING) && RB_TYPE_P(end, T_STRING) &&
RSTRING_LEN(beg) == 1 && RSTRING_LEN(end) == 1) {
if (NIL_P(val)) return Qfalse;
if (RB_TYPE_P(val, T_STRING)) {
if (RSTRING_LEN(val) == 0 || RSTRING_LEN(val) > 1)
return Qfalse;
else {
char b = RSTRING_PTR(beg)[0];
char e = RSTRING_PTR(end)[0];
char v = RSTRING_PTR(val)[0];
if (ISASCII(b) && ISASCII(e) && ISASCII(v)) {
if (b <= v && v < e) return Qtrue;
if (!EXCL(range) && v == e) return Qtrue;
return Qfalse;
}
}
}
}
/* TODO: ruby_frame->this_func = rb_intern("include?"); */
return rb_call_super(1, &val);
}
In the else
branch, we immediately have another if
. We first test to see if our beg
and end
variables are pointers to a string type with the RB_TYPE_P
macro. If those two conditions are met, and the length of each variable is 1 (checked by using another macro, RSTRING_LEN
), we proceed. In Ruby land, this code is testing for a range like ("a".."z")
.
If our val
variable is also a string type and its length is either zero or greater than one, we can immediately return Qfalse
. Otherwise, we use another macro, RSTRING_PTR
, and an array lookup on the returned C string, to store copies of the beginning, ending, and tested value. From there, we simply have to check that each char
is in fact an ASCII character, in which case we can compare their numerical values, much in the same way as we did with range_cover
.
Having eliminated all but one possible suspect, we now know that our warning messages are triggered by something beyond range_include
in the superclass of Range. For those well acquainted with Range and its ancestors, we have a clear culprit: Enumerable.
Rather than dive deeper into the source, let’s return to Ruby land and test our hypothesis with a monkey patch.
class Range
alias_method :old_each, :each
def each
puts "Calling each..."
old_each
end
end
By rerouting the each
method through our own version of the method, we can see if the original line from above is in fact hitting the each
method. So we type the line in again and hit enter.
(10.days.ago..2.days.ago).include? Date.today
Now in place of our wall of warnings, we see our own message repeated until Ruby throws a SystemStackError
. Progress!
Rather than an infinite loop, it looks like we’re trying to iterate over all the intervals within our time range. Let’s test that (with a fresh instance of the Rails console to clear out our monkey patch):
(3.seconds.ago..2.seconds.ago).include? Date.today
With only two seconds to test, in place of a wall of text, we have only two error messages and then the correct return value. So the root problem is not that an infinite loop is present inside Ruby or Rails (of course!), but rather that our range_include
is built to handle ranges like ("ant".."any")
by delegating to Enumerable to test all possible intermediate values (i.e., “anu”, “anv”, “anw”).
So what’s to be done? Take the helpful colleague’s advice and use Range#cover?
instead.