Thursday, December 13, 2012

Refinements in Ruby: an ingenuous implementation

UPDATE: I've worked on Namebox, an improved way to protect methods from changes, inspired on this implementation of Refinements.

I'm back to programming after some months of pause. The last thing I've heard about Ruby before pausing was Refinements. And I fell in love with it.

I found that idea so smart that I couldn't continue programming without it. I couldn't wait for Ruby 2.0. I had to implement it on my own.

Ruby 1.8.7 give us enough tools for designing a lexically scoped activation of refinements. I could use using with set_trace_func to detect the end of blocks (scopes), but I preferred to use enable and disable, because:

  • it's simpler to implement;
  • it's explicit and easy to read;
  • the programmer has the freedom to enable and disable the refinements whenever he/she considers it necessary.

My solution is so simple that I called it "an ingenuous implementation". It has many differences from the original proposal, as I will discuss later, but it brings which I consider the most important feature to me: the refinements are limited to physical ranges within the text file. There's no outside consequences. Anyone can use my refined libraries with no (unpleasant) surprises. And the unrefined methods are not affected (if you're thinking about performance impact).

# Refinements for Ruby: an ingenuous implementation
#
# (c) 2012 Sony Fermino dos Santos
# http://rubychallenger.blogspot.com/2012/12/refinements-in-ruby-ingenuous.html
# 
# License: Public Domain
# This software is released "AS IS", without any warranty.
# The author is not responsible for the consequences of use of this software.
#
# This code is not intended to look professional,
# provided that it does what it is supposed to do.
#
# This software was little tested on Ruby 1.8.7 and 1.9.3, with success.
# However, no heavy tests were made, e.g. threads, continuation, benchmarks, etc.
#
# The intended use is in the straightforward flux of execution.
#
# Instead of using +using+ as in the original proposal, here we use
# Module#enable and Module#disable. They're lexically scoped by the
# file:line of where they're called from.
#
# E.g.: Let StrUtils be a module which refine the String class.
# module StrUtils
#   refine String do
#     def foo
#       #...
#     end
#   end
# end
#
# Using it in the code snippets:
#
# StrUtils.enable
# "abc".foo                       #=> works (foo is "visible")
# def bar; puts "abc".foo; end    #=> bar is defined where foo is "visible"
# StrUtils.disable
# "abc".foo                       #=> doesn't work (foo is "invisible")
# bar                             #=> works, as bar was defined where foo is "visible"
# def baz; puts "abc".foo; end
# baz                             #=> doesn't work.
#
# You can enable and disable a module at any time, since you:
# * enable and disable in this order, in the file AND in the execution flow;
# * disable all modules that you enabled in the same file;
# * don't reenable (or redisable) an already enabled (or disabled) module.
#
# See refine_test.rb for more examples.

# Refinements is to avoid monkey patches, but
# we need some minimal patching to implement it.
class Module

  # Opens an enabled range for this module's refinements
  def enable
    info = ranges_info

    # there should be no open range
    raise "Module #{self} was already enabled in #{info[:file]}:#{info[:last]}" if info[:open]

    # range in progress
    info[:ranges] << info[:line]
  end

  # Close a previously opened enabled range
  def disable
    info = ranges_info

    # there must be an open range in progress
    raise "Module #{self} was not enabled in #{info[:file]} before line #{info[:line]}" unless info[:open]

    # beginning of range must be before end
    r_beg = info[:last]
    r_end = info[:line]
    raise "#{self}.disable in #{info[:file]}:#{r_end} must be after #{self}.enable (line #{r_beg})" unless r_end >= r_beg
    r = Range.new(r_beg, r_end)

    # replace the single initial line with the range, making sure it's unique
    info[:ranges].pop
    info[:ranges] << r unless info[:ranges].include? r
  end

  # Check whether a refined method is called from an enabled range
  def enabled?
    info = ranges_info
    info[:ranges].each do |r|
      case r
      when Range
        return true if r.include?(info[:line])
      when Integer
        return true if info[:line] >= r
      end
    end
    false
  end

  private

  # Stores enabled line ranges of caller files for this module
  def enabled_ranges
    @enabled_ranges ||= {}
  end

  # Get the caller info in a structured way (hash)
  def caller_info
    # ignore internal calls (using skip would differ from 1.8.7 to 1.9.3)
    c = caller.find { |s| !s.start_with?(__FILE__, '(eval)') } and
        m = c.match(/^([^:]+):(\d+)(:in `(.*)')?$/) and
        {:file => m[1], :line => m[2].to_i, :method => m[4]} or {}
  end

  # Get line ranges info for the caller file
  def ranges_info
    ci = caller_info
    ranges = enabled_ranges[ci[:file]] ||= []
    ci[:ranges] = ranges

    # check whether there is an opened range in progress for the caller file
    last = ranges[-1]
    if last.is_a? Integer
      ci[:last] = last
      ci[:open] = true
    end

    ci
  end

  # Here the original methods will be replaced with one which checks
  # whether the method is called from an enabled or disabled region,
  # and then decide which method to call.
  def refine klass, &blk
    modname = to_s
    mdl = Module.new &blk

    klass.class_eval do

      # Rename the klass's original (affected) methods
      mdl.instance_methods.each do |m|
        if method_defined? m
          alias_method "_#{m}_changed_by_#{modname}", m
          remove_method m
        end
      end

      # Include the refined methods
      include mdl
    end

    # Rename the refined methods and replace them with
    # a method which will check what method to call.
    mdl.instance_methods.each do |m|
      klass.class_eval <<-STR
        alias_method :_#{modname}_#{m}, :#{m}

        def #{m}(*args, &b)
          if #{modname}.enabled?
            _#{modname}_#{m}(*args, &b)
          else
            begin
              _#{m}_changed_by_#{modname}(*args, &b)
            rescue NoMethodError
              raise NoMethodError.new("Undefined method `#{m}' for #{klass}")
            end
          end
        end
      STR
    end
  end
end


Here there are some examples:

#!/usr/bin/ruby

require "./refine"

class A
  def a
    'a'
  end
  def b
    a + 'b'
  end
end

module A2
  refine A do
    def a
      a + '2'     # A#a, since here A2 is disabled
    end

    A2.enable     # You must make A2 explicit here
    def d
      a + 'd'     # A2#a, since here A2 is enabled
    end
    A2.disable
  end

  refine String do
    def length
      length + 1  # Original String#length, since A2 is disabled here
    end
  end
end

a = A.new
str = 'abc'

puts a.a        # a
puts a.b        # ab
puts str.length # 3

A2.enable

class A
  def c
    a + 'c'   # a2c, as A2 is enabled
  end
end

puts ''
puts a.a      # a2
puts a.b      # ab (b was not refined nor defined where A2 is enabled)
puts a.c      # a2c
puts a.d      # a2d
puts str.length

A2.disable

puts ''
puts a.a      # a
puts a.b      # ab
puts a.c      # a2c (it was defined where A2 is enabled)
# puts a.d      # NoMethodError, since A2 is disabled
puts str.length

# In-method enabling test

def x(y)
  A2.enable
  puts y.a    # a2
  A2.disable
end

x(a)          # a2
x(a)          # enabling multiple times at same line with no error

# Lazy enabling test

def e
  A2.enable
end

def z(y)
  puts y.a    # defined between enable and disable, but affected only after running e() and d()
end

def d
  A2.disable
end

z(a)          # a
e             # now, activating the range for refinements
d
z(a)          # a2

def e2
  A2.enable
end

e2            # running before d(), but...
d             # error, as you are enabing *after* the disable (physically in the text file)


Differences from the original proposal:
  • enable and disable instead of using;
  • calls to refined methods only works if it's within the enabled range in the file; so subclasses won't be affected unless their code is in an enabled range;
  • super doesn't work for calling the original methods, but you can call it by its name from an un-enabled range; or by calling the renamed methods (see the code for refine).


I think this solution is good enough for me, and I guess it won't have the evil side of refinements which was very well discussed in this post (and I agree).

I'm open to discuss about errors, consequences and improvements to my code; feel free. ;-)

No comments:

Post a Comment