How I shoot in the foot with a case operator in Ruby
Before I started learning Ruby in 2010, I had been programming in Pascal, Delphi and
C++ to solve ACM-like problems. All of those programming languages have a
switch
/case
operator. In all of them it works pretty straightforward:
it compares a variable/object/result of an expression against several values and
decides which branch to execute.
When it was required for me in Ruby for a first time, I just looked up for the
case
syntax. I thought, what could possibly go wrong with the case
operator?
Recently I worked with Dropbox API to migrate an application to API v2.0. I
used the opensource library that has
been developing by a community. Despite most of the primary endpoints and
features are covered in this gem, I’ve found that it ignores
media_info
field for a DropboxApi::Metadata::File
. This field was urgently
important for my task, so I decided to modify the gem and send a pull request.
First of all, I needed to implement Hash
type casting to force_cast
method:
def force_cast(object)
if @type == String
object.to_s
elsif @type == Time
Time.parse(object)
elsif @type == Integer
object.to_i
elsif @type == Symbol
object[".tag"].to_sym
elsif @type == :boolean
object.to_s == "true"
elsif @type.ancestors.include? DropboxApi::Metadata::Base
@type.new(object)
else
raise NotImplementedError, "Can't cast `#{@type}`"
end
end
Instead of writing another elsif
branch I decided to rewrite this method with
case
operator because all the comparisons occur with the same object
@type
. Code written with the case
operator is easier to read. From
the first line it is obvious that there will be no conditions that
compare objects other than an argument.
At the first sight the problem could be in the last elsif
, however
a lambda expression comes here to help us.
def force_cast(object)
case @type
when String
object.to_s
when Time
Time.parse(object)
when Integer
object.to_i
when Symbol
object[".tag"].to_sym
when :boolean
object.to_s == "true"
when -> (t) { t.ancestors.include?(DropboxApi::Metadata::Base) }
@type.new(object)
else
raise NotImplementedError, "Can't cast `#{@type}`"
end
end
When I tested the result of my refactoring, I found that it is broken. The
case
didn’t match any branch and threw NotImplementedError
exception. It was
the first time, when I read how case
works in official Ruby documentation.
After all these years of programming in Ruby I’ve discovered that
case doesn’t compare argument with ==
, it uses ===
operator on it.
This threequel operator doesn’t have anything in common with ==
,
it’s not an equality checking. The ===
operator checks if object on
the right side can be included into a set on the left side. In some cases it is
really simple, for example
/qwe/ === 'qwerty' #=> true
because regular
expression /qwe/
describes all possible strings that are matched. Or another
example
(1..10) === 4 #=> true
integer 4 is included into the range 1..10.
Integer === 4 #=> true
this is also true, because 4 is belong to all possible integers.
It is important to know that ===
operator is not symmetrical.
4 === Integer #=> false
Lets move on to the tricky part:
4 === 4 #=> true
Ruby is object-oriented programming language, it allows to write your own
implementation for a method to any class.
Lets check the ===
implementation for a Fixnum
via pry pry(main)> $ 4.===
:
From: numeric.c (C Method):
Owner: Fixnum
Visibility: public
Number of lines: 15
static VALUE
fix_equal(VALUE x, VALUE y)
{
if (x == y) return Qtrue;
if (FIXNUM_P(y)) return Qfalse;
else if (RB_TYPE_P(y, T_BIGNUM)) {
return rb_big_eq(y, x);
}
else if (RB_TYPE_P(y, T_FLOAT)) {
return rb_integer_float_eq(x, y);
}
else {
return num_equal(x, y);
}
}
Now it is clear. In Ruby Fixnum
objects treat ===
as a simple equality check.
As for the human interpretation, I think that it can be represented like: “This
4 belongs to the set of all possible objects of 4”.
Integer === Integer #=> false
The root of my problem is covered in this line! I’ve used case
to compare
types, but it doesn’t work as I expected.
Let’s jump straight to the implementation $ Integer.===
:
From: object.c (C Method):
Owner: Module
Visibility: public
Number of lines: 5
static VALUE
rb_mod_eqq(VALUE mod, VALUE arg)
{
return rb_obj_is_kind_of(arg, mod);
}
It uses the standard check via kind_of
, but recalling the analogy, it isn’t
possible to put class Integer
in the set of class Integer
.
That’s why my implementation of force_cast
fails. It turns out that case
operator isn’t suitable to compare classes. In the end, I had to revert my
refactoring and add another elsif
condition.