What do you do when you need your code to work in different environments?
New versions of Ruby or important gems are released and we begin to consider how to switch between them. If your code needs to work in old as well as current versions of Ruby, how do you approach it? Or how do you handle changes in Rails for that matter?
Surely, there's a gem out there that would solve your problem for you...
Balancing the need for new features
A few years ago I was working on a project and was looking for a way to make Casting work in versions of Ruby 1.9 and 2.0. I had to write code that would determine what could be done and ensure it would behave.
When Ruby 2.0 was released, it allowed methods defined on a Module to be bound to any object and called:
module Greeter
def hello
"Hello, I'm #{@name}"
end
end
class Person
def initialize(name)
@name = name
end
end
jim = Person.new('Jim')
Greeter.instance_method(:hello).bind(jim).call # => "Hello, I'm Jim"
This was an exciting new feature. I had been working through new ways to structure behavior in my applications. My book, Clean Ruby, was in development. A version of Ruby came with a new feature but I couldn't yet use it in my current application environments with Ruby 1.9. This change gave me ideas about organizing code by application feature.
The desire to use new features is strong. The problem is that upgrading your projects to new versions isn't always easy. Your infrastructure might require updates, your tests should be run and adjusted, dependencies should be checked for compatibility, and you might need to update or remove code.
Running Ruby 1.9 meant binding module methods wouldn't work. It was a challenge to get Casting to work in my current environment.
Bridging the platform gap
Giving objects new behavior often means you must include the module in the object's class. Or you may include it in the object's singleton_class
using extend
.
class Person
include Greeter
end
# or extend the instance:
jim = Person.new('Jim')
jim.extend(Greeter)
jim.hello # => "Hello, I'm Jim"
There was a trick to get module method binding to work. You can clone an object, extend
clone, and grab the unbound method for your original object.
object.clone.extend(Greeter).method(:hello).bind(:object).call
It was definitely a hack and certainly an unintended behavior of Ruby 1.9. But I wanted to use Casting in any Ruby version so I needed to be strategic about how to handle the differences.
One way to check your Ruby version is to look at the RUBY_VERSION
constant and compare numbers. I quickly ruled that out. Checking that version value in JRuby, Rubinius, or some other implementation, might not be good enough. Alternative rubies have their own quirks. Since method binding isn't intended behavior in MRI, it's likely that things wouldn't work the way the code expected. Not every Ruby implementation is the same. RUBY_VERSION
wouldn't be a reliable predictor of behavior.
I came across Redcard which says in its README:
RedCard provides a standard way to ensure that the running Ruby implementation matches the desired language version, implementation, and implementation version.
That seemed to be exactly what I wanted. Relying on an open source project can help you unload work required. Other users of the same project will want to fix bugs and add features. A collaborative and active community can be a great asset.
So I dove in and began placing if RedCard.check '2.0'
and similar code wherever necessary
Build around behavior, not versions
Once it was in, I still didn't feel quite right about adding this dependency.
Third-party code brings along it's own dependencies. Third party-code can add rigidity to your own by reducing your ability to adjust to changes. You're not in charge of the release schedule of third-party code, unlike your own.
I soon realized that I didn't actually care about versions at all. What I cared about was behavior.
I had added a third-party dependency for a single feature. Although RedCard can do more, I didn't need any of those other features. Adding this dependency for one behavior was easy but it exposed the project to more third-party code than I wanted.
It was much easier to check for the behavior I wanted, and store the result. Here's how I tackled that in early versions of Casting (before I dropped Ruby 1.9 support).
# Some features are only available in versions of Ruby
# where this method is true
def module_method_rebinding?
return @__module_method_rebinding__ if defined?(@__module_method_rebinding__)
sample_method = Enumerable.instance_method(:to_a)
@__module_method_rebinding__ = begin
!!sample_method.bind(Object.new)
rescue TypeError
false
end
end
My solution was binding a method to an arbitrary object, catching any failure, and memoizing the result. If it worked my module_method_rebinding?
method would return true without running the test again. If the result was false, then this method would always return false.
The beauty of this solution was that it removed a dependency. It relied on the natural behavior of Ruby: to raise an exception.
Removing the gem makes all the problems of having third-party code go away.
Preparing for the future
Adding dependecies to your code can make you more productive. Adding dependencies can also reduce flexibility in responding to the needs of your code. A new dependency might prevent you from upgrading aspects of your system due to compatibility problems.
Polyfill, a project I recently came across, reminded me of this. The polyfill project says:
Polyfill implements newer Ruby features into older versions.
It might make sense to implement new features in your current environment rather than upgrading. Polyfill is important because it helps us avoid checking for behavior completely and instead implements it. When you're unable to upgrade your Ruby environment, you might pull in a project like polyfill.
Polyfill uses refinements so you can isolate new features without affecting other areas of your code.
Polyfill attempts to get your environment working like a newer version of Ruby. ActiveSupport adds its own features to Ruby core classes, but polyfill adds features which exist by default. This allows you to write your code in a manner consistent with upcoming upgrades to Ruby. Writing code with new versions of Ruby in mind will prepare you to drop the polyfill dependency.
Prepare for the future by implementing your own
My current project had a need to truncate a Float. In Ruby 2.4 the floor
method accepts an argument to limit the number of digits beyond the decimal. In our current environment with Ruby 2.3.x, the floor
method doesn't accept any arguments.
Instead of pulling in polyfill for a new feature, our solution was to do the math required to truncate it. Although using (3.14159265359).floor(2)
would be convenient, we can't yet justify a new dependency on polyfill, and we can implement this method on our own.
Handling versions and behavior limitations takes balance. Whether you are building gems or building applications, it's important to consider the larger impact of upgrading systems or installing new dependencies.
I'll be keeping my eyes on new features in Ruby and polyfill. If I'm unable to use either immediately, I'll at least be able to steal some good ideas.