Ruby Box: two gems, one monkey patch, no fight
You've hit this. Two gems in your Gemfile both decide String needs a slugify, they disagree about
whether a slug uses hyphens or underscores, and the one Bundler happens to load second wins. Nothing
warns you. Your URLs just quietly change shape the day you add a dependency.
Ruby 4.0 shipped an experimental answer, and it's stranger than a sandbox.
The problem, in eight lines
Two tiny gems. Each one reopens String and defines the same method:
# slugkit
class String
def slugify = downcase.strip.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
end
# permalinkpro
class String
def slugify = downcase.strip.gsub(/[^a-z0-9]+/, "_").gsub(/\A_|_\z/, "")
end
Load both and ask for a slug:
require "slugkit"
require "permalinkpro"
"Hello World 42".slugify # => "hello_world_42"
Slugkit lost. Not because it's worse, but because it got there first and the second definition overwrote it. There's no error, no warning, and no way for either gem to notice. This is what people mean when they say monkey patching in Ruby doesn't compose.
Turning boxes on
Ruby Box lives behind an environment variable, and it has to be set before the process starts:
RUBY_BOX=1 ruby app.rb
You'll get a warning on stderr saying the feature is experimental and pointing at the docs. Take it seriously: this is a Ruby 4.0 preview, not something to ship.
With it on, you get a Ruby::Box class. A box is a namespace with its own loader attached:
blog = Ruby::Box.new
docs = Ruby::Box.new
blog.require "slugkit"
docs.require "permalinkpro"
puts blog.eval('"Hello World 42".slugify') # => "hello-world-42"
puts docs.eval('"Hello World 42".slugify') # => "hello_world_42"
Both gems are loaded. Both patches are live. Neither one clobbered the other. And in the main
program, outside either box, "Hello World 42".slugify raises NoMethodError, because the main
program never asked for either gem.
That's the whole pitch in one snippet.
The analogy that makes it click
The obvious mental model is wrong, so it's worth replacing early.
You'd expect a box to be a container: your stuff goes inside, the walls keep it there. Like separate
apartments in a building. But watch what happens to String:
String.object_id # => 64 in the main program
blog::String.object_id # => 64 inside the box
String.equal?(blog::String) # => true
Same class. One String, shared by everybody. So boxes are not apartments.
A better picture: everyone is standing in the same room, and each box is a pair of glasses. The
furniture is shared and there's only one of everything. What changes is what you can see. Put on
slugkit's glasses and String has a slugify that makes hyphens. Put on permalinkpro's and the
same String has a slugify that makes underscores. Take them off and there's no slugify at all.
Nobody moved any furniture.
That sounds like a detail. It's the whole behaviour, and it predicts the surprising parts.
Who's asking decides what they get
Here's the consequence people get wrong. Take a string created in the main program, hand it to a box, and ask the box to slugify it:
# a string made in the main program, slugified by the blog box
blog.const_set(:FROM_MAIN, "Made In Main")
blog.eval('FROM_MAIN.slugify') # => "made-in-main"
# a string made in the blog box, slugified by the docs box
s = blog.eval('"Born In A"')
docs.const_set(:FROM_BLOG, s)
docs.eval('FROM_BLOG.slugify') # => "born_in_a"
Look at the second one. Underscores, which is permalinkpro's version, on a string that was born in slugkit's box. The string doesn't carry its box around with it, doesn't remember where it was made, and isn't tagged in any way.
The method you get depends on which box is running the code, not on where the object came from. That's the glasses again: the object is furniture, and whoever's looking is wearing their own pair.
If you were hoping for objects that stay bound to their origin, this isn't it, and building on the opposite assumption is how you'd get hurt.
The sharp edge
Now the part that isn't in the announcement posts. Inside a box, some perfectly ordinary calls can't find your method.
blog.eval('%w[Bb Aa].map(&:slugify)') # => ["bb", "aa"]
blog.eval('%w[Bb Aa].sort_by(&:slugify)') # NoMethodError
Same array, same method, same &:symbol shorthand. One works, one doesn't.
It isn't random. Run a sweep and the split is clean:
| Works | Raises NoMethodError |
|---|---|
map, select, count, uniq |
sort_by, min_by, max_by |
sum over numbers |
group_by, flat_map, partition |
each_with_object, transform_values |
sum over strings |
Ask Ruby where each one is defined and the pattern names itself:
Array.instance_method(:map).owner # => Array
Array.instance_method(:sort_by).owner # => Enumerable
The ones that work are the ones Array implements itself. The ones that break come from
Enumerable. And sum sits on the fence for a good reason: Array#sum has a fast path for
numbers and falls back to the Enumerable version for anything else, which is why it works on
integers and fails on strings.
To be sure it's Enumerable and not something about Array, build your own collection:
class Bag
include Enumerable
def initialize(*x) = @x = x
def each(&b) = @x.each(&b)
end
Bag.new(1, 2).map(&:boxed_double) # NoMethodError
map is no longer magic. Take it from Enumerable and it breaks too.
It's the &:symbol, not the method
One more turn of the screw, because the fix matters more than the diagnosis:
blog.eval('%w[Bb Aa].sort_by { |s| s.slugify }') # => ["Aa", "Bb"] works
blog.eval('%w[Bb Aa].sort_by(&->(s) { s.slugify })') # => ["Aa", "Bb"] works
blog.eval('pr = :slugify.to_proc; pr.call("Hi There")') # => "hi-there" works
blog.eval('pr = :slugify.to_proc; %w[Bb Aa].sort_by(&pr)') # NoMethodError
Read those four carefully. An explicit block is fine. A lambda is fine. The symbol proc called
directly is fine. The symbol proc handed to an Enumerable method is not.
A block or a lambda carries the box it was written in, the way a person carries their own glasses.
A symbol proc carries nothing; it's a note that says "call the method named slugify", and when
Enumerable's internals unfold that note they're standing in the root box, where nobody has ever
heard of slugify.
So the rule to remember is small: inside a box, don't pass &:your_patched_method to anything
Enumerable implements. Write the block out. It costs you eight characters and it always works.
What state actually splits
Beyond methods, a box gets its own constants, its own global variables, its own class variables, and its own top-level methods. Our two boxes can't see each other's gems at all:
blog::Slugkit::VERSION # => "1.0.0"
docs::Permalinkpro::VERSION # => "2.0.0"
blog::Permalinkpro # NameError
What stays shared is the built-in machinery itself. There's one String, one Array, one object
graph. Built-in methods still run in the root box, which is exactly why the Enumerable hole exists
rather than being an oversight somebody forgot to fix.
Can you use it on a Rails app today
No, and it's worth being specific about why rather than hand-waving.
Plain libraries load fine. json, set, even active_support itself all go into a box without
complaint. But the piece every Rails app actually depends on doesn't:
box.require "active_support/core_ext"
# NameError: uninitialized constant ActiveSupport::Autoload
That one's on Ruby's own known-issues list, alongside native extensions failing to build under
boxing. Add the Enumerable hole, and any real Rails app is going to trip over something in its
first hour.
What it's genuinely ready for is the thing it was built for: running two pieces of code that disagree about the same class, in one process, on purpose. A plugin host loading third-party extensions. A test suite proving a gem works with and without a patch. A migration where the old and new version of a library have to coexist for a week.
What I'd watch
Ruby 4.0 landed on Ruby's thirtieth birthday and the headline features, Box and ZJIT, both shipped marked experimental. That's an unusual amount of honesty for a major version, and it's the right call: the API here is small and pleasant, and the holes in it are real.
The interesting question isn't whether Box is ready. It's whether "which box is executing" turns out to be a model people can hold in their heads, because every surprise above falls out of that one sentence, and none of them are guessable from the word "isolation".
Comments
No comments yet. Be the first.