There's enough for everyone

गते गते पारगते पारसंगते बोधि स्वाहा गते गते पारगते पारसंगते बोधि स्वाहा

Miniature Memoize

Just for fun, a small clean memoize in “about a page of code”.

No eval, which is possible because it just uses method objects.

Tries to use closures wherever possible to minimise lookups, partly driven by the functional workshop after rubyfuza 2014.

Quite naive because it just raises for methods with parameters, and will more than likely fail with singletons.

Just uses instance variables, so you can reset them at will.

Definitely works in 2.1, untested in 2.0.


class Array
  def nilify; empty? ? nil : self; end
  def unify; (0..1) === size ? first : self; end
end

module Memo
  module ClassMethods
    def memoed_methods
      @memoed_methods ||= {}
    end

    # provide a legal ivar name from a method name. instance variables
    # can't have ? ! and other punctuation. Which isn't handled. Obviously.
    def ivar_from( maybe_meth )
      "@#{maybe_meth.to_s.tr '?!','pi'}".intern
    end

    # store the original method, replace it with a method
    # that memoizes the result.
    def memo( meth )
      unbound_previous_method = instance_method meth
      raise "can't memo #{meth} with arity #{unbound_previous_method.arity}" if unbound_previous_method.arity != 0

      memoed_methods[meth] = unbound_previous_method
      ivar = ivar_from meth

      define_method meth do |*args, &blk|
        if instance_variable_defined? ivar
          instance_variable_get ivar
        else
          # bind the saved method to this instance, call the result ...
          to_memo = unbound_previous_method.bind( self ).call( *args, &blk )
          # ... memo it and return value
          instance_variable_set ivar, to_memo
        end
      end
    end
  end

  # hook in class methods on include
  def self.included( other_module )
    other_module.extend ClassMethods
  end

  # reset some or all memoized variables
  # return cleared values
  def clear_memos( *requested_meths )
    (requested_meths.nilify || self.class.memoed_methods.keys).map do |meth|
      if instance_variable_defined? ivar = self.class.ivar_from(meth)
        remove_instance_variable ivar
      end
    end.unify
  end
end

Usage

class YourFunkyChunkyCode
  include Memo

  # for 2.1
  memo def expensive
    # do various things
  end

  # for 2.0
  def even_more_expensive
    # do various more things
  end

  memo :even_more_expensive
end

yfcc = YourFunkyChunkyCode.new
yfcc.expensive

# the currently memo-ed values, and other stuff
ivars = yfcc.instance_variables

# and all the possible memo-ed values (some of which don't exist yet)
memo_ivars = YourFunkyChunkyCode.memoed_methods.keys.map{|meth_name| YourFunkyChunkyCode.ivar_from meth_name}

# only currently memo-ed values
ivars & memo_ivars

Specs

require 'memo.rb'
require 'faker'

describe Array do
  def random_values( range )
    rand(range).times.map{|i| [nil,rand,Faker::Name.name,Object.new].sample}
  end

  describe '#unify' do
    it 'nil for 0 elements' do
      [].unify.should == nil
    end

    it 'atom for 1 element' do
      value = rand
      [value].unify.should == value
    end

    it 'array for >=2 elements' do
      random_values(2..15).unify.should be_a(Array)
    end
  end

  describe '#nilify' do
    it 'nil for 0 elements' do
      [].nilify.should == nil
    end

    it 'array for >=1 elements' do
      random_values(1..15).nilify.should be_a(Array)
    end
  end
end

describe Memo::ClassMethods do
  let (:subject) { Object.new.extend(Memo::ClassMethods) }

  describe '#ivar_from' do
    it 'ivars a normal method to symbol' do
      name = Faker::Name.name
      subject.ivar_from(name).should == "@#{name}".to_sym
    end

    it 'ivars a ? method' do
      name = "are_you_mad?"
      subject.ivar_from(name).should == "@are_you_madp".to_sym
    end

    it 'ivars a ! method' do
      name = "jetais_perdu!"
      subject.ivar_from(name).should == "@jetais_perdui".to_sym
    end
  end
end

describe Memo do
  describe '.memo' do
    it 'raises on non-zero arity' do
      class_def = -> do
        Class.new do
          include Memo

          memo def calc( x )
          end
        end
      end
      class_def.should raise_error(/with arity 1/)
    end

    it 'stores previous method' do
      kl = Class.new do
        include Memo
        memo def calc
          rand
        end
      end

      name, body = kl.memoed_methods.first

      name.should == :calc

      body.name.should == :calc
      body.should be_a(UnboundMethod)
    end

    it 'calls previous method once' do
      inst = Class.new do
        attr_reader :call_count
        include Memo
        memo def calc
          @call_count ||= 0
          @call_count += 1
          rand
        end
      end.new

      3.times{inst.calc}
      inst.call_count.should == 1
    end

    it 'memos value' do
      inst = Class.new do
        include Memo
        memo def calc; rand; end
      end.new

      # value should be the same for all calls, even though it's rand
      first = inst.calc
      6.times.map{inst.calc}.uniq.tap do |uniq_values|
        uniq_values.size.should == 1
        uniq_values.unify.should == first
      end
    end
  end

  describe '#clear_memos' do
    before :each do
      @method_names = %i[calc work apply]

      @inst = Class.new do
        include Memo
        memo def calc; rand; end
        memo def work; rand; end
        memo def apply; rand; end
      end.new

      method_names.each do |meth|
        @inst.send meth
      end
    end

    attr_reader :inst, :method_names

    it 'clears all memos' do
      method_names.each do |meth|
        inst.instance_variable_defined?("@#{meth}").should be_true
        inst.instance_variable_get("@#{meth}").should_not be_nil
      end

      inst.clear_memos

      method_names.each do |meth|
        inst.instance_variable_defined?("@#{meth}").should be_false
      end
    end

    it 'clears some memos' do
      method_names.each do |meth|
        inst.instance_variable_defined?("@#{meth}").should be_true
        inst.instance_variable_get("@#{meth}").should_not be_nil
      end

      to_clear = [:apply, :work]
      inst.clear_memos( *to_clear )

      to_clear.each do |meth|
        inst.instance_variable_defined?("@#{meth}").should be_false
      end

      inst.instance_variable_defined?("@calc").should be_true
    end
  end
end

Comments