(un)extend ruby classes

Par Yves , le 09/02/2016 dans
Yves

One thing I'm really happy with ruby is the power to extend all classes. Even default classes (like String). The ability to do this is a real difference to many other languages.

But before to dive in the subject, why would you extend default (or not) classes?

I always find in ruby a kind of elegance. You can read parts of code as it is a book and you can write as you think. The power to extend classes is what allows you to extend the language, to write a specific and more elegant language according to your needs. For me it's one of the key feature of RoR by example.

A simple example

Let's take a simple example. I have a block in which I get an array of strings. And I want to get an array containing only the first line of each item. I place myself in the situation I write a library, so I want to offer the user the more easy way to use it.

Here is a basic version:

class MyClass
  def self.todo(&block)
    instance = self.new
    instance.instance_eval(&block)
  end

  def get_array
    ["first String\nMultiline", "2nd\nString"]
  end
end

And a user can write:

MyClass.todo {
  arr = get_array
  first_lines_arr = arr.map do |el|
    if el.is_a? String
      el.split("\n").first
    else
      el
    end
  end
}

The instance_eval allows the block to be execute inside the instance and access to the methods of MyClass.

This is functionnal. But honnestly, if the operation is frequent you can help a little your users.

Without extending the language

First, we can place the map in a method.

class MyClass
  def self.todo(&block)
    instance = self.new
    instance.instance_eval(&block)
  end

  def get_array
    ["first String\nMultiline", "2nd\nString"]
  end

  def first_lines(array)
    array.map do |el|
    if el.is_a? String
      el.split("\n").first
    else
      el
    end
  end
end

MyClass.todo {
  arr = get_array
  first_lines_arr = first_lines arr
}

Better. But you can go a little further in extending the Array class. It allows you to stay in a more object-oriented way.

Opening the array

The goal is to write:

MyClass.todo {
  arr = get_array
  first_lines_arr = arr.first_lines
}

To do this, you must add the method first_lines to the class Array.

A simple version is to open the class definition and add the method:

class Array
  def first_lines
    map do |el|
      if el.is_a? String
        el.split("\n").first
      else
        el
      end
    end
  end
end

It works!

Simply imagine the class definition is splitted into small parts, the real definition is the aggregation of all partial classes.

Next level: class_eval

An other solution is to extend the class using class_eval:

Array.class_eval do
  def first_lines
    map do |el|
      if el.is_a? String
        el.split("\n").first
      else
        el
      end
    end
  end
end

It's very simimlar but you can do more differents things with it. By example you are able to encapsulate this in a method, replace Array by an parameter, use myInstance.class to create a real dynamic extension, etc.

Openning on demand

But now, imagine you want to offer this possibility only in your block. You don't want to polluate all arrays with your specific method. You want to extend Array class at the begining of your block and remove method at the end.

Extended the class at the begining is not complicated, simply add the version using class_eval before the call to instance_eval.

class MyClass
  def self.todo(&block)
    instance = self.new
    instance.extend_array
    instance.instance_eval(&block)
  end

  def get_array
    ["first String\nMultiline", "2nd\nString"]
  end

  def extend_array
    Array.class_eval do
      def first_lines
        map do |el|
          if el.is_a? String
            el.split("\n").first
          else
            el
          end
        end
      end
    end
  end
end

MyClass.todo {
  arr = get_array
  first_lines_arr = arr.first_lines
}

Closing on demand

In this version, before the call to todo, the array class is not extended. But now you need to remove the extension after the call to instance_eval to complete the job.

remove_method will make you happy!

class MyClass
  def self.todo(&block)
    instance = self.new
    instance.extend_array
    instance.instance_eval(&block)
    instance.unextend_array
  end

  def get_array
    ["first String\nMultiline", "2nd\nString"]
  end

  def extend_array
    Array.class_eval do
      def first_lines
        map do |el|
          if el.is_a? String
            el.split("\n").first
          else
            el
          end
        end
      end
    end
  end

  def unextend_array
    Array.class_eval do
      remove_method :first_lines
    end
  end
end

MyClass.todo {
  arr = get_array
  first_lines_arr = arr.first_lines
}

Now, the array arr has a method first_lines in the block. But outside the execution of this block, this method is not defined.

Conclusion

You can now extend any class (even default classes like String, Array, etc) only for a given block. Your user will be able to write elegant and readable code in a specific scope. And outside you not polluate the default classes with your Domain Specific Language extensions.