Reading the Ruby Source to Understand Rails Idiosyncrasies

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.

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.

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.

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.

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.

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.

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.

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.