Книга: Metaprogramming Ruby 2
Назад: Chapter 2: Monday: The Object Model
Дальше: Inside the Object Model

Open Classes

Where you refactor some legacy code and learn a trick or two along the way.

Welcome to your new job as a Ruby programmer. After you’ve settled yourself at your new desk with a shiny, latest-generation computer and a cup of coffee, you can meet Bill, your mentor. Yes, you have your first assignment at your new company, a new language to work with, and a new pair-programming buddy.

You’ve only been using Ruby for a few weeks, but Bill is there to help you. He has plenty of Ruby experience, and he looks like a nice guy. You’re going to have a good time working with him—at least until your first petty fight over coding conventions.

The boss wants you and Bill to review the source of a small application called Bookworm. The company developed Bookworm to manage its large internal library of books. The program has slowly grown out of control as many different developers have added their pet features to the mix, from text previews to magazine management and the tracking of borrowed books. As a result, the Bookworm source code is a bit of a mess. You and Bill have been selected to go through the code and clean it up. The boss called it “just an easy refactoring job.”

You and Bill have been browsing through the Bookworm source code for a few minutes when you spot your first refactoring opportunity. Bookworm has a function that formats book titles for printing on old-fashioned tape labels. It strips all punctuation and special characters out of a string, leaving only alphanumeric characters and spaces:

 
def​ to_alphanumeric(s)
 
s.gsub(/[^​\w\s​]/, ​''​)
 
end

This method also comes with its own unit test (remember to gem install test-unit before you try to run it on Ruby 2.2 and later):

 
require ​'test/unit'
 
 
class​ ToAlphanumericTest < Test::Unit::TestCase
 
def​ test_strip_non_alphanumeric_characters
 
assert_equal ​'3 the Magic Number'​, to_alphanumeric(​'#3, the *Magic, Number*?'​)
 
end
 
end

“This to_alphanumeric method is not very object oriented, is it?” Bill says. “This is generic functionality that makes sense for all strings. It’d be better if we could ask a String to convert itself, rather than pass it through an external method.”

Even though you’re the new guy on the block, you can’t help but interrupt. “But this is just a regular String. To add methods to it, we’d have to write a whole new AlphanumericString class. I’m not sure it would be worth it.”

“I think I have a simpler solution to this problem,” Bill replies. He opens the String class and plants the to_alphanumeric method there:

 
class​ String
 
def​ to_alphanumeric
 
gsub(/[^​\w\s​]/, ​''​)
 
end
 
end

Bill also changes the callers to use String#to_alphanumeric. For example, the test becomes as follows:

 
require ​'test/unit'
 
 
class​ StringExtensionsTest < Test::Unit::TestCase
 
def​ test_strip_non_alphanumeric_characters
 
assert_equal ​'3 the Magic Number'​, ​'#3, the *Magic, Number*?'​.to_alphanumeric
 
end
 
end

To understand the previous trick, you need to know a thing or two about Ruby classes. Bill is only too happy to teach you….

Inside Class Definitions

In Ruby, there is no real distinction between code that defines a class and code of any other kind. You can put any code you want in a class definition:

 
3.times ​do
 
class​ C
 
puts ​"Hello"
 
end
 
end
<= 
Hello
 
Hello
 
Hello

Ruby executed the code within the class just as it would execute any other code. Does that mean you defined three classes with the same name? The answer is no, as you can quickly find out yourself:

 
class​ D
 
def​ x; ​'x'​; ​end
 
end
 
 
class​ D
 
def​ y; ​'y'​; ​end
 
end
 
 
obj = D.new
 
obj.x ​# => "x"
 
obj.y ​# => "y"

When the previous code mentions class D for the first time, no class by that name exists yet. So, Ruby steps in and defines the class—and the x method. At the second mention, class D already exists, so Ruby doesn’t need to define it. Instead, it reopens the existing class and defines a method named y there.

In a sense, the class keyword in Ruby is more like a scope operator than a class declaration. Yes, it creates classes that don’t yet exist, but you might argue that it does this as a pleasant side effect. For class, the core job is to move you in the context of the class, where you can define methods.

This distinction about the class keyword is not an academic detail. It has an important practical consequence: you can always reopen existing classes—even standard library classes such as String or Array—and modify them on the fly. You can call this technique Spell: .

To see how people use Open Classes in practice, let’s look at a quick example from a real-life library.

The Money Example

You can find an example of Open Classes in the money gem, a set of utility classes for managing money and currencies. Here’s how you create a Money object:

 
require ​"money"
 
 
bargain_price = Money.from_numeric(99, ​"USD"​)
 
bargain_price.format ​# => "$99.00"

As a shortcut, you can also convert any number to a Money object by calling Numeric#to_money:

 
require ​"money"
 
 
standard_price = 100.to_money(​"USD"​)
 
standard_price.format ​# => "$100.00"

Since Numeric is a standard Ruby class, you might wonder where the method Numeric#to_money comes from. Look through the source of the Money gem, and you’ll find code that reopens Numeric and defines that method:

 
class​ Numeric
 
def​ to_money(currency = nil)
 
Money.from_numeric(self, currency || Money.default_currency)
 
end
 
end

It’s quite common for libraries to use Open Classes this way.

As cool as they are, however, Open Classes have a dark side—one that you’re about to experience.

The Problem with Open Classes

You and Bill don’t have to look much further before you stumble upon another opportunity to use Open Classes. The Bookworm source contains a method that replaces elements in an array:

 
def​ replace(array, original, replacement)
 
array.map {|e| e == original ? replacement : e }
 
end

Instead of focusing on the internal workings of replace, you can look at Bookworm’s unit tests to see how that method is supposed to be used:

 
def​ test_replace
 
original = [​'one'​, ​'two'​, ​'one'​, ​'three'​]
 
replaced = replace(original, ​'one'​, ​'zero'​)
 
assert_equal [​'zero'​, ​'two'​, ​'zero'​, ​'three'​], replaced
 
end

This time, you know what to do. You grab the keyboard (taking advantage of Bill’s slower reflexes) and move the method to the Array class:

 
class​ Array
 
def​ replace(original, replacement)
 
self.map {|e| e == original ? replacement : e }
 
end
 
end

Then you change all calls to replace into calls to Array#replace. For example, the test becomes as follows:

 
def​ test_replace
 
original = [​'one'​, ​'two'​, ​'one'​, ​'three'​]
*
replaced = original.replace(​'one'​, ​'zero'​)
 
assert_equal [​'zero'​, ​'two'​, ​'zero'​, ​'three'​], replaced
 
end

You save the test, you run Bookworm’s unit tests suite, and...whoops! While test_replace does pass, other tests unexpectedly fail. To make things more perplexing, the failing tests seem to have nothing to do with the code you just edited. What gives?

“I think I know what happened,” Bill says. He fires up irb, the interactive Ruby interpreter, and gets a list of all methods in Ruby’s standard Array that begin with re:

 
[].methods.grep /^re/ ​# => [:reverse_each, :reverse, ..., :replace, ...]

In looking at the irb output, you spot the problem. Class Array already has a method named replace. When you defined your own replace method, you inadvertently overwrote the original replace, a method that some other part of Bookworm was relying on.

This is the dark side to Open Classes: if you casually add bits and pieces of functionality to classes, you can end up with bugs like the one you just encountered. Some people would frown upon this kind of reckless patching of classes, and they would refer to the previous code with a derogatory name: they’d call it a Spell: .

Now that you know what the problem is, you and Bill rename your own version of Array#replace to Array#substitute and fix both the tests and the calling code. You learned a lesson the hard way, but that didn’t spoil your attitude. If anything, this incident piqued your curiosity about Ruby classes. It’s time for you to learn the truth about them.

Назад: Chapter 2: Monday: The Object Model
Дальше: Inside the Object Model