oinak
10/16/2012 - 10:16 PM

Mindfuck of the day: versionable Ruby objects

Mindfuck of the day: versionable Ruby objects

# Note, some syntax inspired from that persistent data structures library for Ruby, Hamster??
# Scroll to the bottom to see it in action.

module Versioned
  def self.included(base)
    base.class_eval do
      def self.new(*args)
        new_mutable(*args).freeze
      end

      def self.new_mutable(*args)
        allocate.tap do |instance|
          instance.send :initialize, *args
          instance.instance_eval do
            @transformations = []
            @initial_args    = args
          end
        end
      end
    end
  end

  def mutable
    self.class.new_mutable(*@initial_args).tap do |mutable|
      instance_variables.each do |ivar|
        mutable.instance_variable_set(ivar, instance_variable_get(ivar))
        mutable.instance_variable_set(:@transformations, @transformations.dup)
      end
    end
  end

  def transform(&block)
    mutable.tap do |new_instance|
      new_instance.replay([block.dup])
    end.freeze
  end

  def rollback
    self.class.new_mutable(*@initial_args).tap do |instance|
      instance.replay(history[0..-2])
    end.freeze
  end

  def replay(transformations=nil)
    (frozen? ? mutable : self).tap do |object|
      transformations.each do |transformation|
        object.history << transformation
        object.instance_eval(&transformation)
      end
    end.freeze
  end

  def history
    @transformations
  end

  def history=(transformations)
    @transformations = transformations
  end

  def -(other)
    younger, older = [history, other.history].sort { |a,b| a.length <=> b.length }
    difference     = (older.length - younger.length) - 1
    older[difference..-1]
  end

  def ==(other)
    history == other.history
  end
end

class Person
  include Versioned

  def initialize
    @hunger = 100
  end

  def eat
    transform do
      @hunger -= 5
    end
  end

  def hunger
    @hunger
  end

  def to_s
    "#<Person @hunger=#{@hunger} @history=#{history.inspect}>"
  end
end

# A Versionable object is immutable.
john = Person.new
puts john.frozen? # => true

# We can obtain modified instances by transforming it:
john2 = john.eat
john3 = john2.eat
john4 = john3.eat
john5 = john4.eat
latest = john5.eat
# We can go back one version:
one_step_back = latest.rollback # => this is what john5 was.
puts one_step_back == john5 # => true

# And we can calculate deltas between versions:
difference = john3 - latest
# => this is the difference between what was john3 and the latest john.

# Since deltas are just an array of individual changes, let's see what happens
# when we replay those changes on a younger version:
latest_again = john3.replay(difference)
puts latest_again == latest # => true

# Tada!!!