Removing functionality the Ruby way - a look into SortedSet on Ruby 3
Tags: ruby
Published: 2020-12-30 21:17:26

Ruby 3 has just released - hooray! But naturally, some of my code hit problems with Ruby 3's breaking changes.

Almost all of these were related to [Ruby 3's changes to how keyword arguments are handled](https://www.ruby-lang.org/en/news/2020/12/25/ruby-3-0-0-released/), but I also had [an intriguing issue](https://github.com/AaronC81/sord/pull/123) reported on my Sord type generation project.

Ruby used to include a `SortedSet` class, but it's been removed from Ruby 3 for "dependency and performance reasons". Suppose we try to use it...

```
irb(main)> SortedSet
RuntimeError (The `SortedSet` class has been extracted from the `set` library.You must use the `sorted_set` gem or other alternatives.)
```

Woah, what's that error message!? That's not usually what we see when we try to use a constant which doesn't exist. You'd expect to get something like:

```
NameError (uninitialized constant SortedSet)
```

`SortedSet` gives us a much more useful error message.

How does Ruby do this? Let's take a look!

---

My first guess was that Ruby 3 replaced the `SortedSet` class with a `SortedSet` _method_ which throws an exception. It's not unusual for the Ruby `Kernel` to have [methods which look like classes](https://ruby-doc.org/core-2.7.2/Kernel.html#method-i-Array), so I thought this was a pretty good guess. But nope, there isn't any `SortedSet` method:

```
irb(main)> method(:SortedSet)
NameError (undefined method `SortedSet' for class `#<Class:#<Object:0x000056382e0a84c8>>')
```

---

To dig a bit deeper, I took a look at the [Ruby interpreter source code](https://github.com/ruby/ruby). The [`lib/set.rb`](https://github.com/ruby/ruby/blob/master/lib/set.rb) file revealed the answer:

```ruby
autoload :SortedSet, "#{__dir__}/set/sorted_set"
```

`SortedSet` is now defined with `autoload`. Referencing `SortedSet` will require the given file to try and resolve that constant, acting as a kind of lazy-loading within a Ruby program.

Sure enough, the useful exception we were seeing is thrown by the [`sorted_set`](https://github.com/ruby/ruby/blob/master/lib/set/sorted_set.rb) file which `autoload` points to:

```ruby
begin
  require 'sorted_set'
rescue ::LoadError
  raise "The `SortedSet` class has been extracted from the `set` library." \
        "You must use the `sorted_set` gem or other alternatives."
end
```

---

This is a really clever way of providing useful error messages when functionality is broken out or removed, which I personally haven't seen before! 

There is one quirk with it though, and it's the cause of the bug which made me look into this in the first place.

`SortedSet` is still a constant, so `Object.constants` contains it. But accessing this constant isn't always safe if you don't have the `sorted_set` gem. This means that iterating over all available constants can throw an exception!

```ruby
Object.constants.each do |c|
  p Object.const_get(c)
end

# => KeyError
# => Thread::Queue
# => IndexError
# ...etc, until boom!
# => RuntimeError (The `SortedSet` class has been extracted from the `set` library.You must use the `sorted_set` gem or other alternatives.)
```

I can see this being a problem in some heavily-metaprogrammed environments, just like it was for Sord.

---

For what's basically just a custom error message, I found this approach to the removal of `SortedSet` interesting to think about.

Is breaking a common assumption like "reading a constant which exists won't throw an exception" worthwhile when it might help out the developer?

Or, on the other hand, is consistency key to maximise the effectiveness of Ruby's metaprogramming? Do edge cases like this introduce complexity which makes the internals of Ruby more difficult to understand?

Personally I think this minor touch is a great design decision in Ruby 3, even if it did introduce a bug that had to be solved. If you have any other thoughts, I'd be interested to hear them!