LaunchKit
← All posts
· 9 min read · by The LaunchKit team · 2 views

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.

Two panels. The left one, headed without boxes, shows a white box reading slugkit and a yellow box reading permalinkpro, both pointing into a single box reading String, which points down to a result reading quote hello underscore world underscore 42 quote. A note beside it reads: last one loaded wins, silently, slugkit's version is simply gone. The right panel, headed with boxes, shows one box reading String, captioned one class shared by both, with two arrows going down to two green boxes: one under the label box blog reading slugkit's view, with the result quote hello hyphen world hyphen 42 quote, and one under the label box docs reading permalinkpro, with the result quote hello underscore world underscore 42 quote. Below them: both live at once, nothing was copied. A caption across the bottom reads: a box is not a container, it is a pair of glasses, same furniture, different view.

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.

Three rows, each an input on the left and an outcome on the right. Row one, labelled implemented by the class itself, lists Array map, select, count and uniq, noting however you pass the block, and points to a green box reading it works. Row two, labelled from Enumerable with an explicit block, shows sort by with a block or a lambda, and points to a green box reading it works. Row three, labelled from Enumerable with an ampersand colon symbol, shows sort by of ampersand colon slugify, and min by, group by and partition too, in yellow, pointing to a white box reading NoMethodError. A column on the right explains that a block carries the box it was written in, an ampersand colon symbol carries nothing at all, so the lookup lands in the root box.

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".

#ruby #ruby-4

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.